Repository navigation
Conversation
Code review范围:2 文件、无运行时数据流(dump 脚本 + fixture)。逐文件认领: 正向路径(本机 torch 2.14.1+cpu / transformers 5.16.1 实跑复核):
最小加固:修 M1(digest canon 补两个 budget 常量,与姊妹脚本对齐)、M2(脚本内固化 reload-and-reproduce:strict load 证明权重映射完整 + 逐位复现 out/weights/mask,顺带 |
修改措施Pushed
复验:fixture 以 torch 2.14.1+cpu / transformers 5.16.1 / CPU 重生成(33 tensor,布局与张量集不变),参考输出与原 dump 存在 bf16 ULP 级环境漂移(≤2.5e-4)——与原 committed fixture 相比 31/33 张量逐字节相同,差异仅 |
| # The captured o_proj input must reproduce the module's returned output. | ||
| assert torch.equal(module.o_proj(captured["o_in"]), out), f"case {i}: o_proj replay mismatch" | ||
|
|
||
| # Effective sparse+causal mask = the reference's own indexer mask AND causal. | ||
| selected_bool = captured["indexer_mask"] == 0 | ||
| eff = causal_bool & selected_bool # [1,1,S,S] | ||
|
|
||
| # Self-check: blocked (sparse-dropped or future) positions carry zero softmax mass. | ||
| blocked_mass = (attn_weights.float() * (~eff).float()).abs().max().item() | ||
| assert blocked_mass == 0.0, f"case {i}: blocked positions leaked softmax mass ({blocked_mass})" | ||
|
|
||
| # Drop accounting: causally-valid keys the sparse budget discarded, per row. | ||
| causal_count = causal_bool[0, 0][row_idx].sum(-1) | ||
| kept_count = eff[0, 0][row_idx].sum(-1) | ||
| drops = (causal_count - kept_count).tolist() | ||
| assert all(d == 0 for d in drops[:2]) if seq_len == 2055 else all(d == 0 for d in drops), ( | ||
| f"case {i}: unexpected sparse drop on a short case: {drops}" | ||
| ) | ||
| if seq_len == 2055: | ||
| assert all(d == 0 for d in drops[:2]) and all(d == 4 for d in drops[2:]), ( | ||
| f"case {i}: real boundary drops unexpected: {drops}" | ||
| ) |
Code review审计对象:
修改措施
|
|
This pull request has merge conflicts with main. Please rebase it so it can be reviewed and merged. |
0a552e2 to
19bc006
Compare
|
This pull request has merge conflicts with main. Please rebase it so it can be reviewed and merged. |
Signed-off-by: bohaotu <bohaotu@pony.ai>
… pass - config_sha256 canon now covers REAL_BUDGET/BOUNDARY_BUDGET so a budget change redds the provenance digest (aligns with the indexer golden). - Reload-and-reproduce after save: strict state-dict load proves the stored weights are complete and correctly named; every case replays attn_out / attn_weights / mask bit-for-bit in-process. Docstring records that cross-environment replay is tolerance-bound (bf16 ULP drift <= 2.5e-4). - Explicit isfinite guard on attn_out and a calibration note for the 3e-2 row-sum atol (measured noise floor ~2e-3). - Fixture regenerated for the new digest: 31/33 tensors byte-identical to the previous dump; case1/case2 attn_out carry the known <= 2.4e-4 bf16 ULP environment drift (torch 2.14.1+cpu vs the original 2.5.1+cu121). Signed-off-by: bohaotu <bohaotu@pony.ai>
Rewrite the QSA sparse-attention oracle to use the real frozen checkpoint geometry (hidden 2560, 24 Q / 2 KV heads, head_dim 256, partial rotary 0.25 -> 64 dims, GQA group 12, real indexer budget 2048 -> 512 blocks) instead of the reduced proxy, and hit the real boundary at S=2055. The plain q/k/v/o GEMM weights (98 MB) are not stored; their outputs are captured with forward hooks, and only the small math-carrying weights (q_norm/k_norm, index_qk_proj, q/k_layernorm) are kept. Each case records only the evaluated query rows; metadata moves to a single JSON key (format_version 2). Signed-off-by: bohaotu <bohaotu@pony.ai>
…producer case*_q_proj/k_proj/v_proj held the captured projection outputs, not weights, so the names read like the GEMM weights the golden deliberately does not store. Rename them queries/keys/values (matching the merged indexer golden). Signed-off-by: bohaotu <bohaotu@pony.ai>
…oad pass The fixture stored the indexer's `index_qk_proj`/`q_layernorm`/`k_layernorm` (3.3 MB) that nothing could exercise: their input, the layer's hidden state, is not part of the fixture, and the sparse mask they produce is stored as data. The indexer has its own oracle. The 38 surviving tensors are bit-identical to the previous fixture and the metadata JSON is unchanged. Also drops `replay()`/`verify()` and the `--verify-only` flag. That pass re-implemented the reference forward to show the fixture reproduces itself, a property the captured values do not depend on -- and the only re-implementation this oracle carried. The in-run invariants stay: the `o_proj` replay, zero blocked softmax mass, and the per-row drop accounting. Signed-off-by: bohaotu <bohaotu@pony.ai>
…d asserts The case loop asserted two properties the architecture already guarantees -- zero softmax mass on blocked positions, and the budget's drop pattern -- which this fixture has no business re-checking: it stores the reference's own output, so its job is numerics. What remains is the capture check that `o_proj`(captured input) reproduces the module's returned output. The docstring is compressed and no longer claims the dump asserts those drops. Fixture bytes are unchanged. Signed-off-by: bohaotu <bohaotu@pony.ai>
The `o_proj`(captured input) == module output check guarded nothing a consumer needs: the pre-hook captures `o_proj`'s input by construction, so the stored `attn_out` is already the right tensor. The script is now assert-free, and the docstring no longer claims an in-run check. Fixture bytes are unchanged. Signed-off-by: bohaotu <bohaotu@pony.ai>
The QSA sparse-attention golden this branch adds has had nothing to read it, and reader: the attention half of the three units pegainfer-project#1105 splits QSA into, in `pegainfer-kernels/csrc/qwen4/` behind the kernels crate's own `qwen4` feature, so the model crate stays free of CUDA. Two kernels against the frozen transformers-5.16.1 semantics. One `(1 + w)` RMSNorms and partially rotates every token's two key heads once, because all twelve query heads of a KV head read the same prepared key; the other is one CTA per (query row, head) and does that head's own norm and rotation, the 256-wide score against every key, the float32 softmax, the weights, and the gated context. It consumes the visibility mask instead of deriving it: the mask is the indexer's selection AND causality, which is the previous unit's business. Rounding is reproduced at every point the reference rounds — the norms cast once, cos/sin are cast to bf16 and rotate in bf16, the score is the bf16 GEMM result rounded before the exact 1/16 scale, the weight is rounded before the context GEMM reads it, and the gate's sigmoid rounds before the final product. The float32 reduction *order* is this kernel's own, and that is the whole of the residual: three of the four cases are bit-identical on both observables, and in the 2055-token case 532 of 295920 weights sit one bf16 quantum off and 56 more than one. It follows pegainfer-project#1105's D3-B: the sm_89-first portable subset — no TMA, no cluster, no wgmma — and SIMT shuffle reductions rather than `mma.sync` bodies, because one CTA's score is a 256-wide dot and its context a 256-wide column reduction. The gate replays `test_data/qwen38fn-qsa-attn-golden.safetensors` and holds its recorded `format_version` / `transformers_version` / `config_sha256`, plus the frozen config's own attention geometry, against this build — a golden from another checkpoint fails loudly instead of skipping. Three negative controls keep it from being vacuous: the stored mask replaced by plain causality, where the three causal cases must still match and the boundary case must not; the two KV heads exchanged, a wrong GQA mapping; and the gate saturated, which must leave the weights alone and break only the output. Two more tests pin the entry point's refusals device-free and run one call at exactly the token cap, the widest shared memory this kernel asks for. Nothing on the serving path calls this yet; the qwen4 model crate will when the text graph lands. Refs pegainfer-project#1103 pegainfer-project#1105 Signed-off-by: bohaotu <bohaotu@pony.ai>
19bc006 to
0742cae
Compare
- Validate QsaAttnGeometry against the compiled constants before sizing buffers, and make an empty launch a no-op like the sibling wrappers. - Trap rows/positions outside the key array instead of clamping, with one trap binary per half of the guard. - Share the QSA norm/partial-RoPE rounding helpers and the attention cos/sin table with the indexer; the attention residual drops from 0.42 to 0.17 of the bound because the trig source is now the shared table. - Assert bit-identical short cases, add an empty-launch test, cover every null pointer, and correct the fully-hidden-row claims. Dropping the sigmoid bf16 rounding now fails the case0 exact assertion. - Update the qwen4 feature comment and the public bring-up doc. Signed-off-by: bohaotu <bohaotu@pony.ai>
- Load each lane's eight key dims with one 128-bit load; the old scalar pattern requested 77% excessive sectors on a 2048-token prefill. - Group one/two/four query heads per CTA so they share the key and value scans; four heads up to 2048 tokens, two up to 4096, one above, keeping the score row inside the 48 KiB default shared-memory cap. - Add a manual benchmark example. Same-session ABBA at 2048 tokens: 192.1 -> 111.5 ms median (1.73x); 1024: 48.1 -> 26.7 ms (1.80x); 512: 12.2 -> 6.9 ms (1.76x). - Attention gate 8/8 still passes; case3 parity counts unchanged. compute-sanitizer memcheck 0 errors at 1024/2048, racecheck 0 hazards at 1024. The four-head launch is shared-memory-limited at 33% occupancy. Signed-off-by: bohaotu <bohaotu@pony.ai>
The A/B numbers stay in the PR description; the harness was scratch and should not ship. Signed-off-by: bohaotu <bohaotu@pony.ai>
The merged `shared/ffi_guard.cuh` exposes `consume_last_cuda_error` in the global namespace. The QSA translation units each defined an anonymous-namespace helper with the same name, which nvcc 13 reports as an ambiguous overload (the Qwen4 sm_80 CI row). Give each file's helper a distinct name; behavior is unchanged. Verified with PEGAINFER_CUDA_SM=80 cargo clippy --release -p pegainfer-kernels --features qwen4 --all-targets -- -D warnings. Signed-off-by: bohaotu <bohaotu@pony.ai>
|
This pull request has merge conflicts with main. Please rebase it so it can be reviewed and merged. |
Use the shared `consume_last_cuda_error` from `shared/ffi_guard.cuh` in the Qwen4 translation units; the merge's new global helper made the local anonymous-namespace copies ambiguous under nvcc 13.
Summary
Adds the QSA sparse-attention forward for the Qwen4 line, together with the component golden and gate that pin it. The kernel consumes the indexer/selection mask (it never re-derives it) and produces the gated attention output that
o_projreads. Prefill only, over a contiguous key array, with an 8192-token cap; paged KV, decode, and the streaming softmax are later work.What changed
pegainfer-kernels/csrc/qwen4/qwen4_qsa_attn_forward.cu— one key pass that(1+w)-norms and partially rotates every token's two key heads, then one CTA per (query row, one/two/four query heads) for the bf16 score, fp32 softmax, bf16-rounded weights, context, and sigmoid output gate. The CTA's heads share one key/value scan; the head count follows the score-row shared-memory budget.pegainfer-kernels/csrc/qwen4/qwen4_qsa_norm_rope.cuh— shares the(1+w)RMSNorm and partial-RoPE rounding points with the indexer. Both kernels now read the same shared cos/sin table, so neither computes trig on the device.qwen4_qsa_attn_forward_cuda/qsa_attn_forward_into— the FFI entry and Rust wrapper. The wrapper validatesQsaAttnGeometryagainst the compiled constants before sizing any buffer, refuses a short cos/sin table, trapsrows/positionsoutside0..tokens, and treats an empty launch as a no-op like the sibling wrappers.tests/qwen4_qsa_attn_parity.rs— 8 tests: the four-case replay, three input-level controls (causal mask, KV-head swap, saturated gate), the exactMAX_TOKENSlaunch, an empty-launch test, and the refusal contract. The first three cases' weights and the first two cases' outputs are asserted bit-for-bit; the 2055-token case keeps the calibrated tolerance.tests/qwen4_qsa_attn_rows_{,negative_}trap.rs— the two halves of the out-of-rangerowsguard, one trap per process.tools/accuracy/dump_qwen38fn_qsa_attn_golden.py+test_data/qwen38fn-qsa-attn-golden.safetensors— the four-case component golden (S=8/10/64, and S=2055 with rows 2049..=2054), 38 tensors, one JSON metadata key. The dump is a pure recorder of the reference's own forward hooks; the indexer has its own oracle.Semantics
The kernel reproduces the reference's
q|gatesplit of the fusedq_projrow,(1+w)RMSNorm on q and k atrms_norm_eps, 64-of-256 partial RoPE from the shared table, GQA group 12,q·k/√256, the stored mask's polarity (non-zero = visible), fp32 softmax over the rounded bf16 scores, weights rounded to bf16 before the context reduction, and the sigmoid gate after the head-major reshape. A fully hidden row is outside the contract: the reference's two masks sum to-infand its softmax is NaN, while this kernel's single minimum produces a uniform row.Validation
CUDA 12.9.1, one RTX 5060 (sm_120):
test result: ok. 8 passed; 0 failed, 398160 weights and 540672 output elements.attn_weightsattn_outWorst bound fraction across the four cases: 0.17 (weights), 0.17 (output). The 2055-token residual is the float32 reduction order; moving the trig source to the shared table is what took it down from the earlier 0.42.
The three controls keep the gate from being vacuous: replacing the stored mask with plain causality leaves the causal cases at 0.000 of the bound and fails case 3 at 4.1e4 (weights) / 2.2e1 (output); exchanging the two KV heads fails every case (weights 9.1e3..5.5e4, output 6.1e2..7.1e3); saturating the gate leaves the weights at 0.000..0.17 and fails the output at 1.3e2..2.5e2.
Mutation calibration: dropping the bf16 rounding of the sigmoid now fails the case 0 exact assertion (36077/49152 outputs bit-identical). Before the short cases were asserted bit-for-bit, that mutation passed the whole gate.
The indexer score/selection gate was re-run after the shared-helper refactor:
test result: ok. 4 passed; 0 failed, 3674 block scores and 133 selection rows.cargo fmtand the CI-equivalentPEGAINFER_CUDA_SM=80 cargo clippy --release --locked -p pegainfer-kernels --features qwen4 --all-targets -- -D warningsrow are clean. The two trap binaries compile but were not run here; a trap poisons the context, so they must run last on a disposable GPU.Performance
The kernel was tuned with a local harness that is not committed (the A/B numbers are the evidence; a scratch benchmark stays out of the tree). Same-session ABBA medians (10 iterations x 5 rounds), CUDA 12.9.1, one RTX 5060 (sm_120), 24 heads, head_dim 256, an all-visible mask (the kernel scores every token, so mask density does not change the work), including the
attn_weightswrite:Two changes, measured one at a time: the 128-bit key load alone took 2048 from 192.8 to 133.3 ms (1.44x); two-head CTAs took it to 118.2 ms (1.63x); four-head CTAs reached 111.5 ms (1.73x).
uint4load replaces eight scalar bf16 loads. The baselinencuprofile reported 11.27B excessive sectors (77% of total sectors); the vectorized kernel has no uncoalesced-global warning.Tuning did not change the numerical contract: the attention gate is still 8/8 and case 3's parity counts are unchanged (295911/295920 weights; 36793/36864 output).
compute-sanitizermemcheck reports 0 errors at 1024 and 2048 tokens (four-head path), and racecheck reports 0 hazards at 1024.Limits
positionsarearange, so the key side cannot distinguish position from index. The query side is pinned by case 3 (tokens 2049..=2054).attn_weightswrite (192 MiB at 2048). A serving caller that only needsattn_outcould skip that observable, but the component contract still writes both.PEGAINFER_REQUIRE_GPU=1is required so a missing device cannot pass silently, and CI wiring with a GPU runner is a follow-up.Refs #1103 #1105