Skip to content

ast: keep EString::flatten_rope out of line - #44230

Open
robobun wants to merge 1 commit into
mainfrom
robobun/6a1dce4d/keep-flatten-rope-out-of-line
Open

robobun wants to merge 1 commit into
mainfrom
robobun/6a1dce4d/keep-flatten-rope-out-of-line

Conversation

@robobun

@robobun robobun commented Sep 29, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

Fix

  • Mark EString::flatten_rope #[inline(never)]. Its two callers stay a check plus a call.
  • On main no machine code changes: 0 of 20,326 Rust functions of the release binary differ. With that use added, the cost is 0.
  • Verified on linux-x64 release binaries. The existing tests pass on the debug build.
  • Self-reviewed: 8 concerns raised, 8 addressed (framing, merge order, comment length).

Background

  • A rope is an EString that constant folding joined from several literals. flatten_rope copies the parts into one slice.
  • Release builds pass -Zshare-generics=y (scripts/build/rust.ts:367). A generic function without #[inline] then has one instance, in the upstream-most crate that uses it.
  • On main the inliner rejects flatten_rope by 25 (cost 275, threshold 250). A crate that sees only a declaration accepts it.
  • Three alternatives (Notes) each change machine code on main.

Downsides

  • None found. Checked all Rust functions, section sizes, and instruction counts on 19 inputs.

Behaviour change: none

Notes

Method. linux-x64, release profile (ThinLTO, -Zshare-generics=y). Each build below is a full build. Functions are compared by a hash of their disassembly with addresses masked. Instruction counts are user-mode instructions per function, JIT off, GOMAXPROCS=2, three runs. A delta sums the functions whose count is the same in every run of both binaries. That leaves out allocator and thread pool timing. The counts come from a DynamoRIO client, because the machine denies perf_event_open and has no valgrind. The output files are byte-identical in every build.

Two bases. The function comparisons of this exact diff are against main 9f70da0. The instruction counts and the alternatives are against main 36cd151, with the same attribute. The 9 commits between them do not touch src/ast/e.rs, src/bun_alloc/baby_vec.rs or src/js_printer/lib.rs.

The trigger. One function in src/bun_core/string/immutable.rs that does ArenaVec::<u8>::with_capacity_in and extend_from_slice, with one call from Transpiler::build_css_output. #42753 and #38253 add that same pair to that file.

This diff against main 9f70da0.

Build Rust functions that differ from main flatten_rope resolve_rope_if_needed, flattened out of line Calls to flatten_rope Calls to the two callers
main 281 B no 57 in 30 functions 0
this PR 0 of 20,326 281 B no 57 in 30 functions 0
this PR + trigger 2 274 B no 57 in 30 functions 0
main + trigger 31 folded into its callers yes, 307 B and 376 B 0 53 in 27 functions
  • this PR: the stripped binary has the size of main's (80,832,072 B). 20 bytes differ, all in .note.gnu.build-id. .text is 58,148,853 B in both.
  • this PR + trigger: the functions that differ are flatten_rope and the trigger's caller.

Instruction counts against main 36cd151.

Build Rust functions that differ Per JS line Per JSON key Per rope Bun.Transpiler, 1 MB of TypeScript
main + trigger 31 +28.00 +99 to +100 +13.00 +2,489,306 (+0.689 %)
this attribute 0 0.00 0.00 0.00 +58
this attribute + trigger 2 0.00 0.00 0.00 +63
this attribute and #[inline] on the two callers 16 0.00 +1.00 0.00 +30,019 (+0.008 %)
the same and #[cold] on flatten_rope 30 0.00 +2.00 0.00 +8,569 (+0.002 %)
#[inline] on BabyVec::extend_from_slice only 86 0.00 0.00 0.00 -54
  • Inputs: JS with 5,000, 20,000 and 80,000 lines of globalThis.aN="x..x"; (+0.34 % to +0.38 % with the trigger), a JSON import with as many keys (+1.27 % to +1.41 %), and JS that joins three literals on every line, built with --minify-syntax.
  • The last row changes the CSS tokenizer, the TOML, XML and JSON5 parsers and the shell lexer. Those paths were not measured.
Command, per line or per key, two runs main + trigger this attribute this attribute + trigger
bun run, 5,000 lines of JS +20.94 0.00 0.00
bun run with a JSON import, 5,000 keys -0.05 0.00 0.00
bun build --sourcemap=linked, JS +21.03 0.00 +0.02
bun build --sourcemap=linked, JSON import +88.01 -0.01 0.00
bun build --target=bun, JSON import +98.00 -0.01 0.00
bun build --target=bun --sourcemap=linked, JSON import +95.02 -0.16 +0.03
bun pm pkg get on a package.json with 5,000 keys +63.00 (+3.87 %) 0.00 0.00
Input, three runs main main + trigger this attribute this attribute + trigger
bun build --target=bun typescript.js (9.1 MB) 1,105.2 M +1,057,829 (+0.10 %) 0 +216
bun build of an import of the mime-db JSON (204 KB) 19.5 M +385,771 (+1.98 %) -177 0

The values of this attribute that are not 0 are noise of the run: the machine code is the same as main's.

The margin on main. rustc's inline remarks for bun_ast (-Cremark=inline, the release command line, at 36cd151) report flatten_rope as too costly for each of its callers: cost 275, threshold 250. With this PR they report NeverInline.

Where the slowdown was seen. In the pre-merge instruction check of #41801, and in the builds above. No user reported it, and no CI job counts instructions. The binary size does not show it: .text shrinks by 1,536 B with the trigger.

