NanoGEMM vs NumPy: Benchmarking the 100KB AVX2 Kernel

Show HN landed this morning: NanoGEMM, a "bare-metal AVX2 GEMM in ~100KB, faster than NumPy." I don't trust GEMM claims from anyone — least of all claims with a Dev.to headline of "How I beat NumPy matrix multiplication by 28x." So I cloned it, read all 350 lines of source, built it, and ran it three ways: through its Python API, through NumPy with pre-allocated output, and as a raw C microbenchmark I ported myself (the shipped C benchmark only compiles on Windows).

The headline vs. the README vs. my machine

Three claims walk into a bar. The Dev.to title says 28x. The project's own README table says 2.83x — and, to its credit, openly admits NanoGEMM loses above 128×128 (0.28x at 256). Neither number reproduced on my box. My setup: 2-core x86-64 with AVX2, AVX-512F and FMA, NumPy 2.5.3 riding scipy-openblas 0.3.34 in Haswell (AVX2) mode — I checked, so this is a fair AVX2-vs-AVX2 fight, not AVX-512 cheating.

# fairness check: what BLAS is NumPy actually using?
$ python -c "import numpy; numpy.show_config(mode='dicts')"
"openblas configuration": "OpenBLAS 0.3.34.106.0 USE64BITINT
  DYNAMIC_ARCH NO_AFFINITY Haswell MAX_THREADS=64"

Via the Python API, NanoGEMM never wins. Not at any size. Not even at 16×16, its advertised sweet spot:

Matrix   NumPy out= (µs)  NanoGEMM py (µs)  Speedup
16×16         1.48              3.54          0.42x
32×32         2.05              5.78          0.35x
64×64         4.99             22.37          0.22x
128×128      40.78            205.17          0.20x
512×512    1758.02          14911.63          0.12x

0.42x at the sweet spot. Not "2.83x faster." Not "28x."

The twist: the kernel is genuinely fast

Here's where the story gets interesting. I ported the Windows-only C benchmark to POSIX and timed nanogemm_matmul() with no Python in the loop:

$ gcc -O3 -mavx2 -mfma bench_c_posix.c nanogemm_kernel.c -o bench
4×4    |     0.106 µs |   1.21 GFLOPS
16×16  |     0.764 µs |  10.72 GFLOPS
32×32  |     2.137 µs |  30.67 GFLOPS
64×64  |    18.357 µs |  28.56 GFLOPS
512×512| 15643.311 µs |  17.16 GFLOPS

The raw kernel really does beat NumPy below ~32×32: 0.76µs vs 1.48µs at 16×16 is a legitimate 1.9x, and at 4×4 it's 11x. The 6×16 register-tiling microkernel in nanogemm_kernel.c is real, correct (max abs error ≤1.1e-5 vs NumPy on every shape I tested), and genuinely sub-microsecond at tiny sizes. 350 lines of readable C. Credit where due.

But the same measurement exposes the fatal contradiction. The Python extension adds ~2.7µs of binding overhead (3.54µs − 0.76µs at 16×16) — more than NumPy's entire call path, dispatch included. The README's core pitch is that NanoGEMM "eliminates the heavy function-call dispatch... of heavyweight BLAS libraries." Its own CPython binding, which uses PyArg_ParseTuple plus three PyObject_GetBuffer calls per invocation, burns more dispatch than OpenBLAS does. The project defeats itself at its only point of superiority.

graph TD
    A["NanoGEMM microkernel
0.76µs @ 16×16"] -->|+2.7µs binding| B["Python API
3.54µs"] N["NumPy + OpenBLAS
1.48µs @ 16×16"] --> B B -->|"0.42x — loses"| C["'Faster than NumPy' claim
fails at every size"] A -->|"1.9x — wins, unusable from Python"| D["Raw C embedding
the only real use case"]

Why it collapses above 64×64

The ceiling is structural, not incidental. Peak throughput hits 30.7 GFLOPS at 32×32 and then falls — 28.6 at 64, 17.2 at 512, while OpenBLAS reaches ~153 GFLOPS at 512. Reading the kernel: it broadcasts each scalar A[i][k] into a YMM register per K-step (fine), but the cache-blocking wrapper (BLOCK_M=64, BLOCK_N=128, BLOCK_K=128) copies submatrices through memcpy-style packing on every call, and the B-matrix loads inside the microkernel walk ldb-strided memory per K. At 512×512 that's ~268MB of pack traffic to compute 268 MFLOPs. A tuned kernel (à la BLIS microkernel + packing panel reuse) would crush this shape. This one isn't tuned for it, and the README — again, to its credit — doesn't pretend otherwise.

What you should actually do

Three receipts, three implications:

Bottom line: NanoGEMM is a good kernel wrapped in a self-defeating API, marketed with a number its own README contradicts. I measured all three layers myself: raw C wins sub-32×32 by up to 11x, the Python API loses at every single size, and peak throughput is 5x below OpenBLAS where it matters. If you need tiny-GEMM speed, fork nanogemm_kernel.c and skip the extension module entirely. If you need to believe performance claims — benchmark them yourself. It took me 40 minutes.