Хабрахабр

Как мы сделали PHP 7 в два раза быстрее PHP 5. Часть 2: оптимизация байт-кода в PHP 7.1

В первой части рассказа по мотивам выступленияДмитрия Стогова из Zend Technologies на HighLoad++ мы разбирались во внутреннем устройстве PHP. Детально и из первых уст узнали, какие изменениях в базовых структурах данных позволили ускорить PHP 7 более чем в два раза. На этом можно было бы и остановиться, но уже в версии 7.1 разработчики пошли существенно дальше, так как идей по оптимизации у них было еще много.

0 без JIT и на результаты HHVM с JIT. Накопленный опыт работы над JIT до семёрки теперь можно интерпретировать, смотря на результаты в 7. 1 было решено c JIT не работать, а опять обратиться к интерпретатору. В PHP 7. Если раньше оптимизации касались интрепретатора, то в этой статье посмотрим на оптимизацию байт-кода, с использованием вывода типов, который реализовали для нашего JIT.

Под катом Дмитрий Стогов покажет, как это все работает, на простом примере.

Оптимизация байт-кода

Ниже байт-код, в который компилирует функцию стандартный компилятор PHP. Он однопроходный — быстрый и тупой, но способный сделать свою работу на каждом HTTP-запросе заново (если не подключен OPcache).

Оптимизации OPcache

С приходом OPcache мы стали его оптимизировать. Некоторые методы оптимизации уже давно встроены в OPcache, например, методы щелевой оптимизации — когда мы смотрим на код как бы через глазок, ищем знакомые паттерны, и заменяем их с помощью эвристик. Эти методы продолжают использоваться и в 7.0. Например, у нас есть две операции: сложение и присваивание.

Другой пример пост-инкремент переменной, которая теоретически может вернуть какой-то результат.
Они могут быть объединены в одну операцию compound assignment, которая выполняет сложение непосредственно над результатом: ASSIGN_ADD $sum, $i.

Для этого используется следующая за ним инструкция FREE. Он может быть не скалярным значением и должен быть удален. Но если его изменить на пре-инкремент, то инструкции FREE не потребуется.

Этот код никогда не будет достигнут и его можно удалить.
В цикле осталось всего четыре инструкции. В конце — два оператора RETURN: первый — прямое отражение оператора RETURN в исходном тексте, а второй добавился тупым компилятором по закрывающей скобке. Каждый раз, когда она выполняется: Кажется, что дальше нечего оптимизировать, но не для нас.
Посмотрите на код $i++ и соответствующую ей инструкцию — пре-инкремент PRE_INC.

  • нужно проверить, какой тип переменной пришел;
  • is_long ли это;
  • выполнить инкремент;
  • проверить, не произошло ли переполнение;
  • перейти на следующий;
  • возможно, проверить исключение.

Но человек, просто посмотрев на код PHP, увидит что переменная $i лежит в диапазоне от 0 до 100, и никакого переполнения быть не может, проверок типов не нужно, и никаких исключений тоже быть не может. В PHP 7.1 мы попытались научить компилятор понимать это.

Оптимизация Control Flow Graph

Но начнем мы с построения Control Flow Graph — графа зависимости по управлению. Для этого нужно вывести типы, а чтобы ввести типы надо сначала построить формальное представление потоков данных, понятное компьютеру. Поэтому мы режем код в тех местах, на которых происходит переход, то есть метки L0, L1. Первоначально мы разбиваем код на basic-блоки — набор инструкций с одним входом и одним выходом. Мы также режем его после операторов условного и безусловного перехода, и потом соединяем дугами, которые показывают зависимости по управлению.

Так у нас получился CFG.

Оптимизация Static Single Assignment Form

Ну а теперь нам нужна зависимость по данным. Для этого мы используем Static Single Assignment Form — популярное представление в мире оптимизирующих компиляторов. Оно подразумевает, что значение каждой переменной может быть присвоено только один раз.

В каждом месте, где происходит присваивание мы ставим новый индекс, а там, где мы их используем — пока знаки вопроса, потому что не везде он пока известен. Для каждой переменной мы добавляем индекс, или номер реинкарнации. Например, в инструкции IS_SMALLER $i может прийти как из блока L0 с номером 4, так и из первого блока с номером 2.

Именно такие переменные потом и используются для устранения неоднозначности.
Для решения этой проблемы SSA вводит псевдо-функцию Phi, которая по необходимости вставляется в начало basic->block-а, берет всевозможные индексы одной переменной, пришедшие в basic-block из разных мест, и создает новую реинкарнацию переменной.

Заменив таким образом все знаки вопроса мы и построим SSA.

Оптимизация по типам

Теперь выводим типы — как будто пытаемся выполнить этот код непосредственно по управлению.

Дальше — функция Phi. В первом блоке идет присваивание переменным значений констант — нулей, и мы точно знаем, что эти переменные будут типа long. На вход приходит long, а значения других переменных, пришедших по другим веткам, мы пока не знаем.

Считаем, что на выходе phi() у нас будет long.

Приходим к конкретным функциям, например, ASSIGN_ADD и PRE_INC. Распространяем дальше. В результате может получиться либо long, либо double, если произойдет переполнение.
Складываем два long.

Ну и так далее продолжаем распространение, пока не придем к fixed point и все не устаканится.
Эти значения опять попадают в функцию Phi, происходит объединение множеств возможных типов пришедших по разным веткам.

Это уже хорошо. Мы получили возможное множество значений типов в каждой точке программы. Но мы-то знаем что и double $i быть не может. Компьютер уже знает что $i может быть только long или double, и может исключить часть ненужных проверок. А мы видим условие которое ограничивает рост $i в цикле до возможного переполнения. А как мы знаем? Научим и компьютер видеть это.

Оптимизация Range Propagation

В инструкции PRE_INC мы так и не узнали, что i может быть только целым — стоит long или double. Происходит это потому, что мы не пытались вывести возможные диапазоны. Тогда бы мы могли ответить на вопрос, произойдет или не произойдет переполнение.

В результате получаем фиксированный диапазон переменных $i с индексами 2, 4, 6 7, и теперь можем уверенно сказать что инкремент $i не приведет к переполнению.
Производится этот вывод диапазонов похожим, но чуть более сложным образом.

Скомбинировав эти два результата, мы можем точно сказать, что double переменная $i никогда стать не сможет.

Рассмотрим инструкцию ASSIGN_ADD. Все что мы получили это еще не оптимизация, это информация для оптимизации! Тогда, после сложения, старое значение должно было быть удалено. В общем виде старое значение суммы, которое пришло к этой инструкции, могло быть, например, объектом. Никакого уничтожения не требуется, мы можем заменить ASSIGN_ADD на ADD — инструкцию попроще. Но в нашем случае мы точно знаем, что там long или double, то есть скалярное значение. ADD использует переменную sum в качестве и аргумента и значения.

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

Мы знаем, что значение переменной будет только long — можно сразу проверить это значение, сравнив его с сотней. Теперь сравнение переменной в конце цикла. Если раньше мы записывали результат проверки во временную переменную, а потом еще раз проверяли временную переменную на значение true/false, теперь это можно сделать с помощью одной инструкции, то есть упростить.

Результат байт-кода по сравнению с оригиналом.

В результате код справа работает в 3 раза быстрее, оригинала. В цикле осталось всего 3 инструкции, и две из них высокоспециализированные.

Высокоспециализированные обработчики

Любой обработчик обхода в PHP — это просто С-функция. Слева стандартный обработчик, а наверху справа — высокоспециализированный. Левый проверяет: тип операнда, не произошел ли overflow, не произошел ли exception. Правый просто добавляет единицу и всё. Он транслируется в 4 машинные инструкции. Если бы мы пошли дальше и делали JIT, то нам бы была нужна только однократная инструкция incl.

Что дальше?

Мы продолжаем повышать скорость PHP ветки 7 без JIT. PHP 7.1 опять будет на 60% быстрее на характерных синтетических тестах, но на реальных приложениях выигрыша это практически не дает — всего 1-2% на WordPress. Это не особо интересно. С августа 2016, когда ветка 7.1 была заморожена для существенных изменений, мы снова начали трудиться над JIT для PHP 7.2 или скорее PHP 8.

Он хорош тем, что генерирует код очень быстро: то, что в версии JIT на LLVM компилировалось минуты, сейчас происходит за 0,1-0,2 с. В новой попытке мы используем для генерации кода DynAsm, который разработан Майком Полом для LuaJIT-2. Уже сегодня ускорение на bench.php на JIT в 75 раз быстрее чем PHP 5.

Отчасти, мы получили оптимальный код, но скомпилировав слишком много PHP скриптов, засорили кэш процессора, так что быстрее работать он не стал. На реальных приложениях ускорения нет, и это для нас следующий вызов. Да и не скорость кода была узким местом в реальных приложениях…

Возможно, DynAsm можно применять для компиляции только определенных функций, которые будут выбираться либо программистом, либо эвристиками, основанными на счетчиках — сколько раз функция была вызвана, сколько раз в ней повторяются циклы и т.д.

Многие инструкции скомпилированы оптимально: инкремент — в одну инструкцию CPU, инициализация переменной константам — в две. Ниже машинный код, который генерирует наш JIT все для того же примера. Там, где типы не вывелись, приходится возиться чуть больше.

Возвращаясь к заглавной картинке, PHP в сравнении с подобными языками в тесте Mandelbrot показывает очень даже неплохие результаты (правда, данные актуальны на конец 2016 года).

Неплохо было бы узнать, с какой скоростью заработал бы WordPress на С++, но вряд ли найдется чудак готовый переписать его просто чтобы проверить, да еще повторить все извраты PHP-ного кода. Возможно Mandelbrot — не лучший тест. Он вычислительный, но зато простой и реализован на всех языках одинаково. Если есть идеи по более адекватному набору бенчмарков — предлагайте.

Уже с нами: Встретимся на PHP Russia 17 мая, обсудим перспективы и развитие экосистемы и опыт использования PHP для действительно сложных и крутых проектов.

Конечно, это далеко не все. Да и Call for Papers еще на закрыт, до 1 апреля ждём заявки от тех, кто умеет применять современные подходы и лучшие практики, чтобы реализовать классные сервисы на PHP. Не бойтесь конкуренции с именитыми спикерами — мы ищем опыт использования того, что они делают, в реальных проектах и поможем показать пользу ваших кейсов.

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть