Содержание
Введение
Пространства имён и функции стандартной библиотеки
Опкоды в PHP
Измерение производительности
Заключение
Введение
Несколько лет назад я прочитал статью «How to dump and inspect PHP OPCodes» в которой наконец увидел, что опкоды в PHP действительно существуют. И кроме того, мы, разработчики, которые пишем на PHP на эти опкоды можем влиять, тем самым оптимизируя производительность нашего кода. В статье так же рассказывалось о том, как применение бэкслэшей может ускорить выполнение программ. Я был под впечатлением...
Опкодами (см. Код операции) называется некий промежуточный код, который всё ещё понятен человеку и который выполняется некой исполняющей средой. В случае PHP этой средой является Zend Virtual Machine (она же Zend Engine).
Технология опкодов и виртуальной машины не является чем то уникальным для PHP. Подобный подход использует Java, где опкоды компилируются в их бинарное представление и выполняются Java VM. Исходные коды Java-программ хранятся в файлах с расширением .java
, а скомпилированные опкоды в файлах с расширением .class
(потому что в Java искодники хранятся только в виде классов). В PHP же исходные коды программ хранятся в файлах с расширением .php
. А вот опкоды не хранятся нигде, что вызвало к жизни многочисленные расширения, самым популярным из которых на сегодняшний день является OPcache.
Насколько я понимаю, подобный подход на самом деле используют примерно все интерпретируемые языки программирования и прочие другие, которые хотят обеспечить кроссплатформенность.
Оговорюсь, что в рамках этой статьи я буду называть функции типа explode глобальными, дефолтыми или стандартными подразумевая одно и тоже. Хоть у PHP и есть стандартная библиотека, которая скорее мертва, чем жива, как впрочем и весь PHP :)
Пространства имён и функции стандартной библиотеки
В PHP начиная с версии 5.3 появились пространства имён. И с тех пор у нас есть глобальное пространство имён куда входят все дефолтные (или стандартные) функции, классы, константы (и прочая) и пользовательское пространство имён, определяемое при помощи ключевого слова namespace
. Обычно, разработчики, не ставят обратный слэш перед вызовами функций, типа:
explode(DIRECTORY_SEPARATOR, '/path/to/file')
Обратите внимание, что константа DIRECTORY_SEPARATOR
тоже относится к глобальным константам.
В документации же написано, что если функция вызывается без указания пространства имён, то PHP будет вынужден разрешить (resolve) какую именно функцию нужно вызвать. Этот резолв занимает время и является, обычно, совершенно избыточной процедурой, которую можно было бы легко избежать.
Опкоды в PHP
Для того, чтобы убедиться, что резолв вообще существует нам следует обратиться к опкодам. Для начала давайте напишем немного такого кода в файле test1.php
:
<?php
namespace Foo;
function formatUserName(string $firstName, string $middleName, string $lastName) {
return ucfirst(strtolower($firstName)) . ' ' . ucfirst(substr($middleName, 0, 1)) . '. ' . ucfirst(strtolower($lastName));
}
echo formatUserName('john', 'DanIEl', 'Smith'), PHP_EOL;
Выполним его:
$ php src/test1.php
John D. Smith
И затем посмотрим на опкоды, их тут 44:
$ php -d opcache.enable_cli=1 -d opcache.opt_debug_level=0x20000 src/test1.php 2>&1 > /dev/null
$_main:
; (lines=11, args=0, vars=0, tmps=2)
; (after optimizer)
; /Users/zeleniy/Projects/phpbench/src/test1.php:1-11
0000 EXT_STMT
0001 INIT_FCALL 3 192 string("foo\\formatusername")
0002 SEND_VAL string("john") 1
0003 SEND_VAL string("DanIEl") 2
0004 SEND_VAL string("Smith") 3
0005 V0 = DO_FCALL
0006 ECHO V0
0007 EXT_STMT
0008 T0 = FETCH_CONSTANT (unqualified-in-namespace) string("Foo\\PHP_EOL")
0009 ECHO T0
0010 RETURN int(1)
Foo\formatUserName:
; (lines=33, args=3, vars=3, tmps=4)
; (after optimizer)
; /Users/zeleniy/Projects/phpbench/src/test1.php:5-8
0000 CV0($firstName) = RECV 1
0001 CV1($middleName) = RECV 2
0002 CV2($lastName) = RECV 3
0003 EXT_STMT
0004 INIT_NS_FCALL_BY_NAME 1 string("Foo\\ucfirst")
0005 INIT_NS_FCALL_BY_NAME 1 string("Foo\\strtolower")
0006 SEND_VAR_EX CV0($firstName) 1
0007 V3 = DO_FCALL
0008 SEND_VAR_NO_REF_EX V3 1
0009 V3 = DO_FCALL
0010 T4 = CONCAT V3 string(" ")
0011 INIT_NS_FCALL_BY_NAME 1 string("Foo\\ucfirst")
0012 JMP_FRAMELESS 32 string("foo\\substr") 0019
0013 INIT_NS_FCALL_BY_NAME 3 string("Foo\\substr")
0014 SEND_VAR_EX CV1($middleName) 1
0015 SEND_VAL_EX int(0) 2
0016 SEND_VAL_EX int(1) 3
0017 V3 = DO_FCALL
0018 JMP 0021
0019 V3 = FRAMELESS_ICALL_3(substr) CV1($middleName) int(0)
0020 OP_DATA int(1)
0021 SEND_VAR_NO_REF_EX V3 1
0022 V5 = DO_FCALL
0023 T3 = CONCAT T4 V5
0024 T4 = FAST_CONCAT T3 string(". ")
0025 INIT_NS_FCALL_BY_NAME 1 string("Foo\\ucfirst")
0026 INIT_NS_FCALL_BY_NAME 1 string("Foo\\strtolower")
0027 SEND_VAR_EX CV2($lastName) 1
0028 V3 = DO_FCALL
0029 SEND_VAR_NO_REF_EX V3 1
0030 V5 = DO_FCALL
0031 T3 = CONCAT T4 V5
0032 RETURN T3
LIVE RANGES:
4: 0011 - 0023 (tmp/var)
3: 0020 - 0021 (tmp/var)
4: 0025 - 0031 (tmp/var)
А теперь давайте везде, где можно в коде добавим обратную косую черту и сохраним всё это в test2.php
:
<?php
namespace Foo;
function formatUserName(string $firstName, string $middleName, string $lastName) {
return \ucfirst(\strtolower($firstName)) . ' ' . \ucfirst(\substr($middleName, 0, 1)) . '. ' . \ucfirst(\strtolower($lastName));
}
echo formatUserName('john', 'DanIEl', 'Smith'), \PHP_EOL;
И тоже сгенерим опкодов. Я не буду распечатывать ещё одно полотно неведомых слов, просто скажу, что их получается 36:
$ php -d opcache.enable_cli=1 -d opcache.opt_debug_level=0x20000 src/test2.php
Вот так выглядит diff:

