registry.py 4.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139
  1. """In-memory registry for nodes and modules."""
  2. from __future__ import annotations
  3. import time
  4. import logging
  5. from fabric.shared.schemas import (
  6. Heartbeat, NodeInfo, ModuleStatus, ModuleState,
  7. PlaceRequest, PlaceResponse, PriorityClass,
  8. )
  9. log = logging.getLogger("fabric.registry")
  10. STALE_NODE_TIMEOUT_S = 60
  11. class Registry:
  12. def __init__(self):
  13. self.nodes: dict[str, NodeInfo] = {}
  14. self._version = 0
  15. @property
  16. def version(self) -> int:
  17. return self._version
  18. def handle_heartbeat(self, hb: Heartbeat) -> None:
  19. node = self.nodes.get(hb.node_id)
  20. if node is None:
  21. log.info("New node registered: %s (%s)", hb.node_id, hb.node_ip)
  22. self.nodes[hb.node_id] = NodeInfo(
  23. node_id=hb.node_id,
  24. node_ip=hb.node_ip,
  25. agent_port=hb.agent_port,
  26. hypervisor=hb.hypervisor,
  27. gpu_status=hb.gpu_status,
  28. modules=hb.modules,
  29. last_heartbeat=time.time(),
  30. alive=True,
  31. gpus=hb.gpus,
  32. vram_total_mb=hb.vram_total_mb,
  33. vram_free_mb=hb.vram_free_mb,
  34. driver_version=hb.driver_version,
  35. )
  36. self._version += 1
  37. def prune_stale_nodes(self) -> list[str]:
  38. now = time.time()
  39. stale = []
  40. for nid, node in list(self.nodes.items()):
  41. if now - node.last_heartbeat > STALE_NODE_TIMEOUT_S:
  42. node.alive = False
  43. stale.append(nid)
  44. return stale
  45. def get_all_modules(self) -> list[ModuleStatus]:
  46. modules = []
  47. for node in self.nodes.values():
  48. if not node.alive:
  49. continue
  50. for m in node.modules:
  51. modules.append(m)
  52. return modules
  53. def get_alive_nodes(self) -> list[NodeInfo]:
  54. return [n for n in self.nodes.values() if n.alive]
  55. def find_module(self, model_name: str) -> list[tuple[NodeInfo, ModuleStatus]]:
  56. results = []
  57. for node in self.nodes.values():
  58. if not node.alive:
  59. continue
  60. for m in node.modules:
  61. if m.name == model_name or model_name in m.aliases:
  62. results.append((node, m))
  63. return results
  64. def place(self, req: PlaceRequest) -> PlaceResponse | None:
  65. candidates = self.find_module(req.model)
  66. if not candidates:
  67. return None
  68. state_priority = {
  69. ModuleState.running: 0,
  70. ModuleState.loaded: 1,
  71. ModuleState.idle: 2,
  72. ModuleState.sleeping: 3,
  73. ModuleState.warming: 4,
  74. ModuleState.unloaded: 5,
  75. ModuleState.error: 6,
  76. }
  77. # Capacity-aware tie-break: among equally-good states, prefer the node
  78. # with the most free physical VRAM (more headroom => safer to (re)load).
  79. candidates.sort(key=lambda c: (state_priority.get(c[1].state, 99), -(c[0].vram_free_mb or 0)))
  80. node, module = candidates[0]
  81. return PlaceResponse(
  82. node_id=node.node_id,
  83. node_ip=node.node_ip,
  84. infer_url=module.infer_url,
  85. module_name=module.name,
  86. state=module.state,
  87. )
  88. def total_gpu_vram_mb(self) -> int:
  89. total = 0
  90. for node in self.nodes.values():
  91. if not node.alive:
  92. continue
  93. for g in node.gpu_status:
  94. total += g.vram_mb
  95. return total
  96. def _capacity_by_hypervisor(self, attr: str) -> int:
  97. """Sum `attr` over alive nodes, deduplicated per hypervisor.
  98. On hypervisors that pass ALL physical GPUs through to every LXC node,
  99. each node's nvidia-smi reports the SAME physical GPUs. Summing across
  100. nodes would multiply one GPU set by the node count (e.g. 11 nodes x 8
  101. GPUs => ~4.3 TB phantom capacity). We therefore count each hypervisor
  102. ONCE (max over its nodes). Nodes whose hypervisor is unknown can't be
  103. proven to share GPUs, so each is counted on its own (keyed by node_id).
  104. """
  105. best: dict[str, int] = {}
  106. for n in self.nodes.values():
  107. if not n.alive:
  108. continue
  109. val = getattr(n, attr, 0) or 0
  110. hv = (n.hypervisor or "").strip().lower()
  111. key = hv if hv and hv not in ("unknown", "?") else f"__node__{n.node_id}"
  112. if val > best.get(key, 0):
  113. best[key] = val
  114. return sum(best.values())
  115. def total_gpu_capacity_mb(self) -> int:
  116. """Total physical-GPU VRAM (hardware capacity), deduplicated per hypervisor."""
  117. return self._capacity_by_hypervisor("vram_total_mb")
  118. def total_gpu_free_mb(self) -> int:
  119. """Free physical-GPU VRAM (placement headroom), deduplicated per hypervisor."""
  120. return self._capacity_by_hypervisor("vram_free_mb")