O tempo de um pipeline de dados vai para três lugares: ler e decodificar os dados, o trabalho feito em cada linha e a espera (no armazenamento, em um bloqueio ou em uma thread ocupada). A decodificação geralmente já é ajustada nas bibliotecas. O trabalho em cada linha é o que você escreve, e é onde um loop descuidado custa 2–3×.
A maior parte deste arquivo é sobre o código que consome os lotes de uma varredura. Para o leitor, escritor ou codec subjacente, e para obter um número em que você possa confiar, veja as duas últimas seções e suas referências.
Primeiro, meça onde o tempo é gasto
Raramente você tem uma segunda implementação (Ray, DuckDB, pyarrow) para comparar. Use a própria varredura como referência:
- Meça a varredura sozinha. Execute a mesma varredura, com as mesmas colunas, filtro
e
ScanOptions, e descarte os lotes. Esta é a linha de base de decodificação pura: nenhum pipeline pode ser mais rápido que ela. - Meça seu trabalho isoladamente. Envolva a chamada por lote (
fold,add, ou qualquer coisa que consuma o lote) comperf_counter_ns()e some os tempos. - Compare-os. Em uma thread, um tempo total dentro de cerca de 1,5× do tempo de decodificação pura significa que o trabalho é barato; acima disso, o trabalho é o gargalo e vale a pena otimizar. Em muitas threads, verifique também se o trabalho roda em uma thread enquanto a decodificação roda em muitas.
O exemplo medido: um group-by em 77M viagens levou 4,6 s em uma thread, dos quais 2,1 s foram o trabalho por linha (45%). Em 8 threads levou 2,6 s, dos quais os mesmos 2,1 s foram o trabalho (82%), porque uma thread consumiu cada lote. references/profiling.md tem a receita e suas armadilhas.
Regras para o loop por linha
Cada regra vem com o que economizou no benchmark de shuffle do dista (77M linhas, tabela local, uma thread). O código está em references/hot-loops.md.
| Regra | Efeito medido |
|---|---|
Pegue o ponteiro bruto de cada coluna uma vez por lote e depois indexe o ponteiro. Não indexe List ou Tuple[List, List] dentro do loop. | 1,40 → 0,78 s para todo o trabalho por linha |
| Mantenha tudo que uma chave acumula lado a lado em um único slot (array de structs), não em arrays paralelos, para que uma atualização toque uma única linha de cache. | 2,09 → 1,40 s, junto com a próxima regra |
Filtre sem ramificações: compute cada linha, escreva-a em um índice kept e avance kept por Int(keep). Depois, adicione as linhas mantidas em um segundo loop enxuto. | medido junto com a regra acima |
Quando as chaves são inteiros pequenos (zone × zone × hour é 1,7M), indexe um array denso pela chave em vez de usar um Dict. | não medido isoladamente; ambas as versões usaram array denso |
| Não altere a ordem das adições de ponto flutuante enquanto otimiza. Assim, a resposta deve corresponder exatamente à antiga, bit a bit, o que é uma verificação de correção gratuita. | cada etapa acima correspondeu exatamente |
Regras para o pipeline inteiro
| Regra | Efeito medido |
|---|---|
| Não deixe que uma thread consuma o que muitas threads decodificam. Dê a cada worker seu próprio acumulador e mescle-os ao final, como as tarefas de mapa do dista fazem. | 16 threads levaram um group-by de um processo de 202 s para apenas 126 s na AWS |
Mantenha várias leituras em andamento, mesmo com uma thread de decodificação: ScanOptions.files_at_once (e overlap_io) no iceberg.mojo. | 6,6–6,9× em uma thread lendo do S3 |
| Carregue uma biblioteca C compartilhada uma vez por processo e mantenha o handle, nunca uma vez por chamada. | com zstd aberto em cada chamada, uma varredura gastou 52% do tempo em dyld; corrigido, 2.687 → 262 ms |
| Coloque em cache qualquer coisa que você analise e que se repita byte a byte, como esquemas Avro em manifestos. | o custo fixo do planejamento de manifesto caiu 3,6× (113,7 → 31,9 µs) |
| Não aloque por registro: decodifique em slots reutilizados. | o leitor Avro alocou cerca de 60 vezes por registro antes do cursor |
| Conheça o teto: além de alguns núcleos, uma varredura é limitada pela largura de banda da memória, e mais threads não ajudarão. Leia menos bytes. | uma varredura Iceberg levou 35,8 ms em um núcleo e 12,1 ms em quatro, cerca de 3× |
references/case-studies.md conta cada uma dessas histórias, com o que revelou o problema.
Leitores, escritores e codecs
Quando a própria decodificação é a parte lenta, as regras são principalmente sobre não fazer trabalho que é descartado. Os detalhes e seus números estão em references/readers-and-codecs.md.
- Verifique se cada porta de entrada do caminho rápido pode ser atendida pelos chamadores que o alcançam, e prove por instrumentação que ele é executado. Um caminho vetorizado cuja porta nenhuma coluna de lista poderia atender deixou uma coluna de 9 KiB em 43% de uma leitura.
- Não compute o que uma decisão posterior geralmente descarta. Construir um dicionário apenas para escolher a codificação simples foi 67% de uma escrita de coluna int64; removê-lo tornou a escrita 3,3× mais rápida com saída idêntica byte a byte.
- Tome emprestado em vez de copiar, e decodifique diretamente no layout final, como níveis de definição no bitmap de validade.
- Trabalhe em rajadas onde os dados as têm: com 1% de nulos, cerca de 250
memmoves substituem cerca de 25.000 ramificações por slot. - Saiba quando um estágio já está na largura de banda da memória: um que moveu 18,3 MB para dentro e para fora em 1,2 ms, cerca de 30 GB/s, só poderia ser pulado.
- Encontre o item mais lento antes de adicionar threads. Dividir por bloco de coluna limitou uma leitura a 1,39×, porque uma coluna pesada rodou o tempo todo.
Teste a otimização assim como a resposta: quebre-a de propósito e verifique
se um teste falha (references/testing-optimizations.md).
As próprias armadilhas do Mojo, como valores morrendo em seu último uso e
MutUntrackedOrigin, estão em references/mojo-hazards.md.
Obtendo um número em que você pode confiar
references/measurement.md tem as regras completas. As que mudaram conclusões no magmalake:
- Relate a mediana (p50) com o p90 ao lado, nunca apenas a média. Uma única chamada inicial lenta moveu a média em 30% e o p50 em nada.
- Construa o benchmark com antecedência; nunca o cronometre sob
mojo run. O JIT inflou um benchmark em 1,9×. - Verifique se a máquina ficou parada. O mesmo benchmark levou 31 ms ocioso e 89 ms ao lado de 16 threads ocupadas.
- Nomeie a API de referência e a contagem de threads.
pq.read_tableé o scanner de dataset do pyarrow, não o caminho de leitura do parquet-cpp, e a configuração de threads do pyarrow moveu a leitura de um arquivo em 2,9×. - Quando dois números discordarem, verifique se foram produzidos da mesma forma antes de explicar a diferença.
Antes de alegar um ganho de velocidade
- Compare as respostas antes e depois, exatamente onde puder (veja a última regra do loop). Uma resposta errada mais rápida é pior que uma resposta certa lenta.
- Relate ambos os números e a configuração: linhas, threads, disco local ou S3, e se o cache estava quente.
- Diga o que sobrou. Após a correção do shuffle, 325 ms dos 777 ms ainda são cópia de colunas, e em muitas threads o trabalho ainda está em uma thread.