Не помню, где и когда вычитал, но фраза мне тогда понравилась: «самый быстрый код - это код, которого нет». Прекрасно, но что с того? Как это можно выразить/посчитать выгоду? Давайте заюзаем PHPBench о котором я писал в предыдущей статье.
Измерение производительности
Штош, давайте попробуем измерить это так: напишем класс, который будет форматировать имя пользователя двумя способами:
<?php
namespace My\App;
class Backslash {
public static function formatUserName1(string $firstName, string $middleName, string $lastName) {
return ucfirst(strtolower($firstName)) . ' ' . ucfirst(substr($middleName, 0, 1)) . '. ' . ucfirst(strtolower($lastName));
}
public static function formatUserName2(string $firstName, string $middleName, string $lastName) {
return \ucfirst(\strtolower($firstName)) . ' ' . \ucfirst(\substr($middleName, 0, 1)) . '. ' . \ucfirst(\strtolower($lastName));
}
}
А вот наш benchmark-класс:
<?php
namespace My\App\Tests;
use My\App\Backslash;
/**
* @Revs(1000)
* @Iterations(5)
*/
class BackslashBench {
public function benchFormatUserName1() {
Backslash::formatUserName1('john', 'DanIEl', 'Smith');
}
public function benchFormatUserName2() {
Backslash::formatUserName2('john', 'DanIEl', 'Smith');
}
}
Запускаем:
$ ./vendor/bin/phpbench run ./tests/Benchmark/BackslashBench.php --retry-threshold=3
PHPBench (1.4.1) running benchmarks... #standwithukraine
with configuration file: /Users/zeleniy/Projects/phpbench/phpbench.json
with PHP version 8.4.8, xdebug ❌, opcache ❌
\My\App\Tests\BackslashBench
benchFormatUserName1....................R1 I1 - Mo0.576μs (±1.33%)
benchFormatUserName2....................R1 I0 - Mo0.536μs (±1.20%)
Subjects: 2, Assertions: 0, Failures: 0, Errors: 0
Что же сие означает? Без бэкслешей код выполнился за 0.576μs ± 1.33%
, а с бэкслешами за 0.536μs ± 1.20%
. Т.к. разброс 1.33
и 1.20
примерно равны, то принебрежём им. Итого получается 0.576
и 0.536
. С бэкслешами на 7%
быстрее. Но в секундах 0.576
микросекунд - это 0.000000576
секунд. Господи, это сколько? Плакать уже можно?
Не, ну давайте хоть что-то выжмем из этого. С другой стороны разница между 0.576
и 0.536
состовляет не только 0.04
микросекунды, но и ажно 40
наносекунд. Есть такая знаменитая мантра Latency Numbers Every Programmer Should Know и согласно ей получается, что 40
наносекунд - это несколько раз обратиться в ЦПушный кэш уровня L2. Кажется для нас это выхлоп около нуля. Но давайте подумаем, сколько вызовов стандартных функций производит ваш любимый Laravel, Symfony или какой-либо другой фреймворк. Допустим обработка каждого HTTP-запроса делает 100
вызовов. Тогда 40
наносекунд превращается в 4000
наносекунд, а это 4
микросекунды. А если не 100
, а 1000
? Тогда 40
микросекунд. Давайте будем считать, что HTTP-запрос при хорошем раскладе обрабатывается за 100
милисекунд т.е. за 0.1
секунды (наконец-то хоть одно понятное значение). 40
микросекунд - это 0,04
милисекунды. Нет, ничего нам тут не выжать.
Решение "проблемы"
Как выяснилось, из-за микроскопической разницы кажется, что оптимизировать тут нечего и никакой проблемы с отсутствием или наличием в коде обратных слэшей нет. Но мы не отчаиваемся. Мне кажется, что если вдруг вы можете написать меньше кода или меньше опкода, то этим следует воспользоваться. На всё это тратится электроэнергия, жгётся нефть, каменный уголь и т.п. вещества. Так зачем коптить небо зря?
Конечно, вручную дописывать \
к каждой функции никто не захочет, ради такого мизерного выигрыша. Но у нас есть:
PHP-CS-Fixer с правилом native_function_invocation, которое автоматически добавит обратный слэш ко всем. Обратите внимание, что правило помечено как
risky
.PHP_CodeSniffer с плагином soderlind/coding-standard. Но тут проблема в том, что и сам инструмент устарел и плагин не устанавливается, но его можно реанимировать если вдруг вы у себя исторически используете PHP_CodeSniffer.
Так что лучшим варинатом здесь, возможно, будет просто взять за правило добавлять обратную косую черту ручками при написании кода.
Заключение
Перед написанием статьи я уже как-то делал измерения и видел разницу между двумя способами. Но ни разу обратил внимания на то, какой микроскопический выигрыш можно с этого получить. И я уже хотел бросить и ничего не писать, но потом подумал, что я почти что день потратил на статью, так что пусть уж будет, хоть и без яркого happy end'а.
Всё вышеописанное относится к так называемым микрооптимизациям. И данная статья не является ярким примером того, как можно оптимизировать PHP'ый код продуктивно. Однако, есть и яркие, например, доклад Дмитрия Кириллова «Неочевидные оптимизации опкодов в PHP», где автор ускоряет свой код многократно, обгоняя Си. Если тема в целом показалась интересной, то вам туда. Удачи.
Комментарии (14)
fossfusion
05.08.2025 18:47Статью не читал, ибо неохота. Слэши перед функциями не ставлю, максимум укажу через use.
bolk
05.08.2025 18:47Интересно, зачем люди вообще пишут substr($var, 0, 1), если можно просто $var[0]?
SavinMikhail
05.08.2025 18:47mb_substr надо
aleksandr-s-zelenin Автор
05.08.2025 18:47Не надо. Надо, только если у вас символы используемые в строках выходят за границы ASCII.
arokettu
05.08.2025 18:47Для CodeSniffer есть более современный slevomat/coding-standard. У самого CodeSniffer сменился мейнтейнер и разработка пошла более быстро.
В статье я ожидал функции, которые компилятор оптимизирует в опкоды, вот статья например, устаревшая немного потому что там нет sprintf, без этого всё это экономия на спичках
FanatPHP
05.08.2025 18:47Интересно, что практически одновременно с этой статьёй, с разницей в одну минуту, на Реддите был опубликован пост ровно на ту же тему, но с полностью противоположными выводами!
И приводится условие, при котором разницу (весьма значительную), можно увидеть, и которое не выполнено в приведённом здесь тесте:opcache ❌
Но что ещё интереснее - если здешний тест выполнить при включённом опкеше, то разницы всё равно нет...
FanatPHP
05.08.2025 18:47В общем, там выяснилось, что как и всегда, порядок цифр пропорционален кривизне рук тестировщика. Магия опкеша заключалась в том, что он разумно кешировал сразу результат(!) вызова функции, благодаря константному аргументу. А у исправленного теста результат оказался гораздо скромнее, вплоть до неразличимости. Так что ближе к истине оказался тезис в этой статье, а не в той.
Единственно что, для определённого списка функций подстановка бэкслеша таки позволяет заметить разницу невооружённым глазом. К примеру, замена тела функции на
return strlen($firstName) + strlen($middleName) + strlen($lastName);
Даёт мне такой результат
benchFormatUserName1....................R3 I3 - Mo0.055μs (±1.84%) benchFormatUserName2....................R5 I4 - Mo0.036μs (±0.00%)
sergeytolkachyov
05.08.2025 18:47Единственно что, для определённого списка функций подстановка бэкслеша таки позволяет заметить разницу невооружённым глазом.
Спасибо за ссылку. По ней в конце списка функций указано, что он актуален для PHP 8.1. Ссылка на GitHub в качестве источника ведёт в файл
zend_compile.c
, который уже успел измениться с тех пор, как и список функций PHP, для которых используется специальная обработка. Все эти функции перечислены в zend_try_compile_special_func_ex(). Просто оставлю ссылку посвежее здесь.
aleksandr-s-zelenin Автор
05.08.2025 18:47Спасибо за ссылку, почитаю, может и статью дополню, если будет чем.
SerafimArts
05.08.2025 18:47Давайте не забывать, что:
Проверяем 4 функции, получая +7% с нифига
На реальном проекте 100500 вызовов функций, но при сохранении соотношения (в 7%) получим уже другой результат (ну например 0.9с вместо 1с)
В случае же циклов - разница может достигать до 20%+ (на практике лексер так ускорил)
В php есть и другие оптимизации (интринсики), например как в случае strlen выше или проверки на тип (is_null, is_int, etc) и прочие.
В случае
strlen
, а особенно если значение константное -\strlen($immutableString)
, то в коде не будет никаких вызовов функций, а просто прямая замена сразу на вычисленный заранее результат.Если бекслеш отсутсвует, то PHP не может определить является ли эта функция "глобальной" или пользовательской, а следовательно все оптимизации игнорируются.
Что касается других интринсиков, то разница в исполняемом коде может разнИца, например, в 40 раз.
P.S. Выше в замерах у @FanatPHP отличия в скорости одного из интринсиков почти в ~2 раза, но чисто математически и если отбросить всякие L1/2/3 кеши, предсказания и прочее, как в примере кода ниже, она может отличаться в несколько десятков.
Пример очень простого кода
Код
<?php namespace Example; $a = 23; \is_null($a);
Вывод
JIT$/home/test.php: ; (/home/test.php) call 0x55b6ce3cc7e0 mov $EG(exception), %rax cmp $0x0, (%rax) jnz JIT$$exception_handler jmp ZEND_RETURN_SPEC_CONST_LABEL
А это без бекслеша
Код
<?php namespace Example; $a = 23; is_null($a); // Просто убрали слеш
Вывод
JIT$/home/test.php: ; (/home/test.php) call 0x556ad05cc7e0 mov $EG(exception), %rax cmp $0x0, (%rax) jnz JIT$$exception_handler mov 0x40(%r14), %rdx mov 0x8(%rdx), %rax test %rax, %rax jz .L14 .L1: test $0x1, (%rax) mov $0x60, %rdi jnz .L2 mov $0x1, %edx cmp 0x20(%rax), %edx cmova 0x20(%rax), %edx sub 0x48(%rax), %edx sub 0x40(%rax), %edx shl $0x4, %edx movsxd %edx, %rdx sub %rdx, %rdi .L2: mov $EG(vm_stack_top), %r15 mov (%r15), %r15 mov $EG(vm_stack_end), %rdx mov (%rdx), %rdx sub %r15, %rdx cmp %rdi, %rdx jb .L15 mov $EG(vm_stack_top), %rdx add %rdi, (%rdx) mov $0x0, 0x28(%r15) mov %rax, 0x18(%r15) .L3: mov $0x0, 0x20(%r15) mov $0x1, 0x2c(%r15) mov $0x0, 0x30(%r15) mov %r15, 0x8(%r14) mov 0x18(%r15), %rax test $0x300, (%rax) jnz .L16 cmp $0x0, 0x58(%r14) jz .L20 lea 0x50(%r14), %rdi cmp $0xa, 0x8(%rdi) jnz .L4 mov (%rdi), %rdi add $0x8, %rdi .L4: mov (%rdi), %rdx mov %rdx, 0x50(%r15) mov 0x8(%rdi), %eax mov %eax, 0x58(%r15) test %ah, %ah jz .L5 add $0x1, (%rdx) .L5: mov $0x556ac08d4770, %rax mov %rax, (%r14) mov $0x0, 0x8(%r14) mov %r14, 0x30(%r15) mov 0x18(%r15), %rax cmp $0x2, (%rax) jnz .L10 mov $0x0, 0x8(%r15) mov $0x0, 0x10(%r15) mov 0x38(%rax), %rdx test $0x1, %rdx jz .L6 mov $CG(map_ptr_base), %rcx add (%rcx), %rdx mov (%rdx), %rdx .L6: mov %rdx, 0x40(%r15) mov $EG(current_execute_data), %rcx mov %r15, (%rcx) mov %r15, %r14 mov 0x50(%rax), %r15 mov 0x20(%rax), %edx mov 0x2c(%r14), %ecx cmp %edx, %ecx jg .L21 test $0x100, 0x4(%rax) jnz .L7 mov %ecx, %edx shl $0x5, %rdx add %rdx, %r15 .L7: mov 0x48(%rax), %edx sub %ecx, %edx jle .L9 shl $0x4, %rcx lea 0x50(%r14,%rcx), %rcx .L8: mov $0x0, 0x8(%rcx) sub $0x1, %edx lea 0x10(%rcx), %rcx jnz .L8 .L9: jmp (%r15) .L10: test $0x800, 0x4(%rax) jnz .L22 .L11: mov $EG(current_execute_data), %rcx mov %r15, (%rcx) mov %rsp, %rsi mov $0x1, 0x8(%rsi) mov %r15, %rdi call 0x48(%rax) mov $EG(current_execute_data), %rax mov %r14, (%rax) mov %r15, %rdi mov $zend_jit_vm_stack_free_args_helper, %rax call *%rax test $0x4, 0x2a(%r15) jnz .L23 mov $EG(vm_stack_top), %rax mov %r15, (%rax) .L12: test $0x1, 0x9(%rsp) jnz .L24 .L13: mov $EG(exception), %rax cmp $0x0, (%rax) jnz JIT$$icall_throw mov $EG(vm_interrupt), %rax cmp $0x0, (%rax) jnz .L27 mov $0x556ac08d4790, %r15 jmp .ENTRY1 .ENTRY1: jmp ZEND_RETURN_SPEC_CONST_LABEL .L14: mov $0x556ac08d46d0, %rdi lea 0x8(%rdx), %rsi mov $zend_jit_find_ns_func_helper, %rax call *%rax test %rax, %rax jnz .L1 mov %r15, (%r14) jmp JIT$$undefined_function .L15: mov %rax, %rsi mov $0x556ac08d4730, %rax mov %rax, (%r14) mov $zend_jit_extend_stack_helper, %rax call *%rax mov %rax, %r15 jmp .L3 .L16: cmp $0x0, 0x58(%r14) jnz .L17 mov $0x1, 0x58(%r14) jmp .L18 .L17: cmp $0xa, 0x58(%r14) jnz .L18 mov 0x50(%r14), %rcx add $0x1, (%rcx) mov %rcx, 0x50(%r15) mov $0x10a, 0x58(%r15) jmp .L19 .L18: call _emalloc_32 mov $0x2, (%rax) mov $0x1a, 0x4(%rax) mov $0x0, 0x18(%rax) mov 0x50(%r14), %rdx mov %rdx, 0x8(%rax) mov 0x58(%r14), %ecx mov %ecx, 0x10(%rax) mov %rax, 0x50(%r14) mov $0x10a, 0x58(%r14) mov %rax, 0x50(%r15) mov $0x10a, 0x58(%r15) .L19: jmp .L5 .L20: mov $0x556ac08d4750, %rax mov %rax, (%r14) mov $0x50, %edi mov $zend_jit_undefined_op_helper, %rax call *%rax mov $0x1, 0x58(%r15) test %rax, %rax jz JIT$$exception_handler jmp .L5 .L21: mov $zend_jit_copy_extra_args_helper, %rax call *%rax mov 0x18(%r14), %rax mov 0x2c(%r14), %ecx jmp .L7 .L22: mov $zend_jit_deprecated_helper, %rax call *%rax test %al, %al mov 0x18(%r15), %rax jnz .L11 jmp JIT$$exception_handler .L23: mov %r15, %rdi mov $zend_jit_free_call_frame, %rax call *%rax jmp .L12 .L24: mov (%rsp), %rdi sub $0x1, (%rdi) jnz .L25 mov $0x556ac08d4770, %rax mov %rax, (%r14) call rc_dtor_func jmp .L13 .L25: cmp $0xa, 0x8(%rsp) jnz .L26 test $0x2, 0x11(%rdi) jz .L13 mov 0x8(%rdi), %rdi .L26: test $0xfffffc10, 0x4(%rdi) jnz .L13 call gc_possible_root jmp .L13 .L27: mov $0x556ac08d4790, %r15 jmp JIT$$interrupt_handler
ForwardDev
05.08.2025 18:47Все супер! а я бы еще добавил к статьи просто упоминание, когда это не обязательно, чтоб разжевать уже до конца:)
- В глобальном пространстве имен: Если файл не содержитnamespace ...;
в начале, то все функции и константы по умолчанию ищутся в глобальном пространстве.strlen()
и\strlen()
будут работать одинаково.
- Для функций, импортированных черезuse function
(PHP 5.6+):namespace MyApp; use function \json_encode; function doSomething() { json_encode(...); // Вызов импортированной глобальной функции без \ }
namespace MyApp\Utils; function json_encode($data) { // ... кастомная реализация (плохая практика для такого имени, но технически возможно и даже попадался на такое) ... } function process() { $data = ['a' => 1]; // Вызов КАСТОМНОЙ функции json_encode из текущего пространства MyApp\Utils: $resultCustom = json_encode($data); // Вызов ВСТРОЕННОЙ PHP функции json_encode (с помощью `\`): $resultBuiltin = \json_encode($data); // ... разные результаты ... }
А самое главное Статические анализаторы (Psalm, PHPStan) - могут предупредить о потенциальных конфликтах имен или неоднозначных вызовах.
gro
Когда пространства имён только появились, писал глобальные функции со слэшами. Через какое-то время перестал. Ещё через какое-то время открыл тот свой код, который был со слэшами и блеванул на монитор. Больше не использовал их. Статью же не читал, извините.