You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Significant performance degradation in 'foreach' starting from PHP 8.2.13 (caused by garbage collection) #13193
We noticed significant performance degradation in our application after migration to PHP 8.2.13 (and PHP 8.3.0 has the same issue). Our application performs a lot of computations and in some cases execution time increased from 2 to 6 hours.
Here is a simple script demonstrating the problem (run as 'php -dmemory_limit=-1 perf_test.php'):
For 8.2.12 enabling GC doesn't have major impact on performance, but for 8.2.13 enabling GC increases time from 0.1 sec to 8 seconds (the same for 8.3.0). This is just a simple script to demonstrate the issue, in real application impact on performance is dramatic.
As I understand reason of additional GC overhead is this commit. I understand that it fixes potential memory leak, but perphaps there is a way to optimize it, so it won't cause such dramatic performance degradation?
I also understand that it's possible to selectively disable/enable GC in the application, or somehow get rid of 'foreach' loops. But since our application has thousands of such loops, changing the code would be very hard (and potentially the same issue can be in the 3rd party libraries which we can't change).
@dstogov I looked this report to get my hands of php.
I share my essays with you. Hoping to get your feedback and your advice.
As I said above, I tried to create a temporary reference to feed the "foreach" loop.
I have check that there was no memory leak with the "*.phpt" #12575 by deleting || kind == ZEND_LIVE_LOOP .
I was able to see from the @sboikov1983 test that from 1.1s I fell back to 0.3s
The degradation occurs because abe3673 adds a big array into GC buffer, and then it's iteratively checked by GC.
It doesn't slow-downs foreach but increases amount of GC work, when it's repeatable launched from inside the foreach loop.
The fix looks right, and I don't see a simple way to fix this degradation. I'll think a bit more...
I thought we may reduce the degradation by limiting effect if the old fix only to references.
But this doesn't work. The next example explains this. It starts to fail (leak) when the old fix removed.
<?phpfunctionfoo() {
$a = [];
$a[0] = 1;
$a[1] = &$a;
return$a;
}
foreach(foo() as$v) { // foo() returns a circular arrayvar_dump(gc_collect_cycles()); // GC can't free the array because it's still iteratedbreak;
}
var_dump(gc_collect_cycles()); // GC is able to detect and free the array only if it was added to garbage candidates buffer after the first GC above (see the old fix)?>
I don't really have a better idea. The only thing I think about is changing the VM instead of GC, like so: https://gist.github.com/nielsdos/80c7f19ab12a4ae6678dc403246435ba
this reduces the impact on the performance, it seems on par now with what it used to be.
To be honest I am not very familiar with the GC and its nuances, I did not read much code of it yet. So probably I am missing something why my idea is wrong.
@nielsdod the current solution adds iterated variable into GC buffer only after GC launched from inside a foreach loop. Your solution will add into GC buffer any variable used in foreach. This may help to foreach over very big arrays (like in this test case), but on the other hand will slowdown apps with many foreach over smaller arrays.
@SVGAnimate we can't disable array modification and this won't fix the problem anyway (see my last example above).
The problem that on each foreach iteration we increment/decrement refcounter of the current element (because of assignment to $v) and then store it into GC buffer. This leads to too often calls to GC collector and in case of a big array we get quadratic slowdown.
I think, the problem may be reduced in general, if before the main GC collector algorithm we will remove from GC buffer all elements reachable from the VM roots (global and static variables, CVs and alive temporary variables of stack frames, etc...)
We may start only with alive temporary variables...
I'm not sure when I get time to check this idea. @iluuu1994@nielsdos can you think in this direction?
We are also seeing this in a "real world" example, in our phpunit test run of ~20k tests, a small slice of our tests explicitly call gc_collect_cycles(), we have seen those tests get much slower from 7.4 -> 8.2.19, but they are pure "unit tests", and the slowness comes from the gc_collect_cycles() call. I am OK to reopen this issue, or leave it, up to y'all, I think the larger problem that led us down the path of explicitly doing this was we used to need this due to the old GC's (PHP 5.x) behavior of hitting 10k root nodes and thrashing whenever the root buffer runs full (so we would collect the gc, disable the gc while creating objects over that limit, then re-enable post-creation). As far as I can tell, this was actually resolved in PHP 7, but the documentation (https://www.php.net/manual/en/features.gc.collecting-cycles.php#:~:text=The%20root%20buffer%20has%20a%20fixed%20size%20of%2010%2C000%20possible%20roots%20(although%20you%20can%20alter%20this%20by%20changing%20the%20GC_THRESHOLD_DEFAULT%20constant%20in%20Zend/zend_gc.c) was never updated / it wasn't clearly communicated, so we've still been manually collecting and turning off the GC anytime we thought we would be interacting edge cases that might create the situation of having > 10k root nodes. I'll try to get a PR into https://github.com/php/doc-base to update the docs some.
@SpencerMalone I can reproduce this, it seems that there is an extra check that causes the zend_gc_remove_root_tmpvars function to no longer execute.
Also yes feel free to file a docs PR.
@dstogov I am confused by the GC_G(gc_active) check here:
As far as I understand, GC_G(gc_active) means the GC algorithm is running, so I would expect a check for !GC_G(gc_active) instead. The following diff fixes the issue and brings execution from 11s down to 1s on my machine:
diff --git a/Zend/zend_gc.c b/Zend/zend_gc.c
index e7d9c8ef292..935ce06f46c 100644
--- a/Zend/zend_gc.c+++ b/Zend/zend_gc.c@@ -1474,7 +1474,7 @@ ZEND_API int zend_gc_collect_cycles(void)
bool should_rerun_gc = 0;
bool did_rerun_gc = 0;
- if (GC_G(num_roots) && GC_G(gc_active)) {+ if (GC_G(num_roots) && !GC_G(gc_active)) {
zend_gc_remove_root_tmpvars();
}
@nielsdos I can't remember why did I add that GC_G(gc_active) check, but it seems I made this wrong.
Now I think your proposal is right. Please, commit this.
Activity
SVGAnimate commentedon Jan 19, 2024
I confirm this drop in performance under Linux.
0.3 sec to 1.1s
Warning
I notice that it seems extremely difficult to understand the usefulness of this test.
Tip
If we create a reference above the loop, the memory leak no longer exists
SVGAnimate commentedon Jan 20, 2024
Yop
@dstogov I looked this report to get my hands of php.
I share my essays with you. Hoping to get your feedback and your advice.
As I said above, I tried to create a temporary reference to feed the "foreach" loop.
I have check that there was no memory leak with the "*.phpt" #12575 by deleting
|| kind == ZEND_LIVE_LOOP.I was able to see from the @sboikov1983 test that from 1.1s I fell back to 0.3s
Not having a global vision of how PHP works. I don't know what this modification is worth. Is this the right way to do it?
dstogov commentedon Jan 22, 2024
The degradation occurs because abe3673 adds a big array into GC buffer, and then it's iteratively checked by GC.
It doesn't slow-downs
foreachbut increases amount of GC work, when it's repeatable launched from inside theforeachloop.The fix looks right, and I don't see a simple way to fix this degradation. I'll think a bit more...
dstogov commentedon Jan 22, 2024
The issue was closed by mistake
dstogov commentedon Jan 22, 2024
I thought we may reduce the degradation by limiting effect if the old fix only to references.
But this doesn't work. The next example explains this. It starts to fail (leak) when the old fix removed.
@iluuu1994 @nielsdos may be you have any ideas?
ndossche commentedon Jan 23, 2024
I don't really have a better idea. The only thing I think about is changing the VM instead of GC, like so: https://gist.github.com/nielsdos/80c7f19ab12a4ae6678dc403246435ba
this reduces the impact on the performance, it seems on par now with what it used to be.
To be honest I am not very familiar with the GC and its nuances, I did not read much code of it yet. So probably I am missing something why my idea is wrong.
dstogov commentedon Jan 23, 2024
@nielsdod the current solution adds iterated variable into GC buffer only after GC launched from inside a
foreachloop. Your solution will add into GC buffer any variable used inforeach. This may help to foreach over very big arrays (like in this test case), but on the other hand will slowdown apps with manyforeachover smaller arrays.ndossche commentedon Jan 23, 2024
Okay I understand, thanks for pointing that out.
I was not able to come up with something else.
dstogov commentedon Jan 24, 2024
@SVGAnimate we can't disable array modification and this won't fix the problem anyway (see my last example above).
The problem that on each
foreachiteration we increment/decrement refcounter of the current element (because of assignment to $v) and then store it into GC buffer. This leads to too often calls to GC collector and in case of a big array we get quadratic slowdown.I think, the problem may be reduced in general, if before the main GC collector algorithm we will remove from GC buffer all elements reachable from the VM roots (global and static variables, CVs and alive temporary variables of stack frames, etc...)
We may start only with alive temporary variables...
I'm not sure when I get time to check this idea.
@iluuu1994 @nielsdos can you think in this direction?
9 remaining items
SpencerMalone commentedon Mar 20, 2025
Yep!
SpencerMalone commentedon Mar 20, 2025
We are also seeing this in a "real world" example, in our phpunit test run of ~20k tests, a small slice of our tests explicitly call gc_collect_cycles(), we have seen those tests get much slower from 7.4 -> 8.2.19, but they are pure "unit tests", and the slowness comes from the gc_collect_cycles() call. I am OK to reopen this issue, or leave it, up to y'all, I think the larger problem that led us down the path of explicitly doing this was we used to need this due to the old GC's (PHP 5.x) behavior of hitting 10k root nodes and thrashing
whenever the root buffer runs full(so we would collect the gc, disable the gc while creating objects over that limit, then re-enable post-creation). As far as I can tell, this was actually resolved in PHP 7, but the documentation (https://www.php.net/manual/en/features.gc.collecting-cycles.php#:~:text=The%20root%20buffer%20has%20a%20fixed%20size%20of%2010%2C000%20possible%20roots%20(although%20you%20can%20alter%20this%20by%20changing%20the%20GC_THRESHOLD_DEFAULT%20constant%20in%20Zend/zend_gc.c) was never updated / it wasn't clearly communicated, so we've still been manually collecting and turning off the GC anytime we thought we would be interacting edge cases that might create the situation of having > 10k root nodes. I'll try to get a PR into https://github.com/php/doc-base to update the docs some.ndossche commentedon Mar 20, 2025
@SpencerMalone I can reproduce this, it seems that there is an extra check that causes the
zend_gc_remove_root_tmpvarsfunction to no longer execute.Also yes feel free to file a docs PR.
@dstogov I am confused by the
GC_G(gc_active)check here:php-src/Zend/zend_gc.c
Lines 1920 to 1922 in ead3698
As far as I understand,
GC_G(gc_active)means the GC algorithm is running, so I would expect a check for!GC_G(gc_active)instead. The following diff fixes the issue and brings execution from 11s down to 1s on my machine:I may be wrong though.
dstogov commentedon Mar 21, 2025
@nielsdos I can't remember why did I add that
GC_G(gc_active)check, but it seems I made this wrong.Now I think your proposal is right. Please, commit this.
Fixed bug GH-13193 again
Merge branch 'PHP-8.3' into PHP-8.4
Merge branch 'PHP-8.4'