Skip to content

perf(qtip): arch-specialise the trellis grouped GEMM (D16) + close the silent-failure holes (D18) - #99

Open
heydryft wants to merge 6 commits into
release/openrouter-readyfrom
perf/qtip-grouped-gemm-arch
Open

heydryft wants to merge 6 commits into
release/openrouter-readyfrom
perf/qtip-grouped-gemm-arch

Conversation

@heydryft

@heydryft heydryft commented Aug 17, 2026 •

Copy link
Copy Markdown
Contributor

What this is

The trellis grouped GEMM is the one kernel nobody can adopt from us: FlashInfer's grouped GEMM is stock CUTLASS with a closed dtype enum, no weight-decode hook (gemm/group_gemm.cuh:63,67) and ElementA = ElementB (group_gemm_sm90.cuh:65-66). Trellis is a state machine, not a dtype. This PR takes it from an Ampere baseline to an arch-dispatched pair of kernels, and closes the silent-failure holes on the path.

First, the framing correction

The 22%-of-roofline baseline this work was scoped against does not exist as a valid measurement. FACTS.md:800-812 retracts the 63.5 ms grouped-GEMM microbench (wave35-BM) for two independent defects — an E=64 fixture when V4-Flash has 256 experts, and a LazyLock cap trap that made the grouped-vs-GEMV switch a silent no-op, so every prior "gemv" row re-measured the grouped kernel. FACTS.md:877-879 still derived "~22% of roofline" from that retracted 63.5 ms. It should not be quoted, including as a baseline to beat. (Now corrected in FACTS.md.)

And the roofline is the wrong ceiling anyway. The project has already measured the real one: the gen-2 GEMV sweep ran 98/98 variants and found every latency-hiding axis negative — split-K, staged width, warp specialization −38% — concluding "the cp.async premise is disproven, not unrealized… the ceiling is per-symbol trellis decode serialization — decode FEWER SYMBOLS, do not hide latency" (FACTS.md:815-829). That applies to this kernel too. It is ALU-issue-bound on decode. Faster tensor-core instructions address the multiply, which is not the bottleneck. Both changes below target decode work instead.

What runs today vs what is dead

  • The grouped GEMM is live in production, not test-only: moe/experts.rs:604/608/638 → lib.rs:1404 → bitshift.rs:1824 → :1588 → cuda_ops.rs:1837. No flag required.
  • It serves prefill and batches above 8 tokens only. b=1..8 decode goes to the tuned gather-GEMV (cuda_ops.rs:1452 → qtip_bitshift_tune.cu, baked variants 21/6 — the two V4-Flash expert shapes).
  • qtip_bitshift_tune2.cu (gen-2, 98 variants) has no production caller: nothing in tune.rs:71-83 is ≥44, so it is reachable only via ARC_QTIP_GEMV_VARIANT. Dead in production.
  • qtip2b_beam.cu, qtip_beam.cu, qtip_quantize.cu are bake-time only. qtip_gather_gemv.cu / qtip_gemv.cu serve the older qtip2 rung, not the shipped qtip2b artifact.

The two changes

1. m-tile 16 → 64 on SM90+. Total decode work is (number of m-tiles) × (expert weight count) — every m-tile re-reads and re-decodes its expert's whole matrix. At 64 routed pairs per expert that is 4 m-tiles at 16 and 1 at 64: 4× less decode and 4× less HBM traffic for the same output. Below 16 pairs/expert both schedules issue one tile, so it is weakly better everywhere. This was in the file's own tuning notes and had never been executed. 64 is also wgmma's and tcgen05's M.

2. Contiguous-run decode. q2b_state_from_window is ~11 ALU ops and the Ampere kernel pays it per weight, because an mma B fragment hands each thread runs of length 2. Each thread now owns a run of 32 symbols of one weight row, seeds it once with the window identity and steps the original trellis recurrence (~2 ops) — state reconstruction drops from ~11 ops/weight to ~2.3. Runs stay independent, so there is still no cross-thread state chain and no warm-up replay.

The moat is intact. Packed 2-bit bytes are what cross HBM. Decode lands in a single 64×64 tile of shared memory that is overwritten every k-chunk; full-size weights never materialise. Routing weights through shared memory is also exactly what the arch-native MMAs demand — wgmma's B operand must be in shared memory, and tcgen05 takes no register operands at all — so this producer stage is already their required shape.

