From 2a67d3702a1f9b094a24a01e1f8d9583c4f71398 Mon Sep 17 00:00:00 2001 From: Andreas Kling Date: Fri, 15 May 2026 10:08:37 +0200 Subject: [PATCH] LibGC: Proactively collect garbage when the mutator goes idle Collection was purely allocation-driven: a GC only ran once allocation since the last collection passed a threshold of 7/4 of the live set (floored at 8 MiB). A page that allocated garbage but never reached that threshold held onto it indefinitely once it went idle, so we never handed memory back to the system promptly. Run a 4-second repeating timer while the mutator is allocating; on each tick IdleCollectionPolicy picks one of three actions: - Park the timer when nothing has been allocated since the last collection. The next allocation re-arms it, so a fully idle heap costs nothing. - Collect when this tick's allocation rate fell below 1/4 of the peak rate seen this episode (the mutator left an active phase), provided at least threshold/16 of garbage has piled up, so we don't mark the whole live heap to reclaim a trivial amount. - Otherwise let a watchdog collect after 15 ticks (60 seconds), so garbage cannot sit indefinitely on a heap that allocates too steadily to show a rate drop, or too slowly to clear the gate. The GC heap is never completely silent in practice, since event-loop housekeeping keeps queuing small objects like HTML tasks; that is why the trigger watches for a relative rate drop rather than for zero allocation. The per-tick decision lives in IdleCollectionPolicy, separate from the timer plumbing, with a unit test covering the rate-drop trigger, the minimum-garbage gate, the watchdog, and parking when idle. --- Libraries/LibGC/Heap.cpp | 47 +++++++++++++++++ Libraries/LibGC/Heap.h | 8 +++ Libraries/LibGC/IdleCollectionPolicy.h | 71 ++++++++++++++++++++++++++ Tests/LibGC/CMakeLists.txt | 1 + Tests/LibGC/TestGCIdleCollection.cpp | 58 +++++++++++++++++++++ 5 files changed, 185 insertions(+) create mode 100644 Libraries/LibGC/IdleCollectionPolicy.h create mode 100644 Tests/LibGC/TestGCIdleCollection.cpp diff --git a/Libraries/LibGC/Heap.cpp b/Libraries/LibGC/Heap.cpp index c4b2ddbdd4..574c559c1c 100644 --- a/Libraries/LibGC/Heap.cpp +++ b/Libraries/LibGC/Heap.cpp @@ -51,6 +51,10 @@ static constexpr size_t GC_HEAP_GROWTH_FACTOR_DENOMINATOR { 4 }; static constexpr int GC_INCREMENTAL_SWEEP_INTERVAL_MS = 16; static constexpr int GC_INCREMENTAL_SWEEP_SLICE_MS = 5; +// The idle GC timer ticks at this interval while the mutator is allocating; IdleCollectionPolicy decides on each tick +// whether to proactively collect. See idle_gc_on_timer(). +static constexpr int GC_IDLE_GC_INTERVAL_MS = 4000; + static Heap* s_the; namespace { @@ -293,6 +297,12 @@ void Heap::will_allocate(size_t size) } m_allocated_bytes_since_last_gc += size; + m_total_allocated_bytes += size; + + // Keep the idle GC timer armed while allocation is happening, so a proactive collection runs once the mutator's + // allocation rate drops. + if (!m_idle_gc_timer || !m_idle_gc_timer->is_active()) + start_idle_gc_timer(); } void Heap::did_allocate_external_memory(size_t size) @@ -649,6 +659,10 @@ void Heap::collect_garbage(CollectionType collection_type, bool print_report) g_next_incremental_sweep_should_report = false; run_post_gc_tasks(); + + // A collection just happened: restart the idle policy's episode (peak rate and watchdog tick count) from here, so + // a threshold-driven GC mid-episode doesn't leave it comparing against stale state. + m_idle_collection_policy.reset(m_total_allocated_bytes); } void Heap::run_post_gc_tasks() @@ -1354,6 +1368,39 @@ void Heap::sweep_on_timer() } } +void Heap::start_idle_gc_timer() +{ + if (!m_idle_gc_timer) { + m_idle_gc_timer = Core::Timer::create_repeating(GC_IDLE_GC_INTERVAL_MS, [this] { + idle_gc_on_timer(); + }); + } + m_idle_collection_policy.reset(m_total_allocated_bytes); + m_idle_gc_timer->start(); +} + +void Heap::idle_gc_on_timer() +{ + // Leave an in-progress incremental sweep alone; it is already reclaiming memory. A GC deferral means now is not a + // safe time to collect. In both cases we reconsider on the next tick. + if (m_incremental_sweep_active || is_gc_deferred()) + return; + + switch (m_idle_collection_policy.evaluate(m_total_allocated_bytes, m_allocated_bytes_since_last_gc, m_gc_bytes_threshold)) { + case IdleCollectionPolicy::Decision::KeepWaiting: + return; + case IdleCollectionPolicy::Decision::Park: + // Nothing left to collect; the next allocation will re-arm the timer. + m_idle_gc_timer->stop(); + return; + case IdleCollectionPolicy::Decision::Collect: + m_allocated_bytes_since_last_gc = 0; + collect_garbage(); + m_idle_gc_timer->stop(); + return; + } +} + void Heap::defer_gc() { ++m_gc_deferrals; diff --git a/Libraries/LibGC/Heap.h b/Libraries/LibGC/Heap.h index 97d3709c3b..784e64dbdb 100644 --- a/Libraries/LibGC/Heap.h +++ b/Libraries/LibGC/Heap.h @@ -22,6 +22,7 @@ #include #include #include +#include #include #include #include @@ -152,6 +153,9 @@ private: void stop_incremental_sweep_timer(); void sweep_on_timer(); + void start_idle_gc_timer(); + void idle_gc_on_timer(); + template void for_each_block(Callback callback) { @@ -197,6 +201,10 @@ private: Vector> m_cells_allocated_during_sweep; CellAllocator::SweepList m_allocators_to_sweep; RefPtr m_incremental_sweep_timer; + + RefPtr m_idle_gc_timer; + u64 m_total_allocated_bytes { 0 }; + IdleCollectionPolicy m_idle_collection_policy; }; inline void Heap::did_create_root(Badge, RootImpl& impl) diff --git a/Libraries/LibGC/IdleCollectionPolicy.h b/Libraries/LibGC/IdleCollectionPolicy.h new file mode 100644 index 0000000000..8417e9b6d2 --- /dev/null +++ b/Libraries/LibGC/IdleCollectionPolicy.h @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2026-present, the Ladybird developers. + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#pragma once + +#include +#include + +namespace GC { + +// Decides, once per idle-timer tick, whether the heap should be proactively collected. See Heap::idle_gc_on_timer(). +// +// The GC heap is never completely silent in practice: event-loop housekeeping (queuing an HTML task and so on) keeps a +// small allocation trickle going even on an idle page. So instead of waiting for zero allocation, we watch for the +// mutator transitioning out of an active phase: a tick whose allocation rate has dropped below 1/low_rate_divisor of +// the peak rate seen so far this episode. The rate-drop trigger is gated on enough uncollected garbage having piled up +// to be worth marking the whole live heap. The watchdog bounds how long garbage can sit when the rate-drop trigger +// never fires, e.g. on a heap that allocates too steadily to show a drop or too slowly to clear the gate. +class IdleCollectionPolicy { +public: + enum class Decision : u8 { + KeepWaiting, // No collection yet; leave the idle timer running. + Park, // Nothing left to collect; stop the timer until allocation resumes. + Collect, // Collect now. + }; + + // Begins a fresh episode. `total_allocated_bytes` is the heap's monotonic allocation counter. + void reset(u64 total_allocated_bytes) + { + m_total_allocated_at_last_check = total_allocated_bytes; + m_peak_delta = 0; + m_tick_count = 0; + } + + // Evaluates one idle-timer tick. `total_allocated_bytes` is the monotonic allocation counter, `garbage_bytes` is + // the amount allocated since the last collection, and `gc_threshold` is the allocation-driven GC threshold. + Decision evaluate(u64 total_allocated_bytes, size_t garbage_bytes, size_t gc_threshold) + { + if (garbage_bytes == 0) + return Decision::Park; + + auto delta = total_allocated_bytes - m_total_allocated_at_last_check; + m_total_allocated_at_last_check = total_allocated_bytes; + m_peak_delta = max(m_peak_delta, delta); + + bool rate_dropped = delta * low_rate_divisor < m_peak_delta; + bool watchdog_elapsed = ++m_tick_count >= watchdog_ticks; + bool enough_garbage = garbage_bytes >= gc_threshold / min_garbage_divisor; + + if ((rate_dropped && enough_garbage) || watchdog_elapsed) + return Decision::Collect; + return Decision::KeepWaiting; + } + + // A tick counts as a rate drop when its allocation is below 1/low_rate_divisor of the episode's peak. + static constexpr u64 low_rate_divisor = 4; + // The rate-drop trigger only fires once garbage reaches gc_threshold / min_garbage_divisor. + static constexpr size_t min_garbage_divisor = 16; + // The watchdog forces a collection after this many ticks regardless of the rate or the gate. + static constexpr u32 watchdog_ticks = 15; + +private: + u64 m_total_allocated_at_last_check { 0 }; + u64 m_peak_delta { 0 }; + u32 m_tick_count { 0 }; +}; + +} diff --git a/Tests/LibGC/CMakeLists.txt b/Tests/LibGC/CMakeLists.txt index e12bf4c975..bd03943f2e 100644 --- a/Tests/LibGC/CMakeLists.txt +++ b/Tests/LibGC/CMakeLists.txt @@ -1,5 +1,6 @@ set(TEST_SOURCES TestGCContainers.cpp + TestGCIdleCollection.cpp TestGCVisitor.cpp ) diff --git a/Tests/LibGC/TestGCIdleCollection.cpp b/Tests/LibGC/TestGCIdleCollection.cpp new file mode 100644 index 0000000000..ff6be04dff --- /dev/null +++ b/Tests/LibGC/TestGCIdleCollection.cpp @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2026-present, the Ladybird developers. + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#include +#include + +using Decision = GC::IdleCollectionPolicy::Decision; + +TEST_CASE(collects_once_the_allocation_rate_drops) +{ + GC::IdleCollectionPolicy policy; + policy.reset(0); + + // A burst establishes the episode's peak rate; a single tick is never itself a rate drop. + EXPECT(policy.evaluate(16 * MiB, 16 * MiB, 8 * MiB) == Decision::KeepWaiting); + + // No further allocation: the rate has collapsed and there is plenty of garbage, so collect. + EXPECT(policy.evaluate(16 * MiB, 16 * MiB, 8 * MiB) == Decision::Collect); +} + +TEST_CASE(rate_drop_is_gated_on_having_enough_garbage) +{ + GC::IdleCollectionPolicy policy; + policy.reset(0); + + // A burst far below the minimum-garbage gate (threshold / 16, i.e. 0.5 MiB here). + EXPECT(policy.evaluate(64 * KiB, 64 * KiB, 8 * MiB) == Decision::KeepWaiting); + + // The rate has dropped, but there still isn't enough garbage to be worth marking the whole live heap. + EXPECT(policy.evaluate(64 * KiB, 64 * KiB, 8 * MiB) == Decision::KeepWaiting); +} + +TEST_CASE(watchdog_collects_when_the_rate_never_drops) +{ + GC::IdleCollectionPolicy policy; + policy.reset(0); + + // Steady allocation every tick never looks like a rate drop, so only the watchdog can fire. + u64 total = 0; + for (u32 tick = 1; tick < GC::IdleCollectionPolicy::watchdog_ticks; ++tick) { + total += MiB; + EXPECT(policy.evaluate(total, 1 * MiB, 8 * MiB) == Decision::KeepWaiting); + } + + // The watchdog fires on the final tick regardless of the rate or the gate. + total += MiB; + EXPECT(policy.evaluate(total, 1 * MiB, 8 * MiB) == Decision::Collect); +} + +TEST_CASE(parks_when_there_is_nothing_to_collect) +{ + GC::IdleCollectionPolicy policy; + policy.reset(0); + EXPECT(policy.evaluate(4 * MiB, 0, 8 * MiB) == Decision::Park); +}