Mejoras de rendimiento en el decodificador de video rav1d
(ohadravid.github.io)- El decodificador AV1 rav1d, basado en Rust, era unos 6 segundos, o 9%, más lento que dav1d, basado en C, con la misma entrada, y dos optimizaciones pequeñas redujeron el tiempo de ejecución de 73.914 s a 72.182 s
- El análisis comparó ambos binarios bajo las mismas condiciones con
samplyy usó funciones comunes de ensamblador Arm como anclas para rastrear diferencias entre los wrappers en Rust y las implementaciones de funciones - La primera mejora evitó la inicialización en cero de un búfer temporal en la ruta Arm con
MaybeUninity movió la ubicación de inicialización delr_bak, reduciendo el runtime total en alrededor de 1.6% - La segunda mejora reemplazó comparaciones ineficientes generadas por el
PartialEqpredeterminado destructs numéricos pequeños por comparaciones basadas enas_bytes()dezerocopy, ahorrando otros 0.5 segundos - Los dos PR lograron una mejora total de 2.3% sin agregar nuevo
unsafe, pero la medición se limita a macOS con chip M3, un solo hilo y una entrada de benchmark específica, y aún queda una diferencia de unos 4.2 s frente a dav1d
Rendimiento base y entorno de medición
rav1des un port en Rust dedav1d- Convierte
dav1dconc2rust - Integra funciones de optimización en ensamblador de
dav1d - Incluye trabajo para hacer el código más idiomático y seguro en Rust
- Convierte
- memorysafety.org abrió un concurso para mejorar el rendimiento de
rav1d, y en el estado baserav1d, basado en Rust, era alrededor de 5% más lento quedav1d, basado en C - Las mediciones locales se hicieron en una MacBook Air M3 de 8 núcleos
rav1d: commita654c1e82adb2d9a33ae50d2a82a7a747102cbb6rustc 1.88.0-nightly, LLVM20.1.2dav1d:1.5.1- Homebrew clang
20.1.4 - Archivo de entrada:
Chimera-AV1-8bit-1920x1080-6736kbps.ivf - Opciones de ejecución:
--threads 1, salida a/dev/null
- El resultado inicial de
hyperfinefue rav1d 73.914 s, dav1d 67.912 s- Con el mismo archivo de muestra,
rav1dera unos 6 segundos, o 9%, más lento - Las versiones de LLVM de
clangyrustcsolo diferían en la versión de parche
- Con el mismo archivo de muestra,
Enfoque de profiling
- Para el profiling se usó
samply- La frecuencia de muestreo predeterminada es 1000 Hz
- Una diferencia de 500 muestras en una función equivale aproximadamente a 0.5 segundos de diferencia en tiempo de ejecución
- Como ambos binarios son parecidos y se comportan de manera determinista, resultó válido comparar diferencias de muestras por función en vez de volver a entender todo el decodificador de video
- Se tomaron como ancla las llamadas comunes al ensamblador optimizado
dav1dllama acdef_filter_8x8_neonycdef_filter_4x4_neon, y cada una despacha funciones de ensamblador relacionadasrav1dusacdef_filter_neon_erasedpara manejar todo el despacho de funciones de ensamblador
- La cantidad de muestras de
cdef_filter8_pri_sec_edged_8bpc_neonfue casi igual en ambos snapshots, lo que confirmó que la dirección de la comparación era correcta - La diferencia entre
cdef_filter_neon_erasedyrav1d_cdef_browequivale en conjunto a alrededor de 1% del tiempo total de ejecución derav1d- La suma de muestras Self de
cdef_filter_{8x8,4x4}_neonendav1des de unas 400 - Las muestras Self de
cdef_filter_neon_erasedenrav1dson unas 670 dav1d_cdef_brow_8bpctiene 1790 muestras, mientrasrav1d_cdef_browtiene 2350
- La suma de muestras Self de
Mejora 1: eliminar la inicialización en cero del búfer temporal
cdef_filter_neon_erasedcrea un búfer temporal conAlign16([0u16; TMP_LEN])TMP_LENes12 * 16 + 8 = 200en el peor caso- En la práctica, eso llena con ceros un búfer temporal equivalente a
[u16; 200]
- El código C correspondiente de
dav1dcrea un búfer de stack con la formauint16_t tmp_buf[200] __attribute__((aligned(16))), pero no lo inicializa- Ese búfer es el destino de escritura de la función de ensamblador
padding - Después, la función de ensamblador
filterusa esos valores tal cual
- Ese búfer es el destino de escritura de la función de ensamblador
- En el LLVM IR de
rav1daparece código conllvm.memsetque llena con ceros 400 bytes- El compilador de Rust no podía saber que esa inicialización podía eliminarse
- Se usó
MaybeUninitpara evitar la inicialización en cero del búfer temporalAlign16([0u16; TMP_LEN])se cambió porAlign16([MaybeUninit::<u16>::uninit(); TMP_LEN])- Las firmas internas se ajustaron a formas como
tmp: *mut MaybeUninit<u16>ytmp: &[MaybeUninit<u16>] - Como esto ya estaba dentro de una ruta de código
unsafe, no se agregó ningún bloqueunsafenuevo
- Después del cambio, las muestras Self de
cdef_filter_neon_erasedbajaron de 670 a 274- Quedaron incluso un poco por debajo de la suma de muestras Self de
cdef_filter_{8x8,4x4}_neonendav1d
- Quedaron incluso un poco por debajo de la suma de muestras Self de
Extensión de la mejora 1: reducir inicializaciones dentro del bucle
- Al buscar más búferes grandes
Align16, se encontró la inicialización delr_bakdentro derav1d_cdef_brow- El código anterior inicializaba
lr_baken cero en cada iteración del bucle - El código correspondiente de
dav1dno inicializa ese búfer
- El código anterior inicializaba
- Aquí era más difícil pasar a
MaybeUninit, así que la creación delr_bakse movió fuera del bucle- Así, la inicialización se hace una sola vez en vez de repetirse en cada iteración
- El ahorro es pequeño, pero reduce el mismo tipo de trabajo innecesario
- Con este cambio incluido, el benchmark completo de
rav1ddio 72.644 s- Una mejora de 1.2 s frente a los 73.914 s originales
- Aproximadamente 1.5% de mejora sobre el runtime total
- Todavía quedaba diferencia frente a los 67.912 s de
dav1d
Mejora 2: optimizar la comparación de igualdad de estructuras pequeñas
- Al volver a perfilar con la vista de stack invertido apareció una diferencia clara en
add_temporal_candidate- La diferencia entre la versión Rust y la versión C fue de unas 400 muestras, equivalentes a unos 0.5 s
- La función en sí consta de unas 50 líneas de
if,fory llamadas cortas a utilidades
- Se recompiló con el perfil
release-with-debugpara ver la distribución de muestras por líneaif cand.mv.mv[0] == mv {if cand.mv == mvp {- Entre ambas líneas concentraban unas 600 muestras
- En Rust,
Mves una estructura pequeña que usa#[derive(PartialEq)]#[repr(C)]y: i16,x: i16
- En
dav1d,mvestá definido comounionstruct { int16_t y, x; }uint32_t n- Al comparar, usa
mvstack[n].mv.n == mvp.n, es decir, una comparación de 32 bits
- Usar
unionen Rust haría que el acceso a campos fueraunsafe, lo que podría afectar todos los lugares donde se usaMv- En lugar de eso, se usó
AsBytesdezerocopypara comparar la representación en bytes - En
impl PartialEq for Mvse usóself.as_bytes() == other.as_bytes() - Una verificación en Godbolt mostró que genera el mismo ensamblador optimizado que un enfoque basado en
transmute
- En lugar de eso, se usó
- Se aplicó una optimización parecida a
RefMvs{Mv,Ref}Pair- El resultado del benchmark fue 72.182 s
- Una mejora de unos 0.5 s respecto al resultado anterior de 72.644 s
- Una mejora total de 2.3% frente a la línea base de 73.914 s
PartialEq predeterminado de Rust y límites en la generación de código
- La razón por la que el
PartialEqpredeterminado de estructuras pequeñas genera código ineficiente está relacionada con el issue de Rust#140167 - En C, una
struct { int16_t y, x; }puede existir conyinicializado yxsin inicializar- Si la comparación es
this.y == other.y && this.x == other.xy todos losydifieren, no hace falta leerx - Teniendo eso en cuenta, optimizar a una sola lectura de memoria solo es válido cuando se garantiza que todos los campos siempre están inicializados
- Si la comparación es
- En la discusión relacionada se trata que LLVM no tiene manera de expresar la propiedad de que “una carga a través de este puntero siempre lee bytes inicializados”
zerocopypuede verificar de forma estática las condiciones de seguridad para representar la estructura como un slice de bytes, así que fue posible implementar la comparación optimizada sin nuevounsafe
Resultado final y brecha de rendimiento restante
- El primer PR evita una costosa inicialización en cero en una ruta caliente específica de Arm
- PR #1397
- Mejora de 1.2 s en tiempo de ejecución
- Aproximadamente -1.6%
- El segundo PR cambia la implementación predeterminada de
PartialEqde estructuras numéricas pequeñas por una comparación basada en bytes- PR #1400
- Mejora de 0.5 s en tiempo de ejecución
- Aproximadamente -0.7%
- Entre ambos cambios suman unas pocas decenas de líneas y no introducen nuevo
unsafeen el codebase - El tiempo final de ejecución de
rav1dfue 72.182 s, 2.3% más rápido que al inicio- Sigue a unos 4.2 s de
dav1d, con 67.912 s - Reduce alrededor de 30% de la brecha de rendimiento observada al inicio
- Sigue a unos 4.2 s de
- Aún queda una brecha de alrededor de 6% entre ambas implementaciones, y la comparación de snapshots del profiler entre
dav1dyrav1dpuede seguir ayudando a encontrar más optimizaciones
1 comentarios
Opiniones en Hacker News
Es interesante el issue relacionado con comparar dos u16
https://github.com/rust-lang/rust/issues/140167
La generación de código con
-O3es extraña, pero la salida de-O2es razonable. Si una estructura acaba de ser calculada, intentar leerla como una sola carga de 32 bits puede provocar una falla de store forwarding, anulando la ventaja de fusionar las cargas. Sin inlining ni PGO, al compilador le falta información para decidir si esa optimización es adecuada.Como desarrollador web, muchas veces los issues de GitHub son bastante malos.
No me resulta fácil asegurar que un compilador de C pueda manejar mejor este problema en el caso general.
Por cosas como esta parece que la cuenta de Twitter de ffmpeg toma una postura contraria a Rust
https://x.com/ffmpeg/status/1924137645988356437?s=46
https://github.com/memorysafety/rav1d/issues/1294
Como no inicié sesión, solo puedo ver el tuit original; me pregunto si en las respuestas hay alguna explicación.
Es una lástima que no haya una alternativa decente, y los desarrolladores parecen bastante agresivos. Si controlas todo el pipeline, el máximo rendimiento sería ideal; pero si recibes datos no confiables de usuarios desconocidos, ffmpeg tiene al menos cinco o seis CVE explotables de forma remota cada año. Conviene tener un sandbox bien reforzado.
https://ffmpeg.org/security.html
Más que las posturas a las que cada quien se aferra, creo que hay un punto intermedio en el que todos avanzan hacia una solución segura y rápida.
Si ajustan los criterios de medición de los récords olímpicos y corrigen retroactivamente el récord de Bolt en 100 m de 9.63 s a 9.64 s, a nadie le importa. Pero si alguien corre 100 m en 9 segundos, sí llama la atención. Claro, eso si es humano; si fuera un avestruz no sería impresionante, aunque en general los avestruces no compiten en los 100 m olímpicos.
Es interesante que, dos días después de este artículo, aparezca uno sobre las ventajas de rendimiento de no inicializar un búfer en cero
https://news.ycombinator.com/item?id=44032680
El título subestima el artículo
En realidad, se volvió 2.3% más rápido gracias a dos buenas optimizaciones.
Si asumimos que Arm y x86 serán la mayor parte de las distribuciones en el futuro, quizá sea más correcto contarla como aproximadamente la mitad.
Es un buen artículo, y me pareció interesante la parte donde encontraron código ineficiente al comparar pares de enteros de 16 bits.
Rust puede tener información mucho más precisa sobre si la memoria está inicializada o no.
En igualdad de condiciones, creo que los códecs deberían escribirse en WUFFS más que en Rust
Dicho eso, reescribir algo tan complejo como dav1d en WUFFS podría ser muchísimo más grande que limpiar el resultado de una conversión con c2rust. Me creería que fuera mil veces más difícil. Aun así, creo que para la civilización en su conjunto valdría la pena intentarlo. Me refiero a WUFFS o a un lenguaje especializado equivalente, y WUFFS ya existe.
Sin asignación dinámica de memoria, manejar datos dinámicos es difícil. Un códec de video no se limita a parsear un archivo para obtener datos; tiene que gestionar mucho estado bastante dinámico.
Cuando un artículo empieza con un meme divertido, sabes que va a ser bueno
También parece relacionado con una discusión reciente: $20K Bounty Offered for Optimizing Rust Code in Rav1d AV1 Decoder (memorysafety.org) | 108 comentarios | https://news.ycombinator.com/item?id=43982238
Sinceramente, me sorprende un poco que la primera optimización fuera algo que se veía con bastante claridad usando solo perf
Creo que en el primer artículo ya se había discutido el problema de inicializar el búfer en cero; la segunda optimización sí fue más compleja e interesante, pero aun así perf la señaló. No hay que subestimar esta herramienta.
Existe
perf diff, pero no puede emparejar nombres de símbolos distintos, y tampoco parece que mucha gente lo use.A menudo veo que alguien que viene de otro contexto encuentra un hueco que, “en retrospectiva”, era evidente.
Esto es realmente divertido
Me preguntaba si había algo que impidiera que rustc hiciera ese truco con transmute, pero si hubiera leído el siguiente párrafo me habría enterado de este issue antes de comentar
https://github.com/rust-lang/rust/issues/140167