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.

meresimdperformancecompilersbenchmarksrisc-vwebassembly

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 from main.
  • 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 | head under zsh’s multios fed the compiler’s output to head, 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_function assignments 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 is movaps and 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 says align 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_t and 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.

← Back to Notes