You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
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
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).
spelling
perry
node
bun
perry vs best
String(n), small int
206
106
31
6.6×
"" + n
290
49
32
9.0×
`${n}` template
435
51
30
14.5×
n.toString()
542
103
34
15.9×
String(n), up to 10⁶
570
339
410
1.6×
String(n + 0.5) — float
1205
780
604
1.9×
Two 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 for n.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:
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 in text, 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).
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 uses k % 100, which is entirely inside the 256-entry SMALL_INT_CACHE (format.rs:14); the other three use k % 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
"" + n
213.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 costs String(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 in main = 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 four realsuite programs 0.00%, all four clisuite programs ≤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 no js_number_to_string and no js_string_coerce at all. Its number formatting goes through js_string_concat_chain at 1,174.8 Ir/call.
text's number cost is js_number_to_fixed at 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 an f64 and packs a result of ≤5 bytes into SHORT_STRING_TAG. The other three entry points are declared -> *mut StringHeader and therefore must allocate a heap string for a three-byte result.
But "" + n is 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.
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) put js_string_concat_value at 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-receiver makeTree disassemblies. These paths are local artifacts, not publicly hosted links.
Activity
proggeramlug commentedon Sep 19, 2026
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:k % 100(100% cache hit)k % 1000(25.6% hit)String(n)`${n}`"" + nn.toString()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.perf(runtime): stop routing plain numbers through the slow arms of th…
perf(runtime,codegen): String(n), `${n}` and n.toString() return shor…
perf(runtime,codegen): String(n), `${n}` and n.toString() return shor…
proggeramlug commentedon Oct 2, 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.