company_banner

Улучшение производительности PHP 7

https://blog.blackfire.io/
  • Перевод


PHP — это программное обеспечение, написанное на языке С. Кодовая база PHP содержит около 800 тысяч строк кода и в седьмой версии была существенно переработана.

В этой статье мы рассмотрим, что изменилось в движке Zend седьмой версии по сравнению с пятой, а также разберёмся, как можно эффективно использовать внутренние оптимизации. В качестве исходной точки возьмём PHP 5.6. Зачастую многое зависит от того, как те или иные вещи написаны и представлены движку. При написании критически важного кода необходимо уделять внимание его производительности. Изменив несколько мелочей, вы можете сильно ускорить работу движка, зачастую без ущерба для других аспектов вроде читабельности кода или управления отладкой. Свои рассуждения я докажу с помощью профилировщика Blackfire.

Если вы хотите повысить производительность PHP, мигрировав на седьмую версию, то вам нужно будет:

  1. Мигрировать кодовую базу без внесения изменений в неё (или просто преобразовать её в код, совместимый с PHP 7). Этого будет достаточно для повышения скорости работы.
  2. Воспользоваться нижеприведёнными советами, чтобы понять, как изменились разные части кода виртуальной машины PHP и как их использовать для ещё большего увеличения производительности.

Упакованные массивы


Упакованные массивы — первая из замечательных оптимизаций в PHP 7. Они потребляют меньше памяти и во многих случаях работают гораздо быстрее традиционных массивов. Упакованные массивы должны удовлетворять критериям:

  • Ключи — только целочисленные значения;
  • Ключи вставляются в массив только по возрастанию.

Пример 1:

$a = ['foo', 'bar', 'baz'];

Пример 2:

$a = [12 => 'baz', 42 => 'bar', 67 => [] ];

Эти массивы внутренне очень хорошо оптимизированы. Но очевидно, что на трёхъячеечном массиве вы не почувствуете разницы по сравнению с PHP 5.

К примеру, во фреймворке Symfony упакованные массивы существуют только в генераторе карты классов (class map generator), создающем код наподобие:

array (
  0 => 'Symfony\\Bundle\\FrameworkBundle\\EventListener\\SessionListener',
  1 => 'Symfony\\Component\\HttpFoundation\\Session\\Storage\\NativeSessionStorage',
  2 => 'Symfony\\Component\\HttpFoundation\\Session\\Storage\\PhpBridgeSessionStorage',
  3 => 'Symfony\\Component\\HttpFoundation\\Session\\Storage\\Handler\\NativeFileSessionHandler',
  4 => 'Symfony\\Component\\HttpFoundation\\Session\\Storage\\Proxy\\AbstractProxy',
  5 => 'Symfony\\Component\\HttpFoundation\\Session\\Storage\\Proxy\\SessionHandlerProxy',
  6 => 'Symfony\\Component\\HttpFoundation\\Session\\Session',
  /* ... */

Если выполнить этот код на PHP 5 и PHP 7 и проанализировать с помощью Blackfire, то получим:



Как видите, общая продолжительность компилирования и выполнения этого объявления массива уменьшилась примерно на 72 %. В PHP 7 такие массивы становятся похожими на NOP’ы, а в PHP 5 заметное время тратится на компилирование и на загрузку в ходе runtime.

Давайте возьмём массив побольше, на 10 000 ячеек:

for ($i=0; $i<10000; $i++) {
	$a[] = $i;
}

То же самое сравнение:



Продолжительность использования процессора снизилась примерно в 10 раз, а потребление памяти уменьшилось с 3 Мб до 0,5 Мб.

Команда разработчиков PHP много сил потратила на оптимизацию массивов, потому что массивы — одна из главных структур, на которой базируется язык (вторая по значению структура — объекты, внутренне реализованные по той же модели).

Потребление памяти массивами в PHP 7 гораздо ниже, чем в PHP 5. А при использовании упакованных массивов экономия ещё выше.

Не забывайте:

  • Если вам нужен список, то не используйте строки в ключах (это не даст применять оптимизацию упакованных массивов);
  • Если ключи в списке только целочисленные, постарайтесь распределить их по возрастанию (в противном случае оптимизация тоже не сработает).

Такие списки можно использовать в разных частях вашего приложения: например для перечисления (как карта классов в Symfony) или для извлечения результатов из базы в определённом порядке и с числовыми данными в колонке. Это часто бывает нужно в веб-приложениях ($pdo->query("SELECT * FROM table LIMIT 10000")->fetchAll(PDO::FETCH_NUM)).

Целочисленные и значения с плавающей запятой в PHP 7 бесплатны


В PHP 7 совершенно иной способ размещения переменных в памяти. Вместо кучи они теперь хранятся в пулах стековой памяти. У этого есть побочный эффект: вы можете бесплатно повторно использовать контейнеры переменных (variable containers), память не выделяется. В PHP 5 такое невозможно, там для каждого создания/присвоения переменной нужно выделить немного памяти (что ухудшает производительность).

Взгляните на этот код:

for ($i=0; $i<10000; $i++) {
	$$i = 'foo';
}

Здесь создаётся 10 000 переменных с именами от $0 до $10000 с ‘foo’ в качестве строкового значения. Конечно, при первичном создании контейнера переменной (как в нашем примере) потребляется какая-то память. Но что будет, если теперь мы повторно используем эти переменные для хранения целочисленных значений?

/* ... продолжение ... */
for ($i=0; $i<10000; $i++) {
$$i = 42;
}

Здесь мы просто повторно использовали уже размещённые в памяти переменные. В PHP 5 для этого потребовалось бы заново выделить память для всех 10 000 контейнеров, а PHP 7 просто берёт готовые и кладёт в них число 42, что никак не влияет на память. В седьмой версии использование целочисленных и значений с плавающей запятой совершенно бесплатно: память нужного размера уже выделена для самих контейнеров переменных.

Посмотрим, что скажет Blackfire:



В PHP 7 отказ от дополнительного обращения к памяти при изменении переменной приводит к экономии процессорных циклов во втором цикле for. В результате использование процессора уменьшается на 50 %. А присваивание целочисленного значения ещё больше снижает потребление памяти по сравнению с PHP 5. В пятой версии на размещения в памяти 10 000 целочисленных значений тратится 80 000 байтов (на платформе LP64), а также куча дополнительной памяти на аллокатор. В PHP 7 этих расходов нет.

Оптимизация с помощью encapsed-строк


Encapsed-строки — это значения, в которых выполняется внутреннее сканирование на наличие переменных. Они объявляются с помощью двойных кавычек, или Heredoc-синтаксиса. Алгоритм анализирует значение и отделяет переменные от строк. Например:

$a = 'foo';
$b = 'bar';
$c = "Мне нравится $a и $b";

При анализе строки $c движок должен получить строку: «Мне нравится foo и bar». Этот процесс в PHP 7 также был оптимизирован.

Вот что делает PHP 5:

  • Выделяет буфер для «Мне нравится»;
  • Выделяет буфер для «Мне нравится foo»;
  • Добавляет (копирует в памяти) в последний буфер «Мне нравится» и “foo”, возвращает его временное содержимое;
  • Выделяет новый буфер для «Мне нравится foo и»;
  • Добавляет (копирует в памяти) « Мне нравится foo» и «и» в этот последний буфер и возвращает его временное содержимое;
  • Выделяет новый буфер для «Мне нравится foo и bar»;
  • Добавляет (копирует в памяти) «Мне нравится foo и» и “bar” в этот последний буфер и возвращает его содержимое;
  • Освобождает все промежуточные использованные буферы;
  • Возвращает значение последнего буфера.

Много работы, верно? Такой алгоритм в PHP 5 аналогичен тому, что используется при работе со строками в С. Но дело в том, что он плохо масштабируется. Этот алгоритм не оптимален при работе с очень длинными encapsed-строками, включающими в себя большое количество переменных. А ведь encapsed-строки часто используются в PHP.

В PHP 7 всё работает иначе:

  • Создаётся стек;
  • В него помещаются все элементы, которые нужно добавить;
  • Когда алгоритм доходит до конца encapsed-строки, единовременно выделяется память необходимого размера, в которую перемещаются все части данных, в нужные места.

Телодвижения с памятью остались, однако никакие промежуточные буферы, как в PHP 5, уже не используются. В PHP 7 лишь один раз выделяется память для финальной строки, вне зависимости от количества частей строки и переменных.

Код и результат:

$w = md5(rand());
$x = md5(rand());
$y = md5(rand());
$z = md5(rand());

$a = str_repeat('a', 1024);
$b = str_repeat('a', 1024);

for ($i=0; $i<1000; $i++) {
$$i = "В этой строке много $a, а также много $b, выглядит рандомно: $w - $x - $y - $z";
}

Мы создали 1000 encapsed-строк, в которых находим статичные строковые части и шесть переменных, две из которых весят по 1 Кб.



Как видите, в PHP 7 использование процессора снизилось в 10 раз по сравнению с PHP 5. Обратите внимание, что с помощью Blackfire Probe API (в примере не показано) мы профилировали всего лишь цикл, а не весь скрипт.

В PHP 7:

$bar = 'bar';
/* используйте это */
$a = "foo и $bar";
/* вместо этого */
$a = "foo и " . $bar;

Операция конкатенации не оптимизирована. Если вы используете конкатенацию строк, то в результате будете делать те же странные вещи, что и в PHP 5. А encapsed-строки помогут воспользоваться преимуществами нового алгоритма анализа, выполняющего оценочную конкатенацию (evaluated concatenation) с помощью структуры “Rope“.

Reference mismatch


Reference mismatch возникает тогда, когда в качестве аргумента, передаваемого по ссылке (passed-by-ref), вы передаёте функции нессылочную переменную (non-ref variable), или наоборот. Например, так:

function foo(&$arg) { }
$var = 'str';
foo($var);

Вы знаете, какой в этом случае творится кошмар в движке PHP 5? Если возникает несовпадение ссылки, движку приходится дуплицировать переменную, прежде чем передавать её функции в качестве аргумента. Если переменная содержит что-то большое, вроде массива на несколько тысяч записей, то копирование займёт много времени.

Причина в том, как в PHP 5 построена работа с переменными и ссылками. Переходя к телу функции, движок ещё не знает, измените ли вы значение аргумента. Если измените, то передача аргумента по ссылке должна привести к отражению вовне сделанного изменения при передаче ссылочной переменной (reference variable).

А если вы не измените значение аргумента (как в нашем примере)? Тогда движок должен создать ссылку из нессылочной (non-reference) переменной, которую вы передаёте в ходе вызова функции. В PHP 5 движок полностью дуплицирует содержимое переменной (при очень небольшом количестве указателей много раз вызывая memcpy(), что приводит ко множеству медленных обращений к памяти).

Когда в PHP 7 движок хочет создать ссылку из нессылочной переменной, он просто оборачивает её в заново созданную бывшую ссылку (former). И никаких копирований в память. Всё дело в том, что в PHP 7 работа с переменными построена совсем иначе, а ссылки существенно переработаны.

Взгляните на этот код:

function bar(&$a) { $f = $a; }
$var = range(1,1024);
for ($i=0; $i<1000; $i++) {
bar($var);
}

Здесь двойное несовпадение. При вызове bar() вы заставляете движок создавать ссылку из $var к $a, как говорит &$a сигнатура. Поскольку $a теперь является частью ссылочного набора (reference set) ($var-$a) в теле bar(), вы можете влиять на него с помощью значения $f: это другое несовпадение. Ссылка не влияет на $f, так что $a-$f-$var можно не соединять друг с другом. Однако $var-$a соединены в одну часть, а $f находится в одиночестве во второй части, пока вы не заставите движок создать копии. В PHP 7 можно довольно легко создавать переменные из ссылок и превращать их в ссылки, только в результате может происходить копирование при записи (copy-on-write).



Помните, что если вы не полностью разобрались в работе ссылок в PHP (а этим могут похвастаться не так уж много человек), то лучше вообще их не использовать.

Мы видели, что PHP 7 снова позволяет экономить немало ресурсов по сравнению с PHP 5. Но в наших примерах мы не касались случаев копирования при записи. Здесь всё было бы иначе, и PHP пришлось бы делать дамп памяти под нашу переменную. Однако использование PHP 7 облегчает ситуации, когда несовпадения нарочно могут не выполняться, например если вызвать count($array), когда частью ссылки является $array. В таком случае в PHP 7 не будет дополнительных расходов, зато в PHP 5 процессор раскалится (при достаточно большом массиве, например при сборе данных из SQL-запросов).

Неизменяемые массивы


Концепция неизменяемых массивов появилась в PHP 7, они являются частью расширения OPCache. Неизменяемым называется массив, который заполнен неизменяемыми элементами, чьи значения не требуют вычислений и становятся известны во время компилирования: строковые значения, целочисленные, значения с плавающей запятой или содержащие всё перечисленное массивы. Короче, никаких динамических элементов и переменных:

$ar = [ 'foo', 42, 'bar', [1, 2, 3], 9.87 ];

Неизменяемые массивы были оптимизированы в PHP 7. В PHP 5 не делается разницы между массивами, содержащими динамические элементы ($vars), и статичными при компилировании. В PHP 7 же неизменяемые массивы не копируются, не дуплицируются, а остаются доступными только для чтения.

Пример:

for ($i = 0; $i < 1000; $i++) {
$var[] = [
  0 => 'Symfony\\Bundle\\FrameworkBundle\\EventListener\\SessionListener',
  1 => 'Symfony\\Component\\HttpFoundation\\Session\\Storage\\NativeSessionStorage',
  /* ... go to many immutable items here  */
  ];
}

Этот код тысячу раз создаёт один и тот же массив (обрезанный, его можно представить в виде массива из сотни или тысячи ячеек).

В PHP 5 такой массив дуплицируется в памяти тысячу раз. Если он весит несколько сотен килобайт или даже мегабайты, то при тысячекратном дублировании мы займём большой объём памяти.

В PHP 7 OPCache помечает эти массивы как неизменяемые. Массив создаётся единожды, а где необходимо, используется указатель на его память, что приводит к огромной экономии памяти, особенно если массив велик, как в приведённом примере (взято из фреймворка Symfony 3).
Посмотрим, как меняется производительность:



Снова огромная разница между PHP 5 и PHP 7. PHP 5 нужно создавать массив 1000 раз, что занимает 27 Мб памяти. В PHP 7 с OPCache задействуется всего 36 Кб!

Если вам удобно использовать неизменяемые массивы, то не стесняйтесь. Только не надо нарушать неизменяемость, выдавая подобный код:

$a = 'value';

/* Не делайте так */
$ar = ['foo', 'bar', 42, $a];

/* Лучше так: */
$ar = ['foo', 'bar', 42, 'value'];

Прочие соображения


Мы рассмотрели несколько внутренних хитростей, объясняющих, почему PHP 7 работает куда быстрее PHP 5. Но для многих рабочих нагрузок это микрооптимизации. Чтобы получить от них заметный эффект, нужно использовать огромные объёмы данных или многочисленные циклы. Подобное чаще всего случается, когда в фоновом режиме вместо обработки HTTP-запросов запускаются рабочие PHP-процессы (workers).

В таких случаях вы можете очень сильно уменьшить потребление ресурсов (процессора и памяти), всего лишь по-другому написав код.

Но не верьте своим предчувствиям относительно возможных оптимизаций производительности. Не надо слепо патчить код, используйте профилировщики, чтобы подтвердить или опровергнуть свои гипотезы и проверить реальную производительность кода.
Mail.Ru Group 792,29
Строим Интернет
Поделиться публикацией
Похожие публикации
Комментарии 30
  • 0
    Интересно

    Вопрос в тему — php 7.1 (процесс php-cgi.exe) падает с ошибкой в opcache на Win Server 2016

    Как правильно зарепортить баг может кто подсказать?
      • 0
        Это понятно. А вот как дамп (?) снять — непонятно… Они тоже молодцы — идите на freenode и спрашивайте. Вот только канал #php только по инвайтам…
  • 0
    Операция конкатенации не оптимизирована

    А почему, кто-нибудь в курсе? Вроде принцип-то похожий.

    • 0
      Если вы используете конкатенацию строк, то в результате будете делать те же странные вещи, что и в PHP 5.

      Выделяет буфер для «Мне нравится»;

      и далее по тексту.
      Хотя если конкатенация небольшая — то думаю заморачиваться даже не стоит.

      • 0

        Я это прочитал. Мой вопрос был про то, по каким техническим причинам при конкатенации осталось то же поведение. Она используется чаще encapsed-строк, при этом результат получается аналогичный.

        • –1

          Я алгоритма не знаю, рассуждаю чисто логически, и не претендую на истину.
          Допустим, есть такая конкатенация.


          $str = $substr_1 . ' подстрока 2 ' . func_foo() . $substr_4;

          Я так подозреваю, что с точки зрения работы с памятью это будет некая последовательность операций.


          $buf_str_1 = $substr_1 . ' подстрока 2 ';
          $buf_func_foo = func_foo(); // ключевой момент - вычисляемое выражение
          $buf_str_2 = $buf_str_1 . $buf_func_foo;
          $buf_str_3 = $buf_str_2 . $sub_str_4;
          $str = $buf_str_3;
          unset($buf_str_1);
          unset($buf_func_foo);
          unset($buf_str_2);
          unset($buf_str_3);

          Скорее всего в деталях ошибся. Тут лучше обратиться к первоисточникам (там более что я php7 пока даже и не юзал). Может кто поправит.
          Дальше хотел написать, что конкатенацию нельзя оптимизировать скорее всего из-за возможного наличия вычисляемых выражений (в моём примере вызов функции func_foo). Но заметил, что DarthLegiON уже это сделал ниже. Что лишь подтверждает мою мысль (которую я изначально не озвучил, да, признаю).

          • +2

            Все чуть проще. Разница в способе разбора. Возьмем две строки:


            $str1 = "I like big $foo and I can not $bar";
            $str2 = 'I like big ' . $foo . ' and I can not ' . $bar;

            Результатом разбора будет примерно следующее:


            T_VARIABLE = "T_ENCAPSED_AND_WHITESPACE T_VARIABLE T_ENCAPSED_AND_WHITESPACE T_VARIABLE";
            
            T_VARIABLE = T_CONSTANT_ENCAPSED_STRING . T_VARIABLE . T_CONSTANT_ENCAPSED_STRING . T_VARIABLE;

            то есть в первом случае у нас есть как минимум токен ", который можно спокойно учитывать при построении ast. То есть по итогу все это выражение будет заменено опкодом ZEND_FAST_CONCAT. То есть даже в случае вызовов функций мы все равно получим итоговую длину строки до непосредственно конкатенации.


            Во втором же случае у нас все не так очевидно, PHP с версии 7.0 хоть и имеет LALR(1) парсер, но это уже надо учитывать контекст или оптимизировать итоговый ast чего делать увы не стали. Но это всего-лишь вопрос времени.

            • 0
              Была где-то глубоко мысль, что возможно michael_vostrikov прав в том плане, что всё-таки в будущем возможно будет оптимизировать и конкатенацию (именно в плане анализа контекста перед конкатенацией) в некоторых случаях, не содержащих вызовов функций, но я старался исходить из текста статьи (см. мой первый коммент в ветке).
              Насчёт __toString() не сообразил, значит видимо ошибся насчёт вычисляемых выражений.
              Хоть на истину и не претендовал. Хорошо, что поправили.
              Спасибо. Действительно может оптимизируют когда-нибудь.
              • 0
                Любой вызов функции разве проблема?

                $str = $substr_1 . ' подстрока 2 ' . func_foo() . $substr_4;
                

                это же
                $__tmp001 = func_foo();
                $str = $substr_1 . ' подстрока 2 ' . $__tmp001 . $substr_4;
                

                и в итоге те же
                $__tmp001 = func_foo();
                $str = "$substr_1  подстрока 2  $__tmp001 $substr_4";
                


                Не видно ни одной проблемы оптимизировать ровно тем же образом.
                • +1
                  Не видно ни одной проблемы оптимизировать ровно тем же образом.

                  весь вопрос в разборе кода. Для строк все очень просто, а для конкатенации чуть сложнее. Ну то есть есть даже merge request для этого но по какой-то причине его не стали пока вмердживать в мастер.

                  • 0

                    Да я уже понял, что вопрос не в вычисляемых значениях.


                    Насчёт __toString() не сообразил

                    понятно, что и насчёт других магических вызовов.


                    Пользователь Fesor же ответил выше.
                    Хотя ошибался не только я, но Вы поправили и там ))
                    А насчёт "проблемы оптимизировать" Сергей ответил уже, дважды.
                    Я даже полез было выяснять, что же за ZEND_FAST_CONCAT, напоролся на https://github.com/php/php-src/blob/master/ext/opcache/Optimizer/block_pass.c. Дальше решил в исходниках не разбираться и поверить Сергею Протько на слово ))

                • 0

                  Сергей, можете пояснить, если opcache выключен, будет ли работать описанная оптимизация (хотя бы на encapsed-строках)?
                  У нас просто сейчас php5 и apc, но интересно же, что там в семёрке, на будущее.
                  Я понимаю, что вопросы детские, так и есть, простите. Но полагаю, что это интересно будет не только мне.

          • 0
            Могу предположить, что разница в том, что в encapsed на этапе компиляции известно, сколько нужно вставить строк, а при конкатенации это значение может меняться. Плюс конкатенировать можно любые значения, в том числе динамически преобразуемые в строки, вызовы функций и т. д., а энкапсить — только переменные или свойства объектов. За счет их (условно) статической природы можно заранее узнать, что, каких размеров и в каком количестве нужно вставлять в строку.
            • 0
              Свойства объектов могут быть не явно прописанными в объекте, а отрабатывать через магический метод __get — а значит может появиться всё что угодно, хоть до получения каждый раз разного значения при попытке читать «свойство объекта». Так что функции сильно ближе к делу чем кажутся.

              Да и прочие выражения собственно тоже, вроде
              тринарника
              "str1 " . ($a ? $b : $c) . " str2"
              
              переходит в
              $__tmp002 = $a ? $b : $c;
              "str1 $__tmp002 str2"
              

          • 0
            Подскажите пожалуйста, если PHP5 используется как модуль Apache — «парсинг/компиляция» php-файла происходит при каждом http-запросе или имеет место какое-либо кеширование?
            • 0

              Да, конечно. Opcache для этого и нужен: он хранит данные в разделяемой памяти. Частота/поведение инвалидации зависит от настроек в php.ini

            • 0
              КДПВ отлично коррелирует с ником переводчика)
              • 0

                С оригинальными постами, к сожалению, не коррелирует. Её там просто нет.

              • +1
                Месяца 3 назад разработчик виртуальной машины PHP Дмитрий Стогов (Chief Performance Engineer в Zend Technologies) рассказал у нас на митапе о том, как он с коллегами ускорял PHP 7:



                Там довольно любопытно и познавательно. В том числе, про попытки сделать JIT-компилятор и что из этого вышло. Ну и про дальнейшие планы, конечно.
                • +4
                  PHP — это программное обеспечение, написанное на языке С

                  Так писали бы тогда «PHP — это программное обеспечение, написанное на программном обеспечении написаном на ассемблере». А то как си — так язык, а как php — так ПО написанное на си. Это же тоже язык…

                  </зануда мод>
                  • 0
                    «C» не написан на ассемблере, он написан на самом себе (по крайней мере это верно для GCC).
                    • 0
                      Ну в любом случае С — это язык, стандарт. А реализовать компилятор можно на чём хочется (теоретически). Так и PHP — это же язык, стандарт. А на чём там написана его конкретная реализация — это уже другой вопрос. ИМХО, корректнее было бы что-то в стиле «официальная реализация языка PHP написана на языке С».
                      • 0

                        между "программным обеспечением" и "языком программирования" есть разница, как вы верно заметили. В приведенном вами тексте говорится именно о программном обеспечении с названием PHP (можно было бы еще упомянуть zend engine но это уже реализация виртульной машины а не весь комплекс). Если вы хотите "программное обеспечение позволяющее запускать код на языке PHP, написанное на java" например, то у вас есть JPHP. Заметьте что название отличается.

                  • 0
                    Ключи вставляются в массив только по возрастанию.

                    Вроде ключи должны инкрементироваться с 0.
                  • 0
                    Большое спасибо за полезную информацию.

                    Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                    Самое читаемое
                    Интересные публикации