Skip to main content
Scicom AI

Tech Blog

Heterogeneous TP on gemma-4: running prefill on half the GPUs

Why vLLM could not hand gemma-4’s KV cache from a TP4 prefill to a TP8 decode, the patch we wrote for it, and what the prefill GPUs look like now.

By Ariff Nazhan

Senior AI Infrastructure Engineer

In this post, Ariff Nazhan, our Senior AI Infrastructure Engineer, walks through how the team halved the GPUs used to process AI prompts, freeing four GPUs for speech and retrieval models on the same hardware. Reusing that capacity lets us run more of the services behind our customer-facing AI agents without adding GPUs.

We serve gemma-4-31B for our text and voice agents with NVIDIA Dynamo and vLLM, using disaggregated serving. Prefill engines process the prompt, decode engines generate the tokens, and the KV cache, the model's working memory for a request, moves from one to the other over RDMA. From 12 October our two B300 nodes ran one prefill engine and one decode engine, each at TP8 on a whole node. TP, tensor parallelism, is the number of GPUs one engine is split across.

The prefill node was mostly idle. We wanted to run prefill on four GPUs and give the other four to other models, but vLLM would not hand gemma-4's KV cache from a TP4 prefill to a TP8 decode. This post covers why, the patch we wrote for it, and what the prefill GPUs look like now.

Eight prefill GPUs, mostly idle

DCGM reports how busy each GPU is. Over the week the TP8 prefill engine ran, its eight GPUs averaged 7.7%. In the hours with real traffic, at least 5,000 prompt tokens per second, they averaged 22.5%, and the busiest hour reached 44%.

Prefill GPU utilisation, TP8 on eight GPUsHourly mean of the eight prefill GPUs, and the prompt load they served. Only hours with traffic; nights and quiet hours are cut out. Times in MYT.

Week average

7.7%

128 hours, nights included

Busy hours

22.5%

38 hours with at least 5,000 prompt tokens/s

Busiest hour

44%

at 22,400 prompt tokens/s

Show the data
Prefill GPU utilisation, hour by hour. Over the week the eight TP8 prefill GPUs averaged 7.7%. In 38 busy hours, at least 5,000 prompt tokens per second, they averaged 22.5%, and the busiest hour reached 44.0% at 22,391 prompt tokens per second.
Hour ending (MYT)UtilisationPrompt tokens/s
12 Oct, 13:000.8%340
12 Oct, 14:000.8%494
12 Oct, 15:000.4%943
12 Oct, 16:000.8%638
12 Oct, 17:000.5%763
12 Oct, 18:000.6%366
12 Oct, 19:000.4%168
12 Oct, 20:004.5%3,005
12 Oct, 21:0019.9%17,754
12 Oct, 22:0030.9%32,020
12 Oct, 23:000.6%1,396
13 Oct, 00:0019.0%19,437
13 Oct, 01:0014.6%17,600
13 Oct, 02:0010.8%9,512
13 Oct, 03:000.0%6
13 Oct, 04:000.0%0
13 Oct, 05:000.0%0
13 Oct, 06:000.0%0
13 Oct, 07:000.0%30
13 Oct, 08:0040.8%53,466
13 Oct, 09:000.0%2
13 Oct, 10:000.1%6
13 Oct, 11:000.0%50
13 Oct, 12:000.4%131
13 Oct, 13:000.0%54
13 Oct, 14:000.2%62
13 Oct, 15:000.2%80
13 Oct, 16:001.0%370
13 Oct, 17:000.1%127
13 Oct, 18:000.2%72
13 Oct, 19:000.0%5
13 Oct, 20:000.0%0
13 Oct, 21:000.0%0
13 Oct, 22:000.0%0
13 Oct, 23:000.0%0
14 Oct, 00:000.0%0
14 Oct, 01:000.0%0
14 Oct, 02:000.0%2
14 Oct, 03:000.0%0
14 Oct, 04:000.0%0
14 Oct, 05:000.0%0
14 Oct, 06:000.0%0
14 Oct, 07:000.0%0
14 Oct, 08:000.6%0
14 Oct, 09:000.0%0
14 Oct, 10:000.0%26
14 Oct, 11:000.0%80
14 Oct, 12:000.3%72
14 Oct, 13:000.0%0
14 Oct, 14:000.0%1
14 Oct, 15:000.5%0
14 Oct, 16:000.5%139
14 Oct, 17:000.1%50
14 Oct, 18:000.0%34
14 Oct, 19:000.0%3
14 Oct, 20:0020.5%23,614
14 Oct, 21:0019.7%20,866
14 Oct, 22:0039.8%47,157
14 Oct, 23:000.3%135
15 Oct, 00:0020.1%21,927
15 Oct, 01:0030.4%37,186
15 Oct, 02:005.2%3,671
15 Oct, 03:000.0%0
15 Oct, 04:000.0%0
15 Oct, 05:000.0%0
15 Oct, 06:002.5%3,660
15 Oct, 07:0035.5%41,971
15 Oct, 08:0028.0%31,354
15 Oct, 09:0043.5%50,670
15 Oct, 10:007.8%8,726
15 Oct, 11:0019.6%21,910
15 Oct, 12:0020.3%24,701
15 Oct, 13:000.1%66
15 Oct, 14:0030.5%34,424
15 Oct, 15:0036.7%38,099
15 Oct, 16:0019.5%18,261
15 Oct, 17:000.6%487
15 Oct, 18:000.0%12
15 Oct, 19:000.1%108
15 Oct, 20:000.0%0
15 Oct, 21:000.0%62
15 Oct, 22:000.1%26
15 Oct, 23:000.0%23
16 Oct, 00:000.1%41
16 Oct, 01:000.0%0
16 Oct, 02:000.3%144
16 Oct, 03:000.5%464
16 Oct, 04:000.1%416
16 Oct, 05:000.1%51
16 Oct, 06:000.3%299
16 Oct, 07:000.1%159
16 Oct, 08:000.8%696
16 Oct, 09:004.7%5,563
16 Oct, 10:0015.5%16,643
16 Oct, 11:0020.2%18,336
16 Oct, 12:0021.2%18,815
16 Oct, 13:0014.8%19,527
16 Oct, 14:0014.9%12,551
16 Oct, 15:0013.4%11,884
16 Oct, 16:0015.3%16,532
16 Oct, 17:0012.9%11,755
16 Oct, 18:007.6%8,910
16 Oct, 19:002.5%2,492
16 Oct, 20:007.1%2,475
16 Oct, 21:008.1%2,310
16 Oct, 22:005.4%1,768
16 Oct, 23:002.8%769
17 Oct, 00:001.5%450
17 Oct, 01:001.2%230
17 Oct, 02:000.7%276
17 Oct, 03:000.8%158
17 Oct, 04:002.5%512
17 Oct, 05:006.5%1,017
17 Oct, 06:0016.1%2,018
17 Oct, 07:002.8%822
17 Oct, 08:003.1%906
17 Oct, 09:0011.3%2,890
17 Oct, 10:0024.0%6,785
17 Oct, 11:0019.6%6,074
17 Oct, 12:0023.7%8,800
17 Oct, 13:0032.6%16,276
17 Oct, 14:0021.3%8,459
17 Oct, 15:0044.0%22,391
17 Oct, 16:0016.6%6,046
17 Oct, 17:0024.1%6,626
17 Oct, 18:0016.5%4,349
17 Oct, 19:007.7%2,663
17 Oct, 20:003.8%1,477
Even the busiest hour left more than half of the prefill node idle. Only hours with at least 2,000 prompt tokens per second are drawn; the week average in the first tile includes the quiet hours.DCGM GPU metrics on our B300 prefill GPUs, hourly means, 12 Oct 2026 to 19 Oct 2026.

The decode node was the busy one. In the same busy hours its eight GPUs averaged 66%. Decode keeps a request on the GPUs for every output token, while prefill handles each prompt in one pass. So we wanted to halve the prefill engine to TP4 and keep decode at TP8, which keeps each request's token rate and the decode engine's KV cache where they were.

The prefill engine computes the prompt's KV cache, sharded across its four GPUs. The decode engine pulls it over RDMA, re-sharded across eight GPUs, and generates the reply.Diagram, not measured data.

Mismatched TP answered 200 with the wrong text

vLLM's KV connector supports different TP sizes on the two sides in general, so we tried it. On gemma-4 it did not work. On vLLM 0.28 every pod came up Ready and every request returned HTTP 200. Our recall check caught it: nine prompts that each hide a code in a long numbered list and ask for it back.

The decode engine rejected every KV transfer and still produced text, which was noise. Only the recall check showed it.Measured on B300, 7 Oct 2026.

A load test that only counts status codes would have scored these layouts as healthy. Every attempt so far, on both of our fleets:

