GC controller: idle collections at 10 s, 2 min and 10 min, nothing paged out; the last drops re-decodable bytecode

Merged
JA
1mo ago
+266-74

Replaces #42329, #42383 and #43167 (one PR instead of three). Two commits: the first removes the module-graph page-out from the idle
collection, the second is the ladder.

The idle full collections (BUN_IDLE_GC_SECONDS) let JSC age out code that no longer runs and give its memory back. That memory is
anonymous, so only the runtime can release it, and the price is a one-off re-warm: the next thing the program does compiles again
what a collection aged out. This PR keeps that, makes it cheaper for a program whose user comes back, and fixes what is around it.
GarbageCollectionController.rs is 248 lines (main: 260).

  1. The default list is 10,110,480: collections 10 s, 2 min and 10 min after the heap stopped growing (main: 10 s, 75 s,
    140 s). What is freed is the same; a program that is used again within two minutes no longer pays for the second collection.

  2. The last collection also drops what JSC gets back cheaply: before it, VM::shrinkFootprintNow(LeaveCollectionToCaller | KeepCodeInUse) lets go of the unlinked bytecode of functions that have no linked code any more (the earlier collections
    unlinked what had not run) and that a bytecode cache can hand back (a --compile --bytecode executable's embedded bytecode),
    and of the parser's caches. Nothing that would have to be parsed again, nothing in use. Deleting code waits for a collection
    that is under way (Heap::preventCollection), which on a big heap in the middle of a concurrent full collection is hundreds of
    milliseconds of the event loop, and JSC declines with JS on the stack (a timer fired from a nested event loop): the binding
    declines in the first case and JSC in the second, nothing is dropped, that tick's quiet is not counted, and the next tick tries
    again. What the binding cannot see is a collection that has been requested and has not started: the drop then waits for it,
    which is at most the collection the previous tick requested, an eden collection of a heap that has not grown. The collection
    that follows the drop is the same requested, concurrent one. It is not gated on standalone executables: without embedded
    bytecode there is little to drop and nothing that costs anything to get back.

  3. Every JS thread runs them for its own heap, as on main, and the code now says so. The controller was written for the main
    thread only (if vm.is_main_thread()), but that asks whether the VM has a Worker, and a Worker's VM is initialised before it is
    given one: the test was always true and Workers have always run the ladder. It is removed rather than fixed. Measured: a pool
    of 8 Workers, each with 50 MB of old-generation garbage after a burst, 452 MB resident; 12 s later 40 MB, and 452 MB for good
    when only the main thread ran the ladder (each Worker's collections are its own; nothing the ladder does is process-wide any
    more, and the code drop is per VM).

  4. After an idle collection the timer stays on its fast tick for 30 ticks. The collection is requested, not run: it proceeds at
    the mutator's safepoints, which in a program that runs no JS are this timer's ticks. The second and third were requested on the
    30 s tick, where a server held on to a burst's garbage for a minute and more.

  5. Nothing is paged out. Main's second idle collection also asked the kernel to reclaim the pages of a standalone executable's
    embedded module graph (MADV_PAGEOUT). Those pages are clean and file-backed: they are not ours to evict. The kernel drops them
    by itself, at no cost, as soon as it needs the memory, and until then they are a cache that makes the program's next action
    fast; paging them out by hand only lowers the RSS column, and the next thing the program does reads them back from the disk, one
    major fault at a time (numbers below). StandaloneModuleGraph::page_out, the trait method and the call are gone.
    BUN_FEATURE_FLAG_DISABLE_STANDALONE_MADVISE stays, because two other hints read it, both one-off at start-up and unchanged:
    the read-ahead of the module graph's start-up pages (MADV_WILLNEED / F_RDADVISE) and the MADV_DONTNEED hint for the
    embedded source text once the entry point has been evaluated. After this PR it gates only those two.

"Busy" is what it is on main: the heap grew by more than 2 MB since the last tick. A wrong "idle" now costs a
concurrent collection and a re-warm, which does not justify a finer rule.

What each collection saves, and what the next request pays

A ~200 MB compiled command-line program: a scripted run of 20 requests against a stub API, a pause, one more request (release
builds; every run on its own copy of the executable; faults from /proc/<pid>/stat, instructions from perf stat; medians of 2-4
runs on a busy machine, so wall times are noisy and instructions are not). In steady state a request is 224 ms, 0.9 G instructions,
4 major faults. "main, no page-out" is main with BUN_FEATURE_FLAG_DISABLE_STANDALONE_MADVISE=1; "the page-out alone" is main with
BUN_JSC_forceCodeBlockLiveness=1 (no code aging, so the collection costs the next request nothing).

collectionanonymous memory freedthe next request pays
first (10 s)56-60 MB (the run's garbage; with code aging switched off it is the same)nothing measurable (+4 to +35 ms, no extra instructions)
second (main 75 s, here 2 min)55-57 MB (none of it with BUN_JSC_forceCodeBlockLiveness=1: it all hangs off aged-out code)+200 to +340 ms wall, +1.3 to +2.2 G instructions, +15 K minor faults, no major faults; the request after it +20 to +60 ms
third (main 140 s, here 10 min)24-34 MB (34 with the code drop)+0.4 to +0.9 G instructions on top of the second's when both have run
all of it90 MB of 257 idle after the scripted run; 12 MB of 104 idle after start-up
main's module-graph page-out (with the second collection)none: 57 MB of file-backed RSS (104 -> 46 MB)+240 to +290 ms wall and 210-220 major faults (the page-out alone, after 90 s and 3 min)
the request after a pause ofmainmain, no page-outthis PR
30 s+19 ms-4 msnot measured (nothing differs before 2 min)
90 s+600 ms, +1.55 G instr., 262 major faults+332 ms, +1.98 G, 0+6 ms, +0.1 G, 3
3 min+671 ms, +2.51 G, 262+209 ms, +2.53 G, 0+205 ms, +2.18 G, 0
11 min+768 ms, +2.67 G, 188+348 ms, +2.35 G, 0+197 ms, +1.75 G, 0

Memory, anonymous | file-backed MB, seconds after the scripted run's last request:

+5 s+30 s+80 s+135 s+150 s+300 s+610 s
main315 | 104259 | 105199 | 46199 | 47167 | 47165 | 47168 | 54
main, no page-out324 | 106264 | 104201 | 104200 | 104167 | 104166 | 104169 | 104
main, no code aging321 | 105265 | 105258 | 48257 | 49257 | 49257 | 49259 | 55
this PR317 | 105265 | 105254 | 105197 | 105197 | 105196 | 105162 | 105

Idle after start-up: 115 | 97 at +5 s, 100 | 97 at +135 s, 89 | 97 at +610 s (main without the page-out: 108, 91, 86).

The first request of a program that was idle for 10 minutes after start-up: 1402 ms and 402 major faults on main, 774 ms and
40 without the page-out.

So between 75 s and 2 min after it was last used the program holds 55 MB more than on main, and from 10 minutes on a few MB less;
in exchange the second collection's re-warm is only paid by a program that really was left alone.

A server at scale

Half an LRU Map of objects, half retained 16-512 KiB buffers; 2,000 requests a second for 60 s, then silence. Anonymous RSS in MB:

liveunder load avg / maxend+10 s+14 s+15 s+70 sp50 / p99
800 MBmain1680 / 2470247023858038038030.22 / 14.5 ms
800 MBthis PR1681 / 24702470238511598038030.22 / 13.8 ms
80 MBmain215 / 3371961041031031030.20 / 6.8 ms
80 MBthis PR213 / 3551851251081081070.20 / 6.9 ms

The first idle collection comes 10 s after the load as on main and what it frees is back within 5 s. (With that collection requested
on the 30 s tick, as the second and third are on main, an earlier state held 2.36 GB until 92 s after the traffic had stopped; that
is what the fast ticks after a collection are for.)

What was tried and dropped

#42329 and #42383 also paged out the module graph and, on the last rung, the executable's own code and constants: measured, that
bought 57 + 36 MB of file-backed RSS, which the kernel reclaims for free under pressure, for +240 to +530 ms and 210-560 major
faults on the next request. Because a wrong "idle" was then expensive, #42383
grew a classifier (the program's allocation rate against its own history, several clocks, a wake from the slow tick) that four
rounds of review kept finding edge cases in, and that no allocation-only signal can get right for a server whose traffic never
touches the JS heap. Without page-outs and without a synchronous collection a wrong "idle" is cheap, so all of that is gone.

Tests

gc-controller-cadence.test.ts, 16 tests, 6 s for the file, as on main (the new ones run alongside the existing ones;
test/expected-durations.json still said 2 s and has estimates now, until it is regenerated): a Worker's 100 MB of old-generation
garbage are given back by the Worker's own idle collection (nothing else asks for a full collection of its heap; this pins what
main does); a --compile --bytecode executable (one, shared) loses its re-decodable unlinked code with the second collection of
1,1 and gives the same results afterwards, and still has it right after the first of 1,30 has been logged; 300 MB of
old-generation garbage next to 50 MB of live data are back within seconds of the idle collection on a 20 ms tick. The last two
behaviours fail on main.
No test for the page-out: it would assert the absence of code that is deleted. Release build, 3 runs and twice with
BUN_DESTRUCT_VM_ON_EXIT=1; clippy on bun_jsc, bun_standalone_graph and bun_resolver; cargo check for
x86_64-pc-windows-msvc and aarch64-apple-darwin (before the last small change to idle_tick).