3v324v23 Claude commited on
Commit
e07cd8c
·
1 Parent(s): 84407b7

Add llama.cpp VRAM calculator app and core logic

Browse files

Gradio UI (app.py) plus the pure vramcalc package: quant BPW table,
KV cache + compute-scratch math, multi-GPU proportional split, YaRN
context-extension checks, minimal GGUF v3 header parser (HF range-read),
model presets, and 23 unit tests.

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

.gitignore ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ .eggs/
6
+ build/
7
+ dist/
8
+
9
+ # Test / tooling caches
10
+ .pytest_cache/
11
+ .coverage
12
+ htmlcov/
13
+
14
+ # venv
15
+ .venv/
16
+ venv/
17
+
18
+ # OS / editor
19
+ .DS_Store
20
+ .idea/
21
+ .vscode/
app.py ADDED
@@ -0,0 +1,377 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+
15
+ from vramcalc import (
16
+ QUANT_BPW,
17
+ ModelArch,
18
+ Inputs,
19
+ estimate,
20
+ command_preview,
21
+ format_bytes,
22
+ quant_from_filename,
23
+ parse_hf_range,
24
+ )
25
+ from vramcalc.presets import PRESETS, PRESET_NAMES
26
+
27
+
28
+ QUANT_CHOICES = list(QUANT_BPW.keys())
29
+ CACHE_DTYPES = ["f16", "bf16", "f32", "q8_0", "q8_1", "q4_0", "q4_1", "q5_0", "q5_1"]
30
+ COMPUTE_DTYPES = ["f16", "bf16", "f32"]
31
+
32
+
33
+ def list_gguf_files(repo_id: str, hf_token: str):
34
+ """List *.gguf files in a repo and auto-detect their quant names."""
35
+ if not repo_id or not repo_id.strip():
36
+ return [], "Enter a Hugging Face repo id."
37
+ try:
38
+ api = HfApi(token=hf_token or None)
39
+ files = api.list_repo_files(repo_id=repo_id.strip())
40
+ except Exception as e: # noqa: BLE001
41
+ return [], f"Error listing repo: {e}"
42
+ ggufs = sorted(f for f in files if f.lower().endswith(".gguf"))
43
+ if not ggufs:
44
+ return [], f"No .gguf files found in {repo_id!r}."
45
+ choices = []
46
+ for f in ggufs:
47
+ q = quant_from_filename(f)
48
+ label = f"{f}" + (f" [{q}]" if q else "")
49
+ choices.append((label, f))
50
+ return choices, f"Found {len(ggufs)} GGUF file(s)."
51
+
52
+
53
+ def fetch_arch(repo_id: str, filename: str, hf_token: str):
54
+ """Range-read a GGUF header from HF and return editable arch fields."""
55
+ if not repo_id or not filename:
56
+ return _empty_arch_fields(), "Pick a GGUF file first."
57
+ try:
58
+ meta = parse_hf_range(
59
+ repo_id.strip(), filename, token=hf_token or None
60
+ )
61
+ except Exception as e: # noqa: BLE001
62
+ return _empty_arch_fields(), f"Error reading GGUF header: {e}"
63
+ if not meta.n_layer:
64
+ return _arch_to_fields(meta), (
65
+ "Parsed header but architecture fields look empty; "
66
+ "edit them manually below."
67
+ )
68
+ return _arch_to_fields(meta), (
69
+ f"Fetched {meta.architecture or 'model'}: "
70
+ f"{meta.n_layer} layers, {meta.n_embd} embd, "
71
+ f"{meta.n_head}/{meta.n_head_kv} heads, ctx {meta.training_ctx}, "
72
+ f"params {meta.params or 'n/a'}."
73
+ )
74
+
75
+
76
+ def _empty_arch_fields():
77
+ return _arch_to_fields(ModelArch())
78
+
79
+
80
+ def _arch_to_fields(m: ModelArch):
81
+ return [
82
+ m.name, m.architecture, m.n_layer, m.n_embd, m.n_head, m.n_head_kv,
83
+ m.training_ctx, m.params, m.rope_freq_base, m.n_expert,
84
+ m.n_expert_used, m.n_mtp,
85
+ ]
86
+
87
+
88
+ ARCH_FIELD_NAMES = [
89
+ "name", "architecture", "n_layer", "n_embd", "n_head", "n_head_kv",
90
+ "training_ctx", "params", "rope_freq_base", "n_expert",
91
+ "n_expert_used", "n_mtp",
92
+ ]
93
+
94
+
95
+ def _fields_to_arch(fields) -> ModelArch:
96
+ values = {k: v for k, v in zip(ARCH_FIELD_NAMES, fields)}
97
+ return ModelArch(
98
+ name=str(values["name"] or ""),
99
+ architecture=str(values["architecture"] or ""),
100
+ n_layer=int(values["n_layer"] or 0),
101
+ n_embd=int(values["n_embd"] or 0),
102
+ n_head=int(values["n_head"] or 0),
103
+ n_head_kv=int(values["n_head_kv"] or 0),
104
+ training_ctx=int(values["training_ctx"] or 0),
105
+ params=int(values["params"] or 0),
106
+ rope_freq_base=float(values["rope_freq_base"] or 10000.0),
107
+ n_expert=int(values["n_expert"] or 0),
108
+ n_expert_used=int(values["n_expert_used"] or 0),
109
+ n_mtp=int(values["n_mtp"] or 0),
110
+ )
111
+
112
+
113
+ def load_preset(name: str):
114
+ if name and name in PRESETS:
115
+ return _arch_to_fields(PRESETS[name]), f"Loaded preset: {name}"
116
+ return _empty_arch_fields(), ""
117
+
118
+
119
+ def _parse_gpu_list(text: str) -> list[float]:
120
+ out = []
121
+ for tok in (text or "").replace(";", ",").split(","):
122
+ tok = tok.strip()
123
+ if tok:
124
+ try:
125
+ out.append(float(tok))
126
+ except ValueError:
127
+ pass
128
+ return out or [24.0]
129
+
130
+
131
+ def compute(
132
+ arch_fields,
133
+ quant, n_ctx, cache_dtype, flash_attn, compute_dtype,
134
+ n_batch, n_prompt,
135
+ rope_freq_scale, yarn_ext_factor, yarn_attn_factor,
136
+ yarn_beta_fast, yarn_beta_slow,
137
+ gpu_vram_text, kv_on_largest, safety_margin,
138
+ ):
139
+ arch = _fields_to_arch(arch_fields)
140
+ gpus = _parse_gpu_list(gpu_vram_text)
141
+ inp = Inputs(
142
+ quant=quant,
143
+ n_ctx=int(n_ctx),
144
+ cache_dtype=cache_dtype,
145
+ flash_attn=bool(flash_attn),
146
+ compute_dtype=compute_dtype,
147
+ n_batch=int(n_batch),
148
+ n_prompt=int(n_prompt),
149
+ rope_freq_scale=float(rope_freq_scale),
150
+ yarn_ext_factor=float(yarn_ext_factor),
151
+ yarn_attn_factor=float(yarn_attn_factor),
152
+ yarn_beta_fast=float(yarn_beta_fast),
153
+ yarn_beta_slow=float(yarn_beta_slow),
154
+ gpu_vram_gb=gpus,
155
+ kv_on_largest=bool(kv_on_largest),
156
+ safety_margin_pct=float(safety_margin),
157
+ )
158
+ if arch.n_layer <= 0 or arch.n_embd <= 0 or arch.params <= 0:
159
+ return (
160
+ "⚠️ Model architecture is incomplete. Fill n_layer, n_embd, and "
161
+ "params (or fetch from a GGUF / load a preset).",
162
+ "", "",
163
+ )
164
+ bd = estimate(arch, inp)
165
+
166
+ # breakdown table
167
+ rows = [
168
+ ["Weights (GGUF, " + quant + ")", format_bytes(bd.weights_bytes)],
169
+ ["KV cache (" + cache_dtype + ")", format_bytes(bd.kv_cache_bytes)],
170
+ ["Compute / scratch", format_bytes(bd.compute_scratch_bytes)],
171
+ ["MTP overhead", format_bytes(bd.mtp_overhead_bytes)],
172
+ ["GGUF header / overhead", format_bytes(bd.gguf_overhead_bytes)],
173
+ ["Safety margin (" + str(inp.safety_margin_pct) + "%)",
174
+ format_bytes(bd.safety_margin_bytes)],
175
+ ["**Total**", f"**{format_bytes(bd.total_bytes)}**"],
176
+ ]
177
+ breakdown_md = "| Component | Size |\n|---|---|\n" + "\n".join(
178
+ f"| {a} | {b} |" for a, b in rows
179
+ )
180
+
181
+ # warnings
182
+ warn_md = ""
183
+ if bd.warnings:
184
+ warn_md = "\n\n**⚠️ YaRN / context notes:**\n" + "\n".join(
185
+ f"- {w}" for w in bd.warnings
186
+ )
187
+ eff_md = (
188
+ f"\n\nEffective context (training_ctx / rope_freq_scale): "
189
+ f"**{bd.effective_context}**"
190
+ )
191
+
192
+ # per-GPU table
193
+ gpu_md = ""
194
+ if bd.gpu and bd.gpu.assignments:
195
+ a = bd.gpu.assignments
196
+ header = "| GPU | VRAM | Weights | KV+Compute | Used | Free | Fits? |"
197
+ sep = "|---|---|---|---|---|---|---|"
198
+ body = []
199
+ for g in a:
200
+ kv = format_bytes(g.kv_compute_bytes) if g.kv_compute_bytes else "—"
201
+ badge = "✅" if g.fits else "❌"
202
+ body.append(
203
+ f"| {g.index} | {format_bytes(g.vram_bytes)} | "
204
+ f"{format_bytes(g.weight_bytes)} | {kv} | "
205
+ f"{format_bytes(g.used_bytes)} | {format_bytes(g.free_bytes)} "
206
+ f"| {badge} |"
207
+ )
208
+ total_badge = "✅ all fit" if bd.gpu.all_fit else "❌ over budget"
209
+ gpu_md = (
210
+ "**Per-GPU split (estimate, proportional weight split):**\n\n"
211
+ + header + "\n" + sep + "\n" + "\n".join(body)
212
+ + f"\n\nTotal VRAM: {format_bytes(bd.gpu.total_vram_bytes)} · "
213
+ f"Total used: {format_bytes(bd.gpu.total_used_bytes)} · "
214
+ f"{total_badge} · KV on GPU {bd.gpu.kv_gpu_index}"
215
+ )
216
+
217
+ cmd = command_preview(arch, inp)
218
+ summary = (
219
+ f"**{arch.name or arch.architecture or 'Model'}** @ {quant}, "
220
+ f"ctx {inp.n_ctx} ({cache_dtype} KV"
221
+ + (", FA" if inp.flash_attn else ", no FA")
222
+ + f"), {len(gpus)} GPU(s) → "
223
+ f"**{format_bytes(bd.total_bytes)}** total"
224
+ )
225
+ return summary + "\n\n" + breakdown_md + eff_md + warn_md + "\n\n" + gpu_md, cmd, cmd
226
+
227
+
228
+ def build_ui():
229
+ with gr.Blocks(title="llama.cpp VRAM Calculator") as demo:
230
+ gr.Markdown(
231
+ "# 🦀 llama.cpp VRAM Calculator\n"
232
+ "Estimate VRAM for a Hugging Face GGUF model: quant size, "
233
+ "context, KV cache options, YaRN context extension, MTP heads, "
234
+ "and multi-GPU split. Architecture is auto-fetched from the GGUF "
235
+ "header (range-read — no full model download)."
236
+ )
237
+
238
+ arch_state = gr.State(_empty_arch_fields())
239
+
240
+ with gr.Row():
241
+ with gr.Column(scale=1):
242
+ gr.Markdown("### 1. Model source")
243
+ with gr.Tab("Auto-fetch from HF"):
244
+ repo_id = gr.Textbox(
245
+ label="HF repo id",
246
+ placeholder="e.g. bartowski/Llama-3-8B-Instruct-GGUF",
247
+ )
248
+ hf_token = gr.Textbox(
249
+ label="HF token (optional, for gated/private repos)",
250
+ type="password",
251
+ )
252
+ list_btn = gr.Button("List GGUF files")
253
+ file_picker = gr.Dropdown(
254
+ label="GGUF file", choices=[], interactive=True
255
+ )
256
+ fetch_btn = gr.Button("Fetch architecture from GGUF header")
257
+ fetch_status = gr.Markdown("")
258
+
259
+ with gr.Tab("Presets / manual"):
260
+ preset_dd = gr.Dropdown(
261
+ label="Quick preset", choices=PRESET_NAMES, interactive=True
262
+ )
263
+ load_preset_btn = gr.Button("Load preset")
264
+
265
+ gr.Markdown("### Architecture (editable)")
266
+ arch_inputs = [
267
+ gr.Textbox(label="name", value=""),
268
+ gr.Textbox(label="architecture", value=""),
269
+ gr.Number(label="n_layer", value=0, precision=0),
270
+ gr.Number(label="n_embd", value=0, precision=0),
271
+ gr.Number(label="n_head", value=0, precision=0),
272
+ gr.Number(label="n_head_kv", value=0, precision=0),
273
+ gr.Number(label="training_ctx", value=0, precision=0),
274
+ gr.Number(label="params", value=0, precision=0),
275
+ gr.Number(label="rope_freq_base", value=10000.0),
276
+ gr.Number(label="n_expert (MoE)", value=0, precision=0),
277
+ gr.Number(label="n_expert_used", value=0, precision=0),
278
+ gr.Number(label="n_mtp", value=0, precision=0),
279
+ ]
280
+
281
+ with gr.Column(scale=1):
282
+ gr.Markdown("### 2. Inference options")
283
+ quant = gr.Dropdown(
284
+ label="Quantization", choices=QUANT_CHOICES, value="Q4_K_M"
285
+ )
286
+ n_ctx = gr.Number(label="Target context (n_ctx)", value=8192, precision=0)
287
+ with gr.Row():
288
+ cache_dtype = gr.Dropdown(
289
+ label="KV cache dtype", choices=CACHE_DTYPES, value="f16"
290
+ )
291
+ compute_dtype = gr.Dropdown(
292
+ label="Compute dtype", choices=COMPUTE_DTYPES, value="f16"
293
+ )
294
+ flash_attn = gr.Checkbox(label="Flash attention", value=True)
295
+ with gr.Row():
296
+ n_batch = gr.Number(label="n_batch", value=512, precision=0)
297
+ n_prompt = gr.Number(label="n_prompt (active)", value=0, precision=0)
298
+
299
+ gr.Markdown("### YaRN / RoPE context extension")
300
+ with gr.Row():
301
+ rope_freq_scale = gr.Number(label="rope_freq_scale", value=1.0)
302
+ yarn_ext_factor = gr.Number(label="yarn_ext_factor", value=-1.0)
303
+ yarn_attn_factor = gr.Number(label="yarn_attn_factor", value=1.0)
304
+ with gr.Row():
305
+ yarn_beta_fast = gr.Number(label="yarn_beta_fast", value=32.0)
306
+ yarn_beta_slow = gr.Number(label="yarn_beta_slow", value=1.0)
307
+
308
+ gr.Markdown("### 3. Multi-GPU budget")
309
+ gpu_vram_text = gr.Textbox(
310
+ label="Per-GPU VRAM (GB, comma-separated)",
311
+ value="24",
312
+ placeholder="e.g. 24,24,16",
313
+ )
314
+ with gr.Row():
315
+ kv_on_largest = gr.Checkbox(
316
+ label="Place KV cache on largest GPU", value=False
317
+ )
318
+ safety_margin = gr.Number(label="Safety margin %", value=5.0)
319
+
320
+ compute_btn = gr.Button("Compute VRAM", variant="primary")
321
+
322
+ gr.Markdown("### Results")
323
+ result_md = gr.Markdown("")
324
+ with gr.Accordion("llama.cpp launch command preview", open=False):
325
+ cmd_md = gr.Markdown("")
326
+ cmd_text = gr.Textbox(
327
+ label="Command (copyable)", lines=8, interactive=False
328
+ )
329
+
330
+ # --- wiring ---
331
+ def _store_arch(*fields):
332
+ return list(fields)
333
+
334
+ arch_inputs_and_state = [*arch_inputs, arch_state]
335
+ # keep arch_state synced whenever arch fields change
336
+ for comp in arch_inputs:
337
+ comp.change(
338
+ fn=_store_arch, inputs=arch_inputs, outputs=arch_state
339
+ )
340
+
341
+ list_btn.click(
342
+ fn=list_gguf_files, inputs=[repo_id, hf_token],
343
+ outputs=[file_picker, fetch_status],
344
+ )
345
+ fetch_btn.click(
346
+ fn=fetch_arch, inputs=[repo_id, file_picker, hf_token],
347
+ outputs=[*arch_inputs, fetch_status],
348
+ ).then(
349
+ fn=_store_arch, inputs=arch_inputs, outputs=arch_state
350
+ )
351
+ load_preset_btn.click(
352
+ fn=load_preset, inputs=[preset_dd],
353
+ outputs=[*arch_inputs, fetch_status],
354
+ ).then(
355
+ fn=_store_arch, inputs=arch_inputs, outputs=arch_state
356
+ )
357
+
358
+ compute_btn.click(
359
+ fn=compute,
360
+ inputs=[
361
+ arch_state, quant, n_ctx, cache_dtype, flash_attn, compute_dtype,
362
+ n_batch, n_prompt,
363
+ rope_freq_scale, yarn_ext_factor, yarn_attn_factor,
364
+ yarn_beta_fast, yarn_beta_slow,
365
+ gpu_vram_text, kv_on_largest, safety_margin,
366
+ ],
367
+ outputs=[result_md, cmd_md, cmd_text],
368
+ )
369
+
370
+ return demo
371
+
372
+
373
+ demo = build_ui()
374
+
375
+
376
+ if __name__ == "__main__":
377
+ demo.launch()
requirements.txt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ gradio==6.20.0
2
+ huggingface_hub>=0.20.0
3
+ requests>=2.28.0
tests/test_vramcalc.py ADDED
@@ -0,0 +1,259 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Unit tests for the pure calculation logic in vramcalc."""
2
+
3
+ import pytest
4
+
5
+ from vramcalc import (
6
+ QUANT_BPW,
7
+ weight_bytes,
8
+ quant_from_filename,
9
+ kv_cache_bytes,
10
+ compute_scratch_bytes,
11
+ yarn_effective_context,
12
+ yarn_warnings,
13
+ gpu_split,
14
+ estimate,
15
+ command_preview,
16
+ format_bytes,
17
+ )
18
+ from vramcalc.presets import PRESETS
19
+ from vramcalc.gguf import parse_header_bytes, metadata_to_arch
20
+
21
+
22
+ def test_quant_table_has_common_types():
23
+ for q in ["Q4_K_M", "Q5_K_M", "Q8_0", "Q2_K", "F16", "BF16", "Q6_K"]:
24
+ assert q in QUANT_BPW
25
+
26
+
27
+ def test_quant_from_filename():
28
+ assert quant_from_filename("Llama-3-8B-Instruct-Q4_K_M.gguf") == "Q4_K_M"
29
+ assert quant_from_filename("model.Q5_K_S.gguf") == "Q5_K_S"
30
+ assert quant_from_filename("model.Q8_0.gguf") == "Q8_0"
31
+ assert quant_from_filename("model.F16.gguf") == "F16"
32
+ assert quant_from_filename("model.gguf") is None
33
+
34
+
35
+ def test_weight_bytes():
36
+ # 8B params at Q4_K_M (4.84375 bpw) -> ~4.84 GB
37
+ b = weight_bytes(8_000_000_000, "Q4_K_M")
38
+ assert 4.7e9 < b < 4.9e9
39
+ # F16 -> 16 bytes/param
40
+ assert weight_bytes(1_000_000, "F16") == pytest.approx(2_000_000)
41
+
42
+
43
+ def test_weight_bytes_unknown_raises():
44
+ with pytest.raises(ValueError):
45
+ weight_bytes(1, "BOGUS")
46
+
47
+
48
+ def test_kv_cache_basic():
49
+ # Llama-3 8B: 32 layers, 4096 embd, 32 heads, 8 kv heads, ctx 8192, f16
50
+ # head_dim = 128; per layer = 8192*2*8*128*2 = 33,554,432 bytes
51
+ # * 32 layers = ~1.07 GB
52
+ b = kv_cache_bytes(
53
+ n_layer=32, n_embd=4096, n_head=32, n_head_kv=8,
54
+ n_ctx=8192, cache_dtype="f16",
55
+ )
56
+ assert 1.0e9 < b < 1.1e9
57
+
58
+
59
+ def test_kv_cache_scales_with_ctx():
60
+ b1 = kv_cache_bytes(n_layer=32, n_embd=4096, n_head=32, n_head_kv=8,
61
+ n_ctx=4096, cache_dtype="f16")
62
+ b2 = kv_cache_bytes(n_layer=32, n_embd=4096, n_head=32, n_head_kv=8,
63
+ n_ctx=8192, cache_dtype="f16")
64
+ assert b2 == pytest.approx(2 * b1)
65
+
66
+
67
+ def test_kv_cache_mtp():
68
+ base = kv_cache_bytes(n_layer=61, n_embd=7168, n_head=128, n_head_kv=128,
69
+ n_ctx=8192, cache_dtype="f16", n_mtp=0)
70
+ mtp = kv_cache_bytes(n_layer=61, n_embd=7168, n_head=128, n_head_kv=128,
71
+ n_ctx=8192, cache_dtype="f16", n_mtp=1)
72
+ # n_mtp=1 adds one layer's worth: ratio = 62/61
73
+ assert mtp == pytest.approx(base * 62 / 61)
74
+
75
+
76
+ def test_kv_cache_quantized_smaller():
77
+ f16 = kv_cache_bytes(n_layer=32, n_embd=4096, n_head=32, n_head_kv=8,
78
+ n_ctx=8192, cache_dtype="f16")
79
+ q8 = kv_cache_bytes(n_layer=32, n_embd=4096, n_head=32, n_head_kv=8,
80
+ n_ctx=8192, cache_dtype="q8_0")
81
+ q4 = kv_cache_bytes(n_layer=32, n_embd=4096, n_head=32, n_head_kv=8,
82
+ n_ctx=8192, cache_dtype="q4_0")
83
+ assert q8 < f16
84
+ assert q4 < q8
85
+ assert q8 == pytest.approx(f16 / 2)
86
+ assert q4 == pytest.approx(f16 / 4)
87
+
88
+
89
+ def test_compute_scratch_positive():
90
+ s = compute_scratch_bytes(
91
+ n_layer=32, n_embd=4096, n_head=32, n_head_kv=8,
92
+ n_batch=512, compute_dtype="f16",
93
+ )
94
+ assert s > 0
95
+
96
+
97
+ def test_compute_scratch_no_fa_quantized_adds_dequant():
98
+ base = compute_scratch_bytes(
99
+ n_layer=32, n_embd=4096, n_head=32, n_head_kv=8,
100
+ n_batch=512, compute_dtype="f16", cache_dtype="q8_0", flash_attn=True,
101
+ )
102
+ nofa = compute_scratch_bytes(
103
+ n_layer=32, n_embd=4096, n_head=32, n_head_kv=8,
104
+ n_batch=512, compute_dtype="f16", cache_dtype="q8_0", flash_attn=False,
105
+ )
106
+ assert nofa > base
107
+
108
+
109
+ def test_yarn_effective_context():
110
+ # scale 0.5 doubles effective context
111
+ assert yarn_effective_context(8192, 0.5) == 16384
112
+ assert yarn_effective_context(8192, 1.0) == 8192
113
+ # zero/negative scale falls back to training ctx
114
+ assert yarn_effective_context(8192, 0.0) == 8192
115
+
116
+
117
+ def test_yarn_warnings_target_exceeds():
118
+ w = yarn_warnings(
119
+ training_ctx=8192, target_ctx=32768,
120
+ rope_freq_scale=1.0, yarn_ext_factor=-1.0, yarn_attn_factor=1.0,
121
+ )
122
+ assert any("exceeds training context" in x for x in w)
123
+ assert any("Effective context" in x for x in w)
124
+
125
+
126
+ def test_yarn_warnings_no_warning_when_within():
127
+ w = yarn_warnings(
128
+ training_ctx=131072, target_ctx=8192,
129
+ rope_freq_scale=1.0, yarn_ext_factor=-1.0, yarn_attn_factor=1.0,
130
+ )
131
+ assert w == []
132
+
133
+
134
+ def test_gpu_split_proportional_sums():
135
+ res = gpu_split(
136
+ gpu_vram_bytes=[24 << 30, 24 << 30, 16 << 30],
137
+ weights_bytes=40 << 30,
138
+ kv_compute_bytes=4 << 30,
139
+ )
140
+ # weight shares sum to weights
141
+ total_w = sum(a.weight_bytes for a in res.assignments)
142
+ assert total_w == pytest.approx(40 << 30, rel=1e-6)
143
+ # KV on exactly one GPU (default GPU 0)
144
+ kv_gpus = [a for a in res.assignments if a.kv_compute_bytes > 0]
145
+ assert len(kv_gpus) == 1
146
+ assert kv_gpus[0].index == 0
147
+ # used+free == vram for each gpu
148
+ for a in res.assignments:
149
+ assert a.used_bytes + a.free_bytes == pytest.approx(a.vram_bytes, rel=1e-6)
150
+
151
+
152
+ def test_gpu_split_kv_on_largest():
153
+ res = gpu_split(
154
+ gpu_vram_bytes=[16 << 30, 24 << 30],
155
+ weights_bytes=10 << 30,
156
+ kv_compute_bytes=3 << 30,
157
+ kv_on_largest=True,
158
+ )
159
+ kv_gpus = [a for a in res.assignments if a.kv_compute_bytes > 0]
160
+ assert len(kv_gpus) == 1
161
+ assert kv_gpus[0].index == 1
162
+
163
+
164
+ def test_gpu_split_fit_badge():
165
+ res = gpu_split(
166
+ gpu_vram_bytes=[4 << 30], # tiny
167
+ weights_bytes=10 << 30,
168
+ kv_compute_bytes=1 << 30,
169
+ )
170
+ assert res.all_fit is False
171
+ assert res.assignments[0].fits is False
172
+
173
+
174
+ def test_estimate_llama3_8b():
175
+ arch = PRESETS["Llama-3 8B"]
176
+ from vramcalc import Inputs
177
+ bd = estimate(arch, Inputs(quant="Q4_K_M", n_ctx=8192, cache_dtype="f16",
178
+ flash_attn=True, compute_dtype="f16",
179
+ n_batch=512, gpu_vram_gb=[24.0]))
180
+ # weights ~4.8 GB
181
+ assert 4.5e9 < bd.weights_bytes < 5.0e9
182
+ # KV ~1 GB
183
+ assert 0.9e9 < bd.kv_cache_bytes < 1.1e9
184
+ # total fits on 24 GB
185
+ assert bd.gpu.all_fit is True
186
+ assert bd.total_bytes < 24 << 30
187
+
188
+
189
+ def test_estimate_mtp_overhead():
190
+ arch = PRESETS["DeepSeek-V3 (MoE)"]
191
+ from vramcalc import Inputs
192
+ bd = estimate(arch, Inputs(quant="Q4_K_M", n_ctx=8192, cache_dtype="f16",
193
+ gpu_vram_gb=[80.0]))
194
+ assert bd.mtp_overhead_bytes > 0
195
+
196
+
197
+ def test_command_preview_basic():
198
+ arch = PRESETS["Llama-3 8B"]
199
+ from vramcalc import Inputs
200
+ cmd = command_preview(arch, Inputs(quant="Q4_K_M", n_ctx=8192,
201
+ cache_dtype="q8_0", flash_attn=True,
202
+ gpu_vram_gb=[24.0]))
203
+ assert "llama-server" in cmd
204
+ assert "-c 8192" in cmd
205
+ assert "--cache-type-k q8_0" in cmd
206
+ assert "--flash-attn" in cmd
207
+
208
+
209
+ def test_command_preview_multigpu_split():
210
+ arch = PRESETS["Llama-3 8B"]
211
+ from vramcalc import Inputs
212
+ cmd = command_preview(arch, Inputs(quant="Q4_K_M", n_ctx=8192,
213
+ gpu_vram_gb=[24.0, 24.0]))
214
+ assert "--tensor-split 24.0,24.0" in cmd
215
+
216
+
217
+ def test_command_preview_yarn_when_target_exceeds():
218
+ arch = PRESETS["Llama-3 8B"] # training_ctx 8192
219
+ from vramcalc import Inputs
220
+ cmd = command_preview(arch, Inputs(quant="Q4_K_M", n_ctx=32768,
221
+ rope_freq_scale=0.5,
222
+ gpu_vram_gb=[24.0]))
223
+ assert "--rope-scaling yarn" in cmd
224
+ assert "--rope-freq-scale 0.5" in cmd
225
+
226
+
227
+ def test_format_bytes():
228
+ assert "GiB" in format_bytes(5 << 30)
229
+ assert "MiB" in format_bytes(5 << 20)
230
+ assert "KiB" in format_bytes(5 << 10)
231
+ assert format_bytes(0).endswith("B")
232
+
233
+
234
+ def test_gguf_parse_header_minimal():
235
+ # Build a tiny valid GGUF header in memory: magic, version, tensor_count=0,
236
+ # kv_count=2, with one string and one int metadata entry.
237
+ import struct
238
+ def s(x): return x.encode("utf-8")
239
+ buf = b""
240
+ buf += struct.pack("<I", 0x46554747) # GGUF
241
+ buf += struct.pack("<I", 3) # version
242
+ buf += struct.pack("<Q", 0) # tensor_count
243
+ buf += struct.pack("<Q", 2) # kv_count
244
+ # kv 1: "general.architecture" = "llama"
245
+ name = s("general.architecture")
246
+ buf += struct.pack("<Q", len(name)) + name
247
+ buf += struct.pack("<I", 8) # STRING
248
+ val = s("llama")
249
+ buf += struct.pack("<Q", len(val)) + val
250
+ # kv 2: "llama.block_count" = 32
251
+ name2 = s("llama.block_count")
252
+ buf += struct.pack("<Q", len(name2)) + name2
253
+ buf += struct.pack("<I", 4) # UINT32
254
+ buf += struct.pack("<I", 32)
255
+ meta = parse_header_bytes(buf)
256
+ assert meta["general.architecture"] == "llama"
257
+ arch = metadata_to_arch(meta)
258
+ assert arch.architecture == "llama"
259
+ assert arch.n_layer == 32
vramcalc/__init__.py ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """llama.cpp VRAM estimation helpers.
2
+
3
+ Pure calculation logic, importable without Gradio so it can be unit-tested.
4
+ """
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
9
+ from .gpu import GpuBudget, gpu_split, fit_gpus, GpuSplitResult
10
+ from .gguf import (
11
+ GGUFMetadata,
12
+ parse_header_bytes,
13
+ parse_local_file,
14
+ parse_hf_range,
15
+ metadata_to_arch,
16
+ )
17
+ from .report import (
18
+ ModelArch,
19
+ Inputs,
20
+ Breakdown,
21
+ estimate,
22
+ command_preview,
23
+ format_bytes,
24
+ )
25
+
26
+ __all__ = [
27
+ "QUANT_BPW",
28
+ "weight_bytes",
29
+ "quant_from_filename",
30
+ "kv_cache_bytes",
31
+ "cache_dtype_bytes",
32
+ "compute_scratch_bytes",
33
+ "yarn_effective_context",
34
+ "yarn_warnings",
35
+ "GpuBudget",
36
+ "GpuSplitResult",
37
+ "gpu_split",
38
+ "fit_gpus",
39
+ "GGUFMetadata",
40
+ "parse_header_bytes",
41
+ "parse_local_file",
42
+ "parse_hf_range",
43
+ "metadata_to_arch",
44
+ "ModelArch",
45
+ "Inputs",
46
+ "Breakdown",
47
+ "estimate",
48
+ "command_preview",
49
+ "format_bytes",
50
+ ]
vramcalc/gguf.py ADDED
@@ -0,0 +1,218 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Minimal GGUF v3 header parser.
2
+
3
+ Reads only the metadata key/value section (no tensor data) from a GGUF file,
4
+ either from a local path or via a ranged HTTP GET against the Hugging Face
5
+ resolve URL so we never download the whole model.
6
+
7
+ GGUF layout (little-endian):
8
+ magic: u32 = 'GGUF'
9
+ version: u32
10
+ tensor_count: u64
11
+ metadata_kv_count: u64
12
+ metadata_kv[]: { name_len: u64, name: bytes, value_type: u32, value: ... }
13
+ tensor_info[]: (not parsed here)
14
+
15
+ We parse just enough metadata to recover architecture parameters.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import io
21
+ import struct
22
+ from dataclasses import dataclass, field
23
+
24
+ GGUF_MAGIC = 0x46554747 # "GGUF"
25
+ GGUF_DEFAULT_VERSION = 3
26
+
27
+ GGUF_TYPE_UINT8 = 0
28
+ GGUF_TYPE_INT8 = 1
29
+ GGUF_TYPE_UINT16 = 2
30
+ GGUF_TYPE_INT16 = 3
31
+ GGUF_TYPE_UINT32 = 4
32
+ GGUF_TYPE_INT32 = 5
33
+ GGUF_TYPE_FLOAT32 = 6
34
+ GGUF_TYPE_BOOL = 7
35
+ GGUF_TYPE_STRING = 8
36
+ GGUF_TYPE_ARRAY = 9
37
+ GGUF_TYPE_UINT64 = 10
38
+ GGUF_TYPE_INT64 = 11
39
+ GGUF_TYPE_FLOAT64 = 12
40
+
41
+ _SCALAR_FMT = {
42
+ GGUF_TYPE_UINT8: ("<B", 1),
43
+ GGUF_TYPE_INT8: ("<b", 1),
44
+ GGUF_TYPE_UINT16: ("<H", 2),
45
+ GGUF_TYPE_INT16: ("<h", 2),
46
+ GGUF_TYPE_UINT32: ("<I", 4),
47
+ GGUF_TYPE_INT32: ("<i", 4),
48
+ GGUF_TYPE_FLOAT32: ("<f", 4),
49
+ GGUF_TYPE_BOOL: ("<?", 1),
50
+ GGUF_TYPE_UINT64: ("<Q", 8),
51
+ GGUF_TYPE_INT64: ("<q", 8),
52
+ GGUF_TYPE_FLOAT64: ("<d", 8),
53
+ }
54
+
55
+
56
+ @dataclass
57
+ class GGUFMetadata:
58
+ raw: dict[str, object] = field(default_factory=dict)
59
+ architecture: str = ""
60
+ n_layer: int = 0
61
+ n_embd: int = 0
62
+ n_head: int = 0
63
+ n_head_kv: int = 0
64
+ training_ctx: int = 0
65
+ rope_freq_base: float = 10000.0
66
+ n_expert: int = 0
67
+ n_expert_used: int = 0
68
+ n_mtp: int = 0
69
+ params: int = 0
70
+
71
+
72
+ class _Reader:
73
+ def __init__(self, buf: bytes):
74
+ self.buf = buf
75
+ self.pos = 0
76
+
77
+ def eof(self) -> bool:
78
+ return self.pos >= len(self.buf)
79
+
80
+ def need(self, n: int):
81
+ if self.pos + n > len(self.buf):
82
+ raise EOFError("buffer exhausted while parsing GGUF header")
83
+
84
+ def u32(self) -> int:
85
+ self.need(4)
86
+ v = struct.unpack_from("<I", self.buf, self.pos)[0]
87
+ self.pos += 4
88
+ return v
89
+
90
+ def u64(self) -> int:
91
+ self.need(8)
92
+ v = struct.unpack_from("<Q", self.buf, self.pos)[0]
93
+ self.pos += 8
94
+ return v
95
+
96
+ def string(self) -> str:
97
+ n = self.u64()
98
+ self.need(n)
99
+ s = self.buf[self.pos:self.pos + n].decode("utf-8", errors="replace")
100
+ self.pos += n
101
+ return s
102
+
103
+ def scalar(self, t: int):
104
+ fmt, size = _SCALAR_FMT[t]
105
+ self.need(size)
106
+ v = struct.unpack_from(fmt, self.buf, self.pos)[0]
107
+ self.pos += size
108
+ return v
109
+
110
+ def value(self, t: int):
111
+ if t == GGUF_TYPE_STRING:
112
+ return self.string()
113
+ if t in _SCALAR_FMT:
114
+ return self.scalar(t)
115
+ if t == GGUF_TYPE_ARRAY:
116
+ inner = self.u32()
117
+ count = self.u64()
118
+ return [self.value(inner) for _ in range(count)]
119
+ raise ValueError(f"unsupported GGUF value type {t}")
120
+
121
+
122
+ def parse_header_bytes(buf: bytes) -> dict[str, object]:
123
+ """Parse the metadata KV map from a buffer containing the GGUF header."""
124
+ r = _Reader(buf)
125
+ magic = r.u32()
126
+ if magic != GGUF_MAGIC:
127
+ raise ValueError(f"not a GGUF file (magic={magic:#x})")
128
+ version = r.u32()
129
+ if version < 1 or version > 3:
130
+ raise ValueError(f"unsupported GGUF version {version}")
131
+ # tensor_count (u64) - skipped
132
+ r.u64()
133
+ kv_count = r.u64()
134
+ meta: dict[str, object] = {}
135
+ for _ in range(kv_count):
136
+ if r.eof():
137
+ break
138
+ key = r.string()
139
+ t = r.u32()
140
+ try:
141
+ meta[key] = r.value(t)
142
+ except (EOFError, ValueError):
143
+ # ran out of buffered bytes (range read too small) - stop
144
+ break
145
+ return meta
146
+
147
+
148
+ def _coerce_int(v: object) -> int:
149
+ if isinstance(v, bool):
150
+ return int(v)
151
+ if isinstance(v, (int, float)):
152
+ return int(v)
153
+ return 0
154
+
155
+
156
+ def _coerce_float(v: object) -> float:
157
+ if isinstance(v, (int, float)):
158
+ return float(v)
159
+ return 0.0
160
+
161
+
162
+ def metadata_to_arch(meta: dict[str, object]) -> GGUFMetadata:
163
+ """Project raw GGUF metadata into the architecture fields we need."""
164
+ arch_name = str(meta.get("general.architecture", "") or "")
165
+ p = f"{arch_name}." if arch_name else ""
166
+
167
+ def g(key: str, default=0):
168
+ return meta.get(f"{p}{key}", meta.get(key, default))
169
+
170
+ m = GGUFMetadata(raw=meta, architecture=arch_name)
171
+ m.n_layer = _coerce_int(g("block_count"))
172
+ m.n_embd = _coerce_int(g("embedding_length"))
173
+ m.n_head = _coerce_int(g("attention.head_count"))
174
+ n_head_kv = g("attention.head_count_kv")
175
+ m.n_head_kv = _coerce_int(n_head_kv) if n_head_kv is not None else m.n_head
176
+ if m.n_head_kv == 0:
177
+ m.n_head_kv = m.n_head
178
+ m.training_ctx = _coerce_int(g("context_length"))
179
+ m.rope_freq_base = _coerce_float(g("rope.freq_base", 10000.0))
180
+ m.n_expert = _coerce_int(g("expert_count"))
181
+ m.n_expert_used = _coerce_int(g("expert_used_count"))
182
+ # MTP: not always in GGUF metadata; leave 0 unless present
183
+ m.n_mtp = _coerce_int(g("mtp.count") or g("n_mtp"))
184
+ # parameter count
185
+ m.params = _coerce_int(meta.get("general.parameter_count", 0))
186
+ return m
187
+
188
+
189
+ def parse_local_file(path: str, max_bytes: int = 1 << 20) -> GGUFMetadata:
190
+ with open(path, "rb") as f:
191
+ buf = f.read(max_bytes)
192
+ return metadata_to_arch(parse_header_bytes(buf))
193
+
194
+
195
+ def parse_hf_range(
196
+ repo_id: str,
197
+ filename: str,
198
+ *,
199
+ max_bytes: int = 1 << 20,
200
+ token: str | None = None,
201
+ revision: str = "main",
202
+ timeout: float = 30.0,
203
+ ) -> GGUFMetadata:
204
+ """Range-read the first `max_bytes` of a GGUF file from the HF Hub.
205
+
206
+ Uses a HTTP Range request against the resolve URL so we never download the
207
+ full model. Requires the `requests` package.
208
+ """
209
+ import requests
210
+
211
+ url = f"https://huggingface.co/{repo_id}/resolve/{revision}/{filename}"
212
+ headers = {"Range": f"bytes=0-{max_bytes - 1}"}
213
+ if token:
214
+ headers["Authorization"] = f"Bearer {token}"
215
+ resp = requests.get(url, headers=headers, timeout=timeout, stream=True)
216
+ resp.raise_for_status()
217
+ buf = resp.content
218
+ return metadata_to_arch(parse_header_bytes(buf))
vramcalc/gpu.py ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Multi-GPU VRAM budgeting and tensor-split estimation.
2
+
3
+ llama.cpp's real `--tensor-split` distributes tensor rows across devices; we
4
+ approximate it by splitting the **weights** proportionally to each GPU's VRAM
5
+ (this is the common case when users want an even load). The KV cache and
6
+ compute scratch are placed on a single device (GPU 0 by default, or the GPU
7
+ with the most free space) because llama.cpp keeps the cache on the first
8
+ device unless manually offloaded.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from dataclasses import dataclass, field
14
+
15
+
16
+ @dataclass
17
+ class GpuBudget:
18
+ vram_bytes: list[int]
19
+
20
+
21
+ @dataclass
22
+ class GpuAssignment:
23
+ index: int
24
+ vram_bytes: int
25
+ weight_bytes: float
26
+ kv_compute_bytes: float
27
+ used_bytes: float
28
+ free_bytes: float
29
+ fits: bool
30
+
31
+
32
+ @dataclass
33
+ class GpuSplitResult:
34
+ assignments: list[GpuAssignment] = field(default_factory=list)
35
+ total_vram_bytes: int = 0
36
+ total_used_bytes: float = 0.0
37
+ total_weights_bytes: float = 0.0
38
+ total_kv_compute_bytes: float = 0.0
39
+ all_fit: bool = False
40
+ kv_gpu_index: int = 0
41
+
42
+
43
+ def gpu_split(
44
+ *,
45
+ gpu_vram_bytes: list[int],
46
+ weights_bytes: float,
47
+ kv_compute_bytes: float,
48
+ kv_on_largest: bool = False,
49
+ ) -> GpuSplitResult:
50
+ """Split weights proportionally to VRAM; place KV+compute on one GPU."""
51
+ if not gpu_vram_bytes:
52
+ return GpuSplitResult()
53
+
54
+ total_vram = sum(gpu_vram_bytes)
55
+ if total_vram <= 0:
56
+ return GpuSplitResult(
57
+ assignments=[
58
+ GpuAssignment(i, v, 0.0, 0.0, 0.0, float(v), True)
59
+ for i, v in enumerate(gpu_vram_bytes)
60
+ ],
61
+ total_vram_bytes=total_vram,
62
+ )
63
+
64
+ # weight share per gpu proportional to vram
65
+ weight_shares = [weights_bytes * (v / total_vram) for v in gpu_vram_bytes]
66
+
67
+ # choose KV host
68
+ if kv_on_largest:
69
+ kv_idx = max(range(len(gpu_vram_bytes)), key=lambda i: gpu_vram_bytes[i])
70
+ else:
71
+ kv_idx = 0
72
+
73
+ assignments = []
74
+ for i, vram in enumerate(gpu_vram_bytes):
75
+ w = weight_shares[i]
76
+ kv = kv_compute_bytes if i == kv_idx else 0.0
77
+ used = w + kv
78
+ free = vram - used
79
+ fits = used <= vram
80
+ assignments.append(
81
+ GpuAssignment(
82
+ index=i,
83
+ vram_bytes=vram,
84
+ weight_bytes=w,
85
+ kv_compute_bytes=kv,
86
+ used_bytes=used,
87
+ free_bytes=free,
88
+ fits=fits,
89
+ )
90
+ )
91
+
92
+ return GpuSplitResult(
93
+ assignments=assignments,
94
+ total_vram_bytes=total_vram,
95
+ total_used_bytes=sum(a.used_bytes for a in assignments),
96
+ total_weights_bytes=weights_bytes,
97
+ total_kv_compute_bytes=kv_compute_bytes,
98
+ all_fit=all(a.fits for a in assignments),
99
+ kv_gpu_index=kv_idx,
100
+ )
101
+
102
+
103
+ def fit_gpus(result: GpuSplitResult) -> bool:
104
+ return result.all_fit
vramcalc/kv.py ADDED
@@ -0,0 +1,106 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """KV cache and compute-scratch memory math.
2
+
3
+ Based on llama.cpp's KV cache layout:
4
+
5
+ KV = n_layer * n_ctx * 2 * n_head_kv * head_dim * sizeof(cache_dtype)
6
+
7
+ where head_dim = n_embd / n_head. The factor 2 covers both K and V. With MTP
8
+ heads (e.g. DeepSeek-V3 n_mtp=1) each head adds ~one extra layer's worth of
9
+ KV, so the layer count becomes (n_layer + n_mtp).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+
15
+ # cache dtype -> bytes per element (effective, including scales for quantized)
16
+ CACHE_DTYPE_BYTES: dict[str, float] = {
17
+ "f16": 2.0,
18
+ "bf16": 2.0,
19
+ "f32": 4.0,
20
+ "q8_0": 1.0,
21
+ "q8_1": 1.0625,
22
+ "q4_0": 0.5,
23
+ "q4_1": 0.5625,
24
+ "q5_0": 0.625,
25
+ "q5_1": 0.6875,
26
+ }
27
+
28
+ # compute dtype -> bytes per element
29
+ COMPUTE_DTYPE_BYTES: dict[str, float] = {
30
+ "f16": 2.0,
31
+ "bf16": 2.0,
32
+ "f32": 4.0,
33
+ }
34
+
35
+
36
+ def cache_dtype_bytes(dtype: str) -> float:
37
+ if dtype not in CACHE_DTYPE_BYTES:
38
+ raise ValueError(f"Unknown cache dtype: {dtype!r}")
39
+ return CACHE_DTYPE_BYTES[dtype]
40
+
41
+
42
+ def _head_dim(n_embd: int, n_head: int) -> int:
43
+ if n_head <= 0:
44
+ return 0
45
+ return n_embd // n_head
46
+
47
+
48
+ def kv_cache_bytes(
49
+ *,
50
+ n_layer: int,
51
+ n_embd: int,
52
+ n_head: int,
53
+ n_head_kv: int,
54
+ n_ctx: int,
55
+ cache_dtype: str,
56
+ n_mtp: int = 0,
57
+ flash_attn: bool = True,
58
+ ) -> float:
59
+ """Total KV cache size in bytes for the whole context window."""
60
+ head_dim = _head_dim(n_embd, n_head)
61
+ per_layer = n_ctx * 2 * n_head_kv * head_dim * cache_dtype_bytes(cache_dtype)
62
+ layers = n_layer + max(0, n_mtp)
63
+ base = per_layer * layers
64
+
65
+ # When flash attention is off AND the cache is quantized, llama.cpp keeps a
66
+ # dequantized f32 scratch for the active batch slice. We approximate the
67
+ # worst-case scratch as one batch-sized slice in f32, per layer (K+V).
68
+ if not flash_attn and cache_dtype not in ("f16", "bf16", "f32"):
69
+ # scratch is per-batch, not per-ctx; caller passes batch separately via
70
+ # compute_scratch_bytes. Here we leave it out to avoid double counting.
71
+ pass
72
+ return base
73
+
74
+
75
+ def compute_scratch_bytes(
76
+ *,
77
+ n_layer: int,
78
+ n_embd: int,
79
+ n_head: int,
80
+ n_head_kv: int,
81
+ n_batch: int,
82
+ compute_dtype: str = "f16",
83
+ cache_dtype: str = "f16",
84
+ flash_attn: bool = True,
85
+ n_mtp: int = 0,
86
+ ) -> float:
87
+ """Working/compute scratch memory in bytes.
88
+
89
+ Includes the batch activations and, when FA is off with a quantized cache,
90
+ the per-batch f32 dequantization scratch for K and V across all layers.
91
+ """
92
+ if compute_dtype not in COMPUTE_DTYPE_BYTES:
93
+ raise ValueError(f"Unknown compute dtype: {compute_dtype!r}")
94
+ cb = COMPUTE_DTYPE_BYTES[compute_dtype]
95
+
96
+ head_dim = _head_dim(n_embd, n_head)
97
+ layers = n_layer + max(0, n_mtp)
98
+
99
+ # activations: roughly batch * n_embd * compute_dtype (a couple of buffers)
100
+ activations = n_batch * n_embd * cb * 2
101
+
102
+ scratch = 0.0
103
+ if not flash_attn and cache_dtype not in ("f16", "bf16", "f32"):
104
+ # f32 dequant buffer for one batch across all layers, K and V
105
+ scratch = layers * n_batch * 2 * n_head_kv * head_dim * 4.0
106
+ return activations + scratch
vramcalc/presets.py ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Built-in model architecture presets for offline quick-start."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .report import ModelArch
6
+
7
+
8
+ PRESETS: dict[str, ModelArch] = {
9
+ "Llama-3 8B": ModelArch(
10
+ name="Llama-3 8B", architecture="llama",
11
+ n_layer=32, n_embd=4096, n_head=32, n_head_kv=8,
12
+ training_ctx=8192, params=8_030_000_000, rope_freq_base=500000.0,
13
+ ),
14
+ "Llama-3 70B": ModelArch(
15
+ name="Llama-3 70B", architecture="llama",
16
+ n_layer=80, n_embd=8192, n_head=64, n_head_kv=8,
17
+ training_ctx=8192, params=70_000_000_000, rope_freq_base=500000.0,
18
+ ),
19
+ "Llama-3.1 8B": ModelArch(
20
+ name="Llama-3.1 8B", architecture="llama",
21
+ n_layer=32, n_embd=4096, n_head=32, n_head_kv=8,
22
+ training_ctx=131072, params=8_030_000_000, rope_freq_base=500000.0,
23
+ ),
24
+ "Llama-3.1 70B": ModelArch(
25
+ name="Llama-3.1 70B", architecture="llama",
26
+ n_layer=80, n_embd=8192, n_head=64, n_head_kv=8,
27
+ training_ctx=131072, params=70_000_000_000, rope_freq_base=500000.0,
28
+ ),
29
+ "Mistral 7B": ModelArch(
30
+ name="Mistral 7B", architecture="llama",
31
+ n_layer=32, n_embd=4096, n_head=32, n_head_kv=8,
32
+ training_ctx=32768, params=7_240_000_000, rope_freq_base=10000.0,
33
+ ),
34
+ "Qwen2 7B": ModelArch(
35
+ name="Qwen2 7B", architecture="qwen2",
36
+ n_layer=28, n_embd=3584, n_head=28, n_head_kv=4,
37
+ training_ctx=32768, params=7_620_000_000, rope_freq_base=1000000.0,
38
+ ),
39
+ "Qwen2 72B": ModelArch(
40
+ name="Qwen2 72B", architecture="qwen2",
41
+ n_layer=80, n_embd=8192, n_head=64, n_head_kv=8,
42
+ training_ctx=131072, params=72_700_000_000, rope_freq_base=1000000.0,
43
+ ),
44
+ "DeepSeek-V3 (MoE)": ModelArch(
45
+ name="DeepSeek-V3", architecture="deepseek2",
46
+ n_layer=61, n_embd=7168, n_head=128, n_head_kv=128,
47
+ training_ctx=131072, params=671_000_000_000, rope_freq_base=10000.0,
48
+ n_expert=256, n_expert_used=8, n_mtp=1,
49
+ ),
50
+ "Phi-3 mini 3.8B": ModelArch(
51
+ name="Phi-3 mini", architecture="phi3",
52
+ n_layer=32, n_embd=3072, n_head=32, n_head_kv=32,
53
+ training_ctx=131072, params=3_800_000_000, rope_freq_base=10000.0,
54
+ ),
55
+ "Gemma-2 9B": ModelArch(
56
+ name="Gemma-2 9B", architecture="gemma2",
57
+ n_layer=42, n_embd=3584, n_head=16, n_head_kv=8,
58
+ training_ctx=8192, params=9_240_000_000, rope_freq_base=10000.0,
59
+ ),
60
+ }
61
+
62
+ PRESET_NAMES = list(PRESETS.keys())
vramcalc/quant.py ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Quantization -> bits-per-weight table and weight memory math.
2
+
3
+ Values are the effective bits per weight (including block scales/min) as used
4
+ by llama.cpp's k_quants / imatrix quants. Sourced from the llama.cpp docs and
5
+ the widely-cited community tables. These are estimates; real file sizes vary a
6
+ little due to alignment and metadata.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass
12
+
13
+
14
+ # quant name -> effective bits per weight
15
+ QUANT_BPW: dict[str, float] = {
16
+ # 2-bit
17
+ "Q2_K": 2.5625,
18
+ "IQ2_XXS": 2.0625,
19
+ "IQ2_XS": 2.3125,
20
+ "IQ2_S": 2.5,
21
+ "IQ2_M": 2.7,
22
+ # 3-bit
23
+ "Q3_K_S": 3.4375,
24
+ "Q3_K_M": 3.84375,
25
+ "Q3_K_L": 4.125,
26
+ "IQ3_XXS": 3.0625,
27
+ "IQ3_XS": 3.25,
28
+ "IQ3_S": 3.5,
29
+ "IQ3_M": 3.7,
30
+ # 4-bit
31
+ "Q4_0": 4.5,
32
+ "Q4_1": 5.0,
33
+ "Q4_K_S": 4.5,
34
+ "Q4_K_M": 4.84375,
35
+ "IQ4_NL": 4.5,
36
+ "IQ4_XS": 4.25,
37
+ # 5-bit
38
+ "Q5_0": 5.5,
39
+ "Q5_1": 6.0,
40
+ "Q5_K_S": 5.5,
41
+ "Q5_K_M": 5.6875,
42
+ # 6-bit
43
+ "Q6_K": 6.5625,
44
+ # 8-bit
45
+ "Q8_0": 8.5,
46
+ "Q8_1": 9.0,
47
+ # unquantized
48
+ "F16": 16.0,
49
+ "BF16": 16.0,
50
+ "F32": 32.0,
51
+ }
52
+
53
+
54
+ @dataclass(frozen=True)
55
+ class QuantInfo:
56
+ name: str
57
+ bpw: float
58
+
59
+
60
+ def quant_from_filename(filename: str) -> str | None:
61
+ """Detect a quant name from a GGUF filename like `Model-Q4_K_M.gguf`.
62
+
63
+ Returns the canonical quant name (matching a key in QUANT_BPW) or None.
64
+ Matching is case-insensitive on the quant token.
65
+ """
66
+ import re
67
+
68
+ upper = filename.upper()
69
+ # Try longer / more specific names first so e.g. Q4_K_S isn't caught by Q4_K.
70
+ ordered = sorted(QUANT_BPW.keys(), key=len, reverse=True)
71
+ for q in ordered:
72
+ # match as a token: surrounded by non-alphanumeric or string edges
73
+ if re.search(rf"(^|[^A-Z0-9]){re.escape(q.upper())}([^A-Z0-9]|$)", upper):
74
+ return q
75
+ return None
76
+
77
+
78
+ def weight_bytes(params: int, quant: str) -> float:
79
+ """Estimated weight memory in bytes for `params` parameters at `quant`.
80
+
81
+ `params` is the total parameter count (including all MoE experts; the GGUF
82
+ already encodes them so this is the natural input).
83
+ """
84
+ if quant not in QUANT_BPW:
85
+ raise ValueError(f"Unknown quant type: {quant!r}")
86
+ return params * QUANT_BPW[quant] / 8.0
vramcalc/report.py ADDED
@@ -0,0 +1,199 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Assemble a full VRAM breakdown and a llama.cpp launch-command preview."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+
7
+ from .quant import weight_bytes
8
+ from .kv import kv_cache_bytes, compute_scratch_bytes
9
+ from .yarn import yarn_effective_context, yarn_warnings
10
+ from .gpu import gpu_split, GpuSplitResult
11
+
12
+
13
+ @dataclass
14
+ class ModelArch:
15
+ """Architecture parameters (fetched from GGUF or entered manually)."""
16
+ name: str = ""
17
+ architecture: str = ""
18
+ n_layer: int = 0
19
+ n_embd: int = 0
20
+ n_head: int = 0
21
+ n_head_kv: int = 0
22
+ training_ctx: int = 0
23
+ params: int = 0 # total params including MoE experts
24
+ rope_freq_base: float = 10000.0
25
+ n_expert: int = 0 # MoE
26
+ n_expert_used: int = 0
27
+ n_mtp: int = 0 # MTP heads (e.g. DeepSeek-V3 = 1)
28
+
29
+
30
+ @dataclass
31
+ class Inputs:
32
+ quant: str = "Q4_K_M"
33
+ n_ctx: int = 8192
34
+ cache_dtype: str = "f16"
35
+ flash_attn: bool = True
36
+ compute_dtype: str = "f16"
37
+ n_batch: int = 512
38
+ n_prompt: int = 0
39
+ # YaRN
40
+ rope_freq_scale: float = 1.0
41
+ yarn_ext_factor: float = -1.0
42
+ yarn_attn_factor: float = 1.0
43
+ yarn_beta_fast: float = 32.0
44
+ yarn_beta_slow: float = 1.0
45
+ # GPU
46
+ gpu_vram_gb: list[float] = field(default_factory=lambda: [24.0])
47
+ kv_on_largest: bool = False
48
+ # margin
49
+ safety_margin_pct: float = 5.0
50
+
51
+
52
+ @dataclass
53
+ class Breakdown:
54
+ weights_bytes: float = 0.0
55
+ kv_cache_bytes: float = 0.0
56
+ compute_scratch_bytes: float = 0.0
57
+ mtp_overhead_bytes: float = 0.0
58
+ gguf_overhead_bytes: float = 0.0
59
+ safety_margin_bytes: float = 0.0
60
+ total_bytes: float = 0.0
61
+ effective_context: int = 0
62
+ warnings: list[str] = field(default_factory=list)
63
+ gpu: GpuSplitResult | None = None
64
+
65
+
66
+ def estimate(arch: ModelArch, inp: Inputs) -> Breakdown:
67
+ weights = weight_bytes(arch.params, inp.quant)
68
+
69
+ # KV cache (with MTP layers folded in)
70
+ kv = kv_cache_bytes(
71
+ n_layer=arch.n_layer,
72
+ n_embd=arch.n_embd,
73
+ n_head=arch.n_head,
74
+ n_head_kv=arch.n_head_kv,
75
+ n_ctx=inp.n_ctx,
76
+ cache_dtype=inp.cache_dtype,
77
+ n_mtp=arch.n_mtp,
78
+ flash_attn=inp.flash_attn,
79
+ )
80
+
81
+ # compute / activation scratch
82
+ scratch = compute_scratch_bytes(
83
+ n_layer=arch.n_layer,
84
+ n_embd=arch.n_embd,
85
+ n_head=arch.n_head,
86
+ n_head_kv=arch.n_head_kv,
87
+ n_batch=inp.n_batch,
88
+ compute_dtype=inp.compute_dtype,
89
+ cache_dtype=inp.cache_dtype,
90
+ flash_attn=inp.flash_attn,
91
+ n_mtp=arch.n_mtp,
92
+ )
93
+
94
+ # isolate MTP overhead for display: the extra layer's weights + its KV share
95
+ mtp_overhead = 0.0
96
+ if arch.n_mtp > 0:
97
+ # approximate extra weight as one layer's worth: params/n_layer * bpw/8
98
+ if arch.n_layer > 0:
99
+ per_layer_params = arch.params / arch.n_layer
100
+ mtp_weights = per_layer_params * (
101
+ __import__("vramcalc.quant", fromlist=["QUANT_BPW"]).QUANT_BPW[inp.quant] / 8.0
102
+ )
103
+ else:
104
+ mtp_weights = 0.0
105
+ # KV for the extra layers
106
+ head_dim = arch.n_embd // arch.n_head if arch.n_head > 0 else 0
107
+ mtp_kv = (
108
+ arch.n_mtp * inp.n_ctx * 2 * arch.n_head_kv * head_dim
109
+ * __import__("vramcalc.kv", fromlist=["cache_dtype_bytes"]).cache_dtype_bytes(inp.cache_dtype)
110
+ )
111
+ mtp_overhead = mtp_weights + mtp_kv
112
+
113
+ # GGUF header / alignment overhead: small constant per file, rough estimate
114
+ gguf_overhead = max(arch.n_layer * 4096, 1 << 20) # >= 1 MiB
115
+
116
+ subtotal = weights + kv + scratch + gguf_overhead
117
+ margin = subtotal * (inp.safety_margin_pct / 100.0)
118
+ total = subtotal + margin
119
+
120
+ eff_ctx = yarn_effective_context(arch.training_ctx, inp.rope_freq_scale)
121
+ warns = yarn_warnings(
122
+ training_ctx=arch.training_ctx,
123
+ target_ctx=inp.n_ctx,
124
+ rope_freq_scale=inp.rope_freq_scale,
125
+ yarn_ext_factor=inp.yarn_ext_factor,
126
+ yarn_attn_factor=inp.yarn_attn_factor,
127
+ )
128
+
129
+ gpu_vram_bytes = [int(g * (1 << 30)) for g in inp.gpu_vram_gb]
130
+ gpu = gpu_split(
131
+ gpu_vram_bytes=gpu_vram_bytes,
132
+ weights_bytes=weights,
133
+ kv_compute_bytes=kv + scratch,
134
+ kv_on_largest=inp.kv_on_largest,
135
+ )
136
+
137
+ return Breakdown(
138
+ weights_bytes=weights,
139
+ kv_cache_bytes=kv,
140
+ compute_scratch_bytes=scratch,
141
+ mtp_overhead_bytes=mtp_overhead,
142
+ gguf_overhead_bytes=gguf_overhead,
143
+ safety_margin_bytes=margin,
144
+ total_bytes=total,
145
+ effective_context=eff_ctx,
146
+ warnings=warns,
147
+ gpu=gpu,
148
+ )
149
+
150
+
151
+ def format_bytes(n: float) -> str:
152
+ """Human-readable byte size."""
153
+ n = float(n)
154
+ if n < 0:
155
+ return "-" + format_bytes(-n)
156
+ units = [("GiB", 1 << 30), ("MiB", 1 << 20), ("KiB", 1 << 10)]
157
+ for label, size in units:
158
+ if n >= size:
159
+ return f"{n / size:.2f} {label}"
160
+ return f"{n:.0f} B"
161
+
162
+
163
+ def command_preview(arch: ModelArch, inp: Inputs) -> str:
164
+ """Generate a llama.cpp launch command from the current inputs."""
165
+ parts = ["llama-server"]
166
+ parts.append(f"-m model-{inp.quant}.gguf")
167
+ parts.append(f"-c {inp.n_ctx}")
168
+ parts.append("-ngl 999") # full offload assumption
169
+ parts.append(f"-b {inp.n_batch}")
170
+ if inp.cache_dtype != "f16":
171
+ parts.append(f"--cache-type-k {inp.cache_dtype}")
172
+ parts.append(f"--cache-type-v {inp.cache_dtype}")
173
+ if inp.flash_attn:
174
+ parts.append("--flash-attn")
175
+ if len(inp.gpu_vram_gb) > 1:
176
+ # tensor-split proportional to vram
177
+ split = ",".join(f"{g}" for g in inp.gpu_vram_gb)
178
+ parts.append(f"--tensor-split {split}")
179
+ # YaRN / rope
180
+ yarn_args = []
181
+ if inp.rope_freq_scale != 1.0:
182
+ yarn_args.append(f"--rope-freq-scale {inp.rope_freq_scale}")
183
+ if arch.rope_freq_base != 10000.0:
184
+ yarn_args.append(f"--rope-freq-base {arch.rope_freq_base}")
185
+ if inp.yarn_ext_factor >= 0.0:
186
+ yarn_args.append(f"--yarn-ext-factor {inp.yarn_ext_factor}")
187
+ if inp.yarn_attn_factor != 1.0:
188
+ yarn_args.append(f"--yarn-attn-factor {inp.yarn_attn_factor}")
189
+ if inp.yarn_beta_fast != 32.0:
190
+ yarn_args.append(f"--yarn-beta-fast {inp.yarn_beta_fast}")
191
+ if inp.yarn_beta_slow != 1.0:
192
+ yarn_args.append(f"--yarn-beta-slow {inp.yarn_beta_slow}")
193
+ if inp.n_ctx > arch.training_ctx and arch.training_ctx > 0:
194
+ yarn_args.append("--rope-scaling yarn")
195
+ if yarn_args:
196
+ parts.extend(yarn_args)
197
+ if arch.n_mtp > 0:
198
+ parts.append(f"--mtp {arch.n_mtp}")
199
+ return " \\\n ".join(parts)
vramcalc/yarn.py ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """YaRN / RoPE context-extension sanity checks.
2
+
3
+ YaRN changes how the model *interprets* positions, not the per-token KV size,
4
+ so KV cache memory is driven by the target context length the user requests.
5
+ These helpers surface warnings when the target context exceeds the model's
6
+ training context (YaRN scaling required) and show the effective context as a
7
+ sanity check.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+
13
+ def yarn_effective_context(training_ctx: int, rope_freq_scale: float) -> int:
14
+ """Effective (interpolated) context given a rope_freq_scale.
15
+
16
+ effective = training_ctx / rope_freq_scale (rope_freq_scale < 1 extends).
17
+ """
18
+ if rope_freq_scale <= 0:
19
+ return training_ctx
20
+ return int(round(training_ctx / rope_freq_scale))
21
+
22
+
23
+ def yarn_warnings(
24
+ *,
25
+ training_ctx: int,
26
+ target_ctx: int,
27
+ rope_freq_scale: float,
28
+ yarn_ext_factor: float,
29
+ yarn_attn_factor: float,
30
+ ) -> list[str]:
31
+ """Return human-readable warnings about the YaRN / context config."""
32
+ warns: list[str] = []
33
+
34
+ if target_ctx > training_ctx:
35
+ warns.append(
36
+ f"Target context {target_ctx} exceeds training context "
37
+ f"{training_ctx}; YaRN/RoPE scaling is required for coherent "
38
+ f"long-context output."
39
+ )
40
+
41
+ eff = yarn_effective_context(training_ctx, rope_freq_scale)
42
+ if target_ctx > training_ctx and eff < target_ctx:
43
+ warns.append(
44
+ f"Effective context from rope_freq_scale={rope_freq_scale} is "
45
+ f"{eff}, which is below the target {target_ctx}. The model may "
46
+ f"not fully cover the requested window; lower rope_freq_scale "
47
+ f"to interpolate more aggressively."
48
+ )
49
+
50
+ if target_ctx > training_ctx and yarn_ext_factor < 0.0:
51
+ warns.append("yarn_ext_factor is negative; YaRN extrapolation is off.")
52
+
53
+ if yarn_attn_factor < 1.0:
54
+ warns.append(
55
+ "yarn_attn_factor < 1.0 scales attention down; use ~1.0 for "
56
+ "normal long-context YaRN (lower only for high-context YaRN fine-tunes)."
57
+ )
58
+
59
+ return warns