Arrays de enums eficientes em memória em Zig
(alic.dev)- Ao armazenar em massa enums/tagged unions com variants de tamanhos diferentes, o custo de padding e fragmentação em
VeceHashMapaumenta porque o espaço é reservado com base na maior variant - Zig consegue verificar tamanho de campos, alinhamento e discriminant por meio de
comptimee reflexão de tipos, e transformar genericamente contêineres de enum com base no layout de memória - Um
Vec<Enum>simples usa, para cada elemento, espaço equivalente ao tamanho da maior variant; SoA reduz o padding das tags, mas ainda deixa fragmentação de variants na área de valores - A AoVA densa, que agrupa variants do mesmo tamanho, reduz no enum de exemplo 15 vetores para 3 clusters de 2, 4 e 8 bytes, mas quando várias variants se misturam na mesma allocation fica difícil iterar com segurança de tipos
- Macros procedurais em Rust têm dificuldade para acessar informações de tamanho e alinhamento de tipos e também sofrem restrições para calcular comprimentos de arrays genéricos, enquanto o staging com consciência de tipos do Zig evidencia melhor a composabilidade de eficiência de memória em código de sistemas
O espaço desperdiçado por arrays de enums em Rust
- Um enum/tagged union com variants de tamanhos diferentes precisa reservar memória suficiente para conter a maior variant
- O enum de exemplo
Footem variantsu8,u16,u32eu64; por causa da tag e do alinhamento, o tamanho do tipo vira 16 bytes - Ao colocar muitos desses enums em um
VecouHashMap, cada elemento ocupa espaço com base na maior variant, aumentando padding e fragmentação - A transformação para struct of arrays (SoA), colocando as tags em uma allocation separada, reduz parte do padding, mas não elimina a fragmentação da área de valores causada pela diferença de tamanho entre variants
- Em Rust, até é possível criar manualmente uma estrutura de dados para um enum específico, mas criar uma estrutura de dados genérica e o mais eficiente possível em memória para qualquer enum é difícil ou praticamente impossível
- É difícil aplicar
#[derive]de uma proc macro a tipos de terceiros ou type aliases, e a composabilidade é baixa - Não há consciência de tipos; desvios baseados em
generic_const_exprespalham cláusulaswhereverbosas pelo grafo de chamadas e não combinam bem com generic type parameters
- É difícil aplicar
Por que o problema se destaca em ASTs de compiladores
- Uma das grandes motivações para arrays de enums eficientes é o uso de memória de ASTs de compiladores
- ASTs grandes causam latência de memória e eviction de cache durante a compilação, criando um custo significativo para a performance do front-end
- Em um vídeo de Chandler Carruth sobre o compilador Carbon, é dito que a AST parseada do clang frequentemente consome 50 vezes mais memória do que o código-fonte original
- Um exemplo em Rust para representar nós de expressão é composto por um enum
ExprUnitNumberBinary(Operation, ExprId, ExprId)Ident(Symbol)Eval(ExprId, ExprSlice)BlockExpression(ExprId, StatementSlice)
- Em OCaml, como o sistema de runtime e o GC cuidam do gerenciamento de memória, é possível representar tipos de dados recursivos sem indirection explícito
- O
Vec<Expr>de Rust usa, para todos os elementos, espaço igual asizeof(Enum), incluindo o tamanho da maior variant, a tag e o padding
Reduzindo fragmentação com SoA e AoVA
- Quando um enum simples de 3 variants tem membros de 8, 16 e 32 bits, um
Veccomum reserva espaço grande para todos os elementos para acomodar a variant de 32 bits e os requisitos de alinhamento - Uma melhoria comum é usar tagged index etc. para manter a própria enum variant pequena
- O crate
tagged_indexdo compilador Rust - Casos de small-string optimization
- É uma otimização usada com frequência em código de alta performance, como runtimes de linguagem, GCs, compiladores, game engines e kernels de SO
- O crate
- Também é possível trocar o contêiner e armazenar o discriminant e os valores em allocations separadas, no estilo SoA
- O compilador Zig self-hosted usa essa abordagem
- O padding causado pela tag diminui, mas a coleção de valores da union ainda fica com fragmentação de variants
- A staged compilation do Zig permite criar genericamente um contêiner que realiza a transformação SoA para qualquer tipo
- Rust precisa depender de proc macros como
soa_derive, com a limitação de que não é possível adicionar#[derive]sem alterar o código-fonte de tipos de terceiros
Arrays por variant e clustering por tamanho
- Para reduzir ainda mais a fragmentação da área de valores, é possível manter um vetor por variant
- Na inserção, retorna-se um tagged index que contém a tag do enum e o índice dentro do array da respectiva variant
- Esse padrão é chamado de array of variant arrays (AoVA)
- AoVA pode ser implementado com proc macro em Rust e com
comptimeem Zig - Quando há muitas variants e várias têm o mesmo tamanho, vetores por variant podem se multiplicar demais
- O enum
Foodo exemplo tem 15 variants - A abordagem com vetores por variant adiciona 15 vetores
- Isso pode aumentar realocações e o número de chamadas de sistema, além de exigir mais memória para amortization em comparação com um
Vecingênuo - Os vetores podem ficar espalhados arbitrariamente na memória, aumentando a chance de conflitos de cache
- O próprio contêiner AoVA também usa bastante memória e pode inflar a struct que o contém
- O enum
- Agrupando por tamanho, o enum de exemplo se divide em três clusters: 2 bytes, 4 bytes e 8 bytes
c_2: Vec<[u8; 2]>armazena deAaDc_4: Vec<[u8; 4]>armazena deEaIc_8: Vec<[u8; 8]>armazena deJaO
- A abordagem de AoVA densa pode reduzir o número total de vetores em 80%
- Ao colocar variants diferentes juntas na mesma allocation, fica difícil iterar sobre o vetor com segurança de tipos
- O acesso só é possível por meio do tagged pointer criado no momento da inserção
- Em uma estrutura de árvore baseada em índices flattened que não precise de blind iteration, isso pode ser um trade-off aceitável
- Se for necessária iteração com segurança de tipos, é possível recolocar a tag aceitando o custo de padding
- Se o padding for grande demais, pode-se aplicar transformação SoA a cada array de variant, mas nesse caso o número de vetores dobra
A composabilidade de layout de memória criada pelo comptime do Zig
- O protótipo em Zig está implementado em osmium
- O ponto central é a reflexão em tempo de compilação que inspeciona, por meio de built-ins do compilador, tipo dos campos, tamanho em bytes, tamanho em bits e discriminant
- O código de exemplo verifica o tipo com
@typeInfo(inner)e processa apenas quando ele é uma union- Percorre os campos da union
- Calcula o espaço necessário com
@max(field.alignment, @sizeOf(field.type)) - Armazena as informações de tamanho em um vetor stack-allocated
- Constrói o mapping de cada campo da union para o índice do cluster
- Se não for uma union, gera um compile error
- O trecho exato de código está nesta fonte
- Criar o mesmo exemplo com proc macro em Rust é basicamente impossível
- Proc macros não conseguem acessar informações de size ou alignment de um tipo
- Mesmo que gerem uma
const fnde cálculo de cluster para um enum específico, isso não pode ser usado para especificar o comprimento de arrays de generic types
- Implementações de contêiner genérico em Rust dificilmente conseguem mudar condicionalmente dependendo de o tipo fornecido ser enum ou struct
- Em Zig, conceitualmente é possível escolher entre
EfficientEnumArray<T>eEfficientStructArray<T>de acordo comT.isEnum() - A implementação de AoVA também pode ser escolhida conforme as características do enum
- Por exemplo, é possível especializar só quando o benefício de colocar variants diferentes juntas for considerado significativo, como ao reduzir o número de vetores em mais de 90%
- Se a capacidade máxima for conhecida em tempo de compilação, a função geradora de tipos pode determinar a bitwidth necessária para o tagged index
- Quando esse tagged index é incluído em outra estrutura de dados, por exemplo dentro de outro enum, os bits restantes podem ser aproveitados no discriminant
- Zig permite especificar concretamente o número necessário de bits, oferecendo eficiência de memória composável, na qual outras partes do código aproveitam essa informação naturalmente
- Graças à coerção implícita de widening integers, a usabilidade se mantém mesmo ao lidar com APIs de bitwidths diferentes
- Uma linguagem de programação de sistemas que valorize eficiência e zero-cost abstractions deveria reconsiderar staged programming, especialmente o
comptimedo Zig
1 comentários
Opiniões no Hacker News
Há outra estratégia que tem boa eficiência de armazenamento e também preserva a iteração pelos elementos. O primeiro vetor seria a lista de tags, o segundo vetor teria os deslocamentos em bytes de cada elemento, e o terceiro, mais do que um vetor, conteria os dados compactados das variantes apontados pelo segundo vetor.
Assim, há metade do número de vetores em relação à solução final do autor (6 vs. 3), não se desperdiçam bytes de padding exceto quando necessário por causa de alinhamento, e os dados ficam em sequência na memória independentemente do tipo, permitindo uma iteração amigável ao cache. Também é possível acessar elementos por índice em O(1). No geral, para dados heterogêneos, isso tem características de desempenho parecidas com as de um
Vec.Tpequeno. Por exemplo, é o caso da combinação de umsize_tde 64 bits comuint8_t T; tomando cuidado apenas com o tamanho do offset, parece uma abordagem razoável.Fico curioso sobre como essa estrutura de dados AoVA funciona na prática. Do ponto de vista de array, a aritmética de índices pode deixar de fazer sentido; então não se perde o acesso baseado em índice? A iteração também não parece preservar a ordem de inserção.
Nesse contexto, acho que TLV (tag-length-value), com melhores características de cache, é mais comum. O comprimento pode ser implícito pela tag e, no mínimo, oferece uma iteração para frente significativa. Veja
getdents,inotifye mensagens Netlink.Em comparação com o layout SoA anterior, surge uma ordem parcial, não uma ordem total. Na inserção, a estrutura retorna um índice marcado que contém tanto a tag do enum quanto o índice dentro do array da variante correspondente. Então o acesso ordenado parece ficar fora do escopo aqui. Armazenar um índice global em cada elemento permitiria recuperar a iteração ordenada, mas ainda não ajudaria no acesso aleatório ordenado e provavelmente resultaria em código com bastante branching.
Armazenar objetos por tamanho também é uma técnica usada em garbage collectors e alocadores genéricos. Dá para obter eficiência por conhecer todos os tamanhos possíveis dos objetos, ou por usar uma estratégia de liberação mais simples, como em uma arena.
Nesse caso, os arrays podem ser vistos como um componente de uma estrutura parecida com heap, ou seja, como uma arena. O custo é que o índice precisa se tornar bidimensional, algo como
(tag_idx, va_for_tag_idx). Mas, como o número de tags é conhecido em tempo de compilação, dá para otimizar o armazenamento empacotandotag_idxnos 4 ou 5 bits superiores e deixandova_for_tag_idxusar o restante. Referência: https://www.cs.cornell.edu/~asampson/blog/flattening.htmlÉ um pouco frustrante que o pattern matching do Rust não possa ser expresso mais como uma espécie de trait do sistema de tipos, que qualquer struct arbitrária poderia implementar, em vez de ser um tipo de objeto explícito, de primeira classe e hardcoded, com sua própria estrutura de armazenamento.
Recentemente implementei uma AST como a deste artigo e também um interpretador de opcode/bytecode, e senti que os enums do Rust não são totalmente ideais para nenhum dos dois. Na AST, eu queria adicionar atributos de número de linha/coluna a todos os nós de statement, mas colocar linha/coluna em todos os casos do enum
Stmtdeixava o boilerplate feio; envolver o enum em uma nova structStmtque contivesse o enum original junto com os atributos de linha/coluna exigia muita refatoração e não era elegante. No lado dos opcodes, também não dá para dizer que um enum do Rust usado com pattern matching seja uma codificação ideal para o desempenho de um interpretador de opcodes de VM, mas a linguagem empurra nessa direção e os recursos de padrões de desestruturação são muito atraentes. Parece haver espaço para melhorias no sistema de tipos que permitam obter o pattern matching enquanto se usa a implementação de baixo nível desejada.https://en.wikipedia.org/wiki/Structural_type_system
computed gotopara isso; em Rust, imagino que seria necessário algo com ponteiros de função e uma forma de forçar otimização de chamada em cauda.Se esse salto indireto for colocado no início da implementação de cada opcode, o preditor de saltos indiretos que a CPU tem por causa de OOP passa a ter modelos separados para o fim de diferentes opcodes, aumentando a taxa de acerto da previsão. A próxima instrução em si pode ser difícil de prever, mas, por exemplo, depois de um test, é muito mais provável que venha um branch. Dito isso, outras técnicas, como manter o topo da pilha em um registrador em uma máquina de pilha, provavelmente são mais importantes, e não sei bem se a técnica acima ainda faz sentido hoje.
A afirmação de que “o AST do clang parseado consome regularmente 50 vezes mais memória que o código-fonte original” parece bem grande, mas o contexto que falta é quanto isso pode melhorar. Se for preciso preservar a posição de origem de cada token e codificar informação suficiente para recuperá-la corretamente a partir do AST, fico curioso se o aumento ideal em relação ao original seria 1,5× ou 15×
É difícil dizer qual seria a taxa ideal de expansão de fonte→AST em uma linguagem que seja amigável tanto para usuários quanto para desenvolvedores de compilador, mas 50× ainda funciona. O texto original usa a taxa de expansão de 50× como motivação para automatizar uma otimização específica. Seria interessante se vetores de enums em Rust pudessem decompor automaticamente valores de enum em tags e valores opacos, para armazená-los como uma estrutura de arrays, como o texto original faz em Zig. Também parece que não haveria muitos lugares onde esconder o uso de
unsafeEm documentos compostos majoritariamente por caracteres
[]ou por caracteres0,, o overhead máximo parece ficar em torno de 8×Bytes do fonte: 139 KiB, tokens: 24646 (120 KiB), nós do AST: 10998 (140 KiB). Cada token é bem minimalista, com 5 bytes (tag de 1 byte + offset de arquivo de 4 bytes), e os nós do AST também são codificados de forma densa e não uniforme, ficando neste caso em cerca de 13 bytes por nó. Mesmo com essa codificação mínima, a parse tree fica com quase 2× o tamanho do arquivo-fonte. Ainda assim, 2× é muito melhor que 50×. Fonte:
zig ast-check -t lib/std/zig/Parse.zig | head -n7Esse espaço de problema parece uma variação de problemas de empacotamento
Seria bom poder começar pela estrutura final, fácil para humanos trabalharem, e gerar recomendações de estruturas de dados que reduzam desperdício de memória, respeitem regras de alinhamento e aumentem a localidade espacial. https://en.wikipedia.org/wiki/Packing_problems
Seria bom se proc macros evoluíssem para poder consultar informações do compilador. Isso exigiria um design cuidadoso em relação à adição de fases de compilação, mas coisas como “essa struct implementa este trait?” e “me dê a lista de todos os traits implementados concretamente” costumam ser muito úteis em proc macros
Entendi só parte do artigo, mas, para alguém que quer escrever um motor de planilhas em Rust, isso parece muito relevante. Valores de célula precisam de algo deste tipo
pub enum Expr { Number(i32), String(String), Reference(Position), Binary(Box, char, Box), Function(String, Vec), }Pretendo continuar lendo e estudando, e referências são bem-vindas
Uma técnica comum vinda da área de jogos é dividir um array de structs (AoS) em uma struct de arrays (SoA). Por exemplo, com algo como
struct Humans { healths: Vec, ammo: Vec, … }, o índice i de cada vetor corresponde ao i-ésimoHumanno layout AoS. Esses vetores paralelos são apenas um exemplo, não a eficiência ótima, porque desperdiçam memória duplicando a contabilidade de tamanho e capacidade para cada campo. Este artigo basicamente tenta aplicar automaticamente uma ideia parecida a enums, e em Rust é difícil fazer isso diretamente. Talvez a importância real desse problema esteja um pouco exagerada. Em uma planilha, eu manteria isso em mente apenas como uma otimização possível e primeiro decidiria se você está construindo para velocidade ou para simplicidade e compreensãoSe você permitir 1 milhão × 1 milhão de células e armazenar
nullem todas as células não preenchidas, a memória vai acabar. Portanto, vale considerar armazenar o conteúdo das células de forma esparsa. Uma forma é usar uma implementação de hash map comohashbrown. O ponto deste artigo está nos detalhes de baixo nível; se você começar com um hash map desde o início e evitar as restrições iniciais de memória, não precisa pensar muito nisso por enquantoO problema isolado mais difícil é a estratégia de avaliação
E quanto a https://doc.rust-lang.org/reference/type-layout.html#the-alignment-modifiers?
Acho que há um bug no código de exemplo
field_map[idx] = svec.len - 1;Se
svecjá contiversizeem uma posição que não seja a última entrada, isso vai dar errado