Skip to content

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

@proggeramlug

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).

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).

Activity

  1. proggeramlug commented on Sep 19, 2026

    @proggeramlug
    ContributorAuthor

    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

    "" + n never 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.

  2. proggeramlug commented on Oct 2, 2026

    @proggeramlug
    ContributorAuthor

    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) 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions