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.
58 lines
1.9 KiB
C++
58 lines
1.9 KiB
C++
/*
|
|
* 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);
|
|
}
|