opencoti-llamafile / docs /features /rolling_kv_step_prefetch.md
ManniX-ITA's picture
Upload folder using huggingface_hub
9cee049 verified
|
Raw
History Blame
11.4 kB

Rolling-KV: step/graph-level tail prefetch (design)

Status: DESIGN (2026-07-01). Supersedes the op-level double-buffer's overlap ambition. Gates on user go-ahead β€” this is an M7-scale CUDA effort (full host rebuild + dual-DSO restamp + logit-equiv/RULER-niah gate).

Problem (measured, 3090)

POSITION_WINDOW streams the host KV tail over PCIe every decode step, and the tail DMA is 100% un-hidden β†’ decode collapses on a smooth hyperbola at the raw PCIe rate (6.4 GB/s, +0.156 ms/MiB). Confirmed on both model classes:

model attn streaming layers overflow decode note
Gemma-4-A4B-128e iSWA ~5 global 71β†’19.5 tps @236 MiB tail graceful hyperbola
Qwen2.5-14B-1M full 48 26.9β†’0.86 tps @~1.2 GB tail ~10Γ— worse (48 streaming layers)

Qwen is not CPU_SPILL and not ineligible β€” it engages POSITION_WINDOW (GPU 60–84% busy, CPU ~2.7 cores during overflow decode). It's slow purely because full-attention streams a tail on every layer.

Root cause (code)

fattn.cu streaming forward runs a per-op, 1-tile-ahead ping-pong: lift(t+1) on copy_stream while tile t computes on the main stream. At decode (n_q=1) one attention op's compute is a sub-ms VRAM read; its tail tile DMA is ~30Γ— that. So each tile's DMA can only hide behind the previous tile's tiny compute β†’ DMA-bound from MiB 1.

The specific barrier that prevents cross-layer overlap is the per-op WAR resync at fattn.cu:1379:

CUDA_CHECK(cudaEventRecord(cs_sync, stream));       // record ALL prior compute
CUDA_CHECK(cudaStreamWaitEvent(cs, cs_sync, 0));    // copy_stream waits on it

This orders copy_stream after all prior compute-stream work at every op entry (needed because the slot pool is re-handed per op β†’ WAR hazard). It means the copy stream can never run ahead of the current attention op β€” so layer L+1's tail cannot begin loading during layer L's FFN. The recovery budget is therefore one op's compute (0.1 ms β†’ ~0.7 MiB), not the whole step's (14 ms β†’ ~90 MiB Gemma).

The fix: a persistent, step-spanning tail-prefetch ring

Flatten the per-layer tile loop into one tile stream across the whole step's attention layers, kept B tiles ahead on a dedicated copy stream, hiding tail DMA behind the sum of all intervening compute (attention + FFN of the layers in between), not one op's.

Components:

  1. Shared device staging ring (B = 2–4 buffers, each tile_kv_full Γ— f16, sized once). Replaces the per-op ggml_cuda_pool_alloc slot pool. VRAM cost = B Γ— k_slot+v_slot β€” a few hundred MiB, independent of context length.
  2. Flat prefetch scheduler over the ordered sequence of (layer, tile) tail reads for the step. Issues H2D into ring slots on copy_stream, staying B ahead. A ring slot is refilled only after its compute_done[slot] fires (WAR discipline replaces the per-op cs_sync barrier β€” that's the edit that unblocks cross-layer run-ahead).
  3. Op consumes, does not copy. Each layer's streaming FA waits on its tiles' copy_done, reads the already-resident staging tile, records compute_done. The window (resident) region stays a plain FA, unchanged.
  4. Scheduling home. The scheduler must span op boundaries, so it lives one level up from the op β€” either (a) a context/graph-level KV-prefetch pass that walks the step's attention nodes and their k_tail/v_tail sources, or (b) a persistent per-context streamer object the ops register their tiles with. (a) is cleaner for CUDA-graph capture; (b) is less invasive. Decide in S1.

Bounded win (be honest)

Perfect overlap gives decode_ms = max(step_compute, total_tail_DMA) instead of their sum. Consequences:

  • Below ~`step_compute Γ— PCIe_BW` (~90 MiB on Gemma-A4B, less on the 14B): tail fully hidden β†’ near-1.0Γ— (resident speed). This is the "free zone" the op-level path fails to deliver (currently ~0 MiB).
  • Above it: PCIe-bound at 1000/(tail/BW) regardless β€” physics. E.g. Gemma @236 MiB β†’ max(14, 35) = 35 ms β‰ˆ 28 tps vs today's 19.5 (~1.4Γ—).
  • Does NOT rescue 1M full-attention (14B tail = GBs, step_compute tiny β†’ floor stays low). Route (b) low-bit-resident remains the only long-ctx full-attn ceiling-raiser (Β§5b-14B turbo-KLD, #581).

So this widens the usable-overflow zone and roughly doubles the mid-tail regime; it does not defeat PCIe for extreme context. Worth it for the iSWA/short-overflow serving band; not a substitute for compression.

Companion lever: per-layer residency budget (stream fewer layers)

Orthogonal to how well each tail is hidden is how many tails exist. Today the window/tail split is one global window_cells applied to every layer. But layers differ enormously in how far back they attend:

  • Gemma (iSWA): already per-layer β€” sliding-window layers carry NO tail, only the ~5 global layers stream. This lever is fully banked; it's the 10Γ— gap vs the 14B. Nothing to do.
  • Qwen (full-attn): HAL probe (needle 1k in 12k niah) β†’ only layers 0–4 (+11, ~47) are truly local; ~13 layers attend the needle at β‰₯0.9, ~22 more at 0.45–0.9 β†’ retrieval is distributed across ~35/48 layers. So the safely droppable set is small.

Bounded win (measured): window-only the provably-local layers β†’ 48β†’41 streaming β‰ˆ 1.17Γ—; pushing into the mid-band trades retrieval (StreamingLLM wall β€” the needle drops on the majority; see [[project_qwen_retrieval_distributed_hal]]). Hard ceiling if you could window all but the 13 strong retrievers β‰ˆ 3.7Γ—, but unreachable without breaking niah. Treat as a **1.2–1.5Γ— correctness-gated refinement that STACKS on the prefetch** (fewer streaming layers Γ— each better hidden), NOT a Gemma-style restructure. Cross-layer cache SHARING stays dead (orthogonal caches, cosβ‰ˆ0.001) β€” this only skips a layer's OWN tail when that layer is local.

Mechanism: replace the scalar window_cells with a per-layer window_cells[il] (a layer whose profile is local gets window_cells[il]==kv_size β†’ no tail = resident-cheap). Drive window_cells[il] from an attention-locality profile (reuse the HAL probe offline, or a cheap online out-of-window-mass estimate at prefill). HARD GATE: per-layer niah must hold β€” never widen a layer's window past the point its retrieval mass survives.

Staged plan (de-risk correctness FIRST)

  • S0 β€” per-layer residency budget β€” SHELVED 2026-07-01 (mechanism proven, not shippable as scoped; user pivoted to S1/S2). Implemented via env-gated OPENCOTI_KV_LOCAL_LAYERS in llama-kv-cache.cpp (per-layer window_cells; a local layer β†’ window_cells=0 resident sentinel, layer_window gates the tail machinery). Measured on 3090, 14B-1M @20k overflow, --kv-residency-mode window: the throughput lever is real and scales with resident count β€” baseline (0 resident) 0.97 tps, local1 0.96, local5 1.05, local10 1.24 tps (1.28Γ—), matching the 48/38-streaming ratio. But correctness breaks from N=1: baseline needle=YES, but local1/5/10 all needle=NO. Root cause (bug-1341): the READ dispatch is per-layer (llama-graph.cpp:3274, get_layer_tactic==POSITION_WINDOW; window_cells==0β†’plain FA, correct) but the KV WRITE path splits every layer window/tail on a context-uniform assumption β€” a resident layer routes its whole write to a non-existent tail (tail_c=0) β†’ device KV stays zero β†’ attends zeros β†’ poisons the forward from layer 0. A prior alloc assert was bug-1340 (fixed: layer_window gating). Why shelved: (a) the fix is write-path surgery in llama-graph.cpp + rebuild

    • re-gate, not the "single-file host-only cheap win" it was scoped as; (b) even fixed, only the ~5–6 truly-local layers (0–4, needle_max<0.1 per the HAL profile) are safe β†’ realistic ceiling ~1.12Γ—, still needing bs2 256k validation. The step-prefetch below is the bigger, uniform, correctness-free lever β€” do that first. Resurrect S0 only if a larger justifying win appears; the exact edit recipe is in buglog bug-1340/1341.
  • S1 β€” foundation (low-risk, no math change): shared staging ring + flat scheduler skeleton that still runs 1-deep (B forced to current behaviour). Prove logit-equiv byte-identical to today's op-level path. Establishes the new ownership without changing timing.

  • S2 β€” cross-layer run-ahead: drop the per-op cs_sync barrier, replace with ring WAR discipline; let the scheduler run B ahead across layers. Re-prove logit-equiv (this is the risky reorder β€” the online-softmax combine must be unaffected; ordering is data-independent so it should be identical, gate it).

  • S3 β€” perf gate: re-run the 3090 cliff curve (both models). PASS = the free-zone extends to ~`step_computeΓ—BWand the mid-tail regime β‰ˆmax(compute, DMA)`. Quantify vs the current hyperbola.

  • S4 β€” tune B + tile size for the staging-VRAM/overlap tradeoff; auto-size B from vram_target headroom. bs2 96 GB validation.

  • S5 β€” ship: additive patch(es) + opencoti-hook: markers + UPSTREAM_SYNC + this doc's results + .wolf + pgvector.

Constraints (standing)

vendor-backup WHOLE tree before any vendor mutation; new kernel/reorder = full host rm -rf o + CUDA DSO rebuild + restamp BOTH DSO paths byte-identical + nm -D; correctness via logit-equiv / RULER-niah, NEVER greedy needle; additive soft-fork; CUDA-graph capturability preserved (no cudaEventCreate inside the op β€” reuse the pre-created event pool pattern from #315). Commit only when asked (dev).

Postscript (2026-07-05)

S1 (staging ring) and S2 (barrier drop) landed but measured perf-inert on the 3090 (bug-1838: the PCIe link is saturated; there is no copy-side slack to reclaim). The actual spill-decode ceiling was compute-side: the streaming_lse_kernel recompute on D≀256 decode tiles β€” fixed by bug-1843 / patch 0098-rolling-kv-lse-decode (5.3Γ— spill decode, see rolling_kv.md). S3 (this doc's perf gate) proceeds as #588 on the fixed binary.

#638 / bug-2148 β€” context-shift guard on a spilled window

The spilled position window (GPU window [0,wc) βŠ• CPU tail [wc,kv_size)) is incompatible with in-place KV re-roping (server --context-shift, self-extend). The k_shift graph (llm_graph_input_k_shift / build_rope_shift) views k_per_stream over get_size() cells, but a windowed layer's k_per_stream holds only window_cells, so a shift over-reads it and asserts (ggml.c:1840); the host tail is never re-roped. llama_kv_cache::get_can_shift() now returns false whenever a spilled window is active (window_cells > 0 && populated k_cpu_per_stream), so the server disables ctx_shift + cache-reuse at init and bounds the request at n_ctx instead of crashing. Fully-resident windows and non-window caches are byte-identical and still context-shift; iSWA/hybrid wrappers propagate the leaf guard. The no-degradation alternative β€” a host-side rope-by-delta pass over the CPU tail on every shift β€” is deferred (out of scope for the multi-session prefix-shared serving target). PolyKV shared-prefix (seq_cp/seq_add, does NOT set is_fragmented) composes with spill cleanly and needs no guard β€” validated in #638. Patch 0115-rolling-kv-shift-guard-bug2148.