Skip to content

perf(concat): add owned-merge to avoid re-allocating moved strings#365

Closed
hyfdev wants to merge 1 commit into
oxc-project:mainfrom
hyfdev:rdsm/concat-owned-merge
Closed

perf(concat): add owned-merge to avoid re-allocating moved strings#365
hyfdev wants to merge 1 commit into
oxc-project:mainfrom
hyfdev:rdsm/concat-owned-merge

Conversation

@hyfdev

@hyfdev hyfdev commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

What

ConcatSourceMapBuilder::add_sourcemap stores Cow::Borrowed views into each input map, so a concatenated result borrows from its inputs. A consumer that needs an owned ('static) result — e.g. a bundler joining per-module maps into one chunk map — must then call SourceMap::into_owned, which deep-copies every name / source / sourcesContent string to detach it: one heap allocation per string, on a hot per-chunk path.

This adds ConcatSourceMapBuilder::add_sourcemap_owned, which takes a SourceMap<'static> by value and moves its names / sources / source_contents storage straight in via the already-public SourceMap::into_parts. The merged map is then already 'static, so the consumer can drop into_owned entirely — turning an allocation-per-string into a Vec move.

  • The token translation + boundary dedup + TokenChunk bookkeeping is refactored into a shared private append_tokens helper, so the borrowed and owned paths can't drift.
  • A new test asserts the owned path produces byte-identical output to the borrowed path.
  • Existing signatures are untouched — purely additive.
  • Two benches (concat/into_owned, concat/add_sourcemap_owned) make the path visible (the existing concat benches stop at into_sourcemap(), so neither criterion nor the allocation tracker could see this cost before).

Measured impact

Primary win is allocation count. Validated downstream in rolldown (the main consumer) with a local throwaway SourceJoiner::join(self) prototype + a Cargo [patch] pointing at this branch, on a realistic 2000-module chunk:

metric before after
rolldown cargo allocs · join_with_sourcemap (heap allocations) 4005 5 (−99.9%)
per-join wall-clock (release, mimalloc) ~417 µs ~314 µs (−25%)

Crate-side allocation pre-filter (System allocator, 50× the real_large perf fixture): 1355 → 32 allocations.

Honest scope: the headline is the allocation reduction (fewer allocations ⇒ less allocator pressure on parallel builds), plus a clean ~25 % / ~100 µs-per-chunk speedup on join. join runs once per output chunk and is a small fraction of total bundle time, so the end-to-end build-time delta is small — this is an allocation-efficiency + per-op win, not a large end-to-end number.

Two-part note

This method is the enabling half and is safe to land alone (additive, byte-identical output). The rolldown saving is realized only once rolldown bumps the oxc_sourcemap dependency and adopts the owned path (SourceJoiner::join(self)add_sourcemap_owned, dropping into_owned). The companion rolldown adoption is a separate draft PR (rolldown/rolldown#9881); the numbers above were measured with that prototype wiring.

Behavior

Full CI-parity checks green: tc39 conformance, cargo test, clippy -D warnings, cargo doc -D warnings, cargo fmt --check, typos. The owned path is byte-identical to the borrowed path (parallel test).

@codspeed-hq

codspeed-hq Bot commented Jun 18, 2026

Copy link
Copy Markdown

Merging this PR will degrade performance by 3.37%

⚠️ Different runtime environments detected

Some benchmarks with significant performance changes were compared across different runtime environments,
which may affect the accuracy of the results.

Open the report in CodSpeed to investigate

⚡ 1 improved benchmark
❌ 4 regressed benchmarks
✅ 11 untouched benchmarks
🆕 2 new benchmarks
⏩ 5 skipped benchmarks1

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Benchmark BASE HEAD Efficiency
from_sourcemaps 304.9 µs 323.5 µs -5.72%
add_sourcemap_loop 322.7 µs 341.1 µs -5.39%
serialize[real_medium] 5 µs 5.2 µs -4.46%
serialize[real_small] 4.3 µs 4.5 µs -2.62%
build_single 5.8 µs 5.7 µs +1.53%
🆕 add_sourcemap_owned N/A 892.1 µs N/A
🆕 into_owned N/A 600.2 µs N/A

Tip

Investigate this regression by commenting @codspeedbot fix this regression on this PR, or directly use the CodSpeed MCP with your agent.


Comparing hyf0:rdsm/concat-owned-merge (fe5d31b) with main (1bb86f5)

Open in CodSpeed

Footnotes

  1. 5 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

@Boshen Boshen self-assigned this Jun 18, 2026
@hyfdev
hyfdev force-pushed the rdsm/concat-owned-merge branch from fe5d31b to 0dbe5c4 Compare June 19, 2026 04:06
`ConcatSourceMapBuilder::add_sourcemap` borrows the input map's strings, so a
concatenated result borrows from its inputs and a downstream `into_owned`
deep-copies every name/source/sourcesContent string to detach it.

Add `add_sourcemap_owned`, which takes a `SourceMap<'static>` by value and moves
its strings in via `SourceMap::into_parts`, so the result is already `'static`
and the caller can skip `into_owned` — an allocation-per-string becomes a `Vec`
move. `add_sourcemap` is unchanged; a test asserts the owned path produces
byte-identical output to the borrowed path. A `concat/into_owned` bench tracks
the cost the owned path removes.
@hyfdev
hyfdev force-pushed the rdsm/concat-owned-merge branch from 0dbe5c4 to d1f76ea Compare June 19, 2026 04:23
@hyfdev

hyfdev commented Jun 19, 2026

Copy link
Copy Markdown
Contributor Author

Note on the CodSpeed report above — it's comparing fe5d31b, an earlier revision of this branch. That revision routed the existing add_sourcemap through a shared helper, which added a ~3% regression on the borrowed path; the remaining part of the −3.37% was on serialize, which this change doesn't touch (the "different runtime environments" warning is cross-runner noise).

I've since reverted add_sourcemap to be byte-identical to main — the owned path has its own token loop, with a test asserting byte-identical output. Same-machine cargo bench on d1f76ea shows concat/add_sourcemap_loop at parity with main, so the existing path no longer regresses.

The Benchmark workflow hasn't re-run on the fixed commits (0dbe5c4, d1f76ea) — they're sitting in action_required (fork PR). Could a maintainer approve the workflow run so CodSpeed re-measures the current code?

@hyfdev

hyfdev commented Jun 19, 2026

Copy link
Copy Markdown
Contributor Author

Heads-up: this branch now conflicts with #366, which reworked ConcatSourceMapBuilder into a borrow-only builder (Vec<&'a str>) with into_owned_sourcemap() deciding ownership at the end.

It's a design collision, not just a textual one. This PR's add_sourcemap_owned moves the input maps' owned strings into the builder so the merged result is already 'static and the final copy is skipped — which requires the builder to hold owned strings, something the new Vec<&'a str> storage can't do. So it can't simply be rebased onto #366.

The motivating case is unchanged: when a consumer already owns the input maps (e.g. a bundler joining per-module maps into one chunk map), into_owned_sourcemap() still copies every name/source/sourcesContent string once — ~4000 allocations for a 2000-module chunk — that could be moves instead (measured downstream: rolldown cargo allocs 4005 → 5 on that path).

Since #366 is @Boshen your direction for this file, I'll defer to you on how to handle it — add a "move-in"/owned path on top of the deferred-ownership model, keep the owned merge out of this builder, or close this in favor of a consumer-side approach. Happy to adapt to whatever you prefer.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants