Skip to content

perf: no rope/cons-string representation makes accumulator concatenation quadratic (514x Node at n=16000; 17.9% of iso_miss) #8394

Description

@proggeramlug

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions