Skip to content

cuda: EXL3 projections write fp32 too (qlinear decodeF32 and promptF32) - #806

Open
BHCC2025 wants to merge 4 commits into
ashhart:mainfrom
BHCC2025:exl3-cuda-f32-r2
Open

BHCC2025 wants to merge 4 commits into
ashhart:mainfrom
BHCC2025:exl3-cuda-f32-r2

Conversation

@BHCC2025

@BHCC2025 BHCC2025 commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

Based on #757's head 9a14da6a (qlinear decodeF32 / promptF32). The first two commits are #740's two (EXL3 projections behind qlinear) rebased onto it: a35cc231 is the same patch as #740's 76ac2867, and 241ac1bb differs from 7b6dc024 only in the import lists that met #757's. The last commit, ff00ed30, is this PR. For #593: the GLM-5.3-Flash family sums its ranks' partial rows before rounding, so an EXL3 projection has to hand out its fp32 sums as #757 does for NVFP4.

Change. The EXL3 decode kernel already took an fp32 output flag; the prompt kernel gains the same flag (tf_exl3_store), and qlinear.decodeF32 / promptF32 route .exl3 to them instead of EpilogueNotBuilt. decode and prompt keep their bf16 bits: the bf16 out is the fp32 out rounded. No other format changes.

Test. tf-cuda-test exl3 gains a check of both entries through qlinear on a 2048x256 4-bit mcg matrix: every fp32 value within its FP64 bound, the same bits at every row count (decode 1-16 rows, prompt 1-300), and the bf16 out equal to the fp32 out rounded. With only the qlinear routing reverted it fails with EpilogueNotBuilt.

Receipt. GB10 (sm_121), CUDA 13, Zig 0.17.0, -Dsm=121; checkpoint turboderp GLM-5.3-Flash-exl3 4.05 bpw.

Those GPU runs were on #757's previous head (2f3988f). After rebasing onto its current head (9a14da6, on 1.0.9) the build, host tests and zig fmt --check pass, and the GPU checks were re-run on this head (ff00ed30) with the same results: #806 (comment)

Not run: Metal (no Mac change), server benches (no served family uses EXL3 fp32 out yet; the family PR will).

Bizuayeu and others added 4 commits October 14, 2026 03:45
…ecodeF32 and promptF32)

A tensor-parallel family sums its ranks' partial rows before rounding, so it needs a projection's fp32 sums as
the kernel holds them. qmmf.cu and prompt16.cu already take F32 as a template argument; this instantiates FP4's
fp32 tiles (and reduce_kernel<true> for the split path), loads them beside the bf16 ones, and adds
Lane.matmulOut, Prompt.matmulOut and qlinear's decodeF32 and promptF32. Other formats refuse fp32 out with
EpilogueNotBuilt, as the experts do. matmul, decode and prompt keep their signatures and bf16 bits.
kernels/cuda/exl3.cu decodes EXL3 trellis tiles as checkpoints store
them ([K/16, N/16, 16 bits] int16), with core/exl3_format.zig's codebooks
(3inst, mcg, mul1) and whole widths 1-8. Rows are rotated by suh and the
128-point Hadamard transform, scaled by 2^-4 and rounded once to fp16;
each tile decodes once straight into the B fragments of two
mma.m16n8k16 (the tile's value order is theirs), fp32 sums, then the
output transform, svh and 2^-3 (docs/recipes/exl3.md, Prompts).

Decode rows go 1-16 a launch, read as A fragments from rows written in
fragment order; matrices with fewer than 96 column blocks split their
input blocks into a shape-fixed number of parts that the last part sums
in order. Prompt rows go 64 a block from shared memory. Every row is
independent, so a row's bits are the same at every row count on its
path. qlinear takes it as Weight.exl3, refused with FormatNotLoaded when
the module is absent.

tf-cuda-test exl3 [DIR]: every codebook state and random tiles at every
width bit-equal to exl3_format; FP64 references from
exl3_format.dequantize with a bound for the kernels' rounding order,
worst column and aggregate error; decode windows of 1-16 rows and
prompts of 2-300 rows byte-equal to each row alone; DIR adds real
matrices of a checkpoint and times them.
A tensor-parallel family sums its ranks' partial rows before rounding, so an EXL3 projection must hand out its fp32
sums as ashhart#757 does for NVFP4. The decode kernel already takes an fp32 output flag; the prompt kernel gains the same
flag (tf_exl3_store), and qlinear's decodeF32 and promptF32 route .exl3 to them. decode and prompt keep their bf16
bits: the bf16 out is the fp32 out rounded. The new exl3 test checks both entries within FP64 bounds, the same bits at
every row count, and the bf16 out as their rounding; it fails with EpilogueNotBuilt before this change.
@BHCC2025

Copy link
Copy Markdown
Contributor Author

GPU checks on this head (ff00ed3, on #757's 9a14da6): GB10 sm_121, driver 580.178.04, CUDA 13, -Dsm=121, turboderp GLM-5.3-Flash-exl3 4.05 bpw.

  • tf-cuda-test exl3 <ckpt>: all PASS. The fp32 check covers decodeF32 (1–16 rows) and promptF32 (1–300 rows): 80,896 FP64 values within bounds (max 0.0031 of the bound), the same bits at every row count, and the bf16 out equal to the fp32 out rounded. The 7 real matrices: 21,504 FP64 values, max 0.0308 of the bound. Both figures are unchanged from the previous head.
  • tf-cuda-test quant-ref: PASS, with every product within 0.510 of its float64 tolerance (cuda: the NVFP4 lane matmul and prompt GEMM write fp32 too (qlinear decodeF32 and promptF32) #757's NVFP4 fp32 checks included).

@ashhart

ashhart commented Oct 14, 2026

Copy link
Copy Markdown
Owner

Hello @BHCC2025,

Thank you for EXL3 projections behind qlinear. decode and prompt stay bf16, and that bf16 is the fp32 sum rounded once. decodeF32 and promptF32 hand out the fp32 sum, for a rank that adds before it rounds. A missing module returns error.FormatNotLoaded. The test skips a selected matrix that the checkpoint stores as plain .weight, and fails the real-matrix pass when none of the selected names is EXL3.

Your body reports tf-cuda-test exl3 passing on a GB10 (sm_121): 80,896 fp32 values, the worst 0.0031 of the bound, and the seven real matrices at 0.0308. quant-ref is 4 of 4. Those GPU runs are on #757's previous head 2f3988fe, which is not in 1.0.10. After the rebase onto 9a14da6a (#757's head, not in 1.0.10) you say the build, the host tests and zig fmt --check pass, and the GPU checks will be re-run on this head. Please post that re-run for ff00ed307, your head, which is not in 1.0.10. We have not run it.

Metal was not run, and the server benches were not run. No served family calls Weight.exl3 on this head. #804's GLM family reads ModelOpt NVFP4 only (config.zig sets head_unit when quant_method is exl3, and does not call Weight.exl3). This lands with the first CUDA family that projects EXL3 through qlinear.

The body says the branch is stacked on #740, and that the first two commits belong to #757 and #740. The parent of your commits is #757 (9a14da6a3, that pull request's head, not in 1.0.10). The three commits after it are yours (241ac1bbc, a35cc2314, ff00ed307), and none of them is in 1.0.10. #740's head 7b6dc024b is not in 1.0.10, and it is not an ancestor of ff00ed307.

The head includes #757. The change sits on v1.0.9, and the trial merge onto that base is clean. This pull request is not in 1.0.10.

We will wrap the lines past 120 columns at landing, including exl3_linear.zig:76. The gate is tf-cuda-test exl3 on a CUDA GPU with tensor cores, before a family calls it.

Thank you again.

@BHCC2025

Copy link
Copy Markdown
Contributor Author

@ashhart, thank you. The GPU re-run on ff00ed307 is posted above (#806 (comment)): tf-cuda-test exl3 all PASS (fp32 check 80,896 values, max 0.0031 of the bound; the 7 real matrices 21,504 values, max 0.0308) and quant-ref PASS, on a GB10 (sm_121), driver 580.178.04, CUDA 13, -Dsm=121.

On the stacking, the body was loose and I've corrected it. The parent is #757's head 9a14da6a3. 241ac1bbc and a35cc2314 are #740's two commits (7b6dc024b, 76ac28670, #740's head) rebased onto it: a35cc2314 is the same patch as 76ac28670, and 241ac1bbc differs from 7b6dc024b only where the import lists met #757's. ff00ed307 is this pull request's own change. So #740's head is not an ancestor; its change is carried here rebased.

Agreed on the gate. The first family to call Weight.exl3 through qlinear is our GLM EXL3 glue on #804's branch (glm-exl3-wip on our fork, 9a099a7c), which we'll open once #804 lands. Its glm-shared-check and glm-lanes-concurrent pass at TP=2 and TP=4 on the turboderp 4.05 bpw checkpoint (#593).

@ashhart

ashhart commented Oct 14, 2026

Copy link
Copy Markdown
Owner

@BHCC2025, thank you for the GPU re-run on ff00ed307 and for tightening the stacking notes. 80,896 fp32 values within 0.0031 of the bound, with the real matrices unchanged, is a clean receipt.

@justincasey's #837 builds the same EXL3 projection and grouped experts from the Python engine's kernels, so there are now two implementations of one piece. We want one module behind qlinear: we will run yours and #837 on the same tensors, bits against the float64 oracle and bandwidth against ExLlamaV3's LinearEXL3, keep the faster at equal bits, and credit both of you on it. Both follow @vcruz305's format from #42, so his name goes on it too.

If you and Justin want to compare notes first, #832 is the place.

@BHCC2025

Copy link
Copy Markdown
Contributor Author

@ashhart, thank you, that plan works for us: one module behind qlinear, chosen on the same tensors against the float64 oracle and LinearEXL3, with both of us credited and @vcruz305 on everything that follows #42.

One note for the family side: the GLM EXL3 glue we've carried is already on #804's branch (glm-exl3-wip on our fork, 9a099a7c, on 53309a72), with the family's glm-shared-check and glm-lanes-concurrent passing at TP=2 and TP=4 on turboderp's 4.05 bpw pack. So it can take whichever module is kept.

@justincasey, glad to compare notes in #832.

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.

3 participants