DatePrefill → decodevLLMResult
5 OctTP1 → TP2, TP1 → TP40.28HTTP 200 with wrong text; every KV handshake rejected
7 OctTP8 → TP40.28HTTP 200 with noise; recall 0 of 9; 40 of 40 transfers failed
17 OctTP4 → TP80.31, stockHTTP 500; SPLIT region 5 on every handshake
17 OctTP4 → TP80.31, patchedRecall 9 of 9; no failed transfers

vLLM 0.31 fails loudly where 0.28 failed silently. That is better, but the layout still does not run.

Why gemma-4 breaks the KV handoff

gemma-4-31B mixes two kinds of attention layer. 50 are sliding-window layers with 16 KV heads of size 256. 10 are global layers with 4 KV heads of size 512. Tensor parallelism shards KV heads across GPUs, so each GPU holds a slice of every layer's cache.

vLLM's NIXL connector, which moves the cache from prefill to decode, sized every layer's slice from one model-wide head count of 16. For the sliding layers that is right. For the global layers it breaks at TP8.

A sliding-window layer has 16 KV heads. At TP4 each GPU holds 4, and at TP8 each holds 2. The slice halves, which is what the connector expects.

A global layer has only 4 KV heads. At TP4 that is one whole head per GPU.

Four heads cannot be split eight ways, so at TP8 each head is copied onto two GPUs and every GPU keeps one whole head.

The slice per GPU is the same size at TP4 and at TP8. The connector, assuming 16 heads everywhere, expected it to halve.

Sliding layers halve their slice from TP4 to TP8. Global layers cannot, because at TP8 each head sits on two GPUs and the slice stays the same size.Diagram, not measured data.

Before reading, the decode engine checks that each remote slice is the size it expects. With 16 heads assumed everywhere, it expects the prefill's TP4 slice to be twice its own. For the first global layer, layer 5, the two are equal, and the handshake fails:

AssertionError: SPLIT region 5: remote P KV block_len 131072 must equal
local 131072 * remote_heads 4 // local_heads 2 // block_size_ratio 1

There is a second problem underneath. vLLM's hybrid KV cache manager saves memory on models like gemma-4 by letting a sliding layer and a global layer share cache pages. A shared page cannot be re-sharded layer by layer, so any fix also needs the hybrid manager turned off.

What the patch does

Each side now advertises how many KV heads per GPU every region of its cache holds, and the handshake check and the read plan use that per-region count. A sliding region with 4 heads per prefill GPU and 2 per decode GPU is read in halves, as before. A global region with one head on both sides is read whole, by both decode GPUs that share the head.

Each pair of TP8 decode GPUs reads from one TP4 prefill GPU. Sliding regions are split between the pair. Global regions are read whole by both.Diagram, not measured data.

Two limits apply. Decode TP must be at least prefill TP when layers have different head counts; the reverse raises an error instead of producing wrong output. Both engines also need --disable-hybrid-kv-cache-manager; with the hybrid manager on, the patch stops with a clear error.

Testing it away from production

We tested on our H20 fleet before touching the B300 cluster. The test pair ran on two nodes with its own etcd, NATS and Dynamo frontend on separate ports, so the production frontend and its workers never saw it. Each run used the same flags with stock and with patched vLLM 0.31.0.

Prefill TP4 → decode TP8, two H20 nodes over RoCEStock vLLM 0.31.0Patched
ResponseHTTP 500HTTP 200
Recall checkno answer9 of 9
SPLIT region assertions16, all on region 50
KV transferhandshake failedabout 5.7 GB/s, none failed
Connector unit testsnot applicable64 of 64

A TP2 prefill feeding a TP4 decode passed too, on stock vLLM as well. At TP4 the four global heads still split evenly, one per GPU, so the old arithmetic holds. The problem only appears once a layer has fewer heads than the decode engine has GPUs.

Shipping it through our Dynamo fork

We carry the fix in our Dynamo fork as a patch file against vLLM 0.31.0, together with its tests, and build the serving image from that line. The change is in PR #1 of our Dynamo fork.

Running on B300

Since 17 October the cluster runs one TP4 prefill engine on four GPUs of the first node and one TP8 decode engine on all eight GPUs of the second, with the KV cache crossing between the nodes over RoCE. The four GPUs the prefill engine gave back now run our speech and retrieval models.

One prefill engine on half of the first node, one decode engine on all of the second.Deployed layout, 19 Oct 2026.

What we checked after the rollout:

  • Each of the eight decode GPUs logs TransferTopology(tp_ratio=2, local_tp=8, remote_tp=4).
  • Neither engine logs a SPLIT region or handshake error.
  • The recall check passes 9 of 9 on prompts of about 12,500 tokens.
  • Our gemma-4 checks pass 4 of 4: tool-call turns with thinking on and off, JSON output, and the build header.

Prefill utilisation after the switch

Four GPUs now do the prefill work that eight did. In the busy hours since the switch, again at least 5,000 prompt tokens per second, the four prefill GPUs averaged 30.4%, and the busiest hour reached 49%.

Utilisation against loadEach dot is one hour. Dashed lines are the fitted rate for each layout.

  • TP8 prefill, 128 hours
  • TP4 prefill, 25 hours
Show the data
Prefill GPU utilisation, hour by hour. At the same prompt rate a TP4 prefill GPU is about 2.9 times as busy as a TP8 one: 2.75 against 0.95 utilisation points per 1,000 prompt tokens per second.
Hour ending (MYT)Prefill GPUsUtilisationPrompt tokens/s
12 Oct, 13:0080.8%340
12 Oct, 14:0080.8%494
12 Oct, 15:0080.4%943
12 Oct, 16:0080.8%638
12 Oct, 17:0080.5%763
12 Oct, 18:0080.6%366
12 Oct, 19:0080.4%168
12 Oct, 20:0084.5%3,005
12 Oct, 21:00819.9%17,754
12 Oct, 22:00830.9%32,020
12 Oct, 23:0080.6%1,396
13 Oct, 00:00819.0%19,437
13 Oct, 01:00814.6%17,600
13 Oct, 02:00810.8%9,512
13 Oct, 03:0080.0%6
13 Oct, 04:0080.0%0
13 Oct, 05:0080.0%0
13 Oct, 06:0080.0%0
13 Oct, 07:0080.0%30
13 Oct, 08:00840.8%53,466
13 Oct, 09:0080.0%2
13 Oct, 10:0080.1%6
13 Oct, 11:0080.0%50
13 Oct, 12:0080.4%131
13 Oct, 13:0080.0%54
13 Oct, 14:0080.2%62
13 Oct, 15:0080.2%80
13 Oct, 16:0081.0%370
13 Oct, 17:0080.1%127
13 Oct, 18:0080.2%72
13 Oct, 19:0080.0%5
13 Oct, 20:0080.0%0
13 Oct, 21:0080.0%0
13 Oct, 22:0080.0%0
13 Oct, 23:0080.0%0
14 Oct, 00:0080.0%0
14 Oct, 01:0080.0%0
14 Oct, 02:0080.0%2
14 Oct, 03:0080.0%0
14 Oct, 04:0080.0%0
14 Oct, 05:0080.0%0
14 Oct, 06:0080.0%0
14 Oct, 07:0080.0%0
14 Oct, 08:0080.6%0
14 Oct, 09:0080.0%0
14 Oct, 10:0080.0%26
14 Oct, 11:0080.0%80
14 Oct, 12:0080.3%72
14 Oct, 13:0080.0%0
14 Oct, 14:0080.0%1
14 Oct, 15:0080.5%0
14 Oct, 16:0080.5%139
14 Oct, 17:0080.1%50
14 Oct, 18:0080.0%34
14 Oct, 19:0080.0%3
14 Oct, 20:00820.5%23,614
14 Oct, 21:00819.7%20,866
14 Oct, 22:00839.8%47,157
14 Oct, 23:0080.3%135
15 Oct, 00:00820.1%21,927
15 Oct, 01:00830.4%37,186
15 Oct, 02:0085.2%3,671
15 Oct, 03:0080.0%0
15 Oct, 04:0080.0%0
15 Oct, 05:0080.0%0
15 Oct, 06:0082.5%3,660
15 Oct, 07:00835.5%41,971
15 Oct, 08:00828.0%31,354
15 Oct, 09:00843.5%50,670
15 Oct, 10:0087.8%8,726
15 Oct, 11:00819.6%21,910
15 Oct, 12:00820.3%24,701
15 Oct, 13:0080.1%66
15 Oct, 14:00830.5%34,424
15 Oct, 15:00836.7%38,099
15 Oct, 16:00819.5%18,261
15 Oct, 17:0080.6%487
15 Oct, 18:0080.0%12
15 Oct, 19:0080.1%108
15 Oct, 20:0080.0%0
15 Oct, 21:0080.0%62
15 Oct, 22:0080.1%26
15 Oct, 23:0080.0%23
16 Oct, 00:0080.1%41
16 Oct, 01:0080.0%0
16 Oct, 02:0080.3%144
16 Oct, 03:0080.5%464
16 Oct, 04:0080.1%416
16 Oct, 05:0080.1%51
16 Oct, 06:0080.3%299
16 Oct, 07:0080.1%159
16 Oct, 08:0080.8%696
16 Oct, 09:0084.7%5,563
16 Oct, 10:00815.5%16,643
16 Oct, 11:00820.2%18,336
16 Oct, 12:00821.2%18,815
16 Oct, 13:00814.8%19,527
16 Oct, 14:00814.9%12,551
16 Oct, 15:00813.4%11,884
16 Oct, 16:00815.3%16,532
16 Oct, 17:00812.9%11,755
16 Oct, 18:0087.6%8,910
16 Oct, 19:0082.5%2,492
16 Oct, 20:0087.1%2,475
16 Oct, 21:0088.1%2,310
16 Oct, 22:0085.4%1,768
16 Oct, 23:0082.8%769
17 Oct, 00:0081.5%450
17 Oct, 01:0081.2%230
17 Oct, 02:0080.7%276
17 Oct, 03:0080.8%158
17 Oct, 04:0082.5%512
17 Oct, 05:0086.5%1,017
17 Oct, 06:00816.1%2,018
17 Oct, 07:0082.8%822
17 Oct, 08:0083.1%906
17 Oct, 09:00811.3%2,890
17 Oct, 10:00824.0%6,785
17 Oct, 11:00819.6%6,074
17 Oct, 12:00823.7%8,800
17 Oct, 13:00832.6%16,276
17 Oct, 14:00821.3%8,459
17 Oct, 15:00844.0%22,391
17 Oct, 16:00816.6%6,046
17 Oct, 17:00824.1%6,626
17 Oct, 18:00816.5%4,349
17 Oct, 19:0087.7%2,663
17 Oct, 20:0083.8%1,477
18 Oct, 15:00411.6%6,412
18 Oct, 16:00415.3%5,676
18 Oct, 17:00416.0%5,254
18 Oct, 18:00413.9%4,362
18 Oct, 19:0046.7%2,342
18 Oct, 20:0047.1%2,437
18 Oct, 21:00411.4%3,636
18 Oct, 22:0049.3%2,538
18 Oct, 23:00416.9%1,809
19 Oct, 00:00410.6%1,292
19 Oct, 01:0042.6%788
19 Oct, 02:0042.9%1,128
19 Oct, 03:0043.8%1,174
19 Oct, 04:0043.4%1,477
19 Oct, 05:0043.7%1,287
19 Oct, 06:00414.5%2,223
19 Oct, 07:0043.7%1,240
19 Oct, 08:0043.8%1,436
19 Oct, 09:00415.3%4,390
19 Oct, 10:00436.1%13,943
19 Oct, 11:00444.0%17,264
19 Oct, 12:00448.9%17,096
19 Oct, 13:00433.0%11,996
19 Oct, 14:00436.9%12,084
19 Oct, 15:00431.7%9,530
At the same prompt rate a TP4 prefill GPU is about 2.9 times as busy as a TP8 one was: 2.75 against 0.95 utilisation points per 1,000 prompt tokens per second. Hover over a dot for its hour; every hour is in the data table.DCGM GPU metrics on our B300 prefill GPUs, hourly means, 12 Oct 2026 to 19 Oct 2026.

