Skip to content

feat: SnapKV prefill KV eviction (#51 v1, CPU)#57

Merged
pekkah merged 1 commit into
masterfrom
feat/snapkv-prefill-eviction
May 28, 2026
Merged

feat: SnapKV prefill KV eviction (#51 v1, CPU)#57
pekkah merged 1 commit into
masterfrom
feat/snapkv-prefill-eviction

Conversation

@pekkah

@pekkah pekkah commented May 28, 2026

Copy link
Copy Markdown
Owner

Summary

First slice of #51: SnapKV-style prefill-time KV-cache eviction on the CPU forward pass. The full issue spans four backends + TurboQuant + a long-context eval; this PR lands the CPU foundation that the other ports build on.

  • SnapKvSelector scores each prompt position via per-head attention from the last-W queries, pools across layers/heads/queries, picks top-K + a trailing recency window.
  • PagedKvCache.Compact(int[] keep) copies survivors into the cache prefix and frees trailing pages. Introduces LogicalLength (== original prompt length, preserved through compaction) alongside Length (slot count) so decode-side RoPE keeps using the original position frame even though slot indices renumber.
  • ForwardPass.PrefillCore invokes the selector + compactor as a post-prefill hook gated by SHARPI_SNAPKV_BUDGET=N.
  • Attention.seqLen is clamped to cache.Length + 1 so the prefill loop (increments before Attention) and the decode loop (increments after) both stay correct under the new slot/position decoupling.

Env knobs:

Var Default Effect
SHARPI_SNAPKV_BUDGET unset Total kept positions (0/unset disables eviction).
SHARPI_SNAPKV_WINDOW 64 Number of trailing queries used as the importance probe.
SHARPI_SNAPKV_RECENCY 64 Trailing positions always retained.

Smoke (SmolLM2-1.7B Q4_K_M, CPU)

prompt = 135 tokens
SHARPI_SNAPKV_BUDGET=80  →  cache.Length=80, LogicalLength=135
Decode: 64 tokens, 41.5 t/s  (vs 42.9 t/s un-evicted — within noise)
Output: coherent continuation ("As Whiskers continued to explore...")

Test plan

  • 5 new PagedKvCache unit tests covering identity compact, mid-set eviction, append-after-compact tail slot, out-of-range and unsorted-input validation.
  • 3 new SnapKvTests integration tests on SmolLM2-1.7B: long-prompt eviction (cache shrinks to budget, decode stays well-formed and produces ≥2 distinct argmaxes), disabled-by-default, budget-exceeds-prompt gate skip.
  • Full dotnet test tests/SharpInference.Tests.ForwardPass -c Release — all 246 tests pass.

Follow-ups (tracked separately for #51 acceptance)

  • CUDA hybrid GDN port — the primary 12 GB target. Sequential prefill loop needs last-W per-layer Q capture + a GPU-side Compact over the flat KV ring. Composable with the bf16 KV path (Bf16 KV cache for hybrid GDN models (qwen35, qwen35moe) #27, PR feat: Bf16 KV cache for hybrid GDN models (#27) #56).
  • Dense CUDA + Vulkan ports.
  • TurboQuant composition — TurboQuantKvCache has its own compressed layout and scoring needs to decompress on the fly.
  • LongBench-subset / needle-in-haystack accuracy validation (≤ 2 % regression criterion).
  • Prefix-caching interaction: post-eviction caches currently shouldn't be reused as a prefix. Add a gate on cache.LogicalLength != cache.Length.

🤖 Generated with Claude Code

Add SnapKV-style (arXiv:2404.14469) prefill-time eviction so long prompts
don't keep their full KV cache pinned through decode. After prefill, the
last-W queries (W=64 by default) score each prompt position via per-head
attention; scores pool across layers, heads, and queries, and the top-K
positions plus the trailing N recency positions survive. PagedKvCache
compacts to that set — slot count drops while LogicalLength stays at the
original prompt length so RoPE on subsequent decode tokens lands at the
right angle.

CPU only in this commit. Env: SHARPI_SNAPKV_BUDGET=N activates eviction;
SHARPI_SNAPKV_WINDOW and SHARPI_SNAPKV_RECENCY tune the scoring window and
recency. Disabled by default. Attention's seqLen is clamped to
cache.Length+1 so prefill (which increments cache.Length before Attention)
and decode (which increments after) both stay correct under the new
slot/position decoupling.

Tests: 5 new PagedKvCache.Compact unit tests; 3 SnapKvTests on SmolLM2-1.7B
covering the long-prompt eviction path, the disabled-by-default path, and
the budget-exceeds-prompt gate. Decode rate at budget=80 on a 135-token
prompt is on par with un-evicted (42.9 → 41.5 t/s, within noise).

CUDA / Vulkan ports, TurboQuant composition, and a long-context accuracy
sweep (LongBench / needle-in-haystack) are tracked as follow-ups — each
adds non-trivial backend-specific surgery (sequential GPU prefill loops
need last-W Q capture, the TQ cache has its own compressed layout).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@pekkah pekkah merged commit bdcd7e0 into master May 28, 2026
1 check passed
@pekkah pekkah deleted the feat/snapkv-prefill-eviction branch May 28, 2026 11:31
pekkah added a commit that referenced this pull request May 28, 2026
Ports SnapKV (#51) prefill-time KV eviction to CudaHybridGdnForwardPass —
the 12 GB-card long-context target (qwen35moe / Qwen3.6-27B-MTP /
35B-A3B-MTP). Auto-enables on this path when the configured ctx is large
enough that the full bf16 KV ring would exceed ~256 MiB (i.e. ctx ≳ 16K
on these models); below that the lossy eviction step adds more cost than
it saves. Auto-budget is min(maxSeqLen/4, 4096) floored at 1024, matching
the SnapKV paper's accuracy curve. SHARPI_SNAPKV_BUDGET=N forces an
explicit budget; =0 disables. CPU ForwardPass (#57) keeps the
explicit-opt-in convention.

GPU work:
- New NVRTC kernels llm_snapkv_score / _bf16 compute per-(head, query)
  softmaxed attention against the layer's K ring, atomicAdd-pooling the
  result into a single per-position score buffer — pooling across
  (queries × heads × attention layers) is implicit. llm_kv_compact /
  _bf16 gather the sorted keep list into a staging tensor;
  CopyDeviceRegion writes the prefix back over the ring's
  [0, K*kv_dim) slice.
- CudaHybridGdnForwardPass: lazy [numAttnLayers × W × qDim] capture ring,
  inline post-RoPE/post-Q-norm capture inside GpuAttnBlockAt for the
  trailing W tokens of prefill, post-pass score → SnapKvSelector.LoadScores
  → SelectKeepSet → per-layer compaction. Forward's GpuAttnBlock now
  passes kvPosition = _kvCache.Length so decode addresses the compacted
  slot; BatchForward2's explicit kvPosition stays as-is.
- PagedKvCache.CompactLengthOnly: bookkeeping-only counterpart of Compact
  for backends storing the KV payload outside this cache — drops Length
  to K without forcing per-layer page allocation.
- SnapKvConfig.IsBudgetExplicit distinguishes env-unset (auto) from env=0
  (off). SnapKvConfig.ComputeAutoBudget centralises the formula.

Tests: 4 new — SnapKvConfig_ComputeAutoBudget formula edges,
SnapKvConfig_FromEnvironment tristate, CudaHybridGdnSnapKv_LongPrompt
(cache.Length shrinks to budget, LogicalLength preserved at prompt
length, decode produces ≥2 distinct argmaxes), and
CudaHybridGdnSnapKv_SmallCtxDefault (cache-size threshold keeps short-ctx
runs untouched). Full ForwardPass suite: 251/251 pass.

Remaining #51 follow-ups: dense CUDA + Vulkan ports, TurboQuant
composition, LongBench / needle-in-haystack accuracy validation,
prefix-caching gate.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant