main.py 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215
  1. """Fabric Control Plane — registry + placement + scheduler + installer."""
  2. from __future__ import annotations
  3. import asyncio
  4. import logging
  5. import time
  6. from fastapi import FastAPI, HTTPException, Query, Body
  7. from fastapi.middleware.cors import CORSMiddleware
  8. from pydantic import BaseModel
  9. from fabric.shared.schemas import (
  10. Heartbeat, PlaceRequest, PlaceResponse,
  11. RegistryModelsResponse, RegistryNodesResponse,
  12. )
  13. from fabric.control.registry import Registry
  14. from fabric.control.scheduler import Scheduler
  15. from fabric.control.installer import Installer, RECIPES
  16. from fabric.control import cuda_checkpoint
  17. logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(levelname)s %(message)s")
  18. log = logging.getLogger("fabric.control")
  19. app = FastAPI(title="Fabric Control Plane", version="0.2.0")
  20. app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
  21. registry = Registry()
  22. scheduler = Scheduler(registry)
  23. installer = Installer()
  24. _start_time = time.time()
  25. # Additive VRAM-capacity reporting: per-node physical-GPU capacity captured from
  26. # heartbeats, keyed by node_id. Kept outside the strict registry schemas so the
  27. # change stays backward-compatible (absent fields degrade to 0 / []).
  28. _node_capacity: dict[str, dict] = {}
  29. class GpuMemInfo(BaseModel):
  30. """Per-physical-GPU memory capacity entry (mirrors the agent payload)."""
  31. gpu_index: int = 0
  32. mem_total_mb: int = 0
  33. mem_used_mb: int = 0
  34. class HeartbeatExt(Heartbeat):
  35. """Heartbeat + additive VRAM-capacity fields. Old agents omit these and the
  36. defaults apply; updated agents carry per-GPU capacity through to the registry."""
  37. gpus: list[GpuMemInfo] = []
  38. vram_total_mb: int = 0
  39. @app.get("/health")
  40. async def health():
  41. return {
  42. "ok": True,
  43. "version": registry.version,
  44. "nodes": len(registry.get_alive_nodes()),
  45. "modules": len(registry.get_all_modules()),
  46. "uptime_s": int(time.time() - _start_time),
  47. "cuda_checkpoint_available": cuda_checkpoint.is_available(),
  48. }
  49. @app.post("/registry/heartbeat")
  50. async def heartbeat(hb: HeartbeatExt):
  51. registry.handle_heartbeat(hb)
  52. _node_capacity[hb.node_id] = {
  53. "vram_total_mb": int(hb.vram_total_mb or 0),
  54. "gpus": [g.model_dump(mode="json") for g in hb.gpus],
  55. }
  56. return {"ok": True, "version": registry.version}
  57. @app.get("/registry/models")
  58. async def list_models():
  59. modules = registry.get_all_modules()
  60. alive = registry.get_alive_nodes()
  61. base = RegistryModelsResponse(
  62. models=modules,
  63. nodes_count=len(alive),
  64. total_gpu_vram_mb=registry.total_gpu_vram_mb(),
  65. )
  66. data = base.model_dump(mode="json")
  67. data["total_gpu_capacity_mb"] = sum(
  68. int((_node_capacity.get(n.node_id) or {}).get("vram_total_mb", 0) or 0)
  69. for n in alive
  70. )
  71. return data
  72. @app.get("/registry/nodes")
  73. async def list_nodes():
  74. nodes_out = []
  75. for n in registry.get_alive_nodes():
  76. d = n.model_dump(mode="json")
  77. cap = _node_capacity.get(n.node_id) or {}
  78. d["vram_total_mb"] = int(cap.get("vram_total_mb", 0) or 0)
  79. d["gpus"] = cap.get("gpus", [])
  80. nodes_out.append(d)
  81. return {"nodes": nodes_out}
  82. @app.get("/registry/hypervisors")
  83. async def list_hypervisors():
  84. """Group nodes by hypervisor — multi-hypervisor visibility."""
  85. by_hyp: dict[str, list[dict]] = {}
  86. for n in registry.get_alive_nodes():
  87. hyp = n.hypervisor or "unknown"
  88. by_hyp.setdefault(hyp, []).append({
  89. "node_id": n.node_id,
  90. "node_ip": n.node_ip,
  91. "modules": len(n.modules),
  92. "gpu_processes": len(n.gpu_status),
  93. "vram_used_mb": sum(g.vram_mb for g in n.gpu_status),
  94. })
  95. return {"hypervisors": by_hyp, "total_hypervisors": len(by_hyp)}
  96. @app.post("/place", response_model=PlaceResponse)
  97. async def place(req: PlaceRequest):
  98. result = registry.place(req)
  99. if result is None:
  100. raise HTTPException(status_code=404, detail=f"Model '{req.model}' not found in any node")
  101. if result.state in ("unloaded", "error"):
  102. await scheduler.ensure_loaded(result.node_id, result.module_name)
  103. return result
  104. @app.post("/admin/evict")
  105. async def admin_evict(node_id: str, module_name: str, reason: str = "admin"):
  106. ok = await scheduler.evict(node_id, module_name, reason)
  107. if not ok:
  108. raise HTTPException(400, "Eviction failed")
  109. return {"ok": True}
  110. @app.post("/admin/load")
  111. async def admin_load(node_id: str, module_name: str):
  112. ok = await scheduler.ensure_loaded(node_id, module_name)
  113. if not ok:
  114. raise HTTPException(400, "Load failed")
  115. return {"ok": True}
  116. @app.get("/scheduler/utilization")
  117. async def scheduler_utilization():
  118. return scheduler.get_utilization()
  119. @app.post("/scheduler/preempt")
  120. async def scheduler_preempt(model: str, reason: str = "placement"):
  121. result = await scheduler.preempt_for(model, reason)
  122. if result is None:
  123. raise HTTPException(503, "No resources available for preemption")
  124. return result
  125. @app.post("/batch")
  126. async def batch_submit(model: str, items: list[dict] = Body(...), priority: str = "normal"):
  127. return await scheduler.submit_batch(model, items, priority)
  128. @app.get("/batch/{job_id}")
  129. async def batch_status(job_id: str):
  130. status = scheduler.get_batch_status(job_id)
  131. if status is None:
  132. raise HTTPException(404, "Batch job not found")
  133. return status
  134. # --- v2: Installer endpoints ---
  135. @app.get("/install/recipes")
  136. async def list_recipes():
  137. return {"recipes": installer.list_recipes()}
  138. @app.post("/install")
  139. async def install_model(hf_repo: str, recipe: str = "vllm", name: str | None = None):
  140. try:
  141. return await installer.install(hf_repo, recipe, name)
  142. except ValueError as e:
  143. raise HTTPException(400, str(e))
  144. @app.get("/install/{job_id}")
  145. async def install_status(job_id: str):
  146. s = installer.get_status(job_id)
  147. if s is None:
  148. raise HTTPException(404, "Install job not found")
  149. return s
  150. @app.get("/install")
  151. async def list_install_jobs():
  152. return {"jobs": installer.list_jobs()}
  153. # --- v2: cuda-checkpoint endpoints ---
  154. @app.get("/checkpoint/availability")
  155. async def checkpoint_availability():
  156. return {"available": cuda_checkpoint.is_available(), "binary": cuda_checkpoint.CUDA_CHECKPOINT_BIN}
  157. @app.post("/checkpoint/{pid}/toggle")
  158. async def checkpoint_toggle(pid: int, timeout_s: int = 30):
  159. ok, msg = await cuda_checkpoint.checkpoint(pid, timeout_s)
  160. return {"ok": ok, "pid": pid, "message": msg}
  161. @app.get("/checkpoint/{pid}/state")
  162. async def checkpoint_state(pid: int):
  163. state = await cuda_checkpoint.get_state(pid)
  164. return {"pid": pid, "state": state}
  165. async def _prune_loop():
  166. while True:
  167. await asyncio.sleep(15)
  168. stale = registry.prune_stale_nodes()
  169. if stale:
  170. log.warning("Pruned stale nodes: %s", stale)
  171. async def _idle_sweep_loop():
  172. while True:
  173. await asyncio.sleep(30)
  174. await scheduler.idle_sweep()
  175. @app.on_event("startup")
  176. async def startup():
  177. asyncio.create_task(_prune_loop())
  178. asyncio.create_task(_idle_sweep_loop())
  179. log.info("Fabric Control Plane v0.2.0 started on port 7700")