Como criamos o PHP 7 duas vezes mais rápido que o PHP 5. Parte 2: otimizando o bytecode no PHP 7.1

Na primeira parte da história, com base na apresentação de Dmitry Stogov da Zend Technologies no HighLoad ++, entendemos a estrutura interna do PHP. Aprendemos em detalhes e em primeira mão quais mudanças nas estruturas básicas de dados permitiram que o PHP 7 acelerasse mais de duas vezes. Isso poderia ter sido interrompido, mas já na versão 7.1, os desenvolvedores foram muito mais longe, pois ainda tinham muitas idéias para otimização.

A experiência acumulada trabalhando no JIT antes dos sete agora pode ser interpretada, observando os resultados em 7.0 sem o JIT e os resultados do HHVM com o JIT. No PHP 7.1, foi decidido não trabalhar com o JIT, mas novamente para o intérprete. Se anteriormente a otimização dizia respeito ao intérprete, neste artigo, examinaremos a otimização do bytecode, usando a inferência de tipo que foi implementada para o nosso JIT.



Sob o corte, Dmitry Stogov mostrará como tudo isso funciona, usando um exemplo simples.

Otimização de bytecode


Abaixo está o bytecode no qual o compilador PHP padrão compila a função. É de passagem única - rápido e burro, mas capaz de executar seu trabalho em cada solicitação HTTP novamente (se o OPcache não estiver conectado).


Otimizações do OPcache


Com o advento do OPcache, começamos a otimizá-lo. Alguns métodos de otimização foram incorporados ao OPcache , por exemplo, métodos de otimização de fendas - quando examinamos o código pelo olho mágico, procuramos padrões familiares e os substituímos por heurísticas. Esses métodos continuam a ser usados ​​no 7.0. Por exemplo, temos duas operações: adição e atribuição.


Eles podem ser combinados em uma operação de atribuição composta, que executa a adição diretamente no resultado: ASSIGN_ADD $sum, $i . Outro exemplo é uma variável pós-incremento que teoricamente poderia retornar algum tipo de resultado.


Pode não ser um valor escalar e deve ser removido. Para fazer isso, use as instruções FREE seguir. Mas se você o alterar para um pré-incremento, a instrução FREE não será necessária.


No final, existem duas instruções RETURN : a primeira é um reflexo direto da instrução RETURN no texto de origem e a segunda é adicionada por um compilador burro com um colchete de fechamento. Este código nunca será alcançado e pode ser excluído.
Existem apenas quatro instruções restantes no loop. Parece que não há mais nada a otimizar, mas não para nós.
Veja o $i++ e sua instrução correspondente - o pré-incremento PRE_INC . Cada vez que é executado:

  • precisa verificar que tipo de variável veio;
  • se is_long ;
  • realizar incremento;
  • verifique se há excesso;
  • vá para o próximo;
  • talvez verifique a exceção.

Mas uma pessoa, apenas observando o código PHP, verá que a variável $i está no intervalo de 0 a 100, e não pode haver estouro, as verificações de tipo não são necessárias e também não podem haver exceções. No PHP 7.1, tentamos ensinar o compilador a entender isso .

Otimização do gráfico de fluxo de controle



Para fazer isso, é necessário deduzir tipos e, para inserir tipos, você deve primeiro criar uma representação formal dos fluxos de dados que o computador entende. Mas começaremos criando um gráfico de fluxo de controle, um gráfico de dependência de controle. Inicialmente, dividimos o código em blocos básicos - um conjunto de instruções com uma entrada e uma saída. Portanto, cortamos o código nos locais onde ocorre a transição, ou seja, nos rótulos L0, L1. Também o cortamos após os operadores de ramificação condicional e incondicional e, em seguida, conectamos-o a arcos que mostram as dependências para controle.


Então, nós temos CFG.

Otimização do formulário estático de atribuição única


Bem, agora precisamos de uma dependência de dados. Para fazer isso, usamos o Formulário de atribuição única estática - uma representação popular no mundo da otimização de compiladores. Isso implica que o valor de cada variável pode ser atribuído apenas uma vez.


Para cada variável, adicionamos um índice ou número de reencarnação. Em todos os lugares em que a tarefa ocorre, colocamos um novo índice e os usamos - até os pontos de interrogação, porque nem sempre é conhecido em todos os lugares. Por exemplo, na instrução IS_SMALLER $ i pode vir tanto do bloco L0 com o número 4 quanto do primeiro bloco com o número 2.

Para resolver esse problema, o SSA introduz a pseudo-função Phi , que, se necessário, é inserida no início do bloco básico->, pega todos os tipos de índices de uma variável que chegaram ao bloco básico de diferentes lugares e cria uma nova reencarnação da variável. São essas variáveis ​​que são usadas mais tarde para eliminar a ambiguidade.


Substituindo todos os pontos de interrogação dessa maneira, criaremos o SSA.

Otimização de tipo


Agora deduzimos tipos - como se tentássemos executar esse código diretamente no gerenciamento.


No primeiro bloco, as variáveis ​​recebem valores constantes - zeros, e sabemos com certeza que essas variáveis ​​serão do tipo longa. A seguir, a função Phi. Long chega na entrada e não sabemos os valores de outras variáveis ​​que vieram de outros ramos.


Acreditamos que a saída phi () teremos muito tempo.


Distribuímos mais. Chegamos a funções específicas, por exemplo, ASSIGN_ADD e PRE_INC . Adicione dois longos. O resultado pode ser longo ou duplo se ocorrer um estouro.


Esses valores caem novamente na função Phi, ocorre a união dos conjuntos de tipos possíveis que chegam em diferentes ramos. Bem e assim por diante, continuamos a nos espalhar até chegar a um ponto fixo e tudo se acalmar.


Temos um conjunto possível de valores de tipo em todos os pontos do programa. Isso já é bom. O computador já sabe que $i só pode ser longo ou duplo e pode excluir algumas verificações desnecessárias. Mas sabemos que o dobro de $i não pode ser. Como sabemos? E vemos uma condição que limita o crescimento de $i no ciclo a um possível estouro. Ensinaremos o computador a ver isso.

Otimização da Propagação de Gama


Na instrução PRE_INC nunca descobrimos que eu só posso ser um número inteiro - custa muito tempo ou dobro. Isso acontece porque não tentamos inferir possíveis intervalos. Em seguida, poderíamos responder à pergunta se o estouro ocorrerá ou não.

Essa saída dos intervalos é feita de maneira semelhante, mas um pouco mais complexa. Como resultado, obtemos um intervalo fixo de variáveis $i com os índices 2, 4, 6 7 e agora podemos dizer com segurança que o incremento $i não levará ao estouro.


Combinando esses dois resultados, podemos dizer com certeza que a variável dupla $i nunca $i se tornar.


Tudo o que temos ainda não é otimização, são informações para otimização! Considere a ASSIGN_ADD . Em termos gerais, o valor antigo da soma que veio a esta instrução pode ser, por exemplo, um objeto. Depois da adição, o valor antigo deveria ter sido removido. Mas, no nosso caso, sabemos com certeza que existe um valor longo ou duplo, ou seja, um valor escalar. Nenhuma destruição é necessária, podemos substituir ASSIGN_ADD por ADD - uma instrução mais fácil. ADD usa a variável sum como argumento e valor.


Para operações de pré-incremento, sabemos com certeza que o operando é sempre longo e que os estouros não podem ocorrer. Utilizamos um manipulador altamente especializado para esta instrução, que executará apenas as ações necessárias sem nenhuma verificação.


Agora compare a variável no final do loop. Sabemos que o valor da variável será apenas longo - você pode verificar imediatamente esse valor comparando-o com cem. Se anteriormente registramos o resultado da verificação em uma variável temporária e, mais uma vez, verificamos se a variável temporária é verdadeira / falsa, agora isso pode ser feito com uma instrução, ou seja, simplificada.


Resultado de bytecode comparado ao original.


Restam apenas três instruções no ciclo e duas são altamente especializadas. Como resultado, o código à direita é 3 vezes mais rápido que o original.

Manipuladores altamente especializados


Qualquer manipulador de rastreamento PHP é apenas uma função C. À esquerda, há um manipulador padrão e, no canto superior direito, é altamente especializado. O esquerdo verifica: o tipo do operando, se um estouro ocorreu, se uma exceção ocorreu. O certo apenas adiciona um e é isso. Isso se traduz em 4 instruções da máquina. Se formos além e fizermos o JIT, precisaremos apenas de uma instrução única incl .


O que vem a seguir?


Continuamos a aumentar a velocidade do ramo 7 do PHP sem o JIT. O PHP 7.1 será novamente 60% mais rápido em testes sintéticos típicos, mas em aplicativos reais isso quase não dá vitória - apenas 1-2% no WordPress. Isto não é particularmente interessante. Desde agosto de 2016, quando a ramificação 7.1 foi congelada para grandes mudanças, novamente começamos a trabalhar no JIT para PHP 7.2 ou melhor, PHP 8.

Em uma nova tentativa, usamos o DynAsm para gerar o código, desenvolvido por Mike Paul para LuaJIT-2 . É bom porque gera código muito rapidamente : o fato de os minutos terem sido compilados na versão JIT no LLVM agora acontece em 0,1-0,2 s. Hoje, a aceleração no bench.php no JIT é 75 vezes mais rápida que no PHP 5.

Não há aceleração em aplicativos reais, e este é o próximo desafio para nós. Em parte, obtivemos o código ideal, mas depois de compilar muitos scripts PHP, entupimos o cache do processador, para que não funcionasse mais rápido. E não a velocidade do código foi um gargalo em aplicativos reais ...

Talvez o DynAsm possa ser usado para compilar apenas determinadas funções que serão selecionadas por um programador ou por heurísticas baseadas em contadores - quantas vezes uma função foi chamada, quantas vezes os ciclos se repetem etc.

Abaixo está o código da máquina que nosso JIT gera para o mesmo exemplo. Muitas instruções são idealmente compiladas: incremento em uma instrução da CPU, inicialização variável para constantes em duas. Onde os tipos não são chocados, você precisa se preocupar um pouco mais.


Retornando à imagem do título, o PHP, em comparação com idiomas semelhantes no teste de Mandelbrot, mostra resultados muito bons (embora os dados sejam relevantes no final de 2016).

O diagrama mostra o tempo de execução em segundos, menos é melhor.

Talvez Mandelbrot não seja o melhor teste. É computacional, mas simples e implementado igualmente em todas as línguas. Seria bom saber com que rapidez o Wordpress funcionaria em C ++, mas dificilmente existe uma esquisitice pronta para reescrevê-lo apenas para verificar e até repetir todas as perversões do código PHP. Se você tem idéias para um conjunto de benchmarks mais adequado - sugira.

Nos encontraremos no PHP Rússia em 17 de maio , discutiremos as perspectivas e o desenvolvimento do ecossistema e a experiência de usar o PHP em projetos realmente complexos e interessantes. Já está conosco:


Claro, isso está longe de tudo. E o Call for Papers ainda está fechado. Até 1º de abril, aguardamos aplicativos daqueles que podem aplicar abordagens modernas e práticas recomendadas para implementar serviços PHP legais. Não tenha medo da concorrência com palestrantes eminentes - estamos procurando experiência no uso do que eles fazem em projetos reais e ajudaremos a mostrar os benefícios de seus casos.

Source: https://habr.com/ru/post/pt445512/


All Articles