SIMD in a Homemade Language: Mere's Numbers and What They Say (September 2026)
Three weeks of adding SIMD to a small self-hosted language with five backends: hoisting bounds checks so clang can vectorize, two 128-bit types, and RISC-V vector instructions on a CPU the language wrote for itself. What paid, what did not, and the four properties of a loop that decide which.
Mere is a small language I write for myself. It has an interpreter and four
compiled backends – C, LLVM IR, WebAssembly text, and RISC-V machine code
emitted directly, with no assembler – and a RISC-V emulator, also written in
Mere, that the RISC-V output runs on. In September 2026 I asked it a question
it had never been asked: what does SIMD buy a language like this, and where?
This is the answer with numbers, written for someone who has not followed the
project. Every figure here is reproducible from the public repository
(merelang/mere, benchmarks/), and the honest ones are in the same table as
the flattering ones.
The first measurement was not about SIMD
The obvious benchmark is axpy: c[i] = c[i] + alpha * a[i] over two
vectors of doubles, two million elements, a hundred passes. Mere compiles to
C and hands that C to clang, and clang auto-vectorizes at -O2, so the naive
expectation was a tie with hand-written C. Mere was 2.8x slower.
Reading the assembly of the copy that actually ran – not the function by
name, which turned out to be an uncalled wrapper; the inlined copy inside
main – showed no vector instructions at all. Clang’s own remarks said why:
the loop had three early exits, and the array bounds could not be
established. The exits were Mere’s bounds checks. Every vec_get and
vec_set checks its index and, on failure, leaves the loop through a call
that never returns. Three such exits per iteration, and after each one the
compiler had to reload the vector’s length and data pointer because it could
not prove the failing call had not changed them.
I then hand-edited the emitted C, hoisting the checks out of the loop and
taking the data pointer into a local. Clang vectorized it and the time matched
hand-written C. Rewriting the recursion as a plain for loop without moving
the checks did nothing. So the 2.8x was almost entirely the shape of the
bounds check, and SIMD itself was worth 1.3-1.4x of it. That set the order of
work: fix the shape first, for every backend, then decide about explicit
lanes.
Path one: check the range once, before the loop
Range-check versioning (v0.1.420) is a pass over the AST, so it runs before every backend. For a loop written the way Mere loops are written –
let rec axpy = fn (i: int) ->
if i == n then ()
else
let _ = vec_set c i (f_add (vec_get c i) (f_mul alpha (vec_get a i))) in
axpy (i + 1);
– it adds a sibling axpy__rvfast whose accesses are unchecked twins, and
rewrites each call site axpy 0 into
if 0 >= 0 && 0 <= n && n - 1 + 1 <= vec_len c && n - 1 + 1 <= vec_len a
then axpy__rvfast 0 else axpy 0
The guard checks the whole index range once. The loop body has no exit left,
clang vectorizes it, and every backend – including the interpreter – skips
the per-element checks. The conditions are what you would guess: a
tail-recursive function whose first parameter is the index, a literal stride,
an exit that compares the index with an invariant bound, indices that are the
index itself or monotonic in it (so both endpoints bound the range), containers
bound outside the loop, and a body that cannot change any length and calls
nothing that could. Only the step branch is rewritten. The exit branch keeps
its checks, because an access there reads v[n], one past the end – a
soundness bug I shipped in v0.1.420 and found in v0.1.427 when a program that
read the exit element printed 46 on the C backend and failed on the
interpreter. The gate for this pass now runs every program with the pass on
and off, on four backends, and demands the same output.
Two design points that were not obvious in advance. The first version used a
dispatcher function – axpy itself became if guard then fast else slow.
That broke the LLVM and Wasm backends’ lambda lifting, because the dispatcher
did not itself mention the captured variables it needed to pass on to the fast
copy. Dispatching at the call site, and keeping the original function as the
slow path, needs no new captures. The second was a bug where a top-level
loop’s own name shadowed the plan it had just created, so main never called
the fast copy and the time did not move; the assembly of main (again, the
copy that runs) is what showed the call going to the slow path.
Results, python3 benchmarks/run.py <row> --reps 3, macOS/arm64, Apple
clang 21, C rows compiled by the same clang at -O2:
| row | Mere before | Mere after | hand-written C |
|---|---|---|---|
axpy |
2.8x C | 83 ms; the passes tie C at 50 ms each side | 73 ms |
bytecount (count a byte in a buffer) |
23 ms | 21 ms | 21 ms |
matmul 512 |
144 ms | 144 ms | 132 ms |
crc32 |
43 ms | 43 ms | 45 ms |
The 10 ms axpy still carries is building the two vectors with vec_push
where the C row writes into a malloc’d array. matmul does not move because
its inner loop is a floating-point reduction, and without fast-math clang keeps
the additions in order. crc32 does not move because its loop is a table
lookup indexed by data. Both are explained by the same four properties I will
come to; the point here is that hoisting the checks is free and applies
everywhere, and what it buys depends entirely on the loop.
Path two: two 128-bit types you write lanes with
The automatic path does nothing for WebAssembly – the WAT is assembled and
run as written, with no optimizer behind it – and nothing for any loop clang
cannot see through. So Mere gained two value types (v0.1.422-425): f64x2,
two doubles, and u8x16, sixteen bytes. They are ordinary values, and every
operation is a builtin: f64x2_add a b, u8x16_swizzle table idx,
u8x16_shift_in prev cur k. There are twenty-four of them, chosen by writing
the one program I wanted to be fast and listing what it needed.
That program is a UTF-8 validator in the style of Keiser and Lemire: sixteen
bytes per step, three sixteen-entry tables indexed by nibbles of the current
and previous byte, and-ed together into a per-lane error mask, plus a
saturating subtraction to catch missing continuation bytes. It is the
canonical thing an auto-vectorizer cannot build, because the scalar validator
is a state machine and a state machine is a chain. The representation per
backend: clang’s vector_size(16) types in C, <16 x i8> in LLVM IR, v128
in Wasm, and on RISC-V the V extension (more on that below). The one operation
with no portable spelling is the swizzle – pshufb on x86, tbl on arm64,
i8x16.swizzle on Wasm, vrgather on RISC-V – and Mere fixes its semantics
to Wasm’s, an index above fifteen yielding zero.
| row | scalar Mere | Mere with lanes | scalar C |
|---|---|---|---|
utf8valid (4 MiB, 20 passes) |
72 ms | 29 ms | 54 ms |
axpy_simd (explicit f64x2) |
the auto-vectorized axpy, 0.08 s |
0.12 s | 0.07 s |
The validator is the justification: 2.5x the scalar Mere machine, 1.9x the
scalar C machine, about 4 GB/s. The C row is deliberately scalar – what a C
programmer writes without intrinsics – so the comparison is “lanes against no
lanes in one language” and “Mere with lanes against C without”. axpy_simd
is the loss, and it is in the table on purpose: a programmer writes two lanes
per step where clang unrolls the auto-vectorized loop to the equivalent of
eight, so explicit f64x2 is slower than writing nothing. The manifest of
that benchmark says so.
Wasm has one more thing to say, and it is about memory rather than time. A
SIMD value that crosses a function call or lands in a data structure is a
16-byte heap box, and the Wasm backend never reclaims memory. The first
version boxed every intermediate; a long validation ran out of memory at
64 KiB of input. Keeping values unboxed inside an expression and in v128
locals (v0.1.429) moved that to 256 KiB, and axpy_simd from 20,000 to 50,000
elements – but a loop that carries a u8x16 from one iteration to the next
still allocates per iteration, and wrapping the step in a region block does
not help, because the carried values are copied out of the region and those
copies are the allocation. That is a limit of the memory model, not of SIMD,
and it is written down as one.
Path three: vector instructions on a CPU the language wrote
Mere’s fifth backend emits RV32IM and RV64IM machine code, and the machine it
runs on is memu, an emulator written in Mere. Neither had the vector
extension. Adding SIMD there meant adding RVV 1.0 to both sides, and the
first decision was what “correct” would mean: QEMU, with -cpu rv32,v=true,vlen=128, became the external reference. A Python model of the
instruction subset, memu, and QEMU were run on the same fifteen directed
programs and two hundred fuzzed ones, and had to agree on every register.
They did not at first: the fuzzer found memu happily computing vrgather
with the destination overlapping a source, which the specification reserves;
memu now traps, as QEMU does. QEMU also refused to run any vector instruction
until the program set mstatus.VS, which memu had never modelled – a timeout
that looked like a hang until the reference was read.
The subset is what the UTF-8 validator needs at VLEN=128 and e8: vsetivli,
vle8/vse8, the integer and mask operations, vrgather,
vslideup/vslidedown (which together make shift_in), vredor and a
widening vwredsumu. f64x2 is refused on this backend; there is no
floating-point hardware in the machine and the language’s float library is
software.
| the validator on the Mere-written RV32 core (64 KiB, 20 passes) | time |
|---|---|
| scalar | 2.75 s |
| RVV | 1.85 s |
| RVV, values kept in vector registers instead of boxes (v0.1.430) | 1.6-1.7 s |
The last row is the RISC-V analogue of the Wasm unboxing: an expression tree
is evaluated in v1..v7 and boxed once at its root, and a let-bound
u8x16 used only as an operand lives in v8..v15 as long as no call can
run before its last use – a callee’s own vector code would overwrite it. The
validator’s listing went from 43 box stores to 16; the sixteen that remain
are the values passed to the next iteration through the recursive call. The
emulator interprets, so the ten percent is instruction count.
What decides whether lanes pay
Seven measured rows, and all of them fall out of four properties of the loop.
Dependence between iterations. SIMD is the same operation on independent
elements. c[i] = c[i] + alpha * a[i] is independent in i. A state machine
state[i+1] = f(state[i], byte[i]) is a chain, and no vectorizer splits a
chain into lanes. The explicit validator does not put the state machine on
lanes; it is a different algorithm, in which each byte is classified from the
byte before it through small tables, so each lane depends on its neighbours
only. u8x16_shift_in exists to bring the previous block’s last bytes into the
current one. “Vectorizable” means “the dependence has been made local”, and
finding that form is the programmer’s work, not the compiler’s.
How the index is formed. A contiguous index is one vector load. A
data-dependent index – crc32’s table[b] – is a gather, a load per lane on
most hardware, which is why crc32 did not move. The swizzle is the
exception that makes the validator’s tables work: a table of at most sixteen
entries is a gather inside a register, one instruction.
The kind of operation. Integer and bit operations reassociate, so the
compiler may reorder them across lanes. Floating-point addition does not, so
a reduction stays serial unless fast-math is on; that is matmul. Branches
vectorize only when they can become masks and selects, and a data-dependent
early exit cannot – and the compiler’s own bounds checks were three such
exits until path one hoisted them.
Lane count and arithmetic density. The ceiling on the gain is the lane
count: sixteen for bytes, two for doubles. A memory-bound loop hits the
bandwidth ceiling first: axpy is two loads and a store per multiply-add,
and SIMD was worth 1.3-1.4x of its 2.8x gap. The validator does a dozen
lookups and bit operations per byte, and sixteen lanes became 2.5x.
axpy_simd loses because two hand-written lanes meet eight compiler-unrolled
ones.
| pays | does not |
|---|---|
| iterations independent, or the dependence made local to a few neighbouring bytes | the previous iteration’s result is the next one’s input |
| contiguous index; tables of at most sixteen entries | data-dependent index into a large table |
| integer and bit operations; branches expressible as masks | floating-point reductions in a fixed order; data-dependent early exits |
| byte lanes; many operations per byte | two lanes; memory-bound bodies; allocation or a call inside the loop |
The practical rule that falls out: write element-wise integer loops plainly
and let the automatic path tie C. Floating-point element-wise loops are served
by the same path; floating-point reductions are served by neither. Reach for
u8x16 when a byte-processing loop is slow and its dependence on the
previous byte can be rewritten as a lookup on the combination of neighbouring
bytes – UTF-8 validation, JSON structural scanning, base64. Hand-written
f64x2 pays only where the auto-vectorizer cannot see the loop at all.
What the instruments did to me
More of the three weeks went to measuring than to compiling, and the traps are worth more to a reader than the numbers.
- Counting vector instructions in the function with the right name counted
an uncalled wrapper. The copy that runs is the one inlined into
main; follow the calls frommain. - A shell variable holding several clang flags was not word-split by zsh, so
clang received one flag named
-Rpass=regex ...and printed zero remarks – which read as “not vectorized”. 2>&1 >file | headunder zsh’s multios fed the compiler’s output tohead, which closed early and killed the compiler with SIGPIPE; the truncated C looked like a codegen bug for an hour.- A POSIX shell keeps
VAR=x shell_functionassignments after the function returns, so a gate’s “pass off” setting stayed on for every later emission and the gate reported “nothing planned” in green. - Rosetta does not fault on misaligned SSE loads. A
store <16 x i8>with no alignment annotation assumes sixteen; the heap hands out eight-byte-aligned boxes; on real x86 that ismovapsand a crash. It passed on arm64, it passed in an x86-64 Linux container on Apple silicon, and it failed only on the CI runner. The fix is one line at the end of emission – every vector load and store through memory now saysalign 8– and a test that no bare one remains. - CI had been red for three days before any of this, for a reason nobody
looked at: the emitted C used
uintptr_tand on arm64<arm_neon.h>brought<stdint.h>in transitively, while the x86-64 scalar fallback did not. Every gate that compiles C was failing on Linux, and the build matrix was green because it compiles no emitted C. A red CI hides every gate downstream of the first failure; the two Linux-only bugs above were found only after it was green again, and so was a quadratic fixpoint in the versioning pass that a scaling gate had been reporting into the void.
What is left
Explicit SIMD on Wasm and RISC-V is bounded by the memory model, not by the instruction set: without reclamation, a value carried across iterations costs an allocation per iteration. The LLVM backend has no lowering for reading a file as bytes, so the UTF-8 rows do not run under it. And the RISC-V vector subset is e8 with LMUL=1 only, because nothing in the language asks for wider elements yet; a feature without a first user is a generalization pretending, so that stays open until a program needs it.
The one-sentence summary the numbers support: auto-vectorization is free once the bounds checks are hoisted, and it wins wherever it applies; explicit lanes pay only where no vectorizer can build the loop, and lose where one can.