Repository navigation
perf(string): non-ASCII charCodeAt and bracket scans become quadratic #10055
Description
What happened
At measured baseline 9495bfc, sequential indexing of an umlaut/CJK/emoji string repeatedly decodes the prefix. The charCodeAt and bracket-index workloads exceed 1,000x Node at their largest common completed size, n=10,000, and Perry times out at n=100,000; ASCII controls complete through n=1,000,000 with near-linear scaling. Reproduce on current main first: these are pinned-baseline measurements, not measurements of current main.
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-char-code-at-unicode — TIMEOUT
| n | Node ms / status | Perry ms / status | ratio | Node checksum | Perry checksum |
|---|---|---|---|---|---|
| 100 | 0.015156 | 0.215325 | 14.21× | 319467163 | 319467163 |
| 1000 | 0.169091 | 20.179833 | 119.34× | 431622199 | 431622199 |
| 10000 | 1.849000 | 2043.423958 | 1105.15× | 36132863 | 36132863 |
| 100000 | 15.787104 | TIMEOUT | — | 49951631 | — |
| 1000000 | 378.772833 | SKIPPED | — | 481167302 | — |
Log(time)/log(n) least-squares slopes: Perry 1.989, Node 1.077, delta 0.912.
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.
Slopes cover different completed sizes: Node [100, 1000, 10000, 100000, 1000000], Perry [100, 1000, 10000].
perry n=100000: TIMEOUT, exit -9
Process exceeded 60 s
string-index-bracket-unicode — TIMEOUT
| n | Node ms / status | Perry ms / status | ratio | Node checksum | Perry checksum |
|---|---|---|---|---|---|
| 100 | 0.017616 | 0.262018 | 14.87× | 319467163 | 319467163 |
| 1000 | 0.188020 | 20.659167 | 109.88× | 431622199 | 431622199 |
| 10000 | 1.969405 | 2063.259084 | 1047.66× | 36132863 | 36132863 |
| 100000 | 15.864709 | TIMEOUT | — | 49951631 | — |
| 1000000 | 158.048458 | SKIPPED | — | 481167302 | — |
Log(time)/log(n) least-squares slopes: Perry 1.948, Node 0.983, delta 0.965.
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. The returned one-unit string is consumed with charCodeAt(0), a constant-size checksum operation.
Slopes cover different completed sizes: Node [100, 1000, 10000, 100000, 1000000], Perry [100, 1000, 10000].
perry n=100000: TIMEOUT, exit -9
Process exceeded 60 s
string-char-code-at-ascii — OK
| n | Node ms / status | Perry ms / status | ratio | Node checksum | Perry checksum |
|---|---|---|---|---|---|
| 100 | 0.012323 | 0.008066 | 0.65× | 464151292 | 464151292 |
| 1000 | 0.153895 | 0.100361 | 0.65× | 710929850 | 710929850 |
| 10000 | 1.418564 | 1.117556 | 0.79× | 535454277 | 535454277 |
| 100000 | 12.100791 | 10.322146 | 0.85× | 35382078 | 35382078 |
| 1000000 | 122.271625 | 104.973375 | 0.86× | 153135489 | 153135489 |
Log(time)/log(n) least-squares slopes: Perry 1.024, Node 0.989, delta 0.035.
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.
string-index-bracket-ascii — OK
| n | Node ms / status | Perry ms / status | ratio | Node checksum | Perry checksum |
|---|---|---|---|---|---|
| 100 | 0.012376 | 0.013933 | 1.13× | 464151292 | 464151292 |
| 1000 | 0.138478 | 0.165613 | 1.20× | 710929850 | 710929850 |
| 10000 | 1.455673 | 1.594401 | 1.10× | 535454277 | 535454277 |
| 100000 | 13.069209 | 14.951959 | 1.14× | 35382078 | 35382078 |
| 1000000 | 134.297292 | 156.234875 | 1.16× | 153135489 | 153135489 |
Log(time)/log(n) least-squares slopes: Perry 1.006, Node 1.005, delta 0.001.
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. The returned one-unit string is consumed with charCodeAt(0), a constant-size checksum operation.
What is expected / acceptance criteria
- Reproduce the Unicode and ASCII controls on current main and record the exact revision before choosing an indexing strategy.
- Keep every checksum equal to Node, including the two distinct UTF-16 surrogate halves of each emoji; validate negative/out-of-range indexes, BMP characters, lone surrogates, short strings, and heap strings.
- Complete the Unicode sequential-indexing workloads through n=100,000 and n=1,000,000 within the existing 60-second process limit, and demonstrate an exponent no greater than 1.25 over at least three large completed sizes. Report the same-size before/after medians rather than comparing fits over different ranges.
- Validate nonsequential and repeated random index access as well as the sequential benchmark so any lookup cache remains correct for arbitrary call order and string lifetimes.
- Rerun both ASCII controls and quantify any performance regression; preserve their existing constant-time indexed access.
- If indexing metadata or caches retain pointers, cover moving-GC lifetime/rooting and bounded WTF-8 reads. Reassess Unicode trim/repeat/padStart ratios with the shared checksum-cost improvement identified explicitly.
Implementation to inspect
Hypothesis: Source-grounded hypothesis at 9495bfc: crates/perry-runtime/src/string/char_ops.rs::js_string_char_code_at (around line 65) uses direct indexing for ASCII but calls utf16_unit_at (around line 102) otherwise. utf16_unit_at starts byte and UTF-16 counters at zero and walks the WTF-8 payload until the requested code unit. The bracket path js_string_index_get (around line 217) delegates to js_string_char_at (around line 296), which uses the same prefix walk and materializes the selected code-unit string. Calling these APIs for every index therefore performs a quadratic total amount of prefix decoding. The completed Perry sizes n=100/1k/10k have fitted exponents about 1.99 and 1.95. TIMEOUT takes precedence over a performance label in the benchmark classifier.
These two workloads make the indexed operations themselves observable: charCodeAt folds every returned code unit into an order-sensitive numeric checksum; bracket indexing calls charCodeAt(0) on each returned one-unit string, which is constant-size consumption. They do not use hashString on the growing source inside the timed loop. Their scaling therefore is not the separate roughly-32-prefix-scan checksum confound seen in Unicode trim/repeat/padStart. However, fixing this shared indexing path will also change those other workloads' reported ratios, so attribution must account for that overlap. n counts repeated tokens: the Unicode token ä中😀Ö contains five UTF-16 units.
No matching open indexing-scaling issue was found in the reviewed list or title search. Closed #4793 established WTF-8/lone-surrogate semantics; #2948 fixed the requirement that indexed character access remain code-unit based; #6887 covered short-string pointer dispatch crashes rather than the long non-ASCII prefix-decoding cost.
- crates/perry-runtime/src/string/char_ops.rs:
js_string_char_code_at - crates/perry-runtime/src/string/char_ops.rs:
js_string_index_get
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
Primary implementation is string/char_ops.rs, with possible StringHeader/cache changes in string/mod.rs and allocation/GC helpers. Coordinate those shared files with the Unicode-for-of and Unicode-slice correctness issues. An indexing optimization must preserve code-unit API semantics; the for-of issue requires code-point iteration and can be corrected separately in HIR lowering. Unicode trim/repeat/padStart measurements share this lookup cost through their checksum helper.
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): trim scans and copies the entire interior for constant-size edge whitespace #10054
-
Coordinate with this benchmark task: runtime: string.slice splits astral characters incorrectly and makes suffix parsing quadratic #10061
-
Coordinate with this benchmark task: parity(string): typed for-of splits astral characters into surrogate iterations #10062
-
Related history/context: String representation: support lone surrogates (WTF-8) for UTF-16 semantics #4793
-
Related history/context: runtime: make String.prototype.at return UTF-16 code units for surrogate pairs #2948
-
Related history/context: SIGSEGV: indexing or iterating a short concatenated string (SSO value mask-cast to StringHeader*) #6887
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-char-code-at-unicode.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-char-code-at-unicode'
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-char-code-at-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-char-code-at-unicode", "category": "strings", "verification": "checksum", "sources": [{"file": "crates/perry-runtime/src/string/char_ops.rs", "function": "js_string_char_code_at"}], "hypothesis": "Hypothesis: ASCII uses direct byte indexing, while non-ASCII utf16_unit_at walks the payload from the start for each index, making a tokenizer quadratic.", "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. ", "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): string { return "ä中😀Ö".repeat(n); }
function run(input: string): number {
let h = 0;
for (let i = 0; i < input.length; i++) h = (h * 31 + input.charCodeAt(i)) % 1000000007;
return h;
}
// 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-char-code-at-unicode", category: "strings", n,
ms_per_run: samples[3], runs, checksum}));
}
benchmarkMain();string-index-bracket-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-index-bracket-unicode", "category": "strings", "verification": "checksum", "sources": [{"file": "crates/perry-runtime/src/string/char_ops.rs", "function": "js_string_index_get"}], "hypothesis": "Hypothesis: numeric property lookup materializes a one-unit string and can repeatedly decode the non-ASCII prefix.", "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. The returned one-unit string is consumed with charCodeAt(0), a constant-size checksum operation.", "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): string { return "ä中😀Ö".repeat(n); }
function run(input: string): number {
let h = 0;
for (let i = 0; i < input.length; i++) h = (h * 31 + input[i].charCodeAt(0)) % 1000000007;
return h;
}
// 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-index-bracket-unicode", category: "strings", n,
ms_per_run: samples[3], runs, checksum}));
}
benchmarkMain();string-char-code-at-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-char-code-at-ascii", "category": "strings", "verification": "checksum", "sources": [{"file": "crates/perry-runtime/src/string/char_ops.rs", "function": "js_string_char_code_at"}], "hypothesis": "Hypothesis: ASCII uses direct byte indexing, while non-ASCII utf16_unit_at walks the payload from the start for each index, making a tokenizer quadratic.", "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. ", "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): string { return "aBcD".repeat(n); }
function run(input: string): number {
let h = 0;
for (let i = 0; i < input.length; i++) h = (h * 31 + input.charCodeAt(i)) % 1000000007;
return h;
}
// 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-char-code-at-ascii", category: "strings", n,
ms_per_run: samples[3], runs, checksum}));
}
benchmarkMain();string-index-bracket-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-index-bracket-ascii", "category": "strings", "verification": "checksum", "sources": [{"file": "crates/perry-runtime/src/string/char_ops.rs", "function": "js_string_index_get"}], "hypothesis": "Hypothesis: numeric property lookup materializes a one-unit string and can repeatedly decode the non-ASCII prefix.", "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. The returned one-unit string is consumed with charCodeAt(0), a constant-size checksum operation.", "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): string { return "aBcD".repeat(n); }
function run(input: string): number {
let h = 0;
for (let i = 0; i < input.length; i++) h = (h * 31 + input[i].charCodeAt(0)) % 1000000007;
return h;
}
// 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-index-bracket-ascii", category: "strings", n,
ms_per_run: samples[3], runs, checksum}));
}
benchmarkMain();
Activity
proggeramlug commentedon Sep 12, 2026
Fixed by #10067, landed on
mainvia merge train #10083 at78ef1632e.The close keyword did not fire because the train merges as its own branch, so closing manually.
fix(runtime): make dense array shift drains linear
fix(string): resolve slice start offsets through the UTF-16 index (#1…
fix(string): make codePointAt linear on non-ASCII strings (#10656)
fix(string): resolve slice start offsets through the UTF-16 index (#1…