D16 status — partial, and here is precisely what is missing

in build in dispatch native MMA
sm_80 / sm_89 yes yes mma.sync.m16n8k16 (native)
sm_90 / sm_90a yes (cudaforge auto-suffixes ≥90 to sm_90a) yes, by runtime cc no — still mma.sync, not wgmma
sm_100 / sm_103 yes (auto sm_100a) yes, by runtime cc no — still mma.sync, not tcgen05

What is genuinely arch-specialised: the tile schedule, the decode structure, and the shared-memory budget (cudaFuncSetAttribute — without it a block is capped at 48 KB regardless of the 227 KB that cc 9.0 and 10.x allow, and 8.9 allows only 99 KB). mma.sync.m16n8k16.bf16 is documented "requires sm_80 or higher" under the onion-layer model, so it is valid on sm_90a and sm_100a — but it is not native there, and D16 names wgmma/tcgen05 specifically. I am not claiming otherwise.

Why they are not in this PR. wgmma is sm_90a-exclusive (PTX §9.7.16.5.2 "Requires sm_90a"; a-suffix targets "do not follow the onion layer model" and "cannot be run on later generation devices") and tcgen05 is sm_100a+, so they are two separate kernels, not one. Both hinge on the shared-memory matrix descriptor: a 64-bit value pinning swizzle mode, leading-dimension byte offset and stride byte offset to a core-matrix layout the PTX doc specifies semantically rather than as a numeric table — and the two descriptor layouts differ (wgmma: swizzle in bits 62-63; tcgen05: bits 61-63, different encoding, extra fixed fields). A wrong descriptor produces wrong numbers, not an error. Writing that blind, with no GPU in this session, on the one kernel that is the moat, is how D18 gets its ninth instance.

The unblock is cheap and specific: a ~30-line descriptor probe on any sm_90a box — build a known tile, run one wgmma, diff against this kernel's mma.sync result. Minutes of GPU time, and it converts the swap from guesswork into a checked change. The repo already vendors a make_smem_desc + wgmma_m64n{64,128}k16 helper (src/sage_cuda/kernels/wgmma.cuh); per the PTX doc, adding bf16 is a one-token change (.f16.f16 → .bf16.bf16) with the identical shape set, A-fragment layout and descriptors.

D18 — this path had no launch-status check anywhere

grep -rn "cudaGetLastError\|cudaPeekAtLastError" mistralrs-quant/kernels/qtip/ returned zero hits. The GEMV path has check_gather_gemv_pairs precisely because a discarded launch status hands the caller the alloc_zeros buffer — a silently all-zero MoE layer, shape-correct and error-free. The grouped path had no analogue. Fixed:

  • every launcher returns cudaError_t and ends with cudaGetLastError(); the host bails with the code and the shape.
  • if (grid <= 0) return; → cudaErrorInvalidConfiguration.
  • pre-Ampere devices → cudaErrorNotSupported, instead of launching a kernel whose body is #if-compiled out (an empty kernel returns success and writes nothing).
  • the m-tile schedule is queried from the device and cross-checked against the Rust mirror — route and GEMM disagreeing on tile_m mis-bins the tile map and yields wrong numbers, not an error.
  • qtip_grouped_curve now asks the device for its m-tile instead of assuming the Ampere constant; on an H200 that guess would have bailed 4× early and overcounted tiles 4× in its traffic model.
  • a const _: () = assert! ties GROUPED_DECODE_RUN to (tile_n × tile_k) / threads, so retuning one tile constant stops the build rather than leaving the kernel decoding a partial tile.

I also found and fixed a real race in my own new kernel before it left the branch: the next chunk's cp.async staging could overwrite the buffer the current chunk's mma was still reading, with no barrier between them. Silent corruption, no error. There is now a trailing __syncthreads() with a comment saying why.

A validation blind spot worth naming

The first push failed the CUDA lane with 15 Rust errors while 33/33 .cu files compiled. My report said "Rust side: check + scoped clippy green" — true, and structurally incapable of catching this: on macOS there is no CUDA toolkit, so #[cfg(feature = "cuda")] modules (qtip/cuda_ops.rs, qtip/ffi.rs) are never type-checked at all. "cargo check green" on a Mac does not mean "compiles with cuda", and nobody should read it that way. The gate that catches it is free and already exists: cuda_compile_check.yaml runs cargo build -p mistralrs-quant --lib --features cuda on a GPU-less runner. It should be the first thing consulted on any PR touching cuda-gated Rust — it costs nothing and needs no rental.

Two things fixed as a result:

  • arc-tools/cuda_compile_check.sh blamed the wrong stage. cargo build --features cuda does two things — nvcc on the .cu files, then rustc on the cuda-gated Rust — and the script reported "mistralrs-quant cuda kernels did not compile" for a pure Rust failure, sending the operator into the nvcc output for two round trips. It now names both stages, says how to tell them apart, and keeps 40 lines of tail instead of 5 so the rustc errors are visible.
  • The bug itself was another instance of the shape this PR is about. The launch-status check meant for grouped_dtype! landed in dequant_dtype! ~800 lines earlier: both macros end with the same unsafe { $launch(...); } drop(out_guard); wrap_cuda_slice(...), and my patch asserted its anchor was present, not unique, so it took the first match. dequant_dtype!'s launcher returns () → E0308 ×3; the bail message named cc_major/cc_minor/n_pairs/tile_m, which do not exist in that function → E0425 ×12 (n_rows and num_symbols do exist there, which is exactly why they are absent from the error list). And the grouped GEMM — the launch this whole PR is about — ended up with no status check at all. "The anchor matched" was read as "it matched where I meant": the same mechanical shape as the bug being fixed, in the fix for it.

Verification status — read this literally

  • [CPU-VERIFIED] cargo check, scoped clippy lane green, 257/257 mistralrs-quant lib tests pass. Four new tests cover the run-decode identity, the warm-up region, the arch schedule selector and the grid bound; 4/4 mutations caught (off-by-one symbol, wrong state mask, off-by-one seed, shift-by-1). D14: these are unit tests, not a kernel measurement.
  • [COMPILED] cuda_compile_check.yaml compiles all 33 .cu files for sm_80 and sm_90 and type-checks the cuda-gated Rust. The coordinator's H200 run independently confirmed 33/33 kernels compiled, so the new SM90 kernel is nvcc-clean; the failure was Rust-only and is fixed above.
  • [NOT COMPILED: sm_100a] the repo's CI matrix is sm_80/sm_90 only. arc-tools/wave64_keystone_gate.sh adds sm_100a and reports an explicit ENV-CANNOT-ANSWER line when the toolkit predates it. Blackwell is a compile target only for us either way — we cannot rent one.
  • [NOT MEASURED] no roofline percentage is claimed. The old one was retracted and I have not produced a new one.

arc-tools/wave64_keystone_gate.sh answers all three, in order: stage 1 compiles for sm_80/sm_90a/sm_100a (nvcc cross-compiles — any box with CUDA ≥12.8 does this, an A30 at ~$0.40/hr is enough); stage 2 runs the grouped parity tests on real silicon; stage 3 A/Bs qtip_grouped_curve against the baseline on the same box. Three exit codes — 0 pass, 1 genuine failure, 2 environment-could-not-answer — and a terminal line for every outcome including timeout, so silence cannot read as success. It asserts the parity run actually executed tests and that the curve produced data rows, because both have reported green over nothing before.

