Coverage for src/lilbee/providers/fleet/readback.py: 100%

141 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-09-08 09:20 +0000

1"""What the engine actually allocated, read back from its own report. 

2 

3Compares the planner's estimate against reality per device, and warns naming the 

4role, the estimate and the reality when they diverge. 

5 

6Two sources, picked per engine binary. An engine that advertises ``--memory`` 

7in its ``--help`` (llama.cpp PR 26130; the bundled engine is built from that 

8fork) serves ``GET /memory``: per-device rows carrying model, context, compute 

9and mmproj bytes under the same device names ``--device`` takes. That is the 

10preferred source -- a promised JSON shape, the vision projector reported on its 

11real device, and no trace-level log required. 

12 

13An engine without the flag falls back to its startup log, which is then the 

14only place these numbers exist: ``llama_model_size`` is the whole model's 

15weights and nothing per device, and a stock server's HTTP surface carries none 

16of it (``/props`` is metadata, ``/metrics`` is token counters; both checked 

17against a running server). 

18 

19The log format, from upstream source: 

20 

21 src/llama-model.cpp "%s: %12s model buffer size = %8.2f MiB" 

22 src/llama-kv-cache.cpp "%s: %10s KV buffer size = %8.2f MiB" 

23 src/llama-context.cpp "%s: %10s compute buffer size = %8.2f MiB" 

24 

25These are format strings, not a promised interface. A bundled-engine version bump 

26can break this: re-capture the fixture and confirm :func:`parse_device_buffers` 

27still finds every line. A build that stops matching is reported, not swallowed 

28(see :func:`check_launch`). 

29""" 

30 

31from __future__ import annotations 

32 

33import functools 

34import logging 

35import re 

36import subprocess 

37from pathlib import Path 

38 

39from lilbee.providers.fleet.devices import FleetDevice 

40from lilbee.providers.fleet.vram import usable_vram_fraction 

41from lilbee.providers.roles import WorkerRole 

42 

43log = logging.getLogger(__name__) 

44 

45MIB = 1024 * 1024 

46 

47# The build the checked-in fixture was captured from, named in the drift warning 

48# so a report says what to compare with. Tracks the fixture, not the shipped 

49# engine. A landmark, not a gate: drift is detected by the parse coming back 

50# empty on a load that finished, not by a version comparison. 

51VERIFIED_ENGINE_BUILD = "9310 (e2ef8fe42)" 

52 

53# "load_tensors: MTL0_Mapped model buffer size = 82.41 MiB", and its siblings. 

54# 

55# Matches the shape rather than a list of buffer kinds, so LoRA, RS and the 

56# DeepSeek V4 state buffer parse without being enumerated here. 

57# 

58# The "= N MiB" is load-bearing: it excludes the three lines carrying these words 

59# that are not allocations -- the self-check pair ("compute buffer size is N MiB, 

60# matches expectation" / "... does not match expectation") and ggml-opencl's 

61# "buffer size reduced from A to B". None uses "=". 

62# 

63# The device label is whatever the backend calls itself: CUDA0, MTL0, Vulkan1, 

64# CPU. A timestamp and level prefix the line under --log-file, so the match is 

65# not anchored to the start. 

66_BUFFER_RE = re.compile(r"\S+:\s+(?P<device>\S+)\s+.*?buffer size\s*=\s*(?P<mib>[\d.]+)\s*MiB") 

67# The engine names an mmapped weight buffer "<device>_Mapped" beside the same 

68# device's other buffers. Same memory, so the suffix is folded away rather than 

69# splitting one card's total across two keys. 

70_MAPPED_SUFFIX = "_Mapped" 

71# ggml names a row-split buffer "<backend>_Split", one allocation shared by every 

72# card in the split rather than a device of its own. 

73_SPLIT_SUFFIX = "_Split" 

74# Host memory rather than a GPU, in three shapes: the CPU backend's own 

75# buffers (CPU, CPU_Mapped, CPU_REPACK, and AMX, which is a CPU extension), 

