PHP, почём абстракции для народа?
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 тысяч строк.
3. В качестве среды исполнения возьмём PHP 7. 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) $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 из одного куска памяти в другой.
Понятно, что такое сравнение очень далеко от корректного, но всё же даёт некоторое представление. Чуть дальше произведу замеры.
Давайте замерим на одном миллионе итераций: А теперь посмотрим, насколько затратно создание экземпляра объекта.
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);
830.
Обмен через свойство: 0. Обмен через присвоение: 0. 862.
Как раз тот же порядок разницы, какой получили после обёртывания функций в класс. Самую малость, но дольше.
Банальные выводы
- В следующий раз, когда вы захотите инстанциировать миллион объектов, задумайтесь, так ли оно вам необходимо. Может, просто массив, а?
- Писать спагетти-код ради экономии одной миллисекунды — ну такое. Выхлоп копеечный, а коллеги потом и побить могут.
- А вот ради экономии 500 миллисекунд, может быть, иногда и имеет смысл. Главное, не перегибать палку и помнить, что эти 500 миллисекунд, скорее всего, будут сэкономлены только небольшим участком очень горячего кода, и не превращать весь проект в юдоль скорби.
P.S. Про лямбды в следующий раз. Там интересно. 🙂