- Una optimización que intentaba reducir una bifurcación en un bucle en ensamblador AArch64 terminó siendo 4 veces más lenta, y la causa fue confundir al predictor de bifurcaciones al usar de forma asimétrica el par llamada-retorno
bl/ret
- El código original bifurca dos veces por bucle, con
bl foo y b loop, pero el código modificado intentaba reducir la cantidad de bifurcaciones haciendo un único bl loop para fijar x30 y luego haciendo que varios ret regresaran a la misma dirección
- En un M1 Max, al sumar un arreglo de 1024
float, el código normal con bl/ret tardó 969 ns, pero la estructura con un solo bl y varios ret bajó a 3.85 µs; al cambiar ret por br x30, se recuperó a 913 ns
- Los contadores de rendimiento de Instruments mostraron que, al sumar 1,000 millones de elementos, la estructura asimétrica
bl/ret tuvo alrededor de 93% de fallos de predicción en bifurcaciones de retorno, y también aumentaron mucho FETCH_RESTART, MAP_DISPATCH_BUBBLE y MAP_REWIND
- Las implementaciones más rápidas pasan por inlinear
foo, usar iter().sum() en Rust, y NEON SIMD con desenrollado manual del bucle; la versión SIMD final llegó a 94 ns, pero como cambia el orden de las sumas de punto flotante, el resultado puede diferir
Un efecto contraproducente en un bucle AArch64
- La función de ejemplo recorre un arreglo de
float, pasa cada valor a foo, y foo actualiza el acumulador g
- Una traducción simple a AArch64 tiene el siguiente flujo
- Al inicio del bucle verifica si
n == 0
- Lee el valor con
ldr s1, [x0], #4
- Llama a la subrutina con
bl foo
- El
ret de foo vuelve a la instrucción siguiente a bl
- Regresa al inicio del bucle con
b loop
foo tiene una forma cercana a una naked function, usando el mismo stack frame y los mismos registros que la función padre; lee s1 y acumula en s0
El rol de bl y ret
bl es la instrucción branch and link: salta a la etiqueta indicada y guarda la dirección de la siguiente instrucción en el registro de enlace lr o x30
ret salta a la dirección contenida en el registro de enlace
- En la estructura original,
bl foo y ret están emparejados, y ret siempre vuelve a la instrucción siguiente a ese bl
La optimización fallida para “reducir una bifurcación”
- La estructura modificada intenta reducir una bifurcación dentro del bucle sin cambiar
foo
- Al inicio de la función llama a
bl loop, guardando en x30 la dirección de inicio de loop
- Tras revisar la condición de salida del bucle, cae al código de
foo sin una bifurcación adicional
- El
ret de foo vuelve a loop, que está en x30
- En esta estructura,
x30 no cambia dentro del cuerpo del bucle, por lo que los ret repetidos siempre regresan a la misma dirección
- El
foo simple es una línea que suma un float, así:
foo:
fadd s0, s0, s1
ret
- En este caso, la función completa calcula la suma del arreglo de entrada de
float
Resultados de benchmark y problema de predicción de bifurcaciones
- Con
criterion se hizo un benchmark en una CPU M1 Max con un arreglo de 1024 elementos
bl/ret normal: 969 ns
- Un solo
bl, varios ret: 3.85 µs
- El código que reduce una bifurcación es alrededor de 4 veces más lento que el código original que usa dos bifurcaciones
- Cliff y Dan consideraron que, al no coincidir el par
bl/ret, se confundió el predictor de bifurcaciones
- Según la documentación de ARM,
RET permite que el procesador reconozca un retorno de función y haga una predicción de bifurcaciones más precisa
BR LR puede hacer funcionalmente lo mismo
- Pero
RET es una instrucción separada que el procesador puede reconocer como retorno de función
- Si la predicción de bifurcación acierta, entran al pipeline las instrucciones correctas y se evita esperar instrucciones desde memoria
Pila de direcciones de retorno y experimento con br x30
- Es probable que el predictor de bifurcaciones mantenga internamente una pila de direcciones de retorno de funciones
- Al ejecutar
bl, hace push de la dirección de retorno en la pila
- Al ver
ret, asume que volverá a la dirección de retorno del bl más reciente
- Con base en esa dirección, empieza la precarga y la ejecución especulativa, y luego hace pop de la pila
- Este enfoque funciona bien cuando
bl/ret son pares coincidentes
- Si varios
ret reutilizan repetidamente la misma dirección, la predicción falla y puede haber precarga inútil, ejecución especulativa incorrecta, y detenciones o flushes del pipeline
- Como sugirió Dan, al cambiar
ret por br x30, la pérdida de rendimiento desapareció
bl/ret normal: 969 ns
- Un solo
bl, varios ret: 3.85 µs
- Un solo
bl, varios br x30: 913 ns
- La versión con
br x30 es un poco más rápida que el código original porque ejecuta solo una bifurcación por iteración del bucle
Contadores de rendimiento de Instruments
- Se revisaron con Instruments los contadores de rendimiento de los primeros dos programas
- La medición se hizo mientras se sumaba un arreglo de 1,000 millones de elementos
- En el
bl/ret asimétrico hubo alrededor de 93% de fallos de predicción de bifurcaciones de retorno
| Contador |
bl/ret normal |
Un solo bl, varios ret |
BRANCH_RET_INDIR_MISPRED_NONSPECIFIC |
92 |
928,644,975 |
FETCH_RESTART |
61,121 |
987,765,276 |
MAP_DISPATCH_BUBBLE |
1,155,632 |
7,350,085,139 |
MAP_REWIND |
6,412,734 |
2,789,499,545 |
- Apple no documenta por completo estos contadores
- Se presume que los otros contadores son efectos downstream de una mala predicción de bifurcaciones
FETCH_RESTART: posiblemente una precarga incorrecta
MAP_DISPATCH_BUBBLE: posiblemente relacionado con detenciones del pipeline
MAP_REWIND: posiblemente ejecución especulativa incorrecta que debe deshacerse
Cómo hacerlo más rápido
- El ejemplo es código educativo, y que
foo sea una subrutina responde más a una estructura explicativa que a “código lo más rápido posible”
- Si el contenido de
foo se conoce en tiempo de build y es más corto que la distancia máxima de salto, se pueden eliminar por completo bl y ret e inlinearlo
- Pasa de 969 ns a 911 ns, aproximadamente 6% más rápido
- Si en Rust se usa simplemente
f.iter().sum(), baja a 833 ns
pub fn sum_slice(f: &[f32]) -> f32 {
f.iter().sum()
}
SIMD y desenrollado manual del bucle
- La implementación manual AArch64 SIMD consta de tres bucles
loop: suma valores individuales en s0 hasta que la cantidad restante sea múltiplo de 4
simd: suma 4 valores a la vez en el registro vectorial v1, y repite hasta que la cantidad restante sea múltiplo de 8
simd2: desenrolla simd al doble para procesar 8 valores por iteración y sumar en v1 y v2
- Al terminar la función, acumula en
s0 los valores de v1 y v2, y los devuelve
- El type punning trata
x0, que es float*, como si fuera double*, para leer 128 bits, es decir 4 float, en d3 y d4
- Con
mov v3.d[1], v4.d[0] mueve los 64 bits de d4 a los 64 bits superiores de v3
- En
fadd v1.4s, v1.4s, v3.4s, el sufijo .4s hace que se trate como cuatro float
- Esta implementación SIMD se ejecuta en 94 ns, alrededor de 8.8 veces más rápido que la mejor versión anterior en Rust, de 833 ns
Resumen completo de rendimiento y advertencia
| Implementación |
Tiempo |
bl/ret normal |
969 ns |
Un solo bl, varios ret |
3.85 µs |
Un solo bl, varios br x30 |
913 ns |
Bucle común con b |
911 ns |
| Reescrito en Rust |
833 ns |
| SIMD + desenrollado manual del bucle |
94 ns |
- El código SIMD cambia el orden de las sumas de punto flotante
- La suma de punto flotante no es asociativa, por lo que la versión SIMD podría no producir el mismo resultado que el código lineal
- Es probable que por eso el compilador no generara instrucciones SIMD para la suma
- Todo el código está publicado en GitHub
- En una máquina ARM64, se puede reproducir el benchmark ejecutando
cargo bench
1 comentarios
Opiniones de Hacker News
El último código optimizado termina la suma de un arreglo de 1024 números de punto flotante de 32 bits en 94 ns.
Durante esos 94 ns, el viejo amigo 6502 de 1 MHz apenas estaría empezando a preguntarse si le manda una señal al chip de memoria para traer el primer byte de la primera instrucción del programa.
Eso sí, este código depende por completo de la premisa de que se ejecuta dentro de la caché. Si no, hasta el potente M1 Max mencionado en el artículo se habría quedado detenido esperando la primera lectura de memoria. La DRAM es lenta.
Raymond Chen trató casi lo mismo hace casi 20 años: https://devblogs.microsoft.com/oldnewthing/20041216-00/?p=36...
Salvo por una comprensión básica de las propiedades semiconductoras del silicio y el dopaje, aquí casi no hay nada intuitivo. Si no viste el diagrama del die, el cableado y las rutas, hay muy pocas razones para esperar que A sea más rápido que B a menos que los ingenieros y la hoja de datos lo digan explícitamente. Creo que esto aplica todavía más en ARM.
Lo que agrega este artículo es una corrección simple: cambiar
retpor otra instrucciónbr. Así el par vuelve a ser “simétrico” y se puede obtener código un poco más rápido sin romper el predictor de saltos.Por supuesto, cualquier cosa es posible, y un bucle común para sumar un arreglo en realidad le indica a la computadora que acumule los elementos uno por uno.
Pero, por ejemplo, difícilmente se puede decir que crear cuatro acumuladores en paralelo con SIMD y sumarlos al final sea más incorrecto que sumar los elementos uno por uno.
La suma de punto flotante básicamente debe considerarse como algo con un margen de error, y cualquier respuesta dentro de ese margen debería ser válida. Si se sabe algo especial sobre los números de punto flotante de entrada, el lenguaje debería dar una forma de expresar explícitamente esa intención. Dado que el bucle más básico es el valor predeterminado, creo que por defecto debería ofrecer el mejor rendimiento.
El método ingenuo de sumar uno por uno en un bucle es obvio, pero hay métodos más sofisticados que dan mejores límites para el error acumulado total, y la suma de Kahan es un ejemplo conocido: https://en.wikipedia.org/wiki/Kahan_summation_algorithm
Si son datos en streaming, quizá no quede más que sumarlos uno por uno, pero si se puede usar un búfer de tamaño fijo de N elementos, surgen problemas como qué subconjunto convertir en suma parcial cuando llega un número nuevo, cómo sumarlo al acumulado, y si esa elección tiene una mejora de error demostrable.
Por ejemplo, si
[1e50, -1e50, 1e3, 1e3]se calcula como(((1e50 + -1e50) + 1e3) + 1e3), da2e3, pero si se calcula como((1e50 + 1e3) + (-1e50 + 1e3)), da0.Algo parecido pasa al sumar muchos valores pequeños a un valor grande:
(((1e3 + 1e3) + 1e3) ... + 1e50)y(((1e50 + 1e3) + 1e3) ... + 1e3)son bastante distintos.Si los resultados aritméticos no son estables entre compilaciones, se vuelve una pesadilla total. No debería pasar que, al recompilar y ejecutar el software con la misma entrada, se obtenga un resultado distinto.
Hace mucho también me tocó un caso particular de Intel: la FPU usa internamente registros de 80 bits y memoria de 64 bits, así que si cambiaba el momento en que se llenaban o vaciaban los registros, también cambiaba el momento del redondeo y el resultado. Al iniciar el programa se puede configurar una bandera global de la FPU para forzar el redondeo en cada operación.
Siempre hay una respuesta correcta, y creo que el compilador no debería hacer, al menos por defecto, cambios incorrectos. Dicho eso, siempre es bienvenida una forma de que el programador exprese su intención con más claridad.
Aplicar SIMD a bucles de punto flotante pudo haber sido el valor predeterminado, pero como rompería mucho código existente y haría que la salida cambiara de forma no determinista con frecuencia, se convirtió en una función que el programador debe elegir explícitamente.
Además, muchos programadores podrían no saber esto, así que si
float Sum(float[] values)empieza a devolver un valor distinto, quizá no tengan forma de saber que la causa es la vectorización. Por eso, por ejemplo, la biblioteca estándar de .NET usa SIMD paraintegers.Sum(), pero no parafloats.Sum().Con solo leer la línea de “tras comprobar el final del bucle, cae directamente dentro de la función
foosin bifurcación”, pensé: “ah, ese es el problema”.Creí que iba a ser una discusión profunda sobre vistosas heurísticas del predictor de ramas, pero al final era una violación de una heurística básica.
No hay que pensar que se puede obtener una mejora enorme de velocidad usando instrucciones
call/retque no coinciden. Que el predictor de ramas mantenga una pila sombra de direcciones de retorno es algo que existe desde hace décadas.Por eso estos riesgos deberían estar documentados, pero los diseñadores deberían poder asumir que quien escribe assembly a mano leyó la documentación.
Por otro lado, Sophie Wilson escribió la implementación original de BBC BASIC para ARM, pero en ese entonces no había predictor de ramas. Aunque al ser de 32 bits las reglas eran distintas, me da curiosidad cómo AArch64 vuelve más lento el código cuando cambian las suposiciones arquitectónicas.
Es una referencia al clásico de SNL “Do not taunt happy fun ball”: https://www.youtube.com/watch?v=GmqeZl8OI2M
No hay que perder de vista que es un artículo de 2023. Ahora ya está algo viejo, y desde Rust 1.78 el compilador usa un desenrollado de bucles más agresivo y algo de SIMD: https://godbolt.org/z/zhbobW7rr
El artículo original decía “al mirar el assembly, veo que está desenrollando el bucle” y enlazaba https://godbolt.org/z/Kv77abW6c, pero estaba usando el cambiante “Rust Nightly”. Ahora hay más desenrollado de bucles.
El desenrollado de bucles empezó desde Rust 1.59: https://godbolt.org/z/5PTnWrWf7
Según el código en GitHub, estaba usando Rust
1.67.0-nightly, versión del 2022-11-27.Probé personalmente el benchmark en el mismo hardware con el desenrollado agresivo de bucles del Rust nightly 1.81 más reciente, pero no hubo diferencia; iba a la misma velocidad que hace 1,5 años.
Es un artículo de 2023. Discusión de ese momento: https://news.ycombinator.com/item?id=34520498
Está bien que se vuelva a publicar después de más o menos un año, y el enlace al hilo anterior es para lectores que quieran profundizar.
Como no estoy muy familiarizado con assembly ARM/ARM64, me confundió cómo se incrementa
x0.const float f = *data++;se convierte enldr s1, [x0], #4, y esta instrucción parece leer el valor y al mismo tiempo incrementarx0en 4.Parece que también se pueden usar valores negativos, así que debería permitir recorrer en sentido inverso. Bastante genial. En x86_64 no parece haber una sola instrucción que lea e incremente a la vez.
lodsystoshacen lectura/escritura e incremento al mismo tiempo sobrersiordi, respectivamente. También existemovs, que copia entre dos direcciones de memoria mientras incrementa.Normalmente se usa junto con
rep, que repitercxveces. Por ejemplo, unmemsetde 10 bytes puede hacerse comomov rcx, 10,mov rdi, dest,mov rax, 0,rep stosb.Con los sufijos
w,d,q, avanza 2, 4 y 8 bytes, respectivamente.El artículo estuvo bueno, pero fue una lástima que fuera alternando constantemente entre unidades de µs y ns, lo que dificultaba comparar al revisar las tablas.
Me sorprende que antes de optimizar el código no se hayan probado primero métodos menos ingeniosos.
Si se reescribe el assembly, solo se necesita una rama al final del bucle, y para
X1también se puede manejar con una sola operación ALU en vez de hacer por separado una resta para comparar y un decremento.Yendo más lejos, se podría simplemente inlinear
fooy omitir también la instrucciónRET, sin el truco deBL/RETque no coinciden. No hice el benchmark personalmente, así que no sé cuánto más rápido sería en la práctica.cbnzdebería sercbz. CBZ salta a la etiqueta si el registro es 0, y CBNZ salta si no es 0.