Skip to content

Perf: score DSpark CSA indexer candidates on Cube - #1379

Open
Little-oil wants to merge 1 commit into
hw-native-sys:mainfrom
Little-oil:perf/dspark-csa-cube-gemv
Open

Little-oil wants to merge 1 commit into
hw-native-sys:mainfrom
Little-oil:perf/dspark-csa-cube-gemv

Conversation

@Little-oil

@Little-oil Little-oil commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

What this changes

indexer_score_topk_leaf is half of decode_csa at 128k. Its head reduction
moves from Vector to Cube, and two query tokens are scored against one Key tile
instead of one, following the AscendC quant_lightning_indexer_v2 arch22
kernel.

A2/A3, TP1, B16, S6, swimlane 0, 100 rounds after 5 warmups, A-B-B-A against
07b5d2f, same device and same frozen fixture.

workload main this PR change
--start-pos 16 x 131072 1499.6 us 1241.3 us -258.3 us (-17.2%)
--start-pos 16 x 8192 726.5 us 707.2 us -19.3 us (-2.7%)

Per-arm medians, in run order: 128k 1501.2 / 1238.8 / 1243.8 / 1497.9, 8k
720.0 / 713.2 / 701.2 / 732.9. On both workloads each PR arm's median is below
both main arms', so the gap is larger than the drift between the repeated arms.

Main's 128k samples are bimodal and only 59 and 57 of 100 land in the fast mode;
this PR's land 100 and 99 of 100, so the median stops being a coin flip. The
means move the same way, 1769.4 / 1776.7 against 1366.5 / 1396.5.

The level-4 trace, 128k:

main this PR
score task span 833.6 us 547.5 us
score task AIC busy, summed over its 72 blocks 19414.1 us 12460.0 us
score task AIV busy, summed over its 72 blocks 39085.6 us 25346.1 us
whole-operation span 1623.6 us 1358.7 us
blocks 983 983

Block count is unchanged: the coefficient is built inside weights_proj_reduce,
so the Cube path adds no task of its own.

The buffered scoring path added by #1375 is not on this route. It needs
b_dim >= 64 and a history of at least 32768, and TP1 with B16 meets neither,
so 128k still runs indexer_score_topk_leaf. Carrying the same Cube reduction
into that path is separate work.

Relation to the open PRs

This supersedes #1354 and #1300, which attack the same reduction.
#1300 kept the result bit-exact with compensated FP16 limbs and six GEMVs and
regressed. #1354 dispatches per query between the original Vector path and a
Cube path.

#1380 by @zhangqi-chen was closed in favour of this one. Its two ideas,
pairing two query tokens per Key tile and making the head weights block
diagonal, are what the third and fourth points below describe, and they are
worth 57.7 us at 128k over the version of this PR that had only the Cube
reduction. The credit for them is his.

The idea

The indexer score is sum_h w[t,h] * relu(q[t,h] . k[c]) * kv_scale[c]. Cube
already produced the 64-head q . k; everything after it ran on Vector as a
cast, a ReLU, a broadcast multiply and a column sum over a 64 x 384 FP32 tile.

  1. FIXPIPE's pre_relu and pre_quant replace Vector's cast and
    maximum.
    The ReLU is applied to the INT32 accumulator and a fixed 1/1024
    dequantization on the way out, while L0C drains into an FP16 Mat tile. The
    64-head intermediate never reaches UB or GM.
  2. A Cube matmul replaces Vector's row_expand_mul and col_sum. The
    dequantized score is already on the Cube side, so the head contraction runs
    there against an FP16 coefficient. Vector keeps only the Key-scale multiply.
  3. Two query tokens share one Key tile. Their head blocks are adjacent rows
    of qr_hadamard_i8, so one QK matmul produces both tokens' scores side by
    side and each Key tile is gathered and pushed through L0B once for two
    tokens instead of twice.
  4. The head weights are block diagonal. Row 0 carries token 0's weights in
    head columns 0..63 and zeros elsewhere, row 1 carries token 1's in columns
    64..127. One GEMM therefore reduces both tokens, which is what keeps the
    Query and the coefficient loop-invariant across the leaf's candidate tiles.

What the arithmetic looks like before and after

Per candidate tile. Before: 384 candidates, one query token. After: 256
candidates, two query tokens.

stage before after
QK matmul(q[64,128], kv^T[128,384]) -> L0C INT32 [64,384] matmul(q[128,128], kv^T[128,256]) -> L0C INT32 [128,256]
ReLU + dequant Vector cast then maximum(x, 0) over 24 576 elements FIXPIPE inside the L0C -> Mat writeback, zero Vector instructions
head weighting Vector row_expand_mul over 24 576 elements folded into the coefficient
head reduction Vector col_sum over 64 rows matmul(coef[16,128], R[128,256]) -> L0C FP32 [16,256], rows 0 and 1
Key scale Vector mul over 384 elements Vector mul over 2 x 256 elements

Per candidate per token, Vector goes from four passes over a 64-row column to
one multiply on one element.

Where the data sits