Halving the GPUs accounts for a factor of 2 of that 2.9. We have not explained the rest yet. The two windows carry different traffic, the after window is only a day long, and the hybrid KV manager is now off. Even at its busiest hour the TP4 engine stays under half of its GPUs' time, so four GPUs are enough for the traffic we see today.

Summary

Our TP8 prefill engine used eight GPUs at 22.5% in busy hours. We wanted it on four, but gemma-4's two kinds of attention layer meant stock vLLM could not hand its KV cache from a TP4 prefill to a TP8 decode, and on the version we started with it failed silently. We ported a fix for the KV connector, tested it on a separate stack, and shipped it in our Dynamo fork. Prefill now runs on four GPUs, and the other four run our speech and retrieval models.

If you are doing the same thing, here is what we'd recommend:

  • Look at GPU utilisation per role. Our prefill and decode nodes were sized the same and used very differently.
  • Check the answer, not the status code. A recall check in front of every test caught a layout that returned 200 with noise.
  • Read the per-layer attention shapes of your model before relying on a disaggregation feature. Mismatched TP is supported for models whose layers all have the same number of KV heads, and gemma-4's do not.
  • Carry fixes as patch files with their tests, pinned to the version they were ported to, so they are easy to drop later.

Sources

More from tech blog

← Back to the Newsroom