76# every GPU backend's pinned-host allocator, named "<backend>_Host" by 

77# ggml-cuda, -sycl, -vulkan, -cann and -hip alike (observed as CUDA_Host, 

78# Vulkan_Host, ROCm_Host), and the single "Host" row GET /memory aggregates 

79# them into. None of it occupies VRAM; charging it to a card reports a phantom 

80# overrun on every partially offloaded model. 

81_HOST_PREFIXES = ("CPU", "AMX", "Host") 

82_HOST_SUFFIX = "_Host" 

83 

84 

85def _is_host_device(device: str) -> bool: 

86 """Whether *device* names host memory rather than a GPU.""" 

87 return device.startswith(_HOST_PREFIXES) or device.endswith(_HOST_SUFFIX) 

88 

89 

90def parse_device_buffers(text: str) -> dict[str, int]: 

91 """Bytes the engine reported allocating, per device label, from *text*. 

92 

93 Sums the model, KV, compute and output buffers, which is the same total the 

94 estimate predicts. Empty when the text carries no buffer report: a load that 

95 failed before allocating, a log that has rotated past it, or an engine whose 

96 verbosity is below the level that prints it. 

97 """ 

98 totals: dict[str, int] = {} 

99 for match in _BUFFER_RE.finditer(text): 

100 device = match.group("device") 

101 if device.endswith(_SPLIT_SUFFIX): 

102 # A row-split buffer is spread across every card in the split, so it 

103 # belongs to no single one. Keeping it would invent a device that the 

104 # per-device comparison then reports as an unplanned allocation. 

105 continue 

106 device = device.removesuffix(_MAPPED_SUFFIX) 

107 totals[device] = totals.get(device, 0) + int(float(match.group("mib")) * MIB) 

108 return totals 

109 

110 

111# Printed once the weights are in and slots are being wired up, so its presence 

112# separates "the report is not written yet" from "this engine writes none here". 

113# 

114# Matches only the word the engine has kept: "initializing slots" through b9665, 

115# "initializing, n_slots = N" from b9829. This gate arms the format-drift 

116# warning, so pinning either exact phrase would silence the warning on the other. 

117# The buffer lines held identical across all three builds; only the prose moved. 

118_LOAD_FINISHED_RE = re.compile(r"load_model:\s+initializing\b") 

119# "common_params_print_info: build 9310 (e2ef8fe42) with AppleClang ...", the 

120# engine's own first line. Carried into the format-drift warning so the report 

121# names the exact build to re-verify against. 

122_BUILD_RE = re.compile(r"build\s+(?P<build>\d+)\s+\((?P<commit>[0-9a-f]+)\)") 

123 

124 

125def engine_build(text: str) -> str: 

126 """The engine build the log was written by, or empty when it does not say.""" 

127 match = _BUILD_RE.search(text) 

128 return f"{match.group('build')} ({match.group('commit')})" if match else "" 

129 

130 

131def load_finished(text: str) -> bool: 

132 """Whether the engine got far enough to have reported its buffers.""" 

133 return _LOAD_FINISHED_RE.search(text) is not None 

134 

135 

136def device_footprint(text: str) -> int: 

137 """Total GPU bytes the engine reported, host buffers excluded.""" 

138 return sum( 

139 size for device, size in parse_device_buffers(text).items() if not _is_host_device(device) 

140 ) 

141 

142 

143def device_label(device: FleetDevice) -> str: 

144 """The name the engine prints for *device*, and the join between the two sides. 

145 

146 ``ggml_backend_dev_name`` produces ``CUDA0`` / ``MTL0`` / ``Vulkan1``, which is 

147 the same token ``--device`` and ``--tensor-split`` take and the same one the 

148 buffer report is keyed by. Joining on it keeps the check out of the index-space 

149 ambiguity that ``FleetDevice.from_loader`` exists to mark. 

150 """ 

151 return f"{device.backend}{device.index}" 

152 

153 

154def report_divergence( 

155 role: WorkerRole, 

156 model: str, 

157 estimated_bytes: int, 

158 actual_bytes: int, 

159 *, 

160 tolerance: float, 

161) -> bool: 

