DEV Community

Cover image for Go 1.27's SIMD ties with NumPy until the data fits in cache
Efrain Garay
Efrain Garay

Posted on Originally published at efraingaray.com

Go 1.27's SIMD ties with NumPy until the data fits in cache

Go 1.27 shipped a simd package in the standard library. It is experimental and sits behind a flag, and every write-up I found described the API. None of them said when it actually helps.

So I measured it against NumPy on the same task: a speaker-search index, 346 thousand vectors of 192 dimensions, 66 million multiplications per query.

The answer turned out not to be about Go or NumPy at all.

Same code, two verdicts

With a 253 MB corpus, single-threaded:

median spread
Go SIMD 9.54 ms 14%
NumPy (OpenBLAS) 9.76 ms 10%

That is a tie, not a win. The 2% gap fits entirely inside the noise.

With 31 MB, which fits in the L3 cache:

median
NumPy (OpenBLAS) 0.393 ms
Go SIMD 0.943 ms

NumPy is 2.4× faster. Same code, same machine, same afternoon.

Why the reversal

The number that explains it is not in either table. Parallelising across eight physical cores gave 2.9× in Go and 3.0× in NumPy. Pure computation would give close to 8. Both stopping at 3 is the tell: the ceiling is not the code.

At 253 MB nothing fits in any cache, so both programs spend most of their time waiting on memory. When two implementations hit that wall, code quality stops mattering. Twenty lines of a brand-new portable package match assembly that has been hand-tuned per microarchitecture for years.

At 31 MB the data sits in L3, the bottleneck goes back to arithmetic, and OpenBLAS's years of tuning show up immediately.

What decides is not the language. It is whether the data fits in cache.

The 55-second version of all of this, narrated:

The part worth stealing

I nearly published the opposite conclusion, twice.

The first run gave NumPy double Go's speed. OpenBLAS spreads work across every core without announcing it, while my Go program ran on one thread. OMP_NUM_THREADS=1 and OPENBLAS_NUM_THREADS=1 made NumPy three times slower and flipped the result.

The second time I measured a machine with thirteen foreign processes at 100% CPU and did not notice. The same numbers came out ten times worse on the check run.

So the harness pins cores with taskset, interleaves the implementations round by round instead of measuring all of A then all of B, aborts if load exceeds 2.0, and reports median and spread rather than the best time. The best time measures the best case and hides exactly the instability that matters.

Its first version printed 0.000 ms for NumPy. Not a record: perf_counter returns seconds and I was printing milliseconds. A suspicious zero is a units bug before it is a marvel.

What this does not prove

Two home machines, one embedding model, two corpus sizes. My Go code walks vector by vector while OpenBLAS solves the whole matrix-vector product with blocking and unrolling. That is a naive implementation against an expert one, which is exactly what you get picking the package up fresh.

The exact numbers belong to this hardware. The shape of the curve repeated on both machines, with synthetic and real data, and that is the only part I would call general.


The full write-up has the synthetic benchmark across three cache levels, the control that separates data size from data nature, a second machine that chose 4 lanes when it could have used 8, and the raw numbers:

Go 1.27 brings portable SIMD: it ties with NumPy out of cache and loses inside it

Top comments (0)