Skip to content

kv-cache: lazy-grow compressed KV caches instead of full-ctx up front - #1010

Open
evintunador wants to merge 1 commit into
antirez:mainfrom
evintunador:kv-lazy-grow-upstream
Open

kv-cache: lazy-grow compressed KV caches instead of full-ctx up front#1010
evintunador wants to merge 1 commit into
antirez:mainfrom
evintunador:kv-lazy-grow-upstream

Conversation

@evintunador

Copy link
Copy Markdown

Compressed KV caches are currently provisioned for the full context at session
creation (comp_cap = ctx_size / ratio + 2 rows per layer, allocated eagerly in
kv_cache_init). Long-context serving configurations therefore pay the entire
per-slot KV cost up front, even for a conversation that is a few thousand tokens
deep and may never grow.

This change allocates DS4_KV_COMP_INITIAL_ROWS (4096) rows initially and grows
geometrically, capped at comp_cap. Behaviour at full depth is unchanged.

Scope, deliberately narrow:

  • Metal GPU caches lazy-grow.
  • CUDA/ROCm keep the full-ctx preallocation (comp_alloc == comp_cap, so every
    grow call is a no-op). Their tensor allocations may be arena-backed, where the
    free half of an alloc-copy-free grow cycle does not return memory to the arena.
  • The CPU F32 reference cache lazy-grows on every backend.

Measurements

Machine: Apple M5 Max, 128 GB, macOS 26.6.2. Backend: Metal.
Model: DeepSeek V4 Flash 0731, custom IQ2_XXS mixed quant
(IQ2XXS-w2Q2K-AProjQ8-SExpQ8-OutQ8-imatrix, 80.76 GiB resident).

Memory, --ctx-alloc 262144 with a 2048-token conversation, peak
phys_footprint (/usr/bin/footprint):

build peak dirty footprint
main 7129 MB
this patch 5238 MB

1891 MB (26.5%) saved, consistent with the 2.01 GiB of compressed KV that main
provisions up front at this context size.

Commands:

./ds4-bench --metal --prompt-file tests/long_context_story_prompt.txt \
  --ctx-alloc 262144 --ctx-start 2048 --ctx-max 2048 --gen-tokens 128

Speed

gen_steady_tps, ABBA ordering (main, patch, patch, main) to cancel linear
thermal drift; mean of two runs per build per frontier, machine thermally
settled:

frontier main this patch
2048 (below initial rows, no growth) 38.72 t/s 38.71 t/s
16384 (growth active) 34.31 t/s 34.06 t/s

The 16384 delta (-0.7%) is within the observed run-to-run spread. An earlier
non-ABBA sequential comparison appeared to show a larger regression; that was
clock drift across the sequence, not the patch.

Correctness

./ds4_test --server         # OK
./ds4_test --metal-kernels  # OK, 0 ULP on router batch weights exactness
./ds4_test --long-context   # OK, 30474-token prefill

No failures. Not run: CUDA/ROCm regressions (no hardware); those paths are
no-ops under this change.

🤖 Generated with Claude Code

Reimplementation of the pre-upstream-sync fork feature (a06f232 +
b1c8fc1) on the current engine.  Both the CPU F32 reference cache and
the GPU (Metal) F16 attn / F32 indexer compressed caches allocated
comp_cap = ctx/ratio rows per layer at creation.  Split the logical cap
(comp_cap, unchanged -- the worst-case memory estimate deliberately
still uses it) from the physical allocation (comp_alloc), seeded at
DS4_KV_COMP_INITIAL_ROWS (4096) and grown geometrically as the
conversation actually extends.

What this buys on the current engine:
- Metal: per-session comp-cache *allocated* size tracks conversation
  length instead of ctx (~2.3GB -> tens of MB per idle session at 256k
  ctx; ~9GB at 1M).  Untouched MTLBuffers were never resident, so RSS
  is unchanged -- the win is Metal working-set/allocation headroom,
  which multiplies under --batched-session N.
- CPU: the F32 reference cache memsets its allocation (deliberately, to
  keep VM faults out of the decode loop), so wherever it exists (CPU
  sessions, distributed coordinator, session_cpu_reset_cache) lazy-grow
  avoids dirtying full-ctx pages up front.
- CUDA/ROCm keep full preallocation (comp_alloc == comp_cap, grows
  no-op): their tensor allocations may be arena-backed, where the free
  half of the alloc-copy-free grow cycle does not return memory.

CPU grow is a realloc + eager zero of the new chunk.  GPU grow
(metal_graph_grow_comp) has no realloc, so it is alloc-new + copy-live-
prefix + free-old + repoint, and MUST run at a GPU-synchronized point
with no active command batch.  Every comp writer pre-grows at its own
synchronized boundary before opening its store batch: single/top/MTP
decode, native and serial session-batch decode (before TP batch mode
engages), mixed prefill+decode, chunked prefill, suffix verification
(DSpark/MTP), the distributed layer-slice eval, and both KV restore
paths.  Two fail-safe guards (decode store stage, batched attention
encoder) fail a request cleanly before encoding if any writer is ever
under-grown, instead of writing past the physical buffer.

Validation (M3 Max, community 0731 q2): make && make test green with
zero warnings; an 18,441-token prompt -- crossing the 4096-row seed on
ratio-4 layers, so real growth on the prefill path -- produces greedy
output bitwise identical to the non-lazy build, with no guard trips.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Km5JqR2QvCY8L6AJZxJAKQ
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