3v324v23 Claude commited on
Commit
b53ad2f
·
1 Parent(s): b810d56

VRAM Calculator v2: unified memory, real multi-GPU, spec decoding, multimodal, MTP, auto-fit

Browse files

Rewrite the GPU budget to match llama.cpp's real --split-mode behavior
and add the missing components and a solver.

gpu.py: GpuSpec (with is_unified for Strix-Halo-style shared memory);
gpu_split now handles layer (KV distributed with weights, the default
pipeline-parallel mode), tensor (splits weights+KV, warns on quantized
KV / requires FA), and none (all on main-gpu). Supports explicit
--tensor-split ratios and mixed-GPU sizes. Removes the old
kv_on_largest approximation.

draft.py: DraftInputs + draft_bytes for speculative decoding (-md);
weights + own KV, rides on the main GPU.

mmproj.py: mmproj_bytes_from_tensors sums tensor element counts x dtype
bpe from a parsed mmproj GGUF header; folded into total + split.

report.py: Inputs gains split_mode/main_gpu/tensor_split/unified_flags/
mtp_cache_dtype/draft/mmproj. estimate folds draft + mmproj + MTP KV
into the total and the split; MTP extra-layer KV can use a separate
dtype. command_preview emits --split-mode/--main-gpu/--tensor-split
(only when explicit), -md/--spec-type/--spec-draft-*, --mmproj
(+--no-mmproj-offload), and a ROCmFPX note. Breakdown gains draft and
mmproj rows.

autofit.py: max_context (binary search n_ctx that fits), best_quant
(highest-bpw quant that fits + fallback), min_gpu_setup (smallest
largest-first GPU subset). Pure, reuse estimate.

app.py: new split-mode/main-gpu/tensor-split controls, MTP draft KV
dtype dropdown, Speculative decoding + Multimodal accordions, an mmproj
file picker (fetches its size from the header), and an Auto-fit tab
with the three solvers. Per-GPU table shows unified flag and split mode.

46 tests (was 31); verified end-to-end on the Qwen3.6-27B arch with
24GB discrete + 16GB unified, draft model, and mmproj.

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

app.py CHANGED
@@ -17,11 +17,17 @@ from vramcalc import (
17
  QUANT_BPW,
18
  ModelArch,
19
  Inputs,
 
 
20
  estimate,
21
  command_preview,
22
  format_bytes,
23
  quant_from_filename,
24
  parse_hf_range,
 
 
 
 
25
  )
26
  from vramcalc.presets import PRESETS, PRESET_NAMES
27
 
@@ -29,6 +35,9 @@ from vramcalc.presets import PRESETS, PRESET_NAMES
29
  QUANT_CHOICES = list(QUANT_BPW.keys())
30
  CACHE_DTYPES = ["f16", "bf16", "f32", "q8_0", "q8_1", "q4_0", "q4_1", "q5_0", "q5_1"]
31
  COMPUTE_DTYPES = ["f16", "bf16", "f32"]
 
 
 
32
 
33
 
34
  def list_gguf_files(repo_id: str, hf_token: str):
@@ -102,6 +111,112 @@ def _empty_arch_fields():
102
  return _arch_to_fields(ModelArch())
103
 
104
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
105
  def _arch_to_fields(m: ModelArch):
