Running Gemma 4 26B-A4B on RTX 3090: Setup, Latency, Benchmarks

Waffles is an AI voice agent living on our softcery.com website. It was doing just fine until Cerebras decided to remove Gemma 4 from their public pay-as-you-go inference. Classic.

No other provider that we tried was able to match the latency, so we decided to host our own. Below is the result and what we learned.

The outcome

On a rented RTX 3090 on vast.ai, $0.22 per hour (~$150/mo + network) + Unsloth/Gemma-4-26B-A4B-IT-QAT-GGUF:

llama-server \
  -m  gemma-4-26B-A4B-it-qat-UD-Q4_K_XL.gguf \
  -md mtp-gemma-4-26B-A4B-it.gguf --spec-type draft-mtp --spec-draft-n-max 1 --spec-draft-p-min 0 \
  -ngl 99 -c 98304 -np 3 --cache-type-k f16 --cache-type-v f16 -fa on \
  --ctx-checkpoints 0 --no-cache-idle-slots \
  --jinja --chat-template-file gemma4.jinja --reasoning-format deepseek \
  --chat-template-kwargs '{"enable_thinking": false}' \
  --temp 1.0 --top-k 64 --top-p 0.95 --min-p 0 --repeat-penalty 1.0
Metric Result Context
VRAM 16.8 GiB of 24 3 slots of 32k, 0.92 GiB each. 10 slots fit
Warm turn, on the box 91 to 109 ms to the first token 10k-token history, 22 messages, tools on, 15 reps
Prompt eval alone 10 to 13 ms 1 new token
Decode, one caller ~180 t/s draft acceptance ~0.75
Decode, two callers 120 to 130 t/s each both talking at once
Decode, three callers ~94 t/s each all 3 slots active. 48 t/s each at 10 slots
New call 1.7 s 7.5k-token system prompt, ~4,200 t/s
Barge-in ~120 ms cut within the last ~390 tokens
Barge-in, deep cut 2.4 s cut 430+ tokens back, whole history loaded again
Second caller joins first caller stalls 2.2 to 2.8 s cold 10k prompt on the other slot, 3 of 3. Open
Third caller 206 ms to the first token all 3 talking at once. Was a ~320 ms wait for a slot at 2 slots
Silence bug 0 in 60 6 trigger prompts, 10 samples each. Was 1 in 60 on this build, 13 in 60 non-QAT

Why the A4B, and why not 16 GB

"A4B" is a Mixture of Experts: 26B parameters on disk, 4B of them used per token. Each of the 30 layers holds 128 expert blocks and routes every token through 8 of them. The experts are 22.8B of the 25.2B parameters. The file loads like a 26B model. Each decode step reads weights like a 4B model.

The Gemma 4 sizes, as Unsloth QAT UD-Q4_K_XL:

Model Active Weights Layers
12B 12B 6.72 GB 48
26B-A4B 4B 14.25 GB 30
31B 31B 17.29 GB 60

Google's model card, thinking on:

Benchmark 12B 26B-A4B 31B
MMLU Pro 77.2 82.6 85.2
GPQA Diamond 78.8 82.3 84.3
BigBench Extra Hard 53.0 64.8 74.4
Tau2 69.0 68.2 76.9
LiveCodeBench v6 72.0 77.1 80.0

Within a point of the 12B on tool calling, above it on knowledge and reasoning, and the 31B is a step above both.

Measured on the 3090, 2 slots of 32k, --swa-full off, no checkpoints. The 12B decode is from a replay of a recorded call, the A4B decode from a live call.

Model VRAM after load Decode, p50 Fits 16 GB Fits 24 GB
12B 9.0 GiB 154 t/s yes yes
26B-A4B 15.9 GiB 176 t/s no yes
31B not run not run no ~22 GiB by the formula, not run

The A4B decodes faster than the 12B and scores above it on the card. The cost is 14.25 GB of weights, which leaves a 16 GB card no room for the KV cache and the runtime. Hence the rented 24 GB.

