1 pontos por GN⁺ 2025-05-23 | 1 comentários | Compartilhar no WhatsApp
  • 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 samply e 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 MaybeUninit e moveu o ponto de inicialização de lr_bak, reduzindo o tempo total de execução em cerca de 1,6%
  • A segunda melhoria trocou a comparação ineficiente gerada pelo PartialEq padrão para pequenos structs numéricos por uma comparação baseada em as_bytes() do zerocopy, 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 do dav1d
    • Conversão do dav1d com c2rust
    • 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
  • A memorysafety.org realizou um concurso de melhoria de desempenho do rav1d; no estado de referência, o rav1d em Rust era cerca de 5% mais lento que o dav1d em C
  • As medições locais foram feitas em um MacBook Air M3, em um ambiente de 8 núcleos
    • rav1d: commit a654c1e82adb2d9a33ae50d2a82a7a747102cbb6
    • rustc 1.88.0-nightly, LLVM 20.1.2
    • dav1d: 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 hyperfine foram rav1d 73,914 s e dav1d 67,912 s
    • No mesmo arquivo de amostra, o rav1d ficou cerca de 6 segundos, 9%, mais lento
    • As versões do LLVM do clang e do rustc diferiam apenas na versão de patch

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 dav1d chama cdef_filter_8x8_neon e cdef_filter_4x4_neon, e cada uma faz o dispatch das funções assembly relacionadas
    • No rav1d, cdef_filter_neon_erased processa o dispatch de todas as funções assembly
  • A contagem de amostras de cdef_filter8_pri_sec_edged_8bpc_neon era quase igual nos dois snapshots, confirmando que a direção da comparação estava correta
  • A diferença em cdef_filter_neon_erased e rav1d_cdef_brow, somada, correspondia a cerca de 1% do tempo total de execução do rav1d
    • A soma das amostras Self de cdef_filter_{8x8,4x4}_neon no dav1d era de cerca de 400
    • As amostras Self de cdef_filter_neon_erased no rav1d eram de cerca de 670
    • dav1d_cdef_brow_8bpc tinha 1790 amostras, e rav1d_cdef_brow, 2350 amostras

Melhoria 1: remoção da inicialização com zero do buffer temporário

  • cdef_filter_neon_erased cria um buffer temporário como Align16([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 dav1d cria um buffer de pilha no formato uint16_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 filter usa esses valores diretamente
  • No LLVM IR do rav1d, aparece código que preenche 400 bytes com zero usando llvm.memset
    • O compilador Rust não conseguiu saber que essa inicialização poderia ser eliminada
  • Foi usado MaybeUninit para evitar a inicialização com zero do buffer temporário
    • Align16([0u16; TMP_LEN]) foi alterado para Align16([MaybeUninit::<u16>::uninit(); TMP_LEN])
    • As assinaturas de funções internas foram ajustadas para formas como tmp: *mut MaybeUninit<u16> e tmp: &[MaybeUninit<u16>]
    • Como isso foi tratado dentro de um caminho de código que já era unsafe, nenhum novo bloco unsafe foi adicionado
  • Após a mudança, as amostras Self de cdef_filter_neon_erased caíram de 670 para 274
    • Ficaram ligeiramente abaixo da soma das amostras Self de cdef_filter_{8x8,4x4}_neon do dav1d

Extensão da melhoria 1: redução de inicialização dentro de loops

  • No processo de procurar buffers Align16 grandes, foi encontrada a inicialização de lr_bak dentro de rav1d_cdef_brow
    • O código existente inicializava lr_bak com zero a cada iteração do loop
    • O código correspondente do dav1d não inicializa esse buffer
  • Aqui, a conversão para MaybeUninit era mais difícil, então a criação de lr_bak foi 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 rav1d registrou 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, for e chamadas curtas de utilitários
  • Recompilando com o perfil release-with-debug, foi possível verificar a distribuição de amostras por linha
    • if cand.mv.mv[0] == mv {
    • if cand.mv == mvp {
    • As duas linhas, somadas, respondiam por cerca de 600 amostras
  • Mv em Rust é um pequeno struct que usa #[derive(PartialEq)]
    • #[repr(C)]
    • y: i16, x: i16
  • O mv do dav1d é definido como uma union
    • struct { 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 union em Rust tornaria o acesso aos campos unsafe, podendo afetar todos os locais que usam Mv
    • Em vez disso, foi usado AsBytes do zerocopy para comparar a representação em bytes
    • Em impl PartialEq for Mv, foi usado self.as_bytes() == other.as_bytes()
    • Uma verificação no Godbolt mostrou que isso gera assembly otimizado igual ao de uma abordagem baseada em transmute
  • 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 PartialEq padrão de pequenos structs leva a uma geração de código ineficiente está ligado à issue #140167 do Rust
  • Em C, struct { int16_t y, x; } pode estar em um estado em que apenas y foi inicializado e x não
    • Se a comparação for this.y == other.y && this.x == other.x e todos os y forem diferentes, não é necessário ler x
    • 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
  • 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 zerocopy consegue verificar estaticamente as condições de segurança que permitem representar o struct como uma fatia de bytes, possibilitando uma comparação otimizada sem novo unsafe

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 PartialEq para 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 unsafe na base de código
  • O tempo final de execução do rav1d foi 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 resta uma lacuna de cerca de 6% entre as duas implementações, e a comparação de snapshots do profiler entre dav1d e rav1d pode continuar sendo usada para encontrar otimizações adicionais

1 comentários

 
GN⁺ 2025-05-23
Opiniões no Hacker News
  • A questão relacionada a comparar dois u16 é interessante
    https://github.com/rust-lang/rust/issues/140167

    • É surpreendente que store forwarding não tenha sido mencionado nessa discussão
      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 é apropriada
    • É bom que essa discussão não seja 14 páginas de comentários do tipo “também tenho esse problema” e “quando vai ser corrigido?”
      Como desenvolvedor web, acho que issues do GitHub muitas vezes são bem ruins
    • Esse caso parece mostrar a complexidade de escrever compiladores
      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

    • Normalmente confio nos benchmarks do rbultje, mas a issue de acompanhamento do rav1d tem números multithread em várias plataformas e não mostra uma diferença tão grande
      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
    • Só de ler a conta do ffmpeg no Twitter já dá vontade de não usar ffmpeg
      É 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
    • Uma resposta mais saudável teria sido trabalhar para tornar o dav1d mais rápido
      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

    • A otimização de 1,5% é específica para aarch64, então parece um pouco injusto reivindicar esse número como um resultado geral
      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

    • Fico curioso se o compilador poderia ser melhorado no lado Rust/LLVM para aplicar essa otimização sempre que possível
      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

    • WUFFS seria ótimo para parsear arquivos contêiner como Matroska, webm e mp4, mas não parece nem um pouco adequado para um decodificador de vídeo
      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

    • Pelo que parece, não foi só usar perf; foi mais próximo de fazer profiling diferencial entre as versões em C e Rust e fazer a correspondência manualmente
      Existe perf diff, mas ele não consegue casar nomes de símbolos diferentes, e não parece ser muito usado
    • Parece que isso foi possível porque a abordagem veio da perspectiva de aarch64 em dispositivos Apple
      Vejo 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