Эти два цикла собирают одну и ту же строку размером 1,25 МБ с помощью одной и той же конструкции. Один выполняется за 0,269 миллисекунды, а другой — за 218,836.
<?php
declare(strict_types=1);
$piece = str_repeat('a', 50);
// fast
$s = '';
for ($i = 0; $i < 25_000; $i++) {
$s .= $piece;
}
// 814 times slower
$s = '';
$keep = '';
for ($i = 0; $i < 25_000; $i++) {
$keep = $s;
$s .= $piece;
}
Операция добавления в конец абсолютно одинакова — тот самый
.= , который мануал описывает в одну строчку.
Разница лишь в том, что в процессе роста буфера на него ссылается что-то ещё, и
в этом вся суть проблемы.
Как были получены эти цифры
PHP 8.5.10, NTS, arm64, сборка Homebrew, на ноутбуке Apple M4 Pro с 24 ГБ ОЗУ и
12 логическими ядрами под управлением macOS в обычном рабочем окружении (без перевода в однопользовательский режим). OPcache и JIT отключены,
за исключением измерений, специально посвященных OPcache, о чём явно указано там,
где они приводятся. Параметр memory_limit установлен в 4G.
Значения времени представляют собой медиану 15 чередующихся запусков после отбрасывания трех раундов прогрева,
измеренную с помощью hrtime() внутри процесса; также указан разброс значений. Два
исключения отмечены особо: сравнение четырех вариантов на 25 000 фрагментов — это медиана
пяти чередующихся запусков, а таблица масштабирования для $s = $s . $piece — медиана
трех запусков, поскольку один прогон на максимальном размере занимает одиннадцать секунд. Данные по памяти
абсолютно точны и совпадали в каждом раунде. Воспринимайте замеры времени
в относительном, а не в абсолютном ключе, в силу
причин, изложенных в статье о методологии.
Дампы опкодов получены через opcache.opt_debug_level=0x20000 . В одном из разделов один и тот же
скрипт замеряется в трех конфигурациях — OPcache выключен, разделяемая память OPcache
включена в CLI и OPcache включен со встроенным сервером — поскольку результаты
в них различаются.
Строка — это заголовок, байты и округление
Структура zend_string состоит из 24-байтового заголовка — счетчика ссылок и флагов, закешированного хеша и
длины — за которыми следуют сами байты и завершающий нуль-терминатор. Затем аллокатор
округляет общий размер в большую сторону до одного из своих размерных классов (size bins), что и видит на самом деле измерение
из пользовательского кода (userland):
len 0 0 bytes
len 1 32 bytes
len 7 32 bytes
len 8 40 bytes
len 15 40 bytes
len 16 48 bytes
len 24 56 bytes
len 32 64 bytes
len 100 128 bytes
Двадцать четыре плюс один плюс длина, округленные до числа, кратного восьми, с нижней границей в 32 байта. Строка длины 7 укладывается ровно в 24 + 7 + 1 = 32; строке длины 8 требуется 33, и она получает 40. Пустая строка вообще ничего не стоит, потому что это общий синглтон, который движок просто отдает, вместо того чтобы выделять новую память.
Такова стоимость контейнера. При этом переменная, которая его хранит, стоит шестнадцать байт, и ее размер не меняется, какой бы ни была длина строки.
Литерал — это не аллокация
Две строки с одинаковым содержимым: одна жестко прописана в исходнике, а вторая получена в результате вызова функции:
<?php
declare(strict_types=1);
$baseline = memory_get_usage();
$literal = 'elephantphp';
printf("literal %+d bytes\n", memory_get_usage() - $baseline);
$baseline = memory_get_usage();
$built = strrev('phptnahpele');
printf("built at run %+d bytes\n", memory_get_usage() - $baseline);
debug_zval_dump($literal);
debug_zval_dump($built);
literal +0 bytes
built at run +40 bytes
string(11) "elephantphp" interned
string(11) "elephantphp" refcount(3)
Литерал интернирован: сохранен один раз, переиспользуется везде в скомпилированном коде, где он упоминается, и у него нет подсчета ссылок, потому что ничто и никогда его не освободит. Строка из рантайма — это обычная аллокация со счетчиком ссылок: 40 байт, в полном соответствии с арифметикой из предыдущего раздела для одиннадцати символов.
Сравнение === между ними по-прежнему вернет true. Интернирование отвечает за хранение, а не за идентичность.
Где живут интернированные строки и почему CLI вводит в заблуждение
Когда OPcache включен, интернированные строки из скомпилированного кода попадают в свой собственный пул,
размер которого задается директивой opcache.interned_strings_buffer , отдельно от памяти, которую использует сам кеш
опкодов. Когда ваш код начинает выполняться, он уже не пуст:
interned buffer: used 2,599,584 of 8,388,608 bytes, 10,507 strings
Восемь мегабайт по умолчанию, и четверть из них уже потрачена на собственные строки движка
и только что скомпилированный скрипт. Это отдельный параметр, отличающийся от
opcache.memory_consumption , и он может заполниться сам по себе: кодовая база с очень
большим количеством уникальных имен классов, методов и строковых литералов исчерпает буфер интернирования,
пока в кеше опкодов еще есть место, а когда он заполняется, последующие строки
просто перестают интернироваться. Это
четвертый пул наряду с тремя, которые в этом цикле статей уже разбирались.
А теперь нюанс, который будет стоить вам целого вечера отладки, если вас никто не предупредит. Один и тот же литерал, измеренный четырьмя способами:
OPcache off, CLI interned
OPcache shared memory on, CLI refcount(3)
opcache.file_cache_only=1, CLI interned
OPcache on, built-in web server interned
Три из четырех вариантов сходятся, а второй — нет. В CLI SAPI с включенной разделяемой памятью литерал в выполняемом скрипте стабильно, при каждом запуске и независимо от наличия файлового кеша от предыдущего вызова, оказывался строкой с подсчетом ссылок, а не интернированной строкой.
Мне не удалось выяснить, почему так происходит. Но я могу сказать, что это значит для проведения замеров: CLI с включенным OPcache — это единственная конфигурация, где интернирование ведет себя не так, как в остальных трех случаях, и при этом именно к ней большинство разработчиков прибегает для тестирования. Проводите замеры в SAPI, который сохраняет кеш прогретым.
Сборка большой строки
Десять мегабайт в виде 200 000 кусочков по 50 байт, собранные двумя путями, о которых часто спорят:
| Сборка 10 МБ | Время | Пик памяти |
|---|---|---|
$s .= $piece |
2.569 мс (2.431–2.637) | 10,496,312 |
implode('', $parts) |
2.673 мс (2.572–2.862) | 17,918,368 |
Совет заменять конкатенацию на implode() дает ничью по времени и проигрыш
по памяти. implode() не может начать работу, пока не будут созданы все части, поэтому на пике в памяти
одновременно находятся и массив фрагментов, и готовая строка — в 1,71 раза больше, чем
потребовалось варианту с конкатенацией через append. Вариант с append держит один буфер и увеличивает его размер.
Но ни один из этих случаев не способен намертво подвесить задачу экспорта данных. А вот этот — вполне:
| 25 000 частей, 1,25 МБ | Время | Пик памяти |
|---|---|---|
$s .= $piece |
0.269 мс (0.255–0.277) | 1,739,040 |
implode('', $parts) |
0.326 мс (0.316–0.354) | 2,668,888 |
$s = $s . $piece |
228.456 мс (221.886–235.782) | 2,992,424 |
$s .= $piece , со второй ссылкой |
218.836 мс (215.966–230.497) | 2,992,424 |
В восемьсот сорок девять раз дольше — ради конструкции, которую большинство сочло бы просто другим вариантом синтаксиса. А четвертая строка — это тот же самый оператор, что и в первой, но замедленный строчкой кода, которая даже его не трогает.
Один опкод против двух
Дамп опкодов показывает разницу между первыми двумя строками еще до всяких замеров:
<?php
function appendInPlace(string $piece): string { $s = ''; for ($i=0;$i<3;$i++) { $s .= $piece; } return $s; }
function reassign(string $piece): string { $s = ''; for ($i=0;$i<3;$i++) { $s = $s . $piece; } return $s; }
appendInPlace:
0004 ASSIGN_OP (CONCAT) CV1($s) CV0($piece)
reassign:
0004 T3 = FAST_CONCAT CV1($s) CV0($piece)
0005 ASSIGN CV1($s) T3
ASSIGN_OP использует целевую переменную как операнд над которым совершается действие. FAST_CONCAT
собирает новую строку во временной переменной, а ASSIGN затем заменяет ею
исходную переменную. Это означает полное выделение памяти под новую длину и копирование каждого ранее
записанного байта на каждой итерации цикла.
Функция concat_function() в zend_operators.c — это как раз то место, за счет которого
первый вариант получает преимущество, и комментарий прямо говорит о ее назначении:
if (result == op1) {
/* Destroy the old result first to drop the refcount, such that $x .= ...; may happen in-place. */
...
result_str = zend_string_extend(op1_string, result_len, 0);
Когда целевая переменная и левый операнд указывают на один и тот же zval, движок сбрасывает свою
собственную ссылку и расширяет уже существующую аллокацию памяти. zend_string_extend() может
вернуть тот же самый указатель, просто увеличив его размер, если на него больше ничего не ссылается.
Вот почему вторая ссылка обходится так дорого
Это условие — если на него больше ничего не ссылается — как раз и нарушается в четвертой строке
таблицы. $keep = $s; увеличивает счетчик ссылок буфера, поэтому конкатенация больше
не может расширять память на месте (in-place) и вынуждена вместо этого делать полное копирование. Одно выражение
где-то внутри цикла превращает быстрый путь в медленный, и
профиль расходов становится точно таким же, как у $s = $s . $piece : 218,836 мс против 228,456
при одинаковом пиковом потреблении памяти.
Это в точности то самое правило, которое измерялось на массивах в первой статье этого цикла: расходы возникают при записи, если на значение ссылается более одного имени. Для строк работает тот же механизм, с той лишь разницей, что массив разделяется один раз, а растущая строка — при каждом добавлении фрагмента. Поэтому то самое правило, которое обходится массиву в одно копирование, стоит строке квадратичного их числа.
И вот во что это выливается:
Частей добавлено через $s = $s . $piece |
Время |
|---|---|
| 12,500 | 57.352 мс |
| 25,000 | 235.630 мс |
| 50,000 | 1,582.328 мс |
| 100,000 | 11,249.585 мс |
Медиана трех запусков для каждого значения. Удвоение объема работы увеличивало время в 4,11 раза, затем в 6,72 и затем в 7,11 — что даже хуже множителя 4, ожидаемого от чисто квадратичного алгоритма копирования, поскольку сами операции копирования становятся менее дружелюбными к процессорному кешу по мере того, как буфер перерастает его объем.
Как найти лишнюю ссылку
Счетчик ссылок можно увидеть прямо из пользовательского кода, но с одним условием: читать его нужно внутри цикла. После завершения цикла обе версии покажут одинаковые значения, потому что лишний владелец останется указывать на старый буфер, который последняя конкатенация уже успела заменить.
<?php
declare(strict_types=1);
function countOf(string &$var): string
{
ob_start();
debug_zval_dump($var);
$dump = ob_get_clean();
return trim(substr($dump, strrpos($dump, '"') + 1)) ?: 'interned';
}
$piece = str_repeat('a', 10);
$s = '';
for ($i = 0; $i < 3; $i++) {
printf("sole owner iteration %d: %s\n", $i, countOf($s));
$s .= $piece;
}
sole owner iteration 2: refcount(2)
second holder iteration 2: refcount(3)
Разница ровно в единицу на каждой итерации. Абсолютные значения завышены — вспомогательная функция держит
свою собственную ссылку, и то же самое делает буфер вывода — поэтому смотреть нужно именно на разницу
между двумя циклами, а не на конкретное число. Та же осторожность
требовалась и для debug_zval_dump() ,
когда мы впервые использовали ее в этом цикле. Вариант
с единственным владельцем показывает меньшее число, и именно в нем строка расширяется на месте.
Что делать с циклом, собирающим строку
Добавляйте фрагменты через .= и не трогайте буфер сторонними операциями. Это самый быстрый путь,
он требует меньше всего памяти, и именно этот дефолтный подход фольклор разработки годами
советовал избегать.
Затем проверьте, не ссылается ли на буфер кто-то еще. Проблема кроется не в операторе; она кроется во втором имени, привязанном к тому же буферу во время выполнения цикла. Обратите внимание на присваивания, сохраняющие слепок буфера, передачу значения в функцию, которая его запоминает, свойства объектов, удерживающие копию, и ссылки на переменную: амперсанд, который в этой серии статей оценивался в 32 байта, — это точно такой же второй владелец. Любой из этих факторов превращает одну инструкцию в полное копирование на каждой итерации, причем ни один из них не находится в той строке, на которую укажет профилировщик.
Используйте implode() только тогда, когда части строки уже существуют по другой причине — например, вы собрали
массив строк, потому что этот массив требовался для чего-то еще. Собирать массив
исключительно ради последующего implode означает получить вторую копию всех данных на пике потребления памяти и не выиграть
ничего ощутимого по времени.
Пишите литералы вместо конкатенации константных строк во время выполнения. Литерал интернируется и бесплатен; точно такое же содержимое, полученное вызовом функции, — это аллокация со счетчиком ссылок, а внутри цикла — это новая аллокация на каждой итерации.
Комментарии (0)
Пока нет комментариев — будьте первым.