# 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.

- Published: 2026-09-13
- Tags: memory, profiling, php-fpm
- Source: https://elephantphp.com/blog/where-php-memory-actually-goes/
- Language: en-US
- Author: Alden Pike

---
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](/blog/php-fpm-process-management-in-production/) 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
<?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)');
```

```text
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](/blog/what-a-php-variable-actually-is/), which for this million-integer
list is [the packed layout's sixteen-odd bytes an
element](/blog/php-arrays-are-not-arrays/). `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](/blog/the-gc-only-collects-cycles/), and
neither do they count
[the interned string buffer OPcache keeps for compiled literals](/blog/strings-in-php-interned-copies-and-concatenation/).

The nesting is a property of this workload rather than a rule. `real` counts
address space [the allocator](https://github.com/php/php-src/blob/PHP-8.5/Zend/zend_alloc.c)
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:

```text
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:

```c
#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()`](https://www.php.net/manual/en/function.memory-get-usage.php)
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:

```text
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`:

```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](https://github.com/php/php-src/issues/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()`:

```c
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.
