fix(codegen): keep single-unit globals in their own linkage so zero-init caches stay in __bss (#9610) - #9626
Conversation
…nit caches stay in __bss Splitting a module into codegen units promoted every generated global to `linkonce_odr` so the linker could fold a global that more than one unit defines. Most globals are defined by exactly one unit, and the promotion is not free on Mach-O: `linkonce_odr` is weak-for-linker, and `TargetLoweringObjectFileMachO::SelectSectionForGlobal` routes every weak-for-linker global to the coalesced data section before it looks at whether the initializer is zero. A `zeroinitializer` global that would have gone to zerofill `__DATA,__bss` therefore lands in file-backed `__DATA,__data` and its zeros are written into the binary. Promote only the globals a link would actually see defined twice. ELF and COFF are unaffected: their BSS classification ignores linkage, and the non-Mach-O path already gives each global a single strong owner. Fixes PerryTS#9610 Claude-Session: https://claude.ai/code/session_019iAdropXK3d2gyuGexbKMv
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Team Run ID: 📒 Files selected for processing (1)
Included review availability: Your plan provides up to 8 included reviews per hour; 1 remains after this review. 📝 WalkthroughWalkthroughChangesMach-O global linkage
Estimated code review effort: 3 (Moderate) | ~20 minutes Merge Risk: ⚪ Minimal · up to Mach-O single-unit zero-initialized globals retain local linkage and can be emitted in BSS, reducing file-backed binary data while preserving coalescing for shared or externally visible globals. No current merge-blocking risk remains. 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
Full details: Description checkExplanation The description includes all required sections, explains the implementation, links issue Full details: Linked Issues checkExplanation The changes satisfy the coding objectives in [ ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
Follow-up on the one open item in the test plan — On this branch the gate compiles all 104 route modules, performs six multi-unit splits — including a 36-unit split of It then stops at runtime: That failure is pre-existing on this host, not from this PR. I ran the identical gate on So: the splitting path this PR changes is exercised end-to-end on the real Next.js bundle and produces a linkable dylib, byte-for-byte as far as |
|
Landed via merge train #9638 (rebase-merge, authorship preserved). |
Summary
Codegen-unit splitting (#5391) promoted every generated global to
linkonce_odrso the linker could fold a global that more than one unit defines. Almost every global is defined by exactly one unit, and on Mach-O the promotion is not free:linkonce_odris weak-for-linker, and LLVM's Mach-O section picker routes weak-for-linker globals to the coalesced data section without ever asking whether the initializer is zero. Azeroinitializerglobal that belongs in zerofill__DATA,__bsstherefore lands in file-backed__DATA,__data, and its zeros are written into the binary.Per-site inline caches are
[12 x i64] zeroinitializer, one per property-access site, each referenced by exactly one function and so emitted into exactly one unit. That is what fills__datain a large build.This PR applies the promotion only where a link would actually see a global defined twice, and only to definitions that already carry local linkage — a strong external definition keeps
linkonce_odr, so ld64 still coalesces two modules' same-named globals rather than reporting a duplicate symbol. Nothing else about the split policy changes: every referencing unit still carries its own definition, which is what-dead_stripneeds.Zero runtime behaviour change; ELF and COFF were never affected (their BSS classification ignores linkage, and the non-Mach-O path already gives each global one strong owner).
The mechanism
clang -con one.llwith the same[12 x i64] zeroinitializerglobal at five linkages (LLVM 22.1.8):size -m)readelf -S)private,internal__DATA,__bss(zerofill).bsslinkonce_odr,weak_odr__DATA,__data— file-backed.bssexternal__DATA,__common(zerofill).bssSo the bug is Mach-O only, and it is triggered by the split, not by the cache emission: an unsplit build already puts the caches in
__bss.Changes
crates/perry-codegen/src/module.rs—codegen_unit_partsnow carries global definitions in their original linkage and appliespromote_global_for_unitsper unit, gated ondefining_unit_count[gi] > 1 || !has_local_linkage(def).has_local_linkagehelper, and the doc block forpromote_global_for_unitsmoved onto the function it describes (it was sitting aboveglobal_symbol_name).mach_o_split_promotes_only_globals_two_units_define.Related issue
Fixes #9610
Test plan
Placement, before/after compilers built from this branch (macOS arm64,
--no-link, sections of the merged unit object):__data__bssA split build now places caches exactly as an unsplit build does. The residual 40 B is one global that two units really do share and so is still (correctly)
linkonce_odr.Scale of the problem on a real artifact — the
ccbinary in my tree (2026-08-24 build):__data= 22,233,000 B, of which 16,285 of 2,779,125 words are nonzero — 99.41% literal zeros. #9610's census measures 25,159,016 B / 99.5% on a newer build.Linked binaries, same before/after pair (macOS arm64, 200-function probe, 8-way split vs no split):
__data__bss(zerofill)The split moved 57,728 B out of zerofill into the file; after the fix an 8-unit build is byte-for-byte the same size as the unsplit one.
before, 1 unitandafter, 1 unitare identical (15,760,456), so the two compilers differ only on the split path.Behaviour, Linux x86_64 — one program (classes,
any-typed property access,Map/Setiteration,JSON.stringify) compiled at 1/2/4/8 units, all four binaries produce byte-identical output:Real-world split, Linux —
tests/test_next_app_route_dylib.sh(the #7174 Next.js bundle case) compiles the whole 104-module route through the unit splitter, including a 36-unit split ofapp-page.runtime.prod.jsand an 8-unit split ofchunks/2.js. Codegen and theld -rmerges complete cleanly on this branch. A run I invoked with a non-default--profile releasetripped the fixture's own provider-linker symbol check (provider link selected no app ABI symbols) — downstream of codegen, in the shim that swapslibperry_runtime.rlibfor a dylib; re-running it in its default configuration.Unit tests:
cargo test -p perry-codegen— 1397 lib + 51 integration tests pass. The new test fails on the unfixed lowering (reverting just the gate):cargo build --releaseclean#[test]in the affected cratecargo test --workspace— ran-p perry-codegenplus thenext-app-routedylib end-to-endhttps://claude.ai/code/session_019iAdropXK3d2gyuGexbKMv
Summary by CodeRabbit