Skip to content

CUDA: MMQ tile configuration and FP4 accumulation for GB10 (sm_121) - #198

Draft
danielhanchen wants to merge 5 commits into
masterfrom
opt/gb10-mmq-tiles
Draft

CUDA: MMQ tile configuration and FP4 accumulation for GB10 (sm_121)#198
danielhanchen wants to merge 5 commits into
masterfrom
opt/gb10-mmq-tiles

Conversation

@danielhanchen

@danielhanchen danielhanchen commented Sep 6, 2026

Copy link
Copy Markdown
Member

Prompt processing on GB10 (DGX Spark, sm_121a) is matmul bound, and MMQ is the kernel that runs every
quantized mul_mat. An audit of the sm_121a SASS put the MMQ mainloop at about 31 % of the int8
tensor-core peak and 20 to 27 % of the FP4 peak at prompt-sized batches, against 65 to 71 % for
CUTLASS on the same GPU. Two of the causes are cheap to remove, and both are confined to the DGX
Spark configuration: an exact == GGML_CUDA_CC_DGX_SPARK on the host and the same test on
__CUDA_ARCH__ in device code, for the tile table and for the FP4 accumulator alike.

1. The dense tile is one block per SM. At J = 128 the 128-row tile needs 61.5 KB of shared memory
and the kernel asks for 256 threads, so exactly one block is resident and nothing overlaps the
register-staged global loads or the two __syncthreads per K step. The MMA layout ties I to 16 rows
per warp, so a 64-row tile at 128 threads is the only way to get two resident blocks (40.5 KB per
block at J = 128, two fit in the 100 KB per SM of GB10). Same kernels and more resident warps.

Not the same numerics on GB10, and that correction matters to anyone deploying this. An earlier
version of this section said "same numerics". It is wrong. These kernels use stream-k, which splits
the K dimension across CTAs and combines the partial sums afterwards, and the tile shape is what
decides that split. Changing I from 128 to 64 therefore reassociates a floating point sum.
Measured directly, one MUL_MAT per (type, shape) on the CUDA backend with GGML_CUDA_FORCE_MMQ=1
and a hash of the raw f32 output, 8 types x 6 shapes, every case computed twice to confirm
determinism:

type n=1 n=16 n=128 k=1024 m=512 n=512 k=2048 m=1024 n=2048 m=255 (fallback)
nvfp4 same same same differs differs same
mxfp4 same same same same same same
q4_K same same same differs differs same
q5_K same same same differs differs same
q6_K same same same differs differs same
q4_0, q8_0, iq4_xs same same same same same same

8 of 48, all on the five types this table re-tiles, all where the 64-row tile changes the stream-k
split. The types deliberately left on the stock tile are bit-identical everywhere, and the m=255
fallback column is bit-identical for every type.

Greedy generation follows from it. llama-cli -ngl 99 --temp 0 --top-k 1 --seed 42 -n 160 --single-turn on a Q4_K model, single device, default clocks, no --rpc:

run md5 of generated text
base, run A ebaa06f1579d5edd
base, run B ebaa06f1579d5edd
this PR e404e817a0e8f7c2

The base-against-base control is byte-identical, so the divergence is the change and not
harness noise. The output splits mid-sentence.

This is not a correctness bug. test-backend-ops test -b CUDA0 gives -o MUL_MAT 1193/1193 and
-o MUL_MAT_ID 869/869, matching the merge base exactly on both. A reassociated FP32 sum is what
every MMQ tile change in this file does, and PTX states that for mma "The accumulation order,
rounding and handling of subnormal inputs are unspecified".

It does mean a DGX Spark deployment cannot take this without revalidating output. Anything
pinned to an exact generation, a cached response, a golden transcript or a reproducibility claim on
GB10 will move. No other GPU is affected: sm_75, sm_80 and sm_90 are byte-identical in SASS to the
merge base, and sm_120's only remaining difference is bit-neutral (see below).

mul_mat_id does not want this. The MoE launch picks J from ncols_max = the total token count
while each expert only holds ncols_max*n_expert_used/n_experts columns, so halving the tile height
only doubles the number of row tiles that pay for a mostly empty J tile: measured at -12 to -21 % on
MUL_MAT_ID at 2048 tokens. The table therefore keeps the stock 128-row tile in its
fallback == true entries and mul_mat_id asks for that configuration on Blackwell. Requesting the
bounds-checked variant cannot change results, it only enables the src0->ne[1] range check in the
tile loads and the write-back.

