1 puntos por GN⁺ 2025-05-23 | 1 comentarios | Compartir por WhatsApp
  • 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 samply y 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 MaybeUninit y movió la ubicación de inicialización de lr_bak, reduciendo el runtime total en alrededor de 1.6%
  • La segunda mejora reemplazó comparaciones ineficientes generadas por el PartialEq predeterminado de structs numéricos pequeños por comparaciones basadas en as_bytes() de zerocopy, 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

  • rav1d es un port en Rust de dav1d
    • Convierte dav1d con c2rust
    • Integra funciones de optimización en ensamblador de dav1d
    • Incluye trabajo para hacer el código más idiomático y seguro en Rust
  • memorysafety.org abrió un concurso para mejorar el rendimiento de rav1d, y en el estado base rav1d, basado en Rust, era alrededor de 5% más lento que dav1d, basado en C
  • Las mediciones locales se hicieron en una MacBook Air M3 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
    • Archivo de entrada: Chimera-AV1-8bit-1920x1080-6736kbps.ivf
    • Opciones de ejecución: --threads 1, salida a /dev/null
  • El resultado inicial de hyperfine fue rav1d 73.914 s, dav1d 67.912 s
    • Con el mismo archivo de muestra, rav1d era unos 6 segundos, o 9%, más lento
    • Las versiones de LLVM de clang y rustc solo diferían en la versión de parche

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
    • dav1d llama a cdef_filter_8x8_neon y cdef_filter_4x4_neon, y cada una despacha funciones de ensamblador relacionadas
    • rav1d usa cdef_filter_neon_erased para manejar todo el despacho de funciones de ensamblador
  • La cantidad de muestras de cdef_filter8_pri_sec_edged_8bpc_neon fue casi igual en ambos snapshots, lo que confirmó que la dirección de la comparación era correcta
  • La diferencia entre cdef_filter_neon_erased y rav1d_cdef_brow equivale en conjunto a alrededor de 1% del tiempo total de ejecución de rav1d
    • La suma de muestras Self de cdef_filter_{8x8,4x4}_neon en dav1d es de unas 400
    • Las muestras Self de cdef_filter_neon_erased en rav1d son unas 670
    • dav1d_cdef_brow_8bpc tiene 1790 muestras, mientras rav1d_cdef_brow tiene 2350

Mejora 1: eliminar la inicialización en cero del búfer temporal

  • cdef_filter_neon_erased crea un búfer temporal con Align16([0u16; TMP_LEN])
    • TMP_LEN es 12 * 16 + 8 = 200 en 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 dav1d crea un búfer de stack con la forma uint16_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 filter usa esos valores tal cual
  • En el LLVM IR de rav1d aparece código con llvm.memset que llena con ceros 400 bytes
    • El compilador de Rust no podía saber que esa inicialización podía eliminarse
  • Se usó MaybeUninit para evitar la inicialización en cero del búfer temporal
    • Align16([0u16; TMP_LEN]) se cambió por Align16([MaybeUninit::<u16>::uninit(); TMP_LEN])
    • Las firmas internas se ajustaron a formas como tmp: *mut MaybeUninit<u16> y tmp: &[MaybeUninit<u16>]
    • Como esto ya estaba dentro de una ruta de código unsafe, no se agregó ningún bloque unsafe nuevo
  • Después del cambio, las muestras Self de cdef_filter_neon_erased bajaron de 670 a 274
    • Quedaron incluso un poco por debajo de la suma de muestras Self de cdef_filter_{8x8,4x4}_neon en dav1d

Extensión de la mejora 1: reducir inicializaciones dentro del bucle

  • Al buscar más búferes grandes Align16, se encontró la inicialización de lr_bak dentro de rav1d_cdef_brow
    • El código anterior inicializaba lr_bak en cero en cada iteración del bucle
    • El código correspondiente de dav1d no inicializa ese búfer
  • Aquí era más difícil pasar a MaybeUninit, así que la creación de lr_bak se 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 rav1d dio 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, for y llamadas cortas a utilidades
  • Se recompiló con el perfil release-with-debug para ver la distribución de muestras por línea
    • if cand.mv.mv[0] == mv {
    • if cand.mv == mvp {
    • Entre ambas líneas concentraban unas 600 muestras
  • En Rust, Mv es una estructura pequeña que usa #[derive(PartialEq)]
    • #[repr(C)]
    • y: i16, x: i16
  • En dav1d, mv está definido como union
    • struct { 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 union en Rust haría que el acceso a campos fuera unsafe, lo que podría afectar todos los lugares donde se usa Mv
    • En lugar de eso, se usó AsBytes de zerocopy para comparar la representación en bytes
    • En impl PartialEq for Mv se 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
  • 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 PartialEq predeterminado 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 con y inicializado y x sin inicializar
    • Si la comparación es this.y == other.y && this.x == other.x y todos los y difieren, no hace falta leer x
    • 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
  • 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”
  • zerocopy puede 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 nuevo unsafe

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 PartialEq de 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 unsafe en el codebase
  • El tiempo final de ejecución de rav1d fue 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
  • Aún queda una brecha de alrededor de 6% entre ambas implementaciones, y la comparación de snapshots del profiler entre dav1d y rav1d puede seguir ayudando a encontrar más optimizaciones

1 comentarios

 
GN⁺ 2025-05-23
Opiniones en Hacker News
  • Es interesante el issue relacionado con comparar dos u16
    https://github.com/rust-lang/rust/issues/140167

    • Me sorprende que en esa discusión no se mencione el store forwarding
      La generación de código con -O3 es extraña, pero la salida de -O2 es 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.
    • Me gusta que esta discusión no sean 14 páginas de comentarios tipo “yo también tengo este problema” o “¿cuándo lo arreglan?”
      Como desarrollador web, muchas veces los issues de GitHub son bastante malos.
    • Este caso parece mostrar la complejidad de escribir compiladores
      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

    • Normalmente confío en los benchmarks de rbultje, pero el issue de seguimiento de rav1d tiene cifras multihilo de varias plataformas y no muestran una diferencia tan grande
      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.
    • Leer solo la cuenta de Twitter de ffmpeg ya alcanza para que no den ganas de usar ffmpeg
      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.
    • Una respuesta más sana habría sido trabajar para hacer dav1d más rápido
      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.

    • La optimización de 1.5% es solo para aarch64, así que parece un poco injusto presentar la cifra total tal cual
      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.

    • Me pregunto si del lado de Rust/LLVM se podría mejorar el compilador para aplicar esta optimización siempre que sea posible
      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.

    • WUFFS sería excelente para parsear archivos contenedores como Matroska, webm o mp4, pero no parece en absoluto adecuado para un decodificador de video
      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.

    • Por lo que se ve, no fue simplemente usar perf, sino más bien hacer perfilado diferencial entre la versión en C y la versión en Rust y emparejarlo manualmente
      Existe perf diff, pero no puede emparejar nombres de símbolos distintos, y tampoco parece que mucha gente lo use.
    • Parece que fue posible porque lo abordaron desde la perspectiva de aarch64 en dispositivos Apple
      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