- 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
Uau! Que interessante! (Não faço a menor ideia do que estão falando...)
|
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.
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.