Skip to content

gc: return free pages of surviving GC pools to the OS - #13

Merged
BaruchWeka merged 4 commits into
weka-1.38from
baruch/gc-scavenger-weka15
Aug 9, 2026
Merged

gc: return free pages of surviving GC pools to the OS#13
BaruchWeka merged 4 commits into
weka-1.38from
baruch/gc-scavenger-weka15

Conversation

@BaruchWeka

@BaruchWeka BaruchWeka commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

Returns whole free (B_FREE) pool pages of the conservative GC to the OS, as a final phase of
minimize(), preserving an mlockall(MCL_CURRENT|MCL_FUTURE) guarantee where one exists.

This branch was rewritten (force-push) into an upstream-shaped series: no version (WEKA),
no weka-prefixed identifiers, gated by a new gcopt option instead of a build version. The
previously-reviewed shape is preserved at baruch/gc-scavenger-minimize if you want to diff.
See "What changed since the review" at the bottom.

Why

GC.minimize() can only unmap a pool that is 100 % free, so a heap that is mostly free but
sparsely pinned never shrinks. Measured on a core dump from an internal long-running management
process (LDC 1.38, mlockall'd):

GC pools mapped (130 pools) 7.51 GB
Live objects 106.9 MB (1.4 %), 1.57 M objects
Free space, all of it whole 4 KB pages 7.40 GB
— in contiguous runs ≥64 KB 7.35 GB
Pool range resident 100 %
Pools GC.minimize() could unmap 0

Not one pool was fully free: 108 of 130 round to 0 % occupancy (64 MB pools held as little as
256 B in 2 objects), 120 of 130 are under 10 % occupied. Page-level scavenging recovers
≈7.35 GB there; whole-pool munmap recovers nothing. Sub-page slack — the part page scavenging
can never reach — is 7.4 MB, 0.1 %.

Mechanism

Plain madvise(MADV_DONTNEED) per contiguous free run. On a heap the process has mlocked,
madvise is refused, so the sequence becomes
munlock -> madvise(MADV_DONTNEED) -> mlock2(MLOCK_ONFAULT): the page leaves RSS but stays
locked, so when the GC re-carves it the first touch faults in a zero page that is locked at
fault time. No second fault, no swap exposure, and nothing at the allocation site needs to
know a page was ever scavenged
— which is what keeps the hook surface this small. That case is
now detected from the kernel's EINVAL rather than configured, so a caller only enables the
feature; it never has to describe its own locking to the GC.

New module core/internal/gc/scavenger.d owns all of it: the per-pool scavengedMap, the
global dirty-free-page counter, arm/disable state, the scavenge loop, a vm.max_map_count
pressure guard (partial munlock/mlock2 splits a pool's VMA, so near the ceiling the split itself
can ENOMEM mid-sequence), and sticky-disable-on-syscall-failure with a best-effort re-lock.

impl/conservative/gc.d gets one field on Pool and nine one-line hook calls — every B_FREE
transition and pool lifecycle point (Pool.initialize, Pool.Dtor, both sweep branches,
LargeObjectPool.freePages/allocPages, SmallObjectPool.allocPage, extendNoSync,
reallocNoSync's expand-in-place carve) — plus the minimize() call site.

Configuration

Off unless enabled. Four gcopt options:

option default meaning
scavenge 0 upstream, 1 in this fork run the scavenge phase of minimize()
scavengeMinFree 16 MB leave the heap alone below this much resident-free
scavengeBudget 256 MB most one minimize() may release; call again for more
scavengeNoHugePages 1 MADV_NOHUGEPAGE tracked pools — page-granular release needs it

The default flip lives in its own clearly-marked fork-only commit, because defaulting
MADV_NOHUGEPAGE on for every D program's GC heap is a throughput change nobody opted into.

The series

  1. druntime: return free pages of surviving GC pools to the OS — module, hooks, gcopt options,
    the minimize() phase, arm-on-first-use, unittest, changelog entry
  2. druntime: phase stats and a continuation hint for the GC scavenge phase — the phase runs inside
    minimize() and cannot log, so a live system reads a ScavengeStats snapshot instead
  3. druntime: extern(C) entry points for application-driven GC scavenging — for a host that
    wants to pace scavenging itself, plus the failure injector
  4. Weka druntime modification: enable GC page scavenging by defaultfork only

The extern(C) surface, renamed off weka_gc_*:

gc_scavenger_pass(maxBytes)        // scavenge up to maxBytes, returns bytes scavenged
gc_scavenger_arm(heapMlocked)      // explicit arm; normally unnecessary, see above
gc_scavenger_dirty_free_bytes()    // gauge: free pages known to be resident   @nogc
gc_scavenger_status()              // OK / DISABLED_* reason                   @nogc
gc_scavenger_stats()               // ScavengeStats snapshot of the phase      @nogc
gc_scavenger_continuation_due()    // phase stopped on budget, more to reclaim @nogc
gc_scavenger_min_free()            // the gate the phase itself applies        @nogc
gc_scavenger_inject_fail(mode)     // test hook                                @nogc

The lock-free readers are @nogc so a host can call them from an @nogc context — a statistics
callback, say — rather than caching their results.

Deliberately not plain gc_*: that is druntime's public C ABI for the GC (gc_malloc,
gc_minimize, …), routed through the registry indirection, and a gc_scavenge added from
inside the conservative implementation would join that namespace while silently no-op'ing under
every other GC. Commit 3 says so in its own message — the upstream-native form is a GC
interface method surfaced through core.memory, the way minimize() is, and commit 3 is kept
separate so it can be replaced without touching the mechanism.

Validation

  • druntime unittests run from this tree via ldc-build-runtime --testrunners: the scavenger
    module passes, as does every GC module with unittests including
    core.internal.gc.impl.conservative.gc, which exercises the added hooks.
  • The new unittest was proved to test the right thing: instrumented, the final
    GC.minimize() grows ScavengeStats.scavengedBytes by exactly the dirty-free bytes it started with, so
    the whole drop is attributed to the scavenge phase and not to a pool being unmapped whole.
    Mutating the phase to return early makes dirty-free not fall at all and fails the assertion.
  • Cross-target compile at every commit for x86_64/aarch64/riscv64 Linux, macOS, and Windows.
  • Standalone soak 8/8 locally and on a cloud VM under both mlock variants (RSS 1.04 GB → 5.9 MB
    with VmLck held at ~1.3 GB, i.e. the lock guarantee really is kept); live multi-node cluster
    with a debug-assert build — forced and automatic passes both reclaim, RSS drop equals reported
    scavenged bytes, map count stays ≈1 VMA per pool in steady state, zero process restarts.

What changed since the review

  • version (WEKA) gone from all 13 scavenger sites; the mechanism is version (linux) and the
    feature is gcopt. This answers review ask 3 ("is the version (WEKA) gate the right shape,
    or should it be a runtime switch with an eye to upstreaming") — it is now a runtime switch.
  • Identifiers renamed: wekaScavengerXscavengerX, StatusScavengeStatus,
    the Counter enum → a ScavengeStats struct, weka_gc_*gc_scavenger_*. Header is
    Copyright: D Language Foundation per the surrounding modules.
  • Scavenging now runs from minimize() — the pools that survive its whole-pool phase are
    exactly the mostly-free-but-pinned ones this exists for — and arms itself on first use.
  • Bug found by dropping the version gate: static assert(false) on the mlock2 syscall number
    broke the build on every Linux arch except x86_64/AArch64. gc.d imports the module
    unconditionally, so a druntime build for ARM32, RISC-V, PPC64 or s390x died outright. Now
    SYS_mlock2 = -1 with a haveMlock2 guard: only the locked-heap sequence is unavailable
    there, and an unlocked heap scavenges normally.
  • Second bug from the same cause: Gcx.instance is version (Posix)-only, so the
    extern(C) block broke the Windows build; now version (Posix):-scoped.
  • vm.max_map_count is read once instead of on every pass, and the map-pressure check is skipped
    entirely unless the heap is mlocked — MADV_DONTNEED changes no VMA flags and so cannot split
    a VMA; only munlock/mlock2 can.

Upstream

druntime upstream is dlang/dmd (druntime/), not LDC — LDC's copy is a sync. All hunks of
commits 1–3 apply to current dmd master with offsets only; the sole exception is the extern(C)
block's end-of-file anchor, which moved. A changelog/druntime.gc-page-scavenging.dd entry is
included for that PR. We are not waiting for upstream before merging here; incompatibilities
get fixed when LDC next syncs druntime.

Review asks

  1. Hook-site completeness — is any B_FREE transition or pool-lifecycle point missed? That
    is the one class of bug that would under-count rather than misbehave visibly.
  2. Lock discipline of the extern(C) entry points (modeled on ConservativeGC.minimize).
  3. Is scavenge defaulting on in this fork (commit 4) the right call, given it also turns
    MADV_NOHUGEPAGE on for the whole GC heap?
  4. If it looks good: a v1.38.0-weka16 tag, so consumers can pin a tag instead of this SHA.

@ljmf00-wekaio ljmf00-wekaio left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need to obviously think more about this API if we want to upstream this, but for weka only, this looks good.

__gshared bool g_stickyDisabled;
__gshared int g_status = Status.NOT_ARMED;
__gshared size_t g_poolCursor; // rotates which pool a pass starts scanning from
__gshared int g_injectFailMode; // test hook: 0=off, 1=next madvise(DONTNEED) fails, 2=next mlock2 fails

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We are assuming that this is used in non multithreaded calls

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct, and I documented the invariant in 3193b78 rather than adding synchronisation.

All of this state is mutated only under the GC lock: the hooks are called from inside gc.d with the lock already held, and every extern(C) entry point that writes takes ConservativeGC.lockNR() the same way minimize() does. The exceptions are deliberate and racy-benign — weka_gc_dirty_free_bytes and weka_gc_scavenger_status are lock-free reads (a stale gauge or status code costs nothing: one feeds a policy heuristic, the other a stat), and weka_gc_scavenger_inject_fail is test-only.

If you would rather the counter were atomic I will do it, but I do not think a torn size_t is reachable given every writer holds the lock.

else
private enum compiledOut = false;

private enum MIN_RUN_PAGES = 16; // 64KB -- below this, syscall/VMA churn isn't worth it

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is considering 4K pages, is this still ok for 64K page systems? I guess the GC is still hardcoded to 4K, so I would use the constant used in the GC instead of assuming 4K pages.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, fixed in 3193b78. It is now MIN_RUN_BYTES = 64 * 1024 with MIN_RUN_PAGES = MIN_RUN_BYTES / PAGESIZE, using the GC's own PAGESIZE rather than assuming 4K, so the intent ("64KB is where the syscall and VMA churn stops being worth it") holds whatever the pool page size is.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Coming back to this — your question was broader than my answer, and the real bug was not MIN_RUN_BYTES.

madvise/mlock2 operate in kernel pages. Aiming them at 4K-derived boundaries on a 64K host fails two ways: an unaligned start returns EINVAL, which scavengeRun misreads as "the heap is locked" and promotes the process to the munlock/mlock2 sequence; and the length is rounded up, past the end of the run, discarding live pages.

Fixed in the current tip: scavengerArm records sysconf(_SC_PAGESIZE), and scavengePool trims each run to its kernel-page-aligned interior, leaving the edges dirty — one page each, picked up when a neighbouring run grows over them. Arithmetically a no-op wherever the kernel page is the GC page. It fails closed to DISABLED_PAGE_SIZE_MISMATCH if sysconf fails, or reports a page below the GC's own, or one that is not a power of two.

There is a regression test that overrides the recorded granularity to 64K and asserts every reclaim is a whole number of kernel pages. It is mutation-verified: delete the trimming and it fails. Its blocks are deliberately 5 GC pages, because the first version used 64K blocks whose runs were accidentally already aligned, so it passed with the trimming removed.

Not yet covered: a live 64K host. Every machine I have is 4K, so that path rests on the unit test until I can get an aarch64 box with a 64K kernel.

@BaruchWeka
BaruchWeka force-pushed the baruch/gc-scavenger-weka15 branch from 3193b78 to cdc3211 Compare July 31, 2026 05:46
@BaruchWeka BaruchWeka changed the title gc: add page-level madvise scavenger (weka_gc_scavenge) gc: return free pages of surviving GC pools to the OS Jul 31, 2026
@BaruchWeka
BaruchWeka force-pushed the baruch/gc-scavenger-weka15 branch 3 times, most recently from f297774 to 2760cff Compare July 31, 2026 15:59

@EyalIO EyalIO left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not really familiar enough with this code to give a proper review

@BaruchWeka

Copy link
Copy Markdown
Collaborator Author

@ljmf00-wekaio — you asked for two things: "think more about this API if we want to upstream this" here, and on the wekapp side "why don't we do this as part of minimize API (instead of a separate dedicated one) and only add thresholds in the reactor". Both are done, and this series is the result. Since it changes the shape you approved, worth a fresh look.

The mechanism is now a phase of minimize(), driven by new gcopt options (scavenge, scavengeBudget, scavengeMinFree, scavengeNoHugePages), so a stock D program gets it through the API it already calls, bounded so an existing minimize() caller cannot inherit a syscall storm. The extern(C) entry points are a separate commit on top, for a caller that wants to drive passes on its own schedule — which is exactly what the reactor now does, thresholds only.

Also changed since your review:

  • De-weka-ed. No version (WEKA) in the mechanism (gating is version (linux) + gcopt), weka_gc_*gc_scavenger_*, D Language Foundation header, user-facing changelog entry added.
  • Upstream target is dlang/dmd under druntime/, not LDC — LDC vendors druntime, so this repo is downstream of the real home. Commits 1–3 are the upstream candidate; commit 4 turns the option on by default and is weka-only, because default-on also means MADV_NOHUGEPAGE for every D program's heap.
  • Stats are a ScavengeStats struct returned by value, replacing the index accessor.
  • Two real build breaks that the WEKA guards had been hiding: static assert(false) on the mlock2 syscall number broke every Linux arch except x86_64/AArch64 (now SYS_mlock2 = -1 plus a haveMlock2 check), and Gcx.instance is Posix-only, so the extern(C) block needed version (Posix):.
  • A 64K-page correctness bug, from your question in the thread above — details there.
  • /proc/sys/vm/max_map_count was being read every pass; now read once, and the map-pressure check is skipped entirely unless the heap is mlocked, since MADV_DONTNEED on its own splits no VMAs.

On your thread-safety question: unchanged, and still lock-protected as described. The offer stands to make the counter atomic if you would rather not rely on the invariant.

Upstream submission is gated on copyright assignment, not on anything technical.

minimize() can only unmap a pool that is entirely free, so a single live object
anywhere in a pool pins every page of it. A long-lived process whose heap grew
once and then freed most of it therefore keeps paying for that peak in resident
memory however much of each pool has since become free.

Add a page-level scavenger for the conservative GC that releases the individual
free pages of the pools minimize() had to keep, with madvise(MADV_DONTNEED), and
run it as a final phase of minimize(). It is off unless enabled through the new
gcopt scavenge option, and bounded by scavengeBudget and scavengeMinFree so an
existing minimize() caller cannot inherit an unbounded syscall storm.

Heaps that the process has locked into memory are supported. Where a plain
madvise would be refused because the range is locked, the sequence becomes
munlock/madvise(MADV_DONTNEED)/mlock2(MLOCK_ONFAULT), which drops the page from
the resident set while keeping the mlockall(MCL_CURRENT|MCL_FUTURE) guarantee:
the page is locked again at the moment it faults back in, so it is never
swappable and the allocation site needs to know nothing. That case is detected
from the kernel's EINVAL rather than configured.

Linux only, and a no-op elsewhere: no other platform offers a way to drop a page
out of RSS while keeping it locked.
The scavenge phase runs inside minimize() and cannot log -- it has no access to
the host application's tracing -- so a live system had no way to tell whether it
had acted, or which gate stopped it. Expose a ScavengeStats snapshot instead.

A struct of named fields rather than a counter-by-index accessor: the caller reads
one coherent snapshot instead of a sequence of independent reads, and adding a
counter later cannot silently renumber the ones an existing caller already reads.

Add a continuation hint alongside it. The phase stops on gcopt scavengeBudget, and
nothing calls minimize() again on a quiet process, so record whether it left
reclaimable pages behind rather than strand the remainder until the next caller
happens along.
A host that wants to pace scavenging itself, rather than leave it to minimize(),
needs to trigger a pass, read the dirty-free gauge and read back the phase stats.
Expose them as extern(C) wrappers that resolve the live Gcx and take the GC lock,
following the shape of ConservativeGC.minimize().

The lock-free readers are @nogc, so a host can call them directly from an @nogc
context such as a statistics callback instead of caching their results.

Also add the madvise/mlock2 failure injector the sticky-disable path is tested
with, since it is only reachable from here.

This is not the upstream-native shape -- that would be a GC interface method
surfaced through core.memory, the way minimize() is, and would work under any GC
rather than silently no-op under the others. Kept as a separate commit so it can
be dropped or replaced without touching the mechanism.
Weka's processes lock their heap and depend on the scavenger to return it, so the
gcopt option is on unless explicitly disabled rather than opt-in.

Not for upstream: turning this on by default also turns MADV_NOHUGEPAGE on for
every D program's GC heap, which is a throughput change nobody asked for.
@BaruchWeka
BaruchWeka force-pushed the baruch/gc-scavenger-weka15 branch from 2760cff to dc711a2 Compare August 7, 2026 06:21
@BaruchWeka
BaruchWeka merged commit e638ee9 into weka-1.38 Aug 9, 2026
10 checks passed
@BaruchWeka
BaruchWeka deleted the baruch/gc-scavenger-weka15 branch August 9, 2026 12:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants