Skip to content

gpu: Qwen3.8's ubatch walk stores a delta layer's q, k and v projection as BF16, and runs β and α as one launch - #1039

Merged
juliankang4 merged 3 commits into
midagedev:mainfrom
juliankang4:dgx-spark-q38-stack-prefill-dense-gdn
Oct 31, 2026
Merged

juliankang4 merged 3 commits into
midagedev:mainfrom
juliankang4:dgx-spark-q38-stack-prefill-dense-gdn

Conversation

@juliankang4

Copy link
Copy Markdown
Collaborator

Qwen3.8 prefill에서 GDN 층의 q, k, v 투영을 BF16으로 저장하고 β와 α 투영을 한 번에 launch해서 4K prompt의 prefill 시간을 줄입니다.

Part of #565.

What and why

In a Qwen3.8 ubatch walk, each delta (GDN) layer projects q, k and v as f32 and the conv kernel reads them back as f32, and β and α each took their own launch per layer.

This PR changes two steps of that layer:

  • The q, k and v product stores its output as BF16 (mxfp8_w8a8_gemm_bf16 for the MXFP8 W8A8 site, bf16_mma_cols_gemm_bf16 for the BF16 site), and the conv reads those words and widens them as it goes (gdn_conv_prep_bf16). The store rounds to nearest even. A site with no BF16 product (K-quant, for example) keeps the f32 path.
  • The β and α projections are one launch at every row count, and the conv reads each row's β and α at a pitch. Decode and MTP verify rows take the same single launch.

Because q, k and v are rounded to BF16, a greedy continuation can differ from main's. This is a precision change of the BF16 store, as with the other BF16 stores in the walk.

Release notes: Changed: Qwen3.8 FP4-Local prompt prefill stores the GDN q, k and v projection as BF16 and runs the β and α projections as one launch; a 4096-token prefill is 0.9% faster.

Code shape

The review reads these first. A box left unticked needs one line of why.

  • No second copy. The BF16 and f32 conv share one body (conv_body) over a ConvIn trait, and the BF16 words are read through the existing Bf16Words. The BF16 store is an output form of the existing product launch owners, not a second launcher.
  • Model code holds only architecture facts. The Qwen3.8 files choose BF16 or f32 for the q, k and v buffer and hand β and α to one launch. The common owners gained a trait and an argument, and no per-model branch, flag or trait method.
  • Dead code is gone. site::resident_gemv_rows, the gemv-side SiteWeight argument of site::launch, the verify-node VERIFY_GDN_EXTRA constant and the gate clause that called the removed function are removed, and the pinned node counts are re-derived.
  • No silent failure. A β and α pitch below n_v is a named error with a gate clause, and a BF16 store on a site that has none reports that instead of launching.
  • Comments state what is true now. tools/check-comments.sh is ok.
  • No relaxed gate. No band or tolerance moved. The unsafe ratchet, the verify node counts and the PTX shape tables carry PIN(2026-10-31) lines with their reason.
  • cargo fmt and cargo clippy are clean on what I touched; the lint count did not go up.

Duplication found or removed (path:line), or "none": the duplicate scan lists eight candidates, all different. crates/gpu/src/linear/conv.rs:284 and crates/gpu/src/linear/conv.rs:388 are the f32 and BF16 store_ring, which read different lane layouts. crates/gpu/src/gemm/f32tile.rs:2379 and crates/gpu/src/gemm/f32tile.rs:2459, and crates/gpu/src/gemm/f32tile.rs:2470 and crates/gpu/src/gemm/f32tile.rs:2368, are launch-shape matches for two different MMA forms. crates/gpu/src/gemm/kernels32.rs:1368 with crates/gpu/src/gemm/kernels32.rs:1324 and with crates/gpu/src/q8f32.rs:2147, crates/gpu/src/gemm/nvfp4mma.rs:1850 with crates/gpu/src/gemm/nvfp4mma.rs:1799 and crates/gpu/src/linear/conv.rs:772 with crates/gpu/src/linear/conv.rs:713 are kernel entries that call one shared body, because a #[kernel] entry cannot be generic. crates/gpu/src/site.rs:1639 and crates/gpu/src/site.rs:1622 are the f32 store, which always launches, and the BF16 store, which reports whether the site has one.

What I ran

Card conditions of every run below, from nvidia-smi:

NVIDIA GB10, 595.84
| NVIDIA-SMI 595.84                 Driver Version: 595.84         CUDA Version: 13.3     |

Speed

One FP4-Local checkpoint (Qwen3.8-Flash-Next-FP4-Local) on one DGX Spark, the default sm_121a build. Each request is a 4096-token prompt with 128 greedy tokens. Three rounds per side in alternating order after a warm-up, medians below. The base is upstream main at 904266cfc. The first two rows are the draft-off arm and the rest are the MTP draft arm.

Metric Base Head Difference Ranges overlap
FP4-Local code prefill (tok/s) 2824.44 2850.95 +0.938% no
FP4-Local code TTFT p95 (ms) 1455.58 1441.57 -0.962% no
FP4-Local code decode (tok/s) 31.5715 31.3833 -0.596% yes
FP4-Local MTP code prefill (tok/s) 2649.84 2665.68 +0.598% no
FP4-Local MTP code TTFT p95 (ms) 1551.25 1541.89 -0.604% no
FP4-Local MTP code decode (tok/s) 58.414 57.026 -2.376% no
FP4-Local MTP prose prefill (tok/s) 2697.86 2726.17 +1.049% no
FP4-Local MTP prose TTFT p95 (ms) 1523.85 1507.88 -1.048% no
FP4-Local MTP prose decode (tok/s) 42.1273 41.7408 -0.918% yes

On the draft-off arm, prefill and TTFT improve by more than the same-revision resolution of the measurement (0.568% and 0.565%), and the ranges of the two sides are separate. Every other cell is inside its resolution (0.729% to 7.114%), so I claim a gain only for the draft-off prefill and TTFT.

Reproduce one round, with base on upstream main and head on this branch (the MTP rounds add BLOOMERY_DRAFT=mtp BLOOMERY_MTP_DRAFT="${MTP_MODEL}" BLOOMERY_MTP_WIDTH=cost):

(cd base && cargo oxide build --arch "$(bash tools/arch.sh)" -- -p bloomery-gpu-gates --features glm5next,clef,vision,mimo2,solar2,minimaxm3,gemma-embedding2,hf,checkpoint --release --bin bloomery-serve)
env BLOOMERY_DRAFT=off BLOOMERY_HOST_ROOM=96G ./base/target/release/bloomery-serve -m "${MODEL_FP4L}" --host 127.0.0.1 --port 18731 --parallel 1 --ctx-size 16896 & server_pid=$!
until curl -fsS http://127.0.0.1:18731/health; do sleep 1; done
kill "$server_pid"; wait "$server_pid"

GPU gates

gate_linear covers the BF16 conv (reading channels as BF16 words over a random ring, β and α at pitch 64, and the refused pitch of n_v - 1). gate_gemm --case dense_mma covers the BF16 store of the BF16 and the MXFP8 W8A8 products. The summary lines:

gate_linear: 196 clauses, 0 failed — PASS
conv m=512 at p0=1000, β·α joined at pitch 64: y same=4194304/4194304 max_ulp=0 beta same=16384/16384 max_ulp=0 decay same=16384/16384 max_ulp=0 ring same=90112/90112 max_ulp=0 PASS
conv at pitch n_v - 1 refused by name: enqueue_conv_prep: ba_pitch 31 < n_v 32: a token's β and α rows overlap the next's PASS
conv m=512 at p0=1000 over a random ring, channels as BF16 words: y same=4194304/4194304 max_ulp=0 beta same=16384/16384 max_ulp=0 decay same=16384/16384 max_ulp=0 ring same=90112/90112 max_ulp=0 PASS
dense_mma bf16 cells rows=130 K=128 n=130 row64_odd=true col129_odd=true others_kept_k=true odd_lines_exact=true bf16_cols_bits=true bf16_store_bits=true PASS
gemm32 case=mxfp8_w8a8 rows=129 K=128 n=127 cells_off_oracle=0 eager_graph_eq=true guard=true bf16_store_bits=true PASS

The BF16 store clause tells rounding from truncation. With a store that truncates, the same run fails it:

dense_mma bf16 cells rows=130 K=128 n=130 row64_odd=false col129_odd=true others_kept_k=true odd_lines_exact=true bf16_cols_bits=true bf16_store_bits=false FAIL

