main.py 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196
  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 fabric.shared.schemas import (
  9. Heartbeat, PlaceRequest, PlaceResponse,
  10. RegistryModelsResponse, RegistryNodesResponse,
  11. )
  12. from fabric.control.registry import Registry
  13. from fabric.control.scheduler import Scheduler
  14. from fabric.control.installer import Installer, RECIPES
  15. from fabric.control import cuda_checkpoint
  16. logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(levelname)s %(message)s")
  17. log = logging.getLogger("fabric.control")
  18. app = FastAPI(title="Fabric Control Plane", version="0.2.0")
  19. app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
  20. registry = Registry()
  21. scheduler = Scheduler(registry)
  22. installer = Installer()
  23. _start_time = time.time()
  24. @app.get("/health")
  25. async def health():
  26. return {
  27. "ok": True,
  28. "version": registry.version,
  29. "nodes": len(registry.get_alive_nodes()),
  30. "modules": len(registry.get_all_modules()),
  31. "uptime_s": int(time.time() - _start_time),
  32. "cuda_checkpoint_available": cuda_checkpoint.is_available(),
  33. }
  34. @app.post("/registry/heartbeat")
  35. async def heartbeat(hb: Heartbeat):
  36. registry.handle_heartbeat(hb)
  37. return {"ok": True, "version": registry.version}
  38. @app.get("/registry/models", response_model=RegistryModelsResponse)
  39. async def list_models():
  40. modules = registry.get_all_modules()
  41. return RegistryModelsResponse(
  42. models=modules,
  43. nodes_count=len(registry.get_alive_nodes()),
  44. total_gpu_vram_mb=registry.total_gpu_vram_mb(),
  45. total_gpu_capacity_mb=registry.total_gpu_capacity_mb(),
  46. total_gpu_free_mb=registry.total_gpu_free_mb(),
  47. )
  48. @app.get("/registry/nodes", response_model=RegistryNodesResponse)
  49. async def list_nodes():
  50. return RegistryNodesResponse(nodes=registry.get_alive_nodes())
  51. @app.get("/registry/hypervisors")
  52. async def list_hypervisors():
  53. """Group nodes by hypervisor — multi-hypervisor visibility."""
  54. by_hyp: dict[str, list[dict]] = {}
  55. for n in registry.get_alive_nodes():
  56. hyp = n.hypervisor or "unknown"
  57. by_hyp.setdefault(hyp, []).append({
  58. "node_id": n.node_id,
  59. "node_ip": n.node_ip,
  60. "modules": len(n.modules),
  61. "gpu_processes": len(n.gpu_status),
  62. "vram_used_mb": sum(g.vram_mb for g in n.gpu_status),
  63. "vram_total_mb": n.vram_total_mb,
  64. "vram_free_mb": n.vram_free_mb,
  65. })
  66. return {"hypervisors": by_hyp, "total_hypervisors": len(by_hyp)}
  67. @app.post("/place", response_model=PlaceResponse)
  68. async def place(req: PlaceRequest):
  69. result = registry.place(req)
  70. if result is None:
  71. raise HTTPException(status_code=404, detail=f"Model '{req.model}' not found in any node")
  72. if result.state in ("unloaded", "error"):
  73. # Anti-OOM guard: refuse to (re)load when the chosen node lacks the VRAM
  74. # the model needs. Prefer the DECLARED manifest requirement; fall back to
  75. # the last observed footprint. Skipped when requirement/capacity unknown.
  76. candidates = registry.find_module(req.model)
  77. required = max((m.required_vram_mb for (_n, m) in candidates), default=0)
  78. if required <= 0:
  79. required = max((m.vram_mb for (_n, m) in candidates), default=0)
  80. chosen = registry.nodes.get(result.node_id)
  81. free = (chosen.vram_free_mb if chosen else 0) or 0
  82. if required > 0 and free > 0 and free < required:
  83. raise HTTPException(
  84. status_code=503,
  85. detail=f"Insufficient free VRAM on '{result.node_id}' for '{req.model}': "
  86. f"needs ~{required}mb, free {free}mb",
  87. )
  88. await scheduler.ensure_loaded(result.node_id, result.module_name)
  89. return result
  90. @app.post("/admin/evict")
  91. async def admin_evict(node_id: str, module_name: str, reason: str = "admin"):
  92. ok = await scheduler.evict(node_id, module_name, reason)
  93. if not ok:
  94. raise HTTPException(400, "Eviction failed")
  95. return {"ok": True}
  96. @app.post("/admin/load")
  97. async def admin_load(node_id: str, module_name: str):
  98. ok = await scheduler.ensure_loaded(node_id, module_name)
  99. if not ok:
  100. raise HTTPException(400, "Load failed")
  101. return {"ok": True}
  102. @app.get("/scheduler/utilization")
  103. async def scheduler_utilization():
  104. return scheduler.get_utilization()
  105. @app.post("/scheduler/preempt")
  106. async def scheduler_preempt(model: str, reason: str = "placement"):
  107. result = await scheduler.preempt_for(model, reason)
  108. if result is None:
  109. raise HTTPException(503, "No resources available for preemption")
  110. return result
  111. @app.post("/batch")
  112. async def batch_submit(model: str, items: list[dict] = Body(...), priority: str = "normal"):
  113. return await scheduler.submit_batch(model, items, priority)
  114. @app.get("/batch/{job_id}")
  115. async def batch_status(job_id: str):
  116. status = scheduler.get_batch_status(job_id)
  117. if status is None:
  118. raise HTTPException(404, "Batch job not found")
  119. return status
  120. # --- v2: Installer endpoints ---
  121. @app.get("/install/recipes")
  122. async def list_recipes():
  123. return {"recipes": installer.list_recipes()}
  124. @app.post("/install")
  125. async def install_model(hf_repo: str, recipe: str = "vllm", name: str | None = None):
  126. try:
  127. return await installer.install(hf_repo, recipe, name)
  128. except ValueError as e:
  129. raise HTTPException(400, str(e))
  130. @app.get("/install/{job_id}")
  131. async def install_status(job_id: str):
  132. s = installer.get_status(job_id)
  133. if s is None:
  134. raise HTTPException(404, "Install job not found")
  135. return s
  136. @app.get("/install")
  137. async def list_install_jobs():
  138. return {"jobs": installer.list_jobs()}
  139. # --- v2: cuda-checkpoint endpoints ---
  140. @app.get("/checkpoint/availability")
  141. async def checkpoint_availability():
  142. return {"available": cuda_checkpoint.is_available(), "binary": cuda_checkpoint.CUDA_CHECKPOINT_BIN}
  143. @app.post("/checkpoint/{pid}/toggle")
  144. async def checkpoint_toggle(pid: int, timeout_s: int = 30):
  145. ok, msg = await cuda_checkpoint.checkpoint(pid, timeout_s)
  146. return {"ok": ok, "pid": pid, "message": msg}
  147. @app.get("/checkpoint/{pid}/state")
  148. async def checkpoint_state(pid: int):
  149. state = await cuda_checkpoint.get_state(pid)
  150. return {"pid": pid, "state": state}
  151. async def _prune_loop():
  152. while True:
  153. await asyncio.sleep(15)
  154. stale = registry.prune_stale_nodes()
  155. if stale:
  156. log.warning("Pruned stale nodes: %s", stale)
  157. async def _idle_sweep_loop():
  158. while True:
  159. await asyncio.sleep(30)
  160. await scheduler.idle_sweep()
  161. @app.on_event("startup")
  162. async def startup():
  163. asyncio.create_task(_prune_loop())
  164. asyncio.create_task(_idle_sweep_loop())
  165. log.info("Fabric Control Plane v0.2.0 started on port 7700")