Como criar um interpretador rápido para linguagens dinâmicas
(zef-lang.dev)- Mesmo um interpretador que percorre a AST diretamente pode obter grandes ganhos de desempenho apenas com representação de valores, cache inline, modelo de objetos, watchpoint e otimizações repetidas de detalhes
- O baseline do Zef, feito quase sem considerar desempenho, era 35 vezes mais lento que o CPython 3.10, 80 vezes mais lento que o Lua 5.4.7 e 23 vezes mais lento que o QuickJS-ng 0.14.0, mas após 21 etapas de otimização alcançou aceleração de 16,646 vezes
- O maior salto veio da reformulação do modelo de objetos combinada com cache inline, seguida por um ganho de 4,55 vezes com acesso baseado em Storage e Offsets, especialização de AST com cache e aplicação de watchpoints para monitorar sobrescritas de nomes
- Melhorias adicionais acumularam efeitos como remoção de dispatch baseado em strings, introdução de Symbol, mudança na estrutura de passagem de argumentos, especialização de getter e setter, caminho curto para tabela hash, além de especialização de literais de array e de
sqrtetoString - Incluindo a porta para Yolo-C++, o resultado foi 66,962 vezes mais rápido que o baseline, 1,889 vez mais rápido que o CPython 3.10 e 2,968 vezes mais rápido que o QuickJS-ng 0.14.0, mas sem liberação de memória, o que o torna inadequado para cargas de trabalho de longa duração
Introdução e metodologia de avaliação
- O alvo das otimizações é um interpretador que percorre a AST diretamente, com o objetivo de elevar a linguagem dinâmica Zef, feita por diversão, a um nível capaz de competir com Lua, QuickJS e CPython
- Em vez de ajustes finos em compiladores JIT ou em GC maduro, o foco está em otimizações aplicáveis mesmo a partir de um ponto de partida sem base sólida
- As técnicas abordadas são representação de valores, cache inline, modelo de objetos, watchpoint e aplicação repetida de otimizações sensatas
- Só com as técnicas do texto já foi possível obter grande ganho de desempenho sem SSA, GC, bytecode ou código de máquina
- Aceleração de 16 vezes no escopo do texto
- Aceleração de 67 vezes incluindo a porta inacabada para Yolo-C++
- A avaliação de desempenho usa a suíte de benchmarks ScriptBench1
- Os benchmarks incluídos são o escalonador de SO Richards, o resolvedor de restrições DeltaBlue, a simulação física N-Body e o teste de árvore binária Splay
- Foram usadas portas já existentes para JavaScript, Python e Lua
- As portas de Splay para Python e Lua foram geradas com Claude
- O ambiente experimental foi Ubuntu 22.04.5, Intel Core Ultra 5 135U, 32GB de RAM e Fil-C++ 0.677
- Lua 5.4.7 foi compilado com GCC 11.4.0
- QuickJS-ng 0.14.0 usou o binário de releases do GitHub
- CPython 3.10 usou a versão fornecida por padrão no Ubuntu
- Todos os experimentos usam a média de 30 execuções embaralhadas aleatoriamente
- A maior parte das comparações foi feita entre o interpretador Zef compilado com Fil-C++ e outros interpretadores compilados com o compilador Yolo-C
O interpretador Zef original
- Foi escrito quase sem considerar desempenho, e o autor afirma que houve apenas duas escolhas feitas com desempenho em mente
-
Representação de valores
- Usa tagged value de 64 bits
- Os valores suportados são double, inteiro de 32 bits e
Object*
- Os valores suportados são double, inteiro de 32 bits e
- O double é representado pelo método de offset
0x1000000000000- Apresentado como uma técnica aprendida com o JavaScriptCore
- Na literatura, é chamado de NuN tagging
- Inteiros e ponteiros usam representação nativa
- Isso depende da suposição de que o valor do ponteiro não é menor que
0x100000000 - O próprio autor afirma que é uma escolha arriscada
- Como alternativa, menciona que poderia ter usado uma tag de bits altos
0xffff000000000000para inteiros
- Isso depende da suposição de que o valor do ponteiro não é menor que
- Essa representação permite implementar um caminho rápido baseado em teste de bits para operações numéricas
- Um benefício ainda mais importante é evitar alocação no heap para números
- Ao criar um novo interpretador, é importante escolher bem a representação básica de valores logo no início, porque mudar isso depois é muito difícil
- Como ponto de partida para implementar uma linguagem dinamicamente tipada, o texto sugere tagged value de 32 ou 64 bits
- Usa tagged value de 64 bits
-
Escolha da linguagem de implementação
- Foi escolhida uma linguagem da família C++ por conseguir acomodar otimizações suficientes
- O autor afirma que não escolheria Java por causa do limite inferior para otimizações de baixo nível
- Também afirma que não escolheria Rust por causa do estado global mutável e da representação de heap com referências cíclicas necessários para implementar uma linguagem com GC
- Menciona que talvez fosse possível usar Rust em parte ou no todo se aceitasse uma configuração multilíngue ou bastante código
unsafe
- Menciona que talvez fosse possível usar Rust em parte ou no todo se aceitasse uma configuração multilíngue ou bastante código
-
Escolhas erradas do ponto de vista de engenharia de desempenho
- Uso de Fil-C++
- Permitia desenvolver rápido e fornecia GC de graça
- Reportava violações de segurança de memória com informações de diagnóstico e stack traces
- Não havia comportamento indefinido
- O custo de desempenho costuma ser de cerca de 4 vezes
- Interpretador recursivo de caminhada pela AST
- Estrutura baseada em método virtual
Node::evaluatesobrescrito em vários pontos
- Estrutura baseada em método virtual
- Uso excessivo de strings
- O nó AST
Getarmazena umastd::stringque descreve o nome da variável - Essa string é usada em cada acesso à variável
- O nó AST
- Uso excessivo de tabelas hash
- Ao executar
Get, faz uma busca emstd::unordered_mapusando a string como chave
- Ao executar
- Busca de escopo baseada em cadeia de chamadas recursivas
- Quase qualquer tipo de aninhamento e closure era permitido
- Em um aninhamento como classe A dentro da função F e função G dentro da classe B, um método de A pode enxergar campos de A, variáveis locais de F, campos de B e variáveis locais de G
- A implementação original tratava isso com funções recursivas em C++ que consultavam diferentes objetos de escopo
- Uso de Fil-C++
-
Características da implementação original
- Apesar das escolhas ruins, era possível implementar um interpretador de linguagem relativamente complexo com pouco código
- O maior módulo era o parser
- O restante era relativamente simples e claro
-
Desempenho inicial
- O interpretador original era 35 vezes mais lento que o CPython 3.10
-
80 vezes mais lento que o Lua 5.4.7
- 23 vezes mais lento que o QuickJS-ng 0.14.0
Tabela geral do progresso das otimizações
- A tabela resume a mudança de desempenho desde Zef Baseline até Zef Change #21: No Asserts e Zef in Yolo-C++
- As colunas de comparação são vs Zef Baseline, vs Python 3.10, vs Lua 5.4.7 e vs QuickJS-ng 0.14.0
- Na linha final, Zef Change #21: No Asserts é 16,646 vezes mais rápido que o baseline
-
2,13 vezes mais lento que o Python 3.10
-
4,781 vezes mais lento que o Lua 5.4.7
- 1,355 vez mais lento que o QuickJS-ng 0.14.0
-
-
Zef in Yolo-C++** é 66,962 vezes mais rápido que o baseline
-
1,889 vez mais rápido que o Python 3.10
-
1,189 vez mais lento que o Lua 5.4.7
- 2,968 vezes mais rápido que o QuickJS-ng 0.14.0
-
Etapas iniciais de otimização
-
Otimização #1: chamada direta de operadores
- O parser deixa de criar operadores como nós
DotCallcom nome de operador e passa a gerar nós AST separados para cada operador - Em Zef,
a + bea.add(b)são equivalentes- Antes,
a + bera analisado comoDotCall(a, "add")com o argumentob - Em toda operação aritmética ocorria busca da string com o nome do método do operador
DotCallpassava a string paraValue::callMethodValue::callMethodfazia múltiplas comparações de strings
- Antes,
- Depois da mudança, o parser gera nós
Binary<>eUnary<>- Usando templates e lambdas, ele fornece overrides diferentes de
Node::evaluatepara cada operador - Cada nó chama diretamente o caminho rápido de
Valuecorrespondente ao operador - Por exemplo,
a + bchamaBinary<lambda for add>::evaluatee depoisValue::add
- Usando templates e lambdas, ele fornece overrides diferentes de
- O ganho de desempenho foi de 17,5%
- Nesse ponto, o desempenho era 30 vezes mais lento que o CPython 3.10
- 67 vezes mais lento que o Lua 5.4.7
- 19 vezes mais lento que o QuickJS-ng 0.14.0
- O parser deixa de criar operadores como nós
-
Otimização #2: chamada direta de operadores RMW
- Os operadores normais ficaram mais rápidos, mas formas RMW como
a += bainda usavam despacho baseado em strings - O parser foi alterado para gerar nós separados para cada caso de RMW
- O parser passa a pedir que o nó LValue substitua a si mesmo por um RMW via chamada virtual
makeRMW - Os LValues que viram RMW são Get, Dot e Subscript
- Get corresponde à leitura de variável
id - Dot corresponde a
expr.id - Subscript corresponde a
expr[index]
- Get corresponde à leitura de variável
- Cada chamada virtual usa a macro
SPECIALIZE_NEW_RMW- SetRMW corresponde a
id += value - DotSetRMW corresponde a
expr.id += value - SubscriptRMW corresponde a
expr[index] += value
- SetRMW corresponde a
- A especialização de operadores da mudança #1 usa despacho por lambdas
- Já o RMW usa enum
- A escolha foi feita porque é preciso tratar os três caminhos
get,dotesubscripte passar o enum por vários pontos - No fim, a função template
Value::callRMW<>faz o despacho real da chamada do operador RMW
- A escolha foi feita porque é preciso tratar os três caminhos
- O ganho de desempenho foi de 3,7%
- Nesse ponto, o desempenho era 29 vezes mais lento que o CPython 3.10
- 65 vezes mais lento que o Lua 5.4.7
- 18,5 vezes mais lento que o QuickJS-ng 0.14.0
- 1,22 vez mais rápido em relação ao ponto de partida
- Os operadores normais ficaram mais rápidos, mas formas RMW como
-
Otimização #3: evitar a verificação de IntObject
- O gargalo era que o caminho rápido de
ValueusavaisInt(), e internamenteisIntSlow()fazia uma chamada virtual paraObject::isInt() - Na representação original de valores, havia quatro casos
- tagged int32
- tagged double
- IntObject para int64 que não pode ser representado como int32
- todos os demais objetos
- Mesmo no caso de IntObject, o despacho de métodos de inteiro era responsabilidade de
Value- Isso foi feito para manter todas as implementações de operações aritméticas em um só lugar, ou seja, em
Value
- Isso foi feito para manter todas as implementações de operações aritméticas em um só lugar, ou seja, em
- Após a otimização, o caminho rápido de
Valueconsidera apenas int32 e double- A lógica de tratamento de IntObject foi movida para o próprio
IntObject - Isso evita a chamada a
isInt()que ocorria em cada despacho de método
- A lógica de tratamento de IntObject foi movida para o próprio
- O ganho de desempenho foi de 1%
- Nesse ponto, o desempenho era 29 vezes mais lento que o CPython 3.10
- 65 vezes mais lento que o Lua 5.4.7
- 18 vezes mais lento que o QuickJS-ng 0.14.0
- 1,23 vez mais rápido em relação ao ponto de partida
- O gargalo era que o caminho rápido de
-
Otimização #4: Symbol
- Originalmente, o interpretador usava
std::stringem quase toda parte - Os pontos em que o uso de strings era mais custoso eram
Context::get,Context::set,Context::callFunction,Value::callMethod,Value::dot,Value::setDot,Value::callOperator<>e a famíliaObject::callMethod - Nessa estrutura, em vez de uma simples consulta em tabela hash, havia consultas em tabela hash com chave string, repetindo hashing e comparação de strings durante a execução
- A otimização substitui consultas baseadas em string por ponteiros para objetos
Symbolcom hash consing - Foi adicionada uma nova classe
Symbol- Implementada em
symbol.hesymbol.cpp - Symbol e string podem ser convertidos um no outro
- Ao converter uma string em Symbol, o hash consing é feito com uma tabela hash global
- Com isso, basta comparar a identidade dos ponteiros
Symbol*para saber se dois símbolos são iguais
- Implementada em
- Em vez de literais de string, passam a ser usados símbolos preparados previamente
- Por exemplo, no lugar de
"subscript", usa-seSymbol::subscript
- Por exemplo, no lugar de
- Muitas assinaturas de função foram alteradas para usar
Symbol*no lugar deconst std::string& - O ganho de desempenho foi de 18%
- Nesse ponto, o desempenho era 24 vezes mais lento que o CPython 3.10
- 54 vezes mais lento que o Lua 5.4.7
- 15 vezes mais lento que o QuickJS-ng 0.14.0
- 1,46 vez mais rápido em relação ao ponto de partida
- Originalmente, o interpretador usava
-
Otimização #5: inline de Value
- O ponto principal é permitir o inline de funções importantes
- Quase todas as mudanças giram em torno da introdução do novo header
valueinlines.h - O motivo de separá-lo de
value.hem outro header é que ele usa headers que precisam incluirvalue.h - O ganho de desempenho foi de 2,8%
- Nesse ponto, o desempenho era 24 vezes mais lento que o CPython 3.10
- 53 vezes mais lento que o Lua 5.4.7
- 15 vezes mais lento que o QuickJS-ng 0.14.0
- 1,5 vez mais rápido em relação ao ponto de partida
Redesenho do modelo de objetos e da estrutura de cache
-
Otimização #6: modelo de objetos, cache inline e watchpoint
- Reestruturação em grande escala do funcionamento de
Object,ClassObjecteContextpara reduzir o custo de alocação de objetos e evitar buscas em tabela hash durante o acesso - Essa mudança combina três recursos: modelo de objetos, cache inline e watchpoint
- Reestruturação em grande escala do funcionamento de
-
Modelo de objetos
- Antes, era alocado um objeto
Contextpara cada escopo léxico- Cada
Contextmantinha uma tabela hash com as variáveis daquele escopo
- Cada
- Os objetos tinham uma estrutura mais complexa
- Cada objeto mantinha uma tabela hash que mapeava para
Contextas classes das quais era instância
- Cada objeto mantinha uma tabela hash que mapeava para
- Essa estrutura era necessária por causa de herança e escopos aninhados
- Quando
Barherda deFoo,BareFoofazem closure sobre escopos diferentes - Também podem ter campos privados diferentes com o mesmo nome
- Quando
- A nova estrutura introduz o conceito de
Storage- Os dados são armazenados de acordo com offsets
- O offset é determinado por algum
Context
Contextainda existe, mas passa a ser pré-criado no passoresolveda AST, e não no momento da criação do objeto ou escopo- Na criação real do objeto ou escopo, aloca-se apenas o Storage no tamanho calculado por aquele
Context
- Antes, era alocado um objeto
-
Cache inline
- Técnica que memoriza, em um ponto de código como
expr.name, o tipo dinâmico deexprvisto por último e o último offset para o qualnamefoi resolvido - É uma técnica clássica normalmente explicada no contexto de JIT, mas aqui foi aplicada ao interpretador
- As informações memorizadas são implementadas por meio de placement construct de nós de AST especializados sobre o nó de AST comum
- Técnica que memoriza, em um ponto de código como
-
Componentes do cache inline
CacheRecipe- Rastreia o que um acesso específico fez e se isso pode ser armazenado em cache
- Chamadas a
CacheRecipeforam inseridas em vários pontos deContext,ClassObjectePackage- Para coletar informações do processo de acesso
- Funções de avaliação de AST como
Dot::evaluatepassam oCacheRecipeobtido na operação polimórfica que executaram, junto comthis, paraconstructCache<> constructCache- Compila uma nova especialização de nó de AST de acordo com o
CacheRecipe - Usa maquinaria de templates para gerar vários nós de AST especializados
- Se for acesso a variável local, faz um load direto do
storagerecebido - Faz uma verificação de classe para confirmar se é a mesma classe vista por último
- Depois disso, faz uma chamada direta da função vista por último
- Se necessário, combina chain step e watchpoint
- Compila uma nova especialização de nó de AST de acordo com o
- Cada nó de AST sujeito a cache mantém sua própria variante em cache
- Primeiro tenta uma chamada rápida por meio do objeto
cache - O tipo do objeto
cacheé determinado porconstructCache<>
- Primeiro tenta uma chamada rápida por meio do objeto
-
watchpoint
- É apresentado o exemplo de um escopo léxico com a variável
x, contendo dentro dele a classeFoo, e um método deFooacessandox - Se não houver uma função ou variável chamada
xdentro deFoo, pode parecer que ele pode ler diretamente oxexterno - Porém, uma subclasse pode adicionar um getter
x - Nesse caso, o resultado do acesso deve ser o getter, e não o
xexterno - Para lidar com essa possibilidade de mudança, o cache inline instala um
Watchpointem tempo de execução - No exemplo, é usado um watchpoint que monitora se esse nome foi sobrescrito
- É apresentado o exemplo de um escopo léxico com a variável
-
Por que implementar os três recursos ao mesmo tempo
- Só o novo modelo de objetos dificilmente traria uma melhora relevante se o cache inline não funcionasse bem
- O cache inline também teria pouco ganho prático sem watchpoint, porque ficaria difícil tratar com segurança muitas das condições de cache
- Novo modelo de objetos e watchpoint precisavam funcionar bem em conjunto
-
Progresso da implementação e partes difíceis
- O trabalho começou pela escrita de uma versão simples de
CacheRecipee pelo desenho de Storage e Offsets já próximos da forma final - Uma das tarefas mais difíceis foi substituir a forma de implementação das classes intrínsecas
- Exemplo com arrays
- Antes,
ArrayObject::tryCallMethodimplementava todos os métodos interceptando a chamada virtualObject::tryCallMethod - No novo modelo de objetos,
Objectnão tem vtable nem métodos virtuais - Em vez disso,
Object::tryCallMethoddelega paraobject->classObject()->tryCallMethod(object, ...) - Portanto, para fornecer métodos de
Array, foi necessário criar a própria classe de Array com esses métodos
- Antes,
- Como resultado, boa parte da funcionalidade intrínseca saiu de uma estrutura espalhada por toda a implementação e foi movida para um arranjo mais centrado em
makerootcontext.cpp - Isso foi avaliado como um resultado positivo porque o cache inline também se aplica diretamente às funções nativas/intrínsecas dos objetos
- O efeito sobre o desempenho foi de 4,55x de melhoria
- Nesse ponto, o desempenho ainda era 5,2x mais lento que o CPython 3.10
- 11,7x mais lento que o Lua 5.4.7
- 3,3x mais lento que o QuickJS-ng 0.14.0
- 6,8x mais rápido em relação ao ponto de partida
- A avaliação foi que a desvantagem do Fil-C++ em relação a outros interpretadores ficou, em geral, reduzida a algo próximo do nível de custo do Fil-C
- O trabalho começou pela escrita de uma versão simples de
Otimização de chamadas e caminhos de acesso
-
Otimização #7: melhoria da estrutura de passagem de argumentos
- Antes da mudança, o interpretador Zef passava os argumentos de função como
const std::optional<std::vector<Value>>& - O motivo de precisar de
optionalera que, em alguns casos de canto, era necessário distinguir entre os dois a seguiro.gettero.function()
- No Zef, em geral ambos são chamadas de função, mas existe a seguinte exceção
o.NestedClasso.NestedClass()
- O primeiro retorna o próprio objeto
NestedClass - O segundo cria uma instância
- Portanto, era necessário distinguir entre uma chamada de função sem argumentos e um caso de chamada do tipo getter em que o array de argumentos está vazio
- Porém, a estrutura anterior era ineficiente
- O chamador fazia a alocação do
vector - O chamado então alocava novamente um arguments scope que era uma cópia desse vetor
- O chamador fazia a alocação do
- A mudança foi a introdução do tipo
Arguments- Seu formato é exatamente igual ao arguments scope que o chamado antes criava
- Agora o chamador aloca diretamente nesse formato
- No Yolo-C++ também houve redução no número de alocações ao remover o
mallocdo backing store do vector - No Fil-C++, o próprio
std::optionalfaz alocação no heap- Mesmo sem
std::optional, passarconst std::vector<>&também gera alocação - O que parece ser alocado na pilha na verdade é explicitamente alocado no heap
- Também é mencionado que, do lado do chamador, o vetor não era previamente dimensionado, causando várias realocações
- Mesmo sem
- Grande parte da mudança foi substituir assinaturas de função por
Arguments* - O efeito em desempenho foi de 1,33x de melhora
- Neste ponto, o desempenho era 3,9x mais lento que o CPython 3.10
- 8,8x mais lento que o Lua 5.4.7
- 2,5x mais lento que o QuickJS-ng 0.14.0
- 9,05x mais rápido que o ponto de partida
- Antes da mudança, o interpretador Zef passava os argumentos de função como
-
Otimização #8: especialização de getter
- O Zef, assim como Ruby, tem campos de instância privados por padrão
- Exemplo:
class Foo { my f fn (inF) f = inF }- O valor recebido no construtor é armazenado na variável local
f, visível apenas para a instância
- O valor recebido no construtor é armazenado na variável local
- Mesmo instâncias do mesmo tipo não podem acessar o
fde outro objeto- Exemplo:
fn nope(o) o.f println(Foo(42).nope(Foo(666)))- O
o.fdentro denopenão pode acessar ofdeo
- Exemplo:
- O motivo é que o campo funciona pela forma como aparece na cadeia de escopos dos membros da classe
o.fnão é uma leitura de campo, mas um pedido de chamada de método com o nomef
- Por isso, o seguinte padrão aparece com frequência
my ffn f f- Ou seja, um método chamado
fque retorna a variável localf
- Há uma sintaxe mais curta:
readable f- Forma abreviada de
my fefn f f
- Forma abreviada de
- Muitas chamadas de método são, na prática, chamadas de getter
- É desperdício fazer todos os getters funcionarem avaliando AST
- A otimização foi a especialização de getter
- O centro disso é
UserFunction - O novo método
Node::inferGetterinfere se o corpo da função é um getter simples
- O centro disso é
- Regras de inferência
Block::inferGetterinfere que é getter se tudo o que contém puder ser inferido como getterGet::inferGetterinfere a si mesmo como getter e retorna o offset a ser carregadoContext::tryGetFieldOffsetsretorna umOffsetsnão vazio apenas quando o campo com certeza existe no escopo léxico em que o getter será executadoUserFunctioné resolvida como uma subclasse especial deFunctionque apenas lê diretamente de um offset conhecido quando o corpo da função pode ser inferido como getter
- O efeito em desempenho foi de 5,6% de melhora
- Neste ponto, o desempenho era 3,7x mais lento que o CPython 3.10
- 8,3x mais lento que o Lua 5.4.7
- 2,4x mais lento que o QuickJS-ng 0.14.0
- 9,55x mais rápido que o ponto de partida
-
Otimização #9: especialização de setter
- Na inferência de setter, é necessário fazer pattern matching do padrão
fn set_fieldName(newValue) fieldName = newValue - Na etapa de inferência de
UserFunction, é necessário passar o nome do parâmetro do setter - Na etapa de inferência de
Set, é preciso verificar se não se trata de uma escrita emClassObject, além de conferir se o parâmetro do setter está sendo usado como origem doset - O efeito em desempenho foi de 3,4% de melhora
- Neste ponto, o Zef era 3,6x mais lento que o CPython 3.10
- 8x mais lento que o Lua 5.4.7
- 2,3x mais lento que o QuickJS-ng 0.14.0
- 9,87x mais rápido que o ponto de partida
- Na inferência de setter, é necessário fazer pattern matching do padrão
-
Otimização #10: inline de
callMethod- Uma função importante foi colocada inline com uma mudança de uma linha
- O efeito em desempenho foi de 3,2% de melhora
- Neste ponto, o Zef era 3,5x mais lento que o CPython 3.10
- 7,8x mais lento que o Lua 5.4.7
- 2,2x mais lento que o QuickJS-ng 0.14.0
- 10,2x mais rápido que o ponto de partida
-
Otimização #11: tabela hash
- Quando ocorria um inline cache miss em uma chamada de método, era preciso descer por
ClassObject::tryCallMethodeClassObject::TryCallMethodDirect, e ambos os caminhos eram grandes e complexos - O custo de busca anterior era O(profundidade da hierarquia)
- Para cada classe da hierarquia, era feita uma consulta em tabela hash para verificar se a chamada era resolvida como função membro
- Para cada classe da hierarquia, também era feita uma consulta em tabela hash para verificar se a chamada era resolvida como classe aninhada
- A nova mudança introduz uma tabela hash global que usa como chave a classe receptora e o símbolo
- Com uma única consulta, ela retorna diretamente o callee
- Em
classobject.h, essa tabela global é consultada primeiro antes de descer para todo otryCallMethodSlow - Em
classobject.cpp, os resultados de consultas bem-sucedidas são registrados na tabela global - A própria tabela hash global é uma implementação relativamente simples
- O efeito em desempenho foi de 15% de melhora
- Neste ponto, o Zef era 3x mais lento que o CPython 3.10
- 6,8x mais lento que o Lua 5.4.7
- 1,9x mais lento que o QuickJS-ng 0.14.0
- 11,8x mais rápido que o ponto de partida
- Quando ocorria um inline cache miss em uma chamada de método, era preciso descer por
-
Otimização #12: evitar
std::optional- No Fil-C++, o
std::optionalprecisa de alocação no heap por causa de uma patologia do compilador relacionada a union - Em geral, o LLVM trata de forma flexível os tipos de acesso à memória de union, mas isso entra em conflito com invisicaps
- Há casos em que um ponteiro dentro de uma union perde sua capability de maneira imprevisível do ponto de vista do programador
- Como resultado, no Fil-C pode ocorrer pânico por desreferenciar um objeto com null capability mesmo sem erro do programador
- Para mitigar isso, o compilador Fil-C++ insere intrinsics para fazer o LLVM agir de forma conservadora no tratamento de variáveis locais de tipo union
- Depois disso, o passe
FilPizlonatortenta realizar sua própria escape analysis para permitir que variáveis locais de tipo union possam ser alocadas em registradores- Ainda assim, essa análise não é tão completa quanto a análise SROA geral do LLVM
- Como resultado, em Fil-C++ é comum que passar classes que incluem union, como
std::optional, acabe levando a alocação de memória - Esta mudança evita os caminhos de código que levam a
std::optionalno hot path - O efeito em desempenho foi de 1,7% de melhora
- Neste ponto, o Zef era 3x mais lento que o CPython 3.10
- 6,65x mais lento que o Lua 5.4.7
- 1,9x mais lento que o QuickJS-ng 0.14.0
- No Fil-C++, o
-
12 vezes mais rápido em relação ao ponto de partida
-
Otimização #13: argumentos especializados
- Todas as funções built-in do Zef recebem 1 ou 2 argumentos e, na implementação nativa, não é necessário alocar um objeto
Argumentspara armazená-los - Setters também sempre recebem um único argumento e, quando a inferência de setter é feita, uma implementação de setter especializada também precisa apenas receber diretamente o argumento de valor, sem objeto
Arguments - Com esta mudança, foram introduzidos os tipos de argumentos especializados
ZeroArguments,OneArgumenteTwoArguments- Quando o callee não precisa deles, o caller pode evitar a alocação de um objeto
Arguments
- Quando o callee não precisa deles, o caller pode evitar a alocação de um objeto
ZeroArgumentsé necessário para diferenciá-lo de(Arguments*)nullptr- Antes,
(Arguments*)nullptrera usado com o significado de chamada de getter, e essa lógica foi mantida - Agora,
ZeroArgumentssignifica chamada de função sem argumentos
- Antes,
- Muitas mudanças consistiram em transformar em template as funções que recebem argumentos
- Foi feita instanciação explícita para
ZeroArguments,OneArgument,TwoArgumentseArguments* - Grande parte do código existente usava
Value::getArgcomo helper para extrair argumentos, e foram adicionados aqui overloads para argumentos especializados - As alterações no código nativo que usa argumentos foram relativamente diretas
- Foi feita instanciação explícita para
- O efeito no desempenho foi de melhoria de 3,8%
- Neste ponto, o Zef é 2,9 vezes mais lento que o CPython 3.10
- 6,4 vezes mais lento que o Lua 5.4.7
- 1,8 vez mais lento que o QuickJS-ng 0.14.0
- 12,4 vezes mais rápido em relação ao ponto de partida
- Todas as funções built-in do Zef recebem 1 ou 2 argumentos e, na implementação nativa, não é necessário alocar um objeto
Contornando patologias do Fil-C e especialização detalhada
-
Otimização #14: melhoria do slow path de
Value- Outro desvio de patologia do Fil-C garantiu um grande ganho de velocidade
- Antes da mudança, o slow path out-of-line de
Valueera uma função-membro deValuee exigia um argumento implícitoconst Value* - Nessa estrutura, o caller precisava alocar
Valuena stack - No Fil-C++, toda alocação na stack é uma alocação no heap
- Portanto, o código que chamava o slow path alocava
Valueno heap
- Portanto, o código que chamava o slow path alocava
- Depois da mudança, esses métodos foram tornados
staticeValuepassou a ser passado por valor- Como resultado, não foi necessária nenhuma alocação separada
- O efeito no desempenho foi de 10% de melhora
- Nesse ponto, o Zef era 2,6 vezes mais lento que o CPython 3.10
- 5,8 vezes mais lento que o Lua 5.4.7
- 1,65 vez mais lento que o QuickJS-ng 0.14.0
- 13,6 vezes mais rápido que o ponto de partida
-
Otimização #15: eliminação de duplicação em
DotSetRMW- Foi feita alguma remoção de código duplicado
- Esperava-se que a redução do código de máquina ajudasse em funções de template especializadas por
constructCache<> - O resultado real foi nenhum impacto no desempenho
-
Otimização #16: especialização de
sqrt- O inline cache direciona bem as chamadas para a função desejada, mas só funciona para objetos
- Para não objetos, os fast paths de
Binary<>,Unary<>eValue::callRMW<>dependem de verificar se o receiver éintoudouble - Esse método só se aplica a operadores reconhecidos pelo parser
- Não se aplica a formas como
value.sqrt
- Não se aplica a formas como
- Com esta mudança,
Dotpode ser especializado paravalue.sqrt - O efeito no desempenho foi de 1,6% de melhora
- Nesse ponto, o Zef era 2,6 vezes mais lento que o CPython 3.10
- 5,75 vezes mais lento que o Lua 5.4.7
- 1,6 vez mais lento que o QuickJS-ng 0.14.0
- 13,8 vezes mais rápido que o ponto de partida
-
Otimização #17: especialização de
toString- A especialização de
toStringfoi aplicada quase da mesma forma que na otimização anterior - Esta mudança inclui uma lógica para reduzir o número de alocações ao converter
intem string - O efeito no desempenho foi de 2,7% de melhora
- Nesse ponto, o Zef era 2,5 vezes mais lento que o CPython 3.10
- 5,6 vezes mais lento que o Lua 5.4.7
- 1,6 vez mais lento que o QuickJS-ng 0.14.0
- 14,2 vezes mais rápido que o ponto de partida
- A especialização de
-
Otimização #18: especialização de literais de array
- Em Zef, código como
my whatever = [1, 2, 3]precisa alocar um novo array, porque arrays são passíveis de aliasing e mutáveis - Antes da mudança, a cada execução o interpretador descia pela AST e avaliava recursivamente
1,2,3todas as vezes - Esta mudança especializa o nó
ArrayLiteralpara o caso de alocação de array constante - O efeito no desempenho foi de 8,1% de melhora
- Nesse ponto, o Zef era 2,3 vezes mais lento que o CPython 3.10
- 5,2 vezes mais lento que o Lua 5.4.7
- 1,5 vez mais lento que o QuickJS-ng 0.14.0
- 15,35 vezes mais rápido que o ponto de partida
- Em Zef, código como
-
Otimização #19: melhoria em
Value::callOperator- A mesma otimização que antes trouxe ganho de velocidade por não passar
Valuepor referência também foi aplicada ao slow path decallOperator - O efeito no desempenho foi de 6,5% de melhora
- Nesse ponto, o Zef era 2,2 vezes mais lento que o CPython 3.10
- 4,9 vezes mais lento que o Lua 5.4.7
- 1,4 vez mais lento que o QuickJS-ng 0.14.0
- 16,3 vezes mais rápido que o ponto de partida
- A mesma otimização que antes trouxe ganho de velocidade por não passar
-
Otimização #20: opções melhores de C++
- No Fil-C++, RTTI desnecessário e o hardening do libc++ foram desativados
- Não houve mudança no próprio código C++, apenas alterações na configuração do sistema de build
- O efeito no desempenho foi de 1,8% de melhora
- Nesse ponto, o Zef era 2,1 vezes mais lento que o CPython 3.10
- 4,8 vezes mais lento que o Lua 5.4.7
- 1,35 vez mais lento que o QuickJS-ng 0.14.0
- 16,6 vezes mais rápido que o ponto de partida
-
Otimização #21: desativação de assert
- Como última otimização, foi aplicada a desativação padrão de assertions
- O código existente usava a macro
ZASSERT, específica do Fil-C- Uma estrutura que sempre executava asserts
- Depois da mudança, passou a usar a macro interna
ASSERT- O assert só é executado quando
ASSERTS_ENABLEDestá definido
- O assert só é executado quando
- Esta mudança também inclui outras correções para que o código pudesse ser compilado com Yolo-C++
- Ao contrário do esperado, não houve ganho de velocidade
Resultados e limites do Yolo-C++
- Ao compilar o código com Yolo-C++, foi obtido um ganho de desempenho de 4 vezes
- No entanto, essa abordagem não é sound e é subótima
- Ela não é sound porque as chamadas de GC do Fil-C++ original passam a ser chamadas de
calloc - Como resultado, a memória não é liberada e, em workloads que executam por tempo suficiente, o interpretador acaba chegando à exaustão de memória
- Em ScriptBench1, o tempo de teste é curto, então não ocorre exaustão de memória
- Ela não é sound porque as chamadas de GC do Fil-C++ original passam a ser chamadas de
- Ela é subótima porque o alocador de GC real é mais rápido que o
callocdo glibc 2.35 - Portanto, menciona-se que adicionar um GC real ao port para Yolo-C++ pode permitir um ganho de velocidade ainda maior que 4 vezes
- Neste experimento, foi usado o GCC 11.4.0
- Nesse ponto, o Zef era
-
1,9 vez mais rápido que o CPython 3.10
-
1,2 vez mais lento que o Lua 5.4.7
-
3 vezes mais rápido que o QuickJS-ng 0.14.0
- 67 vezes mais rápido que o ponto de partida
-
Dados brutos de benchmark
- A unidade do tempo de execução dos benchmarks é segundos
- A tabela inclui, para cada interpretador,
nbody,splay,richards,deltablue,geomean -
Python 3.10
nbody0.0364splay0.8326richards0.0822deltablue0.1135geomean0.1296
-
Lua 5.4.7
nbody0.0142splay0.4393richards0.0217deltablue0.0832geomean0.0577
-
QuickJS-ng 0.14.0
nbody0.0214splay0.7090richards0.7193deltablue0.1585geomean0.2036
-
Zef Baseline
nbody2.9573splay13.0286richards1.9251deltablue5.9997geomean4.5927
-
Zef Mudança #1: Operadores diretos
nbody2.1891splay12.0233richards1.6935deltablue5.2331geomean3.9076
-
Zef Mudança #2: RMWs diretos
nbody2.0130splay11.9987richards1.6367deltablue5.0994geomean3.7677
-
Zef Mudança #3: Evitar IntObject
nbody1.9922splay11.8824richards1.6220deltablue5.0646geomean3.7339
-
Zef Mudança #4: Símbolos
nbody1.5782splay9.9577richards1.4116deltablue4.4593geomean3.1533
-
Zef Mudança #5: Valor inline
nbody1.4982splay9.7723richards1.3890deltablue4.3536geomean3.0671
-
Zef Mudança #6: Modelo de objetos e caches inline
nbody0.3884splay3.3609richards0.2321deltablue0.6805geomean0.6736
-
Zef Mudança #7: Argumentos
nbody0.3160splay2.6890richards0.1653deltablue0.4738geomean0.5077
-
Zef Mudança #8: Getters
nbody0.2988splay2.6919richards0.1564deltablue0.4260geomean0.4809
-
Zef Mudança #9: Setters
nbody0.2850splay2.6690richards0.1514deltablue0.4072geomean0.4651
-
Zef Mudança #10:
callMethodinlinenbody0.2533splay2.6711richards0.1513deltablue0.4032geomean0.4506
-
Zef Mudança #11: Tabela hash
nbody0.1796splay2.6528richards0.1379deltablue0.3551geomean0.3906
-
Zef Mudança #12: Evitar
std::optionalnbody0.1689splay2.6563richards0.1379deltablue0.3518geomean0.3839
-
Zef Mudança #13: Argumentos especializados
nbody0.1610splay2.5823richards0.1350deltablue0.3372geomean0.3707
-
Zef Mudança #14: Slow paths de valor aprimorados
nbody0.1348splay2.5062richards0.1241deltablue0.3076geomean0.3367
-
Zef Mudança #15:
DotSetRMW::evaluatededuplicadonbody0.1342splay2.5047richards0.1256deltablue0.3079geomean0.3375
-
Zef Mudança #16:
sqrtrápidonbody0.1274splay2.5045richards0.1251deltablue0.3060geomean0.3322
-
Zef Mudança #17:
toStringrápidonbody0.1282splay2.2664richards0.1275deltablue0.2964geomean0.3235
-
Zef Mudança #18: Especialização de literal de array
nbody0.1295splay1.6661richards0.1250deltablue0.2979geomean0.2992
-
Zef Mudança #19: Otimização de
callOperatorde valornbody0.1208splay1.6698richards0.1143deltablue0.2713geomean0.2810
-
Zef Mudança #20: Melhor configuração de C++
nbody0.1186splay1.6521richards0.1127deltablue0.2635geomean0.2760
-
Zef Mudança #21: Sem asserts
nbody0.1194splay1.6504richards0.1127deltablue0.2619geomean0.2759
-
Zef em Yolo-C++
nbody0.0233splay0.3992richards0.0309deltablue0.0784geomean0.0686
1 comentários
Comentários do Hacker News
Num contexto parecido, esta página sobre o desempenho do interpretador Wren foi bem interessante
Se o texto sobre o Zef é mais centrado em técnicas de implementação, o lado do Wren também mostra como o próprio design da linguagem contribui para o desempenho
Em especial, achei interessante que o Wren abre mão de dynamic object shapes, o que permite copy-down inheritance e torna a busca de métodos muito mais simples
Pessoalmente, isso me parece um trade-off bem razoável. Fico pensando com que frequência, na prática, alguém realmente precisa adicionar métodos a uma classe depois que ela já foi criada
Existem muitas VMs muito otimizadas para linguagens dinâmicas, mas sinto que o LuaJIT é forte porque Lua já é uma linguagem muito pequena e muito adequada para otimização
Há alguns recursos que são difíceis de otimizar, mas são poucos o bastante para valer o investimento
Python, por outro lado, me parece completamente diferente. Com um pouco de exagero, foi praticamente projetada para minimizar a possibilidade de um JIT rápido, e as várias camadas de dinamismo tornam a otimização realmente difícil
O fato de que, depois de tanto tempo de trabalho, o JIT do CPython 3.15 no x86_64 ainda fica só cerca de 5% à frente do interpretador padrão parece mostrar isso muito bem
Claro, isso também faz lembrar que Ruby não é exatamente conhecida como uma linguagem com foco máximo em velocidade
Por outro lado, a ideia de que algum tipo tem um conjunto fechado de funções aplicáveis também me soa um pouco questionável
Existem várias linguagens nas quais você pode definir funções arbitrárias e depois usá-las com sintaxe de método por ponto em variáveis cujo tipo do primeiro argumento seja compatível
Por exemplo, macros em Nim, implicit classes e type classes em Scala, extension functions em Kotlin e traits em Rust
Linguagens dinâmicas complexas tendem a destruir agressivamente essa possibilidade de várias formas, então a otimização acaba ficando difícil
Olhando em retrospecto, isso parece até bastante óbvio
Na mudança do #5 para o #6, o fato de que inline caches e o modelo de objetos com hidden classes geram a maior parte do ganho de desempenho me pareceu muito parecido com a forma como V8 e JSC historicamente ficaram rápidos
O ponto em que um interpretador ingênuo morre, no fim das contas, é o despacho dinâmico no acesso a propriedades, e o resto passa a impressão de ser relativamente um rounding error
Também gostei de como foi organizado para que desse para ver separadamente quanto cada etapa contribuiu. Normalmente textos sobre desempenho só jogam o número final e param por aí
Num interpretador de bytecode, basta modificar offsets estáveis no fluxo de bytecode, então o local de reescrita do IC surge naturalmente
Aqui, porém, a posição do cache é o nó da AST, então foi impressionante ver @pizlonator usar
constructCache<>para construir, in-place, nós especializados da AST sobre nós genéricosNo fim, isso pareceu uma espécie de código automodificante no nível da AST
Em compensação, essa abordagem exige mutable AST nodes, o que entra em conflito com a suposição de ASTs imutáveis que muitos compiladores esperam para coisas como compartilhamento de subárvore ou compilação paralela
Para um interpretador single-threaded, parece elegante, mas imagino problemas se o interpretador estiver modificando nós enquanto a mesma AST é compilada por JIT numa thread em segundo plano
Na minha opinião, talvez isso não represente tão bem a maior parte do código real de produção
Tive essa impressão por causa da parte em que a otimização de
sqrtrendeu 1,6% de melhoriaPara isso acontecer, o benchmark já teria de gastar pelo menos 1,6% do tempo só nisso, o que achei surpreendente
Olhando o repositório git, parece que isso de fato acontece na simulação nbody
Achei ainda mais interessante porque eu também publiquei recentemente a primeira versão do meu AST-walking interpreter
Meu objetivo era entender, num nível básico, o que é necessário para criar uma linguagem interpretada
Eu não queria colocar complexidade de otimização; só queria conseguir entender o meu próprio código em Rust
Mas me surpreendi com o fato de que, só por usar Rust, o desempenho já ficou bem bom
Além disso, como Rust cuida de ownership e lifetimes, foi um bônus sentir que não precisei de um garbage collector separado
Claro que, no momento, ainda dependo de
clonede forma bem conservadora para evitar um inferno de lifetimes em partes como closures, mas mesmo assim o perfil de velocidade e memória me parece totalmente aceitávelSe alguém tiver interesse num tree-walking interpreter simples e fácil de entender, feito em Rust, pode dar uma olhada no meu interpretador gluonscript
O texto ficou realmente muito bom
Em especial, o arco de Arguments, isto é, o caminho do #7 ao #13, conversa muito com a minha experiência
Uma vez, ao criar um async step evaluator em Rust, eu entrei fundo em
Cow<'_, Input>por acreditar que, em geral, borrow traria vantagensEm microbenchmarks parecia bom, mas em cargas reais a complexidade do discriminant do Cow e das lifetimes se espalhou por todos os combinators depois do primeiro
await, e o inlining desmoronou a ponto de eliminar o motivo para usar CowNo fim, troquei isso na fronteira do evaluator por
NoInput / OneInput / MultiInput(Vec), e, apesar de parecer mais tosco, acabei chegando praticamente ao mesmo lugar que a separação entre ZeroArguments / OneArgument / TwoArguments aquiUma coisa que continua me deixando curioso é se foi testado empilhar especialização de tipos por cima da especialização de aridade no caminho nativo
Por exemplo, com algo no estilo binário talvez desse até para eliminar a própria checagem
isIntMinha suspeita é que ou a conta de tamanho de código não fechou, ou então o lado dos objetos já teve os caminhos quentes suficientemente cobertos pelos ICs, de modo que o fast path nativo não faria tanta diferença
Fiquei curioso sobre qual dos dois casos foi o que aconteceu
Isso ficou realmente interessante e muito bem feito
Eu também já trabalhei em algo parecido, mas voltado para Scheme, que é uma linguagem mais funcional
Aqui a maior parte do ganho veio da otimização de objetos, mas no meu caso o grande ponto decisivo foi otimizar closures
Curiosamente, a forma de otimizar acabou sendo bastante parecida
Na minha opinião, a resposta para tornar Scheme suficientemente rápida está quase toda em Three implementation models for scheme
A diferença é que ali há algum nível de etapa de compilação, então não é um modelo de interpretar diretamente a AST original
Foi interessante, e obrigado por compartilhar
Isso me deu vontade de, algum dia, estudar esse assunto com mais profundidade
E também achei bem engraçado e marcante que, no GitHub, o repositório aparece como 99.7% HTML e 0.3% C++
Isso pareceu uma boa prova de que o interpretador é realmente muito pequeno
Por causa da forma como ele gera código para o navegador, a parte do site acabou ficando desnecessariamente grande
Ainda assim, o interpretador em si é realmente muito pequeno
Fiquei curioso se, ao fazer esse trabalho, você aprendeu algo que poderia tornar o próprio fil c melhor
E também aprendi que o custo de tratar métodos de value object como outline call é bem alto
Vi que Lua foi incluída, mas achei que seria bom ver LuaJIT também
Aliás, considerando o nível de engenharia que entrou ali, eu até esperaria que fosse assim mesmo
Havia muitos runtimes que poderiam ter sido incluídos, mas nem todos entraram
E também foi bastante impressionante ver que a PUC Lua é bem mais rápida que QuickJS e Python
Fiquei curioso sobre como é a experiência real de usar Fil-C e se ele tem utilidade prática em produção
Ainda assim, neste projeto isso ajudou de forma bem prática
Ele pegou vários problemas de segurança de memória de forma determinística, o que tornou o design do modelo de objetos muito mais fácil do que seria de outra forma
Além disso, C++ com GC preciso me pareceu um modelo de programação realmente muito bom
A sensação foi de algo como 1,5x mais produtividade do que C++ comum e, mesmo comparando com outras linguagens com GC, algo como 1,2x mais velocidade de desenvolvimento
Acho que isso acontece porque o ecossistema de APIs de C++ é rico, e lambdas, templates e o sistema de classes são muito maduros
Claro, também reconheço que há viés em várias dimensões
Eu mesmo criei o Fil-C++ e também uso C++ há uns 35 anos
Fiquei curioso sobre o que é o compilador YOLO-C/C++ mencionado no texto
Procurei e quase não aparece nada, e o ChatGPT também parece não saber do que se trata