Coverage for src/lilbee/providers/fleet/ctx.py: 100%
33 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-14 11:46 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-14 11:46 +0000
1"""Per-device context sizing for a tensor-split chat model in the fleet.
3Single-GPU chat ctx lives in ``engine_params.resolve_chat_ctx``; this is its
4multi-GPU counterpart, sizing the per-slot context against the busiest card's
5headroom rather than the summed pool. See docs/architecture.md (VRAM estimation).
6"""
8from __future__ import annotations
10from collections.abc import Sequence
11from pathlib import Path
13from lilbee.core.config.enums import KvCacheType
14from lilbee.providers.engine_params import chat_ctx_ceiling
15from lilbee.providers.fleet.placement import _vram_proportional_split
16from lilbee.providers.fleet.vram import estimate_instance_footprint, usable_vram_fraction
17from lilbee.providers.model_cache import _DYNAMIC_CTX_FLOOR, _DYNAMIC_CTX_QUANTUM
19# Extra VRAM held back on the busiest card on top of the gguf-parser estimate.
20# In --split-mode layer, gguf-parser ignores --main-gpu and so under-models the
21# compute graph + logits that real llama.cpp concentrates on the main device.
22# Measured on 2x4090 (70B Q4_K_M, ctx 5888): the main device lands 0.26 GiB over
23# its estimate, an order of magnitude inside the usable_vram_fraction already held
24# back per card, so the reserve stays 0.
25_MAIN_GPU_SKEW_RESERVE_BYTES = 0
28def fit_split_ctx(
29 model_path: Path,
30 *,
31 meta: dict[str, str] | None,
32 slots: int,
33 ratio: tuple[int, ...],
34 per_device_free_bytes: Sequence[int],
35 gpu_layers: int,
36 flash_attn: bool,
37 kv_cache_type: KvCacheType,
38 kv_cache_type_v: KvCacheType,
39 ctx_ceiling: int,
40) -> int:
41 """Largest quantized per-slot n_ctx that fits every card, capped at *ctx_ceiling*.
43 Binary-searches the gguf-parser estimate at the launch tensor-split *ratio*:
44 each probe passes a per-slot value, which the estimator charges across the
45 slot count as the server does, and accepts it when every device's own share
46 stays under that device's usable headroom. *ctx_ceiling* is the working context the
47 caller planned for (``planning._placement_estimate_ctx``: a ``cfg.num_ctx`` pin,
48 else ``cfg.chat_n_ctx_target``); the search never exceeds it, nor the model's
49 trained context. Note the ceiling bounds the PER-SLOT window, not the total:
50 placement reserves KV for one full window, while a split whose cards hold
51 several may serve up to ``_CHAT_SLOTS`` of them. What keeps that honest is
52 the per-device check below against real free bytes, not the placement
53 reserve. Falls to the floor when even the floor overflows; plan_launches
54 refuses a chat launch left at a window below the minimum grounded prompt.
56 An empty *ratio* is the tight placement, which launches without one so the
57 engine runs its own fit pass. It is also the estimator's only device-count
58 signal, so size against a headroom-proportional one here: without it
59 gguf-parser reports the whole model as a single card and nothing fits.
60 """
61 headrooms = [
62 int(free * usable_vram_fraction()) - _MAIN_GPU_SKEW_RESERVE_BYTES
63 for free in per_device_free_bytes
64 ]
65 if min(headrooms) <= 0:
66 return _DYNAMIC_CTX_FLOOR
67 if not ratio and len(per_device_free_bytes) > 1:
68 by_position = {i: float(free) for i, free in enumerate(per_device_free_bytes)}
69 ratio = _vram_proportional_split(list(by_position), by_position)
70 # Bound the per-slot search by the planned working context, not just the model's
71 # trained max: filling VRAM to that max OOM'd large tensor-split models under
72 # load (a 235B took the full 262144-token ctx and crashed). The caller passes the
73 # target placement sized its reserve against, so no single sequence exceeds the
74 # plan; the total across slots can, and is held instead by the per-device
75 # headroom test, which measures each card's real free bytes at launch.
76 upper = min(chat_ctx_ceiling(meta, model_path), ctx_ceiling)
78 def _peak_fits(per_slot: int) -> bool:
79 est = estimate_instance_footprint(
80 model_path,
81 ctx=per_slot,
82 slots=slots,
83 gpu_layers=gpu_layers,
84 flash_attn=flash_attn,
85 kv_cache_type=kv_cache_type,
86 kv_cache_type_v=kv_cache_type_v,
87 tensor_split=ratio,
88 )
89 shares = est.per_device_vram
90 if len(shares) != len(headrooms):
91 # No usable per-device breakdown: fall back to peak vs the tightest card.
92 return est.peak_footprint(unified=False) <= min(headrooms)
93 return all(share <= room for share, room in zip(shares, headrooms, strict=True))
95 if not _peak_fits(_DYNAMIC_CTX_FLOOR):
96 return _DYNAMIC_CTX_FLOOR
97 steps = max(0, (upper - _DYNAMIC_CTX_FLOOR) // _DYNAMIC_CTX_QUANTUM)
98 lo, hi, best = 0, steps, 0
99 while lo <= hi:
100 mid = (lo + hi) // 2
101 if _peak_fits(_DYNAMIC_CTX_FLOOR + mid * _DYNAMIC_CTX_QUANTUM):
102 best, lo = mid, mid + 1
103 else:
104 hi = mid - 1
105 return _DYNAMIC_CTX_FLOOR + best * _DYNAMIC_CTX_QUANTUM