2. The FP4 accumulator is copied. mma.sync...m16n8k64 block-scaled already takes C as an
accumulator input, but ggml_cuda_mmq_vec_dot_fp4_fp4_mma zeroed a temporary tile per instruction
and added it into sum[] afterwards. Accumulating into sum[] directly removes 805 FADD and all of
the register spilling (26 STL / 30 LDL) from the NVFP4 J = 128 kernel; 224 -> 256 OMMA visible in the
loop.

The second commit fixes a latent bug found on the way: mul_mat_q_case re-derived fallback from
args.nrows_x % 128 and ignored the flag ggml_cuda_mul_mat_q had already computed and already used
to size the src1 padding through ggml_cuda_mmq_get_J_max. The two agreed by construction while
the only input was ne01, but any other reason to want the bounds-checked configuration was silently
dropped. The decision is now carried in mmq_args, so the configuration the padding was sized for is
the configuration that runs.

Measurements

DGX Spark GB10, sm_121a, nvidia-smi -lgc 300,2100, every cell at 2086 to 2093 MHz, single node with
the GPU lock held, no other compute process resident. test-backend-ops perf -b CUDA0, each cell
bracketed by a base cell before and after; the two base cells agree to within 2 % on every dense
case and to 0.1 % end to end.

Dense MUL_MAT, TFLOP/s (N x M x K, M = tokens)

shape type base patched
34816 x 512 x 5120 q4_K 62.03 68.28 +10.1 %
34816 x 2048 x 5120 q4_K 62.49 69.19 +10.7 %
34816 x 512 x 5120 q5_K 60.61 65.68 +8.4 %
34816 x 2048 x 5120 q5_K 61.11 66.52 +8.9 %
34816 x 512 x 5120 q6_K 48.36 52.52 +8.6 %
34816 x 2048 x 5120 q6_K 46.89 52.18 +11.3 %
34816 x 512 x 5120 mxfp4 105.03 124.04 +18.1 %
34816 x 2048 x 5120 mxfp4 103.53 123.13 +18.9 %
34816 x 512 x 5120 nvfp4 123.90 136.67 +10.3 %
34816 x 2048 x 5120 nvfp4 126.20 142.12 +12.6 %
8192 x 512 x 5120 q4_K 52.00 61.57 +18.4 %
8192 x 512 x 5120 nvfp4 68.53 117.76 +71.8 %
5120 x 512 x 17408 q4_K 44.95 57.04 +26.9 %
5120 x 512 x 17408 nvfp4 53.16 96.83 +82.1 %
5120 x 2048 x 2048 nvfp4 77.39 89.01 +15.0 %
34816 x 32 x 5120 q4_K 19.07 18.98 -0.5 %
34816 x 512 x 5120 q8_0 66.89 66.44 -0.7 %

Batch 32 and q8_0 are unchanged: q8_0, q4_0, q5_0, iq4_xs and iq4_nl are deliberately left on the
stock tile, since the narrow tile was worth under 2 % for them at large N and cost 23 % at N = 512.
The types that gain are the ones whose mainloop is rescale bound (the K-quants) or FP4.

MoE MUL_MAT_ID, TFLOP/s (256 experts, 8 used)

Two base cells are shown because the first MoE cell of a window is cold; the patched cell sits inside
the base bracket on every case, i.e. no regression.

shape type base patched base (bracket)
1024 x 512 x 2048 q4_K 5.64 6.28 6.47
1024 x 2048 x 2048 q4_K 15.41 16.55 17.01
1024 x 2048 x 2048 q8_0 12.20 12.40 12.46
1024 x 2048 x 2048 mxfp4 18.75 20.91 20.29
1024 x 2048 x 2048 nvfp4 19.86 20.55 20.50
2048 x 2048 x 512 q4_K 10.33 11.83 12.12
2048 x 2048 x 512 nvfp4 11.05 14.30 13.27

Without the mul_mat_id guard the same build gives q4_K 1024 x 2048 x 2048 at 13.64 (-18.6 % against
a 16.75 base) and q8_0 at 9.80 (-20.9 %), which is what the guard is there to avoid.

End to end

llama-batched-bench -m Qwen3.8-27B-UD-Q4_K_XL.gguf -c 4608 -b 2048 -ub 2048 -ngl 99 -fa on -npp 512,2048 -ntg 16 -npl 1, base / patched / base in one lock window at one clock state.

base patched base (bracket)
pp512 t/s 768.03 835.63 768.61 +8.8 %
pp2048 t/s 800.35 830.09 799.96 +3.7 %

Things that were tried and did not work

  • cp.async for the y tile. Both K halves fetched with cp.async.cg into a double buffer, one
    cp.async.wait_all plus one __syncthreads replacing the two register copy loops and one of the
    two barriers per K step. 21 to 24 % slower on every dense shape (q4_K 62.4 -> 48.6 TFLOP/s at
    M = 512). The extra y tile in shared memory costs more occupancy than the asynchronous copy buys.
  • 192-row / 384-thread tiles. ptxas caps the K-quant kernels at 168 registers and spills (136 B
    stack on q4_K J = 128); the build faults at runtime on MUL_MAT_ID.
  • Hoisting the Q4_K block rescale. Both operand scales change every 32 k, so the product
    dA_ik * dB_jk is a different scalar for every (i, j, k-block) and no partial sum can stay in
    int32 across k-blocks. Accumulating in int32 across a 256-wide superblock, which is what
    CUTLASS-style block-scaled kernels do, requires quantizing the activations with one scale per 256
    values instead of per 32. That changes results and needs a new quantize_mmq layout; it is not
    part of this PR.

Proof that nothing else is touched

  • The diff is four files, all under ggml/src/ggml-cuda/: the Blackwell tile table, the FP4 vec_dot,
    and the two lines of mmq.cu / mmq.cuh that carry the tile choice. No RPC, backend-registry,
    CPU, Metal, Vulkan, HIP or SYCL file is touched.

  • Every table entry that changed is inside ggml_cuda_mmq_get_config_dgx_spark, reached only under
    an exact ggml_cuda_highest_compiled_arch(cc) == GGML_CUDA_CC_DGX_SPARK on the host and
    __CUDA_ARCH__ == GGML_CUDA_CC_DGX_SPARK in device code. The mul_mat_id term in fallback is
    gated the same way. Every other GPU selects exactly the configuration it selected before: a
    13200-row evaluation of the real host selector (31 compute capabilities x 20 types x 11 J x both
    fallback values) shows 0 differing rows against the merge base for every non-GB10 cc, and 0 of
    13200 on a build whose architecture list does not contain sm_121.

  • The narrowing took two commits, and this is worth stating precisely rather than as "narrowed".
    The first narrowing was entirely in the tile configuration selector. The FP4 accumulator change in
    mmq-vec-dot.cuh sits inside ggml_cuda_mmq_vec_dot_fp4_fp4_mma, compiled for every
    architecture defining BLACKWELL_MMA_AVAILABLE, that is __CUDA_ARCH__ in [1200, 1300), which is
    all of sm_120 too. So sm_120 went from 5 of 23 MMQ translation units differing, to 2, and only
    reaches 0 with the second commit that gates that hunk as well. Per-arch, differing translation
    units against the merge base, from cuobjdump -sass over all 23 units at
    75-real;80-real;90-real;120a-real;121a-real:

    arch before either narrowing after the tile-selector narrowing now
    sm_75, sm_80, sm_90 0 of 23 0 of 23 0 of 23
    sm_120a 5 of 23 2 of 23 0 of 23
    sm_121a 5 of 23 5 of 23 5 of 23

    The severity of that middle column is worth keeping separate from the tile question above: what
    sm_120 was getting was an unmeasured code generation change, not an unmeasured numerics
    change. The merge base plus that hunk alone, against the merge base, is bit-identical on all 48
    (type, shape) cases
    , MXFP4 and NVFP4 included. Those are different risks. It is gated anyway,
    because it was swept on a DGX Spark and nowhere else.

  • GB10 loses nothing to that second gate: a strict SASS comparison of the tile-selector-narrowing
    commit against the current head is byte-identical on sm_121a for all 23 translation units.

  • The FP4 vec_dot change is inside ggml_cuda_mmq_vec_dot_fp4_fp4_mma, which only exists for the
    block-scaled FP4 MMA path, and is now itself behind
    #if defined(__CUDA_ARCH__) && __CUDA_ARCH__ == GGML_CUDA_CC_DGX_SPARK, with the merge base's
    zero-a-temporary-and-add form restored in the #else.

  • cmake -DGGML_CUDA=OFF -DGGML_RPC=ON -DGGML_NATIVE=ON builds clean with the patch applied
    (test-backend-ops and llama-bench both link).

  • test-backend-ops test -b CUDA0 on the patched build: 13572/13572 tests passed, Backend CUDA0:
    OK
    . Restricted runs: -o MUL_MAT 1193/1193, -o MUL_MAT_ID 869/869, both matching the merge
    base exactly, single device, no --rpc.

  • No AMD hardware was available, so the HIP argument is from source only: the device branch is
    inside the non-HIP arm of #ifdef GGML_USE_HIP and additionally tests defined(__CUDA_ARCH__),
    and the host branch sits after the unconditional GGML_CUDA_CC_IS_AMD return and is itself gated
    on GGML_CUDA_CC_IS_NVIDIA(cc). The host half is executed evidence: 0 of the 1320 AMD rows in the
    selector matrix differ.