The 31B is where we came from. Its weights fit a 24 GB card, its KV cache does not: 41 GiB for 2 callers at 24k with every layer keeping the full history. With --swa-full off it comes to ~22 GiB by the formula, and we have not run it.

Fitting the callers into 24 GB

Gemma 4 has two kinds of attention layers. Global layers see the whole conversation. Sliding-window layers see the last 1,024 tokens only. In the 26B-A4B, 25 of 30 layers are windowed. In the 12B, 40 of 48. In the 31B, 50 of 60.

llama.cpp has a flag for what the KV cache keeps for the windowed layers:

Slots of 32k: the longest call we have recorded is 24.5k tokens, the p99 is 14.6k.

The budget on a 24 GB card, f16 KV cache:

--swa-full Slots KV cache Weights + KV + runtime Fits
on 2 × 24k 10.3 GiB 24.4 GiB no, 0.8 over
on 2 × 32k 13.8 GiB 27.8 GiB no
off 2 × 32k 1.8 GiB 15.9 GiB, measured yes
off 3 × 32k 2.8 GiB 16.8 GiB, measured yes, we run this
off 10 × 32k 9.2 GiB 23.2 GiB, measured yes, 0.8 free
off 15 × 16k 9.1 GiB 23.1 GiB, measured yes, 0.9 free

The sum is the 14.25 GB weights, the 250 MB draft head, the cache, and ~570 MiB of runtime as measured on the 12B. The --swa-full on rows are computed from config.json. The same formula came within 0.5% of the measured 12B cache.

With --swa-full off a slot is 940 MiB at 32k, 618 MiB at 16k, and the card takes 10 or 15 of them. An 11th 32k slot leaves under 300 MiB. VRAM under full load, every slot decoding, is 30 MiB over the load figure, so the load figure is the budget. The slot count is not the VRAM question on this model. It is a decode question, covered under the second caller.

With --swa-full on, a 3-bit quant fits: UD-Q3_K_XL at 12.91 GB leaves 450 MiB. We have no quality measurement of a 3-bit A4B, so we keep the 4-bit QAT file and turn --swa-full off.

With --swa-full off, the server cannot rewind the conversation past the window on its own. So by default it snapshots the window cache as the prompt grows, "context checkpoints", and restores the nearest one on a rollback. Each snapshot is a GPU-to-host copy. On the 12B:

Checkpoints Warm turn, on the box Per request
default 556 ms p50 2 or 3 snapshots of 320 MiB, ~200 ms each
--ctx-checkpoints 0 72 ms p50 none

The copy runs at ~1.6 GB/s on this host, a PCIe x8 link. The snapshot cost is set by the host bus, not the GPU.

The flag is --ctx-checkpoints 0. The cost: a rollback deeper than ~400 tokens is a full reload of the conversation.

The KV cache stays in f16. An early run with an 8-bit cache and --swa-full on grew VRAM during use. We did not chase it. With --swa-full off the cache is 2.8 GiB at 3 slots, and f16 costs nothing we need.

The chat template, or why the cache breaks

Every turn, the agent sends the whole conversation. The server renders it to one text through the chat template, tokenizes it, and compares the tokens with the slot, the cache of one caller, 3 per card here. The common prefix is reused, the rest is computed. In the response, timings.prompt_n is the count of tokens it had to compute. On a warm turn that number must equal the tokens of the new message. Anything larger is a cache break that far back.

With the template that ships in the Unsloth GGUF, it broke on 4 of 6 turn shapes we tested:

Turn shape What the stock template does to the history Prefix
Plain text reply nothing exact
Reply after a tool result nothing exact
Tool call with arguments sorts the arguments alphabetically; the model wrote view then id breaks at the call
Text, then a tool call drops the text from the model turn breaks at the text
Second call in the same turn closes the model turn after the first result; the next call starts outside it breaks at the text
Text and call as 2 messages trims the trailing space of the text breaks at the space