162 """Warn when the engine's real footprint diverges materially from the estimate. 

163 

164 Returns whether a warning was emitted, so a caller can record that this 

165 instance has already been checked and not repeat it on every request. 

166 

167 Both directions are worth saying. An under-estimate is how a plan that fit on 

168 paper OOMs, and it is the one that ends in a failed load. A large 

169 over-estimate is quieter but costs capacity: it is why a role gets fewer 

170 slots, a narrower context, or a split it did not need. 

171 """ 

172 if estimated_bytes <= 0 or actual_bytes <= 0: 

173 return False 

174 ratio = actual_bytes / estimated_bytes 

175 limit = min(tolerance, _absorbable_overrun()) if ratio > 1.0 else tolerance 

176 if abs(ratio - 1.0) <= limit: 

177 return False 

178 log.warning( 

179 "The %s model %s allocated %.1f GiB of GPU memory but was planned for %.1f GiB " 

180 "(%+.0f%%). Placement decisions for this model were made on the smaller figure; " 

181 "if it fails to load or runs slowly, that gap is why.", 

182 role.value, 

183 model, 

184 actual_bytes / 1024**3, 

185 estimated_bytes / 1024**3, 

186 (ratio - 1.0) * 100, 

187 ) 

188 return True 

189 

190 

191# The engine's own log, one per instance, beside the swap process's log. Named 

192# by model id so a role's replicas do not overwrite each other. 

193_ENGINE_LOG_TEMPLATE = "engine-{model_id}.log" 

194# Env the engine reads for its log destination and threshold. Set through the 

195# environment rather than argv: the launch is planned before the data directory 

196# holding these logs is chosen, and neither affects sizing. 

197# 

198# Both spellings, because the engine renamed them. common/arg.cpp registers 

199# LLAMA_ARG_LOG_FILE / LLAMA_ARG_LOG_VERBOSITY on master; builds around 9310 read 

200# LLAMA_LOG_FILE / LLAMA_LOG_VERBOSITY. Verified against both. An unread variable 

201# costs nothing; picking one produced no log at all on half the builds in use. 

202ENV_LOG_FILE = "LLAMA_LOG_FILE" 

203ENV_LOG_VERBOSITY = "LLAMA_LOG_VERBOSITY" 

204ENV_ARG_LOG_FILE = "LLAMA_ARG_LOG_FILE" 

205ENV_ARG_LOG_VERBOSITY = "LLAMA_ARG_LOG_VERBOSITY" 

206# Level 4 ("trace") is where the per-device buffer report appears. Measured 

207# against the bundled engine: the default 3 omits it entirely, and 5 adds a 

208# per-layer and per-slot flood for the same six lines. 

209LOAD_REPORT_VERBOSITY = "4" 

210 

211 

212def engine_log_path(log_dir: Path, model_id: str) -> Path: 

213 """Where the engine serving *model_id* writes its own log.""" 

214 return log_dir / _ENGINE_LOG_TEMPLATE.format(model_id=model_id) 

215 

216 

217def engine_log_env(log_dir: Path, model_id: str) -> dict[str, str]: 

218 """Environment that makes the engine report what it allocated, and where.""" 

219 path = str(engine_log_path(log_dir, model_id)) 

220 return { 

221 ENV_LOG_FILE: path, 

222 ENV_ARG_LOG_FILE: path, 

223 ENV_LOG_VERBOSITY: LOAD_REPORT_VERBOSITY, 

224 ENV_ARG_LOG_VERBOSITY: LOAD_REPORT_VERBOSITY, 

225 } 

226 

227 

228def check_launch( 

229 log_dir: Path, 

230 model_id: str, 

231 role: WorkerRole, 

232 model: str, 

233 estimated_bytes: int, 

234 est_by_device: dict[str, int] | None = None, 

235 unreported_bytes: int = 0, 

236) -> bool: 

