← Writing
systemsJuly 16, 2026 · 20 min readollama PR #17201 ↗

Fixing ollama's MoE memory estimate — and a 3× kernel speedup that wasn't

Auditing Ollama and llama.cpp GPU memory estimation and quantized kernel execution. Shipped PR #17201 fixes a -20.6% VRAM under-estimation in MoE models. Includes analysis of MMQ kernel tile configurations and DeepSeek MLA execution paths.

−6.0%MoE VRAM error, from −20.6% low · shipped as PR #17201

Ollama and llama.cpp predict a model’s GPU memory footprint during startup to determine how many transformer layers can be placed into GPU VRAMThe GPU's onboard memory, where the weights, KV cache, and compute buffer all have to fit.. If the estimator underestimates memory consumption, the runtime either forces unnecessary layer offloading to CPU or triggers out-of-memory (OOM) allocation failures during runtime.

This post analyzes Ollama’s memory estimation accounting alongside GPU kernel execution in llama.cpp. A rewrite of Ollama’s MoE memory estimator (PR #17201) corrected a -20.6% memory under-estimation down to -6.0%. Additionally, we examine an initial 3.43× speedup benchmark in llama.cpp’s MMQ kernel that failed numerical correctness checks on large matrix shapes.

−6.0%
MoE VRAM error, fixed
from −20.6% low · shipped as PR #17201
25 / 78
correctness cases the fast config failed
NMSE ≈ 1.0 · caught inside the tuning loop
1 of 4
investigations that shipped
the other three: measured, not pushed

Auditing Ollama’s MoE Memory Footprint Estimator

When placing model layers between GPU VRAM and system RAM, Ollama calculates expected allocations per layer. For mixture-of-expertsA model whose layers hold many expert sub-networks and fire only a few per token, so its footprint is dominated by weights that differ from layer to layer. (MoE) architectures, legacy allocation estimates ran ~20% below actual memory allocations.

Comparing estimated allocations against actual llama-server runtime allocations revealed two primary sources of error:

  1. KV Cache Sizing: The KV cacheThe key and value vectors stored for every token, sized by layers, KV-head count, head dimension, and context length. was calculated using a single averaged head dimension across all layers.
  2. Compute Scratch Buffer: Memory allocated for intermediate tensor execution (compute bufferScratch space llama.cpp reserves for the largest intermediate tensors in one graph step; it grows with context and batch.) was modeled as zero.

Using a single averaged head dimension fails on modern architectures. DeepSeek-V2’s Multi-Head Latent Attention (MLA) specifies asymmetric key and value dimensions (key_length = 192, value_length = 128); averaging these dimensions under-sized the KV cache by ~20%. Similarly, Grouped-Query Attention (GQAGrouped-query attention: several query heads share one key/value head, so the KV cache is far smaller than the query-head count suggests.) shares key/value heads across query groups; averaging head counts obscured this ratio.

PR #17201 replaced global averaging with per-layer metadata extraction, reading exact key/value dimensions and KV-head counts per layer while assigning a non-zero lower bound to compute scratch buffers.

Estimated vs actual GPU memory allocation (DeepSeek-V2 16B MLA)
Measured actual (llama-server)19,392 MiB · actual target
Legacy estimator15,405 MiB · −20.6% error
PR #17201 estimator18,235 MiB · −6.0% error
Memory estimation breakdown on deepseek-v2:16b (RTX 3090, 32k context)
EstimateKV Cache (MiB)Compute Buffer (MiB)Total (MiB)Error vs. Actual
Actual (measured)8640237619392baseline
Legacy estimator6912015405−20.6%
PR #17201 estimator8640110318235−6.0%

KV cache estimation matches the measured 8,640 MiB exactly. The remaining 6.0% delta reflects a conservative compute buffer floor (1,103 MiB vs 2,376 MiB peak).

Mathematical impact of averaged head dimensions on KV cache sizing

KV cache allocation is defined as:

Memory = 2 × layers × kv_heads × head_dim × context_length × bytes_per_element

Averaging head_dim across asymmetric key (192192) and value (128128) tensors under-sizes memory by ~20%. Reading exact per-layer metadata ensures exact KV cache accounting.

Compute buffer scratch space accounting

In addition to weight matrices and KV caches, llama.cpp allocates scratch space for graph intermediates (attention logits, expert routing buffers, feed-forward activations). Setting this buffer to zero in legacy code created memory estimation deficits that grew proportionally with context length. PR #17201 assigns a conservative non-zero baseline buffer to prevent unexpected OOM errors.

Evaluating llama.cpp MMQ Kernel Tile Sweeps and Correctness Validation

llama.cpp’s quantized matrix multiplication kernel (MMQ) uses a fixed default tile configThe compile-time tuple that sets how a kernel splits a matmul into tiles across threads, registers, and VRAM. (nthreads=256, occupancy=1, I=128, K_vram=256, stream_k=true) across multiple NVIDIA GPU generations (A100, RTX 3090, RTX 4090, H100).

We evaluated candidate tile configurations to test whether hardware-specific tuning could improve throughput. Increasing VRAM staging tile size to K_vram=512 produced a 3.43× throughput jump in microbenchmarks (138.9 TFLOP/s vs 40.5 TFLOP/s on Q4_K) and a 1.60× speedup in llama-bench.

However, validating the candidate configuration against llama.cpp’s test-backend-ops suite revealed numerical failures. The K_vram=512 tile configuration failed 25 out of 78 correctness tests for Q4_K and Q6_K tensor shapes, returning Normalized Mean-Squared Error (NMSENormalized mean-squared error, MSE divided by the variance of the true output. Zero is exact; 1.0 means the output is uncorrelated with the correct answer.) values near 1.0 on large-matrix prefill shapes.

MMQ tile configuration benchmark and correctness results (Q4_K)
Tile ConfigurationQ4_K ThroughputSpeedup vs DefaultMUL_MAT Correctness Test
Shipped default (K_vram=256)40.5 TFLOP/s1.00×78 / 78 Passed
Candidate (K_vram=512)138.9 TFLOP/s3.43× micro / 1.60× bench53 / 78 Passed (25 Failed, NMSE ≈ 1.0)

The 25 failing test cases correspond to large prefill matrix shapes. The 3.43× microbenchmark speedup was an artifact of incomplete matrix computations, and the configuration was retracted.

Interpretation of NMSE = 1.0 error metrics

Normalized Mean Squared Error is defined as NMSE = MSE(output, reference) / Var(reference). An NMSE value near 1.0 indicates that kernel outputs share no correlation with reference outputs, indicating missing or uncalculated matrix tile passes.

Benchmarking INT8 k-Quant Kernels vs. FP16 cuBLAS

llama.cpp provides INT8 quantization paths for Q4_K and Q6_K weights (k-quant). Upstream documentation notes that at larger batch sizes (e.g. batch 512\ge 512), FP16 cuBLAS GEMMGeneral matrix multiply, the dense linear-algebra operation at the core of every transformer layer. execution can outperform custom INT8 dequantization kernels once workload intensity becomes compute-bound.

We benchmarked INT8 k-quant against FP16 cuBLAS on a Qwen2.5-1.5B model using an RTX 3090 GPU:

Relative throughput: Q4_K INT8 k-quant vs FP16 cuBLAS (Qwen2.5-1.5B, RTX 3090)
FP16 cuBLAS baseline1.00×
k-quant INT8 (Prefill phase)1.34× · Compute-intensive
k-quant INT8 (Decode phase)1.74× · Memory-bandwidth bound

On smaller models or memory-bound batch sizes (batch size 1 decode), the INT8 k-quant path maintains a clear advantage (1.74× speedup over FP16 cuBLAS) by reducing HBM weight transfer volume.

Crossover dynamics between k-quant and cuBLAS

At small batch sizes, inference is constrained by HBM read speeds, making weight volume reduction (INT8/Q4_K) the primary driver of performance. At high batch sizes (batch 512\ge 512), execution shifts to compute-bound Tensor Core execution, where FP16 cuBLAS routines sustain higher peak compute density.

DeepSeek Multi-Head Latent Attention (MLA) Execution Paths

DeepSeek architectures utilize Multi-Head Latent Attention (MLA) to compress key and value projections into latent representations. We analyzed MLA execution paths within llama.cpp’s FlashAttention implementation (fattn.cu).

MLA operates in two structural representations within llama.cpp:

llama.cpp FlashAttention execution paths for DeepSeek MLA
MLA RepresentationHead Dims (K / V)GQA RatioTensor Core (MMA) Support
Absorbed representation576 / 512gqa = n_headSupported (MMA kernel active)
Uncompressed representation192 / 128gqa = 1Fallback path (gated at fattn.cu:412)

Absorbed MLA representations dispatch to Tensor Core MMA kernels. Uncompressed representations with GQA ratio = 1 trigger non-tensor-core fallback execution.

MLA execution dispatch in fattn.cu

In absorbed MLA representations, latent up-projection matrices are combined into the attention layer, yielding key/value dimensions of 576/512576 / 512. This shape satisfies llama.cpp’s MMA FlashAttention kernel constraints.

Uncompressed MLA representations (192/128192 / 128 dimensions, GQA=1GQA = 1) are restricted by a condition at fattn.cu:412, forcing execution onto non-tensor-core fallback paths. Re-enabling double-buffering on this fallback path provides a projected 1.15–1.4× performance increase.

Next Steps

  1. Gated MMQ Tile Sweeps: Re-evaluate MMQ tile parameters across GPU microarchitectures with inline test-backend-ops correctness validation.
  2. Compute-Bound Crossover Benchmarking: Benchmark INT8 k-quant kernels against FP16 cuBLAS on 30B+ parameter models at batch sizes 512\ge 512.
  3. MLA Fallback Optimization: Optimize memory double-buffering for uncompressed MLA fallback execution paths in fattn.cu.

Conclusion

Systematic profiling of memory accounting and kernel paths ensures predictable deployment behavior. Correcting Ollama’s MoE memory estimator (PR #17201) prevents premature CPU offloading and OOM failures, while combining throughput benchmarks with numerical correctness gates prevents false performance conclusions during kernel optimization.

Glossary

Glossary of memory and kernel optimization terms
Mixture of experts (MoE)
A model whose layers hold many expert sub-networks and fire only a few per token. Its footprint is dominated by weights that differ from layer to layer, so one averaged number describes it badly.
VRAM
The GPU's onboard memory. Weights, the KV cache, and the compute buffer all have to fit inside it, or a layer moves to the CPU.
Memory estimate
ollama's prediction of a model's GPU footprint, used to decide how many layers to place on the card before loading it.
Offload
Placing part of a model on the CPU when the GPU estimate says it will not fit. Correct when a model genuinely does not fit, needless when an under-estimate triggers it.
KV cache
The stored keys and values for every token seen, sized as 2 × layers × kv_heads × head_dim × context × bytes. The term the ollama fix makes exact.
Compute buffer
Scratch space llama.cpp reserves for the largest intermediate tensors in one graph step. It grows with context and batch, and the old estimate counted it as zero.
MLA (multi-head latent attention)
DeepSeek attention that compresses keys and values into a latent space, with asymmetric key and value dimensions (192 / 128 uncompressed, 576 / 512 absorbed).
GQA (grouped-query attention)
Several query heads share one key/value head, so the KV cache is far smaller than the query-head count implies. A single averaged head hides the ratio.
MMQ
llama.cpp's quantized matrix-multiply kernel (Matrix Multiply Quantized), which runs quantized weights on the GPU using one compile-time tile config per GPU family.
k-quant
llama.cpp weight formats such as Q4_K and Q6_K that pack weights into 4 to 6 bits with per-block scales.
GEMM
General matrix multiply, the dense linear-algebra operation at the core of every transformer layer.
Tile config
The compile-time tuple (nthreads, occupancy, I, K_vram, stream_k) that sets how a kernel splits a matmul into tiles across threads, registers, and VRAM.
NMSE
Normalized mean-squared error: mean-squared error divided by the variance of the true output. Zero is exact; 1.0 means the output carries no more information than a constant would.
Quantization
Storing weights in fewer bits, here 4 to 8, to shrink memory and speed up the matmul. The kernels above run on quantized weights directly.
Prefill / decode
Prefill processes the prompt in one large batch and is compute-bound; decode generates one token at a time and is memory-bound. Which kernel wins often flips between them.
Roofline
The hardware ceiling on a kernel: once it saturates compute or memory bandwidth, no scheduling change makes it faster. A 3× jump past it signals skipped work, not a win.