Melhorias de desempenho no decodificador de vídeo rav1d
(ohadravid.github.io)- Foi observado que o decodificador AV1 rav1d, escrito em Rust, era cerca de 9% mais lento que o dav1d baseado em C
- Foi confirmado que a otimização da inicialização de buffers e a melhoria da lógica de comparação de structs trouxeram ganhos de velocidade de 1,5% e 0,7%, respectivamente
- Com a ferramenta de profiling samply, foi possível identificar de forma concreta a causa da diferença de desempenho entre as duas versões
- Em vez da implementação padrão de PartialEq do Rust, foi adotada uma comparação em nível de bytes para aumentar a eficiência
- Com esta otimização, cerca de 30% da diferença total de desempenho foi reduzida, embora ainda haja espaço para mais otimizações
Contexto e abordagem
- rav1d é um projeto que porta o decodificador AV1 dav1d para Rust com c2rust, incorporando funções otimizadas em asm e melhorias de segurança próprias da linguagem Rust
- Um critério público de desempenho básico foi estabelecido, e o rav1d em Rust estava cerca de 5% mais lento que o dav1d em C
- Em vez de analisar a estrutura geral de um decodificador de vídeo complexo, a análise se concentrou na diferença de tempo de execução entre binários com a mesma entrada
- A comparação foi feita de forma sistemática com a ferramenta de medição de desempenho (hyperfine) e o profiler (samply)
- O ambiente-alvo foi um chip macOS M3, simplificado com execução em thread única
Medição de desempenho: comparação do padrão
- Cada versão foi compilada e testada com benchmark usando o mesmo arquivo de teste (Chimera-AV1-8bit-1920x1080-6736kbps.ivf)
- rav1d: cerca de 73,9 segundos, dav1d: cerca de 67,9 segundos, confirmando uma diferença de aproximadamente 6 segundos (9%) no tempo de execução
- Cada compilador (Clang, Rustc) usava praticamente a mesma versão do LLVM
Análise de profiling
- Com o profiler samply, foi feita uma comparação da contagem de amostras por função em cada executável
- Foi dada atenção especial aos caminhos de chamada e à distribuição de amostras das funções em assembly baseadas em NEON (ARM SIMD)
- O dav1d separa em funções de filtro distintas para chamar condicionalmente as funções asm, enquanto o rav1d gerencia tudo em uma única função de dispatch
- Na função cdef_filter_neon_erased, o número de amostras Self foi cerca de 270 maior que a soma de duas funções equivalentes no dav1d (cerca de 1% do total)
- A análise identificou um trecho em que um buffer temporário (zero-initialized buffer) estava sendo inicializado de forma desnecessariamente grande
Otimização com remoção da inicialização de buffers
- Por segurança, o Rust faz zeroing automático com abordagens como [0u16; LEN]
- Porém, em C (dav1d), o buffer não é zerado explicitamente; apenas a região realmente usada recebe valores
- No Rust, foi usado std::mem::MaybeUninit para eliminar o custo desnecessário dessa inicialização
- As amostras Self da função cdef_filter_neon_erased caíram drasticamente de 670 para 274
- Outro buffer grande Align16 também teve a inicialização movida para fora do loop, reduzindo o custo de inicialização para apenas uma vez
- Após a otimização, o benchmark caiu para cerca de 72,6 segundos, uma melhora de 1,2 segundo (1,5%)
Otimização da comparação de structs
- Na análise de pilha invertida do profiling, foi descoberto que a função add_temporal_candidate estava funcionando de maneira mais ineficiente do que o esperado
- A comparação de campos da struct Mv nessa função (implementação automática de PartialEq) gerava código desnecessariamente lento
- Em C, uma union é usada para realizar uma comparação eficiente em unidades de uint32_t
- Em Rust, sem recorrer a unsafe, a comparação em fatias de bytes foi implementada com a trait zerocopy::AsBytes
- Essa otimização trouxe mais 0,5 segundo de ganho (cerca de 0,7%)
Resultado e resumo
- Duas otimizações simples (remoção da inicialização de buffers e comparação de structs por bytes) reduziram o tempo de execução em mais de 2%
- Ainda resta uma diferença de desempenho de cerca de 6%, com bastante espaço para otimizações adicionais
- Foi confirmado que o método de comparação entre snapshots do profiler é eficaz
- Há grande potencial para novas otimizações no rav1d e no dav1d com base na análise desses snapshots
- Com feedback ativo e colaboração dos mantenedores do projeto, foi possível melhorar sem comprometer a segurança
Resumo
- Com as ferramentas profiler (samply) e benchmark (hyperfine), foi feita uma análise precisa da diferença de 6 segundos (9%) no tempo de execução entre rav1d e dav1d
- Duas otimizações principais:
- remoção de zeroing desnecessário de buffer em código específico para ARM (1,2 segundo, -1,6%)
- troca da implementação de PartialEq de uma struct numérica pequena por uma comparação rápida em nível de bytes (0,5 segundo, -0,7%)
- Cada otimização é concisa, com apenas algumas dezenas de linhas, sem novo código unsafe
- A colaboração com os mantenedores e a revisão de PRs permitiram melhorar confiabilidade e qualidade ao mesmo tempo
- Ainda resta uma diferença de cerca de 6% no desempenho, deixando amplo espaço para mais estudos de otimização baseados em comparação com profiler
Go ahead and give this a try! Maybe rav1d can eventually become faster than dav1d 👀🦀.
1 comentários
Opiniões no Hacker News
u16é um tema interessante, e fornece o link da issue relacionada https://github.com/rust-lang/rust/issues/140167store forwardingnão ter sido mencionado na discussão; o resultado da geração de código em-O3é excessivo, mas em-O2parece uma decisão razoável. Explica concretamente que, se uma das structs tiver acabado de passar por uma operação, tentar um load de 32 bits pode falhar emstore forwarding, tornando o ganho de desempenho irrelevante. Aponta que, em cenários sem inline e sem PGO, o compilador não tem informações suficientes para julgar se a otimização é adequada.aarch64, então mencioná-la como número geral não é totalmente justo; considerando a proporção entre ARM/x86, o correto seria tratar como algo em torno da metade.dav1dpara WUFFS é muito mais difícil do que apenas traduzir/organizar código C existente. Ainda assim, considera uma tentativa valiosa e defende que vale a pena investir nisso em termos civilizatórios.webmemp4, mas não é nada adequada para decodificadores de vídeo. Como não há alocação dinâmica de memória, lidar com dados dinâmicos se torna desafiador, e enfatiza que codecs de vídeo não fazem apenas parsing de arquivos, mas exigem gerenciamento de estados dinâmicos muito variados.rav1d, e expressa identificação ao ver que há outras pessoas com a mesma dúvida.Nominative determinism.perf; achava que a questão do zeroing já tinha sido discutida no primeiro post. A segunda otimização foi mais complexa e interessante, mas ainda assim foi guiada peloperf, e aconselha a não subestimar a utilidade da ferramenta.perf, mas também profiling diferencial entre a versão em C e a versão em Rust, além de um processo manual de correspondência. Aponta como limitação que existe o recursoperf diff, mas os nomes dos símbolos são diferentes, o que dificulta o pareamento automático.aarch64, e ressalta pela experiência que pessoas com históricos diferentes conseguem captar rapidamente coisas que, olhando em retrospecto, parecem “óbvias”.dav1d; usa a metáfora de recordes esportivos para dizer que melhorar apenas os números não empolga tanto quanto bater um recorde de verdade, e explica de forma bem-humorada que a solução real é produzir resultados concretamente rápidos e inovadores.