1 pontos por GN⁺ 2024-08-27 | 1 comentários | Compartilhar no WhatsApp
  • Ao gravar dados em um pipe do Linux com write, o desempenho ficou muito abaixo de uma simples gravação em memória; no ambiente de teste, ficou em torno de 17GB/s para escrita em pipe contra 167GB/s para escrita em buffer de memória
  • O gargalo não é explicado por uma única cópia de dados; dentro de pipe_write, somam-se os custos de alocação de páginas, travas e rotinas de cópia no kernel
  • vmsplice conecta o buffer do espaço do usuário ao pipe sem copiá-lo para o kernel, contornando caminhos caros como __alloc_pages, _raw_spin_lock_irq e copy_user_enhanced_fast_string
  • No caso de throughput do Fizz Buzz, a solução com vmsplice chegou a 60.8GiB/s em um único núcleo e 208.3GiB/s em múltiplos núcleos; em outro experimento, um exemplo com vmsplice registrou 210GB/s
  • Por causa de correções posteriores, a interpretação sobre a penalidade por não usar SIMD não é confiável, e a comunicação entre processos não termina apenas no cache L1, então não é realista esperar 167GB/s como throughput efetivo de pipe

Ponto de partida: a grande diferença criada por vmsplice

  • Alguns programas usam a chamada de sistema vmsplice para mover dados mais rapidamente por pipes
  • Na competição de throughput de Fizz Buzz no Code Golf StackExchange, as soluções se dividiram claramente em dois grupos
    • As soluções que não usam vmsplice ficam na casa de alguns GiB por segundo, e a solução de neil chega a 8.4GiB/s
    • As soluções que usam vmsplice chegam a 15.5GiB/s com tkluck, 60.8GiB/s com ais523 e 208.3GiB/s em múltiplos núcleos com david
  • Apenas reduzir a cópia entre espaço do usuário e espaço do kernel não parece suficiente para explicar uma diferença de cerca de 7 vezes em um único núcleo
  • Em experimentos próprios, a solução de ais523 registrou 96.4GiB/s, e a de david alcançou 277GB/s usando 7 núcleos, cerca de 40GB/s por núcleo

Linha de base: escrita de memória em espaço do usuário

  • Um programa em Rust que copia repetidamente um buffer de 32KiB em memória de espaço do usuário, sem chamadas de sistema, registrou 167GB/s no ambiente de teste
  • Esse valor foi considerado compatível com a velocidade de escrita no cache L1 da CPU usada
    • O sistema de teste usava Ryzen 9 7950X3D, DDR5 6000T/s, Debian 12 e Linux 6.1.0-18-amd64
    • As opções de mitigação da CPU estavam desativadas com mitigations=off
  • No profiling com ftrace, a maior parte do tempo apareceu em __memset_avx512_unaligned_erms
  • Mas correções posteriores limitaram essa interpretação
    • A instrução no ponto de interrupção era rep stos, que não é uma instrução AVX-512
    • Mesmo restringindo a execução a AVX2 e SSE2, o throughput permaneceu em 167GB/s porque em todos os casos foi usado rep stos

O custo real de escrever em um pipe com write

  • Ao gravar um buffer do mesmo tamanho em um pipe com stdout.write() e consumi-lo com pv >/dev/null, o throughput cai para 17GB/s
  • O profiling mostrou que a maior parte do tempo é gasta dentro da chamada de sistema write, com 95% especificamente dentro de pipe_write
  • Dentro de pipe_write, preparação de páginas, travas e cópia se somam como custo
    • __alloc_pages: 36% do tempo total, preparando novas páginas de memória para o pipe
    • __mutex_lock.constprop.0: 25% do tempo total, correspondente ao custo de travas para escrita no pipe
    • _raw_spin_lock_irq: 5% do tempo total, também associado a travas relacionadas à escrita no pipe
    • copy_user_enhanced_fast_string: cerca de 20% do tempo total, copiando dados do espaço do usuário para o kernel
  • Como pv consome os dados movendo páginas para /dev/null com splice, fica difícil reutilizar em loop o mesmo pequeno conjunto de páginas

