[ sys ]fast-mnist-nnsha f8efce9measured-in-repo
A digit net I taught the CPU to run fast, from scratch.
I wrote the dense linear algebra and backprop for an MNIST multilayer perceptron in C++17 with no BLAS, Eigen, or PyTorch, then hand-vectorized the hot paths and proved the optimizations pay off with a reproducible benchmark suite.
- [ fast-mnist-nn ], noun
- 1.a handwritten-digit MLP whose matrix and MLP hot paths are hand-vectorized.
- 2.one kernel, four arch paths: AVX-512, AVX2, NEON, scalar tail, chosen at compile time.
- 3.the losses sit next to the wins in the same chart.
- pillar
- systems ◆
- lang
- C++17
- license
- MIT
- size
- ~3,387 LOC
- baseline
- own scalar build
- harness
- Google Benchmark
dot-product speedup
256x256, SIMD intrinsics · 64-byte aligned memory · blocked GEMM tiling
inference throughput
784-30-10 MLP, classify path (serial SIMD)
compile-time SIMD dispatch
one kernel, four arch paths + scalar tail
The benchmark below is the reproducible evidence behind these figures, each result cited to sha f8efce9.
01Problem
Train and run a digit net fast on the CPU, with nothing borrowed.
Most from-scratch neural nets stop at correctness: a few nested loops over a matrix, a sigmoid, and a training curve that eventually goes down. The harder problem is making the same math fast on a real CPU without reaching for a library that already solved it. I set the constraint up front: no BLAS, no Eigen, no PyTorch. The dense linear algebra and the backprop are mine to write, and the speed is mine to earn.
That turns one project into two questions. Does the network learn MNIST correctly, and does the hand-written math actually run fast enough to matter? The second question only counts if I can prove it, so the benchmark harness is part of the project, not an afterthought.
02Approach
Three moves: align the memory, block the multiply, fuse the step.
Speed here comes from three decisions that compound, each one a deliberate piece of the hot path rather than a compiler flag I flipped on and hoped.
- i
Align the memory.
src/Matrix.cpp:34 - ii
Block the multiply.
src/Matrix.cpp:333 - iii
Fuse the step.
src/NeuralNet.cpp:185
03Architecture
One data path from aligned bytes to a trained step.
The figure follows a single matrix through the optimized path. Aligned rows feed the blocked multiply, the inner dot resolves to one SIMD kernel at compile time, and the scalar result lands in the fused network step. The accent marks only the traversed path. Everything else is structure.
src/Matrix.cpp and src/NeuralNet.cpp at sha f8efce9. The highlighted SIMD lane (AVX-512) is illustrative. The real lane is chosen by the target at compile time.The library is layered, not a single blob.
Each layer consumes the one beneath it. The CLI drives training and evaluation over a PGM parser. The network owns the fused kernels. The matrix owns aligned storage and the SIMD dispatch. The harness turns benchmark JSON into the committed CSV and charts. About 3,387 lines hold the whole thing.
src layout · top consumes the layer beneath it
apps/fast_mnist_cli.cpptraining + eval driverP2 PGM parser · binary image cache
NeuralNet.{h,cpp}fused MLP kernels, static scratchgemv+bias+sigmoid · output δ · Wᵀ·δ · SGD
Matrix.{h,cpp}dense linear algebra + SIMD dispatchaligned alloc · blocked dot · transpose · axpy
benchmarks/ · tools/run_benchmarks.pyreproducible measurement harnessGoogle Benchmark JSON → CSV → SVG
04Tradeoffs · road not taken
Two decisions the data forced.
The data forced two decisions, and neither is clean.
OpenMP only on big matrix ops, never on the network
bench_summary.csvStatic scratch buffers buy speed, cost thread-safety
src/NeuralNet.cpp05Benchmark · vs scalar baseline
Wins and losses, side by side.
The named baseline is the project's single-threaded scalar build (OpenMP off, no -march=native), run side by side with the native and openmp+native configs in the same Google Benchmark harness. There is no BLAS or Eigen comparison, since the whole point was to write the math myself. On the fully optimized matrix path that lands at ~3.5× on the large dot product, the single biggest per-op win. The committed CSV below is the reproducible evidence underneath it.
docs/benchmarks/bench_summary.csv at sha f8efce9. Single-repetition runs. Lower ns/op is better. Raw benchmark JSON ↗The large dot product carries the win: ~3.5× faster at 256×256, with transpose and axpy near 2× at size 1024. The two faint bars below the line are real, not rounding errors. Parallelism has a crossover point, and below it the overhead wins.
Speed alone says nothing about whether the net is right. The int8 model deployed to the browser demo, not the C++ CLI these benchmarks measure, holds 98.85% clean accuracy and 97.62% on an augmented set built to mimic messy handwriting, measured in the accuracy write-up.
[ sys ]fast-mnist-nn
Draw a digit, watch the net decide.
Draw a digit and the network classifies it live in your browser, running the same forward pass as the trained model. The deployed model holds 98.85% accuracy on clean MNIST digits and 97.62% on an augmented set built to mimic messy handwriting, detailed in the accuracy write-up.
Accuracy figures are for the deployed int8 browser model (public/models/mnist-mlp.json), not the C++ training and inference CLI.
Read the source.
The SIMD dispatch, the blocked multiply, the fused kernels, and the benchmark harness are all in the repo, pinned at the commit every number on this page is measured from. Cross-platform CI runs the Catch2 suite on Linux, macOS, and Windows.