llama-bench placement flags, two host-KV fixes, and H1/H2/H3/H5/H13 measured - #62
Draft
Piggidragon wants to merge 7 commits into
Draft
llama-bench placement flags, two host-KV fixes, and H1/H2/H3/H5/H13 measured#62Piggidragon wants to merge 7 commits into
Piggidragon wants to merge 7 commits into
Conversation
llama-bench took -nkvo and -ub and none of the other placement flags, so the host-resident KV configuration space could not be swept from it. Adds -kvgl/--kv-gpu-layers, -kvcp/--kv-cpu-pinned and -rso/--recurrent-state-offload, following the existing -nkvo pattern: they sweep with comma lists and show up as result columns only when set. -rso matters more than it looks on a hybrid model. The recurrent memory is built with offload_kqv || recurrent_state_offload, so -nkvo alone also moves the recurrent state to host memory, which costs about 2x of generation. Without this flag no -nkvo measurement on such a model isolates the attention KV. Assisted-by: Claude Opus 5
Adds the working backlog for host-resident KV on a VRAM-constrained accelerator, with the first four hypotheses settled against measurements on an RTX 4070 and Qwen3.8-27B. H5 rejected: no crossover between host-side attention and shipping the window. The delivery runs at 90% of the pinned link, the host attention path at 28% of host DRAM read and flat in thread count. That parks H10 and H14. H1 rejected: prefill is compute-bound at ub 512, and the compute buffer the larger ubatch costs is worth more than the whole KV cache. That parks H19. H2 confirmed in mechanism only: the delivery is a fixed cost per graph, so the host-KV penalty falls from 29% to 13% between one and eight slots, but aggregate throughput does not scale linearly. H3 confirmed: speculation is worth 1.37x to 1.83x more under a host-resident cache, rising with draft length. Also records that -nkvo moves the recurrent state of a hybrid model to host memory, which costs about 2x of generation for 149.62 MiB. Assisted-by: Claude Opus 5
stage_store_rows() casts the current K/V rows to the cache type before storing them into a host-resident cache. That cast is a GGML_OP_CPY whose output is a quantized tensor in the compute buffer, so the backend's get_alloc_size decides its size. The CUDA buffer type pads a quantized tensor whose row is not a multiple of MATRIX_ROW_PADDING (512). GGML_OP_CPY was not in the list of ops allowed to expand, so any model whose n_embd_k_gqa or n_embd_v_gqa is not a multiple of 512 aborted at context creation with a quantized cache type and --no-kv-offload. Muse-Glimmer-30B has 2 KV heads of 128, so n_embd_k_gqa is 256 and CUDA pads by one 272 byte row. Qwen3.8-27B has 1024 and is unaffected, which is why this went unnoticed. Follows the instruction in [TAG_ALLOC_SIZE_EXPAND]. The allocation only ever grows, so no result changes: greedy output on Muse-Glimmer is identical between a device-resident and a host-resident cache, and tg on Qwen3.8-27B is unchanged at 32.93 t/s. Assisted-by: Claude Opus 5
build_graph_shift() built its view over get_size()*n_stream, the whole allocated cache, rather than over the cells that hold data. With a device-resident cache that is wasteful but fast. With a host-resident cache the ROPE runs on the CPU backend and reads and writes the entire allocation, so the cost is set by -c instead of by the context in use. A cell at or past used_max_p1() is empty, and set_input_k_shift() already wrote a shift of 0 for an empty cell, so the old graph applied an identity rotation to it. Skipping those cells changes no result: logits after a shift are bit-identical, checked at -c of 8192, 32768 and 131072. Several streams keep the old behaviour, because their per-stream extents are not contiguous in the cache and one rope view cannot express them. Muse-Glimmer-30B, host-resident cache, 256 live cells, cost of the shift above a plain decode: -c 8192: 66.84 ms -> 19.56 ms -c 32768: 197.76 ms -> 16.10 ms -c 131072: 687.21 ms -> 16.55 ms The remaining cost no longer grows with -c, which is the point. Assisted-by: Claude Opus 5
H13 measured and implemented: a context shift roped the whole allocated cache, costing 687 ms at -c 131072 for 256 live cells, now flat at about 16 ms. Records the GGML_OP_CPY allocation abort that blocked -nkvo with a quantized cache on any model whose n_embd_k_gqa is not a multiple of 512, and the first host-resident numbers for Muse-Glimmer-30B now that it runs. Corrects the cost model: the sliding window dominates density. Muse-Glimmer is fully dense but 4.9x cheaper per context token than the hybrid reference, not 4x worse. Assisted-by: Claude Opus 5
llama_context sets n_outputs_max to n_batch when a tool does not ask for something smaller, so the prompt-processing reserve allocates logits for every token of a ubatch. On a 248320 token vocabulary that is 0.947 MiB per ubatch token, which dominated the compute buffer and capped -ub well below what the hardware allows. llama-bench decodes with llama_batch_get_one, which marks only the last token of the batch as an output, so it never needs more than one. llama-server already sets n_outputs_max to its slot count and perplexity sets it to n_batch because it genuinely reads every output; this makes llama-bench say what it needs too. Qwen3.8-27B on a 12 GiB card, CUDA0 compute buffer at a 4096 token prompt: -ub 512: 505 MiB -> 132 MiB -ub 2048: 2054 MiB -> 568 MiB -ub 4096 now fits where it used to fail to allocate. Throughput is unchanged, because the reserve only sized a buffer that was never filled: pp at -ub 512 with a host-resident cache is 953.57 t/s against 953.30 before. Assisted-by: Claude Opus 5
The first version of H1 blamed the compute buffer for making large ubatches unaffordable. That was llama-bench reserving logits for a whole batch, not a property of the model, and it is now fixed. H1 is still rejected, but only because the whole prompt transport is 4.1% at 32k and the gain plateaus after ub 2048; the buffer is no longer the reason and the earlier "15x the FFN term" correction is retracted. H7, H9 and H12 sized from the H5 intercepts: 32 extra scheduler splits and the staging round trips cost 0.37 ms in total, under 1.3% of a decode and falling with context. None of them touch the delivered bytes, which H5 shows are the entire cost. Assisted-by: Claude Opus 5
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Overview
Two fixes, one tooling addition, and the measured verdicts on five hypotheses from
docs/dense-kv-hypotheses.md. Draft, because the last section is a behaviour question thatis not mine to answer.
Fix: quantized KV with
-nkvoaborted on many modelsNot
--split-mode tensorand not what #57 fixes; it reproduces on a single device with nosplit mode.
stage_store_rows()casts the current rows to the cache type before storing them into ahost-resident cache. That cast is a
GGML_OP_CPYwhose output is a quantized tensor in thecompute buffer, so
get_alloc_sizesizes it. CUDA pads a quantized tensor whose row is not amultiple of
MATRIX_ROW_PADDING(512), andGGML_OP_CPYwas missing fromggml_backend_op_alloc_size_may_expand(), so the allocation assert fired at context creation.n_embd_k_gqa-nkvowithq8_0f16unaffected, every quantized pair aborted. One line, following the instruction in[TAG_ALLOC_SIZE_EXPAND]. The allocation only grows, so nothing changes numerically:test-backend-ops -o CPY246/246, Qwen3.8 tg unchanged at 32.9 t/s, and Muse-Glimmer greedyoutput byte-identical between a device- and a host-resident cache.
Not behind a flag on purpose: the prior behaviour is an abort, so there is no configuration
where the old path was preferable.
Fix: H13, a context shift roped the whole allocated cache
build_graph_shift()viewedget_size()*n_streaminstead of the live cells. On ahost-resident cache the ROPE runs on the CPU backend over the entire allocation, so the cost
was set by
-crather than by the context in use.Muse-Glimmer-30B, host-resident cache, 256 live cells, cost above a plain decode:
-cAt
-c 131072one shift cost 14x a plain decode for 256 live tokens, and the server takes ashift on every cache-reuse
seq_add, not only on overflow. The remaining cost no longer growswith
-c.The doc's stated risk turned out to be void, which is why this is not behind a flag either: a
cell at or past
used_max_p1()is empty, andset_input_k_shift()already wrote a shift of 0for an empty cell, so the old graph was applying an identity rotation to the tail. Skipping it
is exactly equivalent. Logits after a shift are bit-identical at
-c8192, 32768 and 131072.Several streams keep the old path, because their per-stream extents are not contiguous and one
rope view cannot express them.
llama-bench:
-kvgl,-kvcp,-rsollama-benchaccepted-nkvoand-uband none of the other placement flags, so thehost-resident KV configuration space could not be swept from it at all. This adds
-kvgl/--kv-gpu-layers,-kvcp/--kv-cpu-pinnedand-rso/--recurrent-state-offload, following the existing-nkvopattern exactly: they sweep with,lists, they show up as result columns (kvgl,kvcp,rso) only when set, and they mapstraight onto the matching
llama_context_paramsfields. No behaviour changes anywhere else.-kvglwas the flag the doc asked for.-rsoturned out to be needed first, for the reasonin the last section.
Machine baseline
RTX 4070, gen4 x16, 16 host threads, CUDA 13.3.
The doc assumed host DRAM is "several times" PCIe bandwidth. It is 1.74x here, and that
turns out to decide two of the four results.
Model under test for everything below: Qwen3.8-27B-UD-IQ2_M, 9.6 GiB, all weights device
resident, 16 owned attention layers, q8_0 K and V, so 34 KiB of window per context token per
decode step.
H5, host-side attention: rejected
tg64 at depth,
-ub 512, 3 reps:There is no crossover. Shipping the window wins at every context and the margin grows from
10.6% to 56.3%.
Fitting ms/token against depth separates the fixed cost from the per-context-token cost. All
three arms agree on the fixed cost to within 0.8 ms, which is what makes the slopes worth
comparing:
The delivery runs at 90% of the pinned link. The host attention path runs at 28% of host DRAM
read, and it is flat under
-t 4,8,16(13.32, 13.28, 13.31 t/s at depth 16384), so it is notthread-starved; it is running at about single-thread DRAM speed whatever it is given.
Consequences, both recorded in the doc: H14 is parked, because it would replace a
22.1 GB/s delivery with a 12.0 GB/s host read, and even a perfect CPU kernel at the full
42.8 GB/s ceiling only buys 1.9x on a machine where the raw ratio is 1.74x. H10 is
parked, because there is no crossover for a per-layer policy to find.
Fix: llama-bench reserved logits for a whole batch
llama_contextsetsn_outputs_maxton_batchunless a tool asks for less, so the promptreserve allocated logits for every token of a ubatch. On this 248320 token vocabulary that is
0.947 MiB per ubatch token.
llama-serversetsn_outputs_maxto its slot count and neverpaid it;
perplexitysets it ton_batchbecause it genuinely reads every output;llama-benchsaid nothing and so reserved a buffer it never fills, because it decodes withllama_batch_get_one, which marks only the last token of the batch.CUDA0 compute buffer, Qwen3.8-27B, 4096 token prompt:
-ub 4096now fits where it used to fail to allocate. Throughput is unchanged, since thereserve only sized a buffer that was never filled: pp at
-ub 512with a host-resident cacheis 953.57 t/s against 953.30 before.
H1, prefill transport against ubatch: rejected, on the size of the prize
32768 token prompt,
-b 8192,-rso 1, 2 reps, measured after the fix above:The mechanism is real: raising
ubdivides the prompt transport, and the host-versus-devicepenalty falls from 4.1% to 1.0%. What kills it is that the whole penalty is 4.1% at 32k, so
1/ubatchdivides a term that is already small, and the gain plateaus -512 -> 2048is+2.9%,
2048 -> 4096is -0.5%. Prefill is compute-bound long before transport matters.This corrects an earlier version of this PR. It claimed
ub 512 -> 2048cost 1549 MiB ofcompute buffer, more than the whole KV cache, and that the buffer grew at 15x the
n_ff * ub * 4term. That was the llama-bench artifact above, not a real cost. After the fixthe growth is about 0.28 MiB per ubatch token, a small multiple of the FFN intermediate, so
the doc's original mechanism was right and my correction to it was wrong. H1's verdict is
unchanged; its second reason is not.
H7, H9 and H12: sized out, not implemented
A host-resident decode graph has 34 splits against a device-resident one's 2, two per owned
attention layer - exactly the structure these three items target. The H5 fits price it: the
intercepts are 26.78 ms device against 27.15 ms host, so all 32 extra splits and the staging
round trips together cost 0.37 ms, about 11.6 us each. That is 1.2% of a decode at 2k and
0.47% at 32k, shrinking with context. H12's padded tail is another 0.20 ms.
Three changes the doc itself calls risky, for under 1.3% combined, none of which touch the
delivered bytes that H5 shows are the entire cost. Not worth it.
H2, unified cache across slots: mechanism confirmed, claim overstated
llama-batched-bench -kvu -pps, shared 8192-token prompt, 64 tokens per slot, aggregate tg:The last column is the hypothesis and it holds exactly: the delivery costs about 11 ms per
graph regardless of how many slots feed it, and at
n_kv = 8256the cost model predicts11.68 ms against 11.69 measured.
What does not hold is "close to linearly": aggregate rises 3.96x for 8x the slots, because
compute grows too. The useful form of the result is the third column. The host-KV penalty
falls from 29% at one slot to 13% at eight, and host-resident KV scales better with slots
than a device-resident cache does (3.96x against 3.23x). A multi-slot server is where
host-resident KV is least bad, and the single-stream numbers are its worst case. That is the
opposite of the usual intuition and is not in any current default or doc.
H3, speculative decoding under host KV: confirmed, and the biggest effect measured
27312-token prompt, 256 generated tokens, greedy at
--temp 0with a fixed seed, DFlash2sidecar as the draft,
--spec-draft-kv-gpu-layers 99in the host arm. Speedup is againstthat arm's own
n_max = 0:--spec-draft-n-maxThe last column is the hypothesis. Speculation is worth 1.37x to 1.83x more under a
host-resident cache, and the gap widens with draft length. The curve shapes differ for the
reason the mechanism gives: the device arm peaks at 4 and regresses at 8 because the draft
starts costing more than the verification saves, while the host arm keeps climbing because
the single delivery it amortizes dominates everything else.
Acceptance per draft is identical between arms by construction, same model, prompt, seed and
temperature, rather than by measurement.
One caveat on the absolute numbers. The DFlash sidecar shares
output.weightwith the targetand has to sit on the same device stack, and target plus draft do not fit on the 4070 alone,
so this ran with the model split across both cards. The second card is gen3 x4, about
3.5 GB/s, which is why the host baseline is 4.8 t/s here against 12.7 t/s single-card in H5.
Both arms share the topology so the ratio holds, but the left columns are a slow-second-link
case. Incidentally that is its own finding: a host-resident cache behind gen3 x4 is about 7x
worse than behind gen4 x16.
Muse-Glimmer, now that it runs
tg64 at depth, across a 4070 and a 3060:
Read the host column as a slow-link case: the 3060 is gen3 x4, about 3.5 GB/s against the
4070's 24.6, and it holds half the layers.
This also corrects the cost model in the doc. Density is the wrong axis; the sliding window
dominates it. Muse-Glimmer is fully dense at 52 attention layers, but 39 are SWA-2048 and
n_embd_k_gqais 256, so beyond the window only 13 layers grow: 7072 B per context tokenagainst the hybrid reference's 34816 B. The fully dense model is 4.9x cheaper per context
token, not "roughly 4x worse".
What this adds up to
The actionable result is a configuration, not a code change. Host-resident KV is far less bad
than the numbers usually quoted for it, but the wins are in H2 and H3, not in moving
attention to the host (H5) or in larger ubatches (H1). Run it with several slots, a unified
cache and a draft model. Nothing in the defaults or documentation currently says so, which is
what H11 is for and what I would do next.
The one that is not in this diff:
-nkvoalso moves the recurrent statellama-model.cpp:2215builds the recurrent memory withcparams.offload_kqv || cparams.recurrent_state_offload. On a hybrid model,-nkvothereforemoves the gated-delta-net state to host memory along with the attention KV, and every
recurrent layer's state ops go to the CPU backend.
At 2k of context,
-nkvo --kv-cpu-pinned:--recurrent-state-offload32.2 ms per token, at every context length, and
llama_memory_recurrentreports what it buysback as 149.62 MiB for one sequence, independent of
-c. On the same run the attention KV is8.5 MiB per 256 cells, so 149.62 MiB is about 4500 cells of cache.
I think the default is wrong for one sequence and I am not sure it is wrong at high
-np, where the state scales withn_seq_maxand the argument gets weaker. So this PR onlydocuments it and adds the flag to
llama-bench. Changing the default is a user-visiblebehaviour change and it is your call, not mine.
The immediate practical consequence either way: any
-nkvonumber ever taken on a hybridmodel without
--recurrent-state-offloadis off by about 2x, including anything in thisrepository's history.
Additional information
Everything above is in
docs/dense-kv-hypotheses.mdalongside the hypotheses it settles, sothe file stays the single place the backlog lives. Raw CSVs are not committed; the exact
commands are in each measured block.
Not measured yet, and next in the doc's own order: H11 (
--fitfor dense models), which iswhat turns the H2 and H3 results into something a user gets without reading this file.
Requirements
llama-benchflag plumbing against the existing
-nkvopattern, and drafted this description and themeasured blocks in the doc. The hypotheses, the cost model they are tested against, and the
decision not to touch the
recurrent_state_offloaddefault are mine.