Notes & Code

Code

Overtaking Boa

A month after v0.9.0, JSSE runs the micro-benchmarks about 5× faster, passes all 48 JetStream workloads and beats Boa on every one of the 44 that both engines run. test262 is still at 100%.

Date
Reading
13 min read
Sections
A–G (7)
Figures
3
Jump to sectionsmin
  1. AStill 100%1′
  2. BWhere the Speed Came From1′
  3. CThe Numbers5′
  4. DWhere Boa Still Wins8′
  5. ESince the Checkpoint9′
  6. FWhat’s Next10′
  7. GCorrections or Comments?10′
A small copper robot sprinting ahead of a mechanical boa towards a finish banner, on a track lined with glowing checkmark guardrails
Fig. 1

JSSE v0.12.0 was tagged on October 26, a month after v0.9.0, which makes it a good point to look at the numbers again (two more releases came out while this post sat in draft, and I will get to those at the end). When I took stock of JSSE in September, the last section of that post was called “What’s Still Bad”, and it listed quadratic arrays, a bytecode VM that barely helped, no hidden classes and a benchmark that timed out. It also showed Boa beating JSSE by 2× to 4× on calls, closures and strings.

Boa is the natural yardstick for JSSE. It is a mature JavaScript engine written in Rust which, like JSSE, doesn’t JIT, and it has been around for much longer than JSSE has. Therefore, when I say that JSSE overtook it, keep in mind that this is one interpreter against another, on my benchmarks and on one machine, and not a verdict on Boa.

In this post, I will go through what happened in that month. Between v0.9.0 (September 23) and the October 25 checkpoint used for the numbers below, the agents merged 271 commits into JSSE, 84 of which are performance work1. The short version: on my micro-benchmark suite, JSSE is about 5× faster than v0.9.0 and, while a month ago it was 2.2× slower than Boa 0.22, it is now 2.2× faster2. On JetStream 3, it is faster than Boa on all 44 workloads that both engines run. And test262 still passes every one of the 99,911 scenarios.

As before, I haven’t written a line of JSSE, so this is a report on the results and not a how-to. Everything here comes from the data that lives in the repository, including the numbers that are less flattering. When the tables and charts below say “now”, they mean the October 25 checkpoint.

Still 100%

Let’s start with the guardrail that matters the most. At the benchmarked commit, 58606ad1, I had the agent re-run the whole of test262 (the same checkout as last month) in both execution modes: 99,911 of 99,911 scenarios pass with the default configuration, and 99,911 of 99,911 pass with --no-bytecode, with zero regressions against the baseline. Going 5× faster in a month and not losing a single scenario is what the pass-list baseline is for.

Where the Speed Came From

Did the bytecode VM finally pay off? Yes, and it is now the default. In the last post I explained that entering a compiled function cost about 350 ns, that each op saved only about 22 ns, and that the whole thing broke even at around 16 ops. At v0.9.0, --bytecode was only 3.8% better on JetStream (geometric mean). On October 8, #875 flipped the default (it shipped in v0.11.0 the day after), and --no-bytecode is now the opt-out. My reading of the pull requests is that two things made it worthwhile: a long list of coverage PRs (generators, async functions and async generators, default and rest parameters, let loops, arguments objects), so that fewer functions fall back to the tree-walker, and a series of cuts to the cost of calls and frames.

How much of the speedup is bytecode, and how much is everything else? I asked the agent to run the micro suite once more with --no-bytecode at the same commit, so that we can split it. This is a separate run from the one in the next section, so its times don’t match that section exactly3.

benchv0.9.0 (s)now, --no-bytecode (s)now, default (s)v0.9.0 → no bytecodeno bytecode → default
fib4.0112.0220.9441.98×2.14×
closures5.2852.0711.2882.55×1.61×
opmixn/a53.1236.904n/a7.69×
string1.0430.1450.0777.20×1.89×
array27.5400.2690.154102.5×1.74×
object0.6680.4510.3361.48×1.34×
loop2.0931.2330.5751.70×2.14×
json0.1760.1240.1091.41×1.14×
regex0.1040.0770.0671.35×1.14×
geomean (without opmix)3.40×1.60×

Over the eight scripts that haven’t changed since v0.9.0, about 1.6× of the total (5.4× in this run) comes from the bytecode VM and about 3.4× from everything else4. The split is very uneven, though. The array gain is almost entirely non-bytecode work and string is mostly non-bytecode, while fib and loop are roughly half and half. opmix is the odd one out: in October the script gained two labeled loops (#876), so it does more work than it did at v0.9.0, and I leave it out of every comparison with v0.9.0. Within the October run, the bytecode VM makes it 7.7× faster than --no-bytecode, the biggest jump in the table.

The rest of the work falls roughly into five groups (all the PR numbers are in the collapsible list below):

  • Bytecode and coverage. Besides #875, the generators (#1103), async functions (#1148) and async generators (#1149) are now compiled. Each of the last two cut about 30% of the instructions executed on doxbee-async and async-fs, respectively.
  • Calls and frames. #1120 removed 15% of the instructions executed by fib, #1127 removed 44% on default and rest parameters, and #1134 removed between 26% and 44% on arguments objects. Between the October 14 and October 25 runs of the audit I will describe below, the default-parameter controls went from taking 2.0 to 2.2 times as long as Boa to taking 0.78 to 0.91 times as long.
  • Arrays, strings and sorting. This is the one I promised in the last post. The array script went from 27.5 s to 0.18 s, mostly thanks to array work outside the bytecode VM, starting with #758, which made building the result of map, filter and slice linear. Strings got amortized O(1) concatenation (#945) and inline short strings (#1099), shift became amortized O(1) (#1195), and Array.prototype.sort now uses a merge sort that exploits natural runs (#1193).
  • Generators, promises and the GC. Between the same two audits, the generators pipeline went from 1.59× of Boa’s time to 0.42×, lazy-collections from 2.05× to 0.65× and the deep promise queue from 1.10× to 0.36×. On the GC side, #1136 removed 42% of the instructions on the deep promise queue and #1137 removed 24% on Three.js.
  • Everything else. jemalloc as the allocator (#1074), faster property access, BigInt, Intl caches, private fields and proxies.

Note that most of the percentages above are instruction counts (callgrind), not seconds. The seconds are in the next section.

The performance PRs behind these numbers
  • Bytecode on by default: #875. Coverage: #962, #963, #980 to #984, #1126, #1127 to #1130, #1143, #1144, #1159, #1161. Generators #1103, async functions #1148, async generators #1149.
  • Calls and frames: #1088, #1090, #1120, #1121, #1127, #1133, #1134.
  • Arrays and sorting: #758, #975, #977, #985, #1193, #1195.
  • Strings: #945, #973, #1099.
  • Generators, promises and async: #968, #970, #971, #1076, #1083.
  • GC: #989, #998, #1080, #1136, #1137.
  • Allocator: #1074 (jemalloc, about 6% wall time on six heavy workloads) and #1102 (deferring transparent huge pages, which by its own description fell short of the target).
  • Property access: #986, #987, #1123, #1124.
  • BigInt: #990, #991, #992, #1128.
  • Intl: #1140, #1191 (measured on a loaded host, so I am not quoting the numbers).
  • Classes, proxies and symbols: #993, #994, #996, #999, #1091, #1097.

The Numbers

Let’s look at the same nine scripts as last time. Each one is timed as a whole process (startup included), and we take the median of five interleaved runs. This time Boa shows up twice: version 0.22.0, which I used in the last post, and its current main (commit 64c209f1, which still calls itself 0.22.0), so that nobody can say I compared against something stale. Tick the boxes to add or remove engines from the comparison.

Fig. 2 Micro-benchmarks, median wall-clock seconds, log scale (lower is better).
Micro-benchmark table (seconds)
BenchmarkJSSE v0.9.0JSSE nowJSSE now --no-bytecodeBoa 0.22.0Boa main
fib4.0110.9412.0221.1991.207
closures5.2851.2742.0712.0861.898
opmixn/a6.93053.12312.71610.864
string1.0430.1010.1450.4960.463
array27.5400.1790.2690.3080.310
object0.6680.3640.4510.5460.527
loop2.0930.5761.2331.8481.538
json0.1760.1080.1240.3130.308
regex0.1040.0670.0770.1380.160

Against v0.9.0, JSSE is 4.3× faster on fib, 4.2× on closures, 10× on string and 154× on array, with a geometric mean of about 5× over the eight scripts that haven’t changed (the chart leaves out the v0.9.0 opmix bar for the same reason). Against Boa 0.22.0, it now wins all nine: the narrowest margin is fib (0.78× of Boa’s time) and the widest is string (0.20×). The geometric mean of the time ratio is 0.47 against Boa 0.22.0 and 0.50 against Boa main. Startup, measured with an empty script, is about 16 ms for JSSE and about 30 ms for Boa.

JetStream is again the more realistic set. JSSE passes 48 of 48 workloads (mandreel, the one that timed out in the last post, now runs). Boa 0.22.0 and Boa main both pass 44, and fail Basic, OfflineAssembler, typescript-octane and validatorjs. On the 44 workloads that both engines run, JSSE is faster on every single one. The chart shows the time ratio per workload (JSSE’s time divided by Boa’s, drawn from 1.0 down to the ratio, so the longer the bar, the bigger the JSSE win)5.

Fig. 3 JetStream 3, JSSE time divided by Boa time per workload, log scale (left of 1.0 means JSSE is faster).
JetStream table (JSSE time / Boa time)
Workloadvs Boa 0.22.0vs Boa main
json-stringify-inspector0.110.11
multi-inspector-code-load0.170.18
first-inspector-code-load0.170.18
js-tokens0.170.18
regexp-octane0.250.24
doxbee-promise0.390.26
octane-code-load0.430.44
doxbee-async0.470.32
pdfjs0.500.47
ML0.530.52
Air0.530.49
crypto0.560.59
ai-astar0.560.54
async-fs0.570.43
raytrace0.570.60
raytrace-private-class-fields0.590.60
FlightPlanner0.600.49
navier-stokes0.610.63
json-parse-inspector0.610.66
gbemu0.620.60
stanford-crypto-aes0.630.64
earley-boyer0.640.57
raytrace-public-class-fields0.650.66
cdjs0.650.60
stanford-crypto-sha2560.660.69
Babylon0.660.63
stanford-crypto-pbkdf20.670.71
gaussian-blur0.690.67
bigint-noble-bls12-3810.700.68
bigint-paillier0.710.56
UniPoker0.730.65
bigint-bigdenary0.740.74
delta-blue0.750.77
sync-fs0.750.56
mandreel0.760.76
hash-map0.760.81
lazy-collections0.760.64
Box2D0.760.73
splay0.780.79
proxy-mobx0.810.74
richards0.820.84
bigint-noble-ed255190.860.88
proxy-vue0.870.75
bigint-noble-secp256k10.910.89

The geometric mean is 0.56 against Boa 0.22.0 (JSSE is about 1.8× faster) and 0.53 against Boa main (about 1.9×). The biggest wins are json-stringify-inspector at 0.11 (the benchmark that regressed in the last post, which is now about 9× faster than Boa) and the inspector and js-tokens workloads at 0.17 or 0.18.

The third set of numbers is a 343-case audit that the agent runs against Boa main. It combines JSSE’s own benchmark scripts and diagnostics, Boa’s own benchmark scripts, JetStream workloads and paired control micro-benchmarks written for the audit. Of the 343 cases, 328 run on both engines. Compared with the previous audit of October 14, the geometric mean of JSSE over Boa went from 0.75 to 0.56, the number of cases where JSSE is more than 5% slower went from 93 to 14, and the number where it is more than 30% slower went from 38 to 56. A suite of 61 short scripts, each exercising one language or library feature in a loop, agrees: JSSE is faster than Boa 0.22.0 on 49 of the 61 and faster than Boa main on 53.

Where Boa Still Wins

None of this means JSSE is faster than Boa at everything. At the checkpoint, 14 audit cases were still more than 5% slower than Boa main, and ten of them fall into four groups:

  • bigint_compare_numeric_control (1.89× Boa’s time), script_let_loop (1.82×), bigint_compare (1.60×) and three more, all top-level script loops over global lexical bindings, tracked in #1210, which has since been closed.
  • Closure creation (closures/create, 1.42×), tracked in #1211, which has since been closed.
  • Getter and setter calls (about 1.2× on the proxy case, and 1.07× on proxy_ownkeys_has), tracked in #1212.
  • strings/slice (1.33×). It is a curious one: JSSE executes fewer instructions than Boa (529M against 652M), and which engine wins the wall-clock race depends on the CPU, so I believe it is a memory-system effect and not a real gap.

The other four are smaller gaps, between 1.09× and 1.16×, which have no issue of their own: BigInt invert, mulmod256 and ops_vm_add, and str_keys_control.

On JetStream, the narrowest wins are bigint-noble-secp256k1 (0.91), proxy-vue (0.87), bigint-noble-ed25519 (0.86) and richards (0.82). The margin also depends on bytecode: with --no-bytecode, JSSE is only about 1.16× faster than Boa 0.22 over all nine micro scripts (geometric mean 0.865), and it is slower on opmix (4.2×) and fib (1.7×). And at the checkpoint, hidden classes still hadn’t landed: there were per-site slot hints and cached property order, but no hidden-class transitions. Boa, for comparison, has had hidden classes since v0.17.

Since the Checkpoint

This post sat in draft for longer than I intended, so the numbers above are no longer the whole story. JSSE v0.13.0 came out on November 1 and v0.14.0 on November 8. I haven’t re-run the full set of benchmarks against either of them, so everything above describes the October 25 checkpoint and nothing later.

Three things in the previous section have already changed. The issue for the top-level script loops, #1210, was closed by #1302, which shipped in v0.13.0. The issue for closure creation, #1211, was closed by #1593 on November 9, after v0.14.0, so that change is waiting for the next release. On a reduced test case it brings the instruction count close to Boa’s (644M against 636M), although Boa is still ahead on wall-clock time there (51 ms against 58 ms). And JSSE now has shapes with transitions, for objects that start out empty: #1556, which also shipped in v0.13.0. However, that was memory work and not speed work, since the inline caches still match per object and not per shape.

What’s Next

The audit gaps are collected in the epic #1213, which is still open and remains the next target. After that, I would like to see the inline caches match on shapes, which is where I would expect the speed benefit to show up.

All the numbers come from docs/perf/2026-10-25 and docs/perf/2026-10-25-no-bytecode7.

Corrections or Comments?

Comments are open below. As usual, I am happy to receive corrections, and if you spot something odd in the numbers, open an issue on JSSE.


  1. The numbers were measured at commit 58606ad1. v0.12.0 adds one engine change on top of it, a refactor of the some/every traversals (#1208), which my numbers don’t cover. The rest of the engine is identical. ↩︎

  2. Both figures are over the eight micro scripts that haven’t changed since v0.9.0. bench_opmix gained two loops in October, which I explain below. ↩︎

  3. Scripts that run for more than a second repeat to within about 1%, but scripts under half a second moved by up to about 20% between separate runs of the same binary, so treat differences of a few tens of milliseconds on those as noise. ↩︎

  4. This split measures the bytecode VM on top of the improved tree-walker. It is one reasonable way of decomposing the speedup, not the only one. ↩︎

  5. I don’t quote a JetStream speedup over v0.9.0. The runner changed between the September and October runs, and I can’t be sure the scores are on the same scale. ↩︎

  6. The previous audit ran on a different host with background load, so only the ratios of the two runs are comparable and not the absolute times. ↩︎

  7. Everything ran on a shared build machine (buildbox3). The micro-benchmarks are the median of five runs, with JSSE and Boa interleaved, and the load stayed at or below about 1.65. JetStream uses --iterations 1 with a pinned JetStream commit, and each workload is the median of three runs, with JSSE and Boa run one after the other and without a load gate. The v0.9.0 column was measured on September 25 on the same machine, as the median of five runs that were not interleaved, with the load gate checked only once, and the load reached about 3 during that run. The same Boa 0.22.0 release ran about 8% slower in that September session than in October, so if JSSE was slowed down by the same amount, the speedups over v0.9.0 are overstated by up to about 10% (5.1× would be closer to 4.6×). The comparisons with Boa within each run are not affected in the same way. bench_opmix gained two loops in October, so the speedups over v0.9.0 use the other eight scripts, whereas the comparisons with Boa use all nine, since both engines ran the same script within each run. The headline “about 5x” covers both the 5.1× of the checkpoint run and the 5.4× of the --no-bytecode run. The JSSE column of the micro table comes from the run against Boa 0.22.0, and the comparison with Boa main was a separate run with its own JSSE times, so its 0.50 geometric mean is 0.49 if you compute it from the table. In the same way, the 0.865 for --no-bytecode comes from a run that timed Boa 0.22.0 alongside it, whereas the table gives 0.84. ↩︎

Comments

Notes from readers on Plate XXVI.