Skip to content

Commit 7cd38ae

Browse files
committed
feat: auto-size context to box VRAM + KV-cache quant + RTX PRO 6000 tier
- --context now defaults to 'auto': fill the rented box's spare VRAM instead of guessing or defaulting low. Working-VRAM headroom scales with GPU count (per-device), so it isn't biased toward big boxes. - --kv-quant (q8_0/q4_0) quantizes the llama.cpp KV cache; sizing accounts for the saving so a longer context still finds a fitting box. - Add the RTX PRO 6000 (96GB) GPU tier — previously invisible to the sizer, so big GGUF models skipped it for pricier 8x/H200 boxes.
1 parent 91b2e79 commit 7cd38ae

3 files changed

Lines changed: 133 additions & 6 deletions

File tree

aiod/cli.py

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
from .bootstrap import CONTAINER_PORT, ServerConfig
2323
from .config import Settings
2424
from .health import wait_until_ready
25-
from .sizing import QUANT_LABELS, size_any
25+
from .sizing import QUANT_LABELS, auto_context_for_offer, size_any
2626
from .vast import PricedOption, recommend_disk_gb
2727

2828
app = typer.Typer(
@@ -317,7 +317,14 @@ def spin(
317317
idle: int = typer.Option(
318318
None, "--idle", help="Auto-shutdown after N idle minutes (starts a local watcher)"
319319
),
320-
context: int = typer.Option(None, "--context", help="Max model length to serve"),
320+
context: str = typer.Option(
321+
"auto", "--context",
322+
help="Max model length: an int, or 'auto' to fill the rented box's spare VRAM",
323+
),
324+
kv_quant: str = typer.Option(
325+
None, "--kv-quant",
326+
help="GGUF only: quantize the KV cache (q8_0/q4_0) to fit a longer context",
327+
),
321328
concurrency: int = typer.Option(None, "--concurrency", help="Concurrency for KV sizing"),
322329
yes: bool = typer.Option(False, "--yes", "-y", help="Skip the confirmation prompt"),
323330
no_ccr: bool = typer.Option(False, "--no-ccr", help="Don't touch the CCR config"),
@@ -343,7 +350,12 @@ def spin(
343350
quant = quant or (prof.quant if prof else None)
344351
provider = (provider or (prof.provider if prof else "vast")).lower()
345352
_require_provider_key(s, provider)
346-
context = context if context is not None else (prof.context if prof else None)
353+
# Context: explicit int wins; 'auto' (the default) defers to a pinned profile
354+
# value, else fills the box's spare VRAM once the offer is known (GGUF only).
355+
if context == "auto" and prof and prof.context is not None:
356+
context = str(prof.context)
357+
auto_context = context == "auto"
358+
context = None if auto_context else int(context)
347359
concurrency = concurrency if concurrency is not None else (prof.concurrency if prof else 4)
348360
max_p = (
349361
max_price if max_price is not None
@@ -374,8 +386,12 @@ def spin(
374386
sizing = size_any(
375387
model, engine=engine, hf_token=s.hf_token,
376388
quants=[quant] if quant else None, context_len=context, concurrency=concurrency,
389+
kv_quant=kv_quant,
377390
)
378391
eng = sizing.engine
392+
if kv_quant:
393+
# Quantize the llama.cpp KV cache (sizing already accounts for the saving).
394+
extra_args += ["--cache-type-k", kv_quant, "--cache-type-v", kv_quant]
379395
m = sizing.model
380396

381397
if quant is None:
@@ -406,6 +422,19 @@ def spin(
406422
raise typer.Exit(1)
407423

408424
offer = best.offer
425+
426+
# Auto-context: now that a box is chosen, hand its spare VRAM to the context
427+
# window. GGUF only — vLLM keeps deferring to the model's own max (None).
428+
ctx_note = ""
429+
if auto_context and eng == "llamacpp":
430+
context = auto_context_for_offer(
431+
offer.total_vram_gb, plan.weights_gb, kv_quant,
432+
num_gpus=best.option.num_gpus,
433+
)
434+
ctx_note = f"\nContext: [bold]{context:,}[/] tokens (auto — fills spare VRAM)"
435+
elif not auto_context:
436+
ctx_note = f"\nContext: {context:,} tokens"
437+
409438
console.print(
410439
Panel(
411440
f"[bold]{m.repo_id}[/] · {quant} ({QUANT_LABELS.get(quant, quant)})\n"
@@ -414,7 +443,8 @@ def spin(
414443
f"Disk: {disk} GB\n"
415444
f"Price: [bold]${offer.dph_total:.2f}/hr[/] (~${offer.dph_total * ttl_h:.2f} "
416445
f"over a {ttl_h:g}h session)\n"
417-
f"VRAM: need ~{plan.required_vram_gb:.0f} GB / have {offer.total_vram_gb:.0f} GB",
446+
f"VRAM: need ~{plan.required_vram_gb:.0f} GB / have {offer.total_vram_gb:.0f} GB"
447+
f"{ctx_note}",
418448
title="Launch plan",
419449
border_style="cyan",
420450
expand=False,

aiod/sizing.py

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ class GpuTier:
6969
GpuTier("A100 80GB", 80, ["A100 SXM4 80GB", "A100 PCIE 80GB", "A100_SXM4_80GB", "A100"]),
7070
GpuTier("H100 80GB", 80, ["H100 SXM", "H100 PCIE", "H100_SXM", "H100"]),
7171
GpuTier("H100 NVL 94GB", 94, ["H100 NVL", "H100_NVL"]),
72+
GpuTier("RTX PRO 6000 96GB", 96, ["RTX PRO 6000", "RTX 6000 Pro", "RTX PRO 6000 WS", "RTX6000Pro"]),
7273
GpuTier("H200 141GB", 141, ["H200"]),
7374
GpuTier("B200 180GB", 180, ["B200"]),
7475
]
@@ -397,10 +398,63 @@ def detect_format(repo: str, hf_token: str | None = None, timeout: float = 20.0)
397398
return "unknown"
398399

399400

401+
# KV-cache size relative to f16, by llama.cpp --cache-type. Quantizing the KV
402+
# cache (q8_0/q4_0) is the main lever for fitting a longer context on the same box.
403+
KV_CACHE_FACTOR = {
404+
None: 1.0, "f16": 1.0, "bf16": 1.0,
405+
"q8_0": 0.5,
406+
"q5_0": 0.34, "q5_1": 0.34,
407+
"q4_0": 0.27, "q4_1": 0.27,
408+
}
409+
410+
# llama.cpp needs working VRAM beyond weights+KV (compute graph, fragmentation).
411+
# This is per-device, so it scales with GPU count, not model size — held back when
412+
# auto-sizing context so a full-to-the-brim estimate doesn't OOM. Kept modest
413+
# because the KV estimate itself already over-counts (see size_gguf_model).
414+
GGUF_PER_GPU_BUFFER_GB = 2.0
415+
GGUF_MIN_BUFFER_GB = 3.0
416+
417+
418+
def context_for_vram(kv_budget_gb: float, kv_quant: str | None = None) -> int:
419+
"""Inverse of the GGUF KV model: how many tokens a VRAM budget buys.
420+
421+
`size_gguf_model` models f16 KV as ~8 GB per 8192 tokens; this reverses it,
422+
scaled by the same `--cache-type` factor.
423+
"""
424+
factor = KV_CACHE_FACTOR.get(kv_quant, 1.0)
425+
if kv_budget_gb <= 0:
426+
return 0
427+
return int(kv_budget_gb / (8.0 * factor) * 8192)
428+
429+
430+
def auto_context_for_offer(
431+
total_vram_gb: float,
432+
weights_gb: float,
433+
kv_quant: str | None = None,
434+
*,
435+
num_gpus: int = 1,
436+
round_to: int = 4096,
437+
floor: int = 8192,
438+
cap: int = 262144,
439+
) -> int:
440+
"""Max context that fits a rented box's spare VRAM after weights + headroom.
441+
442+
Uses the offer's real VRAM (not the GPU-tier minimum), so a 98 GB card isn't
443+
sized as 96. The working-VRAM headroom scales with `num_gpus` (it's a
444+
per-device cost), so this isn't biased toward big multi-GPU boxes. Rounded
445+
down to `round_to`, clamped to [`floor`, `cap`].
446+
"""
447+
buffer_gb = max(GGUF_MIN_BUFFER_GB, GGUF_PER_GPU_BUFFER_GB * num_gpus)
448+
kv_budget = total_vram_gb - weights_gb * 1.02 - buffer_gb
449+
ctx = (context_for_vram(kv_budget, kv_quant) // round_to) * round_to
450+
return max(floor, min(ctx, cap))
451+
452+
400453
def size_gguf_model(
401454
repo: str,
402455
hf_token: str | None = None,
403456
context_len: int | None = None,
457+
kv_quant: str | None = None,
404458
) -> SizingResult:
405459
"""Size every GGUF quant in a repo by file size and produce a GPU plan each."""
406460
repo = parse_repo_id(repo)
@@ -410,7 +464,9 @@ def size_gguf_model(
410464

411465
ctx = context_len or 8192
412466
# llama.cpp KV cache scales with context; rough budget (no per-model config here).
413-
kv_gb = max(4.0, (ctx / 8192.0) * 8.0)
467+
# A quantized KV cache (--cache-type-k/v) shrinks it by the factor above.
468+
kv_factor = KV_CACHE_FACTOR.get(kv_quant, 1.0)
469+
kv_gb = max(4.0, (ctx / 8192.0) * 8.0) * kv_factor
414470

415471
plans: list[QuantPlan] = []
416472
for q in sorted(quants.values(), key=lambda x: x.size_gb):
@@ -451,6 +507,7 @@ def size_any(
451507
quants: list[str] | None = None,
452508
context_len: int | None = None,
453509
concurrency: int = 4,
510+
kv_quant: str | None = None,
454511
) -> SizingResult:
455512
"""Engine-aware entry point: auto-detect GGUF vs safetensors (or force via
456513
`engine`) and return the matching SizingResult."""
@@ -459,7 +516,7 @@ def size_any(
459516
if engine == "auto":
460517
eng = "llamacpp" if detect_format(repo, hf_token=hf_token) == "gguf" else "vllm"
461518
if eng == "llamacpp":
462-
return size_gguf_model(repo, hf_token=hf_token, context_len=context_len)
519+
return size_gguf_model(repo, hf_token=hf_token, context_len=context_len, kv_quant=kv_quant)
463520
return size_model(
464521
repo, hf_token=hf_token, quants=quants, context_len=context_len, concurrency=concurrency
465522
)

tests/test_sizing.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
from aiod.sizing import (
22
ModelSpec,
33
_params_from_name,
4+
auto_context_for_offer,
5+
context_for_vram,
46
estimate_vram,
57
parse_repo_id,
68
plan_gpus,
@@ -68,3 +70,41 @@ def test_small_model_fits_one_gpu():
6870
_, _, req = estimate_vram(spec, "bf16", context_tokens=8192)
6971
opts = {o.tier.name: o for o in plan_gpus(req)}
7072
assert opts["A100 80GB"].num_gpus == 1
73+
74+
75+
def test_context_for_vram_scales_with_kv_quant():
76+
# q4_0 KV is ~0.27x f16, so the same VRAM buys ~3.7x the tokens.
77+
f16 = context_for_vram(16.0, None)
78+
q4 = context_for_vram(16.0, "q4_0")
79+
assert f16 == 16384 # 16 GB / 8 GB-per-8k * 8192
80+
assert q4 > f16 * 3
81+
assert context_for_vram(0.0) == 0
82+
83+
84+
def test_auto_context_fills_offer_vram():
85+
# Tight box: weights 342.7 GB on a 392 GB box leaves little for f16 KV,
86+
# but q4_0 stretches it well past the f16 result.
87+
f16 = auto_context_for_offer(392.0, 342.7, None, num_gpus=4)
88+
q4 = auto_context_for_offer(392.0, 342.7, "q4_0", num_gpus=4)
89+
assert q4 > f16 > 0
90+
assert f16 % 4096 == 0 and q4 % 4096 == 0 # rounded to multiple
91+
92+
93+
def test_auto_context_headroom_scales_with_gpu_count():
94+
# The working-VRAM buffer is per-device, so more GPUs reserve more and
95+
# leave less for context on an otherwise identical budget.
96+
few = auto_context_for_offer(360.0, 300.0, None, num_gpus=1)
97+
many = auto_context_for_offer(360.0, 300.0, None, num_gpus=8)
98+
assert few > many
99+
100+
101+
def test_auto_context_small_model_not_starved_by_buffer():
102+
# A 7B-ish Q4 (~4.6 GB) on a single 24 GB card should get a real window,
103+
# not get floored because a fat flat buffer ate the spare VRAM.
104+
assert auto_context_for_offer(24.0, 4.6, None, num_gpus=1) > 8192
105+
106+
107+
def test_auto_context_clamps_to_cap_and_floor():
108+
# Huge box -> cap; box that can't even hold weights -> floor.
109+
assert auto_context_for_offer(2000.0, 342.7, "q4_0", num_gpus=4) == 262144
110+
assert auto_context_for_offer(350.0, 342.7, None, num_gpus=4) == 8192

0 commit comments

Comments
 (0)