From af71b8e0f66fce3ed9b03da0143c67397c12b9d5 Mon Sep 17 00:00:00 2001 From: Kamil Tekiela Date: Fri, 6 Nov 2026 15:12:22 +0000 Subject: [PATCH 1/2] Fix GH-9993: Bad performance with hard-coded GC_THRESHOLD_* values The GC raised its threshold only when a run collected fewer than 100 nodes. A run that traverses a large live graph but also frees some garbage kept the threshold at 10001, so the GC traversed the whole live graph again after every 10000 new roots. Test suites that keep large object graphs alive spent most of their time in the GC after GH-9266 fixed the count that the threshold rule uses. Count the nodes that a run traverses. After the run, set the threshold to at least (traversed - collected) / 8, so that the cost of a run is paid for by the roots added before the next run. The old step rule still sets the base threshold: * The threshold falls back to the base threshold as soon as a run stops traversing the live graph. * The threshold resets to the base threshold at request startup, as the live graph belongs to the request. * The root buffer grows to the new threshold with one reallocation. A higher threshold makes each run free a larger batch of garbage, which fits less well in the CPU cache and keeps garbage alive for longer. A ratio of 4 was faster for large live graphs, but raised peak memory by up to 43% and slowed down a 100k node live graph with large garbage cycles by 40%. With a ratio of 8 that case is 5-13% slower, and peak memory did not change in any measured case. Scripts with small live graphs keep the old threshold. With a live graph of 1M nodes that the script touches while it creates garbage cycles, the run time drops from 24.2s to 3.3s. Co-Authored-By: Claude Opus 5.5 --- NEWS | 2 ++ UPGRADING | 3 ++ Zend/tests/gc/gh9993.phpt | 70 +++++++++++++++++++++++++++++++++++++++ Zend/zend_gc.c | 70 +++++++++++++++++++++++++++------------ 4 files changed, 123 insertions(+), 22 deletions(-) create mode 100644 Zend/tests/gc/gh9993.phpt diff --git a/NEWS b/NEWS index 78f010b74ce7..9a529a29b0bb 100644 --- a/NEWS +++ b/NEWS @@ -6,6 +6,8 @@ PHP NEWS . A flat unkeyed list assignment from an array literal with one value per target, such as [$a, $b] = [$b, $a], no longer builds the array when the result is unused (GH-23048). (Artem Ukrainskiy) + . Fixed bug GH-9993 (Bad performance with hard-coded GC_THRESHOLD_* values). + (Kamil Tekiela) - Curl: . Fixed curl_reset() retaining CURLOPT_PRIVATE after reset. (lacatoire) diff --git a/UPGRADING b/UPGRADING index a1e30018cd69..e0bce481d953 100644 --- a/UPGRADING +++ b/UPGRADING @@ -183,6 +183,9 @@ PHP 8.7 UPGRADE NOTES is copied, and they are assigned to the targets directly. A value its target does not keep is now released at that assignment instead of after the last one. + . The garbage collector now raises its threshold when its runs traverse a + large graph of live objects, so that it runs less often. Before, it + traversed the whole graph again after every 10000 possible roots. - Standard: . Improved performance of strcspn() and strpbrk() when searching longer strings diff --git a/Zend/tests/gc/gh9993.phpt b/Zend/tests/gc/gh9993.phpt new file mode 100644 index 000000000000..d34677a25857 --- /dev/null +++ b/Zend/tests/gc/gh9993.phpt @@ -0,0 +1,70 @@ +--TEST-- +GH-9993: GC threshold follows the cost of traversing a large live graph +--SKIPIF-- + +--INI-- +zend.enable_gc=1 +memory_limit=256M +--FILE-- +data = $b; + $b->data = $a; + unset($a, $b); + + if ($container) { + // Adds a node of the live graph to the root buffer, so that the + // next GC run traverses the whole live graph. + $node = $container->nodes[$i % LIVE_NODES]; + unset($node); + } + } +} + +$container = new stdClass; +$container->nodes = []; +for ($i = 0; $i < LIVE_NODES; $i++) { + $node = new Node; + $node->owner = $container; + $container->nodes[] = $node; +} +unset($node); + +$before = gc_status(); +work($container, 500000); +$after = gc_status(); + +echo "Live graph:\n"; +var_dump($after['threshold'] >= LIVE_NODES / 8); +var_dump($after['buffer_size'] >= $after['threshold']); +// Without the cost rule the GC runs every 10000 roots, i.e. 140 times. +var_dump($after['runs'] - $before['runs'] < 100); + +unset($container); +work(null, 500000); +$after = gc_status(); + +echo "No live graph:\n"; +var_dump($after['threshold']); +?> +--EXPECT-- +Live graph: +bool(true) +bool(true) +bool(true) +No live graph: +int(10001) diff --git a/Zend/zend_gc.c b/Zend/zend_gc.c index 4ea0555957ef..bbbb77db3ecf 100644 --- a/Zend/zend_gc.c +++ b/Zend/zend_gc.c @@ -191,6 +191,8 @@ #define GC_THRESHOLD_STEP 10000 #define GC_THRESHOLD_MAX 1000000000 #define GC_THRESHOLD_TRIGGER 100 +/* A GC run may traverse this many live nodes per root added since the last run */ +#define GC_THRESHOLD_COST_RATIO 8 /* GC flags */ #define GC_HAS_DESTRUCTORS (1<<0) @@ -278,11 +280,13 @@ typedef struct _zend_gc_globals { uint32_t unused; /* linked list of unused buffers */ uint32_t first_unused; /* first unused buffer */ uint32_t gc_threshold; /* GC collection threshold */ + uint32_t gc_base_threshold; /* threshold from the collected count */ uint32_t buf_size; /* size of the GC buffer */ uint32_t num_roots; /* number of roots in GC buffer */ uint32_t gc_runs; /* number of GC runs since reset */ uint32_t collected; /* number of collected nodes since reset */ + size_t visited; /* number of nodes traversed by the last run */ zend_hrtime_t activated_at; /* the timestamp of the last reset */ zend_hrtime_t collector_time; /* time spent running GC (ns) */ @@ -521,6 +525,7 @@ static void gc_globals_ctor_ex(zend_gc_globals *gc_globals) gc_globals->unused = GC_INVALID; gc_globals->first_unused = GC_INVALID; gc_globals->gc_threshold = GC_INVALID; + gc_globals->gc_base_threshold = GC_INVALID; gc_globals->buf_size = GC_INVALID; gc_globals->num_roots = 0; @@ -571,6 +576,8 @@ void gc_reset(void) GC_G(unused) = GC_INVALID; GC_G(first_unused) = GC_FIRST_ROOT; GC_G(num_roots) = 0; + /* The run cost depends on the live graph of the request */ + GC_G(gc_threshold) = GC_G(gc_base_threshold); GC_G(gc_runs) = 0; GC_G(collected) = 0; @@ -608,6 +615,7 @@ ZEND_API bool gc_enable(bool enable) GC_G(buf)[0].ref = NULL; GC_G(buf_size) = GC_DEFAULT_BUF_SIZE; GC_G(gc_threshold) = GC_THRESHOLD_DEFAULT; + GC_G(gc_base_threshold) = GC_THRESHOLD_DEFAULT; gc_reset(); } return old_enabled; @@ -656,35 +664,49 @@ static void gc_grow_root_buffer(void) GC_G(buf_size) = new_size; } +static void gc_grow_root_buffer_to(size_t min_size) +{ + size_t new_size = GC_G(buf_size); + + ZEND_ASSERT(min_size <= GC_MAX_BUF_SIZE); + if (new_size >= min_size) { + return; + } + while (new_size < min_size && new_size < GC_BUF_GROW_STEP) { + new_size *= 2; + } + if (new_size < min_size) { + new_size += ZEND_MM_ALIGNED_SIZE_EX(min_size - new_size, GC_BUF_GROW_STEP); + } + if (new_size > GC_MAX_BUF_SIZE) { + new_size = GC_MAX_BUF_SIZE; + } + GC_G(buf) = perealloc(GC_G(buf), sizeof(gc_root_buffer) * new_size, 1); + GC_G(buf_size) = new_size; +} + /* Adjust the GC activation threshold given the number of nodes collected by the last run */ static void gc_adjust_threshold(int count) { - uint32_t new_threshold; + uint32_t base = GC_G(gc_base_threshold); + size_t live, threshold; - /* TODO Very simple heuristic for dynamic GC buffer resizing: - * If there are "too few" collections, increase the collection threshold + /* If there are "too few" collections, increase the collection threshold * by a fixed step */ if (count < GC_THRESHOLD_TRIGGER || GC_G(num_roots) >= GC_G(gc_threshold)) { - /* increase */ - if (GC_G(gc_threshold) < GC_THRESHOLD_MAX) { - new_threshold = GC_G(gc_threshold) + GC_THRESHOLD_STEP; - if (new_threshold > GC_THRESHOLD_MAX) { - new_threshold = GC_THRESHOLD_MAX; - } - if (new_threshold > GC_G(buf_size)) { - gc_grow_root_buffer(); - } - if (new_threshold <= GC_G(buf_size)) { - GC_G(gc_threshold) = new_threshold; - } - } - } else if (GC_G(gc_threshold) > GC_THRESHOLD_DEFAULT) { - new_threshold = GC_G(gc_threshold) - GC_THRESHOLD_STEP; - if (new_threshold < GC_THRESHOLD_DEFAULT) { - new_threshold = GC_THRESHOLD_DEFAULT; - } - GC_G(gc_threshold) = new_threshold; + base = MIN(base + GC_THRESHOLD_STEP, GC_THRESHOLD_MAX); + } else if (base > GC_THRESHOLD_DEFAULT) { + base = MAX(base - GC_THRESHOLD_STEP, GC_THRESHOLD_DEFAULT); } + GC_G(gc_base_threshold) = base; + + /* Wait for enough new roots to pay for the live nodes that the run traversed */ + live = GC_G(visited) > (size_t)count ? GC_G(visited) - (size_t)count : 0; + threshold = MAX(live / GC_THRESHOLD_COST_RATIO + GC_FIRST_ROOT, base); + threshold = MIN(threshold, GC_THRESHOLD_MAX); + + gc_grow_root_buffer_to(threshold); + GC_G(gc_threshold) = (uint32_t)threshold; } /* Perform a GC run and then add a node as a possible root. */ @@ -1049,6 +1071,7 @@ static void gc_mark_grey(zend_refcounted *ref, gc_stack *stack) tail_call: GC_BENCH_INC(zval_marked_grey); + GC_G(visited)++; if (GC_TYPE(ref) == IS_OBJECT) { zend_object *obj = (zend_object*)ref; @@ -2010,6 +2033,9 @@ ZEND_API int zend_gc_collect_cycles(void) bool did_rerun_gc = false; zend_hrtime_t start_time = zend_hrtime(); + if (!GC_G(gc_active)) { + GC_G(visited) = 0; + } if (GC_G(num_roots) && !GC_G(gc_active)) { zend_gc_remove_root_tmpvars(); } From cfbd52a65e79a154acb99c8296ce88d3467d14bd Mon Sep 17 00:00:00 2001 From: Kamil Tekiela Date: Fri, 13 Nov 2026 11:45:20 +0000 Subject: [PATCH 2/2] Apply suggestion from @ArtUkrainskiy Co-Authored-By: Artem Ukrainskiy --- Zend/zend_gc.c | 1 + 1 file changed, 1 insertion(+) diff --git a/Zend/zend_gc.c b/Zend/zend_gc.c index bbbb77db3ecf..944c03bb3965 100644 --- a/Zend/zend_gc.c +++ b/Zend/zend_gc.c @@ -2214,6 +2214,7 @@ ZEND_API int zend_gc_collect_cycles(void) * run GC another time. */ if (should_rerun_gc && !did_rerun_gc) { did_rerun_gc = true; + GC_G(visited) = 0; goto rerun_gc; }