Skip to content

perf(string): drop localeCompare's two per-comparison allocations, and document the approximate ordering as intentional #10094

Description

@proggeramlug

What happened

On pinned Perry 9495bfc, localeCompare costs 7.80x Node on ASCII and 6.30x on Unicode, because locale_compare_default allocates two fresh lowercased Strings on every single comparison. Inside a sort that cost is paid O(n log n) times. It also orders by code point rather than by collation weights, so "ä".localeCompare("😀") returns -1 where Node returns 1, and the sort-objects-locale-key-unicode checksum diverges from Node. That ordering divergence is now an accepted, intentional limitation — see the scope note below. Do not try to fix it. This issue is the allocation cost plus making the documentation honest.

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-locale-compare-ascii — SLOW

n Node ms / status Perry ms / status ratio Node checksum Perry checksum
100 0.002963 0.029381 9.92× 734908598 734908598
1000 0.033858 0.293159 8.66× 408710883 408710883
10000 0.398906 2.951738 7.40× 420981723 420981723
100000 3.830729 29.636333 7.74× 370082741 370082741
1000000 38.291083 297.125833 7.76× 532146590 532146590

Log(time)/log(n) least-squares slopes: Perry 1.001, Node 1.028, delta -0.026.

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. n seeded short labels, explicit en-US locale; checksum normalizes comparison result to sign as the API specifies.

string-locale-compare-unicode — SLOW

n Node ms / status Perry ms / status ratio Node checksum Perry checksum
100 0.008799 0.056051 6.37× 734908598 734908598
1000 0.089800 0.576973 6.43× 408710883 408710883
10000 0.878049 5.655084 6.44× 420981723 420981723
100000 8.975472 57.779750 6.44× 370082741 370082741
1000000 90.106000 571.383666 6.34× 532146590 532146590

Log(time)/log(n) least-squares slopes: Perry 1.002, Node 1.002, delta -0.000.

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. n seeded short labels, explicit en-US locale; checksum normalizes comparison result to sign as the API specifies.

sort-objects-locale-key-unicode — CORRECTNESS

n Node ms / status Perry ms / status ratio Node checksum Perry checksum
100 0.087271 0.441680 5.06× 466836667 953887857
1000 1.205924 5.521489 4.58× 54763927 77095570
10000 16.192312 69.037083 4.26× 828669467 731022764
100000 231.026458 934.027792 4.04× 391157046 371047858
1000000 3812.927583 TIMEOUT — 155096472 —

Log(time)/log(n) least-squares slopes: Perry 1.107, Node 1.156, delta -0.049.

Workload: n objects; keys and original ids are fully hashed in order. Default-locale ordering may differ because Perry documents approximate collation; report mismatches as correctness findings.

Slopes cover different completed sizes: Node [100, 1000, 10000, 100000, 1000000], Perry [100, 1000, 10000, 100000].

perry n=1000000: TIMEOUT, exit -9

Process exceeded 60 s

What is expected / acceptance criteria

  • Do not change the ordering. Confirm before and after that sort-objects-locale-key-unicode still produces Perry's existing checksum at every size — this issue must be behaviour-preserving for ordering. A change to the sorted order, in either direction, means the fix has overstepped.
  • Remove both per-comparison to_lowercase() allocations from locale_compare_default, comparing case-folded characters in a single walk that short-circuits at the first difference. Rerun string-locale-compare-ascii and string-locale-compare-unicode at 100, 1k, 10k, 100k and 1M and show the ratio improves from its current 7.80x/6.30x; report the sort-objects-locale-key-unicode timing too, since a sort pays this cost O(n log n) times.
  • Prove the comparator is a strict weak ordering with a randomized test over a corpus spanning ASCII, Latin-1 accented characters, CJK, emoji, combining marks and lone surrogates: antisymmetry (sign(cmp(a,b)) === -sign(cmp(b,a))), transitivity across triples, and cmp(a,a) === 0. An inconsistent comparator makes Array.prototype.sort results implementation-dependent, which is a real bug even when the ordering is approximate.
  • Preserve canonical equivalence, which the existing doc comment calls a mandatory part of the contract: decomposed and precomposed forms of the same string must compare equal. Also cover case-only differences (lowercase before uppercase, per the current tertiary pass), empty strings, identical strings, one string a prefix of the other, and strings differing only after a long common prefix.
  • Make the limitation public and accurate. Rewrite the js_string_locale_compare doc comment to state the actual guarantee — case-insensitive code-point ordering with a case tiebreak, no collation weights, no locale tailoring, ordering will differ from Node for accented letters, symbols and emoji — and say that this is intentional rather than unimplemented.
  • Fix the public parity table in docs/typescript-parity-gaps.md: the localeCompare() row currently reads "Missing (needs Intl)", which is incorrect. It should say the method is implemented with approximate ordering and no collation table, and link this issue. While there, check the neighbouring toLocaleLowerCase() / toLocaleUpperCase() row for the same staleness and correct it if it is also wrong.

Implementation to inspect

Hypothesis: Scope decision, already made — read this before starting. Matching Node's ordering requires a Unicode collation table (DUCET / CLDR root weights). Perry has decided not to ship one: the data cost is not justified by the use, and docs/typescript-parity-gaps.md already records the reasoning for the namespace as a whole ("Full Intl — requires ICU data (~27MB) or system ICU linkage"). So the correct ordering is explicitly out of scope, and the sort-objects-locale-key-unicode checksum is expected to stay divergent from Node. Do not hand-tune weights, special-case scripts, or otherwise chase that checksum — overfitting a comparator to one benchmark's keys is worse than the honest approximation, and will be rejected in review.

What this issue asks for is the cost and the honesty.

The cost. locale_compare_default in compare.rs begins with let a_lower = a_str.to_lowercase(); let b_lower = b_str.to_lowercase(); and compares those with String::cmp. Two heap allocations and two full Unicode case-mapping passes, per comparison, discarded immediately. Only when the lowercased forms are equal does it fall through to a per-character tertiary pass that orders by case. Both allocations are avoidable: the primary pass can compare case-folded characters as it walks the two strings, short-circuiting at the first difference, without materializing either lowercased string. Most comparisons differ within the first few characters, so the current code does O(n) allocation and case-mapping work to answer a question that usually needs O(1) reads.

The honesty. The doc comment above js_string_locale_compare says "We don't ship a true ICU collator", which is accurate but buried. Worse, the public parity table in docs/typescript-parity-gaps.md lists the localeCompare() row as "Missing (needs Intl)" — which is simply wrong: the method exists, returns sensible answers for most inputs, and is used. A reader deciding whether Perry fits their application currently cannot learn what it actually does.

For context on what the approximation costs in practice, the reduced diagnostic below shows the measured divergence, and the shape of the gap is: code point order sorts accented letters after the entire plain alphabet (ä is U+00E4, above z at U+007A) and sorts symbols and emoji last, whereas root collation groups accented letters with their base letter and sorts symbols before letters. Note also that the "right" answer is locale-dependent even with a table — German sorts ä with a, Swedish sorts it after z — which is further reason the single-table approach was not worth its bytes here.

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 locale_compare_default and js_string_locale_compare in crates/perry-runtime/src/string/compare.rs, plus the two documentation updates. Note the interaction with crates/perry-runtime/src/string/slice_ops.rs: the cost being removed here comes from to_lowercase(), the same Unicode case-mapping machinery the separate toLowerCase/toUpperCase ASCII fast-path task is changing. Check that task before starting — if it lands an all-ASCII predicate or a case-folding helper, reuse it rather than writing a second one; if this task lands first, say on the issue what helper you added. Do not add a collation table, an ICU dependency, or per-locale tailoring under this issue; that decision has been made and reversing it is a separate conversation, not a pull request.

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.

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-locale-compare-ascii.ts --no-auto-optimize -o /tmp/perry-builtin-repro/app

To 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-locale-compare-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

locale-compare-order.ts

Normalized default-locale comparison signs for keys present in the sorting workload; locale and timezone match the sweep.

function compare(a: string, b: string): number {
  const v = a.localeCompare(b);
  return v < 0 ? -1 : v > 0 ? 1 : 0;
}
console.log(compare("ä", "😀"));
console.log(compare("Ö", "字"));

Compile this reduction with the same command, substituting its filename. Run each engine once; no size argument is needed.

node (SUCCESS):

1
-1

perry (SUCCESS):

-1
-1

Complete standalone benchmark sources

string-locale-compare-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-locale-compare-ascii", "category": "strings", "verification": "checksum", "sources": [{"file": "crates/perry-runtime/src/string/compare.rs", "function": "js_string_locale_compare"}], "hypothesis": "Hypothesis: canonical normalization and custom primary/case collation add per-comparison work and may diverge from ICU semantics.", "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. n seeded short labels, explicit en-US locale; checksum normalizes comparison result to sign as the API specifies.", "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[] {
  const choices = ["aBcD" + 'a', "aBcD" + 'z', "aBcD" + 'B', "aBcD" + '7'];
  const values: string[] = [];
  for (let i = 0; i < n; i++) values.push(choices[Math.floor(rnd() * 4)]);
  return values;
}
function run(input: string[]): number {
  let h = 0;
  for (let i = 1; i < input.length; i++) {
    const result = input[i - 1].localeCompare(input[i], 'en-US');
    h = (h * 31 + (result < 0 ? 1 : result > 0 ? 2 : 3)) % 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-locale-compare-ascii", category: "strings", n,
    ms_per_run: samples[3], runs, checksum}));
}
benchmarkMain();
string-locale-compare-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-locale-compare-unicode", "category": "strings", "verification": "checksum", "sources": [{"file": "crates/perry-runtime/src/string/compare.rs", "function": "js_string_locale_compare"}], "hypothesis": "Hypothesis: canonical normalization and custom primary/case collation add per-comparison work and may diverge from ICU semantics.", "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. n seeded short labels, explicit en-US locale; checksum normalizes comparison result to sign as the API specifies.", "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[] {
  const choices = ["ä中😀Ö" + 'a', "ä中😀Ö" + 'z', "ä中😀Ö" + 'B', "ä中😀Ö" + '7'];
  const values: string[] = [];
  for (let i = 0; i < n; i++) values.push(choices[Math.floor(rnd() * 4)]);
  return values;
}
function run(input: string[]): number {
  let h = 0;
  for (let i = 1; i < input.length; i++) {
    const result = input[i - 1].localeCompare(input[i], 'en-US');
    h = (h * 31 + (result < 0 ? 1 : result > 0 ? 2 : 3)) % 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-locale-compare-unicode", category: "strings", n,
    ms_per_run: samples[3], runs, checksum}));
}
benchmarkMain();
sort-objects-locale-key-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": "sort-objects-locale-key-unicode", "category": "sort", "verification": "checksum", "sources": [{"file": "crates/perry-runtime/src/array/sort.rs", "function": "js_array_sort_with_comparator"}, {"file": "crates/perry-runtime/src/string/compare.rs", "function": "js_string_locale_compare"}, {"file": "crates/perry-runtime/src/string/compare.rs", "function": "locale_compare_default"}], "hypothesis": "Hypothesis: locale_compare_default compares newly lowercased Rust strings lexicographically with String::cmp instead of applying ICU locale collation weights, which may explain different ordering and checksums for umlaut, CJK and emoji keys.", "notes": "n objects; keys and original ids are fully hashed in order. Default-locale ordering may differ because Perry documents approximate collation; report mismatches as correctness findings.", "asynchronous": false, "output_stderr": false, "fresh_input": true}
// 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 makeStrings(n: number): string[] {
  const tokens: string[] = ["ä", "Ö", "漢", "字", "😀", "🚀"];
  const a: string[] = [];
  for (let i = 0; i < n; i++) a.push(tokens[Math.floor(rnd() * tokens.length)] + ":" + String(Math.floor(rnd() * 1000000)));
  return a;
}

function setup(n: number): {key: string, id: number}[] {
  const keys = makeStrings(n);
  const a: {key: string, id: number}[] = [];
  for (let i = 0; i < n; i++) a.push({key: keys[i], id: i});
  return a;
}
function run(a: {key: string, id: number}[]): number {
  a.sort((x, y) => x.key.localeCompare(y.key));
  let h = a.length;
  for (let i = 0; i < a.length; i++) h = (h * 31 + hashString(a[i].key) + a[i].id) % 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;
  
  let checksum = 0;
  let seen = false;
  let warmMs = 0;
  let warmRuns = 0;
  while (warmMs < 200 || warmRuns < 5) {
    seed = 0x12345678;
    const input = setup(n);
    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 = setup(n);
      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: "sort-objects-locale-key-unicode", category: "sort", n,
    ms_per_run: samples[3], runs, checksum}));
}
benchmarkMain();

Activity

  1. added
    bugConfirmed defect or regression
    performanceRuntime, compile-time, build-size, or memory performance
    parityCompatibility gap with Node.js, ECMAScript, or the supported ecosystem
    and removed
    bugConfirmed defect or regression
    on Sep 12, 2026
  2. changed the title [-]bug(string): localeCompare compares lowercased code points instead of collating, reversing Node's order for "ä" vs "😀"[/-] [+]perf(string): drop localeCompare's two per-comparison allocations, and document the approximate ordering as intentional[/+] on Sep 12, 2026
  3. proggeramlug commented on Sep 12, 2026

    @proggeramlug
    ContributorAuthor

    Reproduced on current main (dc0d876fe) and fixed in #10111.

    Baseline confirmed, not inherited. The reduction still prints Perry -1 / -1
    against Node 1 / -1 on a fresh main build, and the base
    sort-objects-locale-key-unicode checksums match the ones in this issue at
    every size, so the numbers below are a same-host A/B against main, not a
    comparison to the pinned revision.

    Ordering preserved. Perry-side checksums are byte-identical between the base
    and fixed runtimes at every size of every workload, including
    sort-objects-locale-key-unicode at n=1,000,000 (138957853 — obtained from
    the base runtime with the 60 s cap lifted, since that row times out on main).
    The reduction prints -1 / -1 on both arms.

    Results (quiet M1 mini, Node v26.5.1, arms interleaved, three rounds per
    point, --no-auto-optimize, both arms built from the same tree with the same
    compiler package set):

    workload n base fixed
    sort-objects-locale-key-unicode 100 2.61× Node 0.79× Node
    sort-objects-locale-key-unicode 10,000 2.86× 0.82×
    sort-objects-locale-key-unicode 100,000 2.92× 0.85×
    sort-objects-locale-key-unicode 1,000,000 TIMEOUT (7.26 s/run uncapped) 0.98× (2.62 s)
    string-locale-compare-unicode 1,000,000 6.80× 4.54×
    string-locale-compare-ascii 1,000,000 8.17× 7.77×

    The sort row is the one that isolates the comparator: it calls
    localeCompare(other) with no locales argument. It goes from 2.9× Node to
    0.85× Node, and the 1M size that timed out now finishes in 2.62 s.


    The two string-locale-compare-* rows are not mostly collation

    Worth recording, because the hypothesis in this issue only accounts for part of
    what those two benchmarks measure. Both call localeCompare(other, 'en-US'),
    and a locales argument makes codegen emit js_string_validate_collator_args
    on every call — CanonicalizeLocaleList plus the InitializeCollator
    option reads, the spec-mandated observable side effect from #5906. It is
    re-derived from the same constant string on every comparison.

    Same workloads, same everything, with only the 'en-US' argument dropped
    (n=1,000,000):

    workload base ms fixed ms base ×Node fixed ×Node
    string-locale-compare-ascii with 'en-US' 290.3 276.1 8.17× 7.77×
    string-locale-compare-ascii without it 61.0 50.7 1.71× 1.42×
    string-locale-compare-unicode with 'en-US' 563.7 376.4 6.80× 4.54×
    string-locale-compare-unicode without it 305.2 148.1 3.77× 1.83×

    So ~79% of string-locale-compare-ascii's base time and ~46% of
    string-locale-compare-unicode's is locale-argument validation rather than
    collation, which is why the ASCII row only improves 5% here. With the argument
    gone the comparator's own gain is visible: 1.20× faster on ASCII, 2.06× on
    Unicode, 3.4× on the sort workload.

    Memoizing that validation looks safe and spec-preserving — for a string
    locales with undefined options it is a pure function of the input, so a
    cache changes nothing observable, including the RangeError for a malformed
    tag. I left it out of #10111 because it lives in intl.rs / locale.rs under
    #5906/#2781, and this issue draws its ownership boundary at compare.rs plus
    the docs. Happy to open it as a separate issue/PR — say the word.


    Coordination note for #10090

    Per the scope note: #10111 adds two case-folding helpers in
    crates/perry-runtime/src/string/compare.rs, both private to that module and
    both reusable by the toLowerCase/toUpperCase ASCII fast-path work:

    • LowerChars — str::to_lowercase as a borrow-only iterator over scalars,
      with an ASCII one-to-one fast path and an explicit LowerStep::Contextual
      signal for U+03A3 (final sigma), the one context-dependent mapping, instead
      of guessing it.
    • locale_primary_cmp(a, b) -> Option<Ordering> — the case-insensitive
      comparison built on it, with a byte-level loop for the leading all-ASCII run.

    If #10090 lands an all-ASCII predicate or a shared case-folding helper first, I
    will rebase onto it rather than keep a second one; if #10111 lands first these
    are here to reuse. Neither touches slice_ops.rs, so there is no file conflict
    either way.

  4. proggeramlug commented on Sep 12, 2026

    @proggeramlug
    ContributorAuthor

    Fixed by #10111, landed on main via merge train #10121 at bcb7d4c390.

    The close keyword did not fire because the train merges as its own branch, so closing manually.

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

    parityCompatibility gap with Node.js, ECMAScript, or the supported ecosystemperformanceRuntime, compile-time, build-size, or memory performance

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions