Spaces:
Running on Zero
VRAM Calculator v2: unified memory, real multi-GPU, spec decoding, multimodal, MTP, auto-fit
Browse filesRewrite 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 +328 -20
- tests/test_vramcalc.py +201 -17
- vramcalc/__init__.py +16 -1
- vramcalc/autofit.py +177 -0
- vramcalc/draft.py +95 -0
- vramcalc/gguf.py +10 -2
- vramcalc/gpu.py +182 -55
- vramcalc/mmproj.py +89 -0
- vramcalc/report.py +130 -24
|
@@ -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,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 164 |
):
|
| 165 |
arch = _fields_to_arch(arch_fields)
|
| 166 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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**⚠️
|
| 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
|
| 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 (
|
| 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}
|
| 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 |
-
|
| 326 |
with gr.Row():
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,
|
| 339 |
)
|
| 340 |
with gr.Row():
|
| 341 |
-
|
| 342 |
-
label="
|
| 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,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
|
|
@@ -155,41 +155,91 @@ def test_yarn_warnings_no_warning_when_within():
|
|
| 155 |
assert w == []
|
| 156 |
|
| 157 |
|
| 158 |
-
def
|
| 159 |
res = gpu_split(
|
| 160 |
gpu_vram_bytes=[24 << 30, 24 << 30, 16 << 30],
|
| 161 |
weights_bytes=40 << 30,
|
| 162 |
-
|
|
|
|
| 163 |
)
|
| 164 |
-
#
|
| 165 |
-
|
| 166 |
-
|
| 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) ==
|
| 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
|
| 177 |
res = gpu_split(
|
| 178 |
-
gpu_vram_bytes=[
|
| 179 |
weights_bytes=10 << 30,
|
| 180 |
-
|
| 181 |
-
|
|
|
|
| 182 |
)
|
| 183 |
-
|
| 184 |
-
assert
|
| 185 |
-
assert
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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 "--
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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():
|
|
@@ -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",
|
|
@@ -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 |
+
)
|
|
@@ -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 |
+
)
|
|
@@ -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 |
-
|
| 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:
|
|
@@ -1,11 +1,26 @@
|
|
| 1 |
-
"""Multi-GPU VRAM budgeting
|
| 2 |
-
|
| 3 |
-
llama.cpp
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
|
| 42 |
|
| 43 |
def gpu_split(
|
| 44 |
*,
|
| 45 |
-
|
|
|
|
| 46 |
weights_bytes: float,
|
| 47 |
-
|
| 48 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
) -> GpuSplitResult:
|
| 50 |
-
"""
|
| 51 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
return GpuSplitResult()
|
| 53 |
|
| 54 |
-
|
| 55 |
-
|
| 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 |
-
|
| 65 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 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=
|
| 98 |
all_fit=all(a.fits for a in assignments),
|
| 99 |
-
|
| 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 |
|
|
@@ -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
|
|
@@ -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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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=
|
| 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 |
-
#
|
|
|
|
| 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 |
-
*
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 130 |
gpu = gpu_split(
|
| 131 |
-
|
| 132 |
-
weights_bytes=
|
| 133 |
-
|
| 134 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:
|