6 pontos por GN⁺ 2026-04-22 | 1 comentários | Compartilhar no WhatsApp
  • 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 sqrt e toString
  • 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*
    • 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 0xffff000000000000 para inteiros
    • 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
  • 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
  • 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::evaluate sobrescrito em vários pontos
    • Uso excessivo de strings
      • O nó AST Get armazena uma std::string que descreve o nome da variável
      • Essa string é usada em cada acesso à variável
    • Uso excessivo de tabelas hash
      • Ao executar Get, faz uma busca em std::unordered_map usando a string como chave
    • 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
  • 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 DotCall com nome de operador e passa a gerar nós AST separados para cada operador
    • Em Zef, a + b e a.add(b) são equivalentes
      • Antes, a + b era analisado como DotCall(a, "add") com o argumento b
      • Em toda operação aritmética ocorria busca da string com o nome do método do operador
      • DotCall passava a string para Value::callMethod
      • Value::callMethod fazia múltiplas comparações de strings
    • Depois da mudança, o parser gera nós Binary<> e Unary<>
      • Usando templates e lambdas, ele fornece overrides diferentes de Node::evaluate para cada operador
      • Cada nó chama diretamente o caminho rápido de Value correspondente ao operador
      • Por exemplo, a + b chama Binary<lambda for add>::evaluate e depois Value::add
    • 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
  • Otimização #2: chamada direta de operadores RMW

    • Os operadores normais ficaram mais rápidos, mas formas RMW como a += b ainda 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]
    • 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
    • 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, dot e subscript e passar o enum por vários pontos
      • No fim, a função template Value::callRMW<> faz o despacho real da chamada do operador RMW
    • 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
  • Otimização #3: evitar a verificação de IntObject

    • O gargalo era que o caminho rápido de Value usava isInt(), e internamente isIntSlow() fazia uma chamada virtual para Object::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
    • Após a otimização, o caminho rápido de Value considera 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
    • 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
  • Otimização #4: Symbol

    • Originalmente, o interpretador usava std::string em 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ília Object::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 Symbol com hash consing
    • Foi adicionada uma nova classe Symbol
      • Implementada em symbol.h e symbol.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
    • Em vez de literais de string, passam a ser usados símbolos preparados previamente
      • Por exemplo, no lugar de "subscript", usa-se Symbol::subscript
    • Muitas assinaturas de função foram alteradas para usar Symbol* no lugar de const 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
  • 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.h em outro header é que ele usa headers que precisam incluir value.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, ClassObject e Context para 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
  • Modelo de objetos

    • Antes, era alocado um objeto Context para cada escopo léxico
      • Cada Context mantinha uma tabela hash com as variáveis daquele escopo
    • Os objetos tinham uma estrutura mais complexa
      • Cada objeto mantinha uma tabela hash que mapeava para Context as classes das quais era instância
    • Essa estrutura era necessária por causa de herança e escopos aninhados
      • Quando Bar herda de Foo, Bar e Foo fazem closure sobre escopos diferentes
      • Também podem ter campos privados diferentes com o mesmo nome
    • A nova estrutura introduz o conceito de Storage
      • Os dados são armazenados de acordo com offsets
      • O offset é determinado por algum Context
    • Context ainda existe, mas passa a ser pré-criado no passo resolve da 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
  • Cache inline

    • Técnica que memoriza, em um ponto de código como expr.name, o tipo dinâmico de expr visto por último e o último offset para o qual name foi 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
  • Componentes do cache inline

    • CacheRecipe
      • Rastreia o que um acesso específico fez e se isso pode ser armazenado em cache
    • Chamadas a CacheRecipe foram inseridas em vários pontos de Context, ClassObject e Package
      • Para coletar informações do processo de acesso
    • Funções de avaliação de AST como Dot::evaluate passam o CacheRecipe obtido na operação polimórfica que executaram, junto com this, para constructCache<>
    • 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 storage recebido
      • 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
    • 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 por constructCache<>
  • watchpoint

    • É apresentado o exemplo de um escopo léxico com a variável x, contendo dentro dele a classe Foo, e um método de Foo acessando x
    • Se não houver uma função ou variável chamada x dentro de Foo, pode parecer que ele pode ler diretamente o x externo
    • Porém, uma subclasse pode adicionar um getter x
    • Nesse caso, o resultado do acesso deve ser o getter, e não o x externo
    • Para lidar com essa possibilidade de mudança, o cache inline instala um Watchpoint em tempo de execução
    • No exemplo, é usado um watchpoint que monitora se esse nome foi sobrescrito
  • 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 CacheRecipe e 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::tryCallMethod implementava todos os métodos interceptando a chamada virtual Object::tryCallMethod
      • No novo modelo de objetos, Object não tem vtable nem métodos virtuais
      • Em vez disso, Object::tryCallMethod delega para object->classObject()->tryCallMethod(object, ...)
      • Portanto, para fornecer métodos de Array, foi necessário criar a própria classe de Array com esses métodos
    • 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

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 optional era que, em alguns casos de canto, era necessário distinguir entre os dois a seguir
      • o.getter
      • o.function()
    • No Zef, em geral ambos são chamadas de função, mas existe a seguinte exceção
      • o.NestedClass
      • o.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
    • 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 malloc do backing store do vector
    • No Fil-C++, o próprio std::optional faz alocação no heap
      • Mesmo sem std::optional, passar const 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
    • 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
  • 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
    • Mesmo instâncias do mesmo tipo não podem acessar o f de outro objeto
      • Exemplo: fn nope(o) o.f
      • println(Foo(42).nope(Foo(666)))
      • O o.f dentro de nope não pode acessar o f de o
    • O motivo é que o campo funciona pela forma como aparece na cadeia de escopos dos membros da classe
      • o.f não é uma leitura de campo, mas um pedido de chamada de método com o nome f
    • Por isso, o seguinte padrão aparece com frequência
      • my f
      • fn f f
      • Ou seja, um método chamado f que retorna a variável local f
    • Há uma sintaxe mais curta: readable f
      • Forma abreviada de my f e fn f f
    • 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::inferGetter infere se o corpo da função é um getter simples
    • Regras de inferência
      • Block::inferGetter infere que é getter se tudo o que contém puder ser inferido como getter
      • Get::inferGetter infere a si mesmo como getter e retorna o offset a ser carregado
      • Context::tryGetFieldOffsets retorna um Offsets não vazio apenas quando o campo com certeza existe no escopo léxico em que o getter será executado
      • UserFunction é resolvida como uma subclasse especial de Function que 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 em ClassObject, além de conferir se o parâmetro do setter está sendo usado como origem do set
    • 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
  • 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::tryCallMethod e ClassObject::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 o tryCallMethodSlow
      • 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
  • Otimização #12: evitar std::optional

    • No Fil-C++, o std::optional precisa 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 FilPizlonator tenta 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::optional no 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
  • 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 Arguments para 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, OneArgument e TwoArguments
      • Quando o callee não precisa deles, o caller pode evitar a alocação de um objeto Arguments
    • ZeroArguments é necessário para diferenciá-lo de (Arguments*)nullptr
      • Antes, (Arguments*)nullptr era usado com o significado de chamada de getter, e essa lógica foi mantida
      • Agora, ZeroArguments significa chamada de função sem argumentos
    • Muitas mudanças consistiram em transformar em template as funções que recebem argumentos
      • Foi feita instanciação explícita para ZeroArguments, OneArgument, TwoArguments e Arguments*
      • Grande parte do código existente usava Value::getArg como 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
    • 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

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 Value era uma função-membro de Value e exigia um argumento implícito const Value*
    • Nessa estrutura, o caller precisava alocar Value na stack
    • No Fil-C++, toda alocação na stack é uma alocação no heap
      • Portanto, o código que chamava o slow path alocava Value no heap
    • Depois da mudança, esses métodos foram tornados static e Value passou 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<> e Value::callRMW<> dependem de verificar se o receiver é int ou double
    • Esse método só se aplica a operadores reconhecidos pelo parser
      • Não se aplica a formas como value.sqrt
    • Com esta mudança, Dot pode ser especializado para value.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 toString foi 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 int em 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
  • 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, 3 todas as vezes
    • Esta mudança especializa o nó ArrayLiteral para 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
  • Otimização #19: melhoria em Value::callOperator

    • A mesma otimização que antes trouxe ganho de velocidade por não passar Value por referência também foi aplicada ao slow path de callOperator
    • 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
  • 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_ENABLED está definido
    • 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 é subótima porque o alocador de GC real é mais rápido que o calloc do 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

    • nbody 0.0364
    • splay 0.8326
    • richards 0.0822
    • deltablue 0.1135
    • geomean 0.1296
  • Lua 5.4.7

    • nbody 0.0142
    • splay 0.4393
    • richards 0.0217
    • deltablue 0.0832
    • geomean 0.0577
  • QuickJS-ng 0.14.0

    • nbody 0.0214
    • splay 0.7090
    • richards 0.7193
    • deltablue 0.1585
    • geomean 0.2036
  • Zef Baseline

    • nbody 2.9573
    • splay 13.0286
    • richards 1.9251
    • deltablue 5.9997
    • geomean 4.5927
  • Zef Mudança #1: Operadores diretos

    • nbody 2.1891
    • splay 12.0233
    • richards 1.6935
    • deltablue 5.2331
    • geomean 3.9076
  • Zef Mudança #2: RMWs diretos

    • nbody 2.0130
    • splay 11.9987
    • richards 1.6367
    • deltablue 5.0994
    • geomean 3.7677
  • Zef Mudança #3: Evitar IntObject

    • nbody 1.9922
    • splay 11.8824
    • richards 1.6220
    • deltablue 5.0646
    • geomean 3.7339
  • Zef Mudança #4: Símbolos

    • nbody 1.5782
    • splay 9.9577
    • richards 1.4116
    • deltablue 4.4593
    • geomean 3.1533
  • Zef Mudança #5: Valor inline

    • nbody 1.4982
    • splay 9.7723
    • richards 1.3890
    • deltablue 4.3536
    • geomean 3.0671
  • Zef Mudança #6: Modelo de objetos e caches inline

    • nbody 0.3884
    • splay 3.3609
    • richards 0.2321
    • deltablue 0.6805
    • geomean 0.6736
  • Zef Mudança #7: Argumentos

    • nbody 0.3160
    • splay 2.6890
    • richards 0.1653
    • deltablue 0.4738
    • geomean 0.5077
  • Zef Mudança #8: Getters

    • nbody 0.2988
    • splay 2.6919
    • richards 0.1564
    • deltablue 0.4260
    • geomean 0.4809
  • Zef Mudança #9: Setters

    • nbody 0.2850
    • splay 2.6690
    • richards 0.1514
    • deltablue 0.4072
    • geomean 0.4651
  • Zef Mudança #10: callMethod inline

    • nbody 0.2533
    • splay 2.6711
    • richards 0.1513
    • deltablue 0.4032
    • geomean 0.4506
  • Zef Mudança #11: Tabela hash

    • nbody 0.1796
    • splay 2.6528
    • richards 0.1379
    • deltablue 0.3551
    • geomean 0.3906
  • Zef Mudança #12: Evitar std::optional

    • nbody 0.1689
    • splay 2.6563
    • richards 0.1379
    • deltablue 0.3518
    • geomean 0.3839
  • Zef Mudança #13: Argumentos especializados

    • nbody 0.1610
    • splay 2.5823
    • richards 0.1350
    • deltablue 0.3372
    • geomean 0.3707
  • Zef Mudança #14: Slow paths de valor aprimorados

    • nbody 0.1348
    • splay 2.5062
    • richards 0.1241
    • deltablue 0.3076
    • geomean 0.3367
  • Zef Mudança #15: DotSetRMW::evaluate deduplicado

    • nbody 0.1342
    • splay 2.5047
    • richards 0.1256
    • deltablue 0.3079
    • geomean 0.3375
  • Zef Mudança #16: sqrt rápido

    • nbody 0.1274
    • splay 2.5045
    • richards 0.1251
    • deltablue 0.3060
    • geomean 0.3322
  • Zef Mudança #17: toString rápido

    • nbody 0.1282
    • splay 2.2664
    • richards 0.1275
    • deltablue 0.2964
    • geomean 0.3235
  • Zef Mudança #18: Especialização de literal de array

    • nbody 0.1295
    • splay 1.6661
    • richards 0.1250
    • deltablue 0.2979
    • geomean 0.2992
  • Zef Mudança #19: Otimização de callOperator de valor

    • nbody 0.1208
    • splay 1.6698
    • richards 0.1143
    • deltablue 0.2713
    • geomean 0.2810
  • Zef Mudança #20: Melhor configuração de C++

    • nbody 0.1186
    • splay 1.6521
    • richards 0.1127
    • deltablue 0.2635
    • geomean 0.2760
  • Zef Mudança #21: Sem asserts

    • nbody 0.1194
    • splay 1.6504
    • richards 0.1127
    • deltablue 0.2619
    • geomean 0.2759
  • Zef em Yolo-C++

    • nbody 0.0233
    • splay 0.3992
    • richards 0.0309
    • deltablue 0.0784
    • geomean 0.0686

1 comentários

 
GN⁺ 2026-04-22
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

    • Na minha visão, a velocidade de um interpretador ou JIT é enormemente determinada pelo design da linguagem
      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
    • Essa abordagem me lembra o que sempre se faz em linguagens nas quais monkey patching é aceito de forma idiomática, especialmente Ruby
      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
    • Pela minha experiência, em geral, se você consegue atribuir um tipo estático a alguma expressão, então normalmente dá para compilá-la com bastante eficiência
      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í

    • No #6, um detalhe de implementação especialmente interessante foi como fazer inline caching num interpretador que percorre a AST diretamente
      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éricos
      No 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
    • Concordo com a direção geral, mas acho que há uma pequena observação de que isso é, no fim das contas, o resultado para apenas um benchmark específico
      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 sqrt rendeu 1,6% de melhoria
      Para 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 clone de 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ável
    Se 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 vantagens
    Em 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 Cow
    No 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 aqui
    Uma 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 isInt
    Minha 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

    • Isso aparece assim porque o site gerado estaticamente foi commitado
      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

    • Com certeza senti que a forma de lidar com unions precisa de uma solução 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

    • Minha expectativa é que o LuaJIT derrotaria o Zef com folga
      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

    • Eu sou o próprio Fil, então antes de tudo deixo claro que há viés
      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

    • Foi uma expressão usada pelo autor de Fil-C e também desta linguagem para se referir a C/C++ comum, sem Fil-C, como Yolo-C/C++