Skip to content

Embed the source hash next to bytecode so compiled modules load without reading their source - #39399

Closed
robobun wants to merge 1 commit into
mainfrom
farm/56c66673/bytecode-source-hash
Closed

Embed the source hash next to bytecode so compiled modules load without reading their source#39399
robobun wants to merge 1 commit into
mainfrom
farm/56c66673/bytecode-source-hash

Conversation

@robobun

@robobun robobun commented Aug 17, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • Loading a module from a bun build --compile --bytecode executable faults the module's entire embedded source into memory even though the bytecode cache means the source is never parsed. Measured on a CommonJS module with 8 MiB of source: require() grows the Rss of the executable's data mapping by 8128 KB (the whole source).
  • Cause: Zig::SourceProvider::m_hash (src/jsc/bindings/ZigSourceProvider.h:64) is declared but never assigned, so SourceProvider::hash() (ZigSourceProvider.cpp:269) falls through to StringImpl::hash(), which walks the whole source string. JSC calls it from the SourceCodeKey constructor (SourceCodeKey.h:85) before it consults the bytecode cache. When module_info is present this is the only thing on the load path that reads the source at all, so on the bytecode path it is also pure CPU cost (and on signed macOS binaries each first-touched page additionally pays signature verification).

Fix

  • The build step already constructs the same key when it generates the bytecode, so generateCachedModuleByteCodeFromSourceCode / generateCachedCommonJSProgramByteCodeFromSourceCode return sourceCode.provider()->hash() through a new out parameter. That is exactly the value the runtime provider has to return for the stored key to match.
  • The hash travels: bun_jsc::cached_bytecode -> dispatch::GeneratedBytecode -> the bytecode OutputFile (bytecode_source_hash) -> CompiledModuleGraphFile::bytecode_source_hash (a u32; the graph format is only read by the binary that wrote it, same as every other field) -> File -> ResolvedSource::bytecode_source_hash -> provider->m_hash in SourceProvider::create, set only on the branch that attaches a bytecode cache.
  • The hash is only recorded for modules embedded with Encoding::Latin1, because that is the only case where the string the runtime hands JSC is byte-for-byte what the bytecode was keyed on. Binary modules (any non-ASCII byte in the chunk) are decoded as UTF-8 into a different string; they stay at 0 and keep hashing at runtime, which is their existing behaviour (their bytecode already misses today; that is a separate pre-existing issue, handed off).
  • The two hasOverriddenModuleWrapper paths in JSCommonJSModule.cpp replace source_code, so they zero the hash; a wrong hash can only ever produce a cache miss (JSC compares hash and length against what is stored in the bytecode), never the wrong bytecode for a source that was built correctly.
  • 0 is a safe "not recorded" value: WTF string hashes are never 0, which is also what the pre-existing if (m_hash) in hash() relies on.
  • Verified: test/bundler/bundler_compile.test.ts, "bytecode modules are loaded without reading their embedded source". It compiles an entry plus an 8 MiB CommonJS module, require()s the second one at runtime, and checks the Rss delta of the executable's writable mappings in /proc/self/smaps, plus that both modules still report [Disk Cache] Cache hit (which proves the embedded hash matches the key stored in the bytecode). Debug build: delta 64 to 128 KB, passes. Release 1.4.0 without the fix: delta 8128 KB, fails. Linux only because it reads /proc/self/smaps; CommonJS because debug builds re-parse ESM modules to cross-check module_info, which reads the source regardless.
  • Also ran: the rest of bundler_compile.test.ts (70 pass; compile/HelloWorldWithProcessVersionsBun fails on main too in debug builds, unrelated, handed off), test/regression/issue/26298.test.ts, bundler_bun, bundler_banner, the standalone madvise test, and the node compile-cache scripts that go through the changed __bun_jsc_generate_cached_bytecode helper. Checked manually with BUN_JSC_verboseDiskCache=1 that ESM --compile builds, non-compile --bytecode output and non-ASCII (Binary) modules behave exactly as before.

Background

  • Bytecode cache: bun build --bytecode runs JSC's parser and bytecode generator at build time and serializes the result (CachedBytecode). At runtime Bun hands that blob to JSC through SourceProvider::cachedBytecode(), and JSC's CodeCache decodes it instead of parsing, provided the SourceCodeKey stored inside the blob matches the one computed for the runtime source.
  • SourceCodeKey: JSC's cache key for a piece of source. It is built from provider->hash() (xor'd with a few parse-mode flags) and compared by hash, length, flags, name and origin host. provider->hash() is normally StringImpl::hash(), a lazily computed, cached hash of every character in the string; this PR makes Bun's provider return the value computed at build time instead of computing it again.
  • Zig::SourceProvider: Bun's JSC::SourceProvider subclass. It wraps the ResolvedSource that Rust fills in for a module (source string, optional bytecode, optional module_info, origin path used for the bytecode key).
  • Standalone module graph: the blob bun build --compile appends to the bun executable. Per module it stores name, contents, optional bytecode, module_info and the origin path in a #[repr(C)] record (CompiledModuleGraphFile); at runtime the OS maps it straight from the executable and File::to_wtf_string() wraps the Latin1 contents zero-copy, so whatever touches the string decides which pages get faulted in. hint_source_pages_dont_need() (the madvise after entry evaluation) is left as is; with this change there is much less for it to drop.
  • module_info: the module's import/export table, precomputed at build time so JSC can skip the analysis parse of ESM modules too. Debug builds still run that parse to cross-check it, which is why the test uses CommonJS.

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/bundler_compile.test.ts

…ut reading their source

JSC builds the bytecode cache key from SourceProvider::hash(). Zig::SourceProvider
never set m_hash, so the key fell through to StringImpl::hash(), which walks the
whole source string. In a compiled executable that faults every page of the
embedded source into memory for a module that, thanks to the bytecode cache, is
never parsed.

The build step already has the hash (it keys the bytecode with it), so return it
from the bytecode generators, store it in CompiledModuleGraphFile, carry it
through ResolvedSource and install it as m_hash when the provider is created
with a bytecode cache. Only modules embedded as Latin1 record it, since that is
the only encoding where the runtime string is the exact bytes the bytecode was
keyed on; everything else stays 0 and hashes at runtime as before. The CommonJS
module-wrapper override paths replace source_code, so they clear the hash.
@robobun

robobun commented Aug 17, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 8:47 PM PT - Aug 16th, 2026

@robobun, your commit 7ce47bc8d8a00eed4064bedfbcd400157da60632 passed in Build #99800! 🎉


🧪   To try this PR locally:

bunx bun-pr 39399

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

bun-39399 --bun

@robobun

robobun commented Aug 17, 2026

Copy link
Copy Markdown
Collaborator Author

Status: closed unmerged. The maintainer who requested this measured it on macOS arm64 (see below): the pages the hash walks are clean file-backed pages that macOS does not count in phys_footprint, so the change only moves RSS plus ~0.6 ms of CPU per 8 MiB of source, which was judged not worth a new module-graph field and a wider FFI signature. Linux numbers for the same setup are in the comment below. Nothing further planned.

@coderabbitai

coderabbitai Bot commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 2fb494d0-99a8-4510-8449-39fa13ae4466

📥 Commits

Reviewing files that changed from the base of the PR and between fea1829 and 7ce47bc.

📒 Files selected for processing (14)
  • src/bundler/OutputFile.rs
  • src/bundler/bundle_v2.rs
  • src/bundler/lib.rs
  • src/bundler/linker_context/generateChunksInParallel.rs
  • src/bundler/linker_context/writeOutputFilesToDisk.rs
  • src/jsc/CachedBytecode.rs
  • src/jsc/NodeCompileCache.rs
  • src/jsc/ResolvedSource.rs
  • src/jsc/bindings/JSCommonJSModule.cpp
  • src/jsc/bindings/ZigSourceProvider.cpp
  • src/jsc/bindings/headers-handwritten.h
  • src/runtime/jsc_hooks.rs
  • src/standalone_graph/StandaloneModuleGraph.rs
  • test/bundler/bundler_compile.test.ts

Included review availability: Your plan includes up to 5 reviews per rolling hour; 0 remain after this review.


Walkthrough

Changes

Bytecode source-hash propagation

