Skip to content

perf(runtime): join/repeat/pad/replace build through a Rust String round-trip (double copy; join lacks with_capacity) #8434

Description

@proggeramlug

Problem

Several string-producing builtins build their result in a Rust String/Vec<u8> on the Rust heap, then copy it into a fresh StringHeader — two full copies plus a malloc/free per call, and in one case no capacity hint at all:

  • Array.prototype.joincrates/perry-runtime/src/array/iter_methods.rs (~line 1122): let mut result = String::new(); — not even with_capacity, so the Rust-side buffer reallocates log(n) times on top of the final copy.
  • String.prototype.repeatcrates/perry-runtime/src/string/pad.rs:314: str_data.repeat(count) then copy.
  • padStart/padEndcrates/perry-runtime/src/string/pad.rs (finish_pad_result, ~line 186): Rust-side assembly then copy.
  • regex replace paths — crates/perry-runtime/src/regex/replace_fn.rs (~lines 84/103) and siblings: Rust String assembly then copy.

Important context — why the naive fix is WRONG

Building in a Rust-heap buffer first is currently the GC-safe pattern: Rust-heap allocation never triggers a Perry collection, so the borrowed source bytes stay valid while building; the single GC allocation happens at the end with an owned source. Writing directly into a StringHeader payload means the GC allocation happens FIRST — and that allocation can collect and move the source strings, invalidating any raw source pointers taken before it.

The sound direct-write shape already exists in-tree; follow it exactly:

  • js_string_alloc_ascii_uninit (crates/perry-runtime/src/string/mod.rs:679) — allocate the destination first, then fill; 5 callers in buffer/ show the pattern.
  • string_copy_range (string/mod.rs:704) — root source, allocate, re-read source from the rooted handle after the allocation, then copy.
  • string/split.rs:479-520 — records byte ranges instead of borrows and re-derives the source pointer from a rooted handle per use.

So the recipe per function: compute the exact output size (join: sum of element string lengths + separators — note elements may need ToString coercion, which can run user JS and collect, so coerce-and-measure must itself be rooted), allocate the destination header once, re-read all source pointers from rooted handles, then memcpy pieces directly. Where sizing requires coercion side effects that must not run twice, coerce once into a rooted scratch array first (the js_string_concat_chain runtime helper, string/concat.rs:478, does exactly this dance and is the best model).

Expectations — measure before claiming

This removes a constant factor (one copy + Rust malloc traffic), not an asymptotic class. Benchmark each converted function on a realistic size sweep (e.g. join of 10k short strings; repeat to 1MB; a replace-heavy text pass) with instruction counts and wall time, before/after. If a conversion's win is inside noise, keep the Rust-String version and say so with numbers — the simpler code wins ties. join (missing with_capacity) is the most likely real win; start there. At minimum, String::with_capacity for join is a one-line no-risk improvement even if the full conversion isn't justified.

Validation

  • Byte-identical output vs Node 26.5.1 on test-files/ fixtures covering: WTF-8 lone surrogates in sources (flag STRING_FLAG_HAS_LONE_SURROGATES must propagate — check how js_string_from_wtf8_bytes sets it), empty separators/elements, holes in join arrays (spec: absent → empty string), astral chars.
  • GC instrument pass on a fixture whose join elements have a user toString that allocates: PERRY_GC_SCHEDULE_SEED=1 PERRY_GC_SCHEDULE_RATE=1 PERRY_GC_PROTECT_FROMSPACE=1 PERRY_GC_VERIFY_EVACUATION=1.
  • RUST_TEST_THREADS=1 cargo test --release -p perry-runtime for the touched modules.
  • Build note: cargo build --profile perry-dev -p perry -p perry-runtime-static -p perry-stdlib-static before any end-to-end check (stale-.a trap).

Workflow

PR = code + tests + changelog.d/<PR>-<slug>.md; no version bump (maintainer bumps at merge). One function family per PR is fine; join first.

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