Skip to content

Marlin int4 tensor-core GEMM for MatMulNBits M>1 (capture-safe, WIP) - #960

Merged
justinchuby merged 28 commits into
mainfrom
squad/marlin-kernel
Aug 14, 2026
Merged

justinchuby merged 28 commits into
mainfrom
squad/marlin-kernel

Conversation

@justinchuby

@justinchuby justinchuby commented Aug 14, 2026 •

Copy link
Copy Markdown
Owner

Marlin-style fused fp16xint4 tensor-core GEMM (WIP -- draft)

Builds the primary decode-perf lever from docs/research/decode-remaining-levers-feasibility.md
and the capture enabler from the spec-capture feasibility doc (#957): a Marlin-style
fused fp16xint4 tensor-core GEMM to replace the slow M>1 path of
com.microsoft::MatMulNBits (today a portable 16x16 CUDA-core tiled GEMM) and,
conditionally, the M=1 decode GEMV. Refs #957.

What's landed (Stage 1-4)

  • New module crates/onnx-runtime-ep-cuda/src/kernels/marlin_gemm.rs (kernel +
    host/device repack isolated from the 475 KB matmul_nbits.rs).
  • From-scratch SM80 mma.sync.m16n8k16 kernel using raw inline PTX (no <mma.h>),
    adapting Marlin ideas (repacked per-lane weights, per-group scale AFTER the
    tensor-core accumulate) to our ONNX-native asymmetric-zp / nibble format,
    group sizes 16/32/64/128.
  • repack_int4_weights + on-device matmul_nbits_marlin_repack: bijective
    8-column-interleaved reorder for coalesced 64-byte/warp loads. Device repack is
    byte-identical to the host reference.
  • device_supports_marlin() SM80 guard for the Rule 11 byte-identical fallback.
    Static launch grid => capture-safe by construction.
  • Wired into the MatMulNBits M>1 dispatch (opt-in ONNX_GENAI_MARLIN_M_GT_1,
    default OFF, tier-scoped). Module-level repack cache repacks immutable-initializer
    weights once during warmup; warm replays do no allocation and are capture-safe.
    Cold-miss-during-capture is rejected. Any ineligibility / launch error falls
    through byte-compatibly to the portable tiled GEMM.
  • capture_support advertises M>1 support on a warm cache -- the property that
    unlocks capture-stable speculative verification at M>1.

Validation

  • Numerics: marlin_parity_vs_f64_oracle (standalone) and
    marlin_m_gt_1_op_parity_and_capture_safety (through the real op) pass vs an f64
    dequant->GEMM oracle across M in {1,2,7,16,33,64}, group in {16,32,64,128}, fp16/fp32
    scales, sym+asym zero-points (abs <= 2e-2*max|out|; op worst_abs=0.013 << tol=0.67).
    The op test asserts cold=NOT-capture-safe / warm=capture-safe + byte-identical replay.

  • Wall vs tiled (through the op), marlin_m_gt_1_op_wall_vs_tiled, H200,
    K=5120 N=13824 block=128 (median us):

    M tiled us marlin us speedup
    1 69.5 69.0 1.01x (M=1 stays on GEMV)
    2 478.0 140.0 3.41x
    4 494.1 138.5 3.57x
    8 536.4 176.9 3.03x
    16 658.1 251.2 2.62x
    32 1076.7 385.0 2.80x
    64 2010.6 821.6 2.45x
    128 3851.5 1357.9 2.84x

    The M=1->M=2 cliff collapses from 6.9x (69.5->478 us tiled) to ~2x (69.5->140 us).
    Marlin is 2.4-3.6x faster than the tiled GEMM across every M>1.

Honest status

  • M>1: correct, capture-safe (warm), 2.4-3.6x faster than tiled -- landed opt-in.
  • M=1: through-op M=1 Marlin ties the GEMV (69 vs 69.5 us), ~3.1% HBM peak -- does
    NOT clear the feasibility section-3 >=40% precondition, so M=1 stays on the
    existing GEMV (reported, not silently dropped).
  • Next: fused epilogues (rmsnorm-prologue -> swiglu/gate_up/down); small-M perf
    levers (split-K / cp.async); e2e on glm-4-9b-int4 / qwen2.5-14b (with Sebastian).

Draft -- do not merge. Numerics gated by Chew; e2e/capture re-probe by Sebastian.

Update — fused RMS-norm epilogue + e2e PASS

  • Fused RMS-norm prologue for Marlin M>1 landed (first fused epilogue). Stages
    the normalized activation into scratch (byte-identical to the tiled prologue),
    then runs Marlin. Test marlin_m_gt_1_rmsnorm_op_parity: worst_abs=0.0156 << tol=0.90.
  • Stage-5 e2e (tests/marlin_m_gt_1_e2e.rs, native CUDA): tiled (flag off) vs
    Marlin (flag on) greedy token diff on the real models. Both byte-identical:
    • glm-4-9b-int4: 24 greedy tokens match.
    • qwen2.5-14b-instruct-int4 (asymmetric zp): 24 greedy tokens match.
  • Remaining: swiglu/gate_up/down fused epilogues; small-M perf levers (split-K /
    cp.async). Numerics gated by Chew; ORT e2e + capture re-probe by Sebastian.

Update — P1 COMPLETE: full M>1 coverage (gate_up SwiGLU fused), zero tiled fallbacks

Every hot MatMulNBits node in glm-4-9b and qwen2.5-14b now runs through Marlin at M>1.

  • gate_up SwiGLU MLP fusion at M>1 (try_launch_marlin_gate_up_prefill): paired
    gate/up Marlin int4 GEMMs + the identical SiluMul epilogue the tiled path uses, with
    an optional RMS-norm prologue. This is the MLP — the bulk of prefill/verify cost.
    down_proj is a plain tall-skinny MatMulNBits already covered by gemm_marlin_int4.
  • Capture-safe scratch pool (ensure_scratch, size+slot-keyed, rejects cold-miss
    during capture) so warm replays of gate_up + rmsnorm allocate nothing. The rmsnorm
    prefill was migrated onto it and is now capture-safe on warm replay too.
  • Per-node coverage audit (tracer-driven e2e test): asserts zero
    gemm_f16_tiled{,_rmsnorm} / gate_up_swiglu{,_rmsnorm}_prefill at M>1.

Measured coverage audit (H200, Marlin M>1 enabled):

  • glm-4-9b-int4: 240 gemm_marlin_int4, 0 tiled M>1.
  • qwen2.5-14b-int4 (asym zp): 240 gemm_marlin_int4 + 48 gate_up_swiglu_marlin_prefill
    • 1 gemm_marlin_int4_rmsnorm = 289 Marlin M>1, 0 tiled.
  • Greedy tokens byte-identical tiled-vs-Marlin on both (24 tokens).

gate_up unit parity (Marlin vs tiled): worst_abs 0.0001–0.008; warm replays
capture-safe and byte-identical. No hot M>1 node remains on the tiled path — this
unblocks the Increment-0 capture re-probe (segments→1). Next: P2 split-K / cp.async +
honest M=1 precondition re-run.


P2 — widen the win: split-K (landed, opt-in) + honest M=1 re-run

split-K (ONNX_GENAI_MARLIN_SPLITK=1, default OFF): partitions the K/group
range over grid.z to fill idle SMs when M is small (the occupancy-bound regime),
fixed-order fp32 partial reduce ⇒ deterministic/capture-stable, pooled partials ⇒
capture-safe. Conservative choose_split_k only splits under ~2 waves so it never
regresses the already-occupied M≥32 / large-N cases.

Measured (H200, split-K vs direct): N=5120 M=1 2.81×, M=2 2.81×, M=8 2.46×;
N=13824 M=1 2.38×, M=2 1.57×. Gates intact: f64-oracle parity + determinism tests
pass; e2e greedy tokens byte-identical vs tiled with SPLITK=1 on glm-4-9b and
qwen2.5-14b (zp); SM80 guard + fallback unchanged.

cp.async multistage: rejected (measured slower — small-M is occupancy-bound,
not latency-bound; hiding load latency only added barrier overhead).

M=1 DRAM precondition — honest re-run: still fails. Best split-K lifts M=1
Marlin weight-DRAM to only ~8.5% peak, far below the ≥40% floor ⇒ M=1 stays on
the existing GEMV
. Remaining: gate_up SwiGLU M>1 stays on the direct kernel (split-K
reduce for the fused SiluMul epilogue is follow-up).


Stage 2 of spec-capture (#957): last two M>1 capture barriers removed

With Marlin clearing all 240 MatMulNBits M>1 seams, the only ops still declaring
KernelCaptureUnsupported at query-width>1 were GroupQueryAttention (×40) and
SkipSimplifiedLayerNormalization (×80). Both were conservative gates, not real
hazards; now both advertise capture support for a warmed M=K signature.

  • SkipSimplifiedLayerNorm (signature relaxation): static grid, shape-keyed
    metadata cache already the pre-warm valve; relaxed the num_groups == 1 latch.
  • GQA (pre-warmed fixed-M flash): admit an fp16/bf16 q_seq>1 fused-flash
    signature. flash_attention::run is already capture-safe (static grid, device
    total_lengths/past_lengths, no host read-back); on-device metadata +
    capture_error_ptr replace host validation; capture-safe branch uses
    present_capacity (device-masked tail) like the M=1 path. f32 M>1 stays eager.

Gates: greedy tokens byte-identical (glm-4-9b-int4 M>1 prefill); GQA + norm unit
tests pass; fmt + lib clippy + native-backend clippy clean. Capture still declared
only for a warmed signature (pre-warm M=K before capture). Sebastian re-probes for
the decisive segments→1 / B*≤2 (speculative-capture GO).


Segment census confirmed (Sebastian) + last dense seam closed

Sebastian's Increment-0 re-probe (glm-4-9b, M=8, MARLIN_M_GT_1=1) confirmed the
GQA + SkipSimplifiedLayerNorm capture-safety: segments 120 → 1, all 40 GQA +
80 SkipSimplifiedLayerNorm + 240 MatMulNBits M>1 seams gone, byte-identical, no
NaN under capture. Two follow-ups (commit 29714037):

  • Split-K default-ON within the opt-in Marlin M>1 path (opt out
    ONNX_GENAI_MARLIN_SPLITK=0). It is the measured B* lever — Sebastian's probe:
    B* 5.10× → 2.69× at M=8 (wall 51.8→27.3ms). choose_split_k keeps large-M
    prefill on the byte-identical direct kernel; greedy tokens byte-identical.
  • Last M>1 capture seam closed: the lone residual node was lm_head/MatMul
    → logits (plain fp16 dense MatMul, not MatMulNBits), which fell to
    cuBLASLt's per-call heuristic at M>1. Added a cached-plan fast path
    (blas::CaptureGemmPlan + DenseGemmPlan): algorithm + persistent workspace
    selected once at warmup, replayed with no heuristic/alloc/sync — the M>1
    analogue of F32GemvPlan, bit-for-bit identical to governed_gemm at a fixed
    shape. Batched/broadcast GEMMs keep the per-call path.

Gates: greedy tokens byte-identical on glm-4-9b-int4 and qwen2.5-14b-int4-zp; fmt

  • lib clippy + native-backend clippy clean; SM80 guard + fallback unchanged.

Small-M split-K retune for the M=8 verify shape

Profiled glm/qwen decode-GEMM dims at M=8 and found choose_split_k was
under-splitting: it picked split_k=2-3 (or 1 for large-N gate_up) where the
optimum is 8. Root cause — block count is a poor fill proxy at small M, where
each block is latency-bound; deeper K-splitting raises weight-DRAM throughput.
Retuned (split for m<=32, oversubscribe ~8 waves, floor toward the measured
small-M optimum, cap 8). M=8 auto factor before→after:

GEMM K,N old new
o/q proj 4096,4096 sk3 2.34× sk8 2.93×
down_proj 13696,4096 sk3 2.45× sk8 3.18×
gate_up (glm) 4096,27392 none sk8 1.11×
gate_up (qwen) 5120,13824 none sk8 1.35×

The dominant MLP verify cost now splits; prefill (m>32) stays on the
byte-identical direct kernel. Gates: split-K f64-oracle parity + determinism at
sk=8; greedy tokens byte-identical on glm-4-9b-int4; fmt + lib clippy clean.

gate_up (SwiGLU MLP) routed through split-K (3735d57)

Found the residual B* gap: try_launch_marlin_gate_up_prefill launched the two
largest MLP GEMMs (gate/up, glm K=4096 N=13696 each ×40 layers) as direct
launch_marlin_gemm, bypassing split-K. The update-7 retune therefore never
reached gate_up. Both projections now go through maybe_launch_marlin_splitk
(sk=8 at M=8); gate/up reuse the slot-4 partials scratch sequentially (correct
under single-stream ordering), and both split-K warm flags fold into the
returned capture warmth. Gates: marlin_gate_up_swiglu_matches_tiled_* PASS at
M=8 (split-K now active; warm replay byte-identical, cold-miss valve intact);
glm-4-9b-int4 and qwen2.5-14b-int4-zp e2e greedy tokens byte-identical; fmt +
lib clippy + native-backend clippy clean.


FINAL STATUS — frozen for merge review (supersedes the "Next"/"Draft" notes above)

Capture-safety is complete and the M>1 Marlin path is landed opt-in. This
branch is frozen for numerics (Chew) + quality (Gaff) review.

Frozen head: a11facbc (code frozen at 3735d57e; later commits are
decision-drops only). Sebastian's decisive perf A/B was re-probed on the frozen
code commit 3735d57e
(glm M=8, GPU7, ×2): B*=2.15–2.19×, captured M=8
verify wall stable 21.9ms
, segments=1 whole-graph, zero unsupported nodes,
byte-identical greedy tokens, no cold-miss. gate_up split-K routing is thus both
the most complete config and the independently-measured one.

Capture / perf arc (glm-4-9b, M=8 speculative verify, Sebastian's Increment-0 probe):

  • Segment census: 41 → 1 — the whole M=8 verify (all 240 MatMulNBits + 40 GQA
    • 80 SkipSimplifiedLayerNorm + the lone dense lm_head MatMul) captures as one
      whole-graph replay, zero KernelCaptureUnsupported nodes
      .
  • Capture B* 8.76 → 4.99 (Marlin) → 2.71 (split-K) → 2.63 (lm_head dense-plan)
    → 2.16× (small-M split-K retune)
    — an honest plateau at the ≤2 practical-GO
    line (residual is intrinsic M=8 mma.m16n8k16 half-occupancy, not a capture
    blocker). Prefill ~2× (glm 218→426, qwen 154→300). Marlin-OFF == BEFORE (no
    <SM80 / consumer-tier regression).

Per-model note (block size). glm-4-9b is block-128: its gate/up run as
separate MatMulNBits through the general Marlin split-K path (covered by the
retune), so the fused gate_up split-K commit (3735d57e) is a no-op for glm
and its 2.16× is the retune alone. The fused gate_up SwiGLU path requires
block_size==32, so 3735d57e benefits block-32 models (qwen2.5-14b), where
it is confirmed firing under capture (qwen census: segments=1, zero unsupported —
a fall-through would hit the non-capture-safe tiled path and fragment). qwen M=8
verify lands at B*≈4.7×: capture-safety fully generalizes (whole-graph, zero
seams, byte-identical eager parity), but the ratio is inflated by a denominator
effect (capture speeds qwen's fast block-32 M=1 GEMV 34% vs the heavier 14B M=8
verify 18%). Closing qwen's B* is a drafting-depth story, not a kernel fix.
glm-4-9b (block-128) is the canonical practical GO at 2.16×.

Env-var contract (both implemented — verified in marlin_gemm.rs):

  • ONNX_GENAI_MARLIN_M_GT_1 — opt-in, default OFF, tier-scoped (Rule 11).
    marlin_m_gt_1_enabled() (1/true/on). SM80 runtime guard
    (device_supports_marlin) + byte-identical fallback to the portable tiled GEMM
    on <SM80 / CPU EP. Two weight layouts coexist; repack is an added packaging step.
  • ONNX_GENAI_MARLIN_SPLITK — default ON within the opt-in M>1 path; opt out
    =0/false/off for the byte-identical direct kernel. choose_split_k only
    elects a split for small-M / low-wave shapes (returns 1 for large-M prefill), so
    prefill/decode stay on the byte-identical direct kernel.

Coverage: zero M>1 tiled-fallback for the hot MatMulNBits nodes on glm-4-9b +
qwen2.5-14b (plain GEMM, rmsnorm-prologue, swiglu/gate_up, down_proj all fused
through Marlin). GQA + SkipSimplifiedLayerNorm made M>1 capture-safe; lone dense
lm_head MatMul made M>1 capture-safe via a cached cuBLASLt plan (CaptureGemmPlan
/ DenseGemmPlan).

Gates at frozen head (H200 GPU6): f64-oracle split-K parity PASS;
marlin_gate_up_swiglu_matches_tiled_{plain,rmsnorm,decomposed} PASS (split-K
active at M=8, warm replay byte-identical, cold-miss valve intact); glm-4-9b-int4
and qwen2.5-14b-int4-zp e2e greedy tokens byte-identical to the tiled
path; cargo fmt --all -- --check clean; cargo clippy lib + native-backend
(warnings-as-errors) clean.


Test hardening (4803b4fc, test-only — kernel frozen at 3735d57e)

marlin_m_gt_1_matches_tiled_on_qwen2_5_14b_int4 was ~25% flaky: it asserted
exact greedy-token equality against the portable tiled GEMM, whose fp32
atomic reduction order is nondeterministic. At a near-degenerate argmax the
tiled reference flips run-to-run (Marlin is the deterministic side); greedy
decode is autoregressive so one early flip cascades. Not a Marlin bug — a test
oracle bug (glm's prompt has no such tie, so its test was already clean).

Replaced exact-equality with a cascade-robust classifier: compare one tiled vs
one Marlin stream; if they first diverge at position d (identical prefix), a
re-run tiled probe that reproduces the prefix but flips at d proves d is a
near-tie → not a regression. Extracted into a pure GPU-free classify_parity()
with deterministic unit tests for every outcome, so the branch is CI-verified
without needing to reproduce a live tie. Gates: unit tests + glm/qwen e2e pass,
fmt + clippy (lib/native-backend/cuda) clean.

justinchuby and others added 5 commits August 14, 2026 18:24
…age 1-2)

Add crates/onnx-runtime-ep-cuda/src/kernels/marlin_gemm.rs: a from-scratch
SM80 mma.sync.m16n8k16 fused fp16xint4 GEMM adapting Marlin techniques
(repacked per-lane weight layout, per-group scale applied after the
tensor-core accumulate) to the ONNX MatMulNBits format (N-major nibble
packing, asymmetric nibble zero-points, group sizes 16/32/64/128).

- repack_int4_weights: bijective reorder of packed int4 into the per-lane
  tensor-core fragment layout (same byte count, verified by roundtrip test).
- MARLIN_GEMM_SRC: NVRTC CUDA source using raw mma.sync inline PTX (no
  <mma.h>/crt dependency), per-group scale-after-accumulate for precision.
- launch_marlin_gemm: static launch grid (capture-safe by construction) +
  SM80 arch guard (device_supports_marlin) for the Rule 11 fallback contract.
- Host unit tests: repack roundtrip (all group sizes) + device-support gate.

Refs #957 (speculative-capture feasibility, Marlin-gated).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
… parity/bench

Interleave the 8 columns of each n-tile so a 32-lane warp reads one
contiguous 64-byte weight chunk per K slice (lane l -> bytes [2l,2l+1]),
Marlin's key coalescing move. M=1 K=N=5120 drops 323->89us (3.6x);
N=13824 M=1 reaches 7.4% HBM peak. Fragment math unchanged (parity holds).

Add GPU tests (ignored, SM80+):
- marlin_parity_vs_f64_oracle: tolerance parity across M in {1,2,7,16,33,64},
  group {16,32,64,128}, fp16/fp32 scales, symmetric+asymmetric zp. PASSES.
- marlin_bandwidth_microbench: honest achieved weight-DRAM GB/s + %peak per
  shape (gates the M=1 feasibility precondition).

N is padded to a multiple of 8 in the repack; tail columns hold zero codes
and are never stored to output.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ppy)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…mbers)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Add matmul_nbits_marlin_repack: an on-device permutation from ONNX packed
int4 into the tensor-core layout, so the op path (weights arrive on device
as immutable initializers) can repack once and cache. launch_marlin_repack
+ repacked_bytes helpers. device_repack_matches_host GPU test confirms
byte-identical output to the host repack_int4_weights across n/group.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@codecov

codecov Bot commented Aug 14, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 79.00%. Comparing base (94c69fd) to head (41e39a6).
⚠️ Report is 8 commits behind head on main.

Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main     #960      +/-   ##
==========================================
- Coverage   79.01%   79.00%   -0.01%     
==========================================
  Files         356      356              
  Lines      150096   150158      +62     
  Branches   150096   150158      +62     
==========================================
+ Hits       118596   118635      +39     
- Misses      26849    26871      +22     
- Partials     4651     4652       +1     
Flag Coverage Δ
cli-ort-linux 83.52% <ø> (ø)
cli-ort-windows 83.11% <ø> (+0.09%) ⬆️
mlas 80.92% <ø> (-0.05%) ⬇️
offline 78.80% <ø> (-0.01%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.
see 5 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

justinchuby and others added 15 commits August 14, 2026 18:52
Route the com.microsoft::MatMulNBits M>1 prefill/verify path through the
SM80 Marlin tensor-core GEMM when ONNX_GENAI_MARLIN_M_GT_1 is set. The
path is opt-in and tier-scoped (Rule 11): default OFF, gated on SM80+,
int4, no fused SwiGLU/RMSNorm epilogue yet, and falls back byte-compatibly
to the portable tiled GEMM on any ineligibility or launch error.

- marlin_gemm.rs: add marlin_m_gt_1_enabled() flag and a module-level
  repack cache (ensure_repacked) keyed by (packed_ptr, ordinal, n, k,
  group). Weights (immutable initializers) are repacked on-device once
  during warmup; captured replays hit the warm cache with no allocation,
  so the static-grid kernel is capture-safe. A cold miss during capture is
  rejected so the caller can fall back rather than allocate inside capture.
- matmul_nbits.rs: add try_launch_marlin_gemm helper and dispatch at the
  `if m > 1` seam; set last_call_capture_safe = warm. Records the
  gemm_marlin_int4 kernel variant.
- Add op-level test marlin_m_gt_1_op_parity_and_capture_safety: drives the
  real op with the flag on, validates vs f64 dequant->GEMM oracle
  (asymmetric zp, worst_abs=0.013 << tol=0.67), and asserts cold=NOT
  capture-safe / warm=capture-safe + byte-identical replay.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Times the real MatMulNBits op through both the portable tiled GEMM
(flag off) and the Marlin tensor-core GEMM (flag on) across an M sweep at
K=5120 N=13824 block=128 (H200 median us):

  M     tiled_us   marlin_us   speedup
  1         69.5       69.0     1.01x (M=1 stays on GEMV)
  2        478.0      140.0     3.41x
  4        494.1      138.5     3.57x
  8        536.4      176.9     3.03x
  16       658.1      251.2     2.62x
  32      1076.7      385.0     2.80x
  64      2010.6      821.6     2.45x
  128     3851.5     1357.9     2.84x

The M=1->M=2 cliff collapses from 6.9x (69.5->478us tiled) to ~2x
(69.5->140us) once M>1 routes to Marlin; Marlin is 2.4-3.6x faster than
the tiled path across all M>1. M=1 correctly stays on the existing GEMV.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ulNBits

Document that the opt-in Marlin M>1 tensor-core GEMM participates in the
existing last_call_capture_safe contract: its launch grid is a pure
function of (M, N), so on a warm repack-cache hit it does no allocation,
D2H, or synchronization and is CUDA-graph capture-safe. A cold (repacking)
first call is reported unsupported; only warm replays advertise support.
This is what unlocks capture-stable speculative verification at M>1 (the
240 MatMulNBits KernelCaptureUnsupported seams collapse once the path is
served by Marlin and the weights are pre-repacked during warmup).

Comment/message-only change at the capture_support seam; the runtime
behavior is already driven by the dispatch storing the cache-warm flag.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
M>1 wired (opt-in, capture-safe when warm) + apples-to-apples wall-vs-tiled
(2.4-3.6x across M>1; M=1->M=2 cliff collapsed 6.9x -> ~2x). M=1 stays on GEMV.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Extend the opt-in Marlin M>1 path to the rmsnorm_prologue fused epilogue
(the first of the fused epilogues MatMulNBits must preserve). Mirrors the
tiled launch_f16_gemm_rmsnorm_prefill: stage the per-token normalized
activation into scratch via launch_rmsnorm_prefill (byte-identical to the
standalone prologue), then run the Marlin int4 tensor-core GEMM over the
normalized rows. The scratch allocation keeps this call off the advertised
capture contract (as with the tiled rmsnorm prefill); prefill is outside
the persistent decode graph. Falls through byte-compatibly to the tiled
rmsnorm GEMM on ineligibility or launch error.

Adds op-level parity test marlin_m_gt_1_rmsnorm_op_parity comparing the
fused Marlin path to the trusted fused tiled path through the real op
(shared rmsnorm staging isolates the GEMM): worst_abs=0.0156 << tol=0.90.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Add an ignored native-CUDA e2e test that runs each real int4 model twice
through the engine — tiled GEMM (flag off) then Marlin M>1 GEMM
(ONNX_GENAI_MARLIN_M_GT_1=1) — and asserts the greedy token streams match.
Prefill runs MatMulNBits at M = prompt length, so this exercises the
Marlin path on production asymmetric-zero-point weights while decode stays
on the M=1 GEMV.

Verified on H200 (GPU 6): both glm-4-9b-int4 and qwen2.5-14b-instruct-int4
produce byte-identical greedy tokens with Marlin vs the tiled path.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Fused RMS-norm Marlin path (worst_abs=0.0156 << tol=0.90) and Stage-5 e2e:
byte-identical greedy tokens tiled vs Marlin on glm-4-9b-int4 and
qwen2.5-14b-instruct-int4 (asymmetric zp).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Extend the Marlin int4 M>1 path to cover the gate/up SwiGLU MLP fusion so
the MLP no longer falls back to the portable tiled two-GEMM at M>1. This is
the bulk of prefill/verify cost and the remaining gap toward zero M>1
tiled-fallback nodes (the capture segments->1 unlock).

- marlin_gemm.rs: add `ensure_scratch` size-keyed scratch pool (slot-keyed,
  FIFO cap 256, rejects cold-miss during capture) so warm replays allocate
  nothing and stay capture-safe.
- matmul_nbits.rs: `try_launch_marlin_gemm_rmsnorm` now uses the pooled
  scratch (returns Option<bool> warm flag); add `try_launch_marlin_gate_up_prefill`
  (paired gate/up Marlin GEMMs + shared SiluMul, optional rmsnorm prologue)
  and wire it into `run_f16_gate_up_swiglu` M>1 branch.
- Tests: gate_up SwiGLU M>1 Marlin-vs-tiled parity + capture-safety
  (plain / rmsnorm / decomposed-silu). worst_abs 0.0001-0.008 vs tiled,
  warm replays capture-safe & byte-identical.

Refs #957, #960.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Add a tracer-driven coverage audit to the M>1 e2e test that runs a
prefill-heavy generation with ONNX_GENAI_MARLIN_M_GT_1=1, tallies the
per-op kernel_variant annotations, and asserts NO MatMulNBits node fell
back to a tiled M>1 kernel (gemm_f16_tiled{,_rmsnorm},
gate_up_swiglu{,_rmsnorm}_prefill). Zero tiled M>1 fallbacks is the
prerequisite for the capture segments->1 collapse.

Measured (H200, GPU 7):
- glm-4-9b-int4 : 240 gemm_marlin_int4, 0 tiled M>1
- qwen2.5-14b   : 240 gemm_marlin_int4 + 48 gate_up_swiglu_marlin_prefill
                  + 1 gemm_marlin_int4_rmsnorm = 289 Marlin M>1, 0 tiled
Greedy token streams remain byte-identical tiled-vs-Marlin on both.

Refs #957, #960.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
P2 "widen the win": adds a split-K variant of the Marlin int4 GEMM to
attack the occupancy-bound small-M regime, where the direct kernel
launches only ~1-1.5 waves of blocks (N=5120 => ~160 blocks on 132 SMs)
and stalls at ~3% weight-DRAM.

- matmul_nbits_marlin_gemm_f16_splitk: partitions the K/group range over
  grid.z, each z accumulates its groups' frag*scale into fp32 and writes
  partials[split_k, M, N] (no bias).
- matmul_nbits_marlin_splitk_reduce: sums the z-slices in FIXED order
  (deterministic => run-to-run identical => capture-stable) then applies
  fold_bias -> fp16.
- choose_split_k: conservative heuristic; only splits when the base grid
  is under ~2 waves (so it NEVER regresses the already-occupied M>=32 /
  large-N cases), caps each split at >=2 groups and factor <= 8.
- launch_marlin_gemm_splitk: static-grid gemm + reduce pair, capture-safe
  once partials is pre-allocated (scratch pool).

Numerics: split-K reorders fp32 partials vs the single-block kernel, so
NOT byte-identical to direct, but passes the f64-oracle parity guard
(tol 2e-2) incl. asymmetric-zp, and is deterministic (new tests
marlin_splitk_parity_vs_f64_oracle, marlin_splitk_is_deterministic).

Measured (H200 GPU 3, median us, split-K vs direct):
  N=5120  M=1 : 90.0 -> 32.0 us  2.81x  (weight 3.0% -> 8.5% peak)
  N=5120  M=2 : 90.2 -> 32.1 us  2.81x
  N=5120  M=8 : 105.5 -> 42.9 us 2.46x
  N=13824 M=1 : 210.3 -> 88.2 us 2.38x (split_k=2)
  N=13824 M=2 : 127.3 -> 81.3 us 1.57x (split_k=8)
Correctly regresses (heuristic returns 1, stays on direct) at M>=32 and
large-N M>=8, where base occupancy already suffices.

Standalone/opt-in only; not yet wired into the op dispatch. Direct kernel
remains the default (byte-identical greedy-token path unchanged).

Refs #957, #960.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Routes the plain and rmsnorm-prologue Marlin M>1 GEMMs through split-K
when ONNX_GENAI_MARLIN_SPLITK=1 AND choose_split_k elects to split
(base grid under ~2 waves). Partials come from the capture-safe scratch
pool (slot 4, distinct from rmsnorm slot 0 / gate_up slot 1), so warm
replays stay allocation-free; the reduce kernel applies the same
fold_bias epilogue. Default OFF => the byte-identical direct kernel
remains the greedy-token path.

New helper Kernel::maybe_launch_marlin_splitk centralizes the
direct-vs-split-K choice and propagates the pooled-scratch warm flag into
the existing capture-safety return contract. gate_up SwiGLU stays on the
direct kernel for now (its fused SiluMul epilogue would need a split-K
reduce variant); documented as remaining work.

Validated (H200 GPU 3):
- f64-oracle parity + determinism (marlin_splitk_* lib tests) pass,
  incl. asymmetric-zp, tol 2e-2.
- e2e greedy tokens BYTE-IDENTICAL vs tiled with SPLITK=1 on both
  glm-4-9b-int4 and qwen2.5-14b-int4 (zp) M>1 prefill.
- SM80 guard + tiled fallback unchanged; split-K itself is SM80-gated.

Refs #957, #960.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The last two MatMulNBits-adjacent capture barriers at query-width > 1:
GroupQueryAttention (x40) and SkipSimplifiedLayerNormalization (x80)
declared KernelCaptureUnsupported for any M>1 shape, keeping the
speculative-verify (M=K) forward segmented even after Marlin removed all
240 MatMulNBits seams. Both were conservative gates, not real hazards.

SkipSimplifiedLayerNormalization:
- The launch is already a static grid (groups_u,1,1) with no mid-kernel
  sync / host read-back / alloc-free on the hot path, and the shape-keyed
  SkipBroadcastMetadataCache already rejects any shape change (incl.
  num_groups) mid-capture — the same pre-warm cold-miss safety valve the
  Marlin repack cache uses. The `num_groups == 1` capture latch was purely
  conservative. Relax it to advertise capture support for a fixed,
  pre-warmed M=K shape (bf16 staging path still demotes on arena growth).

GroupQueryAttention:
- Broaden the capture signature to admit a fp16/bf16 q_seq>1 fused-flash
  verify/prefill shape, not just q_seq==1 Phase2a decode. The fused flash
  kernel is already capture-safe by construction (static grid, device
  total_lengths/past_lengths, no host read-back, skips synchronize during
  capture); the on-device gqa_prepare_metadata kernel + capture_error_ptr
  latch replace the host-side seqlens/position validation, and the
  capture-safe branch uses present_capacity (masking the tail on-device)
  exactly like the M=1 decode path. Backend is resolved against the fixed
  present_capacity so warm and captured runs agree deterministically; f32
  M>1 (reference-scores, host-sized) stays on the eager fallback.

Gates: greedy tokens byte-identical on glm-4-9b-int4 (heavy
SkipSimplifiedLayerNorm) M>1 prefill; GQA + normalization lib unit tests
pass; fmt + lib clippy + native-backend clippy clean. Capture support is
still declared only for a warmed signature (pre-warm the M=K shape before
capture, same safety valve as the Marlin repack/scratch pools).

This is Stage 2 of the merged spec-capture design (#957): "SkipSimplified
LayerNorm = signature relaxation, GQA = pre-warmed fixed-M flash
workspace." With Marlin M>1 + these two, no hot decode/verify node
declares KernelCaptureUnsupported at M>1.

Refs #957, #960.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ard-marlin)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
justinchuby pushed a commit that referenced this pull request Aug 14, 2026
…afe)

Merged Deckard's c842b75 (GroupQueryAttention + SkipSimplifiedLayerNorm M>1
capture support). leverb-phase0 Part D INC0 (glm, M=8, pre-warmed):

- segments 120 -> 1: whole M=8 verify now captures as ONE replayable graph.
  Only residual seam = MatMul[KernelCaptureUnsupported]x1 (lone dense MatMul).
- B* unchanged by capture-safety alone (5.10x vs marlin-only 4.99x): the
  captured M=8 wall is compute-bound, not segmentation-bound (120->1 segments
  moved the wall ~0: 50.7->51.8ms).
- Split-K (exploratory, ALSO capture-safe + byte-identical) is the lever that
  moves B*: 5.10x -> 2.69x (M=8 wall 51.8->27.3ms) -- right at the <=2 GO line.
- Numerics: Marlin greedy tokens byte-identical to tiled with AND without
  split-K (24/24), no NaN under capture. Zero-init KV tail assumption holds.

Path to speculative-capture GO = capture-safety (done, segments=1) + split-K
on the M=K verify. Decision drop updated with progression table + commands.

Refs #957, #960, #962

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
justinchuby pushed a commit that referenced this pull request Aug 14, 2026
…confirm

Refreshed M-scaling + prefill sweep at c842b75 with split-K ON, and re-ran
the decisive capture probe clean for reproducibility:

- Capture re-probe reconfirmed: canonical segments=1 / MatMul x1 seam / B*=5.09x;
  split-K segments=1 / B*=2.71x. Reproduces prior 5.10/2.69 within noise.
- Warmup coverage confirmed: Part-D INC0 warms via a FULL M=K forward, so every
  fixed-shape cache (Marlin repack+scratch, GQA meta, LN meta) is populated in
  one pass; capture_alloc=(0,0) + segments=1 prove no cold-miss valve fired.
- Final tail slope (ms/row) BEFORE -> Marlin -> +split-K:
  glm 2.21 -> 0.93 -> 0.822 (-63%); qwen 3.26 -> 1.10 -> 1.147 (-65%).
- Prefill nuance: canonical Marlin edges split-K at large-M prefill (qwen 291-300
  vs 267-277); split-K's win is small-M M=K verify. Recommend canonical Marlin
  for prefill, split-K auto-on for the speculative verify.

Refs #957, #960, #962

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
justinchuby and others added 2 commits August 14, 2026 20:42
… M>1

Two changes that close the residual gap to the speculative-capture GO gate
after the GQA + SkipSimplifiedLayerNorm capture-safety landed (segments 120->1).

1. Split-K is now ON by default within the already-opt-in Marlin M>1 path
   (ONNX_GENAI_MARLIN_M_GT_1=1); opt out with ONNX_GENAI_MARLIN_SPLITK=0. This
   is the measured B* lever: Sebastian's Increment-0 re-probe on glm-4-9b drops
   capture B* 5.10x -> 2.69x at M=8 with split-K on. choose_split_k still elects
   a split only for small-M / low-wave shapes, so large-M prefill stays on the
   byte-identical direct kernel; greedy tokens remain byte-identical (argmax
   stable over the deterministic, within-f64-tol split reduction).

2. The plain dense MatMul (lm_head logits projection) is the last node that
   declared KernelCaptureUnsupported at M>1 (it fell to cuBLASLt's per-call
   heuristic path). Add a cached-plan fast path (blas::CaptureGemmPlan +
   DenseGemmPlan) for the plain 2-D (batch==1) M>1 GEMM: select the algorithm +
   persistent workspace once at warmup, then replay with no heuristic query,
   allocation, or synchronization. This is the M>1 analogue of F32GemvPlan and
   reproduces governed_gemm's arithmetic bit-for-bit at a fixed shape. Batched/
   broadcast GEMMs keep the per-call path. Now advertises capture support after
   a warmed call, closing the last M>1 capture seam.

Gates: greedy tokens byte-identical on glm-4-9b-int4 and qwen2.5-14b-int4-zp
(marlin_m_gt_1_matches_tiled_on_*); fmt + lib clippy + native-backend clippy
clean. SM80 guard + fallback unchanged (Marlin M>1 still opt-in).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…an census)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
justinchuby pushed a commit that referenced this pull request Aug 14, 2026
…B* 2.63x

