Skip to content

Perf: assemble SWA decode window in L1, drop the GM gather - #882

Open
Hzfengsy wants to merge 2 commits into
hw-native-sys:mainfrom
Hzfengsy:perf/assemble-swa-decode-window-in-l1-drop-gm-gather
Open

Perf: assemble SWA decode window in L1, drop the GM gather#882
Hzfengsy wants to merge 2 commits into
hw-native-sys:mainfrom
Hzfengsy:perf/assemble-swa-decode-window-in-l1-drop-gm-gather

Conversation

@Hzfengsy

@Hzfengsy Hzfengsy commented Jul 31, 2026

Copy link
Copy Markdown
Member
  • Assemble the decode SWA window directly in L1 instead of gathering it
    into GM scratch first. The window is WIN consecutive positions and
    WIN == BLOCK_SIZE, so it is always the tail of one paged block plus
    the head of the next; two pl.gather_row calls carry those runs into
    one pl.create_l1 tile, with the runtime split expressed through
    valid_shape so the cube still sees a single ATTN_K_TILE-column
    matmul.
  • Delete the swa_gather_kv scope, its GATHER_* tiling constants and
    asserts, and the T*WIN GM staging tensor it fed.
  • Replicate the window's oldest visible slot into the rows past the
    visible prefix. Those rows are allocated but not yet written at cold
    start, and a non-finite value there reaches the QK matmul and
    propagates through row_max before sparse_bias can mask its score.
  • Pass swa_lens into sparse_attn_swa to bound that prefix.

qk_pv completes 36.2 -> 32.0 us and merge_norm starts 43.7 -> 40.7 us
with the 9.5 us swa_gather_kv scope removed; the end-to-end device wall
is unchanged within run-to-run spread. Windows shorter than WIN pay the
replication loop instead, moving qk_pv 38.2 -> 40.2 us. Measured on
decode_sparse_attn_swa.py -p a2a3 -d 0, plain and
--short-window-fixture, Ascend 910B1, median of 5 runs.

- Assemble the decode SWA window directly in L1 instead of gathering it
  into GM scratch first. The window is WIN consecutive positions and
  WIN == BLOCK_SIZE, so it is always the tail of one paged block plus
  the head of the next; two pl.gather_row calls carry those runs into
  one pl.create_l1 tile, with the runtime split expressed through
  valid_shape so the cube still sees a single ATTN_K_TILE-column
  matmul.
- Delete the swa_gather_kv scope, its GATHER_* tiling constants and
  asserts, and the T*WIN GM staging tensor it fed.
- Fill window rows past the visible prefix from a live pool row: they
  are allocated but not yet written at cold start, and an unwritten
  cache line reaching the QK matmul would propagate through row_max
  before sparse_bias could mask its score.
- Pass swa_lens into sparse_attn_swa to bound that prefix.

qk_pv completes 36.2 -> 32.0 us and merge_norm starts 43.7 -> 40.7 us
with the 9.5 us swa_gather_kv scope removed; the end-to-end device wall
is unchanged within run-to-run spread. Measured on
decode_sparse_attn_swa.py -p a2a3 -d 0, Ascend 910B1, median of 5 runs.
@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The SWA decode attention path now passes swa_lens into sparse_attn_swa. The kernel directly assembles two paged-cache segments in L1, applies runtime window lengths, and fills unwritten tail rows.

Changes

SWA window flow

Layer / File(s) Summary
SWA length contract and wiring
models/deepseek/v4-flash/decode_sparse_attn_swa.py, models/deepseek/v4-flash/decode_attention_swa.py
sparse_attn_swa accepts swa_lens. The decode path and test harness pass the tensor to the function.
Direct paged-cache window assembly
models/deepseek/v4-flash/decode_sparse_attn_swa.py
The kernel requires WIN == BLOCK_SIZE. It assembles up to two cache-block runs in L1, handles runtime valid lengths, and fills invalid tail rows from a live cache row.

Estimated code review effort: 4 (Complex) | ~30 minutes

Possibly related PRs

Poem

A rabbit hops through windows bright,
swa_lens guides each row just right.
Two cache blocks join in L1,
Tail rows fill when work is done.
Direct paths make the kernels run!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly summarizes the main change: assembling the SWA decode window in L1 and removing the GM gather.
Description check ✅ Passed The description directly explains the L1 assembly, removed GM staging, prefix filling, swa_lens handling, and performance measurements.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4e355fcb0d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +209 to +210
qk_kv = pl.gather_row(qk_kv, ori_kv_flat, [qk_len, 0], [0, 0],
[ATTN_K_TILE, HEAD_DIM], valid_shape=[qk_tail, HEAD_DIM])

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Fill masked tail rows only from initialized storage

When swa_lens[qk_t] < WIN, this does not replicate the live pool row at source offset zero: gather_row copies qk_tail consecutive rows beginning there. For example, at cold start with qk_len == 1, it reads source rows 0 through 126, most of which may themselves be unwritten cache storage. Non-finite values from those rows can therefore reach the QK matmul and propagate through row_max despite the additive mask, defeating the stated purpose of this overwrite; initialize the tail explicitly or source every tail row from storage known to be initialized.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in 14f3863.

The call passed src_offset=[0, 0] with a runtime valid_shape, so it
copied pool rows [0, qk_tail) — a consecutive run, not a replicated
row, exactly as described. Those rows are no better initialized than the
ones they replaced, so the guarantee the comment claimed was not
delivered. That was also a regression against the previous GM gather,
which zero-filled invalid slots explicitly.

The tail is now filled by replicating qk_slot0, the window's oldest
visible slot, which is written by definition since it is a position the
request has already produced. Costs a pl.range(qk_tail) loop of
single-row gathers, taken only when the window is shorter than WIN.

Not caught by the tests because init_ori_kv fills the whole pool with
torch.rand, so every source row is finite in the fixture regardless.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
models/deepseek/v4-flash/decode_sparse_attn_swa.py (1)

659-679: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a test fixture that exercises the block-crossing gather path.

init_swa_indices always sets intra = w % BLOCK_SIZE for w starting at 0, so indices[t, 0] always has intra-block offset 0 regardless of blk. This means qk_r is always 0 in this fixture, and the if qk_r > 0: branch in sparse_attn_swa (the new two-run assembly this PR introduces) is never exercised by the standalone test.

Add a fixture variant where the window start is not block-aligned, so the second pl.gather_row call is actually tested.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@models/deepseek/v4-flash/decode_sparse_attn_swa.py` around lines 659 - 679,
Add a non-block-aligned SWA index fixture alongside init_swa_indices, using a
window start offset within BLOCK_SIZE so the generated indices cross a block
boundary and produce qk_r > 0. Update the standalone sparse_attn_swa test to use
this variant and exercise both pl.gather_row calls, while preserving the
existing aligned fixture coverage.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@models/deepseek/v4-flash/decode_sparse_attn_swa.py`:
- Around line 659-679: Add a non-block-aligned SWA index fixture alongside
init_swa_indices, using a window start offset within BLOCK_SIZE so the generated
indices cross a block boundary and produce qk_r > 0. Update the standalone
sparse_attn_swa test to use this variant and exercise both pl.gather_row calls,
while preserving the existing aligned fixture coverage.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 575d7ec6-7a08-4a46-8a3b-ac8b84956f7d

📥 Commits

Reviewing files that changed from the base of the PR and between 1f48761 and 4e355fc.

📒 Files selected for processing (2)
  • models/deepseek/v4-flash/decode_attention_swa.py
  • models/deepseek/v4-flash/decode_sparse_attn_swa.py

The tail fill sourced qk_tail consecutive rows starting at pool row 0,
so it replaced unwritten rows of the current block with an equally
unwritten run elsewhere in the pool rather than with known-good data.
Replicate the window's oldest visible slot instead: that row is written
by definition, being a position the request has already produced.

sparse_bias masks these rows either way, but a non-finite value reaches
the QK matmul and propagates through row_max before the additive mask
can apply, so the fill must come from initialized storage.
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