VM de bytecode
O caminho de execução padrão é um interpretador de árvore: visita cada nó da AST e retorna um valor. Para funções muito usadas, chamadas milhões de vezes, o custo de distribuição por nó é o gargalo. Phasis adiciona um compilador especulativo de bytecode para closures PHP que reduz esse custo.
Quando a VM entra em ação
Cada JsFunction tem um contador de chamadas frequentes. Quando ele ultrapassa um limite (padrão: 100), o mecanismo tenta compilar o corpo da função para uma closure PHP por meio de src/Bytecode/JsToPhp.php.
A compilação é bem-sucedida quando o corpo da função corresponde a um conjunto conhecido de estruturas:
- Atribuições a variáveis locais com tipos previsíveis (numérico, objeto, função, array, string).
- Operações aritméticas com números.
- Leituras de membros em variáveis locais tipadas como objetos E em
this(this.v,obj.x). - Chamadas de métodos em variáveis locais tipadas como objetos (
obj.method(args)), com o receptor passado como$thisValue. - Chamadas ao construtor
new Ctor(args)— promove o tipo da variável local do lado esquerdo da atribuição paraobjecte protege com Bailout o retorno de um valor que não seja um objeto. - Loops
for/whilecom estruturas condicionais simples. returncom uma expressão computável.if/elseem condições tipadas.
A compilação falha (e a função permanece no interpretador de árvore) quando o corpo contém:
- Instruções
with. try / catch / finally(tratado pelo interpretador de árvore para preservar a semântica correta de conclusão).- Chamadas
eval(). - Mutação direta de
arguments. - Qualquer estrutura que o compilador ainda não saiba compilar.
Quando a compilação é bem-sucedida, o mecanismo redireciona o caminho de chamada da função para a closure compilada. As chamadas seguintes executam PHP nativo com o custo de uma chamada de função, em vez do custo de uma chamada por nó da AST.
Desistências da compilação
O caminho rápido compilado parte de certas premissas (os números continuam sendo números, as propriedades mantêm a mesma estrutura). Quando uma premissa é violada em tempo de execução, a closure lança um Bailout e a execução recomeça no interpretador de árvore para aquela chamada.
É aqui que a conformidade com a especificação é mais importante. O interpretador de árvore precisa conseguir reexecutar do início a chamada que levou à desistência da compilação, sem observar efeitos colaterais do caminho rápido que falhou. Phasis trata disso de duas maneiras:
- Prefixos puros — o compilador procura efeitos colaterais antes de qualquer especulação. Se a função realiza E/S (console.log, funções do host) antes do ponto de especulação, o caminho rápido não ignora essa operação; ele a confirma e prossegue.
- Recusa em tempo de compilação — para estruturas que exigiriam reverter efeitos colaterais observáveis (uma atribuição
let r = fn()não numérica em quefn()altera o estado), o compilador se recusa a compilar. A função permanece no interpretador de árvore.
O caso que motivou isso: uma atribuição não numérica executa fn() no caminho rápido, lança Bailout e depois é executada novamente no interpretador de árvore — executando fn() duas vezes, com dois conjuntos de efeitos colaterais. A recusa em tempo de compilação impede que fn() seja executado no caminho rápido, então o interpretador de árvore observa uma única chamada.
Distribuição de chamadas na VM (a segunda camada)
Algumas funções são compiladas para closures PHP; outras recorrem a um interpretador de bytecode ainda mais simples em src/Bytecode/VM.php. O bytecode é um array linear de opcodes (LOAD_LOCAL, STORE_PROP, CALL_METHOD, …) consumido por um mecanismo de distribuição switch.
A VM existe para funções que não justificam a compilação para uma closure PHP, mas são chamadas com frequência suficiente para se beneficiarem de não percorrer a AST. Chamadas a métodos de objetos integrados (Array.prototype.map, String.prototype.split, …) usam caminhos rápidos na VM que ignoram o trampoline callFunction genérico.
Pilha de chamadas personalizada
Op::CALL, Op::CALL_METHOD e Op::NEW_CALL usam um caminho de chamada inline que mantém toda a pilha de chamadas JS dentro de VM::execute — sem criar um novo quadro PHP a cada chamada JS. Ao entrar, o estado do chamador (pc, fluxo de código, pilha de operandos, variáveis locais, ambiente, this, sinalizador de modo estrito) é empilhado em um pool savedFrames por VM de instâncias SavedFrame, e o loop de distribuição redireciona suas variáveis locais para a função chamada. Op::RET desempilha o quadro salvo e restaura o estado. As exceções são propagadas pelos quadros salvos antes de retornar ao interpretador de árvore.
A elegibilidade é armazenada em cache em JsFunction: vmInlineCallCache para chamadas regulares (corpo compilado para BC, não vinculado a bind(), não nativo), vmInlineConstructCache para expressões new (além disso, isConstructable, não derivada, sem inicializadores de campos de instância ou métodos privados). Todo o restante continua passando pelo trampoline canônico callFunction / vmNewExpression.
A vantagem arquitetônica secundária: o limite da pilha PHP não restringe mais a profundidade da recursão JS. Agora, só há o limite CallStack::maxDepth — definido como 4 096, bem acima de qualquer caso legítimo de test262 (a maioria precisa de menos de 2 mil quadros) e suficientemente baixo para que testes patológicos de recursão infinita falhem rapidamente dentro do tempo limite do executor. O caminho de closures transpiladas (JsToPhp) tem sua própria proteção de 8 quadros 192 para o fallback sem chamadas inline.
Geradores com instantâneo do quadro
Corpos simples de geradores — sem yield*, sem yield dentro de try/catch/finally, sem await — são compilados para bytecode. O compilador gera um novo opcode Op::YIELD onde o corpo é suspenso; Op::YIELD captura o estado de distribuição (cf, pc+1, pilha, sp, variáveis locais, ambiente, thisValue, strict) em um objeto GeneratorSnapshot na heap e retorna um valor sentinela YieldResult de VM::execute. O JsGenerator que controla a execução armazena o instantâneo e o devolve a VM::execute no próximo next() / throw(), substituindo o valor de retomada no topo da pilha de operandos.
Isso substitui o caminho da Fiber PHP para o subconjunto compatível. O executor baseado em Fiber (executeGeneratorBody) continua disponível para estruturas que o compilador rejeita — delegação de yield*, geradores que cedem valores dentro de um bloco try, geradores assíncronos e funções assíncronas com await. JsGenerator opera nos dois modos: cada next() / throw() / return() verifica se a instância tem um instantâneo ou uma Fiber e executa o caminho correspondente.
Não há parâmetro ajustável para benchmarks; a elegibilidade é decidida em tempo de compilação e armazenada em cache em JsFunction. No caminho com instantâneo, a vantagem é eliminar uma troca de contexto PHP por yield; o caminho com Fiber permanece igual em todos os outros casos.
Caches inline
Op::LOAD_MEMBER tem um cache inline por PC indexado pela identidade do protótipo, usado no padrão de método de protótipo em instância (c.sum(...), arr.push(...), toda chamada de método de classe). O cache é válido quando o receptor não tem uma propriedade própria com esse nome, a referência ao protótipo em cache é idêntica à de getPrototype() do receptor e PropertyMap::$version do protótipo ainda corresponde à versão capturada.
A invalidação do cache depende de PropertyMap::$version. Mutações estruturais (definição ou exclusão) sempre o incrementam. Sobrescritas de valores de propriedades de dados só o incrementam quando PropertyMap::$isUsedAsProto é verdadeiro — um sinalizador de barreira de escrita ativado na primeira vez em que o objeto é observado como [[Prototype]] de outro objeto. Objetos de instância que nunca servem como protótipos não pagam nenhum custo da infraestrutura de IC em cada obj.x = v.
Acessores getter nunca são armazenados em cache (precisam ser executados novamente a cada leitura).
PropertyMap tem um segundo contador, $mutationVersion, que é incrementado incondicionalmente a cada gravação (sem barreira de escrita). O cache JSON.stringify fixa esse contador em cada objeto/array aninhado visitado, para que uma mutação exclusiva da instância, como o.a = 99, invalide corretamente a string em cache mesmo quando o $version do IC teria ignorado o incremento.
Cache de JSON.stringify
JsObject mantém $jsonCacheString + $jsonCacheVersions (uma lista de pares de [obj, mutationVersion] para cada objeto aninhado visitado durante a operação trivial de stringify). Na próxima chamada de JSON.stringify(o) sem replacer / sem espaço, o caminho rápido trivial verifica se todas as versões fixadas permanecem inalteradas e retorna a string em cache sem percorrer novamente nem recodificar. Padrões frequentes de clonagem profunda, como JSON.parse(JSON.stringify(o)), obtêm uma aceleração de cerca de 2× na etapa de stringify.
O cache é descartado silenciosamente em caso de falha (qualquer versão fixada é diferente) e preenchido novamente na próxima passagem. Objetos com toJSON, getters, acessores, símbolos ou slots [[PrimitiveValue]] recorrem ao serializador conforme a especificação e nunca preenchem o cache — assim, o caminho em cache só recebe dados estruturalmente uniformes.
Caminhos rápidos integrados
Os maiores ganhos de desempenho vieram de ensinar à VM como lidar com recursos integrados específicos:
Atomics.load/store/compareExchangecom escalonamento cooperativo deloadSpinHook/storeNotifyHook— recuperou 4 testes de loop de espera ativa com Atomics.Date.prototype.getTimezoneOffsetcom cache DST de tabela hash direta — aceleração de 45 % no teste de estresse DST de SpiderMonkey.String.fromCharCodecom caminho rápido ASCII em linha — economiza ~1 M despachos por varredura típica.decodeURI/decodeURIComponentcom caminho ultrarrápido de 12 caracteres%XX%XX%XX%XXque usa decodificação em blocohex2bin.Object.keys/Object.values/Object.entriescom acesso direto ao mapa de propriedades.Array.prototype.map/filter/forEach/reducecom iteração nativa sobre o array PHP subjacente.
Cada caminho rápido é condicionado por uma "verificação de forma" — se o receptor não corresponder à forma esperada (e.g, uma visualização TypedArray sem suporte em um PHP string simples), a VM recorre ao caminho lento correto conforme a especificação.
O que isso não faz
A VM de bytecode não é um JIT no sentido de V8. Ela não gera código de máquina nativo por função nem especula com base nos tipos observados além da verificação monomórfica de identidade de protótipo do IC. A incorporação nos limites das funções é aproximada pela pilha de chamadas personalizada (sem quadro PHP extra por chamada de JS), em vez de reduzir o corpo da função chamada ao bytecode da função chamadora.
O transpilador JsToPhp é o mais próximo que o mecanismo chega de um JIT: ele gera código-fonte PHP para corpos de funções frequentemente executados, que o JIT de rastreamento do 8.5 de PHP compila em código nativo. JsToPhp passa por eval(), então paga um custo de compilação único e o JIT de rastreamento precisa descobrir a função como se fosse código do usuário. Pular a etapa eval() emitindo opcodes do opcache diretamente reduziria ainda mais a diferença — consulte Roteiro de benchmarks.
Benchmark
bench/microbench.js executa pouco mais de uma dúzia de benchmarks pequenos (loop-arith, loop-fib, fn-recurse, fn-deep-recurse, obj-create, obj-prop, proto-method, gen-iterate, arr-push, arr-map, str-concat, str-split-join, json-roundtrip, closure, destructure). Tempo mediano de execução:
php bench/run.php
Os números atuais são registrados em BENCH.md após cada execução do fluxo de trabalho bench. Os fluxos de trabalho de matriz bench e test262 são iniciados manualmente.