This is the technical, reproducible version of a fix I shipped on my own homelab. If you want the narrative version, that's on Medium. This one is the recipe: the measurements, the math, the Modelfile, and the exact prompt I gave Claude Code to generate it. Copy-paste friendly.
Repo for the dashboard used throughout: https://github.com/SikamikanikoBG/homelab-monitor
18.3 + 7.7 = 26GB
β CUDA OOM whenever they overlapped.num_ctx
to 8192 β KV cache drops from ~6.1GB to ~1.25GB β model footprint ~18.3GB β 14.2 + 7.7 = 21.9GB
β both resident, zero OOM, no quality loss.
Host: openSUSE, Xeon (56 threads), 125GB RAM, 1x RTX 3090 (24GB)
GPU svc: WhisperX large-v3 (speech-to-text)
GPU svc: Ollama -> devstral-small-2 (24B, Q4_K_M) for background email triage
Both services run all the time. The OOM only happened when I dictated to my assistant (WhisperX) while the triage loop was active.
nvidia-smi
shows instantaneous VRAM. It can't show you which service spiked or when two of them overlapped β and an intermittent OOM is a timing problem. You need per-service VRAM history.
I use my own dashboard (homelab-monitor) for this. The relevant view is "AI Models", which attributes VRAM per model server and per loaded model, over a time range, with OOM markers and a capacity ceiling line.
What the history showed at the overlap window:
| Service | Peak VRAM |
|---|---|
| Devstral 24B (triage) | ~18.3 GB |
| WhisperX large-v3 | 7.7 GB |
| Total | |
| ~26 GB on a 24 GB card |
If you want to reproduce the measurement, the dashboard runs as a single container:
git clone https://github.com/SikamikanikoBG/homelab-monitor
cd homelab-monitor
docker compose up -d --build
(NVIDIA Container Toolkit required for GPU metrics. Remote hosts are monitored over SSH, no agent.)
Weights are a fixed cost (~15GB for Devstral 24B at Q4_K_M). The variable cost is the KV cache, which scales linearly with num_ctx
. So the question is: how much context does background email triage actually use?
I pulled the request traces from Langfuse. The triage pipeline:
Real prompts never exceeded ~5β8k tokens. The model was loaded with a 40k window β ~32k tokens of reserved KV cache doing nothing.
Devstral Small is mistral3
. Pull the architecture straight from Ollama:
curl -s http://localhost:11434/api/show -d '{"name":"devstral-small-2:latest"}' \
| python -c "import sys,json;mi=json.load(sys.stdin)['model_info'];\
print({k:v for k,v in mi.items() if 'head_count' in k or 'block_count' in k or 'length' in k})"
Relevant values:
block_count (layers) = 40
attention.head_count_kv = 8
attention.key_length = 128
attention.value_length = 128
context_length (native) = 8192 # rope-extended to 393216
KV cache per token (f16) = 2 (K+V) Γ layers Γ kv_heads Γ head_dim Γ 2 bytes
:
2 Γ 40 Γ 8 Γ 128 Γ 2 = 163,840 bytes β 0.156 MB / token
So:
| num_ctx | KV cache (f16) |
|---|---|
| 40,960 | ~6.1 GB |
| 16,384 | ~2.5 GB |
| 8,192 | |
| ~1.25 GB | |
| 4,096 | ~0.6 GB |
8192 is the sweet spot: it's above the real worst-case prompt (~5β8k) and it's the model's native context length, so there's no rope extrapolation quality hit. I rejected 4096 β a 10-email batch with 2k generation can brush up against it.
Ollama lets you inherit existing weights and override parameters in a Modelfile, so this costs no extra disk and no re-download.
Modelfile.triage
:
FROM devstral-small-2:latest
PARAMETER num_ctx 8192
PARAMETER temperature 0
PARAMETER num_predict 2048
SYSTEM """You are a background email-triage engine. Follow the exact output
format in each request. Output only the requested label(s) or field(s). Never
add explanations, preamble, or commentary. When uncertain, pick the closest
valid option. Be terse and deterministic."""
Build it:
ollama create devstral-small-2:triage -f Modelfile.triage
The optional SYSTEM
block is a small bonus: triage prompts want terse, structured output, and pinning that behaviour cuts stray preamble (fewer reparse/retry calls = less GPU time).
I let Claude Code do the measuring and the Modelfile generation. The prompt, roughly:
Analyze my background email triage. Pull the Langfuse traces to find the real prompt/context sizes the triage job uses, decide a safe
num_ctx
cap that won't truncate worst-case batches, confirm the KV-cache savings against the model's actual architecture, and generate an Ollama Modelfile for a context-capped:triage
variant. Then tell me the expected VRAM footprint.
It came back with: traces show β€8k tokens, cap at 8192 (native window), ~5GB KV saved, expected footprint ~14β16GB. Which matched what the dashboard measured after I deployed it.
curl -s http://localhost:11434/api/generate \
-d '{"model":"devstral-small-2:triage","prompt":"ping","stream":false}' >/dev/null
curl -s http://localhost:11434/api/ps \
| python -c "import sys,json;[print(m['name'],round(m['size_vram']/1e9,1),'GB ctx',m['context_length']) for m in json.load(sys.stdin)['models']]"
Result: the triage model holds ~14GB resident at ctx=8192
, down from ~18GB.
| Before | After | |
|---|---|---|
| Triage LLM | ~18.3 GB | ~14.2 GB |
| WhisperX large-v3 | 7.7 GB | 7.7 GB |
| Combined | ||
| ~26 GB β OOM | ||
| ~21.9 GB β fits |
Both services now sit on the card together. Full STT quality, email triage in parallel, ~2GB headroom. No quant change, no CPU offload, no smaller Whisper.
nvidia-smi
can't diagnose it β get per-service VRAM num_ctx
to the real workload.Dashboard used for the per-service VRAM history: ** https://github.com/SikamikanikoBG/homelab-monitor** β it's open source, runs in one container, and exists because I needed exactly this view and
nvidia-smi
wouldn't give it to me.