Merged Deckard's 2971403/1a72c865 (lm_head cached dense-GEMM capture plan +
split-K default within MARLIN_M_GT_1=1). leverb-phase0 Part D INC0, glm, M=8,
GPU7 (Deckard on GPU5):

- Census now TRUE ZERO unsupported nodes: seam nodes '<none: whole-graph>'.
  The whole M=8 verify (incl. lm_head logits projection) captures as ONE
  whole-subgraph replay. Last MatMul[KernelCaptureUnsupported]x1 is gone.
- B* 2.71 -> 2.63x (captured M=8 wall 27.5 -> 26.8ms): folding lm_head out of
  its per-step eager launch+sync nudged B* down as predicted, but strict <=2
  GO line not yet crossed. Residual is genuine small-M GEMM compute.
- Byte-identical greedy tokens on glm-4-9b-int4 with the new default path
  (split-K default + lm_head plan); no NaN. Cleanest capture config to date.

Refs #957, #960, #962

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
justinchuby and others added 2 commits August 14, 2026 20:58
The old "aim for ~2 waves of blocks, else don't split" rule systematically
under-split at the M=8 verify width: it picked split_k=2-3 where the microbench
optimum is 8, and bailed to no-split entirely for large-N projections (gate_up
N=27392) where split_k=8 still wins. Block count is a poor proxy at small M —
each block is latency-bound (low DRAM %), so oversubscribing K keeps more memory
requests in flight and lifts achieved weight-DRAM bandwidth.

