Repository navigation
perf(string): padStart fills its padding one code unit at a time, reaching 80x Node on ASCII #10091
Description
What happened
On pinned Perry 9495bfc, padStart to a 1M-unit ASCII target costs 10.088 ms versus Node's 0.127 ms (79.56x), and the gap grows cleanly with size: 1.30x at n=100, 5.06x at 1k, 24.75x at 10k, 52.67x at 100k. Node's slope is 0.49 against Perry's 0.95, consistent with a doubling bulk copy versus a per-unit loop. repeat on ASCII shows the same mechanism more weakly at 4.13x. Checksums match at every size.
Measured against Node v26.5.1 using Perry perry 0.5.1531 at 9495bfc95e2afcfb5a7cb535e440e61ec0722cb1. This is evidence from that pinned revision, not a claim that current main was remeasured. First reproduce on current main; if it is already fixed, identify the fixing commit and attach the comparison.
Measurements
Times are median milliseconds per workload invocation. Ratios are Perry/Node. A correctness or timeout classification takes precedence over performance; successful smaller-size timings on those rows are diagnostic evidence.
string-pad-start-ascii — ASYMPTOTIC
| n | Node ms / status | Perry ms / status | ratio | Node checksum | Perry checksum |
|---|---|---|---|---|---|
| 100 | 0.001369 | 0.001805 | 1.32× | 716029617 | 716029617 |
| 1000 | 0.002091 | 0.010528 | 5.03× | 354369208 | 354369208 |
| 10000 | 0.004112 | 0.100773 | 24.51× | 998200486 | 998200486 |
| 100000 | 0.019266 | 1.013685 | 52.61× | 880222738 | 880222738 |
| 1000000 | 0.126787 | 10.088333 | 79.57× | 507911534 | 507911534 |
Log(time)/log(n) least-squares slopes: Perry 0.948, Node 0.490, delta 0.458.
Workload: ascii variant. n counts repeated input tokens, not bytes; UTF-16 length and UTF-8 byte length differ for Unicode. String result hashes sample roughly 32 positions for long strings (at most 63 for short strings), plus length. Target length is 4*n+1 UTF-16 units. Pre-sized buffers and a constant number of output passes provide no source evidence of quadratic growing-prefix copies. Small-size overhead and allocation/materialization transitions can produce a finite-range slope gap. Timed result hashing may force Node materialization.
Interpretation note: Both fitted slopes are below one; fixed overhead or allocation thresholds can influence this finite-range classification.
string-repeat-ascii — ASYMPTOTIC
| n | Node ms / status | Perry ms / status | ratio | Node checksum | Perry checksum |
|---|---|---|---|---|---|
| 100 | 0.001359 | 0.000800 | 0.59× | 206218375 | 206218375 |
| 1000 | 0.002014 | 0.000825 | 0.41× | 245031878 | 245031878 |
| 10000 | 0.003966 | 0.006378 | 1.61× | 878703859 | 878703859 |
| 100000 | 0.018994 | 0.056471 | 2.97× | 649091672 | 649091672 |
| 1000000 | 0.129944 | 0.531056 | 4.09× | 282242921 | 282242921 |
Log(time)/log(n) least-squares slopes: Perry 0.748, Node 0.494, delta 0.254.
Workload: ascii variant. n counts repeated input tokens, not bytes; UTF-16 length and UTF-8 byte length differ for Unicode. String result hashes sample roughly 32 positions for long strings (at most 63 for short strings), plus length. Indexed checksum consumption can force Node materialization, so this measures construction plus consumption. Perry's copy schedule is linear in total output bytes. Fixed overhead at small sizes and allocation/materialization transitions can create a finite-range slope gap without a growing-copy algorithm.
Interpretation note: Slope delta is within 0.05 of the classification threshold; repeat before claiming an algorithmic gap.
Interpretation note: Both fitted slopes are below one; fixed overhead or allocation thresholds can influence this finite-range classification.
string-pad-start-unicode — ASYMPTOTIC
| n | Node ms / status | Perry ms / status | ratio | Node checksum | Perry checksum |
|---|---|---|---|---|---|
| 100 | 0.001354 | 0.012477 | 9.21× | 210939553 | 210939553 |
| 1000 | 0.001982 | 0.113440 | 57.23× | 446107232 | 446107232 |
| 10000 | 0.007136 | 1.063430 | 149.02× | 162384118 | 162384118 |
| 100000 | 0.029566 | 10.599437 | 358.50× | 325152999 | 325152999 |
| 1000000 | 0.232254 | 108.351500 | 466.52× | 952841802 | 952841802 |
Log(time)/log(n) least-squares slopes: Perry 0.985, Node 0.564, delta 0.421.
Workload: unicode variant. n counts repeated input tokens, not bytes; UTF-16 length and UTF-8 byte length differ for Unicode. String result hashes sample roughly 32 positions for long strings (at most 63 for short strings), plus length. Target length is 4*n+1 UTF-16 units. Pre-sized buffers and a constant number of output passes provide no source evidence of quadratic growing-prefix copies. Small-size overhead and allocation/materialization transitions can produce a finite-range slope gap. Timed result hashing may force Node materialization. Default sizes tile complete Unicode filler tokens; custom sizes may truncate a surrogate pair. Unicode hashString adds roughly 32 Perry prefix scans, O(n) in total.
Interpretation note: Both fitted slopes are below one; fixed overhead or allocation thresholds can influence this finite-range classification.
string-repeat-unicode — ASYMPTOTIC
| n | Node ms / status | Perry ms / status | ratio | Node checksum | Perry checksum |
|---|---|---|---|---|---|
| 100 | 0.001388 | 0.012860 | 9.27× | 980184627 | 980184627 |
| 1000 | 0.002314 | 0.124926 | 53.98× | 897306695 | 897306695 |
| 10000 | 0.006837 | 1.209892 | 176.95× | 462533374 | 462533374 |
| 100000 | 0.041615 | 11.135333 | 267.58× | 731211008 | 731211008 |
| 1000000 | 0.287361 | 112.398583 | 391.14× | 522650071 | 522650071 |
Log(time)/log(n) least-squares slopes: Perry 0.983, Node 0.589, delta 0.395.
Workload: unicode variant. n counts repeated input tokens, not bytes; UTF-16 length and UTF-8 byte length differ for Unicode. String result hashes sample roughly 32 positions for long strings (at most 63 for short strings), plus length. Indexed checksum consumption can force Node materialization, so this measures construction plus consumption. Perry's copy schedule is linear in total output bytes. Fixed overhead at small sizes and allocation/materialization transitions can create a finite-range slope gap without a growing-copy algorithm. Unicode hashString makes roughly 32 prefix lookups whose total work is O(n); the measured ratio cannot be attributed solely to repeat.
Interpretation note: Both fitted slopes are below one; fixed overhead or allocation thresholds can influence this finite-range classification.
What is expected / acceptance criteria
- Rerun all four complete reproducers on the candidate release build and Node at 100, 1k, 10k, 100k and 1M. Checksums must match at every size. Use
string-pad-start-asciias the acceptance signal and require improvement at 10k, 100k and 1M; report the Unicode rows without treating them as the target. - Replace the per-code-unit fill for pad strings containing no surrogates with a bulk tile-and-copy, and reduce the number of full copies of the result. Show with profiling or bounded counters that padding to 1M units performs a logarithmic or constant number of bulk copies rather than a million per-unit appends.
- Preserve exact spec behaviour for the cases that make padding non-trivial: a multi-character pad string that must be truncated mid-way (including truncation that splits a surrogate pair, which must produce a lone surrogate and set the WTF-8 flag exactly as today), an empty pad string (returns the receiver unchanged), a target length shorter than the receiver, a fractional or negative target length, and the default single-space pad.
- Cover both
padStartandpadEnd(theprepend_padflag at finish_pad_result), and confirm the receiver's ownSTRING_FLAG_HAS_LONE_SURROGATESstill propagates into the result's constructor choice. - Confirm
repeatremains correct and no slower: count 0, count 1, a fractional count, a negative count and Infinity (RangeError), and a repeat whose result exceeds the maximum string length.
Implementation to inspect
Hypothesis: Hypothesis from the measured source: the padding chunk is built one UTF-16 code unit per loop iteration, with a modulo and a surrogate-range test per unit, and the result is then assembled through two more copies.
build_pad_chunk at pad.rs:139 loops while produced < pad_needed, and per iteration performs pad_units[idx % pad_units.len()] (an integer modulo against a runtime length), a high-surrogate range check, a possible low-surrogate lookahead with a second modulo, and a push_code_unit_wtf8 call that appends to a growing Vec<u8>. For the overwhelmingly common case of a single-ASCII-character pad string producing a million units, that is a million modulos and a million single-byte pushes where the correct operation is one Vec::resize or a doubling copy of an already-built block.
finish_pad_result at pad.rs:171 then allocates a second Vec sized str_data.len() + pad_chunk.len() and copies both the padding and the receiver into it, before the runtime string constructor copies a third time. So even after the fill is fixed, the result is assembled with more passes than necessary.
An all-ASCII (or more generally, all-BMP-non-surrogate) pad string makes every per-unit decision constant: no surrogate pairing is possible, the byte length equals the unit count, and the chunk is the pad string's bytes tiled to length. Building one tile and doubling it, or Vec::resize for the single-byte case, replaces the loop entirely.
Scope note on the evidence. The classifier flagged the Unicode rows ASYMPTOTIC at 466x and 391x, but the benchmark's own metadata records that Unicode result hashing adds roughly 32 UTF-16 prefix scans whose total work is O(n), so a significant part of those two ratios is checksum consumption rather than padding. The ASCII rows do not have that problem — their checksum reads are constant cost per index — so string-pad-start-ascii at 79.56x is the trustworthy signal and should be the acceptance measurement. The Unicode rows are included because they run the same code, not as the target number. Do not quote 466x as a padding cost.
Similarly, js_string_repeat at pad.rs:284 already uses a doubling copy schedule per the benchmark's source reading, which is why ASCII repeat is only 4.13x; it is included as the near-control that shows what the padding path should look like.
- crates/perry-runtime/src/string/pad.rs:
js_string_pad_start - crates/perry-runtime/src/string/pad.rs:
build_pad_chunk - crates/perry-runtime/src/string/pad.rs:
finish_pad_result - crates/perry-runtime/src/string/pad.rs:
js_string_repeat - crates/perry-runtime/src/string/char_ops.rs:
js_string_char_code_at - crates/perry-runtime/src/string/char_ops.rs:
utf16_unit_at
Source reading narrows the investigation; it does not establish exclusive runtime/compiler attribution. No compiler or runtime changes were made to obtain these measurements.
Agent scope and coordination
Owner scope is crates/perry-runtime/src/string/pad.rs. If you introduce a shared helper that classifies a string as all-ASCII or surrogate-free, coordinate with the toLowerCase/toUpperCase task — it needs the same predicate in string/slice_ops.rs, and the two should not grow separate implementations. Do not change the runtime string constructors or the WTF-8 flag semantics as part of this task.
Implementation work can proceed in separate branches. Serialize benchmark runs on any shared host; parallel timing runs invalidate small performance comparisons. Preserve language semantics and moving-GC safety.
- Coordinate with this benchmark task: perf(string): toLowerCase/toUpperCase have no ASCII fast path, costing 33x Node on pure-ASCII input #10090
Reproduce and remeasure
Everything needed for the workload is embedded below; no private repository, fixture, npm package, or shared prelude is required. Save a complete benchmark block under its indicated filename in /tmp/perry-builtin-repro/. Use Node 26.5.1 to match this baseline; it runs these TypeScript files directly.
From the Perry checkout/branch being evaluated:
mkdir -p /tmp/perry-builtin-repro
cargo build --release --locked -p perry -p perry-runtime-static -p perry-stdlib-static
export PERRY_RUNTIME_DIR="$PWD/target/release"
export TZ=UTC LC_ALL=en_US.UTF-8
git rev-parse HEAD
node --version
target/release/perry compile /tmp/perry-builtin-repro/string-pad-start-ascii.ts --no-auto-optimize -o /tmp/perry-builtin-repro/appTo reproduce the historical baseline, use the pinned commit above in a separate checkout and build the compiler and both libraries there. Repeat compilation for each additional benchmark below. Run this small driver from the same checkout, changing name and sizes for that benchmark:
import json, math, subprocess
name = 'string-pad-start-ascii'
sizes = [100, 1000, 10000, 100000, 1000000]
result_on_stderr = False
stopped = set()
points = {"node": [], "perry": []}
for n in sizes:
pair = {}
for engine, cmd in [("node", ["node", f"/tmp/perry-builtin-repro/{name}.ts"]),
("perry", ["/tmp/perry-builtin-repro/app"])]:
if engine in stopped: continue
try:
p = subprocess.run(cmd + [str(n)], capture_output=True, text=True, timeout=60)
except subprocess.TimeoutExpired:
print(engine, n, "TIMEOUT"); stopped.add(engine); continue
if p.returncode:
print(engine, n, "ERROR", p.returncode, p.stderr, p.stdout); continue
r = json.loads(p.stderr if result_on_stderr else p.stdout)
pair[engine] = r
points[engine].append((n, r["ms_per_run"]))
print(engine, r)
if len(pair) == 2:
print("ratio", n, pair["perry"]["ms_per_run"] / pair["node"]["ms_per_run"],
"checksum_match", pair["perry"]["checksum"] == pair["node"]["checksum"])
def slope(rows):
if len(rows) < 2: return None
x = [math.log(n) for n, t in rows]; y = [math.log(t) for n, t in rows]
mx = sum(x)/len(x); my = sum(y)/len(y)
return sum((a-mx)*(b-my) for a,b in zip(x,y))/sum((a-mx)**2 for a in x)
print("slopes", {engine: slope(rows) for engine, rows in points.items()})Record before/after results from the same unchanged source, engine versions and host. The measured driver uses seeded setup outside timers, at least 200 ms AND five warmup runs, then seven samples with at least 20 ms measured work each. Fresh input is prepared before each timer for mutating workloads. The median per-run time is reported, with checksum consistency checked on every invocation. Timeouts cover setup, warmup and sampling, not just one builtin call.
Environment and limits
- CPU: Apple M1 Max; 10 logical cores; arm64.
- OS:
macOS-26.5-arm64-arm-64bit-Mach-O; target: native host. - Node:
v26.5.1; Perry:perry 0.5.1531; build: release from source. - Compile flag:
--no-auto-optimize; compiler and both matching runtime archives were rebuilt together. - The pinned source revision and compiler/runtime/Node artifact hashes were unchanged throughout the sweep.
- Load average at measurement start:
[58.3896484375, 53.017578125, 57.90087890625]; end:[25.240234375, 30.416015625, 24.8681640625]. - Host contention limits precise constant-factor claims; repeat on a quiet host before asserting an improvement.
- Timings include timer overhead and checksum calculation. String hashes bound lookup count, not Unicode lookup cost; indexed consumption may also force Node string materialization.
Minimal correctness reductions
This issue is a performance workload; the complete checksum-gated reproducer follows.
Complete standalone benchmark sources
string-pad-start-ascii.ts — sizes [100, 1000, 10000, 100000, 1000000]
Size meanings and fresh-input policy are in the leading metadata. result_on_stderr for this file: False.
// @runtime {"name": "string-pad-start-ascii", "category": "strings", "verification": "checksum", "sources": [{"file": "crates/perry-runtime/src/string/pad.rs", "function": "js_string_pad_start"}, {"file": "crates/perry-runtime/src/string/pad.rs", "function": "build_pad_chunk"}, {"file": "crates/perry-runtime/src/string/pad.rs", "function": "finish_pad_result"}], "hypothesis": "Hypothesis: build_pad_chunk writes padding units into a pre-sized buffer, then finish_pad_result copies into an assembly buffer and runtime string, giving several linear passes.", "notes": "ascii variant. n counts repeated input tokens, not bytes; UTF-16 length and UTF-8 byte length differ for Unicode. String result hashes sample roughly 32 positions for long strings (at most 63 for short strings), plus length. Target length is 4*n+1 UTF-16 units. Pre-sized buffers and a constant number of output passes provide no source evidence of quadratic growing-prefix copies. Small-size overhead and allocation/materialization transitions can produce a finite-range slope gap. Timed result hashing may force Node materialization.", "asynchronous": false, "output_stderr": false, "fresh_input": false}
// Standalone file. Shared helpers/driver are inlined by common.py.
let seed = 0x12345678;
function rnd(): number {
seed ^= seed << 13; seed ^= seed >>> 17; seed ^= seed << 5;
return (seed >>> 0) / 4294967296;
}
function numbers(n: number): number[] {
const a: number[] = [];
for (let i = 0; i < n; i++) a.push(Math.floor(rnd() * 1000000));
return a;
}
function hashArray(a: number[]): number {
let h = a.length;
for (let i = 0; i < a.length; i++) h = (h * 31 + a[i]) % 1000000007;
return h;
}
// Bounded checksum work avoids making string slicing/indexing part of every
// string benchmark's asymptotic cost. The workload itself consumes its result.
function hashString(s: string): number {
let h = s.length;
const step = Math.max(1, Math.floor(s.length / 32));
for (let i = 0; i < s.length; i += step) h = (h * 31 + s.charCodeAt(i)) % 1000000007;
return h;
}
function setup(n: number): number { return n; }
function run(input: number): number { return hashString('!'.padStart(input * 4 + 1, "aBcD")); }
// Size is the final argument: both native Perry and Node expose it reliably.
const n = Number(process.argv[process.argv.length - 1]);
if (!(n > 0)) throw new Error("Expected a positive size argument");
function benchmarkMain(): void {
seed = 0x12345678;
const preparedInput = setup(n);
let checksum = 0;
let seen = false;
let warmMs = 0;
let warmRuns = 0;
while (warmMs < 200 || warmRuns < 5) {
seed = 0x12345678;
const input = preparedInput;
const start = performance.now();
const value = run(input);
const elapsed = performance.now() - start;
if (!(elapsed >= 0)) throw new Error("Invalid monotonic timer");
warmMs += elapsed;
warmRuns++;
if (seen && value !== checksum) throw new Error("CORRECTNESS: unstable checksum during warmup");
checksum = value;
seen = true;
}
const samples: number[] = [];
let runs = 0;
for (let sample = 0; sample < 7; sample++) {
let elapsed = 0;
let count = 0;
// Mutable workloads prepare fresh input BEFORE each timer; immutable
// workloads reuse setup. Neither preparation nor validation is measured.
while (elapsed < 20) {
seed = 0x12345678;
const input = preparedInput;
const start = performance.now();
const value = run(input);
const duration = performance.now() - start;
if (!(duration >= 0)) throw new Error("Invalid monotonic timer");
elapsed += duration;
count++;
if (value !== checksum) throw new Error("CORRECTNESS: unstable checksum during sampling");
}
samples.push(elapsed / count);
runs += count;
}
// Do not depend on Array.sort to compute the median of a sort benchmark.
for (let i = 1; i < samples.length; i++) {
const v = samples[i];
let j = i - 1;
while (j >= 0 && samples[j] > v) { samples[j + 1] = samples[j]; j--; }
samples[j + 1] = v;
}
console.log(JSON.stringify({name: "string-pad-start-ascii", category: "strings", n,
ms_per_run: samples[3], runs, checksum}));
}
benchmarkMain();string-repeat-ascii.ts — sizes [100, 1000, 10000, 100000, 1000000]
Size meanings and fresh-input policy are in the leading metadata. result_on_stderr for this file: False.
// @runtime {"name": "string-repeat-ascii", "category": "strings", "verification": "checksum", "sources": [{"file": "crates/perry-runtime/src/string/pad.rs", "function": "js_string_repeat"}], "hypothesis": "Hypothesis: repeat allocates its destination once and doubles the written prefix with O(log n) bulk-copy calls totaling O(n) bytes.", "notes": "ascii variant. n counts repeated input tokens, not bytes; UTF-16 length and UTF-8 byte length differ for Unicode. String result hashes sample roughly 32 positions for long strings (at most 63 for short strings), plus length. Indexed checksum consumption can force Node materialization, so this measures construction plus consumption. Perry's copy schedule is linear in total output bytes. Fixed overhead at small sizes and allocation/materialization transitions can create a finite-range slope gap without a growing-copy algorithm.", "asynchronous": false, "output_stderr": false, "fresh_input": false}
// Standalone file. Shared helpers/driver are inlined by common.py.
let seed = 0x12345678;
function rnd(): number {
seed ^= seed << 13; seed ^= seed >>> 17; seed ^= seed << 5;
return (seed >>> 0) / 4294967296;
}
function numbers(n: number): number[] {
const a: number[] = [];
for (let i = 0; i < n; i++) a.push(Math.floor(rnd() * 1000000));
return a;
}
function hashArray(a: number[]): number {
let h = a.length;
for (let i = 0; i < a.length; i++) h = (h * 31 + a[i]) % 1000000007;
return h;
}
// Bounded checksum work avoids making string slicing/indexing part of every
// string benchmark's asymptotic cost. The workload itself consumes its result.
function hashString(s: string): number {
let h = s.length;
const step = Math.max(1, Math.floor(s.length / 32));
for (let i = 0; i < s.length; i += step) h = (h * 31 + s.charCodeAt(i)) % 1000000007;
return h;
}
function setup(n: number): number { return n; }
function run(input: number): number { return hashString("aBcD".repeat(input)); }
// Size is the final argument: both native Perry and Node expose it reliably.
const n = Number(process.argv[process.argv.length - 1]);
if (!(n > 0)) throw new Error("Expected a positive size argument");
function benchmarkMain(): void {
seed = 0x12345678;
const preparedInput = setup(n);
let checksum = 0;
let seen = false;
let warmMs = 0;
let warmRuns = 0;
while (warmMs < 200 || warmRuns < 5) {
seed = 0x12345678;
const input = preparedInput;
const start = performance.now();
const value = run(input);
const elapsed = performance.now() - start;
if (!(elapsed >= 0)) throw new Error("Invalid monotonic timer");
warmMs += elapsed;
warmRuns++;
if (seen && value !== checksum) throw new Error("CORRECTNESS: unstable checksum during warmup");
checksum = value;
seen = true;
}
const samples: number[] = [];
let runs = 0;
for (let sample = 0; sample < 7; sample++) {
let elapsed = 0;
let count = 0;
// Mutable workloads prepare fresh input BEFORE each timer; immutable
// workloads reuse setup. Neither preparation nor validation is measured.
while (elapsed < 20) {
seed = 0x12345678;
const input = preparedInput;
const start = performance.now();
const value = run(input);
const duration = performance.now() - start;
if (!(duration >= 0)) throw new Error("Invalid monotonic timer");
elapsed += duration;
count++;
if (value !== checksum) throw new Error("CORRECTNESS: unstable checksum during sampling");
}
samples.push(elapsed / count);
runs += count;
}
// Do not depend on Array.sort to compute the median of a sort benchmark.
for (let i = 1; i < samples.length; i++) {
const v = samples[i];
let j = i - 1;
while (j >= 0 && samples[j] > v) { samples[j + 1] = samples[j]; j--; }
samples[j + 1] = v;
}
console.log(JSON.stringify({name: "string-repeat-ascii", category: "strings", n,
ms_per_run: samples[3], runs, checksum}));
}
benchmarkMain();string-pad-start-unicode.ts — sizes [100, 1000, 10000, 100000, 1000000]
Size meanings and fresh-input policy are in the leading metadata. result_on_stderr for this file: False.
// @runtime {"name": "string-pad-start-unicode", "category": "strings", "verification": "checksum", "sources": [{"file": "crates/perry-runtime/src/string/pad.rs", "function": "js_string_pad_start"}, {"file": "crates/perry-runtime/src/string/pad.rs", "function": "build_pad_chunk"}, {"file": "crates/perry-runtime/src/string/pad.rs", "function": "finish_pad_result"}, {"file": "crates/perry-runtime/src/string/char_ops.rs", "function": "js_string_char_code_at"}, {"file": "crates/perry-runtime/src/string/char_ops.rs", "function": "utf16_unit_at"}], "hypothesis": "Hypothesis: build_pad_chunk writes padding units into a pre-sized buffer, then finish_pad_result copies into an assembly buffer and runtime string, giving several linear passes. Unicode result hashing adds repeated UTF-16 prefix scans.", "notes": "unicode variant. n counts repeated input tokens, not bytes; UTF-16 length and UTF-8 byte length differ for Unicode. String result hashes sample roughly 32 positions for long strings (at most 63 for short strings), plus length. Target length is 4*n+1 UTF-16 units. Pre-sized buffers and a constant number of output passes provide no source evidence of quadratic growing-prefix copies. Small-size overhead and allocation/materialization transitions can produce a finite-range slope gap. Timed result hashing may force Node materialization. Default sizes tile complete Unicode filler tokens; custom sizes may truncate a surrogate pair. Unicode hashString adds roughly 32 Perry prefix scans, O(n) in total.", "asynchronous": false, "output_stderr": false, "fresh_input": false}
// Standalone file. Shared helpers/driver are inlined by common.py.
let seed = 0x12345678;
function rnd(): number {
seed ^= seed << 13; seed ^= seed >>> 17; seed ^= seed << 5;
return (seed >>> 0) / 4294967296;
}
function numbers(n: number): number[] {
const a: number[] = [];
for (let i = 0; i < n; i++) a.push(Math.floor(rnd() * 1000000));
return a;
}
function hashArray(a: number[]): number {
let h = a.length;
for (let i = 0; i < a.length; i++) h = (h * 31 + a[i]) % 1000000007;
return h;
}
// Bounded checksum work avoids making string slicing/indexing part of every
// string benchmark's asymptotic cost. The workload itself consumes its result.
function hashString(s: string): number {
let h = s.length;
const step = Math.max(1, Math.floor(s.length / 32));
for (let i = 0; i < s.length; i += step) h = (h * 31 + s.charCodeAt(i)) % 1000000007;
return h;
}
function setup(n: number): number { return n; }
function run(input: number): number { return hashString('!'.padStart(input * 4 + 1, "ä中😀Ö")); }
// Size is the final argument: both native Perry and Node expose it reliably.
const n = Number(process.argv[process.argv.length - 1]);
if (!(n > 0)) throw new Error("Expected a positive size argument");
function benchmarkMain(): void {
seed = 0x12345678;
const preparedInput = setup(n);
let checksum = 0;
let seen = false;
let warmMs = 0;
let warmRuns = 0;
while (warmMs < 200 || warmRuns < 5) {
seed = 0x12345678;
const input = preparedInput;
const start = performance.now();
const value = run(input);
const elapsed = performance.now() - start;
if (!(elapsed >= 0)) throw new Error("Invalid monotonic timer");
warmMs += elapsed;
warmRuns++;
if (seen && value !== checksum) throw new Error("CORRECTNESS: unstable checksum during warmup");
checksum = value;
seen = true;
}
const samples: number[] = [];
let runs = 0;
for (let sample = 0; sample < 7; sample++) {
let elapsed = 0;
let count = 0;
// Mutable workloads prepare fresh input BEFORE each timer; immutable
// workloads reuse setup. Neither preparation nor validation is measured.
while (elapsed < 20) {
seed = 0x12345678;
const input = preparedInput;
const start = performance.now();
const value = run(input);
const duration = performance.now() - start;
if (!(duration >= 0)) throw new Error("Invalid monotonic timer");
elapsed += duration;
count++;
if (value !== checksum) throw new Error("CORRECTNESS: unstable checksum during sampling");
}
samples.push(elapsed / count);
runs += count;
}
// Do not depend on Array.sort to compute the median of a sort benchmark.
for (let i = 1; i < samples.length; i++) {
const v = samples[i];
let j = i - 1;
while (j >= 0 && samples[j] > v) { samples[j + 1] = samples[j]; j--; }
samples[j + 1] = v;
}
console.log(JSON.stringify({name: "string-pad-start-unicode", category: "strings", n,
ms_per_run: samples[3], runs, checksum}));
}
benchmarkMain();string-repeat-unicode.ts — sizes [100, 1000, 10000, 100000, 1000000]
Size meanings and fresh-input policy are in the leading metadata. result_on_stderr for this file: False.
// @runtime {"name": "string-repeat-unicode", "category": "strings", "verification": "checksum", "sources": [{"file": "crates/perry-runtime/src/string/pad.rs", "function": "js_string_repeat"}, {"file": "crates/perry-runtime/src/string/char_ops.rs", "function": "js_string_char_code_at"}, {"file": "crates/perry-runtime/src/string/char_ops.rs", "function": "utf16_unit_at"}], "hypothesis": "Hypothesis: repeat allocates its destination once and doubles the written prefix with O(log n) bulk-copy calls totaling O(n) bytes. Unicode result hashing adds repeated UTF-16 prefix scans that may dominate construction.", "notes": "unicode variant. n counts repeated input tokens, not bytes; UTF-16 length and UTF-8 byte length differ for Unicode. String result hashes sample roughly 32 positions for long strings (at most 63 for short strings), plus length. Indexed checksum consumption can force Node materialization, so this measures construction plus consumption. Perry's copy schedule is linear in total output bytes. Fixed overhead at small sizes and allocation/materialization transitions can create a finite-range slope gap without a growing-copy algorithm. Unicode hashString makes roughly 32 prefix lookups whose total work is O(n); the measured ratio cannot be attributed solely to repeat.", "asynchronous": false, "output_stderr": false, "fresh_input": false}
// Standalone file. Shared helpers/driver are inlined by common.py.
let seed = 0x12345678;
function rnd(): number {
seed ^= seed << 13; seed ^= seed >>> 17; seed ^= seed << 5;
return (seed >>> 0) / 4294967296;
}
function numbers(n: number): number[] {
const a: number[] = [];
for (let i = 0; i < n; i++) a.push(Math.floor(rnd() * 1000000));
return a;
}
function hashArray(a: number[]): number {
let h = a.length;
for (let i = 0; i < a.length; i++) h = (h * 31 + a[i]) % 1000000007;
return h;
}
// Bounded checksum work avoids making string slicing/indexing part of every
// string benchmark's asymptotic cost. The workload itself consumes its result.
function hashString(s: string): number {
let h = s.length;
const step = Math.max(1, Math.floor(s.length / 32));
for (let i = 0; i < s.length; i += step) h = (h * 31 + s.charCodeAt(i)) % 1000000007;
return h;
}
function setup(n: number): number { return n; }
function run(input: number): number { return hashString("ä中😀Ö".repeat(input)); }
// Size is the final argument: both native Perry and Node expose it reliably.
const n = Number(process.argv[process.argv.length - 1]);
if (!(n > 0)) throw new Error("Expected a positive size argument");
function benchmarkMain(): void {
seed = 0x12345678;
const preparedInput = setup(n);
let checksum = 0;
let seen = false;
let warmMs = 0;
let warmRuns = 0;
while (warmMs < 200 || warmRuns < 5) {
seed = 0x12345678;
const input = preparedInput;
const start = performance.now();
const value = run(input);
const elapsed = performance.now() - start;
if (!(elapsed >= 0)) throw new Error("Invalid monotonic timer");
warmMs += elapsed;
warmRuns++;
if (seen && value !== checksum) throw new Error("CORRECTNESS: unstable checksum during warmup");
checksum = value;
seen = true;
}
const samples: number[] = [];
let runs = 0;
for (let sample = 0; sample < 7; sample++) {
let elapsed = 0;
let count = 0;
// Mutable workloads prepare fresh input BEFORE each timer; immutable
// workloads reuse setup. Neither preparation nor validation is measured.
while (elapsed < 20) {
seed = 0x12345678;
const input = preparedInput;
const start = performance.now();
const value = run(input);
const duration = performance.now() - start;
if (!(duration >= 0)) throw new Error("Invalid monotonic timer");
elapsed += duration;
count++;
if (value !== checksum) throw new Error("CORRECTNESS: unstable checksum during sampling");
}
samples.push(elapsed / count);
runs += count;
}
// Do not depend on Array.sort to compute the median of a sort benchmark.
for (let i = 1; i < samples.length; i++) {
const v = samples[i];
let j = i - 1;
while (j >= 0 && samples[j] > v) { samples[j + 1] = samples[j]; j--; }
samples[j + 1] = v;
}
console.log(JSON.stringify({name: "string-repeat-unicode", category: "strings", n,
ms_per_run: samples[3], runs, checksum}));
}
benchmarkMain();
Activity
proggeramlug commentedon Sep 12, 2026
Fixed by #10115, landed on
mainvia merge train #10139 at741e2a4fc5.The close keyword does not fire through a train branch, so closing manually.
perf(runtime): bulk-tile padStart/padEnd fill instead of per-code-uni…