Эти три объявления свойств — синтаксис, добавленный RFC о хуках свойств в 8.4, — во время выполнения делают три разные вещи. Компилятор же генерирует для всех них один и тот же опкод.
<?php
final class Plain { public int $v = 0; }
final class Backed { public int $v = 0 { get => $this->v; } }
final class Virt { public int $u = 0; public int $v { get => $this->u; } }
function readPlain(Plain $o): int { return $o->v; }
function readBacked(Backed $o): int { return $o->v; }
function readVirtual(Virt $o): int { return $o->v; }
readPlain:
0000 CV0($o) = RECV 1
0001 T1 = FETCH_OBJ_R CV0($o) string("v")
0002 RETURN T1
readBacked:
0000 CV0($o) = RECV 1
0001 T1 = FETCH_OBJ_R CV0($o) string("v")
0002 RETURN T1
readVirtual:
0000 CV0($o) = RECV 1
0001 T1 = FETCH_OBJ_R CV0($o) string("v")
0002 RETURN T1
Один вариант читает слот. Другой вызывает функцию, которая читает слот. Третий вызывает функцию, которая читает другой слот. Дамп не может их различить, как и ревьюер, смотрящий на место вызова.
Как были получены эти цифры
PHP 8.5.10, NTS, arm64, сборка Homebrew, на ноутбуке Apple M4 Pro с 24 ГБ и
12 логическими ядрами, под управлением macOS, без перевода системы в режим покоя. В этой сборке OPcache
вкомпилирован внутрь, а не подключен как разделяемое расширение, поэтому --ri "Zend OPcache" отвечает там, где
--ri opcache молчит.
По умолчанию в архиве OPcache и JIT отключены, и в основной таблице ниже
сохранены эти параметры. Две дополнительные конфигурации подписаны там, где они появляются: включенный OPcache
с отключенным JIT, а также JIT в обоих его режимах. Дампы опкодов получены из
opcache.opt_debug_level=0x20000 , который выводит каждую функцию после работы оптимизатора — тот же вывод,
из которого статья о жизненном цикле запроса читает $_main :
php -d opcache.enable_cli=1 -d opcache.opt_debug_level=0x20000 dump-access.php
Замеры времени представляют собой медиану 15 чередующихся прогонов без учета трех
разогревочных раундов, снятую с помощью hrtime() внутри процесса на 5 000 000 итераций,
с указанием диапазона. Каждое значение времени приводится за вычетом пустого цикла, замеренного
таким же образом в той же конфигурации, поскольку на таких масштабах цикл составляет большую
часть числа. Данные по памяти — это точные дельты memory_get_usage() . Воспринимайте эти
замеры относительно друг друга, а не как абсолютные величины, в силу
причин, изложенных в статье о методологии.
Хук — это функция, и дамп это подтверждает
Тот же дамп, который показывает три идентичных места вызова, чуть дальше в своем выводе показывает и то, чем хук является на самом деле:
Backed::$v::get:
0000 T0 = FETCH_OBJ_R THIS string("v")
0001 RETURN T0
Virt::$v::get:
0000 T0 = FETCH_OBJ_R THIS string("u")
0001 RETURN T0
Каждый хук компилируется в функцию со своим собственным массивом опкодов и своим именем
— именем свойства с суффиксом ::get . Ничто в вызывающей функции
не ссылается на них. Связь устанавливается в рантайме обработчиком за
FETCH_OBJ_R , который проверяет, есть ли у разрешенного свойства хуки, и
вызывает один из них, если они есть.
В этом и заключается весь механизм, и именно поэтому накладные расходы не видны там, где они возникают.
Одну деталь внутри Backed::$v::get стоит прочитать дважды: $this->v скомпилировался
в обычный FETCH_OBJ_R , а не в еще один вызов хука. Внутри собственного хука
backed-свойство обращается к backing storage, что и предотвращает очевидную
бесконечную рекурсию.
Геттер — единственная из четырех форм, которая выглядит иначе:
readGetter:
0000 CV0($o) = RECV 1
0001 INIT_METHOD_CALL 0 CV0($o) string("getV")
0002 V1 = DO_FCALL
0003 VERIFY_RETURN_TYPE V1
0004 RETURN V1
Четыре опкода против одного и явный вызов. Запомните это, потому что он вот-вот окажется более дешевым из двух вариантов.
Виртуальное свойство не занимает слот
По части памяти всё сходится с обещаниями. У виртуального свойства нет backing storage, поэтому оно ничего не забирает из таблицы свойств, которую в этой серии статей оценили в 40 байт плюс 16 на слот:
ThreeOnly 96 bytes
ThreePlusVirtual 96 bytes
Four 112 bytes
FourBackedHooks 112 bytes
Три объявленных свойства и три-плюс-одно-виртуальное — это один и тот же объект. Четыре объявленных свойства и четыре свойства с backed-хуками — тоже один и тот же объект. Так что на вопрос о памяти есть четкий ответ: хук на backed-свойстве ничего не стоит, а виртуальное свойство экономит слот, который вычисляемое значение иначе заняло бы, если бы вы его кэшировали.
Сколько стоит чтение
Пять миллионов чтений одного целого числа за вычетом пустого цикла в 2,986 нс:
| Чтение | Всего | Чистое |
|---|---|---|
| Обычное свойство | 4.323 нс (4.307–4.403) | 1.337 нс |
| Метод-геттер | 11.807 нс (11.534–12.249) | 8.821 нс |
| Виртуальный хук | 14.314 нс (13.525–14.592) | 11.328 нс |
| Backed-хук | 15.054 нс (14.566–15.344) | 12.068 нс |
| Виртуальный хук с арифметикой | 15.825 нс (15.324–16.108) | 12.839 нс |
__get |
29.880 нс (28.048–30.541) | 26.894 нс |
Backed-хук работает в 9,0 раз медленнее обычного свойства, на которое он внешне похож, и в 1,37 раза медленнее метода-геттера, который он призван заменить. Арифметика внутри третьего хука — умножение и сложение — отняла 1,5 нс из 12,8, так что почти вся стоимость хука заключается в самом факте того, что это хук.
__get медленнее хука более чем в два раза — именно в этом сравнении фича хуков
и должна была побеждать, и она побеждает.
Запуск того же бенчмарка со включенным OPcache и отключенным JIT воспроизвел каждую строку в пределах ее диапазона, что и ожидалось: OPcache меняет стоимость компиляции, а не стоимость диспетчеризации.
Хук, который вы не объявили, ничего не стоит
Ветка, которая всем этим управляет, находится в zend_std_read_property в
zend_object_handlers.c , и она выбирается по
смещению свойства, а не по чему-либо в месте вызова:
} else if (IS_HOOKED_PROPERTY_OFFSET(property_offset)) {
zend_function *get = prop_info->hooks[ZEND_PROPERTY_HOOK_GET];
if (!get) {
...
/* Cache the fact that this hook has trivial read. This only applies to
* BP_VAR_R and BP_VAR_IS fetches. */
ZEND_SET_PROPERTY_HOOK_SIMPLE_READ(cache_slot);
retval = OBJ_PROP(zobj, prop_info->offset);
Свойство с хуками, но без хука get , читает свой слот напрямую и записывает этот
факт в кэш-слот места вызова, поэтому последующие чтения полностью пропускают проверку.
Это предсказывает поведение, которого нет в таблице выше, и это предсказание
подтверждается:
| Чтение | Всего | Чистое |
|---|---|---|
| Обычное свойство | 4.373 нс (4.346–4.516) | 1.347 нс |
Свойство только с хуком set |
4.569 нс (4.533–4.718) | 1.543 нс |
Свойство с хуком get |
15.012 нс (14.559–15.491) | 11.986 нс |
Объявление хука set не стоит чтению ничего ощутимого — 0,196 нс, что
укладывается в разброс между прогонами для строки с обычным свойством. Накладные расходы приходятся
конкретно на хук get , и валидации при записи, ради которой и создается большинство хуков,
не приходится за это расплачиваться.
Запись стоит дороже чтения
На стороне set разрыв оказывается наибольшим:
| Запись | Всего | Чистое |
|---|---|---|
| Обычное свойство | 4.291 нс (4.261–4.340) | 1.305 нс |
| Метод-сеттер | 12.798 нс (12.540–13.268) | 9.812 нс |
Хук set |
23.892 нс (23.179–24.527) | 20.906 нс |
Хук set в 2,13 раза медленнее эквивалентного метода-сеттера и в 16 раз медленнее
обычного присваивания. Хук, выполняющий валидацию при записи — случай, ради которого эта фича и существует, —
стартует с этой отметки и прибавляет к ней всё, чего стоит сама валидация.
JIT делает только хуже
Очевидная надежда заключается в том, что JIT сократит этот разрыв. Но он делает обратное. Тот же бенчмарк под tracing JIT за вычетом базовой линии в 1,184 нс в этой конфигурации:
| Доступ | Без JIT | Tracing JIT |
|---|---|---|
| Обычное чтение | 1.337 нс | 0.388 нс |
| Метод-геттер | 8.821 нс | 3.281 нс |
| Чтение виртуального хука | 11.328 нс | 19.250 нс |
| Чтение backed-хука | 12.068 нс | 25.484 нс |
| Обычная запись | 1.305 нс | 2.396 нс |
| Метод-сеттер | 9.812 нс | 4.771 нс |
Хук set |
20.906 нс | 27.567 нс |
JIT сократил время обычного чтения до менее чем половины наносекунды, а вызов метода — в три раза. Чтение с хуком замедлилось. Что бы JIT ни делал с циклом, вызов хука в это не входит, и в итоге две формы различаются в 7,8 раза в сторону, противоположную той, на которую намекает синтаксис.
Function JIT дает ту же картину с меньшими цифрами — 0,818 нс для обычного, 4,769 для геттера, 26,091 для backed-хука, за вычетом базовой линии 2,941 нс в этом режиме, — так что это не причуда трассирующей компиляции. Оба режима оптимизируют обычный доступ и вызов метода, и ни один не добирается до хука.
Это поведение версии 8.5.10, а не дефект, который кто-то обещал сохранить навсегда. Стоит провести повторные замеры на вашей версии, прежде чем предпринимать действия, — фразу, которую я бы написал по поводу любых цифр JIT.
Сколько обращений требуется, чтобы это стало заметно
Само по себе всё это ничего не решает, потому что наносекунда есть наносекунда. Число, которое всё решает, — это количество обращений.
Без JIT разница между хуком и геттером составляет 3,247 нс, так что около 308 000 обращений за запрос дают одну миллисекунду разницы. С tracing JIT разница составляет 22,203 нс, и ту же миллисекунду дают уже 45 000 обращений.
Это не какие-то запредельные цифры для пути гидратации. Результирующий набор ORM из 5 000 сущностей с двенадцатью запрашиваемыми полями у каждой — это 60 000 чтений еще до того, как произойдет что-либо еще, а шаблон, который повторно обращается к той же view model, умножает это число снова.
Ориентир, к которому восходит большинство советов, — это бенчмарк 8.4 от Tideways, в котором сообщается «всего лишь о 9% разницы в производительности» между хуками и методами геттеров/сеттеров и делается вывод, что «производительность не должна вызывать беспокойства» при выборе между ними. В тексте статьи не указаны версия PHP, количество итераций или конфигурация JIT — код спрятан за ссылкой, — поэтому я не могу согласовать эти два результата, могу лишь сообщить свой и его условия. На этой сборке разрыв составляет 37% без JIT и от 5,5 до 7,8 раза с ним, а по сравнению с обычным свойством, которое в том обсуждении описывается как практически идентичное, он составляет 9,0 раз.
В чем обе точки зрения сходятся, так это в самой важной части: при обычном количестве обращений доступ к свойствам составляет малую долю запроса, и эта разница является шумом. Разногласие заключается в том, что считать «обычным», а это число берется из вашего собственного профилирования, а не из какого-либо из наших бенчмарков.
Что делать с аксессорами
Переходите ради API, а не ради скорости. Хук убирает метод из публичного интерфейса и позволяет свойству стать вычисляемым без поломки вызывающего кода, и это дает ценность, которую таблица оценить не может. Таблица говорит о том, что при этом вы не получаете стоимость обычного свойства — вы получаете чуть больше, чем стоил удаленный вами вызов метода, и значительно больше, если задействован JIT.
Не трогайте горячие пути, пока не посчитаете обращения. Место, где это проявляется, — это не класс с хуком, это цикл, который читает одно свойство с хуком на строку по большому набору результатов. Посчитайте обращения, умножьте на разницу из таблицы и сопоставьте с временем запроса, за которое вы боретесь.
Используйте виртуальные свойства там, где значение является производным и небольшим. При чтении они стоят столько же, сколько backed-хук, и экономят слот, поэтому объект-значение, который вычисляет, а не хранит, получается меньше и не медленнее того, который кэширует в свойство — а это как раз тот случай, где хуки дают то, чего геттер никогда не давал.
И если причиной создания хука является валидация, объявляйте только хук set . Чтение
останется на скорости обычного свойства, потому что движку нечего вызывать,
и дорогая половина функциональности окажется той половиной, которую вы не использовали.
Комментарии (0)
Пока нет комментариев — будьте первым.