Retune: split for m <= 32 (compute-bound beyond), oversubscribe ~8 waves, and
floor toward the measured small-M optimum (8 for m<=16, 4 above) even when the
base grid already spans many waves; cap at 8 (16 regresses medium-N). H200
M=8 microbench (glm/qwen decode dims), auto factor before -> after:

  K=4096  N=4096  (o/q proj)   sk3 2.34x -> sk8 2.93x
  K=13696 N=4096  (down)       sk3 2.45x -> sk8 3.18x
  K=4096  N=27392 (gate_up)    sk1 (none) -> sk8 1.11x
  K=5120  N=13824 (qwen g/u)   sk1 (none) -> sk8 1.35x
  K=4096  N=256   (kv)         sk8 5.30x (unchanged, already optimal)

The dominant verify cost (MLP gate_up + down) now splits; large-M prefill still
returns 1 (byte-identical direct kernel). K=5120,N=5120 (qwen square attn proj)
is bimodal (cold-clock 0.9x / clocked-up 2.5x) — a minor fraction of verify cost;
Sebastian's B* re-probe is the e2e arbiter.

Gates: split-K f64-oracle parity + determinism pass at sk=8
(marlin_splitk_parity_vs_f64_oracle covers split_k in {2,4,8}); greedy tokens
byte-identical on glm-4-9b-int4; fmt + lib clippy clean. choose_split_k only
affects m<=32, so prefill/decode paths are unchanged.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
justinchuby pushed a commit that referenced this pull request Aug 14, 2026
Merged Deckard's 4abe4e5/0143f235 (choose_split_k retune for M=8 verify:
gate_up/down/o-q now split sk=8; prefill still sk=1 direct kernel). Re-probed
glm M=8, GPU7 (Deckard on 4/6), reproduced x2:

- B* 2.63 -> 2.16x / 2.17x (captured M=8 verify wall 26.8 -> 22.0ms). Gate_up
  MLP GEMM off the no-split slow path is the dominant win.
- Still segments=1, whole-graph, ZERO unsupported nodes. Byte-identical greedy
  tokens on glm-4-9b-int4 (parity PASS), no NaN.
- 2.16x is an honest plateau a hair above the conservative <=2 line; but <=2 is
  break-even-at-2-accepted-tokens, so this is a PRACTICAL GO once the draft
  accepts >2.16 tok/verify (trivial for an 8-wide draft).

Full arc: segments 41->1; B* 8.76->4.99->5.09->2.71->2.63->2.16x, byte-identical.

Refs #957, #960, #962

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The two largest MLP GEMMs (gate/up, glm K=4096 N=13696 each x40 layers)
were launched as direct launch_marlin_gemm calls in
try_launch_marlin_gate_up_prefill, bypassing split-K entirely. At the M=K
speculative-verify width these are the single biggest untapped small-M
lever. Route both through maybe_launch_marlin_splitk so choose_split_k
elects the 8-way K split at M<=16; gate/up reuse the slot-4 partials
scratch sequentially (gate fully reduces into gate_buf before up
overwrites partials), keeping stream ordering correct and capture-safe.
Fold both split-K warm flags into the returned warmth so capture-safety
accounts for the split-K scratch.

