- 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-readficou em cerca de 15~19ms, enquantostd::fsdo Rust e a implementação em C marcaram cerca de 23ms, fazendo Rust/C parecerem mais lentos que Python - Ao seguir com
strace,eBPFeperf, a diferença acabou ligada ao offset em que o buffer de destino do syscallreadcaía dentro da página, e a queda de desempenho pôde ser reproduzida perto de0x10 - 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 movsbdentro de_copy_to_iterno 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 movsbsomado ao acaso do offset de memória, e a melhora comjemalloctambé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.9mspython-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
fsdo OpenDAL em Rust, o resultado ainda era mais lento que a leitura nativa do Pythonrust-opendal-fs-read: média de 23.8mspython-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
fsdo OpenDAL usa std::fs do Rust, foi escrita uma implementação separada baseada emstd::fspara verificar o custo do próprio OpenDAL - Na implementação direta com
std::fsdo Rust, o mesmo padrão continuourust-std-fs-read: média de 23.1mspython-fs-read: média de 15.2ms- A leitura nativa do Python foi medida como 1.52x mais rápida que
std::fsdo Rust
Syscalls e mmap vistos com strace
- Na análise com
strace, tanto Rust quanto Python usavammmappara grandes alocações de buffer - A execução de
std::fsno Rust seguia o fluxo de abrir/tmp/file, ler 64MiB de uma vez, chamarreadpara confirmar EOF e então fechar - A leitura nativa do Python executava mais syscalls, como
newfstatat,ioctlelseek, 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ônima67112960é 64MiB mais 4KiBMAP_ANONYMOUSsignifica alocação de memória sem relação com arquivo
- O build padrão de Rust para
x86_64-unknown-linux-gnuusamallocdaglibc, e aglibcpode usarmmappara 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 Pythonrust-std-fs-read-with-jemalloc: média de 9.7mspython-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
mmapou o alocador padrão, mas essa interpretação foi corrigida em atualizações posteriores - Segundo a atualização de 2023-12-01,
jemalloc,pymallocemimallocnão eram intrinsecamente mais rápidos queglibc malloc - A diferença real vinha do offset dentro da página do buffer criado pelo alocador
rust-std-fs-read: leitura no offset0x10a partir do endereço inicial dommaprust-std-fs-read-with-jemalloc: leitura no offset0x740a partir do endereço inicial dommap
- A faixa problemática foi resumida como
0x00..0x10dentro da página, e o mesmo problema também pode ser reproduzido comjemalloc
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=offno kernel Linux não mudava o resultado - Alterar Transparent Hugepage para
always,madviseounevermudava os valores absolutos, mas mantinha a proporção relativa - Fixar a execução a um núcleo específico com
core_affinitytambém dava o mesmo resultado
- Reativar
- Na medição de latência do syscall
readcom eBPF, o lado Rust continuava mais lento- Python
read file: 8,134,049ns - Rust
std::fsread file: 24,636,975ns
- Python
- 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 Pythonc-fs-read: média de 23.8mspython-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:
readno offset0x10a partir do endereço retornado pormmap - Python:
readno offset0x30a partir do endereço retornado pormmap
- C:
- 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-reade a versão com offset aplicado em umAMD Ryzen 9 5900HXe analisou comperf - Dependendo da presença do offset, os valores de
L1-dcache-prefetcheseL1-dcache-loadsmudavam bastante- Sem offset:
L1-dcache-loadsem torno de 127,845,213,L1-dcache-prefetchesem torno de 1,843,493 - Com offset:
L1-dcache-loadsem torno de 13,965,813,L1-dcache-prefetchesem torno de 395,578
- Sem offset:
- O hotspot aparecia no caminho de
readdo kernel, passando porshmem_file_read_iter→copy_page_to_iter→_copy_to_iter - A instrução de assembly central dentro de
_copy_to_itererarep 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 movsbpiorava 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 movsbentrega 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 movsberep movsd - FSRM é um recurso que começou na Intel, foi adotado também pela AMD, e a
glibcusa 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 noamd-ucode - A esperança mais realista é a
glibcdesativar 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
- Xuanwo/when-i-find-rust-is-slow: coleção dos trechos de código e scripts usados
- Std::fs::read slow?: relato semelhante na comunidade Rust
- Terrible memcpy performance on Zen 3 when using rep movsb: problema de desempenho de
rep movsbno Zen 3 reportado para a glibc do Ubuntu - binding/python: rust std fs is slower than python fs: issue relacionada ao binding Python do OpenDAL
1 comentários
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 dememset/memcpyJá 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
Pode ter havido algum problema com
rep movsrápido em páginas alinhadas, ou talvez ele tenha sido desativado por vulnerabilidade a algum ataqueNão sei como essa correção deveria ser feita, se seria necessário algo como uma verificação em tempo de execução
Se existe uma implementação em “software” mais rápida, fico curioso por que não fazem
REP MOVSrealizar pelo menos a mesma coisa no microcódigoO 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::fserrado, mas na verdade foi um texto divertido, cheio de toca de coelho de depuração e mistérioFoi muito bem escrito e muito interessante
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
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
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
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óriaSe 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
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
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
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_genericSe 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 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
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
jemallocfoi o alocador padrão do Rust até 2018https://internals.rust-lang.org/t/jemalloc-was-just-removed-...
Fiquei curioso com a parte de que “desenvolvedores Rust podem considerar trocar para
jemallocatorpara 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
jemalloc, é preciso saber que há um problema de observabilidade por causa deMADV_FREE. Ohtopdeixa de mostrar com precisão a memória realmente em usohttps://github.com/jemalloc/jemalloc/issues/387#issuecomment...
https://gitlab.haskell.org/ghc/ghc/-/issues/17411
Agora parece que o
jemallocchamaMADV_DONTNEED10 segundos depois deMADV_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
htopAinda assim, de acordo com https://jemalloc.net/jemalloc.3.html, dá para remover essa latência configurando
opt.muzzy_decay_ms = 0Mesmo assim, o autor do musl é reticente quanto a tornar
jemalloco padrão: https://www.openwall.com/lists/musl/2018/04/23/2A 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
Não será necessariamente mais rápido em todos os cenários, mas em quase todos provavelmente será. O Rust também usava
jemalloccomo padrão antigamente, mas isso mudou porque algumas pessoas acharam surpreendente tê-lo como padrãoIsso 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
jemalloccomo padrão antigamente, mas por volta de 2018 voltou para omallocdo sistema[0]Hoje Rust tem a trait
GlobalAlloce o atributo#[global_allocator], então, se o app quiser, pode usarjemalloccomo alocador. Não sei se o usuário consegue sobrescrever isso com algo comoLD_PRELOADjemallocnã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
jemallocpode 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çaEnviei isso para as pessoas adequadas