• Czas czytania ~12 min
  • 28.03.2023

Cały nasz zespół obecnie ciężko pracuje nad kolejną wersją Flare. Całkowicie przeprojektowujemy aplikację i stronę internetową i jest to ogromny wysiłek, ale niewątpliwie zaprocentuje w przyszłości.

W tym przepisaniu Flare zdecydowaliśmy się przejść z zasobów Laravel do naszego własnego pakietu danych Laravel do wysyłania danych do front-endu. Spowoduje to nie tylko uzyskanie w pełni wpisanej odpowiedzi, ale także uzyskanie definicji TypeScript dla naszych zasobów bez dodatkowego wysiłku.

Gdy wszystkie zasoby zostały ręcznie przekonwertowane na obiekty danych, byliśmy podekscytowani, aby sprawdzić, czy wszystko działa poprawnie. Chociaż nie trafiliśmy na żadne wyjątki ani błędy, niektóre strony aplikacji wydawały się bardzo powolne, zbyt wolne, aby były przydatne dla naszych klientów.

Szybko odkryliśmy, że większość czasu reakcji poświęcono na obiekty danych. Zaczęliśmy od optymalizacji obiektów danych za pomocą leniwej funkcji, która umożliwia późniejsze ładowanie niektórych części danych, gdy strona początkowa jest już wysłana. Dzięki temu strony były szybsze, ale nie chcieliśmy na tym poprzestać, ponieważ całkowicie przeprojektowujemy całą aplikację.

Pakiet laravel-data jest fantastyczny w obsłudze, ale dodaje również wiele złożoności podczas wysyłania danych. W tym poście na blogu przyjrzymy się, w jaki sposób poprawiliśmy wydajność pakietu, a tym samym kompletną aplikację Flare.

Aby rozpocząć, przedstawię Ci trzy narzędzia, których możesz użyć w swoim zestawie narzędzi do badania problemów z wydajnością: Xdebug, PHPBench i QCacheGrind.

Xdebug to rozszerzenie PHP, które zapewnia możliwości debugowania i profilowania. Jest najbardziej znany ze skomplikowanej konfiguracji, która może spowolnić całą aplikację. Na szczęście Xdebug w wersji 3 poczynił znaczne postępy w tej części, a konfiguracja Xdebug jest teraz stosunkowo łatwa.

PHPBench to narzędzie do analizy porównawczej dla PHP, które może pomóc w pomiarze wydajności kodu. Umożliwia tworzenie przypadków testowych i uruchamianie ich wiele razy, aby uzyskać pojęcie o szybkości kodu.

Wreszcie, QCacheGrind jest aplikacją pozwalającą na wizualne przeglądanie profili generowanych przez Xdebug. QCacheGrind jest dostępny dla systemów MacOS i Windows. Jeśli korzystasz z Linuksa, spójrz na KCachegrind, który jest prawie taki sam.

Getting a baseline

Aby rozpocząć, musimy być w stanie sprawdzić, czy zmiany, które wprowadzamy w naszym kodzie, mają pozytywny czy negatywny wpływ na wydajność. Najlepszym sposobem na to jest utworzenie kilku testów porównawczych za pomocą PHPBench. Te testy porównawcze będą uruchamiane wiele razy, a średni czas tego czasu można wykorzystać jako metrykę, aby sprawdzić, czy dokonaliśmy zmiany, aktualizując kod.

PHPBench działa bardzo podobnie do PHPUnit. Tworzysz kilka plików testów porównawczych (takich jak testy) z przypadkami testów porównawczych (takimi jak przypadki testowe). Polecenie PHPBench wykona te pliki, tak jak polecenie PHPUnit. Tylko wykonuje sprawy wiele razy i śledzi, ile czasu to zajmuje.

Zaczynamy jak zwykle, instalując PHPBench:Następnie tworzymy plik, który konfiguruje PHPbench:

composer require phpbench/phpbench --dev

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

Opcja runner.path definiuje katalog, w którym znajdują się nasze benchmarki, więc tworzymy phpbench.json dla nich nowy katalog:

mkdir benchmarks

Ostatnią rzeczą do zrobienia jest utworzenie benchmarku. Nazywamy ten plik ExampleBench.php. Zwróć uwagę na przyrostekBench. Użycie tego sufiksu jest niezbędne, ponieważ w przeciwnym razie PHPBench nie wykona pliku. W ramach ExampleBench.php, dodajemy klasęExampleBench :Stwórzmy pierwszy benchmark:

class ExampleBench
{
     
}

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

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

Jak widać, porównamy, ile czasu zajmuje obliczenie 2^10. Przypadek porównawczy, który dodaliśmy, nazywa się benchPow. Zwróć uwagę na prefiks słowa kluczowegobench, który jest wymagany, aby PHPBench znalazł przypadek benchmarku.

Dodaliśmy również dwa atrybuty do metody:

Revs, skrót od obrotów, odnosi się do liczby kolejnych razy testu porównawczego w ciągu jednego pomiaru czasu. Im więcej obrotów, tym dokładniejszy wynik. W tym przypadku obliczymy 2^10 aż 500 razy!

W idealnym świecie skończyliśmy i możemy zacząć mierzyć. Ale wielokrotne wykonywanie tych obrotów jest bezpieczniejsze i bardziej poprawne, aby zapewnić, że nasze pomiary są stabilne i nie różnią się zbytnio.

Mamy pięć iteracji po 500 obrotów, dzięki czemu funkcja pow zostanie wykonana dla 5 * 500 = 2500 razy.

Teraz nadszedł czas, aby uruchomić PHPBench: I otrzymujemy następujący wynik:

vendor/bin/phpbench run  --report=default

Wygląda na to, że 0.038μs to najlepsza średnia,

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%         |
+------+--------------+----------+-----+------+------------+----------+--------------+----------------+

jaką mamy. Zobaczmy, co jest najszybsze: Nasz test porównawczy będzie teraz wyglądał tak:Uruchomienie PHPBench daje następujące wyniki:

Zasadniczo obliczanie dwóch pow(2, 10) funkcji kosztuje 50

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 (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%        |
+------+--------------+-------------+-----+------+------------+----------+--------------+----------------+

% więcej czasu niż jednejpow(2, 20), to całkiem przydatne!

Wracając do laravel-data, chcemy wiedzieć, jak szybko możemy utworzyć obiekt danych i jak szybko możemy przekształcić go w obiekt JSON. Możemy to opisać w następujący sposób:

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();
    }
}

W pierwszym teście porównawczym tworzymy obiekt danych za pomocą tablic. W drugim stworzyliśmy obiekt tak efektywnie, jak to możliwe, ręcznie go definiując, a następnie przekształcając do JSON.

Dodaliśmy również dwa przypadki, w których robimy to samo, ale zamiast używać obiektów danych, przeprowadzamy testy porównawcze przy użyciu kolekcji danych wielu obiektów danych.

Teraz uruchomienie PHPBench nie powiedzie się, ponieważ pakiet laravel-data zależy od niektórych funkcji Laravel. Na szczęście Laravel zapewnia doskonałą infrastrukturę testową, którą możemy wykorzystać w ramach naszego benchmarku. Robimy to za pomocą CreatesApplication cechy, która jest również obecna w podstawowym przypadku testowym Laravel: Potrzebujemy kolejnej aktualizacji,

use Tests\CreatesApplication;

class DataBench
{
    use CreatesApplication;

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

ponieważ testujemy pakiet laravel, a nie aplikację laravel. Oznacza to, że musimy użyć CreatesApplication cechy z pakietuorchestra/testbench, która jest używana do testowania pakietów Laravel. Musimy również określić dostawcę usług laravel-data, aby uruchomić pakiet:

use Orchestra\Testbench\Concerns\CreatesApplication;

class DataBench
{
    use CreatesApplication;

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

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

Świetnie, mamy teraz kilka testów porównawczych gotowych do pomiaru szybkości naszego kodu!

Getting started with Xdebug

Następnie musimy sprofilować nasz kod. Kiedy profilujemy nasz kod, uruchamiamy go nieco inaczej niż zwykle. Podczas profilowania proces PHP zapisze każde wywołanie funkcji, które wykonamy, a także śledzi, ile czasu zajmuje uruchomienie funkcji. Ostatecznie wszystkie te informacje zostaną zapisane w pliku, który możemy przeanalizować.

Aby to działało, musimy zainstalować i włączyć Xdebug. Instrukcje znajdziesz tutaj.

Dla mnie na komputerze Mac było to tak proste: Musimy również zaktualizować lub utworzyć plik .ini Xdebug:Na moim Macu umieściłem ten plik tutaj:

sudo pecl install xdebug

[xdebug]

xdebug.mode=profile
xdebug.start_with_request=yes

nano /opt/homebrew/etc/php/8.2/conf.d/xdebug.ini .

Konfigurujemy dwie opcje:

  • xdebug.mode=profile aby włączyć profilowanie w Xdebug. aby rozpocząć profilowanie za pomocą PHPBench.
  • xdebug.start_with_request=yes Po zakończeniu profilowania nie zapomnij ustawić tego na off lub trigger. W przeciwnym razie ładowanie wszystkich aplikacji PHP zajmie wieki.

Mamy swoje benchmarki i mamy swojego profilera. Teraz wystarczy połączyć te dwa elementy, a wtedy możemy odkryć, dlaczego nasz kod jest tak wolny!

Profiling the code

Powtórzymy nasze benchmarki, ale tym razem również sprofilujemy benchmarki. Możemy to zrobić za pomocą następującego polecenia:Co daje następujące dane wyjściowe:

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

Uruchomienie pakietu testów porównawczych trwa teraz znacznie dłużej niż wcześniej. W końcu mamy nowy katalog z naszymi profilami:

  • .phpbench/xdebug-profile/3fcb020036c8d7e8efdcddd4dbd66b92.cachegrind.gz
  • .phpbench/xdebug-profile/41c9fe618b93431786fff90b1d624b82.cachegrind.gz

Każdy plik odpowiada przypadkowi testu porównawczego w ramach pakietu testów porównawczych. Najlepszym sposobem, aby dowiedzieć się, który plik należy do którego testu porównawczego, jest sprawdzenie kolejności wykonywania spraw porównawczych i dopasowanie ich do plików w kolejności, w jakiej zostały utworzone.

W tej chwili wszystkie pliki są skompresowane. Rozpakujmy je: W końcu nadszedł czas,

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

Analyzing

aby dowiedzieć się, dlaczego nasz kod jest powolny. Jest to również najbardziej skomplikowany krok w procesie i nie ma przewodnika, aby szybko znaleźć wąskie gardło. Powinieneś dobrze rozumieć swój kod i być w stanie dostrzec dziwne zachowanie w profilach. Niektóre rzeczy, które mogą się wyróżniać:funkcje, które nie powinny być wykonywane

  • funkcje, które są wykonywane zbyt wiele
  • funkcji, które trwają dłużej niż oczekiwano
  • Otwórzmy profil, w którym utworzymy kolekcję obiektów danych:

Najpierw skonfigurujemy QCacheGrind nieco bardziej. Przejdź do:I dodaj ścieżkę,

QCacheGrind -> Preferences -> Source Annotations

w której znajduje się Twój kod. Pozwala to QCacheGrind na podanie kilku wskazówek z przykładami kodu, który kod jest powolny.

Aplikacja posiada trzy panele:Po lewej: funkcje, które zostały wywołane posortowane według najdłuższego wywołania

  • Po prawej stronie: wywołujące wybraną funkcję
  • Po prawej stronie-na dole:
  • dla wybranej funkcji, funktios o nazwie

Po lewej stronie widzimy wszystkie wywoływane funkcje. Metryka Self pokazuje, ile czasu zajęło wywołanie funkcji, metryka Incl. pokazuje, jak długo trwała funkcja i wszystkie funkcje, które wywołała. Na przykład funkcja PHPBench zajmuje 99,93% czasu włącznie, ale spędza tylko 0,01% czasu w funkcji. Cały pozostały czas jest używany do wywoływania innych funkcji, naszych benchmarków z tworzeniem danych.

Natychmiast widzimy coś dziwnego. Spędzamy 24.34% czasu w Illuminate\Container\Container->resolve(). Kliknijmy tę funkcję i zobaczmy, co się dzieje:

Ta funkcja została wywołana przez Illuminate\Foundation\Application->resolve(). Kliknijmy na to. Widzimy Illuminate\Container\Container->make() jako funkcję, zwykle wywołując resolve funkcję. Klikamy najczęściej wywoływane funkcje, aż wejdziemy do helpers.php pliku:Z tych danych widzimy, że rozwiązujemy wiele z kontenera Laravel:

  • Data::from 67500 elementów -> 225000 pozycji
  • DataPipeline in a closure (line 69) -> 187500 elementów
  • DataPipeline in a closure (line 75)

Co dokładnie się tutaj dzieje? Rzućmy okiem na DataPipeline kod:

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
}

Ten kod nie wygląda na problem z wydajnością. Tak, tworzymy te normalizatory i potoki z kontenera, ale dopóki ta metoda nie jest wykonywana tak wiele razy, ostatecznie nie będzie problemu. Ten proces powinien gdzieś nastąpić.

Dowiedzmy się, skąd ten kod jest wywoływany:

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
}

To mogłoby być lepsze. Tworzymy potok dla każdego tworzonego obiektu danych, a tym samym za każdym razem rozwiązujemy wszystkie potoki i normalizatory z kontenera. Za każdym razem nie jest to wymagane, ponieważ potok jest statycznie konstruowany dla obiektu danych, a zatem taki sam dla wszystkich obiektów.

W przypadku kilku obiektów będzie to w porządku. Ale ponieważ mamy kolekcje tych samych obiektów (czasami ponad 500), napotykamy ogromny problem wydajności.

Teraz buforujemy te potoki w wersji 3.2 laravel-data i dodajemy kilka innych ulepszeń wydajności. To powinno wiele zrobić dla naszej wydajności! Na koniec przeprowadźmy testy porównawcze.

Przed optymalizacją wydajności:Po optymalizacji wydajności:

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%)

W przypadku zwykłych obiektów danych zwiększyliśmy wydajność o 57%, a w przypadku kolekcji danych o 62%. To ogromne!

In the end

Dzięki kilku godzinom pracy drastycznie zmniejszyliśmy karę za wydajność na Flare. Profilowanie najbardziej krytycznych części aplikacji może być cenne, ale nie jest takie proste!

Przez następne tygodnie przeprojektowujemy i refaktoryzujemy Flare i mamy nadzieję, że wkrótce wystartujemy! Podgląd naszego nowego projektu można znaleźć tutaj. Czy jesteś zainteresowany testami beta nowej wersji Flare? Wyślij nam e-mail na adres [email protected]

Comments

No comments yet
Yurij Finiv

Yurij Finiv

Full stack

O

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...

O autorze CrazyBoy49z
WORK EXPERIENCE
Kontakt
Ukraine, Lutsk
+380979856297