Still open

The CUTLASS W4A4 path of ggml-org#26704 reaches roughly 250 TFLOP/s on this class of GPU,
still about 1.75x what this gets to, so the tile table is not the end of the story. ggml-org#26977
(routed-MoE tile width from the mean columns per expert) and ggml-org#26159 (compact expert/tile job
list for the NVFP4 MoE grid) cherry-pick cleanly onto this and are a real MoE win here (+37 % q4_K
experts at 128 tokens, +39 % NVFP4 at 2048); they are upstream PRs, so they are not duplicated in
this one.

Dense prompt matmuls on GB10 run the MMQ mainloop at one block per SM: the 128-row tile needs
61.5 KB of shared memory at J=128 and the kernel asks for 256 threads, so nothing can overlap the
register-staged global loads or the two barriers per K step. Halving the tile to 64 rows with 128
threads and occupancy 2 makes two blocks resident and is worth 9 to 13 percent on the K-quants at
prompt-sized batches. mul_mat_id does not want this: each expert only holds
ncols_max*n_expert_used/n_experts columns, so halving the tile height only doubles the number of
row tiles that pay for a mostly empty J tile. The tile table therefore keeps the stock 128-row tile
in its fallback == true entries and mul_mat_id asks for that config on Blackwell; requesting the
bounds-checked variant cannot change results, it only enables the src0->ne[1] range check.

The block-scaled FP4 MMA already takes C as an accumulator input, but the vec_dot zeroed a
temporary tile per instruction and added it to the running sum afterwards. Accumulating into the
sum directly removes 805 FADD and all of the register spilling from the NVFP4 J=128 kernel.

Both changes are confined to the Blackwell configuration and to code under
BLACKWELL_MMA_AVAILABLE, so no other GPU sees a different kernel.
…re-deriving it

mul_mat_q_case re-derived the fallback flag from args.nrows_x % 128, so the flag that
ggml_cuda_mul_mat_q computes (and that already sizes the src1 padding through
ggml_cuda_mmq_get_J_max) never reached the kernel selection. It matched by construction while the
only input was ne01, but any other reason to want the bounds-checked configuration was silently
dropped, including the mul_mat_id case added in the previous commit. Carry the decision in mmq_args
so the configuration the padding was sized for is the configuration that runs. No behaviour change
for the ne01-only rule.
@danielhanchen

Copy link
Copy Markdown
Member Author

@codex security review

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 9, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-09T04:46:12.533305Z d0a262e Manual request
ℹ️ 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Another round soon, please!

Reviewed commit: 585f9c9eb4

ℹ️ 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".

@danielhanchen

Copy link
Copy Markdown
Member Author

What this changes, in four questions

1. Before the PR, what happened?

On GB10 (sm_121) the Blackwell MMQ tile table used the stock tile configuration for the K-quants and the FP4 types, and the block-scaled MMA path re-derived its accumulator handling rather than accumulating into the running sum.

2. After the PR, what happens?

The tile table carries a 64-row / occupancy-2 tile for those types, which is a win for dense mul_mat. mul_mat_id deliberately keeps the stock 128-row tile through the fallback == true entries, and the fallback choice is decided once in ggml_cuda_mul_mat_q and carried down rather than re-derived, so the configuration matches the one the src1 padding was sized for.

3. Is this a real issue or a fake one?

Real, and it is a throughput issue rather than a correctness one, which is worth stating plainly. Any user running a K-quant or FP4 GGUF on a GB10 gets the tile shape chosen for a different Blackwell part. The mul_mat_id half is the sharper point: the MoE launch picks its J from ncols_max, the total token count, while each expert holds only ncols_max * n_expert_used / n_experts of it, so halving I there just doubles the number of row tiles paying for a mostly empty J tile. Selecting the 64-row tile for MoE would have been a regression, which is why the table keeps 128 there.

4. If merged, does it break anything?