106
  return [
107
  m.name, m.architecture, m.n_layer, m.n_embd, m.n_head, m.n_head_kv,
@@ -153,6 +268,29 @@ def _parse_gpu_list(text: str) -> list[float]:
153
  return out or [24.0]
154
 
155
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
156
  @spaces.GPU
157
  def compute(
158
  arch_fields,
@@ -160,10 +298,41 @@ def compute(
160
  n_batch, n_prompt,
161
  rope_freq_scale, yarn_ext_factor, yarn_attn_factor,
162
  yarn_beta_fast, yarn_beta_slow,
163
- gpu_vram_text, kv_on_largest, safety_margin,
 
 
 
 
164
  ):
165
  arch = _fields_to_arch(arch_fields)
166
- gpus = _parse_gpu_list(gpu_vram_text)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
167
  inp = Inputs(
168
  quant=quant,
169
  n_ctx=int(n_ctx),
@@ -178,7 +347,13 @@ def compute(
178
  yarn_beta_fast=float(yarn_beta_fast),
179
  yarn_beta_slow=float(yarn_beta_slow),
180
  gpu_vram_gb=gpus,
181
- kv_on_largest=bool(kv_on_largest),
 
 
 
 
 
 
182
  safety_margin_pct=float(safety_margin),
183
  )
184
  if arch.n_layer <= 0 or arch.n_embd <= 0 or arch.params <= 0:
@@ -195,6 +370,12 @@ def compute(
195
  ["KV cache (" + cache_dtype + ")", format_bytes(bd.kv_cache_bytes)],
196
  ["Compute / scratch", format_bytes(bd.compute_scratch_bytes)],
197
  ["MTP overhead", format_bytes(bd.mtp_overhead_bytes)],
 
 
 
 
 
 
198
  ["GGUF header / overhead", format_bytes(bd.gguf_overhead_bytes)],
199
  ["Safety margin (" + str(inp.safety_margin_pct) + "%)",
200
  format_bytes(bd.safety_margin_bytes)],
@@ -207,7 +388,7 @@ def compute(
207
  # warnings
208
  warn_md = ""
209
  if bd.warnings:
210
- warn_md = "\n\n**⚠️ YaRN / context notes:**\n" + "\n".join(
211
  f"- {w}" for w in bd.warnings
212
  )
213
  eff_md = (
@@ -219,25 +400,27 @@ def compute(
219
  gpu_md = ""
220
  if bd.gpu and bd.gpu.assignments:
221
  a = bd.gpu.assignments
222
- header = "| GPU | VRAM | Weights | KV+Compute | Used | Free | Fits? |"
223
  sep = "|---|---|---|---|---|---|---|"
224
  body = []
225
  for g in a:
226
  kv = format_bytes(g.kv_compute_bytes) if g.kv_compute_bytes else "—"
227
  badge = "✅" if g.fits else "❌"
 
 
228
  body.append(
229
- f"| {g.index} | {format_bytes(g.vram_bytes)} | "
230
  f"{format_bytes(g.weight_bytes)} | {kv} | "
231
  f"{format_bytes(g.used_bytes)} | {format_bytes(g.free_bytes)} "
232
  f"| {badge} |"
233
  )
234
  total_badge = "✅ all fit" if bd.gpu.all_fit else "❌ over budget"
235
  gpu_md = (
236
- "**Per-GPU split (estimate, proportional weight split):**\n\n"
237
  + header + "\n" + sep + "\n" + "\n".join(body)
238
  + f"\n\nTotal VRAM: {format_bytes(bd.gpu.total_vram_bytes)} · "
239
  f"Total used: {format_bytes(bd.gpu.total_used_bytes)} · "
240
- f"{total_badge} · KV on GPU {bd.gpu.kv_gpu_index}"
241
  )
242
 
243
  cmd = command_preview(arch, inp)
@@ -281,6 +464,15 @@ def build_ui():
281
  )
282
  fetch_btn = gr.Button("Fetch architecture from GGUF header")
283
  fetch_status = gr.Markdown("")
 
 
 
 
 
 
 
 
 
284
 
285
  with gr.Tab("Presets / manual"):
286
  preset_dd = gr.Dropdown(
@@ -322,25 +514,74 @@ def build_ui():
322
  n_batch = gr.Number(label="n_batch", value=512, precision=0)
323
  n_prompt = gr.Number(label="n_prompt (active)", value=0, precision=0)
324
 
325
- gr.Markdown("### YaRN / RoPE context extension")
326
  with gr.Row():
327
- rope_freq_scale = gr.Number(label="rope_freq_scale", value=1.0)
328
- yarn_ext_factor = gr.Number(label="yarn_ext_factor", value=-1.0)
329
- yarn_attn_factor = gr.Number(label="yarn_attn_factor", value=1.0)
330
- with gr.Row():
331
- yarn_beta_fast = gr.Number(label="yarn_beta_fast", value=32.0)
332
- yarn_beta_slow = gr.Number(label="yarn_beta_slow", value=1.0)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
333
 
334
  gr.Markdown("### 3. Multi-GPU budget")
335
  gpu_vram_text = gr.Textbox(
336
- label="Per-GPU VRAM (GB, comma-separated)",
337
  value="24",
338
- placeholder="e.g. 24,24,16",
339
  )
340
  with gr.Row():
341
- kv_on_largest = gr.Checkbox(
342
- label="Place KV cache on largest GPU", value=False
343
  )
 
 
 
 
 
 
 
344
  safety_margin = gr.Number(label="Safety margin %", value=5.0)
345
 
346
  compute_btn = gr.Button("Compute VRAM", variant="primary")
@@ -353,6 +594,37 @@ def build_ui():
353
  label="Command (copyable)", lines=8, interactive=False
354
  )
355
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
356
  # --- wiring ---
357
  def _store_arch(*fields):
358
  return list(fields)
@@ -381,6 +653,16 @@ def build_ui():
381
  fn=_store_arch, inputs=arch_inputs, outputs=arch_state
382
  )
383
 
 
 
 
 
 
 
 
 
 
 
384
  compute_btn.click(
385
  fn=compute,
386
  inputs=[
@@ -388,11 +670,37 @@ def build_ui():
388
  n_batch, n_prompt,
389
  rope_freq_scale, yarn_ext_factor, yarn_attn_factor,
390
  yarn_beta_fast, yarn_beta_slow,
391
- gpu_vram_text, kv_on_largest, safety_margin,
 
 
 
 
 
392
  ],
393
  outputs=[result_md, cmd_md, cmd_text],
394
  )
395
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
396
  return demo
397
 
398
 
 
17
  QUANT_BPW,
18
  ModelArch,
19
  Inputs,
20
+ DraftInputs,
21
+ Mmproj,
22
  estimate,
23
  command_preview,
24
  format_bytes,
25
  quant_from_filename,
26
  parse_hf_range,
27
+ max_context,
28
+ best_quant,
29
+ min_gpu_setup,
30
+ mmproj_bytes_from_tensors,
31
  )
32
  from vramcalc.presets import PRESETS, PRESET_NAMES
33
 
 
35
  QUANT_CHOICES = list(QUANT_BPW.keys())
36
  CACHE_DTYPES = ["f16", "bf16", "f32", "q8_0", "q8_1", "q4_0", "q4_1", "q5_0", "q5_1"]
37
  COMPUTE_DTYPES = ["f16", "bf16", "f32"]
38
+ SPLIT_MODES = ["layer", "tensor", "none"]
39
+ SPEC_TYPES = ["none", "draft", "draft-eagle3", "draft-dflash"]
40
+ MTP_CACHE_DTYPES = ["(same as target)", "f16", "bf16", "f32", "q8_0"]
41
 
42
 
43
  def list_gguf_files(repo_id: str, hf_token: str):
 
111
  return _arch_to_fields(ModelArch())
112
 
113
 
114
+ def list_mmproj_files(repo_id: str, hf_token: str):
115
+ """List mmproj-*.gguf files in a repo (for the multimodal projector picker)."""
116
+ if not repo_id or not repo_id.strip():
117
+ return gr.update(choices=[], value=None), ""
118
+ try:
119
+ api = HfApi(token=hf_token or None)
120
+ files = api.list_repo_files(repo_id=repo_id.strip())
121
+ except Exception as e: # noqa: BLE001
122
+ return gr.update(choices=[], value=None), f"Error listing repo: {e}"
123
+ mmps = sorted(
124
+ f for f in files
125
+ if f.lower().endswith(".gguf") and "mmproj" in f.lower()
126
+ )
127
+ if not mmps:
128
+ return gr.update(choices=[], value=None), "No mmproj files in this repo."
129
+ choices = [(f, f) for f in mmps]
130
+ return gr.update(choices=choices, value=mmps[0]), f"Found {len(mmps)} mmproj file(s)."
131
+
132
+
133
+ def fetch_mmproj_bytes(repo_id: str, filename: str, hf_token: str):
134
+ """Range-read an mmproj GGUF header and estimate its weight bytes."""
135
+ if not repo_id or not filename:
136
+ return 0, "Pick an mmproj file first."
137
+ try:
138
+ from vramcalc.gguf import parse_hf_range, parse_header_with_tensors
139
+ # parse_hf_range returns a GGUFMetadata; use the raw dict's dtype hist.
140
+ meta = parse_hf_range(repo_id.strip(), filename, token=hf_token or None)
141
+ except Exception as e: # noqa: BLE001
142
+ return 0, f"Error reading mmproj header: {e}"
143
+ hist = meta.raw.get(".tensor_dtype_hist") if meta.raw else None
144
+ if isinstance(hist, dict) and hist:
145
+ b = mmproj_bytes_from_tensors({int(k): int(v) for k, v in hist.items()})
146
+ else:
147
+ # fall back to params * f16 bpw
148
+ b = (meta.params or 0) * 2.0
149
+ return int(b), f"mmproj {filename}: ~{format_bytes(b)} ({meta.params or 0} elems)"
150
+
151
+
152
+ def _base_inputs(arch_fields, n_ctx, quant, gpu_vram_text, split_mode, main_gpu,
153
+ n_batch, cache_dtype, flash_attn, compute_dtype, safety_margin):
154
+ """Build (ModelArch, Inputs) for the auto-fit solvers from UI fields."""
155
+ arch = _fields_to_arch(arch_fields)
156
+ gpus = _parse_gpu_list(gpu_vram_text.replace("u", "").replace("U", ""))
157
+ unified = _parse_unified_flags(gpu_vram_text)
158
+ inp = Inputs(
159
+ quant=quant, n_ctx=int(n_ctx or 8192),
160
+ cache_dtype=cache_dtype, flash_attn=bool(flash_attn),
161
+ compute_dtype=compute_dtype, n_batch=int(n_batch or 512),
162
+ gpu_vram_gb=gpus, split_mode=split_mode or "layer",
163
+ main_gpu=int(main_gpu or 0), unified_flags=unified,
164
+ safety_margin_pct=float(safety_margin or 5.0),
165
+ )
166
+ return arch, inp
167
+
168
+
169
+ def run_max_context(arch_fields, quant, gpu_vram_text, split_mode, main_gpu,
170
+ n_batch, cache_dtype, flash_attn, compute_dtype, safety_margin):
171
+ arch, inp = _base_inputs(arch_fields, 8192, quant, gpu_vram_text, split_mode,
172
+ main_gpu, n_batch, cache_dtype, flash_attn,
173
+ compute_dtype, safety_margin)
174
+ if arch.n_layer <= 0 or arch.params <= 0:
175
+ return "⚠️ Architecture incomplete — fetch a GGUF or load a preset first."
176
+ res = max_context(arch, inp, step=1024, max_ctx=max(131072, arch.training_ctx * 4))
177
+ if not res.fits:
178
+ return f"❌ {res.note}\n\n(Total @ n_ctx={res.n_ctx}: {format_bytes(res.total_bytes)})"
179
+ return (
180
+ f"**Max context that fits:** **{res.n_ctx:,}** tokens\n\n"
181
+ f"Total VRAM: {format_bytes(res.total_bytes)}\n\n"
182
+ f"_{res.note}_"
183
+ )
184
+
185
+
186
+ def run_best_quant(arch_fields, n_ctx, gpu_vram_text, split_mode, main_gpu,
187
+ n_batch, cache_dtype, flash_attn, compute_dtype, safety_margin):
188
+ arch, inp = _base_inputs(arch_fields, n_ctx, "Q4_K_M", gpu_vram_text, split_mode,
189
+ main_gpu, n_batch, cache_dtype, flash_attn,
190
+ compute_dtype, safety_margin)
191
+ if arch.n_layer <= 0 or arch.params <= 0:
192
+ return "⚠️ Architecture incomplete — fetch a GGUF or load a preset first."
193
+ res = best_quant(arch, inp)
194
+ if not res.fits:
195
+ return f"❌ {res.note}\n\nLowest-bpw attempt: {res.fallback_quant} ({res.fallback_bpw} bpw) → {format_bytes(res.fallback_total)}"
196
+ lines = [
197
+ f"**Recommended quant:** **{res.quant}** ({res.bpw} bpw) → {format_bytes(res.total_bytes)}",
198
+ ]
199
+ if res.fallback_quant:
200
+ lines.append(
201
+ f"**Safe fallback:** {res.fallback_quant} ({res.fallback_bpw} bpw) → "
202
+ f"{format_bytes(res.fallback_total)}"
203
+ )
204
+ return "\n\n".join(lines)
205
+
206
+
207
+ def run_min_gpu_setup(arch_fields, n_ctx, quant, gpu_vram_text, split_mode, main_gpu,
208
+ n_batch, cache_dtype, flash_attn, compute_dtype, safety_margin):
209
+ arch, inp = _base_inputs(arch_fields, n_ctx, quant, gpu_vram_text, split_mode,
210
+ main_gpu, n_batch, cache_dtype, flash_attn,
211
+ compute_dtype, safety_margin)
212
+ if arch.n_layer <= 0 or arch.params <= 0:
213
+ return "⚠️ Architecture incomplete — fetch a GGUF or load a preset first."
214
+ res = min_gpu_setup(arch, inp)
215
+ badge = "✅" if res.fits else "❌"
216
+ subset = ", ".join(f"{g} GB" for g in res.subset)
217
+ return f"{badge} {res.note}\n\nSubset: [{subset}]\n\nTotal: {format_bytes(res.total_bytes)}"
218
+
219
+
220
  def _arch_to_fields(m: ModelArch):
221
  return [
222
  m.name, m.architecture, m.n_layer, m.n_embd, m.n_head, m.n_head_kv,
 
268
  return out or [24.0]
269
 
270
 
271
+ def _parse_unified_flags(text: str) -> list[bool]:
272
+ """Parse the 'u' suffix per device: 24,16u -> [False, True]."""
273
+ flags = []
274
+ for tok in (text or "").replace(";", ",").split(","):
275
+ tok = tok.strip().lower()
276
+ if not tok:
277
+ continue
278
+ flags.append(tok.endswith("u"))
279
+ return flags
280
+
281
+
282
+ def _parse_tensor_split(text: str) -> list[float] | None:
283
+ out = []
284
+ for tok in (text or "").replace(";", ",").split(","):
285
+ tok = tok.strip()
286
+ if tok:
287
+ try:
288
+ out.append(float(tok))
289
+ except ValueError:
290
+ pass
291
+ return out or None
292
+
293
+
294
  @spaces.GPU
295
  def compute(
296
  arch_fields,
 
298
  n_batch, n_prompt,
299
  rope_freq_scale, yarn_ext_factor, yarn_attn_factor,
300
  yarn_beta_fast, yarn_beta_slow,
301
+ gpu_vram_text, split_mode, main_gpu, tensor_split_text, safety_margin,
302
+ mtp_cache_dtype,
303
+ spec_type, draft_quant, draft_params, draft_n_layer, draft_n_max,
304
+ draft_p_min, draft_p_split,
305
+ mmproj_enabled, mmproj_file, mmproj_offload, mmproj_bytes_text,
306
  ):
307
  arch = _fields_to_arch(arch_fields)
308
+ # GPU text may carry a 'u' suffix for unified memory; strip it for vram_gb
309
+ gpus = _parse_gpu_list(gpu_vram_text.replace("u", "").replace("U", ""))
310
+ unified = _parse_unified_flags(gpu_vram_text)
311
+ tensor_split = _parse_tensor_split(tensor_split_text)
312
+
313
+ draft = None
314
+ if spec_type and spec_type != "none" and draft_params:
315
+ draft = DraftInputs(
316
+ spec_type=spec_type,
317
+ quant=draft_quant,
318
+ params=int(draft_params or 0),
319
+ n_layer=int(draft_n_layer or 1),
320
+ n_ctx=int(n_ctx),
321
+ cache_dtype=cache_dtype,
322
+ n_max=int(draft_n_max or 0),
323
+ p_min=float(draft_p_min or 0.0),
324
+ p_split=float(draft_p_split or 0.0),
325
+ )
326
+
327
+ mmproj = None
328
+ if mmproj_enabled:
329
+ mmproj = Mmproj(
330
+ filename=mmproj_file or "",
331
+ enabled=True,
332
+ offload=bool(mmproj_offload),
333
+ bytes_=float(mmproj_bytes_text or 0.0),
334
+ )
335
+
336
  inp = Inputs(
337
  quant=quant,
338
  n_ctx=int(n_ctx),
 
347
  yarn_beta_fast=float(yarn_beta_fast),
348
  yarn_beta_slow=float(yarn_beta_slow),
349
  gpu_vram_gb=gpus,
350
+ split_mode=split_mode or "layer",
351
+ main_gpu=int(main_gpu or 0),
352
+ tensor_split=tensor_split,
353
+ unified_flags=unified,
354
+ mtp_cache_dtype=mtp_cache_dtype if mtp_cache_dtype else None,
355
+ draft=draft,
356
+ mmproj=mmproj,
357
  safety_margin_pct=float(safety_margin),
358
  )
359
  if arch.n_layer <= 0 or arch.n_embd <= 0 or arch.params <= 0:
 
370
  ["KV cache (" + cache_dtype + ")", format_bytes(bd.kv_cache_bytes)],
371
  ["Compute / scratch", format_bytes(bd.compute_scratch_bytes)],
372
  ["MTP overhead", format_bytes(bd.mtp_overhead_bytes)],
373
+ ]
374
+ if bd.draft_bytes_:
375
+ rows.append(["Draft model (spec. decoding)", format_bytes(bd.draft_bytes_)])
376
+ if bd.mmproj_bytes_:
377
+ rows.append(["Multimodal projector", format_bytes(bd.mmproj_bytes_)])
378
+ rows += [
379
  ["GGUF header / overhead", format_bytes(bd.gguf_overhead_bytes)],
380
  ["Safety margin (" + str(inp.safety_margin_pct) + "%)",
381
  format_bytes(bd.safety_margin_bytes)],
 
388
  # warnings
389
  warn_md = ""
390
  if bd.warnings:
391
+ warn_md = "\n\n**⚠️ Notes:**\n" + "\n".join(
392
  f"- {w}" for w in bd.warnings
393
  )
394
  eff_md = (
 
400
  gpu_md = ""
401
  if bd.gpu and bd.gpu.assignments:
402
  a = bd.gpu.assignments
403
+ header = "| GPU | VRAM | Weights | KV | Used | Free | Fits? |"
404
  sep = "|---|---|---|---|---|---|---|"
405
  body = []
406
  for g in a:
407
  kv = format_bytes(g.kv_compute_bytes) if g.kv_compute_bytes else "—"
408
  badge = "✅" if g.fits else "❌"
409
+ tag = f" ({g.name})" if g.name else ""
410
+ unote = " 🔁" if g.is_unified else ""
411
  body.append(
412
+ f"| {g.index}{tag}{unote} | {format_bytes(g.vram_bytes)} | "
413
  f"{format_bytes(g.weight_bytes)} | {kv} | "
414
  f"{format_bytes(g.used_bytes)} | {format_bytes(g.free_bytes)} "
415
  f"| {badge} |"
416
  )
417
  total_badge = "✅ all fit" if bd.gpu.all_fit else "❌ over budget"
418
  gpu_md = (
419
+ f"**Per-GPU split ({bd.gpu.split_mode} mode):**\n\n"
420
  + header + "\n" + sep + "\n" + "\n".join(body)
421
  + f"\n\nTotal VRAM: {format_bytes(bd.gpu.total_vram_bytes)} · "
422
  f"Total used: {format_bytes(bd.gpu.total_used_bytes)} · "
423
+ f"{total_badge}"
424
  )
425
 
426
  cmd = command_preview(arch, inp)
 
464
  )
465
  fetch_btn = gr.Button("Fetch architecture from GGUF header")
466
  fetch_status = gr.Markdown("")
467
+ mmproj_picker = gr.Dropdown(
468
+ label="mmproj file (optional, multimodal)",
469
+ choices=[], interactive=True,
470
+ )
471
+ mmproj_bytes_box = gr.Number(
472
+ label="mmproj weight bytes (auto from header; edit if needed)",
473
+ value=0, precision=0,
474
+ )
475
+ fetch_mmproj_btn = gr.Button("Fetch mmproj size from header")
476
 
477
  with gr.Tab("Presets / manual"):
478
  preset_dd = gr.Dropdown(
 
514
  n_batch = gr.Number(label="n_batch", value=512, precision=0)
515
  n_prompt = gr.Number(label="n_prompt (active)", value=0, precision=0)
516
 
517
+ # MTP draft KV dtype (only meaningful when n_mtp > 0)
518
  with gr.Row():
519
+ mtp_cache_dtype = gr.Dropdown(
520
+ label="MTP draft KV dtype",
521
+ choices=MTP_CACHE_DTYPES, value="(same as target)",
522
+ )
523
+
524
+ with gr.Accordion("YaRN / RoPE context extension", open=False):
525
+ with gr.Row():
526
+ rope_freq_scale = gr.Number(label="rope_freq_scale", value=1.0)
527
+ yarn_ext_factor = gr.Number(label="yarn_ext_factor", value=-1.0)
528
+ yarn_attn_factor = gr.Number(label="yarn_attn_factor", value=1.0)
529
+ with gr.Row():
530
+ yarn_beta_fast = gr.Number(label="yarn_beta_fast", value=32.0)
531
+ yarn_beta_slow = gr.Number(label="yarn_beta_slow", value=1.0)
532
+
533
+ with gr.Accordion("Speculative decoding (draft model)", open=False):
534
+ spec_type = gr.Dropdown(
535
+ label="Spec type", choices=SPEC_TYPES, value="none"
536
+ )
537
+ with gr.Row():
538
+ draft_quant = gr.Dropdown(
539
+ label="Draft quant", choices=QUANT_CHOICES, value="Q4_K_M"
540
+ )
541
+ draft_params = gr.Number(
542
+ label="Draft params", value=0, precision=0
543
+ )
544
+ draft_n_layer = gr.Number(
545
+ label="Draft n_layer (1 for EAGLE-3)",
546
+ value=1, precision=0,
547
+ )
548
+ with gr.Row():
549
+ draft_n_max = gr.Number(
550
+ label="--spec-draft-n-max", value=0, precision=0
551
+ )
552
+ draft_p_min = gr.Number(label="p_min", value=0.0)
553
+ draft_p_split = gr.Number(label="p_split", value=0.0)
554
+
555
+ with gr.Accordion("Multimodal (mmproj)", open=False):
556
+ mmproj_enabled = gr.Checkbox(
557
+ label="Include multimodal projector", value=False
558
+ )
559
+ mmproj_file = gr.Textbox(
560
+ label="mmproj filename (e.g. mmproj-F16.gguf)",
561
+ value="",
562
+ )
563
+ mmproj_offload = gr.Checkbox(
564
+ label="Offload mmproj to GPU", value=True
565
+ )
566
+ # mmproj_bytes_box + fetch_mmproj_btn declared above
567
 
568
  gr.Markdown("### 3. Multi-GPU budget")
569
  gpu_vram_text = gr.Textbox(
570
+ label="Per-GPU VRAM (GB, comma-separated; suffix 'u' = unified)",
571
  value="24",
572
+ placeholder="e.g. 24,16u (24GB discrete + 16GB unified)",
573
  )
574
  with gr.Row():
575
+ split_mode = gr.Dropdown(
576
+ label="split-mode", choices=SPLIT_MODES, value="layer"
577
  )
578
+ main_gpu = gr.Number(label="main-gpu (none mode)", value=0, precision=0)
579
+ tensor_split_text = gr.Textbox(
580
+ label="--tensor-split ratios (optional, comma-sep)",
581
+ value="",
582
+ placeholder="e.g. 3,1 (blank = auto by VRAM)",
583
+ )
584
+ with gr.Row():
585
  safety_margin = gr.Number(label="Safety margin %", value=5.0)
586
 
587
  compute_btn = gr.Button("Compute VRAM", variant="primary")
 
594
  label="Command (copyable)", lines=8, interactive=False
595
  )
596
 
597
+ with gr.Tab("Auto-fit"):
598
+ gr.Markdown(
599
+ "### Auto-fit solvers\n"
600
+ "Uses the current architecture + GPU budget. Run the calculator "
601
+ "first (or load a preset/fetch a GGUF) so the architecture is "
602
+ "populated, then pick a solver."
603
+ )
604
+ with gr.Row():
605
+ af_quant = gr.Dropdown(
606
+ label="Quant for max-context solver",
607
+ choices=QUANT_CHOICES, value="Q4_K_M",
608
+ )
609
+ af_maxctx_btn = gr.Button("Max context that fits")
610
+ af_maxctx_out = gr.Markdown("")
611
+ with gr.Row():
612
+ af_bestq_ctx = gr.Number(
613
+ label="Context for best-quant solver", value=8192, precision=0
614
+ )
615
+ af_bestq_btn = gr.Button("Best quant that fits")
616
+ af_bestq_out = gr.Markdown("")
617
+ with gr.Row():
618
+ af_mingu_ctx = gr.Number(
619
+ label="Context for min-GPU solver", value=8192, precision=0
620
+ )
621
+ af_mingu_quant = gr.Dropdown(
622
+ label="Quant for min-GPU solver",
623
+ choices=QUANT_CHOICES, value="Q4_K_M",
624
+ )
625
+ af_mingu_btn = gr.Button("Min GPUs needed")
626
+ af_mingu_out = gr.Markdown("")
627
+
628
  # --- wiring ---
629
  def _store_arch(*fields):
630
  return list(fields)
 
653
  fn=_store_arch, inputs=arch_inputs, outputs=arch_state
654
  )
655
 
656
+ # mmproj picker: populate from the same repo's *.gguf (filter mmproj)
657
+ list_btn.click(
658
+ fn=list_mmproj_files, inputs=[repo_id, hf_token],
659
+ outputs=[mmproj_picker, fetch_status],
660
+ )
661
+ fetch_mmproj_btn.click(
662
+ fn=fetch_mmproj_bytes, inputs=[repo_id, mmproj_picker, hf_token],
663
+ outputs=[mmproj_bytes_box, fetch_status],
664
+ )
665
+
666
  compute_btn.click(
667
  fn=compute,
668
  inputs=[
 
670
  n_batch, n_prompt,
671
  rope_freq_scale, yarn_ext_factor, yarn_attn_factor,
672
  yarn_beta_fast, yarn_beta_slow,
673
+ gpu_vram_text, split_mode, main_gpu, tensor_split_text,
674
+ safety_margin,
675
+ mtp_cache_dtype,
676
+ spec_type, draft_quant, draft_params, draft_n_layer,
677
+ draft_n_max, draft_p_min, draft_p_split,
678
+ mmproj_enabled, mmproj_file, mmproj_offload, mmproj_bytes_box,
679
  ],
680
  outputs=[result_md, cmd_md, cmd_text],
681
  )
682
 
683
+ # Auto-fit wiring
684
+ af_maxctx_btn.click(
685
+ fn=run_max_context,
686
+ inputs=[arch_state, af_quant, gpu_vram_text, split_mode, main_gpu,
687
+ n_batch, cache_dtype, flash_attn, compute_dtype, safety_margin],
688
+ outputs=[af_maxctx_out],
689
+ )
690
+ af_bestq_btn.click(
691
+ fn=run_best_quant,
692
+ inputs=[arch_state, af_bestq_ctx, gpu_vram_text, split_mode, main_gpu,
693
+ n_batch, cache_dtype, flash_attn, compute_dtype, safety_margin],
694
+ outputs=[af_bestq_out],
695
+ )
696
+ af_mingu_btn.click(
697
+ fn=run_min_gpu_setup,
698
+ inputs=[arch_state, af_mingu_ctx, af_mingu_quant, gpu_vram_text,
699
+ split_mode, main_gpu, n_batch, cache_dtype, flash_attn,
700
+ compute_dtype, safety_margin],
701
+ outputs=[af_mingu_out],
702
+ )
703
+
704
  return demo
705
 
706
 
tests/test_vramcalc.py CHANGED
@@ -155,41 +155,91 @@ def test_yarn_warnings_no_warning_when_within():
155
  assert w == []
156
 
157
 
158
- def test_gpu_split_proportional_sums():
159
  res = gpu_split(
160
  gpu_vram_bytes=[24 << 30, 24 << 30, 16 << 30],
161
  weights_bytes=40 << 30,
162
- kv_compute_bytes=4 << 30,
 
163
  )
164
- # weight shares sum to weights
165
- total_w = sum(a.weight_bytes for a in res.assignments)
166
- assert total_w == pytest.approx(40 << 30, rel=1e-6)
167
- # KV on exactly one GPU (default GPU 0)
168
  kv_gpus = [a for a in res.assignments if a.kv_compute_bytes > 0]
169
- assert len(kv_gpus) == 1
170
- assert kv_gpus[0].index == 0
171
  # used+free == vram for each gpu
172
  for a in res.assignments:
173
  assert a.used_bytes + a.free_bytes == pytest.approx(a.vram_bytes, rel=1e-6)
174
 
175
 
176
- def test_gpu_split_kv_on_largest():
177
  res = gpu_split(
178
- gpu_vram_bytes=[16 << 30, 24 << 30],
179
  weights_bytes=10 << 30,
180
- kv_compute_bytes=3 << 30,
181
- kv_on_largest=True,
 
182
  )
183
- kv_gpus = [a for a in res.assignments if a.kv_compute_bytes > 0]
184
- assert len(kv_gpus) == 1
185
- assert kv_gpus[0].index == 1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
186
 
187
 
188
  def test_gpu_split_fit_badge():
189
  res = gpu_split(
190
  gpu_vram_bytes=[4 << 30], # tiny
191
  weights_bytes=10 << 30,
192
- kv_compute_bytes=1 << 30,
193
  )
194
  assert res.all_fit is False
195
  assert res.assignments[0].fits is False
@@ -218,6 +268,99 @@ def test_estimate_mtp_overhead():
218
  assert bd.mtp_overhead_bytes > 0
219
 
220
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
221
  def test_command_preview_basic():
222
  arch = PRESETS["Llama-3 8B"]
223
  from vramcalc import Inputs
@@ -233,9 +376,50 @@ def test_command_preview_basic():
233
  def test_command_preview_multigpu_split():
234
  arch = PRESETS["Llama-3 8B"]
235
  from vramcalc import Inputs
 
236
  cmd = command_preview(arch, Inputs(quant="Q4_K_M", n_ctx=8192,
237
  gpu_vram_gb=[24.0, 24.0]))
238
- assert "--tensor-split 24.0,24.0" in cmd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
239
 
240
 
241
  def test_command_preview_yarn_when_target_exceeds():
 
155
  assert w == []
156
 
157
 
158
+ def test_gpu_split_layer_distributes_weights_and_kv():
159
  res = gpu_split(
160
  gpu_vram_bytes=[24 << 30, 24 << 30, 16 << 30],
161
  weights_bytes=40 << 30,
162
+ kv_bytes=4 << 30,
163
+ split_mode="layer",
164
  )
165
+ # weights sum preserved
166
+ assert sum(a.weight_bytes for a in res.assignments) == pytest.approx(40 << 30, rel=1e-6)
167
+ # layer mode: KV distributed across ALL gpus (follows the layers), not one
 
168
  kv_gpus = [a for a in res.assignments if a.kv_compute_bytes > 0]
169
+ assert len(kv_gpus) == 3
 
170
  # used+free == vram for each gpu
171
  for a in res.assignments:
172
  assert a.used_bytes + a.free_bytes == pytest.approx(a.vram_bytes, rel=1e-6)
173
 
174
 
175
+ def test_gpu_split_none_mode_all_on_main():
176
  res = gpu_split(
177
+ gpu_vram_bytes=[24 << 30, 24 << 30],
178
  weights_bytes=10 << 30,
179
+ kv_bytes=2 << 30,
180
+ split_mode="none",
181
+ main_gpu=1,
182
  )
183
+ # only main_gpu carries load
184
+ assert res.assignments[0].used_bytes == 0.0
185
+ assert res.assignments[1].weight_bytes == pytest.approx(10 << 30)
186
+ assert res.assignments[0].role == "unused"
187
+ assert "main" in res.assignments[1].role
188
+
189
+
190
+ def test_gpu_split_tensor_mode_splits_kv():
191
+ res = gpu_split(
192
+ gpu_vram_bytes=[24 << 30, 24 << 30],
193
+ weights_bytes=10 << 30,
194
+ kv_bytes=2 << 30,
195
+ split_mode="tensor",
196
+ )
197
+ # both gpus get half weights and half KV (even split by default)
198
+ assert res.assignments[0].weight_bytes == pytest.approx(5 << 30)
199
+ assert res.assignments[0].kv_compute_bytes == pytest.approx(1 << 30)
200
+ assert res.assignments[1].weight_bytes == pytest.approx(5 << 30)
201
+
202
+
203
+ def test_gpu_split_tensor_quantized_kv_warns():
204
+ res = gpu_split(
205
+ gpu_vram_bytes=[24 << 30, 24 << 30],
206
+ weights_bytes=10 << 30,
207
+ kv_bytes=2 << 30,
208
+ split_mode="tensor",
209
+ cache_dtype_quantized=True,
210
+ )
211
+ assert any("quantized KV" in w for w in res.warnings)
212
+
213
+
214
+ def test_gpu_split_explicit_tensor_split_ratios():
215
+ res = gpu_split(
216
+ gpu_vram_bytes=[24 << 30, 8 << 30],
217
+ weights_bytes=16 << 30,
218
+ kv_bytes=0,
219
+ split_mode="layer",
220
+ tensor_split=[3, 1],
221
+ )
222
+ # 3:1 split -> 12 and 4
223
+ assert res.assignments[0].weight_bytes == pytest.approx(12 << 30)
224
+ assert res.assignments[1].weight_bytes == pytest.approx(4 << 30)
225
+
226
+
227
+ def test_gpu_split_unified_memory_warns():
228
+ from vramcalc.gpu import GpuSpec
229
+ res = gpu_split(
230
+ gpu_specs=[GpuSpec(24, "RX7900", False), GpuSpec(16, "Strix", True)],
231
+ weights_bytes=10 << 30,
232
+ kv_bytes=1 << 30,
233
+ )
234
+ assert any("unified" in w for w in res.warnings)
235
+ assert res.assignments[1].is_unified is True
236
 
237
 
238
  def test_gpu_split_fit_badge():
239
  res = gpu_split(
240
  gpu_vram_bytes=[4 << 30], # tiny
241
  weights_bytes=10 << 30,
242
+ kv_bytes=1 << 30,
243
  )
244
  assert res.all_fit is False
245
  assert res.assignments[0].fits is False
 
268
  assert bd.mtp_overhead_bytes > 0
269
 
270
 
271
+ def test_estimate_mtp_dual_kv_dtype():
272
+ """MTP extra-layer KV can use a different dtype than the target KV."""
273
+ arch = PRESETS["DeepSeek-V3 (MoE)"]
274
+ from vramcalc import Inputs
275
+ base = estimate(arch, Inputs(quant="Q4_K_M", n_ctx=8192, cache_dtype="q8_0",
276
+ gpu_vram_gb=[80.0], mtp_cache_dtype="q8_0"))
277
+ mtp_f16 = estimate(arch, Inputs(quant="Q4_K_M", n_ctx=8192, cache_dtype="q8_0",
278
+ gpu_vram_gb=[80.0], mtp_cache_dtype="f16"))
279
+ # f16 MTP KV (2 bytes) vs q8_0 (1 byte) -> MTP overhead roughly doubles its
280
+ # KV portion, so the f16 case is larger.
281
+ assert mtp_f16.mtp_overhead_bytes > base.mtp_overhead_bytes
282
+
283
+
284
+ def test_estimate_includes_draft_and_mmproj():
285
+ from vramcalc import Inputs, DraftInputs, Mmproj
286
+ arch = PRESETS["Llama-3 8B"]
287
+ base = estimate(arch, Inputs(quant="Q4_K_M", n_ctx=8192, gpu_vram_gb=[24.0]))
288
+ draft = DraftInputs(spec_type="draft", quant="Q4_K_M",
289
+ params=1_000_000_000, n_layer=4, cache_dtype="f16")
290
+ mm = Mmproj(enabled=True, bytes_=500 * 1e6)
291
+ with_extras = estimate(arch, Inputs(quant="Q4_K_M", n_ctx=8192,
292
+ gpu_vram_gb=[24.0], draft=draft, mmproj=mm))
293
+ assert with_extras.draft_bytes_ > 0
294
+ assert with_extras.mmproj_bytes_ == pytest.approx(500e6)
295
+ assert with_extras.total_bytes > base.total_bytes
296
+
297
+
298
+ def test_mmproj_bytes_from_tensors():
299
+ from vramcalc.mmproj import mmproj_bytes_from_tensors
300
+ # 1e9 F16 elements (dtype 1) -> 1e9 * 16 / 8 = 2e9 bytes
301
+ b = mmproj_bytes_from_tensors({1: 1_000_000_000})
302
+ assert b == pytest.approx(2e9)
303
+ # mix of F32 (dtype 0, 32 bpe) and F16
304
+ b2 = mmproj_bytes_from_tensors({0: 1_000_000, 1: 1_000_000})
305
+ assert b2 == pytest.approx((1e6 * 32 + 1e6 * 16) / 8)
306
+
307
+
308
+ def test_draft_bytes_basic():
309
+ from vramcalc import DraftInputs, draft_bytes
310
+ d = DraftInputs(spec_type="draft", quant="Q4_K_M", params=1_000_000_000,
311
+ n_layer=4, cache_dtype="f16")
312
+ bd = draft_bytes(draft=d, target_n_ctx=8192, target_n_embd=4096,
313
+ target_n_head=32, target_n_head_kv=8)
314
+ # weights ~0.6 GB for 1B @ Q4_K_M
315
+ assert 0.5e9 < bd.weights_bytes < 0.7e9
316
+ assert bd.kv_bytes > 0
317
+ assert bd.enabled
318
+
319
+
320
+ def test_draft_bytes_disabled():
321
+ from vramcalc import DraftInputs, draft_bytes
322
+ d = DraftInputs(spec_type="none", params=0)
323
+ bd = draft_bytes(draft=d, target_n_ctx=8192)
324
+ assert bd.enabled is False
325
+ assert bd.total_bytes == 0.0
326
+
327
+
328
+ def test_autofit_max_context():
329
+ from vramcalc import Inputs, max_context
330
+ arch = PRESETS["Llama-3 8B"]
331
+ inp = Inputs(quant="Q4_K_M", n_ctx=8192, gpu_vram_gb=[24.0])
332
+ res = max_context(arch, inp, step=1024, max_ctx=131072)
333
+ # 8B @ Q4_K_M fits a huge context on 24GB; expect a large value
334
+ assert res.fits
335
+ assert res.n_ctx >= 8192
336
+
337
+
338
+ def test_autofit_best_quant():
339
+ from vramcalc import Inputs, best_quant
340
+ arch = PRESETS["Llama-3 8B"]
341
+ # tight budget at large ctx -> only lower-bpw quants fit
342
+ inp = Inputs(quant="Q4_K_M", n_ctx=32768, gpu_vram_gb=[8.0])
343
+ res = best_quant(arch, inp)
344
+ # something fits (8B is small) and recommendation is a known quant
345
+ assert res.fits
346
+ assert res.quant in QUANT_BPW
347
+ assert res.bpw == QUANT_BPW[res.quant]
348
+ # fallback is lower bpw than recommendation
349
+ if res.fallback_quant:
350
+ assert res.fallback_bpw < res.bpw
351
+
352
+
353
+ def test_autofit_min_gpu_setup():
354
+ from vramcalc import Inputs, min_gpu_setup
355
+ arch = PRESETS["Llama-3 8B"]
356
+ inp = Inputs(quant="Q4_K_M", n_ctx=8192, gpu_vram_gb=[4.0, 24.0, 16.0])
357
+ res = min_gpu_setup(arch, inp)
358
+ # 8B Q4_K_M fits on a single 24GB GPU (largest first)
359
+ assert res.fits
360
+ assert res.n_gpus == 1
361
+ assert res.subset == [24.0]
362
+
363
+
364
  def test_command_preview_basic():
365
  arch = PRESETS["Llama-3 8B"]
366
  from vramcalc import Inputs
 
376
  def test_command_preview_multigpu_split():
377
  arch = PRESETS["Llama-3 8B"]
378
  from vramcalc import Inputs
379
+ # multi-GPU now emits --split-mode (auto-split, no explicit --tensor-split)
380
  cmd = command_preview(arch, Inputs(quant="Q4_K_M", n_ctx=8192,
381
  gpu_vram_gb=[24.0, 24.0]))
382
+ assert "--split-mode layer" in cmd
383
+ assert "--tensor-split" not in cmd # not emitted without explicit ratios
384
+
385
+ # explicit tensor-split ratios are emitted verbatim
386
+ cmd2 = command_preview(arch, Inputs(quant="Q4_K_M", n_ctx=8192,
387
+ gpu_vram_gb=[24.0, 24.0],
388
+ tensor_split=[3, 1]))
389
+ assert "--tensor-split 3,1" in cmd2
390
+
391
+
392
+ def test_command_preview_none_mode_main_gpu():
393
+ arch = PRESETS["Llama-3 8B"]
394
+ from vramcalc import Inputs
395
+ cmd = command_preview(arch, Inputs(quant="Q4_K_M", n_ctx=8192,
396
+ gpu_vram_gb=[24.0, 24.0],
397
+ split_mode="none", main_gpu=1))
398
+ assert "--split-mode none" in cmd
399
+ assert "--main-gpu 1" in cmd
400
+
401
+
402
+ def test_command_preview_draft_and_mmproj():
403
+ from vramcalc import Inputs, DraftInputs, Mmproj
404
+ arch = PRESETS["Llama-3 8B"]
405
+ draft = DraftInputs(spec_type="draft", quant="Q4_K_M",
406
+ params=1_000_000_000, n_layer=4, cache_dtype="f16")
407
+ mm = Mmproj(filename="mmproj-F16.gguf", enabled=True, offload=True)
408
+ cmd = command_preview(arch, Inputs(quant="Q4_K_M", n_ctx=8192,
409
+ gpu_vram_gb=[24.0],
410
+ draft=draft, mmproj=mm))
411
+ assert "-md draft-Q4_K_M.gguf" in cmd
412
+ assert "--spec-type draft" in cmd
413
+ assert "--mmproj mmproj-F16.gguf" in cmd
414
+
415
+
416
+ def test_command_preview_mmproj_no_offload():
417
+ from vramcalc import Inputs, Mmproj
418
+ arch = PRESETS["Llama-3 8B"]
419
+ mm = Mmproj(filename="mmproj.gguf", enabled=True, offload=False)
420
+ cmd = command_preview(arch, Inputs(quant="Q4_K_M", n_ctx=8192,
421
+ gpu_vram_gb=[24.0], mmproj=mm))
422
+ assert "--no-mmproj-offload" in cmd
423
 
424
 
425
  def test_command_preview_yarn_when_target_exceeds():
vramcalc/__init__.py CHANGED
@@ -6,7 +6,7 @@ Pure calculation logic, importable without Gradio so it can be unit-tested.
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,
@@ -15,6 +15,9 @@ from .gguf import (
15
  parse_hf_range,
16
  metadata_to_arch,
17
  )
 
 
 
18
  from .report import (
19
  ModelArch,
20
  Inputs,
@@ -34,6 +37,7 @@ __all__ = [
34
  "yarn_effective_context",
35
  "yarn_warnings",
36
  "GpuBudget",
 
37
  "GpuSplitResult",
38
  "gpu_split",
39
  "fit_gpus",
@@ -43,6 +47,17 @@ __all__ = [
43
  "parse_local_file",
44
  "parse_hf_range",
45
  "metadata_to_arch",
 
 
 
 
 
 
 
 
 
 
 
46
  "ModelArch",
47
  "Inputs",
48
  "Breakdown",
 
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, GpuSpec, gpu_split, fit_gpus, GpuSplitResult
10
  from .gguf import (
11
  GGUFMetadata,
12
  parse_header_bytes,
 
15
  parse_hf_range,
16
  metadata_to_arch,
17
  )
18
+ from .draft import DraftInputs, DraftBreakdown, draft_bytes, SPEC_TYPES
19
+ from .mmproj import Mmproj, MmprojBreakdown, mmproj_bytes_from_tensors, mmproj_bytes_from_meta
20
+ from .autofit import max_context, best_quant, min_gpu_setup
21
  from .report import (
22
  ModelArch,
23
  Inputs,
 
37
  "yarn_effective_context",
38
  "yarn_warnings",
39
  "GpuBudget",
40
+ "GpuSpec",
41
  "GpuSplitResult",
42
  "gpu_split",
43
  "fit_gpus",
 
47
  "parse_local_file",
48
  "parse_hf_range",
49
  "metadata_to_arch",
50
+ "DraftInputs",
51
+ "DraftBreakdown",
52
+ "draft_bytes",
53
+ "SPEC_TYPES",
54
+ "Mmproj",
55
+ "MmprojBreakdown",
56
+ "mmproj_bytes_from_tensors",
57
+ "mmproj_bytes_from_meta",
58
+ "max_context",
59
+ "best_quant",
60
+ "min_gpu_setup",
61
  "ModelArch",
62
  "Inputs",
63
  "Breakdown",
vramcalc/autofit.py ADDED
@@ -0,0 +1,177 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Auto-fit solvers: pick n_ctx, quant, or GPU setup from a VRAM budget.
2
+
3
+ These call :func:`vramcalc.report.estimate` in a loop — no new VRAM math — so
4
+ they stay consistent with the manual calculator. All three are pure and
5
+ unit-testable.
6
+
7
+ max_context — largest n_ctx that fits the GPU budget at a given quant.
8
+ best_quant — highest-bpw quant that fits a given n_ctx (plus fallback).
9
+ min_gpu_setup — smallest subset of the user's GPUs that fits (largest-first).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from dataclasses import dataclass, field, replace
15
+ from typing import Callable
16
+
17
+ from .quant import QUANT_BPW
18
+ from .report import ModelArch, Inputs, estimate
19
+
20
+
21
+ def _total_vram_bytes(inp: Inputs) -> int:
22
+ return int(sum(g * (1 << 30) for g in inp.gpu_vram_gb))
23
+
24
+
25
+ def _fits(bd) -> bool:
26
+ return bd.gpu is not None and bd.gpu.all_fit
27
+
28
+
29
+ @dataclass
30
+ class MaxContextResult:
31
+ n_ctx: int = 0
32
+ total_bytes: float = 0.0
33
+ fits: bool = False
34
+ note: str = ""
35
+
36
+
37
+ def max_context(
38
+ arch: ModelArch,
39
+ inp: Inputs,
40
+ *,
41
+ step: int = 512,
42
+ max_ctx: int | None = None,
43
+ ) -> MaxContextResult:
44
+ """Binary-search the largest n_ctx whose estimate fits the GPU budget.
45
+
46
+ Search bounds: [step … max_ctx]. Default max_ctx = max(training_ctx, n_ctx)
47
+ * 4 to allow YaRN-extended contexts without an unbounded loop. Returns the
48
+ largest fitting n_ctx (rounded down to a multiple of `step`) and the
49
+ resulting total; ``fits`` is False if even the minimum doesn't fit.
50
+ """
51
+ budget = _total_vram_bytes(inp)
52
+ hi = max_ctx or max(arch.training_ctx, inp.n_ctx, step) * 4
53
+ hi = max(hi, step)
54
+ lo = step
55
+
56
+ # quick check: does the minimum fit at all?
57
+ bd_min = estimate(arch, replace(inp, n_ctx=lo))
58
+ if not _fits(bd_min):
59
+ return MaxContextResult(
60
+ n_ctx=lo, total_bytes=bd_min.total_bytes, fits=False,
61
+ note=f"Even n_ctx={lo} doesn't fit {budget/1e9:.1f} GiB budget.",
62
+ )
63
+
64
+ # does the maximum fit? (small models on big GPUs)
65
+ bd_hi = estimate(arch, replace(inp, n_ctx=hi))
66
+ if _fits(bd_hi):
67
+ return MaxContextResult(
68
+ n_ctx=hi, total_bytes=bd_hi.total_bytes, fits=True,
69
+ note=f"Even n_ctx={hi} fits; try a higher cap.",
70
+ )
71
+
72
+ # binary search the boundary
73
+ best = lo
74
+ best_total = bd_min.total_bytes
75
+ while lo <= hi:
76
+ mid = (lo + hi) // 2
77
+ # snap to step
78
+ mid = (mid // step) * step
79
+ if mid < step:
80
+ mid = step
81
+ bd = estimate(arch, replace(inp, n_ctx=mid))
82
+ if _fits(bd):
83
+ best = mid
84
+ best_total = bd.total_bytes
85
+ lo = mid + step
86
+ else:
87
+ hi = mid - step
88
+ return MaxContextResult(n_ctx=best, total_bytes=best_total, fits=True)
89
+
90
+
91
+ @dataclass
92
+ class BestQuantResult:
93
+ quant: str = ""
94
+ bpw: float = 0.0
95
+ total_bytes: float = 0.0
96
+ fits: bool = False
97
+ fallback_quant: str = ""
98
+ fallback_bpw: float = 0.0
99
+ fallback_total: float = 0.0
100
+ note: str = ""
101
+
102
+
103
+ def best_quant(arch: ModelArch, inp: Inputs) -> BestQuantResult:
104
+ """Highest-bpw quant that fits the GPU budget at the current n_ctx.
105
+
106
+ Iterates quants from highest to lowest bpw; the first that fits is the
107
+ recommendation, the next-lower that also fits is the safe fallback.
108
+ """
109
+ ordered = sorted(QUANT_BPW.items(), key=lambda kv: kv[1], reverse=True)
110
+ res = BestQuantResult()
111
+ found_fit = None
112
+ fallback = None
113
+ for q, bpw in ordered:
114
+ bd = estimate(arch, replace(inp, quant=q))
115
+ fits = _fits(bd)
116
+ if fits and found_fit is None:
117
+ found_fit = (q, bpw, bd.total_bytes)
118
+ elif fits and found_fit is not None and fallback is None:
119
+ fallback = (q, bpw, bd.total_bytes)
120
+ break # first lower-bpw fit is the fallback
121
+ elif not fits and found_fit is not None and fallback is None:
122
+ # keep scanning down for the first that fits as fallback
123
+ continue
124
+ if found_fit is None:
125
+ # nothing fits; report the lowest-bpw attempt (least-bad)
126
+ last_q, last_bp = ordered[-1]
127
+ bd = estimate(arch, replace(inp, quant=last_q))
128
+ res.quant = ""
129
+ res.fits = False
130
+ res.note = f"No quant fits the budget at n_ctx={inp.n_ctx}."
131
+ res.fallback_quant = last_q
132
+ res.fallback_bpw = last_bp
133
+ res.fallback_total = bd.total_bytes
134
+ return res
135
+ res.quant, res.bpw, res.total_bytes = found_fit
136
+ res.fits = True
137
+ if fallback is not None:
138
+ res.fallback_quant, res.fallback_bpw, res.fallback_total = fallback
139
+ return res
140
+
141
+
142
+ @dataclass
143
+ class MinGpuResult:
144
+ n_gpus: int = 0
145
+ subset: list[float] = field(default_factory=list) # vram_gb of chosen GPUs
146
+ total_bytes: float = 0.0
147
+ fits: bool = False
148
+ note: str = ""
149
+
150
+
151
+ def min_gpu_setup(arch: ModelArch, inp: Inputs) -> MinGpuResult:
152
+ """Smallest subset (largest-first) of the user's GPUs that fits.
153
+
154
+ Greedy: sort the configured GPUs by VRAM descending and add them one at a
155
+ time until the estimate fits. Reports the chosen subset and count.
156
+ """
157
+ if not inp.gpu_vram_gb:
158
+ return MinGpuResult(note="No GPUs configured.")
159
+ # sort largest-first, keep original-size list
160
+ sorted_gpus = sorted(inp.gpu_vram_gb, reverse=True)
161
+ res = MinGpuResult()
162
+ for k in range(1, len(sorted_gpus) + 1):
163
+ subset = sorted_gpus[:k]
164
+ bd = estimate(arch, replace(inp, gpu_vram_gb=subset))
165
+ if _fits(bd):
166
+ return MinGpuResult(
167
+ n_gpus=k, subset=subset, total_bytes=bd.total_bytes,
168
+ fits=True,
169
+ note=f"Fits on {k} GPU(s): {[f'{g}GB' for g in subset]}.",
170
+ )
171
+ # none fit even with all GPUs
172
+ bd = estimate(arch, replace(inp, gpu_vram_gb=sorted_gpus))
173
+ return MinGpuResult(
174
+ n_gpus=len(sorted_gpus), subset=sorted_gpus,
175
+ total_bytes=bd.total_bytes, fits=False,
176
+ note=f"Doesn't fit even on all {len(sorted_gpus)} GPU(s).",
177
+ )
vramcalc/draft.py ADDED
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Speculative-decoding draft model memory.
2
+
3
+ A draft model (llama.cpp `-md`) runs alongside the target and contributes its
4
+ own weights and KV cache. Supported spec types (see docs/speculative.md):
5
+ draft — a standalone small draft model
6
+ draft-eagle3 — EAGLE-3 single-layer draft (shares the target tokenizer)
7
+ draft-dflash — DFlash block-diffusion draft
8
+ none — no draft model
9
+
10
+ For VRAM purposes the draft model's cost is its weights + its own KV cache.
11
+ EAGLE-3 drafts are typically a single layer; standalone drafts have their own
12
+ n_layer. The draft usually rides on the main GPU alongside the target, which
13
+ the caller handles by adding draft_bytes to that GPU's used.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from dataclasses import dataclass
19
+
20
+ 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
28
+ class DraftInputs:
29
+ spec_type: str = "none"
30
+ quant: str = "Q4_K_M"
31
+ params: int = 0 # draft parameter count
32
+ n_layer: int = 1 # draft layer count (1 for EAGLE-3 by default)
33
+ n_ctx: int | None = None # defaults to target n_ctx
34
+ cache_dtype: str = "f16"
35
+ n_max: int = 0 # --spec-draft-n-max (informational)
36
+ p_min: float = 0.0
37
+ p_split: float = 0.0
38
+
39
+ @property
40
+ def enabled(self) -> bool:
41
+ return self.spec_type != "none" and self.params > 0
42
+
43
+
44
+ @dataclass
45
+ class DraftBreakdown:
46
+ weights_bytes: float = 0.0
47
+ kv_bytes: float = 0.0
48
+ total_bytes: float = 0.0
49
+ enabled: bool = False
50
+
51
+
52
+ def draft_bytes(
53
+ *,
54
+ draft: DraftInputs,
55
+ target_n_ctx: int,
56
+ target_n_embd: int = 0,
57
+ target_n_head: int = 0,
58
+ target_n_head_kv: int = 0,
59
+ ) -> DraftBreakdown:
60
+ """Return (weights, kv) bytes for a draft model.
61
+
62
+ Reuses weight_bytes and kv_cache_bytes. The draft KV uses the draft's own
63
+ n_head_kv; if unset we fall back to the target's (EAGLE-3 shares the
64
+ target's attention geometry). n_embd likewise defaults to the target's.
65
+ """
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
72
+ n_head = draft.n_head if getattr(draft, "n_head", 0) else target_n_head
73
+ n_head_kv = (
74
+ draft.n_head_kv if getattr(draft, "n_head_kv", 0) else target_n_head_kv
75
+ )
76
+ if n_head_kv == 0:
77
+ n_head_kv = n_head
78
+ n_ctx = draft.n_ctx if draft.n_ctx is not None else target_n_ctx
79
+
80
+ # kv_cache_bytes raises on an unknown cache dtype; guard.
81
+ try:
82
+ kv = kv_cache_bytes(
83
+ n_layer=max(1, draft.n_layer),
84
+ n_embd=n_embd,
85
+ n_head=max(1, n_head),
86
+ n_head_kv=max(1, n_head_kv),
87
+ n_ctx=n_ctx,
88
+ cache_dtype=draft.cache_dtype,
89
+ )
90
+ except ValueError:
91
+ kv = 0.0
92
+
93
+ return DraftBreakdown(
94
+ weights_bytes=w, kv_bytes=kv, total_bytes=w + kv, enabled=True,
95
+ )
vramcalc/gguf.py CHANGED
@@ -158,6 +158,10 @@ def parse_header_with_tensors(buf: bytes):
158
  reach the tensor section we return -1 (unreachable) so callers can tell
159
  "incomplete" from "zero tensors".
160
 
 
 
 
 
161
  Used as a fallback for `params` when general.parameter_count is absent.
162
  """
163
  r = _Reader(buf)
@@ -182,6 +186,7 @@ def parse_header_with_tensors(buf: bytes):
182
  return meta, 0
183
  total = 0
184
  parsed = 0
 
185
  for _ in range(tensor_count):
186
  if r.eof():
187
  break
@@ -191,12 +196,15 @@ def parse_header_with_tensors(buf: bytes):
191
  elems = 1
192
  for _ in range(n_dims):
193
  elems *= r.u64() # dims multiply, not add
194
- total += elems
195
- r.u32() # dtype
196
  r.u64() # data offset
 
 
197
  parsed += 1
198
  except (EOFError, ValueError):
199
  break
 
 
200
  # If we couldn't reach any tensor infos, signal failure with -1 so callers
201
  # can distinguish "unreachable" from "0 tensors".
202
  if parsed == 0 and tensor_count > 0:
 
158
  reach the tensor section we return -1 (unreachable) so callers can tell
159
  "incomplete" from "zero tensors".
160
 
161
+ A per-dtype element histogram is attached to the returned metadata dict
162
+ under ``".tensor_dtype_hist"`` (dtype id -> total elems) when at least one
163
+ tensor was parsed — used by the mmproj projector sizing path.
164
+
165
  Used as a fallback for `params` when general.parameter_count is absent.
166
  """
167
  r = _Reader(buf)
 
186
  return meta, 0
187
  total = 0
188
  parsed = 0
189
+ hist: dict[int, int] = {}
190
  for _ in range(tensor_count):
191
  if r.eof():
192
  break
 
196
  elems = 1
197
  for _ in range(n_dims):
198
  elems *= r.u64() # dims multiply, not add
199
+ dtype = r.u32()
 
200
  r.u64() # data offset
201
+ total += elems
202
+ hist[dtype] = hist.get(dtype, 0) + elems
203
  parsed += 1
204
  except (EOFError, ValueError):
205
  break
206
+ if parsed > 0:
207
+ meta[".tensor_dtype_hist"] = hist
208
  # If we couldn't reach any tensor infos, signal failure with -1 so callers
209
  # can distinguish "unreachable" from "0 tensors".
210
  if parsed == 0 and tensor_count > 0:
vramcalc/gpu.py CHANGED
@@ -1,11 +1,26 @@
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
@@ -13,6 +28,18 @@ from __future__ import annotations
13
  from dataclasses import dataclass, field
14
 
15
 
 
 
 
 
 
 
 
 
 
 
 
 
16
  @dataclass
17
  class GpuBudget:
18
  vram_bytes: list[int]
@@ -22,11 +49,14 @@ class GpuBudget:
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
@@ -37,66 +67,163 @@ class GpuSplitResult:
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
 
 
1
+ """Multi-GPU VRAM budgeting, modelled on llama.cpp's real --split-mode.
2
+
3
+ llama.cpp distributes work across GPUs in one of three split modes
4
+ (see docs/multi-gpu.md):
5
+
6
+ layer (default, pipeline-parallel): each GPU holds a contiguous slice of
7
+ *layers*. The KV cache for layer l lives on the GPU that owns layer
8
+ l, so KV is distributed across GPUs in the same proportion as the
9
+ weights — it is NOT piled on GPU 0.
10
+
11
+ tensor (experimental, tensor-parallel): splits both weights *and* KV across
12
+ the participating GPUs, either evenly or by --tensor-split
13
+ proportions. Requires flash-attn on and a non-quantized KV cache
14
+ (f16/bf16/f32); quantized KV is a hard error upstream.
15
+
16
+ none : everything on --main-gpu (default 0); the other GPUs are unused.
17
+
18
+ We support mixed-GPU setups of differing size (e.g. a 24 GB Radeon + a 16 GB
19
+ unified-memory iGPU). When the caller passes explicit --tensor-split ratios we
20
+ use those; otherwise we split proportionally to each GPU's VRAM (layer mode) or
21
+ to the given ratios (tensor mode). Unified-memory devices (system RAM acting
22
+ as VRAM, e.g. AMD Strix Halo / Apple SoCs) are flagged so callers can warn that
23
+ the estimate is fuzzy — that memory is shared with the OS.
24
  """
25
 
26
  from __future__ import annotations
 
28
  from dataclasses import dataclass, field
29
 
30
 
31
+ @dataclass
32
+ class GpuSpec:
33
+ """A single device: its VRAM and whether it's unified (shared system RAM)."""
34
+ vram_gb: float
35
+ name: str = ""
36
+ is_unified: bool = False
37
+
38
+ @property
39
+ def vram_bytes(self) -> int:
40
+ return int(self.vram_gb * (1 << 30))
41
+
42
+
43
  @dataclass
44
  class GpuBudget:
45
  vram_bytes: list[int]
 
49
  class GpuAssignment:
50
  index: int
51
  vram_bytes: int
52
+ name: str = ""
53
+ is_unified: bool = False
54
+ weight_bytes: float = 0.0
55
+ kv_compute_bytes: float = 0.0
56
+ used_bytes: float = 0.0
57
+ free_bytes: float = 0.0
58
+ fits: bool = True
59
+ role: str = "" # human note: "weights+KV", "weights", "all (main)", ...
60
 
61
 
62
  @dataclass
 
67
  total_weights_bytes: float = 0.0
68
  total_kv_compute_bytes: float = 0.0
69
  all_fit: bool = False
70
+ split_mode: str = "layer"
71
+ main_gpu_index: int = 0
72
+ warnings: list[str] = field(default_factory=list)
73
+
74
+
75
+ def _normalize_shares(ratios: list[float], n: int) -> list[float]:
76
+ """Turn a list of split ratios into n normalized shares summing to 1.
77
+
78
+ None/empty -> equal shares. Otherwise pad/truncate to n and normalize.
79
+ """
80
+ if not ratios:
81
+ s = [1.0] * n
82
+ else:
83
+ s = [float(r) for r in ratios[:n]]
84
+ while len(s) < n:
85
+ s.append(0.0)
86
+ tot = sum(s)
87
+ if tot <= 0:
88
+ s = [1.0] * n
89
+ tot = float(n)
90
+ return [x / tot for x in s]
91
 
92
 
93
  def gpu_split(
94
  *,
95
+ gpu_specs: list[GpuSpec] | None = None,
96
+ gpu_vram_bytes: list[int] | None = None,
97
  weights_bytes: float,
98
+ kv_bytes: float,
99
+ scratch_bytes: float = 0.0,
100
+ split_mode: str = "layer",
101
+ main_gpu: int = 0,
102
+ tensor_split: list[float] | None = None,
103
+ cache_dtype_quantized: bool = False,
104
  ) -> GpuSplitResult:
105
+ """Distribute weights, KV, and scratch across GPUs for a split mode.
106
+
107
+ `gpu_specs` is the preferred input; `gpu_vram_bytes` is accepted for
108
+ backward compatibility (each treated as a discrete, non-unified GPU).
109
+ `kv_bytes` is the model KV cache; `scratch_bytes` is the compute/activation
110
+ buffer, which is placed on the main GPU (the one running the active batch).
111
+ """
112
+ # Normalize inputs into a list of (vram_bytes, is_unified, name).
113
+ if gpu_specs is not None:
114
+ gpus = [(g.vram_bytes, g.is_unified, g.name) for g in gpu_specs]
115
+ elif gpu_vram_bytes is not None:
116
+ gpus = [(int(v), False, "") for v in gpu_vram_bytes]
117
+ else:
118
  return GpuSplitResult()
119
 
120
+ if not gpus:
121
+ return GpuSplitResult()
 
 
 
 
 
 
 
122
 
123
+ warns: list[str] = []
124
+ if any(u for _, u, _ in gpus):
125
+ warns.append(
126
+ "One or more devices is unified memory (shared system RAM); the "
127
+ "VRAM budget is fuzzy because that memory is also used by the OS."
128
+ )
129
 
130
+ n = len(gpus)
131
+ total_vram = sum(v for v, _, _ in gpus)
132
+ if main_gpu < 0 or main_gpu >= n:
133
+ main_gpu = 0
134
+
135
+ mode = split_mode if split_mode in ("layer", "tensor", "none") else "layer"
136
+
137
+ # --- split mode: none -------------------------------------------------
138
+ if mode == "none":
139
+ v, uni, nm = gpus[main_gpu]
140
+ used = weights_bytes + kv_bytes + scratch_bytes
141
+ assignments = []
142
+ for i, (vr, ur, nr) in enumerate(gpus):
143
+ if i == main_gpu:
144
+ assignments.append(GpuAssignment(
145
+ index=i, vram_bytes=vr, name=nr, is_unified=ur,
146
+ weight_bytes=weights_bytes, kv_compute_bytes=kv_bytes,
147
+ used_bytes=used, free_bytes=vr - used, fits=used <= vr,
148
+ role="all (main)",
149
+ ))
150
+ else:
151
+ assignments.append(GpuAssignment(
152
+ index=i, vram_bytes=vr, name=nr, is_unified=ur,
153
+ used_bytes=0.0, free_bytes=float(vr), fits=True,
154
+ role="unused",
155
+ ))
156
+ return GpuSplitResult(
157
+ assignments=assignments, total_vram_bytes=total_vram,
158
+ total_used_bytes=used,
159
+ total_weights_bytes=weights_bytes,
160
+ total_kv_compute_bytes=kv_bytes,
161
+ all_fit=all(a.fits for a in assignments),
162
+ split_mode=mode, main_gpu_index=main_gpu, warnings=warns,
163
+ )
164
 
165
+ # --- split mode: tensor (experimental) -------------------------------
166
+ if mode == "tensor":
167
+ if cache_dtype_quantized:
168
+ warns.append(
169
+ "--split-mode tensor requires a non-quantized KV cache "
170
+ "(f16/bf16/f32); quantized KV is a hard error in llama.cpp."
 
 
 
 
 
 
 
 
 
 
171
  )
172
+ shares = _normalize_shares(tensor_split or [], n)
173
+ # tensor mode splits weights AND KV across all GPUs by the shares;
174
+ # scratch rides on the main GPU.
175
+ assignments = []
176
+ for i, (vr, ur, nr) in enumerate(gpus):
177
+ w = weights_bytes * shares[i]
178
+ kv = kv_bytes * shares[i]
179
+ sc = scratch_bytes if i == main_gpu else 0.0
180
+ used = w + kv + sc
181
+ assignments.append(GpuAssignment(
182
+ index=i, vram_bytes=vr, name=nr, is_unified=ur,
183
+ weight_bytes=w, kv_compute_bytes=kv,
184
+ used_bytes=used, free_bytes=vr - used, fits=used <= vr,
185
+ role="weights+KV" + ("+compute" if i == main_gpu else ""),
186
+ ))
187
+ return GpuSplitResult(
188
+ assignments=assignments, total_vram_bytes=total_vram,
189
+ total_used_bytes=sum(a.used_bytes for a in assignments),
190
+ total_weights_bytes=weights_bytes,
191
+ total_kv_compute_bytes=kv_bytes,
192
+ all_fit=all(a.fits for a in assignments),
193
+ split_mode=mode, main_gpu_index=main_gpu, warnings=warns,
194
  )
195
 
196
+ # --- split mode: layer (default, pipeline-parallel) ------------------
197
+ # Default split is proportional to each GPU's VRAM (llama.cpp's auto-split).
198
+ # If the user gave explicit --tensor-split ratios, use those as the weight
199
+ # proportions instead.
200
+ if tensor_split:
201
+ shares = _normalize_shares(tensor_split, n)
202
+ else:
203
+ if total_vram <= 0:
204
+ shares = [1.0 / n] * n
205
+ else:
206
+ shares = [v / total_vram for v, _, _ in gpus]
207
+ assignments = []
208
+ for i, (vr, ur, nr) in enumerate(gpus):
209
+ w = weights_bytes * shares[i]
210
+ # KV follows the layer that owns it -> distributed like the weights.
211
+ kv = kv_bytes * shares[i]
212
+ sc = scratch_bytes if i == main_gpu else 0.0
213
+ used = w + kv + sc
214
+ assignments.append(GpuAssignment(
215
+ index=i, vram_bytes=vr, name=nr, is_unified=ur,
216
+ weight_bytes=w, kv_compute_bytes=kv,
217
+ used_bytes=used, free_bytes=vr - used, fits=used <= vr,
218
+ role="weights+KV" + ("+compute" if i == main_gpu else ""),
219
+ ))
220
  return GpuSplitResult(
221
+ assignments=assignments, total_vram_bytes=total_vram,
 
222
  total_used_bytes=sum(a.used_bytes for a in assignments),
223
  total_weights_bytes=weights_bytes,
224
+ total_kv_compute_bytes=kv_bytes,
225
  all_fit=all(a.fits for a in assignments),
226
+ split_mode=mode, main_gpu_index=main_gpu, warnings=warns,
227
  )
228
 
229
 
vramcalc/mmproj.py ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Multimodal projector (mmproj) memory.
2
+
3
+ A multimodal projector (--mmproj file.gguf) is a separate small GGUF that
4
+ holds the vision/audio encoder + adapter weights. It is GPU-offloaded by
5
+ default (--no-mmproj-offload disables that). It has no KV cache of its own;
6
+ its cost is just its weight tensor bytes plus a small activation scratch we
7
+ fold into the compute budget.
8
+
9
+ We size it by summing the tensor element counts from the mmproj GGUF header
10
+ multiplied by each tensor's dtype bits-per-element. The mmproj files in the
11
+ wild (e.g. mmproj-F16.gguf / mmproj-BF16.gguf) are almost entirely one dtype,
12
+ so a per-dtype sum is accurate.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from dataclasses import dataclass
18
+
19
+ # bits per element for the dtypes mmproj files actually use. Sourced from the
20
+ # GGML type enum; we only need the projector-relevant subset.
21
+ _DTYPE_BPE: dict[int, float] = {
22
+ 0: 32.0, # F32
23
+ 1: 16.0, # F16
24
+ 30: 16.0, # BF16
25
+ 2: 4.5, # Q4_0
26
+ 4: 4.0, # Q4_1 (raw 4)
27
+ 8: 8.5, # Q8_0
28
+ }
29
+
30
+
31
+ @dataclass
32
+ class Mmproj:
33
+ filename: str = ""
34
+ params: int = 0 # total tensor element count (sum of dims)
35
+ bytes_: float = 0.0 # weight bytes
36
+ offload: bool = True # --mmproj-offload (default on)
37
+ enabled: bool = False
38
+
39
+
40
+ @dataclass
41
+ class MmprojBreakdown:
42
+ bytes_: float = 0.0
43
+ enabled: bool = False
44
+ offload: bool = True
45
+
46
+
47
+ def mmproj_bytes_from_tensors(
48
+ tensor_elems_by_dtype: dict[int, int], params: int | None = None,
49
+ ) -> float:
50
+ """Sum tensor element counts * bits-per-element / 8 -> weight bytes.
51
+
52
+ `tensor_elems_by_dtype` maps GGML dtype id -> total element count for that
53
+ dtype (produced by a header walk). `params` is unused but kept for API
54
+ symmetry with the GGUFMetadata path.
55
+ """
56
+ total_bits = 0.0
57
+ for dtype, count in tensor_elems_by_dtype.items():
58
+ bpe = _DTYPE_BPE.get(dtype)
59
+ if bpe is None:
60
+ # unknown dtype: assume f16 (most mmproj tensors) as a fallback
61
+ bpe = 16.0
62
+ total_bits += count * bpe
63
+ return total_bits / 8.0
64
+
65
+
66
+ def mmproj_bytes_from_meta(meta) -> float:
67
+ """Estimate projector weight bytes from a parsed GGUFMetadata/header.
68
+
69
+ Accepts a `GGUFMetadata` (has .raw dict and .params) or a raw metadata dict.
70
+ Uses general.parameter_count when present; otherwise sums tensor elems by
71
+ dtype if a tensor dtype histogram is available in meta['.tensor_dtypes'].
72
+ """
73
+ raw = getattr(meta, "raw", None)
74
+ if raw is None and isinstance(meta, dict):
75
+ raw = meta
76
+ if raw is None:
77
+ return 0.0
78
+
79
+ # Prefer an explicit tensor-dtype histogram if the caller attached one.
80
+ hist = raw.get(".tensor_dtype_hist") if isinstance(raw, dict) else None
81
+ if isinstance(hist, dict) and hist:
82
+ return mmproj_bytes_from_tensors(
83
+ {int(k): int(v) for k, v in hist.items()}
84
+ )
85
+ # Fall back to general.parameter_count * a default f16 bpw (2 bytes/elem).
86
+ pc = raw.get("general.parameter_count") if isinstance(raw, dict) else None
87
+ if pc:
88
+ return float(pc) * 2.0
89
+ return 0.0
vramcalc/report.py CHANGED
@@ -4,10 +4,12 @@ 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
@@ -42,19 +44,33 @@ class Inputs:
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
@@ -66,7 +82,9 @@ class Breakdown:
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,
@@ -74,7 +92,7 @@ def estimate(arch: ModelArch, inp: Inputs) -> Breakdown:
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
 
@@ -91,29 +109,52 @@ def estimate(arch: ModelArch, inp: Inputs) -> Breakdown:
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
 
@@ -126,19 +167,34 @@ def estimate(arch: ModelArch, inp: Inputs) -> Breakdown:
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,
@@ -148,6 +204,22 @@ def estimate(arch: ModelArch, inp: Inputs) -> Breakdown:
148
  )
149
 
150
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
151
  def format_bytes(n: float) -> str:
152
  """Human-readable byte size."""
153
  n = float(n)
@@ -180,10 +252,44 @@ def command_preview(arch: ModelArch, inp: Inputs) -> str:
180
  parts.append(f"--cache-type-v {inp.cache_dtype}")
181
  if inp.flash_attn:
182
  parts.append("--flash-attn")
183
- if len(inp.gpu_vram_gb) > 1:
184
- # tensor-split proportional to vram
185
- split = ",".join(f"{g}" for g in inp.gpu_vram_gb)
186
- parts.append(f"--tensor-split {split}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
187
  # YaRN / rope
188
  yarn_args = []
189
  if inp.rope_freq_scale != 1.0:
 
4
 
5
  from dataclasses import dataclass, field
6
 
7
+ from .quant import weight_bytes, QUANT_BPW
8
+ from .kv import kv_cache_bytes, compute_scratch_bytes, cache_dtype_bytes
9
  from .yarn import yarn_effective_context, yarn_warnings
10
+ from .gpu import gpu_split, GpuSplitResult, GpuSpec
11
+ from .draft import DraftInputs, draft_bytes
12
+ from .mmproj import Mmproj
13
 
14
 
15
  @dataclass
 
44
  yarn_attn_factor: float = 1.0
45
  yarn_beta_fast: float = 32.0
46
  yarn_beta_slow: float = 1.0
47
+ # GPU / multi-GPU
48
  gpu_vram_gb: list[float] = field(default_factory=lambda: [24.0])
49
+ split_mode: str = "layer" # layer | tensor | none
50
+ main_gpu: int = 0
51
+ tensor_split: list[float] | None = None # explicit --tensor-split ratios
52
+ unified_flags: list[bool] | None = None # per-device is_unified
53
+ # MTP
54
+ mtp_cache_dtype: str | None = None # MTP extra-layer KV dtype (else cache_dtype)
55
+ # Speculative decoding
56
+ draft: DraftInputs | None = None
57
+ # Multimodal
58
+ mmproj: Mmproj | None = None
59
  # margin
60
  safety_margin_pct: float = 5.0
61
 
62
 
63
+ _QUANTIZED_CACHE = {"q8_0", "q8_1", "q4_0", "q4_1", "q5_0", "q5_1"}
64
+
65
+
66
  @dataclass
67
  class Breakdown:
68
  weights_bytes: float = 0.0
69
  kv_cache_bytes: float = 0.0
70
  compute_scratch_bytes: float = 0.0
71
  mtp_overhead_bytes: float = 0.0
72
+ draft_bytes_: float = 0.0
73
+ mmproj_bytes_: float = 0.0
74
  gguf_overhead_bytes: float = 0.0
75
  safety_margin_bytes: float = 0.0
76
  total_bytes: float = 0.0
 
82
  def estimate(arch: ModelArch, inp: Inputs) -> Breakdown:
83
  weights = weight_bytes(arch.params, inp.quant)
84
 
85
+ mtp_dt = inp.mtp_cache_dtype or inp.cache_dtype
86
+
87
+ # KV cache (target; MTP extra layers use mtp_dt when set)
88
  kv = kv_cache_bytes(
89
  n_layer=arch.n_layer,
90
  n_embd=arch.n_embd,
 
92
  n_head_kv=arch.n_head_kv,
93
  n_ctx=inp.n_ctx,
94
  cache_dtype=inp.cache_dtype,
95
+ n_mtp=0, # MTP KV accounted separately for the dual-dtype case
96
  flash_attn=inp.flash_attn,
97
  )
98
 
 
109
  n_mtp=arch.n_mtp,
110
  )
111
 
112
+ # MTP overhead: the extra layers' weights + their KV (routed through the
113
+ # GPU split below, and also displayed separately).
114
  mtp_overhead = 0.0
115
  if arch.n_mtp > 0:
 
116
  if arch.n_layer > 0:
117
  per_layer_params = arch.params / arch.n_layer
118
+ mtp_weights = per_layer_params * (QUANT_BPW[inp.quant] / 8.0)
 
 
119
  else:
120
  mtp_weights = 0.0
 
121
  head_dim = arch.n_embd // arch.n_head if arch.n_head > 0 else 0
122
  mtp_kv = (
123
  arch.n_mtp * inp.n_ctx * 2 * arch.n_head_kv * head_dim
124
+ * cache_dtype_bytes(mtp_dt)
125
  )
126
  mtp_overhead = mtp_weights + mtp_kv
127
 
128
+ # Draft model (speculative decoding): weights + its own KV.
129
+ draft_bd = draft_bytes(
130
+ draft=inp.draft or DraftInputs(),
131
+ target_n_ctx=inp.n_ctx,
132
+ target_n_embd=arch.n_embd,
133
+ target_n_head=arch.n_head,
134
+ target_n_head_kv=arch.n_head_kv,
135
+ ) if inp.draft is not None else None
136
+ draft_total = draft_bd.total_bytes if draft_bd and draft_bd.enabled else 0.0
137
+
138
+ # Multimodal projector.
139
+ mmproj_total = 0.0
140
+ if inp.mmproj is not None and inp.mmproj.enabled:
141
+ mmproj_total = inp.mmproj.bytes_
142
+
143
  # GGUF header / alignment overhead: small constant per file, rough estimate
144
  gguf_overhead = max(arch.n_layer * 4096, 1 << 20) # >= 1 MiB
145
 
146
+ # The split handles weights + KV (target) + MTP KV + draft (on main GPU) +
147
+ # mmproj (on main GPU, if offloaded). scratch rides on the main GPU.
148
+ split_weights = weights + mtp_weights_only(arch, inp)
149
+ split_kv = kv + (mtp_overhead - mtp_weights_only(arch, inp))
150
+ extra_main = draft_total # draft rides on the main GPU
151
+ if inp.mmproj is not None and inp.mmproj.enabled and inp.mmproj.offload:
152
+ extra_main += mmproj_total
153
+
154
+ subtotal = (
155
+ weights + kv + scratch + mtp_overhead + draft_total + mmproj_total
156
+ + gguf_overhead
157
+ )
158
  margin = subtotal * (inp.safety_margin_pct / 100.0)
159
  total = subtotal + margin
160
 
 
167
  yarn_attn_factor=inp.yarn_attn_factor,
168
  )
169
 
170
+ # GPU split
171
+ specs = _gpu_specs(inp)
172
  gpu = gpu_split(
173
+ gpu_specs=specs,
174
+ weights_bytes=split_weights,
175
+ kv_bytes=split_kv,
176
+ scratch_bytes=scratch + extra_main,
177
+ split_mode=inp.split_mode,
178
+ main_gpu=inp.main_gpu,
179
+ tensor_split=inp.tensor_split,
180
+ cache_dtype_quantized=inp.cache_dtype in _QUANTIZED_CACHE,
181
  )
182
+ warns.extend(gpu.warnings)
183
+
184
+ if inp.split_mode == "tensor" and not inp.flash_attn:
185
+ warns.append("--split-mode tensor requires flash attention on.")
186
+ if inp.split_mode == "tensor" and inp.cache_dtype in _QUANTIZED_CACHE:
187
+ warns.append(
188
+ "--split-mode tensor disallows quantized KV cache; use f16/bf16/f32."
189
+ )
190
 
191
  return Breakdown(
192
  weights_bytes=weights,
193
  kv_cache_bytes=kv,
194
  compute_scratch_bytes=scratch,
195
  mtp_overhead_bytes=mtp_overhead,
196
+ draft_bytes_=draft_total,
197
+ mmproj_bytes_=mmproj_total,
198
  gguf_overhead_bytes=gguf_overhead,
199
  safety_margin_bytes=margin,
200
  total_bytes=total,
 
204
  )
205
 
206
 
207
+ def mtp_weights_only(arch: ModelArch, inp: Inputs) -> float:
208
+ """The weight portion of the MTP overhead (the KV portion is split_kv)."""
209
+ if arch.n_mtp <= 0 or arch.n_layer <= 0:
210
+ return 0.0
211
+ per_layer_params = arch.params / arch.n_layer
212
+ return per_layer_params * (QUANT_BPW[inp.quant] / 8.0)
213
+
214
+
215
+ def _gpu_specs(inp: Inputs) -> list[GpuSpec]:
216
+ flags = inp.unified_flags or ([False] * len(inp.gpu_vram_gb))
217
+ return [
218
+ GpuSpec(vram_gb=v, is_unified=(i < len(flags) and flags[i]))
219
+ for i, v in enumerate(inp.gpu_vram_gb)
220
+ ]
221
+
222
+
223
  def format_bytes(n: float) -> str:
224
  """Human-readable byte size."""
225
  n = float(n)
 
252
  parts.append(f"--cache-type-v {inp.cache_dtype}")
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
259
+ if multi or inp.split_mode != "layer":
260
+ parts.append(f"--split-mode {inp.split_mode}")
261
+ if inp.split_mode == "none":
262
+ parts.append(f"--main-gpu {inp.main_gpu}")
263
+ if multi and inp.tensor_split:
264
+ parts.append(
265
+ "--tensor-split " + ",".join(str(s) for s in inp.tensor_split)
266
+ )
267
+
268
+ # MTP draft KV dtype (only when it differs from the target KV dtype)
269
+ if arch.n_mtp > 0 and inp.mtp_cache_dtype and inp.mtp_cache_dtype != inp.cache_dtype:
270
+ # llama.cpp applies a single -ctk/-ctv; we note the MTP draft dtype.
271
+ parts.append(
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:
289
+ parts.append(f"--mmproj {inp.mmproj.filename or 'mmproj.gguf'}")
290
+ if not inp.mmproj.offload:
291
+ parts.append("--no-mmproj-offload")
292
+
293
  # YaRN / rope
294
  yarn_args = []
295
  if inp.rope_freq_scale != 1.0: