• Время чтения ~5 мин
  • 28.03.2023

Вся наша команда в настоящее время усердно работает над следующей версией Flare. Мы полностью перепроектируем приложение и веб-сайт, и это огромные усилия, но это, несомненно, окупится в будущем.

В этом переписывании Flare мы решили перейти от ресурсов Laravel к нашему собственному пакету данных Laravel для отправки данных во внешний интерфейс. Это не только добавляет преимущество полностью типизированного ответа, но мы также получаем определения TypeScript для наших ресурсов без дополнительных усилий.

После того, как все ресурсы были вручную преобразованы в объекты данных, мы были рады проверить, все ли работает нормально. Хотя мы не сталкивались с какими-либо исключениями или ошибками, некоторые страницы приложений казались чрезвычайно медленными, слишком медленными, чтобы быть полезными для наших клиентов.

Мы быстро обнаружили, что большая часть времени отклика была потрачена на объекты данных. Мы начали с оптимизации объектов данных с помощью функции lazy, которая позволяет загружать определенные части данных позже, когда начальная страница уже отправлена. Это сделало страницы быстрее, но мы не хотели останавливаться на достигнутом, так как мы полностью перепроектируем все приложение.

Пакет laravel-data фантастический для работы, но он также добавляет много сложности при выводе данных. В этом блоге мы рассмотрим, как мы улучшили производительность пакета и, таким образом, полное приложение Flare.

Чтобы начать, я познакомлю вас с тремя инструментами, которые вы можете использовать в своем наборе инструментов для исследования проблем производительности: Xdebug, PHPBench и QCacheGrind.

Xdebug - это расширение PHP, которое предоставляет возможности отладки и профилирования. Он наиболее известен своей сложной конфигурацией, которая может замедлить работу всего вашего приложения. К счастью, Xdebug версии 3 добился значительного прогресса в этой части, и настройка Xdebug теперь относительно проста.

PHPBench - это инструмент бенчмаркинга для PHP, который может помочь вам измерить производительность вашего кода. Он позволяет создавать тестовые случаи и запускать их несколько раз, чтобы получить представление о скорости вашего кода.

Наконец, QCacheGrind - это приложение, позволяющее визуально просматривать профили, сгенерированные Xdebug. QCacheGrind доступен для MacOS и Windows. Если вы используете Linux, посмотрите на KCachegrind, который почти такой же.

Getting a baseline

Для начала нам нужно иметь возможность проверить, оказывают ли изменения, которые мы вносим в наш код, положительное или отрицательное влияние на производительность. Лучший способ сделать это - создать несколько тестов с помощью PHPBench. Эти тесты будут выполняться несколько раз, и среднее время, которое это займет, можно использовать в качестве метрики, чтобы увидеть, изменили ли мы ситуацию, обновив код.

PHPBench работает очень похоже на PHPUnit. Вы создаете несколько файлов тестов производительности (например, тесты) с тестовыми случаями (например, тестовыми случаями). Команда PHPBench выполнит эти файлы так же, как и команда PHPUnit. Только он выполняет дела несколько раз и отслеживает, сколько времени это занимает.

Мы начинаем как обычно, устанавливая PHPBench:

composer require phpbench/phpbench --dev

Next, мы создаем phpbench.json файл, который настраивает PHPbench:

{
     "$schema":"./vendor/phpbench/phpbench/phpbench.schema.json",
     "runner.bootstrap": "vendor/autoload.php",
     "runner.path" : "benchmarks"
 }

Опция runner.path определяет каталог, в котором расположены наши тесты, поэтому мы создаем для них новый каталог:

mkdir benchmarks

Последнее, что нужно сделать, это создать тест. Мы называем этот файл ExampleBench.php. Обратите внимание на суффикс Bench . Использование этого суффикса необходимо, поскольку в противном случае PHPBench не выполнит файл. В пределах ExampleBench.php, мы добавим ExampleBench класс:

class ExampleBench
{
     
}

Давайте создадим первый тест:

use PhpBench\Attributes\Iterations;
use PhpBench\Attributes\Revs;

class ExampleBench
{
    #[Revs(500), Iterations(5)]
    public function benchPow()
    {
		pow(2, 10);
    }
}

Как вы можете видеть, мы будем тестировать, сколько времени требуется для вычисления 2^10. Добавленный контрольный случай называется benchPow. Обратите внимание на префикс ключевого bench слова, которое требуется PHPBench для поиска тестового случая.

Мы также добавили два атрибута к методу:

Revs, сокращение от оборотов, относится к количеству раз, когда тест выполняется последовательно в течение одного измерения времени. Чем больше оборотов, тем точнее результат. В этом случае мы рассчитаем 2^10 колоссальных 500 раз!

В идеальном мире мы закончили и можем начать измерение. Но выполнение этих оборотов несколько раз безопаснее и правильнее, чтобы наши измерения были стабильными и не слишком отличались.

У нас есть пять итераций по 500 оборотов, так что функция pow будет выполняться 5 * 500 = 2500 раз.

Теперь пришло время запустить PHPBench:

vendor/bin/phpbench run  --report=default

И мы получаем следующий результат:

PHPBench (1.2.9) running benchmarks... #standwithukraine
with configuration file: /Users/ruben/Spatie/laravel-data/phpbench.json
with PHP version 8.2.3, xdebug , opcache 

\ExampleBench

    benchPow................................I4 - Mo0.038μs (±3.33%)

Subjects: 1, Assertions: 0, Failures: 0, Errors: 0
+------+--------------+----------+-----+------+------------+----------+--------------+----------------+
| iter | benchmark    | subject  | set | revs | mem_peak   | time_avg | comp_z_value | comp_deviation |
+------+--------------+----------+-----+------+------------+----------+--------------+----------------+
| 0    | ExampleBench | benchPow |     | 500  | 1,812,376b | 0.036μs  | -1.58σ       | -5.26%         |
| 1    | ExampleBench | benchPow |     | 500  | 1,812,376b | 0.038μs  | +0.00σ       | +0.00%         |
| 2    | ExampleBench | benchPow |     | 500  | 1,812,376b | 0.038μs  | +0.00σ       | +0.00%         |
| 3    | ExampleBench | benchPow |     | 500  | 1,812,376b | 0.038μs  | +0.00σ       | +0.00%         |
| 4    | ExampleBench | benchPow |     | 500  | 1,812,376b | 0.040μs  | +1.58σ       | +5.26%         |
+------+--------------+----------+-----+------+------------+----------+--------------+----------------+

Похоже, что 0,038 мкс - лучшее среднее значение, которое у нас есть. Давайте посмотрим, что самое быстрое:

  1. 2^20 2^10
  2. * 2^10

Наш бенчмарк теперь будет выглядеть так:

class ExampleBench
{
    #[Revs(500), Iterations(5)]
    public function benchPow()
    {
        pow(2, 20);
    }

    #[Revs(500), Iterations(5)]
    public function benchPowPow()
    {
        pow(2, 10) * pow(2, 10);
    }
}

Запуск PHPBench дает следующие результаты:

PHPBench (1.2.9) running benchmarks... #standwithukraine
with configuration file: /Users/ruben/Spatie/laravel-data/phpbench.json
with PHP version 8.2.3, xdebug , opcache 

\ExampleBench

    benchPow................................I4 - Mo0.039μs (±3.78%)
    benchPowPow.............................I4 - Mo0.060μs (±11.25%)

Subjects: 2, Assertions: 0, Failures: 0, Errors: 0
+------+--------------+-------------+-----+------+------------+----------+--------------+----------------+
| iter | benchmark    | subject     | set | revs | mem_peak   | time_avg | comp_z_value | comp_deviation |
+------+--------------+-------------+-----+------+------------+----------+--------------+----------------+
| 0    | ExampleBench | benchPow    |     | 500  | 1,812,376b | 0.040μs  | +0.27σ       | +1.01%         |
| 1    | ExampleBench | benchPow    |     | 500  | 1,812,376b | 0.042μs  | +1.60σ       | +6.06%         |
| 2    | ExampleBench | benchPow    |     | 500  | 1,812,376b | 0.038μs  | -1.07σ       | -4.04%         |
| 3    | ExampleBench | benchPow    |     | 500  | 1,812,376b | 0.038μs  | -1.07σ       | -4.04%         |
| 4    | ExampleBench | benchPow    |     | 500  | 1,812,376b | 0.040μs  | +0.27σ       | +1.01%         |
| 0    | ExampleBench | benchPowPow |     | 500  | 1,812,376b | 0.076μs  | +1.47σ       | +16.56%        |
| 1    | ExampleBench | benchPowPow |     | 500  | 1,812,376b | 0.072μs  | +0.93σ       | +10.43%        |
| 2    | ExampleBench | benchPowPow |     | 500  | 1,812,376b | 0.060μs  | -0.71σ       | -7.98%         |
| 3    | ExampleBench | benchPowPow |     | 500  | 1,812,376b | 0.060μs  | -0.71σ       | -7.98%         |
| 4    | ExampleBench | benchPowPow |     | 500  | 1,812,376b | 0.058μs  | -0.98σ       | -11.04%        |
+------+--------------+-------------+-----+------+------------+----------+--------------+----------------+

