Структура zval занимает шестнадцать байт: объединение (union) значения и тег типа. То, что записано в этом теге, определяет, копирует ли что-то присваивание, во сколько обходится ссылка и какая переменная за это платит.

Копирование массива из миллиона элементов во вторую переменную изменяет значение memory_get_usage() на ноль байт. Запись одного элемента в любую из двух переменных увеличивает его на 16 793 680 — весь массив списывается на то имя, которое выполнило запись первым.

<?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

Две записи, один счет. В самом массиве между ними ничего не изменилось, и в исходном коде между ними нет никаких различий. Изменилось число, которое находится рядом с массивом, а не внутри какой-либо из переменных. И сами переменные — это лучшее место для начала, потому что они меньше, чем кажутся.

Как снимались эти показатели

PHP 8.5.10, NTS, arm64, сборка Homebrew, ноутбук Apple M4 Pro с 24 ГБ ОЗУ и 12 логическими ядрами под управлением macOS (без перевода системы в фоновый режим). OPcache и JIT отключены для всех приведенных ниже цифр, а memory_limit увеличен до 2G, чтобы поместились сценарии с миллионом элементов — за исключением одного места ниже, где указан другой лимит (в чем и заключается суть того измерения). Остальная часть архива измерялась на PHP 8.5.9 на той же машине.

Показатели памяти представляют собой одиночные точные измерения, а не медианы: два последовательных запуска всего скрипта дали идентичный с точностью до байта результат, поэтому отклонения отсутствуют. Временные замеры — это медиана 15 чередующихся запусков без учета трех прогревочных запусков, снятая с помощью hrtime() внутри процесса; диапазон указан для каждого случая. Когда в таблице замеряется время выполнения одного выражения из последовательности, таймер охватывает только это выражение, а подготовка вокруг него выполняется без замеров. Считывайте временные показатели как относительные по отношению друг к другу, а не как абсолютные для вашего железа — причины описаны в статье как тестировать производительность PHP, не обманывая себя.

Шестнадцать байт, что бы вы в них ни положили

Переменная на уровне пользовательского кода — это zval, и в 64-битной сборке она занимает шестнадцать байт: восьмибайтовое объединение (union), хранящее значение, четырехбайтовое слово с тегом типа и его флагами, и еще четыре байта, которые движок использует для внутренних нужд вроде указателя на следующий элемент в цепочке коллизий хэша. Объединение имеет ширину в одно машинное слово, поэтому целое число (integer) или число с плавающей точкой (float) хранятся прямо внутри него, в то время как строка, массив или объект представляют собой указатель на другую структуру.

Имени переменной там нет. Компилятор назначает каждой именованной локальной переменной пронумерованный слот в фрейме вызова (call frame) — это компилируемая переменная, которую дамп опкодов выводит как CV0, — и шестнадцать байт составляют содержимое этого слота.

Тот факт, что слот никогда не меняет размер, можно измерить из пользовательского кода, поскольку упакованный массив (packed array) — это просто ряд zval-структур и ничего более: внутреннее устройство любого массива в PHP хранит значение и вычисляет ключ исходя из его позиции. Заполнение такого массива миллионом значений четырех разных типов:

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

Четыре типа, одна цифра, с точностью до байта. Получилось 16.79, а не 16.00, потому что массив выделил емкость под 1 048 576 элементов плюс заголовок поверх этого; сохранение 1 048 576 целых чисел вместо миллиона дало те же самые 16 793 680 байт, что представляет собой 1 048 576 слотов по шестнадцать байт плюс 16 464 байта таблицы.

Null — это случай, на котором стоит остановиться подробнее. null — это тег типа без полезной нагрузки, но он все равно стоит целого слота, потому что именно слот представляет собой элемент массива. То же самое справедливо для true и false: у движка есть для них отдельные теги типов, поэтому булево значение даже не использует объединение значений.

Счетчик находится в значении, а не в переменной

Один байт из этого четырехбайтового слова — это тег типа, а еще один содержит флаги. Один единственный флаг определяет все вышеописанное: учитывается ли количество ссылок (reference counted) для сущности, на которую указывает объединение. Для integers, floats, booleans и null ссылки не считаются, потому что указывать не на что. Для строк, массивов и объектов ссылки считаются, и их счетчик хранится в восьмибайтовом заголовке адресуемой структуры, а не в переменной.