← All notes and experiments

Long-context attention: balancing latency and throughput

Chapter 6 of 9 · 13 min read · Published 7 October 2026 · Updated 16 October 2026

Making a ternary reasoning model fast in vLLM

Earlier development stage. Implementation speeds here describe this stage of the work. See the latest published comparison for the current checkpoint.

So far, I had focused on reading the model’s weights: about 6.8 GB per new token, whatever the conversation length. Attention reads something else too: information saved from earlier tokens in the KV cache. That grows with the conversation.

Bonsai can reason for thousands of tokens and supports 262K of context. Part 2 estimated 64 KiB of KV data per token of history: 2.15 GB to read at every generation step with a 32K context.

When does that extra reading become worth optimising?

Measurement note. The opening profiles use an RTX 3090 Ti. Attention-only replay and serving experiments identify an RTX 3090 where used; before/after pairs stay on the same card. This chapter studies speed as context grows. Chapter 8 returns to the deployment memory budget.

Attention grows; projections don't

I profiled the production configuration on RTX 3090 Ti at three context lengths. Each run was a single request, 512 generated tokens, with a separate Nsight trace:

Request performance and GPU profile

Context Generation tok/s Attention + KV update per step Share of decode kernel time Integer projections per step
4,096 71.7 0.902 ms 6.4% 10.06 ms
16,384 65.6 1.986 ms 13.2% 10.01 ms
32,768 59.3 3.517 ms 21.1% 10.10 ms

Kernel time versus context: integer projections remain near 10 milliseconds per step, while attention plus KV update rises from 0.90 to 3.52 milliseconds.

RTX 3090 Ti measurements from the table above; these families are only part of the complete decode step. SVG version.

Projection time is flat, as it should be. Attention almost quadruples from 4K to 32K and takes a fifth of the step.

As a consistency check, 2.15 GB of KV in 3.5 ms is about 610 GB/s, close to what the projection kernels achieve. At 32K, attention is simply another large stream of bytes, not a compute problem.

Only 16 of the 64 layers have this cost. The other 48 use Gated DeltaNet with a fixed-size state. That design choice is why this table stops at 21% instead of something much worse. A standard 64-layer transformer with the same KV geometry would read four times as much.

Give more GPU workers a piece of the attention calculation

Process diagram: One new token + saved information from earlier tokens; Read section 1; Read section 2; Read the other sections; Combine the results with the correct weights; One attention output

Different workers read different sections of the saved history at the same time. Combining their results must preserve the attention calculation.

The full-attention layers have 24 query heads and 4 KV heads (grouped-query attention, 6:1). At batch one, a decode step is one query token per head. Without splitting the sequence, the kernel has very little parallelism: on the order of 4–24 thread blocks for an 84-SM GPU, each streaming thousands of KV tokens.

An attention head is one set of calculations deciding which earlier information matters. Several query heads can share the same saved keys and values. An SM is one of the GPU's processing units. Here, assigning only a few groups of workers to a large GPU leaves many processing units with nothing to do.

The standard remedy is split-KV (flash-decoding). Cut the context into segments, compute partial softmax-weighted sums in parallel, then combine them.

For example, splitting one head's 32K keys into 32 segments creates 32 smaller pieces of work instead of one long scan. Each piece produces a weighted value sum and information needed to normalise it. The merge uses those normalisation terms to recover the complete attention result; averaging the partial outputs would be incorrect.

Splitting adds work for the merge, so more segments help only until that overhead or the smaller tiles outweigh the added parallelism.

FlashAttention 2's automatic heuristic picks a split count. I wanted to know whether it picked well for this shape.

Split count

Using real queries and real paged KV from all 16 attention layers, I replayed FA2 with forced split counts:

Component test

Context 16 splits 40 splits
4,096 +14.2% −6.0%
16,384 +7.2% +6.1%
32,768 +4.0% +12.9%

Attention-only replay, RTX 3090; the auto-vs-auto noise control was within ±0.5%.

The best split count depends on context length, and the automatic choice leaves performance on the table. But in full serving, the gains shrank to −0.5% to +1.6%. Attention is only 6–21% of the step, and the clock effects from part 5 blur anything that small. I left the FA2 heuristic alone.

Compare the available attention programs

FlashAttention 2 (FA2) and FlashInfer are libraries of GPU programs for attention. vLLM also has an attention implementation written in Triton, a GPU programming language. An attention backend is the implementation vLLM chooses for this job. A faster backend for generating tokens may still be slower at reading the prompt.

Next I compared whole kernels: FA2, vLLM's Triton decode attention and FlashInfer 0.7.0. All used the same real KV with 784-token pages, alternating paired timings, and checks against FP32 references:

Component test

Kernel 4K 16K 32K
FA2 auto (control) 1.00× 1.02× 1.00×
FA2, 16 splits 1.15× 1.09× 1.03×
Triton, unsplit 0.18× 0.11× 0.10×
Triton, 16 segments (vLLM default) 1.32× 1.10× 1.04×
Triton, 32 segments 1.52× 1.29× 1.27×
Triton, 64 segments 1.44× 1.34× 1.42×
FlashInfer, CUDA cores 0.23× 0.58× 0.77×
FlashInfer, tensor cores 1.34× 1.40× 1.31×

Attention-only replay on a 3090 Ti, relative to FA2 auto.

Triton with 32–64 segments and FlashInfer's tensor-core decode both beat FA2 by 30–50%. FlashInfer can't serve this model directly yet. The vLLM integration accepts saved-history blocks of 16, 32 or 64 tokens on Ampere, while this model uses blocks of 784 tokens. These blocks are called cache pages. Connecting the two layouts would need more work. Triton was the practical candidate.

The catch: reading the prompt became slower

Switching vLLM's whole attention backend to Triton (32 segments) improved generation at every context length on RTX 3090: +2.5%, +3.9% and +5.6% at 32K. But it made prompt processing much worse:

Request performance

Context FA2 TTFT Triton TTFT Change
4,096 3.29 s 3.37 s +2.7%
16,384 13.87 s 15.78 s +13.8%
32,768 29.62 s 38.15 s +28.8%

A user who pastes a 32K-token document would wait 8.5 seconds longer to save about 3 ms per output token. That's not a good trade unless the answer is very long.

The break-even calculation is 8.5 s ÷ 0.003 s/token ≈ 2,833 output tokens. Under those approximate timings, the answer has to be that long before the faster decode repays the extra prompt-processing time. This is why measuring TTFT and generation rate separately matters.

Use a different program for each phase

The attention dispatch uses each kernel where it is good. FA2 handles prefill and batched decode. Triton (32 segments) handles single-request decode only. Both read the same interleaved KV cache, so there is no repacking.

Request performance

Context FA2 tok/s Hybrid tok/s Gain FA2 TTFT Hybrid TTFT
4,096 76.23 77.77 +2.0% 3.25 s 3.27 s
16,384 70.15 72.45 +3.3% 13.73 s 13.86 s
32,768 63.06 66.45 +5.4% 29.55 s 29.63 s

RTX 3090, median of three 512-token generations per context.

Time to first token stays about the same, and the generation gain grows with context, as the profile predicted. In this experiment, 32 segments was a useful choice. The best count still needs checking for the deployment GPU and workload.

Does it change the answers?

The comparison uses the same questions in both versions. Lost / gained counts questions only one version answered correctly. The p-value checks whether the pattern of wins and losses gives evidence of a difference; a large value does not establish that the two versions are equivalent.

Different attention kernels sum in a different order, so greedy outputs diverge. Generated text differed from FA2 in every throughput run. I ran a paired task check, with each question answered in both modes on the same GPU:

Quality check

Benchmark Questions FA2 Hybrid Lost / gained McNemar p
GSM8K (4-shot, fixed subset) 128 121 120 1 / 0 1.0
GPQA Diamond (zero-shot) 198 70 71 9 / 10 1.0

No difference detectable at this size, but I want to be clear about the limits:

  • A non-significant result is not proof of equivalence.
  • The GPQA run used a 2048-token budget, and 117–119 of 198 answers were truncated before they finished reasoning. That's why the absolute score (about 35%) is nowhere near the 85.8 PrismML reports at an 81,920-token budget.
  • These prompts are short. This is not a 32K-context quality validation.

So hybrid attention is a verified opt-in. FA2 remains the default.

Checkpoint: improve decode without delaying prefill

The dated milestones below show how the implementation developed. Workloads and settings changed during the week, so the rows are historical checkpoints rather than one cumulative, controlled benchmark:

Historical single-request milestones

Request performance

Step Workload tok/s
Starting point (BF16 dequant + HMMA) 128 in / 128 out 25.3
+ integer DP4A decode 128 / 128 38.9
+ cheaper unpacking 128 / 128 41.6
+ prepared code/scale planes 128 / 128 46.7
Same stack, long generation 4096 / 2048 ≈ 50
+ shape tiles, PRMT, integer output ("three winners") 4096 / 2048 62.2
+ geometry v2 4096 / 2048 63.2
+ single-token kernel 4096 / 2048 72.5

For eight concurrent requests, the RTX 3090 Ti configuration at this stage generated about 182 tok/s in total. This is total output across users, rather than the rate each user sees.

The 25.3 and 72.5 tok/s endpoints use different workloads, so their ratio is not a matched speedup measurement. The latter is about 49% of the weight-bandwidth ceiling; integer GEMV kernels themselves stream at about 67% of nominal bandwidth in this snapshot.

The result is a useful division of labour: keep the faster prompt-processing path, and choose a different kernel for the single-token attention scan. The full request improves because the change respects both phases.

There is another way to reduce one user's wait: generate several candidate tokens, then check them together. Part 7 starts with the small-batch kernel problem that made this worthwhile. The practical guide and current results are available separately from these historical settings.

