Repository navigation
Eval bug: with -np 2 and --kv-unified, prompt processing drops 42-54% from the second long request onward (single GPU, sequential requests, no spill, no speculation) #28495
Description
Update — known cause, previously recorded as not affecting prompt processing.
This is the unified-KV limitation in the CUDA/HIP flash-attention kernels: they skip
only the tails of the KQ mask (KV_max), not all--INFblocks in the interior, so
a sequence pays for cells belonging to other (or released) sequences in the shared pool.
Metal has interior block skipping; Vulkan got it in #17186. Extending it to CUDA was
raised in #14924 (comment) and
as far as I can tell has not landed.#19523 is the same root cause for token generation on Vulkan, and its reporter
states "And pp is not affected." On HIP, prompt processing is affected, by 42-54%.
Confirmed by A/B on--kv-unified(-41.8% unified vs -3.1% split, matched prompt) and
reproduced with upstream's ownllama-batched-bench -kvu -tgs: S_PP 885.55 -> 644.39
across B=1,2,4 with-kvuversus 857.79 -> 833.20 with-no-kvuon stock master.
Details: #28495 (comment)Workaround:
--no-kv-unified(also found independently by @alankila on Strix Halo).
Note-cis then divided across slots.
Name and Version
Reproduced on stock master, using the official release binary
(llama-b10825-bin-win-rocm-10.0-x64.zip), nothing local in it:
$ llama-server --version
version: 0.4.0-dev (build 10825, commit 9e0e22059)
built with Clang 20.1.8 for Windows x86_64
It was originally found on a local branch off b10665 (RDNA prefill tuning, a
chat-template fix, and the FA hunks from PR #28102). Stock master turns out to be
worse — -54% there versus -42% on the patched build — so those patches partially
mask the effect rather than causing it. Both sets of numbers are below.
Operating systems
Windows (11 Pro, 10.0.26200)
GGML backends
HIP (ROCm runtime 10.0.0 from TheRock, gfx1201)
Hardware
AMD Radeon AI PRO R9700 — a single discrete card, no second GPU, no iGPU in use
for compute:
- GPU: Radeon AI PRO R9700 (RDNA4, gfx1201, PCI device ID
0x7551), 32 GiB VRAM - Windows display driver 32.0.22042.14002 (2026-04-30)
- ROCm runtime 10.0.0 (TheRock Windows build for gfx120X)
- Host: 32 GiB system RAM
Note for anyone repeating this on the same card: the official win-rocm release zip
ships no rocBLAS/hipBLASLt, so --list-devices reports (none) straight out of the
archive. The runtime libraries have to be supplied alongside it before the GPU is
visible at all.
Models
Qwen3.8-27B, UD-Q5_K_XL (locally requantised), -fa on, -ctk f16 -ctv f16.
Problem description & steps to reproduce
With -np 2, the first long prompt after server start is processed at full speed.
Every subsequent long prompt is ~42% slower, and it never recovers for the lifetime
of the server. Requests are strictly sequential — one at a time, never concurrent.
This is not the known concurrency/speculation family of reports (see "What this is not"
below): it needs neither concurrent requests nor speculative decoding.
Method: to avoid accidentally changing more than one thing, each arm was produced by
reading the command line of the running process and substituting a single token,
rather than by rebuilding the command line. Same binary, same runtime, same model, same
context size, same corpus, same request order in every arm.
Load: 100 607-token prompt, n_predict: 64, cache_prompt: false, ignore_eos: true,
posted to /completion three times in a row.
Results on stock master (b10825)
| iter | -np 2 |
-np 1 |
|---|---|---|
| 1 | 335.7 t/s | 323.5 t/s |
| 2 | 151.4 | 322.3 |
| 3 | 154.5 | 321.9 |
| change | -54.0% | -0.5% |
TTFT for the same 100 607-token prompt goes 299.7 s -> 664.6 s -> 651.1 s at -np 2,
and stays at 311.0 / 312.2 / 312.5 s at -np 1. Memory is flat in both arms
(dedicated 31 892 MiB / shared 434 MiB at -np 2; 31 292 / 490 MiB at -np 1),
sampled during the requests, so nothing is being evicted to host memory.
Results on the patched b10665 branch (where it was found)
| iter | -np 2 |
-np 1 |
-np 2, no speculation |
|---|---|---|---|
| 1 | 690.6 t/s | 683.1 t/s | 752.0 t/s |
| 2 | 398.0 | 660.7 | 438.3 |
| 3 | 394.4 | 649.7 | 430.6 |
| change | -42.4% | -4.9% | -42.7% |
-np 1does not collapse; the mild -4.9% drift over three iterations is normal here.- Removing
--spec-type draft-mtp --spec-draft-n-max 3changes nothing about the
collapse (-42.7% vs -42.4%), so speculative decoding is not involved. - Generation speed is essentially unaffected — the loss is in prompt processing only.
A -np 3 arm behaves the same way (644.3 / 365.4 / 367.5 t/s, -43.3%), i.e. the loss
does not scale with slot count — it looks like a threshold at "more than one slot".
I am reporting that arm with a caveat, though: at -np 3 this model no longer fits and
the server loads with ~1 081 MiB spilled to host memory, so its absolute numbers are
contaminated and only the shape of the drop is meaningful.
It is not memory spill
Per-process GPU memory was sampled continuously during each request, not just after:
| arm | dedicated | shared (peak = end) |
|---|---|---|
-np 2 |
31 978 MiB | 436 MiB, flat across all 3 iterations |
-np 1 |
31 379 MiB | 491 MiB, flat |
-np 2, no spec |
29 768 MiB | 264 MiB, flat |
Nothing is evicted to host memory, and the numbers do not move between the fast first
iteration and the slow later ones.
What this is not
I checked the adjacent open reports before filing; this is a different failure:
- spec + vulkan: Performance Drop with MTP n-max > 1 and -np > 1 #27544 (MTP n-max > 1 with -np > 1) — needs
draft-mtpand genuinely concurrent
sessions; degrades generation. Mine reproduces with speculation removed and with
strictly sequential requests, and generation is fine. - speculative: draft-dflash draft acceptance collapses under concurrent sequences (-np 16 pathological, -np 4 healthy, --spec-draft-n-max 1 recovers) #27117 (draft acceptance collapse under concurrent sequences) — scales with the
number of concurrently drafting sequences. Only one request is ever in flight here. - eval bug: draft-mtp roughly halves prompt processing on multi-GPU layer split (single GPU is fine) #27428 (draft-mtp halves prompt processing) — multi-GPU layer split; the author
reports single-GPU costs only ~3%. This is a single GPU.
First Bad Commit
Unknown — not bisected. An older measurement on this machine with the same harness and
the same -np 2 was flat (569.7 / 550.1 / 541.7 t/s), so conditions existed under which
it did not appear, but I cannot date the change and the -np 1 control locates the cause
without needing to.
Relevant log output
Command line for the -np 2 arm (API key redacted, paths shortened):
llama-server.exe -m Qwen3.8-27B-UD-Q5_K_XL_V2.gguf --mmproj mmproj-F16.gguf \
--device ROCm0 -ngl 999 -c 163840 -fa on -np 2 --kv-unified --no-cache-idle-slots \
--cache-type-k f16 --cache-type-v f16 -t 6 --poll 0 --jinja \
--chat-template-file <patched-template>.jinja \
--temp 0.7 --top-p 0.8 --top-k 20 --min-p 0 --presence-penalty 1.5 --repeat-penalty 1 \
--cache-ram 0 --host 0.0.0.0 --port 8080 --api-key <REDACTED> \
--no-mmproj-offload -ub 512 -b 1024 --spec-type draft-mtp --spec-draft-n-max 3The -np 1 arm is the same line with -np 2 replaced by -np 1; the no-speculation arm
is the same line with --spec-type draft-mtp --spec-draft-n-max 3 removed.
GGML_CUDA_DISABLE_GRAPHS=1 is set in all arms.
Activity
alankila commentedon Sep 6, 2026
Confirming that I probably have the same issue. Strix Halo starts well, with me getting around 320 tok/s prompt processing speed initially, with 4 parallel slots and kvu for about 600k tokens on Qwen3.8-Flash-Next, but after filling the context once, the new context should start from 0 prompt but speed that I get is usually less than half, like 90 instead of 320 tokens.
I think I have observed the same issue on CUDA, ROCm and Vulkan, so I doubt this is in any way backend specific on any of the hardware. I'm currently testing whether disabling KVU will fix this.
DanoPTT commentedon Sep 6, 2026
Reproduced on stock master (b10825), and it is worse there
I said above I would retest on master rather than leave the patched build as the only
evidence. Done — using the official release binary, nothing local in it:
Same machine, same model, same corpus, same 100 607-token prompt, same
cache_prompt: false/n_predict: 64, requests strictly sequential. The command lineis unchanged from the report above except for the binary path; the
-np 1arm differsfrom the
-np 2arm by that one token only.-np 2-np 1So on stock master the loss is -54%, larger than the -42% I measured on my patched
build — the local FA/RDNA prefill work appears to partially mask it, not cause it. The
-np 1control on master is essentially perfectly flat (323.5 / 322.3 / 321.9), whichis a cleaner control than the one in the original report.
TTFT for the same prompt goes from 299.7 s to 664.6 s at
-np 2, and stays at ~312 sacross all three iterations at
-np 1.Memory is again flat and rules out spill: dedicated 31 892 MiB / shared 434 MiB across
all three
-np 2iterations (sampled during the requests, not just after), and31 292 / 490 MiB at
-np 1.Raw numbers, both arms, for completeness:
alankila commentedon Sep 6, 2026
My own test results suggest that disabling KVU gets rid of the problem. I also may have understated what Strix Halo is capable of, when you start prompt over from scratch but have everything warmed up, as then you don't pay some kind of setup cost, it seems:
I slot print_timing: id 1 | task 15545 | prompt processing, n_tokens = 2048, progress = 0.03, t = 4.16 s / 492.88 tokens per second
I don't think I've ever seen nearly 500 tok/s from a Strix Halo before, so I'm willing to state from this evidence that KVU should be turned off as workaround.
[-]Eval bug: prompt processing drops ~42% from the second long request onward with -np 2 (single GPU, sequential requests, no spill, reproduces without speculation)[/-][+]Eval bug: prompt processing drops ~54% from the second long request onward with -np 2 (Radeon AI PRO R9700 / gfx1201, single GPU, sequential requests, no spill, reproduces without speculation)[/+]DanoPTT commentedon Sep 6, 2026
Controlled A/B: it is carried by
--kv-unified, and the cost tracks stale cells in the pool, not request ordinality@alankila's workaround holds up under a controlled test — thanks, that pointed
straight at it. Two experiments below, both on the patched b10665 build (not master),
same machine, same clone-the-running-command-line-and-change-one-token method as the
original report.
1.
--kv-unifiedon/off, matchedNote for anyone repeating this:
-cis the total with--kv-unifiedand isdivided across slots without it, so simply dropping the flag at
-c 163840 -np 2gives 81920 per sequence and a 100k prompt is rejected outright. To change only the one
thing, I used a ~50k prompt that fits both shapes, leaving each sequence the same usable
capacity.
-np 2in both arms, everything else identical:--kv-unified)Memory flat in both, sampled during the requests (shared peak 276 MiB split /
436 MiB unified), so spill is excluded again.
With per-sequence caches the collapse is absent. With one shared pool it appears
immediately.
2. The trigger is the size of what the previous sequence left behind
Fresh server,
-np 2 --kv-unified, one short request before the long ones:690.6 t/s is the fresh-server figure, so 669.9 after a 512-token predecessor is inside
this machine's ~3% run-to-run drift. A 512-token predecessor costs nothing; a
100 605-token predecessor costs 41%. So it is not a binary "this is not the first
request" state — the penalty is proportional to how much of the pool the previous
sequence occupied.
What this suggests
Prompt processing appears to scale with the number of used-or-stale cells in the
shared pool rather than with the length of the sequence being prefilled.
cache_prompt: falseremoves the previous sequence's cells, but that does not appear tolower the pool's occupied high-water mark, so each later prefill is built over roughly
the whole occupied pool.
That predicts a saturation effect, which is visible in the data and which I had not
understood before this run:
saturates after the first request: iter2 ≈ iter3 (398.0 / 394.4), a plateau.
is worse than the last (802.5 / 551.5 / 467.2), heading for the same ceiling.
The plateau in the original report and the progressive decline here are the same
phenomenon at different pool-fill rates.
One thing I could not determine: whether the extra time is host-side mask
construction or device-side attention. Process CPU time ran at ~1.02x wall in every arm,
fast or slow, but HIP's blocking sync busy-waits so the main thread tracks wall time
regardless. That measurement is inconclusive, not evidence either way.
Workaround
Dropping
--kv-unifiedremoves the effect here, matching @alankila's result onStrix Halo. The cost is that
-cis then divided across slots, so the sameper-sequence context needs
-cmultiplied by-np— which may not fit.[-]Eval bug: prompt processing drops ~54% from the second long request onward with -np 2 (Radeon AI PRO R9700 / gfx1201, single GPU, sequential requests, no spill, reproduces without speculation)[/-][+]Eval bug: with -np 2 and --kv-unified, prompt processing drops 42-54% from the second long request onward (single GPU, sequential requests, no spill, no speculation)[/+]DanoPTT commentedon Sep 6, 2026
Correction: this is the known unified-KV limitation in the CUDA/HIP FA kernels, and it reproduces in
llama-batched-benchI owe the thread a correction. In my previous comment I presented the mechanism as
something I had inferred from my own A/B. That was incomplete — this is already known
and named upstream, and I should have found it before filing. Prior art:
-kvuwith--parallel 4: populated-but-idle slots slow the active one,worsening as each slot fills, persisting until restart. Closed after the Vulkan fix.
backend... either disable the unified KV cache and take the memory hit, or switch to
1 slot."
-INFblocks in the FA kernels. Metal has it,Vulkan got it in vulkan: skip all-negative-inf blocks in FA #17186 (regressed by vulkan: Preprocess FA mask to detect all-neg-inf and all-zero. #19281, restored by vulkan: restore -inf check in FA shaders #19582). CUDA/HIP skips only
the tails (the
KV_maxscan), not-INFblocks in the interior — which is exactlywhat @ggerganov raised in
CUDA: skip masked KV slices for all FA kernels #14924 (comment):
"Do you plan to extend the logic to search for -INF blocks within the entire context,
not just at the tails? This would be useful in use cases where the unified KV cache
(-kvu) is enabled."
I could not find a PR implementing that for CUDA. HIP compiles the CUDA backend, so
gfx1201 inherits it.
What is still new here: #19523 is about token generation, on Vulkan, and its
reporter states explicitly "And pp is not affected." This report is about prompt
processing, on HIP, where it costs 42-54%. Same root cause, a phase and a backend that
were previously recorded as unaffected.
Reproduced with
llama-batched-bench -kvu -tgsSince #17103 exists precisely to measure this, here it is with no custom harness — same
machine, same model,
-npp 8192 -ntg 32 -npl 1,2,4 -c 33792 -fa on, only-kvu/-no-kvudiffering:master b10825
-kvuS_PP-no-kvuS_PP-kvuS_TG-no-kvuS_TGpatched b10665 build (has the #28102 FA hunks)
-kvuS_PP-no-kvuS_PPWith
-no-kvuS_PP is flat across B, as in the #17103 example where the-INFblockoptimizations are active. With
-kvuit falls monotonically with the number ofsequences in the pool. The
-kvuS_TG column also reproduces the #19523 symptom on HIP.Note the penalty here (-27%) is smaller than the -54% in the report body because it
scales with how much of the pool is occupied: 8 192-token prompts in a 33 792 pool versus
100 607-token prompts in a 163 840 pool. Same effect, further along the curve.
Ask
Extend the CUDA/HIP FA
-INFblock skipping beyond the tails, as proposed in the #14924comment. That would fix prompt processing here and the generation case in #19523 on
CUDA/HIP at the same time. Happy to test a patch on gfx1201.
Interim workaround remains
--no-kv-unified, with the caveat that-cis then dividedacross slots, so preserving per-sequence context requires multiplying
-cby-np—which may not fit.
DanoPTT commentedon Sep 6, 2026
Offer to test, concretely
To make the offer at the end of my last comment more useful than a one-liner — if anyone
picks up extending the
-INFblock skipping to the interior of the context in theCUDA/HIP FA kernels, I can turn a patch around the same day on the exact configuration in
this report:
side, so a branch or a raw diff both work.
llama-batched-bench -kvu -tgsbefore/after in the table formatabove, and the server-level case that actually hurts — 100 607-token prompts,
strictly sequential, in a 163 840 pool, where the loss is 54% rather than the 27% the
8k-prompt bench shows.
And a correctness check, not only speed. This is a class of change where a
speed-only test can look excellent and still be wrong: skip a block that was not entirely
-INFand the output changes quietly instead of crashing. I have a perplexity baseline onthis card that is bit-identical across runs and across two ROCm runtimes — same value and
the same per-chunk sequence, not just the same final number — so I can tell the
difference between "unchanged" and "close enough to miss". I would run that alongside the
benchmark and report both.
Happy to test partial or exploratory patches too — it does not have to be a finished PR.
superslor commentedon Sep 24, 2026
I hit this on Volta (V100 32GB, sm_70, CUDA 12.9) and measured it carefully. I think the
mechanism is narrower than "unified KV is slower": with
--kv-unified, the mask-skipoptimisation is never computed during token generation at all.
The gate
ggml/src/ggml-cuda/fattn-common.cuh:During token generation
Q->ne[1] == 1, so the first clause fails. With a unified KV cachen_stream == 1, soQ->ne[3] == 1and the second clause fails too.KV_maxis thereforenull for every TG step and each sequence walks the full padded
n_kv, including the cellsbelonging to every other sequence in the pool.
With a split cache
Q->ne[3] == n_seq_max > 1, the gate passes and the skip is active --which is exactly why the split configuration does not show the problem.
The other two conditions always hold in this configuration:
n_kvis padded to 256 inllama_kv_cache::get_n_kvandFATTN_KQ_STRIDEis 256.The cost is the sum of all resident sequences, not the largest
Four identical 15.6k-token conversations, one per slot, generating one at a time
(no concurrency), token counts taken from the server's own
print_timinglines:4 x 15.6k = 62k total, and a single 67k conversation measures 18.1 t/s on the same build.
So a 15.6k conversation with three others parked generates at the speed of a 62k one.
Unified vs split, matched token counts, clean restart per condition
qwen3 hybrid, Q6_K,
-c 262144,--flash-attn on, KVq8_0,-np 4except where noted:Two things worth noting: a conversation's own speed is identical in both modes (20.70 vs
20.68, 18.20 vs 18.13) -- unified costs nothing for the sequence that defines the high-water
mark. And nothing is being evicted in either mode (20 tokens re-evaluated on return), so this
is not a cache-thrashing effect.
Tail trimming alone would not fix it
I checked whether cell placement rescues this, since
flash_attn_mask_to_KV_maxtrims fromthe tail: a small conversation seeded first (so its cells sit low in the shared pool) with a
67k context parked above it still generated at 18.11 t/s. With a unified cache,
find_slothands out cells sequentially from a moving cursor, so a sequence that grows after others were
allocated ends up with its cells split across the array and the fully-masked blocks sit in the
interior, where a tail trim cannot reach them.
Possible direction
Rather than compacting the live blocks into a list (which changes which CUDA block accumulates
which KV blocks, and therefore the floating-point grouping), a bitmap of live blocks that the
kernels skip in place keeps the iteration order and grouping identical. A fully-masked block
contributes exactly 0 to both the softmax numerator and denominator and leaves the running
maximum untouched, so skipping those blocks should be bit-for-bit identical to processing
them, which makes it straightforward to validate.
Happy to share numbers from anything else that would be useful.
superslor commentedon Sep 25, 2026
Following up with prompt-processing numbers, since this affects prefill as well as token
generation and the prefill case is the more painful one in practice.
Same machine as my earlier comment (V100, sm_70, CUDA 12.9, qwen3 hybrid Q6_K,
-c 262144,-np 4,--flash-attn on, KVq8_0,--kv-unified). A 14,626-token prompt, measured fromthe server's own
prompt eval timeline:So ~46-49% of prefill throughput is lost to cells belonging to a conversation that has nothing
to do with the request. Nothing is evicted in either case; the 49k conversation is simply
parked in another slot.
The mechanism is the same one from my earlier comment, but it reaches prefill by a different
route. Generation (1 query column) uses the vec/tile kernels, where the gate
fails outright with a unified cache, so no skip information is computed at all. Prefill is wide
enough that the first clause passes, so
KV_maxis computed - butflash_attn_mask_to_KV_maxonly trims fully-masked blocks off the TAIL. With a unified cache the sequences share one cell
pool, so the other conversations' cells are interleaved and their fully-masked blocks sit in the
INTERIOR, where the tail trim cannot reach them.
Worth noting for anyone reproducing this on Volta: prompt processing here selects the mma kernel
(
Q->ne[1]*gqa_ratio_eff > 16), and with a typical prefilltiles_efficiency_percentcomes outaround 98, so
use_stream_kis false and it runs one CUDA block per output tile - structurallythe same shape as the vec/tile kernels, not the stream-k split.
Measurement notes, since two of these cost me a rerun: time prefill against the server's own
timing line rather than wall clock; make sure the probe prompt is not a prompt-cache hit (check
the token count in
print_timingmatches what you sent); and size test contexts by measuredtokens, not by line count.