Upgrade WebKit to c775a5dc527d: less memory for code that does not run
What does this PR do?
Moves WEBKIT_VERSION from 65513e295c73 to c775a5dc527da387fd84cc444096af38f6810b44 (oven-sh/WebKit main). No Bun source change is needed to build: main builds against it as it is. One workaround the new engine makes redundant is removed (BunDebugger.cpp, below); the other two files are tests.
What comes with it, on the JavaScriptCore side:
- oven-sh/WebKit#628, "less memory per function that never runs, and code that can be dropped and decoded again":
- a module's function declarations are instantiated when their binding is first read, and stay in the embedded bytecode until then;
- call sites in the interpreter and in Baseline code get their link record (and array profile) on their second execution, in either tier, and the collector is told about those records;
- an unlinked code block's value and array profiles are allocated when it first reaches the Baseline JIT, and a metadata table's value-profile predictions are kept out of line and only for code that has warmed up;
- module and program code that has run is released right away; a module record that is not going to run its body is done with its executable's code;
- code decoded from an embedded bytecode cache (
bun build --compile --bytecode) can be dropped and decoded again (VM::shrinkFootprintNowwith flags; nothing in Bun calls it yet), not under a suspended generator or async activation; a released RegExp keeps its atom; - the thunks of the call slow paths clear the stack their C++ function's frame is going to occupy, so that what the last callee left there is not kept alive by a later conservative scan;
Heap::totalBytesAllocated(),sizeAfterLastCollection()andallocationBudgetThisCycle()for embedders;$vm.codeBlockCensus().- Consequence for Bun:
Bun.shrink(), a debugger attaching (recompileAllJSFunctions) and a Worker going away (VM::deleteAllCode) now also drop executables that were decoded from a persistent payload (--compile --bytecode,NODE_COMPILE_CACHE) and decode them again on their next call; before, such code was never touched. There is a test for it now (below).Bun.shrink()also returns early, without collecting or returning allocator memory, when a concurrent collection is in progress, and it clearsvm.lastException(); both are benign.
- oven-sh/WebKit#630:
for-ofand array destructuring over arrays without an Array Iterator object, and one RegExpObject per/x/.test(s)literal site. - oven-sh/WebKit#635:
Heap::evacuateSparseAuxiliaryBlocks(a prototype; nothing calls it by default). - oven-sh/WebKit#657: a suspended generator or async function keeps its scopes' SymbolTables when its code is generated again.
- The bytecode cache format revision goes from 5 to 9 (#630 took 6-8, #628 takes 9), so caches written by an older Bun are rejected and rebuilt, and
--compile --bytecodeexecutables must be rebuilt with the new Bun (as with every revision bump).
The release autobuild-c775a5dc527da387fd84cc444096af38f6810b44 is published with the same 42 assets, name for name, as the release of the current pin (autobuild-65513e295c73...), so every flavour scripts/build/deps/webkit.ts downloads today exists for this one: linux glibc, musl and android, macos, windows and freebsd, amd64 and arm64, each as release, -lto (not windows-arm64, as today) and -debug, plus -asan and -debug-asan for linux amd64/arm64 and macos-arm64 and -asan for windows-amd64.
src/jsc/bindings/BunDebugger.cpp: protectModuleExecutablesFromClearCode() removed every module executable from the VM's clearable-code set before the debugger's recompileAllJSFunctions, because ModuleProgramExecutable::clearCode used to drop the module environment's symbol table, and code regenerated in debugger mode then no longer matched the live environment. At this WebKit a module executable keeps one environment symbol table and its function declarations for its whole life, its code-generation mode is pinned, and module code that has run is released by the engine itself, so the invariant the comment described no longer exists. The function and its call are deleted.
What it gives
A large bundled CLI application built as a standalone executable with embedded bytecode (--compile --bytecode), a scripted 20-request session against a local fake API, release builds (--lto=off) of main (782c4020b1d) and of this branch, same application commit. RSS in MB (anonymous + file-backed).
| main | this PR | |
|---|---|---|
| parked at its first prompt, 10 s after it appears | 229 (133 + 95) | 207-213 (116 + 90-97) |
| ... 30 s | 228-229 (133-134 + 95) | 206-213 (114-116 + 92-96) |
| ... 2.5 min (after main's second idle collection and the page-out of the module graph) | 140-145 (108 + 32-36) | 136-140 (95-99 + 41) |
| ... 10 min | 142-145 (108 + 33-37) | 135-141 (93-98 + 42-43) |
peak of the 20-request session (10 runs each, interleaved; the larger of the kernel's VmHWM and the highest VmRSS seen) | median 474, range 452-490 | median 453, range 434-472 |
| what the highest sample of those runs is made of (medians) | 368 anonymous + 103 file | 342 anonymous + 105 file |
| 30 s after the session | 385-391 (282-288 + 102-103) | 353-359 (259-260 + 94-98) |
| ... 2.5 min | 215-216 (178 + 37-38) | 210-213 (167-171 + 42-43) |
| ... 10 min | 225-231 (179-183 + 45-47) | 217-224 (168-171 + 49-52) |
--help, user-mode instructions (7 runs each, interleaved) | 548.5 M (543.0-548.8) | 507.9 M (506.6-528.8): -7.4% |
Two runs per row unless said otherwise; the idle timer is main's on both sides (its defaults: collections after 10 s, 75 s and 140 s without heap growth).
What it is: about 17 MB less anonymous memory from the moment the prompt is up (functions that are declared and never called no longer exist as objects, call sites that have run once or never carry no link record, code that never reached the Baseline JIT has no profiles), about 10 MB of that left once the idle collections have run on both sides, and 5-9 MB more file-backed memory at rest (the declarations that are not instantiated stay in the embedded bytecode, which is part of the executable's mapping). The peak is lower too, by about the same amount as the fresh prompt and a little more: 22 MB at the median, all of it anonymous; the spread between runs is 40 MB on both sides, and this PR is the lower one in 9 of the 10 interleaved pairs. So the lazily allocated metadata lowers the peak as well as the idle footprint, by roughly what it saves at the prompt; nothing here changes what a request allocates while it runs.
Binary size
Against main's canary (BuildKite's binary-size annotation on this PR's first build):
| target | main | this PR | delta |
|---|---|---|---|
| bun-darwin-aarch64 | 59.95 MB | 60.07 MB | +129.2 KB |
| bun-darwin-x64 | 65.83 MB | 65.97 MB | +144.3 KB |
| bun-linux-aarch64 | 76.43 MB | 76.55 MB | +128.0 KB |
| bun-linux-x64 | 76.38 MB | 76.51 MB | +128.0 KB |
| bun-linux-aarch64-musl | 69.63 MB | 69.70 MB | +64.0 KB |
| bun-linux-x64-musl | 70.51 MB | 70.59 MB | +80.0 KB |
| bun-linux-aarch64-android | 83.40 MB | 83.47 MB | +64.0 KB |
| bun-linux-x64-android | 85.84 MB | 85.95 MB | +112.0 KB |
| bun-freebsd-x64 | 87.94 MB | 88.06 MB | +124.0 KB |
| bun-freebsd-aarch64 | 91.29 MB | 91.41 MB | +128.0 KB |
| bun-windows-x64 | 82.58 MB | 83.14 MB | +575.5 KB |
| bun-windows-aarch64 | 72.27 MB | 72.37 MB | +101.5 KB |
bun-windows-x64 is over the 0.5 MB gate, so the commit carries [skip size check]. Where that target's growth comes from, by the size of the published WebKit prebuilt (.tar.gz bytes, which are not linked bytes, but the shape is clear) at each commit of the range:
| oven-sh/WebKit main at | windows-amd64-lto | step | linux-amd64-lto step |
|---|---|---|---|
| 65513e29 (current pin) | 646,774,059 | ||
a76e6b08 (#316: BCRASH is __builtin_trap on clang-cl) | 648,250,706 | +1.48 MB | +1.4 KB (windows-arm64: +19 KB) |
| 03897bd1 (#630) | 649,000,141 | +0.75 MB | +0.31 MB |
| 16a8ab73 (#635, #485) | 649,449,191 | +0.45 MB | +0.15 MB |
| c775a5dc (#628) | 651,166,232 | +1.72 MB | +0.63 MB |
About a third of the Windows x64 growth is #316, a crash-instruction fix that only shows on Windows x64 with LTO and that any upgrade past it brings; the rest is the new engine code (#628, #630, #635), which costs 64-144 KB on every other target. There is no Bun code in this PR to make smaller. #42383's earlier builds, on a preview of #628 alone, passed the gate.
How did you verify your code works?
Release build (bun scripts/build.ts --profile=release --lto=off) with the downloaded prebuilt; the same build of main next to it.
test/bundler/bundler_bytecode_portable.test.ts: the pinned encoder output changes on a WebKit upgrade that changes the cache format, and the file says to regenerate it then. Regenerated with this build and checked entry by entry: only thebytesandsha256of the serialized caches move (every one of them: the revision is in every header, and seven commits in the range changeruntime/CachedTypes.cpp); thesha256of the printed JavaScript next to each and the string corpus are unchanged. Bundles are 0.1-0.2% smaller (libraries.js22,009,472 -> 21,977,536 bytes),vm.Script/vm.SourceTextModulecaches 0-1.6% larger (typescript.js11,684,048 -> 11,769,312;acorn.mjs257,168 -> 261,216: its function declarations are in the payload now). The cases that decode every corpus from its cache and compare the program's output, and the one that encodes in a second process and compares the bytes, pass: 22 of 22. CI runs the same snapshot on every platform, which is the point of the test.test/cli/inspect/compile-bytecode-tooling.test.ts: the heap-snapshot case expectedhotWork, a module function nobody had read, to be in the snapshot. A module's function declaration is now created when its binding is first read, so that cannot hold; the case now takes one snapshot before anything readshotWork(it is absent) and one after (it is present).- New case in
compile-bytecode-tooling.test.ts, "code dropped by Bun.shrink() is decoded again and runs the same": three rounds ofBun.shrink(), a tick andBun.gc(true), then a function that had run, one that had not, a class method, a generator suspended across the shrinks (its captured locals are the shape #657 fixes) and a pending async function. It asserts exact values, and that the output is identical from source, from the--compile --bytecodeexecutable and fromNODE_COMPILE_CACHEon its second run (the cache directory is checked to have been written). 5 of 5. - Without the
BunDebugger.cppworkaround:test/cli/inspect48 pass (one run had the port-collision flake ofwebsocket > bun --inspect=<port>, which passed on the next),test/js/node/inspector/inspector.test.ts24 of 24 (it has the script that turns breakpoints on between two top-level awaits, which is the case the workaround was for),test/regression/issue/216541 of 1; the same counts with it. test/cli/run/require-cache.test.ts18/18,test/js/workerd/html-rewriter-leak.test.ts13 pass / 1 skip,test/js/bun/jsc/heapStats-mimalloc.test.ts7 pass / 1 skip.test/js/bun/resolve(41 files): 360 pass, 3 fail;test/js/web/fetch/fetch.test.ts: 361 pass, 4 fail;test/js/bun/http/serve.test.ts: 303 pass, 1 fail. The failures are the same ones, test for test, with the build of main on the same machine (they need a user that is not root: unreadable directories and files, a port below 1024).- No Rust changes, so there is nothing for clippy or the cross-target
cargo checkto look at; the C++ change is a deletion.