· 6 min read

Six loops finish level

A nine-profile WebAssembly rerun finds six prime loops clustering within about two percent in V8, while compiler rewrites, ABIs, and runtimes keep the rest of the table apart.

Allyson Felix crosses the finish line ahead of four competitors in the women's 200 metres at the 2007 World Championships.
Eckhard Pecher, CC BY 2.5

The first version of this benchmark collapsed on inspection. I had given C an integer division in its inner prime loop while the other languages used multiplication, sent every module through one common optimizer, kept the fastest of twenty runs, and extrapolated Grain's longest result. Matching answers had let me compare different work. That is an annoyingly small way to invalidate an entire table.

I rebuilt it with C, Rust, Zig, AssemblyScript, MoonBit, standard Go, TinyGo, and two Grain numeric profiles. In this V8 run, six corrected prime loops cluster within about two percent. Fibonacci, module size, and one drifting result make a less convenient point: source-level parity does not erase what each compiler and runtime emits.

Fix the source before timing it#

Every profile now gets recursive fib(30), trial division below 100,000, and a 128-by-128 Mandelbrot checksum capped at 50 iterations. The expected returns are 832,040, 9,592, and 201,544. This is the C change that forced me to discard the old measurements:

c.cdiff
 for (int32_t candidate = 2; candidate < limit; candidate += 1) {   int32_t is_prime = 1;-  for (int32_t divisor = 2; divisor <= candidate / divisor; divisor += 1) {+  for (int32_t divisor = 2; divisor * divisor <= candidate; divisor += 1) {     if (candidate % divisor == 0) { is_prime = 0; break; }   }   count += is_prime; }

Both bounds count the same primes for these positive inputs, but the removed version performs integer division on every pass. Of course it had an impact. The corrected condition appears in all nine sources, and the complete source, build commands, hashes, and raw samples are published with the article. No shared wasm-opt pass follows the compilers.

The prime cluster#

C, Rust, Zig, AssemblyScript, and MoonBit expose primitive values directly. TinyGo and standard Go are WASI reactors. Grain carries its runtime and a tagged Number export ABI. The build column keeps those three cohorts visible before the timings invite a ranking.

On a Ryzen 7 7800X3D, I ran each module and workload in a fresh Node 24.12.0 process. Each process stayed pinned to logical CPU 15. The runner calibrates batches to at least 500 milliseconds, restarts its five warm-ups if a batch drops below 400 milliseconds, then records fifteen batch averages. The desktop was busy and its governor used powersave, so I use these numbers only for broad gaps and clusters.

ProfileBuild / ABIgzipfib(30)primesMandelbrot
Cclang 22; direct385 B3.03 ms3.64 ms0.426 ms
Rustrustc 1.96 nightly; direct670 B1.40 ms3.65 ms0.424 ms
ZigZig 0.15; direct398 B2.41 ms3.64 ms0.428 ms
AssemblyScript0.28; direct, stub runtime304 B2.60 ms3.68 ms0.366 ms
MoonBitMoon 0.1; direct475 B2.78 ms3.60 ms0.360 ms
TinyGo0.41; c-shared, no scheduler, opt=26.3 KiB2.40 ms3.68 ms0.419 ms
Go1.26; stripped c-shared532 KiB11.04 ms4.56 ms0.409 ms
Grain Number0.7.2; runtime, tagged ABI8.9 KiB215 ms480 ms492 ms
Grain Int32/Float640.7.2; runtime, tagged ABI4.0 KiB151 msdrifting†275 ms
Median time per exported call in V8, including host-call and checksum overhead. Lower is faster. †Two fresh typed-Grain processes both jumped after batch four, with later batches taking 5.03–15.86 seconds per call, so no stable median is shown. Sizes use gzip -9 -n.

The prime column supplies the clean result, and it is easier to feel as positions than as nine table cells: six direct builds inside one thin bracket, Go alone to their right. I have not run another engine or inspected V8's generated machine code, so neither the table nor the axis can assign that convergence to its tiered compiler.

six direct builds3.60 ms–3.68 msGo (WASI reactor): 4.56 msGo (WASI reactor)4.56 ms
Median primes time per exported call across build profiles in this V8 run
Go (WASI reactor)4.56 ms
six direct builds (range)3.60 ms to 3.68 ms
The primes column as positions on one axis. Both Grain profiles sit far past its right edge, at 480 ms and drifting.

Mandelbrot spreads farther. MoonBit leads there, and the fastest-to-slowest span across the same six modules is about 19 percent. The table leaves the cause open.

What the compilers emitted#

Clang and rustc rewrite Fibonacci before V8 runs it. Their release Wasm keeps one recursive call inside a loop and changes the source-level call count. This excerpt comes from clang. Rustc emits the same structure.

clang fib, abridged disassemblywat
loop $L1  local.get $p0  local.tee $l2  i32.const -2  i32.add  local.set $p0  local.get $l2  i32.const -1  i32.add  call $fib  ;; accumulator update and loop test omitted  br_if $L1end

The compiler has already changed the workload. Rust's 1.40 milliseconds and C's 3.03 therefore mix those transformations with function-call conventions.

Standard Go carries a full WASI reactor. Its prime and Mandelbrot times stay within 1.3 times the direct modules, while Fibonacci reaches 11.04 milliseconds. The compressed size remains in a separate cohort even after the Go linker strips symbols and DWARF.

Grain changes the arithmetic too. Its general Number supports more values than a Wasm primitive, while Grain 0.7.2's Int32 operators and Float64 operators allocate wrappers for arithmetic results. I expected the explicit types to repair the slowdown. Instead, the prime samples split into two different speed bands.

I have not profiled Grain's collector, so I cannot assign that drift to allocation. The slowdown did repeat. A second fresh process made the same jump after its fourth measured batch, and its fifteenth call took 7.83 seconds. The table still says drifting.