Dominando a latência: estratégias de polling com io_uring em NVMe

      André Linhares 9 min de leitura
      Dominando a latência: estratégias de polling com io_uring em NVMe

      Descubra como eliminar o overhead de interrupções em SSDs NVMe usando io_uring e IOPOLL. Análise técnica de engenharia de performance para reduzir latência e maximizar throughput no Linux.

      Compartilhar:

      Se você ainda confia cegamente em interrupções para notificar a conclusão de I/O em dispositivos NVMe modernos, você está deixando performance na mesa. Em um mundo onde SSDs Gen4 e Gen5 operam na casa dos microssegundos de dígito único, o custo do sistema operacional gerenciar uma interrupção de hardware tornou-se um gargalo inaceitável.

      A latência não é apenas o tempo que o disco leva para buscar o dado; é o tempo total desde a submissão do comando até a aplicação poder usar esse dado. E é aqui que a arquitetura tradicional de I/O do Linux, baseada em interrupções, começa a mostrar sua idade. Vamos dissecar como o io_uring e o polling mudam esse jogo.

      Resumo em 30 segundos

      • O Problema: Em SSDs ultrarrápidos (NVMe), o tempo que a CPU gasta processando a interrupção (IRQ) e trocando de contexto pode ser maior que o tempo de acesso ao próprio disco.
      • A Solução: O Polling (sondagem ativa) permite que a CPU verifique repetidamente se o dado chegou, eliminando a sobrecarga da interrupção e reduzindo drasticamente a latência.
      • A Ferramenta: O io_uring com a flag IORING_SETUP_IOPOLL é a maneira moderna e eficiente de implementar essa estratégia sem a complexidade de drivers customizados.

      Comparação visual do ciclo de vida de uma operação de I/O: o overhead das interrupções versus a precisão do polling. Figura: Comparação visual do ciclo de vida de uma operação de I/O: o overhead das interrupções versus a precisão do polling.

      O custo oculto das interrupções

      Historicamente, as interrupções foram uma benção. Quando os discos rígidos (HDDs) levavam 10 milissegundos para buscar um setor, não fazia sentido a CPU ficar esperando. O processador ia fazer outra coisa e, quando o disco terminava, ele "interrompia" a CPU.

      No entanto, a escala mudou. Um SSD NVMe de classe enterprise hoje responde em 10 a 20 microssegundos (µs). O overhead de tratar uma interrupção — parar o pipeline da CPU, salvar registradores, executar o ISR (Interrupt Service Routine), agendar o SoftIRQ e realizar a troca de contexto de volta para o usuário — pode custar entre 2 a 5 µs.

      Isso significa que, em dispositivos de ultra-baixa latência, 20% a 30% do tempo total da operação é desperdiçado em burocracia do kernel.

      💡 Dica Pro: Em sistemas NUMA (Non-Uniform Memory Access), esse custo é ainda maior se a interrupção for tratada por uma CPU em um soquete diferente daquele onde a aplicação está rodando. O "locality" é rei.

      A anatomia do gargalo

      Quando você usa uma syscall tradicional como read() ou mesmo preadv(), o fluxo é bloqueante e custoso. Mesmo com interfaces assíncronas antigas como libaio, a notificação de conclusão ainda depende do hardware disparar um sinal elétrico (MSI-X) para o processador.

      O problema se agrava com as mitigações de segurança de CPU (Spectre/Meltdown e variantes). Cada transição entre user-space e kernel-space tornou-se mais cara. Se sua aplicação de banco de dados faz milhões de IOPS, esses nanossegundos somados viram segundos de tempo de CPU jogados fora apenas gerenciando o estado do processador, não processando dados.

      io_uring: A mudança de paradigma

      O io_uring (introduzido no Linux 5.1) não é apenas uma nova API; é uma reengenharia de como submetemos e completamos I/O. Ele usa dois anéis circulares (Ring Buffers) na memória compartilhada entre o kernel e a aplicação:

      1. Submission Queue (SQ): Onde você coloca os pedidos.

      2. Completion Queue (CQ): Onde o kernel coloca os resultados.

      Essa estrutura por si só já reduz syscalls, permitindo "batching" (lotes) de operações. Mas a verdadeira mágica para latência acontece quando ativamos o modo de polling.

      A arquitetura de anéis compartilhados do io_uring eliminando barreiras entre a aplicação e o kernel. Figura: A arquitetura de anéis compartilhados do io_uring eliminando barreiras entre a aplicação e o kernel.

      Implementando IORING_SETUP_IOPOLL

      Para eliminar as interrupções, configuramos a instância do io_uring com a flag IORING_SETUP_IOPOLL. Isso altera fundamentalmente o comportamento do driver NVMe.

      Quando essa flag está ativa, o kernel não coloca a thread para dormir esperando uma interrupção. Em vez disso, ele instrui o driver a verificar ativamente (spin) o registro de conclusão do hardware.

      O que acontece nos bastidores:

      1. A aplicação submete um pedido de leitura no SQ.

      2. A aplicação chama io_uring_enter() para avisar o kernel (ou nem isso, se usar SQPOLL junto).

      3. O driver NVMe envia o comando ao SSD.

      4. A Diferença: Em vez de ceder a CPU, o driver mantém a CPU ocupada num loop apertado, verificando a memória mapeada do dispositivo NVMe.

      5. Assim que o bit de "concluído" acende no hardware, o driver detecta instantaneamente e preenche o CQ.

      O resultado é uma latência determinística. Você remove a variabilidade do agendador do SO e o atraso do tratamento de interrupção.

      ⚠️ Perigo: O polling é egoísta. Ele vai consumir 100% de um núcleo de CPU enquanto espera o I/O. Se você não tiver núcleos dedicados ou se o I/O for esporádico, você vai desperdiçar energia e ciclos de processamento à toa.

      Hybrid Polling: O equilíbrio inteligente

      O mundo não é binário. Não precisamos escolher entre "dormir para sempre" (interrupções) ou "ficar acordado gritando" (polling puro). O Linux introduziu o Hybrid Polling (Sondagem Híbrida).

      A lógica é baseada em estatísticas. O kernel rastreia quanto tempo, em média, suas operações de I/O levam. Se o histórico diz que seu SSD leva 100µs para responder:

      1. O sistema submete o I/O.

      2. A thread dorme por aproximadamente 50-70µs (liberando a CPU).

      3. A thread acorda e começa o polling ativo nos últimos microssegundos.

      Isso nos dá a latência próxima do polling puro com uma eficiência de CPU muito superior.

      Gráfico de eficiência: encontrando o ponto ideal entre o desperdício de ciclos e a velocidade de resposta com Hybrid Polling. Figura: Gráfico de eficiência: encontrando o ponto ideal entre o desperdício de ciclos e a velocidade de resposta com Hybrid Polling.

      Comparativo Técnico: Interrupção vs Polling

      Para visualizar o impacto real, vamos comparar as abordagens em um cenário de banco de dados de alta performance rodando sobre NVMe Gen4.

      Característica Interrupção (Padrão) Polling Puro (IOPOLL) Hybrid Polling
      Mecanismo Hardware avisa a CPU (IRQ) CPU pergunta ao Hardware Sleep inicial + Polling final
      Latência Média Base + 3-5µs (overhead) Mínima possível (Hardware puro) Próxima do Polling (< +1µs)
      Uso de CPU Baixo (Context Switches altos) Muito Alto (100% por thread) Moderado / Otimizado
      Throughput Limitado por IRQ storm Máximo (limitado pelo HW) Alto
      Jitter (Variação) Alto (depende do Scheduler) Quase Zero Baixo
      Caso de Uso File Server, Web Server, Logs HFT, Real-time Analytics, AI Training Bancos de Dados Gerais, VMs

      Otimizando a pilha completa

      Não basta apenas ligar uma flag. Para extrair o máximo do io_uring com polling, você precisa alinhar a infraestrutura:

      1. NVMe Queues: Garanta que o número de filas de hardware do seu SSD corresponda ao número de núcleos de polling.

      2. CPU Isolation: Use isolcpus ou cgroups para dedicar núcleos específicos para as threads de polling. Isso evita que o scheduler do Linux mova sua thread crítica no meio de um spin loop.

      3. SQPOLL: Combine IORING_SETUP_IOPOLL com IORING_SETUP_SQPOLL.

        • IOPOLL: Kernel faz polling no hardware.
        • SQPOLL: Kernel cria uma thread para fazer polling na fila de submissão (SQ) da aplicação.
        • Resultado: Você pode submeter e completar I/O com zero syscalls no caminho crítico. É o nirvana do I/O.

      O cenário ideal: IOPS no teto e Syscalls zeradas, o objetivo final da otimização com io_uring. Figura: O cenário ideal: IOPS no teto e Syscalls zeradas, o objetivo final da otimização com io_uring.

      Previsão e Alerta

      O polling não é uma bala de prata para todos. Ele transforma o problema de "espera" em um problema de "gerenciamento de recursos". À medida que avançamos para o PCIe Gen6 e CXL (Compute Express Link), a latência do meio físico cairá tanto que as interrupções se tornarão obsoletas para qualquer carga de trabalho séria de dados.

      Minha recomendação: se você gerencia bancos de dados como PostgreSQL, ScyllaDB ou RocksDB em armazenamento NVMe moderno, audite suas configurações de io_uring. Se você ainda está rodando em kernels antigos (pré-5.10) ou usando libaio, você está pilotando uma Ferrari com o freio de mão puxado.

      O futuro do storage é síncrono na perspectiva do hardware, mas assíncrono e sem bloqueios na perspectiva da aplicação. Domine o polling ou seja deixado para trás na fila de interrupção.

      Referências & Leitura Complementar

      • Jens Axboe (2019). Efficient IO with io_uring. Kernel.org. (O paper original do criador).

      • NVM Express Base Specification 2.0. Seção sobre Completion Queue Entry e mecanismos de sinalização.

      • Linux Kernel Documentation. Block Device Polling. (Documentação técnica sobre /sys/block/<dev>/queue/io_poll_delay).

      • SNIA (Storage Networking Industry Association). Hyperscaler Storage Performance Guidelines.


      Perguntas Frequentes (FAQ)

      O polling de I/O consome 100% da CPU? Sim, o polling puro (ativado via IOPOLL) mantém a CPU em um loop constante verificando a fila de conclusão, o que aparece como 100% de uso no monitoramento. No entanto, o "Hybrid Polling" (disponível em kernels recentes como 6.13) mitiga isso drasticamente: ele coloca a thread para dormir inicialmente e apenas realiza o polling ativo quando o tempo estimado de conclusão da operação se aproxima.
      Qual a diferença entre IORING_SETUP_SQPOLL e IORING_SETUP_IOPOLL? São otimizações para pontas diferentes do processo. O SQPOLL cria uma thread no kernel dedicada a processar a fila de submissão, permitindo que a aplicação envie comandos sem realizar syscalls de entrada. Já o IOPOLL instrui o kernel a verificar ativamente a conclusão no hardware, evitando o custo das interrupções. Para performance máxima (e zero syscalls reais), ambos devem ser usados em conjunto.
      O io_uring substitui completamente o libaio? Para novas aplicações de alta performance, a resposta é um "sim" definitivo. O io_uring é arquiteturalmente mais eficiente, suporta buffer rings, polling nativo e, crucialmente, não sofre das limitações de bloqueio imprevisível que o libaio apresenta em certos sistemas de arquivos e situações de buffer cache.
      #io_uring #NVMe #kernel polling #IOPOLL #linux storage performance #baixa latência #hybrid polling
      André Linhares
      Assinatura Técnica

      André Linhares

      Engenheiro de Performance (Kernel/IO)

      "Vivo no kernel space caçando latência com eBPF. Para mim, context switches excessivos são inimigos pessoais e cada ciclo de CPU desperdiçado é uma ofensa técnica."