PHP, почём абстракции для народа?

в 7:44, , рубрики: funcorp, php, Блог компании FunCorp, ненормальное программирование, Программирование, производительность

PHP, почём абстракции для народа? - 1
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 тысяч строк.
В качестве среды исполнения возьмём 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.

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

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

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

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

Автор: rjhdby

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js