Twenty-four PHP-FPM workers, each holding about 105 MB after one request that
built a large array, came to 2,515 MB of resident memory on a pool configured
with pm.max_children = 24. FPM logged nothing about it, because nothing in FPM
performs that multiplication.
Method
PHP 8.5.9 fpm-fcgi, NTS, arm64, Homebrew build, on an Apple M4 Pro laptop
running macOS with 24 GB of RAM and 12 logical cores, 8 of them performance
cores. Not quiesced. No FastCGI client was installed, so I wrote a small one
that opens a set number of concurrent connections and records each request from
send to FCGI_END_REQUEST.
This is the baseline pool, written with the socket path a reader would actually
use. The pool I measured needed a shorter one: sun_path caps a unix socket at
104 bytes and my scratch directory overran it, which FPM reports by truncating
the path rather than refusing to start. Only the settings named in each section
below varied, and every configuration was restarted and warmed before it was
measured:
[www]
listen = /run/php-fpm.sock
listen.backlog = 511
pm = static
pm.max_children = 8
pm.start_servers = 2
pm.min_spare_servers = 1
pm.max_spare_servers = 3
pm.max_requests = 0
pm.process_idle_timeout = 10s
pm.status_path = /status
php_admin_value[opcache.enable] = 1
php_admin_value[opcache.jit] = disable
php_admin_value[opcache.file_update_protection] = 0
The three spare-server settings are inert under pm = static. Under
pm = dynamic each governs a different moment — pm.start_servers where the
pool begins, pm.min_spare_servers where it stops climbing under load, and
pm.max_spare_servers where it settles back once load drops — which is why that
section restates all three next to the measurements that separate them.
pm.process_idle_timeout governs only ondemand, and the ondemand figures
below use the 10s above with pm.max_children as stated there.
The test script does a fixed amount of arithmetic rather than sleeping to a
deadline, so its reported duration responds to contention instead of reporting
the same figure by construction. Calibration was linear on this machine and the
setting used below takes about 19.6 ms. Resident memory comes from ps, read
per worker process. macOS accounting differs from Linux, so treat the memory
figures as shapes rather than as numbers to carry to a server.
What pm.max_children controls
pm.max_children is the maximum number of worker processes the pool will run,
and therefore the maximum number of requests it can execute at once. That is the
whole of it. It says nothing about memory, and FPM does not read the machine’s
RAM, compare it against anything, or warn.
The multiplication that matters is pm.max_children times the memory a worker
holds at its peak, and no part of the configuration performs it. Twenty-four
workers fresh from a restart came to 52.1 MB together, 2.2 MB each. After light
traffic warmed them they came to 124.8 MB. After one request each that built a
large array they came to 2,514.6 MB, about 104.8 MB apiece — the same pool, the
same setting, forty-eight times the memory of the idle case, on a box where
nothing had changed. Those are medians of three repeats, each from a freshly
started pool; the loaded totals were 2,485.0, 2,514.6 and 2,520.3 MB.
The three modes respond on different timescales
With pm = static the pool runs pm.max_children workers from startup and
keeps them there. Memory is predictable because it does not vary with load, and
idle workers cost their resident memory whether traffic arrives or not.
With pm = dynamic the pool starts pm.start_servers and adds workers to keep
spare capacity within bounds. What that costs is a response time. Configured
with pm.max_children = 8, pm.start_servers = 2, pm.min_spare_servers = 1
and pm.max_spare_servers = 4 — two workers at rest — a burst of 48 concurrent
requests lasting about half a second was served by two to three workers, at a
client-observed median of 287.66 ms — against 73.17 ms for the same burst
against a static pool of eight.
At pm.min_spare_servers = 1 the rate is one worker per second, and it is
strikingly regular. Under a saturating load the pool grew 2, 3, 4, 5, 6, 7, 8 on
consecutive seconds, reaching pm.max_children at about six seconds and
logging server reached pm.max_children setting (8). The rate holds at a larger
ceiling: the same configuration with pm.max_children = 16 climbed by exactly
one per second to sixteen, arriving between 14.06 and 15.06 seconds across
three runs.
Read those absolute times as good to about a second. The gaps between spawns are stable; where the first one falls depends on the phase of FPM’s maintenance tick against the moment load arrives, which nothing in the test controls.
That rate belongs to min_spare_servers rather than to dynamic. FPM spawns
against the idle deficit, and a larger deficit is filled faster: at
pm.min_spare_servers = 4 the same sixteen-worker pool went 4, 5, 7, 11, 15,
16 — steps of one, two, four, four, then a final one clipped by the ceiling —
reaching sixteen in about five seconds. Two things differ there, not one: it
also starts at four rather than two, so it made twelve spawns against fourteen.
The rate is the larger part of the gap but not the whole of it.
Where it stops is demand plus pm.min_spare_servers, or pm.max_children,
whichever is lower. Both halves are measured rather than inferred. Holding
pm.start_servers at 3 and varying only pm.min_spare_servers:
| Offered concurrency | min_spare_servers | Workers |
|---|---|---|
| 3 | 1 | 4 |
| 3 | 3 | 6 |
| 5 | 1 | 6 |
| 5 | 3 | 8 |
Every cell is the sum. pm.max_spare_servers does not enter into where a pool
stops climbing: at 2, 4 and 8, with concurrency held at 3 and min_spare at
1, the pool stopped at four every time.
Climbing is half of what a pool does. pm.max_spare_servers governs the other
half — where it settles back to once the load stops — and that is the half a
memory budget cares about. Driving the same pool up under saturating load — it
reached seven or eight workers depending on where the load happened to stop —
and then cutting the load entirely:
| pm.max_spare_servers | Workers once idle |
|---|---|
| 2 | 2 |
| 4 | 4 |
| 8 | 8 |
It sheds about one worker per second, the same rate at which it added them, and then stops: twenty seconds of sampling with no traffic showed no further change.
Which matters here because of what an idle worker is holding. Repeating that with requests that allocate, and reading the pool’s resident memory instead of its worker count:
| pm.max_spare_servers | Pool RSS at the spike | After 6 seconds idle |
|---|---|---|
| 2 | 1,228.0 MB across 6 workers | 408.8 MB across 2 |
| 8 | 1,228.3 MB across 6 workers | 1,228.3 MB across 6 |
Three repeats, agreeing within 0.7 MB. The spike is the same in both rows and so
is the per-worker high-water mark, about 205 MB; what differs is how many
workers are left alive to hold it. This is the decay from earlier seen from the
other end — a worker gives its memory back only by serving more requests, so a
pool that spiked and then went quiet keeps its peak until traffic returns, and
pm.max_spare_servers decides how many workers are there to keep it. Eight
hundred and nineteen megabytes, on this pool, from that one setting.
pm.start_servers has to move with them, because FPM validates all three
against each other and refuses to start otherwise:
ALERT: [pool www] pm.start_servers(2) must not be less than pm.min_spare_servers(3) and not greater than pm.max_spare_servers(8)
Both halves of that line bind, and they pull in opposite directions. The
min_spare table holds pm.start_servers at 3, which its min_spare = 3 row
requires; the max_spare runs hold it at 2, because 3 against
pm.max_spare_servers = 2 is refused by the second half. So those two
experiments begin from three workers and from two. Where each pool starts
differs; where each one stops does not.
Seconds is the figure that matters, because a burst arrives in milliseconds.
Reaching a ceiling of eight took about six seconds at min_spare = 1 and
reaching sixteen took about fifteen; even the aggressive min_spare = 4 case
needed five. A spike shorter than a few seconds is served almost entirely by the
workers already running, which is what the half-second burst above measured.
With pm = ondemand no workers run until a request arrives. The pool I started
had zero processes at rest and one after a single request. How many it reaches
under load turns out to depend less on how many requests arrive than on how far
apart they arrive. Eight connections against pm.max_children = 8, varying only
the spacing between them:
| Arrival span for 8 connections | Workers |
|---|---|
| 0.03 ms | 2 |
| 1.23 ms | 6 |
| 5.38 ms | 8 |
| 21.15 ms | 8 |
Three repeats per row, each from a freshly started pool; all three agreed on every row. The middle row is the one to distrust — it sits on the slope between two and eight, so a busier machine can move it.
Eight simultaneous connections got two workers; the same eight spread across
five milliseconds got eight. The ceiling still binds — eight arrivals spread
that way against pm.max_children = 4 got four, and logged the warning — but
below it the count tracks how much time the master had to react. That is the
same seconds-against-milliseconds mismatch dynamic shows, measured at a finer
grain. It suits a box hosting many mostly-idle pools, and it pays a fork on the
requests that find no worker free.
Per-worker memory is a high-water mark that decays
A worker that handled one request peaking at 266.02 MB of PHP-managed memory sat at 105.28 MB resident afterward. Left completely idle it held exactly 105.28 MB across four seconds of polling, in each of three repeats — request shutdown did not give it back, and time alone did not either.
What gives it back is more requests. In a separate run, with its own fresh worker — which is why the opening figure sits 0.1 MB below the one above — I sent trivial requests to the worker that had just handled the heavy one and read its resident memory after each:
| Request after the heavy one | Worker RSS |
|---|---|
| none yet | 105.19 MB |
| 1 | 55.19 MB |
| 2 | 29.19 MB |
| 3 | 17.19 MB |
| 4 | 11.19 MB |
| 5 | 7.19 MB |
| 6 and beyond | 7.12 MB |
Three repeats, each starting from a freshly warmed worker. The widest spread on any row was 0.02 MB, so a range column would say nothing: this sequence is very nearly deterministic.
The excess halves on each subsequent request and five brought it back to baseline. The allocator is releasing what it cached against some running measure of recent demand rather than at the moment the request ended; the exact rule I did not chase into the C source.
Two consequences follow. A worker that goes quiet after a heavy request holds
that memory for as long as it stays quiet, so a pool’s resident total is set by
its recent peaks and not by what it is doing now. And every worker can be at its
peak at the same time, which is what the opening 2,515 MB was: not an unlikely
alignment, but the ordinary result of a burst of expensive requests. Peak times
max_children is the number to size against, and the peak is a property of what
the code allocates — the per-element cost of a large
array is usually where a request’s memory
actually goes.
Queueing is invisible to a timer inside the request
Hold the offered load constant — 48 requests, 24 at a time, about 19.6 ms of
work each — and vary only pm.max_children. Medians in milliseconds, three
repeats each:
| pm.max_children | Client-observed latency | Duration the script reported |
|---|---|---|
| 2 | 263.93 / 270.67 / 276.75 | 20.90 / 21.19 / 24.66 |
| 4 | 146.63 / 137.46 / 139.50 | 26.24 / 23.19 / 22.76 |
| 8 | 63.82 / 73.57 / 73.17 | 25.34 / 25.57 / 25.91 |
| 16 | 62.35 / 62.11 / 61.68 | 37.45 / 37.82 / 39.69 |
The control, one request at a time against an eight-worker pool, gave 22.30 ms at the client against 21.97 ms in the script.
At two workers the client waited about twelve times as long as the control while the application reported 21 ms — its own control figure. An undersized pool does not report slow code. It reports nothing at all, and the request duration your monitoring records is measured from the moment a worker picked the request up, which is after the wait.
Sixteen workers is the other failure. Client latency stopped improving past eight, which is this machine’s performance-core count, while the script’s own duration rose to 38 ms because sixteen CPU-bound workers were contending for twelve cores. Oversizing converts queueing into contention and makes the application’s timings worse.
FPM’s own diagnostics were less help than expected here. listen queue and
max listen queue on the status page read zero throughout, including while
twenty-two connections were demonstrably waiting. max children reached stayed
at zero under pm = static, which has no forking to refuse. It reported 1 under
both of the modes that do fork — pm = dynamic driven to its ceiling, and
pm = ondemand — and each wrote a warning to the error log, though not the same
warning. The dynamic path logs server reached pm.max_children setting (8);
the ondemand path logs server reached max_children setting (4), with no
pm. prefix. An alert that greps for the prefixed form alone will never fire on
an ondemand pool. That is the shape of FPM’s self-reporting
throughout: it will tell you when it runs out of workers, and never when the
workers it has are consuming the machine. Whether the queue counters are a
platform limitation of this macOS build I did not establish. The measurement
that did work on every configuration was comparing a client-side timer against
the duration the script reported, which is a thing you can run against
production from one host.
pm.max_requests recycles rather than repairs
With pm.max_requests = 5 and one worker, the process ID changed after every
fifth request and resident memory reset to a fresh baseline. The first request
on a new worker cost 3.426 ms against about 3.3 ms steady, a difference this
method cannot resolve — OPcache holds compiled opcodes in shared memory, so a
replacement worker inherits them rather than recompiling.
What I could not do was make memory grow across requests in the first place.
With recycling off, a worker held steady at 5.70 MB over sixteen requests, and
every array I allocated was released at request shutdown. Growth that survives a
request comes from things that live outside its arena — persistent connections,
extension allocations, a genuine leak in C — none of which I measured here.
Recycling bounds those. It does not identify them, and a low max_requests
hides growth well enough that nobody investigates it.
Sizing a pool from what you measured
Measure the peak resident memory of a worker under production traffic, not a synthetic script, and take the peak rather than the average — the arguments for measuring the real path in benchmarking PHP without lying to yourself apply here, with resident memory in place of elapsed time.
# Resident memory of every worker in a pool, largest first.
# The bracket stops awk from matching its own command line.
ps -o rss=,command= -ax \
| awk '/php-fpm: pool ww[w]/ { printf "%8.1f MB\n", $1/1024 }' \
| sort -rn
Against an eight-worker pool where three workers had recently handled a large request, that printed:
65.5 MB
65.4 MB
65.4 MB
5.7 MB
4.9 MB
4.8 MB
4.8 MB
4.8 MB
Eight lines for eight workers. The spread across them is the reason to size against the top of that list rather than its average.
Decide how much of the box you are willing to hand the pool, leaving room for
everything else on it, then divide by that peak. That quotient is the ceiling
pm.max_children must respect. If the answer is smaller than your traffic
needs, the fix is less memory per request or more boxes, not a larger number.
Check that against cores. A pool larger than the machine can execute in parallel turns waiting into contention, which showed up above as worse application timings for no latency gain. For CPU-bound work the useful range topped out near the performance-core count; for I/O-bound work, where workers spend their time blocked, it sits higher, and the way to find it is to raise the setting until client-observed latency stops improving.
Then instrument the gap. One timer outside the request and one inside it, with the difference recorded, is the signal that says a pool is too small — and it is the one signal that an application-side profiler, sitting entirely inside the worker, cannot produce.
Frequently asked
- Should I use pm = static or pm = dynamic?
- static on a box dedicated to one pool, where predictable memory matters more than idle capacity. dynamic where the box does other work and idle workers are wasteful — accepting that it added one worker per second on the pool I measured, so it under-serves short bursts.
- How do I find per-worker memory?
- Measure resident memory of running workers under real traffic, and read the peak rather than the average. A worker holds its high-water mark for as long as it stays idle, and gives it back gradually once it serves more requests.
- What does pm.max_requests actually fix?
- It recycles workers, which bounds growth that outlives a request — persistent connections and extension allocations. Userland arrays are already freed at request shutdown, so it is not the cure for those.