Joy: What is going on?
Sadness: We’re abstracting! There are four stages. This is the first. Non-objective fragmentation!
Bing Bong: Alright, do not panic. What is important is that we all stay together. [suddenly his abstract arm falls off]
Joy: Oh! [Sadness and Joy start falling apart too]
Sadness: We’re in the second stage. We’re deconstructing! [as Bing Bong falls to pieces]
Bing Bong: I can’t feel my legs! [picks one leg up] Oh, there they are.
© мультфильм Inside Out
Все любят писать красивый код. Чтобы абстракции, лямбды, SOLID, DRY, DI и т.д. и т.п. В этой статье я хочу исследовать, во сколько обходится это всё с точки зрения производительности и почему.
Для этого возьмём простую, оторванную от реальности, задачу и будем постепенно привносить в неё красоту, замеряя производительность и заглядывая под капот.
Дисклеймер: Эта статья ни в коем случае не должна рассматриваться как призыв писать плохой код. Лучше всего, если вы заранее настроитесь сказать после прочтение «Прикольно! Теперь я знаю, как оно там внутри. Но, конечно же, не буду это использовать». :)
Задача:
- Дан текстовый файл.
- Разобьём его по строкам.
- Обрежем пробелы слева и справа
- Отбросим все пустые строки.
- Все не единичные пробелы заменим единичными («A B C»->«A B C»).
- Строки, в которых более 10 слов, по словам перевернём задом наперёд («An Bn Cn»->«Cn Bn An»).
- Посчитаем, сколько раз встречается каждая строка.
- Выведем все строки, которые встречаются более N раз.
В качестве входного файла по традиции возьмём php-src/Zend/zend_vm_execute.h на ~70 тысяч строк.
В качестве среды исполнения возьмём PHP 7.3.6.
На скомпилированные опкоды посмотрим тут https://3v4l.org.
Замеры будем производить следующим образом:
// объявление функций и классов
$start = microtime(true);
ob_start();
for ($i = 0; $i < 10; $i++) {
// тут наш код
}
ob_clean();
echo "Time: " . (microtime(true) - $start) / 10;
Подход первый, наивный
Напишем простой императивный код:
$array = explode("\n", file_get_contents('/Users/rjhdby/CLionProjects/php-src/Zend/zend_vm_execute.h'));
$cache = [];
foreach ($array as $row) {
if (empty($row)) continue;
$words = preg_split("/\s+/", trim($row));
if (count($words) > 10) {
$words = array_reverse($words);
}
$row = implode(" ", $words);
if (isset($cache[$row])) {
$cache[$row]++;
} else {
$cache[$row] = 1;
}
}
foreach ($cache as $key => $value) {
if ($value > 1000) {
echo "$key : $value" . PHP_EOL;
}
}
Время выполнения ~0.148с.
Тут всё просто и разговаривать особо не о чем.
Подход второй, процедурный
Отрефакторим наш код и вынесем элементарные действия в функции.
Постараемся придерживаться принципа единственной ответственности.
function getContentFromFile(string $fileName): array
{
return explode("\n", file_get_contents($fileName));
}
function reverseWordsIfNeeded(array &$input)
{
if (count($input) > 10) {
$input = array_reverse($input);
}
}
function prepareString(string $input): string
{
$words = preg_split("/\s+/", trim($input));
reverseWordsIfNeeded($words);
return implode(" ", $words);
}
function printIfSuitable(array $input, int $threshold)
{
foreach ($input as $key => $value) {
if ($value > $threshold) {
echo "$key : $value" . PHP_EOL;
}
}
}
function addToCache(array &$cache, string $line)
{
if (isset($cache[$line])) {
$cache[$line]++;
} else {
$cache[$line] = 1;
}
}
function processContent(array $input): array
{
$cache = [];
foreach ($input as $row) {
if (empty($row)) continue;
addToCache($cache, prepareString($row));
}
return $cache;
}
printIfSuitable(
processContent(
getContentFromFile('/Users/rjhdby/CLionProjects/php-src/Zend/zend_vm_execute.h')
),
1000
);
Время выполнения ~0.275с… WTF!? Разница почти в 2 раза!
Посмотрим, что из себя представляет функция PHP с точки зрения виртуальной машины.
Код:
$a = 1;
$b = 2;
$c = $a + $b;
Компилируется в:
line #* E I O op fetch ext return operands
-------------------------------------------------------------------------------------
2 0 E > ASSIGN !0, 1
3 1 ASSIGN !1, 2
4 2 ADD ~5 !0, !1
3 ASSIGN !2, ~5
Давайте вынесем сложение в функцию:
function sum($a, $b){
return $a + $b;
}
$a = 1;
$b = 1;
$c = sum($a, $b);
Такой код скомпилируется в два набора опкодов: один для корневого пространства имён, а второй для функции.
Корень:
line #* E I O op fetch ext return operands
-------------------------------------------------------------------------------------
2 0 E > ASSIGN !0, 1
3 1 ASSIGN !1, 1
5 2 NOP
9 3 INIT_FCALL 'sum'
4 SEND_VAR !0
5 SEND_VAR !1
6 DO_FCALL 0 $5
7 ASSIGN !2, $5
Функция:
line #* E I O op fetch ext return operands
-------------------------------------------------------------------------------------
5 0 E > RECV !0
1 RECV !1
6 2 ADD ~2 !0, !1
3 > RETURN ~2
Т.е. даже если просто по опкодам посчитать, то каждый вызов функции добавляет 3 + 2N опкодов, где N — количество передаваемых аргументов.
А если копнуть немного глубже, то тут у нас ещё и переключение контекста выполнения.
Грубая прикидка по нашему отрефакторенному коду даёт такие цифры (помним про 70 000 итераций).
Количество «дополнительных» исполненных опкодов: ~17 000 000.
Количество переключений контекста: ~280 000.
Подход третий, классический
Особо не мудрствуя, обернём все эти функции классом.
class ProcessFile
{
private $content;
private $cache = [];
function __construct(string $fileName) {
$this->content = explode("\n", file_get_contents($fileName));
}
private function reverseWordsIfNeeded(array &$input) {
if (count($input) > 10) {
$input = array_reverse($input);
}
}
private function prepareString(string $input): string {
$words = preg_split("/\s+/", trim($input));
$this->reverseWordsIfNeeded($words);
return implode(" ", $words);
}
function printIfSuitable(int $threshold) {
foreach ($this->cache as $key => $value) {
if ($value > $threshold) {
echo "$key : $value" . PHP_EOL;
}
}
}
private function addToCache(string $line) {
if (isset($this->cache[$line])) {
$this->cache[$line]++;
} else {
$this->cache[$line] = 1;
}
}
function processContent() {
foreach ($this->content as $row) {
if (empty($row)) continue;
$this->addToCache( $this->prepareString($row));
}
}
}
$processFile = new ProcessFile('/Users/rjhdby/CLionProjects/php-src/Zend/zend_vm_execute.h');
$processFile->processContent();
$processFile->printIfSuitable(1000);
Время выполнения: 0.297. Стало хуже. Не сильно, но заметно. Неужели создание объекта (10 раз в нашем случае) такое затратное? Нууу… Не только в этом дело.
Давайте посмотрим, как виртуальная машина работает с классом.
class Adder{
private $a;
private $b;
function __construct($a, $b) {
$this->a = $a;
$this->b = $b;
}
function sum(){
return $this->a + $this->b;
}
}
$a = 1;
$b = 1;
$adder = new Adder($a, $b);
$c = $adder->sum();
Тут будет три набора опкодов, что логично: корень и два метода.
Корень:
line #* E I O op fetch ext return operands
---------------------------------------------------------------------------
2 0 E > NOP
16 1 ASSIGN !0, 1
17 2 ASSIGN !1, 1
18 3 NEW $7 :15
4 SEND_VAR_EX !0
5 SEND_VAR_EX !1
6 DO_FCALL 0
7 ASSIGN !2, $7
19 8 INIT_METHOD_CALL !2, 'sum'
9 DO_FCALL 0 $10
10 ASSIGN !3, $10
Конструктор:
line #* E I O op fetch ext return operands
---------------------------------------------------------------------------
6 0 E > RECV !0
1 RECV !1
7 2 ASSIGN_OBJ 'a'
3 OP_DATA !0
8 4 ASSIGN_OBJ 'b'
5 OP_DATA !1
9 6 > RETURN null
Метод sum:
line #* E I O op fetch ext return operands
---------------------------------------------------------------------------
11 0 E > FETCH_OBJ_R ~0 'a'
1 FETCH_OBJ_R ~1 'b'
2 ADD ~2 ~0, ~1
3 > RETURN ~2
Ключевое слово new фактически преобразуется в вызов функции (строки 3-6).
Она создаёт экземпляр класса и вызывает на нем конструктор с переданными параметрами.
В коде же методов нам будет интересна работа с полями класса. Обратите внимание, что если с обычными переменными при присвоении используется один простой опкод ASSIGN, то для полей класса всё несколько иначе.
Присвоение — 2 опкода
7 2 ASSIGN_OBJ 'a'
3 OP_DATA !0
Чтение — 1 опкод
1 FETCH_OBJ_R ~1 'b'
Тут следует знать, что ASSIGN_OBJ и FETCH_OBJ_R сильно сложнее и, соответственно, более затратны по ресурсам, чем простой ASSIGN, который, грубо говоря, просто копирует zval из одного куска памяти в другой.
Опкод | Количество строк обработчика (С-код) |
---|---|
ASSIGN_OBJ | 149 |
OP_DATA | 30 |
FETCH_OBJ_R | 112 |
ASSIGN | 26 |
Понятно, что такое сравнение очень далеко от корректного, но всё же даёт некоторое представление. Чуть дальше произведу замеры.
А теперь посмотрим, насколько затратно создание экземпляра объекта. Давайте замерим на одном миллионе итераций:
class ValueObject{
private $a;
function __construct($a) {
$this->a = $a;
}
}
$start = microtime(true);
for($i = 0; $i < 1000000; $i++){
// $a = $i;
// $a = new ValueObject($i);
}
echo "Time: " . (microtime(true) - $start);
Присвоение переменной: 0.092.
Инстанциация объекта: 0.889.
Как-то вот так. Не совсем бесплатно, особенно если много раз.
Ну и чтобы два раза не вставать, давайте замерим разницу между работой со свойствами и с локальными переменными. Для этого изменим наш код таким образом:
class ValueObject{
private $b;
function try($a) {
// Обмен через свойство
// $this->b = $a;
// $c = $this->b;
// Обмен через присвоение
// $b = $a;
// $c = $b;
return $c;
}
}
$a = new ValueObject();
$start = microtime(true);
for($i = 0; $i < 1000000; $i++){
$b = $a->try($i);
}
echo "Simple. Time: " . (microtime(true) - $start);
Обмен через присвоение: 0.830.
Обмен через свойство: 0.862.
Самую малость, но дольше. Как раз тот же порядок разницы, какой получили после обёртывания функций в класс.
Банальные выводы
- В следующий раз, когда вы захотите инстанциировать миллион объектов, задумайтесь, так ли оно вам необходимо. Может, просто массив, а?
- Писать спагетти-код ради экономии одной миллисекунды — ну такое. Выхлоп копеечный, а коллеги потом и побить могут.
- А вот ради экономии 500 миллисекунд, может быть, иногда и имеет смысл. Главное, не перегибать палку и помнить, что эти 500 миллисекунд, скорее всего, будут сэкономлены только небольшим участком очень горячего кода, и не превращать весь проект в юдоль скорби.
P.S. Про лямбды в следующий раз. Там интересно. :)
Комментарии (46)
Akdmeh
23.09.2019 11:53Могу посоветовать писать с использованием ООП (который проще поддерживать команде и тестировать при грамотном подходе), но затем проверять «тяжелый код» с помощью xhprof.
Как-то я оптимизировал запросы к базе данных (так как считал, что это замедляет работу тяжелого скрипта), но не получал значительного ускорения работы скрипта. Оказалось, что самое большое замедление давало именно создание объекта ActiveRecord и затем миллионы вызовов __get к виртуальным полям. Да, пришлось переписать конкретно код с объектов на массивы (с потерей возможности работы с сущностями как объектами со своими свойствами), т.е., грубо говоря, вместо $obj->getParentName() писать Something::getParentName($obj), но за счет этого получил троекратное ускорение работы алгоритма.
Итого, все эти предположения — хороши, но всегда нужно смотреть фактическое употребление памяти и процессорного времени с помощью отладчиков в конкретных алгоритмах и получать приемлемую скорость.michael_vostrikov
23.09.2019 19:18А почему не
$obj->parentName
? Публичные поля в объектах все-таки удобнее static-вызовов. Вы же получается все равно заменили функционал ActiveRecord на свой, можно было поля через$this->$name
инициализировать по массиву из БД.
homm
23.09.2019 13:57В качестве входного файла по традиции возьмём php-src/Zend/zend_vm_execute.h на ~70 тысяч строк.
Сделайте пожалуйста ссылкой, чтобы можно было проверить у себя.
namikiri
23.09.2019 15:00Не менее сильно раздражает когда код раздроблен на миллион файлов, и, если убрать из каждого бойлерплейт, собственно рабочего кода там остаётся на пять-шесть строк. И вот сидишь блуждаешь с зажатым Ctrl, где же всё таки реальная бизнес-логика во всей это мишуре…
youROCK
23.09.2019 20:53Скажу честно, первый вариант кода ещё и легче читается (по крайней мере для меня) :). Когда логики не очень много, очень легко читать, когда она вся собрана в одном месте. У этого подхода есть много недостатков, в том числе сложность тестирования такого кода, но это можно частично преодолеть тем, чтобы вынести в функции (скорее классы) те участки, которые хочется уметь подменять.
Другой вопрос, когда весь проект написан в таком стиле — в этом случае поддержка превращается в ад. Поэтому, ИМХО, совет должен быть немного в другом — не стоит переусложнять код и вносить туда кучу абстракций перед тем, как код действительно стал хотя бы чуточку сложным. Особенно если вы пишете на PHPrjhdby Автор
23.09.2019 21:56+1Другой вопрос, когда весь проект написан в таком стиле
Как то раз один уважаемый мною писатель выложил на гитхаб движёк своего стендалона. Взыграло во мне чувство бессмысленного и беспощадного альтруизма помочь в его развитии. Форкнул, загрузил, открыл, и волосы у меня начали шевелиться в неожиданных местах — такой адище, что ни в сказке сказать, ни пером описать. Но, как говорится, взялся за гуж… В общем переписал скрипт работы с базой в лучших практиках. Не так, чтоб прям фанатично, но хотя бы по человечески. Дня два ушло только на то, чтобы разобраться, как оно вообще работает и какие граничные условия какими костылями подперты. В ответку прилетело "Это что за фигня? Классы какие-то! Нафиг, нафиг — моему скрипту уже дцать лет, я там каждый костыль знаю и мне так удобно".
Не знаю, к чему об этом рассказал. Навеяло. :)
dmitryrublev
23.09.2019 23:12+1Знаю такую CMS, где коду лет 10-15 уже, по ощущениям (и редким комментариям) всё писано фрилансерами всего мира. Раз в пару лет заглядываю на форум разработчика, и вижу одинокие посты:
— «А давайте нормально перепишем?..»
— «Иди в *опу,пипл лицензии всёравно покупаетвсех всё устраивает, самый умный штоле?»
Hett
23.09.2019 21:59Что там на счёт opcace, вашем тесте он включен?
rjhdby Автор
23.09.2019 22:02Opcache имели в виду? А зачем он там? Время компиляции то нам тут не так чтоб интересно — оно практически никак не сыграет ни для одного из вариантов.
Hett
24.09.2019 14:35Opcache конечно, отправил сообщение и только потом увидел ошибку.
А PHP 8 с JIT уже доступен для тестов? Интересно как там дела обстоят.rjhdby Автор
24.09.2019 15:05
rjhdby Автор
25.09.2019 19:52Насколько я понимаю принципы работы JIT в PHP, какой-то производительности он добавит, но соотношение не изменится, поскольку накладные расходы на вызов функций никуда не денутся. Мало того, наибольший прирост он даст если просто убрать в одну единственную функцию код изнутри цикла первого примера.
Hett
24.09.2019 14:49Я думал, что он какую-то оптимизацию опкода проводит, но видимо нет. Проверил ваши примеры, разницы во времени с включенным/выключенным opcache нет.
FanAs
23.09.2019 22:48Спасибо за статью. Действительно, в «горячих» фрагментах можно и иногда нужно экономить миллисекунды — в наших проектах это позволяло выиграть до 15% производительности кэш, однако возникает проблема — как оформить императивный код таким образом, чтобы он был понятен при обслуживании? Возможно у вас есть какие-то мысли?
rjhdby Автор
24.09.2019 00:11Боюсь, что с ходу в голову приходит только один способ. К сожалению он является одной из двух самых сложных вещей в программировании. ;)
bolk
23.09.2019 23:04explode("\n", file_get_contents($fileName));
это просто
file($fileName, FILE_IGNORE_NEW_LINES);
А тут вы отбрасываете не только пустую строку, но и строку «0»:
if (empty($row)) continue;
rjhdby Автор
24.09.2019 00:09Не с казал бы, что это принципиально в рамках заданной темы
zorn_v
24.09.2019 03:36Ну городить абстракции над тем что делается из коробки одной функцией, это такое себе. Причем, как заметили выше, в итоге код получился некорректным (отбрасывается «0» как пустая строка).
rjhdby Автор
24.09.2019 08:29Вы напомнили мне моего старого преподавателя по начертательной геометрии. Когда у него не было претензий к содержательной части работы — он начинал придираться к тому, что буква А в легенде не по ГОСТу.
zorn_v
24.09.2019 03:28А `file($fileName, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES)` еще до кучи и пустые строки отбросит ;)
NickyX3
24.09.2019 07:37Во времена php7 файлы в тысячи строк лучше вообще в память не грузить.
Генератор тут выиграет по памяти порядком
public static function readTheRealyBigFile($path) { $handle = fopen($path, "r"); while(!feof($handle)) { yield trim(fgets($handle)); } fclose($handle); }
zorn_v
24.09.2019 08:29Все так, но не понятно при чем тут php7
Генераторы появились в 5.5NickyX3
24.09.2019 08:32Ключевое слово времена. А у нас 2019 год кончается и 7 актуальнее, да ведь?
zorn_v
24.09.2019 08:36Т.е. во «времена» пхп 5.5 память была дешевле что ли? Или может не было файлов в тысячи строк? Не понял посыл.
NickyX3
24.09.2019 08:50Во-первых, в 7.х вообще работа с памятью получше, чем в 5.х
Во-вторых речь то о том, что именно генератор поможет на порядок сократить потребление памяти на операциях чтения больших файлов подобного типа (текстовые файлы с большим количеством строк, логи и т.п.). Статья у нас про экономию и оптимизацию жеrjhdby Автор
24.09.2019 10:13Статья у нас про экономию и оптимизацию же
Статья у меня не про "как", а про "почему". Да и, если на то пошло, не про оптимизацию совсем.
Раз зашла речь про преимущества потоковой обработки файлов, то нельзя не отметить, что хоть она и экономит на памяти, но вот что касается стоимости в перфомансе — там все довольно печально.
NickyX3
24.09.2019 10:41экономит на памяти, но вот что касается стоимости в перфомансе
Есть расклад по опкодам и цыферки? Было бы интересноrjhdby Автор
24.09.2019 11:01По циферкам, при замене
explode("\n", file_get_contents(...
на генератор, на моем железе, получилось в два раза медленнее. Тут дело не столько в опкодах, сколько в том, чтоbulk operation
, возможно за исключением некоторых случаев, которые с ходу не придумываются, всегда быстрее, чемone by one
. По природе своей.
UPD Потоковая обработка хороша в случаях, когда существует фильтрация входных данных и/или условие остановки обработки.
Sabubu
24.09.2019 03:51Сравнивал время загрузки записей из БД в массив через PDO и в объект через Доктрину. Через Доктрину в 10 раз медленнее. Использую Доктрину за исключением случаев, когда надо обработать большое число записей и получить на выходе небольшой объем информации (вроде пары чисел).
Там, где нужна высокая производительность, возможно, стоит использовать не PHP.
iproger
24.09.2019 04:54-1Ваши слова да разработчикам Magento 2 в уши. Я когда первый раз столкнулся с ней не сразу понял с чем имею дело, потом понемногу приходило понимание масштаба «абстракции».
Есть предположение что в ней классов и строчек кода больше чем в каком-нибудь ядре Линукса.
Без кэша загрузка страницы может занимать до 15 секунд.
dzsysop
24.09.2019 06:54Я работал с высоконагруженными сайтами. Если стоит вопрос в производительности, надо смотреть и выпиливать ручками. Но нет единого подхода. Абстракции все равно нужны и ООП легче читать, писать, дебажить и тестировать.
Из опыта, наиболее часто встречающиеся проблемы (с намеками на их решения):
— зачем мы гоняем через луп миллион раз вообще?
— зачем мы имеем миллион чего-то в памяти даже без лупа?
— почему то что мы обрабатываем не существует еще в кэше, а снова вычисляется?
Если же проблемный код не про эти вещи, то обычно абстрации не стоит обвинять.
Я бы сказал, что, надо не от абстракций избавляться, а архитектуру улучшать.
VolCh
24.09.2019 08:26+2Одна из причин использования ООП не по делу в PHP — отсутствие автозагрузуи для функций. Это так, заметка.
Eldhenn
«Экономия» полусекунды спагетти-кодом обернётся проблемами при поддержке и расширении. Если, конечно вы не пишете код «write once and better never run».
gecube
Не любой код в будущем будет доработан. Вполне возможно, что когда понадобится код доработать — выйдет очередной новомодный фреймворк и задача будет переписать все с нуля.
Естественно, что your mileage may vary. И писать говнокод не стоит.
smarthomeblog
ИМХО код без использования классов и функций нельзя прямо так назвать говнокодом. Все зависит от контекста :) Писать большой проект одним скриптом явно не стоит. Равно как и бездумно пихать везде ООП тоже.
gecube
Я не говорил, что любой скрипт — говнокод.
И, да, полностью согласен, что нужно инструменты применять к месту.
rjhdby Автор
Все зависит от задачи. Если, допустим, у вас высоконагруженное веб-апи, то пол секунды там вполне себе оправданный выигрыш. Ну и я же не зря акцентировал внимание на "эти 500 миллисекунд, скорее всего, будут сэкономлены только небольшим участком очень горячего кода"
edogs
AmdY
Поддержка и расширение — это очень абстрактные вещи и большинство статистики собиралось в 80-90 годы, с тех пор появились мощные IDE, выросли возможности языков и появились хорошие по вопросу библиотеки, решающие кучу инфраструктурных проблем.
Вот у меня был проект сети магазинов, спагети код на функциях, который поддерживался и развивался силами 1.5 разработчиков. Затем его переписали на новый модный фреймворк, с эвентами, команд басами, попытками в ДДД, естественно всё реализовали как микросервисы. На момент моего ухода, команда уже перевалила за 20 человек.
trueMoRoZ
«перевалила за 20 человек» — это хорошо или плохо? количество разработчиков изменилось только из-за переезда на модный фреймворк или таки были ещё какие-то факторы?
AmdY
С моей точки зрения, проблема именно в модности подходов, без достаточного опыта работы с ними. Да, появился чуть больший функционал, но не настолько, чтобы команда выросла больше чем на порядок. В то же время много сил начало уходить на тюнинг производительности, ведь то что раньше было вызовом функции, превратилось в запрос к микросервису (прям как в статье). Вынесли склады в отдельный микросервис, для него нужны отдельные разработчики, разработчикам корзины надо меньше знать про склады, но вылазят проблемы взаимодействия команд.
Мне как разработчику, стало легче, меньше знаешь, меньше ответственности, больше бюрократии и митингов, откровенно начал забивать и подстаиваться по скорости к остальной команде, взял себе в нагрузку стажёров и курировал проекты с ними. Благо работа была удалённая.