tensor before after
Query 8 KiB INT8 for one token, L1 -> L0A per tile 16 KiB INT8 for the pair, L1 -> L0A once per leaf
Key tile 48 KiB INT8, L1 -> L0B, one token per load 32 KiB INT8, L1 -> L0B, two tokens per load
64-head score L0C INT32, then 96 KiB per token across the Cube/Vector boundary into UB L0C INT32, then 64 KiB FP16 into L1 and back to L0B; never crosses
head coefficient recomputed per leaf on both AIV lanes, FP32 in UB built once in weights_proj_reduce, FP16 in GM, L1 -> L0A once per leaf
reduced score produced on Vector L0C FP32 [16,256], 16 KiB per pair across the boundary

Normalized per candidate per token, the Cube-to-Vector transfer drops from
256 bytes to 32, and the Key bytes pushed into L0B halve.

The FP16 score reaches the second matmul through pl.tile.transpose_view, a
zero-copy fractal reinterpretation of the same bytes, so the reduction still
writes candidate columns and no Vector transpose is needed.

SCORE_TILE drops from 384 to 256 because the pair doubles every Cube-side
tile: the FP16 score is [128,256], which is all 64 KiB of L0B, and the INT32
accumulator is [128,256], which is all 128 KiB of L0C. That is also why the
Key stays in L0B rather than taking L0A's faster port: with the pair there is no
L0B left to hold the Query.

The numerical contract

  • The reference applies the ReLU to qk_i32 * q_scale and only then multiplies
    by the head weight. q_scale is positive, so relu(x*s) == s*relu(x) and
    applying the ReLU to the raw accumulator is equivalent.
  • R = relu(qk) / 1024 in FP16. The widest INT8 x 128 dot product is
    128 * 127^2 = 2 064 512, which scales to 2016, inside FP16's 65 504. The
    smallest nonzero product scales to 9.8e-4, far above the subnormal floor.
  • The coefficient is fp16(q_scale * weight * 1024). The 1024 cancels the
    writeback scale exactly, so there is no post-scale, and it lifts small
    coefficients clear of FP16 subnormals.
  • The second matmul accumulates in FP32.
  • Each token of the pair masks its own tail. The pair's later token sees more
    candidates, so the shared tile is sized from it, and every store narrows to
    the storing token's own visible count.

topk_idxs keeps main's comparator on both the standalone indexer test and
decode_csa. That comparator is topk_pair_compare: it admits an index swap
only between tied scores and rejects any strictly worse pick, so a selection
that dropped a better candidate would fail it. Contracting on Cube rounds both
operands to FP16, which puts about a quarter of one percent of the diagnostic
score values past the existing relative bound, so topk_scores' outlier
allowance goes from 0.1% to 0.5%. That is the only gate this PR touches;
decode_csa's x_out comparator and every cache gate are untouched and pass
as they are.

The simulator

a2a3sim segfaults here and this PR leaves it failing rather than masking it.
The CPU model of pto.tinsert sizes the scalar pre-quant vector by
GetValidRow() for a column-major source but indexes it by column, so a tail
tile whose valid rows fall below its 64 columns reads past the vector and the
simulator segfaults. Hardware issues the instruction and never runs that code,
which is why every device path here passes. A one-line repro and a fix are in
hw-native-sys/pto-isa#334, which this PR waits on.

Token generation

Operator comparators only bound the score tensor; what matters is whether the
model still says the right thing. An 8192-token prompt carrying the code
BLUE-ORCHID-731 asks for that code back followed by the numbers 1 to 10,
greedy decode, 96 new tokens, DSpark target K0 on TP4/DP4/EP16 over 16 cards,
both arms from the same worktree, venv and devices and differing only in
decode_indexer.py.

Five runs, main three times and this PR twice, across two device allocations.
Every one of them emits BLUE-ORCHID-731 then 1 through 10, consumes 8192
prompt tokens and produces 96.

The free-form text that follows the instructed part differs between runs, and
that is not caused by this change: two runs of identical main code, back to
back in one allocation, diverge from each other after 66 characters. The engine
is not byte-reproducible on this prompt at 96 greedy tokens, so an arm-to-arm
text difference carries no information. What the check establishes is that the
instructed output is correct on every run of both arms.

The mechanism is visible in the comparator above. topk_pair_compare admits a
swap between candidates whose scores are tied, FP16 rounding decides which of a
tied pair the kernel picks, and the two carry different KV. Past the instructed
part the continuation is open-ended, so a small difference in attention output
changes which token wins the argmax.

The code, hunk by hunk

All of it is in models/deepseek_v4_flash_dspark/decode_indexer.py.

  1. Constants. SCORE_QUERY_TILE is the pair width; it falls back to 1 when
    S is odd, which degenerates the block-diagonal weight to a single row and
    restores per-token scoring. That fallback is exercised: the standalone
    indexer test passes on device at S7, T = 112, with topk_idxs still exact.
    SCORE_TILE goes 384 -> 256 for the L0B and L0C
    budgets above. SCORE_FIXPIPE_SCALE and SCORE_COEF_SCALE are the matched
    1/1024 and 1024 pair. SCORE_ARENA_ROWS grows to give each worker lane a row
    per token of the pair.
  2. weights_proj_reduce also builds the coefficient. It gains
    deps=[qh_quant_tid] for the query scale, its body moves from tensor slicing
    to pl.tile.* so the coefficient can be formed from the same in-register
    w_scaled, and a pl.unroll writes one MM_ROW_TILE fractal per query pair:
    zeros, then token t's fp16(q_scale * weight * 1024) into row t, head
    columns t*64 .. t*64+63.
  3. indexer_score_topk_forest takes score_coefficient and the leaf loop
    strides by SCORE_QUERY_TILE, so each item is a (query pair, leaf) instead of
    a (query, leaf).
  4. Per-leaf operand staging. The Query pair and the coefficient are loaded to
    L1 and moved to L0A once, outside the candidate-tile pipeline, because the
    block-diagonal form keeps them invariant across it.
  5. The Key tile becomes a Mat tile. pl.create_l1 becomes
    pl.tile.create(..., target_memory=pl.Mem.Mat) so it can be moved to L0B as a
    transposed view.
  6. The two matmuls and the FIXPIPE writeback replace the single pl.matmul:
    tile.matmul(query -> L0A, transpose_view(kv) -> L0B), then
    tile.assemble(..., pre_quant=SCORE_FIXPIPE_SCALE, pre_relu=True) into an
    FP16 Mat tile, then tile.matmul(coefficient -> L0A, score -> L0B).
  7. The Vector epilogue shrinks. cast, maximum, row_expand_mul and
    col_sum are deleted. What remains is pl.aiv_shard, a per-token row slice
    and the Key-scale multiply, wrapped in a pl.unroll(SCORE_QUERY_TILE) that
    gives each token its own tail mask and arena row. The Key-scale buffer, its
    paged gather, the valid-shape narrowing and the arena store move from the
    tensor-level ops to the matching pl.tile.* ops, because the score now
    arrives as a Tile and mixing the two levels is rejected.
  8. The half-leaf sort loop gains the same per-token unroll, so each token of
    the pair publishes its own two half-leaf rows.

Measured and rejected

  • Scoring a group of queries against one Key tile, one matmul per query.
    This was my first attempt at pairing and it regressed: 1885.9 us at a group of
    two and 1866.9 at four, against 1709.0 for one, at S8 on an earlier base.
    Keeping a matmul per query
    makes the Query and the coefficient vary inside the candidate-tile loop, so
    they have to be re-staged per tile and that costs more than the saved Key
    traffic. I concluded from this that Key traffic was not the binding
    constraint. That conclusion was wrong, and Perf: accelerate DSpark TP1 long-context CSA #1380 is why: with the weights
    block diagonal the group is a single GEMM, the operands stay invariant, and
    the same halved Key traffic is worth 57.7 us.
  • A separate task to prepare the coefficient. Its 48 blocks cost about 9 us
    at 8k, where the score task is not the critical path and the cost is therefore
    pure overhead. weights_proj_reduce already computes the weight, so the
    preparation moved there and the task is gone.
  • Narrowing the Cube-to-Vector tile to its one useful row. L0C is boxed
    16x16, so a one-row subview has no address, and valid_shape=1 on the left
    operand compiles but does not shrink the ring slot, which is sized from the
    physical shape. The pair now uses two of the sixteen rows rather than one.
  • A deeper pipeline, and the Key in L0A instead of the Query. On the
    unpaired version of this PR, three L1 tile stages with four cross-core ring
    slots measured 1542.4 us at 128k against 1706.1 for two slots, again at S8 on
    an earlier base, and the Key
    took L0A because it is the larger operand and L0A's transfer rate is twice
    L0B's. Pairing leaves room for neither: the paired FP16 score fills all of
    L0B and the paired accumulator fills all of L0C. The checked-in depths are
    main's, stage=2 and one ring slot, and the Query is back in L0A.

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented Sep 24, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Advanced

Run ID: ea770a90-68fc-42e5-bda1-3abafc8f1f0a

📥 Commits

Reviewing files that changed from the base of the PR and between d2c159b and 0d2be9e.

📒 Files selected for processing (1)
  • models/deepseek_v4_flash_dspark/decode_indexer.py

Included review availability: Your plan provides up to 1 included review per hour; 0 remain after this review.


📝 Walkthrough

Walkthrough

The indexer score path now precomputes query coefficients and moves score activation, dequantization, and head reduction to Cube operations. The scoring stage uses deeper pipelining and updated tile gather and store operations.

Changes

Indexer score reduction

Layer / File(s) Summary
Coefficient preparation
models/deepseek_v4_flash_dspark/decode_indexer.py
New constants define score scaling and pipeline settings. A new SPMD stage prepares per-query FP16 coefficients for leaf scoring.
Cube score computation and storage
models/deepseek_v4_flash_dspark/decode_indexer.py
Leaf scoring keeps query and coefficient operands resident. Cube FIXPIPE operations apply ReLU and dequantization, then a Cube GEMV applies coefficients and reduces heads. Tile gather, slicing, scaling, and store operations produce the reduced score row.

Priority: ➖ Normal

Estimated code review effort: 3 (Moderate) | ~25 minutes

Change: Refactor

Merge Risk: ⚪ Minimal · up to 0d2be

This change moves the DSpark CSA indexer's head reduction from the Vector units to the Cube, for a reported 17.9% decode speedup at long context. The scoring pipeline still waits for the quantized query to be fully written before reading it, and the earlier numeric-range and layout concerns were ruled out. No outstanding correctness or stability risk was found, so the change appears ready to merge.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 1 functions across 1 files.
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.
Description check ✅ Passed The description clearly explains the Cube-based score reduction, performance results, numerical contract, testing, and scope of the changes.
Title check ✅ Passed The title concisely and accurately identifies the performance change: scoring DSpark CSA indexer candidates on Cube.
✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Commit to this branch
  • Create a new PR

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

A rabbit hops beside the score,
And watches coefficients line the floor.
The Cube makes scores and folds them neat,
Then stores the row with careful feet.
The pipeline hums; the tiles align,
The rabbit nibbles greens at nine.

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

@Little-oil
Little-oil force-pushed the perf/dspark-csa-cube-gemv branch 2 times, most recently from 4526932 to 2cc3178 Compare September 24, 2026 07:32
@Little-oil

Little-oil commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor Author

优化方案逐条说明(对照代码改动)

打分公式是 score[c] = Σ_h w[t,h] · relu(q[t,h] · k[c]) · kv_scale[c]。
Cube 已经算出 64 个 head 的 q·k,之后的 ReLU、乘 head 权重、对 head 求和三步都在 Vector 上。
这三步等价于一次 matmul,所以移回 Cube。

做法参考 AscendC quant_lightning_indexer_v2 的 arch22 内核,分五点。
第 3、4 点来自 @zhangqi-chen 的 #1380。

几何参数:IDX_N_HEADS = 64,IDX_HEAD_DIM = 128,MM_ROW_TILE = 16。
候选 tile SCORE_TILE 从 384 降到 256,每块 tile 同时处理 SCORE_QUERY_TILE = 2 个 query token。


1. 用 FIXPIPE 的 pre_relu + pre_quant 替代 Vector 的 cast + maximum

改动在 indexer_score_topk_forest 的 leaf 分支,候选 tile 循环内:

score_i32 = pl.tile.matmul(
    query_left,
    pl.tile.move(pl.tile.transpose_view(kv_i8), target_memory=pl.Mem.Right),
)
score_relu = pl.tile.create(
    [SCORE_QUERY_TILE * IDX_N_HEADS, SCORE_TILE], pl.FP16, target_memory=pl.Mem.Mat
)
score_relu = pl.tile.assemble(
    score_relu, score_i32, [0, 0],
    pre_quant=SCORE_FIXPIPE_SCALE,
    pre_relu=True,
)

tile.assemble 的 pre_relu 和 pre_quant 让 FIXPIPE 在把 L0C 的 INT32 累加器写进 L1 时
就完成 ReLU 和 INT32 转 FP16 的定标转换,不占用 Vector 指令。生成码是
pto.tinsert ... pre_quant 981467136 {reluPreMode = normal_relu},
981467136 = 0x3A800000 = 1/1024,与 AscendC 的
SetFixpipePreQuantFlag(0x3a800000) 加 reluPre = 1 一致。

被替代的是 Vector 上的 pl.cast 和 pl.maximum(x, 0)。

数据位置:

输入 输出
之前 cast + maximum UB,INT32 [64,384],由 L0C 跨核搬来的 96 KiB UB,FP32 [64,384],96 KiB
之后 pre_relu + pre_quant L0C,INT32 [128,256] L1,FP16 [128,256],64 KiB

关键差别是输出落在 L1 而不是 UB:中间结果不再跨 Cube/Vector 边界,也不占 UB。

2. 用 Cube 的 matmul 替代 Vector 的 row_expand_mul + col_sum

紧接上一段:

score_acc = pl.tile.matmul(
    coefficient_left,
    pl.tile.move(score_relu, target_memory=pl.Mem.Right),
)

pl.tile.matmul 替代了 pl.row_expand_mul(score_fp32, head_coefficient) 和
pl.col_sum(score_fp32) 这两条 Vector 指令。累加用 FP32。

数据位置:

左操作数 右操作数 输出
之前 row_expand_mul + col_sum UB,FP32 [64,192]/lane UB,FP32 [64,1] 系数 UB,FP32 [1,192]/lane
之后 tile.matmul L0A,FP16 [16,128] 系数,4 KiB L0B,FP16 [128,256],64 KiB,由 L1 直接送入 L0C,FP32 [16,256],16 KiB

第 1 点产出的 FP16 结果留在 L1,这里直接作为右操作数进 L0B,不经过 UB 也不经过 GM。
归约输出在 L0C,只有 [16,256] 的 16 KiB 跨核搬到 UB。
按「每个候选、每个 token」折算,跨 Cube/Vector 边界的字节数从 256 降到 32。

Vector 每个 tile 剩下的工作是:

score_shard = pl.aiv_shard(score_acc)
for pair_token in pl.unroll(SCORE_QUERY_TILE):
    score_row = pl.tile.mul(
        pl.tile.slice(score_shard, [1, SCORE_LANE_ROWS], [pair_token, 0]),
        kv_scale,
    )

