The standard PHP-FPM capacity formula is max_children = available_memory / average_worker_RSS. It shows up in every tuning guide, and it overcounts unique memory by a predictable margin. Forked PHP-FPM workers share a large block of read-only memory (OPcache bytecode, shared libraries, interned strings), and tools like ps count those shared pages in every worker’s RSS. Sum RSS across workers and divide, and you overcount by roughly 30 to 50 percent.
The downstream effect is a pm.max_children set too low for the box. You leave worker slots unused, hit max children reached earlier than necessary, then raise the limit using the same flawed math, this time without a model for how close to the OOM cliff you are.
What it is and why it matters
PHP-FPM is a fork-based master/worker model. The master initializes the PHP runtime once (loading extensions, reading php.ini, setting up OPcache), then calls fork() to produce workers. After fork, parent and child share the same physical pages until one side writes to them (copy-on-write). Read-only pages, the compiled bytecode in OPcache, the text segments of libc and PHP extensions, interned strings, stay shared for the worker’s lifetime.
OPcache uses a single mmap(MAP_SHARED) segment. The kernel allocates those physical pages once. Every worker maps that segment read-only to execute precompiled bytecode. This is the performance benefit of OPcache in a process model: compile once, share across workers.
The accounting problem: ps, /proc/<PID>/status VmRSS, and most monitoring agents report RSS per process. RSS includes shared pages in every process that maps them. A 64 MB OPcache segment plus tens of MB of shared libraries shows up in every worker’s RSS, even though the kernel only allocates those pages once. Summing ps RSS across N workers counts that shared block N times.
On a typical production PHP application with OPcache enabled, RSS-based capacity math overstates unique worker memory by 30 to 50 percent. The exact overcount depends on OPcache size, the shared library set, and the worker count, but the direction is consistent: RSS-based math is pessimistic.
How it works
Three memory metrics matter for capacity planning.
| Metric | What it counts | Good for |
|---|---|---|
| RSS (Resident Set Size) | All physical pages mapped by the process, including shared | Leak trending within a worker |
| PSS (Proportional Set Size) | Private pages plus each shared page divided by the number of mapping processes | Honest per-worker cost for capacity math |
| USS (Unique Set Size) | Private pages only | Lower bound; ignores shared pages entirely |
PSS answers “how much does one more worker cost.” It charges each worker only its fair share of the shared pages. When N workers map the OPcache segment, each worker’s PSS includes opcache_size / N rather than the full opcache_size.
The kernel exposes PSS through /proc/<PID>/smaps_rollup, a pre-summarized view available since Linux 4.14 (November 2017). The relevant fields are Pss, Pss_Anon, Pss_File, and Pss_Shmem, alongside Rss, Anonymous, and shared counters. The full /proc/<PID>/smaps file has per-VMA detail but is much more expensive to parse; smaps_rollup is the right interface for live capacity checks.
flowchart TD A["PHP-FPM master forks N workers"] --> B["Each worker maps OPcache + libs read-only"] B --> C["ps and VmRSS path"] B --> E["/proc/PID/smaps_rollup path"] C --> D["Shared pages counted in every worker
naive sum overcounts 30-50%"] E --> F["Shared pages divided by N
plus private pages = honest cost"] D --> G["max_children set too low
headroom unused"] F --> H["Safe max_children from formula"]
Measuring PSS in production
The fastest single command is smem:
# Per-worker PSS, sorted to expose outliers (requires smem)
smem -P php-fpm -c 'pid pss rss' -s pss
smem reads /proc/<PID>/smaps (or smaps_rollup on newer kernels) for each matching process and prints PSS, USS, and RSS.
If smem is not available, read smaps_rollup directly. Average PSS across all workers in MB:
# Average PSS across all FPM workers in MB, excluding the master
for pid in $(pgrep -f 'php-fpm: pool'); do
awk '/^Pss:/ {sum+=$2} END {print sum/1024}' /proc/$pid/smaps_rollup
done | awk '{total+=$1; n++} END {printf "Workers: %d, Avg PSS: %.1f MB\n", n, total/n}'
Compare to the RSS-based number:
# Average RSS of all workers in KB - inflated by shared OPcache pages
ps -eo pid,rss,cmd | grep '[p]hp-fpm' | grep -v master \
| awk '{sum+=$2; count++} END {print sum/count " KB avg"}'
On a real production pool with OPcache enabled, the PSS average is typically 30 to 50 percent lower than the RSS average. A worker showing 60 MB RSS may report a PSS of 35 MB once OPcache and shared libraries are apportioned.
Where the shared bytes come from
The dominant contributors to the shared block:
- OPcache bytecode segment, sized by
opcache.memory_consumption. Compiled script bytecode lives here, shared by every worker. - Interned strings buffer, sized by
opcache.interned_strings_buffer. - Shared library text segments (libc, libxml, the database client library, image processing libraries).
- PHP’s own binary text and read-only data.
All PHP-FPM pools on the same master share a single OPcache instance, so the PSS savings apply across pools, not just within one. Splitting traffic across multiple pools does not multiply OPcache memory.
Where it shows up in production
The RSS trap manifests in two opposite failure modes.
Conservative undersizing. You measure 80 MB average RSS, apply the formula (RAM * 0.7) / 80, get a modest max_children, and ship it. At peak traffic you hit max children reached with memory to spare. The box has headroom you are afraid to use.
Reactive overshooting. During a saturation event you raise max_children based on the same RSS math. Because the math is pessimistic, the new value is usually safe, but you have no model for how close to the cliff you are. The next time you raise it, or OPcache grows, or a slow leak accumulates, the OOM killer fires. See PHP-FPM workers OOM-killed for that failure chain.
Both modes share a root cause: the capacity formula uses a metric (RSS) that does not represent marginal cost. PSS does.
There is also a measurement timing trap. A freshly forked worker has low RSS and low PSS because it has not yet served a request. A worker that just served a large export or image transform has temporarily elevated memory. Sample under normal peak load, across the whole pool, and ignore workers still in warmup.
Tradeoffs and when to use it
PSS is the right metric for one job: capacity math. It is not the right metric for every job.
Use RSS for leak trending. PSS moves as workers are recycled and as the shared block shifts. A slow, monotonic climb in per-worker RSS between recycles is the classic leak signature. PSS dilutes that signal because the shared component is divided across the live worker count. Keep RSS in the leak-detection dashboard; use PSS in the capacity formula.
Re-measure after OPcache or codebase changes. OPcache size depends on the number and size of cached scripts. Adding a large framework, raising opcache.memory_consumption, or raising opcache.interned_strings_buffer changes the shared block and therefore the PSS/RSS ratio. Treat the PSS number as a value to re-measure whenever the codebase or OPcache configuration changes, not as a constant.
Reserve 30 percent of RAM for the OS. PHP-FPM’s total footprint (workers plus OPcache plus the master) should stay under 70 percent of system RAM. The remaining 30 percent is for the OS page cache (which PHP-FPM benefits from on every script, session, or upload read), buffers, and any other process on the host. In containers, apply the same fraction to the cgroup memory limit, not the host.
The formula
With PSS measured and the 30 percent reserve applied:
memory_safe_max_children = floor( (total_RAM * 0.7 - OS_overhead) / avg_worker_PSS )
Where:
total_RAMis the host RAM, or the cgroupmemory.maxin containers.OS_overheadis a fixed estimate for the master process, other daemons, and system reserves. OPcache is already covered by the PSS sum, so do not add it toOS_overhead.avg_worker_PSSis the average PSS measured under normal peak load, excluding warmup workers.
This gives the memory ceiling for pm.max_children. It is independent of CPU: PHP-FPM workers spend most of their time blocked on I/O, so the binding constraint on a typical app server is memory, not cores. CPU only becomes the limit when workers are genuinely computing (heavy framework boot, OPcache thrash, image processing).
The memory ceiling is a maximum, not a recommendation. The actual pm.max_children should also leave worker headroom for the configured pm mode:
static: setpm.max_childrento the memory ceiling for predictable memory and instant burst capacity. All workers are pre-forked and resident.dynamic: setpm.max_childrento the memory ceiling, then tunepm.start_servers,pm.min_spare_servers, andpm.max_spare_serversbelow it. The pool uses less memory at idle.ondemand: setpm.max_childrento the memory ceiling. Workers spawn on request and die after idle. Memory at idle is near zero; cold-start latency is the trade.
See the max_children reached guide for what to do when this ceiling is the thing you are hitting.
Interaction with pm.max_requests
pm.max_requests recycles workers after N requests. With leaks, RSS climbs between recycles and the PSS baseline drifts up as the pool’s average worker age increases. Size max_children against the steady-state PSS at the typical worker age, not against a freshly forked worker. If RSS climbs sharply between recycles, fix the leak first; see PHP-FPM memory leak.
Signals to watch in production
| Signal | Why it matters | Warning sign |
|---|---|---|
| Per-worker RSS (trend) | Leak signature that PSS dilutes | Monotonic climb between recycles |
| Per-worker PSS (point-in-time) | Honest per-worker cost for capacity math | PSS drifting up week over week after deploys |
| Total FPM footprint (PSS-summed) | Validates the capacity formula against the box | Approaching 70 percent of RAM |
| System / cgroup available memory | The actual cliff, including page cache pressure | First swap activity, ever |
| OPcache memory usage and hit rate | Changes the shared block and the PSS/RSS ratio | oom_restarts > 0, hit rate under 99 percent when warm |
max children reached rate | Confirms the ceiling is the binding constraint | Counter incrementing during normal traffic |
pm.max_requests and worker age | Determines which PSS point you measure against | Workers running far past the configured recycle count |
How Netdata helps
- Per-process and per-cgroup memory metrics at per-second resolution let you watch both RSS (for leak trend) and box-level memory pressure side by side, without summing
psoutput by hand. - The PHP-FPM collector pulls the status page (
active processes,idle processes,max children reached,listen queue) so capacity decisions correlate against actual saturation events rather than synthetic load tests. - ML-based anomaly detection on per-worker RSS and total FPM footprint catches the slow drift that precedes the OOM cliff, even when no static threshold has been crossed.
- OPcache memory and hit-rate metrics are collected alongside FPM pool metrics, so changes to the shared block (and the resulting PSS shift) are visible in the same view as worker counts.
- Composite alerts can correlate
max children reachedwith available memory headroom, distinguishing “raise the ceiling” from “the box is full”.
Related guides
- PHP-FPM 504 Gateway Timeout: requests accepted but never finishing in time
- PHP-FPM active processes near max_children: reading pool utilization
- How PHP-FPM actually works in production: a mental model for operators
- PHP-FPM idle processes at zero: no burst headroom left
- PHP-FPM listen queue growing: the earliest signal of saturation
- PHP-FPM “server reached pm.max_children setting (N), consider raising it”
- PHP-FPM memory leak: per-worker RSS climbing until the box runs out
- PHP-FPM monitoring checklist: the signals every production pool needs
- PHP-FPM monitoring maturity model: from survival to expert
- PHP-FPM workers OOM-killed: “child N exited on signal 9 (SIGKILL)” and the memory cliff
- PHP-FPM request duration climbing: spotting stuck and outlier workers
- PHP-FPM request_terminate_timeout: stopping stuck requests from eroding the pool






