Skip to content

[AMD] Strict bit-exact SWA HiCache for DeepSeek-V4 with unified_kv: SWA-window + c4/indexer state riding across L1/L2/L3 - #32214

Open
amd-danli103 wants to merge 43 commits into
sgl-project:mainfrom
amd-danli103:feat/unified-kv-swa-hicache
Open

amd-danli103 wants to merge 43 commits into
sgl-project:mainfrom
amd-danli103:feat/unified-kv-swa-hicache

Conversation

@amd-danli103

@amd-danli103 amd-danli103 commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Motivation

DeepSeek-V4's unified_kv backend on ROCm packs three KV families in one pool: the content-addressed compressed KV (C4/C128), the per-request SWA ring (addressed by req_pool_idx * window + pos % window), and the compressor's c4 / indexer overlap state. Only the compressed KV is content-stable; the SWA ring and the c4/indexer overlap state are not, and are never written into the radix tree.

Two prior PRs made reuse of such prefixes safe but only approximately correct:

Re-prefill recomputes the trailing window (and, implicitly, the c4/indexer overlap state for that region) instead of reproducing the exact values the original prefill wrote. That is enough to prevent gross stale-ring errors and to keep GSM8K-level accuracy within noise — but it is not bit-exact: a partial re-prefill cannot reconstruct the original SWA window and c4/indexer overlap state byte-for-byte, so the reused output can differ from a full fresh compute at the bit level.

This PR closes that gap. Instead of re-prefilling an approximation, it captures and offloads the true values of the SWA window and the c4/indexer overlap state across L1→L2→L3, and restores them exactly on a hierarchical hit — making unified_kv SWA prefix reuse strict and bit-exact. A hard reuse gate re-prefills only when the truth is unavailable (never a stale/approximate read).

This is an opt-in gated by SGLANG_UNIFIED_KV_BIT_EXACT_HICACHE (default off), and it engages only when all of the following hold at once:

  • running DeepSeek-V4 on ROCm/HIP;
  • the unified_kv_triton attention backend is chosen (SGLANG_HACK_FLASHMLA_BACKEND=unified_kv_triton);
  • HiCache is enabled (--enable-hierarchical-cache).

In every other case, the feature is a strict no-op, no pool is allocated, no capture, no restore; behavior is byte-identical to main.

Modifications

Organized as three layers:

1. Substrate (Commit 1–5):

  • SGLANG_UNIFIED_KV_BIT_EXACT_HICACHE flag + --hicache-swa-offload-page-stride arg (environ.py, arg_groups/fields/memory.py).
  • Dedicated pinned-host SWA-window offload pool with capture-done handshake + upstream deferred release (memory_pool_host.py).
  • Two independent, tile-start-addressed L3 pools for c4 state and indexer state, packing (deepseek_v4_memory_pool.py, hybrid_cache/hybrid_pool_assembler.py).
  • Version-namespaced L3 keys (hicache_storage.py, storage/nixl/hicache_nixl.py).
  • Startup fail-fast guard (hybrid_pool_assembler.py): with the strict flag on, raise a clear ValueError unless --hicache-write-policy write_through, before any host pool is pinned.

2. Capture (Commit 6–8):

  • Capture the SWA ring window at each completed prefill/decode page with EAGLE-safe geometry (deepseek_v4_backend_hip_radix.py, model_runner.py, deepseek_v4.py).
  • Snapshot c4/indexer overlap state for the same window into the independent state pools (compress_hip.py, compressor_v2.py).

3. Reuse/restore + strict gate (Commit 9–10):

  • Ride the captured window + state back to device at the correct buf_lo offset on a hierarchical hit; strict reuse gate caps the match and re-prefills any tail that isn't provably bit-exact, never a stale read; commit-coupling guard keeps window+state+full-KV committed atomically (swa_component.py, unified_radix_cache.py, base_prefix_cache.py, schedule_batch.py, schedule_policy.py).

4. Follow-ups on the strict path:

  • HiCache capture/restore now addresses c4/indexer state by request slot (translate_from_req_position_to_state_loc) instead of swa_loc, matching the per-request compressor read path from [AMD][DSV4] Reland unified-KV pool sizing and SWA ring accounting, fully gated #38192 — the old addressing landed a restored window on the wrong C4 rows (compress_hip.py).
  • State staging is sized from one prefix's needs — ceil(ISL/(page*stride)) + 1 windows at per-rank batch 1, ISL = min(200k, context_length) — instead of scaling with cache capacity; bounded at 1.5x durable, overridable by SGLANG_SWA_HICACHE_STATE_STAGING_PAGES (hybrid_pool_assembler.py).

Note to reviewers

Scope. Off by default: no pool is allocated, no capture or restore runs.

deepseek_v4_backend_hip_radix.py and compress_hip.py are ROCm-only by construction. In the common files, the L3 key namespace and the unified_swa_hicache pool branch are gated on the flag at startup; the rest is either a new symbol that re-routes nothing (BindCapturedSWAHost, MatchPrefixParams.for_reuse, the new arg/env fields, the new host-pool methods) or a call whose callee returns early unless strict mode and the SWA host pool are both live.

The one match-path change that could otherwise have altered another configuration — clamping the FULL device anchor to the host-gated boundary on a cross-request reuse match — is opt-in per component (TreeComponent.device_anchor_needs_reuse_clamp, False everywhere except strict SWA), so a non-strict tree matches exactly what it matched before. test_reuse_does_not_clamp_when_no_component_asks_for_it pins that.

This PR can be split into three cumulative sub-PRs on request — substrate / capture / reuse (C1–5 / C6–8 / C9–10) — each gated by the same flag and a no-op by default.

Accuracy Tests

Two detectors (like #30339): a byte-exact harness is the real detector; GSM8K is only a no-regression guard.

1. Unit Tests (6 test files, 194 UTs total)

How: cd test && python3 -m pytest -q srt/mem_cache/test_swa_*.py
Expected: 194 passed — byte-exact swa+c4_state/c4_indexer_state capture/restore across L1→L2→L3, strict-gate reject, commit-coupling guard, dirty-read protection, L3-only restore.

No regression on the shared unified-cache suite. python3 -m pytest -q registered/unit/mem_cache gives the same result on this branch as on the merge-base (2115 passed / 1351 skipped / 401 subtests; the 6 failures are pre-existing in our container — 5 need a built rust ext, 1 is a HIP JIT compile failure).

2. E2E (DeepSeek-V4 unified_kv on ROCm, HiCache + strict flag)

How: launch with the gates on and SGLANG_SWA_DBG_CHECKSUM=1 (emits a per-window checksum vs a fresh device re-read), trigger prefix reuse, grep the log.

export SGLANG_HACK_FLASHMLA_BACKEND=unified_kv_triton
export SGLANG_UNIFIED_KV_BIT_EXACT_HICACHE=1     # dev; set 0 for the "off" baseline
export SGLANG_SWA_DBG_CHECKSUM=1                  # + standard dsv4 env as in #30339
python3 -m sglang.launch_server --model-path /data/models/DeepSeek-V4-Pro --attention-backend dsv4 \
  --page-size 256 --swa-full-tokens-ratio 0.1 --enable-cache-report --port 30001 \
  --enable-hierarchical-cache --hicache-write-policy write_through \
  --hicache-io-backend direct --hicache-mem-layout layer_first \
  --tp 4 --dp 4 --enable-dp-attention  2>&1 | tee server.log
# decode + EAGLE spec path (tp8/dp8): add --speculative-algorithm EAGLE --speculative-num-steps 3 --speculative-num-draft-tokens 4 --speculative-eagle-topk 1
# L3-only variant: add --hicache-storage-backend file --hicache-storage-prefetch-policy wait_complete
#   + SGLANG_HICACHE_FILE_BACKEND_STORAGE_DIR=/ssd/dir, populate, kill, restart same dir, resend prefix

Trigger reuse — send the same long prompt N rounds; cached_tokens climbs 0 → large:

python3 - <<'PY'
import json, urllib.request
P = "This is a long shared prefix sentence. " * 4000    # >> page_size (256) tokens
for r in range(6):                                      # 6 for prefill; 20 for decode dp8 (every rank must hit)
    b = json.dumps({"model": "x", "prompt": P, "max_tokens": 8, "temperature": 0}).encode()
    u = json.loads(urllib.request.urlopen(urllib.request.Request(
        "http://127.0.0.1:30001/v1/completions", data=b,
        headers={"Content-Type": "application/json"})).read())["usage"]
    print("round", r, "cached_tokens", (u.get("prompt_tokens_details") or {}).get("cached_tokens", 0))
PY

Expected (grep server.log):

check grep expect
byte-exact SWA + c4/indexer [C4-STATE-DBG] state ride bit-exact / [LB-DEV] device landing byte-exact / [SWA-DBG] restore verified L1/L2 same-process reuse: all three > 0. L3-only cross-restart: [LB-DEV] + [C4-STATE-DBG] > 0 ([SWA-DBG] is 0 on this path — the prefill checksum is not persisted across a process restart, so the window's byte-exact verification is carried by [LB-DEV] device-landing).
no failure / no dirty read MISMATCH|reuse_reject|BIND-MISS|AssertionError|Traceback 0
L3-only source prefetch .*matched=[0-9]+ loaded=[0-9]+ matched=0 loaded>0

L3-only restore validated (cold restart, empty L1/L2): prefetch matched=0 loaded=2304, the first request immediately reports cached_tokens=2304, [LB-DEV] + [C4-STATE-DBG] byte-exact all pass, zero MISMATCH.

gsm8k eval:
Two arms, each run once per server profile — off (SGLANG_UNIFIED_KV_BIT_EXACT_HICACHE=0) and dev (=1) — with the cache flushed (/flush_cache) before each pass and temperature=0, so the only variable is the strict flag. GSM8K is a no-regression guard (the byte-exact harness above is the real correctness detector); the acceptance criterion is that dev is not below off and invalid ≈ 0.

  • non-thinking
python3 benchmark/gsm8k/bench_sglang.py \
  --host 127.0.0.1 --port 30001 --num-shots 8 --num-questions 1319 --parallel 256

For off (SGLANG_UNIFIED_KV_BIT_EXACT_HICACHE=0) and dev (=1) , all above tests we got acc ~0.94(invalid ≈ 0).

  • thinking
python3 -m sglang.test.run_eval \
  --eval-name gsm8k --host 127.0.0.1 --port 30001 \
  --num-threads 256 --temperature 0.0 --repeat 1 --num-shots 8 --max-tokens 8192

For off, we got 0.933, and for dev, we got 0.942(invalid 0).

Speed Tests and Profiling

Both arms HiCache on, DBG=0, --hicache-ratio 1.0 (single variable = strict flag); customer clients. Just take some of the configs as example.

scenario metric off dev note
Prefill (dp4, 50k ISL, prefix-cache0.6) tokens/s ≈ 32.7k ≈ 30.8k dev ≈ −5.8%
Prefill (dp4, 50k ISL, prefix-cache0.8) tokens/s ≈ 31.4k ≈ 29.1k dev ≈ −7.3%
Decode TPOT (dp8, EAGLE, batch 32/48/64) ms/token ≈ 70.6 / 82.4 / 96.8 ≈ 70.8 / 83.8 / 98.4 dev slightly slower, no regression

The prefill cost is the deterministic price of strict correctness (positional H2D of the SWA window + c4/indexer state on reuse, not a content-addressed page copy), applied only with the flag on.

Optional in-repo reproduction.
The table above comes from the customer perf clients. For a self-contained check that needs no external scripts, the stock bench_serving client reproduces the strict overhead directly in-repo (it is a different, smaller workload, so absolute numbers differ). Launch the same server twice with DBG=0 — once SGLANG_UNIFIED_KV_BIT_EXACT_HICACHE=1 (dev), once =0 (off) — then run against each:

python3 -m sglang.bench_serving --backend sglang --dataset-name random \
  --random-input-len 50000 --random-output-len 200 --random-range-ratio 1 \
  --num-prompts 64 --max-concurrency 64 --seed 42 --port 30001

Compare peak output token throughput: dev trails off by a small, consistent margin (measured off ≈ 1536 tok/s vs dev ≈ 1472 tok/s, ≈ −4%), the same direction as the customer-client table. Note that at this concurrency/input length the Median/P99 TTFT is dominated by request queueing (~50–100 s) and is not a reliable reuse detector — prefix-reuse correctness is verified separately by the byte-exact harness and the climbing cached_tokens above, not by this client's TTFT.

Checklist

Review and Merge Process

  1. Ping Merge Oncalls to start the process. See the PR Merge Process.
  2. Get approvals from CODEOWNERS and other reviewers.
  3. Trigger CI tests with comments or contact authorized users to do so.
    • Common commands include /tag-and-rerun-ci, /tag-run-ci-label, /rerun-failed-ci
  4. After green CI and required approvals, ask Merge Oncalls or people with Write permission to merge the PR.

CI States

Latest PR Test (Base): ❌ Run #34570730373
Latest PR Test (Extra): ❌ Run #34570730288
Latest PR Test (AMD ROCm 10): ❌ Run #34570730197

@gemini-code-assist

Copy link
Copy Markdown
Contributor

Caution

The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased.

The fused transfer keeps reading the host tile after it returns, so a
concurrent promote or L3 fetch corrupts the device rows. The launches it
saved are not worth an async contract on the byte-exact path.
One textual conflict: upstream added RadixKey.cache_salt at the in-batch
prefix-caching match site, the same call this branch passes for_reuse=True
to. Both kept.
One textual conflict: upstream reorganised environ.py into banner-delimited
sections and replaced the `# CUDA Graph` comment this branch had appended its
flag in front of. SGLANG_UNIFIED_KV_BIT_EXACT_HICACHE moves into the "Radix and
sparse KV caches" section next to SGLANG_ENABLE_UNIFIED_RADIX_TREE rather than
staying at the tail of the Mamba section.
Upstream added a buffer-only host memory mode for HiCache (sgl-project#34798), which
rewrote three places in swa_component.py.

Two textual conflicts:

- prepare_prefetch: upstream replaced the inline window-page math with
  full_window_pages and now allows a sub-window fetch at the root or in
  buffer-only mode. Taken as the non-strict branch verbatim; the strict
  ring-paged branch above it is unchanged.
- build_hicache_transfers: upstream renamed sw_pages to num_pages. The
  strict-aware stride keeps the new name.

One silent merge that needed a fix: upstream's new mid-tree graft guard in
_commit_prefetch drops a window whose window_require_pages is below
full_window_pages. That count is in strides, and the strict pool's stride is
the ring (one ring, where full_window_pages counts two page_size pages), so
merged as-is the guard would have dropped every strict window grafted mid-tree.
The requirement is now derived from the same stride, which is identical to
full_window_pages whenever the flag is off.

Test fakes gain root_node, sliding_window_size and full_window_pages, and the
_sync_trailing_keys tests now pass a controller stand-in: upstream consults the
per-pool page size there to shrink a buffer the hit undershot, so a bare None
self no longer works.
Two textual conflicts, both resolved as a union:

- deepseek_v4.py: upstream rewrote the comment above `attn_k` for the new
  fused qk-norm-rope verify path (SGLANG_OPT_FUSED_QK_NORM_ROPE_VERIFY,
  default on), which hands back a non-None kv on target-verify instead of
  storing inside the fused kernel. The unfused path this branch was written
  against already returned a non-None kv there, so the SWA capture hook's
  `kv is not None` gate keeps its meaning; took upstream's comment and left
  the hook above it.
- hybrid_pool_assembler.py: upstream added get_serving to the
  runtime_context import this branch had appended SpeculativeAlgorithm
  next to. Both kept.

Upstream also moved DSAIndexerPoolHost out of memory_pool_host.py and
migrated this file's hicache_size / mem_layout / write_policy / io_backend
reads to get_memory(); neither touches the strict path (all of this branch's
additions there live in DeepSeekV4PagedHostPool, and the projected values are
the same ones ServerArgs carries).

Signed-off-by: amd-danli103 <danli103@amd.com>
One textual conflict, in prepare_prefetch: upstream's HiCache host pool
refactor (sgl-project#36232) replaced the direct _swa_kv_pool_host.alloc +
evict_host + retry with host_pool_group.alloc(pool=PoolName.SWA,
reclaim=...). That allocation is shared with the strict ring-paged branch
above it, so the strict path moves onto the group call as well: every
builder wires ComponentType.SWA from host_pool_group.get_pool(PoolName.SWA),
so the component's _swa_kv_pool_host and the group's SWA entry are the same
object and the group's alloc is the old alloc/reclaim/retry verbatim. Both
branches keep their own num_tokens.

The matching free in free_host_values merged without a conflict and resolves
to that same pool.

The strict prefetch test fake gains a host_pool_group that allocs from the
pool the component already holds, mirroring how the assembler wires them.

Signed-off-by: amd-danli103 <danli103@amd.com>
One textual conflict: sgl-project#33091 swapped the device_swa_evict_fn lambda for the
_evict_swa_for_device_alloc helper and dropped the function-local EvictParams
import this branch appended its write_through guard next to. Guard stays,
import goes.

Signed-off-by: amd-danli103 <danli103@amd.com>
@amd-danli103
amd-danli103 force-pushed the feat/unified-kv-swa-hicache branch from c3d0eb0 to d156531 Compare August 26, 2026 11:46
Conflicts in assembler/swa_component/tree_core/server_args: keep 32214
strict SWA HiCache paths and main APIs (FP4 indexer, host_lock,
layer_mappings). hicache_swa_offload_page_stride moved to arg_groups.

Signed-off-by: amd-danli103 <danli103@amd.com>
Branching-point caching (sgl-project#34565) made finalize_match_result_in_tree_core
align the full-KV hit to a page, which the SimpleNamespace tree_core in these
tests does not carry.

Signed-off-by: amd-danli103 <danli103@amd.com>
…/restore

sgl-project#38192 already isolates the compressor read path per request. HiCache
write/restore still used swa_loc, so a restored window landed on the
wrong C4 rows. Switch the four capture/restore helpers to
translate_from_req_position_to_state_loc.

Signed-off-by: amd-danli103 <danli103@amd.com>
…capacity

Slack is ceil(ISL/(page*stride))+1 at per-rank batch 1, with ISL
min(200k, context_length) and page the resolved tree page size, not
16 * max_running_requests. 1.5x-of-durable bounds the derived value while an
explicit SGLANG_SWA_HICACHE_STATE_STAGING_PAGES is taken as-is, and the DRAM
guard now weighs the coupled c4/indexer pools.

Signed-off-by: amd-danli103 <danli103@amd.com>
…ol_idx

A second cleanup_after_caching_req silently replaced the upstream one,
so out-of-window SWA slots were never freed. Also req.req_pool_idx
moved to req.kv in the last main merge.

Signed-off-by: amd-danli103 <danli103@amd.com>
Only strict SWA can let a device-only validator outrun the host-gated
boundary. Other trees skip the clamp instead of relying on min() being
an identity.

Signed-off-by: amd-danli103 <danli103@amd.com>
Ring mode pages full attention only and never writes full_to_swa_index_mapping,
so every page rep resolved to the padding slot and free_page_ids kept handing
page 0 back until available_size ran past size on the first grouped teardown.
…stream

The check re-read dev[state_locs] after a host sync, by which point the
compressor forward had rewritten the same (r, pos % ring) rows, so it compared
the host tile against post-forward state and failed whenever the forward got
there first.
restore_swa_windows writes the per-request ring from the scheduler stream while the previous forward is still writing the same r * ring + pos % ring rows, so the restored boundary got overwritten. Wait on load_fence_stream, the same fence start_loading already applies to load-back H2D for this hazard.
…joint free

EAGLE +1 on a page-aligned SWA branch emits token-adjacent tails that still
share a page; merging them in free_kv_row matches the existing comment and
stops decode warmup from tripping _page_disjoint.
Three textual conflicts:

- deepseek_v4_backend_hip_radix.py: upstream (sgl-project#38947) replaced the
  get_swa_out_cache_loc / get_unified_swa_loc docstrings with one-line
  comments. This branch had only reworded them, so upstream wins and the
  reword leaves the diff.
- deepseek_v4_memory_pool.py: same PR renamed get_c128_state_buf_infos to
  get_request_state_buf_infos, which this branch had appended
  get_swa_state_coupling_infos in front of. Both kept, new name taken; the
  two call sites merged clean on upstream's side.
- swa_component.py: upstream commented the host_lru lookup in
  redistribute_on_node_split, which this branch had hoisted above the
  strict ring-page split. Nothing used it in between, so the hoist goes and
  upstream's placement stands.
ServerArgs stopped being a dataclass upstream, so __dataclass_fields__ is gone;
msgspec.structs.fields still carries the arg_groups default.
…covers it

Ours returned before clear_full_to_swa_mapping; the landed version clears the
mapping first, which test_direct_free_clears_mapping_in_ring_mode pins.
allocator/swa.py is now byte-identical to main.
@amd-danli103
amd-danli103 force-pushed the feat/unified-kv-swa-hicache branch from 110b435 to a24122d Compare September 11, 2026 06:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant