Skip to content

bundler: index input keys in metafile markdown to avoid O(N^2) scans - #34531

Closed
robobun wants to merge 3 commits into
mainfrom
farm/6815ef87/bundler-metafile-md-quadratic
Closed

robobun wants to merge 3 commits into
mainfrom
farm/6815ef87/bundler-metafile-md-quadratic

Conversation

@robobun

@robobun robobun commented Jul 18, 2026 •

Copy link
Copy Markdown
Collaborator

What

generate_markdown (the --metafile-md report) had two quadratic passes over the metafile inputs:

  1. Reverse-dependency map. For every import edge it ran JsonObject::contains(target) (a linear scan of the inputs object) and, on miss, iterated every input key again to try a suffix match. Import paths are written as record.path.text (absolute) while input keys are source.path.pretty (relative), so the exact match essentially always misses and the fallback runs for every edge.
  2. Full Module Graph. It collected input paths into a Vec, sorted them, then re-fetched each value with inputs_obj.get(path), another linear scan per input.

Both are O(N²) in the input count. This PR builds a StringHashMap exact-match set and a basename -> [input keys] index once before the reverse-dependency pass, and carries the value reference through the sorted-inputs iteration instead of re-looking it up. The two suffix-match criteria in the fallback already required basename(target) == basename(input_key), so restricting the scan to the basename bucket preserves exactly which key is picked (first match in insertion order). Output is byte-identical.

Separately, match_imports_with_exports_for_file allocated a fresh Vec<usize> sort-index per reachable file; this now reuses a scratch buffer on LinkerContext the same way the adjacent cycle_detector does.

Why

--metafile-md is the "help me understand my bundle" report. Large projects are exactly where you want it, and exactly where it was slow.

Flat graph (one entry re-exporting N leaf modules), bun build --metafile-md vs bun build --metafile only:

N markdown overhead (release, before) debug+ASAN before debug+ASAN after
1600 336 ms 1743 ms 327 ms
3200 950 ms 2717 ms noise
6400 / 6000 1864 ms 9197 ms 1088 ms

Release overhead goes 336 ms -> 950 ms -> 1864 ms when doubling N; the remaining debug overhead is JSON parsing of the metafile plus the other linear passes.

The 127 KB markdown for a 301-input graph is byte-identical between the released bun and this branch across repeated runs.

Testing

The two new tests in test/bundler/metafile.test.ts exercise the rewritten lookup: a 41-input graph asserting every leaf is attributed to the entry, and a pair of inputs sharing a basename across directories asserting each is attributed to its own importer. These are correctness tests for the refactored path; the markdown output is unchanged, so they pass against the previous implementation as well. The speedup is demonstrated by the measurements above rather than by a timing assertion.

All 41 pre-existing metafile.test.ts cases and bundler_edgecase / bundler_cjs2esm pass.

Known limitation

The basename bucket degenerates when many inputs share a basename, most obviously node_modules/*/index.js: with P packages the reverse-dependency pass is O(P^2) over the index.js bucket. That is still strictly better than the previous O(N^2) over every input, and for the projects I measured the index.js bucket is in the hundreds, not thousands. The clean fix is upstream of this function: emit sources[record.source_index].path.pretty for resolved imports in the metafile JSON (matching esbuild) so the exact-match hash lookup fires for every internal edge and the suffix fallback becomes unreachable. That is a user-visible change to the inputs[*].imports[].path format (and also resolves the pre-existing Windows separator mismatch and run-to-run nondeterminism in that field), so it is tracked separately rather than folded into this output-preserving change.


no test proof · iteration 0 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/bundler/metafile.test.ts

generate_markdown re-parsed the metafile JSON and, for every import edge,
linearly scanned the full inputs object to find the matching input key
(once via JsonObject::contains, again via the suffix-match fallback). The
Full Module Graph section did the same: sorted input paths, then
re-fetched each value with a linear JsonObject::get. Both are quadratic
in the input count.

Build a StringHashMap exact-match set and a basename->keys index once
before the reverse-dependency pass, and carry the value reference through
the sorted-inputs iteration. Both match criteria in the fallback require
basename equality, so the basename bucket is the complete candidate set
and iteration order within it preserves first-match semantics.

Also reuse a single sort-index scratch Vec across
match_imports_with_exports_for_file calls instead of allocating a fresh
one per reachable file, matching the existing cycle_detector pattern.
@coderabbitai

coderabbitai Bot commented Jul 18, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 6 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: b18171e1-0284-4e8a-83de-cc75dc7210ef

📥 Commits

Reviewing files that changed from the base of the PR and between 3ff6fc5 and eb1f4df.

📒 Files selected for processing (3)
  • src/bundler/LinkerContext.rs
  • src/bundler/linker_context/MetafileBuilder.rs
  • test/bundler/metafile.test.ts

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

@robobun

robobun commented Jul 18, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 12:05 AM PT - Jul 18th, 2026

❌ @robobun, your commit eb1f4df has 2 failures in Build #74888 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 34531

That installs a local version of the PR into your bun-34531 executable, so you can run:

bun-34531 --bun

@robobun

robobun commented Jul 18, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: ready for review.

Self-review complete; two findings addressed in eb1f4df (flattened the many-inputs fixture for Windows, gated the basename-collision fixture with test.skipIf(isWindows), dropped empty-stderr assertions). One finding deferred with rationale in the PR body: the basename bucket degenerates on index.js-heavy node_modules; the clean fix is emitting path.pretty for resolved imports in the metafile JSON, which is a user-visible format change tracked separately.

CI on eb1f4df (build 74888): metafile.test.ts passes on every lane. Remaining red is unrelated to this diff:

  • test/cli/run/require-cache.test.ts on darwin 26 aarch64: memory-threshold test reported 65 MB against a 64 MB ceiling. Runtime require.cache does not go through the bundler linker; LinkerContext is only instantiated inside Bun.build. Tracked separately.
  • test-worker-message-port-transfer-terminate.js on debian x64-asan: pre-existing JSC assertion, tracked separately.
  • Everything else passed on retry.

The new tests are correctness checks for the refactored lookup path; output is byte-identical to the previous implementation, so the fail-before/pass-after gate will not distinguish them. The performance claim is backed by the measurements in the PR body.

Comment thread test/bundler/metafile.test.ts Resolved
…ase on Windows

The byte-level suffix match in generate_markdown compares separators
literally, so a Windows backslash import path never matches a
forward-slash input key that has a directory component. That limitation
is pre-existing; keep the basename-collision test posix-only and use a
flat layout for the many-inputs test so both platforms exercise the
indexed lookup. Also drop the empty-stderr assertions to match the
sibling tests in this block.
Comment thread test/bundler/metafile.test.ts Resolved

@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 — both earlier review findings are addressed and the refactored lookup is behavior-preserving.

What was reviewed:

  • Verified the basename-bucket restriction is equivalence-preserving: both original suffix-match criteria already required basename(target) == basename(input_key) (criterion 2 explicitly; criterion 1 by implication since neither side contains a mixed separator on any given platform), and bucket vecs are populated in inputs_obj.iter() order so first-match-wins is unchanged.
  • The Full Module Graph refactor filters non-Object entries before sorting instead of continue-ing after; those produced no output either way.
  • import_order_scratch take/restore in match_imports_with_exports_for_file — no ? or early return between take and restore; mirrors the neighboring cycle_detector pattern.
  • Confirmed bun_paths::basename → bun_core::strings::basename is platform-gated (posix-only / on non-Windows), which matches the separator conventions of both .pretty input keys and .text import targets.
Extended reasoning...

Overview

This PR eliminates two O(N²) scans in generate_markdown (the --metafile-md bundle-analysis report) and hoists a per-file Vec<usize> allocation in LinkerContext::match_imports_with_exports_for_file to a reusable scratch buffer on the struct. Files touched: src/bundler/linker_context/MetafileBuilder.rs (index build + carry-value-through-sort), src/bundler/LinkerContext.rs (scratch field + take/restore), test/bundler/metafile.test.ts (two new correctness tests for the refactored lookup).

Security risks

None. generate_markdown post-processes bundler-generated JSON; no untrusted input, no FFI, no filesystem writes. The LinkerContext change is a pure allocation-lifetime hoist with no change to what is sorted or how.

Level of scrutiny

Medium. --metafile-md is a diagnostic report — a wrong attribution there is a UX bug, not a miscompile. The match_imports_with_exports_for_file change is in the bundler's linking hot path, but it's mechanically identical to the adjacent cycle_detector reuse pattern: take → clear → extend → use → restore, with no fallible operations or early returns between take and restore (I read the full loop body — every arm is either .expect("unreachable") or a log call). Even under a hypothetical panic the field would just hold an empty Vec and the next call re-allocates.

Correctness of the basename-bucket equivalence

I traced this carefully since it's the load-bearing claim. bun_paths::basename resolves to basename_posix (splits only on /) on non-Windows and basename_windows on Windows. On any given platform, both record.path.text (import target) and source.path.pretty (input key) use that platform's convention consistently within the string, so target.ends_with(input_key) ⇒ basename(target) == basename(input_key). Criterion 2 already checked basename equality explicitly. Bucket vecs preserve insertion order, and only one bucket is consulted per target, so first-match semantics are unchanged. Hoisting target_base/target_has_dots/target_without_dots outside the candidates loop is safe since they depend only on target. The now-dead PathOnly struct and JsonObject::contains are deleted in the same commit.

Other factors

Two prior review rounds surfaced (1) expect(stderr).toBe("") in the new tests and (2) Windows separator mismatch in the fixtures; both were fixed in eb1f4df (stderr assertions dropped; first test flattened to bare basenames, second gated with test.skipIf(isWindows) and a comment naming the pre-existing limitation). Both threads are resolved. The author reports byte-identical 127 KB output on a 301-input graph and 41 pre-existing metafile tests + bundler_edgecase/bundler_cjs2esm passing.

@robobun

robobun commented Sep 13, 2026

Copy link
Copy Markdown
Collaborator Author

Closing as part of a cleanup of stale pull requests. This PR has had no new commits since 2026-07-18, it conflicts with main, and its last CI run failed. This is not a judgment on the fix itself. If the problem still reproduces on a current build, reopen this PR after a rebase or open a new one against main.

@robobun robobun closed this Sep 13, 2026
Jarred-Sumner pushed a commit that referenced this pull request Sep 14, 2026
…#42596)

### Problem

- With `--splitting`, `--metafile-md` reports an `import()` of a bundled
file as external. It prints `[EXTERNAL: entry.js imports
./lazy-6ngx7bj8.js]`, counts the edge under "External imports", and
lists the imported module as "(entry point or orphan)". A split
`require()` (`--target=bun`) has the same problem.
- `generate_markdown`
(`src/bundler/linker_context/MetafileBuilder.rs:801`) reads only `path`
and `external` of each `inputs[file].imports[]` edge. For a split import
these hold the output chunk and `true`. #42423 added `entryPoint` (the
bundled input) to the edge but did not change `--metafile-md`.

### Fix

- The new helper `import_target` reads one edge. An edge that has
`entryPoint` is an import of that input and is not external. All other
edges keep `path` and `external`.
- All three readers of an edge in `generate_markdown` call the helper.
The metafile JSON does not change.
- Correct because `entryPoint` is a key of `inputs`, and the bundler
writes it only for a record that the linker pointed at a chunk. The
chunk stays visible under "Loads these chunks".
- Verified: `test/bundler/metafile.test.ts` (the new test fails on main,
50/50 pass with the change). Also
`test/bundler/esbuild/metafile.test.ts`. Self-reviewed: 8 concerns
raised, all about code outside this diff (see Notes).

### Background

- The metafile is the esbuild-format build report. `inputs` maps each
source file to its import edges.
- `--metafile-md` renders that JSON as markdown. `generate_markdown`
sees only the JSON, not the linker state.
- With `--splitting`, the target of an `import()` becomes an entry point
with its own chunk. The linker points the import record at that chunk.
So the JSON edge has the chunk in `path` and `"external": true`.

<details><summary>Notes</summary>

- Follow-up to #42423, whose body says `--metafile-md` does not use the
field yet. #38465 (closed in favor of #42423) had the same markdown test
idea.
- The three readers are the reverse dependency map with the external
count, the "Imports" list of each module, and the raw `[IMPORT:` /
`[EXTERNAL:` lines.
- Test fixture output before the change: `| External imports | 3 |`,
`[EXTERNAL: entry.js imports ./lazy-6ngx7bj8.js]`, `[EXTERNAL: entry.js
imports external-pkg]`, `[EXTERNAL: entry.js imports
./required-0dd5m4wr.js]`, no `[IMPORTED_BY:` line.
- Test fixture output after the change: `| External imports | 1 |`,
`[IMPORT: entry.js -> lazy.js]`, `[EXTERNAL: entry.js imports
external-pkg]`, `[IMPORT: entry.js -> required.js]`, `[IMPORTED_BY:
lazy.js <- entry.js]`, `[IMPORTED_BY: required.js <- entry.js]`.
- The "Imports" list of `entry.js` now reads `lazy.js (dynamic-import,
contributes 15 bytes, specifier: ./lazy.js)`. Before, it read
`./lazy-6ngx7bj8.js (dynamic-import, **external**, specifier:
./lazy.js)`.
- The chunk also stays visible in `[ENTRY: lazy.js -> ./lazy-6ngx7bj8.js
(54 bytes)]`. Those sections read `outputs`, which this diff does not
touch.
- `cargo clippy -p bun_bundler --no-deps` and `cargo fmt --check` are
clean. `prettier --check` is clean on the test file.

Not changed here. These are in the JSON writer (`generate`) or in code
next to this diff:

- The JSON edge of a split `import()` of a JSON, TOML or text file has
no `with`. The writer derives `with` from `record.source_index`, which
the linker clears for a split import. So the report has no `with type:`
line for that edge.
- The writer marks an unused import as `"external": true`, for example a
type-only `import { Foo } from "./types"` in a TypeScript file. The
report still counts that edge under "External imports".
- #34531 (open) changes the key matching inside the reverse dependency
loop of the same function. The two changes touch adjacent lines.

</details>

<!-- robobun:evidence:begin -->

---

**no test proof** · iteration 0 · platform-specific test(s) that do not
run on this machine, deferring to CI, which covers all platforms:
test/bundler/metafile.test.ts

<!-- robobun:evidence:end -->
usrbinkat pushed a commit to usrbinkat/bun that referenced this pull request Sep 15, 2026
…oven-sh#42596)

### Problem

- With `--splitting`, `--metafile-md` reports an `import()` of a bundled
file as external. It prints `[EXTERNAL: entry.js imports
./lazy-6ngx7bj8.js]`, counts the edge under "External imports", and
lists the imported module as "(entry point or orphan)". A split
`require()` (`--target=bun`) has the same problem.
- `generate_markdown`
(`src/bundler/linker_context/MetafileBuilder.rs:801`) reads only `path`
and `external` of each `inputs[file].imports[]` edge. For a split import
these hold the output chunk and `true`. oven-sh#42423 added `entryPoint` (the
bundled input) to the edge but did not change `--metafile-md`.

### Fix

- The new helper `import_target` reads one edge. An edge that has
`entryPoint` is an import of that input and is not external. All other
edges keep `path` and `external`.
- All three readers of an edge in `generate_markdown` call the helper.
The metafile JSON does not change.
- Correct because `entryPoint` is a key of `inputs`, and the bundler
writes it only for a record that the linker pointed at a chunk. The
chunk stays visible under "Loads these chunks".
- Verified: `test/bundler/metafile.test.ts` (the new test fails on main,
50/50 pass with the change). Also
`test/bundler/esbuild/metafile.test.ts`. Self-reviewed: 8 concerns
raised, all about code outside this diff (see Notes).

### Background

- The metafile is the esbuild-format build report. `inputs` maps each
source file to its import edges.
- `--metafile-md` renders that JSON as markdown. `generate_markdown`
sees only the JSON, not the linker state.
- With `--splitting`, the target of an `import()` becomes an entry point
with its own chunk. The linker points the import record at that chunk.
So the JSON edge has the chunk in `path` and `"external": true`.

<details><summary>Notes</summary>

- Follow-up to oven-sh#42423, whose body says `--metafile-md` does not use the
field yet. oven-sh#38465 (closed in favor of oven-sh#42423) had the same markdown test
idea.
- The three readers are the reverse dependency map with the external
count, the "Imports" list of each module, and the raw `[IMPORT:` /
`[EXTERNAL:` lines.
- Test fixture output before the change: `| External imports | 3 |`,
`[EXTERNAL: entry.js imports ./lazy-6ngx7bj8.js]`, `[EXTERNAL: entry.js
imports external-pkg]`, `[EXTERNAL: entry.js imports
./required-0dd5m4wr.js]`, no `[IMPORTED_BY:` line.
- Test fixture output after the change: `| External imports | 1 |`,
`[IMPORT: entry.js -> lazy.js]`, `[EXTERNAL: entry.js imports
external-pkg]`, `[IMPORT: entry.js -> required.js]`, `[IMPORTED_BY:
lazy.js <- entry.js]`, `[IMPORTED_BY: required.js <- entry.js]`.
- The "Imports" list of `entry.js` now reads `lazy.js (dynamic-import,
contributes 15 bytes, specifier: ./lazy.js)`. Before, it read
`./lazy-6ngx7bj8.js (dynamic-import, **external**, specifier:
./lazy.js)`.
- The chunk also stays visible in `[ENTRY: lazy.js -> ./lazy-6ngx7bj8.js
(54 bytes)]`. Those sections read `outputs`, which this diff does not
touch.
- `cargo clippy -p bun_bundler --no-deps` and `cargo fmt --check` are
clean. `prettier --check` is clean on the test file.

Not changed here. These are in the JSON writer (`generate`) or in code
next to this diff:

- The JSON edge of a split `import()` of a JSON, TOML or text file has
no `with`. The writer derives `with` from `record.source_index`, which
the linker clears for a split import. So the report has no `with type:`
line for that edge.
- The writer marks an unused import as `"external": true`, for example a
type-only `import { Foo } from "./types"` in a TypeScript file. The
report still counts that edge under "External imports".
- oven-sh#34531 (open) changes the key matching inside the reverse dependency
loop of the same function. The two changes touch adjacent lines.

</details>

<!-- robobun:evidence:begin -->

---

**no test proof** · iteration 0 · platform-specific test(s) that do not
run on this machine, deferring to CI, which covers all platforms:
test/bundler/metafile.test.ts

<!-- robobun:evidence:end -->
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