Capa de post contendo engrenagens funcionando juntas

Preload em aplicações Laravel


guias novidades

O Preload foi introduzido ao core do PHP na versão 7.4 e pode trazer ganhos significativos de performance e redução do consumo de memória em aplicações Laravel. Neste post vamos entender o que é esta feature, como ativá-la e configurá-la em aplicações Laravel.

Como o PHP é interpretado

Antes de falar especificamente de preloading é interessante entender como o PHP é interpretado. Este assunto já foi muito bem abordado neste excelente artigo do @nawarian. Ao explicar como o JIT (just in time compiler) funciona também há uma explicação de como o código PHP é interpretado e suas etapas, especificamente no diagrama de fluxo abaixo podemos ver o passo a passo do que acontece quando rodamos um código PHP.

Fluxo de interpretação do PHP com Opcache. Se um arquivo já foi interpretado, o php busca o Opcode em cache em vez de realizar o parsing novamente.

A parte que nos interessa neste artigo está entre o passo de compilação e transformação em opcodes pois é nesta etapa que o preloading irá agir!

Basicamente todo código PHP vira opcode em algum momento e para se transformar em opcode rola todo este fluxo apresentado. Mas e se fosse possível “pular" algumas destas etapas?

Olá, meu nome é Preload

Preload, como o nome já sugere, é a técnica implementada para fazer um pré-carregamento de alguma coisa, no nosso caso, scripts PHP.

Esta técnica nos ajuda -e muito- em alguns cenários principalmente onde uma grande quantia de código PHP reside (alô, ouvi frameworks?). Basicamente o que acontece é que o preload se encarrega de carregar uma série de arquivos PHP de uma lista pré-definida no momento em que o servidor PHP é iniciado, mas como isso ajuda em algo?

Quando uma requisição é enviada ao servidor, a primeira coisa que será feita é verificar se aquele item solicitado já está armazenado no opcache:

Fluxo de interpretação do PHP com Opcache. Se um arquivo já foi interpretado, evidenciando com um retângulo vermelho que o php busca o Opcode em cache em vez de realizar o parsing novamente.

Caso o item solicitado já esteja no opcache todo o resto do contexto é “pulado" e o código é executado de forma direta. Com o preload habilitado e configurado, este processo de gerar opcodes e armazenar no opcache é feito em tempo de inicialização do servidor PHP e não mais em tempo de execução, desta forma todo código que estiver definido nas configurações do preload serão executados “direto" e isso pode gerar ganhos consideráveis tanto em tempo de requisição quanto consumo de recursos.

Como habilitar o Preload

Para a demonstração o ambiente utilizado contará com Ubuntu 20, PHP 7.4 FPM e Nginx.

Habilitar o preloading é relativamente simples! Basta ter acesso/permissão para mexer nas configurações do PHP, ter o opcache ativo e passar um script no qual será feito o preloading dos arquivos PHP. Para que seja possível fazer o preloading de scripts PHP, é necessário que o script passado nas configurações (conforme vamos fazer já já) faça o require do script que gostaríamos que que fosse pré-carregado ou então utilizando a função opcache_compile_file().

Um ponto muito importante a ser ressaltado é que ao trabalhar com orientação a objetos é comum termos muitos use statements, herança, traits etc. Isso influencia na forma como devemos fazer o preloading, não basta gerar o opcache apenas de uma classe específica e não gerar das suas dependências, herança e tudo mais. É por isso que no script para fazer o preloading do Laravel vamos utilizar o require_once pois quando um arquivo é utilizado com esta diretiva, todas suas dependências (use statements) são resolvidas e também serão carregadas para o opcache.

Preload com Laravel

Antes de prosseguir com o preloading é importante observar que o Laravel em específico tem algumas considerações importantes que precisam ser feitas. Não é possível pré-carregar todo o framework pois algumas classes precisam que o bootstrap da aplicação seja feito para que possam ser resolvidas pelo container de injeção de dependência (DI Container), este é o caso das classes Cache, LogManager, o namespace Testing e a classe UploadedFile. Por isso vamos ignorar essas classes e fazer o preloading do restante. Finalmente: vamos ao script que realizará o preload!

