← Playground bench/RESULTS.md
raw

Benchmark results — NURL vs C vs Rust vs Node vs Python

Generated 2026-10-28T09:51:42Z by bench/bench.sh. Do not edit by hand — the next run overwrites it. The machine-readable form of this same run is results/latest.json, which is what the landing page renders its table from.

Environment

ItemValue
HostGitHub Actions ubuntu-latest runner
KernelLinux 6.17.0-1022-azure x86_64
CPUAMD EPYC 7763 64-Core Processor (4 logical cores)
Memory16373452 KiB
Commit2d95263bea9054b6b1f4812165693b11ceea1674
CI runhttps://github.com/nurl-lang/nurl/actions/runs/40666709226
NURLv0.79.1-35-g2d95263b
CUbuntu clang version 18.1.3 (1ubuntu1)
Rustrustc 1.99.1 (58cf8f9c3 2026-10-13)
Nodev22.23.3
PythonPython 3.12.3
SettingValue
NURL flagsnurlc → LLVM IR; clang -O2 -flto=thin -c; link clang -O2 -flto=thin -Wl,-plugin-opt,O3 (ThinLTO backend at O3 — the standard nurl.sh release pipeline)
C flagsclang -O2 -flto=thin -c; link clang -O2 -flto=thin -Wl,-plugin-opt,O3 — the identical pipeline, so neither column gets a backend the other lacks
Rust flagsrustc -C opt-level=3 — rustc has no prelink/backend split; opt-level 3 is the cargo build --release default
Node / Pythonnode / python3, no flags
Timed runs per cellup to 5, adaptive: as many as fit in 8000 ms
Timed compiles per cell3 (median)
Per-run timeout300 s
Workload scale×1 — the published contract (--scale N / BENCH_SCALE=N multiplies it)

1. Run time (median wall clock, ms — lower is better)

Whole-process wall clock, start-up included. Every implementation of a row prints the same line (section 3), so a row's cells are timings of the same computation. Bold is the fastest cell in the row; n/a is a language the row is not implemented in (bench/manifest.tsv).

BenchmarkNURLCRustNodePython
_(floor: empty program)__1.474__1.469__1.699__24.116__17.218_
lcg39.16239.14339.2791894.2345056.844
packet_classifier56.44256.29756.492162.3174546.946
ring_write42.26242.18742.40565.5616508.444
histogram_bins39.46440.60040.87267.5196051.479
prefix_scan21.74721.66921.94966.4364566.479
binary_search39.76838.30640.936106.6976378.417
sort_window26.63326.53726.814198.36111372.552
bloom_filter16.65217.92118.4662842.8997412.206
hash_join26.84927.87529.2573471.1288430.027
sieve18.77518.12518.13365.9593333.093
fib25.06629.76529.829130.6521360.716
collatz12.41012.20912.58851.173738.956
matmul33.53533.42233.65776.1513285.046
json_parse8.3988.56211.82034.06838.454
nbody25.38639.66025.190102.5603092.444
chacha207.35642.44233.762n/an/a
poly130511.64331.73121.160n/an/a
blake2b43.66164.94169.059n/an/a
sha51247.29950.59649.534n/an/a
x2551931.38648.83249.266n/an/a

2. Compile time (median, ms)

NURL's compile is two stages: nurlc emits LLVM IR, then clang lowers and links it against stdlib/runtime.o. NURL total is the number comparable to the C and Rust columns: a cold compile, measured against a wiped cache exactly as C and Rust pay their full cost every time. NURL rebuild is the same compile again with the ThinLTO cache warm — nurl.sh's default on Linux (docs/BUILDING.md → The ThinLTO cache) — which is what every build after the first costs; C and Rust have no default equivalent (ccache/sccache are opt-in add-ons). The floor row is what each toolchain costs for a program that does nothing — for NURL that is dominated by the LTO link every NURL binary pays for, so subtract it to read the marginal cost of the benchmark itself. Node and Python have no column here: they compile at run time, inside their own cells above.

