Skip to content

fix(deepseek-v4): per-layer CSA/HCA compressed-record symbolic dim - #622

Draft
justinchuby wants to merge 1 commit into
mainfrom
deckard/csa-per-layer-record-axis
Draft

fix(deepseek-v4): per-layer CSA/HCA compressed-record symbolic dim#622
justinchuby wants to merge 1 commit into
mainfrom
deckard/csa-per-layer-record-axis

Conversation

@justinchuby

Copy link
Copy Markdown
Member

Problem

The native pkg.nxrt::CompressedSparseAttention exporter (#593) stamped a
single shared symbolic dim (past_compressed_records /
present_compressed_records) on every CSA layer's compressed-record axis,
on the assumption that "every CSA layer advances its cache together."

That assumption is false for the official DeepSeek-V4-Flash interleaved
schedule
(21 ratio-4 CSA + 20 ratio-128 HCA). A ratio-4 layer pools one
compressed record per ~4 tokens; a ratio-128 layer pools one per ~128. Their
record counts diverge as the sequence grows, so a shared symbol forces ORT to
bind the same dim to two different sizes and it (correctly) rejects the graph:

symbol component.model.past_compressed_records bound to conflicting sizes
2 and 0 across bound inputs

This makes any exported graph with a mixed ratio schedule unloadable — the
exact schedule the real checkpoint uses.

Fix

Give each layer its own record-axis symbolic dim via new CsaLayerPlan
properties:

  • past_compressed_records.{layer_id}
  • present_compressed_records.{layer_id}
  • selected_records.{layer_id}

Within a layer, the attention cache and the learned-index cache still share
that one per-layer symbol — they advance in lockstep at the same ratio, which is
a real invariant worth expressing. Across layers of different ratios the
axes no longer alias.

Tests

  • test_native_csa_emits_both_ratios_for_interleaved_schedule — extended to
    assert the ratio-4 and ratio-128 layers carry distinct record axes, and
    that within the ratio-4 layer the attention/index caches share their layer's
    axis.
  • Ratio-128 IO test — updated to the per-layer symbol name.
  • Full deepseek_v4_flash_test.py (45 tests) passes; ruff format --check and
    ruff check clean on the touched files.

No BC shim (dev-time API change, per the no-backward-compat directive).

Downstream

This is the prerequisite for loading a mixed-ratio CSA/HCA graph in onnx-genai's
native decode runtime; the companion onnx-genai E2E proof (stacked on #2063)
regenerates its tiny fixture from this fix.

⚠️ DRAFT — stopping for independent review; do not merge.

The native CompressedSparseAttention exporter stamped a single shared symbolic
dim (`past_compressed_records` / `present_compressed_records`) on every CSA
layer's compressed-record axis, on the assumption that "every CSA layer
advances its cache together". That is false for the official interleaved
schedule: a ratio-4 CSA layer pools one record per ~4 tokens while a ratio-128
HCA layer pools one per ~128, so their record counts diverge as the sequence
grows. A shared symbol then forces ORT to bind the same dim to two different
sizes (e.g. 2 and 0 at prefill), which it correctly rejects:

    symbol component.model.past_compressed_records bound to conflicting sizes
    2 and 0 across bound inputs

Give each layer its own record-axis symbolic dim
(`past_compressed_records.{layer_id}` / `present_compressed_records.{layer_id}`
/ `selected_records.{layer_id}`) via new `CsaLayerPlan` properties. Within a
layer the attention cache and the learned-index cache still share the one
per-layer symbol because they advance in lockstep at the same ratio -- a real
constraint worth expressing -- but layers of different ratios no longer alias.

Extends `test_native_csa_emits_both_ratios_for_interleaved_schedule` to assert
the ratio-4 and ratio-128 layers carry distinct record axes and that the
ratio-4 attention/index caches share their layer's axis; updates the ratio-128
IO test to the per-layer symbol name. No BC shim (dev-time API change).

Surfaced while bringing up the onnx-genai native-decode CSA/HCA E2E proof
against a tiny alternating ratio-4/ratio-128 fixture built by this exporter.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@github-actions

Copy link
Copy Markdown

Performance Comparison

Comparing 0f1fc0c3e73680

Model Metric Baseline Current Delta
bert (feature-extraction) model_size_bytes 359 KB 359 KB +0.0%
bert (feature-extraction) num_nodes 68 68 +0.0%
falcon model_size_bytes 364 KB 364 KB +0.0%
falcon num_nodes 66 66 +0.0%
gemma2 model_size_bytes 428 KB 428 KB +0.0%
gemma2 num_nodes 105 105 +0.0%
gpt2 model_size_bytes 388 KB 388 KB +0.0%
gpt2 num_nodes 54 54 +0.0%
llama model_size_bytes 425 KB 425 KB +0.0%
llama num_nodes 60 60 +0.0%
llama (static-cache) model_size_bytes 425 KB 425 KB +0.0%
llama (static-cache) num_nodes 56 56 +0.0%
mamba (ssm-text-generation) model_size_bytes 296 KB 296 KB +0.0%
mamba (ssm-text-generation) num_nodes 94 94 +0.0%
phi3 model_size_bytes 421 KB 421 KB +0.0%
phi3 num_nodes 58 58 +0.0%
phi3 (static-cache) model_size_bytes 421 KB 421 KB +0.0%
phi3 (static-cache) num_nodes 54 54 +0.0%
qwen2 model_size_bytes 425 KB 425 KB +0.0%
qwen2 num_nodes 60 60 +0.0%
qwen2 (static-cache) model_size_bytes 425 KB 425 KB +0.0%
qwen2 (static-cache) num_nodes 56 56 +0.0%
qwen3_5_moe (hybrid-text-generation) model_size_bytes 506 KB 506 KB +0.0%
qwen3_5_moe (hybrid-text-generation) num_nodes 264 264 +0.0%
qwen3_5_text (hybrid-text-generation) model_size_bytes 458 KB 458 KB +0.0%
qwen3_5_text (hybrid-text-generation) num_nodes 126 126 +0.0%
qwen3_5_vl (hybrid-qwen-vl) model_size_bytes 977 KB 977 KB +0.0%
qwen3_5_vl (hybrid-qwen-vl) num_nodes 428 428 +0.0%
t5 (seq2seq) model_size_bytes 836 KB 836 KB +0.0%
t5 (seq2seq) num_nodes 176 176 +0.0%
whisper (speech-to-text) model_size_bytes 1008 KB 1008 KB +0.0%
whisper (speech-to-text) num_nodes 128 128 +0.0%

No performance regressions.

@github-actions

Copy link
Copy Markdown

🏗️ Architecture Diff

Comparing 0f1fc0c3e73680

Model Sub-model Changes Status
bert (feature-extraction) model 0
falcon model 0
gemma2 model 0
gemma4 (gemma4) decoder 0
gemma4 (gemma4) embedding 0
gemma4 (gemma4) vision_encoder 0
gemma4_text model 0
gpt2 model 0
llama model 0
llama (static-cache) model 0
mamba (ssm-text-generation) model 0
phi3 model 0
phi3 (static-cache) model 0
qwen model 0
qwen (static-cache) model 0
qwen2 model 0
qwen2 (static-cache) model 0
qwen2_moe model 0
qwen2_moe (static-cache) model 0
qwen3 model 0
qwen3 (static-cache) model 0
qwen3_5_moe (hybrid-text-generation) model 0
qwen3_5_text (hybrid-text-generation) model 0
qwen3_5_vl (hybrid-qwen-vl) decoder 0
qwen3_5_vl (hybrid-qwen-vl) embedding 0
qwen3_5_vl (hybrid-qwen-vl) vision_encoder 0
qwen3_moe model 0
qwen3_moe (static-cache) model 0
qwen3_next (hybrid-text-generation) model 0
t5 (seq2seq) decoder 0
t5 (seq2seq) encoder 0
whisper (speech-to-text) decoder 0
whisper (speech-to-text) encoder 0

No architecture changes detected.


Legend: ⚪ No change · 🔵 Minor (attrs/inits) · 🟡 Moderate (nodes added/removed) · 🔴 Major (interface changed)

@justinchuby

Copy link
Copy Markdown
Member Author

Review verdict — APPROVE (numerics/correctness) · Chew (Code Reviewer, Numerics)

Reviewed HEAD 3e7368010189344b96fcfafd2e533d650f2e671c (1 commit, 3 files, +53/-11; base main, stacked on #593/#599/#602…). Review-only — no edit, no merge.

What it fixes

#593 stamped ONE shared symbolic dim on the record axis of every CSA/HCA layer. On the interleaved ratio-4 + ratio-128 schedule the record counts diverge, so ORT rejects binding one symbol to two sizes ("bound to conflicting sizes 2 and 0"). The PR moves symbol creation from outside → inside the per-layer loop and derives per-layer names {past,present}_compressed_records.{layer_id} / selected_records.{layer_id} via three new CsaLayerPlan properties. Within a layer the attention cache and learned-index cache still share the one per-layer symbol (lockstep preserved); across ratios they no longer alias.

Gate results

  1. Per-layer record axis / no invalid sharing ✅ — verified in _compressed_inputs/_compressed_outputs (symbols now created per-layer inside the loop), in the regression test, in an IR export, and in the serialized ONNX proto (dim_param past_compressed_records.1 vs .2).
  2. Shape/dtype/name-pairing/metadata vs CsaStateRole contract ✅ — the PR changes only the symbolic-dim string; dtypes, ranks, tensor names (.{layer}), roles are unchanged. The runtime contract is role-typed and "never names a model, a layer, or a tensor spelling" — the engine binds by CsaStateRole + runtime record size. The string past_compressed_records appears nowhere in the runtime; the tiny-CSA E2E fixture binds per-layer names and treats records == 0 at prefill as a runtime count. ⇒ the rename is invisible to the runtime and cannot break the contract.
  3. No loss of useful within-layer equality ✅ — attention + index caches share the same per-layer symbol (r4_kv_axis == r4_index_key_axis == past_compressed_records.1; reproduced at IR + proto). selected_records.{layer} is a distinct per-layer symbol for the top-k width min(records, topk) and correctly does not alias the record count. past ≠ present; carries stay records-independent [batch, slots, planes, width].
  4. Checker / shape inference / load ✅ — 45/45 deepseek_v4_flash_test.py pass locally; ruff format --check + ruff check clean; strict onnx.shape_inference.infer_shapes(strict_mode=True, data_prop=True) passes on an exported tiny mixed ratio4/ratio128 model. (Plain onnx.checker on the unit-test structural graph trips an unrelated topo-sort/placeholder-weight artifact; the nxrt custom op is not ORT-loadable by design — the authoritative load+generate proof is the onnx-genai runtime E2E and CI L1 Smoke / L3 Synthetic Parity.)
  5. No unrelated changes ✅ — exactly 1 commit, 3 files (2 exporter + 1 test).
  6. CI — unit-test matrix (ubuntu/windows × 3.11/3.12/3.13), Lint, lintrunner, CodeQL, Build, Architecture Diff, Benchmark, L1 Smoke, L3 Synthetic Parity = SUCCESS.

Bonus: the old code also shared selected_records globally across layers (a second latent aliasing bug); the PR correctly makes it per-layer too.

Non-blocking conditions (not defects)

  • Integration (fast), L4 Golden Comparison, and L5 Generation E2E are still QUEUED — these are the strongest mixed-model load + generate proof and the exact place the old "conflicting sizes" bug surfaced. PR is DRAFT / mergeState BLOCKED; final merge should await them going green. The APPROVE is on the numerics/correctness of the change itself, which is complete and minimal.

Scope

Unblocks correct export of the mixed ratio-4/ratio-128 DeepSeek-V4-Flash CSA/HCA compressed-attention state (required by onnx-genai #2063 E2E). It does not touch or enable routed top-k MoE — block-FP8 / planar-FP4 expert weights still typed-reject at the runtime-capability gate (test_native_csa_full_export_typed_rejects_at_runtime_capability_gate, test_preprocess_weights_enforces_runtime_capability_gate). Consistent with my #2088 verdict (planar matmul primitive only; BQMoE stays typed-reject).

— Chew · numerics/precision review · reproduced on host (no GPU needed)

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