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.
This commit is contained in:
parent
42da44868b
commit
2a67d3702a
5 changed files with 185 additions and 0 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@
|
|||
#include <LibGC/ConservativeVector.h>
|
||||
#include <LibGC/Forward.h>
|
||||
#include <LibGC/HeapRoot.h>
|
||||
#include <LibGC/IdleCollectionPolicy.h>
|
||||
#include <LibGC/Root.h>
|
||||
#include <LibGC/RootHashMap.h>
|
||||
#include <LibGC/RootVector.h>
|
||||
|
|
@ -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<typename Callback>
|
||||
void for_each_block(Callback callback)
|
||||
{
|
||||
|
|
@ -197,6 +201,10 @@ private:
|
|||
Vector<GC::Ptr<Cell>> m_cells_allocated_during_sweep;
|
||||
CellAllocator::SweepList m_allocators_to_sweep;
|
||||
RefPtr<Core::Timer> m_incremental_sweep_timer;
|
||||
|
||||
RefPtr<Core::Timer> m_idle_gc_timer;
|
||||
u64 m_total_allocated_bytes { 0 };
|
||||
IdleCollectionPolicy m_idle_collection_policy;
|
||||
};
|
||||
|
||||
inline void Heap::did_create_root(Badge<RootImpl>, RootImpl& impl)
|
||||
|
|
|
|||
71
Libraries/LibGC/IdleCollectionPolicy.h
Normal file
71
Libraries/LibGC/IdleCollectionPolicy.h
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
/*
|
||||
* Copyright (c) 2026-present, the Ladybird developers.
|
||||
*
|
||||
* SPDX-License-Identifier: BSD-2-Clause
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <AK/StdLibExtras.h>
|
||||
#include <AK/Types.h>
|
||||
|
||||
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 };
|
||||
};
|
||||
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
set(TEST_SOURCES
|
||||
TestGCContainers.cpp
|
||||
TestGCIdleCollection.cpp
|
||||
TestGCVisitor.cpp
|
||||
)
|
||||
|
||||
|
|
|
|||
58
Tests/LibGC/TestGCIdleCollection.cpp
Normal file
58
Tests/LibGC/TestGCIdleCollection.cpp
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
/*
|
||||
* Copyright (c) 2026-present, the Ladybird developers.
|
||||
*
|
||||
* SPDX-License-Identifier: BSD-2-Clause
|
||||
*/
|
||||
|
||||
#include <LibGC/IdleCollectionPolicy.h>
|
||||
#include <LibTest/TestCase.h>
|
||||
|
||||
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);
|
||||
}
|
||||
Loading…
Reference in a new issue