SIMD & Native Arrays
NativeArray is a flat, contiguous numeric array type — unlike Array (a
32-way persistent trie, optimized for structural sharing), NativeArray maps
directly onto sequential memory. That makes it cache-friendly for scan
patterns, and lets march --compile hand a handful of its operations
straight to LLVM’s auto-vectorizer, so they run as real SIMD instructions
(NEON on arm64, SSE4.2+ on x86-64) instead of a scalar loop.
This page covers what actually vectorizes today, how to write code that triggers it, and the known gaps — this is a fast path you opt into for numeric hot loops, not the default representation for numeric data.
let arr = NativeArray.make_float(1_000_000, 0.0)
let doubled = NativeArray.map_float(arr, fn x -> x *. 2.0)
let total = NativeArray.sum_float(doubled)
What vectorizes
Only compiled builds (march --compile) vectorize — the interpreter and
REPL never do, since there is no LLVM/clang pass in that path. Everything
below is correct either way; this section is about the compiled fast path.
sum_int/sum_float— both auto-vectorize underclang -O2.sum_floatneeded a scoped#pragma clang fp reassociate(on)around the runtime C loop, since strict IEEE 754 float semantics otherwise forbid the reduction from reassociating at all (and thus from vectorizing).map_int— when the callback is a non-capturing lambda, or captures a single variable, the compiler inlines the callback directly into the loop instead of dispatching through a closure pointer. That lets clang’s own inliner and vectorizer see straight through it, so arithmetic-heavy bodies (fn x -> x * x + 1,fn x -> x + captured) compile to real SIMD.map_float— gets the same inlining treatment asmap_int. If the callback’s signature is concretely all-Float(not still generic), the compiler goes one step further and clones the inlined callback under naturaldoubleparameters/return with zero heap boxing, and the loop vectorizes for real (confirmed via-emit-llvm: NEONfadd.2d/similar vector instructions, not just scalar unrolling).map2_int/map2_float(element-wise over two arrays, e.g.col_add_col’s column-column arithmetic) get the identical treatment — same eligibility bar, same boxing-free clone for a concrete-Floatcallback. Landed after the rest of this page (see the benchmark history below); previously these fell straight through to the general closure-dispatch path.
A closure that’s reused elsewhere (passed to map/map2 and also
called directly, or stored and called later), or that captures more than
one variable, still runs correctly — it just falls back to the general
closure-dispatch path, which does not vectorize.
How to trigger it
- Use
NativeArray, notArrayorList, for the hot data.Array’s trie layout andList’s cons cells are not contiguous in memory, so nothing here applies to them. - Compile with
--compile --opt 2.-O2is what enables clang’s auto-vectorizer; an interpreted or unoptimized build never vectorizes. - Keep the callback simple and single-use. A short arithmetic lambda
passed to
map_int/map_float/map2_int/map2_floatand used nowhere else gets inlined. If you need the same closure for multiple purposes, expect the general (non-vectorized, still correct) path. - For
Float, avoid leaving the callback’s type generic if you want the zero-boxing path — a concreteFloat -> Floatsignature is what lets the compiler drop the boxing entirely.
You can confirm vectorization yourself with march --compile --opt 2 --emit-llvm
your_file.march and grepping the output for vector types (<2 x double>,
<4 x float>) or clang’s %vector.body loop-vectorizer markers.
Benchmarks
March vs. hand-written OCaml/Rust, idiomatic Elixir, naive Python, and NumPy (a hand-tuned, BLAS-backed reference implementation) on three Float-array operations over 5M elements:
| Benchmark (N=5M) | March | OCaml | Rust | NumPy |
|---|---|---|---|---|
sum(arr) |
1.1 ms | 4.7 ms | 5.4 ms | 1.0 ms |
map(x -> x * 2.0 + 1.0) |
5.1 ms | 5.5 ms | 3.9 ms | 2.1 ms |
map2(a, b, (x, y) -> x + y) |
6.4 ms | 7.0 ms | 4.5 ms | 1.6 ms |
All three hold up — March ties a hand-tuned, BLAS-backed reference
implementation (NumPy) on sum, and is competitive with hand-written
OCaml/Rust on all three, via general-purpose LLVM auto-vectorization rather
than a hand-rolled numeric kernel. map2 used to be the odd one out here —
299 ms, no inlining treatment, slower than naive interpreted Python —
until it got the same closure-inlining/boxing-elimination machinery map
already had; see the benchmark history in
SIMD Benchmarks for the
before/after.
Full write-up (machine profile, methodology, per-language source links) at
SIMD Benchmarks. Reproduce
locally with bash bench/run_benchmarks.sh from a checkout.
Known limitations
fold_int/fold_floathave no compiled implementation yet — calling either from a--compilebuild fails to link. Usesum/mapor a manual index loop until this lands.DataFrame:Sum/Meanaggregation andcol_add_col(column-column arithmetic, viamap2_int/map2_float) use the vectorizedNativeArrayprimitives above under the hood.Min/Max/Std/Variance/Medianaggregation,ColExpr-based lazy-frame arithmetic, andfill_nulldo not yet — they’re correct, just not on the fast path.- Vectorization is an LLVM/clang optimizer decision, not a March language guarantee — it can depend on the target architecture and clang version. Nothing above changes program behavior; a callback that doesn’t qualify for inlining, or a loop clang declines to vectorize, still produces identical results, just via a scalar loop.
See also
- Standard Library → NativeArray — full API reference.
- Standard Library → DataFrame — columnar data built on
NativeArray. - Parallel Collections — parallelizing across cores, a different axis from vectorizing within one core.
- Memory Model — how March values are represented, including the float-boxing tradeoff mentioned above.