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

150 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-09-28 17: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.core.health_warnings import HealthWarning, WarningCode 

40from lilbee.providers.fleet.devices import FleetDevice 

41from lilbee.providers.fleet.vram import usable_vram_fraction 

42from lilbee.providers.roles import WorkerRole 

43 

44log = logging.getLogger(__name__) 

45 

46MIB = 1024 * 1024 

47 

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

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

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

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

52VERIFIED_ENGINE_BUILD = "9310 (e2ef8fe42)" 

53 

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

55# 

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

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

58# 

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

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

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

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

63# 

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

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

66# not anchored to the start. 

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

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

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

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

71_MAPPED_SUFFIX = "_Mapped" 

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

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

74_SPLIT_SUFFIX = "_Split" 

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

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

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

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

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

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

81# overrun on every partially offloaded model. 

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

83_HOST_SUFFIX = "_Host" 

84 

85 

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

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

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

89 

90 

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

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

93 

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

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

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

97 verbosity is below the level that prints it. 

98 """ 

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

100 for match in _BUFFER_RE.finditer(text): 

101 device = match.group("device") 

102 if device.endswith(_SPLIT_SUFFIX): 

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

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

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

106 continue 

107 device = device.removesuffix(_MAPPED_SUFFIX) 

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

109 return totals 

110 

111 

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

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

114# 

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

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

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

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

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

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

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

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

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

124 

125 

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

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

128 match = _BUILD_RE.search(text) 

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

130 

131 

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

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

134 return _LOAD_FINISHED_RE.search(text) is not None 

135 

136 

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

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

139 return sum( 

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

141 ) 

142 

143 

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

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

146 

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

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

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

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

151 """ 

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

153 

154 

155def divergence_warning( 

156 role: WorkerRole, 

157 model: str, 

158 estimated_bytes: int, 

159 actual_bytes: int, 

160 *, 

161 tolerance: float, 

162) -> HealthWarning | None: 

163 """The placement warning when the engine's footprint diverges from the estimate.""" 

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

165 return None 

166 ratio = actual_bytes / estimated_bytes 

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

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

169 return None 

170 return HealthWarning( 

171 code=WarningCode.PLACEMENT_DIVERGED, 

172 message=( 

173 f"The {role.value} model {model} allocated {actual_bytes / 1024**3:.1f} GiB " 

174 f"of GPU memory but was planned for {estimated_bytes / 1024**3:.1f} GiB " 

175 f"({(ratio - 1.0) * 100:+.0f}%). Placement decisions for this model were " 

176 f"made on the smaller figure; if it fails to load or runs slowly, that " 

177 f"gap is why." 

178 ), 

179 remedy="Free up GPU memory or use a smaller model." if ratio > 1.0 else None, 

180 ) 

181 

182 

183def report_divergence( 

184 role: WorkerRole, 

185 model: str, 

186 estimated_bytes: int, 

187 actual_bytes: int, 

188 *, 

189 tolerance: float, 

190) -> HealthWarning | None: 

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

192 

193 Returns the warning when one was emitted, so a caller can surface it and not 

194 repeat the check on every request. 

195 

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

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

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

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

200 """ 

201 warning = divergence_warning(role, model, estimated_bytes, actual_bytes, tolerance=tolerance) 

202 if warning is not None: 

203 log.warning("%s", warning.message) 

204 return warning 

205 

206 

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

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

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

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

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

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

213# 

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

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

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

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

218ENV_LOG_FILE = "LLAMA_LOG_FILE" 

219ENV_LOG_VERBOSITY = "LLAMA_LOG_VERBOSITY" 

220ENV_ARG_LOG_FILE = "LLAMA_ARG_LOG_FILE" 

221ENV_ARG_LOG_VERBOSITY = "LLAMA_ARG_LOG_VERBOSITY" 

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

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

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

225LOAD_REPORT_VERBOSITY = "4" 

226 

227 

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

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

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

231 

232 

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

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

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

236 return { 

237 ENV_LOG_FILE: path, 

238 ENV_ARG_LOG_FILE: path, 

239 ENV_LOG_VERBOSITY: LOAD_REPORT_VERBOSITY, 

240 ENV_ARG_LOG_VERBOSITY: LOAD_REPORT_VERBOSITY, 

241 } 

242 

243 

244def check_launch( 

245 log_dir: Path, 

246 model_id: str, 

247 role: WorkerRole, 

248 model: str, 

249 estimated_bytes: int, 

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

251 unreported_bytes: int = 0, 

252) -> HealthWarning | None: 

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

254 

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

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

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

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

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

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

261 number. 

262 

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

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

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

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

267 input is a format nobody promises to keep. 

268 

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

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

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

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

273 """ 

274 try: 

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

276 except OSError: 

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

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

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

280 # unnoticed: the check returned None forever and read as "estimate fine". 

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

282 return None 

283 per_device = { 

284 label: size 

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

286 if not _is_host_device(label) 

287 } 

288 actual = sum(per_device.values()) 

289 if actual > 0 and est_by_device: 

290 return _report_per_device( 

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

292 ) 

293 if actual <= 0: 

294 if load_finished(text): 

295 log.warning( 

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

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

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

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

300 "updated to match.", 

301 role.value, 

302 engine_build(text) or "unknown", 

303 VERIFIED_ENGINE_BUILD, 

304 ) 

305 return None 

306 return report_divergence( 

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

308 ) 

309 

310 

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

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

313 

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

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

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

317 warns on every correctly sized vision load. 

318 """ 

319 if unreported <= 0 or not est_by_device: 

320 return est_by_device 

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

322 adjusted = dict(est_by_device) 

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

324 return adjusted 

325 

326 

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

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

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

330_TOLERANCE = 0.25 

331 

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

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

334_MARGIN_WARN_FRACTION = 0.75 

335 

336 

337def _absorbable_overrun() -> float: 

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

339 

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

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

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

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

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

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

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

347 """ 

348 from lilbee.core.config import cfg 

349 

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

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

352 

353 

354def _per_device_warning( 

355 role: WorkerRole, 

356 model: str, 

357 estimated: dict[str, int], 

358 actual: dict[str, int], 

359) -> HealthWarning | None: 

360 """The placement warning for the card that diverged worst, naming both figures.""" 

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

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

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

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

365 over = landed > planned 

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

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

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

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

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

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

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

373 return None 

374 return HealthWarning( 

375 code=WarningCode.PLACEMENT_DIVERGED, 

376 message=( 

377 f"The {role.value} model {model} did not land where it was planned: " 

378 f"{worst_label} holds {actual.get(worst_label, 0) / 1024**3:.1f} GiB but was " 

379 f"planned for {estimated.get(worst_label, 0) / 1024**3:.1f} GiB. Placement, " 

380 f"the tensor split and the context were all decided per card, so a total " 

381 f"that looks right can still overrun one of them." 

382 ), 

383 remedy="Free up GPU memory or use a smaller model." if worst_over else None, 

384 ) 

385 

386 

387def _report_per_device( 

388 role: WorkerRole, 

389 model: str, 

390 estimated: dict[str, int], 

391 actual: dict[str, int], 

392) -> HealthWarning | None: 

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

394 

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

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

397 at once by construction. 

398 """ 

399 warning = _per_device_warning(role, model, estimated, actual) 

400 if warning is not None: 

401 log.warning("%s", warning.message) 

402 return warning 

403 

404 

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

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

407# the readback mode off its presence. 

408MEMORY_FLAG = "--memory" 

409 

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

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

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

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

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

415# fleet should silently trust either way. 

416_HELP_PROBE_TIMEOUT_S = 15.0 

417 

418 

419@functools.lru_cache(maxsize=8) 

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

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

422 

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

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

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

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

427 a failed launch. 

428 """ 

429 try: 

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

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

432 capture_output=True, 

433 encoding="utf-8", 

434 errors="replace", 

435 timeout=_HELP_PROBE_TIMEOUT_S, 

436 check=False, 

437 ) 

438 except (OSError, subprocess.SubprocessError): 

439 return False 

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

441 

442 

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

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

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

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

447 

448 

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

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

451 

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

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

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

455 """ 

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

457 if not isinstance(rows, list): 

458 return {} 

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

460 for row in rows: 

461 if not isinstance(row, dict): 

462 continue 

463 name = row.get("name") 

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

465 continue 

466 size = 0 

467 for field in _MEMORY_ROW_FIELDS: 

468 value = row.get(field, 0) 

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

470 size += int(value) 

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

472 return totals 

473 

474 

475def check_memory_report( 

476 role: WorkerRole, 

477 model: str, 

478 estimated_bytes: int, 

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

480 payload: object, 

481) -> HealthWarning | None: 

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

483 of :func:`check_launch`. 

484 

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

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

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

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

489 

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

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

492 silent, it looks exactly like a correct estimate. 

493 """ 

494 per_device = { 

495 label: size 

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

497 if not _is_host_device(label) 

498 } 

499 if not per_device: 

500 log.warning( 

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

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

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

504 role.value, 

505 ) 

506 return None 

507 if est_by_device: 

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

509 return report_divergence( 

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

511 ) 

512 

513 

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

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

516 

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

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

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

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

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

522 """ 

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

524 return False 

525 log.warning( 

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

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

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

529 "unverified until that is updated.", 

530 role.value, 

531 engine_log_path(log_dir, model_id), 

532 ENV_LOG_FILE, 

533 ENV_ARG_LOG_FILE, 

534 ) 

535 return True