Skip to content

perf(string): the UTF-16 index cache holds 4 entries, so 5 interleaved non-ASCII strings fall back to O(n²) — a 1,224× cliff at exactly K=5 #10688

Description

@proggeramlug

Summary

The UTF-16 index that makes non-ASCII string indexing linear lives in a four-entry thread-local cache keyed by string identity (CACHE_ENTRIES = 4, crates/perry-runtime/src/string/char_ops/utf16_index.rs). A program that interleaves indexed access across five or more non-ASCII strings evicts the entry it is about to need on every access, rebuilds the index from scratch each time, and falls straight back to the O(n²) behaviour #10055/#10656/#10685 were filed to remove.

It is a step function, not a gradual degradation: 1,224× at exactly K=5.

Measurement

Perry 0.5.1596 with #10656 and #10685 applied, macOS arm64. substring(i, i+8) at increasing i, interleaved round-robin across K strings of 120,000 chars each, normalised to nanoseconds per slice so the K values are comparable. The only difference between the two columns is a single leading é:

K ASCII control non-ASCII Node (non-ASCII)
1 67 ns 67 ns 67 ns
4 33 ns 50 ns 17 ns
5 27 ns 81,525 ns 13 ns
8 25 ns 80,972 ns 17 ns
12 39 ns 81,228 ns 11 ns

The ASCII control is flat across the whole range — ASCII never consults the cache — which isolates the cause to eviction rather than to "more strings" or memory pressure. Node is flat too.

Why this matters

The two fixes that just landed (#10656, #10685) wired the last accessors to the index, which removes the pathology for one hot string at a time. This is the same pathology reached by a different route, and five concurrent strings is not an exotic shape:

  • i18n / locale bundles — several message catalogues, all non-ASCII by definition;
  • any multi-file tool scanning more than four sources with accented text;
  • template or markdown rendering across several documents;
  • CSV/JSON processing over several non-ASCII columns or records.

tsc escapes it only because one 1.87 MB string dominates its scanning.

Suggested fix

Move the index into the string — lazily allocated on first indexed access, freed with the string — instead of a global four-slot cache. That removes:

  • the eviction cliff entirely (no fixed capacity to exceed);
  • the thread-local access and RefCell borrow on every non-ASCII indexed read;
  • the four-entry identity probe.

Cost is roughly 6% of the string's bytes (one Position per CHECKPOINT_BYTES = 128), and only for non-ASCII strings that are actually indexed — against 100% for a materialised UTF-16 side buffer.

Raising CACHE_ENTRIES is not a fix: it moves the cliff to K+1 rather than removing it.

Reproduction

function mk(n, seed) { const s = seed + "a".repeat(n - seed.length); return "é" + s.slice(1); }
function interleave(k, n) {
  const strs = []; for (let i = 0; i < k; i++) strs.push(mk(n, "s" + i));
  const t0 = Date.now();
  for (let i = 0; i + 8 < n; i += 8) for (let j = 0; j < k; j++) strs[j].substring(i, i + 8);
  return Date.now() - t0;
}
console.log("K=4", interleave(4, 120000), "ms");   // fast
console.log("K=5", interleave(5, 120000), "ms");   // ~1,200x slower

Related: #10055, #10067, #10656, #10685.

Activity

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

    performanceRuntime, 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