Optimizing PHP Application Concurrency
Last updated November 28, 2022
Table of Contents
PHP applications on Heroku run under the PHP-FPM FastCGI Process Manager and communicate with the Apache or Nginx web servers using the FastCGI protocol.
FPM spawns and manages child processes that execute the actual PHP application code. Each of these processes handles one request from the web server at a time, meaning that more processes will yield greater concurrency and thus better application performance under higher traffic conditions.
The configured PHP memory limit applies to each child process that runs the application, which may consume memory up to that limit before it gets terminated.
Heroku Enterprise customers with Premier or Signature Success Plans can request in-depth guidance on this topic from the Customer Solutions Architecture (CSA) team. Learn more about Expert Coaching Sessions here or contact your Salesforce account executive.
Default settings and behavior
PHP-FPM
PHP-FPM is configured to run using the static
process managing mode, meaning it spawns a fixed number of child processes. This is the optimal configuration for environments like Heroku, where dyno instance are fully isolated and have a fixed RAM allocation.
The number of child processes to spawn (controlled by the pm.max_children
setting of PHP-FPM) is determined using the WEB_CONCURRENCY
environment variable, which is automatically set to a suitable value by Heroku.
WEB_CONCURRENCY
defaults
If your application uses multiple buildpacks, you must ensure that the PHP buildpack (as the primary language buildpack of your application) is executed after other language buildpacks; otherwise, the WEB_CONCURRENCY
defaults of a buildpack that runs after the PHP buildpack may apply to your application.
For example, when using the heroku/nodejs
buildpack together with heroku/php
, the WEB_CONCURRENCY
defaults will be incorrect if the heroku/nodejs
buildpack runs after the heroku/php
buildpack.
When booting an application, the dyno type will automatically be detected, and the WEB_CONCURRENCY
environment variable will be set to the amount of available RAM on the dyno divided by the memory limit configured for each PHP process:
$ heroku ps:scale web=1:standard-2x
$ heroku logs
2020-02-06T14:52:40… heroku[web.1]: State changed from down to starting
2020-02-06T14:52:42… heroku[web.1]: Starting process with command `heroku-php-apache2`
2020-02-06T14:52:43… app[web.1]: Detected 1073741824 Bytes of RAM
2020-02-06T14:52:43… app[web.1]: PHP memory_limit is 128M Bytes
2020-02-06T14:52:43… app[web.1]: Starting php-fpm with 8 workers...
2020-02-06T14:52:43… app[web.1]: Starting httpd...
2020-02-06T14:52:44… heroku[web.1]: State changed from starting to up
The default memory_limit
on Heroku is the default for the respective PHP version; currently at 128 MB for all versions of PHP.
This means that out of the box, the following settings apply:
Dyno Type | Available RAM | PHP memory_limit | WEB_CONCURRENCY |
---|---|---|---|
eco, basic, standard-1x | 512 MB | 128 MB | 4 |
standard-2x | 1024 MB | 128 MB | 8 |
performance-m | 2.5 GB | 128 MB | 20 |
performance-l | 14 GB | 128 MB | 1121 |
1: WEB_CONCURRENCY defaults to 48 for PHP versions before 7.4. |
For backwards compatibility, the defaults for the performance-l
dyno type do not utilize the entire amount of memory available for PHP versions before 7.4. If you would like to use a higher number of processes than Heroku assigns automatically, use the technique outlined in the Tuning Concurrency Manually section of this document.
These defaults are intentionally chosen to not leave any “headroom” for the PHP-FPM master process or the web server processes because applications are extremely unlikely to consume their entire memory limit on each request and at full saturation, meaning that dynos are slightly over-subscribed by default.
To increase the number of child processes (and thus the number of requests that may be handled concurrently), you simply lower an application’s memory_limit
setting - the memory limit is the primary method of adjusting PHP application concurrency on Heroku.
Tuning concurrency using memory_limit
Configuring the memory_limit
for PHP-FPM
Setting memory limit via .user.ini
A .user.ini
config file containing a memory limit setting may be placed into your application’s document root, which by default is the top level directory of your application. For example, to set a memory limit of 64 MB for an application, your .user.ini
would have the following contents:
memory_limit = 64M
Pay close attention to the correct shorthand notation required by PHP, used here to indicate megabytes using the “M
” suffix.
Your application’s document root may be different from the top level directory of your application if you’ve configured it using a Procfile
command argument.
If you deploy your app with this file and watch your application restart, you’ll then notice that the number of workers was adjusted automatically for the given memory limit, e.g. for a standard-1x dyno:
$ heroku logs
2019-01-15T07:51:24.476056+00:00 heroku[web.1]: State changed from down to starting
2019-01-15T07:51:30.765076+00:00 heroku[web.1]: Starting process with command `heroku-php-apache2`
2019-01-15T07:51:33.188816+00:00 app[web.1]: Optimizing defaults for 1X dyno...
2019-01-15T07:51:33.370674+00:00 app[web.1]: 8 processes at 64MB memory limit.
2019-01-15T07:51:33.414407+00:00 app[web.1]: Starting php-fpm...
2019-01-15T07:51:33.414423+00:00 app[web.1]: Starting httpd...
2019-01-15T07:51:35.865579+00:00 heroku[web.1]: State changed from starting to up
Additional .user.ini
files in sub-directories of the document root will not be evaluated when Heroku determines the memory limit at dyno boot time, but take effect at runtime as documented when serving requests to PHP files in such directories, so keep this in mind in the unlikely (and absolutely not recommended) case you have different memory_limit
settings for different sub-directories of your application.
Setting the memory limit using PHP-FPM configuration
Instead of a .user.ini
file, you may also use a PHP-FPM config include to add a php_value
or php_admin_value
directive to change the memory_limit
setting. For example, to set a memory limit of 64 MB for an application, you would create a file named e.g. fpm_custom.conf
with the following contents:
php_value[memory_limit] = 64M
For these settings to take effect, you need to use the -F
option in your Procfile
command so the config gets loaded:
web: heroku-php-apache2 -F fpm_custom.conf
If you deploy your app with the new fpm_custom.conf
and the changed Procfile
and watch your application restart, you’ll notice that the number of workers was adjusted automatically for the given memory limit, e.g. for a standard-1x dyno:
$ heroku logs
2019-01-15T07:51:24.476056+00:00 heroku[web.1]: State changed from down to starting
2019-01-15T07:51:30.765076+00:00 heroku[web.1]: Starting process with command `heroku-php-apache2 -F fpm_custom.conf`
2019-01-15T07:51:33.109122+00:00 app[web.1]: Using PHP-FPM configuration include 'fpm_custom.conf'
2019-01-15T07:51:33.188816+00:00 app[web.1]: Optimizing defaults for 1X dyno...
2019-01-15T07:51:33.370674+00:00 app[web.1]: 8 processes at 64MB memory limit.
2019-01-15T07:51:33.414407+00:00 app[web.1]: Starting php-fpm...
2019-01-15T07:51:33.414423+00:00 app[web.1]: Starting httpd...
2019-01-15T07:51:35.865579+00:00 heroku[web.1]: State changed from starting to up
Runtime changes of memory_limit
Any change made to the memory limit at runtime using ini_set("memory_limit", ...)
will not affect the concurrency, as the memory limit used for calculation is determined at boot time.
If you have many processes increasing the memory limit beyond its initially configured value at runtime using ini_set()
, and these processes are actually consuming that additional memory, you may get R14 errors indicating that your application has started paging to disk, which may degrade performance. In this case, either increase the static memory_limit
, or set WEB_CONCURRENCY
manually to a lower value.
In many cases, it may be desirable to have a lower memory limit to achieve higher concurrency, and using ini_set()
to dynamically set a higher limit at runtime for only the few code paths in your application that temporarily require a higher memory limit.
Determining a suitable memory limit
The amount of memory your application needs depends on the amount of data it processes during a request, and how much of that data it holds in memory at the same time. Tasks like image processing or handling large database result sets are typically quite memory intensive.
The default memory limit of 128 MB in PHP is a conservative default intended to give enough “breathing room” for virtually any kind of application. It is likely that your code does not consume that much memory during a request, so lowering the limit is a great way of optimizing your application performance.
Once you’ve determined what the maximum memory usage of your application is, don’t forget to leave a reasonable amount of “headroom” when setting a limit to allow for a safety margin for growth and unforeseen circumstances (e.g. growing data sets over time).
Measuring and optimizing memory usage locally
On your development machine, you can use the memory_get_peak_usage()
function in your code (usually towards the very end of a script) to determine the peak memory used. You could, for instance, file_put_contents("path/to/logfile", memory_get_peak_usage()."\n", FILE_APPEND);
at the end of your code, run a load/feature test on it, and determine the highest value in that log file e.g. using sort path/to/logfile | tail -n 1
.
Another quick and easy approach is lowering the memory limit configured in PHP in subsequent steps (of e.g. 16 MB) until you start getting “memory limit exceeded” error messages. A limit of 64 MB is often a safe bet and will result in twice as many worker processes compared to the default configuration.
When performing load tests locally (using ab
, siege
, httperf
or similar), you may also observe the amount of memory consumed by your php-fpm
processes using ps
or top
.
To properly profile your applications during development, we recommend XHProf. The complementing xhprof.io GUI makes navigating profiling results easy and convenient.
Remember to use realistic conditions when performing tests locally, e.g. using suitable large result sets from a database. It’s also highly recommended to audit your code for any functions, loops or algorithms that may scale unfavorably with an increase in input data size and optimize these accordingly.
Measuring memory usage on Heroku
Platform specific nuances aside, an application’s memory consumption, given the same input, should be virtually identical between your local development environment and Heroku, so it’s recommended to work on finding the optimal memory limit in development first.
The log-runtime-metrics Heroku Labs functionality will periodically report memory consumption to the heroku logs
stream. When performing load tests, a memory usage that’s drastically lower than what’s available for the respective dyno type may be an indicator that your memory limit is set too high. Try lowering the limit to increase concurrency and thus actual memory usage.
You may also inspect a basic memory usage report and graph in the overview section for your app on the Heroku Dashboard. Note that this displays an average value across all running dynos.
Application performance monitoring tools such as New Relic will record memory usage and report them for your analysis.
Heroku Pipelines help automate a workflow for promoting deployments from one environment to another (e.g., from staging to production). This makes it easy to measure and optimize changes to memory usage on a non-production app that you can then promote to production when the changes are ready.
Tuning concurrency manually
If you’d like to manually set the number of child processes running your application, you can adjust the WEB_CONCURRENCY
environment variable by setting a config var.
For instance, to statically set the number of child processes to 8, use heroku config:set
:
$ heroku config:set WEB_CONCURRENCY=8
When setting WEB_CONCURRENCY
manually, make sure that its value multiplied by your memory_limit
does not exceed the amount of RAM available on your dyno type.
This will cause your application to restart, and your dyno(s) will report the static setting during startup:
$ heroku logs
2019-01-15T07:51:24.476056+00:00 heroku[web.1]: State changed from down to starting
2019-01-15T07:51:30.765076+00:00 heroku[web.1]: Starting process with command `heroku-php-apache2 -F fpm_custom.conf`
2019-01-15T07:51:33.109122+00:00 app[web.1]: Using PHP-FPM configuration include 'fpm_custom.conf'
2019-01-15T07:51:33.370674+00:00 app[web.1]: Using WEB_CONCURRENCY=8 processes.
2019-01-15T07:51:33.414407+00:00 app[web.1]: Starting php-fpm...
2019-01-15T07:51:33.414423+00:00 app[web.1]: Starting httpd...
2019-01-15T07:51:35.865579+00:00 heroku[web.1]: State changed from starting to up
If you set WEB_CONCURRENCY
to a fixed value, you may have to adjust it when you scale to a different dyno type to take into account the different amount of available RAM on the new dyno type.