Build1 publisher3 min readPublished
A 4 GB laptop GPU decodes quantised Gemma 4 at 4.27x the CPU rate on 1598 MiB
Two arms on the same laptop differ by one flag. The small card wins because llama.cpp leaves Gemma 4's 1.93 GB per-layer embedding table in mmap and pulls a few rows per token, so only about 1.08 GB of body is resident.
The Engineer · Build desk

What happened
- On one laptop where the two command lines differ only by -ngl 0 against -ngl 99, median decode ran 4.27x faster on the GPU arm, with prefill 3.63x and end-to-end 3.81x.
- With the model loaded and serving, the 4 GB GTX 1650 Ti held 1598 MiB, and the write-up concludes the card is about 2.5x larger than this checkpoint needs.
- Decode barely moved across a 21x prompt range on either arm, while time to first token climbed from 1143 ms to 23926 ms on the CPU against 385 ms to 6522 ms on the GPU.
Compiled by The EngineerSomething wrong?How this is made
Why it matters
- constraint The result belongs to this loader as much as this card. A model whose embedding tensors must be resident needs the whole 3.35 GB in VRAM, so 4 GB stops being enough.
- capability A 2021 laptop card can host this quantised Gemma at single-stream with memory to spare, so a small-model serving arm can be sized from hardware already in the chassis.
- decision Anyone choosing CPU-only serving for this model is trading first-token latency for hardware simplicity, since decode holds up under long prompts and prefill does not.
Decode reads the whole model once per token, so it is bandwidth-bound. Prefill multiplies through the prompt, so it is compute-bound [10]. Both arms show that shape. CPU decode moves from 17.61 to 15.66 tok/s across a 21x range of prompt length, and GPU decode from 71.22 to 67.61 [7]. Divide the endpoints: 4.04x at the short prompt, 4.32x at the long one [1], which brackets the 4.04x to 4.34x span reported over all eight cells [6]. The CPU arm gives up about 11% of its decode rate over that range; the GPU arm gives up about 5% [5]. The write-up attributes the difference to the growing KV cache [9].
Of the 3.334 GB of tensor bytes in the file, 2.257 GB is Q6_K embeddings, and `per_layer_token_embd` alone is 1.93 GB, 58% of the artifact [12][13]. In llama.cpp's `src/models/gemma4.cpp` that tensor is created with `TENSOR_READ_LAZY` and served by `GGML_OP_GET_ROWS` straight out of the mmap, a few rows per token [13]. Subtract the embeddings and about 1.08 GB of Q4_0 transformer body is left [2]. That body is what decode reads every token, joined on the card by roughly 60 MiB of KV cache at `-c 8192` with f16 K and V [14][17]. About 2498 MiB of the 4 GB goes unused [3].
On the CPU arm the same file costs extra memory. `RssAnon` is 1,202,152 kB, about 1.15 GiB [15][4], because llama.cpp repacks Q4_0 weights into an interleaved layout for its AVX2 kernels and copies the body out of the mmap into anonymous memory [15].
For the 4.27x to transfer to another model, the loader has to behave the same way [5]. A checkpoint whose embeddings have to be resident would need all 3.35 GB in VRAM [3], which does not fit in 4 GB alongside a KV cache and compute buffers. The measurement is also single-stream: `--parallel 1`, concurrency 1, three repeats in each of eight cells [17][4]. Both arms run on the same laptop, and the exercise does not include a datacentre GPU [1], so what it establishes is that this card is roughly 2.5x larger than this checkpoint needs [16].
The attestation is the part I would copy. Both arms serve the same model on the same port, so an HTTP response cannot identify the device; the binary comes from `/proc/<pid>/exe`, the loaded ggml backends from `/proc/<pid>/maps`, the real `-ngl` from `/proc/<pid>/cmdline`, and that is stamped into every report beside the numbers [18]. `maps` rather than `ldd`, because llama.cpp `dlopen`s its backends and a CUDA backend can be absent from `ldd` output while mapped into the running process [19]. The CPU arm hides the device from the process instead of trusting `-ngl 0`, since a CUDA build at `-ngl 0` still initialises the device and large prefill batches can land on it [20].
Within-cell spread over three repeats was 6.29% at worst on the CPU arm and 0.84% on the GPU arm [6]. The dev.to write-up sets its own floor: "Read the 4x as real and anything under about 7% as nothing" [21]. Order biases against the card. The CPU arm ran first and saturated 12 cores, an i7-1360P shares one thermal envelope with a Max-Q GPU, and the GPU arm started on a warm package after a fixed 120 second cooldown [22].
What to watch
- A concurrency sweep would settle whether the card still wins once several streams share its KV cache.
- Whether llama.cpp keeps lazy embedding reads for other architectures decides how far past gemma4.cpp this result reaches.
- A second machine with a different CPU-to-GPU bandwidth ratio would show how much of the 4.27x belongs to this laptop.