This is the one PR in my batch that is not RPC-gated, so it is live for every CUDA user, and that is the bar it was held to:

  • Other GPUs are untouched. The new tile entries live in mmq-config-blackwell.cuh and the mul_mat_id selection is gated on blackwell_mma_available(cc), so no non-Blackwell part sees a different kernel.
  • Results cannot change. fallback only enables the src0->ne[1] range check in the tile loads and the write-back. Requesting the bounds-checked variant is always safe; it is a check, not a different arithmetic path.
  • Non-FP4 paths are unaffected: the accumulator change is inside the block-scaled MMA branch.
  • Model agnostic. The selection keys on tensor shape and quantisation type, never on an architecture or checkpoint.

Verified: builds clean with -DGGML_CUDA=ON -DGGML_RPC=OFF, and test-backend-ops -b CUDA0 matches the merge-base count exactly, which is the check that would catch a changed numerical result. Not executed on AMD or on a non-Blackwell NVIDIA part; the gating is compile-time and by compute capability, so those paths are unreachable rather than untested by luck.

Also carries a comment-reduction pass, AST-gated as comments-only against the pre-pass head.

@danielhanchen

Copy link
Copy Markdown
Member Author

One coverage note that bears directly on the gate here.

The dispatch predicate is blackwell_mma_available, ggml/src/ggml-cuda/common.cuh:360-363:

static bool blackwell_mma_available(const int cc) {
    return GGML_CUDA_CC_IS_NVIDIA(cc) && ggml_cuda_highest_compiled_arch(cc) >= GGML_CUDA_CC_BLACKWELL &&
           ggml_cuda_highest_compiled_arch(cc) < GGML_CUDA_CC_RUBIN;
}

Note it tests ggml_cuda_highest_compiled_arch(cc) rather than cc directly, so it depends on what the binary was compiled for as well as the device. For a build that targets sm_120, which is the ordinary shipped case, the predicate is true on sm_120. GGML_CUDA_CC_BLACKWELL is 1200 and GGML_CUDA_CC_RUBIN is 1300 (common.cuh:59,61), so the range is all of sm_12x, not just sm_121. That includes every RTX 50-series card and the RTX PRO 6000 Blackwell.

What changes for sm_120: Q4_K, Q5_K and Q6_K previously had no Blackwell entry and fell through to the Ampere config (nthreads 256, occupancy 1, I=128) (mmq-config-ampere.cuh:157-206); they now take (128, 2, 64) from mmq-config-blackwell.cuh:2-36. MXFP4 and NVFP4 move (256, 1, 128) to (128, 2, 64) at :38-54 and :56-72. And mmq.cu:132 now forces every mul_mat_id on any Blackwell GPU onto the bounds-checked fallback kernel even when ne01 % 128 == 0:

const bool fallback = ne01 % 128 != 0 || (ids && blackwell_mma_available(cc));

Q4_K covers essentially every Q4_K_M GGUF, so this is the common case on a 5090.

This is measured, not inferred. Compiling every MMQ translation unit (mmq.cu plus all 22 mmq-instance-*.cu) on the base and on this head and diffing cuobjdump -sass pair by pair gives:

  • sm_75, sm_80, sm_90: 66 of 66 instance TUs plus mmq.cu byte-identical. Pascal through Hopper are provably untouched.
  • sm_120: 5 of 22 differ, specifically q4_k, q5_k, q6_k, mxfp4, nvfp4.

Correctness on GB10 is fine: test-backend-ops gives 0 FAIL on MUL_MAT (1563 cases) and MUL_MAT_ID (872 cases) on both base and head, and shared memory goes down rather than up, so there is no launch-failure risk.

I could not measure the sm_120 performance impact, because there is no RTX 50-series GPU on the pair I tested on. So the position is narrow and specific: the kernels provably change for that population, and the effect on them is unmeasured.

GGML_CUDA_CC_DGX_SPARK = 1210 already exists at common.cuh:60, and exact-equality GB10 gating is already an established pattern in this tree, in three places:

  • mmvq.cu:88, compile time: __CUDA_ARCH__ == GGML_CUDA_CC_DGX_SPARK
  • mmvq.cu:111, runtime: ggml_cuda_highest_compiled_arch(cc) == GGML_CUDA_CC_DGX_SPARK
  • mmvq.cu:321, runtime: cc == GGML_CUDA_CC_DGX_SPARK