A rotina de cópia do kernel e a interpretação corrigida

  • Ao desassemblar copy_user_enhanced_fast_string, vê-se que cópias de buffers grandes são tratadas com a instrução REP MOV
  • Essa função não é escrita em C, mas em assembly do kernel Linux, então não se trata de um caminho acidentalmente sem otimização do compilador
  • No experimento original, chamar rep movsb diretamente no espaço do usuário produziu 80GB/s, o que levou à interpretação de que a rotina de cópia do kernel seria cerca de 2 vezes mais lenta
  • Depois, uma correção mostrou que as condições do experimento eram diferentes
    • Dois buffers de 32KiB saturavam o cache de dados L1
    • Com buffers de 16KiB, o desempenho subiu para 153GB/s
  • Portanto, a interpretação original de que a ausência de instruções vetoriais na cópia do kernel impunha uma grande penalidade não é confiável
  • Ainda assim, permanece o ponto de que o overhead de gerenciamento de memória é alto na escrita em pipes

Os caminhos do kernel que vmsplice evita

  • vmsplice reduz o custo de uso de pipes ao passar o buffer inteiro do espaço do usuário para o pipe sem copiá-lo para o kernel
  • O exemplo ./write incluído em pipes-speed-test, de Francesco, é usado como exemplo mínimo que escreve 'X' infinitamente
  • Esse exemplo registrou 210GB/s, mas, como passa repetidamente o mesmo buffer para vmsplice, ele difere de uma tarefa comum de geração de dados
    • Se o fluxo não for um fluxo constante de bytes, novos dados precisam ser colocados no buffer
    • Nesse caso, o limite simples de escrita em memória de 167GB/s volta a importar
  • Mesmo no caminho de vmsplice, __mutex_lock.constprop.0 ainda consome 37% do tempo
  • Porém, __alloc_pages, _raw_spin_lock_irq e copy_user_enhanced_fast_string, vistos no caminho com write, não aparecem
  • Em vez disso, add_to_pipe, import_iovec e iov_iter_get_pages2 surgem como caminhos principais, mostrando que vmsplice contorna as partes mais caras de write

Conclusões restantes e pontos de atenção

  • Nos experimentos, o caminho de escrita em pipes do Linux com write foi cerca de 10 vezes mais lento do que uma simples escrita em memória
  • A conclusão original dizia que o custo de travas e de salvar/restaurar contexto SIMD era alto na escrita em pipes, e que splice e vmsplice contornavam isso
  • Após as correções, essa conclusão precisa ser vista de forma mais limitada
    • O overhead de gerenciamento de memória no kernel continua sendo um fator importante para a perda de desempenho em pipes
    • A interpretação de que não usar instruções vetoriais gera uma penalidade tão grande quanto se pensava é imprecisa
    • Como a comunicação entre processos não pode ocorrer apenas dentro do cache L1, é difícil atingir 167GB/s em um throughput real que inclua também a leitura do pipe
  • Alguns erros importantes foram corrigidos, e a confiabilidade dos resultados pode ser limitada; os números devem ser interpretados mais como indicação de tendência do que como valores absolutos
  • Para aumentar o throughput de pipes, não basta apenas reduzir o número de chamadas de sistema; também é preciso observar os caminhos internos do kernel percorridos por write e a forma como os buffers são tratados

1 comentários

 
GN⁺ 2024-08-27
Opiniões no Hacker News
  • Há um projeto paralelo tentando tratar desse problema: https://lwn.net/Articles/976836/
    A ideia é criar uma chamada de sistema que obtenha um ring buffer para todos os descritores de arquivo compatíveis. Isso incluiria pipes; se as duas pontas aceitarem o uso de ring buffer, elas poderiam mapear o mesmo ring buffer para fazer E/S sem cópia e, em alguns casos, talvez nem fazer chamadas ao kernel. Estão procurando pessoas para colaborar

    • Pelo menos para usos em espaço de usuário, não sei se um novo recurso do kernel é realmente necessário. Há algum tempo implementei um ring buffer em espaço de usuário, de produtor único/consumidor único, que imitava bem de perto o comportamento de pipes usando eventfd
      Quando o ring buffer fica cheio ou vazio, é possível fazer sleep/poll; fora isso, ele funciona sem locks e sem overhead de chamadas de sistema
    • Fico curioso se está planejada uma forma padrão de sinalização para as duas pontas do pipe informarem à outra parte que aceitam ring buffer. Assim a libc poderia lidar com isso de forma transparente; caso contrário, para pipes especificamente, não vejo bem qual seria a vantagem em relação a memória compartilhada e sincronização com futex
    • Provavelmente ringbuffer_wait() também poderia sinalizar tornando-se pronto para leitura em poll()
    • Fico curioso se as interfaces de ring buffer existentes vão passar a usar isso, ou se teremos uma situação xkcd927. De qualquer forma, parece uma tentativa interessante
    • Buffering existe por um motivo, e essa abordagem pode criar modos de falha estranhos e vulnerabilidades em scripts. O ponto central é que qualquer produtor de stream pode ficar mais lento que um consumidor específico
      Mesmo um hiccup momentâneo basta para bagunçar completamente um pipe se não houver buffering suficiente, e o tamanho de buffer necessário varia de sistema para sistema
  • O motivo de JMP não ser simplesmente RET é a opção CONFIG_RETHUNK. O que aparece na desmontagem do objdump é o resultado de substituir RET por JMP __x86_return_thunk
    https://github.com/torvalds/linux/blob/v6.1/arch/x86/include...
    https://github.com/torvalds/linux/blob/v6.1/arch/x86/lib/ret...
    As instruções NOP no início e no fim da função não são para ftrace; elas vêm das macros ASM_CLAC/ASM_STAC. Essas macros deixam espaço que, em tempo de execução, é preenchido com as instruções CLAC/STAC quando X86_FEATURE_SMAP é detectado. Ambas as instruções têm 3 bytes, o mesmo número de NOPs
    https://github.com/torvalds/linux/blob/v6.1/arch/x86/include...
    https://github.com/torvalds/linux/blob/v6.1/arch/x86/include...
    https://github.com/torvalds/linux/blob/v6.1/arch/x86/kernel/...

    • Acho que dá para contar nos dedos os desenvolvedores do kernel que saberiam esse tipo de coisa e ainda escolheriam um apelido descartável tão travesso
  • Chamar pipes do Linux de “lentos” é como chamar um Toyota Corolla de “lento”. Eles são rápidos o bastante, a menos que você esteja em um caso de uso extremo
    Você está correndo de carro? E é uma modalidade em que velocidade importa mais que técnica? Então compre um carro mais rápido. Caso contrário, continue com o Corolla

    • Isto não é código de algum projeto que será executado só alguns bilhões de vezes ao longo da vida útil; é código usado com frequência em milhões a bilhões de computadores
      Por isso, mesmo ganhos de eficiência minúsculos tornam economicamente sensato gastar bastante tempo otimizando
    • Na verdade, no caso do autor, o pipe “lento” está movendo dados a 17 GB/s, ou seja, mais de 130 Gbps
      Uso pipes para várias finalidades há mais de 10 anos e nunca fui limitado pela velocidade do pipe; na maioria das vezes, o gargalo provavelmente era alguma ferramenta como tar, gzip, find, grep ou nc. Claro que essas ferramentas também são bem rápidas para o que fazem
    • Tenho um projeto que usa um SDK proprietário para decodificar vídeo bruto. Ele gera os dados decodificados como RGBA puro, e o FFMpeg lê por um pipe para reencodar em um codec padrão
      O FFMpeg não pode incluir o SDK Non-Free no código-fonte, e salvar RGBA puro em arquivo é absurdamente impraticável. Por isso o pipe é a única opção, e há uma justificativa legítima para precisar de pipes de alta vazão
    • Fazer algo usado em toda parte ficar alguns por cento mais rápido é um investimento bastante válido. Mesmo que tarefas individuais não fiquem muito mais rápidas, no agregado mundial isso economiza uma quantidade enorme de energia e tempo
    • Às vezes, um Corolla mais rápido é mesmo a melhor resposta
      https://www.toyota.com/grcorolla/
      Esses carros têm engenharia e desempenho impressionantes, e também são uma espécie de hack para contornar as regras que dificultaram trazer o GR Yaris originalmente pretendido para o mercado dos EUA. Acho que há contexto suficiente de engenharia/desempenho/hacking/mercado para o pessoal do HN levar na esportiva. Além disso, o presidente da empresa ainda dirige e controla pessoalmente o carro
  • Embora não seja o ponto central do texto, em CPUs modernas, rep movsb é tão rápido quanto a versão vetorizada mais rápida. Isso porque a CPU sabe acelerá-lo
    O nome da função do kernel copy_user_enhanced_fast_string também sugere isso. Os recursos de CPU relacionados são ERMS (Enhanced Repeat Move String, que torna rep movsb rápido acima de um certo tamanho) e FSRM (Fast Short Repeat Move String, que também acelera cópias curtas)

    • Mas isso não é tudo. rep movsb é rápido até certo limiar, mas depois disso armazenamentos comuns ou armazenamentos não temporais (non-temporal store) são mais rápidos
      Todos os limiares estão descritos em https://codebrowser.dev/glibc/glibc/sysdeps/x86_64/multiarch...
      E esses valores nem são fixos; Noah Goldstein ainda os atualiza todos os anos
    • Também vale notar que, desde o kernel 6.1 usado no texto, o Linux mudou várias vezes a forma como usa ERMS e FSRM em cópias no x86. Para referência, na minha máquina, que tem tanto FSRM quanto ERMS — surpreendentemente, o primeiro não implica o segundo — no Linux 6.8, apenas com um pipe comum e um buffer de 32 KiB, chega a 17 GB/s
    • Para memcpy curtos, ainda estou esperando que rep movsb e rep stosb fiquem rápidos o bastante para podermos remover versões simples em loop C
    • Nesse caso, fico curioso para saber quando os compiladores C também vão inlinear memcpy() de tamanho variável, assim como fazem com memcpy de tamanho fixo
  • Há um ponto sobre AVX512 que não vi no texto: além do overhead de xsave/xrstor, AVX512 consome muita energia e provoca escalonamento da frequência da CPU. Para mais detalhes e para ver como isso pode ficar sutil, veja [1], [2]
    [1] https://www.intel.com/content/dam/www/central-libraries/us/e...
    [2] https://www.intel.com/content/www/us/en/developer/articles/t...

    • Isso só se aplica a determinados modelos de CPU Intel
  • Quase todas as formas de comunicação entre processos são “lentas”. É uma escolha de pagar um custo de desempenho em troca de segurança

    • Não é preciso pagar um custo tão alto assim. Pipes oferecem muito pouco, então também deveriam custar muito pouco
      Em especial, não há muitos motivos para a comunicação entre processos mais rápida ser mais lenta do que uma chamada de função longa
    • Pipes não existem por segurança; existem como uma otimização para passar dados entre programas existentes
  • Está recebendo de novo o hug of death do Hacker News. Graças ao cache de páginas do WordPress, está melhor do que da última vez, mas ainda assim a página pode levar alguns segundos para carregar, então peço compreensão

  • Nunca entendi muito bem por que splice precisava ser tão lento. O texto aponta alocação de buffers e uso de instruções escalares como motivos para ser mais lento que vmsplice, mas não sei por que isso é necessário
    Por que splice simplesmente não poderia ser reimplementado como vmsplice? Deve haver um bom motivo, mas acho que deixei passar

  • Seria interessante ver também uma versão usando io_uring. Daria para compartilhar previamente buffers com o kernel e evitar algumas cópias, e talvez também evitar o overhead de chamadas de sistema. Ainda assim, neste caso, este último parece quase desprezível

  • É uma afirmação bastante ousada para um blog que leva uns 20 segundos para carregar

    • Já que este texto chegou ao topo do Hacker News, acho que vale relevar um pouco
      O artigo em si parece excelente, e há muito a aprender sobre o que acontece por baixo dos panos