2 pontos por GN⁺ 2023-11-30 | 1 comentários | Compartilhar no WhatsApp
  • O ponto de partida foi um relato de que a leitura de arquivos no binding Python do Apache OpenDAL era mais lenta que open().read() nativo do Python, mas o gargalo não estava no OpenDAL nem no próprio PyO3
  • No benchmark de leitura de arquivos de 64MiB, python-fs-read ficou em cerca de 15~19ms, enquanto std::fs do Rust e a implementação em C marcaram cerca de 23ms, fazendo Rust/C parecerem mais lentos que Python
  • Ao seguir com strace, eBPF e perf, a diferença acabou ligada ao offset em que o buffer de destino do syscall read caía dentro da página, e a queda de desempenho pôde ser reproduzida perto de 0x10
  • Fenômeno semelhante foi confirmado em linhas como AMD Ryzen 9 5900X, Ryzen 7 5700X e Ryzen 9 5900HX, e a pista principal foi o desempenho da execução de rep movsb dentro de _copy_to_iter no kernel
  • Python não era intrinsecamente mais rápido; o resultado foi causado por um bug de CPU no AMD Zen 3 relacionado a FSRM/rep movsb somado ao acaso do offset de memória, e a melhora com jemalloc também veio de outro offset, não do alocador em si

O benchmark estranho que começou no binding Python do OpenDAL

  • Apache OpenDAL é uma camada de acesso a dados para ler e gravar dados em vários serviços de armazenamento de forma unificada, e o binding Python é fornecido via PyO3
  • Um usuário relatou que um código usando o binding Python do OpenDAL para ler um arquivo de 150MB era mais lento que a leitura nativa de arquivos do Python
    • open(...).read() nativo do Python, 100 vezes: 4.470868484000675
    • Binding Python do OpenDAL, 100 vezes: 8.993250704006641
  • Mesmo em uma leitura simplificada de arquivo de 64MiB, o binding do OpenDAL continuou mais lento
    • python-fs-read: média de 15.9ms
    • python-opendal-read: média de 32.9ms
    • A leitura nativa do Python foi medida como 2.07x mais rápida que o binding do OpenDAL

Rastreando até o Rust OpenDAL e o std::fs

  • Mesmo implementando a mesma lógica com o serviço fs do OpenDAL em Rust, o resultado ainda era mais lento que a leitura nativa do Python
    • rust-opendal-fs-read: média de 23.8ms
    • python-fs-read: média de 15.6ms
    • A leitura nativa do Python foi medida como 1.52x mais rápida que a implementação Rust do OpenDAL
  • Como o serviço fs do OpenDAL usa std::fs do Rust, foi escrita uma implementação separada baseada em std::fs para verificar o custo do próprio OpenDAL
  • Na implementação direta com std::fs do Rust, o mesmo padrão continuou
    • rust-std-fs-read: média de 23.1ms
    • python-fs-read: média de 15.2ms
    • A leitura nativa do Python foi medida como 1.52x mais rápida que std::fs do Rust

Syscalls e mmap vistos com strace

  • Na análise com strace, tanto Rust quanto Python usavam mmap para grandes alocações de buffer
  • A execução de std::fs no Rust seguia o fluxo de abrir /tmp/file, ler 64MiB de uma vez, chamar read para confirmar EOF e então fechar
  • A leitura nativa do Python executava mais syscalls, como newfstatat, ioctl e lseek, mas ainda assim o tempo total era menor
  • A chamada mmap(NULL, 67112960, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) não era um mapeamento de arquivo, e sim usada para alocação de memória anônima
    • 67112960 é 64MiB mais 4KiB
    • MAP_ANONYMOUS significa alocação de memória sem relação com arquivo
  • O build padrão de Rust para x86_64-unknown-linux-gnu usa malloc da glibc, e a glibc pode usar mmap para grandes alocações

Rust mais rápido com jemalloc e a conclusão intermediária que depois caiu

  • Ao trocar o alocador global do Rust para jemallocator::Jemalloc, ele passou a ficar mais rápido que Python
    • rust-std-fs-read-with-jemalloc: média de 9.7ms
    • python-fs-read: média de 15.8ms
    • A implementação Rust com jemalloc foi medida como 1.64x mais rápida que Python
  • Nesse ponto, parecia que a causa era mmap ou o alocador padrão, mas essa interpretação foi corrigida em atualizações posteriores
  • Segundo a atualização de 2023-12-01, jemalloc, pymalloc e mimalloc não eram intrinsecamente mais rápidos que glibc malloc
  • A diferença real vinha do offset dentro da página do buffer criado pelo alocador
    • rust-std-fs-read: leitura no offset 0x10 a partir do endereço inicial do mmap
    • rust-std-fs-read-with-jemalloc: leitura no offset 0x740 a partir do endereço inicial do mmap
  • A faixa problemática foi resumida como 0x00..0x10 dentro da página, e o mesmo problema também pode ser reproduzido com jemalloc

