No rope / cons-string representation: accumulator concatenation is quadratic
grep -riE '\brope\b|cons_?str' crates/perry-runtime/src returns zero hits. Every
string concatenation materializes bytes eagerly, so the standard accumulate-in-a-loop
pattern is O(n^2) where V8's cons-strings make it O(n) amortized.
This is the largest single cost in the worst benchmark row. iso_miss (Perry 2.13x
Node, the widest remaining gap) spends 17.9% of self time in js_string_concat_chain,
driven by one line:
seen = seen + "[" + names[i] + "]"; // iso_miss.ts:205, per name per env frame
concat_chain itself is already well optimized — #7912 fuses the chain and sizes the
scratch to the real arity, and its comment cites this exact line. The remaining cost is
not call overhead, it is copying the accumulated prefix on every append.
Measured
function build(n: number): number {
let s = "";
for (let i = 0; i < n; i++) s = s + "[" + "abc" + "]";
return s.length;
}
| n |
Perry |
Node 26.5.1 |
| 2000 |
11 ms |
1 ms |
| 4000 |
63 ms |
0 ms |
| 8000 |
459 ms |
1 ms |
| 16000 |
1888 ms |
1 ms |
Perry's time grows ~4-7x per doubling (quadratic); Node is flat.
This is not Node deferring the work. Forcing full materialization by reading every
997th code unit out of the result:
| n |
Perry |
Node |
| 2000 |
10 ms |
1 ms |
| 16000 |
2057 ms |
4 ms |
Node still finishes in 4 ms, so its cons-string plus a single flatten is genuinely linear.
At n=16000 Perry is 514x slower on the same work.
Suggested direction
A cons-string node (left, right, total length) with lazy flattening on first indexed
read is the standard fix and is what V8/JSC do:
+ on two strings allocates an O(1) cons node instead of copying
.length is O(1) from the stored total
- the first operation needing contiguous bytes flattens once, O(n)
The parts that already exist and would need to participate: js_string_concat_chain
(crates/perry-runtime/src/string/concat.rs), js_string_equals, js_string_compare,
and js_get_string_pointer_unified — anything reading raw bytes needs a flatten call
first. The GC also needs to trace the two child edges of a cons node.
This is a real architectural change rather than a local optimization, which is why I am
filing it rather than attempting it: it touches the string representation everywhere. But
it is the single biggest remaining item on the worst row, and accumulator concatenation is
one of the most common patterns in real JavaScript.
No rope / cons-string representation: accumulator concatenation is quadratic
grep -riE '\brope\b|cons_?str' crates/perry-runtime/srcreturns zero hits. Everystring concatenation materializes bytes eagerly, so the standard accumulate-in-a-loop
pattern is O(n^2) where V8's cons-strings make it O(n) amortized.
This is the largest single cost in the worst benchmark row.
iso_miss(Perry 2.13xNode, the widest remaining gap) spends 17.9% of self time in
js_string_concat_chain,driven by one line:
concat_chainitself is already well optimized — #7912 fuses the chain and sizes thescratch to the real arity, and its comment cites this exact line. The remaining cost is
not call overhead, it is copying the accumulated prefix on every append.
Measured
Perry's time grows ~4-7x per doubling (quadratic); Node is flat.
This is not Node deferring the work. Forcing full materialization by reading every
997th code unit out of the result:
Node still finishes in 4 ms, so its cons-string plus a single flatten is genuinely linear.
At n=16000 Perry is 514x slower on the same work.
Suggested direction
A cons-string node (
left,right, total length) with lazy flattening on first indexedread is the standard fix and is what V8/JSC do:
+on two strings allocates an O(1) cons node instead of copying.lengthis O(1) from the stored totalThe parts that already exist and would need to participate:
js_string_concat_chain(
crates/perry-runtime/src/string/concat.rs),js_string_equals,js_string_compare,and
js_get_string_pointer_unified— anything reading raw bytes needs a flatten callfirst. The GC also needs to trace the two child edges of a cons node.
This is a real architectural change rather than a local optimization, which is why I am
filing it rather than attempting it: it touches the string representation everywhere. But
it is the single biggest remaining item on the worst row, and accumulator concatenation is
one of the most common patterns in real JavaScript.