Так что в основном, это стоит на 50% больше времени для вычисления двух pow(2, 10) функций, чем одна pow(2, 20), это довольно полезно!

Теперь вернемся к laravel-data, мы хотим знать, как быстро мы можем создать объект данных и как быстро мы можем преобразовать его в объект JSON. Мы можем описать это следующим образом:

class DataBench
{
	#[Revs(500), Iterations(2)]
    public function benchDataCreation()
    {
        MultiNestedData::from([
            'nested' => ['simple' => 'Hello'],
            'nestedCollection' => [
                ['simple' => 'Flare'],
                ['simple' => 'is'],
                ['simple' => 'awesome'],
            ],
        ]);
    }

    #[Revs(500), Iterations(2)]
    public function benchDataTransformation()
    {
        $data = new MultiNestedData(
            new NestedData(new SimpleData('Hello')),
            new DataCollection(NestedData::class, [
                new NestedData(new SimpleData('Flare')),
                new NestedData(new SimpleData('is')),
                new NestedData(new SimpleData('awesome')),
            ])
        );

        $data->toArray();
    }

    #[Revs(500), Iterations(2)]
    public function benchDataCollectionCreation()
    {
        $collection = Collection::times(
            15,
            fn() => [
                'nested' => ['simple' => 'Hello'],
                'nestedCollection' => [
                    ['simple' => 'Flare'],
                    ['simple' => 'is'],
                    ['simple' => 'awesome'],
                ],
            ]
        )->all();

        MultiNestedData::collection($collection);
    }

    #[Revs(500), Iterations(2)]
    public function benchDataCollectionTransformation()
    {
        $collection = Collection::times(
            15,
            fn() => new MultiNestedData(
                new NestedData(new SimpleData('Hello')),
                new DataCollection(NestedData::class, [
                    new NestedData(new SimpleData('Flare')),
                    new NestedData(new SimpleData('is')),
                    new NestedData(new SimpleData('awesome')),
                ])
            )
        )->all();

        $collection = MultiNestedData::collection($collection);

        $collection->toArray();
    }
}

В первом тесте мы создаем объект данных с помощью массивов. Во втором мы создали объект максимально эффективно, вручную определив его, а затем преобразовав в JSON.

Мы также добавили два случая, когда мы делаем то же самое, но вместо того, чтобы использовать объекты данных, мы тестируем использование коллекций данных нескольких объектов данных.

Теперь запуск PHPBench завершится ошибкой, так как пакет laravel-data зависит от некоторой функциональности Laravel. К счастью, Laravel предоставляет отличную инфраструктуру тестирования, которую мы можем использовать в нашем тесте. Мы делаем это, используя CreatesApplication признак, который также присутствует в базовом тестовом случае Laravel:

use Tests\CreatesApplication;

class DataBench
{
    use CreatesApplication;

    public function __construct()
    {
        $this->createApplication();
    }
    
    // The benchmarks
}   

Нам нужно еще одно обновление, потому что мы тестируем пакет laravel, а не приложение laravel. Это означает, что мы должны использовать CreatesApplication признак из orchestra/testbench пакета, который используется для тестирования пакетов Laravel. И нам также нужно указать поставщика услуг laravel-data для загрузки пакета:

use Orchestra\Testbench\Concerns\CreatesApplication;

class DataBench
{
    use CreatesApplication;

    public function __construct()
    {
        $this->createApplication();
    }

    protected function getPackageProviders($app)
    {
        return [
            LaravelDataServiceProvider::class,
        ];
    }
    
    // The benchmarks
} 

Отлично, теперь у нас есть несколько тестов, готовых измерить скорость нашего кода!

Getting started with Xdebug

Далее нам нужно профилировать наш код. Когда мы профилируем наш код, мы будем запускать его немного иначе, чем обычно. При профилировании процесс PHP будет записывать каждый вызов функции, который мы делаем, а также отслеживать, сколько времени требуется для запуска функции. В конечном счете, вся эта информация будет записана в файл, который мы можем проанализировать.

Чтобы это работало, нам нужно установить и включить Xdebug. С инструкциями можно ознакомиться здесь.

Для меня на Mac это было так же просто

sudo pecl install xdebug

: Нам также нужно обновить или создать файл Xdebug .ini:

[xdebug]

xdebug.mode=profile
xdebug.start_with_request=yes

На моем Mac я поместил этот файл здесь: nano /opt/homebrew/etc/php/8.2/conf.d/xdebug.ini.

Мы настраиваем два варианта:

  • xdebug.mode=profile включить профилирование в Xdebug.
  • xdebug.start_with_request=yes начать профилирование с помощью PHPBench. Когда вы закончите с профилированием, не забудьте установить для него значение off или trigger. В противном случае загрузка всех ваших PHP-приложений займет много времени.

У нас есть свои бенчмарки, и у нас есть наш профайлер. Теперь нам нужно только объединить эти два, и тогда мы сможем обнаружить, почему наш код такой медленный!

Profiling the code

Мы перезапустим наши бенчмарки, но на этот раз мы также профилируем бенчмарки. Мы можем сделать это с помощью следующей команды:

vendor/bin/phpbench xdebug:profile

Которая дает следующий результат:

PHPBench (1.2.9) running benchmarks... #standwithukraine
with configuration file: /Users/ruben/Spatie/laravel-data/phpbench.json
with PHP version 8.2.3, xdebug , opcache 

\DataBench

    benchDataCreation.......................I0 - Mo10.762ms (±0.00%)
    benchDataTransformation.................I0 - Mo1.095ms (±0.00%)
    benchDataCollectionCreation.............I0 - Mo159.879ms (±0.00%)
    benchDataCollectionTransformation.......I0 - Mo13.952ms (±0.00%)

Subjects: 4, Assertions: 0, Failures: 0, Errors: 0

4 profile(s) generated:

/Users/ruben/Spatie/laravel-data/.phpbench/xdebug-profile/3fcb020036c8d7e8efdcddd4dbd66b92.cachegrind.gz
/Users/ruben/Spatie/laravel-data/.phpbench/xdebug-profile/8deba1dcce573e1bf818772ac7a5ace0.cachegrind.gz
/Users/ruben/Spatie/laravel-data/.phpbench/xdebug-profile/06d049dacb2a7a3809e069c6c8289e02.cachegrind.gz
/Users/ruben/Spatie/laravel-data/.phpbench/xdebug-profile/41c9fe618b93431786fff90b1d624b82.cachegrind.gz

Запуск набора тестов теперь занимает гораздо больше времени, чем раньше. В конце концов, у нас есть новый каталог с нашими профилями:

  • .phpbench/xdebug-profile/3fcb020036c8d7e8efdcddd4dbd66b92.cachegrind.gz.phpbench
  • /xdebug-profile/8deba1dcce573e1bf818772ac7a5ace0.cachegrind.gz.phpbench
  • /xdebug-profile/06d049dacb2a7a3809e069c6c8289e02.cachegrind.gz
  • .phpbench/xdebug-profile/41c9fe618b93431786fff90b1d624b82.cachegrind.gz

Каждый файл соответствует тесту производительности в наборе тестов. Лучший способ узнать, какой файл принадлежит к какому тесту, - это посмотреть на порядок выполнения тестов и сопоставить их с файлами в том порядке, в котором они были созданы.

На данный момент все файлы сжимаются. Давайте распакуем их:

gzip --decompress .phpbench/xdebug-profile/*

Analyzing

наконец-то пришло время выяснить, почему наш код медленный. Это также самый сложный шаг в процессе, и нет руководства по быстрому поиску узкого места. Вы должны хорошо понимать свой код и уметь видеть странное поведение в профилях. Некоторые вещи, которые могут выделиться:

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

Давайте откроем профиль, где мы создадим коллекцию объектов данных:

Во-первых, мы собираемся настроить QCacheGrind немного больше. Перейдите к:

QCacheGrind -> Preferences -> Source Annotations

И добавьте путь, по которому находится код. Это позволяет QCacheGrind дать некоторые подсказки с примерами кода, какой код медленный.

Приложение имеет три панели:

  • Слева: функции, которые были отсортированы самым длинным вызовом
  • Справа-вверху: вызывающие выбранную функцию
  • Справа-внизу: для выбранной функции, функции, называемые

С левой стороны мы видим все вызываемые функции. Метрика Self показывает, сколько времени потребовалось для вызова функции, метрика Incl. показывает, сколько времени заняла функция и все вызываемые ею функции. Так, например, функция PHPBench занимает 99,93% времени Incl., но она проводит только 0,01% времени внутри функции. Все остальное время используется для вызова других функций, наши бенчмарки с созданием данных.

Сразу мы видим что-то странное. Мы проводим 24,34% времени в Illuminate\Container\Container->resolve(). Давайте нажмем на эту функцию и посмотрим, что происходит:

Эта функция была вызвана Illuminate\Foundation\Application->resolve(). Давайте нажмем на него. Мы видим Illuminate\Container\Container->make() как функцию, обычно вызывающую функциюresolve. Мы продолжаем нажимать на наиболее вызываемые функции, пока не введем файл:

Из этих данных мы видим, что мы многое решаем из контейнера Laravel:

  • Data::from 67500 элементовDataPipeline in a closure (line 75)
  • -> 225000 элементовDataPipeline in a closure (line 69)
  • -> 187500 элементов

Что именно здесь происходит?helpers.php Давайте посмотрим на DataPipeline код:

public function execute(): Collection
{
    /** @var \Spatie\LaravelData\Normalizers\Normalizer[] $normalizers */
    $normalizers = array_map(
        fn (string|Normalizer $normalizer) => is_string($normalizer) ? app($normalizer) : $normalizer,
        $this->normalizers
    );

    /** @var \Spatie\LaravelData\DataPipes\DataPipe[] $pipes */
    $pipes = array_map(
        fn (string|DataPipe $pipe) => is_string($pipe) ? app($pipe) : $pipe,
        $this->pipes
    );
    
    // Other code, hidden to keep things a bit easier to read
}

Этот код не выглядит как проблема производительности. Да, мы создаем эти нормализаторы и каналы из контейнера, но пока этот метод не выполняется так много раз, в конце концов не будет проблем. Этот процесс должен где-то происходить.

Давайте узнаем, откуда этот код вызывается:

public function execute(string $class, mixed ...$payloads): BaseData
{
    $properties = new Collection();

    foreach ($payloads as $payload) {
        /** @var BaseData $class */
        $pipeline = $class::pipeline();

        foreach ($class::normalizers() as $normalizer) {
            $pipeline->normalizer($normalizer);
        }

        foreach ($pipeline->using($payload)->execute() as $key => $value) {
            $properties[$key] = $value;
        }
    }
    
    // Other code, hidden to keep things a bit easier to read
}

Это могло бы быть лучше. Мы создаем конвейер для каждого объекта данных, который мы строим, и, таким образом, мы каждый раз разрешаем все каналы и нормализаторы из контейнера. Каждый раз это не требуется, так как конвейер статически построен для объекта данных и, следовательно, одинаков для всех объектов.

Для некоторых объектов это будет нормально. Но поскольку у нас есть коллекции одних и тех же объектов (иногда более 500), мы сталкиваемся с огромной проблемой производительности.

Теперь мы кэшируем эти конвейеры в версии 3.2 laravel-data и добавляем некоторые другие улучшения производительности. Это должно многое сделать для нашего выступления! Наконец, давайте перезапустим тесты.

До оптимизации производительности:

benchData...............................I1 - Mo293.376μs (±0.79%)
benchDataCollection.....................I1 - Mo5.619ms (±0.48%)

После оптимизации производительности:

benchData...............................I1 - Mo125.731μs (±0.57%)
benchDataCollection.....................I1 - Mo2.111ms (±0.82%)

Для простых объектов данных мы повысили производительность на 57%, а для коллекций данных — на 62%. Это огромно!

In the end

За несколько часов работы мы резко сократили штраф за производительность на Flare. Профилирование наиболее важных частей вашего приложения может быть ценным, но это не так просто!

Мы продолжаем редизайн и рефакторинг Flare в течение следующих недель и надеемся запустить его в ближайшее время! Вы можете найти предварительный просмотр нашего нового дизайна здесь. Вы заинтересованы в бета-тестировании новой версии Flare? Пожалуйста, отправьте нам электронное письмо по адресу [email protected]

Comments

No comments yet
Yurij Finiv

Yurij Finiv

Full stack

Про мене

Professional Fullstack Developer with extensive experience in website and desktop application development. Proficient in a wide range of tools and technologies, including Bootstrap, Tailwind, HTML5, CSS3, PUG, JavaScript, Alpine.js, jQuery, PHP, MODX, and Node.js. Skilled in website development using Symfony, MODX, and Laravel. Experience: Contributed to the development and translation of MODX3 i...

Об авторе CrazyBoy49z
WORK EXPERIENCE
Контакты
Ukraine, Lutsk
+380979856297