Хабрахабр

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 и т.д. Все любят писать красивый код. В этой статье я хочу исследовать, во сколько обходится это всё с точки зрения производительности и почему. и т.п.

Лучше всего, если вы заранее настроитесь сказать после прочтение «Прикольно! Для этого возьмём простую, оторванную от реальности, задачу и будем постепенно привносить в неё красоту, замеряя производительность и заглядывая под капот.
Дисклеймер: Эта статья ни в коем случае не должна рассматриваться как призыв писать плохой код. Но, конечно же, не буду это использовать». Теперь я знаю, как оно там внутри. 🙂

Задача:

  1. Дан текстовый файл.
  2. Разобьём его по строкам.
  3. Обрежем пробелы слева и справа
  4. Отбросим все пустые строки.
  5. Все не единичные пробелы заменим единичными («A B C»->«A B C»).
  6. Строки, в которых более 10 слов, по словам перевернём задом наперёд («An Bn Cn»->«Cn Bn An»).
  7. Посчитаем, сколько раз встречается каждая строка.
  8. Выведем все строки, которые встречаются более 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.

Как раз тот же порядок разницы, какой получили после обёртывания функций в класс. Самую малость, но дольше.

Банальные выводы

  1. В следующий раз, когда вы захотите инстанциировать миллион объектов, задумайтесь, так ли оно вам необходимо. Может, просто массив, а?
  2. Писать спагетти-код ради экономии одной миллисекунды — ну такое. Выхлоп копеечный, а коллеги потом и побить могут.
  3. А вот ради экономии 500 миллисекунд, может быть, иногда и имеет смысл. Главное, не перегибать палку и помнить, что эти 500 миллисекунд, скорее всего, будут сэкономлены только небольшим участком очень горячего кода, и не превращать весь проект в юдоль скорби.

P.S. Про лямбды в следующий раз. Там интересно. 🙂

Показать больше

Похожие публикации

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

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

Кнопка «Наверх»