Skip to content

Fix GH-9993: Bad performance with hard-coded GC_THRESHOLD_* values - #25243

Open
kamil-tekiela wants to merge 2 commits into
php:masterfrom
kamil-tekiela:Fix-GH-9993
Open

kamil-tekiela wants to merge 2 commits into
php:masterfrom
kamil-tekiela:Fix-GH-9993

Conversation

@kamil-tekiela

Copy link
Copy Markdown
Member

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.

Fixes GH-9993.

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
phpGH-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 <noreply@anthropic.com>
@arnaud-lb

Copy link
Copy Markdown
Member

This makes sense and I will probably merge this.

It results in -50% wall time on some synthetic benchmark with 200k live nodes reachable from roots. A real application shows no regressions. I see no potential drawback, but I need to think more about it.

Do you have a workload that matches #9993?

This reminded me of this experiment: #19709. If you have a real workload, could you try it against that branch as well? (with FULL_GC_FREQ=10) Maybe we could combine both approaches.

@kamil-tekiela

Copy link
Copy Markdown
Member Author

Do you have a workload that matches #9993?

This reminded me of this experiment: #19709. If you have a real workload, could you try it against that branch as well? (with FULL_GC_FREQ=10) Maybe we could combine both approaches.

I only had synthetic workloads. I tried it with PHPStan on Doctrine Dbal and your PR seems to provide similar or even better results, but it did crash so I don't know how accurate that is. Take it with a grain of salt. If you would like you can try to reproduce that yourself and you may be able to get more accurate results than me.

ERROR: AddressSanitizer: heap-use-after-free, READ of size 4
    #0 gc_mark_roots Zend/zend_gc.c:1554
    #1 zend_gc_collect_cycles Zend/zend_gc.c:2348
    #2 gc_possible_root_when_full Zend/zend_gc.c:833
    #3 gc_possible_root Zend/zend_gc.c:888
    #4 gc_check_possible_root_no_ref Zend/zend_gc.h:106
    #5 ZEND_ASSIGN_OBJ_SPEC_CV_CONST_OP_DATA_TMP_HANDLER
freed by:
    zend_array_destroy <- rc_dtor_func <- zend_object_dtor_property
    <- zend_object_std_dtor <- zend_objects_store_del
allocated by:
    _zend_new_array <- ZEND_INIT_ARRAY_SPEC_TMP_UNUSED_HANDLER

ArtUkrainskiy added a commit to ArtUkrainskiy/php-src-bench that referenced this pull request Nov 13, 2026
Review material for php/php-src#25243: master vs the PR on GCC 15 and Clang 21, memory with destructors, a one-line fix for the rerun, doctrine/orm suite.

@ArtUkrainskiy ArtUkrainskiy left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

One issue with destructors, see the line comment. Numbers below.

Comment thread Zend/zend_gc.c Resolved
@ArtUkrainskiy

Copy link
Copy Markdown
Contributor

I measured this on master 4f3d950 with GCC 15 and Clang 21, on a Ryzen 5950X with the flags of the official Docker image. It does what it says. Nothing changes without a big live graph, and with 1M live objects the script takes 85% less time. The one issue is the rerun after destructors, see my line comment.

Change in wall time against master (negative = faster):

script master, GCC GCC 15 Clang 21 peak RSS
1M live objects, 2-object garbage cycles 17.2 s −85% −85% 0%
200k live objects, same 0.33 s −38% −39% 0%
80k live objects, same 0.086 s +2% +3% 0%
no live graph 0.25 s 0% +1% 0%
1M live objects, garbage with __destruct() 0.85 s −34% −33% +6.6%
doctrine/orm test suite 5.1 s −1% 0% −1%

@arnaud-lb, about real workloads. The GC takes 9% of doctrine/orm's test suite and 1% of Symfony Demo's, so the gain there is small. PHPStan and Composer call gc_disable() themselves. I couldn't compare with #19709, it hits a use-after-free on this PR's test workload. I'll post the repro there.

Details and scripts: https://github.com/ArtUkrainskiy/php-src-bench/tree/main/reports/pr-25243-gc-threshold

Co-Authored-By: Artem Ukrainskiy <art.ukrainskiy@gmail.com>
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.

Bad performance with hard-coded GC_THRESHOLD_* values

3 participants