The verify node counts that β and α as one launch changes are re-derived, and the fixture tier of the end-to-end gate holds them:

BLOOMERY_TIER=fixture just gate-gpu-qwen4exp-e2e
gpu-gate.sh: gate_qwen4exp_e2e ran 445 s (exit 0)
verify structure T=2: graph_nodes=113 (want 113; the program counts 113) batch_mem_op=9 (want 9) other=1 (want 1: the PLE rows' copy); argmax launches 1

The by-name site clauses after the removal of the gemv row window:

just gate-gpu-bf16
gpu-gate.sh: gate_bf16 ran 24 s (exit 0)
just gate-gpu-mxfp8
gpu-gate.sh: gate_mxfp8 ran 17 s (exit 0)

The PTX spill ratchet, with the three new entries pinned:

just gate-ptx-spill
GATE-PTX-SPILL rc=0

The A/B above ran the real bloomery-serve of this change on the card.

Hardware (if the change is for a machine)

DGX Spark (GB10), driver 595.84, CUDA 13.3, build sm_121a. mxfp8_w8a8_gemm_bf16 is an sm_121a entry.

AI help

AI 코딩 에이전트가 코드를 작성한 후 설명을 썼고, 테스트를 마쳤습니다. diff 리뷰와 PR 초안 리뷰는 제가 직접 했습니다.

@github-actions github-actions Bot added gpu The card engine: crates/gpu*, crates/app needs-gpu Changes code a card must check, and carries no gate run on a card tools Tools, recipes, CI and the toolchain: tools/, justfile, .github/, Cargo labels Oct 31, 2026

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Verdict: MUST_FIX: Remove the single-use model-level projection wrapper.

Must fix

1. Inline the BF16 buffer adapter

wide38.rs:1921 has exactly one caller, wide38.rs:2614. It only selects the fallback, cuts a buffer window, and forwards to the existing site owner. The diff supplies no second use. This is an unnecessary abstraction under the fewest-elements rule (kind B).
Cost: inlining removes a function and its forwarding signature without changing launches.
Delete dense_gemm_bf16 and replace its call in gdn with:

let bf16 = if reads_q8_1(c, &gp.qkv)? {
    false
} else {
    let mut half =
        WindowMut::<u16>::of_mut(&mut g.x, 0, m * GDN.channels())?;
    site::resident_gemm_bf16(
        c.gpu,
        &dense_kernels(c),
        (w, &gp.qkv),
        &wide_in(mixed, &x.act_hid),
        dense,
        m,
        &mut half,
    )?
};

Non-blocking

⚠ Complete the precision-change evidence: wide38.rs:2614. The timing table names base 904266cfc but no tested head hash; the reproduction block omits the request command. Attach both tested hashes and request commands/output. The BF16 store changes precision: also attach the error model and end-to-end count-pin result required by AGENTS.md's precision-change class. The reported component comparisons and verify-node result do not supply that evidence.

Checked

Duplicates: all three dryer pairs read; they share existing cores with different types or rules. The added model wrapper has one caller.
Complexity: gemm_into moves dispatch and adds output cases; conv_held adds packing/upload/launch/readback setup. Gate growth adds comparisons; launchers add validation/output dispatch; wide38::gdn adds the BF16 choice.
Silent failure: the packing default fills an absent halfword; unsupported BF16 outputs return false for fallback. Conv validates the projection pitch and buffer spans.
Tests: conv comparisons cover output, beta, decay and ring; BF16-store checks compare against round-to-nearest-even bits. Verify-node counts remain asserted. Static inspection only; GPU gates were not rerun.
Gates: verify-node, unsafe and PTX pins have dated reasons; no tolerance moved. The removed gemv-row clause has a dated removal reason.
Escapes: no catalog pattern matched.

Card check: needs-gpu. This pull request changes crates/gpu-gates/src/bin/gate_bf16.rs, crates/gpu-gates/src/bin/gate_gemm.rs, crates/gpu-gates/src/bin/gate_linear.rs … and carries no gate run on a card (CONTRIBUTING's evidence list); a maintainer adds gpu-verified after one.

🔨 Shingler, bloomery's review bot: approves a clean pull request and requests changes on a must-fix (how it works)

… BF16, which the conv widens as it reads

The ubatch walk's q·k·v product of a delta layer (an MXFP8 site's W8A8
product, or a BF16 site's product over its held BF16 form) stores each
cell as the BF16 bits of its F32 sum, rounded to nearest even
(`ProductOut`, `site::resident_gemm_bf16`), into the first halfwords of
the same rows. Any other site type keeps the F32 product and the F32 conv.

The new conv entry, gdn_conv_prep_bf16, reads those words and widens each
value: its outputs are the F32 conv's rule over the widened values, the
ring included. Lane l owns the channel pairs 2l, 2l + 1 and 64 + 2l,
65 + 2l of its head, so each word of a row is one load of the lane and a
warp's load is one 128-byte line, and it writes each pair's two outputs as
one 8-byte store, so a warp's store is one 256-byte line. The conv and the
norm are the F32 entries' helpers (`conv_channel`, `head_norm`); the body
reads the channels through `ConvIn`, which the F32 entries implement with
their own head and ring store.

gate_linear's conv clause runs BF16 words at m=512, 2 and 1 over a random
ring against the host rule over the widened values on the entry's lanes;
gate_gemm's dense_mma and mxfp8_w8a8 cases hold the BF16 store to each
cell's F32 bits rounded to nearest even, a NaN as a NaN.
…onv reads each row's β and α at a pitch

The joined β·α weight (2·n_v rows, β's first) ran as two products past one
row, β's rows and α's rows, so that each landed token-major in its own
window. Both GDN conv entries (F32 and BF16 channels) now take `ba_pitch`,
the values from one token's β (α) to the next, so they read the joined
product's rows in place: each row's β values, then its α's. The ubatch
walk's MXFP8 product and the step's and verify's gemv each run once over
the whole weight, every output bit for bit the part's it replaces; a delta
layer has one launch fewer past one row (GDN_ROWS_LAUNCHES goes). Qwen3.5
and the KDA entries keep their own rows at pitch n_v.

gate_linear's conv clauses run the joined rows at pitch 2·n_v, the BF16
channels' among them, against the host rule and refuse a pitch below n_v
by name.
@juliankang4
juliankang4 force-pushed the dgx-spark-q38-stack-prefill-dense-gdn branch from 0a0180e to 808f410 Compare October 31, 2026 05:11

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Since the last review:

  • Must fix 1: fixed. The single-use adapter is removed and its call is inlined at wide38.rs:2595.
  • Evidence question: still open at the projection below.

Verdict: CLEAN: The interdiff resolves the wrapper finding; the precision-evidence request remains non-blocking.

Non-blocking

⚠ Complete the precision-change evidence: wide38.rs:2599. The body omits the tested head hash and benchmark request command/output. Attach those, the error model (σ against exact-forced-32.tsv), and the gate-gpu-e2e count-pin result required by AGENTS.md's precision-change class. The reported verify-node result covers graph structure.

Checked

Duplicates: no new definitions in the interdiff; the single-use adapter is deleted.
Complexity: gdn inlines the existing BF16 choice and reuses the input descriptors for both projections.
Silent failure: unsupported BF16 output returns false before launch; gdn then uses dense_gemm and the conv reads f32.
Tests: no tests changed in the interdiff. GPU gates were not rerun.
Gates: no pin, tolerance or case changes in the interdiff.
Escapes: no catalog pattern matched. No ledger lessons apply.

Card check: needs-gpu. This pull request changes crates/gpu-gates/src/bin/gate_bf16.rs, crates/gpu-gates/src/bin/gate_gemm.rs, crates/gpu-gates/src/bin/gate_linear.rs … and carries no gate run on a card (CONTRIBUTING's evidence list); a maintainer adds gpu-verified after one.

🔨 Shingler, bloomery's review bot: approves a clean pull request and requests changes on a must-fix (how it works)

@juliankang4

Copy link
Copy Markdown
Collaborator Author

The review is done and the contribution check passed, so I'm merging this.

@juliankang4
juliankang4 merged commit c19d799 into midagedev:main Oct 31, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

gpu The card engine: crates/gpu*, crates/app needs-gpu Changes code a card must check, and carries no gate run on a card tools Tools, recipes, CI and the toolchain: tools/, justfile, .github/, Cargo

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant