How to optimize the PHP garbage collector usage to improve memory and performance?
The behaviour of PHP’s Garbage Collection (GC) can be a small mystery and you might wonder how it works and if you can optimize its usage for your application.
Is your script running out of memory and you are looking for ways to reduce it? PHP’s GC is a way to to reduce the memory of your script. However, arbitrarily littering your code with
gc_collect_cycles() calls everywhere does not automatically help and obviously reduces the expressiveness of your code.
Do you know if the garbage collector is slowing down your requests or speeding them up? When does it get triggered automatically? How much memory does each run clean up?
Maybe you fear that your application is suffering from a similar inefficient garbage collection usage than Composer did three years ago, where selectively disabling the collector improved their performance by up to 90%.
Sadly, answers to these questions are not available to you from the PHP engine, unless you want to recompile with obscure debug flags. Yeah, no thank you!
Before I show you a tool to access all the necessary information, you should first understand the available optimization potential and trade-offs with PHPs GC. Because sometimes you need to enable the garbage collector and sometimes its better to disable it.
The following summary is as bare bones as it can get to understand PHP memory. Please see Anthony Ferrara’s post on “What about garbage” if you want to dive deep into this topic.
How does PHP cleanup memory?
If a variable falls out of scope and is not used in any other place of the currently executed code anymore, then it is garbage collected automatically. You can force this early by using
unset()to end variables scope early.
If a variable is part of a cyclic reference, where A points to B and B back to A, then the variable can only be cleaned up by PHPs cycle garbage collector. It is triggered whenever 10000 possible cyclic objects or arrays are currently in memory and one of them falls out of scope. The collector is enabled by default in every request, but it can be toggled with the functions
If you call the function
gc_collect_cycles(), then collection of cyclic references is triggered explicitly even if you don’t have 10000 of them in memory yet.
The optimal performance strategy is to enable the garbage collector when the GC can clean up as many possible (high efficiency, many cleanups) from the potential 10000 cyclic references and to disable it when it finds out that most of them are still used (low efficiency, few cleanups).
But how then can you find out if you need to enable or disable the garbage collection or not? As I mentioned PHP does not actually provide statistics about cleanup mechanisms 2 and 3.
The garbage_stats PHP extension
This is where our small PHP extension garbage_stats comes to the rescue. It is based on the garbage collection hook that I co-proposed with Adam Harvey for PHP 7 and up. Most of the code is extracted from our tideways extension and enhanced by a simple CLI mode to print statistics without requiring changes to your code.
garbage_stats with a version of Composer that does not disable garbage collection to simulate the previous bottleneck:
$ php -dgc_stats.enable=1 -dgc_stats.show_report=1 bin/composer update Found 157 garbage collection runs in current script. Collected | Efficency% | Duration | Reduction% | Function ----------|------------|----------|------------|--------- 796 | 7.96 % | 2.59 ms | 0.63 % | [..]::loadProviderListings 0 | 0.00 % | 0.91 ms | -0.00 % | [..]::loadProviderListings 0 | 0.00 % | 17.19 ms | -0.01 % | [..]::parseConstraints 0 | 0.00 % | 19.95 ms | -0.03 % | ArrayLoader::load 0 | 0.00 % | 22.36 ms | -0.02 % | Pool::computeWhatProvides 0 | 0.00 % | 30.40 ms | -0.01 % | ArrayLoader::parseLinks 0 | 0.00 % | 29.05 ms | -0.00 % | Rule2Literals::equals 0 | 0.00 % | 29.00 ms | -0.01 % | [..]::createRule2Literals 0 | 0.00 % | 32.90 ms | -0.09 % | RuleWatchNode::__construct 0 | 0.00 % | 35.08 ms | -0.09 % | Solver::solve 0 | 0.00 % | 44.10 ms | -0.09 % | makeAssertionRuleDecisions 0 | 0.00 % | 53.28 ms | -0.01 % | Solver::runSat 0 | 0.00 % | 24.21 ms | -0.00 % | Transaction::findUpdates 183 | 1.83 % | 31.34 ms | 0.01 % | Installer::doInstall 0 | 0.00 % | 24.10 ms | -0.00 % | Installer::doInstall
IEKS. The table only shows a selection of the 157 garbage collection runs, but except two of them they all collect 0 cyclic references when running and don’t reduce the memory at all while still running for 10ms and more. ICEBERG AHEAD! This is the use-case for calling
gc_disable() during the whole execution of your long running script.
Contrast this with a simple test script that can efficiently clean up cyclic references with the this output:
Found 7 garbage collection runs in current script. Collected | Efficency% | Duration | Reduction% | Function ----------|------------|----------|------------|--------- 0 | 0.00 % | 0.00 ms | -0.14 % | gc_collect_cycles 10000 | 100.00 % | 4.23 ms | 89.43 % | foo 10000 | 100.00 % | 3.32 ms | 89.40 % | foo 10000 | 100.00 % | 2.48 ms | 89.37 % | foo 10000 | 100.00 % | 5.01 ms | 89.33 % | Test::foo 9000 | 90.00 % | 2.50 ms | 79.74 % | Test::foo 10000 | 100.00 % | 3.15 ms | 81.36 % | Test::foo
But what if your long running script does not fall into the fully efficient (~100%) or inefficient (~0%) section? The solution is simple and requires thorough work:
- With the
garbage_statsextension, find out which section of your code is triggering the garbage collection inefficiently. In web requests you can use the function
gc_stats()to get information about individual garbage collection runs.
gc_disable()before a non GC-efficient code section is executed.
gc_enable()after a non GC-efficient code section is finished and then
gc_collect_cycles()to clean up.
Happy efficient garbage collection!
P.S.: Tideways is collecting garbage collection details across your application in production If you want to find out how garbage collection affects your user performance, sign up for a 30 days trial.