mmvq.cu:111 is the closest parallel, since it tests the same ggml_cuda_highest_compiled_arch(cc) this dispatch uses. For completeness, fattn.cu:194 also references the constant but as a threshold (cc >= GGML_CUDA_CC_DGX_SPARK) inside a GLM-specific branch, so it is a related use rather than a precedent for equality.

Narrowing the dispatch to an exact-equality check on GGML_CUDA_CC_DGX_SPARK would keep the tuning where it was measured and leave sm_120 on the path it is on today.

The swept tiles were written into mmq-config-blackwell.cuh, so every sm_12x
device got a table measured on one DGX Spark. GB10 is one SM cluster against
unified LPDDR5X; a discrete Blackwell part has neither, and nothing in the sweep
was measured on one.

The tiles move to mmq-config-dgx-spark.cuh and
mmq-config-blackwell.cuh returns to master byte for byte. Types the sweep did not
cover fall through to the Blackwell table, so this narrows the blast radius
without narrowing coverage.

Dispatch is an exact equality against GGML_CUDA_CC_DGX_SPARK, not a >= range,
following mmvq.cu:111, which tests the same ggml_cuda_highest_compiled_arch the
existing dispatch uses. fattn.cu:194 is a >= threshold and is deliberately not
the pattern copied here: a range would catch every future sm_12x part with a
table measured on none of them. The host and device selectors mirror each other,
or the host would size shared memory for one config while the kernel compiled
another.

Verified by SASS rather than by reading. Compiling the q4_k, q5_k, q6_k and q8_0
MMQ instantiations for sm_75, sm_80, sm_90, sm_120 and sm_121a and disassembling
each cubin, against UPSTREAM MASTER as the reference:

  sm_75, sm_80, sm_90, sm_120: match master for all four types
  sm_121a: differs for q4_k, q5_k, q6_k; matches master for q8_0

which is exactly the intended shape, since q8_0 is not in the Spark table. The
residual on every matching cell is 32 lines carrying one constant, 0x3bf to
0x3ca, an assert's __LINE__ shifted by the 11 lines this adds above it,
materialised as MOV on sm_75, IMAD.MOV.U32 on sm_90 and HFMA2 on sm_80 and
sm_120. No instruction, register allocation or scheduling differs, so the claim
is "unchanged apart from an assert's line number" rather than byte-identical.

ggml-cuda builds green for sm_121a.
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Sep 9, 2026
@danielhanchen

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. What shall we delve into next?

Reviewed commit: d0a262e1a6

ℹ️ 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".

@danielhanchen

Copy link
Copy Markdown
Member Author

Simulation against the merge base, fbf9abcc7

Two DGX Sparks, CUDA 13.0.88 (36424714_0) on both, ccache, -j2. Every build directory refuses
to run unless its recorded source tree and the md5 of mmq.cu, mmq.cuh and mmq-vec-dot.cuh
match a manifest written before any test. All 23 MMQ translation units compiled at
75-real;80-real;90-real;120a-real;121a-real and cuobjdump -sassed, 230 dumps, 4.7 GiB.

The gate narrowing works, and it is what it was for

The head adds 11 lines to mmq.cuh above the NO_DEVICE_CODE assert, so 16 instructions per
translation unit carry a __LINE__ immediate that shifts by exactly +11. Those are classified on
the instruction encoding word, not on the disassembly text (ptxas emits MOV R10, 0x3bf on
sm_75 but HFMA2.MMA R10, -RZ, RZ, 0, 5.716e-05 on sm_80 for the same constant). Nothing is
filtered; every differing line lands in one bucket or the other.

arch 585f9c9eb (before the narrowing) d0a262e1a (after)
sm_75 0 of 23 0 of 23
sm_80 0 of 23 0 of 23
sm_90 0 of 23 0 of 23
sm_120a 5 of 23 2 of 23
sm_121a 5 of 23 5 of 23

Before the narrowing, sm_120a and sm_121a got identical SASS in all five units (147760, 136048,
118597, 123954, 135110 in both columns), i.e. an RTX 50-series card was running a GB10-swept tile
table verbatim. That was worth fixing.

Corroborated by a 13200-row host selector matrix (31 compute capabilities x 20 types x 11 J x both
fallback values, evaluating the real ggml_cuda_mmq_get_config):

  • with sm_121 in the arch list: 585f9c9eb changes 220 rows on cc 1200/1201/1205/1209, d0a262e1a
    changes 0;
  • with an arch list that does not contain sm_121 (750,800,900,1200, a consumer-Blackwell CI
    build): 585f9c9eb changes 550 rows, d0a262e1a changes 0 of 13200;
  • all six AMD encodings: 0 rows in either tree.

Finding 1: the narrowing does not cover the FP4 accumulator change

d0a262e1a narrows the tile selector. The other half of the PR, the change in
ggml/src/ggml-cuda/mmq-vec-dot.cuh, is inside ggml_cuda_mmq_vec_dot_fp4_fp4_mma, compiled for
every BLACKWELL_MMA_AVAILABLE arch, which is __CUDA_ARCH__ in [1200, 1300), all of sm_120 too.
That is why sm_120a is 2 and not 0: mmq-instance-mxfp4 (139680 SASS lines) and
mmq-instance-nvfp4 (127968).

It is bit-neutral, and that was measured, not assumed. A tree consisting of the merge base plus
that one hunk and nothing else, against the merge base, GGML_CUDA_FORCE_MMQ=1, 8 types x 6 shapes
including m=255 to force the fallback config, FNV-1a over the raw f32 output, every case computed
twice to prove determinism and the whole set reproduced from a second invocation:

cases differing
base vs base + FP4 accumulator hunk, all 48 0

PTX says of mma that "The accumulation order, rounding and handling of subnormal inputs are
unspecified", so chaining C through the block-scaled MMA instead of zeroing a temporary could have
changed results. It does not.

So sm_120 gets an unmeasured code-generation change, not an unmeasured numerics change. Still, the
header comment in mmq-config-dgx-spark.cuh says "nothing here was measured on a discrete
Blackwell part" and "the override is exactly the swept entries", and that is not true of the diff
as a whole while this hunk is ungated. Wrapping it in
#if defined(__CUDA_ARCH__) && __CUDA_ARCH__ == GGML_CUDA_CC_DGX_SPARK takes sm_120a from 2 to 0.

Finding 2: the tile change is a numerics change on GB10

base vs head, same harness, sm_121a:

type n=1 n=16 n=128 k=1024 m=512 n=512 k=2048 m=1024 n=2048 m=255 (fallback)
nvfp4 same same same DIFFERS DIFFERS same
mxfp4 same same same same same same
q4_K same same same DIFFERS DIFFERS same
q5_K same same same DIFFERS DIFFERS same
q6_K same same same DIFFERS DIFFERS same
q4_0, q8_0, iq4_xs same same same same same same

8 of 48. Only the five types the table names, only where the 64-row tile changes the stream-k
split, and the m=255 fallback column is identical for every type, which is the table falling
through to the Blackwell entries as intended.

Greedy generation follows from it. llama-cli -ngl 99 --temp 0 --top-k 1 --seed 42 -n 160 --single-turn, Qwen3.5-4B-UD-Q4_K_XL, single device, default clocks, no --rpc:

run md5 of generated text
base, run A ebaa06f1579d5edd
base, run B ebaa06f1579d5edd
head e404e817a0e8f7c2

The base/base control is byte-identical, so the divergence is real and not harness noise. Output
splits mid-sentence. This is a floating point reassociation, not a correctness bug, and it is
confined to GB10: sm_75/80/90 are byte-identical in SASS and sm_120's only change is bit-neutral.

But the PR body's "Same kernels, same numerics" is not right for a change that alters I and
nthreads under stream_k, because the tile shape decides how K is split across CTAs and
therefore the order the partial sums are combined. Worth correcting in the body, since a reader
will otherwise assume a Spark deployment can take this without revalidating output.

Everything else is unchanged

  • test-backend-ops -b CUDA0, single device, no --rpc: -o MUL_MAT 1193/1193 on base and
    1193/1193 on head; -o MUL_MAT_ID 869/869 and 869/869. Counts match base exactly.
    MUL_MAT_ID passing matters: it is the operation the new || (ids && spark) term changes.
  • Shared memory: GB10 reports MAX_SHARED_MEMORY_PER_BLOCK_OPTIN 101376 B and 102400 B per SM. Over
    the 55 rows the table changes, the base config asks 57856 B at occupancy 1 (a second block would
    need 115712 B) and the new one 38400 B at occupancy 2 for 76800 B. Nothing in the 13200-row matrix
    exceeds 101376 B on any tree.
  • fallback: mmq_args has exactly two construction sites, both in ggml_cuda_mul_mat_q, both
    updated, so nothing leaves the new field unset. At cc=1210 every fallback == true row is
    byte-identical to the merge base, and the Blackwell table's fallback rows differ from its
    non-fallback rows only in the flag, so forcing it for mul_mat_id adds the range check and
    nothing else, exactly as the body says.
  • HIP: the device branch is inside the non-HIP arm of #ifdef GGML_USE_HIP and additionally tests
    defined(__CUDA_ARCH__); the host branch sits after the unconditional GGML_CUDA_CC_IS_AMD
    return and is itself gated on GGML_CUDA_CC_IS_NVIDIA(cc). There is no AMD hardware on this
    pair, so this is reasoned from source and not tested.
    The host half is executed evidence: 0 of
    1320 AMD rows differ.
  • One caveat on "exact equality": the test is exact on ggml_cuda_highest_compiled_arch(cc), not on
    cc, so any cc from 1210 up to the next compiled arch also selects the table. No such part
    exists, and the merge base has a skew of the same shape for the same hypothetical cc, so this is
    neither introduced nor fixed here.