The agent sends what the model produced. The template rewrites it. From that point on the next prompt no longer matches the cache, and the server recomputes everything after it, every turn.

The patched template changes 3 things: items() in place of dictsort for tool arguments, no trim of the model's text, and the text of a tool-call message stays before the call. 6 of 6 shapes give an exact prefix.

On the 12B, 2 slots of 24k with --swa-full on, the same replay on both:

Template Warm turn to first token, p50 Prompt time, p50 Empty replies after a tool result
stock 124 ms 66 ms 3 of 24
patched 78 ms 35 ms 0 of 8

On the A4B, non-QAT build, a live call of 46 requests: 43 warm turns of 17 to 120 new tokens, prompt time p50 31.5 ms, p95 81.9 ms.

Two breaks came from our own agent, not the template:

How we found the breaks: a jinja2 render of the stock and the patched template on the 6 shapes, then LLAMA_SERVER_SLOTS_DEBUG=1 and GET /slots to read the text the slot holds. The render finds the 4 in the table. The slot dump found a fifth: the empty thought block the template puts at the end of every prompt and nowhere in the history.

The silence bug

Gemma 4 thinks in a channel: <|channel>thought\n...<channel|> before the reply. With thinking off, Google's template still ends every prompt with an empty block, <|channel>thought\n<channel|>, so the model starts its reply after it. The history turns get no such block.

The reply in the cache sits after the empty block. In the next prompt the same reply sits without it. The prefix ends where the reply starts, and every warm turn recomputes the last reply: prompt_n p50 61.5 tokens where 29 would do, +14 ms p50 on the A4B.

Rendering the block in the history too gives an exact prefix, 6 of 6. We shipped that. Then replies started to come back empty.

The model answers a turn with <|channel>thought\n<channel|> repeated to the token limit. No text. On a replay of a recorded call, 2 of 24 turns did this, 512 tokens in ~2.7 s. On each of the 2 live A4B calls it happened once, 200 tokens at the agent's limit, ~0.9 s. On the phone that is a second of silence and then nothing, and the agent moves on. We heard it as a slow turn before we read the log.

The log signature: a reply at exactly the token limit, the fastest decode of the call, draft acceptance over 0.95, and the next prompt drops the whole reply.

We took the 6 prompts that had triggered it and sampled each 10 times per template:

History thought block Build Full loops Extra empty blocks
yes non-QAT 13 of 60 3
yes QAT 1 of 60 4
no either 0 of 60 not counted

Things that did not fix it, same 6 prompts:

So the template stays as Google wrote it on this point: the block at the end of the prompt, none in the history. The 14 ms per turn is the price. On 3 replays of a recorded call, the template without the history block gave 69 of 72 tool calls as recorded, against 64 of 72 with it, and 0 empty replies in 83 rounds against 2 in 88.

Not measured: the 12B showed the signature once in a call with the history block, and we did not measure its rate. And one of the trigger prompts follows a reply cut by a barge-in, and we have not shown that the cut is what triggers it.

The turn budget

A warm turn on the box, from the request to the first token: 91 to 109 ms in 15 reps. The prompt is a recorded call: 10,335 tokens, of which 7.2k the system prompt and 2,613 the tool declarations, in 22 messages, a 38.8 KB request body.

Step Time Measured how
Server work before the response headers 63 to 88 ms headers time, 1 new token in the prompt
Prompt eval 10 to 13 ms timings.prompt_ms
Headers to the first content token ~10 ms stream timestamps

The prompt eval, the part the server log shows, is a tenth of the turn. The pre-header part is the rest, and it depends on the request, not on the model:

System prompt Messages Tools Prompt total Before headers
7.2k tokens 2 no 7,233 27 ms
7.2k 2 yes 9,846 61 to 64 ms
2.0k 2 yes 4,572 44 to 50 ms
7.2k 42 yes 10,185 126 to 139 ms
7.2k 82 yes 10,545 265 to 284 ms
7.2k 82 no 7,973 227 to 240 ms
4.9k 82, ~58 tokens each yes 12,224 278 to 291 ms