Um problema mais reprodutível por máquina do que por configuração de software

  • À medida que a discussão avançou, ficou claro que o fenômeno de Rust ser mais lento que Python era especialmente forte na máquina do autor
  • A CPU do autor era um AMD Ryzen 9 5950X 16-Core Processor, com memória DDR4 3200 MT/s em configuração DIMM de 16GB
  • Mesmo mudando várias configurações, a diferença relativa de desempenho não desaparecia
    • Reativar mitigations=off no kernel Linux não mudava o resultado
    • Alterar Transparent Hugepage para always, madvise ou never mudava os valores absolutos, mas mantinha a proporção relativa
    • Fixar a execução a um núcleo específico com core_affinity também dava o mesmo resultado
  • Na medição de latência do syscall read com eBPF, o lado Rust continuava mais lento
    • Python read file: 8,134,049ns
    • Rust std::fs read file: 24,636,975ns
  • Pelas observações, era difícil explicar a diferença apenas com OpenDAL, PyO3 ou a biblioteca padrão do Rust; o tempo já se perdia no nível do syscall

A pista do offset de memória revelada na implementação em C

  • Mesmo implementando a mesma leitura de arquivo de 64MiB em C com fopen/malloc/fread, ela ainda era mais lenta que Python
    • c-fs-read: média de 23.8ms
    • python-fs-read: média de 19.1ms
    • A leitura nativa do Python foi medida como 1.25x mais rápida que a implementação em C
  • Ao verificar os endereços dos ponteiros com strace -e raw=read,mmap, viu-se que os offsets iniciais do buffer em C e Python eram diferentes
    • C: read no offset 0x10 a partir do endereço retornado por mmap
    • Python: read no offset 0x30 a partir do endereço retornado por mmap
  • Ao ajustar o offset da mesma forma na implementação em C, o desempenho melhorou bastante
    • c-fs-read-with-offset: média de 8.9ms
    • 2.15x mais rápido que Python e 2.68x mais rápido que a implementação C original
  • Esse problema também foi reproduzido em AMD Ryzen 9 5900X e AMD Ryzen 7 5700X
  • No tópico Std::fs::read slow? da comunidade Rust, um fenômeno parecido também foi relatado, e foi apontada a relação entre offset da região de memória e desempenho do syscall

A análise com perf apontou para rep movsb

  • Um desenvolvedor do kernel reproduziu c-fs-read e a versão com offset aplicado em um AMD Ryzen 9 5900HX e analisou com perf
  • Dependendo da presença do offset, os valores de L1-dcache-prefetches e L1-dcache-loads mudavam bastante
    • Sem offset: L1-dcache-loads em torno de 127,845,213, L1-dcache-prefetches em torno de 1,843,493
    • Com offset: L1-dcache-loads em torno de 13,965,813, L1-dcache-prefetches em torno de 395,578
  • O hotspot aparecia no caminho de read do kernel, passando por shmem_file_read_itercopy_page_to_iter_copy_to_iter
  • A instrução de assembly central dentro de _copy_to_iter era rep movsb, e a maior parte das amostras se concentrava nela
  • Em análises posteriores, concluiu-se que, mais do que o prefetch de L1 em si, a pista mais importante era que o desempenho de rep movsb piorava com dados alinhados à página e melhorava quando esse alinhamento era quebrado

O problema com FSRM e AMD Zen 3

  • O relatório compartilhado de bug da glibc no Ubuntu, Terrible memcpy performance on Zen 3 when using rep movsb, também trata do problema de desempenho com rep movsb
  • O exemplo desse relatório explica que, numa cópia de 2113 bytes, o caminho com rep movsb entrega cerca de 3.2GB/s, mas ao mudar o tamanho para 2111 bytes ele sobe para mais de 100GB/s
  • FSRM significa Fast Short REP MOV, um recurso para acelerar rep movsb e rep movsd
  • FSRM é um recurso que começou na Intel, foi adotado também pela AMD, e a glibc usa FSRM por padrão em CPUs que anunciam suporte
  • Portanto, não era que Python fosse essencialmente mais rápido que C/Rust; o que acontecia era que um bug da CPU AMD fazia o caminho de leitura de C/Rust ficar mais lento em certos offsets de memória

