Modelo de Dados de Alto Desempenho do Rust
Toda biblioteca de dataframes ou motor de consulta precisa decidir como organizar os dados na memória antes de decidir como computar sobre esses dados. No ecossistema de dados do Rust, essa decisão foi tomada uma vez, pelo projeto Apache Arrow, e quase tudo que veio depois a herdou. Polars e DataFusion parecem competir em superfície de API, expressões versus SQL, mas por baixo compartilham o mesmo substrato colunar, e entender esse substrato explica por que ambos são rápidos das mesmas maneiras e lentos das mesmas maneiras.
Esta página é a âncora conceitual para o restante desta seção. Ela não detalha a instalação de crates ou a escrita de uma consulta; ela explica o modelo de memória que faz com que os exemplos do restante da seção funcionem como funcionam.
Resumo
- Arrow define um layout colunar, tipado e contíguo em memória, e Polars e DataFusion constroem diretamente sobre esse layout em vez de inventarem suas próprias estruturas baseadas em linhas.
- Por que Importa: Estruturas orientadas a linhas forçam uma varredura a tocar em todos os campos de cada registro, mesmo quando apenas uma coluna é necessária, o que anula a vetorização da CPU e desperdiça a largura de banda do cache em dados que a consulta nunca usa.
- Conceitos Chave: layout colunar, RecordBatch, cópia zero, execução vetorizada, predicate pushdown, bitmap de validade.
- Quando Usar: Decidir entre Polars e DataFusion para um projeto, projetar um serviço Rust que entrega dados para Python ou JVM, avaliar se deve construir um formato de memória personalizado, ou diagnosticar por que uma operação que você assumiu ser rápida não é.
- Limitações / Trade-offs: Layouts colunares tornam consultas de linha única e mutações pequenas frequentes mais caras do que um armazenamento de linha construído especificamente para esse padrão de acesso.
- Tópicos Relacionados: Apache Arrow, Polars, DataFusion, Parquet.
Fundamentos
Uma estrutura orientada a linhas armazena um registro completo de forma contígua, em seguida, o próximo registro logo após ele. Pense em uma planilha lida da esquerda para a direita, uma linha inteira de cada vez, antes de passar para a próxima linha. Esse layout é intuitivo e corresponde à forma como a maioria do código de aplicação acessa os dados: buscar um registro, ler seus campos.
Um layout colunar inverte isso. Ele armazena todos os valores de uma coluna juntos, em um buffer contíguo, antes de armazenar os valores da próxima coluna em um buffer separado. A planilha agora é lida de cima para baixo, uma coluna de cada vez. Isso parece uma pequena reorganização, mas muda quase tudo sobre a velocidade com que um motor de consulta pode rodar.
O Apache Arrow padronizou esse layout colunar como uma especificação entre linguagens, em vez de um detalhe específico da biblioteca. Antes do Arrow, cada motor analítico (pandas, Spark, Drill, dplyr) mantinha sua própria noção privada de "uma tabela em memória", e mover dados entre eles significava serializar para algum formato de linha intermediário e pagar o custo dessa conversão toda vez. A contribuição do Arrow não foi a ideia de armazenamento colunar em si, os motores de banco de dados a usavam há anos, mas a decisão de tornar o layout de bytes idêntico entre linguagens e ferramentas para que os dados pudessem se mover entre eles sem conversão.
Uma analogia útil é um padrão de contêiner de transporte. Qualquer porto, navio ou caminhão construído de acordo com a especificação do contêiner pode mover qualquer contêiner sem reempacotar seu conteúdo. Arrow é esse padrão para dados tabulares em memória: qualquer ferramenta compatível com Arrow, em Rust, Python ou JVM, pode ler os mesmos buffers sem reempacotar.
Polars e DataFusion são duas portas de entrada diferentes para o mesmo pátio de contêineres. Polars oferece uma API de expressão DataFrame; DataFusion oferece SQL. Ambos compilam para operações sobre colunas formatadas em Arrow.
Mecânicas e Interações
Internamente, uma coluna Arrow é um buffer tipado e contíguo mais um bitmap de validade que marca quais posições estão nulas. Como todo valor em um buffer compartilha um tipo e fica ao lado de seus vizinhos na memória, a CPU pode carregar vários valores em uma única instrução. Isso permite a vetorização SIMD (instrução única, múltiplos dados): um kernel que soma uma coluna Int64 processa quatro ou oito valores por ciclo em vez de um, porque ele nunca precisa pular campos não relacionados da maneira que uma varredura de linha faz.
A localidade do cache agrava esse efeito. Quando uma consulta precisa apenas da coluna amount de uma tabela de cinquenta colunas, um motor colunar lê apenas o buffer amount. Uma varredura orientada a linhas da mesma tabela puxaria todas as colunas de todas as linhas para o cache apenas para descartar quarenta e nove delas, desperdiçando a largura de banda da memória, que geralmente é o verdadeiro gargalo em cargas de trabalho analíticas, não os ciclos brutos da CPU.
O DataFrame e o LazyFrame do Polars são, estruturalmente, coleções de Series baseadas em Arrow, agrupadas em um ou mais buffers por coluna. O DataFusion vai um passo adiante e torna o RecordBatch, uma fatia horizontal através de várias colunas Arrow que compartilham um comprimento comum, a unidade real que flui entre os operadores de execução física. Em vez de um modelo "vulcão" clássico linha por linha, onde cada operador puxa uma linha do próximo, os operadores do DataFusion puxam um lote de milhares de linhas por vez. Essa execução vetorizada em lotes só é possível porque os dados subjacentes já são colunares; um modelo vulcão orientado a linhas não ganharia nada com o loteamento, já que os operadores ainda trabalham campo por campo.
Orientado a linhas (um registro por vez):
[id1, nome1, amt1] [id2, nome2, amt2] [id3, nome3, amt3] ...
Colunar (um campo por vez, em todos os registros):
id: [id1, id2, id3, ...]
nome: [nome1, nome2, nome3, ...]
amt: [amt1, amt2, amt3, ...]Compartilhar esse layout também é o que torna possíveis as transferências zero-copy entre ferramentas. Quando o Polars produz um resultado e o entrega ao DataFusion, ou quando um serviço Rust retorna um RecordBatch Arrow para um chamador Python através do PyArrow, nenhuma conversão linha por linha acontece. O processo receptor lê os mesmos buffers, encapsulados em Arc<dyn Array> dentro do Rust, para que clonar um manipulador nunca copie profundamente megabytes de dados. Cópia zero é uma propriedade de layouts correspondentes, não um truque especial que qualquer uma das bibliotecas executa.
O planejamento preguiçoso de consultas em ambos os motores existe para decidir, antes da execução, quais colunas e quais intervalos de linhas realmente precisam ser materializados em buffers. Predicate pushdown e projeção de colunas acontecem no nível do plano, antes que qualquer array Arrow seja construído, então o modelo colunar compensa duas vezes: uma vez pulando colunas não lidas, e novamente pulando linhas não lidas em formatos como Parquet que armazenam estatísticas por grupo de linhas.
Considerações Avançadas e Aplicações
A decisão arquitetônica mais consequente neste ecossistema não foi "devemos usar colunas", mas sim "todas as ferramentas devem concordar com o mesmo layout de bytes exato". Antes desse acordo, a interoperação entre duas ferramentas "rápidas" de análise ainda significava um imposto de serialização em cada fronteira. Arrow Flight (um transporte baseado em gRPC para dados Arrow) e a Arrow C Data Interface (uma ABI estável para compartilhar buffers Arrow através de limites FFI sem cópia) existem especificamente para estender semânticas de cópia zero através de limites de processo e linguagem, não apenas dentro de um único binário Rust.
É também por isso que criar manualmente um formato struct-of-arrays personalizado em um serviço Rust raramente compensa hoje. O layout bruto não é difícil de reproduzir, mas o ecossistema em torno do Arrow, interoperação com PyArrow, transporte Flight, otimizador do DataFusion, motor de expressão do Polars, é o valor real, e um formato específico não obtém nada disso.
A execução vetorizada interage com tempos de execução assíncronos de uma maneira específica que vale a pena nomear: um RecordBatch é uma unidade de trabalho síncrona e limitada pela CPU, enquanto o pipeline que move lotes entre estágios (especialmente em serviços DataFusion) é frequentemente assíncrono. Um lote muito grande bloqueia um thread worker do Tokio por muito tempo; um lote muito pequeno reintroduz sobrecarga por lote e começa a corroer o benefício de localidade de cache que justificou a adoção do colunar em primeiro lugar. O dimensionamento do lote (comumente dezenas de milhares de linhas) é um verdadeiro botão de ajuste, não um detalhe de implementação a ser ignorado.
| Abordagem | Força | Fraqueza | Melhor Ajuste |
|---|---|---|---|
Estrutura orientada a linhas (Vec<Record>) | Leituras e mutações rápidas de registro único | Baixa taxa de transferência de varredura, sem interoperação entre ferramentas | Pequeno estado em processo, acesso estilo OLTP |
| Struct-of-arrays personalizada | Boa taxa de transferência de varredura dentro de um único codebase | Sem especificação compartilhada, cada fronteira precisa de um conversor | Kernels numéricos isolados sem necessidades de interoperação |
| Baseado em Arrow (Polars, DataFusion) | Varreduras vetorizadas, interoperação de cópia zero, ferramentas compartilhadas | Consultas pontuais e mutações pequenas mais caras | Análise, ETL e qualquer pipeline que cruze limites de linguagem ou processo |
A partir do Polars 0.46+, a API de expressão (col, lit, when) fica diretamente sobre esse layout baseado em Arrow e é a superfície documentada e estável a ser visada; as garantias colunares subjacentes descritas aqui não mudaram nas versões recentes do Polars, apenas a ergonomia sobre elas mudou.
Equívocos Comuns
- "O armazenamento colunar torna meu código automaticamente vetorizado." O layout por si só não vetoriza nada; um loop ingênuo por linha escrito sobre arrays Arrow no código da aplicação ainda executa um valor por vez. A vetorização vem de kernels (em Polars, DataFusion ou no módulo de computação do
arrow) escritos para explorar buffers contíguos do mesmo tipo, não do layout por si só. - "Arrow é um formato de arquivo, como Parquet." Arrow descreve um layout em memória; Parquet descreve um layout em disco, comprimido. Motores rotineiramente decodificam arquivos Parquet em lotes Arrow para computação, mas as duas especificações resolvem problemas diferentes.
- "A cópia zero funciona automaticamente entre quaisquer dois processos." Requer alinhamento de buffer correspondente e, para casos entre máquinas, um arranjo de memória compartilhada ou mmap; um salto de rede entre dois serviços ainda copia bytes pelo menos uma vez no lado receptor.
- "Polars e DataFusion têm internos fundamentalmente diferentes porque suas APIs parecem tão diferentes." O DSL de expressão e o parser SQL são apenas duas interfaces frontais; ambos compilam para operações sobre lotes do mesmo formato Arrow, e resultados de um podem ser registrados como tabelas no outro.
- "Colunar é estritamente mais rápido que orientado a linhas, ponto final." Vence para varreduras e agregações em poucas colunas de muitas linhas. Perde para consultas pontuais, mutação frequente de registro único e qualquer padrão de acesso que genuinamente precise de um registro inteiro de uma vez.
- "Eu poderia obter os mesmos benefícios escrevendo meu próprio tipo struct-of-arrays." Você obteria o benefício de velocidade de varredura dentro do seu próprio código, mas não a interoperabilidade que tornou a abordagem do ecossistema digna de adoção em primeiro lugar: transporte Flight compartilhado, transferência PyArrow e todas as ferramentas downstream que já falam Arrow.
FAQs
O que realmente torna um layout "colunar" em vez de "orientado a linhas"?
É sobre quais valores ficam próximos uns dos outros na memória.
- Orientado a linhas: todos os campos de um registro são contíguos, então o próximo registro segue.
- Colunar: todos os valores de um campo são contíguos em todos os registros, então o buffer do próximo campo segue.
- Os mesmos dados, reorganizados, produzem comportamentos de varredura e cache muito diferentes.
Por que Polars e DataFusion não criaram seus próprios formatos de memória?
Eles poderiam ter feito isso, e ferramentas de geração anterior frequentemente o fizeram.
Padronizar no Arrow significa que ambos os projetos herdam a interoperação de cópia zero com Python, JVM e entre si gratuitamente, em vez de cada um manter um formato privado e um conversor para cada outra ferramenta com a qual desejam se comunicar.
Como o layout colunar realmente produz acelerações SIMD?
Como todo valor em um buffer de coluna compartilha um tipo e fica contíguo na memória, a CPU pode carregar e operar em vários valores com uma única instrução vetorial em vez de um valor por vez. Uma varredura de linha interrompe esse padrão toda vez que cruza para um campo não relacionado.
O que é um RecordBatch, em termos simples?
Uma fatia horizontal através de um conjunto fixo de colunas Arrow que compartilham o mesmo comprimento. É a unidade que flui entre os operadores no motor de execução do DataFusion e a unidade trocada sobre Arrow IPC ou Flight entre processos.
Usar Arrow significa que meu código Rust é automaticamente rápido?
Não. O layout permite que kernels vetorizados sejam rápidos; o código que você escreve que percorre os valores do array um por um não se beneficia automaticamente apenas porque o buffer subjacente é colunar.
Como os valores nulos são representados em um array colunar?
Cada array carrega um bitmap de validade separado ao lado de seu buffer de valor, um bit por posição, marcando quais posições são nulas. O buffer de valor ainda reserva espaço nas posições nulas; o bitmap é o que diz aos leitores para ignorá-lo.
Arrow é a mesma coisa que Parquet?
Não. Arrow é um layout em memória; Parquet é um layout comprimido em disco. Motores de consulta comumente decodificam grupos de linhas Parquet diretamente em lotes Arrow para computação, que é por que os dois são frequentemente mencionados juntos.
Quando o modelo colunar é a escolha errada?
Para cargas de trabalho dominadas por leituras ou escritas de registro único, pense em buscar um perfil de usuário por ID, ou atualizar uma linha por vez em um loop ativo, um armazenamento orientado a linhas ou um banco de dados OLTP tradicional superará um motor colunar, que paga sobrecarga para remontar ou reescrever um registro completo a partir de buffers de coluna dispersos.
Como a interoperação de cópia zero com Python realmente funciona?
Ambos os lados concordam com o mesmo layout de bytes, então um processo Rust pode entregar um ponteiro e esquema para um processo Python (tipicamente através do PyArrow) e o Python lê os mesmos buffers sem qualquer etapa de desserialização linha por linha, desde que o alinhamento e a ordem dos bytes correspondam.
Por que Polars e DataFusion parecem tão diferentes se compartilham o mesmo modelo de memória?
A diferença está inteiramente na camada de API. Polars expõe um DSL de expressão nativo do Rust (col, lit, encadeamento de métodos); DataFusion expõe texto SQL analisado em um plano lógico. Ambos os planos são executados como operações sobre lotes em formato Arrow, que é por que mover um resultado de um para o outro como uma tabela registrada é barato.
Uma consulta preguiçosa toca no Arrow em cada etapa, ou apenas no final?
Apenas no final, por design. O plano preguiçoso é uma descrição do trabalho (filtros, projeções, junções) que o otimizador reescreve antes que qualquer buffer Arrow seja alocado; collect() (Polars) ou a execução da consulta (DataFusion) é o ponto em que os buffers são realmente construídos.
Preciso entender Arrow para usar Polars efetivamente?
Não para escrever consultas básicas, mas explica comportamentos que de outra forma você acharia surpreendentes: por que collect() é um limite de pico de memória e CPU, por que select * amplo em um enorme arquivo Parquet é caro, e por que exportar para Python ou DataFusion é quase gratuito em comparação com uma viagem de ida e volta em JSON.
Relacionados
- Noções Básicas de Dados - um tutorial prático que se baseia neste modelo mental
- Apache Arrow - o próprio formato colunar, com construção de RecordBatch e IPC
- Polars - a API de expressão DataFrame construída sobre este layout
- DataFusion - o motor SQL construído sobre o mesmo layout
Versões da Stack: Esta página foi escrita para Rust 1.97.0 (edição 2024), Tokio 1.x, Axum 0.8, serde 1.0, sqlx 0.8, clap 4, e Polars 0.46+.