Not covered.

  • Only linux-x64 was built and inspected.
  • The attribute fixes the size of the two callers. That they inline at each call site is still a link-time decision, as for every small function.
  • A trigger that uses only with_capacity_in and push moves grow_to and grow_exact to bun_core and leaves extend_from_slice in bun_ast. It does not change the rope path, with or without this PR. It changes 45 functions in the parsers named above. This PR does not change that.
  • Found on the way, not changed here, no open PR: the printer's BufferWriter::write_all (src/js_printer/lib.rs:7499) calls Vec<u8>::extend_from_slice out of line, 108 instructions per JS line on main. EString::eql_comptime and EString::string are out of line on the JSON path, 60 and 34 per key.
  • The note at scripts/build/rust.ts:357 says that the flag only removes duplicates of bodies that nobody inlines. The builds above are a case where it changes what is inlined. This PR does not edit the note.

Tests on the debug build of this commit. All pass: transpiler.test.js (237), bundler_dynamic_import_dce.test.ts (333), importstar.test.ts (99), bundler_string.test.ts (59), bundler_minify.test.ts (54), transpiler-enum-concat-chain-oom.test.ts (2), the rope test of bun-build-api.test.ts, and the source lint printer-rope-in-place.test.ts.

Order. #42753 and #38253 keep their ArenaVec<u8> when they merge after this PR. #41801 wrote its copy into an arena slice to avoid the cost (4175d77) and can use ArenaVec<u8> again.


no test proof · iteration 0 · the description declares no behaviour change, so there is no failing test to prove; the existing suite in CI is the check

A guard. It changes no machine code on main.

resolve_rope_if_needed and flattened are a check plus a call to
flatten_rope. The printer, the parser and the linker call them for every
string, and they inline there only while they are that small.

Release builds share generic instances across crates. On main the
inliner leaves flatten_rope out of line by a margin of 25 (cost 275,
threshold 250). With the instance of ArenaVec<u8>::extend_from_slice in
a crate above bun_ast, it folds flatten_rope into both callers, and
every string pays a call.
@coderabbitai

coderabbitai Bot commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository: oven-sh/bun/.coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: 24124715-7175-4bf4-844b-8ddb210807bb

📥 Commits

Reviewing files that changed from the base of the PR and between 9f70da0 and 34a2aba.

📒 Files selected for processing (1)
  • src/ast/e.rs

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 4 remain after this review.


Walkthrough

EString::flatten_rope now has the #[inline(never)] attribute. Its implementation is unchanged.

Changes

EString annotation

Layer / File(s) Summary
Set flattening function inline behavior
src/ast/e.rs
EString::flatten_rope is marked #[inline(never)]. Its body is unchanged.

Suggested reviewers: jarred-sumner

Priority: ⬇️ Low

Merge Risk: ⚪ Minimal · up to 34a2a

The change affects optimization rather than string behavior, and no concrete release risk is established. It is mergeable.

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly and concisely states the main change: keeping EString::flatten_rope out of line.
Description check ✅ Passed The description is detailed and covers the change, motivation, verification methods, test results, scope, and limitations. It does not use the template headings exactly, but it provides the required i…

Comment @coderabbitai help to get the list of available commands.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — a two-line codegen hint on a cold path, no logic or signature change.

What was reviewed:

  • #[inline(never)] on EString::flatten_rope (src/ast/e.rs:1920): only reached after the next.is_none() || !is_utf8() early-return in its two callers, so an extra call is paid only on actual ropes; the non-rope common case is unchanged.
  • Both callers (resolve_rope_if_needed, flattened) still call it with identical arguments; no ownership or lifetime change since the returned slice is bump-allocated either way.
  • Compared with the crate's other #[inline(never)] sites (src/ast/lib.rs:1618, src/ast/known_global.rs:81): same placement pattern, so the attribute is consistent with local convention.
Extended reasoning...

The change touches only src/ast/e.rs, adding a #[inline(never)] attribute and a one-line PERF comment to the private flatten_rope helper; no security-sensitive surface is involved. Behavior, signatures, and ownership are unchanged, and the attribute only affects codegen of a path already gated behind a rope check, so it cannot regress the non-rope common case. No CODEOWNERS entry covers src/ast, no prior reviewer objections exist, and the bug hunt ran dry, which together decided approve.

@robobun

robobun commented Sep 29, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: ready for review. The diff is 2 lines in src/ast/e.rs and changes no machine code on main.

CI (build 121596): 180 of 181 jobs pass. The job that fails is x64-asan test-bun, with one test: test/js/bun/spawn/spawn.test.ts (an idle reader stopped at the highwater). This diff does not touch that code. The same test fails on the same lane in other pull requests, for example build 121031 of #41801.

How the cost was reproduced (linux-x64, release profile, main 9f70da0):

  1. Build main with bun run build:release and keep build/release/bun-profile.
  2. Add this function to src/bun_core/string/immutable.rs, call it from Transpiler::build_css_output, and build again:
    pub fn copy_into_arena_vec<'a>(bytes: &[u8], arena: &'a MimallocArena) -> &'a [u8] {
        let mut out = ArenaVec::<u8>::with_capacity_in(bytes.len(), arena);
        out.extend_from_slice(bytes);
        out.into_bump_slice()
    }
  3. List the rope functions of each binary:
    llvm-nm -S -C --defined-only bun-profile | grep -E 'flatten_rope|resolve_rope_if_needed|EString>::flattened'
Build Functions that exist out of line
main flatten_rope (281 B)
main with the function resolve_rope_if_needed (307 B), flattened (376 B)
this PR flatten_rope (281 B)
this PR with the function flatten_rope (274 B)

PR: #44230

This branch has not been deployed

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant