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. Про лямбды в следующий раз. Там интересно. :)
Автор: rjhdby