Otimização de baixo nível e Zig
(alloc.dev)- Otimização de baixo nível é o trabalho de fazer o compilador entender melhor as intenções e restrições do código, e o Zig se encaixa bem nesse objetivo por facilitar a explicitação de tipos, alinhamento, aliasing e informações de tempo de compilação
- Mesmo compiladores otimizadores como o LLVM nem sempre geram o melhor código possível, então, em trechos com gargalo, ainda é necessário inspecionar o código gerado e ajustar o código-fonte
- O Zig pode produzir código vetorizado menor do que o exemplo em JavaScript ao passar em tempo de compilação informações como
noalias,align, tamanho fixo de array e tipo dos elementos comptimeexecuta código Zig comum em tempo de compilação, permitindo metaprogramação como geração de constantes, implementação de genéricos, reflexão de tipos e otimização de comparação de strings- A força do Zig está menos em macros que alteram diretamente a AST e mais na execução em tempo de compilação integrada à linguagem, além de permitir despachar alguns valores de runtime para funções especializadas em tempo de compilação
Por que confiar no compilador, mas ainda verificar
- Otimização vai além da técnica de criar programas mais rápidos: ela também se conecta à redução de custos, ao ganho de escalabilidade e à manutenção da simplicidade do sistema
- Compiladores modernos produzem resultados impressionantes com backends como o LLVM, mas em algumas situações ainda geram código subótimo
- Linguagens de baixo nível são rápidas não só por terem menos overhead de garbage collection ou de interpretador, mas também porque conseguem expressar mais informações de intenção que o compilador pode entender
- O compilador não consegue mudar o algoritmo nem o paradigma de programação em si, e em geral otimiza dentro de um escopo limitado, como loops
Exemplo de máximo entre arrays em JavaScript e Zig
- O exemplo em JavaScript grava em
xo maior valor entre os elementos de dois arrays, no formatox[i] = y[i] > x[i] ? y[i] : x[i] - Para humanos, o código é claro, mas o bytecode gerado pelo V8 é inflado
- O exemplo em Zig declara com mais precisão, nos argumentos da função, as informações necessárias para otimização
noalias x:xnão faz alias com outros ponteiros*align(64): alinhamento de 64 bytes[65536]f64: tamanho do array e tipo dos elementosconst: argumento somente leitura
- Graças a essas informações, o compilador consegue gerar código melhor e, no exemplo, produz assembly vetorizado
- Um código Rust equivalente também gera praticamente o mesmo assembly
Onde o Zig favorece a otimização e quais são seus limites
- O Zig permite expressões verbosas para transmitir bastante informação sobre o código ao LLVM
- Os principais elementos oferecidos pelo Zig em relação à otimização são:
- O modelo de memória do Rust permite que o compilador assuma sempre que argumentos de função não criam alias, mas no Zig isso precisa ser indicado manualmente
- Se o compilador não souber se os argumentos de uma função Zig têm aliasing, uma função Zig sem anotações pode ser mais lenta que uma função Rust
- Mesmo tomando como base apenas LLVM IR bem anotado, o Zig entrega bons resultados, mas sua força maior está na execução em tempo de compilação
O papel de comptime
- O
comptimedo Zig é um recurso para geração de código em tempo de compilação - Entre as tarefas possíveis em tempo de compilação estão:
- gerar constantes e incluí-las no binário
- evitar reescrever a mesma estrutura de hashmap para vários tipos de dados
- induzir otimizações para remover código desnecessário com base em dados conhecidos em tempo de compilação
- inspecionar, refletir e gerar tipos para implementar genéricos
- Código
comptimeé código Zig comum executado em tempo de compilação e não pode ter efeitos colaterais como IO de rede - A máquina de emulação usada em tempo de compilação corresponde ao alvo de compilação
- Quase todo código Zig pode ser executado em tempo de compilação com
comptime, e todos os tipos podem ser inspecionados, refletidos e gerados nesse estágio
Como isso difere de macros
- O objetivo de
comptimeé parecido com o de macros, mas o modo de funcionamento é diferente - Algumas macros alteram texto bruto, e outras modificam diretamente a AST do programa
- O
comptimedo Zig não altera a AST diretamente, nem oferece recursos como macros de concatenação de tokens - Como o Zig busca ser uma linguagem legível, ele não combina com o estilo de macro que cria ou modifica variáveis em escopos não relacionados
- Entre as tarefas que macros conseguem fazer, mas que o
comptimedo Zig não faz diretamente, estão:- definir outras macros
- alterar a AST
- implementar diretamente minilinguagens ou DSLs
- Ainda assim, é possível criar DSLs em Zig, e a função
printdo Zig faz o parse da string de formatação comcomptimepara montar o grafo de funções que serializa os dados - Exemplos incluem TigerBeetle account testing DSL, comath, zilliam
Otimização de comparação de strings com comptime
- Uma comparação de strings comum retorna
falsese os comprimentos forem diferentes e, se forem iguais, compara cada byte em sequência - Esse método exige ler e comparar bytes de ambas as strings
- Muitas vezes uma das strings já é conhecida em tempo de compilação, então no Zig é possível exigir que um dos argumentos seja
comptimefn staticEql(comptime a: []const u8, b: []const u8) bool
- Ao comparar com uma string estática como
"Hello!\n", o compilador gera código composto por comparação de comprimento e comparações constantes de cada byte - O objetivo desta seção não é apenas mostrar otimizações que o compilador pode fazer automaticamente, mas mostrar que
comptimepode forçar a transformação e abrir oportunidades que o compilador talvez não enxergue sozinho
Comparação em blocos maiores e uso de SIMD
- A comparação simples de strings com
comptimeainda faz comparações byte a byte - A versão melhorada usa
std.simd.suggestVectorLength(u8)ou@sizeOf(usize)para definir o tamanho do bloco de comparação - Depois de verificar primeiro o comprimento da string, ela calcula quantos blocos grandes podem ser comparados e quantos bytes sobram
- Cada bloco é convertido com
@bitCastpara um tipo inteiro criado porstd.meta.Int(.unsigned, block_len * 8)e então comparado - Os bytes restantes também são comparados com um tipo inteiro separado
- No exemplo com
"Hello, World!\n", o assembly gerado usa registradores maiores e reduz o número de desvios condicionais - Em comparações de strings maiores, o assembly gerado passa a usar registradores SIMD maiores
Usando valores de runtime junto com especialização em tempo de compilação
- O
comptimedo Zig não se limita apenas a dados conhecidos em tempo de compilação - Em casos simples, é possível gerar vários procedimentos em tempo de compilação e, depois, fazer despacho dinâmico para o procedimento adequado conforme o valor de runtime
- O código de exemplo envia, no
switch (runtime_val), valores do intervaloinline 0...100parastaticFn(comptime_val)e trata o restante comruntimeFn(runtime_val) - Se não se quiser aumentar o tamanho do binário, é possível fazer fallback para uma implementação totalmente em runtime
Conclusão
- O
comptimedo Zig cumpre o papel de substituir templates, macros, genéricos e geração manual de código - É possível fazer coisas parecidas em outras linguagens, mas no Zig o
comptimeestá integrado de forma mais natural à linguagem - O Zig facilita escrever código de alto desempenho em situações realmente úteis e contrasta com um Turing tar-pit, em que tudo é possível, mas qualquer tarefa interessante é difícil
- Sobre guerras de linguagem, permanece tanto a visão ampla de que completude de Turing bastaria quanto a ideia de que as pessoas ainda podem ter linguagens preferidas
- Frases como “C é mais rápido que Python”, que tratam a linguagem em si como objeto de benchmark, podem ser enganosas; na prática, o alvo do benchmark não é a linguagem, mas um código e uma implementação específicos
1 comentários
Opiniões no Hacker News
O que mais me atrai no Zig é que ele mira em facilidade do sistema de build, cross-compilation e ciclos rápidos de iteração
Como desenvolvedor de jogos, tenho requisitos de desempenho, mas acho que a maioria das linguagens já entrega o desempenho necessário, então isso não é o critério nº 1 na escolha da linguagem
É possível escrever código poderoso em qualquer linguagem, mas o ponto central é escolher um framework altamente preparado para o futuro que permita manter código modular por décadas
C/C++ era a resposta padrão porque é suportado em todos os lugares, e parece que o Zig pode chegar a esse mesmo nível
Zig é hostil ao encapsulamento, e não é possível tornar membros de structs privados: https://github.com/ziglang/zig/issues/9909#issuecomment-9426...
A citação central é a posição de que “campos privados e getters/setters são um antipadrão popularizado por Java; campos são dados que existem, então dê nomes a eles com cuidado e documente-os como parte da API pública”
Sem conseguir ocultar a representação interna, fica difícil criar adequadamente o contrato de API que é a base da modularidade de software; deveria ser possível mudar a representação interna sem quebrar o código dos usuários
A posição do Zig parece ser a de que não deveria existir algo separado chamado representação interna, e que a própria representação deve ser pública, documentada e garantida; espero que um dia revertam essa decisão e passem a oferecer suporte a campos privados
Muita coisa funcionou de primeira, e até consegui depurar bugs estranhos com um GDB antigo
Eu também fui convencido pelo Zig, e escrevi sobre isso aqui: https://news.ycombinator.com/item?id=44211041
Não entendo muito bem por que o odeiam tanto
Generics feios também existem em C# e TypeScript, e o borrow checker é um conceito compreensível se você já fez trabalho de baixo nível
Entre as ferramentas feitas sobre Zig, a que realmente me impressiona é o bun; depois que passei a usar bun, minha vida ficou muito mais simples
Dá para dizer algo parecido sobre o uv, feito em Rust
Em geral, consoles não gostam de nada que não seja C/C++, mas como Zig pode ser convertido para C, talvez não seja totalmente descartado
Sobre a afirmação de que “até compiladores modernos quebram a especificação da linguagem (o Clang assume que todos os loops sem efeitos colaterais terminam)”, não duvido que compiladores às vezes quebrem a especificação, mas, nesse caso, o Clang está correto pelo menos desde o C11
O C11 diz que, se a expressão de controle não for uma expressão constante e o laço não realizar entrada/saída, acesso volatile, sincronização nem operações atômicas, a implementação pode assumir que ele termina
Portanto, em C, um loop infinito simples
for (;;);deve de fato ser compilado como um loop infinito, e oloop {}menos opaco de Rust também deveria serSó que o LLVM é feito por pessoas que às vezes esquecem que nem sempre estão criando um compilador C++; então houve um ponto em que, quando Rust dizia “me dê um loop infinito”, o LLVM respondia “segundo C++, isso não existe, então vou otimizar”, e isso foi uma aplicação incorreta a outra linguagem
Não é estritamente necessário usar comptime para inlinear e desdobrar comparações de strings
Também é possível em C: https://godbolt.org/z/6edWbqnfT
Corrigi o typo
Um exemplo melhor é https://github.com/RetroDev256/comptime_suffix_automaton
Dito isso, o código do godbolt linkado também mostra, na prática, um dos dois exemplos ruins
Não acho que comparar o exemplo em JavaScript com os exemplos em Zig/Rust seja uma boa comparação
Parece que mandaram os compiladores de Zig e Rust escolherem uma CPU-alvo muito moderna, enquanto o V8 não está nas mesmas condições
Um JIT com otimização também sabe vetorizar quando as condições são adequadas
Para referência, a maioria das linguagens modernas realiza a mesma otimização em strings, e há um exemplo em C++ aqui: https://godbolt.org/z/TM5qdbTqh
O exemplo em Zig usa arrays de tipo conhecido e tamanho fixo, enquanto o código JS é “genérico” em tempo de execução, de modo que
xeypodem ser qualquer objetoEm JS, é correto pagar esse custo, mas, ironicamente, neste exemplo específico seria possível transmitir melhor as informações de tipo ao JIT
Se você fizer essa função ser chamada sempre com Float64Array do mesmo tamanho, o JIT pode saber disso e gerar um loop mais rápido. Mesmo que não vetorize, fica muito melhor
Só que arrays tipados têm um custo alto de inicialização, então, na prática, não são muito usados a menos que você aloque uma vez um array tipado grande e o reutilize muitas vezes
O texto também diz que o bytecode JS ficou bem inflado; provavelmente uma parte grande disso vem dos guards inseridos porque o JIT não consegue garantir que
65536seja igual ao tamanho dos dois arraysAinda assim, na prática ninguém escreveria um loop
fordesse jeito, e simi < x.length; nesse caso, o JIT remove pelo menos uma verificação de arraytargetpara uma CPU mais antigaDesculpe por não ter pensado nas limitações do alvo JS
O exemplo em C++ linkado é um bom exemplo do que o Clang consegue fazer em C++, mas, mesmo levando em conta que Zig compila para uma CPU específica, o assembly gerado parece um pouco decepcionante
Seria muito interessante ver um port para C++ de https://github.com/RetroDev256/comptime_suffix_automaton
Esse é um uso de comptime que um compilador C++ não consegue inferir de forma limpa
Não sei se a frase “linguagens de alto nível carecem da riqueza de intenção das linguagens de baixo nível” está realmente certa
Expressão de intenção não parece ser um elemento do espectro alto nível/baixo nível; pelo contrário, quanto mais maneiras houver de expressar a intenção em detalhes, mais perto de alto nível deveria estar
É uma intenção sobre o que você quer fazer a máquina executar, mais do que sobre o que se quer alcançar
Um código como
purchase.calculate_tax().await.map_err(|e| TaxCalculationError { source: e })?;está cheio de intenção, mas não dá para saber qual código de máquina sairá no fimAquela sintaxe de loop
foré horrívelQuer dizer que há duas listas lado a lado e a posição de um item em uma lista corresponde à posição de um item na outra?
Só de olhar já dói os olhos
Parece que as linguagens modernas tomaram o caminho errado quando começaram a adicionar todo tipo de “mágica” ao parser e a espalhar pequenos símbolos por todo o código
Não é um formato que eu queira encarar por horas
Por isso é natural que Zig ofereça uma sintaxe que facilite exatamente isso, deixando claro o que está acontecendo
Pessoalmente acho que ficou muito bom; fico curioso para saber por que dói os olhos
Gosto muito do modelo de alocadores do Zig
Seria bom se em Go também fosse possível usar algo como um alocador por requisição em vez de coleta de lixo
Como a própria linguagem não tem uma forma de expressar e impor regras de ownership, no fim é como escrever C com uma sintaxe um pouco diferente e torcer para dar certo
Sem coleta de lixo, até C++ é muito mais seguro que Go
O modelo de memória do Rust permite que o compilador sempre assuma que argumentos de função nunca fazem referências com alias, mas, em Zig, isso precisa ser especificado manualmente
Tenho evitado essas especificações de aliasing
Poucas pessoas as entendem, e, se usadas incorretamente, podem introduzir bugs difíceis de compreender no código
Sobre a afirmação de que a flexibilidade do comptime do Zig trouxe boas melhorias para outras linguagens, execução de funções em tempo de compilação e funções que recebem argumentos constantes foram introduzidas por D em 2007 e levaram várias linguagens a adotar coisas semelhantes
https://dlang.org/spec/function.html#interpretation
Gosto de Zig, mas dizer que “gosto da verbosidade do Zig” soa estranho
É verdade que C é solto demais em vários cantos, mas, pelo padrão atual, Zig às vezes pende um pouco demais para o lado oposto e acaba gerando muito ruído de anotações de tipo que parecem comentários
Isso acontece especialmente com casts inteiros explícitos em expressões; escrevi um pouco sobre isso aqui: https://floooh.github.io/2024/08/24/zig-and-emulators.html
Em termos de desempenho, quando código Zig é mais rápido que código C semelhante, em geral é por causa das configurações de otimização do LLVM mais agressivas do Zig
Por exemplo, por padrão o Zig usa
-march=nativee otimização de programa inteiro, e compila todo o código Zig do projeto como uma única unidade de compilaçãoQuase todos os “truques”, como usar
unreachablecomo dica de otimização, também são possíveis em C; só que às vezes exigem extensões de linguagem não padronizadasCompiladores C, especialmente o Clang, também fazem constant folding de forma muito agressiva e conseguem reduzir grandes regiões de código que podem ser dobradas para constantes, mesmo com pilhas de chamadas profundas
Então, olhando apenas para a geração de código, muitas vezes não há grande diferença em relação ao comptime do Zig
A vantagem do comptime é que ele não volta silenciosamente a ser código em tempo de execução, e código que não é comptime também fica sujeito às mesmas otimizações de constant folding que em C
Por exemplo, se uma função “pura” não-comptime é chamada com argumentos constantes, o compilador ainda substitui a chamada da função pelo resultado
Em resumo, se código C está mais lento que código Zig, é preciso verificar as configurações do compilador C. No fim, todo o trabalho pesado de otimização acontece por baixo, no LLVM
fn signExtendCast(comptime T: type, x: anytype) T { const ST = std.meta.Int(.signed, @bitSizeOf(T)); const SX = std.meta.Int(.signed, @bitSizeOf(@TypeOf(x))); return @bitCast(@as(ST, @as(SX, @bitCast(x)))); }export fn addi8(addr: u16, offset: u8) u16 { return addr +% signExtendCast(u16, offset); }Compila para o mesmo assembly, é reutilizável e deixa a intenção mais clara
Concordo com isso
Virgil permite usar a linguagem inteira em tempo de compilação desde 2006 e também oferece suporte a compilação de programa inteiro
Porém, como Virgil não tem LLVM como alvo, a comparação de velocidade acaba sendo uma comparação entre os backends dos dois compiladores
Virgil se apoia bastante na análise de alcançabilidade e nas otimizações de especialização possibilitadas por esse modelo de compilação
Por exemplo, ele desvirtualiza chamadas de método de forma agressiva, remove campos e objetos inalcançáveis, promove constantes por meio de campos e objetos no heap, e monomorfiza completamente código polimórfico
Simplesmente porque isso as torna mais fáceis para a IA lidar
Se usar IA para programar é uma boa ideia, e até que ponto, é uma questão à parte; mas muitos desenvolvedores acreditam nisso, e as linguagens tentarão acomodá-los