BenchmarkNURL nurlcNURL clangNURL totalNURL rebuildC clangRust rustc
_(floor: empty program)__4.558__113.310__117.868__61.830__77.581__53.852_
lcg4.754127.142131.89662.07489.04756.494
packet_classifier4.701127.149131.85062.67492.10958.272
ring_write15.476129.077144.55371.46090.99859.351
histogram_bins15.127130.301145.42871.690107.45968.529
prefix_scan15.239129.719144.95872.41597.58864.017
binary_search15.544129.120144.66471.86692.47965.459
sort_window15.881130.893146.77471.760102.06569.091
bloom_filter16.264134.670150.93473.520104.35365.832
hash_join19.750263.146282.89675.794218.648109.026
sieve15.720130.821146.54172.702103.93470.218
fib4.713125.662130.37561.95489.49458.503
collatz5.018131.857136.87564.40093.13760.008
matmul17.041129.265146.30673.860104.43677.680
json_parse49.908488.875538.783106.918159.136154.562
nbody18.204134.877153.08173.905126.40793.064
chacha2058.832782.158840.990117.349142.271105.633
poly130544.766511.510556.276102.640142.824118.506
blake2b39.148376.070415.21894.919145.603113.961
sha51239.480472.503511.98395.839137.434100.024
x2551952.6561193.8181246.474108.8111217.422491.259

3. Correctness gate

Each row is timed only when all of its implementations print the same line. A speed number for a program computing something else is worthless, so a mismatch drops the row out of the tables above rather than being reported as a fast cell.

BenchmarkOutputVerdict
lcg-7585129161289236796identical across 5 languages
packet_classifier4205972061identical across 5 languages
ring_write8299504528805184357identical across 5 languages
histogram_bins1215643728identical across 5 languages
prefix_scan492982549identical across 5 languages
binary_search805907445identical across 5 languages
sort_window2815490238identical across 5 languages
bloom_filter2351703identical across 5 languages
hash_join6152419568754618368identical across 5 languages
sieve664579identical across 5 languages
fib9227465identical across 5 languages
collatz350identical across 5 languages
matmul393199identical across 5 languages
json_parse20identical across 5 languages
nbody4595260366167553674identical across 5 languages
chacha203720502699256473595identical across 3 languages
poly13052498856793803813402identical across 3 languages
blake2b8590291023788228918identical across 3 languages
sha5127091519178481951668identical across 3 languages
x255196127485567278337128identical across 3 languages

4. Reading the numbers

and page faults rather than the benchmark. The rows worth comparing are the ones in the tens of milliseconds and up.

be clever: LLVM will fold an affine recurrence or unroll a loop by a different factor in each language. A cell measures optimised throughput of the same algorithm, not the source-level iteration count.

Python has arbitrary-precision integers and masks; JS has no 64-bit integer at all, so those rows use BigInt where the algorithm genuinely needs 64 bits and Numbers with Math.imul where 32 bits suffice. Each file says which and why. That gap is part of what this table reports.

over IEEE-754 doubles rather than integers. That is the type JavaScript does have — its one numeric type is the double — so Node runs the same arithmetic as the compiled backends with no representation tax, and lands near 2x C instead of the 30-50x the BigInt rows cost it. It is also the only row whose critical path runs through the FPU's long-latency sqrt and divide units rather than the integer ALU. All five ports use the same operation order and the same struct-of-arrays layout, so the checksum — the final energy's bit pattern — is exact across all five.

document" rather than a structural checksum: each language uses the parser in its own box (Python json, Node JSON.parse, NURL stdlib/ext/json.nu), and C and Rust — whose boxes are empty — carry a small hand-written recursive-descent parser in the benchmark file.

Rust only. Their NURL file is a driver around the standard library's own implementation (stdlib/std/chacha20poly1305.nu, hash_blake2b.nu, hash_sha512.nu, x25519.nu) — the row measures the stdlib a NURL program actually gets. C and Rust, whose standard libraries have none of the five, carry the same formulation written out by hand: Poly1305 at radix 2^64 and the donna-c64 X25519 field at radix 2^51, both with native 128-bit products. ChaCha20 is the one deliberate difference: C and Rust run the scalar RFC rounds, while the stdlib runs it on v128 lanes (v256 lanes in its x86-64-v3 clone). Each source names its RFC/FIPS test vector; x25519 at x1 reproduces RFC 7748's 1000-iteration value.

between runs, and more on a shared CI runner. Compare deltas between runs of the same workflow, not absolutes across machines.