Trocar mmap por io_uring deixou a query engine em Rust 60% mais lenta
A Conviva relatou que migrar a leitura de arquivos Arrow IPC de mmap para io_uring com O_DIRECT piorou a latência antes de melhorar. O caso serve de alerta contra o hype.

Existe um consenso implícito rondando os fóruns de engenharia de alta performance: mmap é conveniente mas escala mal, e io_uring é o futuro do I/O no Linux↳Linux34 conteúdosKali Linux em um Servidor VPS: como, quando e por que usar?DevSecOps · dez 2024Construindo um Windows Service ou Linux Daemon com Worker Service & .NET Core – Parte 2Dev (Back & Front) · jul 2020Criando uma WebApi utilizando .NET, Linux e VSCodeDev (Back & Front) · ago 2019Ver tudo em DevSecOps →. A Conviva, empresa que analisa trilhões de eventos por dia para diagnosticar experiência de usuário, resolveu fazer a troca na sua engine de queries escrita em Rust↳Rust7 conteúdosDesmistificando Rust: a linguagem segura e rápida que você precisa conhecerDev (Back & Front) · out 2024Como criar seu primeiro Programa em Rust com Solana PlaygroundDev (Back & Front) · mai 2026Rust no ranking: a segurança de memória perdeu a guerra corporativa?Marketing Tech · abr 2026Ver tudo em Dev (Back & Front) →. O resultado, contado pelo engenheiro Evan Chan, foi o oposto do prometido: a versão com io_uring ficou cerca de 60% mais lenta que o baseline de mmap.
O relato é útil justamente porque contraria o roteiro feliz que domina os blogs. Para quem constrói sistemas de dados no Brasil, e cada vez mais times aqui operam engines de análise sobre NVMe local, é um lembrete de que substituir uma primitiva de I/O por outra sem redesenhar a arquitetura em volta costuma sair pela culatra.
O que estava rodando antes
A engine da Conviva é montada sobre DataFusion, Arrow, Rust, Rayon e Tokio. Eventos são transformados, codificados num formato proprietário majoritariamente numérico e guardados na nuvem. Para consulta, os arquivos são copiados para NVMe local e lidos como grandes arquivos Arrow IPC de 3 a 5 GB.
A escolha do Arrow IPC tinha lógica: o layout em memória e em disco é idêntico, então o custo de decode é mínimo, e mmap entrega leitura zero-copy nativamente suportada pelo arrow-rust. Uma query típica toca 6 colunas em 8 arquivos de batch (um batch por arquivo), cerca de 1,6 GB por batch, algo como 13 GB de dados por dia.
Em carga leve, funcionava bem, respondendo em segundos. O problema apareceu sob concorrência pesada.
O sintoma em produção
Algum aumento de latência sob carga é esperado, mais queries disputando a mesma CPU. Mas o p95 e o p99 dispararam muito além do que o crescimento linear preveria. Os sintomas, segundo o relato:
- O page cache do SO encolheu: cada pod consumia mais memória como alocação privada e menos como cache compartilhado.
- Um número enorme de page faults.
- O p95 saltou de cerca de 30s para mais de 150s sob concorrência real.
- Adicionar pods piorava, não melhorava.
O diagnóstico apontou para thrashing do page cache do mmap sob pressão de memória. Um teste controlado tornou isso explícito: 1 pod venceu 4 pods na mesma máquina, sendo 41% mais rápido no pico e mais de 20% no p95 para queries de 14 dias. O perf record mostrou 100% de contenção de lock no nível do kernel. Os quatro pods não brigavam por CPU, brigavam pelo page cache, que é estado compartilhado implícito: um cache, uma hierarquia de locks, uma política de eviction para todos os processos do host.
Os números de pidstat durante uma execução estressante ilustram a tempestade de faults:
23:27:09 1,255,709 minor faults/sec
23:27:41 2,124,327 minor faults/sec
23:27:46 2,354,383 minor faults/secA mais de 2 milhões de minor faults por segundo, cada um tocando uma cache line via atômicos, o L1/L2 é destruído, algo letal para uma aplicação que depende de grandes lookup tables residentes em cache. Os context switches passaram de 14 mil/s numa execução com cache quente para mais de 2 milhões/s, 150x mais.
O que io_uring prometia resolver
O teto de hardware media o tamanho do prêmio. Um fio com engine io_uring, 4 processos, iodepth 32, blocos de 4 MiB e O_DIRECT atingiu 20,2 GiB/s com os 32 NVMe a 99,75% de utilização. O mmap, no pico, entregava 3,44 GB/s, cerca de 16% do que o hardware podia fazer.
O plano era clássico: bypassar o page cache com O_DIRECT, submeter leituras via io_uring, coordenar com Tokio e decodificar Arrow inline. A implementação usou compio, um wrapper Rust-nativo de io_uring. A primeira versão disparava uma future por coluna, todas as 40 colunas (8 batches × 5 colunas) submetidas concorrentemente.
O choque de realidade no Linux
O teste inicial no macOS (sem io_uring real) já não animava, mas foi no Linux que a conta apareceu:
| Métrica | io_uring, cold | mmap, cold |
|---|---|---|
| Tempo total da query | 21,8 s | 13,6 s |
| Materialize time | 17,4 s | 0 (mmap é "grátis" na leitura) |
| Major faults | 3.647 | 128.957 |
| Minor faults | 8,6 milhões | ~1 milhão |
O io_uring resolveu exatamente o que deveria: os major faults caíram 35x, de 128.957 para 3.647. O kernel parou de thrashear em page-ins físicos. Mas os minor faults subiram 8x, e o tempo total foi de 13,6s para 21,8s. Uma classe de fault foi trocada por outra, e o negócio saiu no prejuízo.
Onde os minor faults se escondiam
Depois de ligar O_DIRECT (runtime caiu de 21,8s para ~19s, ganho real mas modesto), o perf apontou para o Arrow. As amostras concentravam em Buffer::from_slice_ref, a forma padrão do arrow-rs de construir um Buffer a partir de um slice de bytes: aloca memória nova e faz memcpy.
Cada página de destino de 4 KiB que o memcpy toca precisa que o kernel a zere e mapeie, um minor fault por página. Os 8 milhões de minor faults sobre ~13 GB de leituras batem quase exatamente com essa conta. Ou seja: a camada Arrow estava forçando o kernel a refazer o trabalho de gerência de memória que a equipe achava ter bypassado ao adotar io_uring. Construir o Buffer direto dos bytes que o io_uring já possuía baixou o runtime para ~16s, ainda pior que o mmap.
A comunidade não perdoou (e apontou o ponto técnico)
O thread no Hacker News reagiu em duas frentes. A primeira, ao formato: vários leitores acusaram o texto de ser gerado por LLM↳LLMs48 conteúdosConsiderações básicas de hardware para modelos de linguagem em código aberto: Memória, Desempenho e ViabilidadeMarketing Tech · out 2025Modelos de linguagem sob ataque: o lado obscuro da IA generativaDevSecOps · mai 2025Criando um LLM – modelo de linguagem de grande escala – do zero com TransformersAI · abr 2024Ver tudo em AI →. Como resumiu muragekibicho: "Seeing a section titles "The Production Symptom" is a Claudism in itself."
A segunda frente foi mais interessante para quem constrói. samus apontou que o título talvez estivesse mirando na primitiva errada:
"It would have been more precise to say O_DIRECT instead of io_uring. The point of io_uring is to avoid syscall overhead. What they were after was actually managing a page cache on their own, and it turned out to be more complicated than they thought."
samus
E jandrewrogers foi ao cerne arquitetural: "mmap and io_uring require fundamentally different software architectures in a performance context. You shouldn’t swap them out." Segundo ele, io_uring com O_DIRECT existe para você desenhar seu próprio scheduler em espaço de usuário; se você delega o scheduling a um runtime, perde a maior parte da vantagem, e a performance pode até piorar. Foi mais ou menos o que aconteceu.
Vale registrar a ressalva de laserbeam: existe um Part 2, linkado no fim do artigo, em que a equipe torna o io_uring duas vezes mais rápido que o mmap. Ou seja, o título é o gancho de uma série, não a conclusão final.
O que fica para quem constrói no Brasil
O recado não é "io_uring é ruim". É que a primitiva de I/O não é uma peça plug-and-play. Trocar mmap por io_uring mantendo o resto da arquitetura (um único thread de materialização fazendo cinco trabalhos, decode do Arrow com cópia default, prefetch disparando 40 SQEs de uma vez) transfere o gargalo em vez de eliminá-lo.
Para times brasileiros que rodam DataFusion, Arrow ou engines analíticas próprias sobre NVMe local, três lições concretas emergem do relato: o custo real muitas vezes não está no disco (a I/O de NVMe respondeu por só 6,9% do tempo off-CPU medido), o page cache compartilhado do host vira estado global disputado quando você empilha pods, e a cópia default do arrow-rs pode reintroduzir exatamente os faults que você tentou evitar. O restante da história (o momento em que perceberam que 40 SQEs concorrentes eram o problema) fica para a Parte 2, que Chan promete detalhar na P99 CONF, online em 21 e 22 de outubro de 2026.
Fontes: Hacker News · Reações no Hacker News
Este artigo foi escrito por Redação iMasters, um agente de inteligência artificial com revisão editorial humana. Publicado sob revisão editorial de Rafael Chinaglia - iMasters e validação técnica de Tiago Baeta. Saiba como produzimos no expediente.








Comentários
Ninguém comentou ainda. Começa a conversa?