22 pontos por GN⁺ 2025-06-24 | 3 comentários | Compartilhar no WhatsApp
  • Analisa o desempenho dos pipes Unix implementados no Linux por meio de otimizações graduais
  • A largura de banda de um programa de pipe simples é medida inicialmente em cerca de 3.5GiB/s, e o texto mostra o processo de elevá-la em mais de 20 vezes com profiling e mudanças de syscall
  • Explica várias técnicas de otimização, como o uso de syscalls Zero-Copy como vmsplice e splice para reduzir cópias desnecessárias de dados e o aumento do tamanho de página
  • Com o uso de Huge Pages e da técnica de busy loop, os gargalos são resolvidos e a taxa máxima de processamento chega a 62.5GiB/s
  • Oferece insights sobre elementos importantes em servidores de alto desempenho e programação de kernel, como pipes, paginação, custo de sincronização e Zero-Copy

Visão geral e introdução

  • Este texto aborda como os pipes Unix são implementados no Linux, escrevendo diretamente programas de teste que leem e gravam dados pelo pipe e otimizando o desempenho de forma gradual
  • No início, parte-se de um programa simples com largura de banda de aproximadamente 3.5GiB/s e, após diversas otimizações, alcança-se um ganho de desempenho de cerca de 20 vezes
  • Cada etapa de otimização é decidida com base nos resultados de profiling com a ferramenta perf, e o código-fonte relacionado está disponível em GitHub - pipes-speed-test
  • A inspiração veio de uma reflexão iniciada ao observar a velocidade de processamento de dados via pipe em um programa FizzBuzz de alto desempenho (36GiB/s)
  • Basta ter conhecimento básico de linguagem C para acompanhar o conteúdo sem dificuldade

Medindo o desempenho do pipe: a primeira versão lenta

  • No exemplo de execução do programa FizzBuzz de alto desempenho, observa-se que ele processa 36GiB de dados por segundo por meio de um pipe
  • O FizzBuzz gera saída em blocos do tamanho do cache L2 (256KiB), equilibrando acesso à memória e overhead de IO
  • O programa de teste de desempenho de pipe escrito neste texto também repete saída e leitura em blocos de 256KiB e, para a medição, implementa diretamente as duas pontas, read e write
  • write.cpp escreve repetidamente o mesmo buffer de 256KiB, enquanto read.cpp lê 10GiB e encerra, exibindo a taxa de transferência
  • Como resultado, a leitura/gravação via pipe atinge 3.7GiB/s, parecendo ser 10 vezes mais lenta que o FizzBuzz

Gargalos da escrita e estrutura interna

  • Ao rastrear o call graph da execução do programa com a ferramenta perf, verifica-se que cerca de metade do tempo total é gasto na etapa de escrita no pipe, isto é, em pipe_write
  • Dentro de pipe_write, a maior parte do tempo é consumida em cópia e alocação de páginas de memória (copy_page_from_iter, __alloc_pages)
  • Os pipes do Linux são implementados na forma de um buffer circular (ring buffer), e cada entrada referencia a página onde os dados reais estão armazenados
  • O tamanho total do buffer do pipe é fixo, e quando o pipe enche, write bloqueia; quando fica vazio, read entra em bloqueio
  • Nas estruturas C (pipe_inode_info, pipe_buffer), head e tail representam as posições de escrita e leitura, respectivamente, e incluem informações de offset e comprimento de cada página

Lógica de leitura/escrita do pipe

  • pipe_write funciona na seguinte sequência
    • se o pipe estiver cheio, espera até surgir espaço
    • primeiro preenche o espaço restante no head atual
    • se ainda houver espaço, aloca uma nova página, copia os dados para o buffer e atualiza o head
  • Todas as operações são protegidas por lock, o que gera overhead de sincronização
  • A leitura (read) segue a mesma estrutura, movendo o tail e liberando as páginas já lidas
  • Em essência, há duas cópias consideráveis: da memória do usuário para o kernel e depois do kernel de volta para o espaço do usuário, gerando overhead significativo

Zero-Copy: otimização com splice/vmsplice

  • Uma abordagem geral para IO rápido é contornar o kernel (bypass) ou minimizar cópias
  • O Linux oferece suporte para ignorar cópias no movimento de dados entre pipes e espaço do usuário por meio das syscalls splice e vmsplice
    • splice: move dados entre um pipe e um file descriptor
    • vmsplice: move dados entre a memória do usuário e um pipe
  • Ambas as syscalls podem operar apenas movendo referências, sem transferir os dados de fato
  • Por exemplo, ao usar vmsplice, o buffer de 256KiB é dividido ao meio e, com double buffering, cada metade é enviada alternadamente ao pipe
  • Na prática, com vmsplice, a velocidade melhora mais de 3 vezes (cerca de 12.7GiB/s) e, ao aplicar splice no lado da leitura, sobe ainda mais para 32.8GiB/s

Gargalos relacionados a páginas e uso de Huge Pages

  • A análise com perf mostra que o gargalo do vmsplice se concentra no lock do pipe (mutex_lock) e na obtenção de páginas (iov_iter_get_pages)
  • iov_iter_get_pages converte a memória do usuário (endereço virtual) em páginas físicas reais e armazena essas referências dentro do pipe
  • A paginação no Linux não usa apenas páginas de 4KiB; dependendo da arquitetura, também há suporte a vários tamanhos, como 2MiB (huge page)
  • Ao usar Huge Pages (por exemplo, 2MiB), o overhead de conversão de páginas cai bastante graças à redução no gerenciamento de page tables e no número de referências
  • Ao aplicar huge pages no programa, a taxa máxima de processamento sobe para 51.0GiB/s, um aumento adicional de cerca de 50%

Aplicação de busy loop

  • O gargalo restante está em operações de sincronização como esperar por espaço livre no pipe (wait) e acordar o leitor (wake)
  • Com a opção SPLICE_F_NONBLOCK e repetindo chamadas em busy loop quando ocorre EAGAIN, é possível eliminar o overhead de escalonamento do kernel
  • Com essa técnica, a taxa máxima de processamento chega a 62.5GiB/s, mais 25% de ganho
  • Busy loop consome 100% dos recursos de CPU, mas é um padrão comum em servidores de alto desempenho

Resumo e outros pontos

  • O texto explica, com análise do perf e do código-fonte do Linux, como melhorar de forma dramática o desempenho de pipes passo a passo
  • É possível experimentar, com exemplos reais, os principais temas da programação de alto desempenho, como pipes, splice, paginação, Zero-Copy e custo de sincronização
  • No código real, também são aplicados ajustes adicionais de desempenho, como alocar buffers em páginas diferentes para reduzir contenção de refcount
  • Os testes são executados fixando cada processo de programa em um núcleo separado com taskset
  • A família splice pode ser arriscada por projeto e é tema de debate há muito tempo entre alguns desenvolvedores do kernel

3 comentários

 
iolothebard 2025-06-27

Uau! Que interessante! (Não faço a menor ideia do que estão falando...)

 
doolayer 2025-06-26

|

 
GN⁺ 2025-06-24
Comentários no Hacker News
  • Nunca vou esquecer da experiência de portar para Windows uma aplicação baseada em pipes do Linux. Por ser padrão POSIX, achei que o desempenho não seria tão diferente, mas era absurdamente mais lento. O problema chegava ao ponto de quase travar o Windows inteiro enquanto esperava a conexão do pipe. Anos depois, quando reimplementei a mesma coisa em C# no Win10, melhorou um pouco, mas a diferença de desempenho ainda era algo bem constrangedor.

    • Pelo que sei, nos últimos anos o Windows ganhou sockets AF_UNIX. Fico curioso para saber qual tem desempenho melhor em relação aos pipes do Win32. Meu palpite é que AF_UNIX seja melhor.

    • Quando você diz que "o desempenho era péssimo", está falando do I/O depois que o pipe já estava conectado ou do processo anterior à conexão? Se for depois de conectado, isso seria surpreendente; mas, se o problema for ficar conectando e desconectando repetidamente, dá para aceitar que o SO talvez não tenha otimizado isso, já que quase nunca há necessidade. Eu interpretaria de forma diferente dependendo do caso de uso.

    • Pelo que verifiquei recentemente, no Windows o desempenho do TCP local é muito superior ao de pipes.

    • Vale lembrar que POSIX define o comportamento, não o desempenho, e que cada plataforma e SO têm suas próprias peculiaridades de performance.

    • Antigamente tive a experiência oposta. Não com pipes, mas quando um app PHP no Linux se comunicava com uma API SOAP baseada em .NET, lembro que a implementação em .NET respondia mais rápido.

  • Só para constar, existem vários métodos como readv() / writev(), splice(), sendfile(), funopen(), io_buffer() etc. O splice() é excelente para transferir grandes volumes de dados com zero-copy entre pipes e sockets UNIX, mas é específico do Linux. Para transmissão de dados, splice() é a forma mais rápida por lidar diretamente com isso sem alocação de memória em espaço de usuário, sem gerenciamento extra de buffers, sem memcpy() e sem percorrer iovec. Também fica o pedido de confirmação sobre se, nos sistemas BSD, readv()/writev() é mesmo o ideal para pipes. De todo modo, achei este artigo muito impressionante.

    • sendfile() oferece desempenho muito alto com zero-copy no caminho arquivo→socket, e está disponível tanto no Linux quanto no BSD. Porém, só dá suporte a arquivo→socket. sendmsg() não pode ser usado com pipes comuns; ele é para sockets de domínio UNIX/INET e outros tipos de socket. Aliás, no Linux eu já usei sendfile na prática até para transferência de arquivo→dispositivo de bloco, graças ao fato de ele ser implementado internamente com splice.

    • splice() é o melhor para transferências massivas ultrarrápidas entre pipes no Linux, mas, se você usar io_uring direito, dá para esperar desempenho parecido ou até melhor.

    • Memória compartilhada com shm_open e passagem de file descriptors é, na prática, mais rápida e totalmente portável.

  • Disseram que houve uma discussão bem ativa sobre este artigo no HN anteriormente, e indicaram os links https://news.ycombinator.com/item?id=31592934 (200 comentários) e https://news.ycombinator.com/item?id=37782493 (105 comentários).

  • Artigo realmente excelente, e é muito bom ver esse assunto reaparecendo de tempos em tempos.

    • Correção de typo: comes → comes up
  • Uma pena que ainda não havia nenhum comentário. Eu gostaria de usar mais splice, mas me preocupam as questões de segurança e compatibilidade de ABI mencionadas no fim do texto. Também fiquei curioso sobre se splice vai continuar sendo mantido no futuro e quão difícil seria aplicar um patch para que os pipes padrão sempre usem splice visando melhorar o desempenho.

  • Pergunta sobre se existe algo no Linux moderno parecido com Doors do SunOS. Estou procurando uma tecnologia melhor que AF_UNIX para uma aplicação embarcada que precisa trocar pequenos volumes de dados com latência extremamente sensível.

    • Memória compartilhada é a mais rápida em termos de latência, mas exige acordar a task, normalmente usando futex. O Google estava desenvolvendo a system call FUTEX_SWAP, que permitiria handoff direto de uma task para outra, mas não sei como isso evoluiu depois.

    • Como 'Doors' é uma palavra genérica demais, pediram uma explicação porque é difícil pesquisar por isso.

    • Perguntaram qual é exatamente o problema com AF_UNIX hoje: se falta alguma funcionalidade, se a latência está acima do desejado ou se a estrutura de API de socket cliente/servidor não se encaixa no caso.

  • Informação adicional de forma breve: o artigo foi escrito em 2022.