GC controller: one idle ladder of full collections; Worker VMs do not run it

Closed
JA
1mo ago
+466-129

What does this PR do?

The GC controller's idle work (BUN_IDLE_GC_SECONDS: full collections once the heap has been quiet for a while, and the page-out of a standalone executable's embedded module graph with the second of them) becomes one ladder, and Worker VMs stop running it. What runs is what runs on main; this changes when, and on which threads.

The ladder. BUN_IDLE_GC_SECONDS is a list of up to three rungs (seconds before the first and between the others). The controller keeps two facts: when a timer tick last saw the program busy (last_busy_at), and how many rungs have run since (idle_rungs_done). A busy tick stamps the time and starts over; any other tick runs the next rung if the program has been idle that long, at most one rung per tick, and the timer is re-armed for the earlier of its normal interval and the next rung. The tick uses the clock reading the timer dispatcher already took. main accumulated nominal tick intervals instead of time.

rungdefaultwhat runs
110 s idleidle full collection (requested)
2 (or the only one)2 minidle full collection (requested) + page-out of a standalone executable's module graph (Linux), as on main
310 minidle full collection (requested)

Busy is:

  • the heap grew by more than the slack (main's rule, unchanged);
  • or the tick came more than two seconds after it was due: the thread sat in a synchronous call, or the process was stopped or the machine asleep, and that time was not spent idle (Bun.spawnSync(["sleep", "8"]) used to run all three rungs the moment it returned).

As on main, heap growth cannot tell a small server from a parked program: a server's heap does not grow, so Bun.serve answering 20 requests a second walks the ladder (its start-up collection plus three rungs in 12 s with the list 2,2,2), here as on main. That is why this PR adds nothing that would hurt a working server: the rungs are requested collections and the module-graph page-out main already does under the same rule (after 75 s there, 2 min here). #42383 replaces the heap-growth term by what the program allocates against the collector's own budget (the two accessors it needs are in main's WebKit since #42822), and only then adds the page-out of the executable image to the last rung.

Defaults and the ticks after a rung. 10,65,65 (10 s, 75 s, 140 s) becomes 10,110,480 (10 s, 2 min, 10 min): the first rung stays where main has it, because a server that has gone quiet should not keep a burst's garbage for half a minute, and the two that cost something when the program comes back move out to where someone who reads output or thinks for a few minutes still comes back to a warm process. A rung requests its collection; it then proceeds at the mutator's safepoints, which in a program that runs no JS are this timer's ticks, and what it frees goes back to the system on the ticks after that. After a rung the timer is therefore on its fast tick for the next 30 ticks. Without that, a rung that falls on the 30 s tick (an earlier state of this PR had the first one at 30 s, which is exactly when the timer goes slow) started its collection on time and finished it a tick later: a server with 800 MB live that had just served 2,000 requests a second held 2.36 GB until 92 s after its traffic stopped, where main is back at 0.8 GB after 13 s. With the change: 2.39 GB at +10 s, 0.81 GB at +15 s (first below 1.2 GB at +13 s and +14 s in two runs; main +13 s in the same harness).

Workers. A Worker's own VM also ran the idle collections and the module-graph page-out, on main too: GarbageCollectionController::init asks is_main_thread(), which is "has no worker", and runs before the Worker's VM is given one. It now reads the VM's is_main_thread field. And the module graph is not paged out while a Worker is alive: the page-out is for the whole process and the ladder only watches the main thread. The rung itself runs when it is due (its collection, and the later rungs', are for this thread's heap), and the page-out it could not do is owed: the first tick that finds no Worker does it, and a busy tick forgets the debt with the rest of the ladder.

Parsing. An entry that is not a positive decimal number ("", "0", "1.5", "1,,1", "30,abc,480") turns the ladder off; it used to end the list, which made 1,x a list of one with the page-out after a second. A number too large to read is the hour that is the maximum.

In a parked process that runs no JS a requested collection starts on time but finishes at the next mutator safepoint, which is the next timer tick (START at t, END at t + 1.0 s on the 1 s tick; up to 30 s on the 30 s tick, and the module-graph page-out is issued while it is open); nothing is lost.

GarbageCollectionController.rs is 291 lines (main: 260).

How did you verify your code works?

test/js/bun/gc/gc-controller-cadence.test.ts, on 100 ms ticks with lists of one-second entries. The child runs from a file (started with -e it has a single GC marker thread, with which a rung's concurrent collection stays open and the next rung's request is folded into it), stamps its stderr with its own clock, so a collection is timed where it happened however late the parent reads the pipe, and exits when the parent has seen the rungs the test waits for or at its own deadline, after an EXIT stamp that keeps the teardown's collection (BUN_DESTRUCT_VM_ON_EXIT, set on the ASAN lanes) out of the count; it must exit cleanly.

  • lists of one, two and three run that many idle collections, a second apart, and no more afterwards;
  • "", "0", "1,,1", "1,abc", "1.5", "-1" run none; a program whose heap keeps growing gets none; 2.5 s in Bun.sleepSync are not idle time (no rung comes with its return); a program that runs a 10 ms timer still goes idle;
  • Linux release lanes with a disk-backed temp dir: one standalone executable is built, written back, and the tests run it one at a time (shared or dirty pages are not reclaimed). They used to run a copy each, side by side, which is what failed on the ubuntu lanes of BuildKite #115804: the copies are made with synchronous calls one after the other (170 MB each for the profile binary CI tests with), and on a slow disk a child that had already started did not get to run while the following copies were written (3-5 timer ticks in 4-8 s, its own 4 s deadline firing at 7.9 s, no busy term set on any tick). Reproduced with the CI artifact under IOWriteBandwidthMax/IOReadBandwidthMax=60M on an ext4 loop mount. What counts is the resident size of the executable's own mappings in /proc/self/smaps (the code, and the mapping that holds the module graph: a 3 MB embedded file the program reads after start-up), looked at when RssFile falls by 2 MB, because RssFile alone can fall when the kernel reclaims clean pages. The module graph goes with the second rung of three and of two and with the only rung of one, the code never; the graph stays after 1,99999999999, with BUN_FEATURE_FLAG_DISABLE_STANDALONE_MADVISE=1, and while a Worker is alive (2 s, although the rung is due after 1 s), and goes right after the Worker has; with a Worker alive for the whole of a list of three all three collections still run;
  • a child with 50 MB alive and 300 MB that have survived a full collection and that nothing refers to any more, on a 20 ms tick (slow after 0.6 s) with the rung 2 s in, has given 250 MB back within 6 s; on the previous head it still holds 405 MB then;
  • the FTL-aging test (idle collections drop warmed-up code, collections the program forces do not) reports as soon as the code is gone.

On main the malformed lists, the synchronous call, the Worker and the oversized entry fail. 30 of 30 pass (3 runs and once with BUN_DESTRUCT_VM_ON_EXIT=1, 19 s: the page-out tests run one after the other), heapStats-mimalloc.test.ts passes, clippy is clean on bun_jsc, cargo check passes for x86_64-pc-windows-msvc and aarch64-apple-darwin. The file is out of the parallel test batch (test/parallel-denylist.txt, and excludeFiles in the allowlist as the generator would write it): it measures seconds of idleness, which a loaded batch stretches.