The arch witness (D18 #12) — added on review

The dispatch above picks the warpgroup path from the device's compute capability, but qtip2b_grouped_gemm_kernel_wg's body is #if __CUDA_ARCH__ >= 900, which is a property of the binary. Those two can disagree, and when they did, nothing said so:

a build whose archive carries only sm_80 SASS, run on an H200, JITs from compute_80 PTX — PTX in which the warpgroup body was already compiled away. cc_major is 9, so dispatch takes the wide branch, launches a kernel that writes nothing, and cudaGetLastError() returns success. The caller receives its alloc_zeros buffer as a shape-correct, error-free, all-zero MoE layer.

That is this PR's own launch-status hole reappearing one level up. #108 (ARC_CUDA_ARCHS) makes the sm_90 cubin exist, so the chain closes it in practice — but a guarantee that depends on another PR having landed, on nobody building without the env var, and on no branch being cut from an intermediate state is a sequencing hope, not a gate.

qgw_arch_witness_kernel is a real kernel carrying the same __CUDA_ARCH__ guard as the one it vouches for, in the same TU with the same arch flags. Launched once per process, it reports what the running binary actually contains. Checked in three places: qtip2b_grouped_query_schedule (so the Rust caller names the cause in words), the launcher macro (so the C ABI is safe for any other caller), and qtip_grouped_tile_m() (a harness reading the SM90 m-tile off a binary that cannot run the SM90 kernel is modelling a fiction).

Two ways the witness could have become the bug it exists to catch, both closed:

  • Graph capture. It launches on the default stream. If that stream is capturing, the launch is appended to a graph rather than executed and the readback returns the memset zero — a false "no SM90 device code" that would refuse a path which works. It checks cudaStreamIsCapturing on the same stream the launch uses and declines to answer rather than answering wrongly.
  • Caching a non-answer. The result was initially a static const initialized once, so a witness that could not be taken would poison every later call. Only a successful witness is cached; anything else is retried.

It never defaults on failure — "could not answer" is not "answered yes".

Noticed, not shipped

dequant_dtype! (the qtip2b dequantize path) has the same D18 exposure the grouped path had — its launchers return void, so a failed launch hands back the alloc_zeros buffer. The launchers are actually in qtip_bitshift.cu:527-541 (a single Q2B_DEQUANT_LAUNCHER macro), not qtip_dequantize.cu — so the fix is smaller than assumed: void->int, one cudaGetLastError(), an empty-grid guard, three ffi.rs decls and an rc check in the macro. Still a different subsystem from this PR's subject. Worth a separate change.

Scope honesty

The grouped GEMM is not the current bottleneck and this PR does not make it one. At 111.69 tok/s aggregate we are at low single-digit percent of H200 bandwidth, and the measured gaps are dominated by host overhead and the xs history, not kernel math. This is the durable moat and the right long-term build; it is not today's limiting factor, and nobody should describe it as one.

🤖 Generated with Claude Code

…lumbing (D18)

Two kernels behind one dispatch, picked by the device's RUNTIME compute
capability:

  sm_80/sm_89  qtip2b_grouped_gemm_kernel_sm80 — unchanged, byte-identical.
               This is the path with 5/5 hardware parity; it is not touched.
  sm_90/sm_100 qtip2b_grouped_gemm_kernel_wg — 64-pair m-tile, contiguous-run
               trellis decode into a shared-memory weight tile, dynamic shared
               memory claimed at the arch's real budget.
  below sm_80  the launcher returns cudaErrorNotSupported rather than
               launching a kernel whose body is compiled out.

The two levers, both aimed at the MEASURED ceiling (the gen-2 GEMV sweep
measured 98/98 variants and concluded the limit is per-symbol trellis decode,
not memory latency):

1. m-tile 16 -> 64. Total decode work is (number of m-tiles) x (expert weight
   count): every m-tile re-reads and re-decodes its expert's whole matrix. At
   64 routed pairs per expert that is 4 m-tiles at TILE_M=16 and 1 at 64 —
   4x less decode AND 4x less HBM traffic for the same output. Below 16
   pairs/expert both schedules issue one tile, so this is weakly better
   everywhere. This was already in the file's own tuning notes, unexecuted.

2. Contiguous-run decode. q2b_state_from_window is ~11 ALU ops and the Ampere
   kernel pays it per weight, because an mma B fragment gives each thread runs
   of length 2. Each thread now owns a run of 32 symbols of one weight row,
   seeds it once with the window identity and steps the trellis recurrence
   (~2 ops). Runs stay independent — no cross-thread state chain, no warm-up
   replay. Mirrored and property-tested on CPU by grouped::run_states_2b.

The trellis decode stays fused inside the multiply: packed 2-bit bytes are
what cross HBM, and decode lands in a single 64x64 shared-memory tile that is
overwritten every k-chunk. Full-size weights never materialise. Routing weights
through shared memory is also what the arch-native MMAs require — wgmma's B
operand must be in shared memory and tcgen05 takes no register operands — so
the producer stage is already the right shape for them.

D18 (silent success), on a path that had no launch-status check anywhere:
- every launcher returns cudaError_t and ends with cudaGetLastError(); the host
  bails instead of handing back its alloc_zeros buffer as a valid all-zero MoE
  output.
- `if (grid <= 0) return;` is now cudaErrorInvalidConfiguration.
- pre-Ampere devices get cudaErrorNotSupported, not an empty-body launch.
- the m-tile schedule is queried from the device and cross-checked against the
  Rust mirror, because route and GEMM disagreeing on tile_m mis-bins the tile
  map and yields wrong numbers rather than an error.
- qtip_grouped_curve asked the device for its m-tile instead of assuming the
  Ampere constant, which on an H200 would have bailed 4x early and overcounted
  tiles 4x in its traffic model.
- const-assert ties GROUPED_DECODE_RUN to (tile_n * tile_k) / threads so a
  retuned tile cannot leave the kernel decoding a partial tile.

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

github-actions Bot commented Aug 17, 2026 •

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                     72        24328        17592         4018         2718
 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                  143        14830        12217          797         1816
 Shell                    23         5756         3964         1412          380
 Plain Text                4         3801            0         2479         1322
 TOML                     33         1485         1292           43          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                188        37465            0        28743         8722
 |- BASH                  69         1614         1187          311          116
 |- C                      2           12           12            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                  65         2048         1713           77          258
 |- TOML                   6          207          164            0           43
 |- YAML                   4           38           33            5            0
 (Total)                            43185         4661        29265         9259
─────────────────────────────────────────────────────────────────────────────────
 Rust                    662       306982       265873        13815        27294
 |- Markdown             477        22543          471        19392         2680
 (Total)                           329525       266344        33207        29974
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
 Total                  1276       450597       329477        73114        48006
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

heydryft and others added 2 commits August 17, 2026 14:55
The status check meant for `grouped_dtype!` landed in `dequant_dtype!`
instead. Both macros end with the same
`unsafe { $launch(...); } drop(out_guard); wrap_cuda_slice(...)` shape, and
the patch that introduced the check matched on that shape and took the FIRST
occurrence, which is the dequantize path ~800 lines earlier.

Two consequences, both real:

* `dequant_dtype!` got `let rc = unsafe { $launch(...) }` around a launcher
  that returns `()`, so `if rc != 0` was `()` vs integer — E0308 x3 (bf16,
  f16, f32).
* the bail message referenced `cc_major`, `cc_minor`, `n_pairs` and `tile_m`,
  which are locals of `grouped_gemm_2b_cuda` and do not exist in the
  dequantize function — E0425 x12. `n_rows` and `num_symbols` DO exist there,
  which is exactly why those two names are absent from the error list.

* and the grouped GEMM itself — the launch this whole change is about — ended
  up with NO status check at all. The D18 fix was itself silently not applied:
  the patch asserted only that its anchor text was PRESENT, not that it was
  UNIQUE, so "the anchor matched" was read as "it matched where I meant".
  Same mechanical shape as the bug it was fixing.

Fixed: `dequant_dtype!` restored verbatim, the status check moved to
`grouped_dtype!`. Implicit `{ident}` format capture is kept — it resolves
correctly inside a `macro_rules!` body, including through `bail!`'s own macro
layer (verified with a standalone repro); the earlier hygiene theory was wrong
and the real cause was only the misplacement.

Also:

* `arc-tools/cuda_compile_check.sh` said "cuda kernels did not compile" for a
  failure of `cargo build --features cuda`, which does TWO things: nvcc on the
  .cu files, then rustc on the cuda-gated Rust. It sent the operator to the
  nvcc output when all 33 kernels had compiled and the Rust had not. The
  message now names both stages and says how to tell them apart, and keeps 40
  lines of tail instead of 5 so the rustc errors are actually visible.
* `ffi.rs`: the new query fn had been inserted between the routing launcher's
  doc comment and its signature. Comment restored to its declaration.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The grouped-curve harness queries the device's arch-dependent m-tile (D16)
rather than assuming the Ampere constant, but the query fn lived in
qtip::mod without a lib.rs re-export, so the cuda build could not see it and
the nvcc lanes failed to compile the example. Export it under the same cuda
cfg as its FFI, and fully-qualify the non-cuda constant so neither arm warns.
…ss (D18 #12)

The dispatch this PR adds picks the warpgroup path from the DEVICE's compute
capability. But `qtip2b_grouped_gemm_kernel_wg`'s body is `#if __CUDA_ARCH__ >=
900`, which is a property of the BINARY. Those two can disagree, and when they
do nothing says so:

  a build whose archive carries only sm_80 SASS, run on an H200, JITs from
  `compute_80` PTX — PTX in which the warpgroup body was already compiled away.
  `cc_major` is 9, so dispatch takes the wide branch, launches a kernel that
  writes nothing, and `cudaGetLastError()` returns success. The caller receives
  its `alloc_zeros` buffer as a shape-correct, error-free, ALL-ZERO MoE layer.

This is the same failure this PR already closes one level down — a discarded
launch status handing back the zeroed buffer — reappearing one level up, and it
is the exact shape hit eleven times today: the absence of a signal read as a
specific signal.

ARC_CUDA_ARCHS (PR #108) makes the sm_90 cubin exist, so the chain closes this
in practice. That is not good enough. A guarantee that depends on another PR
having landed, on nobody building without the env var, and on no branch being
cut from an intermediate state is a sequencing hope, not a gate. So:

`qgw_arch_witness_kernel` is a real kernel carrying the SAME `__CUDA_ARCH__`
guard as the one it vouches for, compiled in the same TU with the same arch
flags. It is launched once per process (function-local static, thread-safe
initialization — this is reachable from every model-parallel worker at once)
and reports what the running binary actually contains: 900 if the warpgroup
body survived compilation, otherwise the arch it was compiled for.

It does NOT default on failure. A witness that could not be taken is a nonzero
cudaError_t and the caller refuses — "could not answer" is not "answered yes",
which is the same rule the probe harness follows.

Checked in two places on purpose: `qtip2b_grouped_query_schedule` reports it so
the Rust caller can name the real cause in words ("this binary carries no SM90
device code … would have produced an all-zero MoE layer with no error"), and
the launcher macro refuses on its own so the C ABI is safe for any other
caller. `qtip_grouped_tile_m()` checks it too — a harness reading the SM90
m-tile off a binary that cannot run the SM90 kernel is modelling a fiction.
…nd must not cache a non-answer

Two ways the witness could itself become the silent-failure it exists to catch.

1. GRAPH CAPTURE. The witness launches `<<<1,1>>>` on the default stream. If
   that stream is capturing, the launch is APPENDED TO A GRAPH rather than
   executed, and the readback returns the memset zero — reported as "this
   binary carries no SM90 device code" and refusing a path that works
   perfectly. A false negative is still a wrong answer. It now checks
   `cudaStreamIsCapturing` on the same stream the launch uses and declines to
   answer rather than answering wrongly. (Capture also makes the API return
   `cudaErrorStreamCaptureImplicit` in some arrangements; that is a non-success
   status and takes the same path.)

2. CACHING A NON-ANSWER. The result was a `static const` initialized once, so
   the first attempt's outcome was permanent. A witness that could not be taken
   during capture would then poison every later call and keep the path refused
   long after the condition cleared. Only a SUCCESSFUL witness is cached now;
   anything else is retried.

Two threads racing the first call may both take the witness. That is benign and
deliberate — the probe is idempotent and its result is a deterministic property
of the binary, so both writes store identical bytes. A lock on a path that runs
per MoE layer would cost more than the duplicate.
The gate defaulted every artefact to a shared, un-namespaced path:
status.txt, the per-arch compile logs, the parity log, both curve logs and the
baseline worktree. Two gates running concurrently on the same box overwrite
each other's status file — and reading one agent's status as another's is a
failure that has already cost a round trip on this project.

Everything now lives under GATE_DIR (default /tmp/arc-gate-wave64-keystone),
overridable, with STATUS defaulting inside it. This is the same convention the
wave65 ArcTarget gate already follows; #99's gate predates it and did not.

No behavioural change to what the gate measures.
@heydryft

Copy link
Copy Markdown
Contributor Author

ArcGate triage. NEEDS-OWNER — and explicitly NOT superseded by #124. I checked that first, because "the other QTIP grouped-GEMM PR landed" is the obvious wrong conclusion here.

They are disjoint work under one filename. No commit is shared:

#124's own record states it does NOT satisfy D16 — SM90 measured, SM100 never compile-verified — and its author declined that credit explicitly. So this PR is the only dual-arch work in the tree. Closing it as superseded would have deleted the one thing that would earn D16, which is why I did not.

Why it is not landing right now. #124 merged first (it had a real 17/17 CI complete; this branch has 16 lanes and no CI complete at all, because it predates that required job). Absorbing master now conflicts in 4 files, 8 hunks, all in the keystone:

mistralrs-quant/kernels/qtip/qtip_grouped_gemm.cu   3 hunks
mistralrs-quant/src/qtip/grouped.rs                 3 hunks
mistralrs-quant/src/qtip/cuda_ops.rs                1 hunk
mistralrs-quant/examples/qtip_grouped_curve.rs      1 hunk

They are not import collisions. They are the arch-dispatch block (// ARCH PATHS (D16), two kernels chosen by runtime compute capability) landing on top of #124's rewritten decode inner loop, plus both branches editing the same launch macro — this branch adding the grid <= 0 => cudaErrorInvalidConfiguration refusal, #124 changing what surrounds it.

I aborted the merge rather than resolve it, and left this branch untouched on the remote. Reconciling an arch-dispatch fork with a rewritten decode loop is a change to the kernel's meaning, not a textual merge, and getting it wrong here is exactly the failure mode the moat cannot afford: a wrong resolution does not fault — it computes wrong numbers on the one kernel nobody else can adopt from us.

What it needs from its owner:

  1. rebase onto master (which now contains perf(qtip): trellis grouped-GEMM 1.71x — the keystone was ALU-bound on the decode, not tensor-bound #124's variants 1+2);
  2. re-verify the arch witness still fires on the rewritten inner loop — the witness is the thing that makes this PR worth more than a -gencode flag, and a rebase is exactly when it could go silently inert;
  3. re-run the bit-identity gate, using the cached bake fixtures at ~/.config/arc/bakes/ (--bake-cache) so the sweep costs under a minute of card instead of ~28.

Not stale, not superseded, not closeable. Parked on an owner.

@heydryft

Copy link
Copy Markdown
Contributor Author

Triage verdict: REBASE, not close. Checked against current master specifically because this touches what is now the single largest cost in the decode step.

Nothing of its two core changes has landed. Master's grouped GEMM did evolve — ad6426d80 removed __brev+pair-swap from per-weight decode, 7904840eb added the ldmatrix.x4 A-fragment — but both are within the 16-row Ampere tile and orthogonal to this PR. On master today: grouped.rs:29 GROUPED_TILE_M = 16 unchanged, no QGW_* SM90 kernel, no grouped_tile_m_for_cc, and qtip/cuda_ops.rs has zero cudaGetLastError checks (the D18 silent-failure holes this PR closes are all still open). #149 only deleted dead gather-GEMV code; #167 changed the gate, not the kernel.

One conflict must be resolved deliberately, not mechanically. #167 (now on master) writes the amortization gate grouped_gemm_tiles_amortize in gather_policy.rs against the GROUPED_TILE_M constant. Raising that to 64 on SM90+ moves the threshold ~4x. So the rebase must make the gate read grouped_tile_m_for_cc rather than the constant — otherwise the gate and the kernel disagree about tile size, which is a wrong-answer bug, not a perf bug.

Conflicts: 4 files — qtip_grouped_gemm.cu, grouped.rs, cuda_ops.rs, qtip_grouped_curve.rs.

Not merging it now: the m-tile change is a numeric claim and there is no GPU to re-measure it on. #167's own finding — the kernel sits ~60x above its bandwidth bound even at 230% fill — argues the m-tile change should be measured on prefill shapes rather than assumed. Land it when a box exists. The D18 error-check half is independently safe and could be split out.

Productive ordering: #108 -> #109 -> #99. #109's descriptor probe is precisely the unblock this PR's body names for wgmma/tcgen05.

heydryft added a commit that referenced this pull request Aug 20, 2026
…192)

#167 is the only one of the seven PRs merged in the 2026-08-20 queue sweep
that changes behaviour for a flagless user, and it landed on a green-CI gate
that cannot see performance. Recording it in the register so it stays a
visible open question rather than a merged assumption.

The gate moved the 9-682 token band from grouped GEMM to gather-GEMV --
batched decode at B>=9 and every prefill chunk under 683 tokens, which is
where chunked prefill lives. The path it now prefers carries 3.15x redundant
reads, so it may be slower exactly where it fires. Names the A/B that settles
it (B=16/32/64, chunks 128/256/512) and the coupling with the open #99, whose
GROUPED_TILE_M -> 64 on SM90+ moves the threshold ~4x and which must read
grouped_tile_m_for_cc or the gate and kernel will disagree on tile size.

Also records the caveat covering the whole sweep: every number attached to
the seven merged PRs was measured on that branch's own base, not on current
master, and none may be quoted until re-measured.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
@heydryft
heydryft changed the base branch from master to release/openrouter-ready August 21, 2026 15:37
@heydryft

Copy link
Copy Markdown
Contributor Author

Retargeted at the integration branch

Base changed: master → release/openrouter-ready.

The queue is being restructured to the shape the owner asked for: one PR open against master (#194), with everything else merging into a single integration branch. Agents branch off release/openrouter-ready and PR back into it; #194 is the single gate from there to master.

Two things had to land on master first for this to be workable, and both have:

  1. The base-branch CI lane used to hard-fail any PR not targeting master (ci(ArcGate): let the base-branch lane permit the single integration branch — 7 PRs are red for addressing, not quality #216). Seven PRs were red for their addressing, not their quality. The lane now accepts master or release/openrouter-ready — an exact-match allowlist, nothing else — and its intent is intact: release/openrouter-ready reaches master only through Arc → OpenRouter-ready: the single integration PR (everything else merges into this branch) #194, whose own base is master, so nothing enters master without a full run whose base is master.
  2. release/openrouter-ready was re-cut from current master. It had diverged badly — merging it as-is would have reverted TCFRAG (perf(ArcQuant/ArcKernels): TCFRAG-2B — put the qtip2b trellis GEMV on the tensor cores #203) and re-broken the flag-polarity work (fix(ArcGate): read boolean ARC_* flags by value, so =0 means off #212). It is now master plus only the work it uniquely carried.

This PR was not closed and is not considered stale. An audit of the queue found the overwhelming majority of it to be real work that was never merged, not noise.

What you need to do: rebase onto release/openrouter-ready and resolve conflicts against it rather than against master. CI lanes will now actually run and report on this PR instead of failing at the base check.

@heydryft

Copy link
Copy Markdown
Contributor Author

Rebase attempt onto release/openrouter-ready — STOPPED, left open

This branch and the integration branch are two incompatible refactors of the same kernel, not a textual conflict:

  • Integration branch made qtip2b_grouped_gemm_kernel a template over VARIANT — BASELINE/TUNED/LDST, dispatched at runtime from ARC_QTIP_GROUPED_VARIANT, with LDST now the shipped default. Its tests pin the variant-1/2 mechanisms (padded s_x/s_wp strides, ldmatrix bank behaviour, pair-reversal).
  • This branch drops the variant axis entirely, splits by arch into qtip2b_grouped_gemm_kernel_sm80<T> / qtip2b_grouped_gemm_kernel_wg<T> (64-pair m-tile, contiguous-run decode, dynamic smem), and adds the D18 status returns. Its tests pin run_states_2b / GROUPED_DECODE_RUN.

Conflicts: 6 hunks across kernels/qtip/qtip_grouped_gemm.cu (incl. the whole Q2B_GROUPED_GEMM_LAUNCHER macro) and src/qtip/grouped.rs.

Reconciling them means designing a kernel that is specialised on both axes — deciding whether the warpgroup path gets variants, rewiring the launcher to dispatch on arch × variant, and changing the FFI signature to the status-returning form. That is a re-implementation, not a conflict resolution, and the bit-identical-output gates both sides rely on cannot be run without a GPU. Compiling under nvcc would prove nothing here — silent-success is precisely what the D18 half of this PR exists to prevent.

Needs an owner with a GPU to land. Nothing was force-pushed; the branch is untouched.

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