bundler: add minChunkSize / --min-chunk-size to fold small code-splitting chunks

Closed
SO
3mo ago
+1354-12

What does this PR do?

With --splitting, every distinct set of importers gets its own chunk, so a large application with many import() boundaries ends up with hundreds of chunks of a few kilobytes, each costing a module record, a link step and a file read at startup. minChunkSize (Bun.build) / --min-chunk-size (CLI) folds chunks whose source files add up to fewer than that many bytes into another chunk where doing so is unobservable.

Two rules, applied before computeChunks groups files by rewriting File.entry_bits, so chunk membership and cross-chunk imports follow from the existing machinery:

  1. Same load conditions. An import() entry point is redundant in a chunk key when another entry in the key is guaranteed to be loaded already whenever it loads (greatest fixpoint over the import() graph; user entry points are process roots). Keys that are equal after dropping redundant entries describe chunks that are always loaded together — e.g. the {main, lazy} chunk folds into main.js, and lazy imports those symbols from main.js.
  2. Side-effect-free chunks. A chunk whose live parts run nothing at the top level (declarations only, "sideEffects": false, or a non-entry __esm/__commonJS wrapper; a static import of a wrapped or external module counts as running it) may join a chunk loaded by a superset of its entries, provided everything it imports is already loaded there or side-effect free too. Each group tracks the entries under which it is loaded, growing as folds make other chunks import it, so a later fold cannot attach it to a chunk with side effects. What an entry gains is capped at 1/64 of what it loaded to begin with.

Supporting fixes this exposed:

  • an entry point chunk that other chunks import from reserves its own export names in the cross-chunk ExportRenamer, so the generated export {} does not duplicate one
  • /./ segments are collapsed out of final_rel_path (a ./[dir]/… template with [dir] == "." yielded ././x.js, which importers copied verbatim)
  • under --compile the user entry point's chunk is left alone, since the executable keys that module at /$bunfs/root/<outfile> after linking

BUN_DEBUG_MergeChunks=1 logs which files kept a chunk from folding. Docs and types included.

How did you verify your code works?

  • bun bd test test/bundler/bundler_splitting.test.ts (11 new cases: folding into the entry, thresholds, lazy-only shared chunks, pure-chunk superset folds, export alias collisions, and four cases that pin the folding conditions — import()ed user entries stay roots, impurity propagates through folds, importers of CJS modules are not side-effect free, load conditions are tracked across folds) and bundler_compile_splitting.test.ts (--compile with the option).
  • Drove the debug binary on a sample project: bun build --splitting --min-chunk-size 4096 cut 6 output files to 4 with identical stdout from both builds; the --compile executable runs; minChunkSize: 0 without splitting is a no-op and a non-zero value without it is rejected.