Melhorias de desempenho no decodificador de vídeo rav1d
(ohadravid.github.io)- O decodificador AV1 rav1d, baseado em Rust, era cerca de 6 segundos, ou 9%, mais lento que o dav1d, baseado em C, com a mesma entrada; duas pequenas otimizações reduziram o tempo de execução de 73,914 s para 72,182 s
- A análise comparou os dois binários nas mesmas condições com
samplye usou funções Arm assembly comuns como âncoras para rastrear diferenças entre os wrappers em Rust e as implementações de funções - A primeira melhoria evitou a inicialização com zero de um buffer temporário no caminho Arm usando
MaybeUninite moveu o ponto de inicialização delr_bak, reduzindo o tempo total de execução em cerca de 1,6% - A segunda melhoria trocou a comparação ineficiente gerada pelo
PartialEqpadrão para pequenosstructsnuméricos por uma comparação baseada emas_bytes()dozerocopy, economizando mais cerca de 0,5 s - Os dois PRs produziram uma melhoria total de 2,3% sem novo
unsafe, mas a medição se limita ao chip M3 no macOS, single-thread e a uma entrada específica de benchmark; ainda resta uma diferença de cerca de 4,2 s em relação ao dav1d
Desempenho de referência e ambiente de medição
rav1dé um port em Rust dodav1d- Conversão do
dav1dcomc2rust - Integração das funções otimizadas em assembly do
dav1d - Inclui o trabalho de tornar o código mais idiomático em Rust e mais seguro
- Conversão do
- A memorysafety.org realizou um concurso de melhoria de desempenho do
rav1d; no estado de referência, orav1dem Rust era cerca de 5% mais lento que odav1dem C - As medições locais foram feitas em um MacBook Air M3, em um ambiente de 8 núcleos
rav1d: commita654c1e82adb2d9a33ae50d2a82a7a747102cbb6rustc 1.88.0-nightly, LLVM20.1.2dav1d:1.5.1- Homebrew clang
20.1.4 - Arquivo de entrada:
Chimera-AV1-8bit-1920x1080-6736kbps.ivf - Opções de execução:
--threads 1, saída para/dev/null
- Os resultados iniciais do
hyperfineforam rav1d 73,914 s e dav1d 67,912 s- No mesmo arquivo de amostra, o
rav1dficou cerca de 6 segundos, 9%, mais lento - As versões do LLVM do
clange dorustcdiferiam apenas na versão de patch
- No mesmo arquivo de amostra, o
Abordagem de profiling
- Para o profiling foi usado o
samply- A taxa de amostragem padrão é 1000 Hz
- Uma diferença de 500 amostras em uma função específica corresponde, aproximadamente, a 0,5 s de diferença no tempo de execução
- Como os dois binários são parecidos e têm comportamento determinístico, foi eficaz comparar as diferenças de amostras por função em vez de entender novamente todo o decodificador de vídeo
- Chamadas otimizadas em assembly usadas em comum foram adotadas como âncoras
- O
dav1dchamacdef_filter_8x8_neonecdef_filter_4x4_neon, e cada uma faz o dispatch das funções assembly relacionadas - No
rav1d,cdef_filter_neon_erasedprocessa o dispatch de todas as funções assembly
- O
- A contagem de amostras de
cdef_filter8_pri_sec_edged_8bpc_neonera quase igual nos dois snapshots, confirmando que a direção da comparação estava correta - A diferença em
cdef_filter_neon_erasederav1d_cdef_brow, somada, correspondia a cerca de 1% do tempo total de execução dorav1d- A soma das amostras Self de
cdef_filter_{8x8,4x4}_neonnodav1dera de cerca de 400 - As amostras Self de
cdef_filter_neon_erasednorav1deram de cerca de 670 dav1d_cdef_brow_8bpctinha 1790 amostras, erav1d_cdef_brow, 2350 amostras
- A soma das amostras Self de
Melhoria 1: remoção da inicialização com zero do buffer temporário
cdef_filter_neon_erasedcria um buffer temporário comoAlign16([0u16; TMP_LEN])TMP_LENé, no pior caso,12 * 16 + 8 = 200- Como resultado, um buffer temporário equivalente a
[u16; 200]é preenchido com zeros
- O código C correspondente do
dav1dcria um buffer de pilha no formatouint16_t tmp_buf[200] __attribute__((aligned(16))), mas não o inicializa- Esse buffer é o destino de escrita da função assembly
padding - Depois disso, a função assembly
filterusa esses valores diretamente
- Esse buffer é o destino de escrita da função assembly
- No LLVM IR do
rav1d, aparece código que preenche 400 bytes com zero usandollvm.memset- O compilador Rust não conseguiu saber que essa inicialização poderia ser eliminada
- Foi usado
MaybeUninitpara evitar a inicialização com zero do buffer temporárioAlign16([0u16; TMP_LEN])foi alterado paraAlign16([MaybeUninit::<u16>::uninit(); TMP_LEN])- As assinaturas de funções internas foram ajustadas para formas como
tmp: *mut MaybeUninit<u16>etmp: &[MaybeUninit<u16>] - Como isso foi tratado dentro de um caminho de código que já era
unsafe, nenhum novo blocounsafefoi adicionado
- Após a mudança, as amostras Self de
cdef_filter_neon_erasedcaíram de 670 para 274- Ficaram ligeiramente abaixo da soma das amostras Self de
cdef_filter_{8x8,4x4}_neondodav1d
- Ficaram ligeiramente abaixo da soma das amostras Self de
Extensão da melhoria 1: redução de inicialização dentro de loops
- No processo de procurar buffers
Align16grandes, foi encontrada a inicialização delr_bakdentro derav1d_cdef_brow- O código existente inicializava
lr_bakcom zero a cada iteração do loop - O código correspondente do
dav1dnão inicializa esse buffer
- O código existente inicializava
- Aqui, a conversão para
MaybeUninitera mais difícil, então a criação delr_bakfoi movida para fora do loop- A inicialização deixou de ocorrer a cada iteração e passou a ser feita apenas uma vez
- A economia foi pequena, mas reduziu trabalho desnecessário do mesmo tipo
- No benchmark geral incluindo essa mudança, o
rav1dregistrou 72,644 s- Melhoria de 1,2 s em relação aos 73,914 s anteriores
- Cerca de 1,5% de melhoria em relação ao tempo total de execução
- Ainda resta uma diferença em relação aos 67,912 s do
dav1d
Melhoria 2: otimização da comparação de igualdade de pequenos structs
- Ao fazer novamente o profiling com a visualização de pilha invertida, apareceu uma diferença notável em
add_temporal_candidate- A diferença entre as versões em Rust e C era de cerca de 400 amostras, equivalente a aproximadamente 0,5 s
- A função em si consiste em cerca de 50 linhas de
if,fore chamadas curtas de utilitários
- Recompilando com o perfil
release-with-debug, foi possível verificar a distribuição de amostras por linhaif cand.mv.mv[0] == mv {if cand.mv == mvp {- As duas linhas, somadas, respondiam por cerca de 600 amostras
Mvem Rust é um pequeno struct que usa#[derive(PartialEq)]#[repr(C)]y: i16,x: i16
- O
mvdodav1dé definido como umaunionstruct { int16_t y, x; }uint32_t n- Na comparação, ele compara como um valor de 32 bits, como em
mvstack[n].mv.n == mvp.n
- Usar
unionem Rust tornaria o acesso aos camposunsafe, podendo afetar todos os locais que usamMv- Em vez disso, foi usado
AsBytesdozerocopypara comparar a representação em bytes - Em
impl PartialEq for Mv, foi usadoself.as_bytes() == other.as_bytes() - Uma verificação no Godbolt mostrou que isso gera assembly otimizado igual ao de uma abordagem baseada em
transmute
- Em vez disso, foi usado
- Uma otimização semelhante também foi aplicada a
RefMvs{Mv,Ref}Pair- O resultado do benchmark foi 72,182 s
- Uma melhoria de cerca de 0,5 s em relação ao resultado anterior de 72,644 s
- Melhoria de 2,3% em relação ao ponto de partida de 73,914 s
PartialEq padrão do Rust e limites da geração de código
- O motivo pelo qual o
PartialEqpadrão de pequenos structs leva a uma geração de código ineficiente está ligado à issue#140167do Rust - Em C,
struct { int16_t y, x; }pode estar em um estado em que apenasyfoi inicializado exnão- Se a comparação for
this.y == other.y && this.x == other.xe todos osyforem diferentes, não é necessário lerx - Considerando esse tipo de caso, a otimização para um único carregamento de memória só é válida quando há garantia de que todos os campos estão sempre inicializados
- Se a comparação for
- A discussão relacionada aborda o fato de que o LLVM não tem uma forma de expressar a propriedade de que “leituras por meio deste ponteiro sempre leem bytes inicializados”
- O
zerocopyconsegue verificar estaticamente as condições de segurança que permitem representar o struct como uma fatia de bytes, possibilitando uma comparação otimizada sem novounsafe
Resultado final e lacuna de desempenho restante
- O primeiro PR evita uma inicialização com zero custosa em um caminho quente específico de Arm
- PR #1397
- Melhoria de 1,2 s no tempo de execução
- Cerca de -1,6%
- O segundo PR troca a implementação padrão de
PartialEqpara pequenos structs numéricos por uma comparação baseada em bytes- PR #1400
- Melhoria de 0,5 s no tempo de execução
- Cerca de -0,7%
- As duas mudanças, juntas, têm escala de algumas dezenas de linhas e não introduzem novo
unsafena base de código - O tempo final de execução do
rav1dfoi 72,182 s, ficando 2,3% mais rápido que no ponto de partida- Ainda há cerca de 4,2 s de diferença em relação aos 67,912 s do
dav1d - Reduziu cerca de 30% da diferença de desempenho observada no início
- Ainda há cerca de 4,2 s de diferença em relação aos 67,912 s do
- Ainda resta uma lacuna de cerca de 6% entre as duas implementações, e a comparação de snapshots do profiler entre
dav1derav1dpode continuar sendo usada para encontrar otimizações adicionais
1 comentários
Opiniões no Hacker News
A questão relacionada a comparar dois u16 é interessante
https://github.com/rust-lang/rust/issues/140167
A geração de código com
-O3é estranha, mas a saída com-O2é razoável. Se uma struct acabou de ser calculada, tentar lê-la como um único load de 32 bits pode causar uma falha de store forwarding, anulando o benefício de mesclar os loads. Sem inlining e sem PGO, o compilador não tem informações suficientes para decidir se essa otimização é apropriadaComo desenvolvedor web, acho que issues do GitHub muitas vezes são bem ruins
Não tenho tanta certeza de que compiladores C também consigam lidar melhor com esse problema no caso geral
Parece que é por causa desse tipo de coisa que a conta do ffmpeg no Twitter se posiciona contra Rust
https://x.com/ffmpeg/status/1924137645988356437?s=46
https://github.com/memorysafety/rav1d/issues/1294
Como não estou logado, só consigo ver o tuíte original; fico curioso se há alguma explicação nas respostas
É uma pena não haver uma alternativa decente, e os desenvolvedores parecem bem agressivos. Se você controla todo o pipeline, o desempenho máximo é ótimo; mas, se recebe dados não confiáveis de usuários desconhecidos, o ffmpeg tem pelo menos meia dúzia de CVEs exploráveis remotamente por ano. É bom deixar o sandbox bem reforçado
https://ffmpeg.org/security.html
Mais do que as posições que cada lado defende, parece haver um meio-termo em que todos caminham rumo a uma solução segura e rápida
Se ajustarem os critérios de medição dos recordes olímpicos e corrigirem retroativamente o tempo dos 100 m de Bolt de 9,63 s para 9,64 s, ninguém vai se importar. Mas, se alguém correr 100 m em 9 segundos de fato, isso chama atenção. Isso, claro, se for um ser humano; se for um avestruz, não impressiona, embora, em geral, avestruzes não compitam nos 100 m olímpicos
É interessante que um texto sobre o ganho de desempenho por não inicializar buffers com zero tenha aparecido dois dias depois deste
https://news.ycombinator.com/item?id=44032680
O título subestima o texto
Na prática, ele ficou 2,3% mais rápido com duas boas otimizações
Considerando que Arm e x86 devem representar a maior parte das distribuições daqui para frente, talvez faça mais sentido contar como algo em torno de metade disso
Bom texto, e foi interessante a parte em que encontraram código ineficiente na comparação de pares de inteiros de 16 bits
Rust pode ter informações muito mais precisas sobre se a memória foi inicializada ou não
Em igualdade de condições, acho que codecs deveriam ser escritos em WUFFS, e não em Rust
Dito isso, reescrever algo tão complexo quanto o dav1d em WUFFS pode ser muito maior do que limpar o resultado de uma conversão com c2rust. Eu acreditaria se fosse mil vezes mais difícil. Ainda assim, acho que, para a civilização como um todo, vale a pena tentar
Estou falando de WUFFS ou de uma linguagem especializada equivalente, e WUFFS já existe
Sem alocação dinâmica de memória, é difícil lidar com dados dinâmicos. Um codec de vídeo não se limita a parsear um arquivo para obter dados; ele precisa gerenciar bastante estado altamente dinâmico
Quando um texto começa com um meme engraçado, dá para saber que é um bom texto
Também parece relacionado à discussão recente: $20K Bounty Offered for Optimizing Rust Code in Rav1d AV1 Decoder (memorysafety.org) | 108 comments | https://news.ycombinator.com/item?id=43982238
Sinceramente, fiquei um pouco surpreso que a primeira otimização fosse algo bastante claro usando apenas perf
Acho que o primeiro texto já discutia o problema de inicializar o buffer com zero; a segunda otimização foi com certeza mais complexa e interessante, mas ainda assim o perf apontou o caminho. Não dá para subestimar essa ferramenta
Existe
perf diff, mas ele não consegue casar nomes de símbolos diferentes, e não parece ser muito usadoVejo com frequência pessoas de contextos diferentes encontrarem lacunas que, “em retrospecto, eram óbvias”
Isso é muito divertido
Eu estava me perguntando se havia algo impedindo o rustc de fazer aquele truque com transmute, mas, se eu tivesse lido o parágrafo seguinte, teria sabido dessa issue antes de comentar
https://github.com/rust-lang/rust/issues/140167