· 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.

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:
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.
| Profile | Build / ABI | gzip | fib(30) | primes | Mandelbrot |
|---|---|---|---|---|---|
| C | clang 22; direct | 385 B | 3.03 ms | 3.64 ms | 0.426 ms |
| Rust | rustc 1.96 nightly; direct | 670 B | 1.40 ms | 3.65 ms | 0.424 ms |
| Zig | Zig 0.15; direct | 398 B | 2.41 ms | 3.64 ms | 0.428 ms |
| AssemblyScript | 0.28; direct, stub runtime | 304 B | 2.60 ms | 3.68 ms | 0.366 ms |
| MoonBit | Moon 0.1; direct | 475 B | 2.78 ms | 3.60 ms | 0.360 ms |
| TinyGo | 0.41; c-shared, no scheduler, opt=2 | 6.3 KiB | 2.40 ms | 3.68 ms | 0.419 ms |
| Go | 1.26; stripped c-shared | 532 KiB | 11.04 ms | 4.56 ms | 0.409 ms |
| Grain Number | 0.7.2; runtime, tagged ABI | 8.9 KiB | 215 ms | 480 ms | 492 ms |
| Grain Int32/Float64 | 0.7.2; runtime, tagged ABI | 4.0 KiB | 151 ms | drifting† | 275 ms |
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.
| Go (WASI reactor) | 4.56 ms |
|---|---|
| six direct builds (range) | 3.60 ms to 3.68 ms |
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.
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 $L1endThe 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.