Skip to content

Block KV cache streaming: bound VRAM at long context via a shared CUDA phase arena - #357

Open
giveen wants to merge 48 commits into
TheTom:feature/turboquant-kv-cachefrom
giveen:feature/kv-stream-phase-arena
Open

Block KV cache streaming: bound VRAM at long context via a shared CUDA phase arena#357
giveen wants to merge 48 commits into
TheTom:feature/turboquant-kv-cachefrom
giveen:feature/kv-stream-phase-arena

Conversation

@giveen

@giveen giveen commented Sep 6, 2026

Copy link
Copy Markdown

Block KV cache streaming: bound VRAM at long context via a shared CUDA phase arena

Summary

Adds experimental block KV cache streaming, ported and generalized from
Raymond Huang's feature/kv-stream-phase-arena
(https://github.com/RaymondHuang210129/llama.cpp-adaptive-kv-streaming). His
implementation is Qwen3.5-only; this generalizes the same shared CUDA
phase-arena design to any architecture with a standard, uniform-per-layer KV
cache (plain dense/MoE, llama_memory_hybrid, iSWA), wires it into
TurboQuant's native direct_attention kernels (turbo2/3/4), and validates it
against real local models rather than by inspection.

With streaming on, only a resident subset of KV pages plus the active
compute workspace live in one bounded CUDA allocation; the rest of the KV
cache lives in host RAM and streams on demand. Enabled with
--kv-stream-arena-mib N; 0 (default) disables it and behavior is
unchanged.

All raw evidence (results.csv/.jsonl for every sweep and llama-perplexity
logs for every correctness check - every number in the tables below comes
straight from these) is committed at
benchmarks/results/kv-stream-pr-evidence/
(excludes the multi-GB saved-logits .kld files - regenerable with
--save-all-logits if needed).

What's new since the port

  • Wired turbo2/3/4 into the native direct_attention path (measured ~2x
    prefill speedup on Qwen3.8-27B: ~1390 -> ~2930 t/s). This wiring benefits
    ordinary non-streamed attention unconditionally, but streamed
    attention only takes the direct_attention path when ggml-cuda is built
    with GGML_CUDA_FA_ALL_QUANTS (off by default) - without it, every
    streamed KV pair falls back to a slower F16-dequant path (measured
    ~2840 -> ~1200 t/s streamed prefill at 8K context, same model/types).
    See "Attention dispatch: direct vs F16 fallback" in docs/kv-stream.md.
  • Fixed a dormant bug where process_ubatch() never switched the phase
    arena into decode layout for any plain (non-hybrid) llama_kv_cache -
    every model tested had been running decode with the prefill layout for
    its entire lifetime. Fixing it dropped streaming-vs-non-streaming mean
    KL-divergence from 0.003 to ~0.000000.
  • Generalized streaming-target discovery off a fixed set of dynamic_casts
    to llama_memory_i::get_kv_stream_targets(), so an unsupported memory
    type fails closed with a clear error instead of a hard crash.
  • Extended support to iSWA (dual full + sliding-window cache -
    Gemma-family, this fork's own laguna arch): the full-attention
    (kv_base) sub-cache streams, the sliding-window sub-cache stays
    resident (small by construction).
  • Attempted DSV4 (DeepSeek-V4) support; found a real, un-root-caused
    correctness regression (mean KLD 0.07 vs ~0.000000 for every other
    architecture) and reverted it rather than ship it. DSA/MSA/MLA remain
    excluded or unverified for the same reason - see
    docs/kv-stream.md.
  • Fixed two bugs found via code review, both with regression test coverage:
    • --swa-full silently conflicted with streaming (both iSWA sub-caches
      tried to bind the same single-lease arena); now rejected explicitly
      with a named reason instead of a generic construction failure.
    • A no_alloc dry-run context (used by -fit's auto-probe and by MTP's
      parent-memory-sizing pass) built a real CUDA arena anyway, then failed
      with a confusing "requires a memory type with a streamable KV cache"
      error - benign (the real context loads fine moments later) but
      alarming. Now skipped cleanly for no_alloc contexts.
  • Added docs/kv-stream.md: requirements, architecture support matrix, why
    single-sequence-only is a design limitation and not a validation gate,
    arena sizing guidance, and a known-inefficiency note (see below).
  • Added benchmarks/benchmark_kv_memory.py: sweeps context length and
    records GPU VRAM + host process RSS, with a same-context no-streaming
    baseline at every point (including past the point where the baseline
    OOMs), and plots both. Unlike the throughput sweep it needs no
    prefill/decode pass, so it's cheap enough to cover much wider ranges.

Architecture support

Shape Support
Plain dense/MoE (one llama_kv_cache) Yes
llama_memory_hybrid (recurrent + attention, e.g. Qwen3.5) Yes, on the attention sub-cache
iSWA (dual full + sliding-window, Gemma-family / laguna) Yes, on the full-attention sub-cache
MLA (DeepSeek-V2/V3-style) Unverified - not excluded by architecture, but never tested against a real model
DSA, DSV4, MSA Excluded - DSV4 has a proven correctness regression; DSA/MSA never attempted given that finding

Benchmarks

All runs: single RTX 5090-class GPU (32 GiB), -ctk q8_0 -ctv turbo4 -fa on --parallel 1, fixed 8192 MiB shared arena, ggml-cuda built with
GGML_CUDA_FA_ALL_QUANTS=ON, via benchmarks/benchmark_kv_stream.py
(prompt = context - 256 tokens, decode 256 tokens, prompt not cached). A
default build (flag off) gets materially lower streamed prefill throughput
at the same context/arena size - see "Attention dispatch: direct vs F16
fallback" in docs/kv-stream.md.

Qwen3.8-27B (dense/hybrid, no speculative decoding)

17 points, 8K -> 262K context. Smooth, continuous decline in both prefill
and decode throughput as context grows - no cliff, no crash, right through
the model's native 262K training context.

Context Prefill t/s Decode t/s VRAM used
8K 2759 59.5 28.1 GiB
25K 2605 52.6 28.2 GiB
41K 2396 47.3 28.3 GiB
57K 2156 42.8 28.3 GiB
74K 1943 39.6 28.4 GiB
90K 1773 36.4 28.5 GiB
106K 1623 33.6 28.5 GiB
123K 1505 31.5 28.6 GiB
139K 1407 29.7 28.6 GiB
156K 1314 27.7 28.7 GiB
172K 1242 26.3 28.8 GiB
188K 1171 24.8 28.8 GiB
205K 1115 23.7 28.9 GiB
221K 1063 22.6 29.0 GiB
238K 1014 21.6 29.0 GiB
254K 967 20.8 29.1 GiB
262K 946 20.5 29.1 GiB

VRAM grows only ~1 GiB (28.1 -> 29.1 GiB, ~3.5%) across a 32x increase in
context (8K -> 262K) - the fixed 8192 MiB arena plus a small amount of
bookkeeping overhead that scales with layer/page counts, not with total
context. Full per-point data:
qwen3.8-27b-throughput/results.csv.

Correctness (streaming vs non-streaming), this checkpoint:

Context PPL (baseline) PPL (streaming) Mean KLD Max KLD Same top-token
4K 5.7131 5.7275 -0.000000 0.000057 100.000%
32K 6.0108 6.0124 0.000000 0.000070 99.988%

Mean KLD is at the numerical noise floor at both scales. 32K is the more
meaningful check: host RSS measurements (see "VRAM and host RAM" below)
confirm real page streaming is active by that point, not just a
fully-resident no-op. Full methodology and a larger-scale attempt (and the
tooling limitation it hit) are in "Correctness: PPL and KL-divergence, at
increasing scale" below. Raw logs:
kld-logs/.

Qwen3.8-27B + MTP speculative decoding (--spec-type draft-mtp --spec-chain 8)

5 points, 8K -> 106K context. Streaming coexists cleanly with MTP on the
target context (MTP's own draft context never streams - see docs). Draft
acceptance on this benchmark's synthetic repeated-filler prompt was
measured at ~100%, so these decode numbers are a best-case ceiling, not a
typical-prompt expectation - treat the shape of the curve as
representative, not the absolute numbers.

Context Prefill t/s Decode t/s (MTP) VRAM used
8K 2595 294.5 28.6 GiB
33K 2422 260.7 28.8 GiB
57K 2094 227.5 29.0 GiB
82K 1786 213.9 29.2 GiB
106K 1568 191.6 29.5 GiB

Full per-point data:
qwen3.8-27b-mtp-throughput/results.csv.

Required a small fix to benchmarks/benchmark_kv_stream.py: its exact
prompt/decode token-count check had never been run against speculative
decoding before, and MTP's chunked verification (up to spec_chain+1
tokens per step) can clip the last chunk a few tokens short of the target
right at the context boundary. Added a bounded tolerance derived from
--spec-chain; the reported decode_tps is unaffected either way (it's
the server's own measured rate, not derived from the target count).

Gemma-4-26B-A4B-It (MoE, iSWA)

6 points, 8K -> 131K context, using the proper instruction-tuned checkpoint
(gemma-4-26B-A4B-it-UD-Q5_K_S, Unsloth dynamic quant - an earlier pass
used a community "Uncensored" merge; see the correctness section below for
why it was swapped). Prefill is much faster than Qwen (MoE, ~4B active
params/token vs Qwen's dense 27B). Decode falls off far more steeply than
Qwen's dense/hybrid curve.

Context Prefill t/s Decode t/s VRAM used
8K 9674 71.0 27.8 GiB
32K 6007 23.0 27.9 GiB
57K 4026 13.5 27.9 GiB
82K 2923 9.5 28.1 GiB
106K 2502 7.5 28.1 GiB
131K 2073 6.0 28.2 GiB

Full per-point data:
gemma4-it-throughput/results.csv.

Numbers are within noise of the earlier (worse-quant) checkpoint's run,
which rules out quantization/merge quality as the cause of the steep decode
falloff - it's reproduced almost exactly across two unrelated checkpoints of
the same architecture. VRAM is flat (27.8 -> 28.2 GiB) across the whole
16x context range - same arena-boundedness result as Qwen, on a
structurally different (MoE + iSWA) architecture. Streaming itself is
stable (no crash, no errors) through the whole sweep; the falloff looks
architectural (MoE expert routing and/or the mixed full+SWA cache
structure) rather than a streaming-specific regression, but hasn't been
root-caused against a non-streaming Gemma4 baseline - flagging as an open
question rather than asserting a cause.

VRAM and host RAM: where the data actually goes, and what happens without streaming

benchmarks/benchmark_kv_memory.py (new in this PR - see "What's new")
sweeps context length and records GPU VRAM and host process RSS, with a
same-context no-streaming run alongside the streaming one at every point.
It's cheap (no prefill/decode needed - the buffers are sized at context
construction) so it can cover a much wider range than the throughput sweep.

Qwen3.8-27B-AD, 131K -> 786K context (YaRN-scaled past the 262K
training context, 4x, --rope-scale 4 --yarn-orig-ctx 262144):

Context Streaming VRAM No-streaming VRAM Streaming RSS No-streaming RSS
131K 28.3 GiB 24.1 GiB 4.4 GiB 1.2 GiB
262K 28.3 GiB 27.7 GiB 7.7 GiB 1.3 GiB
393K 28.2 GiB CUDA OOM (crash) 10.9 GiB -
524K 28.2 GiB not attempted (already OOM'd at 393K) 14.1 GiB -
655K 28.3 GiB not attempted (already OOM'd at 393K) 17.3 GiB -
786K 28.2 GiB not attempted (already OOM'd at 393K) 20.6 GiB -

No-streaming hits a real CUDA error: out of memory abort at 393K (full
gdb-style backtrace:
qwen3.8-27b-memory/logs/baseline-393216.log -
inside the server's own warmup decode, not a clean pre-flight allocation
failure, an in-flight kernel abort, matching the "hard process abort near
the VRAM ceiling" failure mode in docs/kv-stream.md). Streaming keeps
going another 6x further (to 786K, the max tested) with VRAM pinned to
~28.3 GiB throughout, while host RSS absorbs the growth (4.4 -> 20.6 GiB).
Full per-point data:
qwen3.8-27b-memory/results.csv.

Gemma-4-26B-A4B-It, 65K -> 393K context:

Context Streaming VRAM No-streaming VRAM Streaming RSS No-streaming RSS
66K 28.1 GiB 20.9 GiB 1.8 GiB 1.3 GiB
131K 28.1 GiB 21.7 GiB 2.3 GiB 1.3 GiB
197K 28.1 GiB 22.4 GiB 2.9 GiB 1.4 GiB
262K 28.1 GiB 23.2 GiB 3.4 GiB 1.4 GiB
328K 28.1 GiB 24.0 GiB 3.9 GiB 1.4 GiB
393K 28.1 GiB 24.8 GiB 4.5 GiB 1.5 GiB

No-streaming never hit its ceiling in this range (21.4 -> 25.4 GiB, still
under budget at 393K) - most of Gemma4's layers are small fixed-window SWA
attention that doesn't grow with context, so its non-streaming footprint
grows much more slowly than Qwen's dense/hybrid cache. Streaming stays flat
at ~28 GiB across the same range. Not every architecture needs streaming at
the same context threshold, but streaming costs nothing extra either way.
Full per-point data:
gemma4-it-memory/results.csv.

Correctness: PPL and KL-divergence, at increasing scale

A short check is a weak check here. The mechanism this PR is actually
about - page residency, eviction, and ring-buffer streaming - only
activates once the resident window is a real fraction of the total
context. An early pass validated at -c 4096 against an 8192 MiB arena,
which likely fits entirely resident with little real eviction exercised.
Repeated at increasing scale, on the exact checkpoints benchmarked above
(llama-perplexity, wikitext-2-raw, non-streaming logits saved via
--kl-divergence-base, compared against a streaming
--kv-stream-arena-mib 8192 run of the same chunks):

Model Context PPL (baseline) PPL (streaming) Mean KLD Max KLD Same top-token
Qwen3.8-27B-AD 4K 5.7131 5.7275 -0.000000 0.000057 100.000%
Qwen3.8-27B-AD 32K 6.0108 6.0124 0.000000 0.000070 99.988%
Gemma-4-26B-A4B-It 4K 8067.36 16607.82 -0.000000 0.000056 100.000%

Mean KLD stays at the numerical noise floor at every scale tested, and
same-top-token stays at ~100% - streaming reproduces each model's own
output distribution, at a context large enough (32K) that host RSS
measurements above confirm real page streaming is active, not just a
fully-resident no-op. A 131K attempt on Qwen hit a tooling limitation in
llama-perplexity's own KL-divergence machinery (failed reading log-probs for chunk 0 against a 27 GB saved-logits file, failing immediately rather
than partway through - looks like a pre-existing limit unrelated to
streaming, not something this PR introduced) rather than a real finding
either way; flagging as an open item rather than a validated result at
that scale. Raw log:
kld-logs/qwen-streaming-131k-FAILED-tooling-limitation.log.

Gemma4-It's raw PPL is unusually high on wikitext for both runs equally
(confirmed coherent in normal chat use - see
kld-logs/gemma4-it-coherence-check.log)

  • almost certainly a heavily instruction/reasoning-tuned model scoring
    poorly on raw non-chat continuation, a known effect, not a fork bug. Ruled
    out turbo K/V quantization and Flash Attention as causes by reproducing the
    same result with plain f16 KV cache and with -fa off (see
    kld-logs/gemma4-it-f16-check.log and kld-logs/gemma4-it-nofa-check.log).
    KLD - the metric that actually measures streaming fidelity, independent of
    whether the base distribution is "good" at modeling wikitext - shows no
    divergence regardless.

Gemma4 + MTP: not applicable

Confirmed on two independent checkpoints (the community merge and the
official instruct release) - neither has nextn tensors (checked via
direct GGUF metadata read), so --spec-type draft-mtp fails outright on
both: context type MTP requested but model doesn't contain MTP layers.
This is an architecture-wide limitation (the Gemma4 family wasn't trained
with an MTP/NextN head in these public releases), not a streaming
limitation or a quant/merge quality issue. No streaming-specific finding
here either way.

Known limitations (see docs/kv-stream.md for full detail)

  • Single-sequence only (-np 1). Not a conservative gate - the
    resident-page design indexes pages by absolute buffer offset, the decode
    fast path assumes exactly one query token, and the adaptive
    repartitioning loop tracks one global counter. Real multi-sequence
    support needs a sequence dimension added to all three; not attempted.
  • MTP's own draft-context cache never streams (explicitly zeroed in
    common/speculative.cpp - the draft/target contexts don't yet share one
    pool). Streaming on the target context is unaffected and verified
    working with MTP active (see benchmarks above).
  • MTP verification batches use the prompt-phase arena layout, not the
    generation layout
    (server never calls llama_set_decode_phase()).
    Measured: 89 of 90 decode steps in a real MTP+streaming session ran in
    the prompt-phase layout (1246 resident pages/layer, 10 ring slots)
    instead of the generation layout (1273 resident pages/layer, 12 ring
    slots) - a real but modest gap at this context size. Fixing it requires
    resizing the generation-phase compute reservation for multi-token
    speculative batches, not just relaxing the existing TG1-only guard.
    Documented, not fixed, to avoid touching stable repartitioning logic for
    an unmeasured-at-scale win.
  • No multi-GPU tensor-split streaming. Every eligible layer must be on
    one CUDA device today.

Testing

  • test-kv-stream-config: 11 tests / 47 assertions, 0 failures.
  • test-arg-parser: full suite passes, including new coverage for
    --kv-stream-arena-mib/--kv-stream-stage-mib (legacy alias), negative
    rejection, and 0-disables-cleanly.
  • KL-divergence validation (streaming vs non-streaming,
    llama-perplexity --kl-divergence-base / --kl-divergence) on every
    supported architecture: mean KLD ~0.000000, >99% same-top-token for
    turbo/iSWA. DSV4 was the one architecture that failed this bar (0.07
    mean KLD) and is excluded as a result. Re-verified against the exact
    Qwen3.8-27B-AD and Gemma-4-26B-A4B-It checkpoints used in the benchmarks
    above at increasing context (4K and 32K for Qwen, 4K for Gemma4) - see
    "Correctness: PPL and KL-divergence, at increasing scale" - mean KLD at
    the noise floor at every scale tested, confirmed at a context (32K)
    large enough that host RSS growth shows real page streaming is active.
  • benchmark_kv_memory.py (new): VRAM stays flat while no-streaming grows
    until a real CUDA OOM abort at 393K context (Qwen3.8-27B) - streaming
    ran 6x further with no VRAM growth. Full backtrace in
    qwen3.8-27b-memory/logs/baseline-393216.log.
  • Live production smoke test: real llama-server invocation (mmproj +
    MTP + -c 226000 + streaming) served real chat completions with
    measured MTP draft acceptance for 2+ hours without incident.

Credits

Original shared-phase-arena design and its Python benchmark tooling by
Raymond Huang (https://github.com/RaymondHuang210129/llama.cpp-adaptive-kv-streaming).
This PR generalizes it beyond Qwen3.5 to TurboQuant's supported
architectures and turbo K/V quantization types, and adds the fixes and
validation described above.

🤖 Generated with Claude Code

@github-actions github-actions Bot added documentation Improvements or additions to documentation ggml examples testing CUDA labels Sep 6, 2026
@giveen

giveen commented Sep 6, 2026

Copy link
Copy Markdown
Author

@TheTom that took a while but I think I am done.

@TheTom

TheTom commented Sep 6, 2026

Copy link
Copy Markdown
Owner

Read the whole thing at b08b515, src side and CUDA side separately, with flag-off as the deciding question. The good news first: with --kv-stream-arena-mib unset the FA kernels really do fold back to their old bodies (the output_partial template param and if constexpr leave the existing instantiations byte-identical, stream_k stays true), the refusals (single sequence, --swa-full, -fa off, no kv offload, draft contexts, DSA/DSV4/MSA/recurrent) are centralised in llama_kv_stream_config_validate and throw from the constructor, the copy-stream / event ordering between H2D pages and the partial FA kernels is right, and CUDA graph capture is disabled for any node touching a runtime. Reverting the DSV4 geometry change instead of shipping a 0.07 KLD was the right call. This is a much better base than #326.

Needs changes before it can land:

  1. Rebase. It re-includes perplexity: respect explicit -np instead of silently overriding it #353 (-np in perplexity) and kv-cache: resolve TURBO_LAYER_ADAPTIVE mode per cache construction #354 (per-construction TURBO_LAYER_ADAPTIVE) verbatim; both merged yesterday. It is also one commit behind 4a54c52 (the TQ MMQ arch-config fallback), so the diff currently shows that fix as deleted. Rebase and I will merge with a merge commit, not a squash.

  2. Per-layer mixed KV types are neither modelled nor refused. Page bytes are computed once from the cache-level type_k / type_v (llama-kv-cache.cpp around line 436) and kv_stream_buft is assigned to every layer, but the loop from line 502 can give individual layers q8_0, turbo4 or turbo2 under TURBO_LAYER_ADAPTIVE, and the turbo 128-padding then changes bytes per row inside a pool that assumes uniform pages. Mode 7 auto-enables with no env var whenever type_v == turbo2 and n_layer >= 8, so -ctv turbo2 --kv-stream-arena-mib N hits this silently. Every number in the body is -ctv turbo4. Either refuse streaming when kv_adaptive_mode != 0, or compute geometry per layer. A KLD run at -ctv turbo2 would show which.

  3. test-kv-stream-cuda-attn fails on a default CUDA build. Line 550 asserts ATTENTION_DIRECT for every turbo pair, but that mode only exists under #ifdef GGML_CUDA_FA_ALL_QUANTS (fattn.cu around 1170), which is OFF by default (ggml/CMakeLists.txt:208). The test is registered under a bare if (GGML_CUDA). Guard the assertion or the registration. Separately, test-kv-stream-cuda-buffer allocates on device 0 with no device probe, so a GPU-less CUDA build goes red; skip cleanly when no device is present.

  4. The "~2x prefill via direct_attention" headline is GGML_CUDA_FA_ALL_QUANTS=ON only. Default builds take the F16 dequant-to-workspace path. The body, docs/kv-stream.md and the benchmark tables should name the flag, and a default-build number belongs next to the FA_ALL_QUANTS one.

  5. Two flag-off costs worth removing. ggml_cuda_graph_update_required now scans every node's kv_stream_generation before the cgraph->uid == graph->uid early-out, on every decode step, for everyone; hoist it behind an "any live runtime" check. set-rows.cu gained a runtime dst_row_base kernel argument on every SET_ROWS launch; a template parameter keeps the off path bit-identical. Smaller: process_ubatch builds a target vector per ubatch just to test emptiness.

Concerns, none individually blocking:

  • State save/load, seq_rm, seq_cp and the K-shift path are untouched and untested with a streamed cache, and nothing refuses llama_state_* with the arena on. Either a stated refusal or a test.
  • src/llama-kv-stream-softmax.{cpp,h} has no production caller; it is a CPU model of the CUDA fixup math compiled into libllama. Move it under tests/.
  • The DSV4 plumbing you reverted the geometry for is still in the tree as unreachable code. Delete it and keep the exclusion.
  • GGML_CUDA_PREFER_KV_HOST and GGML_CUDA_PREFER_MODEL_WEIGHTS are new env knobs that only act when streaming is off, unrelated to this feature and undocumented. Separate PR or drop.
  • Head-dim-128 models still get the write-combined pinned buffer and then fall back to plain FA reading KV over PCIe every step. Correct but slow and not in the requirements list.
  • Streamed output is close, not bit-identical (sequential online-softmax rescale across chunks vs flash_attn_combine_results), and the span tuner picks bounded vs unbounded from wall-clock timing, so a given step's kernel choice is timing-dependent. Both fine, both should be stated in the doc. kv_stream_normalize_chunk_results has no zero guard on the denominator.
  • docs/kv-stream.md says a too-tight arena can SIGABRT a serving server mid-request. For an in-tree flag that needs to become an error return.
  • kv_stream_arena_mib is inserted mid-struct in llama_context_params; append it so prebuilt consumers keep their offsets.
  • The NDEBUG asserts in evaluate_and_capture went from buft == cuda_buffer_type(device) to buft_is_cuda, which drops the device identity check.
  • GGML_ASSERT wrapping the dirty-row marking call (llama-kv-cache.cpp ~2150) puts a side effect inside an assert.
  • 795 KB of PNGs under benchmarks/results. Charts belong in the PR body as uploaded images; the CSV/JSONL is defensible.

I'll run the GB10 gates (default build flag-off sweeps, FA_ALL_QUANTS build with -Werror, one streaming-on KLD at -ctv turbo4 and one at -ctv turbo2) as soon as that box has disk again; it is at 100% right now.

@TheTom TheTom left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changes requested per the comment above: rebase off #353/#354, handle or refuse TURBO_LAYER_ADAPTIVE with streaming, fix the two CUDA tests that fail on default or GPU-less builds, and name the FA_ALL_QUANTS dependency on the headline number.

@TheTom

TheTom commented Sep 6, 2026

Copy link
Copy Markdown
Owner

One more from CI, and it is not the runner flake this time. The Windows arm64 cmake-pkg job fails at link:

FAILED: bin/Release/test-kv-stream-plan.exe
lld-link: error: undefined symbol: llama_kv_stream_plan_make(llama_kv_stream_plan_params const &)
lld-link: error: undefined symbol: llama_kv_stream_prefetch_dispatch(...)
lld-link: error: undefined symbol: llama_kv_stream_partition_adapt(...)
lld-link: error: undefined symbol: llama_kv_stream_feedback_delta_make(...)
lld-link: error: undefined symbol: llama_kv_stream_extent_make(...)

Those are internal libllama functions with no LLAMA_API export, and that job links the tests against the shared llama.dll, so the test cannot see them. The other three Windows legs were cancelled by fail-fast, so they would likely hit the same thing. Either compile the plan/softmax/config sources into the test target directly (the way test-kv-stream-softmax presumably needs to as well), or mark the test as static-only.

giveen and others added 21 commits September 6, 2026 09:43
Ports the pure-logic core of RaymondHuang210129/llama.cpp-adaptive-kv-streaming's
phase-arena KV streaming implementation (feature/kv-stream-phase-arena),
onto this fork's clean feature/turboquant-kv-cache base rather than
patching the broken block-KV-streaming stub in PR TheTom#326.

- llama-kv-stream-plan.{h,cpp}: page/region allocator, adaptive
  resident/ring partition sizing (deadline-miss/copy-busy driven
  hysteresis), prefetch dispatch. Ported verbatim - backend-agnostic,
  no TurboQuant-specific concerns.
- llama-kv-stream-softmax.{h,cpp}: online-softmax merge for combining
  resident-chunk and streamed-chunk partial attention results. Ported
  verbatim.
- llama-kv-stream-config.{h,cpp}: ported with one reconciliation -
  upstream's validate() hard-rejects any architecture other than
  Qwen3.5 (a literal allowlist, not an inert flag). Replaced that gate
  with this fork's existing unified_kv_cache check (excludes MLA/
  hybrid-SWA/recurrent caches, not just non-Qwen3.5 archs) and kept a
  type_pair_supported gate, since v1 of this port only wires up
  streaming for standard GGML K/V types - turbo2/3/4 must still be
  rejected here, not silently accepted.

All upstream test files (test-kv-stream-plan/config/softmax/bench-config
and the three CUDA test files) copied over as-is; the config test was
adjusted to reference unified_kv_cache/type_pair_supported instead of
arch_qwen35 so it compiles against the reconciled struct, and to
isolate the arena-below-minimum case from the kv_offload case it was
incidentally also exercising.

Wires the three pure-logic tests into tests/CMakeLists.txt; the CUDA
and bench-config test files are copied but not yet wired in since
their dependencies (CUDA kv-stream runtime, kv-stream-bench tool) land
in later commits on this branch.

Assisted-by: Claude

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…rows staging

Ports the CUDA implementation of RaymondHuang210129/llama.cpp-adaptive-kv-streaming's
phase-arena block KV streaming (feature/kv-stream-phase-arena) onto this
fork's attention/backend code, reconciled with TurboQuant's existing
turbo2/3/4 dequant paths, WHT rotation, and MLA/hybrid-SWA cache handling.

ggml-cuda.cu / ggml-cuda.h:
- New phase arena (one bounded CUDA allocation shared between compute
  workspace, resident KV pages, and an async transfer ring), resident
  cache, and transfer-ring buffer type, replacing nothing on this
  branch (no prior kv-stream implementation existed here).
- ggml_backend_cuda_device_supports_buft now recognizes the new
  kv-stream buffer type as CUDA0-affine - this is the direct fix for
  the scheduler CPU/CUDA0 thrashing (74 splits/eval instead of 2)
  measured against PR TheTom#326's implementation this session.
- GGML_OP_SET_ROWS/FLASH_ATTN_EXT dispatch in ggml_cuda_compute_forward
  route through the new staged/streamed paths only when a kv_stream
  runtime is actually attached to the tensor - turbo tensors (which
  never get a runtime attached, since v1 gates streaming to standard
  types only) fall through to the exact same code path as before,
  unchanged.

fattn.cu / fattn.cuh / fattn-common.cuh / fattn-mma-f16.cuh / fattn-vec.cuh:
- New kv_stream transfer ring, resident cache, per-type capability
  table (ggml_backend_cuda_kv_stream_get_type_capabilities/
  get_attention_mode - classifies only standard GGML types for v1;
  turbo2/3/4 fall through to UNSUPPORTED, same as any other type this
  doesn't recognize, gracefully excluding them rather than crashing),
  and ggml_cuda_flash_attn_ext_streamed (chunked attention over
  resident+streamed KV pages, merged via the online-softmax layer
  ported earlier).
- Added an `output_partial` template parameter (default false) to
  flash_attn_ext_f16_process_tile/flash_attn_ext_f16 and a
  launch_fattn(..., partial_dst, partial_meta) overload, both as
  trailing defaulted parameters so every existing TurboQuant call site
  (which doesn't know about partial output) compiles unchanged.
  ggml_cuda_flash_attn_ext_mma_f16_case_impl gets two thin wrappers:
  the original name (output_partial=false, used by the existing
  DECL_FATTN_MMA_F16_CASE instantiation macro) and a new
  _partial_case for the streamed path.
- Fixed a bug the port exposed rather than introduced: several
  `extern DECL_FATTN_VEC_CASE(...)` call sites for TurboQuant's
  cross-type (turbo x f16/q8_0/turbo) instances only put `extern` on
  the first of the macro's two statements once DECL_FATTN_VEC_CASE
  started emitting a _partial_case instantiation alongside the
  original - the second statement silently became a real definition,
  colliding with the same definition in each instance .cu file at
  link/compile time. Switched those sites to the existing
  EXTERN_DECL_FATTN_VEC_CASE macro, which extern-qualifies both.

set-rows.cu / set-rows.cuh:
- Threaded a `dst_row_base` offset through the standard-type set_rows
  kernel/dispatch chain (k_set_rows, the raw-pointer and
  ggml_backend_cuda_context overloads, including the two explicit
  half/int32_t and half/int64_t specializations) and added
  ggml_cuda_op_set_rows_staged, which stages new K/V rows through a
  pooled device buffer before copying into the (now host-resident)
  destination and optionally mirroring into the resident cache. Fixed
  one call site (IQ4_NL) the source patch didn't cover, which needed
  the same dst_row_base threading as its neighbors.
- Turbo-specific set_rows_cuda_turbo{2,3,4} dispatch is untouched -
  turbo K/V never reaches the staged path in v1.

Regression-tested: turbo3 and q8_0 K/V generation (no --kv-stream)
produce sane output at normal throughput; the three pure-logic
kv-stream unit tests still pass. --kv-stream-arena-mib itself isn't
wired up yet (llama-context.cpp/llama-kv-cache.cpp/common/arg.cpp come
next) so the new code paths aren't reachable yet - only build/link
integration and standard-type/turbo dispatch coexistence have been
verified so far.

Assisted-by: Claude

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…struction

Ports the llama_context/llama_kv_cache/llama_model wiring from
RaymondHuang210129/llama.cpp-adaptive-kv-streaming (feature/kv-stream-phase-arena),
reconciled with this fork's broader architecture zoo and the
unified_kv_cache generalization already made in the config layer.

- include/llama.h: new llama_context_params.kv_stream_arena_mib field
  and the llama_decode_phase enum + llama_set_decode_phase() API (used
  by the phase arena to distinguish prefill from decode-style
  microbatches and pick the right resident/ring split immediately
  instead of waiting several tokens for feedback to react).
- llama-context.{h,cpp}: phase arena bootstrap (validate config, probe
  per-layer page/workspace geometry, create the arena, publish its
  buffer type into llama_memory_params) replacing nothing (no prior
  kv-stream code existed on this branch). Removed the source's
  `arch == LLM_ARCH_QWEN35` gate on the *wiring* itself (only ever
  passing real values through for that one architecture) - replaced
  with a real unified_kv_cache computation mirroring this fork's
  MLA/indexer/recurrent exclusion set (MINIMAX_M3, GLM_DSA,
  DEEPSEEK32/4, recurrent archs, DFLASH only when dsv4_hc_mult>0),
  the same set the config layer's `unified_kv_cache` gate was already
  designed for. iSWA dual-cache and the specialized DSA/DSV4/MSA
  caches are still excluded (matching the source, which never wires
  kv-stream through their constructors either) - kv_base-only iSWA
  support stays a follow-up.
- llama-kv-cache.{h,cpp}: constructor gains kv_stream_stage_bytes/
  kv_stream_phase_arena/kv_stream_maximum_pool_bytes as defaulted
  trailing parameters (after the existing name_tag, which already had
  a default) rather than inserted where the source's diff placed them
  (right after `share`) - this fork's constructor already had a
  `name_tag` param the source's fork doesn't have, and every one of
  the 13 existing call sites across llama-kv-cache-dsa/dsv4/msa.cpp,
  llama-memory-hybrid*.cpp and llama-model.cpp needed to keep
  compiling unchanged. kv_stream_runtime_owner (the function-pointer
  table + adaptive resident/ring repartitioning in kv_stream_adapt)
  ported as-is.
- llama-model.cpp: wired both call sites that ever construct a plain
  llama_kv_cache directly with the model's real type_k/type_v - the
  llama_memory_hybrid attention sub-cache (source's only wired call
  site) and, generalizing past the source's single-architecture scope,
  the plain non-hybrid/non-SWA llama_kv_cache construction used by
  ordinary dense models. Every other call site (DSA/DSV4/MSA/iSWA/MTP
  drafts) is left passing the new parameters' defaults (disabled),
  matching the config layer's architecture exclusions.
- llama-memory-hybrid.{cpp,h}: threaded the same three parameters
  through to the inner llama_kv_cache construction, passing an
  explicit "" for this fork's name_tag parameter that sits between
  `share` and the new parameters.
- llama-memory.h: llama_memory_params gains the three kv_stream fields
  create_memory() reads.
- llama-cparams.h: cparams.kv_stream_arena_mib mirrors the public
  llama_context_params field.

Test fix: removed the type_pair_supported field and gate I'd added to
llama_kv_stream_config in the previous commit. It duplicated a check
the source implementation already does correctly and more precisely -
llama_kv_cache's constructor queries the actual per-layer (type_k,
type_v) backend capability via a proc-address lookup once the real
device is known, which this fork's earlier, coarser context-level
config can't replicate meaningfully before that point. Updated
test-kv-stream-config.cpp to match.

Regression-tested: q8_0 K/V generation (no --kv-stream) still produces
sane output; all three pure-logic kv-stream unit tests pass (104
assertions). --kv-stream-arena-mib CLI flag wiring (common/arg.cpp)
is next - the arena still isn't reachable from the command line yet.

Assisted-by: Claude

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Wires common_params.kv_stream_arena_mib through to
llama_context_params.kv_stream_arena_mib, and adds the
--kv-stream-arena-mib flag (--kv-stream-stage-mib kept as a
compatibility alias, matching the source's own naming history).

Also ports common_params_should_fit_device_memory(), which skips the
device-memory auto-fit pass entirely whenever a nonzero arena is
explicitly configured. This is the actual fix for the -fit crash
TheTom found on PR TheTom#326 and we reproduced empirically this session
(-ngl 10 --kv-stream auto threw "not supported for this K/V cache
type pair" because a CPU-placed layer failed the streaming gate mid
fit-search): the fit search's per-candidate probing is what hit that
gate in the first place, and an explicit arena size makes the probing
unnecessary - the arena's layout is already fully determined by the
user's own -c/--kv-stream-arena-mib, not something -fit needs to
search for.

speculative.cpp: MTP/draft contexts get kv_stream_arena_mib=0
unconditionally (the source's own choice - the streaming pool doesn't
yet support two contexts sharing it).

Assisted-by: Claude

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…dated config

Both ported verbatim in earlier commits, both latent bugs in
RaymondHuang210129/llama.cpp-adaptive-kv-streaming itself that never
surface for its one validated target (Qwen3.8-27B, always hybrid
memory, always head_dim==KV_STREAM_HEAD_DIM==256) but do the moment
this fork's broader model support actually exercises the streaming
path end to end:

1. llama-context.cpp: kv_stream_switch_phase() hard dynamic_cast'd to
   llama_memory_hybrid and errored ("phase arena requires hybrid
   attention memory") for anything else. Since this fork also wires
   the arena through a plain llama_kv_cache (the non-hybrid,
   non-SWA branch in llama-model.cpp, generalized in the previous
   commit), added a fallback: try the hybrid wrapper first, then a
   direct llama_kv_cache cast. Both paths reach the same
   kv_stream_resize_pool() call, which doesn't care how it was reached.

2. ggml-cuda.cu: the GGML_OP_FLASH_ATTN_EXT dispatch in
   ggml_cuda_compute_forward routed to the streamed kernel whenever
   K or V merely had a kv-stream runtime attached, without checking
   ggml_cuda_kv_stream_fattn_fits() first - unlike supports_op's
   FLASH_ATTN_EXT case a few thousand lines down, which already does
   check it. For a streamed cache on a model whose head_dim isn't
   exactly 256 (ggml_cuda_flash_attn_ext_streamed_supported hard-codes
   Q->ne[0] == KV_STREAM_HEAD_DIM), this reached
   GGML_ASSERT(ggml_cuda_kv_stream_fattn_fits(dst)) inside
   ggml_cuda_kv_stream_fattn() and aborted instead of falling back.
   Added the same fits check to the dispatch condition, matching
   supports_op - a streamed cache on an incompatible head_dim now
   silently uses plain attention (the streaming kernel's head_dim
   restriction stays a real limitation; only the crash is fixed).

End-to-end verified on Qwen3-8B.Q5_K_M (head_dim 128, plain non-hybrid
cache, --kv-stream-arena-mib 512, -ctk/-ctv q8_0):
- No crash; coherent generation output.
- GGML_SCHED_DEBUG=2 shows 2 scheduler splits/eval after the arena
  activates - matching the no-streaming baseline exactly, versus the
  74 splits/eval measured against PR TheTom#326's implementation earlier
  this session. The supports_buft fix from an earlier commit is
  confirmed working under real inference, not just at the API level.
- Generation throughput is lower than baseline for this model (head_dim
  128 can't use the actual streamed kernel per the limitation above,
  so this only exercises the memory-placement change, not the real
  page-streaming path) - expected until the KV_STREAM_HEAD_DIM
  restriction is generalized, a follow-up beyond this port.

Assisted-by: Claude

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…aining tests

Ports tools/kv-stream-bench/ (pinned-host vs cudaMallocManaged H2D
bandwidth probe, arg parsing, dry-run/--execute modes) verbatim - fully
self-contained, no dependency on anything reconciled earlier in this
port. Wires it into tools/CMakeLists.txt behind GGML_CUDA, matching
the source.

Also wires the four remaining copied-but-unwired test files now that
their dependencies exist:
- test-kv-stream-bench-config.cpp (the bench tool's arg parser)
- test-kv-stream-cuda-buffer.cpp, test-kv-stream-cuda-attn.cpp,
  test-kv-stream-cuda-set-rows.cpp (CUDA runtime/kernel tests)

One test needed reconciling: "KV stream quant types are classified" in
test-kv-stream-cuda-set-rows.cpp iterates every quantized/f32/f16/bf16
ggml_type and asserts ggml_backend_cuda_kv_stream_get_type_capabilities
classifies all of them - true on the source's vanilla fork, but this
fork's GGML_TYPE_COUNT also includes 8 TurboQuant-specific types
(turbo2/3/4, tq3_1s/tq4_1s, q5/6/8_cr) that v1 deliberately leaves
unclassified (same graceful-fallback-to-UNSUPPORTED behavior as any
other type the capability table doesn't recognize). Excluded those 8
from the loop rather than changing the capability table itself.

All 7 kv-stream test binaries pass: 101 tests, 1373 assertions.

Assisted-by: Claude

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Ports benchmarks/ from RaymondHuang210129/llama.cpp-adaptive-kv-streaming
verbatim - benchmark_kv_stream.py (arena-size auto-probing sweep driving
llama-server), benchmark_kv_type_matrix.py, and their own unit tests
(pure argument-parsing/resume-signature logic, no GPU or model needed).
No reconciliation needed: they drive the CLI by flag name
(--kv-stream-arena-mib, --cache-type-k/v) and HTTP, none of which
differ from what's already wired up on this branch.

Verified: all four scripts byte-compile; the scripts' own test suites
pass (11 passed, 1 skipped).

This completes the full port planned in
/home/jabbatheduck/.claude/plans/lively-forging-platypus.md - core
plumbing, CUDA kernels, llama_context/llama_kv_cache wiring, the CLI
flag, and now benchmark tooling, all landed on
feature/kv-stream-phase-arena and verified end-to-end (KLD ~0 against
baseline on both a fallback-path model and the real streaming-kernel
target, scheduler split count fixed, dynamic phase switching observed
live).

Assisted-by: Claude

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Wires TurboQuant's turbo2/3/4 cache types into the F16-conversion
attention mode already used by every other non-direct-attention type:
they get real backing storage, a working online writer
(set_rows_cuda_turbo{2,3,4}), and a to_fp16 dequantizer, so no new CUDA
kernels are needed. Q is unconditionally pre-rotated at the graph level
whenever K/V is turbo, and turbo's dequantize deliberately doesn't
invert that rotation, so the F16 round-trip is correct as-is.

Also fixes two real gaps found while wiring this in:

- ggml_cuda_kv_stream_page_bytes()/workspace_bytes() sized rows off the
  raw model head_dim, but turbo storage zero-pads non-128-aligned head
  dims to the next multiple of 128 (llama-kv-cache.cpp). Streaming
  pages would have been undersized for any non-128-aligned-head-dim
  turbo model.
- ggml_cuda_kv_stream_staged_set_rows_range() gated purely on the
  online_write capability flag, which would have routed turbo writes
  into the true page-staging fast path relying on a dst_row_base
  offset that set_rows_cuda_turbo{2,3,4} doesn't support - silent data
  corruption rather than a crash. Turbo writes are now forced through
  the existing online_write mirror fallback instead.

Separately, found and fixed a pre-existing mismatch between the
streaming bootstrap pre-scan (llama-context.cpp) and the actual
llama_kv_cache construction: turbo's auto-asymmetric feature upgrades
K to q8_0 on high-GQA-ratio models, but the pre-scan sized its
bootstrap pool off the raw requested type, not the upgraded one. This
made the streaming runtime's internal page-count arithmetic
inconsistent and broke turbo streaming outright on any GQA>=6 model
with symmetric turbo K+V - the default case for this fork's actual
users. The auto-asymmetric decision is now a shared helper
(llama_kv_cache_resolve_stream_type_k) called from both places.

Verified: full kv-stream test suite and the broader test suite pass
(one unrelated pre-existing tokenizer-vocab test failure aside).
Functional smoke tests (turbo3/turbo3, mixed q8_0 K/turbo2 V) stream
without crashing. KLD comparison of turbo3/turbo3 streaming (2GB
arena, forcing real page eviction) against non-streaming at 4096
context: mean KLD 0.003, matching the near-zero bar already met for
standard types.

Assisted-by: Claude
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
kv_stream_unified_kv_cache was a pure architecture-name blacklist that
never checked hparams.is_swa_any(). Any non-blacklisted SWA
architecture (e.g. Gemma-style iSWA models, or this fork's own Laguna)
with --kv-stream-arena-mib set would build a real CUDA phase arena,
then hard-crash in kv_stream_switch_phase() once both its dynamic_casts
fail against llama_kv_cache_iswa ("failed to activate shared CUDA
arena prefill phase").

This turns that crash into the existing clean
llama_kv_stream_config_validate() rejection, whose error message
already (until now, falsely) claimed SWA dual-cache architectures were
excluded. Real streaming support for iSWA is tracked as a follow-up;
this exclusion should be narrowed again once that lands, or iSWA
models will get a clean rejection instead of real streaming.

Assisted-by: Claude
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Turbo already has a full native decode-inline CUDA attention kernel
(fattn-vec.cuh's VEC template, plus a fused MMA path in
fattn-mma-f16.cuh) used unconditionally by the ordinary non-streaming
FA dispatch - the streaming capability table just never marked it
direct_attention-eligible, so streamed turbo attention always paid for
a dequantize-to-F16 round trip even when a native kernel was available.

Wires this up (all gated behind GGML_CUDA_FA_ALL_QUANTS, which stays
off by default - documented in docs/build.md as also controlling this
path now):
- get_type_capabilities(): turbo2/3/4 now set direct_attention.
- kv_stream_resolve_native_partial_for_v<type_K>()/
  kv_stream_resolve_native_partial(): route to the real
  ggml_cuda_flash_attn_ext_vec_partial_case instantiations, which only
  exist for turbo paired with {f16, q8_0, turbo2/3/4} - every other
  pairing is compile-time excluded (if constexpr) to avoid taking the
  address of a template specialization nobody instantiated.
- get_attention_mode(): direct_attention is a per-type capability flag,
  but turbo isn't fully pairwise-connected to the original direct set
  the way F16/Q4_0/.../Q8_0 are with each other. Added an explicit
  pairwise check (kv_stream_direct_attention_pair_supported) so e.g.
  (Q4_0, TURBO2_0) - both individually direct_attention-eligible, but
  with no instantiated kernel for that specific pair - correctly falls
  back to ATTENTION_F16 instead of hitting the DIRECT-mode assert with
  a null kernel pointer.

Added a dedicated test for turbo's actual native pairs (self, f16,
q8_0) rather than folding it into the existing "all native CUDA KV
pairs" test, which assumes a fully-connected type set that turbo isn't
part of.

While extending the bounded-fallback test to also cover turbo paired
with non-companion types (Q4_0 etc., which correctly route to
ATTENTION_F16), found the addition triggers a false-positive mismatch:
the shared test harness's decode-row-update simulation writes a
rotation-agnostic row into the plain-F16 reference tensor while the
real turbo-typed cache correctly stays in the WHT-rotated domain,
desyncing the two. Confirmed via direct comparison that the underlying
CPU and GPU turbo dequantize implementations agree closely (~1e-4,
F16-rounding level) - this is a test-harness limitation, not a
streaming or dequantization bug, and not a configuration this fork's
design pairs turbo with in practice. Left out of the fallback test
with a comment explaining why, rather than papering over it.

Assisted-by: Claude
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
ggml_cuda_kv_stream_page_bytes()/workspace_bytes() unconditionally
reserved both a K and a V region, sized off head_dim_k/head_dim_v.
MLA-shaped layers (llama_kv_cache's has_v == false) have no separate V
cache tensor at all - V is a graph-time view/reconstruction over the
K/KV-latent tensor (see llama-graph.cpp) - so streaming would have
silently reserved a full, entirely unused V region for every MLA
layer, doubling the real page/pool size.

head_dim_v == 0 now means "no separate V storage, size off K alone"
instead of being treated as invalid input. The two call sites
(llama-context.cpp's pre-scan, llama-kv-cache.cpp's constructor) pass
0 for MLA layers (hparams.is_mla()) instead of the raw
n_embd_head_v(il) - no signature changes needed, head_dim_v was
already a plain uint32_t everywhere.

This is prep work for exercising the MLA/DSA streaming paths (DSA's
kv_mla sub-cache inherits the same K-only shape) - plain MLA
(DeepSeek-V2/V3-style) already isn't excluded from streaming today,
but was never actually geometry-correct until now.

Assisted-by: Claude
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…x dormant decode-phase switch

Adds llama_memory_i::get_kv_stream_targets() and
llama_memory_context_i::get_kv_stream_active_targets() - both default
to empty, so no unrelated memory type changes - as the uniform way to
ask "which sub-cache(s) of this memory object have a CUDA phase-arena
streaming runtime attached". Implemented for llama_kv_cache (itself,
once it has a runtime) and llama_memory_hybrid (forwards to its
attention sub-cache). This replaces the two-case
dynamic_cast<llama_memory_hybrid*>/dynamic_cast<llama_kv_cache*> in
kv_stream_switch_phase() with a single, extensible lookup - laying the
groundwork for iSWA/DSA/MSA/DSV4's sub-caches to report themselves the
same way, without hardcoding more per-architecture casts here.

Fixes a real, independently significant bug found while doing this:
process_ubatch() gated the entire per-ubatch kv_stream_switch_phase() +
kv_stream_adapt() call behind dynamic_cast<llama_memory_hybrid*> with
no fallback - unlike kv_stream_switch_phase() itself, which already
had one. For a plain (non-hybrid) llama_kv_cache - what every model
validated on this fork so far actually uses (Qwen3-8B, Qwen3.8-27B) -
this meant the phase arena switched into its prefill layout once at
construction and NEVER switched to the decode layout, and the
adaptive deadline-miss/copy-busy resident:ring feedback loop never ran
at all, for the entire lifetime of every context. Confirmed via
temporary instrumentation: before this fix, only the one
construction-time switch call ever fired; after, a second real switch
fires the first time a ubatch enters generation phase.

Re-ran the turbo3/turbo3 streaming-vs-non-streaming KLD comparison
after this fix (same methodology as the previous phase): mean KLD
dropped from 0.003064 to 0.000000 (max KLD 0.000059), combining the
benefit of this fix with the native-kernel wiring from the previous
commit. Decode now gets the resident-KV allocation the design actually
intended instead of running with the prefill layout for the whole
session.

Assisted-by: Claude
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
llama_kv_cache_iswa (Gemma-style models, and this fork's own Laguna
arch) holds two independent llama_kv_cache instances - kv_base
(non-SWA layers, full context length) and kv_swa (SWA layers,
window-bounded and therefore small by construction, unless swa_full
makes it full-length too). Neither constructor overload accepted the
streaming triplet at all, so streaming was structurally unreachable
through this class regardless of the architecture exclusion list.

Both constructors now accept (kv_stream_stage_bytes,
kv_stream_phase_arena, kv_stream_maximum_pool_bytes), defaulted to
(0, nullptr, 0). kv_base always receives them; kv_swa only receives
them when size_swa == size_base (the swa_full case) - otherwise it
stays always-resident, since streaming a cache that's already small by
construction buys nothing. get_kv_stream_targets()/
get_kv_stream_active_targets() (from the previous commit's
generalized interface) concatenate whichever of the two sub-caches
actually got a runtime - today that's just kv_base in the common case,
with the interface already correct for the swa_full N=2 case once the
CUDA arena's single-lease design is generalized (tracked separately,
not needed yet since no caller reaches swa_full+streaming today).

llama_memory_hybrid_iswa (recurrent+SWA-attention hybrids) gets the
same treatment, forwarding into its internal llama_kv_cache_iswa.
llama-model.cpp's default-branch iSWA call sites (the actual
Gemma/Laguna-shaped case, both the plain and GEMMA4_ASSISTANT-share
variants) and the hybrid-iswa call site now thread
params.kv_stream_* through.

With real streaming support landed, the SWA blanket exclusion added
earlier (to prevent the hard crash from an un-plumbed iSWA memory
type) is removed - kv_stream_switch_phase's target resolution now
fails closed with a clean error for any memory type that still isn't
wired up (e.g. llama_memory_hybrid_idx), rather than needing every
future case pre-emptively blacklisted here.

Verified end-to-end against a real local iSWA model
(/mnt/storage/models/laguna/xs/laguna-xs-2.1-nvfp4.gguf, arch=laguna,
confirmed via GGUF metadata to have real SWA layers and route through
this exact default-branch iSWA path): confirmed via temporary
instrumentation that streaming is genuinely enabled (not silently
falling back), ran to completion with no crash, and a
streaming-vs-non-streaming KL-divergence comparison shows mean KLD
~0.000000 (max 0.000041) and 99.99% same-top-token agreement -
streaming is numerically faithful. (The model's own baseline
perplexity is anomalously high on this build/quant independent of
streaming - flagged separately as unrelated to this work.)

Assisted-by: Claude
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…wasn't the cause, keep it excluded

Wires kv_csa (DSV4's compressed KV cache, the sub-cache that scales
with context length and is worth streaming - kv_raw's actual per-layer
attention runs entirely on its window-bounded SWA half, not its base
half, despite the base half's own class comment suggesting otherwise)
with the streaming triplet, following the same pattern as MLA/DSA's
K-only shape.

While validating this against a real local DeepSeek-V4-Flash model
(previous work in this area had no real model to test against), found
a large, real correctness regression: mean KLD 0.07 (max 4.3) between
streaming and non-streaming output, versus ~0.000000 for every other
architecture validated this way (turbo, iSWA). The initial hypothesis
was a bug in the K-only page-geometry special-case added earlier this
branch (ggml_cuda_kv_stream_page_bytes()'s head_dim_v==0 handling):
DSV4's compressed-cache attention (models/deepseek4.cpp, build_attn_mha
called with k_all passed as both K and V) always needs a real V-shaped
region in the streamed page, even though the persistent cache is
K-only and that geometry code was treating "K-only cache" as "no V
region needed anywhere" - a real, understandable-in-hindsight
confusion between "the cache doesn't persist V" and "the attention op
doesn't consume V".

Tested that hypothesis directly: reverted the head_dim_v==0
special-casing entirely (both in fattn.cu and its two call sites) and
re-ran the same KL-divergence comparison. Result was bit-identical
(0.071177 mean KLD, unchanged to 6 decimal places) - the geometry
special-case was NOT the cause. Kept the revert anyway, since it
removes an assumption now proven wrong without any demonstrated
benefit, and re-excluded LLM_ARCH_DEEPSEEK4 in the architecture
blacklist rather than shipping known-broken streaming for it.

The actual root cause is still unidentified - likely somewhere in
DSV4's online-compression bookkeeping (comp_plan's state_pos/
state_persist_*/state_snapshot_* row addressing) not surviving the
resident-cache/ring-buffer model, but this needs dedicated
investigation into that machinery, not more guessing. DSA's kv_mla and
plain MLA use the same K-only cache shape as kv_csa; since neither has
been empirically tested against a real model either, both should be
treated as similarly unverified rather than assumed safe by extension
of the (inspection-only) conclusion reached for MLA earlier.

The kv_csa constructor plumbing is left in place (harmless, currently
unreachable dead code via the restored exclusion) so whoever
root-causes this doesn't have to redo it - see the comments in
llama-context.cpp and llama-kv-cache-dsv4.cpp for the full context.

Assisted-by: Claude
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…check

The pre-scan that sizes the streaming bootstrap pool scanned every
attention layer indiscriminately and required them all to share
identical page geometry. For iSWA-shaped models this is too strict:
kv_swa's layers don't get a streaming runtime at all (unless swa_full),
so their geometry is irrelevant to what's actually being sized - and
some real models (Gemma-family) have genuinely different per-layer
dimensions between SWA and full-attention layers (separate
key_length/key_length_swa in the GGUF), which made the pre-scan reject
them outright with "block KV streaming requires uniform
attention-layer geometry" even though the layers that actually stream
(kv_base's) are perfectly uniform among themselves.

Now skips SWA layers here the same way it already skips recurrent
ones, unless swa_full is set (in which case kv_swa becomes
full-context-length too and does get a runtime - see
llama-kv-cache-iswa.cpp).

Verified against a real local Gemma-family model
(/mnt/storage/models/gemma/Gemma4-26B-A4B-QAT-Uncensored-HauhauCS-Balanced-Q4_K_M.gguf,
arch=gemma4, confirmed via GGUF metadata to have distinct
key_length/key_length_swa and value_length/value_length_swa - exactly
the non-uniform-geometry case this fixes): previously failed to load
with streaming enabled at all; now loads cleanly, streaming genuinely
active (confirmed via temporary instrumentation), and a
streaming-vs-non-streaming KL-divergence comparison shows mean KLD
0.000000 (max 0.00006) and 99.99% same-top-token agreement. Re-verified
Laguna (iSWA, previously-validated) and Qwen3.8-27B (turbo, plain
cache) are unaffected.

Assisted-by: Claude
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…h streaming

For an iSWA-shaped cache, --swa-full makes kv_swa full-context-length
too, so both kv_base and kv_swa attach a streaming runtime to the same
single-lease CUDA phase arena. Previously this was silently accepted
by validation and only failed deep in cache construction, whichever
sub-cache lost the race to bind the arena, with a generic
"failed to create CUDA block KV streaming runtime" error that gives no
hint --swa-full is the actual cause.

Verified empirically (Laguna, --swa-full + --kv-stream-arena-mib): the
failure is a clean, caught exception at context construction, not a
crash - so this was never unsafe, just confusing. Adds
swa_full_conflict to llama_kv_stream_config so this now fails with a
clear, specific reason at the same validation layer as every other
known-incompatible combination (SWA architecture exclusion, MTP/draft
contexts, non-unified cache, etc).

Assisted-by: Claude
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Covers arena-size propagation, the legacy --kv-stream-stage-mib alias,
and negative-value rejection - gaps flagged in review against the
reference implementation, which had these in its own test suite.
"Disabling streaming for draft contexts" (the other gap flagged) isn't
testable at this pure CLI-parsing layer - it's speculative.cpp's
runtime zeroing of kv_stream_arena_mib for the draft context
(common/speculative.cpp:2805), which needs a live model to exercise;
already verified manually this session (draft acceptance stats confirm
MTP keeps working with streaming enabled on the target context).

Assisted-by: Claude
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Previously streaming was documented only in benchmark material and one
build-flag note - no single place explained arena semantics,
requirements, supported architectures, or known limitations. Consolidates
everything found empirically this session: why -np 1 is a real design
constraint (not just an unvalidated gate), the two distinct VRAM-boundary
failure modes (clean startup failure vs a hard process abort mid-request),
which architectures actually work vs are excluded and why, and the
--swa-full conflict fixed in the previous commit. Cross-linked from
build.md and benchmarks/README.md.

Assisted-by: Claude
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… alone

Reverts the cross-link added to benchmarks/README.md in the previous
commit - keep that file scoped to the benchmark tooling only.
docs/kv-stream.md (linked from build.md) is the place for the feature's
semantics, requirements, and limitations.

Assisted-by: Claude
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
A no_alloc llama_context (e.g. common_get_device_memory_data_with_parent's
dry-run pass to size the parent model for MTP) never allocates real backend
buffers, so llama_kv_cache's streaming-attach loop already skips attaching a
runtime to it (see the !hparams.no_alloc guard in llama-kv-cache.cpp). The
context-level phase-arena construction had no matching guard, so it built a
real CUDA arena anyway, then kv_stream_switch_phase found no cache attached
to it and failed with a confusing "requires a memory type with a streamable
KV cache" error on every MTP+streaming startup. The failure was caught and
logged as a warning ("[spec] failed to measure MTP context memory") and the
real target context loaded fine moments later, but the error looked fatal.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Measured: llama_set_decode_phase() is never called from the server, so
speculative-decoding verification batches (multi-token) always classify as
"prompt" phase instead of "generation" - the arena stays in its
compute-heavy/KV-light layout almost the entire time MTP is active. Real
but modest at 22K context (+2.2% resident pages, +20% ring slots vs the
true generation layout). Documented rather than fixed: a real fix needs to
resize the generation-phase compute reservation for multi-token
speculative batches, not just relax the existing TG1-only guard.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
giveen and others added 9 commits September 6, 2026 09:43
…ation

The exact decode-token-count check had never been exercised against
--spec-type draft-mtp before. MTP verifies up to spec_chain+1 tokens per
step, and the benchmark's exact prompt/decode split leaves no slack past
context_capacity, so the last verification chunk near that boundary can
land a few tokens short of the target instead of exactly on it. decode_tps
is the server's own measured rate (actual predicted_n / predicted_ms), so
it's correct either way - only the strict completeness check needed a
tolerance derived from --spec-chain.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
benchmark_kv_stream.py measures throughput, which requires a full
prefill+decode pass per point. Memory footprint doesn't need that - the
KV/compute buffers are sized at context construction, not grown lazily -
so this sweeps context length with just start/measure/stop per point,
cheap enough to run a same-context no-streaming baseline alongside the
streaming measurement at every point, including past where the baseline
OOMs. When the baseline leg fails, it's recorded (not treated as fatal),
the line stops there, and the plot marks the failure point with a labeled
vertical line instead of silently truncating.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Backing data for the block KV streaming PR: benchmark charts, results.csv/
.jsonl from every throughput and memory sweep, and raw llama-perplexity
output for every PPL/KL-divergence check referenced in the PR description.
Excludes the multi-GB saved-logits .kld files (regenerable via
--save-all-logits) and bulk per-point server logs other than the one OOM
backtrace that's directly cited as evidence.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Force-added: these are curated evidence for the PR description (raw
llama-perplexity output for every correctness check, and the one CUDA OOM
backtrace cited as evidence), not routine run logs - *.log is gitignored
repo-wide for the latter, which doesn't apply to these.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…/MUSA

fattn.cu's KV-stream transfer ring (pinned ready-flags/deadline-counter
buffers and the async CUDA-event completion poll) uses five CUDA runtime
symbols that were never mapped to their HIP/MUSA equivalents:
cudaEventQuery, cudaEventElapsedTime, cudaErrorNotReady,
cudaHostGetDevicePointer, and cudaHostAllocMapped - breaking both the hip
and musa CI builds (8 errors each, identical symbol set, same lines).
Also added cudaHostAllocPortable/WriteCombined proactively since
ggml-cuda.cu and allreduce.cu use them too and the build never got that
far to reveal whether they'd fail next.

Followed the existing line-for-line #define pattern in both vendor
headers exactly; not able to compile-test against a real ROCm/MUSA
toolchain locally, so this should be confirmed by CI rather than treated
as verified.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The previous commit fixed cudaHostAllocMapped/cudaHostGetDevicePointer/
cudaEventQuery/cudaEventElapsedTime/cudaErrorNotReady, which unmasked a
6th error CI hadn't shown yet: cudaHostAlloc itself was never mapped
either. The compiler's original 8-error report on both hip and musa
apparently suppressed this cascading diagnostic since it's used in the
same expression as cudaHostAllocMapped; fixing that symbol let the
compiler get far enough to report this one. Both hip and musa suggested
the exact fix directly in their error output (did you mean 'hipHostAlloc'
/ 'musaHostAlloc').

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Two more genuine issues from this branch's diff (confirmed via
git diff against the merge-base - the sibling cudaSetDeviceFlags/
cudaDeviceScheduleSpin/cudaGraphExecUpdateResultInfo/cudaDevAttrCanUseHost
errors CI also reported are pre-existing on the base branch, unrelated to
this PR, and left alone):

- cudaHostGetFlags (ggml_backend_cuda_kv_stream_host_is_write_combined,
  part of the kv-stream transfer ring) was never mapped - same class of
  gap as the previous two fixes. Mapped to hip/musaHostGetFlags.
- The GGML_CUDA_ENABLE_UNIFIED_MEMORY placement-hint code
  (ggml_backend_cuda_buffer_set_preferred_device/host) uses CUDA 12's
  location-struct cudaMemAdvise signature, which has no HIP/MUSA
  equivalent (the base branch's prior version of this code used the
  older device-int signature via a HIP-specific advice value). Rather
  than guess at each platform's older cudaMemAdvise shape without a way
  to compile-test it, gated the whole block to real CUDA only and made
  it a clean no-op elsewhere - this is an opt-in hint behind an env var,
  so skipping it on HIP/MUSA changes nothing by default.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
llama_kv_cache_context::get_kv_stream_active_targets() built
{ kv, n_kv } where n_kv is int32_t but llama_kv_stream_active_target's
second field is uint32_t - a silent narrowing conversion GCC only warns
about but clang-cl (Windows CI) treats as a hard error
(-Wc++11-narrowing), failing every windows build variant. Explicit cast.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
test-kv-stream-plan/-config/-softmax call internal llama_kv_stream_*
functions that have no LLAMA_API export. Linking these tests only
against the llama target works when llama is a static archive, but
fails on a shared llama.dll build (e.g. Windows arm64 CI) because the
import library exposes only exported symbols.

Compile each test's corresponding src/ source directly into the test
executable, the same pattern already used by test-kv-stream-bench-config.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@giveen
giveen force-pushed the feature/kv-stream-phase-arena branch from c4a5ffe to 2f7892d Compare September 6, 2026 16:19
giveen and others added 18 commits September 6, 2026 10:25
…xed KV types

The streamed page pool sizes its pages once from the cache-level
type_k/type_v and assigns the same buffer type to every layer, but
TURBO_LAYER_ADAPTIVE (modes 1/2/5/6/7) can give individual layers a
different K and/or V precision - including auto-enabling itself with
no env var at all whenever -ctv turbo2 is used on an 8+ layer model.
That combination silently packed differently-shaped rows into pages
sized for a different layer's type.

Extract the mode-resolution and per-layer type-override logic already
in llama_kv_cache's constructor into shared free functions
(llama_kv_cache_turbo_layer_adaptive_mode/_type_k/_type_v), the same
pattern already used for llama_kv_cache_resolve_stream_type_k. Use
them from llama-context.cpp's block KV streaming pre-scan to detect,
before ever touching the CUDA runtime, whether any layer would
actually diverge from the cache's base type and refuse with a message
naming the active mode.

Per-layer heterogeneous page geometry is real future work; refusing is
the correct scope for now since the pool has no support for it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
test-kv-stream-cuda-buffer allocated on device 0 unconditionally, so a
CUDA build with no GPU (e.g. a GPU-less CI runner) went red instead of
skipping. Add a device-count probe at startup that exits 77, the
SKIP_RETURN_CODE convention already used by test-moe-cache.

test-kv-stream-cuda-attn had two problems on a default build (no
GGML_CUDA_FA_ALL_QUANTS):

- "turbo direct-attention pairs" hard-asserted
  GGML_BACKEND_CUDA_KV_STREAM_ATTENTION_DIRECT, but direct_attention
  kernels aren't instantiated at all without that flag, so
  get_attention_mode legitimately returns ATTENTION_F16 instead.
- "all native CUDA KV pairs" tests the full F16/BF16/Q4_0/Q4_1/Q5_0/
  Q5_1/Q8_0 cross product, which is upstream's original
  direct_attention type set. Without GGML_CUDA_FA_ALL_QUANTS, plain
  (non-streamed) CUDA flash attention itself refuses most mismatched
  pairs in that set (see ggml_cuda_get_best_fattn_kernel's mixed-type
  allowlist), so this test cannot even produce a reference result -
  unrelated to kv-streaming. Skip it cleanly on such builds.

Both needed to know the actual runtime capability of the linked
libggml-cuda, not the test binary's own preprocessor state:
GGML_CUDA_FA_ALL_QUANTS is a private compile definition of the
ggml-cuda target and is never propagated to test executables, so an
#ifdef in the test file reflects nothing about how the library was
actually built (confirmed by a real regression while iterating on this
fix: the #ifdef silently always took the "off" branch even in this
repo's own FA_ALL_QUANTS=ON build). Query the backend's runtime
feature list via ggml_backend_get_features instead.

Verified against two local builds (GGML_CUDA_FA_ALL_QUANTS on and off):
all kv-stream tests pass on both.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…lt builds

Most of this file's streaming-mechanics tests (paging, residency, decode,
SET_ROWS, ring layout) default to a q8_0 K / q4_0 V pair via make_inputs()
- a real KV pair for exercising the streaming/paging code, chosen without
regard for GGML_CUDA_FA_ALL_QUANTS. Without that flag this surfaced two
distinct failures, both pre-existing and unrelated to kv-streaming itself:

1. Plain (non-streamed) CUDA flash attention refuses the mismatched pair
   outright (ggml_cuda_get_best_fattn_kernel's mixed-K/V-type allowlist),
   so a test's *reference* computation via the default buffer type could
   not even run - it never reached the streamed code under test. Fixed by
   computing the reference through an F16-domain conversion (the same
   make_f16_reference pattern already used by the bounded-fallback tests)
   whenever the direct pair isn't supported, via new reference_inputs/
   reference_layers/run_attention_layers_reference helpers. The streamed
   side is untouched - its own dispatch was never gated by this.

2. get_attention_mode(q8_0, q4_0) resolves to ATTENTION_F16 without the
   flag (ATTENTION_DIRECT requires it), and ATTENTION_F16 needs a
   conversion workspace that most of this file's runtime params{} blocks
   never allocated, since it's a no-op under the DIRECT path this repo's
   own build normally uses. Fixed by sizing params.conversion_bytes from
   the real dispatch decision (kv_stream_conversion_bytes) at every
   runtime construction site, and folding that size into any explicit
   params.pool_bytes so hand-tuned resident/ring page-count math is
   unaffected (mirrors the existing resident_params.pool_bytes pattern).

A further class of test asserts a DIRECT-attention-specific guarantee
outright - bit-exact equality with the plain reference, or MMA partial-
attention span usage - which cannot hold under the F16 fallback path at
all (direct_attention kernels aren't compiled in without the flag, for
any type pair). These 13 tests now skip cleanly with
backend_has_fa_all_quants() rather than asserting an impossible
equivalence, the same "guard the test" remedy already applied to the
all-native-KV-pairs cross-product test.

Verified against two local builds (GGML_CUDA_FA_ALL_QUANTS on and off):
0 failures on both, and the FA_ALL_QUANTS=ON build's assertion count is
unchanged (712/712) - none of this touches what that build already
exercised.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…QUANTS

Every benchmark number in the PR description and benchmarks/results/ was
measured with ggml-cuda built GGML_CUDA_FA_ALL_QUANTS=ON. Without that
flag (the default), direct_attention kernels aren't instantiated at all
for the streamed path, so every streamed KV type pair falls back to an
F16-dequant-to-workspace path regardless of arena size or model - a real,
measured throughput difference (~2840 -> ~1200 t/s streamed prefill,
Qwen3.8-27B-AD -ctk q8_0 -ctv turbo4, 8K context), not a rare edge case.

Ordinary (non-streamed) attention is unaffected by this flag either way,
since the turbo-native kernel it always uses is unconditionally compiled
in - confirmed by measuring both builds' non-streamed prefill (~2730 vs
~2738 t/s, same within noise).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
ggml_cuda_graph_update_required scanned every node and every src of every
node (up to GGML_MAX_SRC) for a live KV streaming runtime on every graph
compute - including every decode step - before the cheap cgraph->uid
early-out that lets CUDA graph replay skip almost everything. Paid by
every user regardless of whether streaming is in use at all.

Add a process-wide live-runtime counter (incremented/decremented exactly
at real construction/destruction, not at reference-count retain/release)
and skip the scan entirely when it's zero. Sound because a live tensor
can only reference a runtime through its buffer, which itself holds a
reference to that runtime - so once the global count is zero, no live
tensor anywhere can reference one, and the scan would find generation 0
regardless.

set-rows.cu's k_set_rows/k_set_rows_quant kernels gained a dst_row_base
kernel argument and a per-thread subtraction on every SET_ROWS launch,
including the ordinary (non-streamed) path, once KV streaming's resident
cache needed to write into a row-offset staging buffer
(ggml_cuda_op_set_rows_staged). Templated it as a compile-time DstRowBase
flag instead of a runtime value: the ordinary path instantiates false (no
subtraction emitted at all) and the staged-write path instantiates true.
Initially removed the parameter from the quantized kernel entirely, on
the assumption only the plain-cast kernel needed it - test-kv-stream-cuda-
attn's "resident staged writes support every exposed KV-cache format"
caught the real behavior: ggml_cuda_op_set_rows_staged's `staged` tensor
mirrors dst->type, so a resident cache backed by a quantized KV type (as
tested for q4_0/q4_1/q5_0/q5_1/q8_0/iq4_nl) hits the quantized kernel with
a nonzero row base too - restored the same DstRowBase treatment there.

Verified: full kv-stream test suite (7/7, including CUDA), plus real
server smoke tests for both streaming and non-streaming decode (multi-
step CUDA graph reuse, multi-turn SET_ROWS) with coherent output.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
ggml_cuda_graph_evaluate_and_capture's debug-only node/src buffer-type
assert originally compared node->buffer->buft against
ggml_backend_cuda_buffer_type(cuda_ctx->device) directly - a genuine
device-identity check that also catches a node scheduled against the
wrong GPU in a multi-GPU setup. Adding the kv-stream buffer-type family
replaced that with ggml_backend_buft_is_cuda(...)-style checks, which
only confirm a buft belongs to *some* CUDA device's family, silently
dropping the identity check. Restore it for both buffer families (kv-
stream buffer types carry a device field the same way ordinary CUDA ones
do). Verified assert-free in a real Debug build (assert() active) for
both a streaming and a non-streaming decode.

llama_kv_cache::set_input_k_idxs wrapped the dirty-row-marking call
(mark_dirty_rows_fn, which has a real side effect) directly inside
GGML_ASSERT. GGML_ASSERT is not compiled out in this codebase (unlike
plain assert under NDEBUG), so this was not an active bug, but the
pattern reads as one and invites a future refactor to break it silently.
Split the call from the assert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…esults

kv_stream_normalize_chunk_results divided every row's chunked-attention
accumulator by its softmax normalizer unconditionally. A row whose mask
is entirely -inf (a padding row that rounds the launch grid out to a
uniform block/row count, never written back to the real output tensor)
has a normalizer of exactly zero there, producing a silent NaN/Inf.

The CPU reference model of this same math (llama-kv-stream-softmax.cpp)
already treats a non-positive normalizer as an explicit invalid-input
case rather than dividing through it; this kernel had no equivalent
guard. Clamp to zero output instead, so a padding row's otherwise-unused
result stays harmless if it were ever read by mistake.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
It was inserted between type_v and the pre-existing moe_cache_mode/
moe_cache_budget_mib/abort_callback/.../ctx_other fields, shifting all
of their offsets for anyone who built against a pre-streaming header -
a real ABI break for prebuilt consumers of this public struct. Move it
to the end instead, after ctx_other, so every pre-existing field keeps
its offset.

llama_context_default_params() builds this struct with positional
(not designated) initializers, so its own initializer list had to move
in lockstep or every field after the old position would have silently
picked up the wrong value. Every other reference to kv_stream_arena_mib
in the tree already uses named-field access, which is order-independent.

Verified: full rebuild, test-arg-parser (asserts on parsed
kv_stream_arena_mib values) and the full kv-stream suite pass; a real
server smoke test with streaming enabled produces coherent output.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
It's a CPU model of the CUDA FlashAttention fixup kernel's block-merge
math, used only by test-kv-stream-softmax.cpp to validate that math in
isolation - no production code calls llama_kv_stream_softmax_merge.
Compiling it into libllama bloated the public library with test-only
code for no reason.

Move both files under tests/ and compile the .cpp directly into the
test target (the same pattern test-kv-stream-plan/-config already use),
dropping the now-unnecessary src/ include-directory override - a
quoted #include already resolves from the including file's own
directory.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
llama_kv_cache_dsv4::get_kv_stream_targets() and llama_kv_cache_dsv4_
context::get_kv_stream_active_targets() existed only to expose kv_csa
as a potential streaming target, from the DSV4 streaming attempt that
was reverted after finding a real, un-root-caused correctness
regression (see llama-context.cpp's DEEPSEEK4 exclusion comment).
Since that exclusion means llama-context.cpp never attaches a runtime
to any DSV4 sub-cache, both overrides could only ever return an empty
vector - identical to just falling through to the base interfaces'
own `{ return {}; }` defaults (llama-memory.h), which is what happens
now that they're gone.

kv_csa itself and everything else in this file (seq_rm/seq_cp, state
I/O, memory_breakdown, clear) is real production code untouched by
this change - DSV4 streaming stays excluded, nothing else about how
the model runs changes.

Verified against the real local DeepSeek-V4-Flash model: streaming
still refuses construction with the same "requires a standard unified
KV cache" error when --kv-stream-arena-mib is set, and ordinary
(non-streaming) generation still produces coherent output.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ming

GGML_CUDA_PREFER_MODEL_WEIGHTS/GGML_CUDA_PREFER_KV_HOST (plus the
GGML_CUDA_ENABLE_UNIFIED_MEMORY master gate and GGML_CUDA_KV_ACCESSED_BY_GPU)
came from upstream commit 04cc69d ("baseline: mirror production CUDA
unified-memory build", authored by Raymond Huang) - carried over when
this branch ported his fork wholesale, not something added for block KV
streaming. They only ever acted when streaming is off, have no docs or
tests of their own, and are a genuinely separate concern (CUDA UVM
placement hints for model weights/KV buffers) from what this PR does.
Drop them from this PR; they can come back as their own tracked change
against the base branch if still wanted.

Removes ggml_backend_cuda_buffer_set_preferred_device/_host from
ggml-cuda.cu (both call sites in llama-kv-cache.cpp/llama-model.cpp,
and the proc-address registrations), and the benchmark script's
matching env-var stripping/test coverage for them - kept the
unrelated GGML_CUDA_KV_STREAM_FIXED_RING_SLOTS/LLAMA_KV_STREAM_TRACE
stripping in clean_server_env, which are real kv-stream knobs.

Verified: full rebuild, kv-stream test suite (7/7), the benchmark
script's own unit tests (12/12), and a real streaming server smoke
test all pass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
context / 1024 mislabels the axis for round decimal context sizes -
700000 tokens plotted as ~683.6 "Ki" reads as a strange, hard-to-place
number. Plot in decimal K (context / 1000) instead, so 700000 shows up
as an unambiguous 700, with a footnote spelling out the convention so
a reader never mistakes the axis for raw token counts or Ki-scaled
values.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
streamed attention kernel's transient workspace

docs/kv-stream.md already documented this as the one unsafe failure
mode: an arena that constructs fine can still hit a hard, unrecoverable
GGML_ASSERT/SIGABRT the first time fattn.cu's
ggml_cuda_flash_attn_ext_streamed needs its chunked partial-attention
reduction workspace (four small buffers: parts/meta/accumulator/
accumulator_meta). That workspace draws from the device's general CUDA
memory pool (ctx.pool()), not from the arena itself, so a well-sized
arena and a tight VRAM margin can still combine into a mid-request
abort that takes down an already-serving process.

Add ggml_cuda_kv_stream_transient_workspace_bytes(), which computes the
worst case those four allocations can reach from the model's query
head count/head_dim_v and --ubatch-size (bounding both the non-MMA
chunked path, capped at 16*256 workspace rows regardless of batch size,
and the MMA-prefill path, which scales with the real ubatch token
count but uses only one partial). llama_context construction now calls
it once per stream device, queries current free VRAM via the existing
ggml_backend_dev_memory(), and refuses construction with a clear,
actionable error if reserving the arena wouldn't leave that much
headroom - the same clean, caught failure category as an oversized
arena, instead of ever reaching the unsafe one.

This uses free VRAM as measured at that point in construction; later
allocations (e.g. the compute buffer) aren't yet accounted for, so it
catches the clearly-too-tight case, not a formal guarantee - documented
as such in docs/kv-stream.md.

Verified against the real local Qwen3.8-27B-AD model: a normal arena
(512 MiB) still constructs and generates correctly; a genuinely tiny
arena (8 MiB) still hits the pre-existing "too small for bootstrap"
rejection unchanged; an arena sized to leave ~100 MiB of headroom now
hits this new check with an accurate required-headroom estimate in the
error message, cleanly, before ever touching the CUDA runtime.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
state_write_data/state_read_data (and their _meta counterparts) read
and write each layer's K/V tensor via a flat byte offset into
cell-position order (io.write_tensor(k, range.first*k_size_row,
buf_size)). That's only correct when the tensor's own buffer actually
holds every cell contiguously in that order, which is true for an
ordinary KV buffer but not for a block-streamed one: only a resident
subset of pages lives in the buffer kv_stream_adapt() last synced, and
the authoritative copy of the rest lives in host RAM behind the
streaming runtime. Saving state this way would silently write whatever
happened to be resident, not the real KV content, and produce a state
file that looks valid and isn't - untested and unrefused before this.

Refuse cleanly in state_write/state_read_sinfo when kv_stream_runtime
is active, matching how streaming already refuses other unsupported
combinations (single-sequence, --swa-full) rather than support+test a
combination nothing in this branch validates. seq_rm/seq_cp are left
untouched - they're pure cell-metadata bookkeeping that doesn't touch
K/V tensor content and are core to how the server manages context
(e.g. n_discard) even under streaming, so refusing them would break
normal operation for a much smaller and more speculative risk than the
state save/load path's demonstrated one.

Verified against the real local Qwen3.8-27B-AD model via the server's
/slots/{id}?action=save endpoint: with streaming on, the save now fails
cleanly with this error and the server keeps serving; with streaming
off, the same endpoint still saves a full, real state file (157 MB)
exactly as before. test-save-load-state also still passes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
ggml_cuda_flash_attn_ext_streamed_supported (fattn.cu) requires
Q->ne[0] == V->ne[0] == 256 for either direct_attention or the F16
fallback to activate at all - unlike MLA/DSA/DSV4/MSA, this isn't an
architecture-level exclusion, so a head-dim-128 model (common outside
this fork's own turbo/Qwen3.8 testing) passes every other requirement,
gets its KV cache pinned into the streaming buffer, and streams pages,
but every attention op for it silently falls through to ordinary
(non-streamed) Flash Attention - correct (reads K/V from the pinned
host buffer over PCIe) but pays that transfer every decode step with
none of streaming's residency/prefetch benefit. Not documented before
this.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Two intentional design choices mean streamed output is close to
non-streamed, not exactly equal, and can even vary run-to-run:

- The chunked reduction's sequential online-softmax rescale sums the
  same terms in a different order than ordinary Flash Attention's
  single combined pass (flash_attn_combine_results) - floating-point
  addition isn't associative, so this is a real (tiny) numerical
  difference, already reflected in every correctness check this PR
  ran (mean KLD ~0.000000, 99%+ same-top-token, not exact equality).
- The span tuner picks between the coalesced and bounded-span streamed
  kernels by comparing wall-clock timing across trial runs, so which
  one wins - and therefore the exact numerical result within that
  tolerance - depends on transient timing noise and can differ between
  runs of the same request.

Neither was previously stated in the docs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The 5 benchmark charts (~788 KB) were committed only so the PR
description could embed them via raw.githubusercontent.com links -
binary chart images checked into the repo just to back a PR body,
flagged in review as something that belongs elsewhere. Removed the
images entirely and replaced every chart reference in the PR
description with a complete data table (all sweep points, not just a
handful) built directly from the already-committed results.csv files:

- Qwen3.8-27B throughput: 5 -> 17 rows (the full 8K-262K sweep).
- Qwen3.8-27B + MTP throughput: 3 -> 5 rows (the full 8K-106K sweep).
- Gemma4-It throughput: already had all 6 rows, image removed.
- Qwen3.8-27B VRAM/RSS sweep: already had all 6 rows (corrected the
  post-OOM rows from "-" to "not attempted (already OOM'd at 393K)" -
  the sweep tool didn't retry the crashed baseline leg, it skipped it),
  image removed.
- Gemma4-It VRAM/RSS sweep: had no table at all before, only a chart
  link - added the full 6-row table.

The PR description on GitHub is updated to match (no more
raw.githubusercontent.com image links; every number now traces
directly to results.csv under kv-stream-pr-evidence/, still committed
and referenced from the PR body).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…b test

The new common_params kv_stream_params/kv_stream_alias_params/
kv_stream_disabled_params instances were relying on LLAMA_ARG_MODEL
being leaked into the process environment by an earlier test (set at
line 352, never unset) to satisfy the "--model is required" check.
That earlier block is wrapped in #ifdef _WIN32 / skip / #else, so on
Windows it never runs and the env var is never set, causing the
kv-stream parse calls to fail validation for reasons unrelated to
--kv-stream-arena-mib itself. Give the success-path test cases their
own -m flag instead of depending on leaked env state.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@giveen
giveen requested a review from TheTom September 6, 2026 20:08
@tmark00

tmark00 commented Sep 7, 2026

Copy link
Copy Markdown

Tried this on a 24 GB laptop card, which is roughly the situation the feature targets. The VRAM
saving is real, but prefill becomes quadratic in prompt length with the arena on, and — the part I
cannot explain — a larger arena makes it dramatically worse.

Setup: RTX 5090 Laptop (sm_120), driver 616.56, Windows 11, MSVC 14.51, branch at c8c35c39a,
unsloth/Qwen3.8-27B-GGUF Q5_K_M (qwen35, hybrid GDN, 24 of 65 layers hold KV), -c 131072,
-fa on, -np 1, -b 2048 -ub 512, q8_0/turbo3 KV, MTP adaptive drafting, -fit off. Single
38503-token prompt, 300 generated tokens, GPU idle (VRAM 0) before every run.

Totals

config prefill decode VRAM
arena off 1163 t/s 49.0 t/s 23480 MB
--kv-stream-arena-mib 1024 156.6 t/s 15.5 t/s 21116 MB
--kv-stream-arena-mib 2048 155.6 t/s 18.7 t/s 22291 MB
--kv-stream-arena-mib 4096 did not finish

Off-arena figures are the mean of three clean runs (1167.95/1160.78/1160.50 prefill,
49.21/48.91/48.83 decode, VRAM identical at 23480 MB every time); arena-1024 likewise
(158.43/155.80/155.53, 15.50/15.67/15.42).

Prefill is quadratic with the arena on

Cumulative rate from the server's own prompt processing lines:

n_tokens off arena 1024 arena 2048 arena 4096
4138 1319 700 664
8234 1344 513 486
12330 1340 401 380 51.6
18474 1335 301 286 36.1
26666 213 25.7
34858 20.0

Off-arena stays flat at ~1340 t/s, i.e. linear prefill. With the arena the cumulative rate falls
monotonically. Per 2048-token chunk, arena 2048 goes 663 → 430 → 344 → 286 → 245 → 219 → 189 → 170
→ 154 → 141 → 130 → 121 t/s: chunk time grows linearly with position, so total prefill is O(n²).
Off-arena chunk time is constant (1.48–1.58 s per chunk throughout).

A bigger arena is much slower

This is the part that looks wrong rather than merely expensive. At the same 12330-token mark:

  • --kv-stream-arena-mib 2048: 32.45 s
  • --kv-stream-arena-mib 4096: 239.20 s

7.4x slower with twice the resident capacity. The 4096 run was still at 91% of the prompt
(34858/38503) after 29 minutes and I aborted it at the 1800 s client timeout. If more resident
pages mean more per-chunk work, that inverts the intended trade-off, and it is what makes the
feature hard to tune from outside: 1024 and 2048 behave nearly identically, then 4096 falls off a
cliff.

For completeness, the arena has a lower bound too — -c 262144 with --kv-stream-arena-mib 1024
refuses at startup with prefill phase does not fit shared CUDA arena: arena has no KV capacity after reserving compute space, and needs 2048 to start. So the usable window here is narrow.

Note on the baseline

On this model -c 262144 already loads without the arena (24049 MB of 24463), so I could not use
"fits at all" as the test. The KV is small because only 24 of 65 layers have a cache and turbo3 V
is compact — the ceiling here is model weights, not KV. That may be why my numbers look different
from a dense-model baseline.

What I did not check

Whether output stays correct under streaming — I compared throughput only. Also single arena
sizes, one prompt length, and one model; no sweep over -ub, which plausibly interacts with the
compute reservation inside the arena.

🤖 Generated with Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CUDA documentation Improvements or additions to documentation examples ggml testing

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants