- Standalone CMake project and ArcSX QuickJS fork (originally a direct
snapshot of Rayact's customized fork; see
third_party/QUICKJS-PROVENANCE.md). -
.sx,.js,.mjs, and.cjsCLI entrypoints. - Native QuickJS parsing of
.sxsyntax -- interfaces, type annotations,let mut,safe,unsafe, and&/&mutborrow sigils -- with no separate transform step. The earlier in-memory text transformer (sxfe_compile, src/frontend.c) remains as an independently unit-tested component but is no longer on the execution path. - Fixed-layout calculation and aligned growable/poisonable arena primitives.
- Module-loader hook that transforms imported
.sxmodules in memory. - Package command surface with safe argument validation, disabled lifecycle scripts, and a bootstrap npm-compatible backend.
- LSP JSON-RPC transport and VS Code language registration.
- Contextual
safe let/safe constcompatibility parsing and CLI--memory-report/--leak-checkdiagnostics using QuickJS accounting. -
Sxn.ffiis implemented on libffi anddlopen: scalars, pointers and NUL-terminated strings, with structs by value, callbacks and variadics rejected rather than half-supported..nodeaddons load through a Node-API implementation on QuickJS. Which of the two lives in the runtime and which in the node: layer, and why, isspec/NATIVE.md. Native parsing ofunsafe externstill rejects the declaration with a "not yet supported" error rather than mis-parsing it; the standalone compatibility transformer lowers it to theSxn.fficall that now works. - Native SX execution is unconditional;
SXN_NATIVE_SXis no longer read and has no effect. -
.sxbcprecompiled bytecode:sxn compile,sxn --compile-cache, and running a.sxbcfile directly all work, for both module and CommonJS entries.spec/BYTECODE.mdhas the format, the measured gains, and the trust boundary (bytecode is not a safe format for untrusted input). - The vendored QuickJS-ng tooling (
qjs,qjsc) reports itself as ArcSX in every user-visible banner and in the commentqjscwrites atop a generated header;third_party/QUICKJS-PROVENANCE.mdhas the lineage this is built on.
- sxn wins seven of the eight README benchmark categories on both measured
machines: startup, cold start, Buffer and TextEncoder throughput, both
pause-consistency rows, and whole-process parse time. EventEmitter is
Node's by 1.1-1.3x, and that gap is architectural rather than incidental
-- see the README for the measurement and
spec/NATIVE.md's note on why a JIT tier isn't coming.
Recorded so this is not re-derived. All figures are the minimum of 4+ runs on macOS arm64, against Bun 1.2.17; sxn is the Release build.
| Operation | sxn | Bun |
|---|---|---|
| Empty loop iteration | 16.3 ns | 0.4 ns |
Math.max(1,2) (bare native call) |
30.7 ns | 0.4 ns |
Object.is(1,1) |
34.0 ns | 0.4 ns |
TextEncoder.encode (36B ASCII) |
96.4 ns | 22.0 ns |
The decisive line is the second: a JIT inlines Math.max(1,2) to a constant,
so Bun's cost for a builtin call rounds to zero, while an interpreter must
dispatch the opcode and push a C frame. That puts a hard floor under every
per-call benchmark:
- sxn's floor for any native call in a loop is ~30 ns (16 ns dispatch +
14 ns call). Bun's entire
TextEncoder.encodeis 22 ns. So even with an encoder that took zero time, sxn could at best tie Bun on the 200k-call TextEncoder benchmark. It is not reachable by optimizing the encoder. - The same floor explains EventEmitter: roughly half that benchmark is invoking the listener's own bytecode.
Three things were ruled out by measurement along the way, and should not be retried without new evidence:
-
Allocation count is not the limiting factor.
encodeInto, which allocates nothing, measures slower (130.6 ns) thanencode, which allocates a fresh array (101.3 ns). Reducingnew Uint8Array(40)from 7 allocations to 5 moved the TextEncoder benchmark by ~1 ms. -
The object model is not the gap either.
new Plain()(58.7 ns) and a bare{}(58.3 ns) cost the same, so constructor and prototype machinery is not what is being paid for; eliding.prototyperesolution would gain approximately nothing, and memoizing its slot measured ~2%. - Recursion depth was a real compatibility bug, not a performance one. QuickJS budgets 1MB of JS stack regardless of the thread's actual limit, which capped recursion at 948 frames against Node's 8874. Sizing the budget from RLIMIT_STACK with a 2MB reserve raised it to 5682. The reserve is not optional: native builtins descend several C frames between the interpreter's overflow checks, and overshooting the real stack is a crash instead of a catchable RangeError.
-
ta.lengthis not slow. It measures ~21 ns in a clean loop, of which ~11 ns is the loop itself. An earlier figure of 57 ns came from a harness that ran several benchmarks back to back and was reading accumulated memory pressure, not the operation. An inline fast path for the built-in length getter was written, verified and reverted: it changed nothing on any real benchmark. Beware benchmarks that share a process with earlier ones -- measure each in isolation and take a minimum.
One avenue is deliberately not taken, and the reason is a compatibility
boundary rather than performance. A plain JS loop body compiles to 11 opcodes
per iteration (~11 ns), six of them TDZ-checked local accesses; the fork's
fused OP_add_loc_safe_i32 would collapse the accumulator, and the compiler
gates it on safe locals. That gate is load-bearing: the opcode computes
(int32_t)((uint32_t)left + (uint32_t)right), i.e. it wraps on overflow,
which is the defined semantics for SX safe x: i32 but wrong for standard
JavaScript, where x += 1 at 2^31-1 must promote to a double. Extending i32
inference to plain-JS accumulators would silently corrupt arithmetic.
tests/fixtures/js_overflow.mjs guards this boundary. The loop floor is in
any case ~11 ns against per-iteration benchmark costs of 90-100 ns, so
halving it would return roughly 5%.
A pattern worth naming, because it cost several attempts: at this level the
sampling profiler's self-time attribution is not a reliable guide. Four
changes that profiled as 5-13% of a loop measured neutral once A/B'd
interleaved on a settled machine -- caching accessors in the property IC, an
inline fast path for the built-in typed-array length getter, memoizing the
prototype slot (~2%, kept anyway as it is small and correct), and
memoizing find_hashed_shape_proto's (prototype -> empty shape) lookup. What
did pay was always something the profile named as real work rather than
overhead: the mixed int/float arithmetic falling through to js_add_slow, and
the missing let/const compound-assignment fusion. Profile to find candidates,
but only an interleaved minimum-of-N A/B decides.
Two further attempts were reverted for measuring slower: caching accessor
properties in the property inline cache (typed-array .length 35.3 -> 49.7
ns, because the extra branch on every cache hit costs more than the walk it
saves), and a single-way per-call-site inline cache (12% slower than shape
keying on a four-shape call site).
iOS does not grant W^X/JIT entitlements to third-party apps, so a machine-code tier would make this runtime unusable on a target platform. That rules out the one technique -- generating machine code -- that a JIT uses to close this gap. It does not rule out closing it some other way; the dispatch floor above is the floor of what's been tried, not a proof that nothing faster exists. Benchmarks against JIT runtimes should be read with that in mind: on JIT-bound microbenchmarks the comparison is against a technique this project won't use, not necessarily a result it can't reach.
The second-opinion review (see below) rated two collector-level designs as the strongest remaining options: replacing the per-object GC list with arena-block iteration (est. 8-20 ns of the 76 ns object lifecycle) and Bacon-Rajan candidate buffering so a 1->0 death never touches the list (est. 10-25 ns). Both estimates rest on the same premise: that add_gc_object/remove_gc_object's doubly-linked list maintenance is a material per-object cost.
An ablation tested the premise directly. Building with
-DSXN_ABLATE_GC_LIST makes add_gc_object self-loop the link instead of
inserting into gc_obj_list, eliminating the list cost entirely; on
workloads verified GC-free (gcCount 0, so the list is never read), the
binary is behaviorally identical and the A/B isolates pure linkage cost.
Result, interleaved minimums: {} lifecycle 37.0 -> 37.7 ns, {a:1} 63.0
-> 62.7, empty array 45.8 -> 45.7, all three throughput benchmarks within
noise. The ceiling for both rewrites is ~0-1 ns on this allocator: the
insert/remove touches adjacent hot cache lines and is effectively free.
Both designs are therefore closed with a negative result rather than deferred: multi-week collector rewrites cannot pay when an exact upper bound on their benefit measures zero. The remaining lifecycle cost sits in JS_NewObjectFromShape's field initialization, shape refcounting and the allocator fast path itself, per the profile -- diffuse, not concentrated behind any single removable structure.
The interpreter re-tests the temporal dead zone on every read of a let or
const: the emit benchmark's inner loop alone runs four _check opcodes per
iteration on bindings that were initialized long before. Eliminating those
statically needs a dataflow pass -- a fixed point over the CFG, intersecting
initialized-sets at join points -- which is correctness-critical in the worst
way, because a bug does not crash. It silently stops throwing ReferenceError
and the engine quietly accepts programs the spec rejects.
Ablation settles whether that risk is worth taking. Building with -DSXN_ABLATE_TDZ=1 skips the JS_IsUninitialized test in OP_get_loc_check and OP_get_var_ref_check while leaving the opcode, its operand and dispatch untouched, so the A/B isolates exactly the branch a perfect elimination pass would remove -- an exact upper bound, and behaviourally identical on code that never trips TDZ.
The bound is nothing. Interleaved minimums: buffer 20.9 -> 20.8 ms, textencoder 13.7 -> 13.6, events 8.9 -> 8.7, and on the real workloads text.js 16.9 -> 17.1, config.js 207.7 -> 209.0, collections.js 41.6 -> 41.7, i.e. inside noise and signed the wrong way as often as not. A synthetic loop doing eight lexical reads per iteration does show 0.17 ns per check, which is what made the lever look worth pulling; real code does not read the same binding eight times per iteration, and the branch is perfectly predicted not-taken, so the test disappears into the load it accompanies.
Closed as a negative result. The ablation flag stays in the source so the measurement can be re-derived on another target before anyone spends a week on the pass.
What remains available is everything that needs no generated code:
- Direct dispatch of C-function calls (done): calling js_call_c_function straight from OP_call/OP_call_method rather than through JS_CallInternal's prologue cut native-call overhead 14.5 -> 8.4 ns.
-
Quickening / type specialization in the CPython PEP 659 sense --
rewriting opcodes in place once their operand types are observed. Pure
interpreter bookkeeping. Note the hazard recorded below: the fork's
existing fused i32 opcodes wrap on overflow and must stay gated on
safe. -
Tail-call dispatch of the interpreter loop (musttail + preserve_none)-- measured and rejected. Apple clang 21 on arm64 supports both[[clang::musttail]]andpreserve_none, and a spike modelling both dispatch styles over the same opcode mix (benchmarks/engine/dispatch_bench.c) puts musttail 47-70% slower than the computed-goto threading quickjs already uses, repeatably at -O2 and -O3. CPython's reported 10-15% was measured against a baseline that was not using computed goto. This would have been a multi-week restructuring of the interpreter into per-opcode functions for a large regression. - A generational nursery, which is not a JIT and would address the teardown cost -- but see the tradeoff below.
The remaining item is a generational nursery for object churn
(freeing a small object costs ~29 ns here against Bun's ~1 ns, which is
refcounting versus a nursery that reclaims dead young objects for free).
On the nursery's cost, an earlier claim in this ledger was overstated and is
corrected here. The 0.05 ms vs 2.62 ms worst-pause figure comes from
benchmarks/wintercg/pause.sx, where every allocation dies immediately --
the pattern that most flatters refcounting and most penalises a collector,
which must still scavenge. Re-measured on benchmarks/workload/ pause_survivors.js, which keeps 2000 objects live while churning 2M, the
gap nearly closes:
| worst pause | sxn | Node | Bun |
|---|---|---|---|
| allocations die immediately | 0.04 ms | -- | 2.53 ms |
| 2000 live survivors | 0.099 ms | 0.203 ms | 0.241 ms |
Two to three times better on the realistic pattern, not fifty. Note also that this runtime records far more small gaps (348 over 10 us against Bun's 51) -- refcounting spreads its cost rather than avoiding it.
So the pause argument against a nursery is much weaker than this ledger first claimed. The argument that remains is scope, and it is a different one: a nursery is not a bounded change for this engine.
An empty {} costs 76 ns to create and destroy here against Node's 3.8 ns,
and it has no properties, so refcount cascades are not the cost. What is
left is the per-object lifecycle itself -- allocation, linking into the GC
object list, taking and releasing a shape reference, arena free -- spread
across the design rather than sitting in one hotspot. Reaching a few
nanoseconds means not doing that work per object, which means bump
allocation and reclaiming young objects without individual frees.
That is incompatible with how QuickJS works in two ways that matter. A moving nursery cannot be added while raw JSValues live on the C stack and in embedder variables; there is no handle layer to update. A non-moving young generation still has to determine liveness without refcounts, and refcounting is not an implementation detail here -- JS_FreeValue is in the public C API, so every embedder depends on it.
The accurate framing is therefore not "a multi-week project we have not scheduled" but "a different engine". Recorded so the option is neither dismissed for the wrong reason (pause latency, which was overstated) nor adopted under the wrong assumption (that it is incremental work).
The benchmark suite measures seven workloads and missed four genuine defects that ordinary code hits hard. All four were found instead by probing the shape of cost curves -- per-operation cost measured at two collection sizes, flagging anything whose ratio suggests super-linear behaviour -- and by comparing per-op cost against Node, flagging anything far past the ~4x baseline interpreter gap. The probes are checked in:
-
benchmarks/engine/complexity_probe.js-- ratio of per-op cost at two sizes -
benchmarks/engine/op_probe.js-- absolute per-op cost, wide operation mix -
benchmarks/engine/string_probe.js-- string and regexp operations -
benchmarks/engine/dispatch_bench.c-- interpreter dispatch styles
What they found, all since fixed: Map/Set lookup was O(n) for integer keys
and again for object keys (degenerate hashes; 4096 keys in 8 and 64 buckets
respectively), Array#splice permanently converted any array it removed
from into a slow array (~85x on the next splice, and it never recovered),
and Array#includes/#indexOf called a generic comparison per element.
They also cleared a good deal, which is worth as much: string append is correctly amortized O(1), object property reads, array indexing, push/pop and string slicing all scale flat, and global regexp replace is linear in match count (~250 ns/match against Node's ~25 -- a constant factor from the pre-rewrite regexp engine, not a defect; upstream's register-based engine was skipped in the cherry-pick and remains available).
Two probe flags were the probe's own fault and are not defects:
String#indexOf and JSON.stringify genuinely scale with input size.
The lesson worth carrying: a fixed benchmark suite measures what it was written to measure. Sweeping for anomalous shapes found in one sitting several defects that mattered more to real programs than any benchmark row in the suite.
A 32k-line file of top-level lets took 1.03 s against Node's 0.05 s,
growing quadratically. The cause was five separate linear scans, each run
once per declaration or reference, fixed in two passes:
- Parse path:
find_var_in_child_scopeandfind_global_var(the latter given the same open-addressed indexfind_varalready had). - Resolution path:
resolve_scope_var's scope-chain walk (indexed by a name -> single-declaration table with ancestry check via is_child_scope; duplicates, pseudo-vars and_with_functions fall back to the walk);find_closure_var(indexed, maintained on append, first-match order preserved); and the three per-global-variable scans of the closure list in resolve_variables and instantiate_hoisted_definitions (answered by the closure index whenever no_var_/_arg_var_/_with_pseudo entries exist -- they only appear for direct eval -- since without them the scans reduce to first-match name lookups).
Result: 32k lines 1.03 s -> 0.01 s, 60k declarations 2.63 s -> 0.02 s --
from 20x slower than Node to ~5x faster. Guards:
tests/fixtures/declaration_scoping.mjs (redeclaration errors, shadowing,
TDZ, closure capture) and tests/fixtures/eval_scopes.cjs (direct-eval and
with resolution, the pseudo-variable paths the indexes must never
shortcut), both byte-identical to Node.
Building {a: 1, b: 2} transitions empty -> {a} -> {a,b}. The shape hash
table holds no reference, so a shape dies with its last object -- and the
intermediate {a} has no holder at all. It is created, hashed, used for one
property store, then freed, on every literal.
Measured with benchmarks/engine/shape_churn_probe.js, which keeps an
{a:0} object alive from JS for no reason other than to pin that shape:
{a,b} literal creation goes 103.1 -> 78.7 ns, about 24%, on one of the
most common operations in any program. Node builds the same literal in
~4 ns.
An attempt to fix this with a bounded keep-alive ring of recently hashed
shapes segfaults, and the reason is worth recording. In add_property's
transition path the freshly cloned shape is used under
assert(JS_REF_COUNT(p->shape) == 1): add_shape_property mutates it in
place precisely because it is known to be unshared. Taking a reference for a
cache makes it shared, and the in-place mutation then corrupts a shape other
objects can reach. Any fix has to take its reference after the shape is
complete, or make the table an owner and let the cycle collector reclaim
shape->proto->shape cycles -- not simply pin the shape mid-transition.
The ad-hoc pinning of Buffer/Uint8Array/ArrayBuffer shapes in
sxn_pin_core_shapes (src/node.c) is the same problem solved narrowly for
three known types; a general fix would subsume it.
Architectural work with no shortcut, still outstanding:
- Replace the conservative source transformer with QuickJS parser-mode changes and shared parser tables for the LSP.
- Add the per-function ownership CFG and all SX bytecodes described by the ABI.
- Add frame-owned arena storage, exception-safe cleanup, object borrow locks, revocable interop proxies, and typed native registration to QuickJS.
- Replace the npm bootstrap delegation with the pinned native registry, integrity, extraction, resolver, lockfile, and trusted-hook implementation.
- Implement semantic LSP requests and parser-conformance sharing.
- The complete platform CI matrix.
What used to be listed here and now isn't: libuv-backed Node-compatible
timers, file, and fetch modules shipped (spec/RUNTIME.md, spec/NODE.md).
Remaining gaps in that surface -- child_process, worker_threads, generic
classes, decorators -- are tracked as feature gaps in those two documents
rather than as foundational work; they don't block anything else on this
list.
This page is generated from
spec/IMPLEMENTATION.md.
Machine-readable copies of the whole set:
llms.txt,
llms-full.txt.