Repository navigation
perf: crossover map — perry beats node/bun on 7 of 9 primitives at 100k iterations, and loses on regex and JSON #10695
Description
Where perry stops winning, per primitive
perry beats node and bun on every short program, because node pays ~95M instructions of startup and bun ~47M against perry's ~750k. That advantage is a constant, so it is consumed by any program that does enough work. This maps where that happens, per primitive.
Method: one program per primitive, size from process.argv[2], so N varies with no recompile. perf stat -e instructions:u, min of 2, on perrymaster. perry built from main 68a545439 + #10611; node v26.8.1; bun 1.3.14. Every row was checked for output equality against node before its numbers were taken.
Ratios are node÷perry and bun÷perry — above 1.0 perry wins, below 1.0 perry loses.
| primitive | N=1,000 | N=10,000 | N=100,000 |
|---|---|---|---|
string += |
134× / 56× | 53× / 29× | 14.1× / 5.1× |
| object property r/w | 108× / 52× | 57× / 28× | 11.7× / 5.3× |
for...of over array |
110× / 52× | 43× / 23× | 9.1× / 5.3× |
| object literal alloc | 111× / 50× | 49× / 23× | 9.0× / 4.2× |
indexed for loop |
112× / 53× | 43× / 24× | 7.5× / 4.6× |
Array.prototype.map |
93× / 46× | 29× / 15× | 4.4× / 2.4× |
Map set+get |
86× / 40× | 22× / 11× | 3.7× / 2.5× |
regex exec |
11.2× / 5.2× | 1.48× / 0.74× | 0.28× / 0.21× |
JSON round-trip |
2.78× / 1.31× | 0.92× / 0.52× | 0.40× / 0.29× |
Reading
Seven of nine primitives are healthy. At 100,000 iterations they still run 3.7–14× fewer instructions than node and 2.4–5.3× fewer than bun, and the decline across sizes is just the startup advantage being amortised — which is expected and fine.
Two lose outright, and they are the two that matter most for real programs.
Per-element cost, fitted across the 10k→100k interval so the startup constant drops out:
| perry | node | ratio | |
|---|---|---|---|
regex exec |
9,393 instr/call | 1,522 | 6.2× |
JSON stringify+parse |
15,964 instr/round-trip | 5,369 | 3.0× |
regex crosses below node between N=10,000 and 100,000, and below bun before N=10,000. JSON crosses below node just under N=10,000.
Why this is the roadmap and not just a table
Any program that parses configuration, validates input, matches routes, serialises a response or reads JSON off a socket does these two things thousands of times. A program doing 100k regex matches runs 3.6× slower than node and 4.8× slower than bun, regardless of how fast it started.
So the startup win — real and large — does not survive contact with a realistic workload unless these two are fixed. The other seven primitives need nothing; they are already several times ahead at the largest size measured.
Known, already-filed causes
regex has located root causes in flight: #10166 (per-call cost with the regex hoisted), #10518 (spec flags/@@species Gets taken through the generic property path on pristine RegExps), #10519 (KMP reading one UTF-16 unit per bounded-reader call), #10165, #10164.
JSON has no equivalent attribution that I can find, and on this evidence it is the second-biggest obstacle to beating node and bun on real programs. Note that the N=1,000 JSON figure also carries the ~25M one-time constant from #10686, which is a separate defect — the per-element number above is measured above it and is unaffected by fixing it.
Related: #10686 (a ~25M one-time constant), #10689 (its correctness twin).
Activity
proggeramlug commentedon Sep 19, 2026
A real program, and a decomposition: regex is 63% of it
The table above is per-primitive. Here is an actual program — synthesise log lines, parse each with a regex, aggregate into two
Maps, build a report object andJSON.stringifyit, then checksum the output. Deterministic, no I/O. All three runtimes produce byte-identical output at every size (10000 353 188576138).perry loses to bun even at 1,000 lines, and by 100,000 it is 5.6× slower than node and 7.1× slower than bun. Per line: perry 37,383 instructions against node's 5,663 and bun's 4,570.
Removing only the regex
The same program with
line.exec(text)replaced byindexOf/slice/split, everything else untouched, output still byte-identical:Per line:
For node and bun the regex version is cheaper than hand-rolled string parsing — their regex is effectively free. For perry it costs 23,518 instructions per line, 63% of the program's entire runtime.
What this prices
Fixing regex alone moves this program from 0.18× to ~0.54× against node, and from 0.14× to ~0.45× against bun — a 2.7–3.2× whole-program speedup from one subsystem. It does not by itself win; at 100k the no-regex version is still 1.9× behind node. But it is far and away the largest single item, and nothing else on the crossover map comes close.
The residue after regex is 13,865 vs node's 6,255 — about 2.2×, which is where the JSON per-object constant (#10696) and the remaining primitive gaps live. That is the second tranche and it is worth attacking only after the first.
Existing regex issues, already characterised by the session that owns perex: #10166, #10518, #10519, #10165, #10164.
Harness:
/root/rungs/app.{ts,js}andapp_noregex.{ts,js}on perrymaster, size fromprocess.argv[2], so any row re-runs without recompiling.proggeramlug commentedon Sep 19, 2026
Retraction: the table in the description is wrong. It measured node's startup, not perry's speed.
The original table compares total instruction counts at each N. node's own startup is ~95M and bun's ~47M, so at N=100,000 — where most of these primitives do only tens of millions of instructions of actual work — the ratios are dominated by the constant perry does not pay. I reported those ratios as though they measured throughput. They do not.
Recomputed as a per-operation delta across N=10,000 → 100,000, which cancels every runtime's startup:
for...ofover arrayforloop+=Array.prototype.mapMapset+getexecJSONround-tripWhat actually changes
for...ofand indexedfor. Those are real and were understated by nothing; they just are not accompanied by the other five.Why this happened, so it does not recur
A total-count ratio is only a throughput ratio when the measured work is large compared with every runtime's fixed cost. Here it was not, and the error flatters perry in direct proportion to how little work the benchmark does — the most misleading possible bias, because it makes cheap primitives look healthiest.
The same mistake, caught separately, made a string-primitive probe report
charCodeAtat 8.08× node; per-op it is 108 instructions against node's 14 — 0.12×. Same program, same data, opposite conclusion.Rule going in, for anything measured against node or bun: fit a per-op delta across two sizes. Never quote a total-count ratio.
The real-program decomposition in the comment above is unaffected — it was already computed as a per-line delta across 10k→100k.
proggeramlug commentedon Sep 19, 2026
The honest answer: perry beats both, up to a program-specific size
Neither of my earlier framings was right. A total-count ratio credits perry with node's and bun's startup, which it genuinely does not pay — but a per-op ratio discards that advantage, which is genuinely real. Both are true and they answer different questions.
The measurement that answers "does this real program run faster" is total instructions at a given program size, plotted until it crosses. Five realistic programs, byte-identical output in all three runtimes, measured with #10731 (array read hoist) applied:
textat N=2,000 renders 80,000 lines and finishes in 189M instructions against node's 282M and bun's 288M. That is a real program, at a realistic size, beating both.Why the crossover sits where it does
perry starts in ~114k instructions where node needs ~95M and bun ~47M — an advantage of two to three orders of magnitude that no amount of throughput can erase on a short run. Against that, per-operation throughput currently runs 1.2× to 12× behind, so the advantage is consumed at a rate set by how much work the program does.
That gives a clean way to state where perry stands, and what the perf work is actually for: every throughput fix moves each program's crossover to the right. #10731 moved
sim's crossover by roughly an order of magnitude on its own.What this means for the remaining work
The programs with the earliest crossovers are the ones to fix, and they are array-store bound:
graphcrosses at ~10 and is dominated by indexed writes (105 instructions/element against node's 11)simcrosses at ~1,000 and is entirely+=on elements (273 against node's 16)Both are #10718's store side, which #10731 deliberately left. Then regex (0.15× against both, #10695 above) and JSON (#10717).
Correction to the table in the description
It reported "7 of 9 primitives healthy, 3.7–14× ahead" from total-count ratios. Per-op, only 2 of 9 beat both, at 1.08–2.65×. The retraction comment above has the corrected figures; this comment supersedes the framing of both.
proggeramlug commentedon Sep 19, 2026
Second retraction: the per-op table is also wrong — a two-size delta does not cancel a JIT warmup
I retracted this issue's original table because it compared totals, which credited perry with node's ~95M startup. I replaced it with a per-op delta fitted across N=10,000→100,000, and called that the honest answer. It is not.
A two-size delta cancels a constant. node's cost is not constant-plus-linear — it is constant, plus a warmup curve, plus linear, and the warmup sits inside the window I fitted across.
The same fixture, same binaries, fitted at three ranges:
perry is flat to the instruction across all three, because it is AOT-compiled and has no warmup. node falls 14×. So every node figure I fitted at a small range is inflated, and the smaller the range the worse it is.
What this means for the tables in this issue
The per-op crossover table in my earlier comment — the one reporting
for...ofat 2.65×/1.38× and indexedforat 1.59×/1.08× as wins — was fitted at 10,000→100,000, which is inside node's warmup for these workloads. Those two "wins" need re-measuring at a range where node is warm before anyone relies on them, and on this evidence they are likely losses.The rows that survive best are the expensive ones — regex at 0.15× and JSON at 0.32×/0.26× — for the same reason as before: where the measured work genuinely dominates, both the startup and the warmup contamination matter less. The cheaper the primitive, the more of my reported ratio was node's warmup. That is the same directional bias as the totals error, from a different cause, and it flatters perry in the same way.
The crossover model still stands; its numbers do not
"perry wins below a program-specific size on its startup advantage, and loses above it on throughput" is unaffected — it was measured on totals at a given size, which is the right instrument for that question and needs no delta.
textat 1.49×/1.52× after 80,000 rendered lines is a total-count comparison at a fixed size and stands.What is now unreliable is every per-op throughput figure I have quoted from a small-range fit, in this issue and in #10753.
The rule I should have followed
Before quoting a per-op cost against a JIT, fit it at three ranges and check the value is stable. perry's flatness across ranges is what makes the contamination visible: if one runtime's per-op cost moves 14× across fit windows and the other's does not move at all, the moving one is not yet measuring steady state.
I had this warning in hand — I wrote "at small N node is not warm and its per-op numbers run ~2× high" into a brief for someone else earlier today, from a finding they had reported to me, and then did not apply it to my own measurements.
proggeramlug commentedon Sep 19, 2026
Corrected throughput baseline — measured with the JITs warm
Both earlier tables in this issue were wrong: the first compared totals (crediting perry with node's startup), the second fitted per-op deltas inside node's warmup. Here is the same set fitted at two widely separated ranges, so the contamination is visible rather than assumed.
forfor...of+=Mapset+getperry's column is identical at both ranges on four of five primitives. It is AOT-compiled and has no warmup, so its per-op cost is its per-op cost. node and bun move by up to 18× between ranges. That asymmetry is the detector: if one runtime's figure moves across fit windows and the other's does not, only the moving one is still tiering up.
Where perry actually stands at steady state
+=for...offorMapset+getOne primitive of five beats both. The earlier claims of 2.65×/1.38× for
for...ofand 1.59×/1.08× for indexedforwere warmup artifacts; warm, the first loses to bun and the second is parity with node.Object property read/write is the starkest correction: I reported it as 1.64× node. Warm, node does it in 16 instructions against perry's 105.
A new finding: perry's
Mapcost grows with NMapset+get is the one row where perry is not flat — 439 per op at 10k→100k, 683 at 200k→2M, a 56% increase — while node's falls from 505 to 327 and bun's from 603 to 449. Both JITs get faster with scale; perry gets slower. That is the signature of something superlinear (rehashing, or GC pressure from the table's growth), and it is worth its own investigation. It is consistent with #10697's finding that string-keyedMapderives no benefit from key repetition.What this does and does not change
The crossover model stands: perry wins below a program-specific size because of a startup advantage of two to three orders of magnitude, and loses above it. That was measured on totals at fixed sizes, which needs no delta and no warmup assumption.
textat 1.49× node / 1.52× bun after 80,000 rendered lines is unaffected.What changes is the size of the throughput deficit it has to spend that advantage against: larger than reported, on more primitives than reported. The honest summary is that perry's advantage is startup, and its steady-state throughput currently trails both runtimes on four of the five most common operations.
proggeramlug commentedon Sep 19, 2026
Third correction: the one remaining "win" is a narrow sub-case, and it does not survive the realistic shape
My corrected warm baseline reported string
+=as the one primitive of five beating both, at 3.1× node and 2.3× bun. That figure came from a single shape: one long-lived string, growing, with a constant appended. Varying the two things a real program varies:All fitted warm (20k→200k). perry moves only 113 → 220 across all four shapes. node moves 308 → 81 and bun 126 → 36 — the JITs get roughly 4× cheaper when the strings are short-lived, because there is no rope or reallocation to manage. perry does not.
Building many short strings — a line, a row, a log message, a JSON fragment — is what every real program does. In that shape perry loses 3.7× to bun.
So the honest count is zero of five primitives beating both in their realistic shapes. The earlier "one of five" was itself a measurement artifact, of a third distinct kind: not totals, not warmup, but a benchmark shape that is not the shape real code uses.
What else the decomposition of
textfoundFitted warm, per operation:
Number.prototype.toFixed(2)+=building a lineString(n)— number to stringcharCodeAtin a loopString(n)at 431 against bun's 30 is a 14× gap and is not filed anywhere. Number-to-string is ubiquitous — logging, serialisation, templating, any${n}interpolation — and it is the largest single item in thetextprogram, which was my best real-program result.toFixedbeating both is genuine and, on this evidence, the only primitive win I have that survives shape variation.Why I keep having to correct this table
Three errors, three different mechanisms, all flattering perry:
The common thread is that each error was invisible until the specific control that exposes it was run — a second fit range, a second program size, a second shape. perry's flatness is what makes all three detectable: when perry's number does not move and the other runtime's moves 4×, the other runtime is the one telling you something.
proggeramlug commentedon Sep 19, 2026
Real programs that beat both node and bun — the short-lived CLI class
The five programs measured earlier in this issue were long-running loops, chosen to expose throughput. They do expose it, and perry loses. But that is not the shape most real JavaScript takes: validators, formatters, generators, report scripts and dependency tools are short-lived processes doing a bounded amount of real work, and that is precisely the shape node's startup tax falls hardest on.
Four such programs, each doing genuine work, byte-identical output in all three runtimes:
validaterendercsvMap, sorted, formattedresolveWall clock — min of 60, three interleaved rounds
validaterendercsvresolveInstructions, and the corroboration
validaterendercsvresolveCycles agree — on
csv, perry 19.7M against node 62.3M and bun 31.6M, at a higher IPC (2.59 vs 2.16 and 2.24) with fewer cache misses (436k vs 859k and 486k) and 524 page faults against 3,666 and 2,717.A measurement note worth recording: a first pass using p50 of 40 runs reported
csvat 31.2 ms for perry and had it losing to bun. That was interference — on a shared box, contention only ever adds wall time, so min is the robust statistic for process wall clock, where p50 is not. The corrected figure is 5.32 ms, and it agrees with both the cycle count and a coarse/usr/bin/timecheck. (This is the opposite of the pause-quantile case elsewhere in this campaign, where a max was the contaminated statistic and ratios were robust — the right statistic depends on which direction the noise pushes.)The honest boundary
perry wins here for two reasons, and both have limits:
The throughput deficit documented above in this issue is unchanged: warm, perry trails on essentially every common primitive. So the crossover model holds — what these four show is that the crossover sits above the natural size of an entire, large class of real programs, not that the deficit has gone away.
For long-running servers, or a single program processing very large inputs, the earlier results stand and perry loses. For the class of program that starts, does a bounded job and exits — which is most build tooling — perry is currently 1.7× to 5.3× faster than bun and 2.9× to 8.7× faster than node.
perf(codegen): let `a[i] += 1` reach the loop tier `a[i] = a[i] + 1` …