sched: pipeline the delivery of a host-resident KV cache (R4) - #38
Closed
Piggidragon wants to merge 8 commits into
Closed
sched: pipeline the delivery of a host-resident KV cache (R4)#38Piggidragon wants to merge 8 commits into
Piggidragon wants to merge 8 commits into
Conversation
With --no-kv-offload the attention history lives in host RAM and reaches the accelerator on every decode token. The scheduler issued that transfer on the consumer's own stream immediately before the kernels that read it, so a token cost copy + compute in series: 26.80 ms of blocking host-to-device against 25.14 ms of consumer wait, per decode graph at 18.5k tokens of context. This is R4 of the KV-offload roadmap (PR GenerelSchwerz#31). The bytes and the attention operations are unchanged; only the point at which the transfer is issued moves. Greedy server output is byte-identical to the ordered path, at every look-ahead tested and against a build of the parent commit. Three pieces: - ggml_tensor::stable_prefix records how many leading bytes of a tensor's storage the graph about to run will not write. The KV window is not stable for the whole graph -- a CPU split writes this ubatch's rows into it between one layer's attention and the next -- but everything below the lowest written row is, and at decode depth that is 619 of 620 MiB. llama_kv_cache sets it from apply_ubatch(), before the graph is built and allocated, so the scheduler's plan and the deliveries it issues are decided against the same write position even when the graph is reused; build_graph_shift() clears it. - A staging ring the graph allocator cannot reach. ggml-alloc may recycle a graph-owned input copy after its last graph-level consumer, and a look-ahead transfer is still in flight outside that lifetime; that is what made the earlier cross-layer prefetch experiment non-exact. The scheduler allocates the ring itself and points the staged input copies at it before allocation, so ggml-alloc leaves them alone. One ready event and one release event per slot carry the handover in each direction. - A look-ahead that stays clear of the ring's tail. A delivery L splits ahead recycles the slot of the split L - n_slots back, so n_slots == L + 1 recycles the split that was just enqueued and is still running, and every delivery waits for the consumer -- the ordered path with extra steps. The ring keeps two slots of margin, deliveries are issued after a split is enqueued rather than before, and slot recycling is ordered stream to stream rather than through the host. Each of those three, alone, costs the entire gain while still producing correct output. --kv-pipeline-depth N, default 1, 0 restores the ordered path exactly. It only engages where a host-resident cache produces the deliveries; a device-resident run never creates the transport at all and measures unchanged (39.13 -> 39.10 t/s at tg128 @ d4096). A/B/A/B on an RTX 4070, Qwen3.8-27B-UD-IQ2_M, -nkvo --kv-cpu-pinned, q8_0 K/V: depth ordered pipelined gain ceiling (PR GenerelSchwerz#31 §3) 4,096 31.7324, 31.7363 37.0889, 37.0741 +16.9% 96.4% of 38.49 16,384 19.6765, 19.6854 31.5352, 31.5807 +60.4% 90.4% of 34.88 32,768 13.0264, 13.0254 15.5325, 15.5329 +19.3% 74.6% of 20.83 Server decode behind an 18,422-token prompt: 18.468 -> 30.685 t/s, +66.2%. Device allocation high-water at 32,768: 10,271 -> 10,477 MiB, +206 MiB. 32,768 is the weak point and is documented as such: the copy per staged split exceeds the compute between staged splits there, and one split of look-ahead cannot cover it. Raising the look-ahead is not the fix -- N = 1 measured best at every depth, which is why it is the default. docs/kv-transport-pipelining.md carries the design, the numbers and the limits; docs/repro/ carries the two scripts that produced them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SRa5ZTc1bpnk1fE3mrDcSh
# Conflicts: # src/llama-kv-cache.h
…ment guides Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SRa5ZTc1bpnk1fE3mrDcSh
…later reader
Two things the first pass did not cover, both found while measuring the feature
from 4k to 256k of context.
A slot holds one split's whole delivery, so the ring is linear in context length:
27 MiB at 4k, 213 MiB at 32k, 855 MiB at 128k, 1.7 GiB at 256k. It is allocated
after the graph allocator has reserved its buffers, so taking whatever is left
can starve a later reallocation. It now declines unless it can leave 512 MiB of
device memory free, says so once, and stays on the ordered path.
The scheduler also creates one input copy per (tensor, backend) rather than per
split, so a later split can be pointed at the same copy without appearing in its
input list -- and by then the ring may have recycled the slot. The plan now scans
the splits after the owner for such a reader and puts those inputs back on the
ordered path. Attention does not produce this shape, but nothing in the scheduler
forbids it, and the failure would be silently wrong output.
Measured on the same RTX 4070 / Qwen3.8-27B-IQ2_M / -nkvo --kv-cpu-pinned setup,
A/B/A/B with peak device memory sampled across each arm:
context ordered pipelined gain peak VRAM ring
4,096 31.66 37.01 +16.9% 10169 -> 10197 27 MiB
16,384 19.64 31.43 +60.1% 10159 -> 10263 107 MiB
32,768 12.99 15.49 +19.2% 10161 -> 10367 213 MiB
65,536 7.74 8.74 +12.9% 10163 -> 10573 428 MiB
131,072 4.29 4.68 +9.1% 10537 -> 11355 855 MiB
The gain narrows with depth because compute is a shrinking share of the token, so
there is less to hide the copy behind -- arithmetic, not an implementation limit.
What the table also shows is that the ring's cost does not narrow with it, and at
128k it already claims 818 MiB of an 11.9 GiB card.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SRa5ZTc1bpnk1fE3mrDcSh
A host-resident KV cache exists to keep device memory free. Measuring the
transport from 4k to 256k showed it spending that memory back at a rate that
grows with the context, which is working against the thing it accelerates:
context gain ring peak device memory
4,096 +16.9% 27 MiB 10,169 -> 10,197
16,384 +60.1% 107 MiB 10,159 -> 10,263
32,768 +19.2% 213 MiB 10,161 -> 10,367
65,536 +12.9% 428 MiB 10,163 -> 10,573
131,072 +9.1% 855 MiB 10,537 -> 11,355
The gain narrows with depth and the cost does not, so past about 32k this is a
bad trade on a card where the cache is on the host precisely because memory is
short.
--kv-pipeline-budget, default 128 MiB, is an absolute cap on the ring rather
than a fraction of what happens to be free. Over it the scheduler declines and
keeps the ordered path. Three things make declining actually free:
- The cap is checked before anything is allocated, on every plan rather than
only when the ring has to grow.
- The decision is latched. A context only grows, so a ring that fits the small
windows of early prefill would only have to be given back later.
- The transfer backend is created on the first graph that stages something, not
when the depth is set, and is released along with the ring. It is a second
device context and it was costing 112 MiB on a scheduler that never staged
anything.
At 32,768 with the default budget, device memory settles at 10,161 MiB against
the ordered path's 10,161 MiB and throughput matches (12.965 vs 12.984 t/s).
--kv-pipeline-budget 512 buys 15.487 t/s there for 206 MiB, for whoever wants
that trade.
Also tried and reverted: planning the ring during reserve, so that ggml-alloc
would not budget blocks for the staged input copies as well. It reclaims
nothing. The compute buffer is sized by a reserve that runs before the KV cache
exists -- 342.27 MiB either way at 32,768 -- and the copies then fit inside
space already reserved for other transients. The ordered path's copies are free,
so the ring is additive and the only lever is its size.
Known limitation: the cap is applied per graph, so a run whose context grows
past it still shows a transient peak (+112 MiB at 32,768) before the latch
trips, even though the steady state is +0. Deciding against the context's final
size needs KV geometry the scheduler does not have.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SRa5ZTc1bpnk1fE3mrDcSh
…scope as single-GPU Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SRa5ZTc1bpnk1fE3mrDcSh
# Conflicts: # CLAUDE.md # CLAUDE.md~HEAD
The transport picked the first backend that could transfer asynchronously and order streams with events, and stopped there. On a layer-split model that leaves every device after the first paying copy + compute in series while the first one does not, which is the wrong shape for a feature whose whole purpose is to stop paying that. Each eligible backend now carries its own ring: its own transfer backend and stream, its own slots and handover events, its own look-ahead cursor, and its own budget decision. The pieces that had to become per-ring rather than global: - The delivery order is numbered within a ring, not across the graph, so a slot is recycled against its own device's progress. - Each ring walks the split list on its own cursor. One device saturating its look-ahead must not stop another from running ahead on its own. - The budget and the device-memory headroom are decided per ring and latched per ring. A device with no room falls back to the ordered path on its own and un-stages only its own inputs; the others keep pipelining. The cap stays per device, which is the honest reading now that there is more than one: each ring is a claim on its own card, and a second accelerator brings its own memory to spend. Measured on an RTX 4070 + RTX 3060, Qwen3.8-27B-UD-Q5_K_M (18.40 GiB) at -sm layer -ts 55/45, -nkvo --kv-cpu-pinned, q8_0 K/V, tg32 @ d4096: ordered 6.91 t/s peak 9787 / 9069 MiB pipelined 7.52 t/s peak 9815 / 9099 MiB +8.8%, +28 MiB per card with both rings engaged -- CUDA0 staging 9 of 355 splits and CUDA1 staging 7. Single-GPU is unchanged: 19.66 -> 31.42 t/s at d16384 on the 4070, as before. Tensor parallelism (-sm tensor) is still on the ordered path and is not addressed here. The scheduler sees one meta backend there, and ggml-backend-meta implements neither event_record/event_wait on the backend nor event_new/event_free/ event_synchronize on the device, so the transport cannot order a transfer stream against the consumer and correctly declines. Supporting it means adding events to the meta layer, relaxing its set_tensor_async offset == 0 assertion, and giving a transfer-only meta backend a way not to stand up a second NCCL communicator -- all in BeeLlama's tensor-parallel layer rather than the KV-offload line. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SRa5ZTc1bpnk1fE3mrDcSh
2 tasks
Author
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.
Implements R4 of the KV-offload roadmap (#31): the host-to-device delivery of a host-resident KV cache is issued one split ahead, on a transfer stream of its own, so a decode token stops paying transfer and attention in series.
Draft. Works on single GPU and on
-sm layermulti-GPU.-sm tensor(tensor parallelism) is not supported and cannot be, without changes to the meta backend — see What tensor parallelism would take, written so it can be scoped independently.What it does
Three pieces, each load-bearing:
ggml_tensor::stable_prefixrecords it on the tensor that owns the storage;llama_kv_cache::update_stable_prefixes()sets it fromapply_ubatch(), before the graph is built and allocated, so the plan and the deliveries are decided against the same write position even when the graph is reused.build_graph_shift()clears it.ggml-alloc's reach.ggml-allocmay recycle a graph-owned input copy after its last graph-level consumer while a look-ahead transfer is still in flight — that is what made the earlier cross-layer prefetch experiment non-exact (§5 of KV offload: defect register, measurements and roadmap #31: +1.38%, not exact). The scheduler allocates its own ring and points the staged copies at it before allocation; aready/releaseevent pair per slot carries the handover in each direction.Lsplits ahead recycles the slot of the splitL - n_slotsback, son_slots == L + 1recycles the split just enqueued and still running. The ring keeps 2 slots of margin, deliveries are issued after a split is enqueued, and slot recycling is ordered stream-to-stream rather than through the host. Each of those three, alone, costs the entire gain while still producing correct output — the first working version measured +0.5% and looked like "the copy just doesn't overlap".Every accelerator the scheduler drives gets its own ring: own transfer stream, own slots, own look-ahead cursor, own budget decision. A device with no room falls back to the ordered path alone; the others keep pipelining.
--kv-pipeline-depth N(default 1,0= ordered path exactly). Only engages where a host-resident cache produces the deliveries; a device-resident run never creates the transport at all.Device memory
A host-resident cache exists to keep device memory free, so what the transport costs matters as much as what it buys. A ring is
(N + 2)slots of one attention layer's K+V over the whole context — linear in context length:Two curves run opposite ways: the gain narrows with depth (compute is a shrinking share of the token, so there is less to hide the copy behind — arithmetic, not an implementation limit, and more look-ahead makes it worse) while the cost doubles with every doubling of context.
So
--kv-pipeline-budget(default 128 MiB per device) caps each ring outright, not as a fraction of what happens to be free. Three things make declining actually free rather than nominally free:At 32,768 with the default budget, device memory settles at 10,161 MiB — identical to the ordered path — and throughput matches it.
--kv-pipeline-budget 512buys 15.49 t/s there for 206 MiB.Tried and reverted: planning the ring during reserve so
ggml-allocwould not budget blocks for the staged copies as well. It reclaims nothing — the compute buffer is 342.27 MiB either way at 32k, because it is sized by a reserve that runs before the KV cache exists and the copies then fit inside space already reserved for other transients. The ordered path's copies are free; the ring is unavoidably additive, and its size is the only lever.Measurements
Single GPU — RTX 4070, driver 610.57.04 / CUDA 13.3, i5-13400F,
Qwen3.8-27B-UD-IQ2_M.gguf,-nkvo --kv-cpu-pinned --recurrent-state-offload, q8_0 K/V,-sm none,taskset -c 0,2,4. A/B/A/B with reversed arm order; table above.Dual GPU, layer split — RTX 4070 + RTX 3060,
Qwen3.8-27B-UD-Q5_K_M.gguf(18.40 GiB),-sm layer -ts 55/45:Both rings engage — CUDA0 stages 9 of 355 splits, CUDA1 stages 7 — and 98.5% of the staged bytes go early.
Validation
tg128 @ d4096; the transport is never created.test-kvarn(D4 in KV offload: defect register, measurements and roadmap #31),test-upstream-merge-keepers-static(D10), andtest-tokenizers-ggml-vocabs(a vocab GGUF in the repo is an LFS pointer, not a GGUF).What tensor parallelism would take
Scoped separately because all of it lands in
ggml/src/ggml-backend-meta.cpp— BeeLlama's tensor-parallel layer, not the KV-offload line this PR owns.Why it does not work today. Under
-sm tensor,llama.cpp:184-211builds a single meta device wrapping both GPUs, soggml_backend_schedsees one backend, not CUDA0/CUDA1. The transport's eligibility test (ggml-backend.cpp:2751) requiresset_tensor_async+event_record+event_waiton the backend andevent_newon the device. The meta backend hasset_tensor_asyncbut:ggml_backend_meta_i(ggml-backend-meta.cpp:2515) —.event_recordand.event_waitarenullptr(lines 2529-2530)ggml_backend_meta_device_iface(line 178) —.event_new,.event_free,.event_synchronizeare allnullptr(lines 191-193)There is no event mechanism in the meta layer at all, and pipelining is built on ordering a transfer stream against the consumer with events. So the transport declines — correctly, rather than racing.
Four work items, in dependency order:
event_new/event_free/event_synchronizeatggml-backend-meta.cpp:191-193. A meta event is N per-device events, one per sub-device, created via each sub-device's ownevent_new.ggml_backend_meta_device_count/ggml_backend_meta_device_get(lines 200, 206) already give the sub-devices.event_record/event_waitat lines 2529-2530, fanning out over every sub-backend.ggml_backend_meta_n_backends/ggml_backend_meta_simple_backend(lines 2549, 2555) already give the sub-backends. Mostly wiring.set_tensor_asyncwith a non-zero offset.ggml_backend_meta_set_tensor_asyncopens withGGML_ASSERT(offset == 0)(line 1911;get_tensor_asynchas the same at 1956). The prefix delivery passes offset 0 and is fine, but the tail delivery writes[prefix, nbytes)and would assert. The function already splices by chunk offsets internally, so relaxing it is plausible rather than deep — but it is a change to a shared function and needs its own test.ggml_backend_dev_initis cheap. For a meta device it runs the wholeggml_backend_meta_contextconstructor, which callsggml_backend_comm_init(line 1872) across all devices — standing up a second NCCL communicator over both cards purely to issue memcpys. Options: a params flag on the meta constructor (it already takesconst char * params) for a transfer-only backend that skips comm setup, or a way to share the existing comm.What is already favourable. The ring lives in a meta buffer, so it is sliced across the GPUs by the same split state as everything else — the per-card VRAM cost is roughly the ring divided by device count, not the whole ring. And nothing in items 1-3 is conceptually hard; item 4 is the only one that needs a decision rather than an implementation.
Validation gates for it should mirror this PR's: byte-identical greedy output against
-sm tensorwith--kv-pipeline-depth 0, A/B/A/B throughput, and per-card peak device memory.Known limitations
-sm rowis unrelated to this PR but worth recording: the CUDA backend registers noggml_backend_split_buffer_type, so-sm rowfails at model load in this tree regardless of these changes.--kv-gpu-layerswould remove ~19% of H2D traffic against the 9.1% the ring buys. Unmeasured, and it moves with layer count and card.docs/kv-transport-pipelining.mdcarries the design, the numbers and the limits;docs/repro/carries the scripts that produced them.🤖 Generated with Claude Code
https://claude.ai/code/session_01SRa5ZTc1bpnk1fE3mrDcSh