registry.py 3.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118
  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 total_gpu_capacity_mb(self) -> int:
  97. """Sum of physical-GPU total VRAM across alive nodes (hardware capacity)."""
  98. return sum(n.vram_total_mb for n in self.nodes.values() if n.alive)
  99. def total_gpu_free_mb(self) -> int:
  100. """Sum of physical-GPU free VRAM across alive nodes (placement headroom)."""
  101. return sum(n.vram_free_mb for n in self.nodes.values() if n.alive)