Bytecode cache for the JS builtins in bun build --compile --bytecode - #33316
Bytecode cache for the JS builtins in bun build --compile --bytecode#33316robobun wants to merge 2 commits into
bun build --compile --bytecode#33316Conversation
|
Updated 8:05 PM PT - Aug 14th, 2026
❌ @robobun, your commit 247677b has some failures in 🧪 To try this PR locally: bunx bun-pr 33316That installs a local version of the PR into your bun-33316 --bun |
|
Found 2 issues this PR may fix:
🤖 Generated with Claude Code |
|
This PR may be a duplicate of:
🤖 Generated with Claude Code |
|
The duplicate bot is right, and it found something worth acting on. Pulling the thread: Prior art#28461 (@sosukesuzuki) does this, and oven-sh/WebKit#177 is its JSC half. Jarred left "I like this idea" on it in March. It's stalled because it's written against I didn't find it before opening this. Apologies for the noise. Credit for the design belongs there. Where they differWhat #28461 has and this doesn't: it also caches the builtin functions ( What this has and #28461 doesn't: #28461 calls The JSC sideThis is the awkward part: oven-sh/WebKit#270 duplicates #177. Same four files, same idea, arrived at independently. Happy to close mine. Before anyone does, one substantive difference. #177's #270 makes the entry a real So: either keep #177 and add the key check to it, or keep #270. Don't mind which, but I'd rather the API validate than rely on every call site remembering to. On the other botI'm going to leave
Both are in the neighbourhood, neither is closed by this. |
|
Went back through @Jarred-Sumner's two line comments on #28461, since they're feedback on this exact feature. Both now hold here.
Done in cc831ad. Every Bun process compiles its builtins and only a
(on There's only one VM here. The cost is that the chunk-bytecode path now pays |
|
CI on
Two things worth taking from the green 285:
Also corrected something in the description: I'd written that six Still draft, still waiting on the oven-sh/WebKit#177 vs #270 call before |
cc831ad to
72d921c
Compare
|
CI on
The bake harness kills the dev process as soon as it sees ASAN's first line, so the log has no stack. I couldn't pull the CI artifact (egress proxy blocks the S3 redirect), so I built locally with the CI flags ( That's JSC's GC sweeping a CodeBlock's property inline caches, entirely inside the prebuilt WebKit. It sits ~ I've pushed one Still draft, still waiting on the WebKit#177 vs #270 call. |
|
Re-roll (build 71969 at
What 71969 is flagging so far (build still running at the time of writing):
None touch the bundler's I'm not going to keep pushing |
|
Build 71969 finished: 283 passed, 3 failed. One annotation appeared after my previous comment.
None of the three failures touch a code path this diff reaches: on a non-standalone binary the only runtime effect here is one relaxed atomic load per builtin that returns Re-roll already spent on this branch; not pushing another. Same ask as before: still a draft until the WebKit#177 vs #270 call. |
4d29d08 to
69a3d8d
Compare
|
Rebased onto Verified against both a local Nothing else changes: still a draft pending the WebKit#177 vs #270 call; |
|
CI on the rebased
Neither touches anything in this diff. Re-roll already spent on this branch; the next push will be the one that re-points |
## Repro ``` $ bun bd test test/bundler/bundler_compile.test.ts -t HelloWorldBytecode ... - [Disk Cache] Cache miss for sourceCode" + [Disk Cache] Cache miss for sourceCode + debug warn: hintSourcePagesDontNeed: MADV_DONTNEED 4096 bytes" ``` Every standalone executable produced by a debug build prints that line on startup, so every exact-`stderr` assertion in `bundler_compile.test.ts` (`HelloWorldBytecode`, the three `ReactSSR+bytecode` variants) fails under `bun bd`. CI doesn't see it because the test lanes run a release build. Several open PRs (#33337, #33316, #29066) have had to call it out as pre-existing debug noise in their verification sections, and `bun-build-compile.test.ts` grew a workaround comment that discards stderr. ## Cause `hint_source_pages_dont_need()` logs the `madvise(MADV_DONTNEED)` outcome with `debug_warn!`, which writes to stderr unconditionally in debug builds and is not silenced by `BUN_DEBUG_QUIET_LOGS`. The success path is purely informational, and the failure path is documented as a best-effort hint, so neither is a warning. ## Fix Declare a hidden `StandaloneModuleGraph` scope and route both log sites through `scoped_log!`. The information is still available via `BUN_DEBUG_StandaloneModuleGraph=1`; by default a compiled binary's stderr is clean again. Release builds are unaffected either way since both macros compile out. Test changes: - `compile/HelloWorld` now asserts `stderr: ""` so the regression is covered directly rather than only as a side effect of the bytecode cache tests. - `standalone-madvise-tla.test.ts` was asserting the hint on stderr via the old `debug_warn!` path; it now opts in with `BUN_DEBUG_StandaloneModuleGraph=1` and reads the scoped log from stdout (scoped loggers write to the debug stream, which is stdout by default). A second run without the env var asserts the scope stays hidden even with `BUN_DEBUG_QUIET_LOGS` unset. - The workaround comment and discarded stderr in the `#29963` PT_GNU_STACK test in `bun-build-compile.test.ts` are replaced with `expect(stderr).toBe("")`. ## Verification ``` $ bun bd test test/bundler/bundler_compile.test.ts -t "compile/HelloWorld$|HelloWorldBytecode|ImportDeferBytecode|ReactSSR\+bytecode" 7 pass, 0 fail $ bun bd test test/js/bun/compile/standalone-madvise-tla.test.ts 1 pass, 0 fail $ bun bd test test/bundler/bun-build-compile.test.ts -t "preserves PT_GNU_STACK" 1 pass, 0 fail # opt-in still works $ BUN_DEBUG_StandaloneModuleGraph=1 ./compiled-out hi [standalonemodulegraph] hintSourcePagesDontNeed: MADV_DONTNEED 4096 bytes ``` `cargo check -p bun_standalone_graph` and `--target x86_64-pc-windows-msvc` both clean; `cargo clippy -p bun_standalone_graph --no-deps` clean. <!-- 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/bun-build-compile.test.ts test/bundler/bundler_compile.test.ts test/js/bun/compile/standalone-madvise-tla.test.ts <!-- robobun:evidence:end --> --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
A compiled binary already ships bytecode for the app's own modules, but every builtin it touches (node:net, node:fs, the internal/* modules they require) is still parsed from source on first use. That is the bulk of the parse work left at startup. Builtins are compiled with createBuiltinExecutable() into an UnlinkedFunctionExecutable, which the bytecode cache had no entry type for; oven-sh/WebKit#270 adds encodeFunctionExecutable()/decodeFunctionExecutable() for exactly this. WEBKIT_VERSION points at that PR's preview build for now and must be re-pointed at the merged main sha before landing. Unlike the other bytecode generation steps, this one has to walk a graph the bundler cannot see. The bundle's import records name only the builtins the app imports directly; those then require() each other through InternalModuleRegistry by numeric id, resolved at runtime. bundle-modules.ts already derives that graph from the bundled output to lay the source blob out in dependency order; it now also emits it as an adjacency table, and caching "net" means caching node:net plus every internal/* module it transitively reaches. The entries are embedded in the standalone module graph and looked up by module id on first require, behind a flag the graph sets at startup so a Bun that embeds nothing never takes the lookup. JSC validates each entry against a cache version and a SourceCodeKey over the builtin's source; the runtime and the generator obtain that source and build that SourceCode through the same two functions, so a build's entries are always keyed on what the same build parses. A rejected or missing entry parses in builtin mode exactly as before. Cross-compiles skip generation: src/js is specialized per platform at Bun's own build time, so the key would never match. The bytecode VM only registers Bun's private names (JSVMClientData:: registerBuiltinNames) rather than creating full client data: the builtin-mode lexer needs the names, and nothing else in client data applies to a VM with no Bun VirtualMachine behind it. Measured on a debug build running require("node:net") + node:http + node:fs: ~1.3s to ~0.77s startup; 69 builtins served from the cache.
69a3d8d to
6fd90ae
Compare
BuiltinNames::appendExternalName() copies each name into the VM's own private-name set (a set of Strings), so the BunBuiltinNames object is only needed for the duration of its constructor. Leaking it showed up under LSan as a 2992-byte direct leak from every thread that creates the bytecode VM, which since the node:module compile cache landed is most --bytecode users, not just this feature. Also trims comments that restated what the code or a neighbouring definition already says, and removes two that gave a wrong reason for an ordering that does not matter (the bytecode VM is thread-local).
|
Two updates, both now in CI on the re-integrated push found a real bug. Build 96781's ASAN lane reported a 2992-byte direct leak from The line-comment review. The comment checker left one thread on every comment in the diff, one-liners included, all with the same text, and it does so again on each push (56 threads so far across two pushes). I went through the first batch individually rather than dismissing it. Acted on: the leak above; two comments in The PR description is updated for the re-integrated design, and has a collapsed section listing what moved on |
|
Status of build 96831 for The build is still not green only because its two The one annotation is a yellow retry of |
A
--compile --bytecodebinary already ships bytecode for the app's own modules, but every builtin it touches is still parsed from source on first use.node:netalone drags in ~50internal/*modules. That parse is most of what's left at startup.Why this isn't just another bytecode generation step
Two things make the builtins different.
They have no cache entry type. Builtins are compiled with
createBuiltinExecutable()into anUnlinkedFunctionExecutable— a builtin function, which is the only parse mode whose lexer accepts the@-prefixed intrinsics. JSC's bytecode cache has top-level entries for programs, modules and eval, because those are the top-level units of a script; nothing is shaped like a function. oven-sh/WebKit#270 addsencodeFunctionExecutable()/decodeFunctionExecutable()and aCachedFunctionExecutableTagfor exactly this.(Compiling them as programs instead doesn't work:
BytecodeGenerator'sProgramNodeconstructor hardcodesm_isBuiltinFunction(false), so every nested function comes back as a normal executable, and the first one that needs a re-parse hitsSyntaxError: Invalid character '@'at runtime.)Their dependency graph is invisible to the bundler. The bundle's import records name only the builtins the app imports directly. Those then
require()each other throughInternalModuleRegistryby numeric id, resolved at runtime, so the bundler never sees the edges.bundle-modules.tsalready derives that graph from the bundled output to lay the source blob out in dependency order (#36101); it now also emits it as an adjacency table, and the cache set is closed over it — caching "net" means cachingnode:netplus everyinternal/*module it transitively reaches.How it works
BuiltinModuleBytecode.cpp):createBuiltinExecutable()→recursivelyGenerateUnlinkedCodeBlockForFunctionExecutable()→encodeFunctionExecutable(), on the existing off-thread bytecode VM. That VM only gets Bun's@-private names registered (JSVMClientData::registerBuiltinNames()), which is all the builtin-mode lexer needs; it has no BunVirtualMachinefor the rest of the client data to attach to.bundle_v2.rs): walk the reachable import records forImportRecordTag::Builtin/Bun, re-canonicalize through the alias table (the records are de-prefixed:node:fs→fs), map to module ids, close over the adjacency, generate.StandaloneModuleGraph.rs): a sorted(module_id, bytecode)index in the standalone graph, payloads 128-byte aligned like the chunk bytecode already is. The graph sets a flag at startup when it holds any, so a Bun that embeds nothing never takes the lookup.InternalModuleRegistry.cpp):generateModule()tries the embedded entry before compiling from source.The runtime and the generator both get a builtin's source from
builtinModuleSource()(the.incbinblob from #35071 in release, the on-disk bundle in debug) and build theSourceCodewithbuiltinModuleSourceCode(), so a build's cache entries are always keyed on exactly what that build parses; a drift there would silently turn every lookup into a miss.Safety
A cache entry carries a JSC cache version and a
SourceCodeKeyover the builtin's source. A mismatch is rejected and the builtin is parsed, andCachedFunctionExecutable::decode()restoresm_isBuiltinFunction, so even a partially-decoded tree re-parses in builtin mode. Stale, corrupt, or missing entries all degrade to today's behavior rather than failing.Cross-compiles skip generation entirely —
src/jsis specialized per platform at Bun's own build time (process.platformis a define), so the key would never match and the bytes would be dead weight.Attaching a debugger or a profiler changes
defaultCodeGenerationMode(), which is part of the key, so those runs fall back to parsing.Verification
51 =
node:net+ its transitiveinternal/*closure, from an entry point that only ever namesnode:net; +5.8 MB on the binary. An app requiringnode:net+node:http+node:fsgets 69 builtins from the cache and, on a debug build, starts in ~0.77s instead of ~1.3s (release numbers will be smaller, debug parses much slower, but the shape holds).test/bundler/bundler_compile.test.ts→compile/BytecodeCachesBuiltinModulescompiles the same entry point twice into one outfile (these are full copies of the Bun binary, so not two at once) and asserts the decode count is 0 without--bytecodeand >5 with it. Passes against both a localvendor/WebKitbuild of #270 and the prebuilt preview.Re-integration onto current main (909 commits), for reviewers who saw the earlier version
Main moved through this exact area while the PR waited on the WebKit side, so the August rebase was a re-integration rather than a conflict fixup:
.incbinblob plus an offset table. The earlier version of this PR had its own generatedsourceById()switch and had to move the constants include so only one TU paid for it; all of that is gone.builtinModuleSource()now reads the blob through the same offset table, andInternalModuleRegistry.cppends up 21 lines shorter than main because the debug/release source split collapses into that one function.@createInternalModuleByIdis gone;requireGraph()is shared by the layout and by the new adjacency table, so there is one definition of the edges.ResolvedSourceTag::try_from_namehelper this PR added (theirs ispub(crate)); the duplicate is dropped andinternal_module_idnarrowed to match.JSVMClientData::create()grew a VmHandle retain that dereferences the Bun VM, so the earlier "create full client data with a null VM" would now crash on every--bytecodebuild. Replaced withregisterBuiltinNames(), which registers only the names.compile: boolbecameCompileMode; the newcompile_target_is_hostsits beside it.BunBuiltinNamesused byregisterBuiltinNames()(2992 bytes per bytecode-VM thread, which LSan flagged across thenode:modulecompile-cache tests too);appendExternalName()copies the names into the VM's own set, so it is now a stack local.A local-only wrinkle, for anyone running this file under
bun bdA few tests in
bundler_compile.test.tsfail against a local debug build regardless of this branch:compile/HelloWorldWithProcessVersionsBuncomparesprocess.versions.bunagainst the runner's version with-debugstripped, which a debug build never satisfies, and theBanner/FooterUTF-8 tests use the default 5s timeout, which a debug--compile(~8s here) can't meet. CI runs release builds and doesn't see either.no test proof · iteration 7 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/bundler/bundler_compile.test.ts