Files
hermes-agent/hermes_cli/local_runtime/estimator.py
emozilla fbb9453fbb fix(local-runtime): price QSA compute buffers at the launch posture
Qwen4Exp's QSA attention scores the whole window each microbatch. Under
the router's unified KV every slot sees the full window, and b11370
reserves ~46 bytes per microbatch token per window token for each
context, device plus host; an MTP head is a second context. At 256K that
is 5.9 GiB per context at -ub 512 and ~23 GiB at -ub 2048, so the
prefill posture Hermes chose on a 128 GB Spark loaded the target and ran
out of memory creating the MTP context.

plan_launch now prices each posture with its own microbatch and context
count, LaunchPlan carries the profile it priced, and catalog entries
name their architecture so the price holds before download. On the
Spark, Flash Next now runs 256K at -ub 512, priced at 83.1 GiB against
81.5 GiB of measured engine buffers.
2026-10-06 16:07:56 -04:00

229 lines
11 KiB
Python

"""Per-layer context-memory estimator + physics check.
The estimator is ADVISORY: fit's allocation is authoritative at launch and the touch generation is
ground truth after it. Unknown shapes round UP (never underestimate memory).
"""
from __future__ import annotations
from dataclasses import dataclass, field, replace
from enum import Enum
from hermes_cli.local_runtime.gguf import GGUFHeader
# q8_0: 34-byte blocks of 32 f16-equivalent elements (exact).
_Q8_BYTES_PER_ELEM = 34 / 32
_F16_BYTES_PER_ELEM = 2.0
# Architectures with a known SWA layer pattern: arch -> fraction of layers that are
# sliding-window. Unknown SWA archs treat every layer as full attention (overestimate; safe).
_SWA_LAYER_FRACTION = {"gemma3": 5 / 6, "gemma2": 1 / 2}
# Architectures whose llama.cpp loader expands a scalar `sliding_window_pattern` period with
# dense_first=True (the full-attention layer opens each period-length cycle instead of closing
# it). Confirmed against llama.cpp's per-arch hparams.set_swa_pattern() calls; every other
# architecture — including the whole Gemma family — uses the dense_first=False default.
_SWA_DENSE_FIRST_ARCHS = {"cohere2moe", "modern-bert", "smallthinker", "laguna"}
def _expand_swa_period(period: int, n_layer: int, dense_first: bool) -> list[int]:
"""Per-layer SWA/full split from a scalar period, matching llama.cpp's
`llama_hparams::set_swa_pattern()`: one full-attention layer per `period`-length cycle, the
rest sliding-window."""
if period <= 0:
return []
if dense_first:
return [0 if i % period == 0 else 1 for i in range(n_layer)]
return [1 if i % period < period - 1 else 0 for i in range(n_layer)]
# Per-recurrent-layer state allowance (bytes/seq). Deliberately generous: an entire measured
# hybrid slot state is ~99 MB including 8K tokens of full-attn KV, so tens of MiB total is the
# right order; unknown SSM shapes must never underestimate.
_RECURRENT_STATE_PER_LAYER = 4 << 20
# Compute-buffer bytes per microbatch token per window token, per llama.cpp context, for
# architectures whose attention scores the whole window each microbatch. qwen4exp's QSA indexer:
# measured on b11370 with the router's unified KV (every slot sees the full window), device plus
# host buffers, target and MTP head contexts alike (39.5 + 6.1 B; 5.9 GiB per context at 256K and
# -ub 512, ~23 GiB at -ub 2048).
_WINDOW_COMPUTE_BYTES = {"qwen4exp": 46}
class LayerKind(Enum):
FULL = "full"
SWA = "swa"
RECURRENT = "recurrent"
@dataclass
class ModelProfile:
"""Everything the policy needs, decoupled from GGUF parsing so decision-table tests can
construct profiles directly."""
name: str
# Weights the engine loads when it reads lazy_bytes from disk on demand; as_loaded() adds them
# back on a machine where it reads them up front.
weights_bytes: int
embd_table_bytes: int
n_ctx_train: int
layers: list[tuple[LayerKind, int]] # (kind, kv_bytes_per_token_f16); SWA capped, recurrent ignored
swa_window: int = 0
moe: bool = False
architecture: str = ""
n_vocab: int = 0 # prices logits buffers (ubatch x vocab)
# Context-cost multiplier. MTP spec decode keeps a small draft context beside the main one;
# calibrated against four measured server-RSS points on Qwen3.8 Q4 (128K/221K/256K, both
# postures): the draft adds ~17% to per-token KV; 1.2 rounds up so the error stays on the safe
# side (+250 MiB at 256K, never negative).
kv_scale: float = 1.0
# block index -> FFN weight bytes (from the tensor table); empty when unknown.
ffn_block_bytes: dict[int, int] = field(default_factory=dict)
# Bytes of architecture-marked tensors (gguf._LAZY_READ_TENSORS) the engine can read from disk
# on demand instead of loading.
lazy_bytes: int = 0
# Compute-buffer bytes per window token at the launch posture (plan_launch sets it from
# window_compute_bytes, the microbatch and the context count); zero prices none.
window_compute_per_token: int = 0
@property
def window_compute_bytes(self) -> int:
return _WINDOW_COMPUTE_BYTES.get(self.architecture, 0)
@property
def per_token_kv_f16(self) -> int:
"""Uncapped per-token KV cost (full + SWA share)."""
return sum(b for kind, b in self.layers if kind != LayerKind.RECURRENT)
@property
def recurrent_layer_count(self) -> int:
return sum(1 for kind, _ in self.layers if kind == LayerKind.RECURRENT)
@dataclass
class HardwareBudget:
"""Memory the physics check may budget against. Discrete cards may trust the device query;
unified-memory devices must budget from OS free memory minus headroom (device queries observed
off by 3x). Callers construct this accordingly; the estimator just consumes it."""
usable_vram_bytes: int # live free (discrete) / derived (UMA)
total_device_bytes: int
ram_available_bytes: int
uma: bool = False
gpu_name: str = "" # display name; legacy fallback for performance estimates
platform: str = "" # sys.platform of the machine being priced
gpu_pci_id: int | None = None # nvidia-smi's packed PCI device/vendor ID
# The engine reads lazy tensors from disk here. llama.cpp's own default does so everywhere
# except integrated GPUs (b11370 #28160); Hermes passes --lazy-mode on to NVIDIA's, so only
# AMD/Intel integrated GPUs load them up front.
lazy_reads: bool = True
def as_loaded(profile: ModelProfile, budget: HardwareBudget) -> ModelProfile:
"""The profile as this machine loads it: on-demand tensors count as weights where the engine
reads them up front."""
if profile.lazy_bytes and not budget.lazy_reads:
return replace(profile, weights_bytes=profile.weights_bytes + profile.lazy_bytes, lazy_bytes=0)
return profile
def profile_from_gguf(header: GGUFHeader) -> ModelProfile:
kv_heads = header.head_counts_kv()
dk, dv = header.head_dim_k, header.head_dim_v
dk_swa = header.key_length_swa or dk
dv_swa = header.value_length_swa or dv
# Priority ladder, highest first: (1) the file's own per-layer pattern, array or scalar-period
# form — architecture-agnostic and exact; (2) a known-architecture fraction, for older files
# that declare `sliding_window` but no per-layer pattern; (3) no signal at all -> every layer
# priced as full attention (overestimate; safe).
pattern = header.sliding_window_pattern
if pattern is None and header.sliding_window_pattern_period > 0:
pattern = _expand_swa_period(header.sliding_window_pattern_period, header.n_layer,
header.architecture in _SWA_DENSE_FIRST_ARCHS)
has_pattern = (pattern is not None and header.sliding_window > 0
and len(pattern) == len(kv_heads))
swa_fraction = _SWA_LAYER_FRACTION.get(header.architecture, 0.0)
has_fraction = not has_pattern and header.sliding_window > 0 and swa_fraction > 0
n_attn_total = sum(1 for h in kv_heads if h > 0)
n_swa = round(n_attn_total * swa_fraction) if has_fraction else 0
layers: list[tuple[LayerKind, int]] = []
n_attn_seen = 0
for i, heads in enumerate(kv_heads):
if heads == 0:
layers.append((LayerKind.RECURRENT, 0))
continue
if has_pattern:
is_swa = bool(pattern[i])
else:
# Distribute the SWA share across the first n_swa attention layers; only the
# full/SWA split matters to the totals, not which indexes.
is_swa = n_attn_seen < n_swa
layer_dk, layer_dv = (dk_swa, dv_swa) if is_swa else (dk, dv)
per_token = round(heads * (layer_dk + layer_dv) * _F16_BYTES_PER_ELEM)
layers.append((LayerKind.SWA if is_swa else LayerKind.FULL, per_token))
n_attn_seen += 1
return ModelProfile(
name=header.path, weights_bytes=header.tensor_bytes - header.lazy_bytes,
embd_table_bytes=header.embd_table_bytes,
n_ctx_train=header.n_ctx_train, layers=layers, swa_window=header.sliding_window,
moe=header.expert_count > 0, architecture=header.architecture, n_vocab=header.n_vocab,
ffn_block_bytes=dict(header.ffn_block_bytes), lazy_bytes=header.lazy_bytes)
def kv_dtype_factor(flash_attention: bool) -> float:
"""q8_0 with FA (every backend we ship); f16 on exotic non-FA fallbacks — the 64K guarantee
stands either way, the physics check just prices the doubled KV."""
return (_Q8_BYTES_PER_ELEM / _F16_BYTES_PER_ELEM) if flash_attention else 1.0
def ctx_bytes(profile: ModelProfile, window: int, *, flash_attention: bool = True) -> int:
"""Memory that grows with the window: full layers linear in T, SWA layers capped at the sliding
window, recurrent layers constant, KV scaled by profile.kv_scale (MTP draft context), plus the
posture's window-scaled compute buffers."""
factor = kv_dtype_factor(flash_attention)
total = 0.0
for kind, per_token_f16 in profile.layers:
if kind == LayerKind.RECURRENT:
total += _RECURRENT_STATE_PER_LAYER
elif kind == LayerKind.SWA:
total += per_token_f16 * factor * min(window, profile.swa_window)
else:
total += per_token_f16 * factor * window
return int(total * profile.kv_scale) + profile.window_compute_per_token * window
@dataclass
class PhysicsRefusal:
"""The only true refusal: weights + floor-KV + state exceed VRAM + RAM. The remedy is a
smaller quant, never a smaller window."""
needed_bytes: int
available_bytes: int
message: str
def footprint_bytes(profile: ModelProfile, window: int, *, flash_attention: bool = True,
overhead_bytes: int = 0) -> int:
"""Complete estimated footprint; the hardware budget already excludes its reserve."""
return (profile.weights_bytes + ctx_bytes(profile, window, flash_attention=flash_attention)
+ max(0, overhead_bytes))
def physics_check(profile: ModelProfile, budget: HardwareBudget,
floor: int, *, flash_attention: bool = True,
overhead_bytes: int = 0) -> PhysicsRefusal | None:
needed = footprint_bytes(profile, min(floor, profile.n_ctx_train or floor),
flash_attention=flash_attention, overhead_bytes=overhead_bytes)
available = budget.usable_vram_bytes + budget.ram_available_bytes
if needed <= available:
return None
gib = 1 << 30
return PhysicsRefusal(
needed_bytes=needed, available_bytes=available,
message=(f"{profile.name}: needs ~{needed / gib:.1f} GiB at the "
f"{floor // 1024}K floor but only ~{available / gib:.1f} GiB "
"of VRAM+RAM are available — try a smaller model or a supported smaller quant"))