Three numbers for one process, and memory_limit reads the middle one

memory_get_usage(), memory_get_usage(true) and ps disagree about one process. What each counts, which one memory_limit reads, and why a one-megabyte string costs two.

Alden Pike11 min read

At startup this process reports 484,176 bytes used, 2,097,152 allocated and 26,542,080 resident. Same process, same instant, and the largest is fifty-four times the smallest.

Your application logs the first. The fatal error that kills a request compares against the second. The pool-sizing arithmetic that decides how many workers fit on the box needs the third.

How these numbers were taken

PHP 8.5.10, NTS, arm64, Homebrew build, on an Apple M4 Pro laptop with 24 GB and 12 logical cores, running macOS and not quiesced. OPcache and the JIT are off, and memory_limit is stated with each measurement because two of them are about the limit itself.

used is memory_get_usage(), real is memory_get_usage(true), and rss is the resident set size read from ps -o rss= for the running process, converted from kilobytes. The two engine figures are single exact readings: across four runs of every script below they were byte-identical, so there is no variance to report for them. Resident memory is not — the same script’s startup reading ranged from 26,542,080 to 26,738,688 bytes across those four runs, a spread of about 200 KB. The tables print one run, and resident figures should be read to about that precision.

The resident figures are macOS figures. The PHP-FPM measurements made the same caveat and it holds here: treat resident numbers as shapes rather than as values to carry to a Linux server. The two in-process figures come from the engine and do transfer.

Three layers, one workload

One script, one allocation, read at five points:

<?php

declare(strict_types=1);

function rss(): int
{
    $out = shell_exec('ps -o rss= -p ' . getmypid());

    return ((int) trim((string) $out)) * 1024;
}

function row(string $label): void
{
    printf(
        "%-26s used %12s  real %12s  rss %12s\n",
        $label,
        number_format(memory_get_usage()),
        number_format(memory_get_usage(true)),
        number_format(rss())
    );
}

row('at startup');
$rows = range(0, 999_999);
row('after range(0, 999_999)');
$copy = $rows;
$copy[0] = 1;
row('after separating a copy');
unset($copy);
row('after unset($copy)');
unset($rows);
row('after unset($rows)');
at startup                 used      484,176  real    2,097,152  rss   26,542,080
after range(0, 999_999)    used   17,277,888  real   18,890,752  rss   42,663,936
after separating a copy    used   34,071,568  real   35,684,352  rss   58,671,104
after unset($copy)         used   17,277,888  real   18,890,752  rss   42,696,704
after unset($rows)         used      484,208  real    2,097,152  rss   26,689,536

The three columns are three accounting layers, and on this workload each one contains the one to its left. used is what the engine has handed to userland — the sum of the allocations behind every zval, string and hashtable your code holds, which for this million-integer list is the packed layout’s sixteen-odd bytes an element. real is what the engine has taken from the operating system to cut those allocations out of. rss is what the kernel says the process occupies, which includes the binary, its statically linked extensions, the runtime’s own structures and the stack.

Three layers is what this workload needs. It is not everything the process holds: the cycle collector’s root buffer is allocated outside the request allocator entirely, so none of these three columns counts it, and neither do they count the interned string buffer OPcache keeps for compiled literals.

The nesting is a property of this workload rather than a rule. real counts address space the allocator has claimed; rss counts pages that have actually been written to. They can cross, and the section on one-megabyte strings below is where they do.

That last gap is 24,444,928 bytes here, roughly 23 MiB, before a line of application code has run. It is the reason a worker that reports 5 MB of PHP memory shows up as a 30 MB process, and it is charged once per worker rather than once per box.

The middle column is the one with structure worth explaining, because it moves differently from the other two.

The allocator buys in two-megabyte chunks

real does not track used. It moves in steps:

appended               used           real  real step
20,000            1,013,792      2,097,152          0
40,000            1,538,080      2,097,152          0
60,000            1,538,080      2,097,152          0
80,000            2,598,968      4,210,688  2,113,536
100,000           2,598,968      4,210,688          0
120,000           2,598,968      4,210,688          0
140,000           4,696,120      6,307,840  2,097,152
160,000           4,696,120      6,307,840          0

The step is 2,097,152 bytes, and the header names it:

#define ZEND_MM_CHUNK_SIZE ((size_t) (2 * 1024 * 1024))    /* 2 MB  */
#define ZEND_MM_PAGE_SIZE  (4 * 1024)                      /* 4 KB  */
#define ZEND_MM_PAGES      (ZEND_MM_CHUNK_SIZE / ZEND_MM_PAGE_SIZE)  /* 512 */
#define ZEND_MM_FIRST_PAGE (1)

The Zend memory manager asks the OS for 2 MB at a time and cuts every userland allocation out of what it already holds. A chunk is 512 pages of 4 KB, the first of which is reserved for the chunk’s own bookkeeping, leaving 511 to hand out. Between the steps, used climbs and real does not move at all, because the engine is spending memory it already bought.

So used and real answer different questions. The first is what your data costs. The second is what the process asked the OS for, rounded up to whole chunks and never smaller. The gap between them is capacity, not waste — until the sizes involved make it waste, which is the next section.

memory_limit reads the chunk figure

The php.net page for memory_get_usage() defines the parameter and stops there: “Set this to true to get total memory allocated from system, including unused pages.” It does not say anywhere in its body which of the two figures memory_limit is compared against. That is the question a reader arrives with, and the answer is measurable.

Filling an array against a 16 MB limit and reading both figures on the way:

last reading before the fatal
  memory_get_usage()         14,119,640
  memory_get_usage(true)     14,696,448
  memory_limit               16,777,216
  headroom the app sees       2,657,576

Allowed memory size of 16777216 bytes exhausted (tried to allocate 4096 bytes)

The request the allocator refused was for 4,096 bytes, and the application had 2,657,576 bytes of apparent headroom when it was refused. The next chunk would have taken real to 16,793,600, which is 16,384 bytes past the limit, so the chunk was never bought and the 4 KB allocation inside it never happened.

The allocator says as much where it gives up, in zend_alloc.c:

if (UNEXPECTED(ZEND_MM_CHUNK_SIZE > heap->limit - heap->real_size)) {
    if (zend_mm_gc(heap)) {
        goto get_chunk;
    } else if (heap->overflow == 0) {

The quantity compared against the remaining budget is a whole ZEND_MM_CHUNK_SIZE, and the budget is heap->limit - heap->real_size — the same real_size that memory_get_usage(true) reports. So the granularity of enforcement is 2 MB and a script can die anywhere in the last chunk’s worth of headroom. The line below it is worth noting too: before failing, the engine runs a garbage collection of its own cached chunks and retries, which is why the fatal arrives only when there is genuinely nothing left to reclaim.

An application logging memory_get_usage() after a request is reporting a number that was never the one under test.

One megabyte is the worst size to ask for

Two allocations share a chunk only if both fit in the 511 pages a chunk has to give. Just over half a chunk, they stop fitting, and each one takes a chunk of its own. Thirty-two strings at a range of sizes, reading how many chunks the allocator bought for them:

String payload used real chunks ratio
512 KB 16,908,984 23,068,672 11.0 1.36x
1 MB 33,686,200 65,011,712 31.0 1.93x
1.5 MB 50,463,416 65,011,712 31.0 1.29x
2,093,056 − 64 66,978,488 65,011,712 31.0 0.97x
2,093,056 67,110,328 67,108,864 32.0 1.00x
4 MB 134,743,480 134,742,016 64.2 1.00x

Thirty-two one-megabyte strings took thirty-one two-megabyte chunks. Half of every chunk bought was unreachable for a second string of the same size, and real came to 1.93 times what the data weighed.

Resident memory did not follow, which is the part that decides how much this matters. Running the same sizes and reading all three layers:

String payload used real resident real/used resident/used
512 KB 16,908,984 23,068,672 17,629,184 1.36x 1.04x
1 MB 33,686,200 65,011,712 33,538,048 1.93x 1.00x
1.5 MB 50,463,576 65,011,712 49,790,976 1.29x 0.99x
4 MB 134,743,480 134,742,016 134,742,016 1.00x 1.00x

The 1 MB row is the whole point of separating the layers. real says the process took 65,011,712 bytes from the OS; the kernel says it is holding 33,538,048 of them. The unusable half of each chunk was mapped and never written to, so it never became a physical page. It is address space, not memory.

The rows below the fold are the mechanism. ZEND_MM_MAX_LARGE_SIZE is ZEND_MM_CHUNK_SIZE - (ZEND_MM_PAGE_SIZE * ZEND_MM_FIRST_PAGE), which is 2,093,056 bytes; at that size and above the allocator stops carving chunks and maps each request on its own, and the ratio goes to 1.00. Just below it, a string very nearly fills a whole chunk and the ratio is 0.97 — the same one chunk per string, but with almost none of it wasted.

If the rule is “each allocation must fit in 255 pages for two to share”, the cliff is one page wide and predictable to the byte. It is:

Payload used real chunks ratio
1,040,384 (half a chunk − 2 pages) 33,424,056 31,457,280 15.0 0.94x
1,044,480 (half a chunk − 1 page) 33,555,128 65,011,712 31.0 1.94x

Four thousand and ninety-six bytes of payload, and the same thirty-two strings go from fifteen chunks to thirty-one. A zend_string carries a header and a terminating byte on top of the payload, so 1,040,384 rounds to 255 pages and two of them fit in the 511 a chunk has; 1,044,480 rounds to 256, and two of those do not.

Somebody has been here before. php-src issue #13599, opened 2024-03-05 and titled “Out of memory with 1MB strings even though memory_get_usage reports 50% utilization”, reports 257 MB internal against 512 MB real on 8.1, 8.2 and 8.3. It was closed the next day, not as a bug but as intended behavior, with an explanation from a core developer that names the same rule the cliff above measures:

Probably worse, allocations just above 1MB may waste ~50% of memory due to each allocation requiring a new chunk, since the existing chunks occupying >1MB don’t hold enough pages for the new allocation.

The same thread makes the point the resident column makes: the reserved-but- untouched half of a chunk “was never accessed or written to and as such it remains unmapped to your RAM”, so “the only other place where this really matters is memory_limit”. The thread also generalizes the shape — the same waste appears at a third of a chunk, at a quarter, and so on, just in smaller proportions.

So this is not a leak and not a bug to wait out; it reproduces on 8.5.10 because it is the design. What it costs you is headroom against memory_limit, not RAM on the box. If your application buffers file contents, serialized payloads or HTTP response bodies that land near a megabyte, budget its limit against the doubled figure and size its pool against the undoubled one.

The chunks do go back

The standard explanation for a worker’s resident memory is that the engine frees memory inside itself and never returns the chunks to the operating system. On 8.5.10 that is not what happens. In the opening table, after unset($rows) reads real 2,097,152 — exactly the startup value, every chunk returned — and rss came back to 26,689,536, within 147,456 bytes of where it started, which is close to the run-to-run spread of the reading itself.

What the allocator does keep is a cache, and the rule for it is in zend_mm_shutdown():

heap->avg_chunks_count = (heap->avg_chunks_count + (double)heap->peak_chunks_count) / 2.0;
while ((double)heap->cached_chunks_count + 0.9 > heap->avg_chunks_count &&
       heap->cached_chunks) {

At every request shutdown the running average is pulled halfway toward the peak that request reached, and the cache is trimmed to it. That is the answer to a question the FPM post left open: it measured a worker holding 105.19 MB after a heavy request and giving it back over five trivial ones — 55, 29, 17, 11, then 7 — and said the rule was not chased into the C source. It is a halving because the average is a halving, and it is driven by request count rather than by elapsed time, which is why an idle worker holds its peak indefinitely and a busy one sheds it in a few requests.

I could not reproduce that staircase here. Under the built-in server, a process driven to 61,833,216 bytes resident by six heavy requests dropped to 29,851,648 on the first trivial one and stayed there — one step, not five. The rule permits both outcomes, since the average depends on the whole history of the process, and what differs between that rig and the FPM pool is something I did not establish.

Which number belongs in the pool arithmetic

Size a pool from resident memory, at the peak, and now for a stated reason. The FPM post arrived at that empirically; the accounting above is why. used omits the chunks bought but not yet spent. real omits the binary, the extensions and the allocator’s cache — 24,444,928 bytes on this build before any application code ran, charged once per worker. Only resident memory contains all of it, and it is the only one of the three that the box actually has to supply.

Read memory_get_peak_usage(true) when the question is whether a request will survive its limit. It is the figure memory_limit is compared against, so it is the one that answers the question, and the gap between it and memory_get_peak_usage() is the headroom your logs will not show you. On the opening workload those two read 35,684,352 and 34,093,200 — the difference is most of one chunk, which is small until an allocation pattern makes it a factor of two.

Reach for used when the question is what your data costs, and only then. It is the right number for comparing two implementations of the same structure, because it excludes the allocator’s rounding and answers about the data itself. It is the wrong number for every question that ends in “will this fit”, and the distance between those two uses is the 2 MB the engine buys at a time.

Frequently asked

Why did my script die when memory_get_usage() was well under memory_limit?
Because the limit is not compared against that figure. It is compared against memory_get_usage(true), the total the allocator has taken from the OS in 2 MB chunks, which is always the larger of the two and can be a whole chunk larger than the number your application logs.
Which number should I use to size a PHP-FPM pool?
Resident memory from ps, at the peak. The two in-process figures both exclude the binary, the extensions and the allocator's cached chunks, and on this build the engine's own accounting sat 24,444,928 bytes below resident before any userland code ran.
Is it true that PHP never gives memory back to the operating system?
Not on 8.5.10. Freeing a 16 MB array returned every chunk — the allocated figure went back to its startup value and resident memory followed. What the allocator does keep is a cache, trimmed at each request shutdown against a running average of recent peaks.
Why is one megabyte a bad allocation size?
A chunk is 2 MB of 4 KB pages, one of which is reserved, so two allocations share a chunk only if each fits in 255 pages. Just above that each one gets a chunk to itself and the rest of the chunk cannot hold another of the same size. The effect peaks just over half a chunk and disappears above 2,093,056 bytes, where the allocator maps the request on its own. It costs allocated memory rather than resident memory, so what it really costs is headroom against memory_limit.
Share

Written by

Alden Pike

Alden Pike writes about PHP internals, performance, architecture and production behavior — the layer beneath the frameworks. Measurements over assumptions, trade-offs over universal rules.

More about the author

Related posts

Arrow keys to move, Enter to open.