Repository navigation
perf(bundler): a source's symbols the pack planner treats alike ship as one module - #352
Merged
Merged
Conversation
This was referenced Oct 28, 2026
thejackshelton
changed the base branch from
perf/drop-symbol-entry-wrappers
to
main
October 29, 2026 02:32
thejackshelton
force-pushed
the
perf/group-symbol-modules
branch
from
October 29, 2026 02:32
bd1b547 to
c362023
Compare
ApprovabilityVerdict: Approved at Macroscope's review found this PR approvable — Packed client builds combine eligible symbols into shared modules to reduce bundler overhead. Conservative grouping restrictions, group-aware loading and reported browser measurements limit the performance and execution risks. Notes:
You can add or adjust custom eligibility rules. Learn more. |
thejackshelton
force-pushed
the
perf/group-symbol-modules
branch
from
October 29, 2026 04:00
c362023 to
f3cb112
Compare
macroscopeapp
Bot
dismissed
their stale review
October 29, 2026 04:00
Dismissing prior approval to re-evaluate f3cb112
…as one module Rolldown spends about 90 KB of native memory and real time on every module, however small, and the shadcn port's client graph is mostly one-function symbol modules (40,211 of 80,209 modules). A packed client build now merges a source's symbols into one module when its own demand map cannot tell them apart: same kind, same resume use, named or not by an action's first use, same payload record kinds, and the same imported modules. Only function-declaration members merge, so loading a group runs no other member's code; derives and initializers, which render data imports statically, keep their own modules. Builds that log execution keep one module per symbol. The page-wide planner can still see members differently than the file's own map does (another file's first use naming one of them). The closure step guards that: a group counts as first-use code on a route only when the route names every member before input, or a walk took it whole; otherwise it leaves the route's first-use closures and its members load on first call, never earlier. Loading a symbol scanned its module's namespace with for...in, which runs every export's getter through the packed namespace proxy; with grouped modules that ran other members' getters at load. The resolver now walks Reflect.ownKeys, which reads no export. On a 40-page cut of the shadcn port: client modules 7,845 to 4,787. In Chromium on all 40 pages, JavaScript downloaded at load falls on every page (4.2 MB raw, 513 KB gzip in total) and JavaScript executed at load falls on 39 pages; the 40th moves inside an 8 KB swing its old build also shows (min of three runs). The modules every page loads at load are the same symbols as before, and clicking every tab on eight pages works as before.
…e group A group is shared by symbols the file's own demand map cannot tell apart, but a page can still need only some of them (a shared component whose pages use different controls). The first version left such a group to load on first call, and the docs site's /ui/accordion then fetched JS on a first click (perf guard G2). Owner ruling: the page loads the whole group with itself instead. What a page runs at load must not grow and what it downloads must stay at or below main; G15 (#360) holds both.
thejackshelton
force-pushed
the
perf/group-symbol-modules
branch
from
October 29, 2026 05:10
f3cb112 to
d712f96
Compare
macroscopeapp
Bot
dismissed
their stale review
October 29, 2026 05:10
Dismissing prior approval to re-evaluate d712f96
…mbol modules (linux-x64 G16 from CI run 40815323579; build-scale 4,495 client modules at 180 pages)
macroscopeapp
Bot
dismissed
their stale review
October 29, 2026 05:16
Dismissing prior approval to re-evaluate edbdbc1
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
TLDR: a packed client build now ships a source's symbols as one module when the pack planner cannot tell them apart. On a 40-page cut of the shadcn port, client modules fall from 7,845 to 4,787. Every page downloads less JavaScript at load (4.2 MB raw and 513 KB gzip less across the 40 pages), and executes the same or less. Lever 4 of #268. It re-anchors G16 (#360) and the build-scale module count (#353) down in the same change.
Why
Rolldown spends about 90 KB of native memory, plus real time, on every module however small (repro: 40,000 one-function modules take 3.75 GB and 6.9 s; the same code in 1,000 modules takes 355 MB and 0.3 s). The full shadcn port has 80,209 client modules, and 40,211 of them are one-function symbol modules. That is what keeps the build off 16 GB runners.
What merges
In
packages/bundler/src/build/symbol-groups.ts, symbols of one source share a module only when all of these match:Only members that are pure function declarations merge, so loading a group runs no other member's top-level code. Derives and initializers, which render data imports statically, keep their own modules. Builds that log execution keep one module per symbol. A group is named by a hash of its members, so two compiles of one file that group differently never share a module id.
When a page needs only part of a group, it loads the whole group
The page-wide planner can need only some members of a group: a shared component whose pages use different controls, or another file's first use naming one member. On the docs site this happened 245 times. The first version of this PR left such a group to load on first call, and
/ui/accordionthen fetched JS on a first click, which perf guard G2 forbids.Owner ruling: the page loads the whole group with itself. This relaxes the earlier "same code per page" rule: a page may now also load the unneeded members of a group it partly needs. In exchange, two rules hold per page, pinned by perf guard G16 (#360):
G2 (no JS fetched on a first click) still holds. All guards on this branch with G16 applied: every guard holds, and G16 reads lower on all six guarded pages:
On the 40-page shadcn cut, loading whole groups still downloads less than main on every page. Executed bytes match main, apart from the ~8 KB swing a few pages show between runs of either build.
One runtime change it needed
Loading a symbol ran
for (const name in mod)over the module namespace to find chunk initializers. Through the packed namespace proxy, that runs every export's getter. With groups it ran the other members' getters at load: about 32 bytes each, visible as +100 to +300 bytes executed per page. The resolver now walksReflect.ownKeys(mod), which reads no export.packages/compiler/test/symbol-resolver-emit.test.tspins that only the initializer is read. The test fails on the old scan. The compiler's byte-equality snapshot changes in exactly those two lines.Checks, per page, in Chromium (40-page cut, min of three runs)
byte-attribution.json.Build cost (40-page cut, 3 workers, two paired cold runs on a busy machine)
Full-port numbers will follow on #268 once the build machine has room.
Tests
pnpm run typecheckand the fast local CI run pass (6,000+ tests).dense-async-symbol-table.test.tsnow reads the manifest a packed build writes, so it checks each symbol's grouped module.Note
Ship demand-equivalent symbols as one grouped module in the bundler
Reflect.ownKeysand read only matching string-named initializer exports, instead of for-in enumeration over module exports.transform.ts, and consumers ofruntimeDemandSourcesin rolldown.ts now receive asymbolGroupsmap. Build-scale budgets drop from 14911 to 4495.Macroscope summarized edbdbc1.
Anchors this change moves (CI, linux-x64)
Bench pages run 1 char more at load (11,370 to 11,371 on
/), inside G16's swing; their executed anchors stay at main's value. The darwin-arm64 G16 anchors are refreshed in a follow-up measured on a Mac.