Layer / File(s) Summary
Hash metadata contracts
src/jsc/ResolvedSource.rs, src/jsc/bindings/headers-handwritten.h, src/bundler/OutputFile.rs, src/bundler/bundle_v2.rs, src/bundler/lib.rs
Added source-hash fields and defaults to resolved sources and output files. Added GeneratedBytecode for bytecode and source-hash results.
JSC hash capture and generation flow
src/jsc/CachedBytecode.rs, src/jsc/bindings/ZigSourceProvider.cpp, src/jsc/bindings/JSCommonJSModule.cpp, src/jsc/NodeCompileCache.rs
JSC bytecode generation captures source hashes, returns them with generated bytecode, and clears them after CommonJS source rewriting.
Standalone graph hash propagation
src/standalone_graph/StandaloneModuleGraph.rs, src/runtime/jsc_hooks.rs
Standalone graph records serialize and restore source hashes. Runtime resolution exposes hashes for Latin1 embedded content.
Bundler output integration and regression coverage
src/bundler/linker_context/*, src/bundler/bundle_v2.rs, test/bundler/bundler_compile.test.ts
Generated bytecode output records store source hashes. Other output records use zero. A Linux regression test validates embedded CommonJS bytecode loading and memory behavior.

Possibly related PRs

Suggested reviewers: jarred-sumner, dylan-conway, cirospaciari

Merge Risk: ⚪ Minimal · up to 7ce47

This change avoids reading embedded module source when valid bytecode is available, with no actionable merge-blocking risk remaining after normal checks and review.

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly states the main change and its runtime benefit for compiled modules.
Description check ✅ Passed The description explains the problem, fix, data flow, limitations, and verification results in sufficient detail.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

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

@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.

I reviewed this PR and didn't find any bugs. Because it changes an FFI signature, adds a field to two #[repr(C)] structs shared across Rust/C++, and threads state through ~8 layers of the bundler/standalone-graph/JSC path, a human look would still be worthwhile.

What was reviewed:

  • ResolvedSource / CompiledModuleGraphFile layout: new u32 field keeps 4-byte alignment with no padding; Rust and C++ sides updated in lockstep; m_hash is default-initialized to 0 in ZigSourceProvider.h so the non-bytecode branch still falls through correctly.
  • to_bytes refactor: hoisting the encoding computation is behavior-preserving (depends only on output_file.loader and buf_bytes, neither mutated in between); string_builder append order is unchanged.
  • Hash correctness: only propagated for Encoding::Latin1 where runtime bytes match build-time bytes; both hasOverriddenModuleWrapper paths that rewrite source_code zero the hash; a wrong hash can only cause a cache miss, never wrong bytecode.
Extended reasoning...

Overview

Performance optimization for bun build --compile --bytecode: embed the JSC SourceProvider::hash() computed at build time next to each module's bytecode, so at runtime the bytecode cache key can be built without hashing (and thus faulting in) the entire embedded source string. The hash is captured in the C++ bytecode-generation functions via a new out-param, threaded through GeneratedBytecode → bytecode OutputFileCompiledModuleGraphFileFileResolvedSourceSourceProvider::m_hash. 14 files touched across bundler, standalone graph, JSC bindings (Rust + C++), and runtime hooks, plus a Linux-only test measuring RSS via /proc/self/smaps.

Security risks

None identified. The new field is a u32 used only as a cache-key input; a mismatched hash produces a cache miss (JSC compares hash + length + flags + name + origin against what's stored inside the bytecode blob), never execution of wrong bytecode. No user-controlled input reaches new parsing or allocation logic.

Level of scrutiny

Medium-high. The mechanism is straightforward and fail-safe (0 = "compute at runtime", the pre-existing behavior), but the change spans an FFI ABI boundary (new out-param on two extern "C" functions, new trailing field on the shared ResolvedSource struct) and modifies the #[repr(C)] on-disk record format of the standalone module graph. These are exactly the kinds of changes where a subtle mismatch between Rust and C++ layouts, or a missed initialization site, produces hard-to-debug corruption. I verified: m_hash = 0 is default-initialized in ZigSourceProvider.h:64; the Rust ResolvedSource::default() and all output_file::Options construction sites initialize the new field to 0; the CompiledModuleGraphFile struct remains padding-free (56 bytes, 4-byte aligned) so the read_unaligned / bytes-cast serialization stays sound; and the graph format is only ever read by the same binary that wrote it, so no version bump is needed.

Other factors

The PR description is exceptionally thorough and the test is well-constructed: it asserts both the RSS delta (< 4 MB vs. 8 MB without the fix, comfortable margin) and that both modules still hit the bytecode cache (proving the embedded hash matches JSC's stored key). The to_bytes refactor in StandaloneModuleGraph.rs was checked line-by-line for behavior preservation. The two paths in JSCommonJSModule.cpp that rewrite source_code correctly zero the hash. NodeCompileCache was updated to discard the hash it doesn't need. Given the breadth (14 files, cross-language ABI change, on-disk format change) this warrants a maintainer's eyes even though I found nothing wrong.

@sosukesuzuki

Copy link
Copy Markdown
Member

Closing after measuring on macOS arm64 (release builds of main @ 10bc68b and this branch @ 7ce47bc, 8 MiB CommonJS module embedded with --compile --bytecode --format=cjs, delta across require(), 3 runs each, both binaries report [Disk Cache] Cache hit for both modules):

Bun.unsafe.memoryFootprint() (phys_footprint) process.memoryUsage.rss() require() time
main +96–112 KB +9,104–9,136 KB 0.77–0.84 ms (cold 4.4 ms)
this PR +96–128 KB +912–960 KB 0.17–0.18 ms (cold 0.62 ms)

The pages the source hash walks are clean file-backed pages of the executable, which macOS does not count in phys_footprint at all, so the change only moves RSS (the same problem as #39400). The remaining effect is ~0.6 ms of CPU per 8 MiB of source, which is not enough to justify a new field in the module graph format and a wider FFI signature. Linux was not measured.

@robobun

robobun commented Aug 17, 2026

Copy link
Copy Markdown
Collaborator Author

Makes sense, thanks for measuring.

For the record, the Linux side of the same setup (8 MiB CommonJS module, delta across require()): release 1.4.0 without this change +8128 KB RSS, this branch (debug build) +64 to 128 KB. Linux does count those clean file-backed pages in RSS, but they are the same reclaimable pages, and the existing madvise after entry-point evaluation already drops them once startup is done, so the conclusion carries over; I did not measure require() time on a Linux release build. The branch stays up in case the cold-start cost ever becomes worth it.

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.

2 participants