Price weights, context, runtime, projector and batch overhead consistently across catalog admission, initial launch, growth and restored windows. Keep MTP and the larger window when lean batches avoid unnecessary spill. Admit optional external drafts only when their complete footprint fits. Use preset-only model discovery so refused files cannot autoload, and preserve refusal/spill decisions atomically for desktop status read-back. Add regression coverage for complete-footprint boundaries, MTP restarts, growth admission, draft budgets and placement status transitions. Builds on the overhead-accounting contribution in #102993 and the restored-window MTP contribution in #106897. Does not adopt the 40% host-RAM reserve or resolve the remaining requests in #102865/#106895. Co-authored-by: infinitycrew39 <infinitycrew39@gmail.com> Co-authored-by: KoNit-K <124019182+KoNit-K@users.noreply.github.com>
155 lines
6.3 KiB
Python
155 lines
6.3 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
|
|
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}
|
|
|
|
# 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
|
|
|
|
|
|
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_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
|
|
|
|
@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
|
|
|
|
|
|
def profile_from_gguf(header: GGUFHeader) -> ModelProfile:
|
|
kv_heads = header.head_counts_kv()
|
|
dk, dv = header.head_dim_k, header.head_dim_v
|
|
swa_fraction = _SWA_LAYER_FRACTION.get(header.architecture, 0.0)
|
|
has_swa = header.sliding_window > 0 and swa_fraction > 0
|
|
|
|
layers: list[tuple[LayerKind, int]] = []
|
|
n_attn_seen = 0
|
|
n_attn_total = sum(1 for h in kv_heads if h > 0)
|
|
n_swa = round(n_attn_total * swa_fraction) if has_swa else 0
|
|
for heads in kv_heads:
|
|
if heads == 0:
|
|
layers.append((LayerKind.RECURRENT, 0))
|
|
continue
|
|
per_token = round(heads * (dk + dv) * _F16_BYTES_PER_ELEM)
|
|
# Distribute the SWA share across the first n_swa attention layers; only the full/SWA
|
|
# SPLIT matters to the totals, not which indexes.
|
|
kind = LayerKind.SWA if n_attn_seen < n_swa else LayerKind.FULL
|
|
layers.append((kind, per_token))
|
|
n_attn_seen += 1
|
|
|
|
return ModelProfile(
|
|
name=header.path, weights_bytes=header.tensor_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)
|
|
|
|
|
|
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:
|
|
"""Context memory for one window: full layers linear in T, SWA layers capped at the sliding
|
|
window, recurrent layers constant. Scaled by profile.kv_scale (MTP draft context)."""
|
|
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)
|
|
|
|
|
|
@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"))
|