3v324v23 Claude commited on
Commit
eb6e3b5
·
1 Parent(s): 774a364

VRAM Calculator v3: YaRN coherence ladder, real spec enum, live UI + graphs

Browse files

Pre-flight VRAM predictor + llama-server command-builder. See PLAN.md for
the full design spine from the grilling session.

Core logic:
- yarn.py: add yarn_coherence_warnings + extension_ratio (monotonic ladder:
<=2x fine, >2-8x tail degradation, >=8x incoherent/not-recommended), wired
into estimate() warnings.
- report.py: command_preview emits --fit on --fit-target <margin MiB>
(complement path; margin from smallest GPU's VRAM share).
- draft.py: rewrite spec surface to the real llama.cpp --spec-type enum
(draft-simple/eagle3/mtp/dflash + ngram-*). Split weightless (n-gram,
zero VRAM, no -md) vs weighted (draft, weights+KV, full --spec-draft-*
namespace). Fixes invalid --spec-type draft shipped previously.
- quant.py: nvfp4 documented as commented placeholder (VERIFY before ship);
not selectable as a floor until a verified bpw is sourced.

Tests: 67 passing (real-enum spec, YaRN coherence ladder, --fit/--fit-target,
weightless/weighted branches).

UI (app.py): two-phase (fetch once -> live estimate() recompute on every
change) + two matplotlib graphs (VRAM-vs-n_ctx by quant; VRAM-vs-quant with
quality/bpw color) + "assumes your build supports the chosen quant" caveat.

Co-Authored-By: Claude <noreply@anthropic.com>

Files changed (9) hide show
  1. .gitignore +5 -1
  2. PLAN.md +141 -0
  3. app.py +254 -43
  4. tests/test_vramcalc.py +190 -6
  5. vramcalc/__init__.py +10 -1
  6. vramcalc/draft.py +50 -2
  7. vramcalc/quant.py +5 -0
  8. vramcalc/report.py +42 -8
  9. vramcalc/yarn.py +55 -1
.gitignore CHANGED
@@ -18,4 +18,8 @@ venv/
18
  # OS / editor
19
  .DS_Store
20
  .idea/
21
- .vscode/
 
 
 
 
 
18
  # OS / editor
19
  .DS_Store
20
  .idea/
21
+ .vscode/
22
+
23
+ # Local Claude Code tooling
24
+ .claude/
25
+ skills-lock.json
PLAN.md ADDED
@@ -0,0 +1,141 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # VRAM Calculator — v1 Plan
2
+
3
+ Pre-flight VRAM predictor + `llama-server` command-builder. Single-GPU and
4
+ mixed-GPU. Hero use case: "I want 1M context on a model trained for 262k —
5
+ give me the command and tell me it'll still be coherent."
6
+
7
+ This document is the spine agreed in the /grilling session. The grilling is
8
+ closed; everything below is decided.
9
+
10
+ ## Objective
11
+
12
+ **Maximize quality at a VRAM ceiling.** Quality proxy = highest-bpw quant
13
+ that fits. Floor = the 4-bit class (q4_0 / rocmfp4 / nvfp4, ~4.0+ bpw).
14
+ Sub-4-bit quants (2-bit/3-bit family) are offered only on explicit user
15
+ override and flagged "you asked for it." Backend-agnostic.
16
+
17
+ ## The fit loop (core)
18
+
19
+ ```
20
+ target = user_target_n_ctx
21
+ yarn = auto_configure_yarn(training_ctx, target) # rope_freq_scale
22
+ fit = max_context(arch, floor_quant, gpus) # hard VRAM ceiling
23
+ result = min(target, fit)
24
+ if fit < target: present "best you can do = fit, because <reason>"
25
+ if extension_ratio crosses ladder: warn (never hard-block)
26
+ ```
27
+
28
+ Says "no" cleanly when impossible at floor + hardware, and offers the
29
+ floor-quant max_context as the honest fallback.
30
+
31
+ ## YaRN coherence ladder (warnings only, never a hard block)
32
+
33
+ Monotonic — more extension always means same-or-more concern, never goes quiet:
34
+
35
+ - **≤ 2× extension** — fine, no warning.
36
+ - **> 2× up to 8×** — "expect some quality degradation at the tail."
37
+ - **≥ 8× extension** — "likely incoherent at long range; not recommended."
38
+
39
+ Source: YaRN paper behavior + community experience, not measured. Extension
40
+ ratio = target_n_ctx / training_ctx (1.0 when target ≤ training or training
41
+ unknown). Implemented as `yarn_coherence_warnings(training_ctx, target_ctx)`
42
+ in `yarn.py`, surfaced in `estimate()` warnings and the UI.
43
+
44
+ ## Margin = the honesty knob
45
+
46
+ `safety_margin_pct` is the user-facing "how scared am I" knob. **Complement
47
+ path:** the predictor emits *fully explicit* flags (`-c`, `-ngl`, `-sm`,
48
+ `-ts`, `-mg`, `-ctk`, `-ctv`, `--flash-attn`, YaRN flags) **plus**
49
+ `--fit on --fit-target <margin MiB>`. Because everything is already explicit,
50
+ runtime `--fit` is a no-op safety net — the user gets exactly what the
51
+ predictor said. Our number *is* the guarantee, with the margin as the dial.
52
+ Never use the word "guarantee" in UI copy; the margin is the honest version.
53
+
54
+ ## Speculative decoding — two branches (this is a bugfix)
55
+
56
+ The shipped `--spec-type draft` is **invalid**. The real enum (from the
57
+ llama.cpp server README) is:
58
+
59
+ ```
60
+ none, draft-simple, draft-eagle3, draft-mtp, draft-dflash,
61
+ ngram-simple, ngram-map-k, ngram-map-k4v, ngram-mod, ngram-cache
62
+ ```
63
+
64
+ Rewrite the spec surface to this enum, then split the add-on flow:
65
+
66
+ - **Weightless** (`ngram-*`): ~zero extra VRAM. Auto-offer when headroom
67
+ exists; no "acknowledge the tradeoff" checkbox (there's no VRAM tradeoff).
68
+ - **Weighted** (`draft-simple` / `draft-eagle3` / `draft-mtp` /
69
+ `draft-dflash`): a draft model with weights + its own KV. Headroom-gated +
70
+ an acknowledge checkbox. Draft rides the main GPU. Emit the full
71
+ `--spec-draft-*` namespace (`-md`, `--spec-draft-ngl`, `--spec-draft-device`,
72
+ `--spec-draft-n-max`, `--spec-draft-p-min`, `--spec-draft-p-split`), not
73
+ the old `-md`-only stub.
74
+
75
+ `draft-mtp` (a spec *type*) is distinct from `--mtp N` (MTP head count on
76
+ the target). Keep both but emit the right flag for each.
77
+
78
+ ## Backend scope
79
+
80
+ **Dropped.** Assume it runs. One displayed caveat: "fits in VRAM assuming
81
+ your build supports the chosen quant." Keep the existing stock-build
82
+ ROCmFP4/ROCmFPX incompatibility warning (that's not a backend issue — no
83
+ stock build can read those tensor types at all).
84
+
85
+ ## Quarantined unverified data
86
+
87
+ Do **not** block v1 on these:
88
+
89
+ - **nvfp4** (llama.cpp PR #22196): not yet in `QUANT_BPW`. Add a provisional
90
+ row with a `# VERIFY before ship` comment once we have a verified
91
+ bits-per-weight; until then it's absent from the table and can't be
92
+ selected as a floor.
93
+ - **dSpark** (#25167 / #25173 / DeepSeek-V4-Flash-DSpark): folds into the
94
+ existing draft-model VRAM path and emits `--spec-type draft-mtp`
95
+ provisionally. For a *predictor*, dSpark's VRAM cost is just
96
+ draft-weights + draft-KV, same as any MTP draft; the spec-type label only
97
+ affects the command string.
98
+ - **Vulkan quant matrix:** dropped (consequence of dropping backends).
99
+
100
+ ## UI — two phases, two graphs
101
+
102
+ 1. **Fetch phase:** repo ID + HF token → `parse_hf_range` once → cache
103
+ `ModelArch`. Network happens here, once, never again.
104
+ 2. **Live phase:** everything is `estimate()` recompute on the cached arch
105
+ (microseconds). Context slider, quant selector, KV-dtype, GPU sizes,
106
+ margin. The recommended command updates live.
107
+ 3. **Graph 1:** VRAM vs `n_ctx`, one curve per quant — the context ceiling,
108
+ with the slider sitting on it.
109
+ 4. **Graph 2:** VRAM vs quant at fixed `n_ctx`, **with quality as
110
+ color/rank** — the cost+quality frontier (where "maximize quality at the
111
+ ceiling" becomes visible).
112
+
113
+ ## Implementation order
114
+
115
+ Done:
116
+
117
+ 1. ✅ **`yarn.py`** — `yarn_coherence_warnings(training_ctx, target_ctx)` +
118
+ `extension_ratio`; wired into `yarn_warnings()` (and thus `estimate()`).
119
+ 2. ✅ **`report.py`** — emits `--fit on --fit-target <margin MiB>` via
120
+ `_fit_target_mib()` (margin from smallest GPU's VRAM share).
121
+ 3. ✅ **`draft.py` + `report.py`** — spec surface rewritten to the real
122
+ llama.cpp enum (`draft-simple`, `draft-eagle3`, `draft-mtp`,
123
+ `draft-dflash`, `ngram-*`); weightless (ngram, zero VRAM) vs weighted
124
+ (draft, weights+KV) split; `command_preview` emits `-md` + the full
125
+ `--spec-draft-*` namespace for weighted, only `--spec-type` for
126
+ weightless.
127
+ 4. ✅ **`quant.py`** — `nvfp4` documented as a commented placeholder with a
128
+ VERIFY note; not selectable as a floor until a verified bpw is sourced.
129
+ 5. ✅ **Tests** — 67 passing (real-enum spec tests, YaRN coherence-ladder
130
+ tests, `--fit`/`--fit-target` tests, weightless/weighted branch tests).
131
+ 6. ✅ **`app.py`** — two-phase UI (fetch once → live `estimate()` recompute
132
+ on every change), two graphs (VRAM-vs-n_ctx by quant; VRAM-vs-quant with
133
+ quality color), the "assumes your build supports the chosen quant"
134
+ caveat, and the sub-floor "you asked for it" warning. Syntax-checked;
135
+ graph math verified against presets (gradio not installable locally).
136
+
137
+ ## Out of scope for v1
138
+
139
+ Per-backend quant compatibility matrices, MLX, Vulkan-specific quants,
140
+ runtime auto-tuning (that's llama.cpp's `--fit`), measured perplexity/WikiText
141
+ quality tables, throughput/token-per-second modeling.
app.py CHANGED
@@ -1,15 +1,25 @@
1
  """llama.cpp VRAM Calculator — Hugging Face Space.
2
 
3
- A Gradio app that estimates VRAM usage for a Hugging Face GGUF model given
4
- quantization type, context length, KV cache options, YaRN context-extension
5
- parameters, MTP heads, and a multi-GPU budget. Architecture is auto-fetched
6
- from the GGUF header (range-read, no full download) with a manual-override
7
- tab and presets for offline use.
 
 
 
 
 
 
 
8
  """
9
 
10
  from __future__ import annotations
11
 
12
  import gradio as gr
 
 
 
13
  from huggingface_hub import HfApi
14
  import spaces # noqa: F401 — present so ZeroGPU detects a GPU-aware Space
15
 
@@ -29,7 +39,10 @@ from vramcalc import (
29
  min_gpu_setup,
30
  mmproj_bytes_from_tensors,
31
  auto_configure_yarn,
 
 
32
  )
 
33
  from vramcalc.presets import PRESETS, PRESET_NAMES
34
 
35
 
@@ -37,9 +50,18 @@ QUANT_CHOICES = list(QUANT_BPW.keys())
37
  CACHE_DTYPES = ["f16", "bf16", "f32", "q8_0", "q8_1", "q4_0", "q4_1", "q5_0", "q5_1"]
38
  COMPUTE_DTYPES = ["f16", "bf16", "f32"]
39
  SPLIT_MODES = ["layer", "tensor", "none"]
40
- SPEC_TYPES = ["none", "draft", "draft-eagle3", "draft-dflash"]
41
  MTP_CACHE_DTYPES = ["(same as target)", "f16", "bf16", "f32", "q8_0"]
42
 
 
 
 
 
 
 
 
 
 
43
 
44
  def list_gguf_files(repo_id: str, hf_token: str):
45
  """List *.gguf files in a repo and auto-detect their quant names.
@@ -312,10 +334,15 @@ def _parse_tensor_split(text: str) -> list[float] | None:
312
  return out or None
313
 
314
 
315
- @spaces.GPU
316
- def compute(
317
- arch_fields,
318
- quant, n_ctx, cache_dtype, flash_attn, compute_dtype,
 
 
 
 
 
319
  n_batch, n_prompt,
320
  rope_freq_scale, yarn_ext_factor, yarn_attn_factor,
321
  yarn_beta_fast, yarn_beta_slow,
@@ -324,15 +351,14 @@ def compute(
324
  spec_type, draft_quant, draft_params, draft_n_layer, draft_n_max,
325
  draft_p_min, draft_p_split,
326
  mmproj_enabled, mmproj_file, mmproj_offload, mmproj_bytes_text,
327
- ):
328
  arch = _fields_to_arch(arch_fields)
329
- # GPU text may carry a 'u' suffix for unified memory; strip it for vram_gb
330
  gpus = _parse_gpu_list(gpu_vram_text.replace("u", "").replace("U", ""))
331
  unified = _parse_unified_flags(gpu_vram_text)
332
  tensor_split = _parse_tensor_split(tensor_split_text)
333
 
334
  draft = None
335
- if spec_type and spec_type != "none" and draft_params:
336
  draft = DraftInputs(
337
  spec_type=spec_type,
338
  quant=draft_quant,
@@ -377,11 +403,135 @@ def compute(
377
  mmproj=mmproj,
378
  safety_margin_pct=float(safety_margin),
379
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
380
  if arch.n_layer <= 0 or arch.n_embd <= 0 or arch.params <= 0:
381
  return (
382
- "⚠️ Model architecture is incomplete. Fill n_layer, n_embd, and "
383
- "params (or fetch from a GGUF / load a preset).",
384
- "", "",
385
  )
386
  bd = estimate(arch, inp)
387
 
@@ -406,12 +556,19 @@ def compute(
406
  f"| {a} | {b} |" for a, b in rows
407
  )
408
 
409
- # warnings
410
- warn_md = ""
411
- if bd.warnings:
412
- warn_md = "\n\n**⚠️ Notes:**\n" + "\n".join(
413
- f"- {w}" for w in bd.warnings
 
414
  )
 
 
 
 
 
 
415
  eff_md = (
416
  f"\n\nEffective context (training_ctx / rope_freq_scale): "
417
  f"**{bd.effective_context}**"
@@ -449,10 +606,21 @@ def compute(
449
  f"**{arch.name or arch.architecture or 'Model'}** @ {quant}, "
450
  f"ctx {inp.n_ctx} ({cache_dtype} KV"
451
  + (", FA" if inp.flash_attn else ", no FA")
452
- + f"), {len(gpus)} GPU(s) → "
453
  f"**{format_bytes(bd.total_bytes)}** total"
454
  )
455
- return summary + "\n\n" + breakdown_md + eff_md + warn_md + "\n\n" + gpu_md, cmd, cmd
 
 
 
 
 
 
 
 
 
 
 
456
 
457
 
458
  def build_ui():
@@ -556,16 +724,25 @@ def build_ui():
556
  )
557
  yarn_auto_status = gr.Markdown("")
558
 
559
- with gr.Accordion("Speculative decoding (draft model)", open=False):
 
 
 
 
 
 
 
560
  spec_type = gr.Dropdown(
561
  label="Spec type", choices=SPEC_TYPES, value="none"
562
  )
563
  with gr.Row():
564
  draft_quant = gr.Dropdown(
565
- label="Draft quant", choices=QUANT_CHOICES, value="Q4_K_M"
 
566
  )
567
  draft_params = gr.Number(
568
- label="Draft params", value=0, precision=0
 
569
  )
570
  draft_n_layer = gr.Number(
571
  label="Draft n_layer (1 for EAGLE-3)",
@@ -611,13 +788,20 @@ def build_ui():
611
  safety_margin = gr.Number(label="Safety margin %", value=5.0)
612
 
613
  compute_btn = gr.Button("Compute VRAM", variant="primary")
 
 
 
 
 
614
 
615
  gr.Markdown("### Results")
616
  result_md = gr.Markdown("")
617
- with gr.Accordion("llama.cpp launch command preview", open=False):
618
- cmd_md = gr.Markdown("")
 
 
619
  cmd_text = gr.Textbox(
620
- label="Command (copyable)", lines=8, interactive=False
621
  )
622
 
623
  with gr.Tab("Auto-fit"):
@@ -689,21 +873,48 @@ def build_ui():
689
  outputs=[mmproj_bytes_box, fetch_status],
690
  )
691
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
692
  compute_btn.click(
693
- fn=compute,
694
- inputs=[
695
- arch_state, quant, n_ctx, cache_dtype, flash_attn, compute_dtype,
696
- n_batch, n_prompt,
697
- rope_freq_scale, yarn_ext_factor, yarn_attn_factor,
698
- yarn_beta_fast, yarn_beta_slow,
699
- gpu_vram_text, split_mode, main_gpu, tensor_split_text,
700
- safety_margin,
701
- mtp_cache_dtype,
702
- spec_type, draft_quant, draft_params, draft_n_layer,
703
- draft_n_max, draft_p_min, draft_p_split,
704
- mmproj_enabled, mmproj_file, mmproj_offload, mmproj_bytes_box,
705
- ],
706
- outputs=[result_md, cmd_md, cmd_text],
 
 
 
 
 
 
 
 
 
 
 
 
707
  )
708
 
709
  # Auto-fit wiring
 
1
  """llama.cpp VRAM Calculator — Hugging Face Space.
2
 
3
+ A pre-flight VRAM predictor + llama-server command-builder. Hero use case:
4
+ "I want N context on a model trained for less — give me the command and
5
+ warn me about coherence." Two-phase UI:
6
+
7
+ 1. Fetch phase (network, once): repo id + GGUF file -> parse_hf_range ->
8
+ cached ModelArch. Network happens here and never again.
9
+ 2. Live phase (microseconds): every input change re-runs estimate() on the
10
+ cached arch; the command, breakdown, and graphs update live.
11
+
12
+ Two graphs: VRAM-vs-n_ctx (one curve per quant, the context ceiling) and
13
+ VRAM-vs-quant at fixed n_ctx (the cost+quality frontier, quality as color).
14
+ See PLAN.md for the full design spine.
15
  """
16
 
17
  from __future__ import annotations
18
 
19
  import gradio as gr
20
+ import matplotlib
21
+ matplotlib.use("Agg") # headless: render to PNG, no display needed
22
+ import matplotlib.pyplot as plt
23
  from huggingface_hub import HfApi
24
  import spaces # noqa: F401 — present so ZeroGPU detects a GPU-aware Space
25
 
 
39
  min_gpu_setup,
40
  mmproj_bytes_from_tensors,
41
  auto_configure_yarn,
42
+ yarn_coherence_warnings,
43
+ extension_ratio,
44
  )
45
+ from vramcalc.draft import SPEC_TYPES as REAL_SPEC_TYPES
46
  from vramcalc.presets import PRESETS, PRESET_NAMES
47
 
48
 
 
50
  CACHE_DTYPES = ["f16", "bf16", "f32", "q8_0", "q8_1", "q4_0", "q4_1", "q5_0", "q5_1"]
51
  COMPUTE_DTYPES = ["f16", "bf16", "f32"]
52
  SPLIT_MODES = ["layer", "tensor", "none"]
53
+ SPEC_TYPES = list(REAL_SPEC_TYPES) # the real llama.cpp --spec-type enum
54
  MTP_CACHE_DTYPES = ["(same as target)", "f16", "bf16", "f32", "q8_0"]
55
 
56
+ # The 4-bit-class floor quants (q4_0 / ROCmFP4 / ~nvfp4). Sub-4-bit quants
57
+ # (2-bit/3-bit family) are offered only on explicit override and flagged
58
+ # "you asked for it." See PLAN.md "Objective".
59
+ FLOOR_QUANTS = {"q4_0", "ROCmFP4", "ROCmFPX"} # ~4.0+ bpw
60
+
61
+ # n_ctx sweep for the VRAM-vs-context graph (log-ish, capped at 1M).
62
+ GRAPH_CTX_GRID = [1024, 2048, 4096, 8192, 16384, 32768, 65536, 131072, 262144,
63
+ 524288, 1048576]
64
+
65
 
66
  def list_gguf_files(repo_id: str, hf_token: str):
67
  """List *.gguf files in a repo and auto-detect their quant names.
 
334
  return out or None
335
 
336
 
337
+ # --- Live recompute + graphs -------------------------------------------------
338
+ #
339
+ # The fetch phase caches a ModelArch; everything below is pure estimate()
340
+ # math on that cached arch, so it runs in microseconds and can fire on every
341
+ # input change (live sliders). No network in the live phase. See PLAN.md.
342
+
343
+
344
+ def _build_inputs(
345
+ arch_fields, quant, n_ctx, cache_dtype, flash_attn, compute_dtype,
346
  n_batch, n_prompt,
347
  rope_freq_scale, yarn_ext_factor, yarn_attn_factor,
348
  yarn_beta_fast, yarn_beta_slow,
 
351
  spec_type, draft_quant, draft_params, draft_n_layer, draft_n_max,
352
  draft_p_min, draft_p_split,
353
  mmproj_enabled, mmproj_file, mmproj_offload, mmproj_bytes_text,
354
+ ) -> tuple[ModelArch, Inputs]:
355
  arch = _fields_to_arch(arch_fields)
 
356
  gpus = _parse_gpu_list(gpu_vram_text.replace("u", "").replace("U", ""))
357
  unified = _parse_unified_flags(gpu_vram_text)
358
  tensor_split = _parse_tensor_split(tensor_split_text)
359
 
360
  draft = None
361
+ if spec_type and spec_type != "none":
362
  draft = DraftInputs(
363
  spec_type=spec_type,
364
  quant=draft_quant,
 
403
  mmproj=mmproj,
404
  safety_margin_pct=float(safety_margin),
405
  )
406
+ return arch, inp
407
+
408
+
409
+ def graph_vram_vs_ctx(arch: ModelArch, inp: Inputs) -> plt.Figure | None:
410
+ """Graph 1: VRAM vs n_ctx, one curve per quant.
411
+
412
+ Shows the context ceiling each quant reaches on the configured GPUs. The
413
+ user's current n_ctx + the budget total are overlaid so the slider sits
414
+ on the curve. Returns None when the arch is incomplete.
415
+ """
416
+ if arch.n_layer <= 0 or arch.n_embd <= 0 or arch.params <= 0:
417
+ return None
418
+ budget_gb = sum(inp.gpu_vram_gb)
419
+ # sweep the grid up to max(training_ctx*4, current n_ctx, 1M)
420
+ cap = max(arch.training_ctx * 4, inp.n_ctx, 1 << 20)
421
+ grid = [c for c in GRAPH_CTX_GRID if c <= cap] or [cap]
422
+
423
+ # order quants low->high bpw so the legend reads bottom-to-top
424
+ quants = sorted(QUANT_CHOICES, key=lambda q: QUANT_BPW[q])
425
+ fig, ax = plt.subplots(figsize=(7, 4.2))
426
+ palette = plt.cm.viridis
427
+ n_q = max(1, len(quants))
428
+ for i, q in enumerate(quants):
429
+ ys = []
430
+ for c in grid:
431
+ from dataclasses import replace
432
+ bd = estimate(arch, replace(inp, quant=q, n_ctx=c))
433
+ ys.append(bd.total_bytes / (1 << 30))
434
+ color = palette(i / max(1, n_q - 1))
435
+ ax.plot(grid, ys, marker="o", markersize=3, linewidth=1.6,
436
+ label=q, color=color)
437
+
438
+ # current config marker + budget line
439
+ cur = estimate(arch, inp)
440
+ ax.axhline(budget_gb, color="crimson", linestyle="--", linewidth=1.2,
441
+ label=f"budget {budget_gb:.0f} GB")
442
+ ax.scatter([inp.n_ctx], [cur.total_bytes / (1 << 30)],
443
+ color="crimson", zorder=5, s=55,
444
+ label=f"current ({inp.quant})")
445
+ ax.set_xscale("log")
446
+ ax.set_xlabel("context length (n_ctx)")
447
+ ax.set_ylabel("total VRAM (GiB)")
448
+ ax.set_title("VRAM vs context, by quant")
449
+ ax.grid(True, which="both", alpha=0.25)
450
+ ax.legend(fontsize=7, ncol=2, loc="upper left")
451
+ fig.tight_layout()
452
+ return fig
453
+
454
+
455
+ def graph_vram_vs_quant(arch: ModelArch, inp: Inputs) -> plt.Figure | None:
456
+ """Graph 2: VRAM vs quant at fixed n_ctx, colored by quality (bpw).
457
+
458
+ The cost+quality frontier: each bar's height is VRAM, its color is the
459
+ quant's bpw (proxy for quality). The budget is a horizontal line; bars
460
+ below it fit. Returns None when the arch is incomplete.
461
+ """
462
+ if arch.n_layer <= 0 or arch.n_embd <= 0 or arch.params <= 0:
463
+ return None
464
+ budget_gb = sum(inp.gpu_vram_gb)
465
+ from dataclasses import replace
466
+ quants = sorted(QUANT_CHOICES, key=lambda q: QUANT_BPW[q])
467
+ ys, bpws, labels = [], [], []
468
+ for q in quants:
469
+ bd = estimate(arch, replace(inp, quant=q))
470
+ ys.append(bd.total_bytes / (1 << 30))
471
+ bpws.append(QUANT_BPW[q])
472
+ labels.append(q)
473
+
474
+ fig, ax = plt.subplots(figsize=(7, 4.2))
475
+ cmap = plt.cm.plasma
476
+ norm = plt.Normalize(vmin=min(bpws), vmax=max(bpws))
477
+ colors = [cmap(norm(b)) for b in bpws]
478
+ bars = ax.bar(range(len(quants)), ys, color=colors, edgecolor="black",
479
+ linewidth=0.4)
480
+ ax.axhline(budget_gb, color="crimson", linestyle="--", linewidth=1.2,
481
+ label=f"budget {budget_gb:.0f} GB")
482
+ # highlight the currently-selected quant
483
+ if inp.quant in quants:
484
+ idx = quants.index(inp.quant)
485
+ bars[idx].set_edgecolor("crimson")
486
+ bars[idx].set_linewidth(2.0)
487
+ ax.set_xticks(range(len(quants)))
488
+ ax.set_xticklabels(labels, rotation=55, ha="right", fontsize=7)
489
+ ax.set_ylabel("total VRAM (GiB)")
490
+ ax.set_title(f"VRAM vs quant @ ctx {inp.n_ctx} (color = quality/bpw)")
491
+ ax.grid(True, axis="y", alpha=0.25)
492
+ ax.legend(fontsize=8, loc="upper left")
493
+ # colorbar for quality
494
+ sm = plt.cm.ScalarMappable(cmap=cmap, norm=norm)
495
+ sm.set_array([])
496
+ fig.colorbar(sm, ax=ax, label="bits per weight (quality)")
497
+ fig.tight_layout()
498
+ return fig
499
+
500
+
501
+ def live_compute(
502
+ arch_fields,
503
+ quant, n_ctx, cache_dtype, flash_attn, compute_dtype,
504
+ n_batch, n_prompt,
505
+ rope_freq_scale, yarn_ext_factor, yarn_attn_factor,
506
+ yarn_beta_fast, yarn_beta_slow,
507
+ gpu_vram_text, split_mode, main_gpu, tensor_split_text, safety_margin,
508
+ mtp_cache_dtype,
509
+ spec_type, draft_quant, draft_params, draft_n_layer, draft_n_max,
510
+ draft_p_min, draft_p_split,
511
+ mmproj_enabled, mmproj_file, mmproj_offload, mmproj_bytes_text,
512
+ ):
513
+ """Live recompute: estimate() on the cached arch + graphs + command.
514
+
515
+ Returns (summary_markdown, command_text, graph1, graph2). No network —
516
+ the arch was fetched once and stored in arch_state. Fires on every input
517
+ change so sliders are live.
518
+ """
519
+ arch, inp = _build_inputs(
520
+ arch_fields, quant, n_ctx, cache_dtype, flash_attn, compute_dtype,
521
+ n_batch, n_prompt,
522
+ rope_freq_scale, yarn_ext_factor, yarn_attn_factor,
523
+ yarn_beta_fast, yarn_beta_slow,
524
+ gpu_vram_text, split_mode, main_gpu, tensor_split_text, safety_margin,
525
+ mtp_cache_dtype,
526
+ spec_type, draft_quant, draft_params, draft_n_layer, draft_n_max,
527
+ draft_p_min, draft_p_split,
528
+ mmproj_enabled, mmproj_file, mmproj_offload, mmproj_bytes_text,
529
+ )
530
  if arch.n_layer <= 0 or arch.n_embd <= 0 or arch.params <= 0:
531
  return (
532
+ "⚠️ Fetch a GGUF (or load a preset) to populate the architecture, "
533
+ "then adjust the sliders.",
534
+ "", None, None,
535
  )
536
  bd = estimate(arch, inp)
537
 
 
556
  f"| {a} | {b} |" for a, b in rows
557
  )
558
 
559
+ # warnings (incl. the YaRN coherence ladder) + the build-support caveat
560
+ warns = list(bd.warnings)
561
+ if quant not in FLOOR_QUANTS and QUANT_BPW.get(quant, 99) < 4.0:
562
+ warns.append(
563
+ f"{quant} ({QUANT_BPW[quant]} bpw) is below the 4-bit quality "
564
+ f"floor — you asked for it."
565
  )
566
+ warns.append(
567
+ "Estimate assumes your llama.cpp build supports the chosen quant "
568
+ "and KV dtype. ROCmFP4/ROCmFPX need the ciru-ai/ROCmFPX runner."
569
+ )
570
+ warn_md = "\n\n**⚠️ Notes:**\n" + "\n".join(f"- {w}" for w in warns) if warns else ""
571
+
572
  eff_md = (
573
  f"\n\nEffective context (training_ctx / rope_freq_scale): "
574
  f"**{bd.effective_context}**"
 
606
  f"**{arch.name or arch.architecture or 'Model'}** @ {quant}, "
607
  f"ctx {inp.n_ctx} ({cache_dtype} KV"
608
  + (", FA" if inp.flash_attn else ", no FA")
609
+ + f"), {len(inp.gpu_vram_gb)} GPU(s) → "
610
  f"**{format_bytes(bd.total_bytes)}** total"
611
  )
612
+ md = summary + "\n\n" + breakdown_md + eff_md + warn_md + "\n\n" + gpu_md
613
+
614
+ g1 = graph_vram_vs_ctx(arch, inp)
615
+ g2 = graph_vram_vs_quant(arch, inp)
616
+ return md, cmd, g1, g2
617
+
618
+
619
+ # The live handler is CPU-only estimate math (no GPU needed). `spaces` is
620
+ # imported above so ZeroGPU detects a GPU-aware Space; we don't decorate the
621
+ # hot path with @spaces.GPU because that would queue every slider drag.
622
+ def compute(*args, **kwargs): # noqa: D401 — backward-compat name
623
+ return live_compute(*args, **kwargs)
624
 
625
 
626
  def build_ui():
 
724
  )
725
  yarn_auto_status = gr.Markdown("")
726
 
727
+ with gr.Accordion("Speculative decoding (speed add-on)", open=False):
728
+ gr.Markdown(
729
+ "_Two branches. **Weightless** (`ngram-*`): ~zero extra "
730
+ "VRAM, no draft model — auto-offered when headroom exists. "
731
+ "**Weighted** (`draft-*`): a draft model with its own "
732
+ "weights + KV; only enable when you have headroom and "
733
+ "accept the tradeoff._"
734
+ )
735
  spec_type = gr.Dropdown(
736
  label="Spec type", choices=SPEC_TYPES, value="none"
737
  )
738
  with gr.Row():
739
  draft_quant = gr.Dropdown(
740
+ label="Draft quant (weighted only)",
741
+ choices=QUANT_CHOICES, value="Q4_K_M"
742
  )
743
  draft_params = gr.Number(
744
+ label="Draft params (weighted only)",
745
+ value=0, precision=0
746
  )
747
  draft_n_layer = gr.Number(
748
  label="Draft n_layer (1 for EAGLE-3)",
 
788
  safety_margin = gr.Number(label="Safety margin %", value=5.0)
789
 
790
  compute_btn = gr.Button("Compute VRAM", variant="primary")
791
+ gr.Markdown(
792
+ "_Live: every slider/dropdown above updates the results "
793
+ "and graphs on change. The Compute button is a manual "
794
+ "trigger for the same recompute._"
795
+ )
796
 
797
  gr.Markdown("### Results")
798
  result_md = gr.Markdown("")
799
+ with gr.Row():
800
+ graph_ctx = gr.Plot(label="VRAM vs context (by quant)")
801
+ graph_quant = gr.Plot(label="VRAM vs quant @ ctx (color = quality)")
802
+ with gr.Accordion("llama.cpp launch command preview", open=True):
803
  cmd_text = gr.Textbox(
804
+ label="Command (copyable)", lines=10, interactive=False
805
  )
806
 
807
  with gr.Tab("Auto-fit"):
 
873
  outputs=[mmproj_bytes_box, fetch_status],
874
  )
875
 
876
+ # Live recompute inputs — every input that affects the estimate.
877
+ live_inputs = [
878
+ arch_state, quant, n_ctx, cache_dtype, flash_attn, compute_dtype,
879
+ n_batch, n_prompt,
880
+ rope_freq_scale, yarn_ext_factor, yarn_attn_factor,
881
+ yarn_beta_fast, yarn_beta_slow,
882
+ gpu_vram_text, split_mode, main_gpu, tensor_split_text,
883
+ safety_margin,
884
+ mtp_cache_dtype,
885
+ spec_type, draft_quant, draft_params, draft_n_layer,
886
+ draft_n_max, draft_p_min, draft_p_split,
887
+ mmproj_enabled, mmproj_file, mmproj_offload, mmproj_bytes_box,
888
+ ]
889
+ live_outputs = [result_md, cmd_text, graph_ctx, graph_quant]
890
+
891
  compute_btn.click(
892
+ fn=live_compute,
893
+ inputs=live_inputs,
894
+ outputs=live_outputs,
895
+ )
896
+
897
+ # Live: fire on every input change (estimate() is microseconds on the
898
+ # cached arch — no network). See PLAN.md "Live phase".
899
+ live_comps = [
900
+ quant, n_ctx, cache_dtype, flash_attn, compute_dtype,
901
+ n_batch, n_prompt,
902
+ rope_freq_scale, yarn_ext_factor, yarn_attn_factor,
903
+ yarn_beta_fast, yarn_beta_slow,
904
+ gpu_vram_text, split_mode, main_gpu, tensor_split_text,
905
+ safety_margin,
906
+ mtp_cache_dtype,
907
+ spec_type, draft_quant, draft_params, draft_n_layer,
908
+ draft_n_max, draft_p_min, draft_p_split,
909
+ mmproj_enabled, mmproj_file, mmproj_offload, mmproj_bytes_box,
910
+ ]
911
+ for comp in live_comps:
912
+ comp.change(
913
+ fn=live_compute, inputs=live_inputs, outputs=live_outputs,
914
+ )
915
+ # arch_state changes (fetch/preset) also trigger a recompute.
916
+ arch_state.change(
917
+ fn=live_compute, inputs=live_inputs, outputs=live_outputs,
918
  )
919
 
920
  # Auto-fit wiring
tests/test_vramcalc.py CHANGED
@@ -10,6 +10,8 @@ from vramcalc import (
10
  compute_scratch_bytes,
11
  yarn_effective_context,
12
  yarn_warnings,
 
 
13
  auto_configure_yarn,
14
  gpu_split,
15
  estimate,
@@ -320,7 +322,7 @@ def test_estimate_includes_draft_and_mmproj():
320
  from vramcalc import Inputs, DraftInputs, Mmproj
321
  arch = PRESETS["Llama-3 8B"]
322
  base = estimate(arch, Inputs(quant="Q4_K_M", n_ctx=8192, gpu_vram_gb=[24.0]))
323
- draft = DraftInputs(spec_type="draft", quant="Q4_K_M",
324
  params=1_000_000_000, n_layer=4, cache_dtype="f16")
325
  mm = Mmproj(enabled=True, bytes_=500 * 1e6)
326
  with_extras = estimate(arch, Inputs(quant="Q4_K_M", n_ctx=8192,
@@ -342,8 +344,8 @@ def test_mmproj_bytes_from_tensors():
342
 
343
  def test_draft_bytes_basic():
344
  from vramcalc import DraftInputs, draft_bytes
345
- d = DraftInputs(spec_type="draft", quant="Q4_K_M", params=1_000_000_000,
346
- n_layer=4, cache_dtype="f16")
347
  bd = draft_bytes(draft=d, target_n_ctx=8192, target_n_embd=4096,
348
  target_n_head=32, target_n_head_kv=8)
349
  # weights ~0.6 GB for 1B @ Q4_K_M
@@ -437,14 +439,14 @@ def test_command_preview_none_mode_main_gpu():
437
  def test_command_preview_draft_and_mmproj():
438
  from vramcalc import Inputs, DraftInputs, Mmproj
439
  arch = PRESETS["Llama-3 8B"]
440
- draft = DraftInputs(spec_type="draft", quant="Q4_K_M",
441
  params=1_000_000_000, n_layer=4, cache_dtype="f16")
442
  mm = Mmproj(filename="mmproj-F16.gguf", enabled=True, offload=True)
443
  cmd = command_preview(arch, Inputs(quant="Q4_K_M", n_ctx=8192,
444
  gpu_vram_gb=[24.0],
445
  draft=draft, mmproj=mm))
446
  assert "-md draft-Q4_K_M.gguf" in cmd
447
- assert "--spec-type draft" in cmd
448
  assert "--mmproj mmproj-F16.gguf" in cmd
449
 
450
 
@@ -620,4 +622,186 @@ def test_gguf_tensor_section_unreachable_returns_minus_one():
620
  meta, total = parse_header_with_tensors(buf)
621
  assert total == -1
622
  arch = metadata_to_arch(meta, total)
623
- assert arch.params == 0 # negative fallback is treated as no fallback
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
  compute_scratch_bytes,
11
  yarn_effective_context,
12
  yarn_warnings,
13
+ yarn_coherence_warnings,
14
+ extension_ratio,
15
  auto_configure_yarn,
16
  gpu_split,
17
  estimate,
 
322
  from vramcalc import Inputs, DraftInputs, Mmproj
323
  arch = PRESETS["Llama-3 8B"]
324
  base = estimate(arch, Inputs(quant="Q4_K_M", n_ctx=8192, gpu_vram_gb=[24.0]))
325
+ draft = DraftInputs(spec_type="draft-simple", quant="Q4_K_M",
326
  params=1_000_000_000, n_layer=4, cache_dtype="f16")
327
  mm = Mmproj(enabled=True, bytes_=500 * 1e6)
328
  with_extras = estimate(arch, Inputs(quant="Q4_K_M", n_ctx=8192,
 
344
 
345
  def test_draft_bytes_basic():
346
  from vramcalc import DraftInputs, draft_bytes
347
+ d = DraftInputs(spec_type="draft-simple", quant="Q4_K_M",
348
+ params=1_000_000_000, n_layer=4, cache_dtype="f16")
349
  bd = draft_bytes(draft=d, target_n_ctx=8192, target_n_embd=4096,
350
  target_n_head=32, target_n_head_kv=8)
351
  # weights ~0.6 GB for 1B @ Q4_K_M
 
439
  def test_command_preview_draft_and_mmproj():
440
  from vramcalc import Inputs, DraftInputs, Mmproj
441
  arch = PRESETS["Llama-3 8B"]
442
+ draft = DraftInputs(spec_type="draft-simple", quant="Q4_K_M",
443
  params=1_000_000_000, n_layer=4, cache_dtype="f16")
444
  mm = Mmproj(filename="mmproj-F16.gguf", enabled=True, offload=True)
445
  cmd = command_preview(arch, Inputs(quant="Q4_K_M", n_ctx=8192,
446
  gpu_vram_gb=[24.0],
447
  draft=draft, mmproj=mm))
448
  assert "-md draft-Q4_K_M.gguf" in cmd
449
+ assert "--spec-type draft-simple" in cmd
450
  assert "--mmproj mmproj-F16.gguf" in cmd
451
 
452
 
 
622
  meta, total = parse_header_with_tensors(buf)
623
  assert total == -1
624
  arch = metadata_to_arch(meta, total)
625
+ assert arch.params == 0 # negative fallback is treated as no fallback
626
+
627
+ # --- YaRN coherence ladder (warnings only, never a hard block) -----------------
628
+
629
+
630
+ def test_extension_ratio():
631
+ # no extension -> 1.0
632
+ assert extension_ratio(8192, 8192) == 1.0
633
+ # 4x extension
634
+ assert extension_ratio(8192, 32768) == pytest.approx(4.0)
635
+ # within training context -> 1.0 (no extension counted)
636
+ assert extension_ratio(131072, 8192) == 1.0
637
+ # unknown training ctx -> 1.0 (can't grade)
638
+ assert extension_ratio(0, 32768) == 1.0
639
+
640
+
641
+ def test_yarn_coherence_no_warning_within_2x():
642
+ # 1.5x: fine, no warning
643
+ assert yarn_coherence_warnings(8192, 12288) == []
644
+ # exactly 2x: fine, no warning (boundary)
645
+ assert yarn_coherence_warnings(8192, 16384) == []
646
+
647
+
648
+ def test_yarn_coherence_warns_above_2x():
649
+ # 4x: tail degradation warning
650
+ w = yarn_coherence_warnings(8192, 32768)
651
+ assert len(w) == 1
652
+ assert "degradation" in w[0].lower() or "tail" in w[0].lower()
653
+
654
+
655
+ def test_yarn_coherence_warns_at_8x():
656
+ w = yarn_coherence_warnings(8192, 65536) # exactly 8x
657
+ assert len(w) == 1
658
+ assert "incoherent" in w[0].lower() or "not recommended" in w[0].lower()
659
+
660
+
661
+ def test_yarn_coherence_monotonic_ladder():
662
+ # more extension => same-or-stronger warning severity (never goes quiet)
663
+ w2x = yarn_coherence_warnings(8192, 16384) # 2x (boundary, fine)
664
+ w6x = yarn_coherence_warnings(8192, 49152) # 6x
665
+ w8x = yarn_coherence_warnings(8192, 65536) # 8x
666
+ w16x = yarn_coherence_warnings(8192, 131072) # 16x
667
+ assert w2x == []
668
+ assert len(w6x) == 1 and "degradation" in w6x[0].lower() or "tail" in w6x[0].lower()
669
+ assert len(w8x) == 1 and "incoherent" in w8x[0].lower()
670
+ assert len(w16x) == 1 and "incoherent" in w16x[0].lower()
671
+
672
+
673
+ def test_yarn_coherence_no_warning_when_within_training():
674
+ assert yarn_coherence_warnings(131072, 8192) == []
675
+
676
+
677
+ def test_yarn_coherence_invalid_inputs():
678
+ assert yarn_coherence_warnings(0, 32768) == []
679
+ assert yarn_coherence_warnings(8192, 0) == []
680
+
681
+
682
+ def test_yarn_warnings_includes_coherence():
683
+ # yarn_warnings should fold in coherence warnings at high extension
684
+ w = yarn_warnings(
685
+ training_ctx=262144, target_ctx=1048576, # 4x
686
+ rope_freq_scale=0.25, yarn_ext_factor=-1.0, yarn_attn_factor=1.0,
687
+ )
688
+ assert any("degradation" in x.lower() or "tail" in x.lower() for x in w)
689
+
690
+
691
+ # --- Speculative decoding: real enum + weightless/weighted split ----------------
692
+
693
+
694
+ def test_spec_types_real_enum():
695
+ from vramcalc.draft import SPEC_TYPES, is_weightless, is_weighted
696
+ # invalid legacy value must not be present
697
+ assert "draft" not in SPEC_TYPES
698
+ # real enum members are present
699
+ for t in ("none", "draft-simple", "draft-eagle3", "draft-mtp",
700
+ "draft-dflash", "ngram-simple", "ngram-map-k", "ngram-mod",
701
+ "ngram-cache"):
702
+ assert t in SPEC_TYPES
703
+ # weightless classification
704
+ assert is_weightless("ngram-simple")
705
+ assert not is_weightless("draft-simple")
706
+ assert is_weighted("draft-simple")
707
+ assert not is_weighted("ngram-simple")
708
+
709
+
710
+ def test_draft_weightless_zero_vram():
711
+ from vramcalc import DraftInputs, draft_bytes
712
+ # ngram spec types: enabled with params=0, zero VRAM
713
+ d = DraftInputs(spec_type="ngram-simple", params=0)
714
+ assert d.enabled is True
715
+ bd = draft_bytes(draft=d, target_n_ctx=8192)
716
+ assert bd.enabled is True
717
+ assert bd.total_bytes == 0.0
718
+
719
+
720
+ def test_draft_weighted_requires_params():
721
+ from vramcalc import DraftInputs
722
+ # weighted type with no params -> disabled
723
+ d = DraftInputs(spec_type="draft-simple", params=0)
724
+ assert d.enabled is False
725
+
726
+
727
+ def test_command_preview_ngram_no_md_flag():
728
+ # ngram (weightless) emits --spec-type but no -md draft model
729
+ from vramcalc import Inputs, DraftInputs
730
+ arch = PRESETS["Llama-3 8B"]
731
+ d = DraftInputs(spec_type="ngram-simple", params=0, n_max=4)
732
+ cmd = command_preview(arch, Inputs(quant="Q4_K_M", n_ctx=8192,
733
+ gpu_vram_gb=[24.0], draft=d))
734
+ assert "--spec-type ngram-simple" in cmd
735
+ assert "-md" not in cmd
736
+ assert "--spec-draft-ngl" not in cmd
737
+
738
+
739
+ def test_command_preview_weighted_emits_full_spec_draft_namespace():
740
+ from vramcalc import Inputs, DraftInputs
741
+ arch = PRESETS["Llama-3 8B"]
742
+ d = DraftInputs(spec_type="draft-eagle3", quant="Q4_K_M",
743
+ params=1_000_000_000, n_layer=1, n_max=5, p_min=0.5,
744
+ p_split=0.1)
745
+ cmd = command_preview(arch, Inputs(quant="Q4_K_M", n_ctx=8192,
746
+ gpu_vram_gb=[24.0], draft=d))
747
+ assert "--spec-type draft-eagle3" in cmd
748
+ assert "-md draft-Q4_K_M.gguf" in cmd
749
+ assert "--spec-draft-ngl 999" in cmd
750
+ assert "--spec-draft-device all" in cmd
751
+ assert "--spec-draft-n-max 5" in cmd
752
+ assert "--spec-draft-p-min 0.5" in cmd
753
+ assert "--spec-draft-p-split 0.1" in cmd
754
+
755
+
756
+ # --- --fit complement path ---------------------------------------------------
757
+
758
+
759
+ def test_command_preview_emits_fit_on():
760
+ from vramcalc import Inputs
761
+ arch = PRESETS["Llama-3 8B"]
762
+ cmd = command_preview(arch, Inputs(quant="Q4_K_M", n_ctx=8192,
763
+ gpu_vram_gb=[24.0],
764
+ safety_margin_pct=5.0))
765
+ assert "--fit on" in cmd
766
+ # --fit-target derived from 5% of 24GB = ~1228 MiB
767
+ assert "--fit-target" in cmd
768
+
769
+
770
+ def test_command_preview_fit_target_scales_with_margin():
771
+ from vramcalc import Inputs
772
+ arch = PRESETS["Llama-3 8B"]
773
+ small = command_preview(arch, Inputs(quant="Q4_K_M", n_ctx=8192,
774
+ gpu_vram_gb=[24.0],
775
+ safety_margin_pct=2.0))
776
+ big = command_preview(arch, Inputs(quant="Q4_K_M", n_ctx=8192,
777
+ gpu_vram_gb=[24.0],
778
+ safety_margin_pct=10.0))
779
+ def ft_target(cmd):
780
+ import re
781
+ m = re.search(r"--fit-target (\d+)", cmd)
782
+ return int(m.group(1)) if m else 0
783
+ assert ft_target(big) > ft_target(small) > 0
784
+
785
+
786
+ def test_command_preview_fit_target_uses_smallest_gpu():
787
+ from vramcalc import Inputs
788
+ arch = PRESETS["Llama-3 8B"]
789
+ # mixed GPU: margin should be based on the 8GB device, not the 24GB one
790
+ cmd = command_preview(arch, Inputs(quant="Q4_K_M", n_ctx=8192,
791
+ gpu_vram_gb=[24.0, 8.0],
792
+ safety_margin_pct=10.0))
793
+ import re
794
+ m = re.search(r"--fit-target (\d+)", cmd)
795
+ assert m
796
+ # 10% of 8GB = ~819 MiB
797
+ assert 800 < int(m.group(1)) < 840
798
+
799
+
800
+ def test_command_preview_no_fit_target_when_margin_zero():
801
+ from vramcalc import Inputs
802
+ arch = PRESETS["Llama-3 8B"]
803
+ cmd = command_preview(arch, Inputs(quant="Q4_K_M", n_ctx=8192,
804
+ gpu_vram_gb=[24.0],
805
+ safety_margin_pct=0.0))
806
+ assert "--fit on" in cmd
807
+ assert "--fit-target" not in cmd
vramcalc/__init__.py CHANGED
@@ -5,7 +5,14 @@ Pure calculation logic, importable without Gradio so it can be unit-tested.
5
 
6
  from .quant import QUANT_BPW, weight_bytes, quant_from_filename
7
  from .kv import kv_cache_bytes, cache_dtype_bytes, compute_scratch_bytes
8
- from .yarn import yarn_effective_context, yarn_warnings, YarnConfig, auto_configure_yarn
 
 
 
 
 
 
 
9
  from .gpu import GpuBudget, GpuSpec, gpu_split, fit_gpus, GpuSplitResult
10
  from .gguf import (
11
  GGUFMetadata,
@@ -36,6 +43,8 @@ __all__ = [
36
  "compute_scratch_bytes",
37
  "yarn_effective_context",
38
  "yarn_warnings",
 
 
39
  "YarnConfig",
40
  "auto_configure_yarn",
41
  "GpuBudget",
 
5
 
6
  from .quant import QUANT_BPW, weight_bytes, quant_from_filename
7
  from .kv import kv_cache_bytes, cache_dtype_bytes, compute_scratch_bytes
8
+ from .yarn import (
9
+ yarn_effective_context,
10
+ yarn_warnings,
11
+ yarn_coherence_warnings,
12
+ extension_ratio,
13
+ YarnConfig,
14
+ auto_configure_yarn,
15
+ )
16
  from .gpu import GpuBudget, GpuSpec, gpu_split, fit_gpus, GpuSplitResult
17
  from .gguf import (
18
  GGUFMetadata,
 
43
  "compute_scratch_bytes",
44
  "yarn_effective_context",
45
  "yarn_warnings",
46
+ "yarn_coherence_warnings",
47
+ "extension_ratio",
48
  "YarnConfig",
49
  "auto_configure_yarn",
50
  "GpuBudget",
vramcalc/draft.py CHANGED
@@ -21,7 +21,45 @@ from .quant import weight_bytes, QUANT_BPW
21
  from .kv import kv_cache_bytes, cache_dtype_bytes
22
 
23
 
24
- SPEC_TYPES = ("none", "draft", "draft-eagle3", "draft-dflash")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
 
26
 
27
  @dataclass
@@ -38,7 +76,13 @@ class DraftInputs:
38
 
39
  @property
40
  def enabled(self) -> bool:
41
- return self.spec_type != "none" and self.params > 0
 
 
 
 
 
 
42
 
43
 
44
  @dataclass
@@ -66,6 +110,10 @@ def draft_bytes(
66
  if not draft.enabled:
67
  return DraftBreakdown(enabled=False)
68
 
 
 
 
 
69
  w = weight_bytes(draft.params, draft.quant)
70
 
71
  n_embd = draft.n_embd if getattr(draft, "n_embd", 0) else target_n_embd
 
21
  from .kv import kv_cache_bytes, cache_dtype_bytes
22
 
23
 
24
+ SPEC_TYPES = (
25
+ "none",
26
+ # weighted (draft model: weights + own KV)
27
+ "draft-simple",
28
+ "draft-eagle3",
29
+ "draft-mtp",
30
+ "draft-dflash",
31
+ # weightless (statistical / n-gram; no draft model, ~zero extra VRAM)
32
+ "ngram-simple",
33
+ "ngram-map-k",
34
+ "ngram-map-k4v",
35
+ "ngram-mod",
36
+ "ngram-cache",
37
+ )
38
+
39
+ # Spec types that need no draft model — pure statistical speculation. These
40
+ # have ~zero extra VRAM cost (no draft weights, no draft KV), so they are
41
+ # auto-offered when headroom exists without an "acknowledge the tradeoff"
42
+ # gate. See PLAN.md "Speculative decoding".
43
+ WEIGHTLESS_SPEC_TYPES = (
44
+ "ngram-simple",
45
+ "ngram-map-k",
46
+ "ngram-map-k4v",
47
+ "ngram-mod",
48
+ "ngram-cache",
49
+ )
50
+
51
+ # Spec types backed by a draft model (-md) with its own weights + KV.
52
+ WEIGHTED_SPEC_TYPES = ("draft-simple", "draft-eagle3", "draft-mtp", "draft-dflash")
53
+
54
+
55
+ def is_weightless(spec_type: str) -> bool:
56
+ """True if this spec type needs no draft model (n-gram family)."""
57
+ return spec_type in WEIGHTLESS_SPEC_TYPES
58
+
59
+
60
+ def is_weighted(spec_type: str) -> bool:
61
+ """True if this spec type needs a draft model (-md)."""
62
+ return spec_type in WEIGHTED_SPEC_TYPES
63
 
64
 
65
  @dataclass
 
76
 
77
  @property
78
  def enabled(self) -> bool:
79
+ # Weightless (n-gram) spec types need no draft model: enabled as long
80
+ # as a type is chosen. Weighted types need params > 0.
81
+ if self.spec_type == "none":
82
+ return False
83
+ if is_weightless(self.spec_type):
84
+ return True
85
+ return self.params > 0
86
 
87
 
88
  @dataclass
 
110
  if not draft.enabled:
111
  return DraftBreakdown(enabled=False)
112
 
113
+ # Weightless spec types (n-gram family) have no draft model: zero VRAM.
114
+ if is_weightless(draft.spec_type):
115
+ return DraftBreakdown(enabled=True)
116
+
117
  w = weight_bytes(draft.params, draft.quant)
118
 
119
  n_embd = draft.n_embd if getattr(draft, "n_embd", 0) else target_n_embd
vramcalc/quant.py CHANGED
@@ -59,6 +59,11 @@ QUANT_BPW: dict[str, float] = {
59
  # ciru-ai/ROCmFPX runner; see command_preview for the warning.
60
  "ROCmFP4": 4.34, # 27B Strix Lean: 14.82 GB / 27.32B params
61
  "ROCmFPX": 7.08, # 35B A3B MoEQuality: model card states 7.08 BPW
 
 
 
 
 
62
  }
63
 
64
 
 
59
  # ciru-ai/ROCmFPX runner; see command_preview for the warning.
60
  "ROCmFP4": 4.34, # 27B Strix Lean: 14.82 GB / 27.32B params
61
  "ROCmFPX": 7.08, # 35B A3B MoEQuality: model card states 7.08 BPW
62
+ # nvfp4 (NVIDIA FP4, llama.cpp PR #22196): NOT yet in this table. The
63
+ # bits-per-weight is unverified from here, so it cannot be selected as a
64
+ # floor quant. Add it only once a verified effective bpw is sourced; see
65
+ # PLAN.md "Quarantined unverified data".
66
+ # "nvfp4": 4.0, # VERIFY before ship
67
  }
68
 
69
 
vramcalc/report.py CHANGED
@@ -220,6 +220,22 @@ def _gpu_specs(inp: Inputs) -> list[GpuSpec]:
220
  ]
221
 
222
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
223
  def format_bytes(n: float) -> str:
224
  """Human-readable byte size."""
225
  n = float(n)
@@ -253,6 +269,16 @@ def command_preview(arch: ModelArch, inp: Inputs) -> str:
253
  if inp.flash_attn:
254
  parts.append("--flash-attn")
255
 
 
 
 
 
 
 
 
 
 
 
256
  # Multi-GPU: emit --split-mode / --main-gpu / --tensor-split only when the
257
  # user has more than one GPU or chose a non-default mode.
258
  multi = len(inp.gpu_vram_gb) > 1
@@ -272,17 +298,25 @@ def command_preview(arch: ModelArch, inp: Inputs) -> str:
272
  f"# MTP draft KV dtype: {inp.mtp_cache_dtype} (target: {inp.cache_dtype})"
273
  )
274
 
275
- # Speculative decoding draft model
 
 
 
 
276
  if inp.draft is not None and inp.draft.enabled:
277
  d = inp.draft
278
- parts.append(f"-md draft-{d.quant}.gguf")
279
  parts.append(f"--spec-type {d.spec_type}")
280
- if d.n_max:
281
- parts.append(f"--spec-draft-n-max {d.n_max}")
282
- if d.p_min:
283
- parts.append(f"--spec-draft-p-min {d.p_min}")
284
- if d.p_split:
285
- parts.append(f"--spec-draft-p-split {d.p_split}")
 
 
 
 
 
286
 
287
  # Multimodal projector
288
  if inp.mmproj is not None and inp.mmproj.enabled:
 
220
  ]
221
 
222
 
223
+ def _fit_target_mib(inp: Inputs) -> int:
224
+ """Per-device --fit-target margin in MiB, from safety_margin_pct.
225
+
226
+ --fit-target is a *per-device* MiB reserve. We derive one from the safety
227
+ margin as a share of the smallest configured GPU's VRAM (the tightest
228
+ device is what binds). Returns 0 to omit the flag (margin disabled).
229
+ """
230
+ if not inp.gpu_vram_gb:
231
+ return 0
232
+ if inp.safety_margin_pct <= 0:
233
+ return 0
234
+ smallest_gb = min(inp.gpu_vram_gb)
235
+ margin_bytes = smallest_gb * (1 << 30) * (inp.safety_margin_pct / 100.0)
236
+ return int(round(margin_bytes / (1 << 20)))
237
+
238
+
239
  def format_bytes(n: float) -> str:
240
  """Human-readable byte size."""
241
  n = float(n)
 
269
  if inp.flash_attn:
270
  parts.append("--flash-attn")
271
 
272
+ # Complement path: we emit fully explicit flags, then --fit on with a
273
+ # per-device margin target so runtime --fit is a no-op safety net that
274
+ # can only *reduce* usage, never exceed our estimate. The margin is the
275
+ # user's "how scared am I" knob (safety_margin_pct). We never claim a
276
+ # guarantee; the margin is the honest version.
277
+ parts.append("--fit on")
278
+ margin_mib = _fit_target_mib(inp)
279
+ if margin_mib:
280
+ parts.append(f"--fit-target {margin_mib}")
281
+
282
  # Multi-GPU: emit --split-mode / --main-gpu / --tensor-split only when the
283
  # user has more than one GPU or chose a non-default mode.
284
  multi = len(inp.gpu_vram_gb) > 1
 
298
  f"# MTP draft KV dtype: {inp.mtp_cache_dtype} (target: {inp.cache_dtype})"
299
  )
300
 
301
+ # Speculative decoding. Two branches:
302
+ # - weightless (n-gram family): no draft model, ~zero extra VRAM. Emit
303
+ # only --spec-type (no -md).
304
+ # - weighted (draft-*): a draft model with weights + KV. Emit -md plus
305
+ # the full --spec-draft-* namespace, not the old -md-only stub.
306
  if inp.draft is not None and inp.draft.enabled:
307
  d = inp.draft
 
308
  parts.append(f"--spec-type {d.spec_type}")
309
+ from .draft import is_weightless
310
+ if not is_weightless(d.spec_type):
311
+ parts.append(f"-md draft-{d.quant}.gguf")
312
+ parts.append("--spec-draft-ngl 999") # full draft offload
313
+ parts.append("--spec-draft-device all")
314
+ if d.n_max:
315
+ parts.append(f"--spec-draft-n-max {d.n_max}")
316
+ if d.p_min:
317
+ parts.append(f"--spec-draft-p-min {d.p_min}")
318
+ if d.p_split:
319
+ parts.append(f"--spec-draft-p-split {d.p_split}")
320
 
321
  # Multimodal projector
322
  if inp.mmproj is not None and inp.mmproj.enabled:
vramcalc/yarn.py CHANGED
@@ -110,4 +110,58 @@ def yarn_warnings(
110
  "normal long-context YaRN (lower only for high-context YaRN fine-tunes)."
111
  )
112
 
113
- return warns
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
110
  "normal long-context YaRN (lower only for high-context YaRN fine-tunes)."
111
  )
112
 
113
+ warns.extend(yarn_coherence_warnings(training_ctx, target_ctx))
114
+
115
+ return warns
116
+
117
+
118
+ # Extension-ratio thresholds for the coherence ladder. These are warnings
119
+ # only — never a hard block — and come from YaRN-paper behavior and community
120
+ # experience, not measurement. The ladder is monotonic: more extension means
121
+ # same-or-more concern. See PLAN.md "YaRN coherence ladder".
122
+ _YARN_FINE_RATIO = 2.0 # <= 2x: no warning
123
+ _YARN_BAD_RATIO = 8.0 # > 2x..8x: tail degradation; >= 8x: incoherent
124
+
125
+
126
+ def extension_ratio(training_ctx: int, target_ctx: int) -> float:
127
+ """How far the target context stretches beyond the training context.
128
+
129
+ Returns 1.0 when target <= training (no extension) or when training_ctx
130
+ is unknown/invalid.
131
+ """
132
+ if not training_ctx or training_ctx <= 0:
133
+ return 1.0
134
+ if target_ctx <= 0:
135
+ return 1.0
136
+ if target_ctx <= training_ctx:
137
+ return 1.0
138
+ return target_ctx / training_ctx
139
+
140
+
141
+ def yarn_coherence_warnings(training_ctx: int, target_ctx: int) -> list[str]:
142
+ """Warn about coherence risk when extending context beyond training.
143
+
144
+ These are heuristic warnings, not guarantees. The ladder is monotonic:
145
+ <= 2x : fine, no warning
146
+ > 2x..8x : expect some degradation at the tail of the context window
147
+ >= 8x : likely incoherent at long range; not recommended
148
+ """
149
+ if training_ctx <= 0 or target_ctx <= 0:
150
+ return []
151
+ if target_ctx <= training_ctx:
152
+ return []
153
+ ratio = target_ctx / training_ctx
154
+ w: list[str] = []
155
+ if ratio >= _YARN_BAD_RATIO:
156
+ w.append(
157
+ f"Target context {target_ctx} is {ratio:.1f}x the training "
158
+ f"context {training_ctx}. Likely incoherent at long range; "
159
+ f"not recommended."
160
+ )
161
+ elif ratio > _YARN_FINE_RATIO:
162
+ w.append(
163
+ f"Target context {target_ctx} is {ratio:.1f}x the training "
164
+ f"context {training_ctx}. Expect some quality degradation at "
165
+ f"the tail of the context window."
166
+ )
167
+ return w