A zval is sixteen bytes, a value union and a type tag. What that tag says decides whether an assignment copies anything, what a reference costs, and which variable pays.

Copying a million-element array into a second variable moves memory_get_usage() by zero bytes. Writing one element to either variable moves it by 16,793,680 — the whole array, charged to whichever name wrote first.

<?php

declare(strict_types=1);

$baseline = memory_get_usage();
$rows = range(0, 999_999);
printf("build %+d bytes\n", memory_get_usage() - $baseline);

$baseline = memory_get_usage();
$copy = $rows;
printf("second name %+d bytes\n", memory_get_usage() - $baseline);

$baseline = memory_get_usage();
$copy[0] = 1;
printf("first write %+d bytes\n", memory_get_usage() - $baseline);

$baseline = memory_get_usage();
$copy[1] = 1;
printf("second write %+d bytes\n", memory_get_usage() - $baseline);
build        +16793680 bytes
second name +0 bytes
first write +16793680 bytes
second write +0 bytes

Two writes, one bill. Nothing about the array changed between them, and nothing in the source distinguishes them. What changed was a number that sits next to the array rather than inside either variable, and the variables themselves are the place to start, because they are smaller than they look.

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 for every figure below, and memory_limit is raised to 2G so the million-element cases fit — except in the one place further down that names a different limit, which is the point of that measurement. The rest of the archive was measured on 8.5.9 on the same machine.

Memory figures are single exact readings rather than medians: two consecutive runs of the whole script produced byte-identical output, so there is no variance to report. Timings are the median of 15 interleaved runs with three warmup runs discarded, taken with hrtime() inside the process, and the range is given with each one. Where a table times one statement out of a sequence, the clock brackets that statement alone and the setup around it runs untimed. Read the timings as relative to each other, not as absolutes for your hardware — the reasons are in benchmarking PHP without lying to yourself.

Sixteen bytes, whatever you put in them

A userland variable is a zval, and on a 64-bit build it is sixteen bytes: an eight-byte union holding the value, a four-byte word carrying the type tag and its flags, and four more bytes the engine reuses for bookkeeping such as the next entry in a hash collision chain. The union is one machine word wide, so an integer or a float lives directly inside it, while a string, an array or an object is a pointer to something else.

Nothing in there is the variable’s name. The compiler assigns each named local a numbered slot in the call frame, which is the compiled variable an opcode dump prints as CV0, and the sixteen bytes are what that slot holds.

That the slot never changes size is measurable from userland, because a packed array is a row of zvals and nothing more — the layout behind every PHP array stores the value and derives the key from the position. Filling one with a million values of four different types:

appended 1,000,000 integers                    16.79 bytes/element
appended 1,000,000 floats 16.79 bytes/element
appended 1,000,000 booleans 16.79 bytes/element
appended 1,000,000 nulls 16.79 bytes/element

Four types, one figure, to the byte. It is 16.79 rather than 16.00 because the array allocated capacity for 1,048,576 elements and a header on top; storing 1,048,576 integers instead of a million produced the identical 16,793,680 bytes, which is 1,048,576 slots of sixteen bytes plus 16,464 bytes of table.

Null is the case worth pausing on. null is a type tag with no payload, and it still costs a full slot, because the slot is what an array element is. The same holds for true and false: the engine has separate type tags for them, so a boolean does not even use the value union.

The count lives with the value, not with the variable

One byte of that four-byte word is the type tag and one carries flags. A single flag there decides everything above: whether the thing the union points at is reference counted. Integers, floats, booleans and null are not, because there is nothing to point at. Strings, arrays and objects are, and the count for them lives in an eight-byte header on the pointed-to structure — not in the variable.

Continue reading at elephantphp.com.