Voltar para todos os artigos
io_uring para workloads de IA/ML: Quando o kernel para de esperar

io_uring para workloads de IA/ML: Quando o kernel para de esperar

Como o io_uring elimina gargalos de bloqueio a nível de kernel para operações de banco de dados e carregamento de dados em sistemas de IA. Focado no PostgreSQL 18 AIO.

Pesquisa técnica projetada por humanos, sintetizada com assistência de personas de IA.
20 min de leitura

TL;DR / Sumário Executivo

Como o io_uring elimina gargalos de bloqueio a nível de kernel para operações de banco de dados e carregamento de dados em sistemas de IA. Focado no PostgreSQL 18 AIO.

💡 TL;DR (Too Long; Didn't Read)

Principais conclusões em 90 segundos:

  1. O gargalo principal: À medida que o hardware de treinamento e inferência de IA/ML escala, a velocidade de processamento das GPUs superou o I/O de armazenamento. O principal overhead no carregamento de dados reside na troca de contexto de chamadas de sistema (syscalls) e nas transições do page cache a nível de kernel.
  2. Epoll vs. io_uring: Loops de eventos padrão (epoll) são baseados em prontidão (readiness), o que significa que alertam a aplicação quando um descritor está pronto para I/O, exigindo chamadas de sistema subsequentes bloqueantes ou não bloqueantes. O io_uring é baseado em conclusão (completion), utilizando buffers de anel (ring buffers) compartilhados para executar operações de forma assíncrona sem overhead de chamadas de sistema.
  3. Modo SQPOLL: Ao ativar o Polling de Fila de Submissão (SQPOLL), uma thread dedicada do kernel monitora o anel de submissão. Isso permite que aplicações em userspace realizem I/O de disco e rede de alta frequência com zero chamadas de sistema após o loop aquecer.
  4. AIO no PostgreSQL 18: No PostgreSQL 18, a introdução do motor de I/O Assíncrono (AIO) permite que o banco de dados submeta centenas de operações simultâneas de leitura e escrita usando o io_uring, alcançando até 3× mais vazão em scans sequenciais e operações de vácuo (vacuuming).
  5. Nossa conclusão: Para aplicações de IA que lidam com checkpoints massivos de pesos, índices de busca vetorial ou streaming de datasets de treinamento, otimizar a camada de sistemas com o io_uring não é mais opcional. A verdadeira eficiência de engenharia significa eliminar a taxa de barreira do kernel de suas pipelines de alta vazão.

1. Introdução: O gargalo de armazenamento na Era Agêntica

Nas fases iniciais da revolução da IA, a atenção da engenharia concentrou-se compreensivelmente nos requisitos brutos de computação do deep learning. Gastamos nossos orçamentos cognitivos otimizando kernels de GPU, escalando topologias de clusters e minimizando a latência de comunicação entre nós através de interconexões de alta velocidade. No entanto, à medida que a indústria transita do treinamento de modelos de fundação para a implantação em escala de sistemas agênticos intensivos em bancos de dados, um gargalo diferente surgiu: as barreiras de armazenamento e rede do sistema operacional.

As infraestruturas modernas de IA/ML operam sob extrema pressão de I/O. Durante a inferência de LLMs, os modelos precisam consultar bancos de dados de busca vetorial massivos, buscar o histórico de prompts dos agentes e carregar pesos de adaptadores de pequena escala (como matrizes LoRA) em tempo real. Durante o treinamento, as pipelines de deep learning devem transmitir continuamente terabytes de datasets tokenizados e blocos de imagens para manter os clusters de GPUs saturados.

O termo técnico da indústria para a falha em atender a essa demanda é inanição de GPU (GPU starvation). Enquanto um cluster H100 ou Blackwell fica ocioso, esperando que o próximo lote de dados seja lido dos discos NVMe ou recuperado pela rede, as organizações continuam pagando os custos de capital e operacionais de silício inativo.

Historicamente, tentamos resolver isso adicionando mais hardware ao problema: configurando matrizes RAID-0 NVMe massivas, escalando caches de memória e atualizando para interfaces de rede de múltiplos gigabits. No entanto, os desenvolvedores rapidamente observaram que suas aplicações limitadas por armazenamento atingiam um platô muito abaixo dos limites físicos do hardware.

A razão não é física; é arquitetônica. Os modelos tradicionais de I/O do Linux, originalmente projetados para processadores de núcleo único (single-core) e discos rígidos lentos, impõem uma taxa pesada sobre operações concorrentes de alta frequência. Cada leitura, escrita, seleção ou poll exige atravessar a fronteira entre userspace e kernel, gerando overhead de troca de contexto, cópia de memória e bloqueio síncrono de threads.

Para resolver a inanição de GPU e escalar a infraestrutura moderna de IA, devemos otimizar a camada de sistemas. Devemos contornar a taxa de barreira entre userspace e kernel. É aqui que o io_uring, a interface assíncrona moderna do kernel do Linux, redefine a forma como as aplicações se comunicam com o armazenamento e a rede.


2. Por baixo do capô: Epoll vs. io_uring

Para compreender por que o io_uring representa um salto geracional, precisamos primeiro analisar as limitações das interfaces assíncronas tradicionais de rede e armazenamento no Linux: o epoll e o AIO nativo do Linux/POSIX.

O modelo de prontidão: epoll

Por mais de duas décadas, servidores de rede de alto desempenho dependeram do epoll (e de seus predecessores select e poll) para lidar com concorrência. A arquitetura do epoll é construída sobre um modelo baseado em prontidão (readiness-based).

Nesse modelo, a aplicação registra um conjunto de descritores de arquivos com o kernel. Quando um evento ocorre (por exemplo, os dados chegam a um socket de rede), o kernel alerta a aplicação de que o descritor está "pronto" para leitura ou escrita. A thread da aplicação acorda, analisa a lista de eventos e emite uma chamada de sistema read() ou write() padrão para executar o I/O real.

Embora seja altamente eficaz para sockets de rede, o epoll apresenta três desvantagens estruturais quando aplicado a workloads modernos de IA:

  1. Dupla troca de contexto: Para cada evento, a aplicação deve fazer pelo menos duas chamadas de sistema: uma para esperar a prontidão (epoll_wait) e outra para executar o I/O (read ou write). Cada chamada de sistema exige uma troca de contexto, o que invalida os caches de CPU e força a limpeza do buffer de tradução de endereços (TLB).
  2. Incompatibilidade com arquivos de disco: Fundamentalmente, o kernel do Linux não suporta eventos de prontidão para arquivos de disco comuns via epoll. Os arquivos de disco são sempre considerados "prontos" pelo sistema de arquivos virtual (VFS), o que significa que uma aplicação que tenta ler um arquivo de forma assíncrona via epoll bloqueará silenciosamente a thread chamadora caso os dados não estejam previamente em cache no page cache do sistema operacional.
  3. Limitações do AIO no Linux: Para lidar com I/O de disco, o Linux introduziu o AIO nativo de kernel (io_submit). No entanto, o AIO do Linux é notoriamente frágil. Ele funciona apenas se o arquivo for aberto com a flag O_DIRECT (ignorando o page cache), exige buffers alinhados em nível de bloco e retorna silenciosamente ao comportamento bloqueante caso operações de metadados (como estender o tamanho de um arquivo) sejam necessárias.

O modelo de conclusão: io_uring

Introduzido pelo mantenedor do kernel Jens Axboe no Linux 5.1 (2019) e maduro como padrão de produção no Linux 6.x, o io_uring implementa um modelo baseado em conclusão (completion-based). Em vez de verificar se um descritor está pronto para ser lido, a aplicação diz ao kernel: "Aqui está uma lista de operações de I/O que quero que você execute. Avise-me quando estiverem concluídas."

A arquitetura principal do io_uring é construída sobre dois buffers de anel circulares livres de travas (lock-free ring buffers) compartilhados diretamente entre userspace e o kernel:

  1. Fila de Submissão (SQ - Submission Queue): A aplicação grava entradas da fila de submissão (SQEs) nesse anel para solicitar operações de I/O (por exemplo, ler, escrever, aceitar, enviar, receber).
  2. Fila de Conclusão (CQ - Completion Queue): O kernel grava entradas da fila de conclusão (CQEs) nesse anel quando as operações terminam, contendo o status e o resultado do I/O solicitado.

Como esses buffers de anel residem em memória mapeada compartilhada diretamente entre userspace e o kernel (via mmap), a aplicação pode enviar requisições e ler conclusões sem copiar dados através dessa fronteira.

Além disso, o io_uring oferece três modos avançados de execução que otimizam a vazão na camada de sistemas:

  • Operações encadeáveis: As aplicações podem encadear SQEs. Por exemplo, você pode enviar uma SQE para ler o offset de um arquivo e encadeá-la a uma segunda SQE que envia esses dados através de um socket. O kernel executa a cadeia sequencialmente, sem devolver o controle ao userspace entre as etapas.
  • Pool de workers do kernel: Para operações bloqueantes (como leituras de disco que não estão no page cache), o io_uring delega o trabalho a um pool de threads interno do kernel (io-wq), garantindo que a thread em userspace nunca bloqueie.
  • Modo SQPOLL: Nesse modo, o kernel cria uma thread dedicada (io_uring-sq) que monitora continuamente a fila de submissão em busca de novas entradas. Uma vez ativado, a aplicação simplesmente escreve as SQEs na fila e lê as CQEs do anel de conclusão. Toda a pipeline de I/O é executada com zero chamadas de sistema, eliminando completamente a taxa de troca de contexto.

3. SQL no Loop: PostgreSQL 18 e o AIO do Linux

Embora o io_uring tenha sido utilizado por frameworks de rede de baixo nível e motores de execução (como o Node.js via libuv e o Rust via tokio-uring), sua integração aos principais motores de banco de dados marca um marco crítico para a infraestrutura de IA. Especificamente, o lançamento do PostgreSQL 18 (e sua posterior versão secundária estável 18.4 em maio de 2026) introduz um motor de I/O Assíncrono (AIO) completamente redesenhado.

Historicamente, o PostgreSQL dependia de um modelo baseado em processos. Cada conexão de cliente gerava um processo backend dedicado. Para operações de leitura em disco, o PostgreSQL dependia de prefetching síncrono padrão e read-ahead a nível de sistema operacional. Quando uma consulta exigia varrer um índice grande ou realizar um scan sequencial em uma tabela que excedia o pool de buffers compartilhados, o processo backend bloqueava, esperando que o dispositivo de bloco buscasse as páginas.

O subsistema de AIO do PostgreSQL 18

No PostgreSQL 18, a arquitetura do banco de dados implementa um gerenciador de AIO unificado que pode ser configurado para usar o io_uring em sistemas Linux modernos (através das configurações de provedor io_combined ou io_uring).

Quando o PostgreSQL precisa ler blocos do disco (por exemplo, durante um scan sequencial, um scan de índice bitmap ou uma operação de vácuo), ele não emite mais chamadas bloqueantes pread(). Em vez disso, o processo backend gera uma série de requisições de leitura de blocos, grava-as como SQEs na instância compartilhada de io_uring do banco de dados e continua executando processamento em memória ou preparando as etapas subsequentes do plano de consulta.

Verified SourceDocumentação do PostgreSQL 18

O PostgreSQL 18 introduz suporte nativo a I/O assíncrono, permitindo que varreduras de tabelas, varreduras de índices e operações de vácuo submetam solicitações de leitura assíncronas diretamente ao kernel do sistema operacional.

Essa arquitetura oferece três benefícios principais:

  1. Maximização da profundidade da fila: Dispositivos de armazenamento NVMe tradicionais só alcançam suas velocidades nominais de leitura ao processar solicitações altamente paralelas (geralmente exigindo uma profundidade de fila de 32 ou 64). As leituras síncronas tradicionais do PostgreSQL não podiam explorar esse paralelismo sem gerar dezenas de processos concorrentes. O motor de AIO do PostgreSQL 18 consegue manter a fila de hardware saturada a partir de um único processo backend, utilizando plenamente os canais paralelos do NVMe.
  2. Redução do overhead de CPU: Ao contornar os caminhos padrão de syscalls e usar os buffers de anel mapeados em memória do io_uring, o consumo de CPU por gigabyte de dados lidos cai significativamente. Durante varreduras massivas, isso libera ciclos de CPU para operações de consulta caras, como cálculos de distância vetorial, agregações e joins.
  3. Vácuo mais rápido: O daemon VACUUM do PostgreSQL, crítico para manter a integridade do banco de dados e o desempenho dos índices, passa a maior parte de sua vida útil esperando por leituras de blocos. No PostgreSQL 18, o vácuo roda de forma assíncrona, utilizando o io_uring para buscar páginas do heap antes do processamento, resultando em uma redução de até 3× na duração do vácuo.

Para desenvolvedores de IA, esse ganho de desempenho no banco de dados é diretamente aplicável. Extensões de busca vetorial como o pgvector armazenam embeddings de alta dimensão em estruturas de índice HNSW (Hierarchical Navigable Small World) index structures. Traversing these graphs requires hopping between memory locations and fetching index blocks from storage. Under high concurrent load, PostgreSQL 18's AIO engine ensures these traversal queries spend their time executing distance math on the CPU, rather than waiting in blocking queues for disk blocks.


4. Impactos em Workloads de IA/ML: Dados de Treinamento e Inferência

Para observar como o io_uring atua como um multiplicador de força para a engenharia de machine learning, devemos analisar seu impacto em duas fases distintas do ciclo de vida do modelo: a ingestão de dados de treinamento e a recuperação em tempo real na inferência.

Resolvendo a inanição de GPU em pipelines de treinamento

Os loops de treinamento de deep learning são altamente estruturados. Uma época típica processa dados de maneira encadeada:

Para modelos que processam mídias ricas (imagens de alta resolução, arquivos de vídeo ou ondas de áudio brutas), a fase de "Ler & Descriptografar" é um grande gargalo. O DataLoader padrão do PyTorch lida com isso criando vários processos worker em segundo plano. Cada worker executa um loop que lê um lote do disco, analisa-o, aplica aumentações e o copia para a memória compartilhada e fixada do host (pinned memory).

Sob esse modelo multiprocesso, os workers competem pelo I/O de disco. Cada worker emite chamadas de leitura síncronas e bloqueantes. Como a GPU processa os lotes em milissegundos, os workers têm dificuldade para acompanhar, levando à inanição da GPU. A GPU fica ociosa com 0% de utilização enquanto espera que os workers retornem das filas do dispositivo de bloco.

Ao implementar um leitor de arquivos baseado em io_uring (como a integração dos bindings em C++ do worker do dataloader com a liburing), a arquitetura muda:

  • Ingestão com cópia zero: Os workers enviam solicitações de leitura para múltiplos segmentos de dados diretamente para a SQ. O kernel realiza a leitura diretamente nos buffers de userspace mapeados para a memória fixada da GPU, contornando cópias intermediárias.
  • Armazenamento e rede unificados: Em clusters de grande escala, os datasets costumam ser armazenados em sistemas de arquivos de rede distribuídos (como Lustre, Ceph ou GPFS) montados via TCP ou RDMA. O io_uring trata leituras de arquivos e recepções de sockets de rede (recv) de maneira idêntica. Um único loop de eventos pode gerenciar tanto a recuperação de dados do servidor de armazenamento de rede quanto a leitura de blocos do disco, mantendo a profundidade da fila de armazenamento saturada.

Otimizando a inferência de baixa latência

Durante a inferência de LLMs, a latência é medida em milissegundos por token. Embora os pesos de um modelo de fronteira estejam armazenados na memória da GPU, a camada de sistemas ao redor deve buscar contextos auxiliares em tempo real:

  • Busca no banco vetorial: Recuperação de trechos semelhantes em um índice HNSW para preencher o contexto do prompt (Retrieval-Augmented Generation).
  • Troca de KV Cache: Ao lidar com conversas longas de agentes, o Key-Value (KV) cache de agentes inativos costuma ser transferido da memória da GPU para a RAM do host ou armazenamento NVMe local para economizar espaço. Quando o agente se torna ativo novamente, o cache deve ser carregado de volta para a VRAM instaneamente.

Transferir um KV cache de 16 GB do NVMe para a memória da GPU via chamadas bloqueantes padrão bloqueia o worker de inferência, causando um atraso visível na resposta do primeiro token.

Usando o io_uring com acesso direto ao dispositivo de bloco, o sistema pode transmitir o KV cache de forma assíncrona. Ao enviar solicitações de leitura concorrentes para os segmentos do cache, o sistema satura completamente a largura de banda do barramento PCIe, transferindo os dados para a memória do host em paralelo com a execução do processamento do prompt inicial pela GPU. A transferência ocorre em segundo plano, minimizando a latência do tempo para o primeiro token (TTFT) para o usuário final.


5. Implementação Prática: Reconstruindo Submissões de Anel Assíncronas

Para entender como programar utilizando o io_uring, podemos examinar uma implementação básica em C usando a biblioteca auxiliar padrão liburing. O exemplo a seguir demonstra como inicializar um anel (ring), submeter uma solicitação de leitura assíncrona para um bloco de dataset e recuperar o evento de conclusão.

c
#include <stdio.h> #include <fcntl.h> #include <string.h> #include <stdlib.h> #include <unistd.h> #include <liburing.h> #define QUEUE_DEPTH 32 #define BLOCK_SIZE 4096 int main(int argc, char *argv[]) { struct io_uring ring; struct io_uring_sqe *sqe; struct io_uring_cqe *cqe; struct iovec iov; int fd, ret; void *buf; if (argc < 2) { fprintf(stderr, "Uso: %s <nome_do_arquivo>\n", argv[0]); return 1; } // 1. Inicializa a instância do io_uring // Solicitamos uma profundidade de fila de 32 entradas. ret = io_uring_queue_init(QUEUE_DEPTH, &ring, 0); if (ret < 0) { fprintf(stderr, "Falha ao inicializar a fila do io_uring: %s\n", strerror(-ret)); return 1; } // 2. Abre o arquivo do dataset alvo fd = open(argv[1], O_RDONLY); if (fd < 0) { perror("Falha ao abrir o arquivo"); io_uring_queue_exit(&ring); return 1; } // Aloca um buffer alinhado para I/O mapeado diretamente buf = malloc(BLOCK_SIZE); if (!buf) { perror("Falha ao alocar o buffer"); close(fd); io_uring_queue_exit(&ring); return 1; } // Configura a estrutura iovec apontando para o nosso buffer iov.iov_base = buf; iov.iov_len = BLOCK_SIZE; // 3. Obtém uma Entrada da Fila de Submissão (SQE) do anel sqe = io_uring_get_sqe(&ring); if (!sqe) { fprintf(stderr, "Falha ao obter entrada da fila de submissão\n"); free(buf); close(fd); io_uring_queue_exit(&ring); return 1; } // 4. Prepara a operação de leitura // Registramos o arquivo alvo da leitura, o endereço do buffer e o offset do arquivo (0). io_uring_prep_readv(sqe, fd, &iov, 1, 0); // Anexa metadados personalizados do usuário para identificar a operação após a conclusão io_uring_sqe_set_data(sqe, (void *)0xDEADBEEF); // 5. Submete a SQE ao kernel // Em um loop de alta frequência, podemos submeter várias SQEs em uma única chamada. ret = io_uring_submit(&ring); if (ret < 0) { fprintf(stderr, "Falha ao submeter SQE: %s\n", strerror(-ret)); free(buf); close(fd); io_uring_queue_exit(&ring); return 1; } printf("Solicitação de I/O submetida assincronamente. Aguardando conclusão...\n"); // 6. Aguarda pela Entrada da Fila de Conclusão (CQE) // Isso bloqueia a thread chamadora até que pelo menos uma operação seja concluída. ret = io_uring_wait_cqe(&ring, &cqe); if (ret < 0) { fprintf(stderr, "Falha ao esperar por CQE: %s\n", strerror(-ret)); free(buf); close(fd); io_uring_queue_exit(&ring); return 1; } // Verifica os metadados de conclusão e o status if (cqe->res < 0) { fprintf(stderr, "Operação de I/O falhou: %s\n", strerror(-cqe->res)); } else { printf("I/O concluído com sucesso. Lidos %d bytes.\n", cqe->res); printf("Token de metadados UserData: %p\n", io_uring_cqe_get_data(cqe)); } // 7. Marca a CQE como processada para limpá-la do anel de conclusão io_uring_cqe_seen(&ring, cqe); // Limpeza de recursos free(buf); close(fd); io_uring_queue_exit(&ring); return 0; }

Esse loop estrutural expõe o modelo de programação assíncrono e livre de travas. A thread da aplicação escreve, submete e processa eventos de forma independente, permitindo que sistemas de alta vazão escalem sem a necessidade de criar threads adicionais ou bloquear mutexes para operações básicas.


6. Conclusão: Pare de esperar pelo disco

A transição da otimização limitada por computação para a otimização limitada por armazenamento representa um amadurecimento natural da engenharia de IA. Conforme construímos sistemas que consultam contextos maiores, atualizam espaços vetoriais massivos e executam pipelines persistentes, o design de camada de sistemas das nossas aplicações torna-se o principal direcionador de desempenho.

Os modelos tradicionais síncronos e baseados em prontidão (epoll) não são mais suficientes para atender às demandas de vazão de workloads de IA. Eles introduzem overhead de CPU, latência de troca de contexto e bloqueio de threads que sufocam o hardware de processamento.

Ao adotar o io_uring na camada de kernel e utilizar motores de banco de dados como o PostgreSQL 18 que o integram nativamente, removemos essas barreiras estruturais. Permitimos que nossos workers de treinamento ingiram dados na velocidade de barramento dos dispositivos NVMe modernos, e que nossos servidores de inferência troquem estados de memória sem bloquear os tokens dos usuários finais.

A verdadeira excelência de engenharia não consiste apenas em usar um modelo maior; trata-se de construir um sistema que permita aos seus recursos rodarem em sua capacidade máxima. Ao projetar sua próxima pipeline de ingestão de dados ou escalar seu cluster de banco de dados vetorial, lembre-se: o kernel não precisa bloquear seu progresso. Pare de esperar pelo disco.


Fontes Externas

Leituras Relacionadas no gsstk

Este artigo foi arquitetado por humanos e sintetizado com assistência de IA sob a persona Aether (AI).

Receba novos artigos

Cadastre-se para receber notificações sobre novos artigos direto no seu email

Não enviaremos spam. Você pode cancelar a inscrição a qualquer momento.