Historical settings: 7 October

The large wins sit behind opt-in master switches, because the INT8 activation path still lacks an agreed task-level quality threshold:

# Master switches (opt-in)
export GGUF_PQ2_INT_GEMV=1        # DP4A integer decode
export GGUF_PQ2_PREPARED=1        # code/scale planes, prepared once at load
export GGUF_PQ2_MMQ=1             # INT8 tensor-core prefill (M >= 128)

# Already default within the enabled paths:
#   GGUF_PQ2_SINGLE_GEMV=1, GGUF_PQ2_INT_OUTPUT=1, GGUF_PQ2_BATCH8_CHAINED=1,
#   GGUF_PQ2_SMALL_FLOAT_OUTPUT=1  (v2 tiles and PRMT decoding are now fixed; see part 7)

# Optional, long single-request contexts (keep FLASH_ATTN as the vLLM backend)
export GGUF_PQ2_SINGLE_ATTN_BACKEND=triton
export GGUF_PQ2_SINGLE_ATTN_SEGMENTS=32

All of this is SM 8.6-specific (RTX 30-series) and TP = 1.

Retrospective: lessons from the first phase

1. Test assumptions with a profile. Python overhead did not explain the single-request gap in these measurements. The GPU was busy 99% of the time, and llama.cpp actually had more idle gaps. The fix was in the kernels.

2. The format dictates the arithmetic. Treating ternary weights as BF16 did not make efficient use of the available integer instructions. Moving to integer DP4A was the largest single gain of the project, and it came from taking the format seriously rather than from tuning.

3. Specialisation beat search. The single-token kernel (+18% serving) came from removing generality: registers went from 128 to 93 and shared memory from 8 KB to 1 KB. Geometry sweeps of 180 and 260 configurations produced a few percent between them.

4. Defaults must be scoped by batch size. Chained DP4A lost 13% at M = 1 and won 4% at M = 8. A single global setting would have been wrong for one of them.

5. Build a measurement ladder, and know what each rung is good for.

Rung Speed Good for
Synthetic microbenchmark seconds Identifying candidates with clear regressions
Projection replay (real activations) ~20 s per screen Ranking candidates; bit-exactness
Complete-step replay minutes Sizing gains (within ~0.2 pp)
Serving, matched GPU, alternating order tens of minutes The only number that counts

Each level had different limitations. Microbenchmarks missed real activation patterns. Projection replay over- and underestimated gains. Serving measurements included clock behaviour.

6. On a power-capped card, efficiency is performance. The same kernel was +2.2% at fixed clocks and +0.1% at automatic clocks. Record clocks and power with every serving result, and run fixed-clock A/Bs as a deliberate, separate experiment.

7. Noise controls are cheap and essential. An identical-kernel "candidate" showed about 1% gains in some pairs. Without that control I would have shipped several non-improvements.

8. Correctness has layers too. Bit-exactness against captured outputs, teacher-forced KL and total-variation over full vocabularies, CUDA-graph input-mutation tests, compile-cache identity (the prepared-layout bug produced invalid output), and finally paired task evaluations. Most of the "fusions" I rejected failed on numerics, not speed.

Open questions at this stage of the work

These were the next steps at this point in the work. Parts 7 and 8 follow the speculative-decoding and KV-memory investigations.

  • A same-day rematch with llama.cpp on identical single-request workloads, to replace the cross-workload comparison in part 4. Chapter 9 records the later comparison.
  • Power-aware tuning. Pick kernels by tokens per joule at the cap, not by microseconds at a fixed clock. Re-test the "flat" latency-hiding kernels at 300 W, now that the cap has headroom, and repeat the power sweep at batch 8 and at long context.
  • Getting the decoder projections from about 70% to about 90% of bandwidth, the way the LM head already is. The counters say the problem is loads in flight, within a register budget the governor doesn't punish.
  • KV compression. Chapter 8 follows the later INT8 work. At this stage, PrismML's roadmap mentioned early sub-4-bit KV results. At 32K that would cut the 2.15 GB per step to well under 1 GB, attacking the one cost that grows.
  • PTQ1_0. It is 18% fewer weight bytes. My kernels are latency-bound rather than bandwidth-bound, and PrismML's A100 numbers favour PQ2_0 on Ampere, so its benefit on these cards remains uncertain. A matched experiment would be needed to assess it.
  • Quality acceptance for the INT8 activation path, with a real reasoning budget, so the master switches can become defaults.

The compact weights make a 27B reasoning model practical to investigate on these cards: the language-model pack is about 5.93 GB in PTQ1_0 or 7.25 GB in the PQ2_0 format used here. Serving also needs memory for runtime allocations, recurrent state and context. Thanks to PrismML for publishing the model, the format and a whitepaper detailed enough to reason from.

The split count remains a hardware- and workload-dependent choice; the deployment card needs its own measurements.

For the component evidence, see synthetic split tests, real-KV split confirmation and the backend screen.


Sources