Mejoras de rendimiento en el decodificador de video rav1d
(ohadravid.github.io)- Se detectó que el decodificador AV1 rav1d, escrito en Rust, es aproximadamente un 9% más lento que dav1d, basado en C
- Se comprobó que la optimización de la inicialización de buffers y la mejora de la lógica de comparación de estructuras aportan por separado mejoras de velocidad de 1.5% y 0.7%, respectivamente
- Se utilizó la herramienta de perfilado samply para identificar con precisión las causas de la diferencia de rendimiento entre ambas versiones
- En Rust, se aumentó la eficiencia reemplazando la implementación predeterminada de PartialEq por una comparación a nivel de bytes
- Con esta optimización se mejoró alrededor del 30% de la diferencia total de rendimiento, aunque todavía queda margen para seguir optimizando
Antecedentes y enfoque
- rav1d es un proyecto que porta el decodificador AV1 dav1d a Rust con c2rust, incorporando además funciones optimizadas en asm y mejoras de seguridad propias del lenguaje Rust
- Ya existe una referencia pública de rendimiento base, y el rav1d basado en Rust sigue siendo alrededor de un 5% más lento que el dav1d basado en C
- En lugar de analizar la estructura general de un decodificador de video complejo, el análisis se centró en las diferencias de tiempo de ejecución binario con la misma entrada
- La comparación se realizó de forma sistemática con la herramienta de medición de rendimiento (hyperfine) y el profiler (samply)
- El entorno objetivo fue un chip macOS M3, simplificado con ejecución en un solo hilo
Medición de rendimiento: comparación base
- Se compiló y evaluó cada versión con el mismo archivo de prueba (Chimera-AV1-8bit-1920x1080-6736kbps.ivf)
- rav1d: aproximadamente 73.9 segundos, dav1d: aproximadamente 67.9 segundos, lo que mostró una diferencia de cerca de 6 segundos (9%) en tiempo de ejecución
- Cada compilador (Clang, Rustc) usa prácticamente la misma versión de LLVM
Análisis de perfilado
- Con el profiler samply se comparó la cantidad de muestras por función en cada ejecutable
- Se revisaron especialmente las rutas de llamada y la distribución de muestras de funciones en ensamblador basadas en NEON (ARM SIMD)
- dav1d separa las funciones de filtro y llama condicionalmente a funciones asm, mientras que rav1d administra todo desde una sola función de dispatch
- La cantidad de muestras Self de la función cdef_filter_neon_erased mostró unas 270 muestras más que la suma de las dos funciones equivalentes en dav1d (alrededor del 1% del total)
- El análisis permitió detectar una sección donde se inicializaba de forma innecesariamente grande un buffer temporal (zero-initialized buffer)
Optimización eliminando la inicialización del buffer
- Por seguridad, Rust realiza zeroing automático con formas como [0u16; LEN]
- Sin embargo, en C (dav1d) el buffer no se rellena explícitamente con ceros, sino que solo se escriben valores en la parte realmente utilizada
- En Rust se eliminó el costo innecesario de inicialización usando std::mem::MaybeUninit
- Las muestras Self de la función cdef_filter_neon_erased bajaron significativamente de 670 a 274
- Otro buffer grande de Align16 también movió su inicialización fuera del bucle para reducir ese costo a una sola vez
- Después de la optimización, el benchmark bajó a aproximadamente 72.6 segundos, una mejora de 1.2 segundos (1.5%)
Optimización de comparación de estructuras
- El análisis de inverted stack en el perfilado reveló que la función add_temporal_candidate estaba funcionando de forma más ineficiente de lo esperado
- La comparación de campos de la estructura Mv dentro de esa función (implementación automática de PartialEq) generaba código innecesariamente lento
- En C se usa un union para realizar una comparación eficiente en unidades de uint32_t
- En Rust, evitando unsafe, se implementó una comparación por slices de bytes mediante el trait zerocopy::AsBytes
- Esta optimización aportó otra mejora de 0.5 segundos (aproximadamente 0.7%)
Resultados y cierre
- Con dos optimizaciones simples (eliminación de la inicialización del buffer y comparación de estructuras por bytes) se logró reducir el tiempo de ejecución en más de un 2%
- Aun así, sigue quedando una diferencia de rendimiento de alrededor del 6%, por lo que hay bastante margen para optimizaciones adicionales
- Se confirmó la efectividad del método de comparación entre snapshots del profiler
- Hay alta probabilidad de seguir optimizando rav1d y dav1d a partir del análisis de snapshots
- Gracias a la retroalimentación activa y la colaboración de los mantenedores, se lograron mejoras sin comprometer la seguridad
Resumen
- Se analizó con precisión la diferencia de 6 segundos (9%) en tiempo de ejecución entre rav1d y dav1d usando herramientas de profiler (samply) y benchmark (hyperfine)
- Dos optimizaciones principales:
- Eliminación del zeroing innecesario de buffers en código especializado para ARM (1.2 segundos, -1.6%)
- Cambio de la implementación de PartialEq en estructuras numéricas pequeñas por una comparación rápida por bytes (0.5 segundos, -0.7%)
- Cada optimización fue concisa, de apenas unas decenas de líneas, sin agregar nuevo código unsafe
- Con colaboración de los mantenedores y revisión de PR, se mejoraron al mismo tiempo la confiabilidad y la calidad
- Aún queda una brecha de rendimiento de aproximadamente 6%, así que hay bastante espacio para seguir investigando optimizaciones comparativas basadas en profiler
Go ahead and give this a try! Maybe rav1d can eventually become faster than dav1d 👀🦀.
1 comentarios
Comentarios de Hacker News
u16es un tema interesante, junto con el enlace al issue relacionado https://github.com/rust-lang/rust/issues/140167-O3es excesivo, pero con-O2parece una decisión razonable. Explicó en concreto que, si una de las estructuras acaba de ser operada, intentar una carga de 32 bits puede hacer fallar el store forwarding, con lo que la mejora de rendimiento perdería sentido. También señaló que, en escenarios sin inlining y sin PGO, el compilador carece de la información necesaria para juzgar si la optimización convieneaarch64, así que mencionarla como cifra global no es del todo justo; sostuvo que, considerando la proporción entre ARM y x86, lo correcto sería verla como aproximadamente la mitaddav1da WUFFS sería muchísimo más difícil que simplemente traducir y limpiar el código C existente. Aun así, sostuvo que vale la pena intentarlo y que es una inversión digna a nivel civilizatoriowebmymp4, pero no sirve en absoluto para decodificadores de video. Señaló que, al no haber asignación dinámica de memoria, manejar datos dinámicos se vuelve complicado, y enfatizó que los códecs de video no solo parsean archivos, sino que requieren administrar una gran variedad de estados dinámicosrav1d, y expresó empatía al ver que había otra persona con la misma inquietudNominative determinism(efecto del nombre)perf; pensaba que el tema del zeroing ya se había discutido en el primer post. Dijo que la segunda optimización era más compleja e interesante, pero aun así también fue guiada porperf, y aconsejó no subestimar la utilidad de la herramientaperf, sino que también se recurrió a perfilado diferencial entre la versión en C y la versión en Rust, además de un proceso manual de emparejamiento. Señaló queperf diffexiste, pero tiene la limitación de que, al diferir los nombres de los símbolos, el emparejamiento automático resulta difícilaarch64, y subrayó desde su experiencia que personas con contextos distintos pueden detectar rápidamente cosas que, vistas después, parecen “obvias”dav1d; usó la analogía de los récords deportivos para decir que mejorar números de forma aislada tiene menos impacto que lograr un verdadero nuevo récord. Explicó de forma amena que la solución real sería obtener resultados sustancialmente más rápidos e innovadores