AQIT 0.1.0
Loading...
Searching...
No Matches
model_runtime.py
Go to the documentation of this file.
1# Copyright (c) 2025-present Aquin Labs Private Limited. All Rights Reserved.
2"""
3Unified model lifecycle — one resident model in VRAM, Ollama-style.
4
5Owns phase transitions (idle → loading → ready → unloading), coordinates LLM
6loaders, and is the single source of truth for daemon + CLI load/unload.
7"""
8from __future__ import annotations
9
10import threading
11import time
12from dataclasses import dataclass
13from enum import Enum
14from typing import Any, Callable, Literal
15
16ProgressFn = Callable[[str, float], None]
17
18_lock = threading.Lock()
19# Serialize load_weights / unload_weights so two builds never share the process.
20_load_serial = threading.RLock()
21_phase = "idle"
22_target_id: str | None = None
23_resident_id: str | None = None
24_kind: Literal["llm"] | None = None
25_error: str | None = None
26_started_at: float | None = None
27_loaded_at: float | None = None
28_load_message: str | None = None
29# Bumped on unload/cancel — in-flight builds check this and discard weights.
30_load_epoch = 0
31_load_epoch_at_start = 0
33
34class ModelPhase(str, Enum):
35 IDLE = "idle"
36 LOADING = "loading"
37 READY = "ready"
38 UNLOADING = "unloading"
39 ERROR = "error"
42@dataclass(frozen=True)
44 phase: ModelPhase
45 model_id: str | None
46 target_id: str | None
47 kind: Literal["llm"] | None
48 error: str | None
49 elapsed_s: float
50 loaded_for_s: float | None
53def snapshot() -> RuntimeSnapshot:
54 """Thread-safe view of the in-process lifecycle state."""
55 with _lock:
56 now = time.monotonic()
57 elapsed = (now - _started_at) if _started_at is not None else 0.0
58 loaded_for = (now - _loaded_at) if _loaded_at is not None else None
59 return RuntimeSnapshot(
60 phase=ModelPhase(_phase),
61 model_id=_resident_id,
62 target_id=_target_id if _phase == "loading" else None,
63 kind=_kind,
64 error=_error,
65 elapsed_s=elapsed,
66 loaded_for_s=loaded_for,
67 )
68
69
70def _set_phase(
71 phase: str,
72 *,
73 model_id: str | None = None,
74 target_id: str | None = None,
75 kind: Literal["llm"] | None = None,
76 error: str | None = None,
77) -> None:
78 global _phase, _resident_id, _target_id, _kind, _error, _started_at, _loaded_at
79 with _lock:
80 _phase = phase
81 if model_id is not None:
82 _resident_id = model_id
83 if target_id is not None:
84 _target_id = target_id
85 if kind is not None:
86 _kind = kind
87 _error = error
88 if phase == "loading":
89 _started_at = time.monotonic()
90 _loaded_at = None
91 elif phase == "ready":
92 _loaded_at = time.monotonic()
93 _error = None
94 elif phase in ("idle", "unloading"):
95 if phase == "idle":
96 _resident_id = None
97 _target_id = None
98 _kind = None
99 _loaded_at = None
100 _error = None if phase == "idle" else _error
101
102
103def reset_state() -> None:
104 """Clear lifecycle markers (tests / daemon shutdown)."""
105 with _lock:
106 global _phase, _target_id, _resident_id, _kind, _error, _started_at, _loaded_at, _load_message
107 global _load_epoch, _load_epoch_at_start
108 _phase = "idle"
109 _target_id = None
110 _resident_id = None
111 _kind = None
112 _error = None
113 _started_at = None
114 _loaded_at = None
115 _load_message = None
116 _load_epoch = 0
117 _load_epoch_at_start = 0
118
119
120def resolve_kind(model_id: str) -> tuple[str, Literal["llm"]]:
121 """Resolve slug + model family for any supported id."""
122 from aquin.compute.model_loader import resolve_model_id
123
124 return resolve_model_id(model_id), "llm"
125
126
127def resident_from_cache() -> tuple[str | None, Literal["llm"] | None]:
128 """What is actually resident in this process's VRAM caches."""
129 try:
130 from aquin.compute.model_loader import get_loaded_llm_id
132 llm = get_loaded_llm_id()
133 if llm:
134 return llm, "llm"
135 except Exception:
136 pass
137 return None, None
138
139
140def sync_from_cache() -> RuntimeSnapshot:
141 """Reconcile lifecycle state with actual VRAM caches (health checks)."""
142 global _phase, _resident_id, _target_id, _kind, _error, _loaded_at
143 resident, kind = resident_from_cache()
144 with _lock:
145 if _phase == "loading":
146 pass
147 elif resident:
148 _resident_id = resident
149 _kind = kind
150 if _phase != "error":
151 _phase = "ready"
152 _error = None
153 elif _phase == "ready":
154 _phase = "idle"
155 _resident_id = None
156 _target_id = None
157 _kind = None
158 _loaded_at = None
159 return snapshot()
160
161
162def begin_load(model_id: str) -> tuple[str, Literal["llm"]]:
163 global _load_message, _load_epoch_at_start
164 slug, kind = resolve_kind(model_id)
165 _set_phase("loading", target_id=slug, kind=kind, error=None)
166 with _lock:
167 _load_message = f"resolving {slug}"
168 _load_epoch_at_start = _load_epoch
169 return slug, kind
170
171
172def finish_load(slug: str, kind: Literal["llm"]) -> None:
173 global _load_message
174 _set_phase("ready", model_id=slug, kind=kind, error=None)
175 with _lock:
176 _load_message = None
177
178
179def fail_load(model_id: str, error: str) -> None:
180 global _load_message
181 _set_phase("error", model_id=model_id, error=error)
182 with _lock:
183 _load_message = None
184
185
186def begin_unload() -> None:
187 snap = snapshot()
188 _set_phase("unloading", model_id=snap.model_id, kind=snap.kind)
189
191def finish_unload() -> None:
192 global _load_message
193 _set_phase("idle")
194 with _lock:
195 _load_message = None
196
197
198def request_load_cancel() -> None:
199 """Mark any in-flight build as cancelled (checked after from_pretrained)."""
200 global _load_epoch
201 with _lock:
202 _load_epoch += 1
203
204
205def is_load_cancelled() -> bool:
206 """True if unload/cancel happened after this load began."""
207 with _lock:
208 return _load_epoch != _load_epoch_at_start
210
211def _emit(progress: ProgressFn | None, message: str) -> None:
212 global _load_message
213 with _lock:
214 _load_message = message
215 if progress:
216 snap = snapshot()
217 progress(message, snap.elapsed_s)
218
219
220def load_weights(
221 model_id: str,
222 *,
223 progress: ProgressFn | None = None,
224) -> str:
225 """
226 Load model_id into this process. Returns resolved slug. Updates lifecycle state.
227
228 Process-wide serialized — concurrent builds on MPS exhaust unified memory.
229 """
230 with _load_serial:
231 slug, kind = begin_load(model_id)
232 try:
233 _emit(progress, f"resolving {slug}")
234 from aquin.compute.model_loader import clear_sae_cache, load_model
235
236 clear_sae_cache()
238 raise RuntimeError(f"Model load cancelled ({slug})")
239 _emit(progress, f"loading model weights")
240 load_model(slug)
241
243 from aquin.compute.model_loader import clear_llm_models, clear_sae_cache
244
245 clear_llm_models()
246 clear_sae_cache()
248 raise RuntimeError(f"Model load cancelled ({slug})")
249
250 from aquin.compute.model_loader import _save_active_model
251
252 _save_active_model(slug)
253 finish_load(slug, kind)
254 _emit(progress, f"{slug} ready")
255 return slug
256 except Exception as exc:
257 msg = str(exc) or exc.__class__.__name__
258 if "cancelled" in msg.lower():
260 else:
261 fail_load(slug, msg)
262 raise
263
264
265def unload_weights(*, progress: ProgressFn | None = None, clear_active: bool = True) -> None:
266 """Drop all resident weights from VRAM in this process."""
268 with _load_serial:
270 try:
271 _emit(progress, "releasing VRAM")
272 from aquin.compute.model_loader import clear_active_model_file, clear_llm_models, clear_sae_cache
273
274 clear_llm_models()
275 clear_sae_cache()
276 if clear_active:
277 clear_active_model_file()
278 finally:
280
281
282def release_foreign_daemon() -> bool:
283 """
284 Optionally unload a background engine so this process can claim VRAM.
285
286 Default is **never** — the desktop app keeps a resident model in the daemon.
287 Headless CLI tools that try load_model in a short-lived process used to call
288 model_daemon.unload() here, which looked like the model “randomly disappearing”.
289
290 Opt-in only: set AQUIN_CLAIM_DAEMON_VRAM=1 when you intentionally want an
291 exclusive in-process load that frees the background engine first.
292 """
293 import os
294
295 if os.environ.get("AQUIN_DAEMON") == "1":
296 return False
297 claim = (os.environ.get("AQUIN_CLAIM_DAEMON_VRAM") or "").strip().lower()
298 if claim not in ("1", "true", "yes", "on"):
299 return False
300 try:
301 from aquin.engine import model_daemon
302
303 if model_daemon.is_running():
304 model_daemon.unload()
305 return True
306 except Exception:
307 pass
308 return False
309
310
311def vram_line() -> str | None:
312 """Short VRAM summary for status output."""
313 try:
314 from aquin.compute.vram_guard import accelerator_vram_gib
315 from aquin.compute.device import resolve_compute_device
316
317 info = accelerator_vram_gib()
318 dev = resolve_compute_device()
319 if info:
320 free_g, total_g = info
321 return f"{free_g:.1f} / {total_g:.1f} GiB free ({dev})"
322 if dev == "mps":
323 return "Metal unified memory"
324 except Exception:
325 pass
326 return None
327
328
329def health_payload(*, daemon: bool, port: int) -> dict[str, Any]:
330 """JSON health block for the local engine server."""
332 snap = snapshot()
333 resident, _ = resident_from_cache()
334 with _lock:
335 load_message = _load_message
336 return {
337 "status": "ok",
338 "port": port,
339 "daemon": daemon,
340 "model_id": resident,
341 "model_status": snap.phase.value,
342 "loading_model_id": snap.target_id if snap.phase == ModelPhase.LOADING else None,
343 "load_message": load_message if snap.phase == ModelPhase.LOADING else None,
344 "model_kind": snap.kind,
345 "last_error": snap.error,
346 "elapsed_s": round(snap.elapsed_s, 1) if snap.phase == ModelPhase.LOADING else None,
347 "loaded_for_s": round(snap.loaded_for_s, 1) if snap.loaded_for_s is not None else None,
348 "vram": vram_line(),
349 }
None _set_phase(str phase, *, str|None model_id=None, str|None target_id=None, Literal["llm"]|None kind=None, str|None error=None)
tuple[str, Literal["llm"]] begin_load(str model_id)
RuntimeSnapshot snapshot()
tuple[str, Literal["llm"]] resolve_kind(str model_id)
RuntimeSnapshot sync_from_cache()
dict[str, Any] health_payload(*, bool daemon, int port)
str load_weights(str model_id, *, ProgressFn|None progress=None)
tuple[str|None, Literal["llm"]|None] resident_from_cache()
None _emit(ProgressFn|None progress, str message)
None unload_weights(*, ProgressFn|None progress=None, bool clear_active=True)
None fail_load(str model_id, str error)
None finish_load(str slug, Literal["llm"] kind)