Repository navigation
perf: number-to-string is 6.6x-15.9x bun, and four spellings of it differ by 2.6x inside perry while bun spans 30-34 instructions #10762
Description
Activity
Correction: the "four spellings" table is two spellings and two value ranges
The table above does not hold the value range fixed. The
String(n)row usesk % 100, which is entirely inside the 256-entrySMALL_INT_CACHE(format.rs:14); the other three usek % 1000, which misses 74.4% of the time. Measured properly, at both ranges:Ir/op k % 100(100% cache hit)k % 1000(25.6% hit)String(n)190.0 433.3 `${n}`190.0 433.3 "" + n213.6 264.5 n.toString()293.0 536.3 String(n)and the template literal are the same path — same functions, same call counts, same totals to the unit. The 206-against-435 gap I reported was entirely value range. There is no template-literal defect, and I should not have listed it as the second-worst spelling.What is real is
n.toString(): it costsString(n)plus exactly 103.0 instructions at both ranges — flat, therefore pure dispatch, not work. That is the #10743-shaped defect, and it is a four-frame detour:js_jsvalue_to_string_method→to_string_method_impl(which writes a thread-local one-shot) →js_jsvalue_to_string(which reads and clears it, probes for a JS handle, then walks its own eight-arm ladder) →js_number_coerce. The arithmetic closes exactly: (2 + 33 + 21 + 70) − 31 = 95, plus 8 inmain= 103.0.Also, ~82 Ir/op of every absolute number in the table above is harness — a bare loop with no conversion at all costs 82.0, about 60 of that being inline software ToInt32 for the fixture's own
|0. The identical contamination was found independently in #10761, where it was ~52% of the reported row. Accumulate into a float when measuring anything that is not integer arithmetic.Where real-program number formatting actually is
PR #10767 improves the measured rows (
toString−19.3%,String(n)−14.2%) and moves almost nothing in real programs:text−0.91%, the other fourrealsuiteprograms 0.00%, all fourclisuiteprograms ≤0.06%.The profiles say why, and this is the part worth acting on: real perry programs do not use the spellings this issue measures.
csv's profile contains nojs_number_to_stringand nojs_string_coerceat all. Its number formatting goes throughjs_string_concat_chainat 1,174.8 Ir/call.text's number cost isjs_number_to_fixedat 477.9 Ir/call — 10.5% of the entire program, which is more real-program number-formatting cost than everything in this issue combined.
So the next pass belongs on those two symbols, not on further microbenchmark spellings.
Why parity needs an ABI change
"" + nnever allocates:js_string_concat_value_box(concat.rs:980) returns anf64and packs a result of ≤5 bytes intoSHORT_STRING_TAG. The other three entry points are declared-> *mut StringHeaderand therefore must allocate a heap string for a three-byte result.But
"" + nis still 8.3× bun at 264.5 while allocating nothing — so removing the allocation is necessary and not sufficient. Parity also requires the conversion to be inlined at the call site. Those are two changes, and #10767 is neither of them.- added 3 commits that reference this issue
on Sep 20, 2026 Additional real-workload evidence involving prefix-plus-number construction:
Measured 2026-10-01 on macOS arm64, Perry main
d40ed1a47019bcae547819ebb5756bcd96ba1057(v0.5.1655), with matching compiler and runtime archives. This snapshot already includes #11645. These are diagnostic probes; no compiler/runtime fix was applied and the original comparison remains unchanged.Each node executes
this.text = TEXT + seed, where TEXT is a fixed 255-byte ASCII prefix (the 85-character sentence repeated three times). Three 8-second/1-ms stack captures (19,648 main-thread observations) putjs_string_concat_valueat 8.33% inclusive / 7.22% exclusive observations. This includes number formatting and flat-string construction; it is not an isolated numeric-conversion percentage.A separate explicit-full-GC census finds approximately 3.08 MiB live strings out of 5.16 MiB reachable tracked heap data with 10,920 DocNodes. Strings are the largest live category. No formatting-only rewrite or alternate prefix representation was benchmarked, so this supplies workload context rather than a new numeric-formatting speedup claim.
A separate ticket tracks repeated immutable-prefix copying/materialization and requires attribution between formatting, allocation and copying before choosing an implementation. #8394/#8417's growing-accumulator fix does not cover this distinct-results pattern.
Separate immutable-prefix construction opportunity: #11745.
Compiler-side opportunity and complete unchanged reproducer: #11743.
Evidence in the project-comparison workspace:
demo/results/stress/perry-profile/summary.md,profile-manifest.json,cpu-summary.json,experiment-summary.json,experiments.json,gc-diagnostics.stderr,nursery-4-gc-diagnostics.stderr,census.jsonl,vmmap-*.txt, and the original/typed-receivermakeTreedisassemblies. These paths are local artifacts, not publicly hosted links.
Summary
Converting a number to a string costs perry 6.6× to 15.9× what bun costs, and four different spellings of the same operation differ by 2.6× inside perry while bun does all of them in ~30 instructions.
Fitted warm across N=500k→5M (perry is flat across fit ranges; the JITs are not — see the measurement note).
String(n), small int"" + n`${n}`templaten.toString()String(n), up to 10⁶String(n + 0.5)— floatTwo things the spread says
Floats are nearly fine; integers are not. Float formatting is 1.9× off — that is a real algorithm (shortest round-trip) and perry is in the same league. Small-integer formatting is 6.6× to 15.9× off, and bun does it in ~30 instructions, which is about what a fast itoa or a small-value cache costs.
Four spellings of one operation span 2.6× inside perry — 206 for
String(n)against 542 forn.toString()— while bun spans 30 to 34. That means perry has multiple conversion paths and the better ones are not reached by the common spellings. A template literal, which is the most idiomatic way to interpolate a number, is the second-worst at 435.This is the third instance of this exact pattern on this campaign:
a[i] += 1cost 11.5×a[i] = a[i] + 1. Fixed in the matcher; 277 → 25.5.O[K]with a hoisted const key costs 7.6× the identical read spelledO.a.n.toString()costs 2.6×String(n).In each case a fast path exists and a common spelling cannot reach it. #10743 proved that shape can be tractable without touching semantics.
Why it matters
Number-to-string is ubiquitous: logging, serialisation, every
${n}interpolation, CSV and report generation, JSON output, key construction. It was the single largest item intext, the string-heavy program in #10695, at 431 instructions against bun's 30.Measurement note
Fit at two widely separated ranges and check the value is stable. perry's figures are identical across ranges because it is AOT; node's and bun's fall sharply once warm, so a small-range fit understates every gap here. This campaign published three wrong tables before adopting that rule — one comparing totals, one fitting inside node's warmup, and one using a benchmark shape real code does not use.
Related: #10761 (property access, same campaign), #10695 (where perry stands on real programs).