feat(kernel): int8 coopmat MMQ GEMM lowers to Vulkan/Metal/ROCm, not just CUDA

The MMQ GEMM was targets(cuda) and read as though SPIR-V could not carry it.
Nothing in cubecl blocked it: RADV gfx1151 advertises M16 N16 K16 / A=i8 B=i8
C=i32 R=i32 at subgroup scope, exactly the shape the kernel asks for.

The blocker was ours. A cooperative-matrix op is plane-scoped: the fragment's
256 elements are spread across every lane of the plane, so a block narrower than
the plane leaves the absent lanes' elements unwritten. The block was a literal 32
-- the CUDA warp. RADV's plane is 64, so half the tile was never written. The
failure said so precisely: rows 0,1,4,5,8,9,12,13 bit-exact and the rest zero --
absence, not corruption. The block now comes from plane_size_max and partitions
the fragment by it.

One kernel source, one island, no fork. The manual-MMA family stays CUDA/ROCm
only, and that is a real SPIR-V limit: its cooperative matrix is an opaque type
with no queryable per-lane layout, so the explicit-register path cannot be
expressed.

Gated on the CPU oracle (35/35) and, on RADV, WMMA hello exact at 256/256 and
MMQ within 1.6e-7 of the oracle at three tile shapes.

The quant.rs comments claimed comptime shapes gave "full unroll". The shipped
SPIR-V carried one static OpSDot; cubecl does not unroll a comptime-bounded loop
without #[unroll]. Adding it multiplied the OpSDot count as advertised and moved
decode by +0.38% -- noise, on a bandwidth-bound path -- so the claim is dropped
from the comments rather than the loop from the kernel.
This commit is contained in:
Hanzo Dev
2026-07-16 19:27:04 -07:00
parent f71d48f820
commit c16382cf64
6 changed files with 446 additions and 184 deletions
+26 -1
View File
@@ -27,6 +27,10 @@
//! };
//! ```
//!
//! An island sits in either position: bound (`let x = island! { ... };`) when the arms yield a value,
//! or bare (`island! { ... };`) when they act on shared memory — a warp-cooperative tile op has no
//! per-lane result to bind, so the bare form is the natural one for it.
//!
//! The scrutinee is found BY TYPE: the kernel must take one `#[comptime] <name>: Target` parameter
//! (any name), which the launch layer fills from the runtime — so the branch site names no target and
//! the selection stays out of the authoring surface. `default` is mandatory and defines the oracle:
@@ -41,7 +45,7 @@ use syn::{
parse_macro_input, parse_quote,
punctuated::Punctuated,
visit_mut::{self, VisitMut},
Expr, FnArg, Ident, ItemFn, Pat, Token, Type,
Expr, ExprMacro, FnArg, Ident, ItemFn, Pat, Stmt, Token, Type,
};
/// The backends a kernel can name. Each maps 1:1 to a runtime the same source lowers to.
@@ -148,6 +152,27 @@ struct IslandRewrite {
}
impl VisitMut for IslandRewrite {
/// `island! { ... }` at STATEMENT position — the form an island takes when its arms act on shared
/// memory instead of yielding a value (a warp-cooperative tile op has no per-lane result to bind).
/// syn parses a braced macro statement as `Stmt::Macro`, never `Stmt::Expr`, so `visit_expr_mut`
/// alone would never see it. Re-seat it as the equivalent expression statement and rewrite that,
/// preserving the original trailing semicolon (or its absence, when the island is the block's value).
fn visit_stmt_mut(&mut self, stmt: &mut Stmt) {
if let Stmt::Macro(sm) = stmt {
if sm.mac.path.is_ident("island") {
let mut expr = Expr::Macro(ExprMacro {
attrs: sm.attrs.clone(),
mac: sm.mac.clone(),
});
let semi = sm.semi_token;
self.visit_expr_mut(&mut expr);
*stmt = Stmt::Expr(expr, semi);
return;
}
}
visit_mut::visit_stmt_mut(self, stmt);
}
fn visit_expr_mut(&mut self, expr: &mut Expr) {
if let Expr::Macro(mac) = expr {
if mac.mac.path.is_ident("island") {
-1
View File
@@ -48,4 +48,3 @@ required-features = ["vulkan"]
[[bin]]
name = "mmq-check"
path = "src/mmq_check.rs"
required-features = ["cuda"]
+16 -8
View File
@@ -577,14 +577,19 @@ fn main() {
let (rows, k) = (4096usize, 4096usize);
let ctrl = 256usize; // small-K control: reorder noise ~ ctrl*eps, should be ~1e-6
// `matvec-check dump` dispatches exactly the engine-bound MoE block kernels at their evo-optimal
// baked nt (Q4_K 64, Q6_K 32) so `CUBECL_DEBUG_SPIRV=<dir>` writes exactly one .spv per kernel --
// the committed hanzo-ml Vulkan artifact. Build with `--features vulkan,spirv-dump`.
// `matvec-check dump` dispatches exactly the engine-bound MoE block kernels for each LIVE model
// shape at its evo-optimal power-of-2 nt, so `CUBECL_DEBUG_SPIRV=<dir>` writes one .spv per (shape).
// The DSL source is ONE fn per quant; comptime lowers it to a fast specialized artifact per shape
// (like llama's templated kernels). Disambiguate by LocalSize (nt): q4k 64 = gate/up, 8 = down.
// Qwen3-30B-A3B Q4_K_M: ffn_gate/up_exps Q4_K [128,768,2048]; ffn_down_exps Q6_K/Q4_K [128,2048,768].
// `matvec-check dump` dispatches the engine-bound kernels once per LIVE model shape at that
// shape's baked nt, so `CUBECL_DEBUG_SPIRV=<dir>` writes one .spv per shape. The DSL source is ONE
// fn per quant; comptime lowers it to a specialized artifact per shape (like llama's templated
// kernels). Disambiguate the two Q4_K MoE shapes by LocalSize (= nt): 64 = gate/up, 32 = down.
// Qwen3-30B-A3B Q4_K_M: ffn_gate/up_exps Q4_K [128,768,2048]; ffn_down_exps Q6_K [128,2048,768].
// Build with `--features vulkan,spirv-dump`.
//
// A dumped .spv is NOT yet the committed artifact: CubeCL names the entry point after the kernel,
// while the hanzo-ml loader creates every pipeline with entry point `main` (vulkan_backend.rs).
// Installing a dump verbatim therefore binds a nonexistent entry point -- undefined behaviour, and
// RADV faults rather than erroring. Rename the entry point when installing:
// spirv-dis in.spv | sed -E 's/(OpEntryPoint GLCompute %[0-9]+ )"[^"]+"/\1"main"/' \
// | spirv-as --target-env vulkan1.2 -o hanzo-ml/src/vulkan/spv/<name>.spv
#[cfg(all(feature = "vulkan", feature = "spirv-dump"))]
if std::env::args().any(|a| a == "dump") {
use cubecl::wgpu::{WgpuDevice, WgpuRuntime};
@@ -705,6 +710,9 @@ fn main() {
let c = WgpuRuntime::client(&WgpuDevice::default());
check::<WgpuRuntime>("METAL", &c, rows, k);
check_q4k::<WgpuRuntime>("METAL", &c, rows, k);
// Same Qwen3-30B-A3B-shaped MoE decode the Vulkan block gates on, so the DSL's expert-gather is
// proven on Metal too and the two backends are compared on identical shapes.
check_moe_q4k::<WgpuRuntime>("METAL", &c, 128, 768, 8, 2048);
check_dp4a::<WgpuRuntime>("METAL", &c, rows, k, true);
}
#[cfg(feature = "cuda")]
+348 -138
View File
@@ -6,13 +6,24 @@
//! and lowered to the hardware int8 matrix cores, instead of hand-written four times (CUDA fast_mmq,
//! Vulkan mul_mm_q8_mmq, ROCm qmmq, Metal simdgroup).
//!
//! Two DSL routes to the int8 matrix cores, both registered by CubeCL on CUDA sm>=70/80:
//! Two DSL routes to the int8 matrix cores, and they do NOT have the same reach:
//! - high-level WMMA (`cmma::Matrix`, i8->i32 at 16x16x16): auto fragment load/store, one tile at a
//! time. Ergonomic; the fragment layout is opaque so the per-block dequant-scale is applied via a
//! shared-memory round-trip in the epilogue.
//! shared-memory round-trip in the epilogue. PORTABLE -- lowers to WMMA on CUDA/ROCm, to
//! `OpCooperativeMatrixMulAddKHR` (signed-component operands) on SPIR-V, and to simdgroup matrix on
//! Metal. This is the prefill path.
//! - manual MMA (`cmma::MmaDefinition`, i8->i32 at m16n8k32): `mma.sync` with explicit registers.
//! The fragment element<->lane map IS exposed (`position_of_nth`), so scales could be applied in
//! register -- the higher-ceiling path. Included here as the proven-by-CubeCL int8 probe.
//! register -- the higher ceiling, but CUDA/ROCm ONLY: SPIR-V's cooperative matrix is an opaque
//! type with no queryable per-lane layout, so cubecl-spirv rejects the whole manual family
//! (see `mma_hello_i8`). Kept as the tuned peak path, not the portable one.
//!
//! Portability is carried by ONE island per GEMM, not by a second kernel: the accelerated arm issues
//! cmma, the `default` arm expresses the identical int8 contraction as scalar MACs. `default` is what
//! the CPU runtime runs (cubecl-cpu rejects every CoopMma op), which is what makes a bit-exact CPU
//! oracle possible at all -- it gates the tiling, the shared-memory epilogue and the f32 accumulation
//! order. The cmma arm itself is equivalent by construction (int8 accumulation is exact) and is gated
//! on-GPU against the same `mmq_q8_ref`.
//!
//! MMQ math (Q8_0 weight x q8_1 activation), the exact contraction llama does:
//! out[m,n] = sum over 32-blocks kb of (xs[m,kb] * wd[n,kb]) * sum_{k in block} xq[m,k]*wq[n,k]
@@ -28,7 +39,11 @@ use crate::prelude::*;
// ================================================================================================
/// out[16,16] = A[16,16] @ B[16,16]^T, int8 inputs, i32 accumulate. Single warp, one WMMA tile.
#[kernel(targets(cuda), unchecked)]
///
/// No island: the kernel IS the cmma probe, so there is no meaningful non-cmma arm to fall back to.
/// It therefore has no CPU oracle (cubecl-cpu rejects every CoopMma op) and is proven on-GPU against
/// [`hello_i8_ref`]. The MMQ GEMMs below carry the oracle; this only answers "does the op JIT and run".
#[kernel(targets(cuda, rocm, vulkan, metal), unchecked)]
pub fn wmma_hello_i8(a: &Array<i8>, b: &Array<i8>, out: &mut Array<i32>) {
let ma = cmma::Matrix::<i8>::from_slice(
cmma::MatrixIdent::A,
@@ -66,7 +81,13 @@ pub fn wmma_hello_i8(a: &Array<i8>, b: &Array<i8>, out: &mut Array<i32>) {
}
/// Run the WMMA hello on a runtime; returns the 256 i32 outputs.
///
/// The block is ONE PLANE wide, queried from the runtime -- not a literal 32. A cooperative-matrix op
/// is plane-scoped: the fragment's 256 elements are distributed across every lane of the plane, so a
/// block narrower than the plane leaves the absent lanes' elements unwritten. The plane is 32 lanes on
/// CUDA but 64 on RADV, which is why a hardcoded 32 silently produces a half-written tile there.
pub fn wmma_hello_i8_run<R: Runtime>(client: &ComputeClient<R>, a: &[i8], b: &[i8]) -> Vec<i32> {
let plane = client.properties().hardware.plane_size_max;
let ah = client.create_from_slice(i8::as_bytes(a));
let bh = client.create_from_slice(i8::as_bytes(b));
let oh = client.create_from_slice(i32::as_bytes(&vec![0i32; 256]));
@@ -74,7 +95,7 @@ pub fn wmma_hello_i8_run<R: Runtime>(client: &ComputeClient<R>, a: &[i8], b: &[i
wmma_hello_i8::launch_unchecked::<R>(
client,
Grid::Static(1, 1, 1),
Block::new_1d(32),
Block::new_1d(plane),
ArrayArg::from_raw_parts(ah.clone(), 256),
ArrayArg::from_raw_parts(bh.clone(), 256),
ArrayArg::from_raw_parts(oh.clone(), 256),
@@ -105,7 +126,16 @@ pub fn hello_i8_ref(a: &[i8], b: &[i8]) -> Vec<i32> {
// ================================================================================================
/// out[16,8] = A[16,32] @ B[32,8] over int8, i32 accumulate, via one `mma.sync.m16n8k32.s8`.
#[kernel(targets(cuda), unchecked)]
///
/// NOT portable to Vulkan, and not by omission: manual MMA needs the fragment element<->lane map, and
/// cubecl-spirv rejects every op that exposes it -- `RowIndex`/`ColIndex` (what `position_of_nth`
/// emits), `LoadMatrix`/`StoreMatrix`, `ExecuteManual`, `ExecuteScaled` all hit one
/// `panic!("Manual register management not currently supported in SPIR-V")`
/// (cubecl-spirv-0.10.0/src/cmma.rs:37-44). SPIR-V's CooperativeMatrixKHR type is opaque by design:
/// it has no defined per-lane layout to query, so this is a spec-level gap, not a missing match arm.
/// The cubecl-cpp backends do implement it (cuda/dialect.rs, hip/dialect.rs:171, metal/dialect.rs:1219),
/// hence cuda + rocm. The portable prefill path is the high-level WMMA route below.
#[kernel(targets(cuda, rocm), unchecked)]
pub fn mma_hello_i8(
a: &Array<i8>, // [16,32] row-major
b: &Array<i8>, // [32,8] row-major
@@ -233,8 +263,13 @@ pub fn mma_hello_ref(a: &[i8], b: &[i8]) -> Vec<i32> {
// fragment, stores the i32 tile to shared memory, then applies xs*wd per element into an f32 tile.
// ================================================================================================
/// int8 tensor-core MMQ GEMM. Grid = (N/16, M/16); block = one warp (32 lanes).
#[kernel(targets(cuda), unchecked)]
/// int8 tensor-core MMQ GEMM. Grid = (N/16, M/16); block = ONE PLANE.
///
/// `plane` is comptime because a cooperative-matrix op is plane-scoped: the 16x16 fragment's 256
/// elements are spread over every lane of the plane, so the block must be exactly one plane wide and
/// the scalar prologue/epilogue must partition 256 by that same width (8 each on a 32-lane CUDA warp,
/// 4 each on a 64-lane RADV wave). Hardcoding 32 leaves the absent lanes' elements unwritten.
#[kernel(targets(cuda, rocm, vulkan, metal, cpu), unchecked)]
pub fn mmq_q8_wmma(
xq: &Array<i8>,
xs: &Array<f32>,
@@ -244,81 +279,113 @@ pub fn mmq_q8_wmma(
#[comptime] m: usize,
#[comptime] n: usize,
#[comptime] k: usize,
#[comptime] plane: usize,
#[comptime] target: Target,
) {
let nt = CUBE_POS_X as usize; // output column-tile (16 cols of N)
let mt = CUBE_POS_Y as usize; // output row-tile (16 rows of M)
let lane = UNIT_POS as usize; // 0..31
let lane = UNIT_POS as usize; // 0..plane-1
let kb_count = k / 32;
let mrow0 = mt * 16;
let ncol0 = nt * 16;
let per = 256 / plane; // tile elements this lane owns in the scalar prologue/epilogue
let mut accf = SharedMemory::<f32>::new(256usize); // f32 output tile [16x16]
let mut ci = SharedMemory::<i32>::new(256usize); // i32 fragment store scratch [16x16]
let mut ci = SharedMemory::<i32>::new(256usize); // i32 tile: the 32-block's int8 contraction
// Zero the f32 accumulator (each lane owns 8 of 256).
// Zero the f32 accumulator (each lane owns `per` of 256).
#[unroll]
for e in 0usize..8 {
accf[lane * 8 + e] = 0.0f32;
for e in 0usize..per {
accf[lane * per + e] = 0.0f32;
}
sync_cube();
for kb in 0..kb_count {
let k0 = kb * 32;
// Accumulate int8 over the 32-wide block into one i32 fragment (2 WMMA K=16 steps).
let c = cmma::Matrix::<i32>::from_value(
cmma::MatrixIdent::Accumulator,
16usize,
16usize,
16usize,
cmma::MatrixLayout::Undefined,
0i32,
);
let a0 = cmma::Matrix::<i8>::from_slice(
cmma::MatrixIdent::A,
16usize,
16usize,
16usize,
cmma::MatrixLayout::RowMajor,
&xq.slice(mrow0 * k + k0, m * k),
k as u32,
);
let b0 = cmma::Matrix::<i8>::from_slice(
cmma::MatrixIdent::B,
16usize,
16usize,
16usize,
cmma::MatrixLayout::ColMajor,
&wq.slice(ncol0 * k + k0, n * k),
k as u32,
);
cmma::execute::<i8, i8, i32, i32>(&a0, &b0, &c, &c);
// The 32-wide K-block's int8 contraction into the i32 tile `ci`. int8 accumulation is exact,
// so both arms are the SAME integer value -- only the idiom differs.
island! {
// Accelerated: the hardware int8 matrix core, 2 x cmma(16x16x16) i8->i32. Lowers to WMMA
// (CUDA/ROCm), OpCooperativeMatrixMulAddKHR with signed-component operands (SPIR-V), and
// simdgroup matrix (Metal). The fragment layout is opaque, so the tile is spilled to
// shared memory for the scale epilogue below.
cuda | rocm | vulkan | metal => {
let c = cmma::Matrix::<i32>::from_value(
cmma::MatrixIdent::Accumulator,
16usize,
16usize,
16usize,
cmma::MatrixLayout::Undefined,
0i32,
);
let a0 = cmma::Matrix::<i8>::from_slice(
cmma::MatrixIdent::A,
16usize,
16usize,
16usize,
cmma::MatrixLayout::RowMajor,
&xq.slice(mrow0 * k + k0, m * k),
k as u32,
);
let b0 = cmma::Matrix::<i8>::from_slice(
cmma::MatrixIdent::B,
16usize,
16usize,
16usize,
cmma::MatrixLayout::ColMajor,
&wq.slice(ncol0 * k + k0, n * k),
k as u32,
);
cmma::execute::<i8, i8, i32, i32>(&a0, &b0, &c, &c);
let a1 = cmma::Matrix::<i8>::from_slice(
cmma::MatrixIdent::A,
16usize,
16usize,
16usize,
cmma::MatrixLayout::RowMajor,
&xq.slice(mrow0 * k + k0 + 16, m * k),
k as u32,
);
let b1 = cmma::Matrix::<i8>::from_slice(
cmma::MatrixIdent::B,
16usize,
16usize,
16usize,
cmma::MatrixLayout::ColMajor,
&wq.slice(ncol0 * k + k0 + 16, n * k),
k as u32,
);
cmma::execute::<i8, i8, i32, i32>(&a1, &b1, &c, &c);
let a1 = cmma::Matrix::<i8>::from_slice(
cmma::MatrixIdent::A,
16usize,
16usize,
16usize,
cmma::MatrixLayout::RowMajor,
&xq.slice(mrow0 * k + k0 + 16, m * k),
k as u32,
);
let b1 = cmma::Matrix::<i8>::from_slice(
cmma::MatrixIdent::B,
16usize,
16usize,
16usize,
cmma::MatrixLayout::ColMajor,
&wq.slice(ncol0 * k + k0 + 16, n * k),
k as u32,
);
cmma::execute::<i8, i8, i32, i32>(&a1, &b1, &c, &c);
// Spill the i32 tile to shared memory, then apply the per-block f32 scale in the epilogue.
cmma::store(&mut ci.to_slice_mut(), &c, 16, cmma::MatrixLayout::RowMajor);
cmma::store(&mut ci.to_slice_mut(), &c, 16, cmma::MatrixLayout::RowMajor);
}
// NORMATIVE oracle: the identical i32 contraction as portable scalar MACs, each lane
// owning 8 of the tile's 256 elements. This arm carries no cmma op, so it is the arm the
// CPU runtime executes -- and thus the bit-exact gate for the whole kernel's structure
// (tiling, shared-memory epilogue, f32 accumulation order).
default => {
#[unroll]
for e in 0usize..per {
let idx = lane * per + e;
let mm = idx / 16;
let nn = idx % 16;
let mut isum = 0i32;
for l in 0usize..32 {
isum += i32::cast_from(xq[(mrow0 + mm) * k + k0 + l])
* i32::cast_from(wq[(ncol0 + nn) * k + k0 + l]);
}
ci[idx] = isum;
}
}
};
sync_cube();
// Epilogue: apply the per-block f32 scale. Shared by both arms -- the scale is a property of
// block-quantized GEMM, not of the idiom that produced the int8 sum.
#[unroll]
for e in 0usize..8 {
let idx = lane * 8 + e;
for e in 0usize..per {
let idx = lane * per + e;
let mm = idx / 16;
let nn = idx % 16;
let xsc = xs[(mrow0 + mm) * kb_count + kb];
@@ -329,15 +396,16 @@ pub fn mmq_q8_wmma(
}
#[unroll]
for e in 0usize..8 {
let idx = lane * 8 + e;
for e in 0usize..per {
let idx = lane * per + e;
let mm = idx / 16;
let nn = idx % 16;
out[(mrow0 + mm) * n + (ncol0 + nn)] = accf[idx];
}
}
/// Host launch for the MMQ GEMM. `iters` amortized kernel-only bench dispatches; returns (out, ms).
/// Host launch for the MMQ GEMM -- the PRODUCTION surface. Derives the island tag from the runtime and
/// hides it: callers pass only data. `iters` amortized kernel-only bench dispatches; returns (out, ms).
#[allow(clippy::too_many_arguments)]
pub fn mmq_q8_wmma_run<R: Runtime>(
client: &ComputeClient<R>,
@@ -350,6 +418,25 @@ pub fn mmq_q8_wmma_run<R: Runtime>(
k: usize,
iters: usize,
) -> (Vec<f32>, f64) {
mmq_q8_wmma_run_with(client, xq, xs, wq, wd, m, n, k, iters, Target::of(client))
}
/// Host launch with an explicit island tag. The oracle test pins [`Target::Cpu`] to run the normative
/// arm; production goes through [`mmq_q8_wmma_run`], which derives the tag from the runtime.
#[allow(clippy::too_many_arguments)]
pub fn mmq_q8_wmma_run_with<R: Runtime>(
client: &ComputeClient<R>,
xq: &[i8],
xs: &[f32],
wq: &[i8],
wd: &[f32],
m: usize,
n: usize,
k: usize,
iters: usize,
target: Target,
) -> (Vec<f32>, f64) {
let plane = client.properties().hardware.plane_size_max;
let xqh = client.create_from_slice(i8::as_bytes(xq));
let xsh = client.create_from_slice(f32::as_bytes(xs));
let wqh = client.create_from_slice(i8::as_bytes(wq));
@@ -360,7 +447,7 @@ pub fn mmq_q8_wmma_run<R: Runtime>(
mmq_q8_wmma::launch_unchecked::<R>(
c,
grid.clone(),
Block::new_1d(32),
Block::new_1d(plane),
ArrayArg::from_raw_parts(xqh.clone(), xq.len()),
ArrayArg::from_raw_parts(xsh.clone(), xs.len()),
ArrayArg::from_raw_parts(wqh.clone(), wq.len()),
@@ -369,6 +456,8 @@ pub fn mmq_q8_wmma_run<R: Runtime>(
m,
n,
k,
plane as usize,
target,
);
};
launch(client);
@@ -423,8 +512,12 @@ pub fn mmq_q8_ref(
// the opaque i32 fragment (cmma::store -> shared -> scale). Requires M%32==0, N%64==0, K%32==0.
// ================================================================================================
/// Tiled int8 MMQ GEMM. Grid = (N/64, M/32); block = 256 threads (8 warps, 2x4 of 16x16 subtiles).
#[kernel(targets(cuda), unchecked)]
/// Tiled int8 MMQ GEMM. Grid = (N/64, M/32); block = 8 PLANES (2x4 of 16x16 subtiles, one per plane).
///
/// `plane` is comptime for the reason given on [`mmq_q8_wmma`]: one plane cooperatively owns one 16x16
/// fragment, so the block is 8 planes wide -- 256 threads on a 32-lane CUDA warp, 512 on a 64-lane RADV
/// wave -- and every scalar partition below divides its buffer by that width rather than by a literal.
#[kernel(targets(cuda, rocm, vulkan, metal, cpu), unchecked)]
pub fn mmq_q8_wmma_blk(
xq: &Array<i8>,
xs: &Array<f32>,
@@ -433,97 +526,122 @@ pub fn mmq_q8_wmma_blk(
out: &mut Array<f32>,
#[comptime] n: usize,
#[comptime] k: usize,
#[comptime] plane: usize,
#[comptime] target: Target,
) {
let tid = UNIT_POS as usize;
let warp = tid / 32; // 0..7
let lane = tid % 32; // 0..31
let warp = tid / plane; // 0..7 (one plane per 16x16 subtile)
let lane = tid % plane; // 0..plane-1
let wm = warp / 4; // 0..1 (M-subtile)
let wn = warp % 4; // 0..3 (N-subtile)
let mrow0 = CUBE_POS_Y as usize * 32;
let ncol0 = CUBE_POS_X as usize * 64;
let kb_count = k / 32;
let nthread = 8 * plane; // block width: 8 subtiles, one plane each
let per = 256 / plane; // fragment elements this lane owns in the scalar epilogue
let mut sa = SharedMemory::<i8>::new(1024usize); // A tile [32 x 32]
let mut sb = SharedMemory::<i8>::new(2048usize); // B tile [64 x 32]
let mut ci = SharedMemory::<i32>::new(2048usize); // per-warp i32 fragment scratch [8 x 256]
let mut accf = SharedMemory::<f32>::new(2048usize); // f32 output tile [32 x 64]
for e in 0usize..8 {
accf[tid * 8 + e] = 0.0f32;
for e in 0usize..(2048 / nthread) {
accf[tid * (2048 / nthread) + e] = 0.0f32;
}
sync_cube();
for kb in 0..kb_count {
let k0 = kb * 32;
// Stage A[32x32] and B[64x32] int8 tiles into shared memory (coalesced, reused across warps).
for i in 0usize..4 {
let idx = tid + i * 256;
for i in 0usize..(1024 / nthread) {
let idx = tid + i * nthread;
sa[idx] = xq[(mrow0 + idx / 32) * k + k0 + idx % 32];
}
for i in 0usize..8 {
let idx = tid + i * 256;
for i in 0usize..(2048 / nthread) {
let idx = tid + i * nthread;
sb[idx] = wq[(ncol0 + idx / 32) * k + k0 + idx % 32];
}
sync_cube();
// This warp's 16x16 subtile: accumulate the 32-block via 2 WMMA(K=16) from shared memory.
let c = cmma::Matrix::<i32>::from_value(
cmma::MatrixIdent::Accumulator,
16usize,
16usize,
16usize,
cmma::MatrixLayout::Undefined,
0i32,
);
let a0 = cmma::Matrix::<i8>::from_slice(
cmma::MatrixIdent::A,
16usize,
16usize,
16usize,
cmma::MatrixLayout::RowMajor,
&sa.slice(wm * 512, 1024),
32,
);
let b0 = cmma::Matrix::<i8>::from_slice(
cmma::MatrixIdent::B,
16usize,
16usize,
16usize,
cmma::MatrixLayout::ColMajor,
&sb.slice(wn * 512, 2048),
32,
);
cmma::execute::<i8, i8, i32, i32>(&a0, &b0, &c, &c);
let a1 = cmma::Matrix::<i8>::from_slice(
cmma::MatrixIdent::A,
16usize,
16usize,
16usize,
cmma::MatrixLayout::RowMajor,
&sa.slice(wm * 512 + 16, 1024),
32,
);
let b1 = cmma::Matrix::<i8>::from_slice(
cmma::MatrixIdent::B,
16usize,
16usize,
16usize,
cmma::MatrixLayout::ColMajor,
&sb.slice(wn * 512 + 16, 2048),
32,
);
cmma::execute::<i8, i8, i32, i32>(&a1, &b1, &c, &c);
// This warp's 16x16 subtile: the staged 32-block's int8 contraction into `ci`. Both arms read
// the SAME shared-memory A/B tiles, so operand staging is proven by the oracle too.
island! {
// Accelerated: 2 x cmma(16x16x16) i8->i32 straight out of shared memory.
cuda | rocm | vulkan | metal => {
let c = cmma::Matrix::<i32>::from_value(
cmma::MatrixIdent::Accumulator,
16usize,
16usize,
16usize,
cmma::MatrixLayout::Undefined,
0i32,
);
let a0 = cmma::Matrix::<i8>::from_slice(
cmma::MatrixIdent::A,
16usize,
16usize,
16usize,
cmma::MatrixLayout::RowMajor,
&sa.slice(wm * 512, 1024),
32,
);
let b0 = cmma::Matrix::<i8>::from_slice(
cmma::MatrixIdent::B,
16usize,
16usize,
16usize,
cmma::MatrixLayout::ColMajor,
&sb.slice(wn * 512, 2048),
32,
);
cmma::execute::<i8, i8, i32, i32>(&a0, &b0, &c, &c);
let a1 = cmma::Matrix::<i8>::from_slice(
cmma::MatrixIdent::A,
16usize,
16usize,
16usize,
cmma::MatrixLayout::RowMajor,
&sa.slice(wm * 512 + 16, 1024),
32,
);
let b1 = cmma::Matrix::<i8>::from_slice(
cmma::MatrixIdent::B,
16usize,
16usize,
16usize,
cmma::MatrixLayout::ColMajor,
&sb.slice(wn * 512 + 16, 2048),
32,
);
cmma::execute::<i8, i8, i32, i32>(&a1, &b1, &c, &c);
// Spill this warp's i32 tile, apply the per-block f32 scale, accumulate into the f32 tile.
cmma::store(
&mut ci.slice_mut(warp * 256, warp * 256 + 256),
&c,
16,
cmma::MatrixLayout::RowMajor,
);
cmma::store(
&mut ci.slice_mut(warp * 256, warp * 256 + 256),
&c,
16,
cmma::MatrixLayout::RowMajor,
);
}
// NORMATIVE oracle: the identical i32 contraction as portable scalar MACs.
default => {
for e in 0usize..per {
let p = lane * per + e;
let smm = p / 16;
let snn = p % 16;
let mut isum = 0i32;
for l in 0usize..32 {
isum += i32::cast_from(sa[(wm * 16 + smm) * 32 + l])
* i32::cast_from(sb[(wn * 16 + snn) * 32 + l]);
}
ci[warp * 256 + p] = isum;
}
}
};
sync_cube();
for e in 0usize..8 {
let p = lane * 8 + e;
// Apply the per-block f32 scale, accumulate into the f32 tile.
for e in 0usize..per {
let p = lane * per + e;
let smm = p / 16;
let snn = p % 16;
let gmm = wm * 16 + smm;
@@ -535,15 +653,16 @@ pub fn mmq_q8_wmma_blk(
sync_cube();
}
for e in 0usize..8 {
let p = lane * 8 + e;
for e in 0usize..per {
let p = lane * per + e;
let gmm = wm * 16 + p / 16;
let gnn = wn * 16 + p % 16;
out[(mrow0 + gmm) * n + (ncol0 + gnn)] = accf[gmm * 64 + gnn];
}
}
/// Host launch for the tiled MMQ GEMM. Returns (out, ms/dispatch over `iters`).
/// Host launch for the tiled MMQ GEMM -- the PRODUCTION surface; derives the island tag from the
/// runtime. Returns (out, ms/dispatch over `iters`).
#[allow(clippy::too_many_arguments)]
pub fn mmq_q8_wmma_blk_run<R: Runtime>(
client: &ComputeClient<R>,
@@ -556,6 +675,24 @@ pub fn mmq_q8_wmma_blk_run<R: Runtime>(
k: usize,
iters: usize,
) -> (Vec<f32>, f64) {
mmq_q8_wmma_blk_run_with(client, xq, xs, wq, wd, m, n, k, iters, Target::of(client))
}
/// Host launch for the tiled MMQ GEMM with an explicit island tag. See [`mmq_q8_wmma_run_with`].
#[allow(clippy::too_many_arguments)]
pub fn mmq_q8_wmma_blk_run_with<R: Runtime>(
client: &ComputeClient<R>,
xq: &[i8],
xs: &[f32],
wq: &[i8],
wd: &[f32],
m: usize,
n: usize,
k: usize,
iters: usize,
target: Target,
) -> (Vec<f32>, f64) {
let plane = client.properties().hardware.plane_size_max;
let xqh = client.create_from_slice(i8::as_bytes(xq));
let xsh = client.create_from_slice(f32::as_bytes(xs));
let wqh = client.create_from_slice(i8::as_bytes(wq));
@@ -566,7 +703,7 @@ pub fn mmq_q8_wmma_blk_run<R: Runtime>(
mmq_q8_wmma_blk::launch_unchecked::<R>(
c,
grid.clone(),
Block::new_1d(256),
Block::new_1d(8 * plane),
ArrayArg::from_raw_parts(xqh.clone(), xq.len()),
ArrayArg::from_raw_parts(xsh.clone(), xs.len()),
ArrayArg::from_raw_parts(wqh.clone(), wq.len()),
@@ -574,6 +711,8 @@ pub fn mmq_q8_wmma_blk_run<R: Runtime>(
ArrayArg::from_raw_parts(oh.clone(), m * n),
n,
k,
plane as usize,
target,
);
};
launch(client);
@@ -616,3 +755,74 @@ pub fn gen_mmq(m: usize, n: usize, k: usize) -> (Vec<i8>, Vec<f32>, Vec<i8>, Vec
.collect();
(xq, xs, wq, wd)
}
#[cfg(all(test, feature = "cpu"))]
mod tests {
use super::*;
use cubecl::cpu::{CpuDevice, CpuRuntime};
fn cpu_client() -> ComputeClient<CpuRuntime> {
CpuRuntime::client(&CpuDevice)
}
/// Scale-relative gate: max|delta| / max|ref|. A per-element relative error is meaningless here --
/// a signed int8 sum cancels to near-zero often, which explodes the per-element denominator and
/// reports a false failure. A real decode/accumulation bug moves this number; cancellation does not.
fn rel_to_max(got: &[f32], want: &[f32]) -> f32 {
let mut maxabs = 0f32;
let mut refmax = 1e-9f32;
for (g, w) in got.iter().zip(want) {
maxabs = maxabs.max((g - w).abs());
refmax = refmax.max(w.abs());
}
maxabs / refmax
}
/// The oracle. The naive MMQ GEMM's normative arm, on the CPU runtime, IS `mmq_q8_ref` -- bit-exact,
/// not merely close. It can be bit-exact because the kernel accumulates the f32 scale in the same
/// order the reference does (one 32-block at a time, ascending), and the int8 inner sum is integer.
#[test]
fn mmq_default_arm_is_bit_exact_on_cpu() {
let client = cpu_client();
for (m, n, k) in [(16usize, 16usize, 64usize), (32, 16, 128), (16, 32, 256)] {
let (xq, xs, wq, wd) = gen_mmq(m, n, k);
let (got, _) = mmq_q8_wmma_run_with::<CpuRuntime>(
&client, &xq, &xs, &wq, &wd, m, n, k, 1, Target::Cpu,
);
let want = mmq_q8_ref(&xq, &xs, &wq, &wd, m, n, k);
let gb: Vec<u32> = got.iter().map(|x| x.to_bits()).collect();
let wb: Vec<u32> = want.iter().map(|x| x.to_bits()).collect();
assert_eq!(gb, wb, "MMQ {m}x{n}x{k}: default arm != mmq_q8_ref (bit-exact gate)");
}
}
/// The tiled GEMM's normative arm agrees with the same reference through the shared-memory A/B
/// staging path. Gated scale-relative rather than bit-exact: this kernel accumulates each warp's
/// subtile independently, so the f32 add order differs from the reference's single ascending sweep.
#[test]
fn mmq_blk_default_arm_matches_ref_on_cpu() {
let client = cpu_client();
let (m, n, k) = (32usize, 64usize, 128usize);
let (xq, xs, wq, wd) = gen_mmq(m, n, k);
let (got, _) = mmq_q8_wmma_blk_run_with::<CpuRuntime>(
&client, &xq, &xs, &wq, &wd, m, n, k, 1, Target::Cpu,
);
let want = mmq_q8_ref(&xq, &xs, &wq, &wd, m, n, k);
let rel = rel_to_max(&got, &want);
assert!(rel < 1e-6, "tiled MMQ {m}x{n}x{k}: rel_to_max={rel:.3e} vs mmq_q8_ref");
}
/// The production surface derives the island tag from the runtime, so the CPU runtime resolves to
/// the normative arm with no caller naming a target.
#[test]
fn production_run_selects_oracle_on_cpu() {
let client = cpu_client();
let (m, n, k) = (16usize, 16usize, 64usize);
let (xq, xs, wq, wd) = gen_mmq(m, n, k);
let (got, _) = mmq_q8_wmma_run::<CpuRuntime>(&client, &xq, &xs, &wq, &wd, m, n, k, 1);
let want = mmq_q8_ref(&xq, &xs, &wq, &wd, m, n, k);
let gb: Vec<u32> = got.iter().map(|x| x.to_bits()).collect();
let wb: Vec<u32> = want.iter().map(|x| x.to_bits()).collect();
assert_eq!(gb, wb);
}
}
+54 -34
View File
@@ -1,13 +1,16 @@
//! mmq-check: does the DSL emit a working int8 tensor-core MMQ GEMM on CUDA?
//! mmq-check: does the DSL emit a working int8 tensor-core MMQ GEMM on THIS backend?
//!
//! Staged, cheapest-kill-first (run with --features cuda on the GPU box):
//! Staged, cheapest-kill-first (`--features cuda` or `--features vulkan` on the GPU box):
//! 1a high-level WMMA hello (i8 16x16x16 -> i32) exact-int gate
//! 1b manual MMA hello (i8 m16n8k32 -> i32) exact-int gate
//! 2 Q8_0 x q8_1 MMQ tile (one/few tiles, K-loop) bit-close vs the quantized CPU oracle
//! 1b manual MMA hello (i8 m16n8k32 -> i32) exact-int gate, CUDA/ROCm only
//! 2 Q8_0 x q8_1 MMQ tile (one/few tiles, K-loop) scale-relative vs the quantized oracle
//! 3 full prefill GEMM (M=512 N=4096 K=4096) verify + kernel-only GFLOP/s & GB/s
//!
//! Stage 1b is skipped off CUDA/ROCm by construction, not by failure: SPIR-V has no per-lane fragment
//! layout to query, so the manual-MMA family cannot lower there (see `hanzo_kernel::mmq::mma_hello_i8`).
//!
//! Each stage is isolated with catch_unwind so a JIT failure in one path still lets the rest report
//! (a decisive negative -- "the DSL can't express X on sm_121" -- must survive to the log).
//! (a decisive negative -- "the DSL can't express X here" -- must survive to the log).
use hanzo_kernel::mmq::*;
use hanzo_kernel::prelude::*;
@@ -47,17 +50,18 @@ fn rand_i8(n: usize, seed: u64) -> Vec<i8> {
.collect()
}
fn main() {
use cubecl::cuda::{CudaDevice, CudaRuntime};
let client = CudaRuntime::client(&CudaDevice::default());
/// The whole probe, over any runtime -- ONE staged gate, every backend. `name` labels the log.
fn probe<R: Runtime>(name: &str, client: &ComputeClient<R>) {
let client = &client.clone();
let plane = client.properties().hardware.plane_size_max;
println!("== hanzo-kernel MMQ probe (CUDA, plane={plane}) ==\n");
let target = Target::of(client);
println!("== hanzo-kernel MMQ probe ({name}, plane={plane}, target={target:?}) ==\n");
// ---- Stage 1a: high-level WMMA hello ----
let r = catch_unwind(AssertUnwindSafe(|| {
let a = rand_i8(256, 0x1234);
let b = rand_i8(256, 0x9E37);
let got = wmma_hello_i8_run::<CudaRuntime>(&client, &a, &b);
let got = wmma_hello_i8_run::<R>(client, &a, &b);
let want = hello_i8_ref(&a, &b);
let bad = got.iter().zip(&want).filter(|(x, y)| x != y).count();
println!(
@@ -75,33 +79,37 @@ fn main() {
println!("[1a] WMMA hello i8 16x16x16->i32 PANIC/JIT-FAIL ✗ (see stderr above)");
}
// ---- Stage 1b: manual MMA hello ----
let r = catch_unwind(AssertUnwindSafe(|| {
let a = rand_i8(16 * 32, 0xABCD);
let b = rand_i8(32 * 8, 0x5555);
let got = mma_hello_i8_run::<CudaRuntime>(&client, &a, &b, plane);
let want = mma_hello_ref(&a, &b);
let bad = got.iter().zip(&want).filter(|(x, y)| x != y).count();
println!(
"[1b] manual MMA i8 m16n8k32->i32 {} ({} / 128 mismatched)",
if bad == 0 {
"EXACT ✓"
} else {
"MISMATCH ✗"
},
bad
);
bad == 0
}));
if r.is_err() {
println!("[1b] manual MMA i8 m16n8k32->i32 PANIC/JIT-FAIL ✗ (see stderr above)");
// ---- Stage 1b: manual MMA hello (CUDA/ROCm only -- SPIR-V has no queryable fragment layout) ----
if matches!(target, Target::Cuda | Target::Rocm) {
let r = catch_unwind(AssertUnwindSafe(|| {
let a = rand_i8(16 * 32, 0xABCD);
let b = rand_i8(32 * 8, 0x5555);
let got = mma_hello_i8_run::<R>(client, &a, &b, plane);
let want = mma_hello_ref(&a, &b);
let bad = got.iter().zip(&want).filter(|(x, y)| x != y).count();
println!(
"[1b] manual MMA i8 m16n8k32->i32 {} ({} / 128 mismatched)",
if bad == 0 {
"EXACT ✓"
} else {
"MISMATCH ✗"
},
bad
);
bad == 0
}));
if r.is_err() {
println!("[1b] manual MMA i8 m16n8k32->i32 PANIC/JIT-FAIL ✗ (see stderr above)");
}
} else {
println!("[1b] manual MMA i8 m16n8k32->i32 N/A (no fragment layout on {target:?})");
}
// ---- Stage 2: MMQ tile(s), bit-close vs the quantized oracle ----
for (m, n, k) in [(16usize, 16usize, 64usize), (32, 64, 256), (64, 128, 512)] {
let r = catch_unwind(AssertUnwindSafe(|| {
let (xq, xs, wq, wd) = gen_mmq(m, n, k);
let (got, _) = mmq_q8_wmma_run::<CudaRuntime>(&client, &xq, &xs, &wq, &wd, m, n, k, 1);
let (got, _) = mmq_q8_wmma_run::<R>(client, &xq, &xs, &wq, &wd, m, n, k, 1);
let want = mmq_q8_ref(&xq, &xs, &wq, &wd, m, n, k);
let rel = max_rel(&want, &got);
let (maxabs, relmax) = err_robust(&want, &got);
@@ -125,7 +133,7 @@ fn main() {
let mrows = 8usize;
let want = mmq_q8_ref(&xq, &xs, &wq, &wd, mrows, n, k); // exact 8-row stripe oracle
let (g0, ms0) = mmq_q8_wmma_run::<CudaRuntime>(&client, &xq, &xs, &wq, &wd, m, n, k, 50);
let (g0, ms0) = mmq_q8_wmma_run::<R>(client, &xq, &xs, &wq, &wd, m, n, k, 50);
let (a0, r0) = err_robust(&want, &g0[..mrows * n]);
println!(
"\n[3 ] GEMM {m}x{n}x{k} (naive 1-warp/16x16 tile) verify rel_to_max={:.2e} max_abs={:.2e} {}",
@@ -138,8 +146,7 @@ fn main() {
wbytes / (ms0 * 1e6)
);
let (g1, ms1) =
mmq_q8_wmma_blk_run::<CudaRuntime>(&client, &xq, &xs, &wq, &wd, m, n, k, 50);
let (g1, ms1) = mmq_q8_wmma_blk_run::<R>(client, &xq, &xs, &wq, &wd, m, n, k, 50);
let (a1, r1) = err_robust(&want, &g1[..mrows * n]);
println!(
"[3 ] GEMM {m}x{n}x{k} (tiled 8-warp/32x64 tile) verify rel_to_max={:.2e} max_abs={:.2e} {}",
@@ -154,3 +161,16 @@ fn main() {
println!("[3 ] GEMM 512x4096x4096 PANIC/JIT-FAIL ✗");
}
}
fn main() {
#[cfg(feature = "cuda")]
{
use cubecl::cuda::{CudaDevice, CudaRuntime};
probe::<CudaRuntime>("CUDA", &CudaRuntime::client(&CudaDevice::default()));
}
#[cfg(feature = "vulkan")]
{
use cubecl::wgpu::{WgpuDevice, WgpuRuntime};
probe::<WgpuRuntime>("VULKAN", &WgpuRuntime::client(&WgpuDevice::default()));
}
}
+2 -2
View File
@@ -413,7 +413,7 @@ pub fn moe_matvec_q4k_blk<F: Float>(
x: &Array<F>,
ids: &Array<u32>,
out: &mut Array<F>,
#[comptime] n: usize, // comptime shape: outrow/n & nsub/nt fold to magic-multiply + full unroll
#[comptime] n: usize, // comptime shape: outrow/n & nsub/nt fold to magic-multiply divides
#[comptime] k: usize, // (the DSL's job -- one source fn, one fast .spv per live shape, like llama)
#[comptime] nt: usize, // threads per output element (k/32 a multiple of nt)
) {
@@ -1089,7 +1089,7 @@ pub fn moe_matvec_q6k_blk<F: Float>(
x: &Array<F>,
ids: &Array<u32>,
out: &mut Array<F>,
#[comptime] n: usize, // comptime shape (see moe_matvec_q4k_blk): fold + full unroll
#[comptime] n: usize, // comptime shape (see moe_matvec_q4k_blk): folds to magic-multiply divides
#[comptime] k: usize,
#[comptime] nt: usize,
) {