Validated on H200 (GPU6):
- marlin_gate_up_swiglu_matches_tiled_{plain,rmsnorm,decomposed}: PASS
  (split-K now active at M=8; worst_abs 0.0001-0.008 << tol, warm replay
  byte-identical, cold-miss safety valve intact)
- glm-4-9b-int4 + qwen2.5-14b-int4-zp e2e greedy tokens byte-identical
- fmt + lib clippy + native-backend clippy clean

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
justinchuby pushed a commit that referenced this pull request Aug 14, 2026
…plateau)

Merged Deckard's 3735d57/dffabf0d (gate_up SwiGLU MLP GEMM was DIRECT-launching,
bypassing split-K even after the 4abe4e5 factor retune; now routes sk=8).
Re-probed glm M=8, GPU7, reproduced x2:

- B* 2.15x / 2.19x; captured M=8 verify wall stable ~21.9ms (ratio jitter is
  from the M=1 captured baseline 9.98-10.18ms, not the M=8 wall). segments=1,
  whole-graph, zero unsupported nodes, byte-identical (parity PASS), no NaN.
- This is the small-M GEMM floor: M=8 mma.m16n8k16 is ~half-occupancy (~50% MMA
  waste), so split-K+occupancy is the last GEMM lever; path to universal <=2 is
  drafting depth, not more GEMM tuning.
- Practical GO: any 8-wide draft accepting >2.16 tok/verify wins. Capture-safety
  fully solved.

Full arc: segments 41->1 whole-graph; B* 8.76->4.99->5.09->2.71->2.63->2.16x.

Refs #957, #960, #962

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…en for review

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
justinchuby pushed a commit that referenced this pull request Aug 14, 2026
…wen block_32 B*~4.7x

Key finding: the fused gate_up SwiGLU Marlin path requires block_size==32
(matmul_nbits.rs run_f16_gate_up_swiglu). Verified via onnx: glm=block_128,
qwen=block_32.

- glm (block_128) is NOT eligible for the gate_up fusion -> 3735d57 is a NO-OP
  for glm; glm's B*=2.16x comes from the 4abe4e5 general split-K retune, not
  gate_up fusion. Explains the flat glm wall (22.0->21.9ms) after 3735d57.
- qwen (block_32) M=8 verify re-probe: segments=1, whole-graph, zero unsupported
  (48 GQA + 96 LN captured -> capture-safety generalizes), but B*=4.62-4.72x
  (captured M=8 35.5ms / M=1 7.5ms). NOT at GO.
- Honest read: captured_ratio > eager_ratio (4.7>3.8) is a DENOMINATOR effect
  (capture speeds the launch-bound M=1 by 34% vs M=8 by 18%), not a capture
  inefficiency. qwen's tuned block-32 M=1 is fast (inflates ratio) and its
  14B/48-layer M=8 verify is genuinely heavier. Deckard caveat #1 (qwen attn
  proj cold-clock bimodal) may add.

Small-M GEMM floor is model-dependent: glm practical GO (2.16x), qwen 4.7x needs
more small-M work or deeper drafting. glm remains canonical gate.

Refs #957, #960, #962

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
… fusion) + qwen breadth

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
justinchuby pushed a commit that referenced this pull request Aug 14, 2026
…OT a Marlin regression

Ran qwen byte-identical parity at 3735d57; first run failed (Marlin greedy
stream diverged from tiled at token 19). Investigated before raising a regression:

- 4 runs (split-K on x3 + off x1): Marlin output DETERMINISTIC every time
  (5692,374,264,1140,315). The TILED reference diverged (320,94409,...) on only
  1 of 4 runs. Token 19 is a near-degenerate argmax; the tiled GEMM's own
  nondeterministic (atomic/reduction-order) accumulation occasionally flips it.
- Conclusion: NOT a Marlin numerics regression. Marlin (fixed split-K reduction)
  is the MORE deterministic path; the tiled oracle flakes ~1-in-4 at this tie.
  Split-K on and off both produce identical deterministic Marlin tokens.
- Action for Chew/Gaff: the qwen exact-token parity test is flaky (~25%) because
  it asserts equality vs a nondeterministic tiled reference at a near-tie. Not a
  blocker; harden the assertion (logit tolerance / deterministic oracle / assert
  Marlin self-determinism). glm's prompt doesn't hit such a tie -> glm clean.

Refs #957, #960, #962

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@justinchuby
justinchuby marked this pull request as ready for review August 14, 2026 21:30
@justinchuby
justinchuby merged commit 7774ec5 into main Aug 14, 2026
10 of 13 checks passed
@justinchuby
justinchuby deleted the squad/marlin-kernel branch August 14, 2026 21:30
justinchuby added a commit that referenced this pull request Aug 14, 2026
Perf + capture-probe harness for the Marlin int4 M>1 GEMM program (#960):
M=1 DRAM%, M-scaling wall, prefill sweep, e2e-vs-ORT, and the Part-D INC0
capture B* progression (glm 8.76->2.16x, segments 41->1 whole-graph).
Bench-only delta rebased onto clean main (kernel already landed via #960).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
justinchuby added a commit that referenced this pull request Aug 14, 2026
Scribe consolidation for the Marlin int4 GEMM program landing
(#960/#961/#962).

- Merge 5 decision inbox drops (deckard/pris/sebastian
kernel+bench+numerics + chew/gaff reviews) into one consolidated Marlin
program entry; corrected per-model attribution (glm block-128 → gate_up
fusion no-op; 2.16× was the general split-K retune).
- Archive resolved #957/#948/#949 spec-capture + Lever B entries to
decisions-archive/2026-08.md (decisions.md 45,991 → 42,104 B, under the
50 KB gate).
- Orchestration logs (deckard/sebastian/pris/chew/gaff), session log,
cross-agent history updates; summarized sebastian history (>8 chronicle
threshold).

State-only change. Co-authored-by: Copilot
<223556219+Copilot@users.noreply.github.com>

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@github-actions

Copy link
Copy Markdown

🔴 Benchmark Regression Detected

Comparison of criterion micro-benchmarks: PR head vs merge-base, measured on the same runner in the same job (base first → PR second).

ℹ️ Absolute times are informational only — they vary with runner load. The % change column is the reliable signal because both sides ran under identical conditions.

Status Scenario Base PR Change
🔴 matmul/medium_generic_bf16_threads=8/32x512x512 371.45 µs 699.07 µs +88.2%
🔴 matmul/medium_generic_f32_threads=8/32x512x512 962.18 µs 1.53 ms +59.4%
🔴 matmul/large_generic_f32_threads=1/32x1024x1024 8.93 ms 14.15 ms +58.4%
🔴 block_quantized_matmul_cached_dense/mxfp4_preexpanded_dense_oncelock_like_proxy/1x1024x1024 52.59 µs 68.50 µs +30.3%
⚠️ matmul/small_generic_bf16_threads=8/1x256x256 29.55 µs 36.40 µs +23.2%
⚠️ gather/large_f16_threads=1-internal/131072 10.85 µs 13.12 µs +21.0%
⚠️ matmul/large_generic_f16_threads=8/32x1024x1024 86.61 µs 104.68 µs +20.9%
⚠️ matmul/large_generic_f32_threads=8/32x1024x1024 3.94 ms 4.72 ms +19.7%
⚠️ grammar_masking/llguidance_compute_mask/32 69.33 µs 82.73 µs +19.3%
⚠️ matmul/medium_generic_bf16_threads=1/32x512x512 512.80 µs 605.87 µs +18.1%
⚠️ matmul/small_generic_bf16_threads=1/1x256x256 29.14 µs 34.05 µs +16.9%
⚠️ sampling_latency/min_p_per_token 191.57 µs 223.04 µs +16.4%
⚠️ matmul/medium_generic_f16_threads=1/32x512x512 28.41 µs 32.93 µs +15.9%
⚠️ block_quantized_matmul_cached_dense/mxfp4_cached_dense_repeated_call/1x1024x1024 47.27 µs 54.39 µs +15.1%
✅ sampling_latency/greedy_per_token 2.97 µs 3.33 µs +12.2%
✅ matmul/medium_generic_f32_threads=1/32x512x512 2.31 ms 2.59 ms +12.2%
✅ tokenization/encode_tokens_per_second 351.94 µs 389.97 µs +10.8%
✅ logit_processing/seven_processor_chain_per_step 299.68 µs 331.22 µs +10.5%
✅ sampling_latency/top_p_per_token 354.53 µs 389.57 µs +9.9%
✅ tokenization/decode_tokens_per_second 5.85 ms 6.41 ms +9.6%
✅ qwen3_sampling_processors/top_k_partial_selection 129.02 µs 141.00 µs +9.3%
✅ gather/small_f32_threads=1-internal/4096 631.1 ns 686.8 ns +8.8%
✅ matmul/medium_generic_f16_threads=8/32x512x512 28.22 µs 30.64 µs +8.6%
✅ matmul/large_generic_f16_threads=1/32x1024x1024 81.98 µs 88.98 µs +8.5%
✅ qwen3_sampling_processors/top_k_top_p_fast 626.52 µs 675.64 µs +7.8%
✅ qwen3_sampling_processors/top_k_full_sort_baseline 2.01 ms 2.16 ms +7.5%
✅ qwen3_sampling_processors/top_p_full_sort_after_top_k_baseline 3.37 ms 3.61 ms +7.4%
✅ kv_cache/alloc_dealloc_pages 36.10 µs 38.47 µs +6.6%
✅ qwen3_sampling_processors/top_k_top_p_full_sort_baseline 5.59 ms 5.89 ms +5.4%
✅ matmul/large_generic_bf16_threads=1/32x1024x1024 2.00 ms 2.09 ms +4.9%
✅ sampling_latency/top_k_per_token 52.19 µs 54.71 µs +4.8%
✅ gather/small_f16_threads=1-internal/4096 462.2 ns 479.4 ns +3.7%
✅ matmul/large_generic_bf16_threads=8/32x1024x1024 1.29 ms 1.33 ms +3.0%
✅ matmul/small_generic_f16_threads=1/1x256x256 28.56 µs 29.26 µs +2.5%
✅ block_quantized_matmul_cached_dense/mxfp4_uncached_dequant_each_call/1x1024x1024 532.41 µs 536.34 µs +0.7%
✅ gather/small_bf16_threads=1-internal/4096 446.0 ns 446.5 ns +0.1%
✅ reduce_mean/large_f32_threads=1-internal/262144 949.97 µs 946.45 µs -0.4%
✅ gather/medium_f16_threads=1-internal/32768 2.24 µs 2.21 µs -1.5%
✅ matmul/small_generic_f32_threads=1/1x256x256 35.17 µs 34.43 µs -2.1%
✅ matmul/small_generic_f16_threads=8/1x256x256 28.48 µs 27.79 µs -2.4%
✅ reduce_mean/small_f32_threads=1-internal/4096 14.30 µs 13.94 µs -2.6%
✅ gather/medium_bf16_threads=1-internal/32768 2.29 µs 2.21 µs -3.6%
✅ qwen3_sampling_processors/top_p_fast_after_top_k 531.11 µs 511.13 µs -3.8%
✅ matmul/small_generic_f32_threads=8/1x256x256 33.86 µs 32.26 µs -4.7%
✅ add/large_f16_threads=1-internal/4194304 40.54 ms 38.22 ms -5.7%
✅ reduce_mean/medium_f32_threads=1-internal/65536 242.90 µs 225.11 µs -7.3%
✅ gather/medium_f32_threads=1-internal/32768 3.62 µs 3.33 µs -8.0%
✅ gather/large_f32_threads=1-internal/131072 26.29 µs 23.16 µs -11.9%
✅ add/large_bf16_threads=1-internal/4194304 44.67 ms 39.17 ms -12.3%
✅ add/small_bf16_threads=1-internal/1024 13.79 µs 11.98 µs -13.1%
🟢 gather/large_bf16_threads=1-internal/131072 12.92 µs 10.97 µs -15.1%
🟢 add/medium_f32_threads=1-internal/262144 2.74 ms 2.32 ms -15.2%
🟢 add/medium_bf16_threads=1-internal/262144 3.06 ms 2.58 ms -15.9%
🟢 block_quantized_moe_cached_dense/mxfp4_cached_dense_expert_repeated_call/rows=1,H=256,I=256,E=4,top_k=1 96.34 µs 77.70 µs -19.3%
🟢 block_quantized_moe_cached_dense/mxfp4_uncached_expert_dequant_each_call/rows=1,H=256,I=256,E=4,top_k=1 564.74 µs 447.40 µs -20.8%
🟢 add/large_f32_threads=1-internal/4194304 48.77 ms 37.55 ms -23.0%
🟢 add/medium_f16_threads=1-internal/262144 3.23 ms 2.37 ms -26.7%
🟢 add/small_f16_threads=1-internal/1024 16.88 µs 11.83 µs -30.0%
🟢 add/small_f32_threads=1-internal/1024 319.3 ns 183.3 ns -42.6%

Visual flags: ⚠️ ≥ 15% slower, 🔴 ≥ 30% slower — calibrated against measured runner noise (~27% worst-case on multi-threaded matmul)

Host info
CPU: Apple M1 (Virtual)
Cores: 3
OS: Darwin 25.5.0 arm64
Rust: rustc 1.97.1 (8bab26f4f 2026-07-14)
Load avg: { 4.63 3.80 6.56 }
What this cannot catch
  • Regressions in code paths not covered by these benchmarks (e.g., end-to-end decode with a real model)
  • Sub-threshold regressions that compound over multiple PRs
  • Performance changes that only manifest under GPU execution
  • Latency changes in the ORT integration path (these benchmarks exercise the native Rust kernels)

justinchuby added a commit that referenced this pull request Aug 14, 2026
…ifier) (#974)

The qwen e2e parity test
(`marlin_m_gt_1_matches_tiled_on_qwen2_5_14b_int4`) was ~25% flaky. Root
cause: it asserted exact greedy-token equality against the portable
TILED GEMM reference, whose fp32 atomic reduction order is
nondeterministic; at a near-degenerate argmax (token 19) the *tiled*
side flips run-to-run — Marlin is the deterministic side. Test-oracle
bug, not a kernel regression (kernel unchanged, frozen at the #960
landing).

Fix: replace exact-equality with a cascade-robust `classify_parity()` —
on first divergence at position d (identical prefix), re-run the tiled
reference under the same prefix; if it too flips at d, it's a proven
near-tie (pass), otherwise a real corruption (panic). Extracted as a
pure GPU-free function with 5 deterministic unit tests. glm keeps its
strict byte-identical assertion (no near-tie).

Validation: 5 unit tests pass; qwen e2e 5/5 stable; glm e2e pass; fmt +
clippy clean.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
justinchuby added a commit that referenced this pull request Aug 14, 2026
…wen 2×2 (#975)

Follow-up to the Marlin int4 M>1 capture arc (kernel #960, bench #962,
numerics gate #961, flaky-parity hardening #974). Refs #957 and
`docs/research/decode-remaining-levers-feasibility.md`.

## What
Deckard's final ask before Chew's #960 numerics review: an explicit
**qwen captured-M=8 vs eager-M=8 token-equality** data point to fill the
last cell of the captured/eager × glm/qwen 2×2. The shipping speculative
path uses the *eager* verify path (`native_decode/backend.rs`), and the
existing leverb INC0 probe writes a constant token (timing-only), so no
existing harness answered this. This adds a **test-only** parity variant
that feeds real tokens and compares captured-replay vs eager
warm-forward per-row argmax **and** raw logits bytes.

- `native_decode/cuda.rs`: `leverb_increment0_token_parity_attempt`
(gated `#[cfg(all(test, feature="cuda"))]`).
- `native_decode/leverb_phase0_probe.rs`: **Part E** prints per-row
eager vs capture argmax + `logits_byte_identical` + PASS/FAIL.

## Result — full 2×2 GREEN, captured == eager **byte-identical** on both
models
| Model (block) | M | argmax_match | byte_identical | segments | B* |
|---|---|---|---|---|---|
| qwen2.5-14b (block-32) | 8 | ✅ | ✅ | 1 | 4.45× |
| qwen2.5-14b | 1 | ✅ | ✅ | 1 | — |
| glm-4-9b (block-128, canonical) | 8 | ✅ | ✅ | 1 | 2.19× |
| glm-4-9b | 1 | ✅ | ✅ | 1 | — |

B* refresh matches the frozen plateau (glm ≈2.16×, qwen ≈4.45×). No
shipping path touched; fmt + engine clippy clean. Reproduce commands in
`.squad/decisions/inbox/sebastian-captured-eager-parity.md`.

⚠️ Bench/validation follow-up — a squad member (Chew/Deckard) should
confirm before merge.

Co-authored-by: Sebastian <sebastian@squad.local>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.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