237 """Compare the engine's own report for *model_id* against the estimate. 

238 

239 Checked per device when *est_by_device* says what each card was planned for, 

240 because per device is the only dimension the planner decides in: a split is a 

241 ratio, a placement is a card, and a shortfall is recorded against a role on a 

242 card. Two cards planned 50/50 that land 80/20 sum to exactly the planned 

243 total, so a scalar comparison sees nothing while card 0 is the one that runs 

244 out. Falls back to the total for a model the estimator could only size as one 

245 number. 

246 

247 Three outcomes, and the third is the one that matters. The engine has no API 

248 for any of this: /props carries no memory keys and /metrics is token 

249 counters, both checked against a running server, so its log is the only 

250 place these numbers exist. That makes this the one part of the fleet whose 

251 input is a format nobody promises to keep. 

252 

253 So a load that finished without a readable report is reported, not swallowed. 

254 Left silent it would look exactly like a correct estimate, and the check 

255 would quietly become decoration the first time llama.cpp renames a line or 

256 renumbers its verbosity levels. Loud, it names itself as the thing to fix. 

257 """ 

258 try: 

259 text = engine_log_path(log_dir, model_id).read_text(encoding="utf-8", errors="replace") 

260 except OSError: 

261 # No log at all. Usually the engine simply has not written one yet, so 

262 # this is silent by default. It is also exactly what a wrong environment 

263 # variable name looks like, which is how an earlier spelling went 

264 # unnoticed: the check returned False forever and read as "estimate fine". 

265 # report_missing_log is how a caller that knows the engine is up says so. 

266 return False 

267 per_device = { 

268 label: size 

269 for label, size in parse_device_buffers(text).items() 

270 if not _is_host_device(label) 

271 } 

272 actual = sum(per_device.values()) 

273 if actual > 0 and est_by_device: 

274 return _report_per_device( 

275 role, model, _without_unreported(est_by_device, unreported_bytes), per_device 

276 ) 

277 if actual <= 0: 

278 if load_finished(text): 

279 log.warning( 

280 "The %s engine (build %s) finished loading but reported no memory usage where " 

281 "lilbee reads it, so its estimate could not be checked. The engine's log format " 

282 "or verbosity levels have most likely changed since build %s, which lilbee's " 

283 "parser was written against; placement estimates are unverified until it is " 

284 "updated to match.", 

285 role.value, 

286 engine_build(text) or "unknown", 

287 VERIFIED_ENGINE_BUILD, 

288 ) 

289 return False 

290 return report_divergence( 

291 role, model, estimated_bytes - unreported_bytes, actual, tolerance=_TOLERANCE 

292 ) 

293 

294 

295def _without_unreported(est_by_device: dict[str, int], unreported: int) -> dict[str, int]: 

296 """*est_by_device* less the bytes the engine allocates without reporting them. 

297 

298 Charged to the busiest device, which is where the planner put them: a vision 

299 projector loads on the main GPU rather than across a split. Comparing the 

300 full estimate against a report that structurally cannot contain these bytes 

301 warns on every correctly sized vision load. 

302 """ 

303 if unreported <= 0 or not est_by_device: 

304 return est_by_device 

305 main = max(est_by_device, key=lambda label: est_by_device[label]) 

306 adjusted = dict(est_by_device) 

307 adjusted[main] = max(0, adjusted[main] - unreported) 

308 return adjusted 

309 

310 

311# How far the engine may land from the estimate before it is worth saying. Wide 

312# enough that the estimator's normal error is quiet, narrow enough to catch the 

313# whole-slot and whole-cache mistakes this exists to surface. 

314_TOLERANCE = 0.25 

315 

316# Share of the card's remaining margin an overrun may eat before it is worth 

317# saying, leaving the operator a gap between the warning and the overflow. 

318_MARGIN_WARN_FRACTION = 0.75 

319 

320 

321def _absorbable_overrun() -> float: 

