SIMD Benchmarks
Cross-language numbers for the numeric-array operations described in
SIMD & Native Arrays. This page covers three
stories, in order: (1) the original cross-language comparison: March vs.
hand-written OCaml and Rust, idiomatic Elixir, naive interpreted Python, and
NumPy (a hand-tuned, BLAS-backed reference implementation) on three
Float(f64)-array operations, all three now competitive (map2 wasn’t
always one of them; see Fix history: map2 for the
before/after); (2) a March-only, same-box f32 vs. f64 narrow-width
comparison, plus a like-for-like f32 rematch against NumPy (see Narrow
element widths: f32 vs. f64); and (3) the
explicit Simd module’s own validation kernels: a byte-scanning win and an
candid dot-product loss against NativeArray’s composed fast path (see
Simd module kernels).
Read this before the numbers
This is a shared development machine, not a dedicated benchmark box. The run below shared the machine with several other active sessions; load average was 7.9–11.1 at the time, on a 14-core machine. That mostly washes out for the SIMD numbers specifically (each program self-times only its own operation over up to a few milliseconds; see Methodology), but treat everything here as directional, not authoritative. Run it yourself on your own hardware if the exact numbers matter to you; the reproduction command is at the bottom of this page.
No cherry-picking. These are the median/min/max of 10 runs, taken
directly from a single invocation of bench/run_benchmarks.sh, unedited.
Where March loses or is only mid-pack, that’s shown too (map loses to Rust
here; map2 did too, along with everything else, before the fix below).
Reproducing this. bench/run_benchmarks.sh now prints its own provenance
before it times anything: date, host, CPU, core count, load average, and the
version of every compiler it resolved, including March’s taken from the
dune exec compiler that actually builds the benchmarks rather than from
whatever march sits on PATH. If a comparison language is missing it announces
this visibly and names the rows that will be absent, because the old failure mode was
quiet: a missing tool dropped that language’s row and produced a smaller table
with no indication it was smaller. The tables below and the profile beneath them
were transcribed from such a run; the run prints them so you never have to take
this page’s word for it.
Machine profile
| Chip | Apple M3 Max |
| Cores | 14 (10 performance + 4 efficiency) |
| Memory | 36 GB |
| OS | macOS 26.5.2 (Darwin 25.5.0, arm64) |
| Load average at run time | 7.89, 11.10, 10.28 (1m/5m/15m), not idle |
| Date | 2026-07-27 |
Versions
| Language | Version | Compilation |
|---|---|---|
| March | HEAD | march --compile --opt 2 → clang native |
| OCaml | 5.3.0 | ocamlopt native (simd-* link unix via ocamlfind) |
| Rust | 1.94.0 | rustc -O native |
| Elixir | 1.20.1-otp-29 | BEAM JIT (script mode) |
| Python | 3.14.3 | CPython, interpreted |
| NumPy | 2.5.1 | vectorized/BLAS reference |
| clang | 17.0.0 (Apple) | backs march --compile and rustc codegen |
Methodology
Every benchmark operates on 5,000,000 Floats. Each program self-times
only the operation under test; data generation and (for interpreters)
process startup are excluded from the reported number. This isn’t the usual
whole-process wall-clock this project’s other benchmarks use (see
the fib/binary-trees/tree-transform/list-ops results);
it’s necessary here because building the input dominates the operation
itself at this scale. Building a 5M-element List of boxed floats in March
(the natural way to construct one, then convert with
NativeArray.from_list_float) costs ~200ms on its own; that’s ~150x the
~1ms the vectorized sum actually takes. Measuring the whole process would
report “how fast March allocates a linked list,” not the SIMD claim under
test, and every other language would face the same distortion from its own
input-construction cost, which is likewise unrelated to vectorization.
Two per-language notes, both to keep the comparison fair rather than to flatter
March: OCaml’s Array.fold_left/Array.map box every float through a
polymorphic accumulator (confirmed ~5x slower than a manual loop), not what
a performance-conscious OCaml numeric loop looks like, so the OCaml sources
use manual for loops instead. Rust’s iterator-based .sum()/.map()/.zip()
were checked against a manual-loop control and found already at parity; Rust’s
zero-cost-abstraction claim applies here, so the idiomatic iterator form is used
as-is.
Run 10 times per language per benchmark; median, min, max reported.
Results
sum(arr): Float array reduction
| Language | Median | Min | Max |
|---|---|---|---|
| March | 1.1 ms | 1.0 ms | 1.4 ms |
| OCaml | 4.7 ms | 4.7 ms | 4.8 ms |
| Rust | 5.4 ms | 5.3 ms | 6.5 ms |
| Elixir | 83.9 ms | 81.2 ms | 91.7 ms |
| Python | 296.9 ms | 283.0 ms | 319.3 ms |
| NumPy | 1.0 ms | 1.0 ms | 1.0 ms |
native_float_arr_sum auto-vectorizes under clang -O2; see
SIMD & Native Arrays for the
#pragma clang fp reassociate(on) this needed. March ties NumPy here.
Source: bench/simd_sum.march ·
bench/ocaml/simd_sum.ml ·
bench/rust/simd_sum.rs ·
bench/elixir/simd_sum.exs ·
bench/python/simd_sum.py ·
bench/python/simd_sum_numpy.py
map(x -> x * 2.0 + 1.0): elementwise Float map
| Language | Median | Min | Max |
|---|---|---|---|
| March | 5.1 ms | 4.8 ms | 5.5 ms |
| OCaml | 5.5 ms | 5.4 ms | 5.6 ms |
| Rust | 3.9 ms | 3.7 ms | 4.3 ms |
| Elixir | 244.8 ms | 236.9 ms | 281.8 ms |
| Python | 194.1 ms | 192.9 ms | 199.5 ms |
| NumPy | 2.1 ms | 2.1 ms | 2.9 ms |
This one needs a compiler-side trick, not just clang: March’s closure-call
ABI heap-boxes every Float crossing a call boundary, which blocks
vectorization entirely. For a concrete-Float, single-use callback like this
one, a dedicated pass inlines the callback and drops the boxing so the loop
vectorizes; without it, this number would look like map2’s before-the-fix numbers
below, not like this (the pass details are in
Compiler internals). March is competitive with
hand-written OCaml/Rust and within 3x of NumPy.
Source: bench/simd_map.march ·
bench/ocaml/simd_map.ml ·
bench/rust/simd_map.rs ·
bench/elixir/simd_map.exs ·
bench/python/simd_map.py ·
bench/python/simd_map_numpy.py
map2(a, b, (x, y) -> x + y): elementwise two-array zip
| Language | Median | Min | Max |
|---|---|---|---|
| March | 6.4 ms | 6.3 ms | 8.8 ms |
| OCaml | 7.0 ms | 6.9 ms | 7.1 ms |
| Rust | 4.5 ms | 4.3 ms | 4.8 ms |
| Elixir | 101.6 ms | 99.4 ms | 132.0 ms |
| Python | 197.1 ms | 189.3 ms | 207.5 ms |
| NumPy | 1.6 ms | 1.5 ms | 1.6 ms |
NativeArray.map2_int/map2_float (added to unblock
DataFrame.col_add_col, column-column arithmetic) now gets the same
inlining/boxing-elimination treatment map does, so a concrete-Float
callback vectorizes the same way. Beats OCaml, within 3x of NumPy.
(Compiler internals covers how the pass was extended to
the two-array shape.)
Fix history: map2
This wasn’t always true. map2 originally shipped correctness-first:
the primitive itself (runtime, interpreter, typechecker, compiled-path
registration) landed without the inlining pass most other NativeArray
operations get, a known, explicitly-documented scoping decision. The numbers
made the cost of that gap concrete rather than a caveat:
| March (before) | March (after) | Speedup | |
|---|---|---|---|
map2 |
299.2 ms | 6.4 ms | ~47x |
Before the fix, every element dispatched through the general closure-call
path (heap-box each argument, indirect call through the closure’s function
pointer, unbox the result): 299ms, slower than naive interpreted
Python, and ~47x slower than March’s own map doing essentially the same
arithmetic. The inlining pass was extended to the two-array map2 shape the
same day these numbers were first published; Compiler
internals covers what that took.
Source: bench/simd_map2.march ·
bench/ocaml/simd_map2.ml ·
bench/rust/simd_map2.rs ·
bench/elixir/simd_map2.exs ·
bench/python/simd_map2.py ·
bench/python/simd_map2_numpy.py
Narrow element widths: f32 vs. f64
Added 2026-08-10 alongside NativeArray.make_f32/map_f32/map2_f32/sum_f32
(narrow f32 element storage, 50% of the width of the f64/Float element
storage every table above uses). bench/simd_f32.march runs the same three
shapes as simd-sum/simd-map/simd-map2 above (sum,
map(fn x -> x *. 2.0 +. 1.0), map2(fn (x, y) -> x +. y)) at the same
N=5M, self-timed the same way (data generation excluded; see Methodology
above). Unlike the tables above, this one is March-only, same-box,
same-build, f32 vs. f64: the point isn’t a cross-language comparison, it’s
whether halving the element width (and doubling the SIMD lane count) actually
pays off. Absolute ms are not a regression baseline across machine/load
states; the f32/f64 ratio measured in the same run is what matters.
Methodology: 6 timed samples per operation (two 3-run round-robins, one
f32, f64-sum, f64-map, f64-map2 ordered and one reversed, to cancel the
first-timed-variant warmup bias this page’s other tables also control for;
neither f32 nor f64 was consistently first).
| N=5M | f32 | f64 | Speedup |
|---|---|---|---|
sum(arr) |
0.49 ms | 1.19 ms | ~2.4x |
map(x -> x*2+1) |
2.27 ms | 4.54 ms | ~2.0x |
map2(a, b, +) |
2.67 ms | 6.40 ms | ~2.4x |
map’s ~2.0x sits right at the theoretical upper limit from doubling the lane
count; sum/map2 beat that ratio slightly, within the noise of a shared,
loaded benchmark machine. map_f32/map2_f32/sum_f32 get the identical
inline-loop vectorization treatment map_float/map2_float/sum_float get
above, confirmed via -emit-llvm to compile to real <4 x float> NEON
vector instructions, not just scalar unrolling.
Cross-language f32 rematch: March vs. NumPy
A second run after the narrow-widths work merged, this time including NumPy
at both element widths; the NumPy rows in the sum/map/map2 tables
above are float64 (np.arange(n) / 100.0 yields float64), so
bench/python/simd_{sum,map,map2}_numpy_f32.py (explicit
.astype(np.float32), float32 scalar operands) exist to make the f32
comparison like-for-like. Same self-timed-operation-only protocol as every
table on this page; March legs are medians of 10 samples, NumPy legs medians
of 5, interleaved.
| N=5M, medians (ms) | March f64 | March f32 | NumPy f64 | NumPy f32 |
|---|---|---|---|---|
sum |
1.02 | 0.40 | 0.70 | 0.74 |
map(x*2+1) |
3.69 | 1.81 | 1.58 | 2.05 |
map2(a+b) |
4.90 | 1.85 | 1.18 | 1.76 |
March f32/f64 ratios in this run track the table above (sum 2.5x, map 2.0x,
map2 2.6x). Like-for-like at f32, March beats NumPy on sum (1.8x) and
map (~13%), and ties it on map2 (~5%); the pre-narrow-widths 4x map2
gap (6.4 ms vs 1.6 ms, see Fix history: map2) is gone.
Don’t overclaim past that: this is one comparison on one shared machine, not
a universal “March beats NumPy” result, and map on f64 still trails NumPy
in the main table above.
Source: bench/simd_f32.march ·
full methodology and load-state notes in
bench/RESULTS.md’s
simd-f32 section.
Simd module kernels
Added 2026-08-11 as the validation kernels for the explicit Simd module
(128-bit vector types; see SIMD & Native Arrays → Explicit SIMD).
bench/simd_kernels.march runs two explicit-Simd-vs-baseline pairs,
compiled only (--compile --opt 2), self-timed the same way as every other
table on this page: a dot product (5,000,000 f32 pairs) and a delimiter
scan (16,000,000 u8 bytes). Medians of 5 interleaved rounds; per-leg
coefficient of variation stayed under 5%.
scan(16MB u8) |
Median | Min | Max |
|---|---|---|---|
scan_simd (Simd.eq_u8x16 + first_set_u8x16) |
19.28 ms | 19.07 ms | 20.41 ms |
scan_scalar (byte-at-a-time March loop) |
221.32 ms | 221.14 ms | 222.97 ms |
scan_simd is ~11.5x faster than the scalar byte-at-a-time loop: the
classic memchr-shaped SIMD win: --emit-llvm confirms the loop never
allocates (the mask value is consumed immediately by first_set_u8x16
within the same iteration, never escaping as a call argument).
dot(5M f32) |
Median | Min | Max |
|---|---|---|---|
dot_simd (hand-written Simd accumulator loop) |
10.01 ms | 9.96 ms | 10.13 ms |
dot_composed (NativeArray.map2_f32 + sum_f32) |
2.55 ms | 2.40 ms | 2.72 ms |
dot_simd is ~3.9x slower than dot_composed, and that gap is not a
SIMD cost. Holding the loop framework constant and comparing only the vector
lowering isolates why:
| 5M f32 dot, loop framework held constant | ms |
|---|---|
| SIMD index loop (4 lanes/iter) | 9.89 |
| scalar index loop (1 elem/iter) | 39.95 |
map2_f32 + sum_f32 (one C call) |
2.34 |
The SIMD index loop is 4.0x faster than the equivalent scalar March index
loop over the same 5M pairs; the vector lowering is doing its job.
dot_composed wins for an unrelated reason: it’s a single call into a tight
C runtime pipeline, while any hand-written March index loop, Simd-driven
or not, pays per-iteration overhead unrelated to vectors (a
preemption check, a stack save/restore, RC bookkeeping on locals, an
unhoisted length call). That overhead is general to every hand-written
NativeArray index loop and is tracked separately at
specs/todos/2026-08-11-march-index-loop-per-iteration-overhead.md; dot_simd
keeps its straightforward accumulator-loop shape rather than being
rewritten around the gap, so the comparison stays fair.
Recommendation (same as SIMD & Native Arrays): for a simple
elementwise-then-reduce pipeline, reach for NativeArray.map/map2/sum
first. Reach for Simd directly for cross-lane structure (masks, select,
scans), fused multi-op kernels (fma), or byte-level scanning, where, as
the scan numbers above show, it’s a clear and large win.
DataFrame’s Min/Max aggregation was evaluated against a Simd-based
migration on the same shape and intentionally not migrated: a
Simd.min_i64x2/max_i64x2 accumulator loop measured ~8.2x slower than the
existing native_int_arr_min/native_float_arr_min C reduction, for the
same index-loop-overhead reason above (and with a lower upper limit even once
that’s fixed: i64x2/f64x2 are only 2 lanes wide, vs. 4 for the
f32x4/i32x4/u8x16 families used above).
Source: bench/simd_kernels.march ·
full methodology, load-state notes, and the DataFrame Min/Max probe in
bench/RESULTS.md’s
simd-kernels section.
Compiler internals
For compiler hackers: you don’t need any of this to use
NativeArray. It records the infrastructure behind the boxing-free numeric fast path the benchmarks above exercise.
The inlining and boxing-elimination live in the Native_map_inline.ml pass. It
recognizes a map/map2 call with a callback that is fresh, single-use, and either
non-capturing or single-capture (the same eligibility bar for both shapes), inlines that
callback into the loop, and, when the callback’s signature is concretely all-Float,
clones it under natural double parameters and return with zero heap boxing (internally
“Float-boxing Stage 4, Option B”). The two-array map2 support reuses the identical
synthetic-call-name mechanism and unboxed-clone path as single-array map; it just
matches a 3-argument call shape (two arrays + closure) instead of map’s 2-argument one.
Because the inlined loop bypasses the native_int_arr_map2 / native_float_arr_map2
runtime helpers entirely, it includes its own length-mismatch guard
(native_arr_map2_check_len in runtime/march_runtime.c) so it still panics on a length
mismatch exactly like the non-inlined path, covered by a dedicated regression test, not
just the happy path.
Reproducing
# From a march-language/march checkout:
bash bench/run_benchmarks.sh
# More iterations (default is 10):
RUNS=20 bash bench/run_benchmarks.sh
The NumPy row needs a local venv (not committed to the repo):
python3 -m venv bench/.venv
bench/.venv/bin/pip install numpy
Every other row and benchmark runs without it; the script detects
bench/.venv and skips the NumPy row if it’s absent.
The runner (bench/run_benchmarks.sh)
also compares fib(40), binary-trees(15), tree-transform, and list-ops
against OCaml/Rust/Elixir: allocation, recursion, and HOF-pipeline shaped
workloads unrelated to SIMD. Full results, including those, are in
bench/RESULTS.md.
See also
- SIMD & Native Arrays: what vectorizes, how to trigger it, known limitations.
- Standard Library → NativeArray: full API reference.
- Standard Library → Simd: full API reference for the explicit 128-bit vector types.