Skip to content

PoC simple generational GC - #19709

Draft
arnaud-lb wants to merge 3 commits into
php:masterfrom
arnaud-lb:gen-gc
Draft

arnaud-lb wants to merge 3 commits into
php:masterfrom
arnaud-lb:gen-gc

Conversation

@arnaud-lb

@arnaud-lb arnaud-lb commented Sep 4, 2025 •

Copy link
Copy Markdown
Member

This is a PoC of making the cycle collector generational.

In some applications, there is an object that transitively references everything (e.g. the DI container or the UnitOfWork), and many objects transitively reference this object back. As a result, most GC runs in these applications will end up scanning the entire application. This defeats the premise of the cycle collector.

Based on that, a generational cycle collector should improve throughput to some degree.

Unlike a tracing GC, we don't need to keep track of created inter-generational references, so this turns out relatively simple.

  1. Nodes that are scanned but not collected during a run are marked as OLD
  2. There are two kinds of GC runs: Partial and full. Partial runs ignore OLD nodes during trial deletion and other steps.
  3. gc_possible_root() adds OLD nodes to a separate buffer that doesn't count towards the threshold
  4. At the end of a partial run, OLD roots are moved to a separate buffer. This buffer is appended to the roots before full runs.

This is enabled by setting the environment variable FULL_GC_FREQ to a non-zero value (e.g. 10 will run a full GC every 10 runs).

I've used the following script to test this PoC: https://gist.github.com/arnaud-lb/6bfb493f361e056979571941f1b85990. This simulates a typical Doctrine or Symfony application with a very large DI container or UnitOfWork; a worse-case scenario for the GC.

With FULL_GC_FREQ=10, the benchmark runs 10-20% faster when the root object (the Tree class) is large, or it creates garbage often (keeping the threshold low). The benchmark runs slower when the root object is relatively small and it does not create garbage often, as in comparison the non-gen GC will keep the threshold high in this case. This could be mitigated by improving how the threshold is adjusted in the gen GC. In the first case the non-gen GC is slow because of the low threshold, so we may achieve similar results in the non-gen GC with better adjustment of the threshold, at the cost of higher peak mm usage than the gen GC.

Of course this is only one benchmark. This needs to be tested on real applications.

This should improve throughput and average pause time, but not maximum pause time.

Related: #17131

Implementation details:

  • Introduced a new type_info flag: GC_OLD. This flag is not part of GC_INFO_MASK, so it's ignored by GC_INFO_MASK() / GC_REMOVE_FROM_BUFFER() / GC_REF_SET_INFO() and persists across GC runs. Caveat: Had to reclaim a bit from GC_ADDRESS.
  • Added a separate buffer for old roots.
  • gc_mark_roots() either ignores OLD nodes (in partial runs), or removes the OLD flag (in full runs)
  • gc_scan_roots() always ignores OLD nodes
  • gc_collect_roots() moves OLD roots to the old roots buffer (unless this is a full run, as in this case we have proven these roots are not part of a garbage cycle).

@dktapps

dktapps commented Sep 5, 2025 •

Copy link
Copy Markdown
Contributor

Haven't given much thought to this, but have you considered having it only do a full GC run when e.g. the collector didn't manage to collect some minimum number of cycles? Though I suppose this would still penalise applications which create very few cycles...

In my particular case it'd be preferable to avoid full GC entirely. Realistically I'd still end up doing manual GC triggers anyway (so my application would have control over when to do a full vs incremental GC) but it's probably worth thinking about what conditions should trigger a full automatic GC.

@ArtUkrainskiy

Copy link
Copy Markdown
Contributor

I tried this branch at 3e57c1e, merged onto master 4f3d950, with FULL_GC_FREQ=10. Under ASan with USE_ZEND_ALLOC=0 it's a heap-use-after-free. A partial run frees an object that a live array still holds, and the next read from that array touches freed memory. kamil-tekiela also hit a use-after-free on this branch with PHPStan, in gc_mark_roots(): #25243 (comment)

This reproduces it every time. It's the workload of the #25243 test, and without the env variable it's clean:

<?php
class Node { public $owner; public $data; }
$c = new stdClass;
for ($i = 0; $i < 200000; $i++) { $n = new Node; $n->owner = $c; $c->nodes[] = $n; }
for ($i = 0; $i < 500000; $i++) {
    $a = new Node; $b = new Node; $a->data = $b; $b->data = $a; unset($a, $b);
    $n = $c->nodes[$i % 200000]; unset($n);
}
echo "ok\n";

Without ASan, doctrine/orm's test suite segfaults on this branch, and garbage rings of 100k objects end with zend_mm_heap corrupted.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants