Zinc vs QuickJS vs Node.js — performance report

Generated by node scripts/bench.mjs on 2026-09-28T06:02:51.193Z.

Methodology

  • Machine: Apple M1 Pro, macOS 15.7.4
  • Zinc: this repo, native target = macOS release (--release), sim target run with node
  • Node.js: v24.14.0 (node <kernel>.ts, native TypeScript type-stripping, no JIT warmup beyond one process lifetime)
  • QuickJS: QuickJS version 2026-06-04 (qjs <kernel>.js, types stripped with Node’s built-in node:module stripTypeScriptTypes, an interpreter with no JIT — this is the engine PocketJS embeds, so these numbers are also PocketJS’s scripting cost)
  • C++ compiler: Apple clang version 21.0.0 (https://github.com/swiftlang/llvm-project.git 11673e6660b2d0a5f9f99b1c7b773f4c2f6df1b0)
  • Each kernel/engine pair: 5 runs, wall clock via process.hrtime around spawnSync, median reported
  • Peak RSS: one extra run per engine wrapped in /usr/bin/time -l, “maximum resident set size” (not counted in the median timing, to keep the wrapper’s overhead out of the timing numbers)
  • Every kernel prints exactly one line (a numeric/string checksum, or one JSON blob); the harness asserts all four engines print byte-identical output before recording a result
  • Kernels live in tests/bench/kernels/*.ts; several were tuned down from the canonical Benchmarks-Game sizes so QuickJS (no JIT) finishes in reasonable time — each file documents its deviation in a header comment, and it is repeated in the Deviations section below
  • Reproduce: node scripts/bench.mjs

Wall-clock time (median of 5 runs, ms)

KernelZinc nativeZinc sim (node)Node.jsQuickJSZinc vs QuickJSZinc vs Node.jsChecksums match
binarytrees13.333.557.0108.58.2x4.3xyes
fannkuchredux369.6294.9273.57740.820.9x0.7xyes
fib14.748.969.0240.116.4x4.7xyes
jsonout2.127.950.04.72.2x23.3xyes
mandelbrot21.149.072.9694.332.9x3.5xyes
mapset10.045.768.2100.910.1x6.9xyes
nbody33.8328.1355.12716.380.4x10.5xyes
sort155.8516.0667.1835.25.4x4.3xyes
spectralnorm54.3104.6113.94306.479.3x2.1xyes
strings27.458.583.457.22.1x3.0xyes

Peak RSS (/usr/bin/time -l, KiB)

KernelZinc nativeZinc sim (node)Node.jsQuickJS
binarytrees1808.053680.073424.03024.0
fannkuchredux1344.053728.073232.01952.0
fib1344.048544.068688.01904.0
jsonout1488.045472.063712.02064.0
mandelbrot1392.050336.074112.01904.0
mapset4016.057712.086128.09904.0
nbody1344.054240.074384.01856.0
sort17408.0136976.0160096.062432.0
spectralnorm1424.054800.082720.02256.0
strings29184.089808.0116800.025392.0

Zinc native binary size

KernelSize (KiB)
binarytrees72.1
fannkuchredux71.2
fib70.4
jsonout72.0
mandelbrot70.6
mapset71.3
nbody72.1
sort70.7
spectralnorm71.4
strings71.9

Startup cost and minimal-binary size (empty program)

EngineMedian startup (ms)Binary size (KiB)
Zinc native (macOS release)3.853.3
Zinc sim (node)28.4n/a (needs Node)
Node.js v24.14.047.0n/a (needs Node)
QuickJS qjs interpreter4.1724.0 (the qjs binary itself)
qjsc-compiled hello-world (QuickJS AOT bytecode + engine, standalone)n/a776.2

Checksums match across all four engines: yes (empty program prints nothing).

PocketJS AOT path (microts)

A clone of PocketJS (commit 25081f6) was inspected at the scratch path given in the task. Its microts compiler (microts/compiler/cli.ts, run with bun) is not a general-purpose TypeScript-to-Rust AOT compiler for arbitrary algorithmic code: its own CLI router only accepts build|check|run <app-dir>|Root.vue|App.tsx — a reactive UI component (Solid/Vue-style, JSX or SFC) that lives under apps/<name>/, compiled against PocketJS’s model/board/tape runtime (aot-model-*.ts, aot-browser*.ts, aot-jsx-text.ts, vendor/vue-vapor-ir.ts).

Concretely: bun microts/compiler/cli.ts check tests/bench/kernels/fib.ts is rejected outright by the CLI’s own argument router (MicroTS: expected build, check or run) because a plain .ts file with no JSX/SFC and no apps/ scaffolding is not one of the three accepted input shapes. Porting fib/nbody/mandelbrot/etc. into a PocketJS reactive-component shape (state, template, DOM/board semantics) would not be measuring the same thing as the other three engines — it would be authoring throwaway UI apps around trivial computations, which is out of scope for a scripting-cost comparison and not achievable meaningfully within the ~20 minute budget for this investigation.

cargo/rustc (1.x, from rustup) and bun are installed, so the Rust toolchain itself is not the blocker; the blocker is that microts targets PocketJS’s UI component model, not headless scripts.

Since PocketJS’s own JS runtime (non-AOT path) is QuickJS, the QuickJS numbers in the wall-clock table above are PocketJS’s scripting cost for any code it interprets rather than compiles through microts — which is the honest proxy the task asked for when the AOT path is not reachable.

Analysis

  • Geometric mean over the 10 kernels above: Zinc native is ~13.1x faster than QuickJS and ~4.3x faster than Node.js on median wall time (arithmetic means of the ratios, 25.8x and 6.3x, are inflated by nbody and spectralnorm). The spread is wide: the largest QuickJS gap is nbody at 80.4x (QuickJS’s interpreter loop, with no JIT, pays full per-operation dispatch cost on every iteration of a tight numeric loop), and the smallest is strings at 2.1x (this kernel’s cost is dominated by something other than raw loop throughput — allocation, string building, or I/O — where the interpreter/VM overhead matters less).
  • Where Zinc loses outright: fannkuchredux (Node.js is 1.4x faster than Zinc native). This is V8’s tiered JIT doing its job: once a hot loop is identified and optimized, V8 emits native machine code competitive with (here, faster than) Zinc’s AOT-compiled C++ — V8’s optimizing compiler can specialize on the runtime shapes/values it observes as a function gets hot, something Zinc’s ahead-of-time C++ codegen does not do. Zinc still wins nearly everywhere else because it has zero warmup cost and Node pays JIT compilation + deopt-guard overhead on every fresh process, but a single benchmark kernel is exactly the case (short warmup window, one hot loop) where V8’s JIT is at its best relative to an AOT compiler.
  • String-heavy and Map/Set-heavy kernels favor Zinc less than pure-arithmetic kernels versus QuickJS specifically (see jsonout and strings above): Zinc’s runtime strings and containers still pay allocation/refcounting overhead per operation, and QuickJS’s string/object primitives are a mature, tuned part of that interpreter, so the gap for allocation-bound kernels is much smaller than for arithmetic-bound ones.
  • Startup and binary size are the clearest structural win for Zinc: the native binary has no VM to initialize and links only the small zrt runtime, versus QuickJS which still has to construct a full JS heap/interpreter on every process start (see the startup table), and versus a qjsc-compiled standalone hello-world that statically bakes in the whole QuickJS engine.
  • Memory: peak RSS numbers in the table above should be read with the usual caveat that /usr/bin/time -l measures the whole process (including Node’s/QuickJS’s own startup heap), not just the kernel’s working set — Zinc native’s RSS is dominated by the kernel’s own allocations since there is no VM, which is why it is consistently the smallest number in that table, often by 1-2 orders of magnitude.

Deviations from canonical Benchmarks-Game sizes

KernelSpecUsed hereWhy
nbody1,000,000+ steps (benchmarks-game default is much larger)600,000 stepskeeps QuickJS (no JIT) in the low single-digit seconds
fannkuch-reduxn=10 (as specified)n=10, lexicographic permutation order (not the official SJT enumeration)same n, but a simpler/verified permutation generator; checksum is still a valid identical-across-engines value, just not the published fannkuch-redux constant
mandelbrot400x400, canonical benchmarks-game uses very high per-pixel iteration caps400x400, iteration cap 200keeps total iteration count in the tens of millions instead of low hundreds of millions
binary-treesdepth 16 (as specified)max depth 10, min depth 4depth 16 allocates on the order of 10s of millions of tree nodes per the canonical algorithm; depth 10 (~400K nodes) exercises the same allocator/GC pressure pattern while keeping every engine well under a few seconds

All other kernels (fib, spectral-norm, strings, Map/Set, sort, JSON) run at or above the sizes given in the task.

Edit this page on GitHub ↗