Atualização: conhecimento da AMD e resposta da glibc

  • Segundo a atualização de 2023-12-01, a AMD aparentemente já conhecia esse bug desde 2021
  • Depois que o texto foi publicado, vários leitores enviaram o link para a AMD, então considera-se que a empresa está ciente do problema
  • O autor entende que a AMD deveria assumir a correção desse bug via amd-ucode, mas há informações não confirmadas de que corrigir isso em Zen 3 pode ser difícil no amd-ucode
  • A esperança mais realista é a glibc desativar FSRM quando necessário
  • Do lado da glibc, o trabalho x86: Improve ERMS usage on Zen3 está em andamento

Código de reprodução e materiais relacionados

1 comentários

 
GN⁺ 2023-11-30
Comentários no Hacker News
  • Existem até duas flags de recursos da CPU dedicadas que indicam que REP STOS/MOV é rápido e pode ser usado com sequências curtas de instruções de memset/memcpy
    Já faz décadas que continua esse sofrimento de ter que reescrever manualmente as rotinas otimizadas a cada nova geração de CPU, então parece que isso já deveria fazer parte da suíte de testes de temporização dos fabricantes de CPU

    • É totalmente especulação, mas também parece possível que seja efeito de alguma correção de bug aplicada por atualização de microcódigo no último momento ou depois do lançamento
      Pode ter havido algum problema com rep movs rápido em páginas alinhadas, ou talvez ele tenha sido desativado por vulnerabilidade a algum ataque
    • Se entendi corretamente, fico em dúvida se isso significa que é preciso gerar dois executáveis para cada build em tempo de compilação, ou se significa que é preciso compilar em um hardware específico
      Não sei como essa correção deveria ser feita, se seria necessário algo como uma verificação em tempo de execução
    • É fácil presumir que o fabricante da CPU conhece melhor a própria CPU
      Se existe uma implementação em “software” mais rápida, fico curioso por que não fazem REP MOVS realizar pelo menos a mesma coisa no microcódigo
  • O bug relacionado no glibc está aqui. Mas neste caso é Zen 4: https://sourceware.org/bugzilla/show_bug.cgi?id=30994

  • No começo, li o texto já pronto para rir do autor por ter usado std::fs errado, mas na verdade foi um texto divertido, cheio de toca de coelho de depuração e mistério
    Foi muito bem escrito e muito interessante

    • Foi um texto realmente muito bom. A abordagem de depuração de criar programas de teste e ir removendo as camadas uma a uma foi inteligente, e a conclusão foi interessante e inesperada; além disso, o texto estava claro e fácil de acompanhar
  • A premissa é um pouco confusa. Não foi uma comparação entre código Python puro e código nativo C/Rust, mas sim entre o método de leitura de arquivos do Python, que é um wrapper de Python sobre código nativo, e o OpenDAL, que é outro wrapper sobre código nativo
    A diferença de desempenho continua sendo interessante, mas descrever isso como “mais lento que Python” é bem estranho. Será que esperavam que a biblioteca padrão do Python fosse toda escrita em Python puro? Eu, pelo contrário, esperaria que as implementações de funções da biblioteca padrão do Python fossem nativas e altamente otimizadas caso a caso
    Não me surpreende que a conclusão tenha a ver com o funcionamento de código nativo, mas a resposta específica foi inesperada. Só achei o ponto de partida confuso; o texto em si foi muito interessante
    Além disso, o título “C is slower than Python with specified offset” soa, para um falante nativo, como “mesmo com offset especificado, C é mais lento que Python”. Na realidade, era o contrário: quando aplicaram em C o offset que já estavam usando em Python, C ficou mais rápido

    • Na verdade, nem entendo muito bem por que isso seria confuso
      Seria surpreendente que uma tarefa simples como leitura de arquivos fosse mais lenta na biblioteca padrão do Rust do que na biblioteca padrão do Python. Mesmo sabendo que essas chamadas da biblioteca padrão do Python foram escritas em C, ainda assim dá para esperar que as chamadas da biblioteca padrão do Rust tenham velocidade parecida
      Então, normalmente, a expectativa seria de uso incorreto ou de algum comportamento estranho na biblioteca padrão do Rust, mas desta vez não era nenhum dos dois: era um abismo de desempenho causado, em um hardware específico, pelo alinhamento da alocação
      Espero que a leitura de sistema de arquivos seja bem otimizada em Python, mas penso o mesmo do Rust, então o fato de o lado do Rust ter sido muito mais lento foi surpreendente, e mais surpreendente ainda por depender do hardware e do alocador
    • Não entendo por que Python é criticado como linguagem lenta quando está lento, mas, quando está rápido, dizem que “não é Python de verdade”
      Se um código escrito em Python é rápido, para mim isso significa que Python é rápido. Se a implementação por baixo foi feita em outra linguagem ou por qualquer outro motivo, isso não me importa muito
    • Não sei por que haveria motivo para esperar que fosse “altamente otimizado caso a caso”
      O que aconteceu no post original foi quase puramente acidental. O código C do CPython nem sequer se preocupa com consistência de const, e faz muita alocação dinâmica de memória e muitas chamadas auxiliares/de conveniência. Até coisas como aritmética fazem alocação dinâmica de memória
      Se você já trabalhou com CPython, normalmente não espera um bom desempenho. Quando quer melhorar performance, tende a tentar contornar os recursos que ele oferece
      Além disso, Python não tem padrão, então, tecnicamente, nem existe biblioteca padrão, e a maior parte das bibliotecas distribuídas junto é escrita em Python. Algumas são escritas em C, mas mesmo nesse código C há uma parte considerável que, na prática, é Python traduzido mecanicamente para C. Por exemplo, a implementação de busca binária do Python foi originalmente escrita em Python e depois traduzida para C usando a Python C API
      O que dá para esperar é que recursos que mapeiam diretamente para funções do sistema operacional tenham wrappers relativamente finos. Ou seja, leitura de arquivos basicamente entra direto na interface do sistema, então não deve exigir muito código de binding
    • Obrigado pelo apontamento. Corrigi o título
    • A premissa é que usar frases como “Python é mais rápido que Rust” rende pageviews, mesmo quando não é verdade
      Depois de dezenas de posts parecidos, todo mundo percebeu isso
  • O texto em si é excelente e traz muita informação interessante relacionada a esse problema
    Mas a parte que mais chama atenção e preocupa é como o problema é reportado e registrado, e como a comunicação é conduzida
    Os relatos acontecem no Discord, que é um ambiente proprietário, não indexado, difícil de pesquisar e sem preservação adequada. As discussões acontecem no Discord e no Telegram, e neste contexto o Telegram pode ser ainda pior
    Este post de blog e o repositório no GitHub são tudo o que restou como rastro. Se Xuanwo não tivesse escrito no blog, tudo teria desaparecido na timeline. É uma situação bastante interessante

    • É verdade que é uma plataforma proprietária, e isso não é bom. Mas a crítica de que não dá para indexar ou pesquisar é difícil de aceitar
      Quase nenhum mensageiro faz indexação e busca, por padrão, de logs com acesso público. Nem todo servidor IRC fornece logs públicos, e o mesmo vale para grupos Matrix. Não vejo por que se considera que as discussões ali não desaparecem na timeline
      O motivo pelo qual é possível fornecer logs públicos não é o fato de não ser proprietário, mas sim a existência de uma API que permite registrar os logs. O Telegram também tem essa API, e os logs pesquisáveis do nosso grupo de discussão podem ser vistos aqui: https://luoxu-web.vercel.app/#g=1264662201
      O fato de não haver indexação pública se deve principalmente à privacidade, não ao fato de a plataforma ser proprietária
    • É exatamente por isso que, sempre que lamento o declínio da USENET, não aceito a resposta “agora existe o Discord”
      Antigamente, dava para pesquisar todas as mensagens de forma limpa no DejaNews e, depois, no Google
      Comunicações importantes de projetos open source importantes, como a pilha da internet/WWW e ferramentas e bibliotecas centrais de programação, deveriam voltar a usar padrões abertos
  • Foi a leitura mais interessante desta semana. Excelente compilação

  • Naturalmente, parece que o que precisa ser feito é enviar um patch para o método de kernel copy_user_generic
    Se um CPU problemático for detectado e causar um bug que torna o alinhamento de memória mais lento, basta fazer com que outra implementação de cópia de memória seja usada

    • Não é tão óbvio assim. Se isso puder ser corrigido com microcódigo, parece melhor fazer as pessoas usarem microcódigo atualizado do que espalhar pelo kernel código de correção para um problema que, na prática, pode ser corrigido por software
      Não seria uma correção trivial a ponto de ser aceita por alguém sem experiência com kernel. Mais importante ainda, também não está claro como a solução de contorno deveria ser ativada. Provavelmente o melhor seria medir isso no boot; caso contrário, fica nebuloso como saber quais modelos e steppings são afetados
    • Não é uma correção trivial. A AMD provavelmente precisa descobrir por que o aliasing quebra em endereços próximos ao alinhamento de página, então a correção deve acabar ficando do lado do microcódigo
      Uma mitigação por software também seria complicada. O kernel na prática não pode usar as instruções vetoriais que normalmente seriam usadas no caminho alternativo quando ERMS não pode ser utilizado
  • jemalloc foi o alocador padrão do Rust até 2018
    https://internals.rust-lang.org/t/jemalloc-was-just-removed-...

  • Fiquei curioso com a parte de que “desenvolvedores Rust podem considerar trocar para jemallocator para melhorar o desempenho”
    Não sei se isso significa que qualquer um pode obter um ganho de desempenho quase de graça, ou se há ressalvas. Também fiquei curioso se codebases em C também poderiam se beneficiar, e se isso é um desempenho que estamos simplesmente deixando passar

    • Ao usar jemalloc, é preciso saber que há um problema de observabilidade por causa de MADV_FREE. O htop deixa de mostrar com precisão a memória realmente em uso
      https://github.com/jemalloc/jemalloc/issues/387#issuecomment...
      https://gitlab.haskell.org/ghc/ghc/-/issues/17411
      Agora parece que o jemalloc chama MADV_DONTNEED 10 segundos depois de MADV_FREE: https://github.com/JuliaLang/julia/issues/51086#issuecomment...
      Então isso até “corrige” o problema, mas cria uma latência confusa entre o momento em que a memória é liberada e o momento em que isso pode ser observado no htop
      Ainda assim, de acordo com https://jemalloc.net/jemalloc.3.html, dá para remover essa latência configurando opt.muzzy_decay_ms = 0
      Mesmo assim, o autor do musl é reticente quanto a tornar jemalloc o padrão: https://www.openwall.com/lists/musl/2018/04/23/2
      A ideia é que há problemas de otimização voltados a deixá-lo o mais rápido possível sem se preocupar com fragmentação séria, enfraquecimento de ASLR e uso de memória. Esse valor de ajuste talvez alivie um pouco isso, mas a tendência geral — foco em desempenho vs. foco em uso de memória — provavelmente continua sendo um trade-off
    • Eu diria que é desempenho que está sendo deixado na mesa quase de graça. Há o custo de um pequeno aumento no tamanho do binário
      Não será necessariamente mais rápido em todos os cenários, mas em quase todos provavelmente será. O Rust também usava jemalloc como padrão antigamente, mas isso mudou porque algumas pessoas acharam surpreendente tê-lo como padrão
    • Mudar para um alocador não padrão nem sempre melhora o desempenho
      Isso depende muito da carga de trabalho, então é necessário fazer profiling e benchmarking. Ainda assim, linguagens de baixo nível como C/C++/Rust deveriam permitir escolher esses alocadores
      Um ponto de atenção é o tamanho do binário. Um alocador customizado adiciona mais bytes ao executável
    • Rust usava jemalloc como padrão antigamente, mas por volta de 2018 voltou para o malloc do sistema[0]
      Hoje Rust tem a trait GlobalAlloc e o atributo #[global_allocator], então, se o app quiser, pode usar jemalloc como alocador. Não sei se o usuário consegue sobrescrever isso com algo como LD_PRELOAD
      jemalloc não é sempre a melhor opção para todas as cargas de trabalho e casos de uso. Os alocadores do sistema muitas vezes estão longe de ser perfeitos, mas pelo menos foram amplamente testados como alocadores de uso geral
      [0] https://github.com/rust-lang/rust/issues/36963
    • Desempenho não é uma escala unidimensional em que o programa vai de “lento” para “rápido”. Sempre há outros fatores em jogo
      jemalloc pode ser a escolha certa para algumas aplicações, mas em outros casos outro alocador pode ser mais rápido. Ou pode ser mais lento, mas ainda assim se adequar melhor a objetivos como menos memória suja, melhor observabilidade ou garantias específicas de segurança
  • Enviei isso para as pessoas adequadas

    • Quer dizer que você enviou para o pessoal da AMD?