Three costs:

A voice call is a long list of short messages. At 82 messages the server spends ~270 ms before it starts on the prompt, on a turn whose prompt eval takes 11 ms. Trimming the history breaks the cache prefix, one full recompute per trim, 2.4 s at 10k tokens. We have not settled that trade.

A remote client pays about 3 round trips on top: a TCP connect, then the 38.8 KB body over a fresh connection still in slow start. From a laptop 60 ms away that was 150 to 230 ms over the local figure. The connection is fresh every turn because llama-server closes it after each streamed reply, with a Keep-Alive: timeout=5, max=100 header on the way out. The cause is one line in tools/server/server-http.cpp: the chunked provider calls sink.done() and then returns false, which the vendored httplib reads as a cancel and closes the socket. A return true after done() keeps it open.

Decode is not the constraint. 180 t/s alone, ~94 t/s with 3 callers talking, against ~4.5 t/s of speech. The table by caller count is under the second caller.

Barge-in

When the caller interrupts, the agent cuts the reply at the word the caller heard last and sends the next turn with the cut reply in the history. To the server that is a prompt whose prefix ends before the end of the cache. It has to rewind.

With --swa-full off and no checkpoints, each windowed layer holds 1,536 cells: the 1,024-token window plus one batch of 512. A rewind works while the cells it needs are still there. We edited one word at a set distance before the end of a 10,335-token cached prompt and read the cost:

Rewind, tokens recomputed Prompt eval
57 42 ms
182 75 ms
394 121 ms
~430 and deeper: all 10,340 2,397 to 2,450 ms

The cut sits between 390 and ~430 tokens back, in 11 edit points from ~430 to ~4,100. The source puts the reach at one batch, 512 tokens, less what the server generated since. We measure ~400 and have not explained the gap.

A spoken reply is 6 to 107 tokens on our calls, so a barge-in cut stays inside. What does not: any edit further back. A changed system prompt, a trimmed history, a tool result rewritten after the fact. Each of those is 2.4 s at 10k tokens, and on a voice call 2.4 s is a dropped turn.

The rule for the agent: append only, and cut only inside the last reply.

The second caller

llama-server keeps a prompt cache in host RAM, 8 GiB by default, so a caller whose slot was given away can get their context back without a recompute. On every new request, the server saves each idle slot into that cache, unless the cache already holds it. A 10k-token slot on this model saves as 403 MiB: 20 KiB per token for the global layers plus 200 MiB for the 1,024 window cells; the 512 batch cells are not saved. The copy takes 230 to 260 ms, and it blocks the request that triggered it.

With one caller the idle slot never changes and the save runs once. With two callers taking turns, the idle slot has changed every time:

Case Before headers, per turn Reps
one caller 81 to 96 ms 7 of 7
two callers, alternating turns 317 to 366 ms 16 of 16
two callers, --no-cache-idle-slots 87 to 108 ms 16 of 16

The flag costs nothing we could measure. The cache never restored a context in 6 cases where a caller's slot had gone to a cold prompt: cache_n 0 and a 2.4 s recompute every time, with the flag and without.

Three more cases, 2 slots, on the box, with the flag, 3 reps each:

Case Result
two callers speak at once, both warm first token 97 to 132 ms for both
three callers speak at once the third waits for a slot, 318 to 364 ms. The other two 93 to 120
one caller warm, a new caller's cold 10k prompt the warm caller's reply stalls 2.2 to 2.8 s

The stall: the server runs every active slot in one batch. The new caller's 10k tokens go through in chunks of 2,048, ~450 ms each, and each decode step of the warm caller rides with the next chunk. So the warm caller gets a few tokens per chunk until the cold prompt is through. In 2 reps the stall came after the first word, in 1 rep before it. Either way the caller hears the agent stop for 2 to 3 s.

More slots

