Skip to content

perf(ArcKernels): blockwise-FP8 GEMM on tensor cores, scale promoted per 128-K - #200

Merged
heydryft merged 4 commits into
masterfrom
perf/fp8-tensorcore-gemm
Aug 21, 2026
Merged

heydryft merged 4 commits into
masterfrom
perf/fp8-tensorcore-gemm

Conversation

@heydryft

Copy link
Copy Markdown
Contributor

Feeds the #194 release gate. Branched off master 3c22cb5b5.

🔴 UNVERIFIED ON HARDWARE — never run

No GPU was available for this wave. Nothing in this PR has executed. Every performance number below is a derivation from published machine limits, not a measurement. Do not quote any of it as a result. Rule 2 normally bans this; it was explicitly overridden for this wave.

The target

fp8_matmul_tiled is 66% of a B=256 decode step — 524 ms of 794. Its inner loop is

acc += s_input[ty][k] * s_weight[tx][k];

two shared-memory float loads to feed one FMA, no register blocking, no tensor-core instruction anywhere in it. Shared memory delivers 32 floats/clk/SM against 128 FP32 lanes, so that loop cannot exceed ~1/4 of the FP32 CUDA-core rate however it is tuned. It is a structural ceiling, not a tuning problem.

What this adds

fp8_matmul_wmma, alongside the old kernel: a tensor-core GEMM that keeps weights in FP8 and applies the [N/128, K/128] block scale by promoting an FP32 accumulator at every scale-block boundary along K — DeepGEMM's structure. K_BLK == block_size_x, so one K-tile is exactly one scale block and the scale is a single scalar per tile.

Numerically this is better than what it replaces, not a trade. The scalar kernel computes f32(act) * f32(w * scale) and rounds every product. Here the FP8 → fp16/bf16 weight conversion is exact (e4m3's 3 mantissa bits and 2^-9..2^8 exponent range are representable in both), the tensor core forms each product at full width into f32, and the scale is applied once per 128 K-elements instead of once per element.

Derived cost — a derivation, NOT a measurement

V4 at B=256: 7 sites/layer × 43 layers = 4.238 G params ⇒ FLOP = 2 × 256 × 4.238e9 = 2.170 TFLOP.

bound rate time
H200 FP32 non-tensor 67 TFLOP/s 32.4 ms — the scalar kernel's own bound; it achieves 6.8% of it
H200 BF16/FP16 tensor 989.5 TFLOP/s 2.19 ms ← this kernel's bound
H200 FP8 tensor 1979 TFLOP/s 1.10 ms
HBM over 4.238 GB 4.8 TB/s 0.88 ms

Compute-bound at a derived 2.19 ms floor against a measured 524 ms today. A WMMA GEMM of this shape typically realises 50–70% of peak, which would put it at a derived 3.1–4.4 ms. Arithmetic only; none of it has run.

The remaining 2× to 1.10 ms is not reachable by tuning this kernel — mma with e4m3 operands needs both operands in FP8, hence FP8 activations with their own per-token 128 scales. Named, not built.

Cheap checks done first, as instructed

cuBLASLt block scaling: supported on our target, unavailable at our toolkit pin. CUBLASLT_MATMUL_DESC_B_SCALE_MODE = CUBLASLT_MATMUL_MATRIX_SCALE_BLK128x128_32F is documented at compute capability 9.0 — Hopper, exactly our target (and notably not on Blackwell, which uses the MX VEC32_UE8M0 modes). But the enum does not exist before CUDA 12.9: absent in 12.4.1, absent in 12.8.0, present in 12.9.0 — and .github/workflows/cuda_compile_check.yaml pins 12.4.1. It also requires FP8 activations with VEC128_32F per-token scales and TN layout, so it is not the one-attribute change it first looks like. Worth revisiting if the toolkit pin moves.

wgmma is reachable but deliberately not used. cudaforge auto-suffixes sm_90 → sm_90a (cudaforge-0.1.5 compute_cap.rs::auto_suffix), which is the target wgmma requires, and it would be worth maybe another 1.3–1.5×. Not used because this file cannot be run before it is committed, and a hand-rolled wgmma descriptor that is subtly wrong returns plausible logits rather than an error. nvcuda::wmma fixes the fragment layout in the compiler, compiles for both sm_80 and sm_90a, and has a working precedent in-tree (kernels/mxfp4/mxfp4_gemm_wmma.cu). The wgmma rung belongs on a box that can run the A/B this PR sets up.

Kill switch — fast-path-default

(default)         -> tensor-core WMMA GEMM
ARC_NO_FP8_WMMA=1 -> the scalar fp8_matmul_tiled, i.e. exactly the
                     behaviour of the commit before this one

One binary, both arms, no rebuild. Run the control arm first.

No M threshold, deliberately

This codebase has frozen a dispatch threshold from a single measured point twice — ARC_FP8_CUBLAS_MIN_M = 512 (512 was the only M ever measured, so M = 5..511 fell through to the scalar kernel) and qtip::gather_policy's n >= 683. A threshold is a claim about every value it excludes. I have zero measured points, so inventing one would be strictly worse than either of those. Sweep on hardware, then add one if the sweep shows one.

Correctness guards worth a reviewer's eye

  • Eligibility is read from the kernel, not duplicated in Rust. fp8_matmul_wmma_tile_dims exports N_BLK/K_BLK so fp8_wmma_eligible cannot drift from the tiling the kernel was compiled with. The failure mode of that drift is a numerically wrong GEMM, not a crash.
  • Shared-memory pad is +8 and must stay a multiple of 8. wmma::load_matrix_sync requires a 16-byte-multiple leading dimension for 16-bit types; (128+8)*2 = 272 B = 17*16 satisfies it. This is the opposite of the right answer for the scalar kernel next door, where +1 breaks a bank conflict. A +1 pad here would be undefined. Called out in the source so nobody "harmonises" them.
  • --use_fast_math, which side we're on: unaffected. No transcendentals, no division, no sqrt. -fmad=true only contracts the promotion into an FMA (more accurate). --ftz=true flushes f32 denormals and nothing here goes near 1e-38. Tensor-core MMA is not governed by these flags.

What was actually verified

  • cargo check -p mistralrs-quant (no cuda) — green.
  • cargo test -p mistralrs-quant --test cuda_kernel_build_guard — 5/5, including expected_kernel_count_matches_disk.
  • EXPECTED_KERNEL_COUNT 41 → 43 (kernel + its cc<8.0 link stub); the dummy-stub count in that file's own note updated 5 → 6.
  • nvcc for sm_80 and sm_90 plus cargo check --workspace --features cuda --tests, via the free no-GPU Actions gate on this PR.

Nothing else. In particular: no kernel executed, no output compared against a reference, no timing taken.

🤖 Generated with Claude Code

https://claude.ai/code/session_01UMmjFy8TvsgypxVWNVhhC7

…per 128-K

UNVERIFIED ON HARDWARE -- never run.

No GPU was available for this wave. Nothing in this commit has executed.
Every number below is a DERIVATION from published machine limits, not a
measurement. Do not quote any of it as a result, and do not claim a speedup.

WHAT

`fp8_matmul_tiled` is 66% of a B=256 decode step -- 524 ms of 794. Its inner
loop is `acc += s_input[ty][k] * s_weight[tx][k]`: two shared-memory float
loads to feed one FMA, no register blocking, no tensor-core instruction
anywhere in it. Shared memory delivers 32 floats/clk/SM against 128 FP32
lanes, so that loop cannot exceed ~1/4 of the FP32 CUDA-core rate however it
is tuned. It is a structural ceiling, not a tuning problem.

This adds `fp8_matmul_wmma` next to it: a real tensor-core GEMM that keeps the
weights in FP8 and applies the `[N/128, K/128]` block scale by promoting an
FP32 accumulator at every scale-block boundary along K -- DeepGEMM's
structure. `K_BLK == block_size_x`, so one K-tile is exactly one scale block
and the scale is a single scalar per tile.

Numerically this is better than what it replaces, not a trade. The scalar
kernel computes `f32(act) * f32(w * scale)` and rounds every product. Here the
FP8 -> fp16/bf16 weight conversion is exact (e4m3's 3 mantissa bits and
2^-9..2^8 exponent range are representable in both), the tensor core forms
each product at full width into f32, and the scale is applied once per 128
K-elements rather than once per element.

DERIVED COST (a derivation, NOT a measurement)

V4 at B=256: 7 sites/layer x 43 layers = 4.238 G params, so
FLOP = 2 * 256 * 4.238e9 = 2.170 TFLOP.

  H200 FP32 non-tensor      67 TFLOP/s -> 32.4 ms  (the scalar kernel's own
                                                    bound; it achieves 6.8%)
  H200 BF16/FP16 tensor  989.5 TFLOP/s ->  2.19 ms <-- this kernel's bound
  H200 FP8 tensor       1979   TFLOP/s ->  1.10 ms
  HBM 4.8 TB/s over 4.238 GB           ->  0.88 ms

Compute-bound at a derived 2.19 ms floor against a measured 524 ms today. A
WMMA GEMM of this shape typically realises 50-70% of peak, which would put it
at a derived 3.1-4.4 ms. Arithmetic only; none of it has run.

The remaining 2x to 1.10 ms is NOT reachable by tuning this kernel: `mma` with
e4m3 operands needs BOTH operands in FP8, hence FP8 activations with their own
per-token 128 scales. Named, not built.

WHY NOT cuBLASLt, AND WHY NOT wgmma

cuBLASLt does support exactly this layout -- `CUBLASLT_MATMUL_DESC_B_SCALE_MODE
= CUBLASLT_MATMUL_MATRIX_SCALE_BLK128x128_32F` -- and the docs put it at
compute capability 9.0, i.e. Hopper, our target. But the enum does not exist
before CUDA 12.9 (absent in 12.4.1, absent in 12.8.0, present in 12.9.0) and
.github/workflows/cuda_compile_check.yaml pins 12.4.1. It also wants FP8
activations with VEC128 scales and TN layout, so it is not the one-attribute
change it first looks like. Checked before writing the kernel, as instructed.

wgmma is reachable -- cudaforge auto-suffixes sm_90 to sm_90a -- and worth
maybe another 1.3-1.5x. Not used, because this file cannot be run before it is
committed and a hand-rolled wgmma descriptor that is subtly wrong returns
plausible logits rather than an error. `nvcuda::wmma` fixes the fragment
layout in the compiler, compiles for both sm_80 and sm_90a, and has a working
precedent in this tree (kernels/mxfp4/mxfp4_gemm_wmma.cu). The wgmma rung
belongs on a box that can run the A/B this commit sets up.

KILL SWITCH

Default-on per house fast-path-default policy, so the first box A/Bs both arms
from one binary with no rebuild:

  (default)         -> tensor-core WMMA GEMM
  ARC_NO_FP8_WMMA=1 -> the scalar `fp8_matmul_tiled`, i.e. exactly the
                       behaviour of the commit before this one

Run the control arm first.

NO M THRESHOLD, DELIBERATELY

This codebase has frozen a dispatch threshold from a single measured point
twice -- `ARC_FP8_CUBLAS_MIN_M = 512` (512 was the only M measured, so
M = 5..511 fell through to the scalar kernel) and `qtip::gather_policy`'s
`n >= 683`. A threshold is a claim about every value it excludes. I have zero
measured points, so inventing one here would be strictly worse than either.
Sweep on hardware first, then add one if the sweep shows one.

WHAT WAS ACTUALLY VERIFIED

  * `cargo check -p mistralrs-quant` (no cuda) green.
  * `cargo test -p mistralrs-quant --test cuda_kernel_build_guard` -- 5/5,
    including `expected_kernel_count_matches_disk`.
  * EXPECTED_KERNEL_COUNT 41 -> 43 (kernel + its cc<8.0 link stub); the
    dummy-stub count in that file's own note updated 5 -> 6.
  * nvcc for sm_80 and sm_90 via the free no-GPU GitHub Actions gate.

Nothing else. In particular: no kernel executed, no output compared against a
reference, no timing taken.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UMmjFy8TvsgypxVWNVhhC7
@github-actions

Copy link
Copy Markdown
Code Metrics Report
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
 Language              Files        Lines         Code     Comments       Blanks
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
 C Header                  5          305          210           52           43
 CSS                       2         1181         1036           34          111
 CUDA                     78        27867        19381         5568         2918
 Dockerfile                1           39           22            8            9
 JavaScript               16         3546         2676          482          388
 Jinja2                    7          694          656            5           33
 JSON                     74         4600         4597            0            3
 Makefile                  1            6            5            0            1
 Metal Shading Lan|       33        12224         9431         1142         1651
 PowerShell                1          300          227           30           43
 Python                  147        15371        12677          824         1870
 Shell                    42        10258         6849         2734          675
 Plain Text                4         3801            0         2479         1322
 TOML                     33         1498         1294           54          150
 YAML                      3           25           23            2            0
─────────────────────────────────────────────────────────────────────────────────
 HTML                      4         2687         2604           43           40
 |- CSS                    2          543          479           37           27
 |- JavaScript             1         1233         1215           12            6
 (Total)                             4463         4298           92           73
─────────────────────────────────────────────────────────────────────────────────
 Jupyter Notebooks         4          122           83           23           16
 |- Markdown               1           60           30           22            8
 |- Python                 1          122          113            1            8
 (Total)                              304          226           46           32
─────────────────────────────────────────────────────────────────────────────────
 Markdown                204        45330            0        35183        10147
 |- BASH                  72         1655         1203          331          121
 |- C                      3           17           17            0            0
 |- CUDA                   2           84           56           16           12
 |- JSON                  18          708          708            0            0
 |- PowerShell             1            1            1            0            0
 |- Python                23         1008          787          113          108
 |- Rust                  66         2051         1716           77          258
 |- TOML                   6          207          164            0           43
 |- YAML                   5           41           36            5            0
 (Total)                            51102         4688        35725        10689
─────────────────────────────────────────────────────────────────────────────────
 Rust                    678       335044       288629        17172        29243
 |- Markdown             497        30361          471        26183         3707
 (Total)                           365405       289100        43355        32950
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
 Total                  1337       502989       357396        92632        52961
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

heydryft and others added 3 commits August 21, 2026 03:19
…++ guide understates the ISA

UNVERIFIED ON HARDWARE -- never run.

Two NVIDIA documents govern `wmma::load_matrix_sync`'s leading dimension and
they DISAGREE. The laxer one is the one people quote, and the first version of
this kernel followed it -- which compiles, and is wrong.

CUDA C++ Programming Guide 12.4 §7.24.1:

    "mptr must be a 256-bit aligned pointer ... and [ldm] must be a multiple
     of 8 for __half element type or multiple of 4 for float element type.
     (i.e., multiple of 16 bytes in both cases)."

PTX ISA 12.4 §9.7.13.3.2, working our exact shape through:

    "The starting address of each instance of the leading dimension (row or
     column) must be aligned with the size of the corresponding fragment in
     bytes." ... for `wmma.load.a.sync.aligned.row.m16n16k16.f16` the fragment
     is 32 B (eight `.f16x2` elements), so "p is a multiple of 32" and "2*s is
     a multiple of 32"

i.e. ldm must be a multiple of SIXTEEN __half elements, double the guide's
number, and the base pointer must be 32 B aligned, not 16.

The +8 pad gave ldm = 136 elements = 272 B. 272 % 16 == 0 satisfies the guide;
272 % 32 == 16 violates the ISA. Worst of both worlds, because nothing would
have complained until the numbers came out subtly wrong on a rented box.

  * SMEM_PAD 8 -> 16, so ldm = 144 elements = 288 B = 9 * 32 B. Satisfies both
    documents, for f16 and bf16 alike. Asserted at compile time now, with the
    ISA rule as the assertion message so the next person does not "optimise"
    the pad back down to the guide's number.
  * The shared tile was `__align__(16)`; every fragment pointer is `base + k*32`,
    so a 16 B aligned base put all of them on 16 B boundaries. Now
    `__align__(128)`.
  * Shared memory 34,816 -> 36,864 B, still under the 48 KB no-opt-in limit
    (asserted).

Also renames N_SUBTILES -> N_SUB_TILES; the `typos` CI lane reads the
concatenation as a misspelling of SUBTITLES.

Found by verifying the WMMA contract against the shipped CUDA 12.4 headers and
both specs rather than against memory. Nothing here has run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UMmjFy8TvsgypxVWNVhhC7
…kernel dead code

The forward() dispatch chooses between the native FP8 path and dequantize_w()
+ cuBLASLt BEFORE reaching the WMMA kernel. Master's threshold is 512 so
B=256 reaches it; PR #201 lowers it to 5, which would leave this kernel
unreachable at every batch size that matters -- silently, since it still
compiles and still passes its tests.

The two are answers to the same question asked before and after the premise
changed: cuBLASLt won the M=8..128 sweep because it had tensor cores and the
native path did not. This kernel removes that asymmetry without paying the
~12.7 GB/step dequantize or the +8.48 GB of resident BF16 weights.

Records the three-arm sweep that has to replace the two-arm one. Nothing has
run; the expectation that (b) beats (a) is a derivation from bytes moved.

UNVERIFIED ON HARDWARE -- never run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UMmjFy8TvsgypxVWNVhhC7
…one measures

At V4's B=256 shapes the grid is ~128 blocks against 132 SMs -- one wave, one
block per SM, 8 warps of a possible 64. That is the most likely reason the
kernel lands short of its 2.19 ms derived bound, and it is where a first
profile should point. Lists the three knobs (smaller tiles, cp.async double
buffering, split-K) and deliberately guesses none of them, for the same reason
the dispatch threshold is left unset.

UNVERIFIED ON HARDWARE -- never run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UMmjFy8TvsgypxVWNVhhC7
@heydryft

Copy link
Copy Markdown
Contributor Author

Route-2 handover: the cuBLASLt block-scaled FP8 path is more reachable than either of us thought

Recorded here so it isn't rediscovered. Three findings, each verified, that change route 2's scope.

1. 🔑 The Rust bindings already have the enums — no toolkit pin bump is needed to compile

I originally reported route 2 as blocked because CUBLASLT_MATMUL_MATRIX_SCALE_BLK128x128_32F is absent from the CUDA 12.4.1 C header, which is what cuda_compile_check.yaml pins. That reasoning was about the C header and does not apply to our actual call path.

We reach cuBLASLt through cudarc, and candle-core 0.9.2 pins cudarc 0.19.4, whose src/cublaslt/sys/mod.rs contains:

CUBLASLT_MATMUL_DESC_A_SCALE_MODE          = 31
CUBLASLT_MATMUL_DESC_B_SCALE_MODE          = 32
CUBLASLT_MATMUL_MATRIX_SCALE_VEC128_32F    = 4
CUBLASLT_MATMUL_MATRIX_SCALE_BLK128x128_32F = 5

cudarc 0.19.4 has no bindgen in its build.rs — the bindings are static Rust constants, not generated from the local toolkit. So the descriptor wiring compiles today, at the 12.4.1 pin, through the existing gate. The ≥12.9 requirement is a runtime cuBLAS-library requirement on the serving box, not a build-time one.

This also independently corroborates the peer's header grep from a real 13.0 box — 31, 32, and BLK128x128_32F = 5 match exactly, from a completely different source. Those magic numbers are no longer unverified.

Also present in the 12.4-era cudarc 0.17.8, so this is not new.

2. 🔑 The activation quantiser already exists — it does not need writing, and EXPECTED_KERNEL_COUNT does not move for route 2

quant_fp8_blockwise_kernel (kernels/blockwise_fp8/blockwise_fp8.cu) is fully general in weight_block_size_y/_x. Called as fp8_blockwise_quantize(x_2d, vec![1, 128]) it produces scales of shape [M, ceil(K/128)], M-major — which is exactly the M x L layout VEC128_32F requires. The Rust op fp8_blockwise_quantize is already pub and already exported.

3. ⚠️ But it is not fit for the decode hot path as written — this is the real work item

With block_size_y = 1 the launcher still uses dim3 blockDim(32, 32) = 1024 threads, and the tile loop is local_y = threadIdx.y; local_y < 1; local_y += 32. So:

  • 32 of 1024 threads do work; 31/32 of every block idles.
  • The absmax reduction is atomicMaxFloat onto one shared address — 128 serialised atomics per 128-element block.
  • At M=256, K=7168 that is 14,336 blocks x 1024 threads to quantise 1.8 M elements.

It is correct, so it unblocks a first end-to-end A/B immediately. It is not something to leave in the steady-state decode path. The fix is conventional — one warp per 128-block, __shfl_down_sync reduction, vectorised loads — and it is a genuine new kernel (or a block_size_y == 1 specialisation), so that is what would move EXPECTED_KERNEL_COUNT, not the wiring.

Remaining scope for route 2, with the unknowns removed

  1. Warp-per-block specialisation of the quantiser for block_size_y == 1 (new kernel + stub, count 43 -> 45).
  2. cublaslt/api.rs: lift the a_scale/b_scale f32-scalar requirement (:70-76), pass device scale tensors, set A_SCALE_MODE = VEC128_32F and B_SCALE_MODE = BLK128x128_32F. weight_scale_inv is already the correct B layout.
  3. Wire into BlockwiseFP8Linear::forward, dropping dequantize_w().
  4. Constraints to honour: TN on Hopper, M and N multiples of 4, scale base addresses 16 B aligned, BLK128x128 scales K-major with column stride a multiple of 4, D not FP8.
  5. Runtime gate: the box needs cuBLAS >= 12.9. Below that cublasLtMatmulDescSetAttribute will reject attribute 31/32 — loudly, per the docs ("If the corresponding scale is set for a non-narrow precisions tensor, cuBLAS will return an error"), so it fails visibly rather than silently. Needs a runtime version check and a fallback, since our boxes have run 12.4.

How this PR relates

It does not compete with route 2 — it is the control arm for it, and the only arm that works below cuBLAS 12.9. Route 2's ceiling is better (FP8 tensor cores, 1.10 ms derived, vs 2.19 ms here) because it quantises activations to FP8; this kernel deliberately does not touch activation numerics. Both delete the 12.7 GB/step dequantize_w().

Whoever lands route 2 should keep ARC_NO_FP8_WMMA and this kernel as the fallback, and run the three-arm sweep documented in ops.rs, not a two-arm one.

None of this has run. Every figure above is a derivation or a code reading, not a measurement.

@heydryft
heydryft merged commit 9ee4591 into master Aug 21, 2026
18 checks passed
@heydryft
heydryft deleted the perf/fp8-tensorcore-gemm branch August 21, 2026 06:05
heydryft added a commit that referenced this pull request Aug 21, 2026
…nce, rebased onto master (#215)

* fix(ArcKernels): record why FP8's dispatch threshold is 512, and why 512 stays

Cherry-pick of 52a8750 (`fix/fp8-cublas-crossover`), resolved against
`origin/master` @ 6ffdac7. The doc lands; the constant does not.

`arc_fp8_cublas_min_m` defaulted to 512 and the doc said why outright: "the
default is set to that measured point rather than to an interpolated
crossover". `fp8_gemv_warp` owns M <= 4, so M = 5..511 fell through to
`fp8_matmul_tiled` -- the kernel whose own comment says there is "no
tensor-core instruction anywhere in it". A profile of the shipped default
caught it at 7,525 launches for 17.6% of GPU time in one decode window.

Swept on an H200 against V4-Flash at 52a8750, one binary and one env toggle
(`ARC_FP8_CUBLAS_MIN_M`), aggregate tok/s, CLEAN ROWS ONLY (>=95% achieved
concurrency, <15% derived-vs-measured spread):

    M=1     34.92 vs  25.27   cuBLASLt 0.72x   <- the GEMV floor is real
    M=8     49.25 vs  64.95   cuBLASLt 1.32x
    M=16    45.61 vs  52.55   cuBLASLt 1.15x
    M=128  114.21 vs 130.76   cuBLASLt 1.14x

(M=32, M=64 and M=256 came back dirty at this generation length and are
excluded from the decision rather than averaged in.)

52a8750 concluded from those rows that the default should be 5. THAT
CONCLUSION DOES NOT SURVIVE THE REBASE, so this commit keeps 512:

* The sweep was taken at 01:47 on 2026-08-21. At 07:04 the same day, #200
  (9ee4591) merged a tensor-core blockwise-FP8 GEMM, and
  `fp8_blockwise_matmul_impl` now selects it by default for everything
  `fp8_gemv_warp` does not own (`use_wmma`, ops.rs). Our shipped
  `weight_block_size` is [128, 128] and the kernel tiles N_BLK=64, K_BLK=128,
  so `fp8_wmma_eligible` passes: 128 % 64 == 0, 128 % 128 == 0. At M = 5..511
  the native arm is no longer the scalar kernel the sweep measured.
* Lowering the default to 5 would therefore route every M >= 5 into the
  dequantize path and make #200's kernel unreachable at every batch size that
  matters -- silently, because it compiles and the tests pass. That kernel's
  own docs already name the merge rule: three arms, not two, and "do not merge
  a threshold on the two-arm result".
* The sweep also predates the rank-3 flatten in the next commit, so arm (a) was
  dispatching a B-way batched GEMV with m=1. Those rows understate cuBLASLt and
  have to be retaken regardless.
* Arm (a) pays an uncached full-model `dequantize_w()` on every forward that
  arms (b) and (c) do not. Nothing here caches it, so the crossover this sweep
  found is the crossover of the uncached path.

What settles the constant: re-run the M sweep with the flatten fix present,
three arms on one binary (`ARC_FP8_CUBLAS_MIN_M` / `ARC_NO_FP8_WMMA` toggle all
three with no rebuild), and take the first M at which dequantize+cuBLASLt beats
the better of the two native kernels. Until that exists, moving 512 to 5 would
be a second constant with no measurement behind it -- the exact defect this doc
was written to record.

Also recorded as a recurring failure, because 512 is the second such gate:
`qtip::gather_policy`'s tile-fill predicate needed n >= 683 and kept the grouped
GEMM unreachable in every decode step (worth 1.45x at B=128). Both were derived
from a single working point and neither was ever swept.

`ARC_FP8_CUBLAS_MIN_M` remains the kill switch in both directions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UMmjFy8TvsgypxVWNVhhC7

* perf(ArcKernels): UNVERIFIED — remove the 4-way shared-memory bank conflict

⚠️ NEVER COMPILED. The box went down (port 22 closed, 100% ICMP loss) before
this could be built or measured. Committed so it is not lost; it must be built
and A/B'd before any number is quoted from it.

`fp8_matmul_tiled`'s inner product reads `s_weight[tx][k]` with `tx` varying
fastest within a warp, so consecutive lanes sit `BLOCK_K + pad` floats apart.
At the shipped BLOCK_K=32 (`TILE_K = 32`, blockwise_fp8_gemm.cu:381/399) a +4
pad gives stride 36; 36 mod 32 = 4, gcd(4,32) = 4, so the warp reaches only 8
of the 32 shared-memory banks — a 4-way conflict on every FMA of the k loop.
A +1 pad gives stride 33, coprime with 32, so all 32 banks are hit.

Output is bit-identical by construction: the k loop is bounded `k < BLOCK_K`,
so the pad columns are written by nobody and read by nobody. The padding exists
only to set the row stride.

This does not change the kernel's ceiling. The inner loop still issues two
shared-memory loads per single FMA with no register blocking, which bounds it
near an eighth of even the scalar roofline. The real fix is a tensor-core
blockwise-FP8 GEMM.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
heydryft added a commit that referenced this pull request Aug 21, 2026
… allowed to move ARC_FP8_CUBLAS_MIN_M

arc_fp8_cublas_min_m's doc and the WMMA kernel's doc both forbid merging a
threshold from a two-arm result: the only sweep ever taken predates both the
WMMA GEMM (#200) and the rank-3 flatten, so it compared cuBLASLt-as-batched-
GEMV against the scalar kernel only. This script is the three-arm re-run they
demand, on ONE binary, env toggles only, fresh server per leg (the gates are
OnceLock-latched, so re-exporting into a live server would silently measure
the previous leg).

Design: the threshold candidates {5, 8, 64, 256, 512} are swept as the
OFFERED DECODE BATCH M (a grid that varies only the env at one fixed batch
routes every MIN_M <= batch leg to the same kernel and cannot name a
crossover in M), and each ladder point runs all three arms:
  (a) cublaslt: ARC_FP8_CUBLAS_MIN_M=5
  (b) wmma:     ARC_FP8_CUBLAS_MIN_M=1000000, ARC_NO_FP8_WMMA=0
  (c) tiled:    ARC_FP8_CUBLAS_MIN_M=1000000, ARC_NO_FP8_WMMA=1

A leg counts only if ALL of:
  1. ENGAGEMENT — its expected [arc-fp8-dispatch] path= line present in the
     server log and both rivals ABSENT (a WMMA leg whose log shows tiled is
     eligibility silently failing: VOID, loudly);
  2. FLOOR — summed usage.completion_tokens >= MIN_TOKENS_FLOOR (default
     1000); the rate divides by tokens the server SAYS it generated, never by
     requested max_tokens;
  3. CANARY — greedy fixed-prompt stream vs the baseline leg (tiled @ M=5);
     cross-arm divergence is expected numerics and is REPORTED with its first
     index; a degenerate stream (<5 tokens / no finish_reason) voids the leg.

The summary table names, per M, cublaslt vs min(wmma, tiled) and the FIRST
clean M where cuBLASLt wins — the value ARC_FP8_CUBLAS_MIN_M may move to. No
clean crossover => the default stays 512.

Script only — NOT run here (no GPU; D14). House lock/preflight/provenance
discipline copied from arcspec_token_identity_b8.sh.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WK8VgocBUrr5djjE1ZfCNf
heydryft added a commit that referenced this pull request Aug 21, 2026
…ispatch engagement log, three-arm sweep harness, SASS probe (#218)

* fix(ArcKernels): the cuBLASLt FP8 branch never flattened rank-3 to 2-D

The native branch flattens `[B, T, hidden]` to `[B*T, hidden]` before its GEMM.
The dequantize + cuBLASLt branch did not — it passed `x` through untouched.

V4's activation is rank-3, and bias is `None` on every V4 linear, so
`UnquantLinear::forward` took `w.broadcast_left(B)` into
`cublaslt.batch_matmul` with `stride_b = 0`. At decode T=1, making it a **B-way
batched GEMM with m=1**: a batched GEMV wearing a GEMM's name, and the one
shape cuBLASLt has no advantage in. The documented 27x was measured at prefill,
where `[1, 512, hidden]` collapses to batch=1 and the call is a real GEMM.

This invalidated the threshold A/B rather than merely slowing it: lowering
`ARC_FP8_CUBLAS_MIN_M` selected the batched-GEVM shape, so the sweep was
comparing the tiled kernel against a degenerate call and would have supported
"cuBLASLt loses at decode" — a conclusion about a shape the change was never
meant to select. Flattening makes the two branches comparable, which is the
premise the A/B rests on.

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

* perf(ArcKernels): pow2 shift fast path for the tiled FP8 GEMM's per-element scale divisions

get_scale in fp8_matmul_tiled performs TWO runtime signed integer divisions
per weight element staged into shared memory (n / block_size_y, k /
block_size_x). Both divisors are kernel arguments, so nvcc expands each into
a ~15-20 instruction reciprocal-and-fixup sequence — paid once per element of
every weight tile, ~33.9G times per B=256 decode step by the source-derived
count. The shipped weight_block_size is [128, 128]: both powers of two.

This adds a POW2_SCALE template arm: the host launcher tests pow2-ness once
per launch, computes shift = log2(block_size) on the host, and the kernel
indexes the scale grid with two SHFs instead. Non-power-of-two geometries
keep the division path, unchanged.

Bit-identical by construction: for the non-negative indices used here,
n >> log2(d) == n / d for every input — same quotient, same scale word
fetched, same arithmetic after it. Dispatching on POW2_SCALE can never change
what an A/B leg measures, only how fast the scalar arm runs.

The C ABI (launch_fp8_matmul_{f16,bf16}) is unchanged; no Rust edits needed.
Compile-unverified locally (macOS has no nvcc); covered by the nvcc CI lane
(.github/workflows/cuda_compile_check.yaml), which builds this TU for
sm_80/sm_90 on every PR touching it.

The bank-conflict half of this lane's brief (+4 -> +1 tile padding) was
already landed by PR #215 and is an ancestor of this branch; the kernel's
stride-33 comment documents the bank arithmetic.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WK8VgocBUrr5djjE1ZfCNf

* feat(ArcLab): ARC_LOG_FP8_DISPATCH — engagement lines proving WHICH FP8 kernel served a forward

Every threshold sweep on this lane needs an engagement assertion: a server-log
line proving which kernel actually ran, per leg. None existed — a leg could
silently measure the wrong arm (the ARC_NO_DEDICATED_DECODE incident produced
two void A/Bs exactly this way, both arms running the same code).

ARC_LOG_FP8_DISPATCH=1 (read by VALUE via env_flag_is_set, latched once per
process) makes each dispatch path print ONE line per process:

  [arc-fp8-dispatch] path=<gemv_wide|gemv_warp|wmma|tiled|dequant_cublaslt> first_shape=mM_nN_kK

Sites: the native dispatch chain in fp8_blockwise_matmul_impl (both dtype
arms, mirroring the if/else exactly) and the ARC_FP8_CUBLAS_MIN_M divert to
dequantize+cuBLASLt in BlockwiseFP8Linear::forward. Cost when unset: one
latched bool read. When set: one relaxed atomic swap per call — no locks, so
enabling it does not perturb the rates a sweep records.

Registered in mistralrs-core/tests/capability_reachability.rs (Status::Live);
the registry run passes (11/11). The Rust is cfg(cuda) — compile-unverified
locally on macOS; covered by the cuda-typecheck job of the nvcc CI lane.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WK8VgocBUrr5djjE1ZfCNf

* feat(ArcLab): three-arm FP8 threshold sweep harness — the measurement allowed to move ARC_FP8_CUBLAS_MIN_M

arc_fp8_cublas_min_m's doc and the WMMA kernel's doc both forbid merging a
threshold from a two-arm result: the only sweep ever taken predates both the
WMMA GEMM (#200) and the rank-3 flatten, so it compared cuBLASLt-as-batched-
GEMV against the scalar kernel only. This script is the three-arm re-run they
demand, on ONE binary, env toggles only, fresh server per leg (the gates are
OnceLock-latched, so re-exporting into a live server would silently measure
the previous leg).

Design: the threshold candidates {5, 8, 64, 256, 512} are swept as the
OFFERED DECODE BATCH M (a grid that varies only the env at one fixed batch
routes every MIN_M <= batch leg to the same kernel and cannot name a
crossover in M), and each ladder point runs all three arms:
  (a) cublaslt: ARC_FP8_CUBLAS_MIN_M=5
  (b) wmma:     ARC_FP8_CUBLAS_MIN_M=1000000, ARC_NO_FP8_WMMA=0
  (c) tiled:    ARC_FP8_CUBLAS_MIN_M=1000000, ARC_NO_FP8_WMMA=1

A leg counts only if ALL of:
  1. ENGAGEMENT — its expected [arc-fp8-dispatch] path= line present in the
     server log and both rivals ABSENT (a WMMA leg whose log shows tiled is
     eligibility silently failing: VOID, loudly);
  2. FLOOR — summed usage.completion_tokens >= MIN_TOKENS_FLOOR (default
     1000); the rate divides by tokens the server SAYS it generated, never by
     requested max_tokens;
  3. CANARY — greedy fixed-prompt stream vs the baseline leg (tiled @ M=5);
     cross-arm divergence is expected numerics and is REPORTED with its first
     index; a degenerate stream (<5 tokens / no finish_reason) voids the leg.

The summary table names, per M, cublaslt vs min(wmma, tiled) and the FIRST
clean M where cuBLASLt wins — the value ARC_FP8_CUBLAS_MIN_M may move to. No
clean crossover => the default stays 512.

Script only — NOT run here (no GPU; D14). House lock/preflight/provenance
discipline copied from arcspec_token_identity_b8.sh.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WK8VgocBUrr5djjE1ZfCNf

* ci(ArcGate): free SASS probe for the FP8 dense-GEMM lane — LDS width census + WMMA HMMA presence

Two questions this settles with nvcc + cuobjdump alone, no GPU:

1. Whether the 4-way bank-conflict cost model for fp8_matmul_tiled describes
   the compiled artifact: the model is a claim about SCALAR 32-bit LDS in the
   inner product, and if ptxas had vectorized those shared loads to LDS.128
   its arithmetic would be about instructions that do not exist. The probe
   prints a per-kernel LDS/LDS.64/LDS.128 census. (Note: the +1 pad that
   kills the conflict also forecloses LDS.128 on tile rows — scalar LDS is
   the EXPECTED reading on current source.)

2. Whether the never-executed tensor-core GEMM (blockwise_fp8_gemm_wmma.cu)
   actually contains tensor-core instructions: HARD FAIL if the TU has zero
   HMMA/HGMMA — the three-arm sweep would otherwise compare cuBLASLt against
   two scalar kernels while calling one of them WMMA.

Teeth: exactly 4 fp8_matmul_tiled kernels asserted (2 dtypes x 2 POW2_SCALE
instantiations — a dropped instantiation makes the census about a kernel not
in the build), and every tiled kernel must show >=1 LDS or the extractor lost
the function body.

Wired into the nvcc CI lane as a step beside the QTIP spill gate, so it runs
for sm_80 and sm_90 on every Rust/kernel PR; also runnable standalone:
  arc-tools/fp8_gemm_sass_check.sh sm_90

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WK8VgocBUrr5djjE1ZfCNf

* fix(ArcGate): SASS probe — replace GNU-awk-unsupported \< \> ERE escapes with cuobjdump -fun extraction

The probe's first CI run went red on itself, honestly: GNU awk treats \< \>
(and warns on \.) as plain characters in ERE, so the awk body-splitter's
counting regexes matched NOTHING, the per-function census returned zero LDS
for all 4 fp8_matmul_tiled instantiations, and the fail-on-zero guard refused
to trust its own zero — which is exactly what it exists for.

This revision removes regex body-splitting entirely: the mangled kernel names
come from the Function listing (awk '{print $NF}', no regex), and
`cuobjdump -sass -fun <mangled>` extracts each function's SASS directly. All
counting is grep -cE with POSIX classes and bracket expressions only — no
\< \> anywhere. Also, per review:

* The two failure modes are now DISTINGUISHED: FAIL[extractor] (no
  instruction lines came back — the census is unverified) vs FAIL[zero-LDS]
  (body extracted, genuinely no shared loads — the conflict model has no
  subject). Body presence is judged on /*<hex>*/ instruction lines, so the
  two cannot be confused.
* The VERDICT[tiled] line prints ONLY when the per-function census is
  complete and non-empty; otherwise it prints NOT ESTABLISHED. A TU-level
  cross-check line (whole cubin, includes the GEMV/MoE kernels) is always
  printed, labeled informational.
* HMMA counting moved off the \< \> GNUism onto the same POSIX pattern.

Verified locally against a stub cuobjdump with a realistic SASS fixture:
happy path (4 kernels, LDSM excluded, LDS/LDS.64/LDS.128 counted per
function, scalar arithmetic correct, exit 0) plus three negative controls
(broken -fun -> FAIL[extractor] + verdict NOT ESTABLISHED; LDS-free body ->
FAIL[zero-LDS]; HMMA-stripped WMMA TU -> hard fail), all exit 1. The real
cuobjdump run is CI's — if -fun ever returns nothing there, the probe fails
loudly rather than lying.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WK8VgocBUrr5djjE1ZfCNf

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
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