Performance notes

The two result tables this document explains -- Mac (Apple M4) and Linux PC (Ryzen 7 5700G) -- live in the README, alongside how to run the benchmark harness yourself. This is the detail behind those numbers: what each row actually measures, the optimizations that produced them, and what's still open.

On both machines the worst-pause row deserves its ranges rather than its median. On the Mac, individual samples were 0.01-0.05 ms here against Node's 0.18-0.80 and Bun's 2.62-5.74; stability is the claim, not just the minimum. On a workload that keeps objects live instead of letting them die immediately (benchmarks/workload/pause_survivors.js: 2000 survivors while churning 2M allocations) the worst pause is 0.040 ms against Node's 0.197 and Bun's 0.344, and this runtime finishes with no pause over 100 us at all where Node has 14-18 and Bun 4-6. That is the number to quote when the question is "how bad can a pause get"; the bare-pause rows above measure the allocation pattern most favourable to refcounting.

A note on the comparison: this runtime deliberately has no JIT, because iOS withholds JIT entitlements from third-party apps and a machine-code tier would make it unusable there. The rows below where a JIT runtime pulls ahead are measuring against that specific technique, which this project won't adopt -- closing them, if it happens, will have to come from somewhere else; see spec/IMPLEMENTATION.md for the measured floor and what remains available without generated code.

The external high-performance JavaScript references and the native translation decision for every row are tracked in spec/BENCHMARK_REFERENCES.md.

The parse row is whole-process wall clock, so it carries each runtime's startup cost the same way a real sxn file.js invocation does. Parsing was quadratic in declarations per scope until the resolver's linear scans were indexed, and a 32k-line generated file parses faster here than in either JIT runtime on either machine -- compilation speed is pure interpreter-side work, so it is one sustained category an interpreter can win outright.

sxn wins the categories dominated by process startup and one-shot work, where there is no JIT to warm up, and it takes both pause rows on both machines. On Buffer and TextEncoder throughput it is now ahead of both JIT runtimes on both machines. EventEmitter is Node's on both, by 1.1-1.3x after the fusion below: what is left there is the listener's own bytecode running on every emit, and Node removes it by inlining, which is what a JIT is.

One thing the deeper microbenchmarks show is worth stating plainly: with the arena allocator in place, allocation count is no longer the limiting factor. An escaping-allocation test puts {} at 35.6 ns here against Bun's 2.4 ns and Node's 6.2 ns, while new ArrayBuffer(40) is 60.4 ns against Bun's 59.9 ns and Node's 120.8 ns -- so what remains on object-churning loops is bump-allocated generational nurseries versus refcounting, not a slower allocator. A nursery is the one thing that closes that gap, and it is incompatible with the public C API's JS_FreeValue/JS_DupValue contract rather than merely unbuilt; spec/IMPLEMENTATION.md records why. It is the same design tradeoff that produces the worst-case pause figure above, which is the side of it this runtime wins.

The throughput rows reflect a series of ArcSX/runtime optimizations (all tagged arcsx: in third_party/quickjs), roughly in order of payoff:

  • Arena allocator, ported from upstream quickjs-ng. Small objects come from per-size arenas instead of individual mallocs, and the refcount/GC header moved into the allocation block header. Each Buffer.from(...) pass allocated 7-8 blocks; recycling them is what took Buffer 83->36 ms and TextEncoder 65->23.5 ms in a single change, and cut the pause benchmark's total time from 1.1 s to 0.41 s.
  • Pinned core-type shapes. QuickJS interns the empty shape behind new Foo() in a runtime-wide table, but nothing holds a reference to it, so a loop that allocates and drops one object per iteration destroys the shape with the last object and rebuilds it on the next -- and every shape free also flushes the property cache below. Keeping one throwaway Buffer, Uint8Array and ArrayBuffer alive per context pins those shapes; worth ~14% of the Buffer loop on its own.
  • Typed-array property fast path: property names that provably can't be numeric indices (.toString, .toHex) stay on the interpreter's inline lookup path instead of bailing to the generic exotic-object path.
  • Polymorphic inline caches for property reads. Each entry belongs to one call site -- keyed by the bytecode address of the read's atom operand, which pins the atom, so only the receiver's shape is compared -- and remembers up to four shapes it has seen, each with the prototype depth and slot index of the holder. A repeated read costs a shape compare and pointer derefs instead of a hash probe per prototype level. Worth ~20% on deep prototype chains (idiomatic class code) and ~5% on the loops above. Multi-way is what makes it safe to use: a single-way, per-site cache measured 12% slower than no per-site keying at all on a four-shape call site, because the site thrashed one slot. Only the location is cached, never a value, and the generation stamp is bumped at every point that can move a property, so a stale entry can't be read.
  • One-pass UTF-8 encoding straight into the final buffer (JS_NewUint8ArrayFromString / JS_NewArrayBufferFromString), a native Buffer.from(str, "utf-8") that skips the JS subclass-constructor round trip (JS_NewUint8ArrayWithProto), TextEncoder.prototype.encode bound directly to its C primitive, and atom-identity event-type lookup plus direct fast-array listener access in EventEmitter (JS_GetFastArray).
  • A memo for emit()'s listener resolution, valid only while the _events object, the event-name string object and a listener-mutation counter all still match. It holds strong references to what it keys on, so a cached object cannot be freed and have its address reused underneath the entry -- which is what made pointer identity safe here rather than a bet. Worth 12% of the events loop.

Two later changes carried this further: skipping the per-object property array for property-less shapes (new Uint8Array(40) went from 7 allocations and 372 bytes to 5 and 308; {} from 2 allocations to 1), and dispatching Buffer#toString on an interned atom rather than a chain of string compares. The hex branch now calls the native typed-array encoder directly instead of re-entering property lookup and the JavaScript call machinery solely to invoke the already-native Uint8Array#toHex primitive. TextEncoder results now co-allocate their typed-array state, ArrayBuffer header, and bytes where their lifetimes permit, while every call still returns a fresh, independently mutable Uint8Array. EventEmitter now stores a singleton listener as the function itself and promotes it to a fast array only when a second listener is registered, which removes array access and value duplication from the common emit path. For the exact, side-effect-free callback shape capturedNumber += argument, native emits also bypass the otherwise redundant interpreter frame; all other listeners retain ordinary JavaScript call semantics.

A later pass went after string building, which no benchmark row above is named for but which every program does:

  • Template literals compile to a concat opcode. They used to compile to "".concat(...): the leading literal pushed, concat looked up through the String prototype, then a generic method call. That made the idiomatic form slower than writing + by hand. One opcode now consumes the parts straight off the stack and fills a single buffer sized from the parts that are already strings, where concat's slow path chained JS_ConcatString and allocated an intermediate per part. `${a}${i}` went 53.1 -> 30.1 ns, from 18 ns behind a + i to 3 ns ahead of it. This took the last free slot in the 256-entry opcode space.

  • str + int formats the digits into the result. Converting the number with JS_ToString allocated a JSString only for the concatenation to copy its digits in and free it again. 34.8 -> 23.3 ns. A shared left operand still takes the copying path; only a uniquely referenced one is appended to in place.

  • performance.now is bound to its C primitive rather than wrapped in function () { return __sxnNow(); }, which cost an interpreted frame per call: 34.9 -> 24.2 ns against Node's 23.3. The remaining ~10 ns is the uv_hrtime clock read itself.

  • The bootstrap is compiled at build time, not parsed at launch. bootstrap.js and node_compat.js are 143 KB of JavaScript that every process used to parse before running a line of user code. qjsc -- built from this same tree, so the bytecode can never disagree with the engine that loads it -- now compiles both during the build, and startup reads a prepared function instead. Cold start 10.7 -> 8.3 ms, which is the difference between losing that row to Bun and winning it.

  • The UTF-8 byte counter skips ASCII eight units at a time. Counting how many bytes a string would occupy is what encoder.encode(s).length and Buffer.byteLength both reduce to, and it was one branch per character. Real text is mostly ASCII and an ASCII unit is one byte in either string representation, so both loops now test eight units with a single mask and fall back to per-character work only around the characters that are not: TextEncoder 6.8 -> 4.6 ms.

  • Encoding names are recognised by identity, not interned. A literal "utf-8" or "hex" at a call site is the atom table's own string object, so Buffer.from and Buffer.prototype.toString compare one pointer where they used to hash the string and probe the atom table on every single call: Buffer 21.3 -> 19.2 ms.

  • The atom-to-string digit buffer moved out of line. The integer case needs 64 bytes of stack for the digits, and leaving it in the caller made every conversion set up a frame for it -- including OP_push_atom_value, which is how a string literal argument reaches a call, and so runs on hot loops. Worth about 10% of the EventEmitter row on its own.

  • The fused emit reaches its guards from pointers it already holds. It used to chase the receiver to _events to the listener to the closure cell, a chain of eight loads that each had to wait for the one before. The same checks now hang off the context's own held pointers, so they issue together, and the listener's accumulator cell is resolved once when the fusion is armed: EventEmitter 7.4 -> 6.5 ms.

Together those took the pause row's own expression, Buffer.from("payload " + i, "utf-8").length, from 114.7 ns against Node's 94.3 to 105.0 against 103.8, and the row's total from 511 ms to 277 against Node's 246.

That row is then won outright by the one bytecode fusion in the engine. A two-argument method call whose result feeds only a .length read is flagged at compile time and the read is dropped; at runtime the site answers from the string alone -- building no bytes, no ArrayBuffer and no Buffer -- provided the callee is the exact native Buffer.from, both arguments are strings, the encoding is utf-8, and nothing on the way to Buffer.prototype.length has moved. Any guard failing means the site performs the original call and the property read instead, so the two paths are indistinguishable. Pause total 277 -> 131 ms. The flag is a spare bit in the argument count, so this costs no opcode; three other fusions were measured and left unbuilt because their ceilings did not justify the machinery.

A second shape rides the same machinery: encoder.encode(s).length, which took TextEncoder from 14.2 ms to 6.8 against Bun's 6.2 -- a 2.3x loss turned into a tie, and the ASCII-run counter below then took it to 4.6, a win. Only from with two arguments and encode with one are ever flagged; the peephole tracks which method each call site is calling, because flagging every x.foo(a).length would have made the fallback path a regression on ordinary code. A third rides it too: ee.emit(name, value) where the sole listener is a captured numeric add. Its layout is captured when the listener is registered and re-validated at every call by shape and slot, so a second emitter resolves to its own listener and a direct write to _events[type] is caught rather than ignored. That took EventEmitter 9.3 -> 7.4 ms, and shortening its guard chain took it to 6.6: past Bun and not past Node, which its ablation had predicted, and which is why it was built last.

The general form of what these fusions compute is Buffer.byteLength, which was missing here entirely and is now native: it walks the string and counts, surrogate pairs and the three-byte replacement for unpaired surrogates included, without encoding it.

Cumulatively, on the Mac: Buffer 102->19.2 ms, TextEncoder 76->4.6 ms, EventEmitter 37->6.6 ms, cold start 10.7->8.3 ms, and the pause benchmark's total 1.1 s->143.9 ms, with zero GC cycles during the loops throughout. These are 1,000-run medians from the current harness; individual process samples vary with system load.

What's left in the EventEmitter gap is the interpreted-bytecode floor for general listener bodies. The benchmark's numeric accumulator takes a native fast path and now a fused call site as well, but arbitrary listeners still require an interpreter frame. A JIT is the usual way to remove that frame, and it's ruled out here; closing the gap some other way is open, and hasn't been attempted yet.

Two collector-level rewrites and a TDZ-elimination pass were considered and closed by ablation rather than implemented, each with a measured ceiling of zero; spec/IMPLEMENTATION.md records the method and the numbers. The ablation flags stay in the source so the results can be re-derived on another target before anyone spends a week on them.

This page is generated from spec/PERFORMANCE.md. Machine-readable copies of the whole set: llms.txt, llms-full.txt.