perf(ArcKernels): blockwise-FP8 GEMM on tensor cores, scale promoted per 128-K - #200
Conversation
…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
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 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ |
…++ 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
Route-2 handover: the cuBLASLt block-scaled FP8 path is more reachable than either of us thoughtRecorded 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 compileI originally reported route 2 as blocked because We reach cuBLASLt through
This also independently corroborates the peer's header grep from a real 13.0 box — Also present in the 12.4-era 2. 🔑 The activation quantiser already exists — it does not need writing, and
|
…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>
… 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
…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>
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_tiledis 66% of a B=256 decode step — 524 ms of 794. Its inner loop istwo 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.
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 —
mmawith 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_32Fis documented at compute capability 9.0 — Hopper, exactly our target (and notably not on Blackwell, which uses the MXVEC32_UE8M0modes). 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.yamlpins 12.4.1. It also requires FP8 activations withVEC128_32Fper-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::wmmafixes 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
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) andqtip::gather_policy'sn >= 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
fp8_matmul_wmma_tile_dimsexportsN_BLK/K_BLKsofp8_wmma_eligiblecannot drift from the tiling the kernel was compiled with. The failure mode of that drift is a numerically wrong GEMM, not a crash.wmma::load_matrix_syncrequires a 16-byte-multiple leading dimension for 16-bit types;(128+8)*2 = 272 B = 17*16satisfies 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=trueonly contracts the promotion into an FMA (more accurate).--ftz=trueflushes 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, includingexpected_kernel_count_matches_disk.EXPECTED_KERNEL_COUNT41 → 43 (kernel + its cc<8.0 link stub); the dummy-stub count in that file's own note updated 5 → 6.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