Cache-blocked, packed, batch-invariant matmul in C (optional)
Overview
Section titled “Overview”| Module | L9.1 · side · C · Pass 6 · 5 to 7 h |
| You build | c/src/kernels/matmul.c, taken over from M03.1: the same tl_matmul_f32 signature and contract, now with Goto’s loop nest (column strips, k-blocks, packed panels, a 4 x 16 register micro-kernel), threads from your rt.03 pool, and a fixed reduction order that makes every row’s bits independent of the batch |
| Contract | course/contracts/c/include/tinyllm/matmul.h · rules: c/ABI.md (rule 10, batch invariance) |
| Tests | course/tests/L9.1/: test_matmul_tiled.c (C, under ASan and UBSan, and ThreadSanitizer for the pool case) and shared file fixtures (what they check: section 4); M03.1’s tests run as your regression. Bench: course/tests/L9.1/bench/matmul_512.c |
| Needs | rt.02 the error slot and the loader · rt.03 tl_parallel_for (or --ref-deps). Reading: M03.1 (your v0 of this file) · M09.3 (the error bound) · M05.1 (FLOP counting) · rt.02 (why this kernel does not use an arena) |
| Used by | L9.5 measures its quantized kernel against this optional C baseline; Python and Rust have independent implementations |
| Milestone | MS-L9 (the C backend generates the same tokens as numpy, and faster) |
| Optional depth | Goto and van de Geijn, “Anatomy of High-Performance Matrix Multiplication” (2008); Van Zee and van de Geijn, “BLIS: A Framework for Rapidly Instantiating BLAS Functionality” (2015); Williams, Waterman, and Patterson, “Roofline” (2009); He et al., “Defeating Nondeterminism in LLM Inference” (Thinking Machines, 2025) |
Key Takeaways
Section titled “Key Takeaways”- The triple loop is slow because of memory, not arithmetic. Blocking reuses each loaded value many times from cache and registers; your packed version is 10 to 20 times faster at with the same operations (
ol bench L9.1). - Packing copies a block into the exact order the micro-kernel reads it, padded with zeros to whole micro-tiles, so the inner loop is unit-stride and every output element goes through the same code path (
shapes_across_tile_edges). - Batch invariance is a property of the reduction order. Each is a sum over k-blocks of fixed size , each a sum from 0 in increasing : nothing depends on , on the row’s position, or on the threads, so a row has the same bits alone or in a batch of 37 (
batch_invariant_rows,batch_invariant_at_1_7_37). - applies once, in the first k-block, and later blocks add; still never reads (
beta_applies_once_across_k_blocks). - Threads split columns, never a sum, so 1 and 4 threads give identical bits (
pool_result_equals_serial_bitwise).
How to work this chapter
Section titled “How to work this chapter”ol start L9.1 # prints the contract diff: you edit your own c/src/kernels/matmul.col tests L9.1 # read the test catalog firstol check L9.1 # exit code is the verdict (M03.1's tests run as the regression)ol check L9.1 --ref-deps # only if your rt.03 is not passing yetol bench L9.1 --assert # the speed budget: at least 10x M03.1's loop at 512^3 (local only)ol parity matmul # both of your versions against the float64 goldenol diff L9.1 # after passing: your code against the reference1. Why now
Section titled “1. Why now”The naive triple loop is easy to understand but slow on large matrices because it repeatedly fetches values far apart in memory. Blocked multiplication reuses a small tile in cache, and a fixed reduction order makes each row’s result independent of the batch size. This optional kernel demonstrates those optimizations; the Python and Rust engine paths keep their own implementations.
2. Principles
Section titled “2. Principles”| Symbol | Meaning | Type / shape |
|---|---|---|
, (or with trans_b), , row-major with leading dimensions | float* | |
| the micro-tile held in registers: | constants MR, NR | |
| k values per block, the fixed reduction step: 128 | KC | |
| rows of packed at once: 64 | MC | |
| columns per strip, one thread’s unit of work: 128 | NC | |
| partial sum of block : , from 0, in increasing | float | |
| arithmetic intensity: FLOPs per byte moved from memory | FLOP/byte |
2.1 Why the triple loop is slow: the roofline
Section titled “2.1 Why the triple loop is slow: the roofline”A core can do floating-point operations per second only if operands arrive fast enough. Memory delivers bytes per second, so a loop that does FLOPs per byte it loads runs at most at : the roofline. In M03.1’s loop, each inner step loads one float of and one of (8 bytes) for 2 FLOPs, , and walking down a column of touches a new 64-byte cache line on every step. A product of matrices does FLOPs on only distinct numbers, so the data could be reused times; the naive loop reloads it instead. Blocking is the art of keeping a piece of data in a fast level (registers, L1, L2) while it is used many times.
2.2 Goto’s loop nest
Section titled “2.2 Goto’s loop nest”Five loops, each sized for one level of the memory hierarchy:
- Column strips of columns of (and ). One strip is one unit of work for a thread.
- k-blocks of : pack the block of into a buffer
bp(it stays in L2 while every row block of passes it). - Row blocks of : pack the block of into
ap(it stays in L1/L2 across the strip). - Micro-tiles: for each -wide panel of
bpand -tall panel ofap, - the micro-kernel computes an block of partial sums in 64 accumulators, reading one column of values of and one row of values of per : loads for FLOPs.
The micro-kernel’s intensity is what makes it fast: 6.4 FLOPs per float loaded instead of 1, and every load from the packed buffers is sequential. The compiler turns acc[r][c] += a[r] * b[c] with constant into vector registers (16 floats of a row is four 128-bit NEON or two 256-bit AVX registers).
2.3 Packing
Section titled “2.3 Packing”Packing copies a block into the order the micro-kernel will read it. pack_b writes panel (columns to ) as consecutive rows of floats; pack_a writes panel (rows to ) as consecutive columns of floats. Two more jobs happen during the copy:
trans_band the leading dimensions disappear. The packer reads atB[k*ldb + j]orB[j*ldb + k]and atA[i*lda + k]; the micro-kernel only ever sees contiguous panels.- Edges are padded with zeros. When is not a multiple of 4 or not a multiple of 16, the last panel is filled with zeros, so the micro-kernel always computes a full tile. A zero row contributes nothing, and the store writes only the real corner back into through
ldc. One code path for every tile is what makes the next section possible.
The buffers are on the stack: floats, 96 KiB. c/ABI.md rule 1 forbids a kernel to allocate, and this signature has no arena to borrow from.
2.4 The reduction order, and batch invariance
Section titled “2.4 The reduction order, and batch invariance”Floating-point addition is not associative: and can differ in the last bit (M03.1 2.6). So “the same sum” is really “the same sum in the same order”. In this kernel, element is
Every quantity in that expression depends only on row of , column of , , , , and the constant . It does not depend on , on which micro-tile row landed in, on the zero padding, or on which thread computed the strip. That is batch invariance (c/ABI.md rule 10): row of a product computed with equals row computed with , bit for bit.
Three ways to lose it, all of which real libraries do for speed:
- A block size that depends on the shape, such as a “GEMV path” for that sums all of in one block. The decode step () and the prefill () then round differently (section 5, mutant
s10). - Splitting across threads (“split-K”) and adding the thread partials in completion order.
- Different instructions for edge tiles, for example a fused multiply-add in the vectorized body and separate multiply and add in a scalar remainder. A fused multiply-add rounds once where the pair rounds twice. Zero padding gives every element the same code path, and
#pragma STDC FP_CONTRACT OFFforbids the compiler to fuse, so the arithmetic is the same on every path and every machine.
2.5 Threads
Section titled “2.5 Threads”tl_parallel_for(tp, n_strips, 1, strips, &args) (rt.03) runs each column strip exactly once, on some worker. Strips write disjoint columns of and only read and , so there is no race, and a strip’s arithmetic does not depend on the worker, so the result is the same bits with 1 or 8 threads. Splitting columns rather than keeps every sum inside one thread. With tp == NULL the strips run serially on the caller.
2.6 What it costs
Section titled “2.6 What it costs”The packed kernel still does FLOPs. Packing costs copies per call and packing costs copies per strip, small next to when the matrices are large. For (decode) the micro-kernel computes a tile of which one row is real, so a quarter of the arithmetic is wasted on padding; that is the price of invariance here, and L9.5’s quantized GEMV is where decode gets its own kernel.
3. Worked example by hand
Section titled “3. Worked example by hand”The product of M03.1, through the tiles. , : . One strip (columns 0 and 1), one k-block (), one row block.
pack_b writes one 16-wide panel, 3 rows: {7, 8, 0, ..., 0 | 9, 10, 0, ..., 0 | 11, 12, 0, ..., 0} (14 zeros of padding per row). pack_a writes one 4-tall panel, 3 columns: {1, 4, 0, 0 | 2, 5, 0, 0 | 3, 6, 0, 0}. The micro-kernel’s accumulator after each :
| adds for , | acc row 0 | acc row 1 | |
|---|---|---|---|
| 0 | , | (7, 8) | (28, 32) |
| 1 | , | (25, 28) | (73, 82) |
| 2 | , | (58, 64) | (139, 154) |
Rows 2 and 3 of the accumulator and columns 2 to 15 are zero (padding). The store writes only the corner: , the first test, hand_example, and test_hand_example.
Why the block size must not depend on . Take one output whose four products are, in order, (float32). With the sum in one block, : float32 spacing near is 8, so adding 1 or 3 rounds back to each time, and the result is . With blocks of 2, , which rounds up to . Same numbers, different grouping, different bits. If the kernel used one block for and blocks of 2 otherwise, a decode step and a prefill would disagree in exactly this way, which is what batch_invariant_at_1_7_37 and mutant s10 are about.
across blocks. With and , element goes through three stores: , then , then . appears once (beta_applies_once_across_k_blocks).
4. The interface
Section titled “4. The interface”The signature and the contract are M03.1’s, unchanged; what is new is the promise of rule 10:
tl_status tl_matmul_f32(const float *A, const float *B, float *C, int64_t M, int64_t N, int64_t K, int64_t lda, int64_t ldb, int64_t ldc, float alpha, float beta, int trans_b, tl_pool *tp);/* From L9.1 the k order is fixed and independent of M and of the row's position: row i computed with M = 1 equals row i computed with any M, bit for bit. tp may be NULL (serial); with a pool, column strips run in parallel. */ol start L9.1 does not overwrite your file: it prints the contract diff, and you rewrite the body of your own c/src/kernels/matmul.c in place.
What the tests check
Section titled “What the tests check”| Test | KIND | Checks | Why it matters downstream |
|---|---|---|---|
hand_example | unit, smoke | section 3 through one padded micro-tile | the definition, and the edge store |
shapes_across_tile_edges | differential | around 4, 16, 64, 128, up to 257, both trans_b, against float64 within | every tile edge |
beta_applies_once_across_k_blocks | unit | , , ; then over NaN | accumulation across k-blocks |
views_and_padding_untouched | unit | lda, ldb, ldc larger than the rows; ‘s other columns unchanged | views into fused projections |
empty_and_invalid_like_v0 | boundary | M03.1’s rules: empty dims, gives , TL_EINVAL leaves untouched | the contract carries over |
batch_invariant_rows | property | 16 rows of a Linear layer (): each alone vs inside every batch at every offset, bitwise | batched decode (L10.2) |
batch_invariant_at_1_7_37 | property | the catalog’s , bitwise | prefill vs decode |
pool_result_equals_serial_bitwise | property | 4 threads vs serial, three strips (the last partial), under TSan too | threads never change bits |
baseline_row_major, trans_b_reads_rows_of_b | unit | original row-major and transposed-B contract cases | the v0 interface remains compatible |
alpha_and_beta_scale_and_accumulate, beta_zero_ignores_garbage_in_c | unit | alpha/beta behavior, including no read of C when beta is zero | preserves the original arithmetic contract |
empty_dimensions, bad_arguments_are_einval, leading_dimensions_skip_padding | boundary | empty shapes, invalid arguments, and padded leading dimensions | preserves the original shape and view rules |
The bench (ol bench L9.1, a B test, never part of ol check) times your kernel against M03.1’s loop at and reports speedup_vs_naive (budget ) and GFLOP/s. The reference reaches about 19x and 50 GFLOP/s on one Apple M-series core; Apple’s Accelerate (cblas_sgemm, AMX units) is several times faster again, which is the ceiling to compare against if you are curious, not a budget.
5. Pitfalls
Section titled “5. Pitfalls”| Pitfall | Symptom | Caught by |
|---|---|---|
| applying in every k-block | for ; with only the last block survives | beta_applies_once_across_k_blocks (mutant s01) |
| storing the whole micro-tile at an edge | writes past or into its padding (ASan, or a neighbour’s columns) | hand_example, views_and_padding_untouched (mutant s02) |
packing without trans_b | Linear layers multiply by the wrong matrix | shapes_across_tile_edges (mutant s03) |
packing with instead of lda | right on packed matrices, wrong on every view | views_and_padding_untouched (mutant s04) |
| later k-blocks overwriting instead of adding | only the last products survive | beta_applies_once_across_k_blocks (mutant s05) |
storing with instead of ldc | views of are scrambled | views_and_padding_untouched (mutant s06) |
| reading when | NaN in a fresh buffer reaches the logits | empty_and_invalid_like_v0 (mutant s07) |
| returning early for | keeps old values instead of | empty_and_invalid_like_v0 (mutant s08) |
dropping M03.1’s argument checks while rewriting | a short lda reads past the buffer | empty_and_invalid_like_v0 (mutant s09) |
| a “GEMV fast path” that sums all of at once when | decode and prefill differ in the last bit; greedy tokens flip on near-ties | batch_invariant_at_1_7_37, batch_invariant_rows (mutant s10) |
| a partition that drops or repeats a strip | wrong only with a pool | pool_result_equals_serial_bitwise (mutant s11) |
| packing buffers sized for the full matrix on the stack | stack overflow on worker threads (512 KiB on macOS); size them by the block constants | the pool test under ASan and TSan |
6. Where it’s used next
Section titled “6. Where it’s used next”| Direction | Module | How it uses this |
|---|---|---|
| Back | rt.02 | the error slot behind TL_EINVAL |
| Back | rt.03 | tl_parallel_for runs the column strips |
| Forward | L9.5 | its benchmark and its float32 baseline are this kernel on the dequantized weight |
If you skip this module, ol check L9.5 stops with BLOCKED ... needs L9.1: build it, or pass --ref-deps.
Going further
Section titled “Going further”| Your piece | Production equivalent | What it adds | Where to look |
|---|---|---|---|
| 4 x 16 C micro-kernel | BLIS and OpenBLAS assembly micro-kernels | hand-scheduled FMA pipelines, prefetching, per-microarchitecture block sizes | BLIS kernels/armv8a/, kernels/haswell/ |
| column-strip threads | BLIS multithreading | parallelism in several loops at once (the and loops), sized by the cache topology | Smith et al., “Anatomy of High-Performance Many-Threaded Matrix Multiplication” (2014) |
| fixed for invariance | batch-invariant kernels for LLM serving | invariant matmul, RMSNorm, and attention as a PyTorch library | Thinking Machines batch_invariant_ops |
| float32 CPU GEMM | Apple Accelerate / AMX, cuBLAS | dedicated matrix units, several times the vector throughput | cblas_sgemm; cuBLASLt |
| one kernel for every dtype | llama.cpp ggml_mul_mat | dispatch to quantized dot-product kernels per weight format | ggml/src/ggml-cpu/ggml-cpu.c |