hugpy-wrapper 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fitevict/__init__.py +34 -0
- fitevict/adapters/__init__.py +6 -0
- fitevict/adapters/db.py +487 -0
- fitevict/adapters/llama_cpp.py +1298 -0
- fitevict/adapters/transformers.py +149 -0
- fitevict/calls_view.py +225 -0
- fitevict/evict.py +167 -0
- fitevict/flex.py +174 -0
- fitevict/front_door.py +782 -0
- fitevict/gpu_state.py +173 -0
- fitevict/host.py +84 -0
- fitevict/lifted/__init__.py +8 -0
- fitevict/lifted/app_dirs.py +177 -0
- fitevict/lifted/binaries.py +47 -0
- fitevict/lifted/constants.py +91 -0
- fitevict/lifted/gguf_election.py +263 -0
- fitevict/lifted/gguf_inspect.py +985 -0
- fitevict/lifted/gguf_need.py +142 -0
- fitevict/lifted/hardware.py +204 -0
- fitevict/lifted/model_resolve.py +227 -0
- fitevict/lifted/native_resolve.py +115 -0
- fitevict/lifted/no_think.py +30 -0
- fitevict/lifted/nvml.py +128 -0
- fitevict/lifted/platform_facade.py +64 -0
- fitevict/lifted/procutil.py +78 -0
- fitevict/lifted/spill_reserve.py +227 -0
- fitevict/lifted/tf_child.py +556 -0
- fitevict/lifted/tf_facts.py +144 -0
- fitevict/lifted/timings.py +148 -0
- fitevict/plan.py +312 -0
- fitevict/predict.py +107 -0
- fitevict/run_engine.py +223 -0
- fitevict/sizing.py +585 -0
- fitevict/types.py +345 -0
- hugpy_wrapper-0.1.0.dist-info/METADATA +101 -0
- hugpy_wrapper-0.1.0.dist-info/RECORD +39 -0
- hugpy_wrapper-0.1.0.dist-info/WHEEL +5 -0
- hugpy_wrapper-0.1.0.dist-info/entry_points.txt +3 -0
- hugpy_wrapper-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1298 @@
|
|
|
1
|
+
"""A minimal llama.cpp adapter STUB — the concrete seam, not a working engine.
|
|
2
|
+
|
|
3
|
+
It shows how `EngineHost.footprint` would price a GGUF from its typed structure
|
|
4
|
+
following llama.cpp's placement rules (clean-room; no hugpy import):
|
|
5
|
+
|
|
6
|
+
* `--n-gpu-layers L` puts the top L of `block_count` blocks on the GPU; the
|
|
7
|
+
output tensor rides the GPU only when all layers do.
|
|
8
|
+
* `--n-cpu-moe N` keeps the expert tensors of the first N blocks on the CPU;
|
|
9
|
+
every other tensor of a GPU block stays on the GPU.
|
|
10
|
+
* KV rides its block (a GPU layer holds its KV slice on the GPU).
|
|
11
|
+
|
|
12
|
+
`gguf_footprint` is pure math over a structure dict; a real adapter would read
|
|
13
|
+
that structure from the file and implement `measure` / `list_residents` /
|
|
14
|
+
`evict` / `load` against the running engine. Only the pricing seam is made
|
|
15
|
+
concrete here, deliberately — geometry belongs in the adapter, never the core.
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from typing import Optional
|
|
20
|
+
|
|
21
|
+
from ..types import ModelFootprint
|
|
22
|
+
|
|
23
|
+
_GPU_MARGIN = 1.15
|
|
24
|
+
_RAM_MARGIN = 1.15
|
|
25
|
+
_DTYPE_BYTES = 2.0
|
|
26
|
+
_COMPUTE_CUSHION_BYTES = 512 * 2 ** 20
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def gguf_footprint(struct: dict, *, ctx_tokens: int, n_gpu_layers: int = -1,
|
|
30
|
+
n_cpu_moe: int = 0, ctx_pct: Optional[int] = None,
|
|
31
|
+
ctx_deviation_pct: Optional[float] = None) -> ModelFootprint:
|
|
32
|
+
"""Price a GGUF load into a `ModelFootprint`.
|
|
33
|
+
|
|
34
|
+
`struct` carries the typed per-layer bytes the way a GGUF inspector would
|
|
35
|
+
report them::
|
|
36
|
+
|
|
37
|
+
{"block_count": int,
|
|
38
|
+
"token_embd_bytes": int, "output_bytes": int, "global_bytes": int,
|
|
39
|
+
"is_moe": bool, "output_tied": bool,
|
|
40
|
+
"kv_elems_per_token": int, # full-attention KV elems / token / layer
|
|
41
|
+
"layers": {i: {"always_bytes": int, "expert_bytes": int}}}
|
|
42
|
+
|
|
43
|
+
The returned footprint's `weights_bytes`/`kv_bytes` are the GPU-side figures
|
|
44
|
+
(what admission gates on); `expert_bytes`/`non_expert_bytes` carry the MoE
|
|
45
|
+
split so the planner can reason about an expert spill."""
|
|
46
|
+
layers = {int(k): v for k, v in (struct.get("layers") or {}).items()}
|
|
47
|
+
n_block = int(struct.get("block_count") or len(layers))
|
|
48
|
+
ngl = int(n_gpu_layers if n_gpu_layers is not None else -1)
|
|
49
|
+
if ngl < 0 or ngl > n_block:
|
|
50
|
+
start, out_on_gpu = 0, True
|
|
51
|
+
else:
|
|
52
|
+
start, out_on_gpu = n_block - ngl, False
|
|
53
|
+
gpu_layers = {i for i in layers if i >= start} if ngl != 0 else set()
|
|
54
|
+
|
|
55
|
+
is_moe = bool(struct.get("is_moe"))
|
|
56
|
+
ncm = max(0, int(n_cpu_moe or 0)) if is_moe else 0
|
|
57
|
+
|
|
58
|
+
gw = rw = experts_gpu = experts_cpu = non_expert = 0
|
|
59
|
+
for i, L in layers.items():
|
|
60
|
+
a = int(L.get("always_bytes") or 0)
|
|
61
|
+
e = int(L.get("expert_bytes") or 0)
|
|
62
|
+
non_expert += a
|
|
63
|
+
if i in gpu_layers:
|
|
64
|
+
gw += a
|
|
65
|
+
if i < ncm:
|
|
66
|
+
rw += e
|
|
67
|
+
experts_cpu += e
|
|
68
|
+
else:
|
|
69
|
+
gw += e
|
|
70
|
+
experts_gpu += e
|
|
71
|
+
else:
|
|
72
|
+
rw += a + e
|
|
73
|
+
experts_cpu += e
|
|
74
|
+
|
|
75
|
+
embd = int(struct.get("token_embd_bytes") or 0)
|
|
76
|
+
outb = int(struct.get("output_bytes") or 0)
|
|
77
|
+
glob = int(struct.get("global_bytes") or 0)
|
|
78
|
+
non_expert += embd + outb + glob
|
|
79
|
+
rw += embd # input layer is always on the CPU
|
|
80
|
+
if out_on_gpu:
|
|
81
|
+
gw += outb + glob + (embd if struct.get("output_tied") else 0)
|
|
82
|
+
else:
|
|
83
|
+
rw += outb + glob
|
|
84
|
+
|
|
85
|
+
# KV: full-attention per-token elems * ctx * dtype, shared by GPU layers.
|
|
86
|
+
per_tok = int(struct.get("kv_elems_per_token") or 0)
|
|
87
|
+
kv_total = int(ctx_tokens * per_tok * _DTYPE_BYTES)
|
|
88
|
+
n_gpu = len(gpu_layers)
|
|
89
|
+
kv_gpu = int(kv_total * (n_gpu / float(n_block))) if n_block else 0
|
|
90
|
+
cushion = _COMPUTE_CUSHION_BYTES if (gpu_layers and is_moe) else 0
|
|
91
|
+
|
|
92
|
+
gpu_weights = int(gw * _GPU_MARGIN)
|
|
93
|
+
gpu_total = gpu_weights + kv_gpu + cushion
|
|
94
|
+
return ModelFootprint(
|
|
95
|
+
weights_bytes=gpu_weights,
|
|
96
|
+
kv_bytes=kv_gpu,
|
|
97
|
+
total_bytes=gpu_total,
|
|
98
|
+
layers=n_block,
|
|
99
|
+
moe=is_moe,
|
|
100
|
+
expert_bytes=int(experts_gpu + experts_cpu) or None,
|
|
101
|
+
non_expert_bytes=int(non_expert) or None,
|
|
102
|
+
ctx_pct=ctx_pct,
|
|
103
|
+
ctx_deviation_pct=ctx_deviation_pct,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
108
|
+
# LlamaCppHost — the concrete EngineHost that RUNS llama-server children.
|
|
109
|
+
#
|
|
110
|
+
# GLUE (new code, the only kind the task allows here): it maps the LIFTED results
|
|
111
|
+
# onto fitevict's types and executes drive()'s plan. Every fact comes from a
|
|
112
|
+
# lifted function or a live measurement — nothing is hardcoded:
|
|
113
|
+
# * footprint <- lifted gguf_inspect.gguf_structure + fit.gguf_need.gguf_need
|
|
114
|
+
# * measure <- lifted hardware.detect_gpus + spill reserve/free_* closure
|
|
115
|
+
# * resolution <- lifted model_resolve (is_model_dir / get_gguf_file / marker)
|
|
116
|
+
# * binary <- lifted native_resolve.server_bin
|
|
117
|
+
# * launch <- lifted procutil.popen_detached, argv per slot_agent._build_cmd
|
|
118
|
+
# * stop <- lifted procutil.terminate_tree
|
|
119
|
+
# * timings <- lifted timings.tok_s_from_timings / generation_split
|
|
120
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
121
|
+
import json as _json
|
|
122
|
+
import os as _os
|
|
123
|
+
import socket as _socket
|
|
124
|
+
import subprocess as _subprocess
|
|
125
|
+
import time as _time
|
|
126
|
+
import urllib.request as _urllib
|
|
127
|
+
import uuid as _uuid
|
|
128
|
+
from dataclasses import dataclass, field
|
|
129
|
+
from typing import Dict, List, Optional
|
|
130
|
+
|
|
131
|
+
from ..types import (
|
|
132
|
+
DeviceBudget, FitPlan, HostBudget, ModelFootprint, Resident, VRAM,
|
|
133
|
+
)
|
|
134
|
+
from ..lifted import (
|
|
135
|
+
hardware as _hw,
|
|
136
|
+
model_resolve as _mr,
|
|
137
|
+
native_resolve as _nr,
|
|
138
|
+
spill_reserve as _spill,
|
|
139
|
+
timings as _tm,
|
|
140
|
+
)
|
|
141
|
+
from ..lifted.gguf_need import gguf_need as _gguf_need, structure_for as _structure_for
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def discover_models(models_home: Optional[str] = None) -> "Dict[str, str]":
|
|
145
|
+
"""{model_key: gguf_path} for every GGUF model dir on disk, via the lifted
|
|
146
|
+
legacy resolution (walk MODELS_HOME, keep dirs is_model_dir() accepts with a
|
|
147
|
+
model GGUF get_gguf_file() elects). model_key = the repo dir basename, or
|
|
148
|
+
``owner~repo`` when two owners hold a repo of the same name (hugpy's key
|
|
149
|
+
convention). A vision projector is never elected as the model. This box's
|
|
150
|
+
discovery is THE catalog: the front door publishes it to the DB."""
|
|
151
|
+
from ..lifted.constants import MODELS_HOME
|
|
152
|
+
root = _os.path.join(models_home or MODELS_HOME, "gguf")
|
|
153
|
+
found: "list[tuple[str, str, str]]" = []
|
|
154
|
+
if not _os.path.isdir(root):
|
|
155
|
+
return {}
|
|
156
|
+
for owner in sorted(_os.listdir(root)):
|
|
157
|
+
od = _os.path.join(root, owner)
|
|
158
|
+
if not _os.path.isdir(od):
|
|
159
|
+
continue
|
|
160
|
+
for repo in sorted(_os.listdir(od)):
|
|
161
|
+
rd = _os.path.join(od, repo)
|
|
162
|
+
if not _os.path.isdir(rd) or not _mr.is_model_dir(rd):
|
|
163
|
+
continue
|
|
164
|
+
g = _mr.get_gguf_file(rd)
|
|
165
|
+
if g and ("/mmproj/" in g or _os.path.basename(g).lower().startswith("mmproj")):
|
|
166
|
+
g = None
|
|
167
|
+
if g:
|
|
168
|
+
found.append((owner, repo, g))
|
|
169
|
+
# transformers chat dirs (the other engine hugpy serves chat with)
|
|
170
|
+
from . import transformers as _tf
|
|
171
|
+
for owner, repo, d in _tf.discover(models_home or MODELS_HOME):
|
|
172
|
+
found.append((owner, repo, d))
|
|
173
|
+
counts: Dict[str, int] = {}
|
|
174
|
+
for _o, repo, _g in found:
|
|
175
|
+
counts[repo] = counts.get(repo, 0) + 1
|
|
176
|
+
out: Dict[str, str] = {}
|
|
177
|
+
for owner, repo, g in found:
|
|
178
|
+
out[f"{owner}~{repo}" if counts[repo] > 1 else repo] = g
|
|
179
|
+
return out
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def engine_of_path(path: Optional[str]) -> str:
|
|
183
|
+
"""'gguf' for a GGUF file, 'transformers' for a model dir."""
|
|
184
|
+
return "gguf" if path and str(path).lower().endswith(".gguf") else "transformers"
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def model_weights(key: str, gguf_path: str) -> dict:
|
|
188
|
+
"""Discovery detail for one model: its directory, the GGUF the loader elects,
|
|
189
|
+
and EVERY weight file in the directory (quant + bytes; the mmproj projector
|
|
190
|
+
flagged). Pure filesystem facts — nothing is opened or priced."""
|
|
191
|
+
from ..lifted.gguf_election import quant_token
|
|
192
|
+
if engine_of_path(gguf_path) == "transformers":
|
|
193
|
+
from ..lifted import tf_facts as _tff
|
|
194
|
+
d = gguf_path
|
|
195
|
+
weights = []
|
|
196
|
+
for root, _dn, files in _os.walk(d):
|
|
197
|
+
for f in sorted(files):
|
|
198
|
+
if f.endswith(_tff.WEIGHT_EXT):
|
|
199
|
+
p = _os.path.join(root, f)
|
|
200
|
+
try:
|
|
201
|
+
size = _os.path.getsize(p)
|
|
202
|
+
except OSError:
|
|
203
|
+
size = None
|
|
204
|
+
weights.append({"file": _os.path.relpath(p, d), "path": p, "quant": None,
|
|
205
|
+
"bytes": size, "mmproj": False, "elected": True})
|
|
206
|
+
mp = _os.path.join(d, "hugpy.json")
|
|
207
|
+
hj, hje = None, None
|
|
208
|
+
try:
|
|
209
|
+
with open(mp, "r", encoding="utf-8") as fh:
|
|
210
|
+
hj = _json.load(fh)
|
|
211
|
+
except (OSError, ValueError) as exc:
|
|
212
|
+
hje = f"{type(exc).__name__}: {exc}"
|
|
213
|
+
return {"dir": d, "path": d, "hugpy_json": hj, "hugpy_json_error": hje,
|
|
214
|
+
"file": "dir", "quant": "dir", "engine": "transformers",
|
|
215
|
+
"bytes": _tff.dir_bytes(d), "mmproj": None, "weights": weights}
|
|
216
|
+
d = _os.path.dirname(gguf_path)
|
|
217
|
+
while d and _os.path.basename(d) != key and _os.path.dirname(d) != d:
|
|
218
|
+
d = _os.path.dirname(d)
|
|
219
|
+
if _os.path.basename(d) != key:
|
|
220
|
+
d = _os.path.dirname(gguf_path)
|
|
221
|
+
weights = []
|
|
222
|
+
for g in _mr.get_guffs_in_dir(d):
|
|
223
|
+
try:
|
|
224
|
+
size = _os.path.getsize(g)
|
|
225
|
+
except OSError:
|
|
226
|
+
size = None
|
|
227
|
+
weights.append({"file": _os.path.relpath(g, d), "path": g,
|
|
228
|
+
"quant": quant_token(g) or None, "bytes": size,
|
|
229
|
+
"mmproj": _mr.is_mmproj_file(g),
|
|
230
|
+
"elected": g == gguf_path})
|
|
231
|
+
elected = next((w for w in weights if w["elected"]), None)
|
|
232
|
+
hugpy_json, hugpy_json_error = None, None
|
|
233
|
+
mp = _os.path.join(d, "hugpy.json")
|
|
234
|
+
if _os.path.isfile(mp):
|
|
235
|
+
try:
|
|
236
|
+
with open(mp, "r", encoding="utf-8") as fh:
|
|
237
|
+
hugpy_json = _json.load(fh)
|
|
238
|
+
except (OSError, ValueError) as exc:
|
|
239
|
+
hugpy_json_error = f"{type(exc).__name__}: {exc}"
|
|
240
|
+
else:
|
|
241
|
+
hugpy_json_error = "no hugpy.json in the model dir"
|
|
242
|
+
return {"dir": d, "path": gguf_path, "hugpy_json": hugpy_json,
|
|
243
|
+
"hugpy_json_error": hugpy_json_error,
|
|
244
|
+
"file": _os.path.basename(gguf_path),
|
|
245
|
+
"quant": (elected or {}).get("quant"),
|
|
246
|
+
"bytes": (elected or {}).get("bytes"),
|
|
247
|
+
"mmproj": _mr.find_mmproj(gguf_path),
|
|
248
|
+
"weights": weights}
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _compute_apps_vram() -> "Dict[int, int]":
|
|
252
|
+
"""{pid: used_vram_bytes} — EXACT bytes from NVML (lifted.nvml); nvidia-smi's
|
|
253
|
+
whole-MiB rows only when NVML is unavailable."""
|
|
254
|
+
try:
|
|
255
|
+
from ..lifted import nvml as _nvml
|
|
256
|
+
procs = _nvml.processes()
|
|
257
|
+
if procs is not None:
|
|
258
|
+
return {int(pid): int(r["bytes"]) for pid, r in procs.items()}
|
|
259
|
+
except Exception: # noqa: BLE001
|
|
260
|
+
pass
|
|
261
|
+
smi = _hw.resolve_bin("nvidia-smi")
|
|
262
|
+
if not smi:
|
|
263
|
+
return {}
|
|
264
|
+
try:
|
|
265
|
+
out = _subprocess.check_output(
|
|
266
|
+
[smi, "--query-compute-apps=pid,used_memory",
|
|
267
|
+
"--format=csv,noheader,nounits"],
|
|
268
|
+
stderr=_subprocess.DEVNULL, timeout=10).decode("utf-8", "replace")
|
|
269
|
+
except Exception: # noqa: BLE001
|
|
270
|
+
return {}
|
|
271
|
+
res: Dict[int, int] = {}
|
|
272
|
+
for line in out.strip().splitlines():
|
|
273
|
+
parts = [p.strip() for p in line.split(",")]
|
|
274
|
+
if len(parts) >= 2 and parts[0].isdigit() and parts[1].isdigit():
|
|
275
|
+
res[int(parts[0])] = int(parts[1]) * 1024 * 1024
|
|
276
|
+
return res
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
_SERVER_FLAG_CACHE: dict = {}
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _server_supports_flag(server_bin, flag) -> bool:
|
|
283
|
+
"""Lifted from slot_agent._server_supports_flag: probe `llama-server --help`
|
|
284
|
+
once and cache whether `flag` appears. Can't probe -> assume unsupported."""
|
|
285
|
+
key = (str(server_bin), str(flag))
|
|
286
|
+
cached = _SERVER_FLAG_CACHE.get(key)
|
|
287
|
+
if cached is not None:
|
|
288
|
+
return cached
|
|
289
|
+
ok = False
|
|
290
|
+
try:
|
|
291
|
+
out = _subprocess.run([server_bin, "--help"], capture_output=True,
|
|
292
|
+
text=True, timeout=15)
|
|
293
|
+
ok = flag in ((out.stdout or "") + (out.stderr or ""))
|
|
294
|
+
except Exception: # noqa: BLE001
|
|
295
|
+
ok = False
|
|
296
|
+
_SERVER_FLAG_CACHE[key] = ok
|
|
297
|
+
return ok
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _proc_rss_bytes(pid: int) -> Optional[int]:
|
|
301
|
+
try:
|
|
302
|
+
with open(f"/proc/{pid}/status") as fh:
|
|
303
|
+
for line in fh:
|
|
304
|
+
if line.startswith("VmRSS:"):
|
|
305
|
+
return int(line.split()[1]) * 1024
|
|
306
|
+
except Exception: # noqa: BLE001
|
|
307
|
+
return None
|
|
308
|
+
return None
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _gpu_changed(reason: str) -> None:
|
|
312
|
+
"""Tell the live GPU-state publisher the card just changed (fitevict.gpu_state)."""
|
|
313
|
+
try:
|
|
314
|
+
from .. import gpu_state as _gs
|
|
315
|
+
_gs.changed(reason)
|
|
316
|
+
except Exception: # noqa: BLE001 — telemetry never stops a load/evict
|
|
317
|
+
pass
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
# llama-server parallel slots per model (operator 2026-10-03: one): a hybrid
|
|
321
|
+
# model's recurrent state is allocated PER SLOT (4 slots = 4x the state).
|
|
322
|
+
_PARALLEL = int(_os.environ.get("FITEVICT_PARALLEL", "1"))
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _db_kv_structure(worker, file):
|
|
326
|
+
from . import db as _db
|
|
327
|
+
return _db.get_kv_structure(worker, file)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _db_get_driver(worker, device):
|
|
331
|
+
from . import db as _db
|
|
332
|
+
return int(_db.get_cuda_context(worker, device + ":driver") or 0)
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
# the request whose placement is evicting (set by the front door under its placement lock)
|
|
336
|
+
_CURRENT_REQUEST: dict = {"id": None}
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
@dataclass
|
|
340
|
+
class _Child:
|
|
341
|
+
key: str
|
|
342
|
+
proc: object
|
|
343
|
+
port: int
|
|
344
|
+
model_file: str
|
|
345
|
+
struct: dict
|
|
346
|
+
need: dict
|
|
347
|
+
n_gpu_layers: int
|
|
348
|
+
n_cpu_moe: Optional[int]
|
|
349
|
+
ctx: int
|
|
350
|
+
total_layers: Optional[int]
|
|
351
|
+
is_moe: bool
|
|
352
|
+
quant: str
|
|
353
|
+
mmproj: Optional[str]
|
|
354
|
+
resident_since: float
|
|
355
|
+
weights_staged_at: Optional[float] = None
|
|
356
|
+
weights_loaded_at: Optional[float] = None
|
|
357
|
+
load_error: Optional[str] = None
|
|
358
|
+
last_used: Optional[float] = None
|
|
359
|
+
calls: int = 0
|
|
360
|
+
vram_at_load: Optional[int] = None
|
|
361
|
+
argv: Optional[list] = None
|
|
362
|
+
contract: Optional[dict] = None
|
|
363
|
+
|
|
364
|
+
@property
|
|
365
|
+
def base_url(self) -> str:
|
|
366
|
+
return f"http://127.0.0.1:{self.port}"
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
_HOSTROWS = __import__("re").compile(r"\|\s*-\s*([A-Za-z0-9_]+)\s*\|\s*(\d+)\s*=\s*(\d+)\s*\+\s*(\d+)\s*\+\s*(\d+)")
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def host_rows_first_block(text):
|
|
373
|
+
"""Every host-side row (Host, CPU_REPACK, ...) of llama.cpp's FIRST memory
|
|
374
|
+
breakdown (the initial full config), summed: [total, model, context, compute]
|
|
375
|
+
in MiB, or None. CUDA rows have the '= free + (self = ...)' shape and never match.
|
|
376
|
+
CPU_REPACK (weights repacked for the CPU kernels, anonymous memory) is its own
|
|
377
|
+
row — reading only '- Host' missed 35,450 MiB on Qwen3-Coder-Next (2026-10-03)."""
|
|
378
|
+
i = text.find("memory breakdown")
|
|
379
|
+
j = text.find("projected to use", i if i >= 0 else 0)
|
|
380
|
+
if i < 0 or j < 0:
|
|
381
|
+
return None
|
|
382
|
+
rows = _HOSTROWS.findall(text[i:j])
|
|
383
|
+
if not rows:
|
|
384
|
+
return None
|
|
385
|
+
out = [sum(int(r[k]) for r in rows) for k in (1, 2, 3, 4)]
|
|
386
|
+
out.append(sum(int(r[2]) for r in rows if r[0] != "Host")) # [4] repacked weights (CPU_REPACK, ...)
|
|
387
|
+
return out
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
class LlamaCppHost:
|
|
391
|
+
"""Concrete `EngineHost`: measures the box, prices GGUFs, launches/stops
|
|
392
|
+
llama-server children, and lists the children IT started as residents."""
|
|
393
|
+
|
|
394
|
+
def __init__(self, ctx: int = 4096, main_gpu: int = 0,
|
|
395
|
+
log_dir: Optional[str] = None):
|
|
396
|
+
self.ctx = int(ctx)
|
|
397
|
+
self.main_gpu = int(main_gpu)
|
|
398
|
+
self.children: "Dict[str, _Child]" = {}
|
|
399
|
+
self.models: Dict[str, str] = {}
|
|
400
|
+
self._models_published = False
|
|
401
|
+
self.refresh_models()
|
|
402
|
+
self.contracts: Dict[str, dict] = {}
|
|
403
|
+
# Host constraints handed down by the harness (never knobs): a ceiling
|
|
404
|
+
# on what this front door may hold, and seats it must never evict.
|
|
405
|
+
self.gpu_limit: Optional[int] = None
|
|
406
|
+
self.ram_limit: Optional[int] = None
|
|
407
|
+
self.protected: set = set()
|
|
408
|
+
# 🔒static models (DB: the worker's config.residency) — never evicted
|
|
409
|
+
# once loaded; refreshed before every placement.
|
|
410
|
+
self.static_keys: set = set()
|
|
411
|
+
self.server_bin = _nr.server_bin()
|
|
412
|
+
self.log_dir = log_dir or _os.path.join(
|
|
413
|
+
_os.path.expanduser("~"), ".fitevict", "run-logs")
|
|
414
|
+
_os.makedirs(self.log_dir, exist_ok=True)
|
|
415
|
+
self._port_next = int(_os.environ.get("FITEVICT_PORT_BASE", "8190"))
|
|
416
|
+
|
|
417
|
+
# ── measurement ─────────────────────────────────────────────────────────
|
|
418
|
+
def measure(self) -> HostBudget:
|
|
419
|
+
gpus = _hw.detect_gpus()
|
|
420
|
+
g0 = gpus[self.main_gpu] if len(gpus) > self.main_gpu else {}
|
|
421
|
+
total = _hw.total_vram_bytes(self.main_gpu) or g0.get("memory_total")
|
|
422
|
+
free = _spill.free_vram_bytes() # budgetable (reserve removed)
|
|
423
|
+
if free is None:
|
|
424
|
+
free = g0.get("memory_free")
|
|
425
|
+
# a dry-run projection probe (HUGPY_PROBE) is transient — its bytes and
|
|
426
|
+
# its G7 driver bytes are not taken from the room a placement sees
|
|
427
|
+
if free is not None:
|
|
428
|
+
try:
|
|
429
|
+
from .. import gpu_state as _gs
|
|
430
|
+
procs = _compute_apps_vram()
|
|
431
|
+
drv = int(_db_get_driver(self._worker(), self._device_key()) or 0)
|
|
432
|
+
free = int(free) + sum(int(b) + drv for pid, b in procs.items() if b and _gs._is_probe(int(pid)))
|
|
433
|
+
except Exception: # noqa: BLE001
|
|
434
|
+
pass
|
|
435
|
+
ram_free = _spill.free_ram_bytes()
|
|
436
|
+
if self.gpu_limit is not None or self.ram_limit is not None:
|
|
437
|
+
apps = _compute_apps_vram()
|
|
438
|
+
mine_vram = mine_ram = 0
|
|
439
|
+
for ch in self.children.values():
|
|
440
|
+
if self._alive(ch):
|
|
441
|
+
pid = getattr(ch.proc, "pid", None)
|
|
442
|
+
mine_vram += int(apps.get(pid) or 0)
|
|
443
|
+
mine_ram += int(_proc_rss_bytes(pid) or 0) if pid else 0
|
|
444
|
+
if self.gpu_limit is not None:
|
|
445
|
+
total = min(int(total or self.gpu_limit), int(self.gpu_limit))
|
|
446
|
+
room = max(0, int(self.gpu_limit) - mine_vram)
|
|
447
|
+
free = room if free is None else min(int(free), room)
|
|
448
|
+
if self.ram_limit is not None:
|
|
449
|
+
room = max(0, int(self.ram_limit) - mine_ram)
|
|
450
|
+
ram_free = room if ram_free is None else min(int(ram_free), room)
|
|
451
|
+
dev = DeviceBudget(id=self.main_gpu, total_bytes=total, free_bytes=free,
|
|
452
|
+
external_floor_bytes=_spill.vram_reserve_bytes())
|
|
453
|
+
ram_total = None
|
|
454
|
+
try:
|
|
455
|
+
import psutil
|
|
456
|
+
ram_total = int(psutil.virtual_memory().total)
|
|
457
|
+
except Exception: # noqa: BLE001
|
|
458
|
+
ram_total = None
|
|
459
|
+
return HostBudget(devices=(dev,), ram_free_bytes=ram_free,
|
|
460
|
+
ram_total_bytes=ram_total,
|
|
461
|
+
ram_reserve_bytes=_spill.ram_reserve_bytes(),
|
|
462
|
+
now=_time.time())
|
|
463
|
+
|
|
464
|
+
def refresh_static(self) -> None:
|
|
465
|
+
from . import db as _db
|
|
466
|
+
got = _db.static_keys(_os.environ.get("FITEVICT_WORKER", "ae"))
|
|
467
|
+
if got is not None:
|
|
468
|
+
self.static_keys = got
|
|
469
|
+
|
|
470
|
+
def refresh_models(self) -> int:
|
|
471
|
+
"""Re-walk this box's disk (the catalog's source) and publish it to the
|
|
472
|
+
DB (inference_engine.models) whenever it changed."""
|
|
473
|
+
found = discover_models()
|
|
474
|
+
if found != self.models or not self._models_published:
|
|
475
|
+
self.models = found
|
|
476
|
+
from . import db as _db
|
|
477
|
+
rows = []
|
|
478
|
+
for key, path in sorted(found.items()):
|
|
479
|
+
w = model_weights(key, path)
|
|
480
|
+
files = w.get("files") or w.get("weights") or []
|
|
481
|
+
rows.append({"key": key, "path": path, "model_dir": w.get("dir"),
|
|
482
|
+
"quant": w.get("quant"), "engine": engine_of_path(path),
|
|
483
|
+
"bytes": (_os.path.getsize(path) if _os.path.exists(path) else None),
|
|
484
|
+
"files": files})
|
|
485
|
+
worker = _os.environ.get("FITEVICT_WORKER", "ae")
|
|
486
|
+
self._models_published = _db.write_models(worker, rows)
|
|
487
|
+
return len(self.models)
|
|
488
|
+
|
|
489
|
+
def list_residents(self) -> "List[Resident]":
|
|
490
|
+
apps = _compute_apps_vram()
|
|
491
|
+
res: List[Resident] = []
|
|
492
|
+
for key, ch in list(self.children.items()):
|
|
493
|
+
if not self._alive(ch):
|
|
494
|
+
continue
|
|
495
|
+
pid = getattr(ch.proc, "pid", None)
|
|
496
|
+
vram = apps.get(pid)
|
|
497
|
+
if vram is not None:
|
|
498
|
+
ch.vram_at_load = vram
|
|
499
|
+
res.append(Resident(
|
|
500
|
+
key=key, device_id=self.main_gpu, vram_bytes=vram,
|
|
501
|
+
ram_bytes=_proc_rss_bytes(pid) if pid else None,
|
|
502
|
+
last_used=ch.last_used, calls=ch.calls, pref=VRAM,
|
|
503
|
+
resident_since=ch.resident_since,
|
|
504
|
+
protected=key in self.protected or key in self.static_keys))
|
|
505
|
+
return res
|
|
506
|
+
|
|
507
|
+
# ── pricing ──────────────────────────────────────────────────────────────
|
|
508
|
+
# ── per-call load contract (the caller's knobs; never stored past the call) ─
|
|
509
|
+
def set_contract(self, key: str, knobs: Optional[dict]) -> dict:
|
|
510
|
+
"""Resolve the caller's knobs for ``key`` into a load contract:
|
|
511
|
+
``path`` (gguf_file inside the model's dir, else the elected file) and
|
|
512
|
+
``moe`` (True = experts in RAM, False = no expert split, None = auto).
|
|
513
|
+
Unknown/unsafe knob values fall back to the default, never fail."""
|
|
514
|
+
knobs = knobs if isinstance(knobs, dict) else {}
|
|
515
|
+
path = self.models.get(key)
|
|
516
|
+
want = knobs.get("gguf_file")
|
|
517
|
+
if path and engine_of_path(path) == "gguf" and isinstance(want, str) and want.strip():
|
|
518
|
+
d = _os.path.dirname(path)
|
|
519
|
+
while d and _os.path.basename(d) != key and _os.path.dirname(d) != d:
|
|
520
|
+
d = _os.path.dirname(d)
|
|
521
|
+
if _os.path.basename(d) != key:
|
|
522
|
+
d = _os.path.dirname(path)
|
|
523
|
+
cand = _os.path.realpath(_os.path.join(d, want.strip()))
|
|
524
|
+
if cand.startswith(_os.path.realpath(d) + _os.sep) and _os.path.isfile(cand) \
|
|
525
|
+
and cand.lower().endswith(".gguf"):
|
|
526
|
+
path = cand
|
|
527
|
+
moe = knobs.get("moe")
|
|
528
|
+
mode = knobs.get("alloc_mode")
|
|
529
|
+
mode = mode if mode in ("gpu-only", "ram-only", "max-gpu", "max-ram", "explicit") else None
|
|
530
|
+
|
|
531
|
+
def _layers(v):
|
|
532
|
+
try:
|
|
533
|
+
return int(v) if v is not None and int(v) >= -1 else None
|
|
534
|
+
except (TypeError, ValueError):
|
|
535
|
+
return None
|
|
536
|
+
# ctx pin (ctx_pct of the trained window) -> the launch context; absent
|
|
537
|
+
# = the host default. 4-bit (bitsandbytes) applies to transformers only.
|
|
538
|
+
ctx = None
|
|
539
|
+
try:
|
|
540
|
+
pct = knobs.get("ctx_pct")
|
|
541
|
+
if pct is not None and path:
|
|
542
|
+
if engine_of_path(path) == "transformers":
|
|
543
|
+
from . import transformers as _tf
|
|
544
|
+
trained = int(_tf.geometry(path).get("ctx_train") or 0)
|
|
545
|
+
else:
|
|
546
|
+
trained = int((_structure_for(path) or {}).get("ctx_train") or 0)
|
|
547
|
+
if trained:
|
|
548
|
+
ctx = max(1024, (int(trained * float(pct) / 100) // 1024) * 1024)
|
|
549
|
+
except (TypeError, ValueError):
|
|
550
|
+
ctx = None
|
|
551
|
+
kvt = str(knobs.get("kv_cache_type") or "f16").lower()
|
|
552
|
+
kvt = kvt if kvt in ("f16", "q8_0", "q4_0") else "f16"
|
|
553
|
+
c = {"path": path, "moe": moe if isinstance(moe, bool) else None,
|
|
554
|
+
"alloc_mode": mode, "ctx": ctx, "kv_type": kvt,
|
|
555
|
+
"flash_attn": knobs.get("flash_attn") is True or kvt != "f16",
|
|
556
|
+
"bnb4": knobs.get("bnb_4bit") is True and bool(path) and engine_of_path(path) == "transformers",
|
|
557
|
+
# explicit placement in llama.cpp terms (legacy 0.2.6 explicit panel)
|
|
558
|
+
"n_gpu_layers": _layers(knobs.get("attention_gpu_layers", knobs.get("n_gpu_layers"))),
|
|
559
|
+
"n_cpu_moe": _layers(knobs.get("experts_cpu_layers", knobs.get("n_cpu_moe")))}
|
|
560
|
+
self.contracts[key] = c
|
|
561
|
+
return c
|
|
562
|
+
|
|
563
|
+
def apply_ctx(self, key: str, info: dict, knobs: Optional[dict]) -> Optional[int]:
|
|
564
|
+
"""The launch context from the DB (operator 2026-10-03): a pinned ctx_pct
|
|
565
|
+
is that tick's own ctx (kv_cost.ticks), no pin = the history default
|
|
566
|
+
(model_default_ctx). Sets contract ctx / ctx_start; returns the ctx."""
|
|
567
|
+
c = self.contracts.get(key)
|
|
568
|
+
if c is None or not info:
|
|
569
|
+
return None
|
|
570
|
+
ticks = info.get("ticks") or {}
|
|
571
|
+
pct = (knobs or {}).get("ctx_pct")
|
|
572
|
+
ctx = None
|
|
573
|
+
if pct is not None:
|
|
574
|
+
t = ticks.get(str(max(1, min(100, int(pct))))) or {}
|
|
575
|
+
ctx = int(t["ctx"]) if t.get("ctx") else c.get("ctx")
|
|
576
|
+
c["ctx_source"] = "pinned"
|
|
577
|
+
elif info.get("default_ctx"):
|
|
578
|
+
ctx = int(info["default_ctx"])
|
|
579
|
+
c["ctx_source"] = f"history ({info.get('basis')}, largest used {info.get('need_tokens')} tokens)"
|
|
580
|
+
if ctx:
|
|
581
|
+
c["ctx"] = ctx
|
|
582
|
+
c["ctx_start"] = ctx
|
|
583
|
+
c["ctx_ladder"] = sorted({int(v["ctx"]) for v in ticks.values() if isinstance(v, dict) and v.get("ctx")})
|
|
584
|
+
c["ctx_need"] = info.get("need_tokens")
|
|
585
|
+
return ctx
|
|
586
|
+
|
|
587
|
+
def contract_of(self, key: str) -> dict:
|
|
588
|
+
return self.contracts.get(key) or {"path": self.models.get(key), "moe": None,
|
|
589
|
+
"alloc_mode": None, "n_gpu_layers": None,
|
|
590
|
+
"n_cpu_moe": None, "ctx": None, "bnb4": False,
|
|
591
|
+
"kv_type": "f16", "flash_attn": False}
|
|
592
|
+
|
|
593
|
+
def footprint(self, key: str, quant: Optional[str] = None) -> ModelFootprint:
|
|
594
|
+
c = self.contract_of(key)
|
|
595
|
+
path = c["path"]
|
|
596
|
+
if not path:
|
|
597
|
+
return ModelFootprint() # unknown -> planner fails open
|
|
598
|
+
if engine_of_path(path) == "transformers":
|
|
599
|
+
from . import transformers as _tf
|
|
600
|
+
f = _tf.footprint(path, c.get("ctx") or self.ctx, bnb4=bool(c.get("bnb4")),
|
|
601
|
+
worker=self._worker(), key=key)
|
|
602
|
+
return ModelFootprint(
|
|
603
|
+
weights_bytes=f["weights_bytes"] + f["compute_bytes"],
|
|
604
|
+
kv_bytes=f["kv_bytes"], total_bytes=f["total_bytes"],
|
|
605
|
+
layers=f["layers"], moe=False)
|
|
606
|
+
struct = _structure_for(path)
|
|
607
|
+
mmproj = _mr.find_mmproj(path)
|
|
608
|
+
mmproj_bytes = _os.path.getsize(mmproj) if mmproj and _os.path.isfile(mmproj) else 0
|
|
609
|
+
ngl, ncm = self._placement(c, struct)
|
|
610
|
+
ctx = c.get("ctx") or self.ctx
|
|
611
|
+
p = self.project(path, ctx, ngl, ncm, c.get("kv_type") or "f16", bool(c.get("flash_attn")))
|
|
612
|
+
cuda = 0 if ((key in self.children and self._alive(self.children[key])) or ngl == 0) \
|
|
613
|
+
else self.cuda_context_bytes() # RAM-only (ngl 0, device none) has no CUDA process
|
|
614
|
+
need = _gguf_need(struct, ctx=ctx, n_gpu_layers=ngl, n_cpu_moe=ncm, mmproj_bytes=mmproj_bytes,
|
|
615
|
+
gpu_margin=1.0, ram_margin=1.0, cushion_bytes=0)
|
|
616
|
+
if p: # formula sheet G2/G5 (fitevict.sizing): measured if this config ran, else page-rounded projection + context
|
|
617
|
+
from .. import sizing as _S
|
|
618
|
+
new_proc = bool(cuda)
|
|
619
|
+
drv = int(_db_get_driver(self._worker(), self._device_key())) if new_proc else 0
|
|
620
|
+
# KV: llama.cpp's own projected context buffers (KV + recurrent state).
|
|
621
|
+
# K1 is NOT used over a projection: on hybrid models it omits the
|
|
622
|
+
# recurrent-state buffers (measured 2026-10-03, Qwen3.6-35B-A3B ctx
|
|
623
|
+
# 169,984: K1 3,547,136,000 vs llama.cpp 3,744,464,896 -> OOM at load)
|
|
624
|
+
st = _db_kv_structure(self._worker(), _os.path.basename(path))
|
|
625
|
+
kvt_ = c.get("kv_type") or "f16"
|
|
626
|
+
kv_k2 = None
|
|
627
|
+
if st and ngl < 0 and not ncm: # K2 (measured structure) for whole-GPU loads
|
|
628
|
+
cell = int(st["k_cell_bytes"]) + int(st["v_cell_bytes"])
|
|
629
|
+
cell = cell if kvt_ == "f16" else cell * (17 if kvt_ == "q8_0" else 9) // 32
|
|
630
|
+
kv_k2 = _S.kv_device_bytes(ctx, cell, 0, int(st.get("rs_bytes") or 0))
|
|
631
|
+
pred = _S.gguf_predict(p, cuda, measured_process=p.get("measured_process") if new_proc else None,
|
|
632
|
+
kv_exact=kv_k2, driver=drv)
|
|
633
|
+
kv = int(p["dev_context"])
|
|
634
|
+
total = int(pred["device_bytes"]) + (0 if pred["exact"] else _S.page(mmproj_bytes))
|
|
635
|
+
# G8: what the process grows by once it serves (cuBLAS handle +
|
|
636
|
+
# workspace, pool growth) — must be free after the load
|
|
637
|
+
if ngl != 0:
|
|
638
|
+
from . import db as _db8
|
|
639
|
+
g8, g8_src = _db8.serving_growth(self._worker(), _os.path.basename(path), ctx, ngl, ncm,
|
|
640
|
+
c.get("kv_type") or "f16")
|
|
641
|
+
total += int(g8)
|
|
642
|
+
self.last_g8 = {"bytes": int(g8), "source": g8_src}
|
|
643
|
+
else: # llama.cpp could not project: the structure's exact tensor + KV bytes
|
|
644
|
+
kv = int(need["kv_gpu_bytes"])
|
|
645
|
+
total = int(need["gpu_bytes"]) + cuda
|
|
646
|
+
# R1: what a RAM-only load of this config holds in host memory
|
|
647
|
+
ram_only = None
|
|
648
|
+
try:
|
|
649
|
+
from .. import sizing as _S1
|
|
650
|
+
from . import db as _db1
|
|
651
|
+
kvt1 = c.get("kv_type") or "f16"
|
|
652
|
+
mh = _db1.get_measured_host(self._worker(), _os.path.basename(path), ctx, 0, 0, kvt1) or {}
|
|
653
|
+
p0 = None if (mh.get("anon") is not None) else self.project(path, ctx, 0, 0, kvt1, bool(c.get("flash_attn")))
|
|
654
|
+
ram_only = int(_S1.ram_only_bytes(p0, _S1.gguf_file_bytes(path) + mmproj_bytes,
|
|
655
|
+
mh.get("anon"), mh.get("file"))["bytes"])
|
|
656
|
+
except Exception as exc: # noqa: BLE001
|
|
657
|
+
print(f"[ram_only] {key}: {exc}")
|
|
658
|
+
return ModelFootprint(
|
|
659
|
+
weights_bytes=max(0, total - kv),
|
|
660
|
+
kv_bytes=kv,
|
|
661
|
+
total_bytes=total,
|
|
662
|
+
ram_only_bytes=ram_only,
|
|
663
|
+
layers=int(need["block_count"]) or None,
|
|
664
|
+
moe=bool(need["is_moe"]),
|
|
665
|
+
expert_bytes=int(need["experts_gpu_bytes"] + need["experts_cpu_bytes"]) or None,
|
|
666
|
+
non_expert_bytes=int(need["gpu_weights_bytes"]) or None,
|
|
667
|
+
)
|
|
668
|
+
|
|
669
|
+
def _placement(self, c: dict, struct: dict) -> "tuple[int, int]":
|
|
670
|
+
"""(n_gpu_layers, n_cpu_moe) the contract asks for (before any partial plan)."""
|
|
671
|
+
ncm = int(struct.get("block_count") or 0) if (c.get("moe") and struct.get("is_moe")) else 0
|
|
672
|
+
ngl = -1
|
|
673
|
+
if c.get("alloc_mode") == "explicit":
|
|
674
|
+
if c.get("n_gpu_layers") is not None:
|
|
675
|
+
ngl = int(c["n_gpu_layers"])
|
|
676
|
+
if c.get("n_cpu_moe") is not None and struct.get("is_moe"):
|
|
677
|
+
ncm = int(c["n_cpu_moe"])
|
|
678
|
+
elif c.get("alloc_mode") == "ram-only":
|
|
679
|
+
ngl, ncm = 0, 0
|
|
680
|
+
return ngl, ncm
|
|
681
|
+
|
|
682
|
+
# ── exact sizing ──────────────────────────────────────────────────────────
|
|
683
|
+
def _worker(self) -> str:
|
|
684
|
+
return _os.environ.get("FITEVICT_WORKER", "ae")
|
|
685
|
+
|
|
686
|
+
def _device_key(self) -> str:
|
|
687
|
+
return f"cuda{self.main_gpu}"
|
|
688
|
+
|
|
689
|
+
def cuda_context_bytes(self) -> int:
|
|
690
|
+
"""The MEASURED per-process CUDA context on this card (a new process pays
|
|
691
|
+
it); 0 until the first load on this box has measured it."""
|
|
692
|
+
from . import db as _db
|
|
693
|
+
return int(_db.get_cuda_context(self._worker(), self._device_key()) or 0)
|
|
694
|
+
|
|
695
|
+
def project(self, path: str, ctx: int, ngl: int, ncm: int, kv: str = "f16",
|
|
696
|
+
flash: bool = False) -> "Optional[dict]":
|
|
697
|
+
"""llama.cpp's OWN projected device memory for this file at these params
|
|
698
|
+
(common_params_fit: model / context / compute buffers, no weights
|
|
699
|
+
loaded), cached in the DB. None when llama.cpp cannot project."""
|
|
700
|
+
from . import db as _db
|
|
701
|
+
file = _os.path.basename(path)
|
|
702
|
+
hit = _db.get_projection(self._worker(), file, ctx, ngl, ncm, kv)
|
|
703
|
+
if hit and hit.get("dev_self") is not None:
|
|
704
|
+
return hit
|
|
705
|
+
import re as _re, select as _select
|
|
706
|
+
port = self._free_port()
|
|
707
|
+
argv = [self.server_bin, "-m", path, "--host", "127.0.0.1", "--port", str(port),
|
|
708
|
+
"-c", str(int(ctx)), "--n-gpu-layers", "all" if int(ngl) < 0 else str(int(ngl)),
|
|
709
|
+
"-t", "6", "--fit", "on", "-lv", "4", "-np", str(_PARALLEL)]
|
|
710
|
+
if ncm:
|
|
711
|
+
argv += ["--n-cpu-moe", str(int(ncm))]
|
|
712
|
+
if kv != "f16":
|
|
713
|
+
argv += ["--cache-type-k", kv, "--cache-type-v", kv]
|
|
714
|
+
if flash or kv != "f16":
|
|
715
|
+
argv += ["-fa", "on"]
|
|
716
|
+
env = dict(_os.environ)
|
|
717
|
+
env["CUDA_VISIBLE_DEVICES"] = str(self.main_gpu)
|
|
718
|
+
env["HUGPY_PROBE"] = "projection" # gpu_state: a transient probe, never external use
|
|
719
|
+
if int(ngl) == 0: # RAM-only: no GPU at all
|
|
720
|
+
argv += ["-dev", "none"]
|
|
721
|
+
env["CUDA_VISIBLE_DEVICES"] = ""
|
|
722
|
+
from ..lifted.procutil import popen_detached, terminate_tree
|
|
723
|
+
proc = popen_detached(argv, stdout=_subprocess.PIPE, stderr=_subprocess.STDOUT, env=env)
|
|
724
|
+
dev = host = None
|
|
725
|
+
deadline = _time.time() + 120
|
|
726
|
+
row = _re.compile(r"CUDA\d+[^|]*\|.*?\(\s*(\d+)\s*=\s*(\d+)\s*\+\s*(\d+)\s*\+\s*(\d+)\s*\)")
|
|
727
|
+
hrow = _re.compile(r"-\s*Host\s*\|\s*(\d+)\s*=\s*(\d+)\s*\+\s*(\d+)\s*\+\s*(\d+)")
|
|
728
|
+
try:
|
|
729
|
+
buf = b""
|
|
730
|
+
while _time.time() < deadline and proc.poll() is None:
|
|
731
|
+
r, _, _ = _select.select([proc.stdout], [], [], 1.0)
|
|
732
|
+
if not r:
|
|
733
|
+
continue
|
|
734
|
+
chunk = _os.read(proc.stdout.fileno(), 65536)
|
|
735
|
+
if not chunk:
|
|
736
|
+
break
|
|
737
|
+
buf += chunk
|
|
738
|
+
text = buf.decode("utf-8", "replace")
|
|
739
|
+
if dev is None:
|
|
740
|
+
m = row.search(text)
|
|
741
|
+
if m:
|
|
742
|
+
dev = [int(x) for x in m.groups()]
|
|
743
|
+
if "projected to use" in text: # the first breakdown block is complete
|
|
744
|
+
host = host_rows_first_block(text)
|
|
745
|
+
break
|
|
746
|
+
finally:
|
|
747
|
+
try:
|
|
748
|
+
terminate_tree(proc)
|
|
749
|
+
proc.wait(timeout=20)
|
|
750
|
+
except Exception: # noqa: BLE001
|
|
751
|
+
pass
|
|
752
|
+
# wait until the driver has RELEASED the probe's device memory (NVML
|
|
753
|
+
# no longer lists it) — a placement must never price around a dying probe
|
|
754
|
+
_deadline = _time.time() + 30.0
|
|
755
|
+
while proc.pid in _compute_apps_vram() and _time.time() < _deadline:
|
|
756
|
+
_time.sleep(0.02)
|
|
757
|
+
if dev is None and int(ngl) == 0 and host is not None:
|
|
758
|
+
dev = [0, 0, 0, 0] # device none: nothing on the GPU
|
|
759
|
+
if dev is None:
|
|
760
|
+
return None
|
|
761
|
+
M = 2 ** 20
|
|
762
|
+
out = {"dev_self": dev[0] * M, "dev_model": dev[1] * M, "dev_context": dev[2] * M,
|
|
763
|
+
"dev_compute": dev[3] * M,
|
|
764
|
+
"host_model": host[1] * M if host else None, "host_context": host[2] * M if host else None,
|
|
765
|
+
"host_compute": host[3] * M if host else None,
|
|
766
|
+
"host_repack": host[4] * M if host else None}
|
|
767
|
+
_db.put_projection(self._worker(), file, ctx, ngl, ncm, kv, out)
|
|
768
|
+
return out
|
|
769
|
+
|
|
770
|
+
# ── execution ─────────────────────────────────────────────────────────────
|
|
771
|
+
def _alive(self, ch: _Child) -> bool:
|
|
772
|
+
p = ch.proc
|
|
773
|
+
return bool(p) and p.poll() is None
|
|
774
|
+
|
|
775
|
+
def evict(self, key: str, reason: str = "evict-to-fit") -> int:
|
|
776
|
+
"""Stop ``key``'s process and MEASURE the unload: NVML process bytes and
|
|
777
|
+
device free before, the exit time, and the time the card's free memory
|
|
778
|
+
rose by those bytes (polled every 20 ms, up to 30 s). Recorded in
|
|
779
|
+
inference_engine.evictions; returns the bytes actually freed."""
|
|
780
|
+
ch = self.children.get(key)
|
|
781
|
+
if ch is None:
|
|
782
|
+
return 0
|
|
783
|
+
from ..lifted.procutil import terminate_tree
|
|
784
|
+
from ..lifted import nvml as _nvml
|
|
785
|
+
from . import db as _db
|
|
786
|
+
|
|
787
|
+
def _free():
|
|
788
|
+
for d in (_nvml.devices() or []):
|
|
789
|
+
if d["index"] == int(self.main_gpu):
|
|
790
|
+
return d.get("free")
|
|
791
|
+
return None
|
|
792
|
+
pid = getattr(ch.proc, "pid", None)
|
|
793
|
+
held = _compute_apps_vram().get(pid)
|
|
794
|
+
free_before = _free()
|
|
795
|
+
started = _time.time()
|
|
796
|
+
terminate_tree(ch.proc)
|
|
797
|
+
try:
|
|
798
|
+
ch.proc.wait(timeout=30)
|
|
799
|
+
except Exception: # noqa: BLE001
|
|
800
|
+
pass
|
|
801
|
+
exited = _time.time()
|
|
802
|
+
released = None
|
|
803
|
+
free_after = _free()
|
|
804
|
+
if held and free_before is not None:
|
|
805
|
+
deadline = exited + 30.0
|
|
806
|
+
while free_after is not None and free_after - free_before < int(held) and _time.time() < deadline:
|
|
807
|
+
_time.sleep(0.02)
|
|
808
|
+
free_after = _free()
|
|
809
|
+
if free_after is not None and free_after - free_before >= int(held):
|
|
810
|
+
released = _time.time()
|
|
811
|
+
elif not held:
|
|
812
|
+
released = exited # nothing on the GPU (RAM-only) — released at exit
|
|
813
|
+
self.children.pop(key, None)
|
|
814
|
+
_gpu_changed(f"evict {key}")
|
|
815
|
+
try: # an eviction frees room: missing sizing facts may be measurable now
|
|
816
|
+
from .. import gpu_state as _gsw
|
|
817
|
+
_gsw.FACTS_WAKE.set()
|
|
818
|
+
except Exception: # noqa: BLE001
|
|
819
|
+
pass
|
|
820
|
+
try:
|
|
821
|
+
_db.put_eviction({"worker": self._worker(), "key": key,
|
|
822
|
+
"engine": engine_of_path(ch.model_file), "request_id": _CURRENT_REQUEST.get("id"),
|
|
823
|
+
"reason": reason, "pid": pid, "process_bytes": held,
|
|
824
|
+
"free_before": free_before, "free_after": free_after,
|
|
825
|
+
"started_at": started, "exited_at": exited, "released_at": released})
|
|
826
|
+
except Exception as exc: # noqa: BLE001
|
|
827
|
+
print(f"[eviction] record failed: {exc}")
|
|
828
|
+
if free_before is not None and free_after is not None:
|
|
829
|
+
freed = max(0, int(free_after) - int(free_before))
|
|
830
|
+
if held and released and freed > int(held): # G7: driver bytes per process
|
|
831
|
+
try:
|
|
832
|
+
_db.put_cuda_context(self._worker(), self._device_key() + ":driver", freed - int(held))
|
|
833
|
+
except Exception: # noqa: BLE001
|
|
834
|
+
pass
|
|
835
|
+
return freed
|
|
836
|
+
return int(held or 0)
|
|
837
|
+
|
|
838
|
+
def load(self, key: str, plan: FitPlan) -> None:
|
|
839
|
+
"""Launch a llama-server child per the committed plan. argv flag mapping
|
|
840
|
+
follows the lifted slot_agent._build_cmd reference."""
|
|
841
|
+
contract = dict(self.contract_of(key))
|
|
842
|
+
path = contract["path"] or self.models[key]
|
|
843
|
+
if engine_of_path(path) == "transformers":
|
|
844
|
+
return self._load_transformers(key, path, plan, contract)
|
|
845
|
+
struct = _structure_for(path)
|
|
846
|
+
mmproj = _mr.find_mmproj(path)
|
|
847
|
+
mmproj_bytes = _os.path.getsize(mmproj) if mmproj and _os.path.isfile(mmproj) else 0
|
|
848
|
+
|
|
849
|
+
# Placement from the plan: partial -> the offload's layer/expert split;
|
|
850
|
+
# otherwise the whole model on the GPU (-ngl -1), no forced expert split.
|
|
851
|
+
n_gpu_layers = -1
|
|
852
|
+
n_cpu_moe: Optional[int] = None
|
|
853
|
+
if getattr(plan, "placement", None) == "ram":
|
|
854
|
+
n_gpu_layers = 0 # the plan put 0 B on the card
|
|
855
|
+
elif plan.action == "partial" and plan.offload is not None:
|
|
856
|
+
n_gpu_layers = int(plan.offload.n_gpu_layers)
|
|
857
|
+
n_cpu_moe = plan.offload.n_cpu_moe
|
|
858
|
+
# Caller's MoE knob: True = every layer on the card with ALL experts in
|
|
859
|
+
# RAM; False = never split experts (a partial plan keeps its layer split).
|
|
860
|
+
if contract.get("moe") is True and struct.get("is_moe") and n_gpu_layers != 0:
|
|
861
|
+
n_gpu_layers, n_cpu_moe = -1, int(struct.get("block_count") or 0) or None
|
|
862
|
+
elif contract.get("moe") is False:
|
|
863
|
+
n_cpu_moe = None
|
|
864
|
+
# explicit allocation: the operator's exact layer counts (llama.cpp terms)
|
|
865
|
+
if contract.get("alloc_mode") == "explicit":
|
|
866
|
+
if contract.get("n_gpu_layers") is not None:
|
|
867
|
+
n_gpu_layers = int(contract["n_gpu_layers"])
|
|
868
|
+
if contract.get("n_cpu_moe") is not None and struct.get("is_moe"):
|
|
869
|
+
n_cpu_moe = int(contract["n_cpu_moe"]) or None
|
|
870
|
+
elif contract.get("alloc_mode") == "ram-only" or contract.get("decided_mode") == "ram-only":
|
|
871
|
+
n_gpu_layers, n_cpu_moe = 0, None # pinned, or sizing.decide() chose RAM-only
|
|
872
|
+
ctx = contract.get("ctx") or self.ctx
|
|
873
|
+
need = _gguf_need(struct, ctx=ctx, n_gpu_layers=n_gpu_layers,
|
|
874
|
+
n_cpu_moe=n_cpu_moe or 0, mmproj_bytes=mmproj_bytes,
|
|
875
|
+
gpu_margin=1.0, ram_margin=1.0, cushion_bytes=0)
|
|
876
|
+
|
|
877
|
+
kvt0 = contract.get("kv_type") or "f16"
|
|
878
|
+
port = self._free_port()
|
|
879
|
+
# Flag mapping follows the lifted slot_agent._build_cmd reference: when
|
|
880
|
+
# the binary is --fit capable, turn its own auto-fit OFF (fitevict is the
|
|
881
|
+
# fit authority now) and pass -ngl "all" for the whole-model case.
|
|
882
|
+
fit_capable = _server_supports_flag(self.server_bin, "--fit")
|
|
883
|
+
ngl_arg = "all" if (fit_capable and n_gpu_layers == -1) else str(n_gpu_layers)
|
|
884
|
+
# max-gpu (pinned): llama.cpp's own --fit picks the layer split that fits
|
|
885
|
+
# the card, keeping a DERIVED margin = G8 (serving growth) + G7 (driver
|
|
886
|
+
# bytes per process) + one PAGE per device buffer (model, KV, RS,
|
|
887
|
+
# compute: the page rounding llama.cpp's raw sizes do not include)
|
|
888
|
+
fit_solver = fit_capable and contract.get("alloc_mode") == "max-gpu" and n_gpu_layers != 0 \
|
|
889
|
+
and not n_cpu_moe
|
|
890
|
+
argv = [self.server_bin, "-m", path,
|
|
891
|
+
"--host", "127.0.0.1", "--port", str(port)]
|
|
892
|
+
if not fit_solver:
|
|
893
|
+
argv += ["--n-gpu-layers", ngl_arg]
|
|
894
|
+
argv += ["-c", str(ctx), "-t", "6", "-np", str(_PARALLEL)]
|
|
895
|
+
if fit_solver:
|
|
896
|
+
from . import db as _dbf
|
|
897
|
+
from .. import sizing as _Sf
|
|
898
|
+
g8 = _dbf.serving_growth(self._worker(), _os.path.basename(path), ctx, -1, 0, kvt0)[0]
|
|
899
|
+
g7 = int(_db_get_driver(self._worker(), self._device_key()) or 0)
|
|
900
|
+
margin = int(g8) + g7 + 4 * _Sf.PAGE
|
|
901
|
+
argv += ["--fit", "on", "-fitt", str(-(-margin // _Sf.MiB))]
|
|
902
|
+
elif fit_capable:
|
|
903
|
+
argv += ["--fit", "off"]
|
|
904
|
+
argv += ["-lv", "4"] # formula sheet G1 source B: per-buffer sizes in the load log
|
|
905
|
+
if n_cpu_moe and _server_supports_flag(self.server_bin, "--n-cpu-moe"):
|
|
906
|
+
argv += ["--n-cpu-moe", str(int(n_cpu_moe))]
|
|
907
|
+
if mmproj:
|
|
908
|
+
argv += ["--mmproj", mmproj]
|
|
909
|
+
kvt = contract.get("kv_type") or "f16"
|
|
910
|
+
if kvt != "f16":
|
|
911
|
+
argv += ["--cache-type-k", kvt, "--cache-type-v", kvt]
|
|
912
|
+
if contract.get("flash_attn") or kvt != "f16":
|
|
913
|
+
argv += ["-fa", "on"]
|
|
914
|
+
|
|
915
|
+
if n_gpu_layers == 0: # RAM-only: no GPU at all (no CUDA process, no device buffers)
|
|
916
|
+
argv += ["-dev", "none"]
|
|
917
|
+
logf = open(_os.path.join(self.log_dir, f"{key}.{port}.log"), "ab")
|
|
918
|
+
from ..lifted.procutil import popen_detached
|
|
919
|
+
env = dict(_os.environ)
|
|
920
|
+
if self.main_gpu is not None:
|
|
921
|
+
env["CUDA_VISIBLE_DEVICES"] = str(self.main_gpu)
|
|
922
|
+
if n_gpu_layers == 0:
|
|
923
|
+
env["CUDA_VISIBLE_DEVICES"] = ""
|
|
924
|
+
staged = _time.time()
|
|
925
|
+
proc = popen_detached(argv, stdout=logf, stderr=logf, env=env)
|
|
926
|
+
ch = _Child(key=key, proc=proc, port=port, model_file=path, struct=struct,
|
|
927
|
+
need=need, n_gpu_layers=n_gpu_layers, n_cpu_moe=n_cpu_moe,
|
|
928
|
+
ctx=ctx, total_layers=int(need["block_count"]) or None,
|
|
929
|
+
is_moe=bool(need["is_moe"]), quant=_os.path.basename(path),
|
|
930
|
+
mmproj=mmproj, resident_since=staged)
|
|
931
|
+
ch.weights_staged_at = staged
|
|
932
|
+
ch.argv = list(argv)
|
|
933
|
+
ch.contract = contract
|
|
934
|
+
self.children[key] = ch
|
|
935
|
+
ok, err = self._wait_healthy(ch)
|
|
936
|
+
if ok and fit_solver: # the split llama.cpp chose: "offloaded X/Y layers to GPU"
|
|
937
|
+
try:
|
|
938
|
+
import re as _ref
|
|
939
|
+
with open(_os.path.join(self.log_dir, f"{key}.{port}.log"), "r", encoding="utf-8", errors="replace") as fh:
|
|
940
|
+
m = list(_ref.finditer(r"offloaded (\d+)/(\d+) layers to GPU", fh.read()))
|
|
941
|
+
if m:
|
|
942
|
+
ch.n_gpu_layers = n_gpu_layers = int(m[-1].group(1))
|
|
943
|
+
except Exception: # noqa: BLE001
|
|
944
|
+
pass
|
|
945
|
+
if ok:
|
|
946
|
+
ch.weights_loaded_at = _time.time()
|
|
947
|
+
self._measure_cuda_context(ch, path, ctx, n_gpu_layers, n_cpu_moe or 0, kvt,
|
|
948
|
+
bool(contract.get("flash_attn")), mmproj_bytes)
|
|
949
|
+
if n_gpu_layers == 0: # R1: RAM-only host bytes, measured
|
|
950
|
+
try:
|
|
951
|
+
from . import db as _dbh
|
|
952
|
+
rss = {}
|
|
953
|
+
with open(f"/proc/{proc.pid}/status") as fh:
|
|
954
|
+
for line in fh:
|
|
955
|
+
if line.startswith(("RssAnon:", "RssFile:")):
|
|
956
|
+
rss[line.split(":")[0]] = int(line.split()[1]) * 1024
|
|
957
|
+
if rss:
|
|
958
|
+
_dbh.put_measured_host(self._worker(), _os.path.basename(path), ctx, 0, 0, kvt,
|
|
959
|
+
rss.get("RssAnon", 0), rss.get("RssFile", 0))
|
|
960
|
+
except Exception as exc: # noqa: BLE001
|
|
961
|
+
print(f"[host] measure failed: {exc}")
|
|
962
|
+
else:
|
|
963
|
+
ch.load_error = err
|
|
964
|
+
_gpu_changed(f"load {key}")
|
|
965
|
+
|
|
966
|
+
def _load_transformers(self, key: str, path: str, plan: FitPlan, contract: dict) -> None:
|
|
967
|
+
"""Launch the lifted tf_child for a transformers model per the plan:
|
|
968
|
+
ram -> CPU; partial -> the plan's GPU share, the rest in RAM; else the
|
|
969
|
+
whole model on the card (device_map=auto capped to it, no CPU spill)."""
|
|
970
|
+
from . import transformers as _tf
|
|
971
|
+
ctx = contract.get("ctx") or self.ctx
|
|
972
|
+
bnb4 = bool(contract.get("bnb4"))
|
|
973
|
+
f = _tf.footprint(path, ctx, bnb4=bnb4, worker=self._worker(), key=key)
|
|
974
|
+
mode = contract.get("alloc_mode")
|
|
975
|
+
if getattr(plan, "placement", None) == "ram" or mode == "ram-only" or contract.get("decided_mode") == "ram-only":
|
|
976
|
+
placement, gpu_b = "ram", None
|
|
977
|
+
elif plan.action == "partial" and plan.offload is not None and f["layers"]:
|
|
978
|
+
placement = "split"
|
|
979
|
+
frac = max(0, int(plan.offload.n_gpu_layers)) / float(f["layers"])
|
|
980
|
+
gpu_b = int(f["weights_bytes"] * frac)
|
|
981
|
+
else:
|
|
982
|
+
placement = "gpu"
|
|
983
|
+
gpu_b = f["total_bytes"]
|
|
984
|
+
port = self._free_port()
|
|
985
|
+
argv = _tf.argv(key, path, port, ctx, placement=placement, gpu_bytes=gpu_b, bnb4=bnb4)
|
|
986
|
+
logf = open(_os.path.join(self.log_dir, f"{key}.{port}.log"), "ab")
|
|
987
|
+
from ..lifted.procutil import popen_detached
|
|
988
|
+
env = dict(_os.environ)
|
|
989
|
+
if self.main_gpu is not None and placement != "ram":
|
|
990
|
+
env["CUDA_VISIBLE_DEVICES"] = str(self.main_gpu)
|
|
991
|
+
if placement == "ram":
|
|
992
|
+
env["CUDA_VISIBLE_DEVICES"] = ""
|
|
993
|
+
staged = _time.time()
|
|
994
|
+
proc = popen_detached(argv, stdout=logf, stderr=logf, env=env)
|
|
995
|
+
layers = f["layers"]
|
|
996
|
+
ngl = 0 if placement == "ram" else (-1 if placement == "gpu"
|
|
997
|
+
else int(plan.offload.n_gpu_layers))
|
|
998
|
+
ch = _Child(key=key, proc=proc, port=port, model_file=path, struct={},
|
|
999
|
+
need={"gpu_bytes": gpu_b or 0, "kv_gpu_bytes": f["kv_bytes"] if placement != "ram" else 0,
|
|
1000
|
+
"gpu_weights_bytes": f["weights_bytes"] if placement == "gpu" else (gpu_b or 0)},
|
|
1001
|
+
n_gpu_layers=ngl, n_cpu_moe=None, ctx=ctx, total_layers=layers,
|
|
1002
|
+
is_moe=False, quant="dir-bnb4" if bnb4 else "dir", mmproj=None, resident_since=staged)
|
|
1003
|
+
ch.weights_staged_at = staged
|
|
1004
|
+
ch.argv = list(argv)
|
|
1005
|
+
ch.contract = contract
|
|
1006
|
+
self.children[key] = ch
|
|
1007
|
+
ok, err = self._wait_healthy(ch, hard_cap=1800.0, stall=180.0)
|
|
1008
|
+
if ok:
|
|
1009
|
+
ch.weights_loaded_at = _time.time()
|
|
1010
|
+
if placement != "ram":
|
|
1011
|
+
try:
|
|
1012
|
+
from . import db as _db
|
|
1013
|
+
used = _compute_apps_vram().get(getattr(proc, "pid", None))
|
|
1014
|
+
if used:
|
|
1015
|
+
_db.put_tf_measure(self._worker(), key, bnb4, ctx,
|
|
1016
|
+
tensor_bytes=f["weights_bytes"], load_bytes=int(used))
|
|
1017
|
+
if int(used) > f["weights_bytes"]:
|
|
1018
|
+
_db.put_cuda_context(self._worker(), "torch", int(used) - f["weights_bytes"])
|
|
1019
|
+
except Exception as exc: # noqa: BLE001
|
|
1020
|
+
print(f"[tf_measure] {key}: {exc}")
|
|
1021
|
+
else:
|
|
1022
|
+
ch.load_error = err
|
|
1023
|
+
_gpu_changed(f"load {key}")
|
|
1024
|
+
|
|
1025
|
+
def record_host(self, key: str) -> None:
|
|
1026
|
+
"""R1 after a call: a RAM-only resident's RssAnon / RssFile grow as mmap'd
|
|
1027
|
+
pages fault in — keep the configuration's MAX measured host bytes."""
|
|
1028
|
+
ch = self.children.get(key)
|
|
1029
|
+
cfg = self.config_of(key)
|
|
1030
|
+
if ch is None or cfg is None or cfg[2] != 0:
|
|
1031
|
+
return
|
|
1032
|
+
from . import db as _db
|
|
1033
|
+
rss = {}
|
|
1034
|
+
try:
|
|
1035
|
+
with open(f"/proc/{ch.proc.pid}/status") as fh:
|
|
1036
|
+
for line in fh:
|
|
1037
|
+
if line.startswith(("RssAnon:", "RssFile:")):
|
|
1038
|
+
rss[line.split(":")[0]] = int(line.split()[1]) * 1024
|
|
1039
|
+
except OSError:
|
|
1040
|
+
return
|
|
1041
|
+
old = _db.get_measured_host(self._worker(), *cfg) or {}
|
|
1042
|
+
anon = max(rss.get("RssAnon", 0), int(old.get("anon") or 0))
|
|
1043
|
+
filb = max(rss.get("RssFile", 0), int(old.get("file") or 0))
|
|
1044
|
+
if (anon, filb) != (old.get("anon"), old.get("file")):
|
|
1045
|
+
_db.put_measured_host(self._worker(), *cfg, anon, filb)
|
|
1046
|
+
|
|
1047
|
+
def live_room(self) -> "tuple[Optional[int], Optional[int]]":
|
|
1048
|
+
"""(room_gpu, room_ram) NOW by the shared formulas (sizing.room_gpu /
|
|
1049
|
+
room_ram — central computes the console's room with the same ones)."""
|
|
1050
|
+
from .. import sizing as _S
|
|
1051
|
+
from .. import gpu_state as _gs
|
|
1052
|
+
from ..lifted import nvml as _nvml
|
|
1053
|
+
rg = rr = None
|
|
1054
|
+
try:
|
|
1055
|
+
d = next((x for x in (_nvml.devices() or []) if x["index"] == int(self.main_gpu)), None)
|
|
1056
|
+
procs = _compute_apps_vram()
|
|
1057
|
+
ours = {getattr(c.proc, "pid", None) for c in self.children.values() if self._alive(c)}
|
|
1058
|
+
static = {getattr(c.proc, "pid", None) for k, c in self.children.items()
|
|
1059
|
+
if self._alive(c) and k in (getattr(self, "static_keys", None) or set())}
|
|
1060
|
+
probes = {p for p in procs if _gs._is_probe(int(p))}
|
|
1061
|
+
if d and d.get("total") is not None:
|
|
1062
|
+
rg = _S.room_gpu({"usable": int(d["total"]) - int(d.get("reserved") or 0), "used": int(d["used"]),
|
|
1063
|
+
"processes": procs}, ours - {None}, static - {None}, probes,
|
|
1064
|
+
int(_db_get_driver(self._worker(), self._device_key()) or 0))
|
|
1065
|
+
mem = {}
|
|
1066
|
+
with open("/proc/meminfo") as fh:
|
|
1067
|
+
for line in fh:
|
|
1068
|
+
k, _, v = line.partition(":")
|
|
1069
|
+
if v.split():
|
|
1070
|
+
mem[k] = int(v.split()[0]) * 1024
|
|
1071
|
+
anon = {}
|
|
1072
|
+
for pid in ours - {None}:
|
|
1073
|
+
try:
|
|
1074
|
+
with open(f"/proc/{pid}/status") as fh:
|
|
1075
|
+
for line in fh:
|
|
1076
|
+
if line.startswith("RssAnon:"):
|
|
1077
|
+
anon[str(pid)] = int(line.split()[1]) * 1024
|
|
1078
|
+
except OSError:
|
|
1079
|
+
pass
|
|
1080
|
+
rr = _S.room_ram(mem.get("MemAvailable", 0), anon, static - {None})
|
|
1081
|
+
except Exception as exc: # noqa: BLE001
|
|
1082
|
+
print(f"[room] {exc}")
|
|
1083
|
+
return rg, rr
|
|
1084
|
+
|
|
1085
|
+
def ensure_facts(self, key: str, facts: dict, kv: str = "f16") -> bool:
|
|
1086
|
+
"""Measure the projection points decide() is missing: llama.cpp dry runs
|
|
1087
|
+
at the ladder's two ends for the GPU (ngl -1) and RAM-only (ngl 0) —
|
|
1088
|
+
every tick in between is linear from them. One-off per quant (facts are
|
|
1089
|
+
keyed to the exact configuration and never go stale). Returns True when
|
|
1090
|
+
anything was measured."""
|
|
1091
|
+
c = self.contract_of(key)
|
|
1092
|
+
path = c.get("path") or self.models.get(key)
|
|
1093
|
+
ladder = sorted(int(x) for x in (facts.get("ladder") or []))
|
|
1094
|
+
if not path or not ladder or engine_of_path(path) == "transformers":
|
|
1095
|
+
return False
|
|
1096
|
+
have_g = {int(p["ctx"]) for p in facts.get("gpu_points") or [] if p.get("model") is not None}
|
|
1097
|
+
have_r = {int(p["ctx"]) for p in facts.get("ram_points") or [] if p.get("host_repack") is not None}
|
|
1098
|
+
did = False
|
|
1099
|
+
for ctx in {ladder[0], ladder[-1]}:
|
|
1100
|
+
if ctx not in have_g:
|
|
1101
|
+
did |= self.project(path, ctx, -1, 0, kv) is not None
|
|
1102
|
+
if ctx not in have_r:
|
|
1103
|
+
did |= self.project(path, ctx, 0, 0, kv) is not None
|
|
1104
|
+
return did
|
|
1105
|
+
|
|
1106
|
+
def config_of(self, key: str) -> "Optional[tuple]":
|
|
1107
|
+
"""(file, ctx, n_gpu_layers, n_cpu_moe, kv) of a resident — the key every
|
|
1108
|
+
measured term (G3/G5/G8) is stored under."""
|
|
1109
|
+
ch = self.children.get(key)
|
|
1110
|
+
if ch is None or engine_of_path(ch.model_file) == "transformers":
|
|
1111
|
+
return None
|
|
1112
|
+
kvt = (getattr(ch, "contract", None) or {}).get("kv_type") or "f16"
|
|
1113
|
+
return (_os.path.basename(ch.model_file), int(ch.ctx), int(ch.n_gpu_layers), int(ch.n_cpu_moe or 0), kvt)
|
|
1114
|
+
|
|
1115
|
+
def measure_full_ctx(self, key: str) -> "Optional[dict]":
|
|
1116
|
+
"""Formula sheet G8/A: send the resident ONE request that fills its whole
|
|
1117
|
+
context, through the SAME path and parameters real traffic uses
|
|
1118
|
+
(self.chat -> /v1/chat/completions; measured 2026-10-03: the raw
|
|
1119
|
+
/completion path grows the process 2,097,152 B less than chat does).
|
|
1120
|
+
Prompt = the chat template + k copies of one token, k = n_ctx − template
|
|
1121
|
+
tokens − 1 (the single generated token takes the last position). Then
|
|
1122
|
+
read the NVML process bytes; stored on the configuration's row."""
|
|
1123
|
+
import json as _json, urllib.request as _ur
|
|
1124
|
+
from . import db as _db
|
|
1125
|
+
ch = self.children.get(key)
|
|
1126
|
+
cfg = self.config_of(key)
|
|
1127
|
+
if ch is None or cfg is None or cfg[2] == 0:
|
|
1128
|
+
return None
|
|
1129
|
+
pid = getattr(ch.proc, "pid", None)
|
|
1130
|
+
base = ch.base_url
|
|
1131
|
+
|
|
1132
|
+
def _post(path, body, timeout):
|
|
1133
|
+
req = _ur.Request(base + path, _json.dumps(body).encode(), {"Content-Type": "application/json"})
|
|
1134
|
+
return _json.loads(_ur.urlopen(req, timeout=timeout).read())
|
|
1135
|
+
props = _json.loads(_ur.urlopen(base + "/props", timeout=10).read())
|
|
1136
|
+
n_ctx = int((props.get("default_generation_settings") or {}).get("n_ctx") or ch.ctx)
|
|
1137
|
+
empty = _post("/apply-template", {"messages": [{"role": "user", "content": ""}]}, 10).get("prompt") or ""
|
|
1138
|
+
overhead = len(_post("/tokenize", {"content": empty, "parse_special": True}, 10).get("tokens") or [])
|
|
1139
|
+
one = _post("/tokenize", {"content": " word word"}, 10).get("tokens") or []
|
|
1140
|
+
if len(one) != 2 or one[0] != one[1]:
|
|
1141
|
+
return None # " word" is not one token here: cannot size exactly
|
|
1142
|
+
k = n_ctx - overhead - 1
|
|
1143
|
+
# repeat the fill until the process stops growing: measured 2026-10-03,
|
|
1144
|
+
# the SECOND full-context request adds one more 2 MiB allocation, then
|
|
1145
|
+
# it plateaus — the max is reached when two readings agree
|
|
1146
|
+
before = _compute_apps_vram().get(pid)
|
|
1147
|
+
t0 = _time.time()
|
|
1148
|
+
readings = []
|
|
1149
|
+
while True:
|
|
1150
|
+
res = self.chat(key, [{"role": "user", "content": " word" * k}], max_tokens=1)
|
|
1151
|
+
if not res.get("ok"):
|
|
1152
|
+
return None
|
|
1153
|
+
readings.append(_compute_apps_vram().get(pid))
|
|
1154
|
+
if len(readings) >= 2 and readings[-1] == readings[-2]:
|
|
1155
|
+
break
|
|
1156
|
+
secs = _time.time() - t0
|
|
1157
|
+
after = readings[-1]
|
|
1158
|
+
if not after:
|
|
1159
|
+
return None
|
|
1160
|
+
used = res.get("prompt_tokens")
|
|
1161
|
+
rid = "g8-" + __import__("uuid").uuid4().hex[:16]
|
|
1162
|
+
_db.put_full_ctx(self._worker(), *cfg, int(after), int(used or k + overhead), secs, rid)
|
|
1163
|
+
return {"request_id": rid, "prompt_tokens": used, "n_ctx": n_ctx, "template_tokens": overhead,
|
|
1164
|
+
"readings": readings, "fills": len(readings),
|
|
1165
|
+
"prefill_s": secs, "process_before": before, "process_after": after, "config": cfg}
|
|
1166
|
+
|
|
1167
|
+
def _measure_cuda_context(self, ch, path, ctx, ngl, ncm, kv, flash, mmproj_bytes) -> None:
|
|
1168
|
+
"""Formula sheet G3 + G5: process = NVML bytes of the new process;
|
|
1169
|
+
context = process − Σ page(device buffers from its load log)."""
|
|
1170
|
+
try:
|
|
1171
|
+
from .. import sizing as _S
|
|
1172
|
+
from . import db as _db
|
|
1173
|
+
pid = getattr(ch.proc, "pid", None)
|
|
1174
|
+
used = _compute_apps_vram().get(pid)
|
|
1175
|
+
logp = _os.path.join(self.log_dir, f"{ch.key}.{ch.port}.log")
|
|
1176
|
+
with open(logp, "r", encoding="utf-8", errors="replace") as fh:
|
|
1177
|
+
bufs = _S.load_log_buffers(fh.read())
|
|
1178
|
+
dev = [b for (d, _k, b) in bufs if d.startswith("CUDA") and d != "CUDA_Host"]
|
|
1179
|
+
if not used or not dev:
|
|
1180
|
+
return
|
|
1181
|
+
context = _S.gguf_context(int(used), dev)
|
|
1182
|
+
_db.put_measured(self._worker(), _os.path.basename(path), ctx, ngl, ncm, kv, int(used), context)
|
|
1183
|
+
# K2: the exact KV / recurrent-state structure + the engine's slot count
|
|
1184
|
+
try:
|
|
1185
|
+
with open(logp, "r", encoding="utf-8", errors="replace") as fh2:
|
|
1186
|
+
st = _S.kv_structure_from_log(fh2.read())
|
|
1187
|
+
if st:
|
|
1188
|
+
import json as _j, urllib.request as _u
|
|
1189
|
+
slots = _j.loads(_u.urlopen(ch.base_url + "/props", timeout=10).read()).get("total_slots")
|
|
1190
|
+
_db.put_kv_structure(self._worker(), _os.path.basename(path), st, slots, ctx, logp)
|
|
1191
|
+
if st["kv_type"] == "f16":
|
|
1192
|
+
_db.rewrite_ticks(_os.path.basename(path), st)
|
|
1193
|
+
except Exception as exc: # noqa: BLE001
|
|
1194
|
+
print(f"[kv_structure] {exc}")
|
|
1195
|
+
if context > 0:
|
|
1196
|
+
_db.put_cuda_context(self._worker(), self._device_key(), context)
|
|
1197
|
+
except Exception as exc: # noqa: BLE001
|
|
1198
|
+
print(f"[measure] failed: {exc}")
|
|
1199
|
+
|
|
1200
|
+
def _free_port(self) -> int:
|
|
1201
|
+
"""Next port from the base that nothing is bound to. A port held by a
|
|
1202
|
+
stray llama-server would answer our health check for a child that
|
|
1203
|
+
never bound it."""
|
|
1204
|
+
while True:
|
|
1205
|
+
port = self._port_next
|
|
1206
|
+
self._port_next += 1
|
|
1207
|
+
with _socket.socket(_socket.AF_INET, _socket.SOCK_STREAM) as s:
|
|
1208
|
+
try:
|
|
1209
|
+
s.bind(("127.0.0.1", port))
|
|
1210
|
+
except OSError:
|
|
1211
|
+
continue
|
|
1212
|
+
return port
|
|
1213
|
+
|
|
1214
|
+
# health wait (lifted slot_agent._wait_healthy logic: progress/stall aware)
|
|
1215
|
+
def _wait_healthy(self, ch: _Child, hard_cap: float = 600.0,
|
|
1216
|
+
stall: float = 90.0) -> "tuple[bool, Optional[str]]":
|
|
1217
|
+
start = _time.time()
|
|
1218
|
+
last_progress = 0
|
|
1219
|
+
last_progress_ts = start
|
|
1220
|
+
while True:
|
|
1221
|
+
if not self._alive(ch):
|
|
1222
|
+
rc = getattr(ch.proc, "returncode", None)
|
|
1223
|
+
return False, f"child exited rc={rc}"
|
|
1224
|
+
if self._health(ch) and self._alive(ch):
|
|
1225
|
+
return True, None
|
|
1226
|
+
now = _time.time()
|
|
1227
|
+
if now - start >= hard_cap:
|
|
1228
|
+
return False, f"hard cap {hard_cap:.0f}s"
|
|
1229
|
+
pid = getattr(ch.proc, "pid", None)
|
|
1230
|
+
cur = (_proc_rss_bytes(pid) or 0) + (_compute_apps_vram().get(pid, 0))
|
|
1231
|
+
if cur - last_progress >= 8 * 2 ** 20:
|
|
1232
|
+
last_progress, last_progress_ts = cur, now
|
|
1233
|
+
elif now - last_progress_ts >= stall:
|
|
1234
|
+
return False, f"stalled (no RSS/VRAM progress {stall:.0f}s)"
|
|
1235
|
+
_time.sleep(1.0)
|
|
1236
|
+
|
|
1237
|
+
def _health(self, ch: _Child) -> bool:
|
|
1238
|
+
try:
|
|
1239
|
+
req = _urllib.Request(ch.base_url + "/health")
|
|
1240
|
+
with _urllib.urlopen(req, timeout=2.0) as r:
|
|
1241
|
+
return r.status == 200
|
|
1242
|
+
except Exception: # noqa: BLE001
|
|
1243
|
+
return False
|
|
1244
|
+
|
|
1245
|
+
# ── a real chat call through the running seat ─────────────────────────────
|
|
1246
|
+
def chat(self, key: str, messages: list, max_tokens: int = 64) -> dict:
|
|
1247
|
+
ch = self.children[key]
|
|
1248
|
+
forwarded = {"messages": messages, "max_tokens": max_tokens,
|
|
1249
|
+
"temperature": 0.1, "cache_prompt": False}
|
|
1250
|
+
body = _json.dumps(forwarded).encode()
|
|
1251
|
+
req = _urllib.Request(ch.base_url + "/v1/chat/completions", data=body,
|
|
1252
|
+
headers={"Content-Type": "application/json"})
|
|
1253
|
+
call_start = _time.time()
|
|
1254
|
+
try:
|
|
1255
|
+
with _urllib.urlopen(req, timeout=600) as r:
|
|
1256
|
+
payload = _json.loads(r.read().decode("utf-8", "replace"))
|
|
1257
|
+
ok, error = True, None
|
|
1258
|
+
except Exception as exc: # noqa: BLE001
|
|
1259
|
+
payload, ok, error = {}, False, repr(exc)
|
|
1260
|
+
responded = _time.time()
|
|
1261
|
+
ch.last_used = responded
|
|
1262
|
+
ch.calls += 1
|
|
1263
|
+
|
|
1264
|
+
timings = payload.get("timings") if isinstance(payload, dict) else {}
|
|
1265
|
+
usage = payload.get("usage") if isinstance(payload, dict) else {}
|
|
1266
|
+
usage = usage if isinstance(usage, dict) else {}
|
|
1267
|
+
comp = (usage.get("completion_tokens")
|
|
1268
|
+
or (timings.get("predicted_n") if isinstance(timings, dict) else None))
|
|
1269
|
+
split = _tm.generation_split(timings, elapsed_s=responded - call_start,
|
|
1270
|
+
completion_tokens=comp)
|
|
1271
|
+
tok_s = _tm.tok_s_from_timings(payload)
|
|
1272
|
+
text = ""
|
|
1273
|
+
try:
|
|
1274
|
+
text = payload["choices"][0]["message"]["content"]
|
|
1275
|
+
except Exception: # noqa: BLE001
|
|
1276
|
+
pass
|
|
1277
|
+
prompt_s = split.get("prompt_s")
|
|
1278
|
+
gen_s = split.get("generation_s")
|
|
1279
|
+
first_token_at = call_start + (prompt_s or 0.0)
|
|
1280
|
+
ended_at = first_token_at + (gen_s or 0.0)
|
|
1281
|
+
return {
|
|
1282
|
+
"ok": ok, "error": error, "payload": payload, "text": text,
|
|
1283
|
+
"forwarded_body": forwarded,
|
|
1284
|
+
"timings": timings, "usage": usage, "tok_s": tok_s, "split": split,
|
|
1285
|
+
"call_start": call_start, "first_token_at": first_token_at,
|
|
1286
|
+
"ended_at": ended_at, "responded_at": responded,
|
|
1287
|
+
"prompt_tokens": usage.get("prompt_tokens")
|
|
1288
|
+
or (timings.get("prompt_n") if isinstance(timings, dict) else None),
|
|
1289
|
+
"completion_tokens": comp,
|
|
1290
|
+
"decode_tokens": split.get("gen_tokens"),
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
def shutdown(self) -> None:
|
|
1294
|
+
for key in list(self.children):
|
|
1295
|
+
self.evict(key, reason="front door shutdown")
|
|
1296
|
+
|
|
1297
|
+
|
|
1298
|
+
__all__ = ["gguf_footprint", "LlamaCppHost", "discover_models", "model_weights", "engine_of_path"]
|