Introduction
gemmkit is a pure-Rust workspace for GEMM, the general matrix multiply C <- alpha*A*B + beta*C. The core crate works over strided views or raw pointers. It selects the fastest instruction set available on the machine at runtime. Under a fixed input and configuration, it keeps its results reproducible from run to run. Around that core sit 3 zero-copy adapters, one each for ndarray, nalgebra, and faer. An install-time autotuner calibrates the engine for the machine it will actually run on.
This book is the narrative documentation for the whole workspace. The API reference on docs.rs remains the authority on exact signatures and item-level details. The book explains the pieces in context: the reasoning, the trade-offs, and the corners of the API that a reference page cannot cover in depth.
What is in the book
The gemmkit user guide covers the core crate. It starts with the first multiply and goes on to parts most users never need:
- matrix views and layouts
- the optional element types (
f16/bf16,i8, complex) - parallel execution
- prepacked operands
- fused epilogues
- batched GEMM
- small shapes and GEMV
- instruction-set pinning
- the tuning knobs
no_stdand WebAssembly builds- the unchecked raw-pointer tier
The adapter guides show how to drive the engine straight from ndarray, nalgebra, and faer types with no copies. Each adapter gets a chapter with a getting-started page and an advanced page. The advanced page covers the full surface: fused operations, integer and complex GEMM, batching, and prepacking, all in the host library’s native types.
The gemmkit-tune guide explains the autotuner. It covers how to run the autotuner on a deployment machine, what the emitted profile contains, and how the sweep behind it works.
The architecture chapter walks through the inside of the engine, layer by layer. It covers how a call travels from the public API down to the microkernel, and how instruction sets and element types stay pluggable without macros. It also covers how blocking is derived from the cache hierarchy, and how the whole thing is tested. It is a more detailed, more approachable companion to the compact ARCHITECTURE.md in the repository, written to be read front to back.
How to read it
If you just want fast matrix multiplication in an application, start with Getting Started. Read the user guide as far as your use case demands.
If your matrices already live in ndarray, nalgebra, or faer, jump straight to that adapter’s chapter. When you need the underlying concepts, fall back to the user guide. The adapters forward to the same engine and share its semantics.
If you are curious how the engine works, or you plan to contribute, the architecture chapter is the intended path. It assumes you have skimmed the user guide, but not that you know BLIS. It explains the design decisions, not just the code.
Conventions and resources
Code examples target Rust edition 2024 and the workspace MSRV of 1.89. Examples that need an optional Cargo feature say so where they appear. Repository paths like gemmkit/src/driver.rs are relative to the repository root.
Related resources include the API reference, the CHANGELOG, and the crates on crates.io: gemmkit, gemmkit-ndarray, gemmkit-nalgebra, gemmkit-faer, gemmkit-tune.
本书也有简体中文版。
Getting Started
gemmkit computes C <- alpha*A*B + beta*C over strided views of ordinary Rust slices.
It picks the fastest instruction set your CPU actually has, at runtime. There is no
build-time ISA choice to make and no BLAS to link. You add 1 dependency, hand it 3
matrices, and call gemm.
Adding the dependency
The core crate is gemmkit. For plain f32/f64 work on a normal (std) target, this
single line is all you need:
[dependencies]
gemmkit = "0.1"
This pulls in the 2 default features, std and parallel. std gives you runtime
cache and CPU feature detection. It also gives you the GEMMKIT_REQUIRE_ISA and
GEMMKIT_* tuning knobs, and a thread-local workspace pool. The pool makes repeated
same-size calls allocation-free. parallel adds rayon multithreading, and implies
std.
The optional element-type families (half, complex, int8) and the epilogue
capability are off by default. A plain float build pays for none of their codegen or
dependencies. To build the crate as no_std (only core + alloc), turn the defaults
off with default-features = false. See no_std and WebAssembly
for that setup.
A first complete example
This example computes a 2x3 matrix times a 3x2 matrix, all row-major, run
single-threaded:
use gemmkit::{gemm, MatMut, MatRef, Parallelism};
fn main() {
// 2x3 times 3x2 = 2x2, all row-major
let a = [1.0_f32, 2.0, 3.0, 4.0, 5.0, 6.0];
let b = [7.0_f32, 8.0, 9.0, 10.0, 11.0, 12.0];
let mut c = [0.0_f32; 4];
gemm(
1.0,
MatRef::from_row_major(&a, 2, 3),
MatRef::from_row_major(&b, 3, 2),
0.0,
MatMut::from_row_major(&mut c, 2, 2),
Parallelism::Serial,
);
assert_eq!(c, [58.0, 64.0, 139.0, 154.0]);
}
The arguments are exactly the terms of C <- alpha*A*B + beta*C. They are the scalar
alpha, the 2 input views, the scalar beta, the output view, and a
Parallelism selector.
MatRef::from_row_major(&a, 2, 3) reads a as a 2-by-3 row-major matrix. The shapes
must line up: A.cols must equal B.rows, and C must be A.rows by B.cols. If
they do not line up, the call panics before it touches memory.
Transposition never needs a copy. It is a stride change, not a data move.
MatRef::from_col_major(&b, 3, 2) reads the same buffer as a column-major matrix.
MatRef::new lets you set the row and column strides
directly.
What happened under the hood
The gemm entry does a small amount of work before it does any arithmetic.
First, it validates the call. It checks that the inner dimensions agree. It checks that
each view stays inside its slice. It checks that C addresses every (i, j) at a
distinct offset, and that C’s storage does not overlap A’s or B’s storage. Any
failed check raises a panic with a specific message, before a single unsafe operation
runs. Only then does it lower the 3 views to raw pointers and strides, and hand them to
the dispatch layer.
Dispatch resolves which kernel to run. The first GEMM call for a given element type
runs CPU feature detection once. It records the winning entry point in a OnceLock,
and returns. Every later call is a plain indirect call through that cached pointer, with
no repeat detection. So the runtime ISA choice is a one-time cost, amortized across the
whole process.
You can override the automatic choice with the GEMMKIT_REQUIRE_ISA environment
variable. You can also use it to pin a specific backend for testing. gemmkit reads the
variable once and memoizes it the same way. See Runtime ISA Dispatch
for details. Life of a GEMM Call walks the
full path from call to microkernel.
alpha and beta, precisely
alpha scales the product A*B. beta scales the incoming contents of C. The one
detail to remember is what happens at the edges.
When beta == 0, the engine does not read C at all. It overwrites C with
alpha*A*B. That rule is what makes let mut c = [0.0_f32; 4] correct above, even
though you could have left the buffer uninitialized. In concrete terms, a beta == 0
output slice may hold garbage. Through the unchecked tier it may even be genuinely
uninitialized memory, and the result is still well defined.
When beta == 1, the engine leaves the existing C untouched and accumulates the
product onto it. Any other beta value first multiplies C through.
There is also a degenerate fast path. If k == 0 (an empty contraction) or alpha == 0
(the product vanishes), the call reduces to C <- beta*C. It never touches A or B
at all, and just scales the output in place. Combined with the beta == 0 rule,
alpha == 0, beta == 0 zeroes C, and k == 0, beta == 1 is a no-op. Narrow types
scale in f32 and round back on the store. So the degenerate path rounds exactly as the
full kernel would.
The Cargo features
| Feature | Default | Unlocks | Pulls in |
|---|---|---|---|
std | yes | runtime cache/CPU detection, env knobs, thread-local workspace pool. Off = no_std (core + alloc) | raw-cpuid (x86 only) |
parallel | yes | rayon multithreading (Parallelism::Rayon). Implies std | rayon |
wasm_threads | no | a sized rayon pool for wasm32-wasip1-threads. Implies parallel | (via parallel) |
half | no | f16/bf16 mixed-precision GEMM, f32 accumulate | half |
complex | no | c32/c64 GEMM with conjugation (gemm_cplx) | num-complex |
int8 | no | i8 -> i32 integer GEMM (gemm_i8) | (none) |
epilogue | no | fused bias/activation, i8/u8 requantization, per-element map | (none) |
The element-type and capability features compose. half + epilogue gives fused f16
GEMM. int8 + epilogue gives the requantizing entries, and so on. See
Element Types and Fused Epilogues for each
combination.
Version requirements
gemmkit targets Rust 1.89 on edition 2024. It is licensed MIT OR Apache-2.0. The API reference is on docs.rs/gemmkit. This book is the long-form companion to that reference.
Where to next
- Matrix Views and Layouts: how
MatRef/MatMut, strides, transposition, and submatrices work, and exactly what the safe API validates. - Element Types:
f16/bf16,i8, and complex, with their accuracy characteristics. - Parallelism in Practice: what
Rayon(0)auto actually does and whenSerialis the right call. - Runtime ISA Dispatch and The Unchecked Tier: overriding the backend, and the raw-pointer engine.
- Working with an existing array library? See the adapters: ndarray, nalgebra, faer.
Matrix Views and Layouts
Every gemmkit call takes its operands as views: a slice, a shape, and 2 strides.
MatRef<'a, T> is the immutable input view. MatMut<'a, T> is the mutable output
view. Neither view owns its data. Both borrow a slice you already have.
The library’s whole layout vocabulary lives in those 2 stride numbers: row-major, column-major, transposed, submatrix, and broadcast. So the same buffer can be read a dozen ways without ever being copied.
The 2 strides
Element (i, j) of a view lives at slice offset i*rs + j*cs, where rs is the row
stride and cs is the column stride. Strides are counted in elements, not bytes. An
rs of 4 means the next row sits 4 elements further along the slice. That single offset
formula is the entire model. Everything else is a choice of rs and cs.
3 constructors cover the common cases. Each exists on both MatRef and MatMut:
#![allow(unused)]
fn main() {
use gemmkit::MatRef;
let data = [0.0_f32; 12];
let row_major = MatRef::from_row_major(&data, 3, 4); // rs = cols = 4, cs = 1
let col_major = MatRef::from_col_major(&data, 3, 4); // rs = 1, cs = rows = 3
let general = MatRef::new(&data, 3, 4, 4, 1); // explicit rs, cs (here == row-major)
}
from_row_major(data, rows, cols) sets rs = cols, cs = 1. Rows are contiguous: the
classic C order. from_col_major(data, rows, cols) sets rs = 1, cs = rows. Columns
are contiguous: Fortran order.
new(data, rows, cols, rs, cs) takes the strides verbatim. Reach for it when neither
canonical layout matches, for example a submatrix, or a view whose leading dimension
differs from its logical width. MatRef and MatMut also expose .rows() and
.cols().
Transposition is a stride swap
Because (i, j) maps through i*rs + j*cs, swapping the roles of the 2 strides (and
the 2 dimensions) transposes the view in place. Say a holds an m x k matrix in
row-major order (rs = k, cs = 1). Its transpose is the k x m matrix whose (i, j)
is the original (j, i), at offset j*k + i. That offset is exactly rs = 1, cs = k
over the same slice:
#![allow(unused)]
fn main() {
use gemmkit::MatRef;
// `a` is m x k row-major
let (m, k) = (2, 3);
let a = [1.0_f32, 2.0, 3.0, 4.0, 5.0, 6.0];
let a_rowmajor = MatRef::from_row_major(&a, m, k); // m x k
let a_transposed = MatRef::from_col_major(&a, k, m); // k x m, same bytes, no copy
}
So from_col_major over a row-major buffer is the transpose, and the reverse also
holds. new with rs/cs swapped does the same for any layout. A transposed operand
therefore costs nothing at the API level: the kernel walks the strides you give it.
This is how you feed A^T * B or A * B^T without materializing a transpose.
Submatrices and strided views
A submatrix is a view whose leading dimension (the distance between successive rows or
columns) is larger than its logical extent. Build one by slicing the buffer so the
block’s top-left element sits at the start of the slice, then handing over the
parent’s strides. Here is the top-left 2 x 2 block of a 4 x 4 row-major matrix,
starting at row 1, column 1:
#![allow(unused)]
fn main() {
use gemmkit::MatRef;
let parent = [0.0_f32; 16]; // 4x4 row-major, leading dimension 4
let block = MatRef::new(&parent[1 * 4 + 1..], 2, 2, 4, 1); // rs stays 4, cs stays 1
}
The row stride is still 4, the parent’s width. So consecutive rows of the block skip
over the columns you excluded. The slice begins at offset 5, the block’s (0, 0). The
safe API verifies the tail slice is long enough to reach the block’s far corner.
The same mechanism expresses a broadcast input. A stride of 0 makes a dimension
repeat 1 element. A 1 x n row broadcast down m rows is MatRef::new(row, m, n, 0, 1).
Every logical row then reads the same storage. gemmkit allows broadcasts for the
read-only inputs A and B, but never for the output. The next section explains why.
What the safe API accepts, and what it rejects
The safe entries (gemm, gemm_i8, gemm_cplx, and the fused variants) accept
non-negative strides only, including 0 for a broadcast input. A negative stride
is outside what a &[T] view can describe safely. So is a base pointer that sits in the
middle of a buffer, rather than at element (0, 0). Those cases live in
The Unchecked Tier, the raw-pointer engine the adapters use to
express arbitrary layouts.
Before any arithmetic, the safe entries run one validation prologue over the
(A, B, C) trio. Every failure is a panic, raised ahead of the first unsafe operation:
- Shape agreement.
A.cols == B.rows,A.rows == C.rows,B.cols == C.cols. A mismatch panics with the offending pair, for examplegemmkit: A.cols (3) != B.rows (4). - In-bounds views. For each view, the engine computes the highest slice offset it
will touch, and checks it against the slice length. Too small a slice panics with
gemmkit: A view of 3x4 (strides 4,1) needs 12 elements but slice has 8. A view whose strides are negative, or so large the addressing overflowsusize, panics with... has negative strides or is too large to address; use gemm_unchecked. Caddresses each element uniquely. gemmkit writes the output, so 2 distinct(i, j)must never land on the same offset. A self-aliasingC, such as a zero row or column stride, or strides that collide, would become a data race in parallel mode. That is reachable from entirely safe code, so it panics:gemmkit: C view aliases itself (...); C must address each (i,j) uniquely. This is why broadcast strides are fine forA/B(read-only) but forbidden forC.Cdoes not overlapAorB. The output’s byte range must be disjoint from each input’s. gemmkit compares byte ranges, not element counts, so the heterogeneous integer API (i8inputs,i32output) stays exact. Overlap panics withgemmkit: C aliases A or B. In fully safe Rust, the borrow checker already forbids an overlapping&mut/&pair. This check is a defensive backstop that also covers the raw-lowered paths.
These messages are stable. The correctness suite asserts on their wording, so you can rely on them in tests.
Zero-sized dimensions
A view with a zero dimension is legal and validates cleanly. gemmkit accepts a 0 x k,
m x 0, or m x n x (k = 0) shape. Any slice, even an empty one, satisfies the
in-bounds check, because such a view addresses nothing.
If m == 0 or n == 0, the call is a no-op: there is no output to write. If only
k == 0, the contraction is empty, and the call reduces to C <- beta*C. That is the
same scale-only path alpha == 0 takes. See Getting Started for
that degenerate rule.
Where to next
- Element Types: the same views over
f16/bf16,i8, and complex data. - The Unchecked Tier: negative strides, interior base pointers, and the raw-pointer engine.
- The adapters (ndarray, nalgebra, faer) build these views for you from each library’s native matrix types.
Element Types
gemmkit multiplies more than f32. The same engine, driver, and blocking model serve 4
element-type families. Each family is a Cargo feature. Each also has a SIMD
implementation on every backend, over the portable scalar fallback.
What changes between the families is the input type, the accumulator type, and the output type. With that comes a change in the accuracy you should expect. This page maps what is available and how precise each family is.
The built-in real floats
f32 and f64 need no feature flag. They go through the generic gemm (and
gemm_with, and the unchecked entries), and accumulate in their own type. They are the
baseline every other family is measured against:
#![allow(unused)]
fn main() {
use gemmkit::{gemm, MatMut, MatRef, Parallelism};
let a = [1.0_f64, 2.0, 3.0, 4.0];
let b = [5.0_f64, 6.0, 7.0, 8.0];
let mut c = [0.0_f64; 4];
gemm(2.0, MatRef::from_row_major(&a, 2, 2), MatRef::from_row_major(&b, 2, 2),
0.0, MatMut::from_row_major(&mut c, 2, 2), Parallelism::Serial);
}
Accuracy follows the textbook GEMM story. Relative error grows roughly with the
contraction depth k and the machine epsilon of the type. The correctness suite holds
results to a relative Frobenius gate of 8*k*eps, checked against an independent f64
reference. So f64 is near exact for any realistic k, and f32 carries its usual
~1e-7 per-element relative precision.
Narrow floats: the half feature
With half on, f16 and bf16 become element types. gemmkit re-exports them as
gemmkit::f16 and gemmkit::bf16, so you need not depend on half directly. They
share the generic gemm surface: MatRef<'_, f16> in, MatMut<'_, f16> out, because
they implement the same scalar trait as the real floats.
The defining property is mixed precision. The engine widens inputs to f32 on load.
The entire contraction accumulates in f32. The engine rounds the result back to the
narrow type exactly once, at the store. There is no repeated narrow rounding inside
the k loop. That single rounding point is what keeps the accuracy usable.
#![allow(unused)]
fn main() {
use gemmkit::{f16, gemm, MatMut, MatRef, Parallelism};
let a: Vec<f16> = (0..6).map(|i| f16::from_f32(i as f32)).collect();
let b: Vec<f16> = (0..6).map(|i| f16::from_f32(i as f32)).collect();
let mut c = vec![f16::ZERO; 4];
gemm(f16::ONE, MatRef::from_row_major(&a, 2, 3), MatRef::from_row_major(&b, 3, 2),
f16::ZERO, MatMut::from_row_major(&mut c, 2, 2), Parallelism::Serial);
}
Because the accumulation is in f32, the dominant error is that single final round, not
the sum. f16 carries about 9.8e-4 (2^-10) relative precision, and bf16 about
7.8e-3 (2^-7), both essentially independent of k. A narrow-precision GEMM is
therefore close to computing in f32 and rounding once. That is far more accurate than
accumulating in 16 bits would be.
One consequence of rounding once is that at a large k, a single depth panel would
stream an intermediate result too large for L2 cache. The engine handles this itself.
Past an auto-derived byte gate, it switches to an f32-output internal twin. That twin
re-blocks the contraction to stay cache-resident, and narrows the result at the end. The
twin matches the single panel byte for byte for the common case of beta in {0, 1}, and
stays within tolerance otherwise. This switch is automatic and needs no configuration.
The mechanism is detailed in Dot Kernels and the Deep-K Twin.
On AVX-512 BF16 hardware, bf16 also uses the vdpbf16ps dot kernel. See
Runtime ISA Dispatch for that mechanism.
Integer: the int8 feature
int8 adds gemm_i8, a separate entry point. Its input and output types differ, i8
in and i32 out, and the homogeneous gemm<T> surface cannot express that. alpha,
beta, and C are all i32:
#![allow(unused)]
fn main() {
use gemmkit::{gemm_i8, MatMut, MatRef, Parallelism};
let a = [1_i8, 2, 3, 4, 5, 6];
let b = [7_i8, 8, 9, 10, 11, 12];
let mut c = [0_i32; 4];
gemm_i8(1, MatRef::from_row_major(&a, 2, 3), MatRef::from_row_major(&b, 3, 2),
0, MatMut::from_row_major(&mut c, 2, 2), Parallelism::Serial);
}
Integer GEMM is exact. It is i32 ring arithmetic that wraps on overflow, the
conventional integer-GEMM semantics. There is no tolerance to speak of, because there is
no rounding. The result is bit-for-bit identical across every ISA (scalar, FMA,
AVX-512F, and the AVX-512 VNNI vpdpbusd dot kernel). It is also identical between a
serial and a parallel run, because integer addition over a ring does not depend on
order.
If you feed values whose products can exceed i32, the wraparound is defined and
reproducible. It is not undefined behavior. The int8 feature pulls in no extra
dependency. Adding epilogue on top unlocks the requantizing entries, which give i8
or u8 output in one pass. See Fused Epilogues for those entries.
Complex: the complex feature
complex adds gemm_cplx over num-complex values. gemmkit re-exports them as
gemmkit::c32 (Complex<f32>) and gemmkit::c64 (Complex<f64>). Its signature
carries a conjugation flag for each operand:
#![allow(unused)]
fn main() {
use gemmkit::{c32, gemm_cplx, Complex, MatMut, MatRef, Parallelism};
let a = [Complex::new(1.0_f32, 1.0), Complex::new(2.0, 0.0)];
let b = [Complex::new(0.0_f32, 1.0), Complex::new(1.0, 0.0)];
let mut c = [c32::default(); 1];
gemm_cplx(
Complex::new(1.0, 0.0),
MatRef::from_row_major(&a, 1, 2), false, // conj_a
MatRef::from_row_major(&b, 2, 1), false, // conj_b
Complex::new(0.0, 0.0),
MatMut::from_row_major(&mut c, 1, 1),
Parallelism::Serial,
);
}
The computation is C <- alpha*op(A)*op(B) + beta*C. op(A) is conj(A) when
conj_a is set, and likewise op(B) is conj(B) when conj_b is set. Passing
false, false gives the plain product A*B. The flags conjugate the operands only.
Complex accumulates in its own type. It is held to a relative Frobenius gate of
16*k*eps, with eps the real component’s epsilon. So a c32 GEMM is about as
accurate as an f32 one, and a c64 GEMM about as accurate as an f64 one. complex
pulls in num-complex.
Internally, complex does not ride the float kernel. It uses a dedicated split (structure-of-arrays) kernel instead, which is why it gets a separate entry point. That design is covered in The Complex Split Kernel.
Choosing a type
| Family | Feature | In / Acc / Out | Accuracy | Determinism |
|---|---|---|---|---|
f32, f64 | (built in) | same / same / same | textbook, ~8*k*eps | reproducible, today bit-exact between serial and parallel on driver paths |
f16, bf16 | half | narrow / f32 / narrow | one final round, ~1e-3 (f16), ~8e-3 (bf16) | reproducible, deep-k twin bit-exact for beta in {0,1} |
i8 | int8 | i8 / i32 / i32 | exact, wrapping i32 | bit-identical across every ISA and worker count |
c32, c64 | complex | same / same / same | ~16*k*eps | reproducible, today bit-exact between serial and parallel |
If you need speed and can tolerate ~1e-3 precision, choose bf16 or f16. Both use
half the bytes per element that f32 does, and both still accumulate in f32. If you
need exactness, int8 gives it. If you need range and precision, stay on f32/f64.
The reproducibility contract is the same for all of them. See Parallelism in Practice for what “reproducible” does and does not promise.
Where to next
- Fused Epilogues: bias, activation, requantization, and per-element maps fused into the store.
- Runtime ISA Dispatch: the VNNI and BF16 dot kernels these types can reach.
- Matrix Views and Layouts: constructing the views every family shares.
Parallelism in Practice
Every GEMM entry takes a Parallelism argument as its last parameter. It is a small
enum with 3 practical modes. Using it well comes down to 2 things: understand
what the auto mode decides for you, and know when to take over yourself.
The 3 modes
#![allow(unused)]
fn main() {
pub enum Parallelism {
Serial, // single-threaded
Rayon(usize), // rayon with at most n threads; Rayon(0) auto-detects
}
}
Serial runs the whole call on the calling thread. Rayon(n) asks for at most n
workers. Rayon(0) is auto, and it is also the Default, so Parallelism::default()
gives you auto. The n in Rayon(n) is a ceiling on partitions, not a promise to use
them all. A problem with less work than n chunks, or fewer cores than n, gets fewer.
What auto actually does
Auto does not mean “use all cores.” It makes 2 decisions based on the problem size.
First, a workload gate applies. Below a total-work threshold on m*n*k (the
GEMMKIT_PARALLEL_THRESHOLD knob, default 48*48*256), the call stays serial no
matter what. On a matrix that small, fork/join overhead would swamp any gain. This
gate runs before everything else, so it applies even to an explicit Rayon(n).
Below the gate, Rayon(8) still runs on one thread.
Above the gate, auto scales the worker count with the total work instead of jumping
straight to the full core count. It targets m*n*k divided by
GEMMKIT_PAR_MNK_PER_WORKER (default 2_000_000, one worker per that much work).
It then caps the result by the machine’s core count and by the number of available
job chunks, floored at one. The count is work-based, not dimension-based, because the
best worker count tracks total flops, not linear size. No single stride on a linear
dimension can fit that whole range. A small product uses a handful of workers, and a
large one uses many. Setting GEMMKIT_PAR_MNK_PER_WORKER to 0 (which behaves as
1) forces full width for anything above the serial gate.
Explicit counts
Rayon(n) with n > 0 bypasses the ramp heuristic and asks for exactly n
partitions. For safety, this is still capped by the machine’s core count
(available_parallelism) and by the number of job chunks the problem actually splits
into. So Rayon(1000) on a 16-core box computing a small product does not
oversubscribe: it collapses to what the machine and the work can absorb. This
exactness is why the test suite and the scaling diagnostics use explicit counts.
Rayon(4) gives you four-way partitioning, when there is that much work and that many
cores, not a heuristic guess. Use an explicit count once you have measured your own
workload and know the sweet spot. Also use it when you want reproducible
partitioning across runs for benchmarking.
How gemmkit uses the rayon pool
gemmkit does not require you to hand it a rayon pool. If you wrap a call in your own
pool’s install, the GEMM’s workers run on that pool instead of anywhere else.
#![allow(unused)]
fn main() {
let pool = rayon::ThreadPoolBuilder::new().num_threads(4).build().unwrap();
pool.install(|| {
gemm(1.0, a, b, 0.0, c, Parallelism::Rayon(0)); // runs on `pool`
});
}
The worker count gemmkit chooses is still bounded by available_parallelism, the
whole machine. Rayon’s work-stealing scheduler distributes the partitions over
whatever threads the current pool has. A smaller custom pool simply runs the same
partitions on fewer threads. Work distribution inside a call is demand-driven:
workers pull contiguous chunks from a shared lock-free cursor. On a heterogeneous
part (a mix of P-cores and E-cores), a faster core absorbs more chunks instead of
everyone waiting on the slowest one.
If a call does not run inside a pool you installed yourself, gemmkit reaches for one
of its own pools instead. This is on by default on native targets, x86_64 and
aarch64. gemmkit keeps up to GEMMKIT_POOL_CLASSES (default 2
on x86_64, 1 on aarch64) private, persistent pools. Each is sized to an exact halving
tier of the machine width. On a 32-thread part that means tiers of 16 and 8 threads.
On a 14-core M4 Max it means a single 7-wide tier. Each pool builds lazily on first
use and is never rebuilt.
Auto snaps its worker count exactly onto one of these tiers instead of forking the full-width global pool. A fork’s overhead tracks the pool’s idle slack: the threads it owns beyond the ones actually engaged. A small GEMM drowns in that slack on a full-width pool, so matching the pool to the work avoids the drag.
None of this changes what you already know above. An install’d call is still fully
respected and never redirected to a tier pool. An explicit Rayon(n) still gets
exactly n workers, just routed into whichever tier pool is the smallest fit. What
does change is idle memory. By default, an x86_64 process now parks about 24 extra
threads, the 16- and 8-wide tier pools, alongside the global pool. An aarch64 M4 Max
process parks 7 (its single half-width tier). All of them stay asleep until a small
GEMM needs them. Set GEMMKIT_POOL_CLASSES=0 to disable tier pools entirely. Every
call then falls back to the ambient pool.
The threaded-wasm story is different: there gemmkit always sizes a dedicated pool of its own. See no_std and WebAssembly for that case.
The reproducibility promise, precisely
For a fixed input, environment, and configuration, the output is identical regardless of the worker count. That is the contract.
It holds for 2 reasons. First, kc, nc, and the fixed depth-panel order are the
only things that shape each output element’s summation. gemmkit computes all 3
independently of how many threads will run them. Second, a single worker reduces
each output element start to finish, over the full contraction depth. No split
reduction exists whose order could depend on the schedule.
The flat job list itself is not strictly identical across worker counts: a wide
worker count can shrink mc to keep the list deep enough. But mc always stays a
multiple of mr, so the set of microtiles, and their numerics, stay unchanged. The
packed bytes do not depend on who packs them, either. Which worker computes a given
tile varies from run to run. The numerical result does not.
What is not promised is bitwise identity between Serial and Rayon(n). It
happens to hold today on the driver paths, since serial and parallel run the same
kernel. But build on reproducibility under a fixed config, not on
serial-versus-parallel bit equality. You will not get cross-machine or cross-config
bit equality here. Floating-point GEMM is order-sensitive, and the config (ISA,
blocking, thread cap) is part of the fixed input. Integer gemm_i8 is the
exception. It is bit-identical across ISAs and worker counts, because i32
addition is order-independent.
When Serial is the right call
Reach for Serial in 3 situations.
- Small problems. Below the workload gate, auto is serial anyway. Passing
Serialexplicitly also skips theavailable_parallelismprobe and the fork machinery entirely, which is cheaper in a tight loop of tiny GEMMs. - When you own the outer parallelism. Suppose you already run many independent
GEMMs across a rayon pool, or you parallelize a batch loop yourself. Do not let
each inner call also fan out. That oversubscribes the machine, and it usually
hurts performance instead of helping. Run the inner calls
Serialand keep the parallelism at the outer level. For a batch of products, prefer the built-in Batched GEMM entries instead, since they schedule the whole batch as one unit. - Determinism-sensitive debugging. Use the single-threaded path to rule out scheduling as a variable.
Bandwidth-bound shapes get their own policy
A matrix-vector product (m == 1 or n == 1), and other memory-bound shapes, are
not compute-bound. The work-based worker count above is the wrong model for them, so
these routes use a separate rule.
Below a cache-derived byte floor, the matrix fits one core’s private cache. That core already saturates the cache alone, so splitting the work only adds contention, and the route stays serial. Above the floor, the route steps straight to a width chosen for the bytes touched. This width tops out at half the logical core count, because a gemv saturates its bandwidth well before the last core joins in.
That width climbs in steps, not smoothly. Each step is one of the exact-fit thread pools described above, so a bandwidth-bound call also gets a pool sized exactly to it. A few workers is the worst point on a bandwidth scaling curve, so the policy jumps over that point instead of ramping through it.
This whole policy is automatic. The byte floor, the step spacing, and a flat override
are all tunable, through GEMMKIT_GEMV_PARALLEL_BYTES, GEMMKIT_GEMV_TIER_STEP, and
GEMMKIT_GEMV_THREAD_CAP. See Small Shapes and GEMV for
the full treatment.
Where to next
- Small Shapes and GEMV: the bandwidth-bound policy in detail.
- Batched GEMM: scheduling many products as one batch instead of nesting parallelism.
- Tuning Knobs: the
GEMMKIT_*thresholds behind these decisions. - Parallel Execution: the job cursor and worker-count resolution internals.
Prepacked Operands
Before the microkernel can touch A and B, the engine copies each into a cache-friendly
micropanel layout. This layout holds contiguous tiles, and the microkernel walks each tile
with unit strides. For a one-shot product, that copy is pure setup. The engine pays for it
once and never uses it again.
Many workloads multiply the same matrix over and over. A linear layer applies one fixed weight matrix to a stream of activation batches. A solver applies one fixed operator to many right-hand sides. Repacking a fixed operand on every call throws away work the engine already did. The prepacked-operand API lets you pay for the pack once, then reuse the result across every product that shares that operand.
Packing the right-hand side
The common case fixes B (the weights) and streams a series of differently sized A
matrices (the activations). Call prepack_rhs once to turn a
k x n B into a PackedRhs handle. Then feed that handle to
gemm_packed_b for each product:
#![allow(unused)]
fn main() {
use gemmkit::{prepack_rhs, gemm_packed_b, MatRef, MatMut, Parallelism};
// fixed weights: a k x n matrix reused across many activation batches
let (k, n) = (512, 256);
let weights = vec![0.0f32; k * n];
let packed = prepack_rhs(MatRef::from_col_major(&weights, k, n));
// per activation batch: an m x k input, sharing the packed weights
let mut c = vec![0.0f32; m * n];
gemm_packed_b(
1.0,
MatRef::from_row_major(&input, m, k),
&packed,
0.0,
MatMut::from_col_major(&mut c, m, n),
Parallelism::Rayon(0),
);
}
prepack_rhs accepts any layout of B and reads it through its strides. A row-major,
column-major, or transposed view all pack the same way. The pack runs once, inside
prepack_rhs, on a single thread. Every later call to gemm_packed_b skips it.
The buffer records its own blocking geometry: nr, kc, and nc. Every
consuming call reads that same geometry back, so a panel is always read against its own
tiling. The buffer is read-only for the whole GEMM, so gemmkit never writes to it after
the pack. A single PackedRhs is therefore safe to share across threads and across
concurrent calls, with no extra synchronization. PackedRhs::rows() reports the original
k. PackedRhs::cols() reports the original n.
The handle works for any product whose (k, n) match the packed B, as long as the
output C is column-major-ish (|csc| >= |rsc|). That constraint is the one surprise in
this API. A row-major C would force the engine to swap A and B internally, to keep
its stores contiguous. A prepacked B cannot move into the A role. So gemm_packed_b
panics on a row-major C and points you back to plain gemm for that layout. Only C is
pinned this way. A’s layout is unconstrained.
Under a fixed configuration, gemm_packed_b reproduces plain gemm and is deterministic
across worker counts. There is one narrow caveat. It applies to a small product, where
both m and n are at or below the small_mn_dim knob (16 by default, 32 on aarch64).
It also applies to a gemv-shaped product, where m == 1 or n == 1. In both cases the
2 calls may differ in the last ULP. The reason is routing, not error. Plain gemm
reroutes those shapes to a special path, while the prepacked
entry always drives the general packed kernel. Both answers are correct. They sum in a
slightly different order, only on the shapes where the special paths would otherwise take
over.
The left-hand-side mirror
The symmetric case fixes A and streams a series of varying B matrices.
prepack_lhs produces a PackedLhs
handle, and gemm_packed_a consumes it. This mirrors the RHS pair exactly, with the axes
relabeled. PackedLhs::rows() is the original m. PackedLhs::cols() is the shared k.
Internally, the LHS pack is not a separate code path. By the engine’s A/B symmetry, a
prepacked A is exactly the prepacked B of the transposed product C^T = B^T A^T. So
prepack_lhs lays down the identical micropanel buffer, and only records the dimensions
in LHS terms.
This has one visible consequence: the C-layout constraint flips. gemm_packed_a
requires a row-major-ish C (|csc| <= |rsc|), the exact opposite of the RHS entry. A
column-major C would keep A in the genuine LHS role, and a transposed-RHS buffer
cannot fill that role. Pick the packed-A entry when your C is row-major. Pick the
packed-B entry when your C is column-major. Together the 2 entries cover both
orientations.
Fused variants
Each packed entry has a fused twin, under the epilogue feature. gemm_packed_b_fused
and gemm_packed_a_fused add a per-row or per-col bias, plus an optional activation, in
the same store the packed kernel already runs. See Fused Epilogues
for the bias and activation types.
The same PackedRhs or PackedLhs handle serves both the plain entry and the fused
entry. The epilogue applies only at the store, so it never touches the pack. Build the
buffer once, then choose per call whether to fuse.
2 details are specific to the packed path. First, unlike plain gemm_fused, the packed
fused entries never reroute to the gemv, small-m,n, or small-k kernels. They always
drive the general packed kernel, the same divergence the plain packed entries document.
Second, gemmkit always gives the per-row or per-col bias in the natural user frame.
gemm_packed_a_fused handles the internal transpose for you, so a PerRow bias has
length A.rows, no matter which entry you call.
Prepacking i8 weights
Under the int8 feature, the same pattern extends to quantized inference. prepack_rhs_i8
packs a fixed i8 weight matrix into a PackedRhs<i8>, and gemm_i8_packed_b consumes
it. It takes i8 inputs and produces an i32 output.
Prepacking is a bigger win here than for floats, for a structural reason. The AVX-512
VNNI kernel (vpdpbusd) reads its RHS from a k-quad-interleaved layout. The engine cannot
produce that layout in place, so this kernel’s RHS pack is mandatory on every call. At
small m, that per-call O(k*n) pack easily dominates the O(m*k*n) compute. Prepacking
removes it from the hot loop entirely.
The packed buffer also pins the kernel choice. It is laid out for whichever integer kernel
the process’s dispatch selected, either the VNNI interleave or the widen kernel’s plain
panels. gemm_i8_packed_b always runs that same family, so the buffer is never misread.
Integer accumulation is exact and does not depend on the ISA. So the packed and plain paths agree bit-for-bit for every valid shape, with no small-shape caveat at all.
#![allow(unused)]
fn main() {
use gemmkit::{prepack_rhs_i8, gemm_i8_packed_b, MatRef, MatMut, Parallelism};
let packed = prepack_rhs_i8(MatRef::from_col_major(&weights_i8, k, n));
let mut c = vec![0i32; m * n];
gemm_i8_packed_b(
1,
MatRef::from_row_major(&input_i8, m, k),
&packed,
0,
MatMut::from_col_major(&mut c, m, n),
Parallelism::Rayon(0),
);
}
When prepacking pays
Prepacking trades one upfront O(k*n) copy for a saved repack on every later product
against that operand. It pays off exactly when you reuse the operand enough to amortize
that copy. A single product, or an operand that changes on every call, gains nothing. The
pack is then pure overhead, and plain gemm is the right tool.
Be aware that the float path does not always pack B in the first place. For small m,
plain gemm reads B in place, unpacked, a choice governed by the rhs_pack_threshold
knob. So prepacking a lightly reused float B can even lose.
The clearest wins are the fixed-weight inference loops this API is named for. Above all,
that means the i8 VNNI path, whose RHS pack is otherwise unavoidable on every single
call. When in doubt, measure the loop both ways. The crossover point depends on your
reuse count and your machine, not on a fixed rule.
The raw-pointer forms (prepack_rhs_unchecked, gemm_packed_b_unchecked, and their
_with, LHS, and i8 counterparts) exist for adapters and FFI that validate their own
inputs. See The Unchecked Tier.
Fused Epilogues
A GEMM rarely stands alone. Its output usually feeds straight into a bias add, an
activation, or a quantization step. Done naively, that means a second full pass over C.
The GEMM writes m*n values, then a separate loop reads them all back, transforms them,
and writes them again.
A fused epilogue folds that transform into the GEMM’s own store. It transforms each
output element in-register, at the moment the store writes it, so the extra pass over
memory disappears. Everything on this page lives behind the epilogue Cargo feature.
Bias and activation
gemm_fused is the vectorized workhorse. It computes
C <- act(alpha*A*B + beta*C + bias) in one pass. The bias is a
Bias enum: either Bias::PerRow(&[T]) (one value per output
row, length m) or Bias::PerCol(&[T]) (one per output column, length n). gemmkit adds
that value to every element of the matching row or column, after the product. The
activation is an Activation: Relu (max(v, 0)) or
LeakyRelu(slope). Both arguments are Option. Passing None for both delegates
straight to plain gemm.
#![allow(unused)]
fn main() {
use gemmkit::{gemm_fused, Bias, Activation, MatRef, MatMut, Parallelism};
let bias = vec![0.0f32; m]; // one value per output row
gemm_fused(
1.0,
MatRef::from_row_major(&a, m, k),
MatRef::from_col_major(&b, k, n),
0.0,
MatMut::from_col_major(&mut c, m, n),
Some(Bias::PerRow(&bias)),
Some(Activation::Relu),
Parallelism::Rayon(0),
);
}
The bias, the LeakyRelu slope, and the activation all apply in-register on the vector
fast path. So the fusion costs almost nothing over the raw GEMM.
An arbitrary per-element map
When the transform is not a bias or a standard activation, use
gemm_map. It takes a closure f(value, row, col) -> value.
gemmkit applies that closure to each output element, exactly once, at its final value,
fused into the store. It is the general extension point for an epilogue with no dedicated
fast path in gemmkit: GELU, sigmoid, a clamp, or a position-dependent transform.
#![allow(unused)]
fn main() {
use gemmkit::{gemm_map, MatRef, MatMut, Parallelism};
let f = |v: f32, _r: usize, _c: usize| v.tanh();
gemm_map(
1.0,
MatRef::from_row_major(&a, m, k),
MatRef::from_col_major(&b, k, n),
0.0,
MatMut::from_col_major(&mut c, m, n),
&f,
Parallelism::Rayon(0),
);
}
The (row, col) handed to the closure are in the user frame of C. The closure may
capture its environment by reference. The bound is + Sync, so gemmkit can share that
reference safely across the parallel workers, for example to borrow a lookup table.
gemm_map works for f32/f64 only. It trades one indirect call per output element,
cheap next to the O(k) work per element, for total generality. For a plain bias or
activation, prefer gemm_fused, which vectorizes the transform.
Integer requantization
Quantized inference wants the opposite of a widening GEMM. It takes i8 inputs and
accumulates into i32. It produces an i8 (or u8) output again, applying a scale and a
zero-point on the way down. gemm_i8_requant and
gemm_i8_requant_u8 do the whole thing in one pass. That
deletes the full m*n i32 materialization a separate gemm_i8 call, followed by a
requantize step, would need. Both entries take a Requantize
struct:
#![allow(unused)]
fn main() {
use gemmkit::{gemm_i8_requant_u8, Requantize, RequantScale, MatRef, MatMut, Parallelism};
let req = Requantize {
scale: RequantScale::PerRow(&per_channel_scales), // length m, per-channel
zero_point: 128,
bias: Some(&i32_bias), // optional per-row i32 bias, length m
};
gemm_i8_requant_u8(
MatRef::from_row_major(&activations, m, k),
MatRef::from_col_major(&weights, k, n),
req,
MatMut::from_col_major(&mut out_u8, m, n),
Parallelism::Rayon(0),
);
}
The output is C[i,j] = clamp(zero_point + round_ne(scale * (sum_k A*B + bias[i])), LO, HI), using round-half-to-even. scale is either a single RequantScale::PerTensor(f32)
or a per-row RequantScale::PerRow(&[f32]), the per-channel convention. The entry sets
the clamp band: [-128, 127] for gemm_i8_requant, [0, 255] for the u8 twin. There
is no alpha, since it folds into scale. There is no beta, since accumulating into an
already-quantized C is not well defined. The requantize map is bit-exact across every
ISA (scalar, FMA, AVX-512F, VNNI) and across the vector and scalar store paths. So the
answer never depends on which kernel ran.
Complex bias
Under the complex feature, gemm_cplx_fused adds a per-row or per-col bias to a
complex product: C <- alpha*op(A)*op(B) + beta*C + bias. It takes the same optional
operand conjugation as gemm_cplx. It is bias-only by design. An ordering-based
activation like ReLU has no definition on complex numbers. The conj_a and conj_b
flags conjugate the operands only. gemmkit adds the bias verbatim and never conjugates
it.
What you can rely on
Every fused entry routes each shape through the same kernel plain gemm would pick: the
general driver, or one of the special paths. It fuses the
epilogue into that kernel’s store without changing its accumulation order. So a fused
call is not a different algorithm. It runs the same GEMM and applies the map at store
time. The concrete guarantees:
- For
f32/f64, a fused result is bitwise identical to plaingemmfollowed by the same scalar map. This holds for every shape, every layout, and every worker count.gemm_mapgives the same guarantee for a per-elementf. The complex bias entry gives the same guarantee against a complexgemm_cplxcall followed by the bias add. - For the narrow floats
f16/bf16(featurehalf), there is one documented exception. gemmkit widens the bias and the slope exactly tof32, applies the epilogue inf32, and narrows to the output once, on store, with round-to-nearest-even. That is more precise thangemmfollowed by a separate map, which would round to the narrow type, widen, and round again. So for narrow types, the fused result is deliberately not bitwise-equal to that two-step form. Reproducibility and determinism are unchanged. - Serial and parallel runs agree bit-for-bit today. The identity-fused case (
None/None, or an absent bias) const-folds back to exactly plaingemm. The reproducibility contract covers only a fixed configuration, and the worker count is part of that configuration.
The payoff is the pass over C you no longer make. On a memory-bound epilogue, that
second pass can cost as much as the store itself. So fusing a bias or activation into the
GEMM is close to free, in a case where the two-step form is not.
The fused epilogues also compose with the other API tiers. gemm_batched_fused applies
one shared bias and activation to every element of a batched GEMM.
gemm_packed_b_fused and gemm_packed_a_fused fuse over a
prepacked operand. Every checked entry has raw-pointer
_unchecked twins for adapters and FFI. Those twins carry the bias as a
(ptr, BiasDim) pair instead of the Bias enum. See
The Unchecked Tier.
Batched GEMM
A single large GEMM saturates a modern CPU on its own. A crowd of tiny ones does not.
Attention heads, grouped convolutions, per-sample linear layers, and block-diagonal solves
all produce many small independent products. Running them as a plain loop of gemm calls
leaves most of the machine idle. Each call is too small to parallelize usefully. Yet the
loop still pays a fork/join, or serializes, once per element. The batched entries take the
whole set in one call and schedule it as a unit. They assign whole GEMMs to workers, so a
batch of small matrices actually fills the cores.
Batched GEMM is an orchestration layer, not a new kernel. Each element re-dispatches
through the full single-GEMM engine. So a batch composes automatically with the driver,
the gemv path, and the small-shape paths. A batch of
1 x 1 x k products runs the horizontal dot inside each element. A batch of ordinary
shapes runs the register-blocked driver. Every element is an independent GEMM, and the
whole batch is reproducible across worker counts.
The strided form
The elements might sit at a regular stride, one matrix after another in a flat buffer. In
that case, gemm_batched takes the single-element shape and
strides once, plus a batch stride for each of A, B, and C. Element b sits at
A + b*a_batch_stride, B + b*b_batch_stride, and C + b*c_batch_stride. All elements
share the same shape:
#![allow(unused)]
fn main() {
use gemmkit::{gemm_batched, MatRef, MatMut, Parallelism};
// `batch` independent m x k times k x n products, packed contiguously
gemm_batched(
batch,
1.0,
MatRef::new(&a, m, k, 1, m as isize), (m * k) as isize, // A element + batch stride
MatRef::new(&b, k, n, 1, k as isize), (k * n) as isize, // B element + batch stride
0.0,
MatMut::new(&mut c, m, n, 1, m as isize), (m * n) as isize, // C element + batch stride
Parallelism::Rayon(0),
);
}
A batch stride of 0 broadcasts one operand across the whole batch. This is valid for
the read-only A or B, for example one shared weight matrix against a batch of inputs,
but never for C. Workers write C’s elements concurrently, so those elements must stay
disjoint. The result reproduces a loop of gemm calls exactly.
Under the epilogue feature, gemm_batched_fused applies one shared bias and one shared
activation to every element, the batched-linear-layer case. It reproduces a loop of
gemm_fused calls. gemmkit sizes the single bias vector for one
element, not the whole batch.
The slice form: per-element shapes
When the elements differ in shape, or simply do not sit at a fixed stride, use
gemm_batched_slice. It takes a slice of
BatchProblem, each carrying its own alpha, A, B,
beta, and a distinct &mut C view:
#![allow(unused)]
fn main() {
use gemmkit::{gemm_batched_slice, BatchProblem, MatRef, MatMut, Parallelism};
let mut problems: Vec<BatchProblem<'_, f32>> = /* one per product, each its own shape */;
gemm_batched_slice(&mut problems, Parallelism::Rayon(0));
}
Because every C is a distinct &mut, the outputs are pairwise disjoint and cannot alias
the inputs by construction. So validation only checks per-element shape agreement and
in-bounds strides. Reach for this form when your matrices already live as a Vec of
views. Its raw counterpart, gemm_batched_ptr_unchecked over a slice of GemmProblem,
takes the same per-element shapes as bare pointers. It serves FFI and adapters that
validate their own inputs and may use arbitrary or negative strides.
The Unchecked Tier covers both.
How the batch is scheduled
The interesting decision is how the work spreads across cores. The engine makes that decision once per call, from the shared shape and the batch size. There are 3 schedules:
- Batch-parallel. Workers pull disjoint ranges of elements from a shared cursor. Each element runs serially on one worker, cache-hot. This is the whole point of the API. For many small matrices, it pays a single fork/join for the entire batch instead of one per element. It also keeps every core busy on complete GEMMs. This schedule never splits an element across workers, so it is bit-identical to the serial run at any worker count.
- Serial. The whole batch runs on the calling thread, each element single-threaded. The engine picks this when there is too little total work to justify a fork/join.
- Sequential with internal parallelism. For the few-but-large, memory-bound regime,
the engine loops the batch and hands each element the full engine parallelism in
turn. When an element is big enough to saturate memory bandwidth on its own, spreading
it across all cores wins. Running several elements at once instead just thrashes the
cache. This schedule runs only for
m, n > 1shapes, whose routes already reduce each output within one worker. So it stays reproducible.
The upshot for you is simple: hand the engine the whole batch and let it choose. Many small independent products are exactly where batching wins over a hand-written loop. A hand-written loop cannot make the fork-join-once-for-all-elements choice. It either parallelizes each tiny GEMM, which is mostly overhead, or runs them serially. For a handful of large products, the batched call converges to what a plain loop already does well. So batching neither helps much nor hurts there.
Determinism spans all 3 schedules. Every element is independent, so the whole batch is reproducible across worker counts. The serial and batch-parallel schedules go further: they are bit-identical across worker counts, because each element runs on exactly one worker. The few-but-large schedule inherits whichever serial-versus-parallel behavior its own per-element route has. A zero-length batch is a no-op.
Like the rest of the API, every batched entry has a _with variant that reuses a
caller-owned Workspace, to avoid a per-call allocation. One detail is worth knowing.
Under the batch-parallel schedule, the packing cannot go through a single shared
Workspace, because concurrent workers would collide on it. So that schedule packs
through each worker’s own persistent per-thread pool instead, reused across calls the
same way your Workspace is.
Small Shapes and GEMV
The register-blocking driver at the heart of gemmkit needs enough work per output tile. It must amortize packing, cache blocking, and a full MR x NR accumulator.
Some shapes break that premise outright. A matrix-vector product has no tile reuse at all. A k = 4 contraction finishes before the pack pays for itself. An 8 x 8 x 100000 product would spend most of the driver’s effort multiplying padding. For shapes like these, the driver is the wrong tool, so the engine quietly routes around it.
There is nothing to opt into. The same gemm entry (and gemm_i8, gemm_fused, gemm_map) inspects the shape and strides at the top of dispatch. It reroutes to a special-case kernel when one fits the shape, and it falls through to the general driver otherwise. Every reroute stays behind the same public entry.
Each reroute keeps the library’s reproducibility contract: the same call, on the same machine, with the same configuration, returns the same result. Each reroute is also gated by a tuning knob that you can move or disable without recompiling. You never call these paths directly. You benefit from writing the shape naturally, instead of hand-rolling a dot loop.
gemv: the memory-bound edge
A shape with m == 1 or n == 1 is a matrix-vector product. Each output element needs only 2k floating-point operations, but reading it takes k matrix elements. The arithmetic is trivial, so the whole problem is minimizing DRAM traffic. The dedicated gemv path handles both cases with one core routine. It views the matrix as rows x k times a k-vector, transposing the matrix first when m == 1. This path is correct for every layout, and it vectorizes the contiguous ones.
This also covers the degenerate m == n == 1 dot product. A 1 x k view has both strides equal to 1, so it fits the column-major strategy and the row-major strategy at once. The column-major strategy vectorizes over output rows, but there is only one output row here, too few to fill a SIMD register. So the route sends any sweep shorter than one register to the k-vectorizing strategy instead. There is nothing to opt into and nothing to know. A dot product built from MatRef::from_col_major(a, 1, k), the shape a column-major library hands you, runs on the same fast path as the row-major spelling.
2 properties matter to a caller. First, gemv follows the library’s general reproducibility contract. The same call, on the same machine, with the same configuration, always returns the same result. Each output element is reduced in a single pass over k by one worker.
Splitting the rows across workers changes only which worker does the work, not how the work is done. The library does not extend gemv’s guarantee to bitwise agreement across different worker counts. The worker count is part of the configuration, the same as everywhere else in gemmkit.
Second, gemv has its own parallel policy. It is bandwidth-bound, so the worker count comes from a bandwidth model instead of the compute ramp the general driver uses. Past the few cores that saturate DRAM, more workers stop helping, and only add fork/join overhead and shared-cache contention. The count follows a ladder over the bytes a call touches. Below a floor, the matrix fits one core’s private cache, so the call stays serial. Past that floor, the width climbs in steps, and it never reaches the full machine width.
4 knobs expose this policy:
gemv_parallel_bytessets the byte floor below which gemv stays single-threaded.gemv_tier_stepsets how many bytes apart the ladder’s steps sit.gemv_thread_capsets a flat width that replaces the ladder outright.gemv_thresholdsits alongside the other 3. Since a gemv shape always hasmin(m, n) == 1, this knob works as an on/off switch rather than a graduated cap.
The small-k path
The contraction k can also be too small for packing to pay off. The threshold is small_k_threshold, with a default of 16 on x86 and 8 on aarch64. At or below that depth, the whole product is a single depth panel, and every packed element would be read exactly once. Packing has nothing to amortize, so the driver’s packing step becomes pure overhead.
The small-k route covers these skinny, low-depth shapes: gevv, rank-k updates, and tall-skinny products. It computes C directly with the family’s microkernel, reading A and B in place, unpacked, in one pass. This route inherits the family’s widen, bias, conjugate, and rounding behavior for free, and it is bit-identical to the serial run for any worker count. It needs a column-major A, meaning unit-stride rows (rsa == 1). When A does not have that layout, packing rarely amortizes at such a small k anyway. The route then defers to the general driver, which still computes the correct result.
The small-m,n path
The mirror case is a tiny output with a long contraction. Both m and n sit far below the microtile, at or below small_mn_dim (default 16 on x86, 32 on aarch64), while k is long. The driver would pad the tiny row and column tiles up to a full microtile. It would then spend most of its work on that padding.
This route instead computes each output as a horizontal dot, C[i,j] = alpha * <A[i,:], B[:,j]> + beta * C[i,j]. It streams SIMD along the contraction with no blocking or orientation machinery. It also register-blocks the small output grid, so several independent FMA chains stay in flight.
The horizontal kernel needs both operands unit-stride along k. That means A’s rows must be contiguous (csa == 1, a row-major A) and B’s columns must be contiguous (rsb == 1, a column-major B). When both hold, the route reads A and B in place with zero copies. This is the fast path.
The 2 most common layouts each miss exactly one side. An all-row-major pair fails rsb, and an all-column-major pair fails csa. When that happens, an internal pre-pack step copies only the failing operand once into k-contiguous scratch, then runs the same horizontal dot over it. That copy reads roughly m*k elements, or n*k for the other operand.
That is a fraction of about 1/n (or 1/m) of the m*n*k work in the product itself. It costs far less than what the horizontal route saves, so a strided small-m,n shape still beats falling back to the driver’s padded microtile.
A flop count understates that copy. The copy does no arithmetic per byte it moves. The dots do about 2. The copy therefore has less to hide memory latency behind, and it takes a much larger share of the time than of the work.
The copy runs across workers on its own, apart from the dots. A small m, n leaves a tiny output grid for the dots to split. The copy splits the depth instead, and a deep contraction makes the depth long. You configure nothing. On the reference machine, a column-major small-m,n shape with a deep contraction got 1.1-3.1x faster (f32, auto width). The larger gains land where the packed operand still fits cache.
The pre-pack tier engages once k clears its own knob, small_mn_pack_min_k (default 16), separate from the zero-copy tier’s small_k_threshold. This path is likewise bit-identical to the serial run at any worker count. It computes each output as one fixed-order reduction on a disjoint tile.
Practical guidance
Prefer the ordinary entries over a hand-written dot loop. Your problem might be a matrix-vector product, a rank-k update, or a grid of small outputs over a long contraction. In each case, gemm already carries a kernel tuned for that shape. It comes with the bandwidth-aware threading and the reproducibility guarantees that a hand-written loop would have to reinvent.
Layout is the one lever in your hands. For the horizontal small-m,n path, a row-major A and a column-major B stream unit-stride and hit the zero-copy fast path. For the small-k path, a column-major A stays on the in-place route. Any other layout still works, but it pays either the small-m,n pre-pack copy or a fall-back to the general driver.
Every threshold named in this chapter is a tunable knob. Each one resolves per call from an argument, a programmatic setter, a GEMMKIT_* environment variable, or a calibrated compile-time default, in that order. If a reroute is miscalibrated for your machine, or if you want to force a shape onto the general driver, see the Tuning Knobs chapter. It covers gemv_threshold, small_k_threshold, small_mn_dim, small_mn_pack_min_k, gemv_parallel_bytes, gemv_tier_step, gemv_thread_cap, and k_stream_max in full. For the special-path internals, meaning why each kernel is shaped the way it is, see the architecture chapter’s Special Paths.
Runtime ISA Dispatch
gemmkit ships one engine. It picks the instruction set it runs on when your program
starts, not when you compile it. Take a build made on a laptop and copied to a
server. That binary uses the server’s AVX-512 if the server has it. The same binary,
run on an older machine, quietly falls back to a narrower kernel. You do not select a
backend, gate on a cfg, or rebuild per host. The first GEMM call detects the CPU’s
features and caches the winning kernel. Every later call is then a plain indirect
call through that cached pointer.
The backend roster
The set of kernels a build carries depends on the target architecture. Which one runs depends on the CPU. From fastest to slowest, the candidates are:
- AVX-512F on x86-64, the widest float kernel. 2 dot-product specializations
sit alongside it for the narrow element types: AVX-512 VNNI (
vpdpbusd) fori8 -> i32, and AVX-512 BF16 (vdpbf16ps) forbf16. These require theint8andhalffeatures respectively, and the CPU must report the matching feature bit. - FMA / AVX2 on x86-64, the widen-FMA kernel for machines without AVX-512.
- NEON on aarch64, where SIMD is baseline (every aarch64 CPU has it), so there is nothing to detect at runtime.
- simd128 on wasm32, chosen at compile time rather than runtime (see below).
- scalar, the portable floor. It exists on every target and runs when nothing better is available. A correct, if unaccelerated, result is always reachable.
Tile geometry is the one thing that changes per (element type, ISA) pair. The
microkernel computes an MR x NR register tile, sized to the ISA’s vector width. For
f32 the shipped tiles are:
| ISA | f32 tile (MR x NR) |
|---|---|
| AVX-512F | 32 x 12 |
| FMA / AVX2 | 16 x 6 |
| NEON | 16 x 4 |
| simd128 | 8 x 4 |
| scalar | 4 x 4 |
All 5 run the same generic float microkernel. Only the tile shape differs. MR
is MR_REG * LANES, so a wider vector buys a taller tile. f64 halves the lane
count and therefore halves MR (AVX-512F f64 is 16 x 12, and so on). The VNNI
and BF16 dot kernels use their own depth-grouped geometry, covered under
Element Types. This table is background for reasoning about why
a kernel packs and blocks the way it does. It is not a knob you set.
Automatic selection
Each element type owns a single dispatch slot: a OnceLock holding a typed function
pointer. On the first call for that type, the selection ladder runs feature
detection once and picks the best available kernel. It then stores that kernel’s
monomorphized entry points (plain, prepacked, fused), plus the tile geometry.
Whichever call happens first pays this one-time cost: the is_x86_feature_detected!
probe and the OnceLock initialization. From then on, dispatch is a cached pointer
load and an indirect call, with no per-call branching on the ISA. There is no
transmute and no atomic pointer juggling behind this, just a typed slot per type.
A consequence worth stating plainly: there is no public API that reports which ISA was selected. The choice is an internal detail of the memoized slot. If you need to be certain a specific kernel is live, do not try to read it back. Pin it instead (next section), and let a mismatch fail loudly.
Pinning a kernel with GEMMKIT_REQUIRE_ISA
Setting the environment variable GEMMKIT_REQUIRE_ISA forces exactly one kernel end
to end, instead of auto-selecting. The accepted values (case-insensitive,
surrounding whitespace trimmed) are:
| Value | Forces | Also accepts |
|---|---|---|
scalar | the portable scalar kernel | |
fma | the FMA / AVX2 widen kernel | avx2 |
avx512f | the AVX-512F widen kernel | |
avx512vnni | the i8 vpdpbusd dot kernel (plain AVX-512F for other types) | vnni |
avx512bf16 | the bf16 vdpbf16ps dot kernel (plain AVX-512F for other types) | bf16 |
neon | the aarch64 NEON kernel | |
simd128 | the wasm32 simd128 kernel | wasm |
auto | normal auto-selection (also the default when unset or empty) |
The avx512vnni and avx512bf16 pins select the dot kernel for their one narrow
type. Everything else runs the plain AVX-512F path, so a mixed workload under one of
these pins still runs correctly for its other types.
The contract is panic, not fallback. Dispatch panics, rather than silently running a different kernel, whenever the requested ISA is unavailable. 3 cases count as unavailable:
- the CPU does not report the feature
- the value names an ISA that does not exist on this target architecture (
neonon x86,avx512fon aarch64) - the value is an outright typo
This is deliberate, and it is exactly what you want for CI. A job whose whole
purpose is to exercise the AVX-512 VNNI path must not pass by quietly testing the
scalar fallback instead. That could happen if a feature flag was misspelled, or if
an emulator was misconfigured. gemmkit’s own CI pins each kernel this way: it runs the
x86 dot kernels under Intel SDE, NEON on aarch64, and simd128 on wasm. A broken pin
then turns into a red build, instead of a false green.
The value is read once, before the first dispatch, and memoized alongside the
kernel choice. Set it in the process environment before any GEMM runs. Changing it
mid-process has no effect, because the slot is already populated. An unrecognized
value is a hard error, precisely so it cannot be mistaken for auto and slip
through.
WebAssembly is compile-time
wasm32 has no runtime feature detection, so simd128 is not chosen by probing the
machine. It is selected by a compile-time cfg, and the build must actually enable
it with -C target-feature=+simd128. Forget the flag, and the wasm build silently
uses the scalar floor. Pinning GEMMKIT_REQUIRE_ISA=simd128 turns that silent
degradation into an assertion: the build panics if the SIMD path is not live. This
is why the wasm CI jobs pin it. See
no_std and WebAssembly for the full wasm build story,
including the threaded target.
Tuning Knobs
Every heuristic in gemmkit is a named threshold with a shipped default, not a hard-coded constant. This covers questions like when to go parallel, when to pack an operand, and where a shape stops being small. A handful of knobs are split by architecture, where aarch64 needs a different value from the rest. The defaults are good on most hardware. When one is not right for your machine, you can reach it 3 ways without touching the source.
Resolution order
A knob resolves at the point it is read, taking the first of these that is set:
- Per-call argument. Where a knob has a call-site equivalent, that wins
outright. The clearest case is parallelism: the
Parallelismargument you pass togemmoverrides any global thread policy. This layer lives in the API, not intuning. - Programmatic setter.
gemmkit::tuning::set_*(v)stores a value unconditionally. Once set, later reads never consult the environment again. This is for an application that tunes itself in code. It should win over whatever the deployment environment supplies. - Environment variable.
GEMMKIT_*. This is the deployment layer.sourcea profile, for instance one emitted by gemmkit-tune, to retune an already-built binary for a host with no recompile. - Compiled default. The calibrated constant, arch-split where it needed to be.
The ordering of setter over env is deliberate. An app that calls the setters has opted out of the environment. An app that wants a deployment profile to apply simply does not call them.
Environment variables are read once, on the first access to that knob, then
cached as an atomic. A value set after the first read of a given knob is ignored,
so export the profile before the process starts. A GEMMKIT_* var that is set but
does not parse as a non-negative integer is treated as a typo, not a silent no-op.
gemmkit warns on stderr and falls back to the default. The warning fires once per
knob, since the fallback is then cached. It never panics: a perf-knob typo must not
crash the process.
The knobs
The table below is the full catalog, across every feature and target configuration.
The internal tuning::knob_env_names registry is the source of truth these are drawn
from. 2 knobs are feature- or target-gated and only exist when compiled in. Every
getter has a matching set_*. The env var name is the getter’s name, upper-cased,
with the GEMMKIT_ prefix.
Serial / parallel gate
| Env var | Setter | Default | Controls |
|---|---|---|---|
GEMMKIT_PARALLEL_THRESHOLD | set_parallel_threshold | 4848256 | Below this m*n*k, work is forced onto a single thread. This is the serial-to-parallel break-even. Raise it if your thread pool is expensive to fork. Lower it if you have cheap threads and small products worth splitting. |
Pack gates and strides
| Env var | Setter | Default | Controls |
|---|---|---|---|
GEMMKIT_RHS_PACK_THRESHOLD | set_rhs_pack_threshold | 2048 | Pack the RHS macro-panel only when m (how many row blocks reuse it) exceeds this. Below it, B is read in place. |
GEMMKIT_LHS_PACK_THRESHOLD | set_lhs_pack_threshold | 1024 (aarch64: 256) | Pack the LHS only when per-worker column reuse exceeds this. Packing is cheaper on aarch64, so it pays from lower reuse there. |
GEMMKIT_LHS_PACK_STRIDE | set_lhs_pack_stride | 0 (auto) | Byte gate on the column-major depth stride csa * sizeof(Lhs). Once the stride reaches this many bytes, A is packed to dodge a TLB- and cache-hostile strided read, independent of reuse. 0 derives it from the OS page size. This gate is ANDed with the span and reuse gates below, so stride, span, and reuse must all hold before the force-pack fires. |
GEMMKIT_LHS_PACK_SPAN | set_lhs_pack_span | 0 (auto) | Address-span companion to the stride gate above. The page-scale stride only force-packs a column-major A when the whole depth-slice walk (csa * sizeof(Lhs) * kc) also reaches this many bytes. Below that span, the walk stays cache-resident and re-reads warm lines, so it is faster in place than the pack it would cost. 0 means auto (4 MiB). |
GEMMKIT_LHS_PACK_REUSE | set_lhs_pack_reuse | 128 (aarch64: 4) | Reuse floor that prices the force-pack’s benefit, not its cost. The stride and span gates above only fire above a reuse floor. That floor is measured in nr-wide column tiles reusing each packed panel (min(n, nc) / nr, rounded up). A tall, skinny shape (m much greater than n) has a huge span but few column tiles. It would amortize an expensive pack over too little reuse, so this floor holds it back. 0 drops the floor and lets the stride+span pair decide alone. On aarch64 the tradeoff nearly inverts: packing is cheap there, and the in-place walk strides small pages. So the aarch64 default packs from far lower reuse than the x86 one. |
GEMMKIT_SHARED_LHS_MNK | set_shared_lhs_mnk | 8e9 (aarch64: 6e6, 32-bit: disabled) | m*n*k gate for the shared-A pre-pass on the parallel packed path, which removes redundant per-worker packs at the cost of a fork-join barrier. The crossover trades the barrier’s cost against the packing it saves. Packing costs relatively less on aarch64, so its gate sits far below the x86 one. Independent of this gate, the pre-pass also opens from 16 workers up, where the per-worker redundancy always outweighs the barrier. |
GEMMKIT_PACK_TRANSPOSE_TILE | set_pack_transpose_tile | 16 | Strip length for the cache-blocked transpose used when a packed operand is strided, turning a per-element gather into blocked copies. Backs both the real and complex packers. |
Special-path thresholds
| Env var | Setter | Default | Controls |
|---|---|---|---|
GEMMKIT_GEMV_THRESHOLD | set_gemv_threshold | unbounded | Caps min(m, n) for the dedicated gemv path when the other dimension is 1. Shape, not size, triggers gemv. This knob only bounds it. |
GEMMKIT_SMALL_K_THRESHOLD | set_small_k_threshold | 16 (aarch64: 8) | At or below this k, a shape takes the generic small-k route (one depth panel, no packing) instead of the register-tiling driver. |
GEMMKIT_SMALL_MN_DIM | set_small_mn_dim | 16 (aarch64: 32) | Both m and n at or below this (with a long k) take the horizontal inner-product route, where each output is one SIMD-reduced dot. 0 disables the route. The register-tiling driver instead pads small row and column tiles up to a full microtile. It spends much of its work on that padding. The point where the driver starts to win differs by machine, which is why the aarch64 cap sits above the x86 one. |
GEMMKIT_SMALL_MN_PACK_MIN_K | set_small_mn_pack_min_k | 16 | The k gate for the small-m,n pack tier: a strided small shape copies the failing operand into k-contiguous scratch only above this k. |
GEMMKIT_GEMV_PARALLEL_BYTES | set_gemv_parallel_bytes | 0 (auto) | Byte floor below which a bandwidth-bound gemv/gevv stays single-threaded. Below it, the matrix fits one core’s private cache, which that core saturates alone, so splitting only loses. 0 derives it from the detected cache. On a part with an L3, that is the per-core private L2. On an L3-less aarch64 part, it is an eighth of the shared cluster L2. |
GEMMKIT_GEMV_TIER_STEP | set_gemv_tier_step | 0 (auto) | Byte spacing between the rungs of the auto gemv/gevv worker ladder. The width climbs one exact-fit pool tier per factor of this in bytes touched, starting from the byte floor above. 0 means 8. 1 collapses the ladder onto its top tier. This knob has no effect with fewer than 2 pool tiers active, so it does nothing under the single-tier aarch64 default. |
GEMMKIT_GEMV_AXPY_PAR_MIN_ROWS | set_gemv_axpy_par_min_rows | 16384 (x86), 1024 (aarch64) | Output-row floor below which a column-major gemv stays serial instead of splitting its rows. For a column-major matrix, the output-row axis is the inner memory axis. A split then gives every worker a strided walk over the whole matrix, while the serial route makes one sequential pass. Only once each worker’s run is long enough does splitting pay for the sequentiality it gives up. 0 disables the floor. The 2 defaults differ by an order of magnitude because the crossover sits much lower on aarch64. On that target the crossover also follows the bytes in one column rather than the row count, so an f64-dominated workload there wants half the default. A row-major gemv and the half mixed twin are never gated: both scale well when split, at every size. |
GEMMKIT_GEMV_THREAD_CAP | set_gemv_thread_cap | 0 (auto) | A flat worker count for a bandwidth-bound gemv/gevv, replacing the ladder above outright. Use it to pin an exact width for a known machine. 0 keeps the ladder, which tops out at half the logical cores, since a gemv saturates its bandwidth far below the full width. |
GEMMKIT_K_STREAM_MAX | set_k_stream_max | 32 | The k ceiling below which an axpy-shape gemv holds its output panel in registers across the whole depth sweep. Above it, the plain column-outer form wins. |
GEMMKIT_SEQ_INTERNAL_BYTES_PER_WORKER | set_seq_internal_bytes_per_worker | 128 KiB | aarch64 batched-GEMM crossover: a batch element splits across the machine rather than running one-per-worker cache-hot once its per-batch-worker byte share exceeds this. Only consulted on aarch64. |
GEMMKIT_I8_VNNI_MIN_PAR_MNK | set_i8_vnni_min_par_mnk | 768^3 | Below this m*n*k, an auto-selected VNNI i8 kernel hands a multi-threaded problem to the widen fallback instead. VNNI’s mandatory RHS-pack barrier does not pay on a small parallel problem. Bit-identical to VNNI. Requires the int8 feature. |
Scheduler grains
| Env var | Setter | Default | Controls |
|---|---|---|---|
GEMMKIT_PARALLEL_OVERSAMPLE | set_parallel_oversample | 8 | The parallel driver aims for this many work chunks per worker, drained from a shared cursor on demand. Higher gives finer load balance with a smaller tail, at the cost of more atomic claims. Lower is coarser with less overhead. |
GEMMKIT_PAR_MNK_PER_WORKER | set_par_mnk_per_worker | 2000000 (threaded wasm: 262144) | Auto worker-count granularity. The auto path targets m*n*k divided by this much work per worker, then caps the result by cores and jobs, floored at 1. The count then scales with total flops rather than linear size. A wasm worker costs far less to engage than a native thread, hence the lower wasm floor. 0 behaves as 1 (always full width). |
GEMMKIT_PACKED_OVERSAMPLE | set_packed_oversample | 2 | The packed-LHS path’s split target, distinct from the general grain above. Splitting harder re-packs A too often and regresses, so this optimum is lower. |
GEMMKIT_POOL_CLASSES | set_pool_classes | 2 (aarch64: 1, elsewhere: 0) | Number of halving tiers below full machine width: half, then quarter. For each active tier, gemmkit keeps a private, persistent rayon pool, built lazily on first use and never rebuilt. The auto path snaps its worker count exactly to a tier so no thread sits idle at the fork/join barrier. An explicit Rayon(n) still gets exactly n workers and merely runs in the smallest tier pool that fits. 0 disables tier pools, leaving every call on the ambient pool. Clamped to 3. Defaults to 2 tiers on x86_64, 1 on aarch64, and 0 (off) on every other target. |
GEMMKIT_FULL_WIDTH_MNK | set_full_width_mnk | 0 (auto) | The m*n*k above which the auto path leaves its largest tier pool for full machine width. Below it, auto stays on its largest tier even though more cores exist. The extra full-width workers would not yet pay for the added fork/join cost. 0 derives it per architecture: 110_000_000 on x86, 14_000_000 on aarch64, where full width, including the E-cores, pays off at a smaller problem size. MAX pins the auto path to the largest tier unconditionally, so full width never engages. |
Blocking caps
| Env var | Setter | Default | Controls |
|---|---|---|---|
GEMMKIT_MC_REG_PANELS | set_mc_reg_panels | 8 | The A macro-panel is bounded to this many microtile rows (this * MR), following BLIS’s rule that MC stays a small multiple of MR. |
GEMMKIT_NC_NO_L3_PANELS | set_nc_no_l3_panels | 512 | The no-L3 column block (Apple Silicon and the like) is min(this * NR, N). Dead where an L3 exists. |
GEMMKIT_TINY_BLOCK_DIM | set_tiny_block_dim | 64 | A shape with both m and n at or below this skips the full BLIS blocking model and just keeps A/B panels in L2. |
GEMMKIT_KC | set_kc | 2048 (aarch64: 16384) | The depth block in the tiny-matrix shortcut: k clamped to this. The count is in 4-byte elements, and a narrower element divides it, so the packed panel bytes hold. On x86 a wider element divides it too. On aarch64 a wider element keeps the whole depth, because dividing it there multiplies the slice count, and each slice costs another worker fork. Deeper slices also keep winning further on aarch64 than on x86, so the aarch64 shortcut runs close to a single slice. |
GEMMKIT_KC_MIN | set_kc_min | 512 | The main-model kc floor: the L1-fit depth estimate is raised to at least this, so a small L1 never starves the microkernel’s depth walk. |
Deep-contraction and wasm
| Env var | Setter | Default | Controls |
|---|---|---|---|
GEMMKIT_DEEP_KC_BYTES | set_deep_kc_bytes | 0 (auto) | The engage gate, in bytes, for the deep-contraction path. A narrow-output family (f16/bf16) normally runs the whole contraction as a single depth panel. It switches to an f32-output, multi-slice twin once its RHS micropanel (nr * k * sizeof(N)) outgrows this. 0 derives it from half the detected L2. |
GEMMKIT_PREFETCH_MIN_BYTES | set_prefetch_min_bytes | 0 (auto) | The engage gate, in bytes, for the driver’s C-tile software prefetch. Once a call’s working set (A + B + C bytes) exceeds this, the output microtiles stream from beyond the LLC. The driver then issues a T0 prefetch of each microtile just ahead of its microkernel call, hiding the read-modify-write latency. Below it, the tiles are cache-resident and the hint would be pure overhead. 0 derives it from the per-core-reachable LLC (L3 where present, else L2). A non-zero value is the threshold verbatim, so usize::MAX disables the prefetch and 1 forces it on. This knob is x86_64-only, a no-op on other targets, so aarch64 and wasm are unchanged. It is also numerics-invisible: bit-identical on or off. |
GEMMKIT_WASM_THREADS | set_wasm_threads | 8 | The worker count for a threaded wasm build, since wasm has no available_parallelism to query. Sizes gemmkit’s wasm rayon pool. Only exists on wasm32 with the wasm_threads feature. |
A note on GEMMKIT_FAST_TEST
You may see GEMMKIT_FAST_TEST in the test harness. It shrinks the correctness
sweeps to run faster, and it is a test-suite-only switch. The library itself
never reads it, and setting it has no effect on a production GEMM.
Beyond hand-tuning
Setting knobs by hand is for when you already know which one to move. To calibrate
the whole set for a specific machine, run the autotuner. It sweeps each knob over a
probe-shape set and writes a GEMMKIT_* profile you source before running, with no
recompile. That is the subject of the
gemmkit-tune chapter.
no_std and WebAssembly
gemmkit’s core does not need an operating system. Turn the default features off, and the crate becomes #![no_std]. It then needs only core and alloc, and depends on nothing else. This makes it usable in kernels, embedded firmware, and WebAssembly. The same code path is also how the wasm SIMD backend gets built. This page covers what a no_std build gives up, what it keeps, and the extra steps a wasm target needs.
The no_std core
The std feature is on by default, as part of default = ["std", "parallel"]. Switch the defaults off, and you get the alloc-only path:
[dependencies]
gemmkit = { version = "0.1", default-features = false }
alloc is always required, because packing scratch is heap-backed in both builds. Beyond that, the crate pulls in nothing at all in this configuration. Each optional feature adds at most one dependency:
stdpullsraw-cpuid, on x86 only, for CPUID cache and feature detection.parallelpullsrayon.halfpullshalf.complexpullsnum-complex.
The int8 and epilogue features add no dependency at all. The element-type features compose freely with no_std. A default-features = false, features = ["half", "int8"] build, for example, is a valid, dependency-free f16/bf16/i8 engine.
Note that parallel implies std, because rayon needs the standard library. So a no_std build is always single-threaded. Everything still compiles and runs. It just runs on one thread.
What changes without std
3 things move from runtime to compile time, or from automatic to explicit:
Feature detection becomes compile-time. With std, x86 dispatch calls is_x86_feature_detected!. It picks the best kernel the running CPU reports. Without std, there is no runtime CPU detection. That capability lives in the std-gated raw-cpuid. So the ISA ladder falls back to cfg!(target_feature = ...) instead, and the build runs whatever its compile-time target features guarantee.
To get an accelerated x86 kernel from a no_std build, you must compile for it. For example, pass -C target-cpu=native or an explicit -C target-feature=+avx512f. Otherwise you get the scalar floor. On aarch64 and on wasm, this is already how selection works, so nothing is lost there.
The env knobs are off. Reading an environment variable needs std. Without it, GEMMKIT_REQUIRE_ISA is never consulted (dispatch always auto-selects), and every GEMMKIT_* tuning knob resolves straight to its compiled default. The programmatic tuning::set_* setters still work, so you retune a no_std build in code rather than through the environment. See Tuning Knobs for the setter layer.
A per-call workspace replaces the pool. The default thread-local packing pool is a std construct. Without std, there is no pool. Each call instead allocates a fresh Workspace for its scratch, and frees it on return. That is correct, but it allocates on every call. To reach a zero-allocation steady state, create a Workspace once, and thread it through the *_with entries: gemm_with, and the _with variant of every family. After the first sufficiently large call, those entries reuse the buffer with no further heap traffic.
Building for WebAssembly
wasm32 has no runtime feature detection, so the simd128 backend is selected by a compile-time cfg. The build must enable that target feature explicitly. If you forget it, the wasm build still compiles and runs correctly, but only on the scalar floor, which is much slower. Pass the flag through RUSTFLAGS:
RUSTFLAGS="-C target-feature=+simd128" \
cargo build --target wasm32-wasip1 --no-default-features --features std
To run the result, you need a wasm runtime. gemmkit’s CI uses wasmtime, so point Cargo’s target runner at it. Sometimes you want to be certain the SIMD path is actually live, rather than silently falling back to scalar. Pin the ISA for that: GEMMKIT_REQUIRE_ISA=simd128 turns a missing +simd128 into a panic, instead of a quiet fallback. That is exactly what a test job wants. This pin needs std, which is already on in the wasm builds.
RUSTFLAGS="-C target-feature=+simd128" \
CARGO_TARGET_WASM32_WASIP1_RUNNER="wasmtime --env GEMMKIT_REQUIRE_ISA=simd128" \
cargo test --target wasm32-wasip1 --no-default-features --features std
Baseline wasm32-wasip1 has no threads. If you build with parallel on for a baseline wasm target, gemmkit does not trap. An internal guard makes rayon unusable there, so Parallelism::Rayon(_) degrades to the serial loop instead. A portable wasm binary can therefore carry the parallel feature and simply run single-threaded, with no target-specific build.
Threaded wasm
Real multithreading on wasm needs the threads-capable target and the matching feature:
RUSTFLAGS="-C target-feature=+simd128" \
CARGO_TARGET_WASM32_WASIP1_THREADS_RUNNER="wasmtime -W threads=y -W shared-memory=y -S threads=y" \
cargo test --target wasm32-wasip1-threads \
--no-default-features --features std,parallel,wasm_threads
The wasm_threads feature implies parallel, and it targets wasm32-wasip1-threads. It turns on gemmkit’s dedicated wasm rayon pool. Because a wasm runtime cannot report a core count, the pool’s width is not auto-derived. It comes from the GEMMKIT_WASM_THREADS knob instead, default 8, which both caps the auto worker count and sizes the pool. Set it to match the number of workers your runtime actually provisions. Everything else behaves exactly as on a native threaded build: blocking, the job list, and reproducibility.
The Unchecked Tier
The safe entries (gemm, gemm_fused, and the rest) validate their inputs before touching memory. Shapes must agree. Every strided view must stay inside its slice. The output must address each element once, and it must not overlap the inputs.
Under every one of those checks sits the same engine. It is reached through a raw-pointer, isize-stride interface with no checks at all. That is the unchecked tier. It exists for callers who already hold the invariants that the safe API would otherwise re-derive.
Who it is for
3 kinds of caller live here. Adapters over other matrix libraries, such as ndarray, nalgebra, and faer, already have a validated pointer and strides straight out of the host type. Re-checking bounds would be redundant work on data the library already guarantees. FFI callers arriving from C or another language have a pointer and strides, and no Rust slice to bound-check against. Custom matrix types that a codebase owns can lower to pointers and call the engine directly, instead of copying into a MatRef. In each case, the caller is the party that knows the memory is valid, so the checks move to where that knowledge lives.
If none of that describes you, use the safe API. The unchecked tier is not faster for a single call. The validation cost is cheap relative to the multiply itself. It exists to let a caller that already owns the invariants avoid proving them again.
The catalog
Every safe entry has a raw twin, named by appending _unchecked, and most also offer a _with form that takes a caller-owned workspace (next section). The full raw surface, by family:
| Family | Raw entries | Feature |
|---|---|---|
| Plain GEMM | gemm_unchecked, gemm_unchecked_with | core (f32/f64, plus f16/bf16 under half) |
| Complex | gemm_cplx_unchecked, gemm_cplx_unchecked_with | complex |
| Integer | gemm_i8_unchecked, gemm_i8_unchecked_with | int8 |
| Fused bias/activation | gemm_fused_unchecked, gemm_fused_unchecked_with | epilogue |
| Map (per-element closure) | gemm_map_unchecked, gemm_map_unchecked_with | epilogue |
| Complex fused | gemm_cplx_fused_unchecked, gemm_cplx_fused_unchecked_with | complex + epilogue |
| Requantize | gemm_i8_requant_unchecked, gemm_i8_requant_u8_unchecked (+ _with) | int8 + epilogue |
| Strided batched | gemm_batched_unchecked, gemm_batched_unchecked_with | core |
| Pointer-array batched | gemm_batched_ptr_unchecked | core |
| Batched fused | gemm_batched_fused_unchecked, gemm_batched_fused_unchecked_with | epilogue |
| Prepack | prepack_rhs_unchecked, prepack_lhs_unchecked, prepack_rhs_i8_unchecked | core / int8 |
| Consume prepacked | gemm_packed_a_unchecked, gemm_packed_b_unchecked (+ _with, _fused_) | core / epilogue |
| Consume prepacked (i8) | gemm_i8_packed_b_unchecked (+ _with) | int8 |
The pointer-array batched form is worth calling out. gemm_batched_ptr_unchecked takes a slice of GemmProblem<T>, each with its own shape and its own pointers. So a batch can mix sizes, and it can scatter its operands anywhere in memory. It has no safe counterpart of the same shape. Expressing “an array of independent raw problems” is precisely what the raw tier is for. The nalgebra and faer adapters build their batched GEMM on top of it.
The safety contract
Calling into the unchecked tier means signing, per call, for what the safe API would otherwise check:
- Valid pointers and strides. For every
(i, j)implied by the dimensions and strides,aandbare valid for reads andcis valid for read and write. Nothing bounds-checks this. An out-of-range stride is undefined behavior, not a panic. - A uniquely-addressed output.
C’s strides must map every distinct(i, j)to a distinct location. The parallel driver assumes output tiles are disjoint, and it writes them concurrently. A self-aliasingC(for examplersc == 0) would then be a data race. The inputs may alias themselves freely, since they are only read. So a broadcast, zero-stride,AorBis fine. - No overlap between
CandA/B. The output is written. If it overlapped an input, the result would be garbage.
One relaxation comes with the territory. When beta == 0, the output is not read, so C need not be initialized. One capability the safe API withholds is available here too: negative strides, and pointers into the middle of a buffer, are both allowed. A reversed view (rs < 0), or an operand addressed backward from its last element, is exactly the kind of layout the safe MatRef refuses. The raw engine accepts it instead. This is why adapters over libraries that produce reversed strides forward to this tier.
Reusing a workspace
Each raw entry comes in 2 allocation flavors. The plain form, gemm_unchecked, borrows the thread-local packing pool. It allocates at most once per thread. The _with form, gemm_unchecked_with, takes a &mut Workspace that you own instead:
#![allow(unused)]
fn main() {
use gemmkit::{Workspace, Parallelism};
let mut ws = Workspace::new();
// each iteration reuses `ws`; after the first large call it does no heap work
for _ in 0..iters {
// SAFETY: pointers/strides valid, c uniquely addressed, c disjoint from a/b
unsafe {
gemmkit::gemm_unchecked_with(
&mut ws, m, k, n,
1.0_f32, a, rsa, csa, b, rsb, csb, 0.0_f32, c, rsc, csc,
Parallelism::Serial,
);
}
}
}
The workspace grows to fit the largest problem it has served. It reuses that allocation thereafter, so a hot loop of GEMMs reaches zero steady-state allocation. This is the mechanism that no_std builds rely on for reuse, since they have no thread-local pool. It is equally useful under std, for real-time or latency-sensitive loops where you want allocation off the hot path.
A worked example: a custom tile type
Suppose your code already carries its own dense row-major matrix, and you want to multiply two of them without copying into a MatRef:
#![allow(unused)]
fn main() {
use gemmkit::{gemm_unchecked, Parallelism};
// a dense row-major matrix the caller already owns
struct Tile {
data: Vec<f32>,
rows: usize,
cols: usize,
}
// c = a * b for row-major tiles
fn matmul(a: &Tile, b: &Tile, c: &mut Tile) {
assert_eq!(a.cols, b.rows);
assert_eq!(a.rows, c.rows);
assert_eq!(b.cols, c.cols);
// row-major: row stride = cols, column stride = 1
// SAFETY: shapes checked above; each tile owns a dense rows*cols buffer, so
// every addressed element is in bounds; c is a distinct &mut, so it cannot
// alias a or b, and a dense layout addresses each (i, j) once
unsafe {
gemm_unchecked(
a.rows, a.cols, b.cols,
1.0_f32,
a.data.as_ptr(), a.cols as isize, 1,
b.data.as_ptr(), b.cols as isize, 1,
0.0_f32,
c.data.as_mut_ptr(), c.cols as isize, 1,
Parallelism::Serial,
);
}
}
}
The assert_eq! shape checks and the &mut Tile borrow together discharge the whole contract. Dense storage makes every offset land in bounds, and it makes every (i, j) distinct. The exclusive borrow of c then rules out overlap with a or b. This is the pattern to reach for: prove the invariants at the boundary of your own type, then hand raw pointers to the engine.
The adapters are the reference
The cleanest examples of doing this well are the adapter crates themselves. Each one pulls a pointer and strides out of a native view: C-order, F-order, general strides, or reversed strides, all with no copies. Each then forwards to the *_unchecked engine, with a short safety argument at each call site. If you are wrapping a matrix type of your own, read one of the adapter chapters and mirror its structure. The nalgebra chapter is a good place to start. For the prepacked entries in the catalog above, the fixed-weight reuse pattern they serve is covered in Prepacked Operands.
Using gemmkit with ndarray
gemmkit-ndarray is a thin bridge between ndarray’s two-dimensional arrays and the
gemmkit engine. It does no numerical work of its own. Each entry takes an ArrayBase.
It reads the base pointer and the 2 axis strides straight out of it, and hands those
raw parts to gemmkit’s unchecked engine. The whole crate is a stride-plumbing layer.
Everything gemmkit knows how to do, such as runtime ISA selection, cache blocking, and
reproducible parallelism, applies unchanged. The arrays never get reshaped or copied on
the way in.
The entries accept &ArrayBase<S, Ix2> for any storage S: Data. Both an owned
&Array2<T> and a borrowed ArrayView2<T> work, along with ArcArray, CowArray, and
slices of any of them. The only internal helper is worth seeing. It is the whole of the
adapter’s data extraction:
gemmkit-ndarray/src/common.rs:
#![allow(unused)]
fn main() {
pub(crate) fn dims_strides<T, S: Data<Elem = T>>(
a: &ArrayBase<S, Ix2>,
) -> (usize, usize, isize, isize) {
let (r, c) = a.dim();
let s = a.strides();
(r, c, s[0], s[1])
}
}
That (rows, cols, row_stride, col_stride) tuple, plus a.as_ptr(), is everything
gemmkit needs. The strides are signed isize, so a negative (reversed) stride forwards
just like a positive one.
Adding it to a project
2 crates, not 3. The adapter re-exports everything its own signatures name, so a direct
gemmkit dependency is not part of the normal setup:
[dependencies]
gemmkit-ndarray = "0.1"
ndarray = "0.17.1"
gemmkit_ndarray re-exports everything a caller needs, so a direct gemmkit dependency
is rarely necessary:
- The
Parallelismselector and theWorkspacetype every_withvariant takes. - The fused selectors
BiasandActivation. - The prepacked handles
PackedLhsandPackedRhs. - The requantization parameters
RequantizeandRequantScale. - The element-type bounds
GemmScalar,FusedScalar,MapScalar, andComplexScalar. Name these when you write a wrapper generic over an entry. - The element types
f16,bf16,Complex,c32, andc64, each under its own feature. This keepshalfandnum-complexout of your manifest too. - The
tuningmodule.
Reach for tuning through the adapter. Do not add a separate gemmkit dependency of
your own for it. The tuning knobs are process-global atomics. A second, separately
resolved gemmkit would give you a set of atomics the adapter never reads.
Every feature on gemmkit-ndarray forwards directly to the same-named feature on
gemmkit. Turn on a capability here, and the matching entry points light up:
parallel(default): rayon multithreading.wasm_threads: threading onwasm32-wasip1-threads. Impliesparallel.half:f16/bf16inputs withf32accumulation.complex:Complex<f32>/Complex<f64>matrices.int8:i8inputs accumulating intoi32.epilogue: fused bias / activation,i8/u8requantization, and a user per-element map.
The default is ["parallel"]. The advanced page
covers the feature-gated families, such as gemm_cplx, gemm_i8, and gemm_fused.
The minimum ndarray version is 0.17.1.
The core entries
3 functions cover the plain real path. dot is the convenience entry. It multiplies
A * B into a freshly allocated row-major Array2, the way ndarray’s own .dot()
reads.
#![allow(unused)]
fn main() {
use ndarray::array;
let a = array![[1.0_f32, 2.0], [3.0, 4.0]];
let b = array![[5.0_f32, 6.0], [7.0, 8.0]];
let c = gemmkit_ndarray::dot(&a, &b);
assert_eq!(c, array![[19.0, 22.0], [43.0, 50.0]]);
}
dot is generic over T: GemmScalar, which is f32 and f64 unconditionally, plus
f16 and bf16 when the half feature is on. It parallelizes with
Parallelism::default() and allocates its own output. Use it for a one-off product
where you do not already own the destination.
gemm writes the general form C <- alpha*A*B + beta*C in place. This is where
alpha, beta, an existing accumulator, and an explicit Parallelism come in. Its
signature is:
#![allow(unused)]
fn main() {
pub fn gemm<T, S1, S2, SC>(
alpha: T,
a: &ArrayBase<S1, Ix2>,
b: &ArrayBase<S2, Ix2>,
beta: T,
c: &mut ArrayBase<SC, Ix2>,
par: Parallelism,
)
where
T: GemmScalar,
S1: Data<Elem = T>,
S2: Data<Elem = T>,
SC: DataMut<Elem = T>;
}
The output binds SC: DataMut, so C is a &mut Array2 or an ArrayViewMut2 and, like
the inputs, may carry any layout. Here A is a row-major buffer transposed into a
column-major view with no copy. The multiply runs single-threaded:
#![allow(unused)]
fn main() {
use gemmkit_ndarray::Parallelism;
use ndarray::{Array2, array};
// row-major storage, transposed into a column-major view with no copy
let a = Array2::from_shape_vec((2, 2), vec![1.0_f32, 2.0, 3.0, 4.0])
.unwrap()
.reversed_axes();
let b = Array2::from_elem((2, 2), 1.0_f32);
let mut c = Array2::zeros((2, 2));
gemmkit_ndarray::gemm(1.0, &a, &b, 0.0, &mut c, Parallelism::Serial);
assert_eq!(c, array![[4.0, 4.0], [6.0, 6.0]]);
}
Layouts that cost nothing
Because the adapter only reads strides, any two-dimensional view that ndarray can
express forwards without a copy. That includes:
- the standard C-order (row-major) layout
- an F-order (column-major) view from
.reversed_axes(),.t(), or an array built with.f() - a windowed
.slice(...)view with non-unit strides - a reversed view from a negative-step slice such as
s![..;-1, ..], which produces a negative row stride
The destination C is just as free. Array2::zeros((m, n).f()) gives a column-major
output, and gemm fills it directly.
“Zero-copy” here means the adapter never copies to normalize a layout. gemmkit’s
engine still packs operands into its own scratch buffers when the microkernel needs
contiguous panels. That internal packing is part of the algorithm, not a materialization
of a transposed input. The point is that you never pay a to_owned() or a manual
transpose to satisfy the call, whatever your arrays look like.
Panics: shapes, not aliasing
The adapter validates shapes and nothing else. Each entry asserts that the inner
dimensions line up and that C matches the product. On a mismatch it panics with a
gemmkit-ndarray: message that names the offending dimensions, for instance
A.cols (k) != B.rows (kb), A.rows (m) != C.rows (cm), or B.cols (n) != C.cols (cn).
A dimension mismatch is the only reason a plain gemm or dot panics.
The adapter does not check aliasing at runtime, and it does not need to. C arrives as
&mut ArrayBase<SC, _>, an exclusive borrow. The type system already guarantees it
cannot overlap the shared & borrows of A and B. That is exactly the precondition
gemmkit’s _unchecked engine asks its caller to uphold, and the &mut signature upholds
it for free. The fused entries add 1 more runtime check, for a bias slice overlapping
C. See the advanced page for that check.
Choosing parallelism
Parallelism is re-exported by the adapter. Parallelism::Serial runs on the calling
thread. Parallelism::Rayon(n) uses a rayon pool of at most n threads, and Rayon(0)
auto-detects the machine’s core count. Parallelism::default() is Rayon(0), which is
what dot uses, so dot is parallel out of the box. The threaded paths need the
parallel feature, which is on by default. With that feature off, treat every call as
serial.
Blocking and job order do not depend on the thread count, so a fixed input and configuration give a reproducible result. Serial and parallel runs agree bit-for-bit today, because both walk the same blocking and the same kernel. That agreement is a property of the current implementation, not a promise across every configuration. The reasoning behind the thread counts lives in Parallelism in Practice.
Reusing a workspace
Every allocating entry borrows scratch from gemmkit’s internal thread-local pool for the
duration of the call. A lone gemm call never leaks an allocation into your steady
state. When you run a hot loop of similar products, the _with variants let you own
that scratch instead. Pass a &mut Workspace as the first argument. It grows once to
the largest size the loop needs, then gets reused with no further allocation.
#![allow(unused)]
fn main() {
use gemmkit_ndarray::{Parallelism, Workspace};
use ndarray::Array2;
let mut ws = Workspace::new();
let par = Parallelism::default();
for &(m, k, n) in &[(256, 256, 256), (512, 128, 512)] {
let a = Array2::<f32>::zeros((m, k));
let b = Array2::<f32>::zeros((k, n));
let mut c = Array2::<f32>::zeros((m, n));
// reuses ws across iterations; allocates at most once
gemmkit_ndarray::gemm_with(&mut ws, 1.0, &a, &b, 0.0, &mut c, par);
}
}
gemm_with is identical to gemm apart from the leading workspace, and it produces the
same result. Every family in the adapter has a matching _with twin. The pattern
carries over to the integer, complex, fused, batched, and prepacked entries covered
next. If you multiply a fixed weight matrix against a stream of activations, the
workspace pairs naturally with the prepacked-operand path on the
advanced page.
ndarray Adapter Advanced Usage
Beyond the plain real product, the adapter mirrors the whole of gemmkit’s surface:
- integer GEMM
- requantized quantized-inference output
- complex products with optional conjugation
- fused bias and activation
- a user-supplied per-element map
- batched multiplication over rank-3 arrays
- prepacked operands
The adapter gates each family behind the Cargo feature named for it. Each family also
keeps the shape of the plain entries from the
getting-started page. Each reads strides straight from
the arrays, forwards to gemmkit, and panics only on a dimension mismatch. The fused
entries add 1 more panic case, for a bias slice that overlaps C. Every entry also has
a _with twin that threads a caller-owned Workspace.
Integer GEMM (int8)
gemm_i8 multiplies i8 inputs into an i32 accumulator:
C(i32) <- alpha*A(i8)*B(i8) + beta*C, with alpha, beta, and C all i32.
It is a separate entry from gemm
because the input and output element types differ. Arithmetic wraps on overflow. This is
the conventional integer-GEMM contract. dot_i8 is the convenience twin. It returns a
fresh Array2<i32>.
#![allow(unused)]
fn main() {
use gemmkit_ndarray::{Parallelism, dot_i8, gemm_i8};
use ndarray::Array2;
let a = Array2::<i8>::zeros((16, 12));
let b = Array2::<i8>::zeros((12, 10));
// i8 inputs, i32 accumulator
let c: Array2<i32> = dot_i8(&a, &b);
// general form with i32 alpha/beta into an existing accumulator
let mut acc = Array2::<i32>::zeros((16, 10));
gemm_i8(2, &a, &b, 1, &mut acc, Parallelism::Serial);
}
Requantized output (int8 + epilogue)
Quantized inference rarely wants the raw i32 accumulator. It wants an 8-bit tensor
back. gemm_i8_requant fuses the multiply and the requantize into 1 pass. It folds the
i32 accumulator to an i8 output without ever materializing the full m*n
intermediate. There is no alpha parameter, because it folds into the scale, and no
beta parameter, because accumulating into a quantized output is ill-defined. The
parameters live in a Requantize:
#![allow(unused)]
fn main() {
use gemmkit_ndarray::{Parallelism, RequantScale, Requantize, gemm_i8_requant, gemm_i8_requant_u8};
use ndarray::Array2;
let a = Array2::<i8>::zeros((16, 12));
let b = Array2::<i8>::zeros((12, 10));
// i8 output in [-128, 127], one per-tensor scale, per-row bias (length A.rows)
let bias: Vec<i32> = vec![0; 16];
let mut c = Array2::<i8>::zeros((16, 10));
let req = Requantize {
scale: RequantScale::PerTensor(0.05),
zero_point: -7,
bias: Some(&bias),
};
gemm_i8_requant(&a, &b, req, &mut c, Parallelism::default());
// u8 output in [0, 255], per-channel scales, no bias
let scales: Vec<f32> = vec![0.02; 16]; // one per output row / channel
let mut cu = Array2::<u8>::zeros((16, 10));
gemm_i8_requant_u8(
&a,
&b,
Requantize { scale: RequantScale::PerRow(&scales), zero_point: 128, bias: None },
&mut cu,
Parallelism::default(),
);
}
The output is clamp(zero_point + round_ne(scale * (accumulator + bias[i])), LO, HI)
with round-half-to-even, where scale is the per-tensor value or the per-row scale_i.
The u8 variant is the ONNX-QLinearMatMul-style activation: identical to
gemm_i8_requant apart from the output domain [0, 255] and the zero_point band.
Both entries reject:
- a non-finite or non-positive scale, per-tensor or per-row
- a per-row scale or bias whose length is not
A.rows - a slice that overlaps
C - a
zero_pointoutside the entry’s domain
Complex GEMM (complex)
Complex products get their own entries, because the 2 conjugation flags do not fit the
homogeneous real signature. gemm_cplx computes C <- alpha*op(A)*op(B) + beta*C, over
Complex<f32> or Complex<f64>. op(A) is conj(A) when conj_a is set. op(B) is
conj(B) when conj_b is set. dot_cplx is the non-conjugated convenience.
#![allow(unused)]
fn main() {
use gemmkit_ndarray::{Complex, Parallelism, dot_cplx, gemm_cplx};
use ndarray::Array2;
type C = Complex<f64>;
let a = Array2::<C>::from_elem((8, 6), Complex::new(0.0, 0.0));
let b = Array2::<C>::from_elem((6, 5), Complex::new(0.0, 0.0));
// plain A*B
let c = dot_cplx(&a, &b);
// conjugate A, accumulate into an existing C
let mut acc = Array2::<C>::from_elem((8, 5), Complex::new(0.0, 0.0));
gemm_cplx(
Complex::new(1.0, 0.0),
&a,
true, // conj_a
&b,
false, // conj_b
Complex::new(0.0, 0.0),
&mut acc,
Parallelism::Serial,
);
}
With complex and epilogue both on, gemm_cplx_fused adds an optional Bias (added
verbatim, never conjugated) in the same pass. It takes no activation parameter. An
ordering activation like ReLU is undefined on complex numbers.
Fused bias, activation, and maps (epilogue)
gemm_fused computes C <- act(alpha*A*B + beta*C + bias) in 1 pass. The bias is an
optional Bias::PerRow (length A.rows) or Bias::PerCol (length B.cols). The
activation is an optional Relu or LeakyRelu(slope), applied last. With both set to
None, gemm_fused is exactly gemm.
#![allow(unused)]
fn main() {
use gemmkit_ndarray::{Activation, Bias, Parallelism, gemm_fused, gemm_map};
use ndarray::Array2;
let a = Array2::<f32>::zeros((12, 9));
let b = Array2::<f32>::zeros((9, 7));
// C <- ReLU(A*B + bias) in one pass; PerRow bias has length A.rows
let bias: Vec<f32> = vec![0.0; 12];
let mut c = Array2::<f32>::zeros((12, 7));
gemm_fused(
1.0, &a, &b, 0.0, &mut c,
Some(Bias::PerRow(&bias)),
Some(Activation::Relu),
Parallelism::default(),
);
// arbitrary per-element closure f(value, row, col); here a relu6
let f = |v: f32, _r: usize, _c: usize| v.max(0.0).min(6.0);
let mut c2 = Array2::<f32>::zeros((12, 7));
gemm_map(1.0, &a, &b, 0.0, &mut c2, &f, Parallelism::default());
}
Bias and Activation are re-exported from gemmkit_ndarray, so you need not name
gemmkit for them. For f32/f64, gemm_fused is bit-identical to gemm followed by
the same scalar map, for every shape. For f16/bf16, the epilogue runs in f32 before
the single narrowing. This is more precise than a separate narrow map, so the result is
not bitwise-equal to gemm-then-map for those types.
gemm_map is the general per-element extension point. The closure f(value, row, col)
sees each output element at its final value, with (row, col) in the user frame of C.
gemmkit calls it exactly once per element. It costs 1 indirect call per element. Prefer
gemm_fused for a plain bias or activation, because it vectorizes. Reach for gemm_map
instead for GELU, sigmoid, clamps, or position-dependent transforms. Here, T is
f32/f64 only.
Batched GEMM
This is the only operation with no plain-gemm analogue, and no counterpart in the
sibling adapters. It is a stack of independent products on a rank-3 Array3, with the
batch on axis 0. a is (batch, m, k), b is (batch, k, n), and c is
(batch, m, n). Axis 0 is each operand’s batch stride. Axes 1 and 2 are the element
strides. gemm_batched parallelizes across the batch. Each element runs on 1 worker, so
the result reproduces a loop of gemm calls exactly.
#![allow(unused)]
fn main() {
use gemmkit_ndarray::{Parallelism, dot_batched, gemm_batched};
use ndarray::Array3;
let a = Array3::<f32>::zeros((32, 8, 5)); // (batch, m, k)
let b = Array3::<f32>::zeros((32, 5, 6)); // (batch, k, n)
// stack of products
let c = dot_batched(&a, &b); // (32, 8, 6)
// general form into an existing accumulator
let mut acc = Array3::<f32>::zeros((32, 8, 6));
gemm_batched(0.7, &a, &b, 1.3, &mut acc, Parallelism::default());
}
The adapter reads only strides, so a permuted-axes or otherwise general-stride 3-D view
forwards without a copy. For example, a.view().permuted_axes([0, 2, 1]) turns a
(batch, k, m) buffer into a (batch, m, k) view that batches straight through.
Under epilogue, gemm_batched_fused applies 1 shared Bias/Activation to every
element of the stack. This is the batched-linear-layer case. The bias is sized for a
single element (PerRow length m, PerCol length n), not the whole batch.
Prepacked operands
When one operand is fixed and the other streams, pack the fixed side once and reuse it.
This skips the per-call repack. prepack_rhs returns a PackedRhs<T> for a reused B,
consumed by gemm_packed_b. prepack_lhs returns a PackedLhs<T> for a reused A,
consumed by gemm_packed_a. The prepack functions read strides directly, so B or A
may have any layout.
Each has 1 orientation constraint. gemm_packed_b needs a column-major-ish C
(|col stride| >= |row stride|). gemm_packed_a needs a row-major-ish C
(|col stride| <= |row stride|). The other orientation would swap the operands and
invalidate the packed handle, which gemmkit rejects. Use plain gemm for the layout
that does not fit.
The fused twins gemm_packed_b_fused and gemm_packed_a_fused accept the same handles
plus a bias and activation. This is exactly the fixed-weight inference layer. The
example below packs a weight matrix once as the LHS, and reuses it across inference
steps. It folds in a per-output-channel bias and a ReLU:
#![allow(unused)]
fn main() {
use gemmkit_ndarray::{Activation, Bias, Parallelism, gemm_packed_a_fused, prepack_lhs};
use ndarray::Array2;
let (out, in_features) = (256usize, 512usize);
// pack the fixed weight W: (out, in) once
let w = Array2::<f32>::zeros((out, in_features));
let packed = prepack_lhs(&w);
let bias: Vec<f32> = vec![0.0; out]; // per-output-channel, length C.rows
// each inference step: activations x (in, batch) -> y (out, batch)
let batch = 32;
let x = Array2::<f32>::zeros((in_features, batch));
let mut y = Array2::<f32>::zeros((out, batch)); // row-major (packed_a orientation)
gemm_packed_a_fused(
1.0,
&packed,
&x,
0.0,
&mut y,
Some(Bias::PerRow(&bias)),
Some(Activation::Relu),
Parallelism::default(),
);
}
Pair the packed handle with the _with workspace variant, gemm_packed_a_fused_with. A
steady inference loop then allocates nothing after the first call. You specify the bias
axis in the user frame. The packed path forwards it unflipped for gemm_packed_b_fused,
and lets the core flip it for gemm_packed_a_fused. PerRow always means “1 value per
output row,” regardless of which operand you packed.
This adapter versus ndarray’s own product
ndarray already multiplies matrices: .dot() for the plain product and
general_mat_mul for the in-place alpha/beta form. For a one-off f32/f64
product with no extra requirements, those functions work well and pull in 1 less
dependency. There is no reason to route through gemmkit out of habit.
Reach for this adapter when you want what ndarray’s built-in path does not offer.
gemmkit picks the fastest instruction set on the machine it runs on, at runtime. It does
not bake 1 choice in at compile time (see
Runtime ISA Dispatch). It brings the wider
surface this page covers: fused bias and activation, i8 and requantized inference,
complex with conjugation, batched products, and prepacking. It also exposes tuning
knobs, plus an install-time autotuner that calibrates blocking to the deployment machine
(see Tuning Knobs).
None of that changes the arrays you pass or the results you get back. The adapter is the same zero-copy stride plumbing throughout. It only widens what you can ask for.
Using gemmkit with nalgebra
gemmkit-nalgebra lets you drive the gemmkit engine straight from nalgebra matrices without copying them first. It targets nalgebra 0.35 and accepts &Matrix<T, R, C, S> for any storage S: RawStorage<T, R, C>. Owned DMatrix, static SMatrix, and every view or slice type all qualify. The adapter reads the matrix’s data pointer and its 2 strides. It hands them to gemmkit’s raw engine. The engine does not reshape the input, transpose it into a scratch buffer, or duplicate it on the way in.
nalgebra’s natural layout is column-major, which is also gemmkit’s preferred orientation, so the common case is the fast case. Row-major and general-stride views work too, with the same zero-copy behavior, because the engine reads strides directly instead of assuming one layout.
Adding it to a project
2 crates go into Cargo.toml. The adapter re-exports everything its own signatures name, so a direct gemmkit dependency is not part of the normal setup.
[dependencies]
gemmkit-nalgebra = "0.1"
nalgebra = "0.35"
gemmkit_nalgebra re-exports several types, so a project does not need a direct gemmkit dependency for them:
- the
Parallelismselector and theWorkspaceevery_withvariant takes - the fused selectors
BiasandActivation - the prepacked handles
PackedLhsandPackedRhs - the requantization parameters
RequantizeandRequantScale - the element-type bounds
GemmScalar,FusedScalar,MapScalar, andComplexScalar. Name these when you write a wrapper generic over an entry - the element types
f16,bf16,Complex,c32, andc64, gated behind their features, sohalfandnum-complexalso stay out of your manifest - the
tuningmodule
Reach for tuning through the adapter instead of through your own gemmkit dependency. The tuning knobs are process-global atomics. A second, separately resolved gemmkit crate would give you a set of atomics the adapter never reads.
The default feature set enables parallel, which turns on rayon-based threading in the engine (gemmkit/parallel). Every adapter feature is a thin forward to the same-named feature on gemmkit:
halfaddsf16/bf16inputscomplexaddsComplex<f32>/Complex<f64>int8adds thei8 -> i32pathepilogueadds fused bias/activation and the per-element mapwasm_threadslayersparallelontowasm32-wasip1-threads
The feature-gated entries are covered in nalgebra Adapter Advanced Usage. This page stays on the always-available real-scalar surface.
The 3 real-scalar entries
The base surface is 3 functions, all generic over GemmScalar (which is f32 and f64 always, plus f16 and bf16 under the half feature). gemm is the accumulating multiply, gemm_with is the same call reusing a caller-owned workspace, and dot is a convenience wrapper that allocates its result.
Here is gemm verbatim, from gemmkit-nalgebra/src/float.rs:
#![allow(unused)]
fn main() {
pub fn gemm<T, R1, C1, S1, R2, C2, S2, RC, CC, SC>(
alpha: T,
a: &Matrix<T, R1, C1, S1>,
b: &Matrix<T, R2, C2, S2>,
beta: T,
c: &mut Matrix<T, RC, CC, SC>,
par: Parallelism,
) where
T: GemmScalar,
R1: Dim,
C1: Dim,
S1: RawStorage<T, R1, C1>,
R2: Dim,
C2: Dim,
S2: RawStorage<T, R2, C2>,
RC: Dim,
CC: Dim,
SC: RawStorageMut<T, RC, CC>,
{
gemm_common(None, alpha, a, b, beta, c, par);
}
}
The 10 generic parameters look heavy, but they say one simple thing. A, B, and C may each be any nalgebra matrix or view, with independent row-dimension, column-dimension, and storage types. A and B are read through RawStorage. C needs RawStorageMut, because gemm writes into it in place. The operation is C <- alpha*A*B + beta*C.
gemm_with takes the same arguments, preceded by a &mut Workspace. dot(a, b) -> DMatrix<T> computes A*B into a freshly allocated column-major matrix. It calls gemm internally with beta == 0, so the fresh buffer is never read before it is overwritten.
A first multiply with DMatrix
#![allow(unused)]
fn main() {
use gemmkit_nalgebra::Parallelism;
use nalgebra::DMatrix;
let a = DMatrix::from_row_slice(2, 2, &[1.0_f32, 2.0, 3.0, 4.0]);
let b = DMatrix::from_row_slice(2, 2, &[5.0_f32, 6.0, 7.0, 8.0]);
// dot: A*B into a fresh column-major DMatrix
let c = gemmkit_nalgebra::dot(&a, &b);
assert_eq!(c, DMatrix::from_row_slice(2, 2, &[19.0, 22.0, 43.0, 50.0]));
// gemm: accumulate C <- alpha*A*B + beta*C in place
let mut acc = DMatrix::<f32>::zeros(2, 2);
gemmkit_nalgebra::gemm(1.0, &a, &b, 0.0, &mut acc, Parallelism::default());
assert_eq!(acc, c);
}
dot is the right tool when you just want the product and are happy with a DMatrix back. gemm is the tool when you already own the destination. Use it to scale the destination (beta), scale the product (alpha), or avoid the allocation dot performs. dot always returns a DMatrix<T>, even for static inputs, because the wrapper only knows the output dimensions at the value level.
Static and mixed-shape matrices
Static matrices go through the same functions with no special casing. Because the row, column, and storage generics are independent per operand, a static A can multiply a dynamic B. gemm can write into a static &mut SMatrix output just as readily as into a DMatrix.
#![allow(unused)]
fn main() {
use nalgebra::{DMatrix, Matrix2, SMatrix};
// static x static
let a = Matrix2::new(1.0_f32, 2.0, 3.0, 4.0);
let b = Matrix2::new(5.0_f32, 6.0, 7.0, 8.0);
let c = gemmkit_nalgebra::dot(&a, &b); // -> DMatrix<f32>
assert_eq!(c[(0, 0)], 19.0);
// static A x dynamic B: the independent Dim generics allow it
let a34 = SMatrix::<f64, 3, 4>::from_fn(|i, j| (i as f64) - 0.5 * (j as f64) + 1.0);
let b = DMatrix::<f64>::from_element(4, 2, 0.25);
let c = gemmkit_nalgebra::dot(&a34, &b); // -> DMatrix<f64>, shape 3x2
}
Layouts and zero-copy
The adapter never copies an operand. It pulls (rows, cols, row-stride, col-stride) out of the matrix and forwards the pointer plus strides to the engine. The source layout only decides which stride pair the engine sees. It never decides whether an allocation happens. A column-major DMatrix is read in place, and so is a row-major slice built with from_slice_with_strides. A non-contiguous stepped view, such as every other row of a larger matrix, is read in place too. nalgebra reports strides as non-negative element counts, and the adapter widens them to the signed strides the engine expects.
Whatever internal packing the engine does to feed its microkernel is independent of the source layout and happens regardless of where the data came from. That is a property of gemmkit, not a copy the adapter introduces. If you want to eliminate the repeated internal repacking of a reused operand, use the prepacked-operand path instead. It is described in the advanced page.
When it panics
The entries validate shapes and panic on a mismatch, with the offending dimensions in the message. gemm (and its siblings) check 3 equalities before touching any memory: A.cols == B.rows, A.rows == C.rows, and B.cols == C.cols. A mismatch on the inner dimension, for instance, aborts with gemmkit-nalgebra: A.cols (k) != B.rows (kb) instead of reading out of bounds. The output matrix must therefore already have the right shape. gemm writes into it but does not resize it. dot, which allocates the output itself, can only fail on the inner-dimension check.
Choosing parallelism
Every call takes a Parallelism as its last argument. There are 2 variants. Parallelism::Serial runs single-threaded, and Parallelism::Rayon(n) runs on rayon with at most n threads, where Rayon(0) auto-detects the thread count. The Default is Rayon(0), which is what dot uses internally.
For small matrices, or when you are already inside a parallel region and want to avoid nested threading, pass Parallelism::Serial. For large multiplies on an otherwise idle machine, Parallelism::Rayon(0) lets the engine spread the work. The threading strategy and how the engine picks a thread count are covered in Parallelism in Practice.
Reusing a workspace
The engine needs scratch space to pack blocks of A and B. By default it borrows that space from a thread-local pool, so gemm and dot allocate nothing of their own per call in the steady state. When you run many multiplies in a tight loop and want full control over that buffer, gemm_with takes a &mut Workspace you own and reuse:
#![allow(unused)]
fn main() {
use gemmkit_nalgebra::{Parallelism, Workspace};
use nalgebra::DMatrix;
let mut ws = Workspace::new();
let a = DMatrix::<f64>::from_element(64, 64, 1.0);
let b = DMatrix::<f64>::from_element(64, 64, 2.0);
for _ in 0..1000 {
let mut c = DMatrix::<f64>::zeros(64, 64);
gemmkit_nalgebra::gemm_with(&mut ws, 1.0, &a, &b, 0.0, &mut c, Parallelism::default());
}
}
A single Workspace can back multiplies of different shapes across iterations. It grows to fit the largest one it has seen and keeps that capacity. Workspace::new() starts empty and costs nothing until the first call fills it. The _with form exists on all the accumulating entries, including the feature-gated ones. The same reuse pattern carries over to integer, complex, and fused calls.
nalgebra Adapter Advanced Usage
Beyond the real-scalar gemm/gemm_with/dot covered in Using gemmkit with nalgebra, the adapter exposes the engine’s full surface behind Cargo features:
- integer GEMM
- complex GEMM
- fused epilogues
- requantized output
- per-element maps
- prepacked operands
- batching
Every entry keeps the adapter’s core promise. It reads nalgebra’s pointer and strides straight through with no copy. It also mirrors a gemmkit core function of the same name. Cargo gates each family, so you pay only for what you turn on.
| Feature | Adds |
|---|---|
half | f16/bf16 through the same gemm/gemm_fused generics |
int8 | gemm_i8, gemm_i8_with, dot_i8 (i8 -> i32) |
complex | gemm_cplx, gemm_cplx_with, dot_cplx |
epilogue | gemm_fused, gemm_map (and prepacked-fused twins) |
int8 + epilogue | gemm_i8_requant, gemm_i8_requant_u8 |
complex + epilogue | gemm_cplx_fused |
Every helper type these entries name comes from the adapter crate itself, so you do not need to name gemmkit to use them. These are Bias, Activation, RequantScale, Requantize, PackedLhs, PackedRhs, Parallelism, Workspace, and Complex (with its c32/c64 aliases).
Integer GEMM
Under int8, gemm_i8 multiplies 2 i8 matrices into an i32 output. The inputs are i8. alpha, beta, and C are i32, because an i8*i8 product needs the wider accumulator. Arithmetic wraps on overflow, the conventional integer-GEMM convention. This is a separate entry from gemm, because the input and output element types differ.
#![allow(unused)]
fn main() {
use gemmkit_nalgebra::{Parallelism, dot_i8, gemm_i8};
use nalgebra::DMatrix;
let a = DMatrix::from_row_slice(2, 3, &[1_i8, 2, 3, 4, 5, 6]);
let b = DMatrix::from_row_slice(3, 2, &[1_i8, 0, 0, 1, 1, 1]);
// dot_i8: A*B into a fresh DMatrix<i32>
let c = dot_i8(&a, &b);
// gemm_i8: scale and accumulate into an i32 output
let mut acc = DMatrix::<i32>::zeros(2, 2);
gemm_i8(1, &a, &b, 0, &mut acc, Parallelism::Serial);
assert_eq!(acc, c);
}
dot_i8(a, b) -> DMatrix<i32> is the allocating convenience form, and gemm_i8_with reuses a caller-owned Workspace for the fixed-cost quantized-inference loop.
Requantized output
Requantization folds the dequantize-scale-round-clamp step of quantized inference into the GEMM, so the m*n i32 accumulator never has to be materialized in full. gemm_i8_requant takes i8 inputs and writes an i8 output. gemm_i8_requant_u8 writes an unsigned u8 output instead (the ONNX QLinearMatMul convention). Both need int8 + epilogue. There is no alpha (it folds into the scale) and no beta (accumulating into a quantized C is ill-defined). The parameters ride in a re-exported Requantize:
#![allow(unused)]
fn main() {
use gemmkit_nalgebra::{Parallelism, RequantScale, Requantize, gemm_i8_requant};
use nalgebra::DMatrix;
let a = DMatrix::from_row_slice(2, 3, &[10_i8, -4, 7, 3, 8, -2]);
let b = DMatrix::from_row_slice(3, 2, &[2_i8, 1, -1, 5, 4, 0]);
let bias = [100_i32, -50]; // per-row, length A.rows
let req = Requantize {
scale: RequantScale::PerTensor(0.05),
zero_point: -7, // in [-128, 127] for the i8 output
bias: Some(&bias),
};
let mut c = DMatrix::from_element(2, 2, 0_i8);
gemm_i8_requant(&a, &b, req, &mut c, Parallelism::Serial);
}
RequantScale::PerTensor(s) applies 1 scale to every element. RequantScale::PerRow(&[f32]) gives 1 scale per output row (per output channel, the standard per-channel convention), with length A.rows. Every scale must be finite and > 0. The engine adds zero_point as an integer after rounding. It must lie in the output domain: [-128, 127] for gemm_i8_requant, [0, 255] for gemm_i8_requant_u8. The engine adds the optional per-row i32 bias (length A.rows) to the accumulator before scaling.
The adapter validates all of this. It panics with the core engine’s wording on any violation:
- a non-finite or non-positive scale
- a per-row scale or bias of the wrong length
- a
zero_pointout of range - a scale or bias slice that overlaps
C
Complex GEMM
Under complex, gemm_cplx computes C <- alpha*op(A)*op(B) + beta*C for T = Complex<f32> or Complex<f64>. op(A) is conj(A) when the conj_a flag is set, and op(B) is conj(B) when conj_b is set. The conjugation flags are why complex needs its own entry: they do not fit the homogeneous real-scalar signature. dot_cplx(a, b) is the non-conjugated A*B convenience form. For a conjugated product, use gemm_cplx directly.
#![allow(unused)]
fn main() {
use gemmkit_nalgebra::{Complex, Parallelism, dot_cplx, gemm_cplx};
use nalgebra::DMatrix;
type C = Complex<f64>;
let a = DMatrix::from_element(2, 2, C::new(1.0, 1.0));
let b = DMatrix::from_element(2, 2, C::new(0.0, -1.0));
// plain product
let p = dot_cplx(&a, &b);
// conjugate A, plain B, accumulate into an existing C
let mut acc = DMatrix::from_element(2, 2, C::new(0.0, 0.0));
gemm_cplx(C::new(1.0, 0.0), &a, true, &b, false,
C::new(0.0, 0.0), &mut acc, Parallelism::Serial);
}
Complex with a fused bias
gemm_cplx_fused (needs complex + epilogue) adds a bias in the same pass as the complex product: C <- alpha*op(A)*op(B) + beta*C + bias. The bias is a re-exported Bias, either Bias::PerRow (length A.rows) or Bias::PerCol (length B.cols). gemmkit adds it verbatim, without conjugation. There is deliberately no activation parameter here: an ordering activation like ReLU is undefined on complex numbers. With bias == None the call is exactly gemm_cplx.
Fused epilogues
The epilogue feature adds gemm_fused, which computes C <- act(alpha*A*B + beta*C + bias) in a single pass over f32/f64. When half is also on, f16/bf16 use the same path. Their epilogue runs in f32, with 1 narrowing store at the end. The optional Bias is PerRow (length A.rows) or PerCol (length B.cols). The optional Activation is Relu or LeakyRelu(slope), applied last. Passing None for both is bit-for-bit identical to plain gemm.
#![allow(unused)]
fn main() {
use gemmkit_nalgebra::{Activation, Bias, Parallelism, gemm_fused};
use nalgebra::DMatrix;
let a = DMatrix::<f32>::from_element(12, 9, 0.5);
let b = DMatrix::<f32>::from_element(9, 7, -0.25);
let bias: Vec<f32> = (0..12).map(|i| 0.5 * i as f32 - 2.0).collect();
let mut c = DMatrix::<f32>::zeros(12, 7);
gemm_fused(1.3, &a, &b, -0.7, &mut c,
Some(Bias::PerRow(&bias)), Some(Activation::Relu), Parallelism::Serial);
}
The fused pass is not just a convenience. It avoids a second sweep over C, and it avoids the round trip through memory that a separate bias-add and activation would cost. For f32/f64, the result is bit-identical to running gemm and then applying the same bias and activation element by element. You can adopt it without changing numerical results. The design behind it is covered in Fused Epilogues.
Per-element map
gemm_map fits epilogues that a bias-plus-activation shape cannot express. It applies an arbitrary closure to each finished output element: C[r, c] <- f(alpha*A*B + beta*C, r, c). The closure fires exactly once per element, with (r, c) in the user frame of C. T is f32/f64 only. The closure is &(dyn Fn(T, usize, usize) -> T + Sync). It must be Sync to run in parallel, and it can close over data by reference.
#![allow(unused)]
fn main() {
use gemmkit_nalgebra::{Parallelism, gemm_map};
use nalgebra::DMatrix;
let a = DMatrix::<f64>::from_element(8, 6, 0.3);
let b = DMatrix::<f64>::from_element(6, 5, 0.4);
let mut c = DMatrix::<f64>::zeros(8, 5);
// sigmoid, ignoring position
let sigmoid = |v: f64, _r: usize, _c: usize| 1.0 / (1.0 + (-v).exp());
gemm_map(1.0, &a, &b, 0.0, &mut c, &sigmoid, Parallelism::Serial);
}
Prefer gemm_fused for a plain bias or ReLU, because it vectorizes. gemm_map is the general extension point for GELU, sigmoid, clamps, and position-dependent transforms, at the cost of 1 indirect call per output element. Like the fused entry, its f32/f64 result is bit-identical to gemm followed by the same map. gemm_map_with reuses a Workspace.
Prepacked operands
For example, a weight matrix served against a stream of activations has 1 fixed operand across many multiplies. Prepacking it once removes the per-call repack. prepack_rhs(b) -> PackedRhs<T> packs a right operand. gemm_packed_b then consumes the handle in place of B. prepack_lhs/gemm_packed_a do the mirror for a fixed left operand.
#![allow(unused)]
fn main() {
use gemmkit_nalgebra::{Parallelism, gemm_packed_b, prepack_rhs};
use nalgebra::DMatrix;
let weights = DMatrix::<f32>::from_fn(64, 32, |i, j| 0.01 * (i as f32 - j as f32));
let packed = prepack_rhs(&weights); // pack the fixed B once
for step in 0..100 {
let x = DMatrix::<f32>::from_element(16, 64, step as f32); // an activation batch
let mut y = DMatrix::<f32>::zeros(16, 32); // column-major output
gemm_packed_b(1.0, &x, &packed, 0.0, &mut y, Parallelism::default());
}
}
There is an orientation constraint. gemm_packed_b needs a column-major-ish C (|col stride| >= |row stride|). A row-major C would force the engine to swap A and B internally. That would invalidate a prepacked RHS, so gemmkit rejects a row-major C. gemm_packed_a is the opposite: it needs a row-major-ish C, and rejects a column-major one. For a C in the wrong orientation, fall back to plain gemm.
Each packed entry has a _with twin for workspace reuse. Under epilogue, the fused twins gemm_packed_b_fused and gemm_packed_a_fused add a bias and activation off the same handle. PackedRhs and PackedLhs expose .rows() and .cols() if you need to re-check dimensions. See Prepacked Operands for the underlying reuse model.
Batched GEMM
nalgebra has no rank-3 array type, so batched GEMM does not take a 3-D tensor the way the ndarray adapter does. Instead, gemm_batched takes the batch as a slice of per-element (&A, &B) input pairs, matched positionally with a slice of &mut C outputs. It runs C_e <- alpha*A_e*B_e + beta*C_e for every element in 1 call, over gemmkit’s pointer-array engine. The alpha, beta, and par arguments are shared by the whole batch.
#![allow(unused)]
fn main() {
use gemmkit_nalgebra::{Parallelism, gemm_batched};
use nalgebra::DMatrix;
let a = DMatrix::from_row_slice(2, 2, &[1.0_f32, 2.0, 3.0, 4.0]);
let b = DMatrix::from_row_slice(2, 2, &[5.0_f32, 6.0, 7.0, 8.0]);
let mut c = vec![DMatrix::<f32>::zeros(2, 2), DMatrix::<f32>::zeros(2, 2)];
let ab = [(&a, &b), (&a, &b)];
gemm_batched(1.0, &ab, 0.0, &mut c, Parallelism::Serial);
assert_eq!(c[0], DMatrix::from_row_slice(2, 2, &[19.0, 22.0, 43.0, 50.0]));
}
Element shapes may differ, so a heterogeneous batch is fine, as long as each element’s own dimensions agree: A_e.cols == B_e.rows, and so on. The shared storage type carries the varying runtime dimensions. DMatrix or dynamic-stride views therefore cover heterogeneous shapes and mixed layouts under 1 type. The input count and the output count must match: ab.len() == c.len(). A mismatch there, or a dimension mismatch in any element, panics.
The batch parallelizes across elements, not within a single element. The dispatcher assigns whole per-element GEMMs to workers, and each worker runs its GEMM serially and cache-hot. As a result, the batch reproduces a plain loop of gemm calls. It also stays reproducible across thread counts. The serial and parallel schedules are bit-identical, because each element always runs wholly on 1 worker.
The C slice is a single storage type. There is no pointer-array analogue of a single shared fused epilogue for it, so unlike the ndarray adapter, there is no gemm_batched_fused here. The batching model is discussed further in Batched GEMM.
Where this sits next to nalgebra’s own multiply
nalgebra already multiplies matrices: &a * &b, a.mul_to(&b, &mut c), and the rest of its operator surface. These return properly typed matrices and integrate with its const-generic dimensions. For an ordinary f32/f64 product, especially of small static matrices, those are the idiomatic choice and there is no reason to reach for this adapter.
The adapter earns its place when you want something nalgebra’s operators do not offer:
- the engine’s runtime SIMD dispatch, which selects the best available instruction set on the machine at run time instead of at compile time
- the
i8 -> i32and requantizing integer paths - fused bias/activation and per-element epilogues in a single pass
- batched multiplication of many small problems
- prepacked operands for a fixed weight reused across calls
When your matrices already live in nalgebra and you need any of those, reach for the adapter. It gives you the engine’s throughput and features without leaving nalgebra’s types, and without a copy.
Using gemmkit with faer
gemmkit-faer is a thin, zero-copy bridge from faer’s view types to the gemmkit GEMM
engine. It accepts a MatRef<'_, T> for each input and a MatMut<'_, T> for the output.
It reads the data pointer and the element-unit row and column strides straight out of the
view. Then it hands them to gemmkit’s raw engine. The adapter does not transpose, copy, or
repack anything on the way in.
faer already stores its strides the way gemmkit’s engine wants them. Because of this, a
faer Mat, a transposed view, an offset sub-matrix, and a reversed (negative-stride) view
all reach the kernel untouched.
The crate targets faer 0.24. It requires Rust 1.89.
Installation and features
gemmkit-faer re-exports everything its own signatures name. Because of this, a direct
gemmkit dependency is not part of the normal setup.
[dependencies]
gemmkit-faer = "0.1"
faer = "0.24"
gemmkit_faer re-exports:
- The
Parallelismselector, and theWorkspacetype every_withvariant takes. - The fused selectors
BiasandActivation. - The prepacked handles
PackedLhsandPackedRhs. - The requantization parameters
RequantizeandRequantScale. - The element-type bounds
GemmScalar,FusedScalar,MapScalar, andComplexScalar. Name these when you write a wrapper generic over an entry. - The element types
f16,bf16,Complex,c32, andc64, each under its own feature. This keepshalfandnum-complexout of your manifest too. - The
tuningmodule.
Reach tuning through the adapter, not through a separate gemmkit dependency of your
own. The knobs are process-global atomics. A second, separately resolved gemmkit gives
you a different set of atomics, one the adapter never reads.
Every Cargo feature forwards to the same-named gemmkit feature. So when you enable an
element family or a fused entry here, the core turns it on too.
parallel(default): rayon-based parallelism.wasm_threads: threading onwasm32-wasip1-threads. Also enablesparallel.half: thef16andbf16element types, accumulated inf32.complex: thec32andc64element types.int8:i8inputs into ani32output.epilogue: the fused bias/activation, requantization, and per-element map entries.
The advanced usage page covers the feature-gated
families and the fused entries. This page stays on the always-available f32/f64 (plus
f16/bf16 under half) surface.
What zero-copy means here
Every entry routes through one small helper. This helper pulls the raw parts out of a
MatRef. faer reports strides in element units as isize, negative for a reversed view.
This is exactly the shape gemmkit’s unchecked engine expects, so no conversion step exists
at all.
#![allow(unused)]
fn main() {
// gemmkit-faer/src/common.rs
pub(crate) fn ref_parts<T>(a: MatRef<'_, T>) -> (usize, usize, isize, isize, *const T) {
(a.nrows(), a.ncols(), a.row_stride(), a.col_stride(), a.as_ptr())
}
}
The adapter validates the 3 shared dimensions itself. It then forwards the pointers
and strides to gemmkit’s _unchecked engine inside a single unsafe block. The safety
argument is short. faer’s view types guarantee that the pointer plus strides describe a
valid in-bounds layout. The output is a MatMut, an exclusive borrow, so C cannot alias
A or B. That is the entire adapter for the plain path.
All of gemmkit’s cache blocking, ISA dispatch, packing, and parallel scheduling live in the core. The core documents them there too. See the architecture chapter for the internals.
gemm and dot
The 2 workhorses are dot, which returns a fresh product, and gemm, which updates an
output in place. Both are generic over GemmScalar: f32 and f64 always, plus f16
and bf16 when the half feature is on.
#![allow(unused)]
fn main() {
use faer::Mat;
let a = Mat::from_fn(2, 2, |i, j| [[1.0_f64, 2.0], [3.0, 4.0]][i][j]);
let b = Mat::from_fn(2, 2, |i, j| [[5.0_f64, 6.0], [7.0, 8.0]][i][j]);
// A*B into a fresh column-major Mat
let c = gemmkit_faer::dot(a.as_dyn_stride(), b.as_dyn_stride());
assert_eq!(c[(0, 0)], 19.0);
assert_eq!(c[(1, 1)], 50.0);
}
dot(a, b) computes A*B into a newly allocated column-major Mat. It runs with the
default parallelism, Parallelism::Rayon(0), which auto-detects the thread count. Use
dot as the one-shot convenience. When you own the output buffer, or want the general
update, use gemm instead.
#![allow(unused)]
fn main() {
use faer::Mat;
use gemmkit_faer::{Parallelism, gemm};
let a = Mat::<f64>::from_fn(4, 3, |i, j| (i + j) as f64);
let b = Mat::<f64>::from_fn(3, 5, |i, j| (i as f64) * (j as f64));
let mut c = Mat::<f64>::zeros(4, 5);
// c <- 1.5 * a * b + 2.0 * c, single-threaded
gemm(1.5, a.as_dyn_stride(), b.as_dyn_stride(), 2.0, c.as_dyn_stride_mut(), Parallelism::Serial);
}
gemm(alpha, a, b, beta, c, par) computes C <- alpha*A*B + beta*C in place. With
beta == 0, gemm overwrites the prior contents of C and never reads them. This is
exactly what dot does internally. With a nonzero beta, the call accumulates onto what
C already holds.
The signatures are the ones you see above. The inputs are MatRef<'_, T>, the output is
MatMut<'_, T>, and par is a Parallelism. The .as_dyn_stride() and
.as_dyn_stride_mut() conversions turn faer’s statically typed strides into the
dynamic-stride views the adapter accepts. They cost nothing at runtime.
Layouts that pass through untouched
The adapter only ever reads a pointer and 2 strides. Because of this, any faer view works without a copy or a fallback. A transposed operand is the common row-major-A case. Transposing a column-major matrix yields a view whose row stride is non-unit, and that view goes straight to the kernel.
#![allow(unused)]
fn main() {
// `at` is k x m column-major; `.transpose()` gives an m x k view with a non-unit
// row stride - read straight through, no copy
let a = at.as_dyn_stride().transpose();
let c = gemmkit_faer::dot(a, b.as_dyn_stride());
}
The same holds for an offset sub-matrix. submatrix(...) moves the base pointer and
keeps a non-contiguous column stride. It also holds for a reversed view. reverse_rows()
and reverse_cols() carry a negative stride.
gemmkit’s unchecked path handles negative strides directly. So a reversed input
accumulates correctly under beta, just like any other input. See
Matrix Views and Layouts for how the
engine treats general strides.
Choosing parallelism
Every entry takes a Parallelism. Parallelism::Serial runs single-threaded.
Parallelism::Rayon(n) uses rayon with at most n threads. Rayon(0) auto-detects the
thread count.
gemmkit ramps the thread count with the workload, instead of jumping straight to every core. For a fixed machine and a fixed configuration, a call gives reproducible results. Serial and parallel runs also agree bit-for-bit today. That agreement is not a hard guarantee, because the reproducibility contract covers a fixed configuration only, and the worker count is part of that configuration. The Parallelism in Practice guide covers the scheduling model.
Reusing a workspace across calls
gemm allocates its scratch space from a thread-local pool. Every entry also has a
_with twin. If you drive many GEMMs in a loop and want to own the scratch buffer
explicitly, use the _with twin instead. It takes a &mut Workspace as its first
argument and reuses that workspace across calls.
#![allow(unused)]
fn main() {
use gemmkit_faer::{Parallelism, Workspace, gemm_with};
let mut ws = Workspace::new();
for (a, b, mut c) in problems {
// same result as `gemm`, but the scratch buffer is reused
gemm_with(&mut ws, 1.0, a, b, 0.0, c.as_dyn_stride_mut(), Parallelism::Rayon(0));
}
}
A single Workspace grows to fit the largest problem it has seen. After that, gemmkit
reuses it as-is. This matters most for a stream of similar small-to-medium GEMMs, where
allocation would otherwise show up in the profile.
Panic behavior
The adapter checks the 3 shared dimensions before dispatching, and panics on a
mismatch. A.cols must equal B.rows. A.rows must equal C.rows. B.cols must equal
C.cols. The adapter prefixes each message with gemmkit-faer: and names the 2 conflicting
extents, for example gemmkit-faer: A.cols (4) != B.rows (5). These are the only panics on the
plain gemm/dot path.
The feature-gated entries add a few more checks: bias length and overlap, requantize
parameters, and prepacked-C orientation. Those checks reproduce gemmkit’s own
checked-entry wording. Each entry on the
advanced usage page lists its own panics.
faer Adapter Advanced Usage
Beyond gemm and dot, the faer adapter mirrors the rest of gemmkit’s surface. This
includes the extra element families, the fused epilogues, batched GEMM over a slice, and
prepacked operands. Each of these is feature-gated. Each also reads raw pointers and
strides straight out of faer’s views. So transposed, sub-matrix, and reversed operands
keep working exactly as they do on the plain path. This page walks the families one at a
time. It closes with a note on when the adapter earns its place next to faer’s own matmul.
The introductory page covers installation, the zero-copy
mechanism, gemm/gemm_with/dot, parallelism, and the workspace pattern. Everything
here builds on that page. As on the plain path, every entry also has a _with twin that
reuses a caller-owned Workspace.
Integer GEMM (int8)
With the int8 feature, gemm_i8 and dot_i8 take i8 inputs and accumulate into an
i32 output. The input and output element types differ. That is why this is a separate
entry from gemm, rather than another instance of the generic. faer’s view types are
generic over the element. So an i8 MatRef and an i32 MatMut need no special
handling.
#![allow(unused)]
fn main() {
use faer::Mat;
use gemmkit_faer::{Parallelism, dot_i8, gemm_i8};
let a = Mat::<i8>::from_fn(16, 12, |i, j| ((i + j) as i8 % 7) - 3);
let b = Mat::<i8>::from_fn(12, 10, |i, j| ((i * 2 + j) as i8 % 5) - 2);
// i8 * i8 accumulated into a fresh Mat<i32>
let c = dot_i8(a.as_dyn_stride(), b.as_dyn_stride());
// Mat::zeros is ComplexField-only, so integer outputs use from_fn
let mut acc = Mat::<i32>::from_fn(16, 10, |_, _| 0);
// c <- 3 * a * b + (-2) * c, all of alpha/beta/C in i32
gemm_i8(3, a.as_dyn_stride(), b.as_dyn_stride(), -2, acc.as_dyn_stride_mut(), Parallelism::Serial);
}
alpha, beta, and C are i32. The arithmetic wraps on overflow. This is the
conventional integer-GEMM semantics.
Requantized output (int8 + epilogue)
With both int8 and epilogue, gemm_i8_requant fuses the requantize step into the
kernel’s store. i8 inputs multiply into an i32 accumulator. The kernel scales, biases,
rounds, and clamps that accumulator down to an i8 output in a single pass. It never
materializes the full m*n i32 matrix. gemm_i8_requant_u8 does the same, but clamps
to an unsigned u8 output, the ONNX QLinearMatMul-style activation domain.
There is no alpha, because it folds into the scale. There is no beta, because
accumulating into a quantized output is ill-defined.
The parameters come in a Requantize. The crate re-exports this type, so you need not
depend on gemmkit for it. scale is a RequantScale, either PerTensor(f32) or a
PerRow(&[f32]) of per-channel scales. zero_point joins in as an integer after
rounding. bias is an optional per-row i32 vector, added to the accumulator before
scaling.
#![allow(unused)]
fn main() {
use faer::Mat;
use gemmkit_faer::{Parallelism, RequantScale, Requantize, gemm_i8_requant};
let (m, n) = (17, 13);
let bias: Vec<i32> = (0..m as i32).map(|i| 40 * i - 200).collect();
let mut c = Mat::<i8>::from_fn(m, n, |_, _| 0);
let req = Requantize {
scale: RequantScale::PerTensor(0.05),
zero_point: -7,
bias: Some(&bias),
};
gemm_i8_requant(a.as_dyn_stride(), b.as_dyn_stride(), req, c.as_dyn_stride_mut(), Parallelism::Serial);
}
The output is C[i,j] = clamp(zero_point + round_ne(scale * (sum_k A*B + bias[i])), LO, HI), with round-half-to-even. [LO, HI] is [-128, 127] for the i8 entry and
[0, 255] for the u8 entry.
The adapter validates the requantize parameters before dispatch. It reproduces gemmkit’s own checked-entry wording. The checks cover:
- A non-finite or non-positive scale.
- A per-row scale slice of the wrong length, or one that overlaps
C. - A
zero_pointoutside the output domain. - A bias of the wrong length, or one that overlaps
C.
That validation is raw pointer math against C’s byte footprint. The adapter never
builds a C slice. This is what lets it forward negative-stride views to the raw engine
safely.
Complex GEMM (complex)
With the complex feature, gemm_cplx, gemm_cplx_with, and dot_cplx operate on
complex matrices with optional per-operand conjugation. The element type T is
Complex<f32> or Complex<f64>.
This is not a separate representation from faer’s own. faer 0.24’s c32 and c64 are
type aliases for num_complex::Complex<f32> and num_complex::Complex<f64>. This crate
re-exports the same types as Complex, with the same c32/c64 aliases, and constrains
its ComplexScalar bound over them. So a faer complex Mat reaches the adapter with no
conversion, just like a real one.
gemm_cplx is a separate entry from gemm, because the conjugation flags do not fit the
homogeneous surface. It computes C <- alpha*op(A)*op(B) + beta*C, where
op(A) = conj(A) when conj_a is set, and op(B) = conj(B) when conj_b is set.
The implementation in cplx.rs pulls the same raw parts as the real path. It threads the
2 bool flags through to gemm_cplx_unchecked. Nothing else differs, so transposed,
sub-matrix, and reversed views work identically. dot_cplx is the non-conjugated A*B
convenience.
#![allow(unused)]
fn main() {
use faer::Mat;
use gemmkit_faer::{Complex, Parallelism, gemm_cplx};
type C = Complex<f64>;
let a = Mat::<C>::from_fn(12, 9, |i, j| C::new(i as f64, j as f64));
let b = Mat::<C>::from_fn(9, 7, |i, j| C::new((i + j) as f64, 1.0));
let mut c = Mat::<C>::zeros(12, 7);
// C <- alpha * conj(A) * B + beta * C
gemm_cplx(
C::new(1.3, -0.4),
a.as_dyn_stride(), true, // conjugate A
b.as_dyn_stride(), false, // leave B
C::new(0.5, 0.7),
c.as_dyn_stride_mut(),
Parallelism::Serial,
);
}
Under complex plus epilogue, there is gemm_cplx_fused. It adds an optional bias in
one pass: C <- alpha*op(A)*op(B) + beta*C + bias. The bias is a Bias::PerRow (length
A.rows) or a Bias::PerCol (length B.cols). gemmkit adds it verbatim, to every
element of that row or column, and never conjugates it.
There is deliberately no activation parameter. An ordering activation such as ReLU is undefined on complex numbers, so the fused complex entry carries a bias only.
Fused bias and activation (epilogue)
With epilogue, gemm_fused computes C <- act(alpha*A*B + beta*C + bias) in a single
pass. The optional Bias is PerRow or PerCol. The optional Activation is Relu or
LeakyRelu(slope), applied last. Passing None for both gives exactly gemm. The crate
re-exports both selectors.
#![allow(unused)]
fn main() {
use gemmkit_faer::{Activation, Bias, Parallelism, gemm_fused};
let bias: Vec<f64> = (0..m).map(|i| 0.5 * i as f64 - 2.0).collect();
// C <- relu(1.3 * A*B - 0.7 * C + rowbias)
gemm_fused(
1.3, a.as_dyn_stride(), b.as_dyn_stride(), -0.7,
c.as_dyn_stride_mut(),
Some(Bias::PerRow(&bias)),
Some(Activation::Relu),
Parallelism::Rayon(0),
);
}
For f32/f64, the fused result is bit-identical to plain gemm followed by the same
scalar map, for every shape. The epilogue folds into the same kernel’s store without
perturbing the accumulation order. Serial and parallel runs also agree bit-for-bit today.
That agreement is a property of today’s implementation, not a hard guarantee. The
reproducibility contract itself covers only a fixed configuration, and the worker count is
part of that configuration.
For f16/bf16 (under half), the fused result is more precise instead of identical. A
separate gemm() call followed by a narrow map rounds to the narrow type, widens back,
and rounds again. The fused path skips that extra rounding. The bias and slope widen
exactly to f32, the epilogue applies in f32, and the result rounds once to the narrow
output. So for f16/bf16 the fused result is more precise, though it is not
bit-identical to gemm followed by a narrow map. The f32/f64 bitwise guarantee above
does not extend to these narrow types. Serial and parallel runs still agree bit-for-bit
for these types too, under the same fixed-configuration reproducibility contract. The
Fused Epilogues guide has the full contract.
For an arbitrary per-element function, there is gemm_map (f32/f64 only):
C[r,c] <- f(alpha*A*B + beta*C, r, c). The closure runs once per output element, at its
final value, with (r, c) in the user frame of C.
Use gemm_map for GELU, sigmoid, clamps, or position-dependent transforms. Prefer
gemm_fused instead for a plain bias or ReLU, because it vectorizes. gemm_map pays one
indirect call per element.
Batched GEMM
faer has no rank-3 array type, so gemmkit-faer expresses batched GEMM over slices instead.
gemm_batched
takes a &[(MatRef, MatRef)] of per-element (A, B) inputs, paired positionally with a
&mut [MatMut] of C outputs. All elements share one alpha, beta, and Parallelism.
gemmkit’s pointer-array engine parallelizes the batch across elements. Its scheduler assigns whole GEMMs to workers. Each worker runs its GEMM serially and stays cache-hot for it.
#![allow(unused)]
fn main() {
use faer::Mat;
use gemmkit_faer::{Parallelism, gemm_batched};
let a = Mat::from_fn(2, 2, |i, j| [[1.0_f64, 2.0], [3.0, 4.0]][i][j]);
let b = Mat::from_fn(2, 2, |i, j| [[5.0_f64, 6.0], [7.0, 8.0]][i][j]);
let mut c0 = Mat::<f64>::zeros(2, 2);
let mut c1 = Mat::<f64>::zeros(2, 2);
let ab = [
(a.as_dyn_stride(), b.as_dyn_stride()),
(a.as_dyn_stride(), b.as_dyn_stride()),
];
let mut c = [c0.as_dyn_stride_mut(), c1.as_dyn_stride_mut()];
gemm_batched(1.0, &ab, 0.0, &mut c, Parallelism::Serial);
}
Element shapes may differ, a heterogeneous batch, as long as each element’s own dimensions agree. The call panics if the input and output counts disagree. It also panics if any element’s dimensions are inconsistent, naming the offending element index.
Each element re-dispatches through the full engine. So the batch reproduces a plain loop
of gemm calls. It is deterministic across thread counts, because each element runs
wholly on one worker. For the same reason, serial and batch-parallel output are
bit-identical.
There is no batched fused entry here. The ndarray adapter offers a shared-epilogue batched form, but it has no pointer-array analogue in the core. See Batched GEMM for the scheduling policy.
Prepacked operands
When one operand stays fixed across many calls, for example weights against a stream of
activations, pre-pack it once and skip the per-call repack. prepack_rhs turns a B
into a reusable PackedRhs, consumed by gemm_packed_b. prepack_lhs turns an A into
a PackedLhs, consumed by gemm_packed_a. The crate re-exports both handles.
#![allow(unused)]
fn main() {
use gemmkit_faer::{Parallelism, gemm_packed_b, prepack_rhs};
let packed = prepack_rhs(weights.as_dyn_stride()); // pack the fixed B once
for (act, mut out) in stream {
// out must be column-major-ish (|col stride| >= |row stride|)
gemm_packed_b(1.0, act.as_dyn_stride(), &packed, 0.0, out.as_dyn_stride_mut(), Parallelism::Rayon(0));
}
}
The one constraint is output orientation. A prepacked B fixes the operand roles, so
gemm_packed_b needs a column-major-ish C (|col stride| >= |row stride|). A
row-major C would swap the A/B roles and invalidate the packed RHS, so gemmkit
rejects it. Symmetrically, gemm_packed_a needs a row-major-ish C. For a mismatched
output layout, fall back to plain gemm.
Under epilogue, the prepacked entries have fused twins: gemm_packed_b_fused and
gemm_packed_a_fused. Each takes the same Bias/Activation as gemm_fused, off the
same handle. The Prepacked Operands guide
explains the reuse model.
When to reach for this adapter
faer ships its own matmul. For a plain f32/f64 product of 2 faer matrices, use that
instead. This adapter earns its place when you need something the core faer operator
does not offer, on faer’s own types, without leaving the faer ecosystem:
- Extra element families:
i8 -> i32integer GEMM, and requantization that fuses straight down to ani8oru8output. - Fused epilogues: the kernel computes bias and activation, or an arbitrary
per-element closure, in the same pass as the product, not as a second sweep over
C. - Prepacking across calls: pack a fixed weight matrix once, then reuse it over a long inference loop.
- A shared tuning surface: all 3 gemmkit adapters sit on the same engine, so one
GEMMKIT_*environment profile from gemmkit-tune applies to all of them. See Tuning Knobs for the knob surface.
If none of those apply, use faer’s built-in matmul instead. It is the simpler choice. This adapter supplements it. It does not replace it.
Tuning with gemmkit-tune
gemmkit-tune is a small command-line autotuner. You run it once on the machine that
will run your gemmkit workload. It sweeps gemmkit’s runtime GEMMKIT_* knobs. For each
knob, it measures a representative set of matrix shapes, then writes a shell profile of
export GEMMKIT_*=... lines. Source that file before you launch a gemmkit binary. The
already-built binary is then retuned for the host, with no recompile, no code change,
and no dependency on how the binary was distributed.
Why it exists
gemmkit’s compiled-in defaults are not arbitrary. Every threshold in
gemmkit::tuning was hand-calibrated against real
measurements on one reference machine. The value that is optimal on that machine
encodes its cache sizes, its core count, and its ratio of DRAM bandwidth to compute.
A different CPU has a different L2, a different core count, and a different memory bandwidth. Its crossovers land in different places, for example:
- the
kat which packing starts to pay - the problem size at which a shared pre-pass beats per-worker packing
- the byte floor below which a bandwidth-bound gemv should stay single-threaded
gemmkit-tune re-discovers those crossovers on the silicon in front of it and pins them.
There is a useful corollary. Run the tool on the reference machine and it re-selects essentially the shipped defaults. The report says every knob kept its default. That is the correct outcome, not a disappointment. It validates the tool.
The payoff comes on a machine unlike the reference machine. Examples include a laptop, a cloud instance sharing a socket, a wide Graviton, and an Apple part with a shared cluster-L2 and no L3. The further the deploy host is from the reference machine, the more there is to win.
The sweep contains no randomness. Given the same machine and the same flags, it produces the same profile every time. A profile is therefore a reproducible artifact you can commit alongside a deployment.
Why the deploy host, and never build.rs
The knobs are calibrated against the CPU that executes the work, so the tool has to run there. 2 consequences follow.
First, do not run it in a build.rs. The build host is usually not the deploy host. You
compile on a CI runner or a developer laptop, then ship the binary to something else
entirely. A build.rs autotune would measure the builder instead, then bake the
builder’s crossovers into a binary that runs somewhere with a different cache
hierarchy. A cross-compiled build cannot even execute the target’s code. The whole point
is to measure the real silicon, so the tool must run on it.
Second, the knobs are plain runtime env vars, read once per process at startup. You do
not need to rebuild to apply a profile. The same shipped binary reads whatever
GEMMKIT_* values are in its environment. Tune the host, source the profile, and
launch. The engine reconfigures itself.
Install and a first run
Install the binary and run it on the target machine:
cargo install gemmkit-tune
gemmkit-tune
A full run takes a minute or two and prints a report as it goes. By default it writes
gemmkit-tune.env in the current directory. Source that file in the shell that launches
your application:
source gemmkit-tune.env
./your-gemmkit-app
That is the whole workflow. Everything below is refinement. It covers bounding the run, matching it to how you deploy, and knowing when to do it again.
The flags
gemmkit-tune takes no positional arguments. All behavior is on 5 flags.
--threads <n>
Tune for this worker count. Every parallel probe runs under Parallelism::Rayon(n), so
the scheduling knobs (the oversample factors and the auto worker-count ramp) are
optimized for exactly that width. The default is the machine’s available parallelism.
It is capped at the machine width. You cannot tune for more workers than the box
physically has. The stamped worker count is always truthful.
The rule of thumb: run the sweep with the same worker count your application will
actually use. If your app pins Parallelism::Rayon(8), pass --threads 8. A profile
tuned for 32 workers can pick a different scheduling grain than one tuned for 8, and the
mismatch costs you.
--time-budget <dur>
Cap the sweep and coarsen it to fit. It accepts 30s, 2m, 1h, or a bare number of
seconds. Under a budget the tool takes fewer timing repetitions per estimate: 7 by
default, 5 under 90 seconds, and 3 under 30 seconds. Once the deadline passes, it stops
sweeping and lists the remaining knobs as skipped for “time budget exhausted”.
Use it when install time must be bounded. Leave it off for the most reliable profile. If the budget is so small that not even one knob gets measured, the report says so and tells you to raise it.
--large-matrices <GiB>
Opt into the 2 memory-heavy probes, GEMMKIT_K_STREAM_MAX and GEMMKIT_SHARED_LHS_MNK,
with the given GiB figure as the budget for the giant gemv matrices. These knobs only
bite in an expensive regime. One needs a gemv output that spills the last-level cache,
which means multi-gigabyte matrices. The other needs a very high-FLOP shape above the
shared-pre-pass crossover. Both are off by default.
If the budget you pass cannot hold the required probe, the tool skips that knob cleanly
and prints the exact GiB figure to re-run with. The GEMMKIT_K_STREAM_MAX probe is
64-bit only. GEMMKIT_SHARED_LHS_MNK still sweeps on 32-bit. Start with 4 or 8 and
follow the advice if it asks for more. See Inside the Sweep for
what these 2 probes actually do.
--out <path>
Write the profile somewhere other than ./gemmkit-tune.env.
--dry-run
Run the full sweep and print the report, but write no profile. This is good for
previewing what a machine would choose before you commit a file. -h / --help prints
usage.
Anatomy of the emitted profile
The file is a header comment, followed by one export line per swept knob, followed by
a footer listing what was not swept. It looks like this. The values depend entirely on
the host:
# gemmkit-tune profile. Source this before you run a gemmkit app: `source <this file>`
# generated 2026-07-19 14:12:03 UTC by gemmkit-tune 0.1.2
# host: 16 logical cores; L1d 32 KiB, L2 1024 KiB, L3 32 MiB; page 4 KiB
# tuned for 16 worker(s)
export GEMMKIT_MC_REG_PANELS=8 # default (1.00x)
export GEMMKIT_LHS_PACK_THRESHOLD=256 # tuned (1.07x)
export GEMMKIT_PAR_MNK_PER_WORKER=4000000 # tuned (1.03x)
# not swept on this host:
# GEMMKIT_PARALLEL_THRESHOLD: serial/parallel break-even is strongly shape-dependent ...
# GEMMKIT_DEEP_KC_BYTES: narrow-only (f16/bf16 deep-contraction twin); no narrow probe here ...
The header records the host it was tuned on. It lists the logical core count, the 3 cache levels, the page size, the worker count, the tool version, and a UTC timestamp. That stamp tells you, months later, whether a profile still matches the box you are looking at.
Each export line carries a trailing comment. The comment marks the line default when
the winner equals the shipped default, or tuned when the winner moved. It also names
the measured speedup over the default. A knob that kept its default is still written.
The profile is therefore a complete, self-documenting record of the decision, not just
the deltas.
The values are always raw integers. An “unbounded” winner is written as its numeric
value, never as a MAX alias, because gemmkit’s env parser reads a plain decimal
integer. A malformed GEMMKIT_* value is not fatal. gemmkit warns once on stderr and
falls back to the compiled default, so a hand-edited typo degrades to the default
instead of crashing.
Deploying the profile
gemmkit reads each GEMMKIT_* variable once, on first access, then caches it for the
life of the process. The profile must therefore be in the environment before the first
GEMM call. Sourcing it before launch guarantees exactly that. There are 3 common ways to
arrange it.
Shell profile or launch script. This is the direct case. Run
source gemmkit-tune.env in the same shell that runs the binary, or in the service’s
launch script. Full shell semantics apply, so the file drops in unchanged.
Container entrypoint. Bake the profile into the image and source it in the
entrypoint before you exec your app. Every container then starts pre-tuned. Tune on a
host that matches the container’s runtime hardware, not the image builder.
systemd EnvironmentFile. This works, with one caveat. systemd’s EnvironmentFile
parser wants bare NAME=value lines. It does not understand the export keyword or the
trailing # tuned (...) comments. Convert the profile first, for example with
grep '^export' gemmkit-tune.env | sed -e 's/^export //' -e 's/[[:space:]]*#.*$//' > gemmkit.env,
then point EnvironmentFile= at the result. The #-comment header is fine to leave in.
Only the assignment lines need this transform.
One note on precedence. A GEMMKIT_* env var is overridden by a programmatic
tuning::set_* call in the application. If your app tunes a knob in code, the profile
does not change that knob. This is deliberate: self-tuning code wins over a deployment
profile. An app that wants the profile to apply simply does not call the setters. See
Tuning Knobs for the full precedence order.
Run in a clean environment
Any GEMMKIT_* variable already set in the tuning shell skews the sweep, because
gemmkit reads it while measuring the baseline. The tool neutralizes the knobs it sweeps
and warns you about any GEMMKIT_* variable it finds set. The reliable move is still to
tune from a shell with none of them present. Do not source a previous
gemmkit-tune.env and then re-run the tool in the same shell. That is exactly the
polluted baseline the warning is about.
When to retune
Retune when the thing the profile was stamped for changes. That means a different deploy machine, since a different CPU brings different cache sizes, which is the whole reason the tool exists. It also means a different worker count: a profile tuned for 8 workers is not the right one for 32.
A gemmkit or gemmkit-tune version bump can also add knobs, so regenerate the profile after upgrading. A profile that no longer matches its header stamp is a profile to throw away and regenerate.
To understand what the sweep actually measures, how it scores candidates, and how to sanity-check a profile against your own workload, read Inside the Sweep.
Inside the Sweep
Tuning with gemmkit-tune covers how to run the tool. This page is the mechanism behind the sweep. It covers:
- what the sweep measures
- how it decides a winner
- why it is biased toward the shipped default
- how to check that a profile actually helps you
One knob at a time
The sweep is a set of independent one-dimensional searches, not a joint optimization. For each knob, every other knob stays at its default. The tool measures the knob’s candidate values back to back and chooses a winner. Then it restores the knob to its default before it sweeps the next one. Each knob is therefore evaluated against an otherwise-default engine.
This is a deliberate simplification. A full joint search over about 30 knobs is combinatorially hopeless, and it would be dominated by noise. The crossovers these knobs gate are, by design, individually meaningful, so a one-dimensional search suits them.
The cost is that the sweep does not explore cross-knob interactions. This is an acceptable trade, because the defaults already sit at a good joint operating point. The tool’s job is only to move individual crossovers to where the host puts them.
The tool measures candidates in a fixed order with no randomness. The default value goes first, since it is the tie-break incumbent, then each distinct extra candidate follows. It rebuilds the buffers for every shape with the same seeds. An A/B comparison between 2 candidate values therefore sees byte-identical inputs, and any machine drift cancels out.
The sweep table stays in lockstep with the engine
gemmkit enumerates its knobs in one place: gemmkit::tuning::knob_env_names(). This
machine-readable registry is the single source of truth for every GEMMKIT_* name. The
tuner classifies each knob as either TUNED, meaning it has a real sweep, or NEVER_TUNED,
with a reason. A test asserts that these 2 lists partition knob_env_names() exactly.
No knob is missing, and no entry is stale.
The practical guarantee is direct: a knob added to gemmkit cannot silently escape the autotuner. The build fails until someone writes a sweep for it, or records why it is deliberately left alone. So when you read the tool’s list of swept knobs, you are reading a list the compiler keeps honest against the engine.
What is measured, and in what unit
Each candidate’s score is a throughput. A GEMM, i8, or batched probe is scored in
GFLOP/s, computed as 2*m*k*n per call, times the batch count for a batched probe. A
gemv probe is scored in GB/s instead, because a matrix-times-vector is bandwidth-bound,
and the bytes moved is the honest figure of merit there.
A single-shape estimate is deliberately robust. First, the tool warms up the probe closure a few times. Then it auto-sizes an iteration count so one timed batch runs for about 50 ms. It times several such batches and reports the median rate, along with the observed min and max. The min and max are not cosmetic. They record the run-to-run spread, and the winner logic uses that spread to stay honest under noise.
Scoring: geometric mean over a probe-shape set
A knob is never judged on one shape. Each knob carries a small set of probe shapes. The tool chooses these shapes so the knob actually binds and so its crossover is bracketed on both sides. A candidate’s score is then the geometric mean of its per-shape median throughputs.
The geometric mean gives every shape equal weight, regardless of its absolute size. One big shape therefore cannot flatter a value that only helps that shape. A winner has to be a broad improvement across the whole set. The worst shape’s spread carries through the geomean, so the noise gate stays conservative across the whole set instead of trusting only the calmest shape.
The probes are picked per knob to make the knob bind. A few examples:
| knob | probe family | why these shapes |
|---|---|---|
MC_REG_PANELS | square f32, 512 to 3072, parallel | the 3072 tier stresses A-macro-panel residency in L2 |
LHS_PACK_THRESHOLD | col-major A, candidates 32..MAX | brackets both the aarch64 low-reuse plateau and the x86 default of 1024 |
SMALL_K_THRESHOLD | skinny large-m,n small-k, e.g. 4096x16x4096 | k straddles the in-place / packed-driver crossover |
GEMV_PARALLEL_BYTES | huge-m gemv, GB/s | spans the cache-resident / DRAM-bound byte floor |
GEMV_TIER_STEP, GEMV_THREAD_CAP | gemv from about 2.4 to 134 MiB touched, GB/s | straddles a rung of the gemv worker ladder, since a probe set sitting entirely in one rung would score every candidate the same |
SEQ_INTERNAL_BYTES_PER_WORKER (aarch64) | batched shapes giving 96/192/384/432 KiB per batch-worker | straddles the ~128 KiB default on both sides, a two-sided validator |
I8_VNNI_MIN_PAR_MNK (x86) | square i8, 384/512/640 | brackets the VNNI / widen-fallback parallel crossover |
The tie-break is default-biased and noise-aware
Picking the highest geomean would be wrong. On a noisy machine, a 1% edge is usually luck. The winner logic instead starts at the default. It upgrades to a candidate only when that candidate’s geomean beats the current best by more than the larger of the 2 candidates’ measured spreads. Run-to-run noise cannot clear that bar by construction, so it can never rewrite a knob. An exact tie keeps the default.
There is a further margin for the “auto” knobs, whose default is 0. These knobs
derive their value from the machine, such as LLC size, core count, or page size. A
fixed candidate must beat auto by an extra 5% beyond noise. These auto derivations
adapt to shapes the probe set does not cover. A fixed number that wins by a hair on the
probes is not worth trading that adaptivity for.
This default bias is the right call under noise. The default is a known-good, deliberately chosen value, and the tool often runs unattended on a machine nobody is watching. The asymmetric bar means the worst case is that the tool just reproduces the defaults. It never regresses you into a measurement artifact.
The sweep also has no RNG anywhere, so a run is safe to trust. At worst it does nothing. When it does move a knob, a real and repeatable improvement cleared the noise.
How the time budget caps and coarsens the sweep
--time-budget acts in 2 ways.
First, it coarsens each estimate up front: 7 timing repetitions with no budget, 5 under 90 seconds, and 3 under 30 seconds. This trades a little measurement stability for speed.
Second, it enforces a hard deadline. Before each knob, the tool checks the clock. Once the deadline has passed, it stops starting new sweeps and records every remaining knob as skipped for “time budget exhausted”.
A tight budget therefore both blurs the measurements it does take and drops knobs off the tail. With no budget, the sweep runs to completion at full repetitions.
Which knobs are skipped, and why
Some knobs are never swept. The report and the profile footer say why for each:
PARALLEL_THRESHOLD: the serial/parallel break-even is strongly shape-dependent. A singlem*n*kscalar cannot fit every aspect ratio, so the tool keeps the calibrated cross-shape default instead of auto-fitting it. ContrastGEMV_THRESHOLD, which is a clean binary on/off decision and is swept.DEEP_KC_BYTES: this gates the f16/bf16 deep-contraction twin, and the tuner runs no narrow-type probe. Its auto default derives from L2, a machine property. Override it directly if you need to retune the narrow deep-kengage point.PREFETCH_MIN_BYTES: this gates the driver’s C-tile prefetch. Its auto default derives from the detected LLC, a machine property, and probing the crossover would need a beyond-LLC working set on every candidate. Override it directly to retune the engage point (usize::MAXdisables the prefetch, and1forces it on).
Other knobs are inert on the current target and get skipped for that reason.
SEQ_INTERNAL_BYTES_PER_WORKER is read only by the aarch64 batched-split planner. It is
swept there, and it is inert and skipped on x86. I8_VNNI_MIN_PAR_MNK gates the x86
VNNI small-parallel fallback, which no other target’s i8 kernel has. NC_NO_L3_PANELS
is consulted only on a machine with no L3. It is swept there, and it is inert and
skipped on an L3 host.
The 2 heavy knobs are skipped unless you pass --large-matrices.
What –large-matrices unlocks
2 knobs only matter in a regime that is expensive to reproduce, so they are opt-in behind a memory budget.
K_STREAM_MAX caps how far the axpy-gemv output stays register-blocked. It only wins
once the output is clearly DRAM-bound. Its probe therefore fixes the output at about
twice the last-level cache. A 1x-LLC output sits on the cache boundary and measures
nothing decisive, so the probe avoids that size. The probe then sweeps k around the
calibrated ceiling.
That output size is fixed, not budget-scaled, so reaching it takes multi-gigabyte matrices. If the budget you passed cannot hold the largest probe, the tool skips the knob. It prints the GiB figure you need to re-run with, rounded up. On a 32-bit target, it skips the knob outright, because the matrices do not fit the address space at all.
SHARED_LHS_MNK gates the shared-LHS pre-pass. This pre-pass removes redundant
per-worker A-packing, but it adds a fork-join barrier. So it only pays off above a
large m*n*k value (about 8e9 on x86). Its probes use tall, high-FLOP shapes above
that crossover.
The tool neutralizes both knobs during the ordinary sweeps, whether or not it is sweeping them itself. This way a stale env value cannot skew a baseline that reads them.
Reading the terminal report
The report opens with a one-row-per-knob summary table. Its columns are knob, unit,
shape count, default, winner, speedup, and a result column that reads keeps default or
-> <value>, with moved knobs highlighted.
Below the table, a candidate-detail block prints the full sweep landscape for each knob. It lists every candidate’s geomean median. A leading mark flags the default value, and a separate mark flags the winner, so you can see how flat or sharp the optimum was.
After that comes the skipped list with reasons, then a footer. The footer counts how many knobs were swept, how many moved off default, and how many were skipped. On the reference machine, the footer notes that all knobs kept their defaults, which is expected, and the profile reproduces them.
Sanity-checking a profile
The sweep measures synthetic, roughly-square probes. That is the right choice for finding a machine’s crossovers. Your workload has its own shapes, though, so confirm the win transfers before you trust a profile in production.
There are 2 ways to check. The direct one: time your own application with and without
gemmkit-tune.env sourced, on the deploy host, and compare. The reproducible one: run
gemmkit’s criterion benches, which cover 5 headline groups (sgemm, dtypes, gemv,
prepacked, batched) under a saved baseline:
cargo bench -p gemmkit -- --save-baseline stock
source gemmkit-tune.env
cargo bench -p gemmkit -- --baseline stock
If a knob moved and something you care about regressed, the profile is a plain text
file. Delete or comment out that one export line and keep the rest. The header stamp
and per-line tuned/default tags make it easy to see which line to touch.
Design Goals and the Big Picture
gemmkit is a pure-Rust GEMM engine. It computes C <- alpha*A*B + beta*C over &[T]
slices with explicit strides, or over raw pointers with isize strides. At runtime it
selects the best instruction set the machine offers. On x86-64 that is AVX-512 or
FMA/AVX2, with dedicated VNNI and BF16 dot kernels. On aarch64 it is NEON. On wasm32 it
is simd128. A portable scalar path runs everywhere else. The workspace uses edition
2024, rust-version 1.89, and the MIT OR Apache-2.0 license.
3 kinds of callers shape the API surface. Application code uses the safe slice
entries, such as gemm, gemm_fused, and gemm_i8. These entries validate everything
before any unsafe work runs. Linear algebra libraries use the *_unchecked tier. This
group includes the shipped ndarray, nalgebra, and faer adapters, plus anything
built the same way. The *_unchecked tier trusts the caller’s own invariants and
accepts layouts the safe tier cannot express. Constrained deployments get a core that
builds #![no_std] with zero mandatory dependencies, down to wasm32 with compile-time
SIMD. Everything else in this chapter follows from 4 design tenets.
ARCHITECTURE.md
states them compactly under “Goals and constraints”. This chapter expands each one with
the reasoning behind it.
Safety at the boundary
The checked entries run validate_gemm_views (gemmkit/src/api.rs) before touching any
unsafe code. Its panic catalog is deliberately exhaustive:
- Shape mismatch: gemmkit checks
A.cols != B.rows,A.rows != C.rows, andB.cols != C.cols. Each panic message names the 2 numbers that disagree. - A view addressing outside its slice: for A, B, and C, gemmkit computes the
highest offset each view’s strides can reach (
extent). It checks that offset against the slice length. A view that needs more elements than its slice holds panics with the exact shortfall. - Negative strides: the safe tier rejects these, with a message that points to
gemm_unchecked. A&[T]view with a negative stride would address below the slice start. The safe extent math cannot vouch for that address. - A self-aliasing output: a stride on
Ccan map 2 distinct(i, j)pairs to the same offset. A zero stride is the common case. This is fine onAorB, since a broadcast input is only read. OnCit panics, because the parallel driver assumes output tiles do not overlap. Writing through such a view would create a data race that entirely safe code could reach. CoverlappingAorB: gemmkit checks this as byte ranges. This stays exact even when C (i32) and A/B (i8) have different element sizes. The fused entries also check bias length (PerRow= m,PerCol= n) and bias and C disjointness.- A problem too large to size: broadcast strides allow logical dimensions near
isize::MAX. The internal pack-buffer sizing can then overflowusize. Every such product panics, fail-closed, at the element-to-byte chokepoint (Workspace::regions), instead of wrapping and under-allocating.
The panic wording is itself a tested contract. The correctness suite asserts the exact strings. Changing an error message is therefore a deliberate, visible act.
The *_unchecked tier exists because this validation is only meaningful at one
boundary. The adapters pull pointers and strides straight out of ndarray, nalgebra,
and faer types. Those types already guarantee validity through their own invariants,
so re-checking would be pure overhead. The slice-based checks could not even express
what the adapters need. For example, a reversed ndarray view has a negative stride and
a base pointer in the middle of its allocation. Both are legal and sound for the raw
engine. Safety is therefore paid exactly once, either by gemmkit’s validator or by the
caller’s type system, never both.
The unchecked entries are ordinary unsafe fns with documented contracts. The guide
covers them in The Unchecked Tier.
Reproducible, not bitwise, parallel results
gemmkit promises reproducible parallel results. For a fixed input, a fixed environment, and a fixed configuration, the output does not depend on the worker count. Three mechanisms carry this promise.
The first mechanism is the blocking sizes. KC and NC come from the cache model
alone and never depend on the thread count. MC only ever changes by an MR-aligned
regroup. Every run therefore reduces each output element in the same fixed order.
The second mechanism is the reduction order. One worker reduces each output element start to finish, over the full depth. The engine never splits a reduction across workers.
The third mechanism is demand-driven scheduling. Packed bytes do not depend on who packs them, so any worker can take any tile. Which worker computes a tile varies from run to run. The result never does.
Just as important is what gemmkit does not promise. Bitwise serial-versus-parallel identity is not part of the contract. It happens to hold on the driver paths today, because serial and parallel runs execute the same kernel over the same blocking. Nothing pins this fact in place. Bitwise identity across configurations is explicitly out too. Change a tuning knob, and the blocking, and so the floating-point summation order, may legitimately change.
Bitwise identity across kernels of the same type is out as well. The bf16 vdpbf16ps
dot kernel reshapes the accumulation rounding relative to the widen-and-FMA path.
gemmkit holds that kernel to a tolerance, not to exact equality.
Why draw the line there? A permanent promise of bitwise serial-versus-parallel identity would forbid useful engineering. It would rule out dot-product instructions that fuse depth pairs. It would also rule out a blocking choice that takes parallelism into account. Such a promise would buy nothing a user can rely on across machines or library versions anyway.
Parallelism-aware blocking is no longer hypothetical. The driver already has a
job-depth floor that shrinks MC with the worker count, to keep the parallel job list
deep enough. This stays bitwise-reproducible precisely because the weaker contract
leaves room for it. MC still stays an MR multiple, so the microtile set and every
element’s KC-shaped accumulation order stay unchanged.
Reproducibility under a fixed configuration is the property tests can assert. Deployments
can depend on it, and the engine can keep it while it evolves. Where a path
can promise more cheaply, it does. gemv partitions output rows and is bit-identical
across worker counts. The i8 integer path uses exact arithmetic, so its VNNI dot
kernel is bit-identical to the widen kernel.
No macros, no transmute at the variation points
The engine varies along 3 axes: instruction set, element type, and operation
family. Each axis is an ordinary trait. Simd and SimdOps cover the ISA. Scalar
covers the element type. KernelFamily covers the operation family.
Dispatch slots are typed function pointers, cached in OnceLocks. Microtile geometry
is a pair of const generics, chosen at the dispatch site. Here is what a “kernel
variant” actually looks like, from gemmkit/src/dispatch/float.rs:
#![allow(unused)]
fn main() {
unsafe fn gemm_f32_fma(t: Task<f32>, par: Parallelism, ws: &mut Workspace) {
// MR = 2*8 = 16, NR = 6 -> 12 acc + 2 lhs + 1 rhs = 15 of 16 YMM
unsafe { run_typed::<f32, Fma, 2, 6>(Fma, t, par, ws) }
}
unsafe fn gemm_f32_avx512f(t: Task<f32>, par: Parallelism, ws: &mut Workspace) {
// MR = 2*16 = 32, NR = 12 -> 24 acc + 2 lhs + 1 rhs = 27 of 32 ZMM
unsafe { run_typed::<f32, Avx512F, 2, 12>(Avx512F, t, par, ws) }
}
}
That is the entire per-(type, ISA) surface. Each variant is one wrapper that names a token and a tile. The alternative, macro-stamped or hand-copied per-ISA kernels in the C BLAS tradition, was rejected for its cost to review and to extend.
Traits and const generics leave exactly one generic microkernel to read, step through,
and fix. A scheduling improvement lands once, and every ISA inherits it. The compiler
type-checks every monomorphization. The OnceLock slots hold typed function pointers,
not type-erased ones, so a signature drift is a compile error, not a latent transmute
bug.
Extension follows the same shape. A new ISA needs a zero-sized token, its SimdOps
impls, and one arm per selection ladder. A new element type needs a Scalar impl, a
family (or a reuse through the widen/narrow seam), and a dispatch slot. The driver,
packing, and blocking never change. A test (gemmkit/tests/open_closed.rs) enforces
this by driving the driver with a second, trivial family. 2 follow-up pages walk each
seam in detail: SIMD Tokens and ISA Dispatch and
Scalars and Kernel Families.
no_std and a zero-mandatory-dependency core
With default features off, the core crate builds #![no_std]. It needs only core and
alloc, and depends on nothing else. Every optional feature pulls in at most one crate:
| Feature | Dependency added | What it buys |
|---|---|---|
std (default) | raw-cpuid (x86/x86-64 targets only) | runtime cache and CPU-feature detection, GEMMKIT_* env knobs, the thread-local workspace pool |
parallel (default) | rayon | Parallelism::Rayon multi-threading |
half | half | f16/bf16 mixed-precision GEMM |
complex | num-complex | c32/c64 complex GEMM |
int8 | none | i8 -> i32 integer GEMM |
epilogue | none | fused bias/activation/map epilogues (requantize additionally needs int8) |
wasm_threads | none beyond parallel | an explicitly sized rayon pool on threaded wasm |
Without std, compile-time target features replace runtime CPU detection. The env
knobs turn off, though the programmatic tuning::set_* setters still work, since they
are plain atomics. A per-call workspace replaces the thread-local pool.
A kernel this low in the stack should not force a dependency policy on its hosts. An embedded or wasm deployment gets the same driver, the same families, and the same reproducibility contract as a desktop build. It loses only the machinery that genuinely needs an OS. The practical how-to lives in no_std and WebAssembly.
The workspace map
5 crates release in lockstep at version 0.1.2, plus a fuzzing crate that deliberately sits in its own workspace root:
| Path | Crate | Role |
|---|---|---|
gemmkit/ | gemmkit | The core GEMM engine (everything this chapter describes) |
gemmkit-ndarray/ | gemmkit-ndarray | Zero-copy adapter over ndarray (>= 0.17.1) views |
gemmkit-nalgebra/ | gemmkit-nalgebra | Zero-copy adapter over nalgebra 0.35 matrices |
gemmkit-faer/ | gemmkit-faer | Zero-copy adapter over faer 0.24 matrices |
gemmkit-tune/ | gemmkit-tune | Install-time autotuner binary emitting a GEMMKIT_* env profile |
gemmkit/fuzz/ | gemmkit-fuzz | cargo-fuzz targets, nightly-only, excluded from the stable workspace |
The adapters are thin by design. Each one pulls the matrix pointer and strides straight
out of the host library’s native view. That view may be C-order, F-order, general
strides, or reversed strides, and the adapter never copies data. Each adapter then
forwards to the *_unchecked engine,
relying on the host type’s own invariants for the validation the safe tier would
otherwise do. Each adapter also forwards the same-named Cargo features (parallel,
wasm_threads, half, complex, int8, epilogue) to gemmkit, so the feature story
stays identical everywhere. The adapter chapters cover their full surfaces. See
ndarray,
nalgebra, and
faer.
gemmkit-tune is the out-of-process calibrator. Every heuristic threshold in the
engine is a runtime knob (see Tuning Knobs). The
compiled defaults were calibrated on one machine, so the tuner exists to redo that
calibration on yours.
Run the tuner binary once on the deploy host. It sweeps each knob over a set of probe
shapes, then writes a gemmkit-tune.env profile of export GEMMKIT_*=... lines. Source
that file before you launch your application. This needs no recompile and no
build-time coupling. The only contract between the tuner and the library is the
documented env-var surface. The tuning::knob_env_names registry keeps that contract
honest, since the tuner’s sweep table is checked against it. The
gemmkit-tune chapter has the practical
guide.
The fuzz crate sits outside the workspace on purpose. cargo-fuzz needs nightly, for
build-std and AddressSanitizer. Excluding the fuzz crate keeps cargo test --workspace
and the MSRV build on stable.
This chapter and ARCHITECTURE.md
The repository’s ARCHITECTURE.md is the compact map. It gives the layer table, the
call path, the seams, and one section per subsystem. It is written for a reader who has
the code open in another pane. This book chapter is the guided tour of the same material.
It uses the same layer labels and the same file references, but leaves room for the
reasoning, the rejected alternatives, and worked examples. When the two disagree, the
code wins, and both documents have a bug.
Read The Layer Stack next for the structure. Then read Life of a GEMM Call for the motion.
The Layer Stack
Every module in the core crate opens by declaring its place in a stack. api.rs says
“Public core API (layer L8a)”. driver.rs says “The generic GEMM driver (layer L5)”,
and so on down to simd.rs at L0. The labels are not decoration. They write the
crate’s dependency discipline where a reader cannot miss it. The map below lists the
modules in dependency order, which is what makes the downward claim checkable. This
page walks the stack from the bottom up. By the time it reaches the public API, every
word the API uses has already been defined. The next page,
Life of a GEMM Call, traverses the same stack in the other
direction, following one call.
L8a api safe slice entries, *_with, *_unchecked; MatRef/MatMut
L7 dispatch runtime ISA selection, one memoized fn pointer per type
L6 special gemv, small-k, small-m,n, batched reroutes
L5 driver the generic 5-loop blocked GEMM, one for all families
L4 kernel KernelFamily seam (float/mixed/int/complex) + Epilogue
L3 cache topology detection + BLIS analytical blocking
L2 parallel worker-count resolution, JobCursor work distribution
L1 pack micropanel packing primitives
L0 simd ISA tokens + SimdOps vocabulary; scalar: Scalar/Acc types
--- cross-cutting: tuning (GEMMKIT_* knobs), workspace (buffers)
2 placements are worth calling out, because they are what keep every arrow pointing
down. parallel sits low, below the kernel families, the cache model, and the driver,
because it is self-contained worker vocabulary. The Parallelism policy enum, the
Ptr Send-pointer wrapper, and the JobCursor depend only on tuning. kernel,
driver, special, and dispatch all reach down to them. pack sits below kernel
because the families’ pack hooks build on the packing primitives, not the other way
around.
L0: the vocabulary, scalar.rs and simd.rs
The bottom layer defines what the rest of the crate is allowed to talk about.
gemmkit/src/scalar.rs holds the data-type seam, and it is deliberately tiny:
#![allow(unused)]
fn main() {
pub trait Scalar: Copy + Send + Sync + PartialEq + 'static {
/// The type in which products are accumulated. `Self` for `f32`/`f64`
type Acc: Scalar<Acc = Self::Acc>;
/// The additive identity
const ZERO: Self;
/// The multiplicative identity
const ONE: Self;
}
}
That is the whole trait: identity constants and the accumulator type. f16 and
bf16 accumulate in f32. i8 accumulates in i32. f32, f64, and the complex
types accumulate in themselves. No arithmetic lives on Scalar itself. All real math
happens vectorized in SimdOps, or in per-family scalar epilogues, so adding an
element type never drags in a scalar arithmetic surface. The refinement traits
Float, NarrowFloat, and ComplexFloat layer on the few extra capabilities specific
paths need. What scalar.rs deliberately does not know: that SIMD exists. It has no
idea its constants will end up broadcast into vector registers.
gemmkit/src/simd.rs forms the load-bearing wall. The simd/ backends join it:
avx512.rs, fma.rs, neon.rs, scalar.rs, wasm.rs, and the complex glue in
complex.rs. 3 traits split the job. Simd is a zero-sized ISA token. Examples
include Avx512F, Fma, Neon, ScalarTok, Simd128, and the dot-capable
Avx512Vnni and Avx512Bf16. Its sole method is vectorize, the #[target_feature]
trampoline. It puts runtime-selected intrinsics into feature-enabled codegen.
SimdOps<T> is the thick per-element-type vocabulary: register type, LANES,
load/store/broadcast/mul/add/fma/reduce, and the overridable accumulate_tile
schedule. KernelSimd<L, R, A, O> on top of them is the widen/narrow seam that makes
mixed precision work without a driver branch. What this module deliberately does not
know: anything above it. Its module doc states it depends only on scalar and core,
so it could be split into its own crate unchanged. SimdOps has no idea what a
micropanel, a cache, or a GEMM is.
L1: the mechanical copy, pack.rs
gemmkit/src/pack.rs holds the 2 shared packing primitives that turn a strided A or
B region into contiguous, microkernel-sized panels. These are the copies the kernel
families of L4 delegate their pack hooks to. The complex family’s plane-splitting pack
is the one exception, and it lives with its family.
The 2 primitives are pack_panels and pack_kgroup_panels. pack_panels is the
micropanel-major copy. LHS panels are mr rows tall, and RHS panels are nr columns
wide. It is the same routine for both, with the “leading” and “depth” strides swapped.
Tails are zero-filled, and a cache-blocked transpose handles strided sources.
pack_kgroup_panels is the k-group-interleaved variant the dot-product families use.
What pack.rs deliberately does not know: where its output goes. The same routine
fills a transient per-call scratch region, a shared parallel pack buffer, and a
caller-held PackedRhs that lives for the whole process. pack.rs never sees a
Workspace, a worker, or a lifetime, only dst, src, and strides. That indifference
is what makes the prepacked path byte-identical to the per-call path. Depending only on
scalar, it names no family and no cache, which is why it can sit this low.
L2: work distribution, parallel.rs
gemmkit/src/parallel.rs owns 3 things.
First, the Parallelism enum: Serial, or Rayon(n) with Rayon(0) meaning auto.
Second, workload-aware worker-count resolution. A serial gate applies below a
total-work threshold. An explicit count is honored, but capped. An auto count scales
with the total work m*n*k, rather than jumping straight to all cores. A separate
bandwidth rule covers the memory-bound matrix-vector shapes.
Third, the demand-driven machinery. JobCursor is a lock-free atomic cursor workers
pull contiguous chunks from. The job_grain and packed_block_grain knobs size those
chunks. The for_each_worker fork-join is the barrier the higher layers use.
parallel.rs also provides Ptr, the Send + Sync pointer shim that lets raw
pointers cross into rayon closures.
Depending only on tuning, parallel.rs sits below everything that calls it. The same
worker vocabulary serves kernel, driver, special, and dispatch alike. What it
deliberately does not know: what a job is. JobCursor hands out index ranges over an
abstract count. Nothing in this file mentions tiles, matrices, or families. That is why
the same cursor later schedules driver tiles, B-pack panels, A-pack row blocks, and
gemv row panels, without distinguishing between them. More in
Parallel Execution.
L3: the machine model, cache.rs
gemmkit/src/cache.rs and its backends (cache/cpuid.rs, cache/sysfs.rs,
cache/sysctl.rs) answer 2 questions. What does the cache hierarchy look like? What
blocking follows from it? Detection is a best-effort fallback chain that cannot fail.
It tries CPUID on x86, then Linux sysfs, then macOS sysctl, then a static default
calibrated on a Zen5 part. #[cfg] only ever picks the sniffing method, never the
values, and the result is memoized once in Machine.
blocking() then computes (MC, KC, NC) analytically from the BLIS model. KC is
sized so the A and B micropanels coexist in L1. MC is sized so the A macro-panel fits
L2. NC is sized so the B macro-panel fits L3. The key types are Level (with its
carefully documented shared_by contention field), CacheTopology, and the blocking
result. What this layer deliberately does not know: the thread count. blocking() has
no worker parameter, and that omission is load-bearing. Thread-count-independent
blocking is the mechanism behind the reproducibility contract described in
Design Goals. Full detail in
Blocking and the Cache Model.
L4: the operation-family seam, kernel.rs
gemmkit/src/kernel.rs and kernel/ (float.rs, mixed.rs, int.rs, complex.rs,
epilogue.rs) define KernelFamily. This is the bundle of everything that
distinguishes one kind of GEMM from another. A family bundles the Lhs, Rhs, Acc,
and Out types. It bundles the pack layout too: pack_lhs and pack_rhs, which
delegate to the L1 primitives. It bundles the microkernel, microkernel_epi. It also
bundles constants like OUT_IS_ACC and DEPTH_MULTIPLE, which tell the driver how to
block for the family.
FloatGemm<T> is the baseline. MixedGemm, IntGemm/IntGemmVnni, and ComplexGemm
are siblings that reuse the driver unchanged. This layer also owns the Epilogue
trait, with its zero-cost Identity. It owns the AlphaStatus and BetaStatus enums
too. The driver precomputes both, so the microkernel never compares floats. What a
family deliberately does not know: its own tile size. MR_REG and NR are const
generics on the microkernel method, chosen per (type, ISA) at the dispatch site three
layers up. The family compiles for any geometry, and a new tile is a new instantiation,
never a new type.
L5: the engine, driver.rs
gemmkit/src/driver.rs is the one blocked loop nest that serves every family. It has
the BLIS-order jc -> pc -> flat job list structure. It makes the adaptive packing
decisions. It can pack B per depth slice, or read B in place. It can pack A per
worker, pack A through a shared pre-pass, or not pack A at all. It also handles
prepacked-RHS consumption, through the pack_rhs_full layout the prepack API reuses.
That reuse is why prepacked and plain GEMM produce identical panel bytes.
Its public faces are run, run_epilogue, run_packed_rhs, and
run_packed_rhs_epilogue, all funneling into the private run_inner. What it
deliberately does not know: any concrete element type or ISA. The whole file is
generic over Fam: KernelFamily and a KernelSimd token. It never names f32, never
names AVX-512, and never branches on element type. That is the open/closed property.
Adding a family or an ISA leaves this file untouched. gemmkit/tests/open_closed.rs
proves this. It drives the driver with a second, trivial family the crate does not
ship.
L6: the reroutes, special.rs
gemmkit/src/special.rs and special/ (gemv.rs, small_k.rs, small_mn.rs,
batched.rs) hold the paths for shapes the register-tiling driver fits poorly. These
are matrix-times-vector, low-depth GEMM, small-m,n long-k inner products, and the
batched orchestration layer. All sit behind the same public entries and are covered in
Special Paths. What a special path deliberately does not know: why
it was chosen. The gates, gemv_threshold, small_k_threshold, and small_mn_dim,
live in the dispatch layer above and the tuning module beside it. small_k::run
cannot even tell whether it is serving gemm, gemm_fused, or gemm_map, because the
epilogue arrives as an opaque generic parameter. The batched path is the one place a
layer reaches back up. batched.rs forwards each element through dispatch::execute
(L7), so it inherits the whole ladder above. That is the single annotated exception
discussed at the end of this page.
L7: runtime ISA selection, dispatch.rs
gemmkit/src/dispatch.rs and dispatch/ (isa.rs, float.rs, mixed.rs, int.rs,
complex.rs) turn “which kernel should this machine run” into a one-time decision.
Each element type has one OnceLock<Dispatched<T>> slot. Feature detection runs once.
The winning monomorphized entry points (plain, prepacked, fused) get cached, along
with the tile geometry. Every later call is a plain indirect call through a typed
function pointer, with no transmute and no AtomicPtr<()>.
This layer also owns the Task<T> problem descriptor, the degenerate-case handling in
execute, the orientation normalization orient_transpose, and the special-path
gates. It also owns the GEMMKIT_REQUIRE_ISA pin, which forces, or fails loudly on, a
specific kernel. What it deliberately does not know: where the pointers in a Task
came from. Checked slice views and raw unchecked pointers arrive identical. Validation
happened above or not at all, and dispatch neither knows nor cares. See
SIMD Tokens and ISA Dispatch and the user-facing
Runtime ISA Dispatch.
L8a: the public boundary, api.rs
gemmkit/src/api.rs and api/ (batched.rs, cplx.rs, fused.rs, int8.rs,
map.rs, packed.rs) define several things. These are the MatRef/MatMut strided
views, the per-family safe entries, and the validate_gemm_views panic catalog. The
safe entries come in *_with (caller-owned workspace) and *_unchecked (raw engine)
variants. This layer also lowers views into Tasks.
What it deliberately does not know: everything below dispatch. The API layer cannot
see which ISA will run, what blocking will be chosen, or whether packing will happen.
After validation it hands a Task to dispatch::execute and its job is done.
Symmetrically, MatRef never appears below this layer. The rest of the crate speaks
only pointers and strides.
Why the arrows only point down
The dependency direction is the architecture’s one hard rule. Each layer is driven by
the layers above it, and knows nothing about them. simd depends only on scalar and
core. The driver never names an element type or ISA. Nothing below L7 knows dispatch
exists. Nothing below L8a has ever heard of a slice.
The rule has exactly one deliberate, annotated exception. special/batched.rs (L6)
forwards each batch element back through dispatch::execute (L7). This re-entry lets
every element inherit the same driver, small-k, small-mn, and gemv routing a
standalone gemm call would take. It avoids a second dispatch ladder maintained by
hand. It is an upward arrow by design, and the only one in the crate.
3 payoffs justify the discipline. First, extension cost. Because knowledge only
flows downward, a new ISA, element type, or family plugs in at its own layer.
Everything below it is provably untouched. The seams described in
Extension Points work only because no lower layer could have
special-cased what sits above it. Second, review locality: to audit the microkernel,
read kernel/float.rs and the SimdOps contract, nothing else. To audit the
scheduling, read driver.rs and parallel.rs. Third, testability: lower layers are
exercised in isolation. SIMD conformance tests check every token against scalar
models, and the open/closed test drives the driver with a foreign family. This is what
makes the correctness story in
Testing and Verification tractable.
The 2 cross-cutting modules
2 modules sit beside the stack rather than in it, because every layer needs them and
neither depends on anything above core/alloc.
gemmkit/src/tuning.rs is the unified knob surface. Every heuristic threshold in the
engine lives here: the serial/parallel gate, pack gates and strides, the special-path
thresholds, scheduler grains, and blocking caps. Each one resolves in this order:
per-call argument, then programmatic setter (tuning::set_*), then environment
variable (GEMMKIT_*), then compiled default. Env vars are read once and cached. A
malformed value warns on stderr and falls back rather than panics, because a perf-knob
typo must not crash the process. The full set of GEMMKIT_* names is enumerated in
the tuning::knob_env_names registry. The out-of-crate consumers, the gemmkit-tune
sweep table, the knob property tests, and the fuzz setters, assert their lists against
it. So a new knob cannot silently escape coverage. The user-facing tour is
Tuning Knobs.
gemmkit/src/workspace.rs is the scratch-memory story. Workspace is a growable
64-byte-aligned buffer. Workspace::regions carves it into per-worker (or
per-row-block) LHS regions plus one shared RHS region, with fail-closed overflow
checks at the element-to-byte chokepoint. Under std a re-entrancy-safe thread-local
pool supplies the default, so plain gemm allocates at most once per thread. The
*_with entries thread a caller-owned workspace through instead, giving zero heap
allocation after the first sufficiently large call. Without std, each call uses a
fresh workspace. Details in
Packing and Workspaces.
Life of a GEMM Call
The previous page described the stack at rest. This page follows a single call through it. The specimen is the quick-start example from the crate docs:
#![allow(unused)]
fn main() {
use gemmkit::{gemm, MatRef, MatMut, Parallelism};
// 2x3 * 3x2 = 2x2, all row-major
let a = [1.0_f32, 2.0, 3.0, 4.0, 5.0, 6.0];
let b = [7.0_f32, 8.0, 9.0, 10.0, 11.0, 12.0];
let mut c = [0.0_f32; 4];
gemm(
1.0,
MatRef::from_row_major(&a, 2, 3),
MatRef::from_row_major(&b, 3, 2),
0.0,
MatMut::from_row_major(&mut c, 2, 2),
Parallelism::Serial,
);
assert_eq!(c, [58.0, 64.0, 139.0, 154.0]);
}
This toy shape takes one of the early exits below. The walk keeps 2 problems in
mind: the 2x2x3 example above, and a 2048x2048x2048 f32 product on an AVX-512
machine. That larger product goes all the way down, through every layer. Here is the
route, compressed:
gemm(alpha, A, B, beta, C, par)
| validate_gemm_views: shapes, bounds, aliasing [api.rs]
v
Task<T>: raw pointers + isize strides
| m == 0 || n == 0 -> return [dispatch.rs]
| k == 0 || alpha == 0 -> C <- beta*C, done
v
memoized per-type kernel (OnceLock fn pointer)
| gemv shape (m==1||n==1) -> special/gemv.rs [dispatch/float.rs]
| orient: row-major-ish C -> compute C^T = B^T*A^T
| small m,n + long k -> special/small_mn.rs
| k <= small_k_threshold -> special/small_k.rs
v
driver::run [driver.rs]
jc over NC -> pc over KC (never parallel)
-> flat job list (ic row-block x jt column-tile),
workers drain a shared JobCursor, pack A/B adaptively
v
Fam::microkernel_epi: MR x NR tile in registers [kernel/float.rs]
alpha/beta epilogue store (vector fast path | scratch drain)
Stage 1: validation and lowering
gemm itself is one line. It borrows the thread-local workspace and forwards to
gemm_with (gemmkit/src/api.rs). gemm_with runs validate_gemm_views, the full
panic catalog from Design Goals. Shapes must
agree. Every view must stay inside its slice. C must address each (i, j) uniquely.
C must overlap neither input.
Then the views dissolve. Everything below this point speaks Task<T>: a Copy struct
of m, k, n, alpha/beta, and 3 raw pointers with isize row/column strides.
Transposition never exists as a flag. A transposed view is just swapped strides. When
beta == 0, the contract says C is never read, so it may arrive uninitialized.
The unsafe boundary is crossed exactly here, justified by the validation that just
ran. gemm_unchecked enters one step later, with the caller carrying that
justification instead.
Stage 2: dispatch early exits
dispatch::execute (gemmkit/src/dispatch.rs) handles the degenerate algebra while
the element type is still concrete:
#![allow(unused)]
fn main() {
if task.m == 0 || task.n == 0 {
return;
}
// k == 0 or alpha == 0 => the A*B term vanishes: C <- beta*C only
if task.k == 0 || task.alpha == T::ZERO {
T::scale_c(task.beta, task.c, task.m, task.n, task.rsc, task.csc);
return;
}
T::dispatch(task, par, ws);
}
An empty output means nothing to do. A vanished A*B term (k == 0 or alpha == 0)
degrades the call to a C <- beta*C scale. That scale never reads A or B. Within
it, beta == 0 stores zeros without reading C, which keeps the uninitialized-C
contract honest.
Only a real product reaches T::dispatch, which reads the per-type OnceLock slot.
On first use, the selection ladder probes CPU features, honoring a
GEMMKIT_REQUIRE_ISA pin, which panics rather than falls back. It then caches the
winning monomorphized entry points plus the tile geometry. Every later call is a
single indirect call. On the AVX-512 machine, f32 resolves to
run_typed::<f32, Avx512F, 2, 12>, the 32x12 tile.
Stage 3: routing in run_typed
run_typed (gemmkit/src/dispatch/float.rs) is a short gauntlet of gates, each
rerouting a shape the register-tiling driver would serve poorly.
First, gemv. If m == 1 || n == 1, and the path is not capped off through
GEMMKIT_GEMV_THRESHOLD, the call goes straight to special/gemv.rs. This happens
before orientation normalization, in the user’s original frame. gemv resolves its
own orientation. It treats the m == 1 case as the transposed rows x k problem, and
it partitions output rows itself.
Everything else is orientation-normalized by orient_transpose. If C is
row-major-ish (|csc| < |rsc|), the dispatcher rewrites the problem as its transpose:
C^T = B^T * A^T. This swaps m with n, the A/B pointers and strides, and
rsc with csc.
The identity is free. No data moves, only the descriptor changes. It buys a strong
invariant. After this point, the output’s row stride is the small one (rsc == 1
for a fully contiguous C). Each output column then occupies consecutive memory, and
the kernel walks down contiguous columns.
The microkernel’s fast store path needs exactly that: rsc == 1, so it can use
vector stores of LANES consecutive rows in a column. Every layer below optimizes
for one orientation instead of 2. The all-row-major 2048-cube hits this swap. The
engine actually computes C^T, and nobody below dispatch knows.
Then 2 more gates apply to the normalized task.
A small-m,n shape goes to special/small_mn.rs. Both dimensions must be at or below
small_mn_dim, with a contraction longer than small_k_threshold. There, each output
element is one horizontal SIMD dot. This is zero-copy when both operands stream
unit-stride along k. When one operand is strided (k > small_mn_pack_min_k), it
goes through a pack tier that copies just the offending operand.
A small-k shape, k <= small_k_threshold (16 on x86, 8 on aarch64 by default), goes
to special/small_k.rs. This computes the whole product as one in-place depth panel
over the microkernel, with no blocking or packing setup.
Whatever passes all the gates, and the 2048-cube does, enters driver::run. The
driver states its preconditions: m, n, k > 0, alpha != 0, and orientation
normalized.
Stage 4: the driver loop nest
driver::run forwards to run_inner (gemmkit/src/driver.rs) with the zero-cost
Identity epilogue. The fused entries land in the same function, with a real
epilogue instead. The driver is generic over the family and the ISA token. For this
call, that is FloatGemm<f32> and Avx512F, with mr = MR_REG * LANES = 32 and
nr = 12.
Blocking comes first. cache::topology().blocking(mr, nr, sizeof_lhs, m, n, k) yields
(MC, KC, NC) from the BLIS cache model. These are sized in packed-input elements,
sizeof(Lhs), not the accumulator, so narrow types get deeper blocks. The loop nest
then runs in BLIS order:
jcoverNC: column blocks, sized so the packed B macro-panel stays L3-resident.pcoverKC: depth slices. This loop is never parallel. All depth slices accumulate into the same C tiles. Parallelizing depth would mean a synchronized read-modify-write on C, or split reductions. Keeping depth serial lets every output element be reduced start to finish by one worker, which is half of the reproducibility contract.betaparticipates only on the first slice (pc == 0). Later slices run with an effective beta of one, and accumulate. For mixed-precision families (OUT_IS_ACC = false), there is exactly one slice,kc = k, so the running sum never rounds through the narrow output type.- A flat 1-D job list: inside each depth slice, the remaining work is
n_mcrow blocks timesn_ntcolumn tiles. These flatten inton_jobs = n_mc * n_ntindices. Workers pull contiguous chunks from a shared, lock-freeJobCursoron demand. There is no static partition, so a faster core absorbs proportionally more work. The chunk grain oversamples the worker count (job_grain). The packed-LHS path uses a row-block-alignedpacked_block_graininstead, so chunks never straddle a pack boundary. The worker count itself comes frompar.resolve(m*n*k, n_jobs). This is work-based: it scales with the total workm*n*kover a per-worker floor, rather than jumping straight to all cores. If that count would leave the job list shallower than a few chunks per worker, the driver first shrinksmc. This only cuts more, smaller row blocks, so it cannot move a result bit. Shrinkingmcdeepens the list before the cursor hands work out.
Packing is adaptive, and each side decides separately.
B is packed once per depth slice, when m clears rhs_pack_threshold. The packed
panel is reused across all n_mc row blocks, so the copy only pays off when that
reuse is high. Otherwise B is read in place, through its original strides. When
packing does happen, it is itself parallel. Workers pull nr-wide column panels from
a cursor. The for_each_worker join doubles as the write-before-read barrier. Packed
B is the one buffer all compute workers share, so this barrier matters.
A has 3 modes. Each worker can pack the row block it is working on into its own
private workspace region. This is forced when rsa != 1, or when the block is a
partial mr multiple. It is chosen otherwise when per-worker column reuse, or a
TLB-hostile column stride, makes it pay off. On large parallel problems, a shared
pre-pass can instead pack each row block exactly once. It packs into a per-block
region behind its own barrier (the shared_lhs_mnk gate). This eliminates redundant
per-worker packing. When reuse is too low to pay for any copy, A is read in place.
Sizing for these regions happens up front, through Workspace::regions, with the
fail-closed overflow checks noted earlier. On a no-pack route, the workspace is not
even touched.
For each job, the worker resolves its A panel, packed or in-place. It locates the B
panel: per-call packed, prepacked buffer, or in-place. It then calls the microkernel
for every mr-row strip of the block. All of this runs inside simd.vectorize, so
the entire strip executes in target-feature codegen.
Stage 5: the microkernel and its store
Fam::microkernel_epi (gemmkit/src/kernel/float.rs, microkernel_impl) computes
one MR x NR tile. For this call, that is 32x12 f32 values, held in 24 ZMM
accumulator registers as a [[Reg; MR_REG]; NR] array.
A full-width tile runs SimdOps::accumulate_tile, the ascending-k
fused-multiply-add schedule. This is the seam a load-bound ISA like NEON overrides,
with a software-pipelined variant that reorders loads, never arithmetic. An edge
column tile instead takes a runtime-bounded loop. That loop reads exactly nr_eff
columns, so an unpacked B is never read past its last real column.
Then alpha folds into the accumulators. This step is skipped entirely when
alpha == 1, thanks to the AlphaStatus the driver precomputed.
The store is where beta and the epilogue live. It has 2 routes.
The fast path fires for a full tile with unit output row stride:
mr_eff == mr && nr_eff == NR && rsc == 1. The orientation normalization from stage 3
is what makes this common. Each accumulator register combines with C directly. It
is stored as-is for beta == 0 (C unread), added for beta == 1, or
fused-multiply-added for a general beta. The result is written back with vector
stores.
Edge tiles and strided outputs take the general path instead. All accumulators drain
into a stack scratch tile, a SCRATCH_LEN array in the worker’s frame, with no
allocation. A scalar loop then applies the same beta arithmetic element-wise, through
whatever strides C has.
Plain gemm threads the Identity epilogue through all of this. Every epilogue hook
is gated on !E::IS_IDENTITY, an associated const. The guards fold away at
monomorphization, so the emitted kernel is byte-for-byte the pre-epilogue code.
A fused call, such as gemm_fused, gemm_map, or requantize, runs the same engine
with a real epilogue. That epilogue fires only when last_k is true, on the final
depth slice, once per output element. That story continues in
Epilogue Fusion.
The short way home
The 2x2x3 example never saw most of this. It entered execute with m, n, k all
positive and alpha == 1. It reached run_typed, and failed the gemv gate
(n != 1, m != 1). It was orientation-swapped, then failed the small-m,n gate,
since k = 3 is not a long contraction. With k = 3 <= 16, it took
special/small_k.rs. That route is one in-place depth panel over the same
microkernel, with no blocking, no packing, and no workspace traffic.
The 2048-cube took the full driver, with parallel B-packing. At
Parallelism::Rayon(0), its worker count scaled to its total work.
Same entry point, same result contract, 2 very different journeys. The layers below decide, and the caller never has to. For the deeper mechanics of each stage, see Blocking and the Cache Model, Packing and Workspaces, Parallel Execution, and Special Paths.
SIMD Tokens and ISA Dispatch
gemmkit picks its instruction set at runtime. That decision collides with how Rust compiles SIMD intrinsics. AVX and AVX-512 intrinsics compile correctly only inside a context where the target feature is enabled. Normally that context comes from a #[target_feature(enable = "...")] attribute on the enclosing function. The program only knows which features are safe to enable once it runs on a concrete CPU. The microkernel is one generic function shared by every instruction set, so no single attribute can sit on it.
This page explains 2 things. First, how the L0 SIMD layer (gemmkit/src/simd.rs and gemmkit/src/simd/) resolves that tension with zero-sized ISA tokens and a trampoline function. Second, how the L7 dispatch layer (gemmkit/src/dispatch.rs and gemmkit/src/dispatch/) selects and caches the winning kernel.
ISA tokens and the vectorize trampoline
An ISA token is a zero-sized type that stands for one instruction-set choice. On x86 the tokens are Fma (AVX2 + FMA) and Avx512F, plus the dot-kernel variants Avx512Vnni and Avx512Bf16. On aarch64 the token is Neon. On wasm32 the token is Simd128. ScalarTok exists on every platform as the portable floor.
Each token implements the Simd trait. Its only method is vectorize, which runs a closure with this token’s target features enabled. The code below shows the entire mechanism, from gemmkit/src/simd/fma.rs.
#![allow(unused)]
fn main() {
/// AVX2 + FMA ISA token
#[derive(Copy, Clone, Default)]
pub struct Fma;
impl Simd for Fma {
#[inline(always)]
unsafe fn vectorize<R>(self, f: impl FnOnce() -> R) -> R {
#[target_feature(enable = "avx2,fma,f16c")]
unsafe fn inner<R>(f: impl FnOnce() -> R) -> R {
f()
}
// SAFETY: the caller of `vectorize` (the runtime dispatcher) guarantees
// the CPU supports avx2+fma(+f16c); `inner` then establishes the codegen
// context, and `f` inlines into it
unsafe { inner(f) }
}
}
}
The trick is the direction of inlining. inner is a tiny function with a #[target_feature] attribute. The closure f inlines into it. f holds the packing loops and the microkernel calls, and every one of those is built from #[inline(always)] primitives. Every intrinsic therefore lands in a codegen context where the feature is enabled. No attribute ever touches the generic kernel itself.
The unsafe contract has exactly one obligation. The caller must guarantee the CPU really supports the token’s features. The runtime dispatcher establishes this once per process.
This is the same pattern pulp and faer use. It works the same way for the serial path and for rayon worker closures. The driver wraps each column strip of microkernel calls in simd.vectorize(|| ...). This amortizes the trampoline overhead over many tiles.
ScalarTok’s vectorize is just f(). It has nothing to enable. This is what makes the scalar path run everywhere, including under Miri.
SimdOps: the per-type vocabulary
L0 builds on 3 traits, not 2. Simd is the ISA token trait above. SimdOps<T> is the per-element-type vocabulary this section covers. KernelSimd<L, R, A, O> is a third trait. It widens loads and narrows stores when a family’s input type, accumulator type, and output type are not all the same. This section covers it near the end.
The token itself knows nothing about element types. All the raw operations live on SimdOps<T>, implemented once per (ISA, T) pair. It names the register type Reg, the lane count LANES, and every primitive the microkernel needs. The token and the element type are decoupled, so LANES varies with the pair. f32 gets 8 lanes under Fma and 16 lanes under Avx512F. f64 gets half as many lanes as f32 on the same token.
The vocabulary is deliberately thick. The basic operations are zero, splat, loadu, storeu, mul, add, and the fused mul_add. Its subtractive partner is fnma, which computes c - a*b. The complex kernel needs fnma for one of its accumulation terms. The vocabulary also has the horizontal reduce_sum, used by the gemv and dot-product epilogues.
On top of those sit a few more primitives. max and min exist only on the real-float tokens, for the fused ReLU and clip epilogues. The LANE_FMA flag and its fma_bvec method give NEON a lane-indexed FMA path. It loads a block of RHS columns as one vector, instead of issuing a separate splat per column. accumulate_tile is the GEMM inner loop itself. Its portable default schedule already compiles down to the canonical register-blocked kernel on any out-of-order core.
The complex split kernel has its own seam here too, cplx_microkernel. The dot kernels have theirs as well, dot_accumulate, which lives on KernelSimd rather than on SimdOps. KernelSimd<L, R, A, O> is the seam a family drives on when its input types, accumulator type, and output type are not all equal. One example is an f16 input with an f32 accumulator. It widens a narrow input load into the accumulator type and narrows an accumulator value back down on store. A homogeneous family has all 4 types equal. It gets a KernelSimd implementation for free through a blanket impl. It never needs any per-ISA code of its own. See Scalars and Kernel Families and Dot Kernels and the Deep-K Twin for more on both seams.
The thickness is the point. matrixmultiply’s per-ISA trait is thin, so each instruction set has to reimplement the kernel from scratch. Here every primitive the kernel needs sits behind SimdOps, so the microkernel is one generic function over every ISA. Adding an instruction set costs a new token, its SimdOps impls, and one line in each dispatch ladder. The simd module depends only on crate::scalar and core. It has no reverse dependency on the kernel, driver, or cache layers, so the whole abstraction could move into its own crate unchanged.
The dispatch layer
Dispatch turns the question “which token applies” into a one-time decision. Each dispatched element type owns one OnceLock slot holding a Dispatched<T> descriptor. f32 and f64 use gemmkit/src/dispatch/float.rs. f16 and bf16 use dispatch/mixed.rs. i8 uses dispatch/int.rs, with its own IntDispatched and IntRequantDispatched shapes, because those types are heterogeneous. c32 and c64 use dispatch/complex.rs. The code below, lightly trimmed, is from dispatch/float.rs.
#![allow(unused)]
fn main() {
#[derive(Copy, Clone)]
pub(super) struct Dispatched<T> {
pub(super) run: GemmFn<T>,
pub(super) run_packed: PackedFn<T>,
#[cfg(feature = "epilogue")]
pub(super) run_fused: FusedFn<T>,
#[cfg(feature = "epilogue")]
pub(super) run_packed_fused: PackedFusedFn<T>,
pub(super) mr: usize,
pub(super) nr: usize,
pub(super) depth_multiple: usize,
}
}
The slot caches the winning monomorphized entry points: the plain kernel, the prepacked-RHS kernel, and, under the epilogue feature, their fused twins. It also caches the microtile geometry (mr, nr) and the family’s depth_multiple. The geometry is cached so prepack_rhs can size a buffer through the same ISA choice the consuming call will make. depth_multiple lets the bf16 prepack path round its packed depth to match the dot kernel’s layout. Everything here is a typed function pointer. There is no transmute and no AtomicPtr<()>.
A call flows through a fixed chain. gemm calls dispatch::execute, which handles the degenerate cases. dispatch::execute calls T::dispatch, which reads the memoized slot. The slot resolves to one indirect call into a wrapper, such as gemm_f32_avx512f. That wrapper instantiates the shared generic entry as run_typed::<f32, Avx512F, 2, 12>.
Selection runs once, inside the OnceLock initializer. It first honors any GEMMKIT_REQUIRE_ISA pin, covered below. After that, the auto ladder on x86 probes avx512f first, then avx2 plus fma, and falls back to scalar last. On aarch64, NEON is the baseline. The architecture makes NEON mandatory, so no probe is needed there.
On wasm32 there is no runtime feature detection at all. simd128 is chosen at compile time, through cfg(target_feature = "simd128"). The build must pass -C target-feature=+simd128, or it gets the scalar kernel instead. Scalar is the floor on every architecture.
Each per-type ladder adds its own gate on the same skeleton. The f16 FMA arm also requires f16c, for the vcvtph2ps and vcvtps2ph conversions. The bf16 ladder tries the avx512bf16 dot kernel before plain AVX-512F. The i8 ladder tries avx512vnni (with avx512bw) before the widen kernel.
2 build-mode details are worth knowing. Under std, feature detection uses is_x86_feature_detected!, and the result is memoized in the OnceLock. Without std, there is no runtime CPU detection, because raw-cpuid is gated on std. The probe macro degrades to cfg!(target_feature = ...), GEMMKIT_REQUIRE_ISA parsing degrades to Auto, and the select function runs on every call. Every branch inside it is now a compile-time constant, so it folds down to a direct choice. A no_std build simply runs whatever its compile-time target features guarantee. See no_std and WebAssembly.
Tile geometry as const generics
Besides the instruction encoding, the one thing that genuinely varies per (type, ISA) is the microtile shape. It is expressed as a pair of const generics, (MR_REG, NR), chosen at the dispatch site. It is never a new type, trait, or macro. MR_REG is how many registers tall the tile is, so the row count is MR = MR_REG * LANES. The table below covers f32.
| ISA | (MR_REG, NR) | LANES | Tile MR x NR | Register budget |
|---|---|---|---|---|
| AVX-512F | (2, 12) | 16 | 32 x 12 | 24 acc + 2 lhs + 1 rhs = 27 ZMM |
| FMA (AVX2) | (2, 6) | 8 | 16 x 6 | 12 acc + 2 lhs + 1 rhs = 15 YMM |
| NEON | (4, 4) | 4 | 16 x 4 | 16 acc + 4 lhs + 1 rhs = 21 of 32 vregs |
| simd128 | (2, 4) | 4 | 8 x 4 | 8 acc + 2 lhs + 1 rhs = 11 live v128 |
| scalar | (4, 4) | 1 | 4 x 4 | plain locals |
f64 halves the lane count. The same (MR_REG, NR) pairs then yield 16x12 on AVX-512F, 8x6 on FMA, 8x4 on NEON, and 4x4 on simd128. The budgets are not accidents. NEON deliberately leaves about 11 registers free, as rename headroom for a wide out-of-order core to overlap loads with FMAs. simd128 stays at 11 live vectors, because LLVM’s wasm backend starts spilling past roughly 16. These comments live next to the wrappers in dispatch/float.rs, so the table above is the code, not an aspiration.
Pinning with GEMMKIT_REQUIRE_ISA
By default the best available ISA wins. Setting the environment variable GEMMKIT_REQUIRE_ISA forces exactly one kernel instead of the automatic choice. It accepts these values, case-insensitive:
scalarfma(aliasavx2)avx512favx512vnni(aliasvnni)avx512bf16(aliasbf16)neonsimd128(aliaswasm)auto
Unset or empty means auto. An unrecognized value causes a hard panic, so a typo in a CI configuration cannot silently select the wrong thing. avx512vnni pins the i8 vpdpbusd dot kernel. avx512bf16 pins the bf16 vdpbf16ps dot kernel. For every other element type, both resolve to the plain AVX-512F path.
The defining behavior is that a pin never falls back. Selection panics if the CPU, or an emulator such as Intel SDE, does not report the required feature. It also panics if the requested ISA does not exist on the target architecture at all, such as neon off aarch64 or fma/avx512* off x86. The panic message names the missing feature. The rationale is CI honesty. A job that means to exercise a given kernel must fail loudly, rather than silently test a different one.
The simd128 pin earns its keep the same way on wasm. The target feature is an easily forgotten compile-time flag. Pinning turns a dropped flag from a silent scalar fallback into a build that refuses to run.
The value is read once. Selection is memoized in the per-type OnceLock, so the variable must be set in the process environment before the first GEMM call. Changing it afterward has no effect for the life of the process. See Runtime ISA Dispatch for the user-facing side of pinning, including CI recipes and how it interacts with the tuning knobs.
Scalars and Kernel Families
gemmkit multiplies several element types: f32, f64, f16, bf16, i8, Complex<f32>, and Complex<f64>. u8 also appears, but only as a requantize output. Every one of these types flows through the same driver, the same packing framework, the same cache model, and the same parallel scheduler.
2 traits carry all the variation. Scalar, at L0 (gemmkit/src/scalar.rs), answers what a type is and what it accumulates in. KernelFamily, at L4 (gemmkit/src/kernel.rs), answers what makes this kind of GEMM different from the others. The driver is generic over the family. It never branches on element type. This page walks through that split, and why it falls where it does.
Scalar: constants and an accumulator, nothing else
Scalar is deliberately tiny. The whole trait, from gemmkit/src/scalar.rs:
#![allow(unused)]
fn main() {
pub trait Scalar: Copy + Send + Sync + PartialEq + 'static {
/// The type in which products are accumulated. `Self` for `f32`/`f64`
type Acc: Scalar<Acc = Self::Acc>;
/// The additive identity
const ZERO: Self;
/// The multiplicative identity
const ONE: Self;
}
}
Scalar has no Add, no Mul, and no conversions. It has only the identity constants and the associated accumulator type. This omission is deliberate. All vectorized arithmetic lives in SimdOps (see SIMD Tokens and ISA Dispatch). The scalar arithmetic that an epilogue needs lives instead on narrow side traits, and only the types that need them implement those traits. Float covers f32 and f64, and, through num-complex’s operators, the complex types too. NarrowFloat covers the f16/bf16 widen and narrow conversions. ComplexFloat covers the real and imaginary accessors of the split complex kernel.
If Scalar itself carried arithmetic, every new element type would owe a full set of operations it may not actually have. i8 is the clearest case. It needs no arithmetic trait at all, because its kernel does everything through the SIMD seam and exact i32 integer operations.
Acc is the mixed-precision seam. The table is short.
| Element type | Accumulates in |
|---|---|
f32, f64 | itself |
f16, bf16 | f32 |
i8 (and the output-only u8) | i32 |
Complex<f32>, Complex<f64> | itself |
The recursive bound Acc: Scalar<Acc = Self::Acc> pins the chain after one step: f16 -> f32 -> f32 -> .... Generic code can then name the accumulator’s accumulator without caring how narrow the input was. For the homogeneous types, the Acc = Self branch collapses at compile time and costs nothing.
KernelFamily: everything that distinguishes one GEMM from another
KernelFamily bundles the rest. It carries the 4 element types (Lhs, Rhs, Acc, Out), the pack layout (pack_lhs/pack_rhs, which write micropanel-major panels), and the microkernel.
3 associated constants shape how the driver treats a family. OUT_IS_ACC says whether a running partial sum may round-trip through C between depth panels. This is the pivotal constant, covered below. FORCE_PACK_LHS and FORCE_PACK_RHS are set when packing performs a transform the kernel depends on, such as complex conjugation or dot-kernel interleaving. In that case, the driver must never read the operand in place. DEPTH_MULTIPLE is the instruction-group depth of a dot kernel. It is 1 for every other family.
A family overrides exactly one of 2 microkernel methods. A non-fusing family overrides the plain microkernel and inherits the default microkernel_epi. That default forwards to microkernel, after it asserts E::IS_IDENTITY, a fail-closed guard. A real epilogue reaching a family that cannot fuse panics, instead of being silently dropped. A fusing family, such as the float, mixed, and requantizing families, overrides microkernel_epi instead. It threads the epilogue through its own store, and its plain microkernel method is then dead code, keeping the default unreachable! body.
Tile geometry is deliberately not on the trait. (MR_REG, NR) is a pair of const generics, chosen per (family, ISA) at the dispatch site. A new tile is therefore a new instantiation of that pair, not a new type.
The payoff shows up in the driver’s signature. driver::run::<Fam, S, MR_REG, NR> is generic over the family and the ISA token. It calls Fam::pack_lhs, Fam::pack_rhs, and Fam::microkernel_epi, and it contains not one if on an element type. Adding a kind of GEMM means writing a new family. It never means touching the driver.
The family roster
10 family types ship today. They fall into generations: homogeneous, widen, dot, requantize, and complex. Reading them in that order makes the seams visible.
| Family | Types (Lhs/Rhs -> Acc -> Out) | OUT_IS_ACC | DEPTH_MULTIPLE | Notes |
|---|---|---|---|---|
FloatGemm<T> | T -> T -> T for f32/f64 | true | 1 | The baseline: one generic microkernel for every ISA |
MixedGemm<N> | N -> f32 -> N for f16/bf16 | false | 1 | Widen-FMA through the KernelSimd seam |
Bf16DotGemm | bf16 -> f32 -> bf16 | false | 2 | vdpbf16ps dot kernel. Both operands force-packed, k-pair-interleaved |
MixedGemmF32<N> / Bf16DotGemmF32 | N -> f32 -> f32 | true | 1 / 2 | The f32-output deep-k twins: same accumulation, f32 store |
IntGemm | i8 -> i32 -> i32 | true | 1 | Exact, wrapping. Sign-extend on load |
IntGemmVnni | i8 -> i32 -> i32 | true | 4 | vpdpbusd dot kernel, +128 signedness correction, bit-identical to IntGemm |
IntGemmQ<O> / IntGemmVnniQ<O> | i8 -> i32 -> i8 or u8 | false | 1 / 4 | Requantizing variants (feature epilogue) |
ComplexGemm<T, CONJ_A, CONJ_B> | T -> T -> T for c32/c64 | true | 1 | Split (SoA) kernel. Both operands force-packed planar. Conj is a pack-time sign flip |
FloatGemm is the reference point. It is homogeneous, with one generic microkernel_impl shared by every ISA and every tile.
The mixed and integer families introduce Acc != Lhs. They lean entirely on the widen/narrow seam covered below. The dot families, Bf16DotGemm and IntGemmVnni, go further. Each swaps in an interleaved pack layout and a hardware dot instruction. The f32-output twins exist so a deep contraction can re-block. Dot Kernels and the Deep-K Twin covers all of this.
The requantizing variants bolt an exact i32 -> i8/u8 requantize onto the integer accumulation. They are part of the fusion story in Epilogue Fusion.
ComplexGemm keeps Acc = T, so complex alpha/beta thread through the driver unchanged. Its hot loop instead runs on the real component, through a dedicated seam. The Complex Split Kernel covers that seam.
This page stays at the roster level. The deep dives live in those other pages.
KernelSimd: the widen/narrow seam
The driver’s bound on the ISA token is S: KernelSimd<Fam::Lhs, Fam::Rhs, Fam::Acc, Fam::Out> (gemmkit/src/simd.rs). KernelSimd<L, R, A, O> extends SimdOps<A>, so it accumulates in A. It adds 4 moves a family needs at the type boundary.
load_lhs loads LANES LHS values and widens them to an A register. splat_rhs widens one RHS scalar and broadcasts it. load_out widens output values for the beta != 0 read of C. store_out narrows an A register to LANES output values, and rounds to nearest-even when it actually narrows.
The homogeneous case costs nothing. A blanket impl, KernelSimd<A, A, A, A> for S: SimdOps<A>, forwards all 4 methods to plain loadu, splat, and storeu. So FloatGemm<f32> and its relatives need zero per-ISA code.
A mixed family instead adds per-ISA impls. Its loads genuinely widen, such as f16 -> f32 through vcvtph2ps, or i8 -> i32 through sign extension. Its store_out genuinely narrows. Coherence comes free here. The all-equal blanket and a mixed impl with L != A can never describe the same types.
2 further impl groups are derived, rather than hand-written per ISA. The requant blankets cover Out = i8 or u8. They forward the accumulate side to the <i8, i8, i32, i32> impl. The f32-output twins cover <N, N, f32, f32> for N = f16 or bf16. Those are written as 2 concrete heads, rather than one generic blanket over N. A generic blanket could not rule out colliding with the homogeneous blanket at N = f32.
KernelSimd also hosts 2 more seams. dot_accumulate is the dot seam. Only dot-capable tokens override it, and its default is unreachable!. requant_store is the vectorized requantize store, following the same pattern.
The constant that ties this seam to the driver’s blocking is OUT_IS_ACC. The driver normally accumulates across k by splitting it into kc panels. The partial sum round-trips through C, with beta = 1 after the first panel. That round-trip is exact only when Out == Acc.
When the output is narrower than the accumulator, that round-trip would round to 16 bits at every panel boundary. So a narrow family declares OUT_IS_ACC = false. The driver then responds with kc = k, one depth panel that spans the entire contraction. The whole contraction accumulates in f32 registers, and the result rounds to the narrow output exactly once, at the end.
That single-rounding guarantee is what makes the mixed-precision results defensible. It has one cost. A single panel means its RHS micropanel can outgrow the L2 cache when k is large. The f32-output twins exist to pay that cost down. Dot Kernels and the Deep-K Twin covers how they do it.
The open/closed proof
The claim that the family seam is open for extension is not just prose. gemmkit/tests/open_closed.rs enforces it. This is an integration test that lives outside the crate, so it sees only the public API.
The test declares NaiveFloat, a deliberately naive second float family that shares nothing with FloatGemm. It reimplements micropanel packing from scratch, because the crate’s internal pack helper is not visible to it. This is exactly the situation a third party would be in. NaiveFloat also supplies a plain scalar triple-loop microkernel.
The test then drives the unchanged generic driver, driver::run::<NaiveFloat, ScalarTok, 4, 4>, on a 40x33x28 problem. It checks the result against an f64 reference.
The test’s main value is that it compiles at all. A second family drove the driver with no edit to driver.rs or pack.rs. It used nothing but public items: gemmkit::kernel::KernelFamily, gemmkit::simd::ScalarTok, gemmkit::driver::run, Workspace, and Parallelism.
Any refactor that closes the seam breaks this file first. Examples include a driver branch on a concrete family, a newly required private helper, or a leaked internal type in the trait’s signature. Such a refactor breaks this test before it breaks a downstream user.
Testing and Verification covers the wider testing story, including how the real families are cross-checked against oracles. Extension Points covers what third parties can build on this seam.
Dot Kernels and the Deep-K Twin
Most kernel families consume the contraction one depth step at a time. Each step loads a column of the packed A panel, and broadcasts one element of packed B. It then issues one FMA, or widening multiply-add, per accumulator register.
2 AVX-512 extensions break that rhythm. Each folds several depth steps into a single instruction. VNNI’s vpdpbusd multiplies 4 consecutive i8 depth steps into each of 16 i32 lanes. AVX-512 BF16’s vdpbf16ps folds 2 consecutive bf16 depth steps into each f32 lane.
An instruction that consumes several depth steps at once wants those steps adjacent in memory. For floats, it also changes how the accumulation rounds. So gemmkit gives dot kernels their own kernel families and ISA tokens, instead of hiding them behind a branch in the shared microkernel.
This page walks through 3 things. First, the 2 seams that carry dot kernels. Second, the 2 concrete dot kernels themselves. Third, the deep-contraction route that both narrow families share.
Why dot instructions get their own families
A dot kernel differs from its widen sibling in exactly 2 places. Each difference lands on a different extension axis of the engine. See Scalars and Kernel Families for the family and token split.
The pack layout is a family concern. KernelFamily::pack_lhs and pack_rhs take no ISA parameter, so a different interleave must key off the family instead. This is why Bf16DotGemm is a sibling of MixedGemm<bf16>, rather than a branch inside it.
The inner loop is a token concern. Only a CPU that actually has vpdpbusd or vdpbf16ps can run it. So the instruction lives behind a KernelSimd method, and only dot-capable tokens override that method.
The concrete dot families are IntGemmVnni, and its requantizing variant IntGemmVnniQ, with 4 depth steps per instruction. Bf16DotGemm has 2 depth steps per instruction. 2 f32-output twins, covered at the end of this page, round out the set.
DEPTH_MULTIPLE and the k-group pack
A family that folds Q depth steps per instruction declares const DEPTH_MULTIPLE: usize = Q. The default is 1. The contract, spelled out in gemmkit/src/kernel.rs, works as follows. The family’s packers write panels of width * kc.next_multiple_of(Q) elements, and pad the depth tail. The driver strides packed panels by that same padded depth, so both sides stay in lockstep. For every ordinary family, DEPTH_MULTIPLE = 1, and every next_multiple_of call degenerates to an identity.
The layout itself comes from one shared routine, pack_kgroup_panels in gemmkit/src/pack.rs. It is the single source of truth for the interleave index math.
Plain pack_panels stores a panel depth-major. At each depth step, it stores width contiguous leading elements: mr rows for the LHS, nr columns for the RHS.
pack_kgroup_panels instead groups the depth axis Q at a time, so one lane’s Q consecutive depth values become contiguous. Within a panel, group g, lane i, in-group position t lands at offset g*width*Q + i*Q + t.
That is exactly the shape one dot instruction reads. A 64-byte A register covers LANES rows times Q contiguous depth elements. A B group broadcasts Q contiguous depth values of one column, as a single 32-bit load.
2 more details of the shared packer matter. It takes a per-element transform, xform. That transform is the identity for bf16, and the +128 bias for VNNI’s A operand. The packer fills every pad position with xform(0). Pad positions are the leading positions past the block, and the depth positions past kc. This keeps the pad always consistent with the live elements.
The interleaved layout cannot be read in place. So every dot family sets FORCE_PACK_LHS and FORCE_PACK_RHS, overriding the driver’s cost-based pack decision. A dot kernel always pays the pack cost. This is precisely the cost the gates described below hedge against.
A byte-level oracle in pack.rs’s tests reimplements the layout naively. It checks that the real routine reproduces it bit-for-bit, across width tails, depth tails, and strided sources.
On the consuming side, KernelSimd::dot_accumulate is the seam the families call, instead of the widen-FMA loop. Its default body is unreachable!. Only a dot-capable token overrides it, and only a dot family ever calls it.
Avx512Vnni and Avx512Bf16 are distinct tokens from Avx512F, because #[target_feature] works per token. _mm512_dpbusd_epi32 needs an avx512vnni codegen context. Avx512F::vectorize only establishes avx512f, so it cannot provide that context.
The method receives the real, unpadded kc. It reads ceil(kc / Q) instruction groups from the depth-padded panels. Any signedness or bias correction is applied internally, so the accumulators hold the true sum_k(A*B) on return.
The fold lives on this dedicated seam, rather than on the generic accumulate_tile, for a documented reason. Fusing depth steps reshapes the accumulation rounding, and accumulate_tile’s contract forbids that.
i8 through vpdpbusd
vpdpbusd computes an unsigned-times-signed dot. It takes u8 from the first operand and i8 from the second. GEMM wants signed-times-signed instead.
The fix is algebraic, not per-element. The LHS pack offsets every byte by +128, into the unsigned domain. This transform is vnni_a_xform in gemmkit/src/kernel/int.rs. It uses the constant VNNI_A_BIAS = 128, defined once in gemmkit/src/simd.rs, so the pack and the correction can never drift apart.
sum_k((A+128)*B) = sum_k(A*B) + 128*sum_k(B). So the kernel recovers the true product by subtracting a per-column correction, 128 * sum_k(B[k][j]). Avx512Vnni::dot_accumulate computes those column sums with a small scalar pass over the signed packed B panel, before the vector loop. It then subtracts the splatted correction from every accumulator at the end.
The pads cooperate with this scheme. The A pad is xform(0) = 128, and the correction cancels its contribution exactly. The B pad is 0, so it contributes nothing to the product or to the column sums.
i32 accumulation wraps, and wrapping addition is associative modulo 2^32. So regrouping the sum into quads, plus the bias correction, equals the ascending-k widen sum bit-for-bit. IntGemmVnni and the widen IntGemm produce identical output on every input.
The ISA choice can therefore never change an i8 result. This is a stronger property than the reproducibility contract requires. That contract only promises reproducible results on a fixed machine and configuration, not bitwise agreement across kernel choices.
That freedom to swap kernels mid-flight is what the small-parallel fallback gate exploits. The VNNI pack is mandatory on both operands. On a small multi-threaded problem, that pack barrier can dominate the compute it is meant to save.
3 conditions trigger the fallback. The ISA selection must be automatic. The parallelism must be Rayon(n) with n != 1. And m*n*k must fall below GEMMKIT_I8_VNNI_MIN_PAR_MNK, whose default is 768^3. When all 3 hold, dispatch/int.rs hands the call to the in-place widen kernel instead.
Serial runs and large parallel runs keep VNNI. A forced GEMMKIT_REQUIRE_ISA=avx512vnni disables the gate entirely, because a forced pin must run exactly that kernel.
The prepacked-RHS path also bypasses the gate, for 2 reasons. A k-quad-interleaved buffer is only consumable by the VNNI family. The pack barrier the gate hedges against was already amortized once, at prepack time. VNNI’s RHS pack is otherwise mandatory on every call. So prepacking is a bigger win there than for any kernel that can read its operand in place.
bf16 through vdpbf16ps
Bf16DotGemm is the floating-point sibling. Its DEPTH_MULTIPLE is 2. Both operands are packed k-pair-interleaved, with each pair stored as one 32-bit __m512bh element. dot_accumulate issues one vdpbf16ps per accumulator, per pair-step.
Everything downstream of the accumulation is shared verbatim with MixedGemm<bf16>. This includes the alpha fold and the widen-read, narrow-store epilogue, through the common mixed_epilogue helper. The family keeps OUT_IS_ACC = false, so the whole contraction accumulates in f32 and rounds to bf16 exactly once.
The numeric story differs from VNNI in one essential way. vdpbf16ps’s fused 2-term dot rounds differently from 2 separate widen-FMAs. So the dot kernel’s result is only tolerance-equal to the widen path, not bitwise equal.
That is exactly what the engine’s consistency bar allows. Results must be reproducible under a fixed input, environment, and configuration. They need not be bitwise-identical across kernel choices. The dot kernel is fully deterministic. Serial, parallel, and prepacked runs all share the same kernel and pack layout, so they reproduce each other bit-for-bit.
There is no size gate on this path. Auto-selection prefers Bf16DotGemm whenever the CPU reports avx512bf16, because it is a structural win over the plain widen path. Unlike VNNI, there is no small-parallel fallback here.
3 special-path reroutes are the only exceptions. gemv, small_mn, and small-k shapes deliberately stay on MixedGemm<bf16>’s widen seam. A tiny or degenerate output folds nothing, and the dot pack’s depth padding is pure loss there. The i8 dispatch reroutes its own tiny shapes to the widen kernel, for the same reason.
The deep-K problem
OUT_IS_ACC = false buys single-rounding at a structural price. The driver runs kc = k, one depth panel spanning the entire contraction. This replaces the cache-model kc slices every homogeneous family gets (see Blocking and the Cache Model).
The RHS micropanel a microtile call reads is then nr * k * sizeof(N) bytes. Once that micropanel outgrows the L2 cache, every one of the m/mr microtile calls in a column strip streams it from L3 or DRAM instead. The even larger mr * k LHS micropanel streams from there too. The cliff this creates is sharp. Throughput stays near peak while the micropanel fits in L2, and falls once it does not.
The engage gate in gemmkit/src/dispatch/mixed.rs compares that micropanel size against a byte threshold:
#![allow(unused)]
fn main() {
let engage_deep_k = NR
.checked_mul(t.k)
.and_then(|x| x.checked_mul(core::mem::size_of::<N>()))
.is_some_and(|bytes| bytes > crate::cache::deep_k_engage_bytes());
if engage_deep_k {
run_deep_k_twin::<N, Fam::Twin, S, MR_REG, NR>(simd, &t, par, ws);
return;
}
}
The threshold is the GEMMKIT_DEEP_KC_BYTES knob, taken verbatim when it is non-zero. The default, 0, derives the threshold as half the effective per-worker L2 capacity.
Half of L2, rather than the whole of it, is a deliberate choice. A gate set to the full L2 size would engage too late. It would fire well past the point where the micropanel no longer fits, and so it would miss the cliff. Half of L2 leaves room for the rest of the working set, and engages the twin while there is still time to avoid the cliff.
The checked_mul chain fails closed. A broadcast operand can pass validation with a logically absurd k. An overflowing size must fall through to the single panel instead. That panel’s own pack sizing then rejects the problem, rather than let a twin multi-slice that k forever.
The f32-output twin
Above the gate, dispatch does not run the narrow family at all. A small DeepKTwin trait maps each narrow family to its f32-output twin. MixedGemm<N> maps to MixedGemmF32<N>, and Bf16DotGemm maps to Bf16DotGemmF32. The only type change in each twin is Out = f32 = Acc.
That one change flips OUT_IS_ACC back to its default of true. The driver’s ordinary multi-slice K blocking then applies unchanged, and every slice’s panels are L2-resident again. That is the entire point of the twin.
The pack layout and the accumulation loop are the narrow family’s, verbatim. MixedGemmF32 reuses pack_panels and the shared widen-FMA helper. Bf16DotGemmF32 reuses pack_kgroup_panels and dot_accumulate. The accumulation helpers only touch the input side of the KernelSimd seam. So the accumulator they produce is byte-for-byte what the narrow family would compute.
The twin runs with alpha = 1 and beta = 0, into an m x n column-major f32 scratch buffer. That buffer is drawn from a dedicated Workspace. Deep-K is by definition a large-k regime, so one m*n f32 allocation is negligible. Keeping it separate also leaves the pooled packing workspace free for the twin driver to use.
Afterward, one vectorized sweep computes narrow(alpha*scratch + beta*widen(C)). This replicates mixed_epilogue’s arithmetic operation for operation, including the same store_out narrowing.
What makes the route more than an approximation is how the slices chain together. The twin’s microkernel seeds its accumulator registers from the scratch buffer, through a third KernelSimd<N, N, f32, f32> seam, twin_seed in gemmkit/src/kernel/mixed.rs. On an accumulate slice, it loads the running partial into the registers and continues the ascending-k chain. It never sums a slice from zero and adds the result afterward.
A store and reload of an f32 is exact. So the multi-slice sum is exactly the single-panel sum, merely split at slice boundaries. For beta in {0, 1}, the deep-K result is byte-for-byte the single-panel result.
For a general beta, the result holds only to tolerance instead. The reason is mundane. The single panel fuses beta*C + AB in one FMA on full tiles, but combines the same terms unfused on edge tiles. No single sweep can match both cases at once.
Serial and parallel runs remain bit-identical in every case. The twin driver’s blocking does not depend on the thread count, and the final sweep is elementwise.
The dot twin has one extra alignment rule. The driver rounds the blocking kc up to DEPTH_MULTIPLE, so an interior slice boundary never splits a k-pair. A split pair would zero-pad mid-contraction and regroup the fused dot incorrectly. With this rule, only the final short tail is ever padded, exactly as in the single-panel case.
3 routes deliberately keep the single panel. They are shallow k below the gate, the fused-epilogue entries, and the prepacked-RHS path. On the prepacked path, a DEPTH_MULTIPLE > 1 buffer requires the whole contraction to stay one depth slice. The driver enforces this with a hard assert, because violating it would misaddress micropanels silently.
The parity claims are tested directly. gemmkit/tests/deep_k_narrow.rs toggles GEMMKIT_DEEP_KC_BYTES. A value of 1 forces the twin at any k, and usize::MAX forces the single panel. The test checks byte-for-byte equality for beta in {0, 1}, and tolerance for general beta, on whichever ISA the host selects. Tuning Knobs documents this knob along with the rest.
The Complex Split Kernel
Complex GEMM is the one homogeneous type family that does not ride FloatGemm. On paper it could. Complex<f32> and Complex<f64> accumulate in themselves, so Lhs = Rhs = Acc = Out. That is exactly the shape the float family handles.
The problem is the memory layout. num_complex stores a complex number as an adjacent (re, im) pair. So a SIMD register loaded from a complex slice holds re, im, re, im, .... One complex multiply needs cross-lane combinations: re*re - im*im for the real part, and re*im + im*re for the imaginary part.
On interleaved lanes, those combinations force shuffle and fmaddsub-style instructions inside the innermost loop. That cost repeats once per depth step, O(mnk) times in total. gemmkit instead rewrites the layout once, at pack time, and keeps the hot loop as pure real FMAs, with no in-loop shuffles at all.
This page covers 5 things. First, the split design itself. Second, how conjugation falls out of it for free. Third, the seam the kernel runs through. Fourth, the register-budget arithmetic behind the tile shapes. Fifth, what the numerics guarantee.
The split layout
The family is ComplexGemm<T, CONJ_A, CONJ_B>, in gemmkit/src/kernel/complex.rs. Its packers are where the design lives.
pack_planar lays each micropanel down in structure-of-arrays form. For every depth step, the panel stores width real parts, immediately followed by width imaginary parts. width is mr for the LHS and nr for the RHS, with strides swapped exactly as in the shared pack_panels.
The kernel then loads a register of reals and a register of imaginary parts with plain contiguous loads. The de-interleave cost moves out of the kc inner loop and into the pack step. The amortized cost becomes O(MK + KN), instead of O(MNK).
The kernel can only consume that planar layout. So both operands are always packed. The family sets FORCE_PACK_LHS = FORCE_PACK_RHS = true, overriding the driver’s cost-based decision to read an operand in place instead.
pack_planar mirrors pack_panels’ 2 write paths. One is a straight walk, used when the leading dimension is contiguous. The other is a cache-blocked transpose, used for a strided source, so a row-major operand packs without a cache miss per element. The 2 paths write byte-identical panels. Only the write order differs. See Packing and Workspaces for the shared framework.
Conjugation as a pack-time sign flip
Conjugation only negates the imaginary part. The pack already writes the imaginary plane separately. So conj(A)*B and A*conj(B) cost nothing in the hot loop.
Setting the CONJ_A or CONJ_B const generic makes the packer negate the imaginary plane as it copies. This is a true negation, so +0.0 maps to -0.0, matching num_complex’s .conj(). The same real-FMA loop then runs unchanged, with no per-element conjugation branch anywhere.
This is also the second reason for the force-pack flags. When packing does more than a plain copy, the transform must always run.
The runtime-to-compile-time bridge lives in gemmkit/src/dispatch/complex.rs. The public entry, gemm_cplx, takes conj_a and conj_b as plain bools. run_complex matches on that pair once, and dispatches to the matching one of 4 ComplexGemm monomorphizations. The branch happens once per call, never inside the loop.
One subtlety rides along with this. The orientation swap that normalizes a row-major-ish C computes C^T = B^T * A^T instead. Since (conj(A)*B)^T = B^T * conj(A)^T, the swap must also swap the conj flags. It does this before the match runs.
Output conjugation, conjC, is not implemented. On the degenerate path, where k == 0 or alpha == 0, the flags are simply irrelevant. With no A*B term, there is nothing to conjugate.
The hot loop: 4 real FMAs per complex MAC
There is one more layering problem to solve before the loop can run. The family is homogeneous, so the driver’s bound is KernelSimd<T, T, T, T> with T complex. That bound only yields SimdOps<Complex<..>>, not the real-typed operations the split kernel actually needs.
The bridge is the SimdOps::cplx_microkernel seam. The family’s microkernel forwards to it. Each ISA token’s override, generated by the impl_complex_simd! glue macro in gemmkit/src/simd/complex.rs, forwards in turn to one shared, ISA-generic function, soa_microkernel, written over S: SimdOps<C::Real>.
The accumulator stays Complex-typed at the family seam. So complex alpha and beta thread through the driver unchanged. Inside the seam, though, the accumulators are 2 banks of real registers.
The thin SimdOps<Complex<..>> glue exists only so the driver can read LANES, and so the homogeneous blanket impl applies. Its element operations are all unreachable!, because complex GEMM never calls them. LANES is set to the real lane count. So one real lane spans one complex row, and the driver’s mr = MR_REG * LANES counts complex rows.
The loop itself, from gemmkit/src/simd/complex.rs:
#![allow(unused)]
fn main() {
for p in 0..kc {
let are_p = a_re.add(p * 2 * mr); // re plane of this depth step
let aim_p = are_p.add(mr); // im plane (offset by `mr`)
let ar: [<S as SimdOps<C::Real>>::Reg; MR_REG] =
core::array::from_fn(|i| simd.loadu(are_p.add(i * lanes)));
let ai: [<S as SimdOps<C::Real>>::Reg; MR_REG] =
core::array::from_fn(|i| simd.loadu(aim_p.add(i * lanes)));
let bre_p = b_re.add(p * 2 * NR);
let bim_p = bre_p.add(NR);
for j in 0..NR {
let br = simd.splat(*bre_p.add(j));
let bi = simd.splat(*bim_p.add(j));
for i in 0..MR_REG {
acc_re[j][i] = simd.mul_add(ar[i], br, acc_re[j][i]); // += ar*br
acc_re[j][i] = simd.fnma(ai[i], bi, acc_re[j][i]); // -= ai*bi
acc_im[j][i] = simd.mul_add(ar[i], bi, acc_im[j][i]); // += ar*bi
acc_im[j][i] = simd.mul_add(ai[i], br, acc_im[j][i]); // += ai*br
}
}
}
}
One complex multiply-accumulate is 4 fused real steps, into 2 running banks. acc_re gets a mul_add and an fnma, the fused negate-multiply-add, vfnmadd on x86. acc_im gets 2 mul_adds.
Every operation is a plain lane-parallel FMA, on contiguous loads and scalar broadcasts. Nothing crosses lanes until the epilogue. The fixed per-p order is deliberate. It is what makes full tiles and edge tiles of the same matrix round identically.
After the loop, the banks drain to planar scratch. A scalar epilogue folds complex alpha, skipping the complex multiply when alpha == 1. It combines beta*C case by case, and re-interleaves on store. This is an amortized O(MN) pass that handles full, edge, and strided output tiles uniformly.
The scalar de-interleave in the pack, and the scalar re-interleave in the epilogue, are both a deliberate choice, not an oversight. The inner loop dominates the total cost of the kernel. So the generic scalar path stays the floor for both steps, on every ISA.
Register pressure and the NR choice
The split design doubles the accumulator count. An MR_REG x NR complex tile needs 2*MR_REG*NR accumulator registers, a real bank and an imaginary bank. It also needs 2*MR_REG A-plane registers, and 2 B splats per column step. That budget is documented tile by tile in gemmkit/src/dispatch/complex.rs. It makes the complex tiles smaller than the float ones, and it is where the tile shapes on this page come from.
On FMA, with 16 YMM registers, c32 runs MR_REG = 1, which is 8 complex rows at 8 real lanes, with NR = 5. That is 10 accumulators, plus 2 A registers, plus 2 B splats, for 14 of 16 registers. The 2 spare registers matter. A full 16-of-16 tile with NR = 6 instead spills accumulators to the stack. So NR was shrunk to 5, and the code comment records why.
AVX-512’s 32 ZMM registers relax the pressure. c32 runs MR_REG = 2, NR = 6, using 24 + 4 + 2 = 30 of 32 registers. NEON, with 32 vector registers, runs MR_REG = 2, NR = 5, for 26 of 32, leaving room for in-flight load temporaries. wasm simd128 runs MR_REG = 1, NR = 4, for 12 live v128 registers.
Each c64 variant keeps its c32 sibling’s MR_REG and NR, with halved lanes. The budget arithmetic does not depend on the lane count.
Accuracy and reproducibility
Complex has no special paths. run_complex routes every shape to driver::run, with no gemv, small_mn, or small-k arms. The Special Paths machinery is real-float and integer only.
This makes the numeric contract easy to state. gemmkit/tests/correctness/complex.rs tests each piece of it directly.
Determinism and thread independence hold bitwise. Blocking is thread-count independent, so serial and parallel runs of the same problem produce bit-identical output. The test asserts equality of the raw re/im bit patterns across thread counts.
Within one run, the fixed per-step order of the 4 FMAs means full tiles and edge tiles of the same matrix round identically. So results do not depend on where a tile boundary happened to fall.
Conjugation adds no rounding at all. It is a sign flip on exact values. A dedicated test on small-integer inputs, where every product and sum is exactly representable, checks all 4 conjugation combinations against a naive reference. That test uses exact equality, not tolerance.
Against an external oracle, the bar is necessarily looser. The correctness suite compares gemm_cplx, including every conjugation combination, against the gemm crate, under the suite’s usual L2-style tolerance. It separately exercises a negative-row-stride view, plus conjugation, against a row-reversed reference. This looser bar exists because a blocked SoA contraction legitimately rounds differently from another engine’s ordering.
That is the reproducibility contract, applied to complex numbers. Under a fixed input, environment, and configuration, the result is the same bits. Across different engines or different orderings, results agree only to tolerance.
The fused-bias entry is the exception that gets a bitwise guarantee. gemm_cplx_fused supports a per-row or per-column complex bias. It deliberately supports no activation, since ReLU-style activations are undefined on an unordered field.
Its epilogue never touches the kernel’s arithmetic. The SoA kernel stores exactly the bits plain gemm_cplx would store. A tile-local post-pass then maps them in place, on the final depth panel only. Complex is OUT_IS_ACC = true, so intermediate panels must keep their raw partials.
The result is bit-identical to gemm_cplx followed by the same element-wise bias add, for every shape and every conjugation combination. The Identity instantiation const-folds the post-pass away entirely. So the non-fused path pays nothing for the hook’s existence. Epilogue Fusion covers the general mechanism.
Blocking and the Cache Model
The microkernel at the bottom of the driver multiplies one MR x NR tile of C. It holds the tile in registers and streams a micropanel of A and a micropanel of B along the depth axis. This loop reaches machine peak only when both streams come from nearby cache. A GEMM touches far more data than any cache holds. It reuses every element of A across n output columns, and every element of B across m output rows.
Blocking is how the driver arranges this reuse. It partitions the problem so each operand block enters a specific cache level once, then gets read many times before anything evicts it. KC slices the depth axis so the 2 micropanels a tile multiplication reads stay resident in L1 for the whole tile. MC sizes the packed A macro-panel so it stays in L2 while the driver sweeps it across every column tile of the current column block. NC sizes the packed B macro-panel so it stays in L3 while every row block sweeps over it. Panel residency across the loop nest is the entire point. Without it, the same bytes would stream from DRAM m, n, or k times over.
Many libraries hard-code (MC, KC, NC) per microarchitecture. gemmkit instead computes them analytically for each call, in CacheTopology::blocking (gemmkit/src/cache.rs, layer L3). The function follows the BLIS model and derives the sizes from cache geometry detected at runtime. Its inputs are the microtile (mr, nr), the byte size of one packed input element, and the problem shape (m, n, k). Its output is the Blocking { mc, kc, nc } triple that the driver’s loop nest iterates by. Life of a GEMM Call shows where each value lands in the nest. This page explains how the model derives the values, and why it takes this shape.
3 constraints, 3 block sizes
KC: both micropanels in L1 without self-eviction
Every microtile call walks kc depth steps. Each step reads mr packed A elements and nr packed B elements. So an mr x kc micropanel and an nr x kc micropanel must both fit in L1d for the whole tile. The subtle requirement is without self-eviction. A cache is not a byte pool. It holds sets of ways. A panel that maps too many of its own lines onto the same sets evicts itself before its total size even reaches the cache size.
The model therefore works in lines and sets, not bytes. It computes how many L1 lines one depth step of each micropanel claims. Then it picks the largest kc whose combined footprint stays within the L1 associativity. It raises that result to the GEMMKIT_KC_MIN floor (default 512), so a small L1 never starves the microkernel’s depth walk, and clamps it to k. A final rebalance splits k into ceil(k / kc) panels of near-equal size, so the last depth slice is never a sliver.
MC: the A macro-panel in L2, minus what B needs
Within one row block, the driver reuses the packed mc x kc A panel across every column tile of the current column block. So the panel should fill L2. It cannot fill all of L2, though. The nr x kc B micropanel of the same depth slice also streams through L2 on every tile call. The model counts how many L2 ways that micropanel occupies. It reserves those ways plus one spare way, then hands the rest of the capacity to A.
It divides that remaining capacity by kc to get mc, and rounds the result down to a multiple of mr. It then rebalances the result so the row blocks come out even. A BLIS-style hard cap of GEMMKIT_MC_REG_PANELS * MR rows (default 8 microtile rows) clamps the result last. This cap is a calibration point, not a bound strictly derived from the L2 term. In practice the cap binds before the L2 capacity term does. So MC ends up as a small multiple of MR, and the L2 capacity term mostly serves as headroom.
NC: the B macro-panel in L3, or a panel cap without one
When an L3 is present, the model reserves one way for the A traffic passing through. It budgets the rest for the packed kc x nc B macro-panel. It divides that capacity by kc to get nc, rounds the result down to a multiple of nr, and rebalances it across n.
Some machines report no L3 at all. On Apple Silicon, for example, a cluster-shared L2 tops the hierarchy. There, the model runs full-N up to a panel-count cap instead. nc is GEMMKIT_NC_NO_L3_PANELS * nr (default 512 panels, that is, 2048 columns at nr = 4), capped by n. With no L3 to keep B resident, B streams from DRAM regardless. The cap only bounds the shared packed-B buffer. It does not model residency.
Sized in packed elements, not accumulator elements
The sizeof argument is the size of one packed input element. The driver passes size_of::<Fam::Lhs>(), not the accumulator size, because the model stores the panels it budgets in packed Lhs/Rhs units. For f32 and f64 the 2 sizes coincide, so nothing changes.
For narrow types the distinction matters more. i8 packs 1 byte per element against a 4-byte i32 accumulator. f16 and bf16 pack 2 bytes against a 4-byte f32 accumulator. Sizing by the accumulator would cut their kc and nc to a quarter or half of what the caches actually fit. Narrow types instead get proportionally deeper blocks, which is why they can outperform f32 on the same hardware. The prepack entries reuse the same model with a sentinel row count, so a prepacked operand’s geometry stays independent of the eventual m.
Prefetching the output tile past the LLC
The 3 block sizes keep the A and B panels resident, but they say nothing about C. Every microtile call reads, modifies, and writes its mr x nr output tile. Once a call’s working set (the A, B, and C bytes together) outgrows the per-core-reachable LLC, that output tile no longer lives in cache. Its store then reaches into DRAM.
The driver answers with a software prefetch, decided once per call. It compares the working set against cache::prefetch_ws_bytes (the GEMMKIT_PREFETCH_MIN_BYTES gate, where 0 means auto: the per-core-reachable LLC, L3 where present, otherwise L2). Once the gate clears, the driver issues a T0 prefetch of each output microtile just ahead of that tile’s microkernel call. This pulls the lines the store will touch into L1 while the microkernel still computes.
The hint walks whole 64-byte cache lines along the tile’s unit-stride dimension. A tile strided in both dimensions has no contiguous lines, so the driver skips it. The driver emits the hint only on x86_64 (prefetcht0, baseline SSE, needs no feature gate). On any other target it lowers to nothing, so aarch64 and wasm stay unaffected. The prefetch moves cache lines only, never arithmetic, so results stay bit-identical whether the gate is on, off, or forced. Below the gate, where the tiles stay cache-resident, the prefetch path adds no extra cost.
The tiny-matrix shortcut
When both m and n are at or below GEMMKIT_TINY_BLOCK_DIM (default 64), the driver skips the full model. It sets kc to k, clamped to the GEMMKIT_KC ceiling (default 2048, or 16384 on aarch64). It sets mc to whatever row count keeps the panel in L2 at that depth, capped by m itself. It sets nc to n rounded up to nr.
A problem whose whole working set fits in L2 gains nothing from 3 levels of residency analysis. The shortcut spends the saved arithmetic where it matters: on the fixed per-call overhead that dominates small products.
The ceiling counts 4-byte elements, and a narrow element divides it. So f16 gets 2 times the depth of f32, and int8 gets 4 times. What stays fixed is the byte budget, which is what the hardware limit is about. One number then calibrates every element family, and a tuner sweep run on f32 transfers to the rest.
The ceiling sets the depth-slice count, and the slice count drives 2 costs that pull in opposite directions. Each extra slice re-reads and re-writes C, re-enters the driver, and forks the workers once more. That argues for a deep ceiling.
A deeper slice also grows the packed A and B panels. Those panels must stay in a private L2, so that argues for a shallow ceiling. On x86 the panels reach about 1.1 MiB at the default, which is one Zen5 L2. The parallel cost is the larger of the 2, so the default sits at the residency limit rather than below it.
A wide element is the case where those 2 costs disagree about the divisor. A byte budget is not a slice budget. At a fixed budget, a 16-byte element takes 4 times the slices of a 4-byte element, so it pays the per-slice cost 4 times as often. Residency wants the division, and the slice count does not.
Which cost wins is a property of the machine, so the divisor carries an arch-split cap. On x86 the private L2 is 1 MiB, residency binds first, and the divisor applies at every element size. On aarch64 the effective L2 is 4 times larger, and unified memory feeds an overflowing panel well. Residency barely binds there, so a wide element keeps the whole ceiling. Measured on an M4 Max, dividing it cost c64 21 to 51 percent on the parallel path.
Detection: a fallback chain that cannot fail
The model is only as good as the geometry it receives, and there is no portable way to ask for cache geometry. gemmkit instead runs a best-effort chain, where #[cfg] only ever picks the sniffing method, never the values. A #[cfg(target_arch)] check cannot tell an Intel part apart from an AMD part, and a VM or container can mask CPUID or hide /sys. So every backend returns an Option, and the chain bottoms out in a constant that cannot fail.
#![allow(unused)]
fn main() {
// gemmkit/src/cache.rs
#[cfg(feature = "std")]
fn detect() -> CacheTopology {
// try the CPUID backend
#[cfg(all(any(target_arch = "x86", target_arch = "x86_64"), not(miri)))]
if let Some(t) = cpuid::detect().filter(plausible) {
return t;
}
// try the sysfs backend
#[cfg(all(target_os = "linux", not(miri)))]
if let Some(t) = sysfs::detect().filter(plausible) {
return t;
}
// try the sysctl backend
#[cfg(all(target_os = "macos", not(miri)))]
if let Some(t) = sysctl::detect().filter(plausible) {
return t;
}
ZEN5_FALLBACK
}
}
The backends run in this order.
CPUID (cache/cpuid.rs) is a single instruction, so it works regardless of OS, in containers, and in most VMs. CPUID reads both vendors through the per-cache topology leaf (Intel 04h, AMD 0x8000_001D), which describes each cache as reachable from the executing core. On a multi-die part such as a 2-CCD Ryzen, the L3 figure is the one complex a core can reach (32 MiB on the 9950X). It is not the package total. That per-core-reachable figure is the semantic every consumer of the value wants. AMD parts or hypervisors without that leaf fall back to the legacy L1 (0x8000_0005) and L2/L3 (0x8000_0006) leaves. There, L3 size arrives in units of 512 KiB as a package total, and the legacy fields cannot even encode 16-way associativity.
Linux sysfs (cache/sysfs.rs) parses /sys/devices/system/cpu/cpu0/cache/index*/ with plain std::fs. It serves as a fallback on x86 Linux, for a hypervisor that masks CPUID. It is also the primary source on aarch64 Linux, which has no CPUID instruction.
macOS sysctl (cache/sysctl.rs) reads sysctlbyname keys through a 2-line extern "C" block, with no libc dependency. It prefers the Apple Silicon per-performance-level keys (hw.perflevel0.*, the P-cores), with the flat Intel-Mac keys as a fallback. sysctl does not expose associativity, so the backend assumes conservative typical values. This is safe, because the model clamps associativity with .max(2) and needs it only approximately.
The bottom of the chain is ZEN5_FALLBACK, a static default calibrated on the Ryzen 9950X dev machine. L1d is 48 KiB and 12-way. L2 is 1 MiB, 16-way, and private. L3 is 32 MiB and 16-way.
2 guards make the chain robust rather than merely ordered. plausible rejects half-populated reads. Any level smaller than 4 KiB, a line under 16 bytes, or zero associativity fails the whole backend. A masked leaf therefore cannot poison blocking with zeros. Detection also runs at most once per process. Machine::current() memoizes the topology behind a OnceLock. It also memoizes the OS page size (getpagesize, validated as a power of two between 4 KiB and 2 MiB). That page size drives the LHS-packing stride gate described in Packing and Workspaces. A no_std build skips detection entirely and uses the Zen5 fallback with a 4 KiB page.
shared_by: contention for what the driver puts there
Each Level carries bytes, assoc, line, and one derived field, shared_by, which divides the level’s capacity into the effective_bytes that the model actually budgets. Storing the hardware core-sharing count there would seem natural, but it would be wrong. shared_by instead models per-worker contention for the data the driver actually places at that level. The driver’s placement is: per-worker A/B micropanels in L1d, each worker’s private A macro-panel in L2, and one shared B macro-panel in L3.
That placement fixes the values. L1d is per-core, so its whole capacity serves one worker’s micropanels, and shared_by = 1. L3 is shared by every core in hardware. The data the driver keeps there, though, is a single panel. All workers read that panel in common: the same bytes, not per-worker copies. So the whole level belongs to that one panel, and shared_by is again 1. Dividing by the raw core count would shrink the budget many times over and crater NC for no reason.
Only L2 holds genuinely private per-worker data, so only L2 uses the physical-core L2-sharing degree. That degree is 1 on parts with a private L2, such as mainstream x86 and Neoverse. It is the cluster size on parts where a core cluster shares one L2, such as Apple Silicon. There, several workers’ private A panels really do contend for the same ways. Each backend must derive this value rather than copy a raw count. sysfs divides the raw L2 shared_cpu_list count by the SMT degree read from L1d’s sharing list, so hyperthread siblings are not double-counted. sysctl reads hw.perflevel0.cpusperl2. The CPUID backend hard-sets 1, because x86 L2s are private to each physical core.
On x86 and Graviton the whole mechanism reduces to all-ones. It exists for the cluster-L2 parts. There, it decides whether the model blocks for the L2 a worker actually gets, or for one it must share across a whole cluster.
What the thread count moves, and what it cannot
blocking takes no thread-count parameter. For KC and NC that omission carries real weight. Both depend only on the machine and the problem. So a serial run and a wide parallel run derive the same KC and the same NC. That gives every run the same depth slices, and the same fixed-order depth chain for every output element.
MC is the one blocking dimension the driver does adjust for parallelism. A wide worker count can leave the flat job list too shallow, with fewer than a handful of chunks per worker. The run’s tail then degenerates into idle workers waiting on whoever drew the last chunks. When that happens, the driver shrinks MC to cut more row blocks and deepen the list. Parallel Execution details this parallel job-depth floor. So the panel boundaries and the flat job list are not strictly worker-count independent anymore.
Bit-identity survives the MC shrink regardless, because the shrink itself carries no numerics. MC always stays an MR multiple. So the set of microtiles it produces (every MR-aligned row offset plus the single m-tail tile) stays identical under any split. A wider worker count only regroups the same tiles into more, smaller row blocks. KC, the only blocking dimension that shapes a tile’s accumulation order, never moves with the thread count. So under a fixed configuration, changing only the worker count leaves every output element’s accumulation order unchanged. That is the mechanism behind gemmkit’s reproducibility contract. Parallel Execution assembles the full contract and states its exact scope.
Parallelism otherwise influences packing decisions only. The LHS pack gate reads per-worker column reuse, and the shared-A pre-pass engages only on large parallel problems. Those decisions choose where the driver stages packed bytes and who writes them. They never change what values the kernel computes. Parallel Execution covers how the job list splits, and how the contract holds end to end. Tuning Knobs catalogs every GEMMKIT_* threshold named on this page, and every other one.
Packing and Workspaces
The microkernel wants its inputs in exactly one shape. For each depth step, it wants mr elements of A contiguous in memory, and nr elements of B contiguous in memory. It wants them panel after panel, with nothing between them. User matrices almost never look like that. They have arbitrary row and column strides, tails that do not divide the microtile, and depth walks that can stride across memory pages.
Packing is the copy that closes this gap. It rearranges each macro-block into micropanel-major layout once, so the innermost loop reads pure unit-stride streams, full mr/nr vectors every time, from 64-byte-aligned scratch. The copy costs O(mc*kc), against the O(mc*kc*nc) compute that reuses it. That is why it amortizes, and why the driver still skips it when reuse is too low to pay for it.
One routine, both operands
The mechanical copy lives in a single routine, pack_panels (gemmkit/src/pack.rs, layer L1). The LHS and RHS layouts are the same layout, viewed from different sides. An LHS macro-block packs into panels mr rows tall, stored column by column. Panel 0 holds rows 0..mr, with each depth step’s mr elements contiguous. Panel 1 holds rows mr..2*mr, and so on. An RHS macro-block packs into panels nr columns wide, stored row by row.
Both layouts are width contiguous leading elements per depth step. The only difference is which matrix axis plays the leading role. So the 2 KernelFamily hooks call the same routine, with the strides swapped:
#![allow(unused)]
fn main() {
// gemmkit/src/kernel/float.rs
#[inline]
unsafe fn pack_rhs(
dst: *mut T,
src: *const T,
rs: isize,
cs: isize,
kc: usize,
nc: usize,
nr: usize,
) {
// RHS panels are `nr` columns wide, stored row-by-row: the "leading"
// direction is columns (stride `cs`) and the "depth" is rows (stride
// `rs`), the transpose of the LHS case, handled by swapping strides
unsafe {
pack_panels(
dst, src, /*lead*/ cs, /*depth*/ rs, /*n_lead*/ nc, kc, nr,
)
}
}
}
pack_lhs is the mirror image: lead = rs, depth = cs, width = mr. When the block does not divide evenly, the routine zero-fills the tail panel’s dead lanes. The kernel then always reads full mr/nr vectors, and edge tiles need no masking in the multiply itself.
Inside the routine, 2 paths write byte-identical output. The 1st case is a contiguous leading dimension (lead == 1), for a column-major A or a row-major B. There, each depth step’s live elements already sit adjacent in the source. The panel is then a sequence of straight copy_nonoverlapping calls, plus a tail zero-fill.
The 2nd case is a strided leading dimension. There, a naive gather would take a cache miss per element (width strided loads per depth step). Instead the routine runs a cache-blocked transpose. It walks the source along its contiguous dimension in strips of GEMMKIT_PACK_TRANSPOSE_TILE depth steps (default 16), and scatters each strip into the panel. This produces the same packed bytes as a pure reordered copy, but far more cheaply for a strided source. That is what makes row-major-A layouts cost little more than column-major ones.
The dot-product families (i8 VNNI, bf16 vdpbf16ps) have a sibling routine, pack_kgroup_panels. It additionally interleaves DEPTH_MULTIPLE consecutive depth steps per lane, so one dot instruction can consume a whole group. That layout belongs to Dot Kernels and the Deep-K Twin.
Whether to pack at all is the driver’s call, and the 2 operands are asymmetric.
The microkernel reads A as mr-wide vectors. So the driver must pack A whenever its rows are not unit-stride, or the row panel is partial. Beyond that, the driver packs A when each worker’s column reuse clears GEMMKIT_LHS_PACK_THRESHOLD (default 256 columns on aarch64, 1024 elsewhere).
The driver also packs a column-major A under one more condition. Its depth walk must be page-scale in stride, wide in span, and reused by enough column tiles to be worth the cost. All 3 conditions must hold together:
- The per-step stride reaches half a memory page (
GEMMKIT_LHS_PACK_STRIDE, auto-derived from the page size memoized inMachine). - The whole depth-slice walk (
csa * sizeof(Lhs) * kc) reachesGEMMKIT_LHS_PACK_SPANbytes (auto: 4 MiB). - At least
GEMMKIT_LHS_PACK_REUSEnr-wide column tiles reuse each packed panel (min(n, nc) / nr, rounded up, default 128 on x86, 4 on aarch64).
Each gate rules out a different case where packing would not pay for itself. A page-scale stride over a span that still fits in cache only re-walks lines that are already warm. Reading A in place then costs less than paying for a pack. The span condition keeps A in place until the walk is wide enough to thrash the TLB, regardless of how much reuse follows.
The reuse floor prices the opposite failure mode. Take a tall, skinny shape, where m is much greater than n. It reaches a large span from very few column tiles. Packing it would then amortize an expensive copy over too little reuse to be worth it.
The reuse floor differs by architecture, because the pack-versus-read-in-place trade differs by architecture. On x86 a pack costs more relative to an in-place read. So the driver waits for more reuse before it pays for one, and the default floor is 128 tiles. On aarch64 a pack costs less relative to an in-place strided read. So the driver packs sooner, and the default floor is 4 tiles.
B, by contrast, is only ever read by broadcasting single elements, so any layout works unpacked. The driver packs B purely for reuse: once per depth slice, when m clears GEMMKIT_RHS_PACK_THRESHOLD (default 2048) and enough row blocks will re-read it. Who performs these packs, and the barriers between packing and compute, are scheduling questions. Parallel Execution covers them.
Prepacked operands
A per-call pack is wasted work when the same operand appears in call after call. This is the inference pattern: fixed weights multiplied against a stream of activations. The prepack entries in gemmkit/src/api/packed.rs pack a whole operand once, up front. prepack_rhs walks any-layout B through its strides and returns a PackedRhs<T>. gemm_packed_b then multiplies against it, skipping the per-call RHS pack entirely. Prepacked Operands covers the usage side of this API. Architecturally, 3 properties matter.
First, the buffer records the blocking geometry it was built for: nr, kc, and nc. The consuming call reads that geometry back verbatim. The driver substitutes the recorded kc and nc for its own model output. Only mc still derives from the real m. So panel addresses always match what the buffer packed, even if a tuning knob changed between the pack call and the consuming call. The geometry itself resolves through the same blocking model as a plain call, with a sentinel row count of tiny_block_dim() + 1. This keeps it off the tiny-matrix branch, so it stays independent of the eventual m.
Second, the layout has one source of truth. prepack_rhs fills the buffer through driver::pack_rhs_full. This lays panels down in exactly the order the driver’s own per-slice pack writes them. The order is jc blocks outermost, then depth slices, then the nr-wide panels of each slice. The prepacked bytes therefore equal the per-call packed bytes. So a prepacked GEMM reproduces a plain gemm under the same configuration. The documented exceptions are tiny products (m and n at or below tiny_block_dim) and gemv-shaped products. Plain gemm reroutes those to special paths, so they may differ in the last ULP.
Third, the buffer stays read-only during the GEMM, so every worker shares it with no synchronization. Unlike the per-call B pack, it needs no barrier.
PackedLhs costs almost no extra code, because of the engine’s A/B symmetry. An m x k LHS is the RHS of the transposed product C^T = B^T*A^T. So prepack_lhs delegates to prepack_rhs_unchecked with the strides swapped, and gemm_packed_a consumes it through the transposed problem. This symmetry also explains the orientation asserts. A prepacked B requires a column-major-ish C (|csc| >= |rsc|), and a prepacked A requires a row-major-ish one. The other orientation would make dispatch swap the operand roles, and the baked-in layout could not serve that swap.
The int8 feature adds a heterogeneous twin, prepack_rhs_i8 and gemm_i8_packed_b, with 3 deliberate differences.
First, prepack_rhs_i8 pins its layout to whichever integer kernel the process’s memoized dispatch selected: the VNNI k-quad-interleaved layout, or the widen kernel’s plain panels. The consuming entry always runs that same family, so it can never misread the buffer.
Second, it rounds the buffer depth up to the dot kernel’s DEPTH_MULTIPLE = 4 and packs the whole contraction as one depth slice. This satisfies the driver’s single-slice guard for depth-padded families.
Third, it deliberately bypasses the dynamic small-parallel widen fallback that plain gemm_i8 applies below GEMMKIT_I8_VNNI_MIN_PAR_MNK. A vpdpbusd buffer is quad-interleaved, so the widen kernel simply cannot consume it. Because integer accumulation is exact, the result is bit-identical to plain gemm_i8 either way.
Prepacking matters most on exactly this path. The VNNI RHS pack is otherwise mandatory on every call, so at small m the per-call O(k*n) pack cost dominates the O(m*k*n) compute.
The workspace
All of this packing needs scratch memory. Workspace (gemmkit/src/workspace.rs) is its allocator: a growable buffer, 64-byte aligned (enough for AVX-512 stores), that grows to the next power of 2 and never shrinks. Per call, Workspace::regions carves it into a_regions equal LHS regions plus one shared RHS region, with each region rounded up to the alignment.
The LHS region count is the worker count on the per-worker pack path, or the row-block count on the shared-A path. The carving works the same way either way. When neither operand packs, the driver skips the reservation entirely, so an all-in-place workload never grows the pool.
Fail closed at the byte product
The sizing arithmetic is where a memory-safety subtlety hides. gemmkit accepts broadcast (zero-stride) views. These pass bounds validation with a tiny backing slice, while presenting logical dimensions up to isize::MAX. So the products that size the pack buffer can genuinely overflow usize. A wrapped (too-small) size would then under-allocate a buffer that the pack writes past.
The driver guards its element-count products with checked_mul, but element counts alone are not enough. Take k = 2^56 on the mixed-precision path, where kc == k. An LHS region of mc * kc elements, say 32 * 2^56 = 2^61, fits usize comfortably and sails through every element-level check. Multiply that count by the element size and round up to the 64-byte alignment, though, and the value wraps. The overflow only appears at the element-to-byte conversion. That is where the guard must sit: at the chokepoint every region size funnels through:
#![allow(unused)]
fn main() {
// gemmkit/src/workspace.rs
fn region_bytes(elems: usize, esize: usize) -> usize {
elems
.checked_mul(esize)
.and_then(|b| b.checked_next_multiple_of(ALIGN))
.unwrap_or_else(|| workspace_too_large())
}
}
Workspace checks every step: the byte product, the alignment round-up, the region sum, and the final A + B total. Any overflow panics with the same “too large” contract as the driver’s own sizing. This is fail closed. The code rejects an absurd problem loudly, instead of corrupting memory. The driver runs its element-count guards unconditionally for the same reason, even on routes that end up packing nothing. Skipping them would also skip the abort, and send the absurd k into the in-place loops to spin for an effectively unbounded time.
The pool, _with, and no_std
Callers rarely see a Workspace, because a thread-local pool supplies one transparently. The common gemm call allocates at most once per thread, and reuses that buffer for every later call.
The pool is also re-entrancy-safe. Nested rayon can re-enter a GEMM on a thread already inside one. For example, a worker might work-steal another GEMM while blocked in its own for_each, or a batch-parallel worker might run an element inline. In that case the pool’s RefCell is already borrowed. So with_thread_pool hands out a fresh scratch workspace for that one call, instead of panicking. Packing buffers hold no result state between calls, so this fallback is invisible. Only that one call skips the buffer reuse.
For explicit control there is the *_with tier. Every entry has a variant (gemm_with, gemm_packed_b_with, and so on) that threads a caller-owned Workspace through. From the second sufficiently large call on, this gives zero heap allocation. It is the tool for hot loops of small products and for latency-sensitive code, and Workspace::with_capacity avoids even the first-call allocation spike.
Without std there is no thread-local storage, so with_thread_pool simply builds a fresh workspace per call. Because parallel requires std, there are no threads to re-enter either. A caller who wants reuse on such a build holds its own Workspace and uses *_with. no_std and WebAssembly recommends this same pattern.
Parallel Execution
Parallelism in gemmkit lives in one small layer, gemmkit/src/parallel.rs (layer L2), with 2 jobs. It decides how many workers a problem deserves, and hands which work to each of them.
Both decisions are deliberately conservative, because more threads are not free. The layer’s design starts from one observation: the wrong worker count loses more performance than the wrong schedule does. Both decisions are also shaped so that the numerical result never depends on either one.
The user-facing surface is a single enum. It is Parallelism::Serial, or Parallelism::Rayon(n). Rayon(0), the default, means auto.
Workload-aware worker resolution
Parallelism::resolve turns the request into an actual partition count. The request is only one input. The workload is the other.
First comes the total-work serial gate. Below GEMMKIT_PARALLEL_THRESHOLD (default 48*48*256 for m*n*k), everything stays serial, before the resolver even samples the core count. Forking rayon for a product that takes only microseconds would cost more than the product itself. The gate precedes the request check, so even an explicit Rayon(n) stays serial below it.
Above the gate, the resolver honors an explicit count, capped by the core count and by the number of available jobs. So Rayon(huge) can neither over-subscribe the machine nor over-allocate per-worker pack regions. Only the auto path is heuristic. This keeps forced widths exact for tests and for scaling diagnostics.
The auto path is work-based. It divides the total work m*n*k by GEMMKIT_PAR_MNK_PER_WORKER (default 2_000_000, the per-worker floor below which fork/join overhead outweighs the gain). This division gives the worker count, floored at 1 and capped by the core count and the job count.
The path is work-based rather than dimension-based, because the optimal worker count tracks total flops, not linear size. A small cube runs fastest serial. A mid-size cube wants only a few workers. A large cube wants every hardware thread the machine has. No single stride on a linear dimension can fit that spread.
Scaling to full width at mid sizes depends on one more thing: avoiding redundant per-worker packing of the same A panel. Packing and Workspaces covers the LHS in-place gate that keeps that redundancy from happening. The GEMMKIT_PAR_MNK_PER_WORKER knob is the escape hatch for a machine whose per-worker floor differs from the compiled default.
Bandwidth-bound shapes get a different rule entirely. A gemv or gevv does O(1) arithmetic per byte, so the compute ramp’s logic does not transfer. resolve_bandwidth gates on bytes touched instead.
Below a cache-derived byte floor, the matrix fits one core’s private cache, and that core saturates it alone. Splitting then only adds fork/join and shared-cache contention, with no bandwidth to gain. gemv_parallel_floor_bytes (in cache.rs) derives this floor from the topology. On parts with an L3, the floor is the per-core private L2. On parts without one, it is a fraction of the full shared cluster L2. GEMMKIT_GEMV_PARALLEL_BYTES overrides the floor directly.
Above the floor, the matrix spills to the shared L3, whose bandwidth a single core cannot saturate on its own. So the auto count steps straight to a wider width for those bytes, instead of ramping up to it. That width climbs a ladder built from the exact-fit pool tiers described below. The smallest tier sits at the floor, and the count climbs one tier for each GEMMKIT_GEMV_TIER_STEP (default auto, 8) factor of touched bytes. The ladder stops at the largest tier rather than the full machine width. A gemv saturates its bandwidth well before the machine runs out of cores, and more workers past that point can cost more than they gain.
gemmkit builds the rungs from the pool tiers themselves, not from a separate set of fractions. So an auto gemv width always has an exact-fit pool waiting for it, and it never pays the slack tax those tiers exist to remove. GEMMKIT_GEMV_THREAD_CAP replaces the whole ladder with one flat width, for a deployment that wants to fix it.
There is no ramp between rungs, because a bandwidth-bound scaling curve dips at a handful of workers. Fork/join and contention costs are already paid there, and aggregate bandwidth has not yet arrived. Any ramp through that dip would lose to both of its endpoints. So the rule stays simple: serial below the floor, a tier width above it, and nothing in between.
Batched GEMM has its own resolver, resolve_batch, which chooses between 3 plans. Serial runs every element in turn on the calling thread. BatchParallel(n) gives each worker whole, cache-hot GEMMs to run, paying one fork/join for the whole batch. Because no element ever splits, this plan stays bit-identical across worker counts. SequentialInternal instead loops the batch on the calling thread, giving each large, DRAM-bound element the full engine parallelism in turn.
resolve_batch gates the SequentialInternal split to m, n > 1 shapes, whose routes are worker-count independent. A gemv-shaped element always stays whole on one worker instead. Special Paths covers the routing, and Batched GEMM covers the API.
Demand-driven work distribution
Given a worker count, the driver does not build a nested task tree. For each column block and depth slice, it flattens the inner work into a flat 1-D job list: n_mc row blocks times n_nt column tiles. Job q decodes to (ic_idx, jt) = (q / n_nt, q % n_nt). Workers pull contiguous chunks from a shared, lock-free cursor until it is empty:
#![allow(unused)]
fn main() {
// gemmkit/src/parallel.rs
impl JobCursor {
/// Atomically claim the next `[start, end)` chunk, or `None` once the job space
/// is exhausted
#[inline]
pub(crate) fn next_chunk(&self) -> Option<(usize, usize)> {
let start = self.next.fetch_add(self.grain, Ordering::Relaxed);
if start >= self.n_jobs {
None
} else {
Some((start, (start + self.grain).min(self.n_jobs)))
}
}
}
}
Each claim costs one fetch_add. There are no locks and no per-job queues. Demand-driven pulling is what makes heterogeneous cores work well. On a big.LITTLE part, a P-core that finishes chunks faster simply pulls proportionally more of them. A static n_jobs / n_threads split would instead leave every core waiting on the slowest one. The same property also absorbs OS noise and frequency differences on homogeneous machines.
The chunk grain balances 2 costs. Too coarse a grain leaves the tail of the job list idling workers at the join. Too fine a grain lets the atomic claims start to show, along with, on the packed-LHS path, re-packing at chunk edges.
The general grain oversamples the worker count. job_grain targets GEMMKIT_PARALLEL_OVERSAMPLE chunks per worker (default 8), so each worker expects several pulls, and imbalance self-corrects.
The packed-LHS path is special. Its natural chunk is a whole row block (n_nt consecutive jobs). A worker packs the block’s A panel once, and reuses it across all the block’s column tiles. That yields only n_mc chunks. So when the row-block count is small, packed_block_grain splits each block into power-of-two column sub-chunks. It splits until there are about GEMMKIT_PACKED_OVERSAMPLE * n_threads chunks (default target 2), and only by divisors of n_nt. So a chunk never straddles a row-block boundary and re-packs A mid-chunk. Splitting harder than this target re-packs too often and makes performance worse.
2 parallel phases run before the compute region in each depth slice, and their boundaries are the only barriers in the driver.
When B packs, workers pull nr-wide column panels from their own cursor. The fork/join of that region is the write-before-read barrier the compute region depends on, because packed B is the one buffer shared non-disjointly across workers.
The shared-A pre-pass packs each row block’s panel once into a shared slot, under the same discipline. It opens above a size gate, and it also opens from 16 workers up regardless of size. At that width, every extra worker is another redundant copy of each panel it touches, so deduplicating pays off even on mid-size problems.
Everything else is disjoint by construction. Workers write only their own output tiles and their own pack regions. That invariant is what lets the Ptr shim declare the captured raw pointers Send + Sync, in one audited place.
Size-class thread pools
Rayon’s fork/join cost does not scale with the problem. It scales with the pool’s idle slack: the gap between the threads a pool owns and the workers a given call actually engages. Forking k workers into a w-wide pool wakes w threads, not k. The w - k threads that get no work still pay their share of the barrier and the OS-level wake/park round trip.
The full-width global pool is the worst case for a small parallel GEMM. A mid-size product often wants only a fraction of the machine’s threads. Forking it into the full-width global pool then wastes most of that width as pure slack tax on every call.
gemmkit’s answer is a small set of private, persistent pools, each sized to exactly one of the worker counts the auto path actually asks for. Below full machine width, it keeps up to GEMMKIT_POOL_CLASSES halving tiers (default 2 on x86_64, 1 on aarch64, clamped to 3, 0 elsewhere). The tiers are a half-width pool, a quarter-width pool, and so on, each halving the machine’s physical width again. Each tier pool builds lazily, on first use, at a small one-time cost, and it never rebuilds afterward. The tiers are a fixed halving of the machine width, not a value tuned per shape.
The auto path snaps its worker count exactly onto a tier, so it carries zero slack by construction. It stays on its largest tier until the total work m*n*k clears GEMMKIT_FULL_WIDTH_MNK (default auto, arch-split: 110_000_000 on x86_64, 14_000_000 on aarch64). Past that point, the extra full-width workers finally pay for the fork/join they add, and full machine width takes over.
3 rules keep this mechanism from ever fighting the caller’s own scheduling.
- A call already running inside a rayon pool, whether the caller’s own
installor a nested gemmkit call, never redirects to a tier pool. The ambient pool always wins, exactly as it did before tier pools existed. - An explicit
Rayon(n)keeps its exact semantics of preciselynworkers. It only picks the smallest tier pool that fitsn, instead of forking into the global pool. The worker count stays unaffected. Only the pool it forks into changes. - Threaded wasm keeps its own dedicated pool, described below, untouched by any of this. Tier pools are a native, non-wasm concern.
The wasm story
On wasm32-wasip1 there are no threads to spawn, and rayon would trap if it tried. The compile-time constant RAYON_USABLE captures whether the target can run workers at all. On a wasm build without the threading opt-in, every resolver returns 1, and for_each_worker runs the plain serial loop. parallel degrades gracefully instead of trapping.
The opt-in is the wasm_threads feature, which targets wasm32-wasip1-threads or a browser with SharedArrayBuffer. Because available_parallelism is unsupported on wasm, rayon’s global pool would otherwise silently size itself to 1 thread. So gemmkit builds its own pool instead, sized by the GEMMKIT_WASM_THREADS knob (default 8), and installs it around the worker loop. The deployer states the width, and everything else stays unchanged. no_std and WebAssembly has the details of the wasm builds.
The reproducibility contract, assembled
gemmkit’s reproducibility contract is simple. For a fixed machine and a fixed configuration, the engine produces reproducible output. This is not a promise of bitwise-identical results across different configurations, and the worker count counts as part of that configuration.
Today, gemmkit also holds a stronger property, as an engineering fact rather than a separate promise. Changing only the worker count, with everything else held fixed, still produces bitwise-identical output. 3 mechanisms make that true. Here is how they fit together.
First, the numerics do not depend on the worker count. Blocking derives kc and nc from the cache model alone, never from the thread count. The depth slices always run in the same fixed pc order. So every output element’s floating-point reduction takes the same shape, whether one worker drains the cursor or many do. The one dimension a worker count can move is mc. A wide count shrinks it, through the driver’s parallel job-depth floor, so the flat job list stays several chunks deep per worker. The job list itself is therefore not always identical, chunk for chunk, across widths. But mc always stays an mr multiple. So the microtile set (every mr-aligned row offset plus the one m-tail tile) is the same under any split. With kc and the pc order untouched, no result bit moves. The worker count changes how work is grouped and partitioned. It never changes what each tile computes.
Second, no reduction ever splits across workers. Within a depth slice, one worker computes an output tile’s whole update, so a chunk is always a set of whole tiles. The depth slices themselves run sequentially, since the pc loop is never parallel. beta applies on the first slice, and later slices accumulate. So every output element’s floating-point reduction runs in one fixed order, determined by the blocking alone. The special paths keep the same discipline. gemv partitions output rows on register-panel boundaries, so each row’s SIMD/scalar split does not depend on the partition. That makes gemv bit-identical across worker counts outright. Batched plans either keep whole elements on one worker, or split only shapes whose routes are already worker-count independent.
Third, packed bytes do not depend on who packs them. pack_panels is a pure reorder, and both of its branches write identical bytes. A panel packed by 1 worker, by the shared-A pre-pass, or by prepack_rhs earlier, is therefore the same sequence of bytes. See Packing and Workspaces. The kernel cannot observe who staged its input.
Which worker computes a given tile genuinely varies from run to run, since the cursor hands chunks to whoever asks first. By the 3 mechanisms above, though, nothing numerical depends on that choice. This bitwise agreement across worker counts is a property of gemmkit’s design today, not a wider guarantee. It leaves room for a tolerance-held kernel, such as the bf16 dot path, to reshape its accumulation without breaking the actual contract. Parallelism in Practice covers how to choose worker counts in practice, and what the contract means for testing.
Special Paths
The register-tiling driver described in Life of a GEMM Call is built around 1 assumption. It assumes there is enough work per output tile to amortize packing, blocking, and a full MR x NR register accumulator. Some shapes break that assumption badly. A matrix-vector product has no tile reuse at all. A k = 4 product finishes before the pack pays for itself. An 8 x 8 x 100000 contraction would spend most of the driver’s effort multiplying zero padding.
Layer L6 (gemmkit/src/special/) reroutes exactly these shapes to dedicated kernels. Every reroute holds 3 properties. It hides behind the same public entries, so gemm and its siblings never expose which path ran. It is threshold-gated through tuning knobs, so a mis-calibrated gate can move or turn off without a recompile. It also keeps the library’s reproducibility contract: the same machine and the same configuration give a reproducible result. Most of the routes below go further and stay bit-identical across worker counts for a fixed shape. gemv is the exception. Its own section below explains why.
The gates live at the top of each per-type dispatch entry, in a fixed order. This is run_typed from gemmkit/src/dispatch/float.rs, trimmed for clarity:
#![allow(unused)]
fn main() {
// gemmkit/src/dispatch/float.rs (run_typed, trimmed)
if (t.n == 1 || t.m == 1) && core::cmp::min(t.m, t.n) <= tuning::gemv_threshold() {
gemv::run_typed_epi::<T, S, Identity>(/* user frame, before orientation */);
return;
}
orient_transpose(&mut t);
if small_mn_eligible(&t) || small_mn_pack_eligible(&t) {
small_mn::run_epi::<T, S, Identity>(/* horizontal dot kernel */);
return;
}
if t.k <= tuning::small_k_threshold() {
small_k::run::<FloatGemm<T>, S, MR_REG, NR>(/* one depth panel, in place */);
return;
}
driver::run::<FloatGemm<T>, S, MR_REG, NR>(/* the general blocked driver */);
}
gemv fires before orientation normalization, in the user’s coordinate frame. The other gates run on the oriented problem. Each special path also exists in a fused-epilogue form, so a gemm_fused call takes the same route its plain twin would. That contract is the subject of Epilogue Fusion.
gemv: the memory-bound edge
A shape with m == 1 or n == 1 (gemmkit/src/special/gemv.rs) does 2k flops per output element, against k matrix elements read once. This makes it memory-bound. The whole design question is how to cut DRAM traffic, not how to schedule FMAs. Both orientations reduce to 1 core routine. It views the matrix, transposed when m == 1, as a rows x k block times a k-vector.
The gate checks shape, not size. min(m, n) is 1 for any gemv shape, so the GEMMKIT_GEMV_THRESHOLD knob it compares against works as an on/off switch rather than a size cap. Set it to 0 to force gemv shapes onto the general driver instead, which still produces a correct result.
Parallelism follows a bandwidth model, not the compute ramp. Parallelism::resolve_bandwidth stays serial below a cache-derived byte floor. Below that floor, the matrix fits inside 1 core’s private cache, and that core saturates it alone. Above the floor, the resolver steps straight to a width sized for the bytes touched. That width climbs a ladder over the exact-fit pool tiers. It tops out at half the logical cores, because a handful of workers is the worst point on a bandwidth scaling curve.
row_sweep partitions output rows across workers in panels whose grain is a multiple of the SIMD width. Each row’s full k-reduction stays inside 1 worker, and no worker ever merges a partial result from another. This keeps gemv reproducible at a fixed worker count, the same bar the rest of the engine holds to. Splitting the rows across more or fewer workers does not, by itself, promise bitwise agreement across those different worker counts. small-k, small-mn, and batched, below, go further than that.
Inside a worker’s row range, the code picks 1 of 4 strategies based on layout. A column-major matrix takes the axpy shape. It offers 2 variants that are deliberately bit-identical and differ only in memory traffic.
The register-blocked output form holds a panel of output rows in SIMD registers across the whole k-sweep. It reads the matrix and the output exactly once each. The plain column-outer form re-reads the output every few columns, but it streams the matrix as 1 contiguous read. The choice between them depends on output cache residency, computed in output_register_block. The route picks register-blocking under 2 conditions. First, the output (rows * sizeof) must outgrow a fraction of the last-level cache, so the plain form’s re-reads would otherwise reach DRAM. Second, k must sit at or below GEMMKIT_K_STREAM_MAX (default 32). Past that k, the register-blocked form’s many concurrent column streams start to thrash the hardware prefetcher. Both variants run the same ascending-k fused accumulation per element, and the same per-row SIMD-versus-scalar split. Switching between them never changes a single output bit. It only changes speed.
A row-major matrix takes a dot form instead. It register-blocks rows in groups of 4 to overlap FMA latency chains, while each row still runs the shared, fixed-order dot_contiguous reduction. Fully strided operands fall back to a scalar loop.
One shape fits 2 of those classifications at once, and the way that tie breaks decides whether anything vectorizes at all. The axpy forms vectorize over output rows, holding lanes of them in a register. The dot form vectorizes over k instead. A matrix of a single row has both its strides equal to 1, so column-major and row-major describe the same bytes equally well. This is the pure dot product, m == n == 1.
Handing that shape to the axpy form leaves its vector loop unreachable, since while i + lanes <= e never holds when e == 1. The whole reduction then falls onto the scalar remainder. axpy_yields_to_dot avoids this. It gives the sweep to the dot form whenever the row count is short of 1 SIMD register, and the dot form’s own strides hold. The dot form also carries a wider accumulator tree, so it is the more accurate of the two. This choice matters in practice. Column-major adapter libraries such as nalgebra and faer describe a row vector exactly this way. A caller reaching for a dot product would otherwise land on the slow classification by default.
Whether the rows split across workers is a separate decision from which strategy computes them. For a column-major matrix the answer is usually no. The output-row axis is that matrix’s inner, fastest-varying memory axis. Cutting it hands every worker a strided walk over the entire matrix, where each worker consumes only its own slice of every column. The serial route instead makes 1 sequential pass: row_sweep short-circuits to a single body(0, rows) call, with no blocking at all. That pass already runs near the achievable single-stream rate. Below some row count, extra workers have little to win and a great deal of sequentiality to lose.
GEMMKIT_GEMV_AXPY_PAR_MIN_ROWS holds that row-count floor. Below it, the axpy split stays serial regardless of the requested worker count. This floor deliberately exempts 2 routes. A row-major matrix gives each worker whole k-contiguous rows, so its stream stays sequential even when split, and splitting it keeps winning at every size. The mixed-precision twin’s widening axpy is compute-bound enough to scale on the same column-major stream, so it keeps splitting worthwhile too.
The mixed-precision twin, run_mixed (feature half), serves f16/bf16 gemv. It uses the same row partition and the same reproducibility argument as the float routine above. Every load widens to f32 through the KernelSimd<N, N, f32, N> seam, and the reduction runs in f32. The result rounds to the narrow type exactly once, at the store.
A single asymmetry follows from that single-rounding rule. The mixed axpy always register-blocks the output. The plain column-outer form would re-read and re-write the narrow output every column group. That would round it once per group, instead of once per element.
The mixed fused gemv does not route here at all, on purpose. The float fused gemv fuses by re-reading each stored output and mapping it in place. That is bit-exact only because the float output is the accumulator. A narrow output has already rounded once at the store, so reading it back and mapping it again would round it twice. Instead of threading the epilogue through every widening store, the mixed fused entry keeps gemv shapes on the general driver. The driver already applies the epilogue in f32, before its single narrowing step (gemmkit/src/dispatch/mixed.rs).
small-k: one depth panel, nothing to amortize
At a tiny k (gemmkit/src/special/small_k.rs), the whole product is a single depth panel. The driver’s cache-blocking model, its workspace carving, and above all its A/B packing would all be pure setup. Every packed element would be read only once anyway. This route instead computes C <- alpha*A*B + beta*C directly over the family’s microkernel, with kc = k. It reads A and B in place: no packing, no blocking, no workspace traffic. It still inherits the family’s widen and rounding semantics for free, because it stays generic over KernelFamily.
The gate is k <= GEMMKIT_SMALL_K_THRESHOLD. Its default splits by architecture: 16 on x86, 8 on aarch64. The narrower NEON microkernel tile packs cheaply enough that the driver wins sooner there, hence the lower default.
The in-place read needs 3 preconditions. When any of them fails, the route defers to the driver instead, and the result stays correct, just differently scheduled. First, the microkernel needs unit-stride LHS rows, so A must be column-major (rsa == 1). Second, a FORCE_PACK_* family, such as complex, transforms the data into a planar form while packing, so it cannot be read in place by construction. Third, k past a hard SMALL_K_MAX = 32 would overflow the one stack buffer this route uses: a zero-padded panel for the bottom, partial row-tile. That panel still needs packing, because the microkernel always loads a full mr rows.
The route partitions work over output tiles, one full k-pass per tile, with 1 worker per tile. The bandwidth model caps the worker count itself, since the m*n output write dominates at a small k. Every tile is a complete reduction owned by a single worker. So the result stays bit-identical across worker counts, a stronger property than gemv holds to.
small-mn: horizontal dots for tiny outputs
Both m and n can sit far below the microtile while k stays long (gemmkit/src/special/small_mn.rs). There, the driver would pad the tiny row and column tiles up to full MR x NR microtiles, and compute mostly padding. This route instead computes each output element as 1 horizontal SIMD dot, C[i,j] = alpha*<A[i,:], B[:,j]> + beta*C[i,j], streaming along the contraction. The output is register-blocked into 4 x 4 tiles of accumulators, so 16 independent FMA chains stay in flight across the k-sweep. Each A-row and B-column loads only once per tile. This is the same latency-hiding trick as gemv’s dot form, generalized to a small grid.
The dims gate is m, n <= GEMMKIT_SMALL_MN_DIM (default 16, 32 on aarch64), together with k above the small-k threshold. The cap is arch-split because the point where the driver’s padding starts to cost more than this route’s horizontal dots differs by machine. The 2 small-shape routes split the k axis between them this way.
The kernel needs both operands unit-stride along k. It wants A’s rows contiguous (csa == 1, row-major A) and B’s columns contiguous (rsb == 1, column-major B). This is the zero-copy tier. The 2 most common layouts each fail exactly 1 side: all-row-major fails B, and all-column-major fails A. For these, a second, mutually exclusive gate (k > GEMMKIT_SMALL_MN_PACK_MIN_K, default 16) engages a pre-pack tier. prepack_operands copies only the failing operand into a k-contiguous workspace scratch buffer, then runs the identical kernel over it with unit strides.
The copy touches m*k (or n*k) elements, against the m*n*k work of the dot itself. This is a small tax next to the horizontal kernel’s win, so a strided small-m,n shape still beats falling back to the driver’s padded microtiles. The scratch buffer rounds each line up to an odd number of cache lines (packed_line_stride). A natural stride of exactly k would map every packed line to the same L1 set whenever k is a power of 2. That collapses the benefit of the re-reads, so the odd-line rounding avoids it.
That tax is small in flops, but flops are the wrong unit for it. The copy does no arithmetic per byte it moves. The dots do about 2. The copy therefore has less to hide memory latency behind. On a long k it takes a far larger share of the time than of the work. On the calling thread it dominated the route.
The copy now runs across workers itself. It forks after its traffic clears the cache-derived byte floor that the bandwidth-bound routes share (GEMMKIT_GEMV_PARALLEL_BYTES). Below that floor the copy stays serial. It resolves its own worker count, apart from the tile sweep that follows. The 2 axes offer different amounts of parallelism. The MT x NT output grid caps the sweep, and a small m, n makes that grid tiny. The depth caps the copy, and a long k makes the depth large.
The copy splits the depth, never the lead axis. A contiguous t range lets each worker read whole depth lines, lead consecutive elements per step. A split of the few lead lines would instead take a single element per step from each line, across the whole operand.
Measured on the Zen5 reference machine (f32, auto width, column-major A): 8x8x524288 3.1x, 16x16x262144 2.0x, 4x4x1048576 1.8x, 8x8x2097152 1.7x, 16x16x1048576 1.1x. Footprint is the one trend that holds across these shapes. At a fixed m,n, the smaller packed operand gains more. The order across m,n at a fixed footprint is not monotonic. This page therefore claims no mechanism beyond the copy’s share of the serial time.
The pre-pack step is a pure reorder: the same values, in the same per-line order. So the packed route stays bit-identical to the eligible-layout route. Each worker writes every cell once, with the value a serial copy writes. A split of the copy across workers therefore moves no byte either. This route has 2 siblings. One is mixed (f16/bf16, widen to f32, round once per cell). The other is integer (i8 -> i32, wrapping, and therefore bit-exact against the driver). Both share the same tiling, the same pre-pack helper, and the same reproducibility argument.
batched: orchestration, not a kernel
Batched GEMM (gemmkit/src/special/batched.rs) is deliberately not a new kernel. Every element re-dispatches through the full single-GEMM engine, so a batch composes with the driver, gemv, small-k, and small-mn automatically. What this layer adds is a schedule, chosen once per call by Parallelism::resolve_batch:
BatchParallel: chosen when there are enough elements to fill the workers. Each worker runs whole GEMMs serially and cache-hot, and the batch pays 1 fork/join instead of 1 per element. This is the model for the motivating workload: many small matrices.SequentialInternal: chosen for few, large, DRAM-bound elements. It loops the batch on 1 thread and gives each element the full engine parallelism in turn. On x86 the split engages once an element spills the per-core L2. aarch64 shares an L2 cache across a cluster and has high unified bandwidth. There, it engages once the per-batch-worker share,elem_bytes / batch, exceedsGEMMKIT_SEQ_INTERNAL_BYTES_PER_WORKER(default 128 KiB). This plan splits 1 element’s own work across workers, so it is gated tom, n > 1shapes. The driver, small-k, and small-mn routes all reduce every output within 1 worker. So they agree bit-for-bit between serial and parallel, under the current, thread-independent blocking. gemv is held only to the base reproducibility promise, not to that bitwise agreement, so this plan excludes it.- Serial: chosen below the total-work gate, or when no threads are usable.
Every element is independent, and the serial and batch-parallel plans never split one across workers. So a batch stays bit-identical across worker counts under those 2 plans, whatever the per-element route. The strided-batched entries, gemm_batched and gemm_batched_fused, thread 1 shared epilogue through the same skeleton and share a single schedule implementation. The plain and fused forms cannot drift apart.
For a batch whose elements differ in shape, the pointer-array form gemm_batched_ptr_unchecked takes a slice of GemmProblem descriptors instead. Each descriptor carries its own dimensions, strides, and pointers. This form uses the simpler resolve_batch_flat policy instead. It hands whole GEMMs to workers, and never splits a single element, since there is no uniform per-element residency to test. gemm_batched_slice is its safe, validated twin.
Batched GEMM and Small Shapes and GEMV cover the user-facing view of all this. Parallel Execution describes the worker-count machinery these paths depend on.
Epilogue Fusion
A GEMM output rarely leaves the routine raw. Inference layers add a bias and an activation. Quantized pipelines requantize the i32 accumulator down to a byte. Done naively, each of those is a second full pass over C: every element gets written to memory, evicted, read back, transformed, and written again.
The epilogue feature (gemmkit/src/kernel/epilogue.rs) fuses the transform into the microkernel’s store instead. It transforms the element in the register, or scratch slot, it already occupies, at the moment the microkernel would have stored it anyway. The second pass disappears. The saving is even larger for requantization. An unfused flow would have to materialize the entire m x n matrix in i32 before narrowing it.
The seam
The seam is the Epilogue trait. It threads through KernelFamily::microkernel_epi, so every family’s store site can apply it, and the driver never needs to know it exists:
#![allow(unused)]
fn main() {
// gemmkit/src/kernel/epilogue.rs (trimmed)
pub trait Epilogue<Fam: KernelFamily>: Copy + Send + Sync {
/// true => every hook const-folds away; the kernel is bit-identical to non-fused
const IS_IDENTITY: bool = false;
/// true => apply_reg is implemented, enabling the fast vector store path
const VECTOR: bool = false;
/// true => apply_store is implemented (the Out != Acc requantize pattern)
const VECTOR_STORE: bool = false;
/// Scalar transform at absolute (row, col) in the oriented problem frame
unsafe fn apply(&self, v: Fam::Acc, row: usize, col: usize) -> Fam::Out;
/// Vector transform of LANES consecutive rows; MUST agree with apply bit-for-bit
unsafe fn apply_reg<S>(&self, simd: S, v: ..., row: usize, col: usize) -> ...;
/// Vector transform of a whole MR_REG x NR register tile; defaults to a loop over
/// apply_reg. Overridden to hoist a runtime discriminant out of the unrolled pass
unsafe fn apply_tile<S, const MR_REG: usize, const NR: usize>(&self, simd: S, acc: ..., row0: usize, col0: usize) -> ...;
/// Vector store-transform from Acc scratch to Out; same bit-agreement contract
unsafe fn apply_store<S>(&self, simd: S, src: *const Fam::Acc, dst: *mut Fam::Out, ...);
}
}
The whole design rests on 2 invariants. The first is zero-cost identity. Plain gemm passes the Identity epilogue, whose IS_IDENTITY = true makes every hook const-fold away. The monomorphized, non-fused kernel stays bit-identical to what it was before the seam existed. Fusion costs nothing when a caller does not use it.
The second is fire-once semantics. The driver hands the microkernel a last_k flag, and the epilogue applies only on the final depth panel. Earlier panels store raw Acc partials, exactly as the non-fused kernel would. A family with OUT_IS_ACC = false, such as the narrow f16/bf16 outputs, runs the whole contraction as a single kc = k panel, by construction. So last_k is structurally true there. The deep-K twin would break that single-panel guarantee, so it deliberately never engages on the fused path. The special paths fire once for free too, since each of their output elements is a single complete reduction with a single store.
The built-ins
The library ships 3 epilogues, each behind its own public entry point (feature epilogue). The requantize entries also need int8. See Fused Epilogues for the user-facing view:
FusedEpi is the runtime-composed bias-plus-activation epilogue. It adds a per-row or per-column bias (Bias::PerRow / Bias::PerCol), then applies Relu or LeakyRelu(slope). A single monomorphization covers every combination, so the fused kernel count does not multiply by the number of epilogue kinds. That only holds because the enum branches decode once per tile, in the apply_tile override, rather than once per accumulator.
That distinction is not a micro-optimization. The tile const generics unroll the kernel’s store pass. A per-register hook would replicate both match statements in every one of the tile’s accumulator slots. On a wide tile, the resulting branch web costs the compiler the accumulator tile itself. It stops keeping acc in registers. Instead, it writes every value through to the stack from inside the kc loop, where the epilogue does not even run. Hoisting the decode out of the loop, so it runs once per tile instead, avoids that spill entirely. tests/perf/fused.rs pins the ratio between the fused and the plain rate, so a future change cannot let this regression back in unnoticed.
FusedEpi backs gemm_fused and its whole constellation. This includes gemm_batched_fused, which shares 1 bias and 1 activation across the whole batch, the prepacked twins gemm_packed_b_fused and gemm_packed_a_fused, and the complex entry gemm_cplx_fused. The complex entry is bias-only, because an ordering-based activation has no mathematical definition on complex numbers. FusedEpi sets VECTOR = true. On the fast path, the bias add and the activation run as register operations, such as max(v, 0). Its NaN contract on the SIMD max/min is chosen so the vector and scalar forms agree exactly: both compute ReLU(NaN) = 0.
MapEpi is the escape hatch. gemm_map applies an arbitrary user closure, f(value, row, col) -> value, to each output element at its final value. It uses (row, col) in the user frame. The closure is a borrowed &dyn Fn + Sync, so there is 1 monomorphization per (type, ISA), not 1 per closure. It runs scalar, once per element, amortized by the O(k) flops behind each element. MapEpi supports f32/f64 only. A narrow type would have to round to N, apply the N-domain closure, then round again, which would break the bitwise contract described below.
KRequantize implements the quantized-inference store: C[r,c] = clamp(zp + round_ne(scale*(acc + bias)), LO, HI). It maps the i32 accumulator down to i8 (gemm_i8_requant, band [-128, 127]) or u8 (gemm_i8_requant_u8, band [0, 255], the ONNX QLinearMatMul convention). The scale can be per-tensor or per-row (RequantScale). The zero point joins in as an integer after the rounding step. An optional i32 bias joins in as an integer before the single f64 rounding step. The rounding itself is round-half-to-even, through a no_std-safe 2^52 trick (round_ne_f64). KRequantize has no alpha, since that folds into the scale, and no beta, since accumulating into an already-quantized C has no clear meaning.
The correctness contract
This contract is stated precisely because it is exactly what the epilogue tests pin down, bit by bit. It is composed of 3 ingredients:
- Identical routing. A fused call routes every shape through the same kernel plain
gemmwould use. The general driver, gemv, small-k, and small-mn each exist in a fused form (see Special Paths). The fused dispatch entries mirror the plain gates one for one. No shape pays the driver’s overhead just because it asked for a bias. The one deliberate exception is the mixedf16/bf16fused gemv, which stays on the driver for a rounding reason explained in Special Paths. Narrow types sit outside the bitwise contract below anyway. - An epilogue-independent engine. Blocking, scheduling, packing, and the accumulation order do not depend on which epilogue is threaded through. The epilogue only touches the store.
- Bit-agreeing apply paths. A full column-major tile stores through the vector path,
apply_tile, itself defaulting toapply_reg, or throughapply_store. An edge or strided tile drains through scratch and the scalarapplyinstead. A single output matrix can freely mix the two. So the trait contract requires both paths to agree bit-for-bit under the same token. Anapply_tileoverride inherits that obligation. It must leave exactly whatapply_regwould have, element for element.
Together these 3 give the headline guarantee. For f32/f64, gemm_fused, gemm_map, and the batched and prepacked fused entries all equal gemm() followed by the same scalar map, bitwise, for every shape.
MapEpi shows how deliberate that guarantee is. It sets VECTOR = true. This is not to vectorize the closure, which it cannot do. It is so the kernel takes the same path selection plain gemm does. The fast path’s fused beta*C + alpha*AB store differs from the scalar path’s unfused arithmetic by 1 ULP for a general beta. A scratch-only epilogue would therefore hand the closure a value plain gemm never actually wrote. Instead, apply_reg drains the register to a stack buffer, and calls the same scalar apply once per lane. So f always sees exactly the bits plain gemm produced.
The documented exception is f16/bf16. The narrow blanket implementation applies the bias and the activation in f32, on the accumulator, before the single round-to-nearest-even narrowing to the output. That is deliberately more precise than gemm()-then-map, which would round to the narrow type, widen it back, and round again. So for narrow types the fused entries are not bitwise equal to gemm-then-map. The documentation states this plainly, rather than weaken the fused semantics to match the less precise alternative. Within 1 fused run, the vector and scalar paths still agree bit-for-bit, since both compute act(bias(v)) in f32 and round exactly once. Reproducibility across worker counts stays unchanged too.
KRequantize earns its vector path differently. The x86 tokens implement KernelSimd::requant_store: a vectorized widen-to-f64, scale, hardware round-to-nearest-even, clamp, and low-byte store. Its documentation carries a case-by-case proof that every lane equals the scalar clamp(zp + round_ne(scale*v), lo, hi). The i32 -> f64 and f32 -> f64 widenings are exact. The 2^52 trick agrees with the hardware rounding below 2^52, and saturation agrees above it. A NaN cannot occur, because the API validates that every scale is finite and positive. A per-row scale varies per lane, so that case takes the per-lane scalar map instead. Non-x86 tokens keep REQUANT_VECTOR = false and use the scalar map throughout. An in-module conformance sweep, the requant_store tests in gemmkit/src/simd.rs, checks this bit-equality on every capable token. The word “proven” in this contract is therefore enforced by a test, not just an aspiration.
One last corner remains. When the A*B term vanishes, because k == 0 or alpha == 0, the fused entries still owe C <- act(beta*C + bias). That degenerate map runs element-wise in the user frame. fused_degenerate, in gemmkit/src/dispatch/float.rs, handles this, with a narrow sibling that combines in f32 and narrows once. So even the no-op-product case honors the same semantics as the full kernel.
Extension Points
gemmkit’s variation points are traits, const generics, and typed function pointers stored in OnceLock slots. There are no macros and no transmute. That discipline exists for 1 reason. The library expects 4 kinds of growth: a new instruction set, a new element type, a new dot-product instruction, and a new fused transform. Each one should land as additive code, with a short, checkable list of touch points. Each one should also leave the driver, the packing routines, and the blocking model untouched.
This page turns those 4 recipes into walkthroughs. It is written for someone extending the crate itself. The seams stay public enough that the most important one, driving the generic driver with your own kernel family, also works from outside the crate. A test proves it.
A new ISA backend
An ISA backend is a zero-sized token, plus a set of vocabulary implementations. The wasm simd128 backend (gemmkit/src/simd/wasm.rs) is the most recent complete example. It is worth reading end to end, since it is only 1 file plus a handful of dispatch lines.
The token’s only inherent behavior is Simd::vectorize, the #[target_feature] trampoline. Runtime CPU detection cannot pair with a fixed #[target_feature] attribute on the generic kernel. So every kernel invocation runs inside a tiny, annotated function. The #[inline(always)] primitives fold into that function, so every intrinsic lands in feature-enabled codegen:
#![allow(unused)]
fn main() {
// gemmkit/src/simd/wasm.rs
impl Simd for Simd128 {
#[inline(always)]
unsafe fn vectorize<R>(self, f: impl FnOnce() -> R) -> R {
#[target_feature(enable = "simd128")]
fn inner<R>(f: impl FnOnce() -> R) -> R {
f()
}
inner(f)
}
}
}
The checklist:
-
The token. Add a
Copy + Send + Sync + 'staticzero-sized struct in a newgemmkit/src/simd/module.cfg-gate it to its architecture, and give it thevectorizetrampoline shown above. -
SimdOps<T>implementations. Add 1 for each element type the ISA accelerates. Each one needs a register type andLANES. It also needs the primitive vocabulary: load, store, splat, mul, add,mul_add,fnma,reduce_sum, plusmax/minif the fused float epilogue should vectorize. The vocabulary stays deliberately thick, so the microkernel can stay 1 generic function. You implement primitives, never a kernel.Honor the documented contracts here. The simd128 implementation uses
f32x4_pmax, notf32x4_max, because the trait’smaxrequires a NaN inato yieldb. This is theReLU(NaN) = 0agreement between the vector and scalar epilogues. It also passes the operands reversed, asf32x4_pmax(b, a), becausepmax(x, y)computesx < y ? y : x. The natural argument order would return NaN for a NaNa, and-0.0formax(-0.0, +0.0), the opposite of the contract in both cases. It also spellsmul_addas an unfusedmulfollowed byadd, because wasm has no hardware FMA. The relaxed-SIMD alternative is nondeterministic by specification, which would break reproducibility. -
Tile geometry. Pick
(MR_REG, NR)for each type, and encode it as the const generics of the per-ISA wrapper functions in the dispatch modules. This is the only per-(type, ISA)knob. Budget the registers explicitly. simd128 runs 2x4 forf32: 8 accumulators, 2 LHS registers, and 1 RHS register, for 11 livev128values. LLVM’s wasm backend starts to spill past about 16 live vectors, which is why simd128 stays at that width. NEON runs 4x4 instead, leaving registers spare on purpose. -
A
Dispatcheddescriptor, and 1 arm perselect_*ladder. The memoized selection ladders live ingemmkit/src/dispatch/. They areselect_f32/select_f64for float,select_f16/select_bf16for mixed,select_i8for int, andselect_c32/select_c64for complex, plus the map-epilogue selectors. Each ladder arm bundles the plain, prepacked, and fused entry points together with the tile geometry. So adding the ISA costs 1 descriptor constant and 1 match arm per type it accelerates. -
A
GEMMKIT_REQUIRE_ISAname. Add aForcedIsavariant and its parse string ingemmkit/src/dispatch/isa.rs. The current values arescalar,fma,avx512f,avx512vnni,avx512bf16,neon,simd128, andauto. Follow the fail-loudly rule. If the pinned ISA is unsupported, dispatch must panic rather than fall back. This way, a CI job that means to exercise your kernel cannot silently pass on a different one instead. -
Tests ride along mostly for free.
tests/simd_conformance.rsconstructs tokens directly and checks every primitive against scalar references. Anenv_isa_*pin binary, plus a CI job, makes the dispatch route itself testable too (see Testing and Verification).
You should not need to touch driver.rs, any kernel family, pack.rs, or cache.rs. The simd128 backend changed none of them.
A new element type
Element types vary along 2 small traits (see Scalars and Kernel Families). Scalar (gemmkit/src/scalar.rs) declares only the identity constants and the accumulator type Acc. Choosing Acc is the single most consequential decision here, since it fixes the rounding story. f16 chose Acc = f32. i8 chose Acc = i32, which makes integer GEMM exact. KernelFamily (gemmkit/src/kernel.rs) bundles everything else that distinguishes the operation: the Lhs/Rhs/Acc/Out types, the pack layout, and the microkernel.
Often a new family is not needed at all. If the type is a narrow input over an existing accumulator, implement the KernelSimd<L, R, A, O> widen/narrow seam on the capable tokens instead. That means widening loads, plus 1 narrowing store. Then reuse the generic microkernel, the way MixedGemm<f16> and MixedGemm<bf16> do. The homogeneous case is a blanket implementation, and the mixed implementations cannot overlap it. A genuinely new operation shape, such as the planar complex kernel or the requantizing integer families, gets its own KernelFamily instead.
Wiring the new type into the public API means adding a dispatch module under gemmkit/src/dispatch/, with its own OnceLock slot per type. Feature detection runs once, the winning monomorphized entry points get cached, and every later call becomes 1 indirect call. Copy dispatch/mixed.rs as a pattern for 2 types, gemv/small-mn/small-k reroutes, and a dot-kernel selection wrinkle. Copy dispatch/int.rs instead for a heterogeneous task type.
The open/closed property here is not folklore. gemmkit/tests/open_closed.rs enforces it directly. That test defines NaiveFloat, an independently written family with its own packing and a plain scalar microkernel, built using only public items. It then drives the unchanged public driver::run with NaiveFloat, and checks the result against an f64 reference. If a driver change ever breaks the family seam, that test fails to compile. It also works as the template to start a new family from.
A dot-product instruction
Instructions such as vpdpbusd and vdpbf16ps fold several depth steps into 1 operation. That reshapes the accumulation rounding, so they must never arrive as a clever override of the portable tile loop. The seam is split to keep that distinction clear:
- The family declares
DEPTH_MULTIPLE = Q(greater than 1), and packs throughpack_kgroup_panels(gemmkit/src/pack.rs). That function interleavesQconsecutive depth steps contiguously per lane. The driver rounds panel depths up toQ, and keeps k-groups from straddling slice boundaries. - The capable token overrides
KernelSimd::dot_accumulate, and consumes whole instruction groups from those panels. The packed layout is a private contract between the family’s packers and the overriding token. Any signedness correction, such as VNNI’s+128trick with its column-sum compensation, lives inside the override. So the accumulator holds the true sum when it returns. SimdOps::accumulate_tileoverrides are reserved for scheduling changes that keep the rounding shape unchanged. 2 examples are an in-order core that needs explicit software pipelining, and a scalable-vector ISA whose length is not a compile-time constant. Its documentation is explicit that rounding-reshaping instructions are out of scope for this seam. Those should arrive as a new family with the dot seam instead. Anaccumulate_tileoverride must stay deterministic, and must round consistently with the edge path. The default implementation already saturates the FMA pipes on any wide out-of-order core, so prove an override earns its keep before adding one.
IntGemmVnni and Bf16DotGemm are the 2 worked examples. IntGemmVnni is bit-exact against the widen path, because integer arithmetic is associative. Bf16DotGemm is held to a tolerance instead, within the reproducibility contract. Dot Kernels and the Deep-K Twin covers both in depth.
A new fused transform
A fused transform is just 1 Epilogue implementation (gemmkit/src/kernel/epilogue.rs). The driver’s last_k plumbing, the zero-cost Identity default, and the routing through every special path all come for free (see Epilogue Fusion). The design work is choosing an application path, and honoring 1 hard rule. The vector and scalar paths must agree bit-for-bit. Full tiles take the vector path, edge and strided tiles take the scalar path, and a single output matrix can mix the two.
- A transform on
Acc-typed values with a natural register form setsVECTOR = trueand implementsapply_reg. This is theFusedEpipattern. Mind the NaN and signed-zero semantics here.LeakyRelu, for instance, is written as the identicalmax + slope*mincomposition in both forms. - A transform that narrows
Accto a differentOutsetsVECTOR_STORE = trueand implementsapply_store. This is theKRequantizepattern. Argue the bit-equality case by case, and pin it with a conformance sweep. - A transform with no profitable vector form keeps both flags
false, and routes everything through scratch and the scalarapply. This stays correct for any tile shape. If the scalar value could differ by 1 ULP from the fast path’s fused store, though, borrowMapEpi’s trick instead. SetVECTOR = true, and implementapply_regas a drain-to-stack-and-apply-per-lane. This way the transform always sees exactly the bits plaingemmwould have stored.
Whatever path a new transform takes, add its own gemm-then-map equivalence test next to the existing ones in gemmkit/tests/epilogue/. That suite is where the bitwise contract actually gets enforced.
Testing and Verification
A library whose headline promises are “bit-identical here, tolerance there, reproducible everywhere” lives or dies by how precisely its tests pin those words down. gemmkit’s suites live in gemmkit/tests/. The first structural decision is what is not a test. The performance harnesses are measurement tools. They never gate CI.
tests/perf/ is the exhaustive internal investigation suite. It runs #[ignore] benchmarks over a median-of-9 harness, serialized behind a shared lock, because each one saturates every core. Someone runs it by hand when a change needs numbers. gemmkit/benches/gemm_bench.rs is the curated public cargo bench surface instead. It holds criterion benchmarks in 5 headline groups, sgemm, dtypes, gemv, prepacked, and batched, meant for --save-baseline regression tracking against the gemm crate and matrixmultiply. Neither suite can fail a merge. A performance assertion on a shared CI runner would mostly assert noise.
Correctness, properties, conformance, fuzzing
The correctness suite (tests/correctness/) sweeps shapes, layouts, and alpha/beta combinations against an independent f64 reference GEMM. The reference, and the accuracy machinery around it, live once in tests/oracle_common/. This includes the element traits, the deterministic fills, the f64 reference itself, and the relative-Frobenius accuracy gates for each element type. Both the correctness and the property harnesses include this module with #[path], so there is exactly 1 oracle to trust.
On top of the oracle sweeps sit several more checks. Cross-checks against the external gemm crate catch shared-blind-spot bugs the in-repo reference cannot, since it is an independent implementation. Parallel bit-identity tests cover the routes where the library promises that agreement. Per-ISA kernel runs go through the generic driver. The safe API’s exact panic wording, easy to underrate, gets held by #[should_panic(expected = ...)] substrings, such as "A.cols" and "aliases itself". So a validation message cannot quietly degrade into a less useful one.
The property tests generalize these sweeps, and all 3 drive proptest over shapes, strides, and knob values. tests/props_api.rs covers oracle accuracy, run-to-run bit determinism, serial-equals-parallel agreement, the beta == 0 overwrite semantics, broadcast strides, batching, and panic guarantees. tests/props_packed.rs covers prepacked-versus-plain bit-identity in the general regime, and tolerance on the documented tiny/gemv exception set. tests/props_knobs.rs covers behavior under randomized knob settings.
One layer down, tests/simd_conformance.rs checks the L0 vocabulary itself. It constructs every ISA token the host supports directly, bypassing dispatch. It then compares each SimdOps primitive, the homogeneous KernelSimd blanket, and the portable fma_bvec fallback, lane-by-lane, against scalar references. This is where primitives the product kernels rarely touch, such as integer reduce_sum, fnma, and the widen seam, get exercised at all. In-module sweeps, such as the requant_store bit-equality tests in gemmkit/src/simd.rs, do the same for the vector requantize contract. The suite has no proptest dependency, so it also runs on wasm and conformance-tests the compile-time simd128 token.
Fuzzing lives in gemmkit/fuzz/, a nightly-only cargo-fuzz sub-workspace with its own workspace root, excluded from the stable build. It holds 6 libFuzzer targets. fuzz_gemm builds valid-by-construction problems and checks them differentially against naive references, so any panic there is a library bug. fuzz_batched does the same for batched calls. fuzz_prepack and fuzz_prepack_i8 round-trip through the prepack APIs, with the i8 one gated bit-exactly. fuzz_api_validation throws adversarial geometry at the checked entries. There, a documented "gemmkit:" panic counts as an accepted outcome, and anything else counts as a validation gap. fuzz_knobs sets every process-global tuning knob to an adversarial value before each run. This is the target that mechanically finds arithmetic-overflow classes in the blocking model.
Isolation discipline
A naive test layout turns racy around 2 kinds of global state, and the suite’s structure exists to work around both.
Tuning knobs are process-global atomics. Every test that mutates one lives in its own dedicated binary. tests/tuning.rs holds the setters, tests/env.rs holds environment-variable resolution, and there is also tests/props_knobs.rs and tests/deep_k_narrow.rs, which toggles GEMMKIT_DEEP_KC_BYTES to force each deep-k route. tests/env.rs holds exactly 1 test, so its environment access is single-threaded by construction. A separate binary is a separate process, and it cannot race another binary’s knob state.
Within one binary, though, libtest still runs tests concurrently. So every knob-touching test there serializes under a per-binary KNOB_LOCK mutex, and restores whatever it changed before it releases that lock. The property-test binary adds an RAII guard on top, one that survives proptest’s internal catch_unwind.
GEMMKIT_REQUIRE_ISA is stickier still, because dispatch memoizes it once per process. So there is 1 pin binary per value: tests/env_isa_avx512f.rs, _vnni, _bf16, _scalar, _neon, and _wasm, plus env_isa_garbage.rs, which asserts the unknown-value panic. Each binary routes every test through a shared Once in tests/env_isa_common/. That Once performs the single set_var call before any dispatch resolves. Because every test in one binary pins the same value, it does not matter which test wins the race to run that Once. The write deliberately overrides an inherited GEMMKIT_REQUIRE_ISA. That override is what lets the SDE-pinned CI jobs below run these same binaries and still exercise the real, per-ISA routes.
Miri rounds out the memory-safety story where fuzzing’s sanitizers stop. CI runs the scalar-path correctness suite (miri_scalar_path) and the complex negative-stride unchecked entry under Miri. Miri interprets the actual unsafe pointer arithmetic of the pack and microkernel paths directly. A cfg(miri) detour exists only where Miri cannot interpret a hardware conversion. It never exists to skip logic.
The CI matrix
.github/workflows/ci.yml turns this pinning machinery into coverage of kernels the runners do not physically have:
| Job | What it exercises |
|---|---|
test | Default features, then --all-features, then parallel off. no_std-style builds with std off, in 4 feature combinations. |
kernel-scalar / kernel-fma | The full suite with GEMMKIT_REQUIRE_ISA pinned to each natively available kernel. |
avx512f_test / avx512vnni_test / avx512bf16_test | The suite under Intel SDE (sde64 -spr), with the AVX-512F, VNNI-dot, and BF16-dot kernels pinned. SDE emulates the silicon, but the code paths are real. |
kernel-neon | The whole workspace, run natively on an arm64 macOS runner, then run again with neon pinned. |
wasm_simd128 / wasm_simd128_threads | Correctness and conformance under wasmtime on wasm32-wasip1, with simd128 pinned. The threads job runs real 8-way parallelism on wasm32-wasip1-threads. |
no_std | Builds for x86_64-unknown-none, aarch64-unknown-none, and wasm32-unknown-unknown. |
i686_check / msrv / lint / miri / coverage | A 32-bit check, a build on Rust 1.89.0 (the minimum supported version), fmt plus clippy -D warnings, and the Miri jobs above.Coverage is report-only: cargo-llvm-cov with a pinned ISA list, so the reported percentage cannot swing with the runner pool. |
SDE emulation runs far slower than native execution. This is where GEMMKIT_FAST_TEST earns its keep. It is a test-suite-only switch, implemented once in tests/fast_test_common/ and included from the harnesses. The library itself never reads it. The switch shrinks the deterministic dimension and coefficient sweeps down to 1 representative per redundant combination, while still visiting every branch and path class. The SDE jobs set it, alongside PROPTEST_CASES=16. Native jobs keep the full sweeps instead. Keeping this switch out of the library proper means a test-convenience flag can never change shipping behavior.
The net effect ties this chapter together. Every claim the earlier pages made is held by a test you can point to, in a binary whose isolation rules make the result trustworthy. This includes the bit-identity guarantees in Special Paths, the gemm-then-map equivalence in Epilogue Fusion, and the open/closed property in Extension Points. It also includes each pinned kernel’s correctness on hardware the project does not own.