Neste ótimo artigo, especificamente na seção indicada (“preloading in practice") o autor criou uma classe excelente para realizar o preloading de arquivos e/ou todo diretório de arquivos que forem indicados no construtor ou no método paths(), como não precisamos reinventar a roda vamos utilizar esta classe e adicionar os itens que queremos e os que não queremos que sejam carregados no preload.

Vamos fazer o preloading do framework como um todo, dos controllers, dos arquivos de bootstrap da aplicação, dos arquivos de configuração e do entrypoint da aplicação. Não é possível fazer o pré-carregamento de todos os arquivos do namespace do Carbon mas algumas das classes mais utilizadas são possíveis então também vamos fazer o preloading destas classes de maneira individual.

A classe Preloader pode ser encontrada de forma completa no github, para o código de exemplo não ficar gigantesco vou mostrar apenas a utilização da classe.

// laravel-preload.php

$dir = __DIR__;
(new Preloader())
    ->paths("{$dir}/vendor/laravel", "{$dir}/app/Http/Controllers",
    "{$dir}/bootstrap", "{$dir}/config", "{$dir}/public/index.php",
    '/home/jose/Studies/palestra-summit-otimizada/vendor/nesbot/carbon/src/Carbon/CarbonImmutable.php',
    '/home/jose/Studies/palestra-summit-otimizada/vendor/nesbot/carbon/src/Carbon/Translator.php',
    '/home/jose/Studies/palestra-summit-otimizada/vendor/nesbot/carbon/src/Carbon/CarbonPeriod.php',
    '/home/jose/Studies/palestra-summit-otimizada/vendor/nesbot/carbon/src/Carbon/CarbonInterval.php',)
    ->ignore(
        \Illuminate\Filesystem\Cache::class,
        \Illuminate\Log\LogManager::class,
        \Illuminate\Http\Testing\File::class,
        \Illuminate\Http\UploadedFile::class,
        'Illuminate\Foundation\Testing',
    )
    ->load();

Agora que já definimos quais arquivos queremos e quais não queremos fazer o preloading, vamos atualizar a configuração do PHP-FPM passando o caminho do script que criamos:

// /etc/php/7.4/fpm/conf.d/10-opcache.ini
; configuration for php opcache module
; priority=10
zend_extension=opcache.so

opcache.preload=/home/jose/Studies/php-summit-2020/laravel-preload.php
opcache.preload_user=www-data

O caminho onde as configurações do PHP-FPM são encontradas pode variar de acordo com a forma que o PHP foi instalado ou sistema operacional, no ubuntu com a instalação padrão deve estar em/etc/php/7.4/fpm.

A diretiva que precisamos utilizar é a opcache.preload e esta diretiva recebe o caminho do script de onde será feito o carregamento dos arquivos que queremos fazer o preload conforme exemplo acima. Quando o PHP-FPM for reiniciado, este script será executado e as classes irão passar pelo processo de interpretação do PHP e será gerado o opcode e este posteriormente será armazenado no opcache.

Outra diretiva necessária é a opcache.preload_user, o valor passado para esta diretiva será o usuário utilizado na execução do preload, geralmente é o mesmo que está rodando o webserver (apache ou nginx) que no nosso caso é o nginx, logo, o usuário padrão é o www-data.

Vamos reiniciar o PHP-FPM:

sudo service php7.4-fpm restart
// ou
sudo systemctl restart php7.4-fpm

Ao verificar os logs gerados pelo processo do PHP podemos verificar como foi o andamento do script, abaixo segue um trecho:

…
jan 03 15:01:27 jose-uotz php-fpm7.4[167700]: [Preloader] Preloaded `Illuminate\Broadcasting\InteractsWithSockets`
jan 03 15:01:27 jose-uotz php-fpm7.4[167700]: [Preloader] Preloaded `Illuminate\Broadcasting\Broadcasters\RedisBroadcaster`
jan 03 15:01:27 jose-uotz php-fpm7.4[167700]: [Preloader] Preloaded `Illuminate\Broadcasting\Broadcasters\PusherBroadcaster`
jan 03 15:01:27 jose-uotz php-fpm7.4[167700]: [Preloader] Preloaded `Illuminate\Broadcasting\Broadcasters\Broadcaster`
jan 03 15:01:27 jose-uotz php-fpm7.4[167700]: [Preloader] Preloaded `Illuminate\Broadcasting\Broadcasters\UsePusherChannelConventions`
jan 03 15:01:27 jose-uotz php-fpm7.4[167700]: [Preloader] Preloaded `Illuminate\Broadcasting\Broadcasters\LogBroadcaster`
jan 03 15:01:27 jose-uotz php-fpm7.4[167700]: [Preloader] Preloaded `Illuminate\Broadcasting\Broadcasters\NullBroadcaster`
jan 03 15:01:27 jose-uotz php-fpm7.4[167700]: [Preloader] Preloaded `Illuminate\Config\Repository`
jan 03 15:01:27 jose-uotz php-fpm7.4[167700]: [Preloader] Preloaded `Laravel\Tinker\TinkerServiceProvider`
jan 03 15:01:27 jose-uotz php-fpm7.4[167700]: [Preloader] Preloaded `Laravel\Tinker\ClassAliasAutoloader`
jan 03 15:01:27 jose-uotz php-fpm7.4[167700]: [Preloader] Preloaded `Laravel\Tinker\Console\TinkerCommand`
jan 03 15:01:27 jose-uotz php-fpm7.4[167700]: [Preloader] Preloaded `Laravel\Tinker\TinkerCaster`
jan 03 15:01:27 jose-uotz php-fpm7.4[167700]: [Preloader] Preloaded `App\Http\Controllers\PostController`
jan 03 15:01:27 jose-uotz php-fpm7.4[167700]: [Preloader] Preloaded `App\Http\Controllers\CommentsController`
jan 03 15:01:27 jose-uotz php-fpm7.4[167700]: [Preloader] Preloaded `App\Http\Controllers\Controller`
jan 03 15:01:27 jose-uotz php-fpm7.4[167700]: [Preloader] Preloaded 855 classes
jan 03 15:01:27 jose-uotz systemd[1]: Started The PHP 7.4 FastCGI Process Manager.
-- Subject: A start job for unit php7.4-fpm.service has finished successfully
-- Defined-By: systemd
-- Support: http://www.ubuntu.com/support
--
-- A start job for unit php7.4-fpm.service has finished successfully.
--
-- The job identifier is 19455.

Pronto, preload habilitado e já foi feita a “mágica"!

É possível verificar quais arquivos estão no opcache através da função opcache_get_status(). Apenas a fim de matar a curiosidade vou deixar um dd(opcache_get_status(true)) ao final do arquivo index.php do Laravel, desta forma qualquer requisição que fizermos irá nos mostrar o status do opcache:

array:8 [▼
  "opcache_enabled" => true
  "cache_full" => false
  "restart_pending" => false
  "restart_in_progress" => false
  "memory_usage" => array:4 [▶]
  "interned_strings_usage" => array:4 [▶]
  "opcache_statistics" => array:13 [▶]
  "scripts" => array:463 [▶]
]

No último índice chamado “scripts" estão os scripts importados para o opcache e tivemos 463 arquivos importados.

Benchmark

Em setembro de 2020 tive a oportunidade de apresentar uma palestra sobre otimização de aplicações Laravel no PHP Community Summit e lá falei sobre o preload. Você pode conferir os slides da palestra onde podemos ver vários exemplos de benchmark realizados. Abaixo segue o exemplo de um mesmo método sendo executado com preloading desativado à esquerda e ativado na direita:

a esquerda imagem com uma requisição sem preload, a direita com preload. A imagem a esquerda mostra ~66% de ganho de memória

Houve um ganho bastante significativo no consumo de memória, foram necessários aproximadamente ~66% menos memória para executar a mesma tarefa apenas por habilitar o preload. A maior parte dos ganhos, conforme evidenciado no print acima, foi no bootstrap do framework onde foi necessário utilizar menos da metade da memória quando o preload está ativado e as classes estão no opcache.

Conclusão

O preload é bastante simples de ser configurado e com pouco esforço podemos ter ganhos bem expressivos quanto ao consumo de recursos e algum ganho com relação ao tempo de resposta. Em conjunto com outras técnicas como caching e as próprias otimizações que o Laravel já faz por padrão somos capazes de obter excelentes ganhos de performance!

Vale lembrar que no exemplo foi feito o preload do framework e dos arquivos pasta Controllers mas somos livres para adicionar/remover quaisquer arquivos que sejam necessários. Provavelmente você irá querer fazer o preloading dos seus services, repositories etc. Aqui cabe tirar um tempo para “brincar" com esta ferramenta e verificar o que faz sentido no seu contexto. Ative o preload, faça testes e descubra a melhor configuração para sua aplicação!

-- Up the Laravel's \o/

← Post Anterior