322 """How far past its estimate a load may land while the card still holds it. 

323 

324 Placement packs a card up to ``cfg.usable_vram_fraction`` and a single-card 

325 chat sizes its cache against ``cfg.gpu_memory_fraction``; the binding one is 

326 whichever leaves less room, since either can be raised past the other. A load 

327 filling that share overflows once it exceeds its estimate by the remainder, 

328 so the warning has to come before that, which makes the threshold a function 

329 of the margin rather than a constant. At the stock 0.9 usable fraction the 

330 room is 11%, well inside the flat 25% this replaces. 

331 """ 

332 from lilbee.core.config import cfg 

333 

334 committed = max(cfg.gpu_memory_fraction, usable_vram_fraction()) 

335 return max(0.0, 1.0 / committed - 1.0) * _MARGIN_WARN_FRACTION 

336 

337 

338def _report_per_device( 

339 role: WorkerRole, 

340 model: str, 

341 estimated: dict[str, int], 

342 actual: dict[str, int], 

343) -> bool: 

344 """Warn about the card that diverged worst, naming both figures. 

345 

346 One warning rather than one per card: the operator needs to know the plan did 

347 not hold and which card to look at, and a split that skews puts every card out 

348 at once by construction. 

349 """ 

350 worst_label, worst_gap, worst_over = "", 0.0, False 

351 for label in set(estimated) | set(actual): 

352 planned, landed = estimated.get(label, 0), actual.get(label, 0) 

353 gap = abs(landed - planned) / planned if planned else float(landed) 

354 over = landed > planned 

355 # An overrun outranks an equal shortfall: a card holding more than it was 

356 # planned for is the one that fails to load, while its partner holding 

357 # less is only the symptom of the same skew. 

358 if (over, gap) > (worst_over, worst_gap): 

359 worst_label, worst_gap, worst_over = label, gap, over 

360 limit = min(_TOLERANCE, _absorbable_overrun()) if worst_over else _TOLERANCE 

361 if not worst_label or (estimated.get(worst_label) and worst_gap <= limit): 

362 return False 

363 log.warning( 

364 "The %s model %s did not land where it was planned: %s holds %.1f GiB but was " 

365 "planned for %.1f GiB. Placement, the tensor split and the context were all " 

366 "decided per card, so a total that looks right can still overrun one of them.", 

367 role.value, 

368 model, 

369 worst_label, 

370 actual.get(worst_label, 0) / 1024**3, 

371 estimated.get(worst_label, 0) / 1024**3, 

372 ) 

373 return True 

374 

375 

376# llama-server flag (llama.cpp PR 26130) that both enables GET /memory and 

377# marks, via --help, an engine that has it. Launch argv and the swap config key 

378# the readback mode off its presence. 

379MEMORY_FLAG = "--memory" 

380 

381# The flag as --help advertises it. The lookahead keeps historic longer flags 

382# (--memory-f32) from reading as support for the endpoint. 

383_HELP_MEMORY_RE = re.compile(r"--memory(?!\S)") 

384# --help exits from arg parsing, before any backend or model load, so this is 

385# generous; a binary that cannot even print usage in this time is not one the 

386# fleet should silently trust either way. 

387_HELP_PROBE_TIMEOUT_S = 15.0 

388 

389 

390@functools.lru_cache(maxsize=8) 

391def supports_memory_readback(binary: Path) -> bool: 

392 """Whether *binary* serves ``GET /memory`` when launched with ``--memory``. 

393 

394 Read from the binary's own ``--help``, once per path: a stock llama-server 

395 exits with "unknown argument" when handed a flag it lacks, so the launch 

396 must never carry it on advertisement it did not see. Any failure to run or 

397 to answer reads as unsupported, which degrades to the log path rather than 

398 a failed launch. 

399 """ 

400 try: 

401 done = subprocess.run( # noqa: S603 - argv is the resolved engine binary and a literal 

402 [str(binary), "--help"], 

403 capture_output=True, 

404 encoding="utf-8", 

405 errors="replace", 

406 timeout=_HELP_PROBE_TIMEOUT_S, 

407 check=False, 

408 ) 

409 except (OSError, subprocess.SubprocessError): 

410 return False 

411 return bool(_HELP_MEMORY_RE.search(f"{done.stdout}\n{done.stderr}")) 

412 

413 

414# The per-device byte fields of one GET /memory row. Summed because the total 

415# they form is the same one the estimate predicts and the log path sums from 

416# its buffer lines; mmproj is the projector the log path could never see. 

417_MEMORY_ROW_FIELDS = ("model", "context", "compute", "mmproj") 

418 

419 

420def parse_memory_rows(payload: object) -> dict[str, int]: 

421 """Bytes the engine reported allocating per device, from a /memory payload. 

422 

423 Empty for anything that is not the endpoint's ``{"data": [rows]}`` shape, 

424 and per row for junk within it: this crosses a process boundary, and the 

425 caller says "unverified" for an empty parse rather than crashing readiness. 

426 """ 

427 rows = payload.get("data") if isinstance(payload, dict) else None 

428 if not isinstance(rows, list): 

429 return {} 

430 totals: dict[str, int] = {} 

431 for row in rows: 

432 if not isinstance(row, dict): 

433 continue 

434 name = row.get("name") 

435 if not isinstance(name, str) or not name: 

436 continue 

437 size = 0 

438 for field in _MEMORY_ROW_FIELDS: 

439 value = row.get(field, 0) 

440 if isinstance(value, (int, float)) and not isinstance(value, bool): 

441 size += int(value) 

442 totals[name] = totals.get(name, 0) + size 

443 return totals 

444 

445 

446def check_memory_report( 

447 role: WorkerRole, 

448 model: str, 

449 estimated_bytes: int, 

450 est_by_device: dict[str, int] | None, 

451 payload: object, 

452) -> bool: 

453 """Compare a ``GET /memory`` payload against the estimate; the API-mode twin 

454 of :func:`check_launch`. 

455 

456 No ``unreported_bytes`` adjustment exists here on purpose: the endpoint 

457 reports the vision projector per device in its ``mmproj`` field, so the 

458 quantity that adjustment approximates is in the rows themselves and the 

459 comparison is exact where the log path had to guess. 

460 

461 An engine that took the flag but produced no usable rows is reported, not 

462 swallowed, for the same reason a finished load with an unreadable log is: 

463 silent, it looks exactly like a correct estimate. 

464 """ 

465 per_device = { 

466 label: size 

467 for label, size in parse_memory_rows(payload).items() 

468 if not _is_host_device(label) 

469 } 

470 if not per_device: 

471 log.warning( 

472 "The %s engine answered /memory without any per-device rows, so its " 

473 "estimate is unverified. The endpoint's response shape has most likely " 

474 "changed since the build lilbee's reader was written against.", 

475 role.value, 

476 ) 

477 return True 

478 if est_by_device: 

479 return _report_per_device(role, model, est_by_device, per_device) 

480 return report_divergence( 

481 role, model, estimated_bytes, sum(per_device.values()), tolerance=_TOLERANCE 

482 ) 

483 

484 

485def report_missing_log(log_dir: Path, model_id: str, role: WorkerRole) -> bool: 

486 """Warn when a ready engine wrote no log where lilbee told it to. 

487 

488 Separate from :func:`check_launch` because only the caller knows the engine 

489 finished loading; an absent file before that is ordinary. After it, the file 

490 should exist, and its absence means the engine never accepted the settings 

491 that produce it. That is a silent no-op rather than a wrong answer, which is 

492 the harder kind to notice, so it is stated. 

493 """ 

494 if engine_log_path(log_dir, model_id).exists(): 

495 return False 

496 log.warning( 

497 "The %s engine is running but wrote no log to %s, so its memory use could not " 

498 "be checked against the estimate. The engine build most likely does not read " 

499 "the variables lilbee sets to ask for one (%s or %s); placement estimates are " 

500 "unverified until that is updated.", 

501 role.value, 

502 engine_log_path(log_dir, model_id), 

503 ENV_LOG_FILE, 

504 ENV_ARG_LOG_FILE, 

505 ) 

506 return True