We loaded 10 slots of 32k and 15 slots of 16k and had every slot talk at once, on the box, 3 reps each, replies of 3 to 43 tokens. An idle slot costs nothing but its VRAM: one caller alone on 10 slots decodes at 218 t/s. The cost comes with the active callers, because each decode step runs the whole batch:

Callers talking at once First token, p50 First token, max Decode per caller, p50 Decode, all callers
1 149 ms 155 ms 218 t/s 201 t/s
2 152 ms 195 ms 128 t/s 297 t/s
3, the config we run 206 ms 278 ms 94 t/s 287 t/s
4 125 ms 250 ms 79 t/s 333 t/s
8 317 ms 355 ms 52 t/s 387 t/s
10 290 ms 455 ms 48 t/s 422 t/s
15, slots of 16k 521 ms 739 ms 29 t/s 437 t/s

At 15 callers each one still gets 6 times the speech rate. What does not scale is the cold join. On 10 slots, 9 warm callers and one new caller with a cold 11k prompt: the new caller waits 3.3 to 3.6 s for the first token, 18 of the 27 warm turns wait 2.5 to 3.4 s, and the warm decode drops to 10 t/s p50 while the prompt goes through. The same batch, more callers in it. The -b fix in the last section is what makes the count usable.

We run 3. The third caller no longer waits for a reply to end, and 7.3 GiB stays free.

Renting and booting

The card is a 1× RTX 3090 on vast.ai, the cheapest verified offer in the EU that passes a filter: reliability over 0.98, 100 GB disk, CUDA 12.8 or newer, 2 open ports, traffic at most $0.05/GB each way. The numbers behind the filter and the boot:

Item Number
Price, Sweden host, 100 GB disk included $0.2163/h, $0.026/GB traffic each way
Traffic price across 23 offers $0 to $0.0326/GB. The 14.5 GB weight pull costs $0 to $0.47, up to 2 hours of rent
Driver 535 host rejects kernels built with CUDA 12.8: device kernel image is invalid
Vast "ssh" launch mode 18 and 21 min of setup scripts before the container starts, on 2 hosts
Rent to the running state 26 s
Weights, 1 curl connection 13 to 73 MB/s from the Hugging Face CDN
Weights, aria2c with 16 connections 465 to 538 MiB/s
Empty disk to /health 200 30 s
Restart or model switch, files on disk 8 to 12 s to /health 200

The image holds llama-server from the upstream CUDA build, aria2c, sshd and our source tree. No weights, no keys. The weights come from Hugging Face at boot, the API key from the environment, the ssh host keys get created at first start. A Hugging Face token does not change the pull speed.

What we did not measure

What to try next

In run order:

Try Targets Expected Cost
-b 512, then -b 256 the 2.2 to 2.8 s stall behind a cold caller the warm caller waits one chunk, ~120 or ~60 ms per token, at a slower cold load one restart, no agent change
Pre-arm the slots with the 7.5k static prefix, pin each call to a slot with id_slot, re-arm after hangup the 1.7 s new-call load a new call starts from the cached prefix, ~100 ms plus its own tokens. The re-arm is still a 7.5k recompute and must run in a gap, or behind the -b fix agent change
--cache-ram 0 moving parts no change. The RAM cache restored 0 of 6 contexts here and does nothing for a pinned slot one restart
MTP draft --spec-draft-n-max 4 decode the 12B gained 1.8x from the head against no head. n 1 against n 4 is unmeasured on the A4B one restart
More slots, behind the -b fix callers per card 10 slots of 32k load. 48 t/s per caller with all 10 talking. Every caller on the card hears each cold join until -b is set one restart
--ctx-checkpoints 2 --checkpoint-min-step 4096 the 2.4 s deep rewind a rewind lands on a snapshot at most 4k tokens back, under 1 s. Each snapshot is ~200 ms on a turn that crosses a step. Low priority: barge-in stays inside the ~400 reach, and the rule forbids deeper edits one restart

Not on the list: a smaller quant, the sampler, an 8-bit KV cache. The card has 7.3 GiB free at 3 slots.