取本 token 那一行,乘 key scale。原来是四条指令各处理 64×384 个元素。

3. 两个 query token 共用一块 Key tile

一个请求的相邻两个 token,它们的 64 个 head 在 qr_hadamard_i8 里是连续的 128 行,
所以一次取出来就是一个合法的左操作数:

query_vector = pl.tile.load(
    qr_hadamard_i8, [query_head_begin, 0],
    [SCORE_QUERY_TILE * IDX_N_HEADS, IDX_HEAD_DIM],
    target_memory=pl.Mem.Mat,
)
query_left = pl.tile.move(query_vector, target_memory=pl.Mem.Left)

leaf 循环相应改成按 pair 步进:

for item in pl.range(worker, query_count // SCORE_QUERY_TILE * max_leaves, TOPK_SCORE_WORKERS):
    query = (item // max_leaves) * SCORE_QUERY_TILE

一次 tile.matmul 替代了原来两个 token 各做一次的两次 pl.matmul。
Key tile 的 gather_row 和进 L0B 的搬运也从两次变成一次。

数据位置:

左操作数 右操作数 输出
之前,每 token 一次 L0A,INT8 [64,128],8 KiB L0B,INT8 [128,384],48 KiB L0C,INT32 [64,384],96 KiB
之后,每 pair 一次 L0A,INT8 [128,128],16 KiB,整个 leaf 常驻 L0B,INT8 [128,256],32 KiB L0C,INT32 [128,256],128 KiB

按「每个候选、每个 token」折算,进 L0B 的 Key 字节数从 128 降到 64。

SCORE_TILE 从 384 降到 256,是因为配对把 Cube 侧的每块 tile 都翻了一倍:
FP16 分数 [128,256] 正好占满 L0B 的 64 KiB,INT32 累加器 [128,256] 正好占满 L0C 的 128 KiB。
这也是 Key 留在 L0B、Query 回到 L0A 的原因——配对之后 L0B 没有空间再放 Query。

SCORE_QUERY_TILE 在 S 为奇数时取 1,退回每次一个 token,块对角矩阵退化成一行。

4. head 权重排成块对角矩阵,一次 matmul 归约两个 token

配对之后左操作数是 [128, 256],128 行里前 64 行是 token 0 的 head,后 64 行是 token 1 的。
要在一次 matmul 里把两个 token 分别按自己的 head 求和,系数矩阵排成块对角:

        head 列 0..63        head 列 64..127
row 0   token 0 的权重        0
row 1   0                    token 1 的权重
row 2..15  0                 0

代码在 weights_proj_reduce 里(见第 5 点):先把整块 [16, 128] 清零,
再把 token t 的系数写进第 t 行、第 t*64 列起的 64 列。

for w_pair in pl.unroll(MM_ROW_TILE // SCORE_QUERY_TILE):
    w_pair_row = (w_r0 // SCORE_QUERY_TILE + w_pair) * MM_ROW_TILE
    pl.tile.store(
        pl.tile.full([MM_ROW_TILE, IDX_N_HEADS * SCORE_QUERY_TILE], dtype=pl.FP16, value=0.0),
        [w_pair_row, 0], score_coefficient,
    )
    for w_token in pl.unroll(SCORE_QUERY_TILE):
        ...
        pl.tile.store(coefficient_row, [w_pair_row + w_token, w_token * IDX_N_HEADS], score_coefficient)

结果 score_acc 的第 0 行是 token 0 的分数,第 1 行是 token 1 的,第 2 到 15 行是 0。
Vector 侧按 pair_token 取对应行。

这一点是配对能成立的前提。只做第 3 点、两个 token 各做一次 matmul 是不行的:
那样 Query 和系数在候选 tile 循环里就不再是不变量,每块 tile 都要重新进 L0,
反而比省下的 Key 搬运更贵。实测分组 2 是 1885.9 us、分组 4 是 1866.9 us,都比不分组的 1709.0 us 慢。
排成块对角之后,一个 pair 只有一次 matmul,两个左操作数整个 leaf 只加载一次。

每个 token 的尾部单独 mask:pair 里靠后的 token 看到的候选更多,所以共用 tile 按它定大小,
每个 token 存回 arena 时再按自己的可见长度收窄。

5. 系数在 weights_proj_reduce 里一并算出,不新增任务

改动在 indexer_weights_score:

score_coefficient = pl.create_tensor(
    [T_PAD // SCORE_QUERY_TILE * MM_ROW_TILE, IDX_N_HEADS * SCORE_QUERY_TILE], dtype=pl.FP16
)
with pl.spmd(row_blocks, name_hint="weights_proj_reduce", deps=[qh_quant_tid], ...):
    ...
    w_scaled = pl.tile.muls(w_sum, WEIGHTS_SCALE)
    pl.tile.store(w_scaled, [w_r0, 0], weights)
    ...
    coefficient_row = pl.tile.cast(
        pl.tile.muls(
            pl.tile.mul(coefficient_scale,
                        pl.tile.slice(w_scaled, [1, IDX_N_HEADS], [w_member, 0])),
            SCORE_COEF_SCALE,
        ),
        target_type=pl.FP16, mode="rint",
    )

系数是 fp16(head 权重 × query 反量化 scale × 1024)。这个任务本来就在算 weights,
所以系数也放在这里算,直接用还在 UB 里的 w_scaled,不必把 weights 从 GM 读回。
为此整段从 tensor 级改成 tile 级,否则两种类型层级不能混用。

代价是增加一条 deps=[qh_quant_tid],因为要读 query 量化的 scale。
好处是不增加任何任务:四组泳道的 block 数都是 983。

数据位置:

读 head 权重 读 query scale 写系数
并入 weights_proj_reduce 不读,直接用 UB 里刚算完的 w_scaled GM → UB UB → GM
若另起一个任务 GM weights → UB GM → UB UB → GM

系数在 leaf 里的取用路径是 GM → L1 → L0A,每个 leaf 一次。

开发过程中先写的是另起一个 indexer_score_coefficient 任务的版本
(24 block × 2 AIV = 48 block)。它在 8k 上是约 9 us 的额外开销——该长度下打分任务
不在关键路径,省下的时间转不成整体收益,所以最终改成并进 weights_proj_reduce。


数值说明

  • 参考实现把 ReLU 作用在 qk_i32 × q_scale 上,再乘 head 权重。q_scale 恒正,
    relu(x·s) == s·relu(x),所以把 ReLU 作用在原始累加器上结果相同。
  • R = relu(qk) / 1024 存 FP16。INT8×128 的最大点积是 128 × 127² = 2064512,
    缩放后 2016,小于 FP16 上限 65504;最小非零值缩放后 9.8e-4,高于次正规数下界。
  • 系数带的 1024 倍与写回的 1/1024 相消,因此没有后置缩放;同时避免小系数落入次正规数。
  • 第二次矩阵乘用 FP32 累加。
  • 块对角矩阵里 token 之间的元素是 0,两个 token 的归约互不相加。

topk_idxs 用的还是 main 上的 topk_pair_compare,没动。它只允许分数打平时的下标互换,
如果选到了严格更差的候选就会失败。Cube 归约把两个操作数舍入到 FP16,
使约 0.25% 的分数值超出原相对界,所以 topk_scores 的离群比例从 0.1% 放宽到 0.5%。
这是本 PR 唯一调整的门限,decode_csa 的 x_out 和所有 cache 门限未改动且通过。


前后对照

之前:一块 384 候选 tile、一个 query token。之后:一块 256 候选 tile、两个 query token。

阶段 之前 之后
QK matmul(q[64,128], kv^T[128,384]) → L0C INT32 [64,384] matmul(q[128,128], kv^T[128,256]) → L0C INT32 [128,256]
ReLU + 反量化 Vector cast + maximum FIXPIPE 的 pre_relu + pre_quant
乘 head 权重 Vector row_expand_mul 并入系数
head 维求和 Vector col_sum Cube matmul(coef[16,128], R[128,256]) → L0C FP32 [16,256],取第 0、1 行
乘 key scale Vector,384 个元素 Vector,2 × 256 个元素
数据 之前 之后
Query INT8 8 KiB,每块 tile 进一次 L0A 16 KiB,整个 leaf 只进一次 L0A
Key tile INT8 48 KiB 进 L0B,服务 1 个 token 32 KiB 进 L0B,服务 2 个 token
64-head 中间结果 L0C INT32,再以每 token 96 KiB 跨 Cube/Vector 边界进 UB L0C INT32,再以 64 KiB FP16 进 L1,回到 L0B,不跨边界
head 系数 每个 leaf 在两条 AIV 上各算一次,FP32 在 UB 每个 pair 算一次,FP16 在 GM,再经 L1 进 L0A
归约后分数 在 Vector 上产生 L0C FP32 [16,256],每 pair 16 KiB 跨边界

关于 #1375 的 buffered 路径

上游 #1375 新增的 indexer_score_topk_buffered 做了第 1 点,没做第 2 点:
它同样用 FIXPIPE 的 pre_relu + pre_quant,但 head 维求和仍是 Vector 的 row_expand_mul + col_sum。

它的写回是:

pl.store(buf_scores, [buf_transfer_row, 0], buf_score_transfer,
         pre_quant=1.0 / BUFFERED_SCORE_SCALE, pre_relu=True)

BUFFERED_SCORE_SCALE = 1024.0,和本 PR 是同一个常量、同一个 FIXPIPE ReLU。
区别在于它写向 GM(tile.store,Acc → GM),把 64×768 的 FP16 中间结果送到
buf_score_transfer,再由 AIV 读回,仍然用 row_expand_mul 加 col_sum 在 Vector 上归约。
本 PR 写向 L1(tile.assemble,Acc → L1),中间结果留在片上,用 Cube 的 matmul 归约。

它这样做是为了把 AIC 和 AIV 完全分开:整个任务拆成一个纯 AIC 的循环和一个 split_aiv 的循环,
中间用 FFTS 事件握手(SCORE_READY_EVENT / SCORE_CONSUMED_EVENT)加两个 GM 槽轮换,
Cube 因此可以一直跑在 Vector 前面,不受跨核环深度限制。代价是中间结果要经过 GM。

把第 2 点也加进 buffered 路径是可行的后续工作:在它的 AIC 段把 tile.store 换成
tile.assemble 写进 L1,接一次 tile.matmul,只把归约后的 [16, 768] 送去 GM。
这样经过 GM 的数据从 64 行降到 16 行(实际有用的只有 1 行),
同时 Vector 侧的三遍处理也一并去掉,FFTS 握手结构不用改。

但它对本 PR 的目标没有意义。 buffered 路径的启用条件是 b_dim >= 64 且历史长度 ≥ 32768,
而我们关注的是 TP1 / B16,b_dim 是 16,这条路径根本不会执行。
本 PR 所有测量都走 indexer_score_topk_leaf。

另外该工作本身还有两个待解决项:768 候选的 Key tile 是 96 KiB,超过 L0A 的 64 KiB,
需要改 tile 大小或拆分 k 维;以及它与 #1375 直接重叠,应由该 PR 作者或经协调后再改。

@Little-oil

Little-oil commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor Author

打分路径的前后流程图

范围是 indexer_score_topk_leaf 的候选 tile 内循环,也就是本 PR 改动的部分。
改动前是一块 384 候选 tile、一个 query token;改动后是一块 256 候选 tile、两个 query token。
节点是操作,节点后括号里是执行单元,连线是数据传输,线上标注的是搬运的数据及其所在位置。

之前(384 候选,1 个 token)

flowchart TD
    G1["idx_kv_cache(GM)"] -->|"12 次 gather_row<br/>INT8 48 KiB"| L1K["kv tile(L1)"]
    G2["qr_hadamard_i8(GM)"] -->|"load<br/>INT8 8 KiB"| L1Q["query(L1)"]
    L1Q -->|"move<br/>8 KiB 进 L0A"| MM["matmul<br/>q @ kv^T(Cube)"]
    L1K -->|"b_trans + move<br/>48 KiB 进 L0B"| MM
    MM -->|"INT32 [64,384]<br/>96 KiB 在 L0C"| CROSS["aiv_shard<br/>跨 Cube/Vector 边界"]
    CROSS -->|"INT32 96 KiB<br/>L0C 到 UB"| CAST["cast 转 FP32(Vector)"]
    CAST -->|"FP32 [64,384]<br/>96 KiB 在 UB"| RELU["maximum(x,0)(Vector)"]
    G3["qr_hadamard_scale_dq(GM)"] -->|"load"| COEF["mul 求系数(Vector)"]
    G4["weights(GM)"] -->|"load"| COEF
    COEF -->|"FP32 [64,1]<br/>在 UB"| RMUL
    RELU -->|"FP32 96 KiB<br/>在 UB"| RMUL["row_expand_mul(Vector)"]
    RMUL -->|"FP32 96 KiB<br/>在 UB"| CSUM["col_sum(Vector)"]
    CSUM -->|"FP32 [1,384]<br/>在 UB"| SMUL["mul kv_scale(Vector)"]
    G5["idx_kv_scale(GM)"] -->|"6 次 gather_row<br/>FP32"| SMUL
    SMUL -->|"FP32 [1,384]"| ST["store(Vector)"]
    ST -->|"FP32"| G6["score_arena(GM)"]
Loading

之后(256 候选,2 个 token)

flowchart TD
    G1["idx_kv_cache(GM)"] -->|"8 次 gather_row<br/>INT8 32 KiB"| L1K["kv tile(L1)"]
    G2["qr_hadamard_i8(GM)"] -->|"load 2 个 token<br/>INT8 16 KiB"| L1Q["query pair(L1)"]
    L1Q -->|"move<br/>16 KiB 进 L0A<br/>整个 leaf 只做一次"| MM1["matmul<br/>q @ kv^T(Cube)"]
    L1K -->|"transpose_view + move<br/>32 KiB 进 L0B"| MM1
    MM1 -->|"INT32 [128,256]<br/>128 KiB 在 L0C"| ASM["assemble<br/>pre_relu + pre_quant(FIXPIPE)"]
    ASM -->|"FP16 [128,256]<br/>64 KiB 写进 L1"| L1S["score_relu(L1)"]
    G7["score_coefficient(GM)<br/>块对角"] -->|"load<br/>FP16 4 KiB"| L1C["系数(L1)"]
    L1C -->|"move<br/>4 KiB 进 L0A<br/>整个 leaf 只做一次"| MM2["matmul<br/>coef @ score(Cube)"]
    L1S -->|"move<br/>64 KiB 进 L0B"| MM2
    MM2 -->|"FP32 [16,256]<br/>16 KiB 在 L0C"| CROSS["aiv_shard<br/>跨 Cube/Vector 边界"]
    CROSS -->|"FP32 16 KiB<br/>L0C 到 UB"| SLICE["slice 取第 0、1 行(Vector)"]
    SLICE -->|"每 token FP32 [1,256]<br/>在 UB"| SMUL["mul kv_scale(Vector)"]
    G5["idx_kv_scale(GM)"] -->|"4 次 gather_row<br/>FP32"| SMUL
    SMUL -->|"FP32 [1,256] × 2"| ST["store(Vector)"]
    ST -->|"FP32"| G6["score_arena(GM)"]
Loading

两张图的差别

Vector 节点从五个(cast、maximum、mul 求系数、row_expand_mul、col_sum)
减到两个(slice、mul kv_scale)。求系数那个节点整体移出了这个循环,
现在每个 query pair 在 weights_proj_reduce 里算一次。

新增的 assemble 节点没有指令开销,它是 Cube 写回 L1 时顺带做的。
新增的第二个 matmul 节点在 Cube 上。

64 head 的中间结果在改动前要出 Cube 侧进 UB,改动后停在 L1,再回到 L0B。

两个左操作数(query pair 和系数)在整个 leaf 内不变,只进一次 L0A,不随候选 tile 重复。
这是因为系数排成了块对角,一个 pair 只需要一次 matmul。

按「每个候选、每个 token」折算:

之前 之后
进 L0B 的 Key 字节 128 B 64 B
跨 Cube/Vector 边界的字节 256 B 32 B
Vector 指令遍数 4 遍 64 行 + 1 遍 1 行 1 遍 1 行

这个 tile 循环在整个 CSA 里的位置

flowchart LR
    A["qr 投影 + RoPE + Hadamard"] --> B["weights_proj"]
    B --> C["weights_proj_reduce<br/>(本 PR 在这里加算块对角系数)"]
    C --> D["indexer_score_topk_leaf<br/>(本 PR 改的就是它的内循环)"]
    E["kv 压缩 + 写 cache"] --> D
    D --> F["indexer_topk_query_merge"]
    F --> G["sparse_attn_csa / qk_pv"]
    G --> H["o_proj"]
Loading

128k 下 indexer_score_topk_leaf 的 span 是 833.6 us,整个操作 1623.6 us,
改动后分别是 547.5 us 和 1358.7 us,两个降幅相同(286.1 和 264.9 us),
说明省下的时间直接落到了整体上。

@Little-oil
Little-oil force-pushed the perf/dspark-csa-cube-gemv branch 5 times, most recently from 588775b to 3f447ad Compare September 24, 2026 09:59
indexer_score_topk_leaf dominates decode_csa at 128k. It kept a 64-head INT32
score on Vector and reduced it there with a cast, a ReLU, a broadcast multiply
and a column sum. Move that reduction onto Cube, following the AscendC
quant_lightning_indexer_v2 arch22 kernel.

- FIXPIPE drains the INT32 QK accumulator from L0C straight into an FP16 Mat
  tile, applying ReLU on the accumulator and a fixed 1/1024 dequantization in
  the same writeback. This replaces Vector's cast and maximum, and the 64-head
  intermediate never reaches UB or GM.
- A second Cube matmul contracts the heads against an FP16 coefficient,
  replacing Vector's row_expand_mul and col_sum. The dequantized score moves
  from Mat to L0B and the coefficient from Mat to L0A, so the reduction happens
  where the score already is. Vector keeps only the Key-scale multiply and
  Top-K, on a tile that is 64x narrower.
- Two query tokens share one Key tile. Their coefficients sit block diagonally
  in one FP16 matrix, so a batch of GEMVs becomes a single GEMM and each Key
  tile is loaded into L0B once for two tokens instead of twice. This geometry
  follows hw-native-sys#1380.
- The coefficient is built by a loop inside weights_proj_reduce alongside the
  weight that task already computes, so its preparation costs no task of its
  own. It carries a 1024x factor that cancels the writeback scale exactly and
  keeps small coefficients clear of FP16 subnormals.

A2/A3, TP1, B16, S6, swimlane 0, 100 rounds after 5 warmups, A-B-B-A against
07b5d2f, one session on one device against one frozen fixture.

At start_pos = 16 x 131072 the median goes 1499.6 -> 1241.3 us, -258.3 us
(-17.2%). At 16 x 8192 it goes 726.5 -> 707.2 us, -19.3 us (-2.7%). On both
workloads each arm's median is below both baseline arms'.

The level-4 trace at 128k puts the score task at 833.6 -> 547.5 us and the whole
operation at 1623.6 -> 1358.7 us over an unchanged 983 blocks.

topk_idxs keeps main's comparator, topk_pair_compare, which admits an index
swap only between tied scores and rejects any strictly worse pick. Contracting
on Cube rounds both operands to FP16, which puts about a quarter of one percent
of the diagnostic score values past the existing relative bound, so
topk_scores' outlier allowance goes from 0.1% to 0.5%. That is the only gate
this change touches; the decode_csa x_out comparator and every cache gate are
untouched and pass as they are.

a2a3sim segfaults on this kernel: the CPU model of pto.tinsert sizes the scalar
pre-quant vector by rows while indexing it by columns, so a tail tile whose
valid rows fall below its 64 columns reads out of bounds. Hardware never runs
that code. hw-native-sys/pto-isa#334 carries a repro and a one-line fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

This branch has not been deployed

No deployments
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