Thermal guard log had 559 lines before and 559 after the measured window, so no cap overlaps any
number above.

The gate narrowing in d0a262e was entirely in the tile configuration selector. The other half of
this PR, the FP4 accumulator change, lives in ggml_cuda_mmq_vec_dot_fp4_fp4_mma, which is compiled
for every architecture defining BLACKWELL_MMA_AVAILABLE, i.e. __CUDA_ARCH__ in [1200, 1300). That
is all of sm_120 as well as sm_121, so an RTX 50-series card was still getting it.

Measured with cuobjdump -sass over all 23 MMQ translation units at
75-real;80-real;90-real;120a-real;121a-real, against the merge base, with each differing line
classified rather than filtered (the +11 __LINE__ shift this PR introduces in mmq.cuh is compared
on the instruction encoding word, since ptxas renders the same constant as MOV on sm_75 and
HFMA2.MMA on sm_80):

  arch      before the narrowing   at d0a262e   with this commit
  sm_75     0 of 23                0 of 23       0 of 23
  sm_80     0 of 23                0 of 23       0 of 23
  sm_90     0 of 23                0 of 23       0 of 23
  sm_120a   5 of 23                2 of 23       0 of 23
  sm_121a   5 of 23                5 of 23       5 of 23

The two that remained on sm_120a were mmq-instance-mxfp4 (139680 SASS lines) and
mmq-instance-nvfp4 (127968).

GB10 loses nothing. A strict comparison of d0a262e against this commit is byte-identical on
sm_121a for all 23 translation units, so the Spark runs the same machine code either way.

The change being gated is bit-neutral, so this is about not shipping unmeasured code generation
rather than about results: the merge base plus that hunk alone, against the merge base, is
bit-identical on all 48 (type, shape) MUL_MAT cases including MXFP4 and NVFP4. It is gated anyway,
because PTX leaves mma's accumulation order unspecified and the hunk was swept on a DGX Spark and
nowhere else, which is the same reason the tile table is gated.
@danielhanchen

Copy link
Copy Markdown
Member Author

Pushed daccdb273, and corrected the body.

The commit gates the FP4 accumulator hunk in mmq-vec-dot.cuh on
__CUDA_ARCH__ == GGML_CUDA_CC_DGX_SPARK, the same test the tile table uses, and restores the merge
base's form in the #else. One file, +17 -1.

arch, MMQ TUs differing from the merge base before either narrowing at d0a262e1a at daccdb273
sm_75, sm_80, sm_90 0 of 23 0 of 23 0 of 23
sm_120a 5 of 23 2 of 23 0 of 23
sm_121a 5 of 23 5 of 23 5 of 23

GB10 loses nothing: a strict SASS comparison of d0a262e1a against daccdb273 is byte-identical
on sm_121a for all 23 translation units
, so the device cannot tell them apart and no run is needed
to establish it.

The body now says what was measured instead of "same kernels, same numerics": the 8-of-48 bitwise
table, the greedy divergence with its byte-identical base-against-base control, the stream-k
mechanism (the tile shape decides the K split, so changing I reassociates the sum), that it is
not a correctness bug with MUL_MAT 1193/1193 and MUL_MAT_ID 869/869 matching base exactly, and
that a DGX Spark deployment cannot take it without revalidating output. The 5-to-2-to-0 sequence is
there too, with the severity distinction: what sm_120 was getting in the middle column was an
unmeasured code-generation change, not an unmeasured numerics change, because that hunk alone is
bit-identical on all 48 cases.

prepush_gate.py --skip-agpl passed bare, exit 0, and the push was chained behind it with &&.
The simulation harness is not in the commit; the PR gets the one-file fix and nothing else.

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