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.
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 increasingi, 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é: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:
tscescapes 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:
RefCellborrow on every non-ASCII indexed read;Cost is roughly 6% of the string's bytes (one
PositionperCHECKPOINT_BYTES = 128), and only for non-ASCII strings that are actually indexed — against 100% for a materialised UTF-16 side buffer.Raising
CACHE_ENTRIESis not a fix: it moves the cliff to K+1 rather than removing it.Reproduction
Related: #10055, #10067, #10656, #10685.