¿Por qué hay un gran impacto en el rendimiento cuando se repite una matriz con 240 o más elementos?

230

Al ejecutar un bucle de suma sobre una matriz en Rust, noté una gran caída de rendimiento cuando CAPACITY> = 240. CAPACITY= 239 es aproximadamente 80 veces más rápido.

¿Existe una optimización de compilación especial que Rust está haciendo para los arreglos "cortos"?

Compilado con rustc -C opt-level=3.

use std::time::Instant;

const CAPACITY: usize = 240;
const IN_LOOPS: usize = 500000;

fn main() {
    let mut arr = [0; CAPACITY];
    for i in 0..CAPACITY {
        arr[i] = i;
    }
    let mut sum = 0;
    let now = Instant::now();
    for _ in 0..IN_LOOPS {
        let mut s = 0;
        for i in 0..arr.len() {
            s += arr[i];
        }
        sum += s;
    }
    println!("sum:{} time:{:?}", sum, now.elapsed());
}
Guy Korland
fuente
44
¿Tal vez con 240 está desbordando una línea de caché de CPU? Si ese es el caso, sus resultados serían muy específicos de la CPU.
rodrigo
11
Reproducido aquí . Ahora supongo que tiene algo que ver con el desenrollado del bucle.
rodrigo

Respuestas:

355

Resumen : debajo de 240, LLVM desenrolla completamente el bucle interno y eso le permite notar que puede optimizar el bucle de repetición, rompiendo su punto de referencia.



Encontraste un umbral mágico por encima del cual LLVM deja de realizar ciertas optimizaciones . El umbral es de 8 bytes * 240 = 1920 bytes (su matriz es una matriz de usizes, por lo tanto, la longitud se multiplica por 8 bytes, suponiendo x86-64 CPU). En este punto de referencia, una optimización específica, solo realizada para la longitud 239, es responsable de la gran diferencia de velocidad. Pero comencemos lentamente:

(Todo el código en esta respuesta está compilado -C opt-level=3)

pub fn foo() -> usize {
    let arr = [0; 240];
    let mut s = 0;
    for i in 0..arr.len() {
        s += arr[i];
    }
    s
}

Este código simple producirá aproximadamente el ensamblado que uno esperaría: un bucle que suma elementos. Sin embargo, si cambia 240a 239, el conjunto emitido difiere bastante. Véalo en Godbolt Compiler Explorer . Aquí hay una pequeña parte de la asamblea:

movdqa  xmm1, xmmword ptr [rsp + 32]
movdqa  xmm0, xmmword ptr [rsp + 48]
paddq   xmm1, xmmword ptr [rsp]
paddq   xmm0, xmmword ptr [rsp + 16]
paddq   xmm1, xmmword ptr [rsp + 64]
; more stuff omitted here ...
paddq   xmm0, xmmword ptr [rsp + 1840]
paddq   xmm1, xmmword ptr [rsp + 1856]
paddq   xmm0, xmmword ptr [rsp + 1872]
paddq   xmm0, xmm1
pshufd  xmm1, xmm0, 78
paddq   xmm1, xmm0

Esto es lo que se denomina desenrollado de bucle : LLVM pega el cuerpo del bucle un montón de tiempo para evitar tener que ejecutar todas esas "instrucciones de gestión de bucle", es decir, incrementar la variable del bucle, verificar si el bucle ha finalizado y saltar al inicio del bucle. .

En caso de que se pregunte: las paddqinstrucciones y otras similares son instrucciones SIMD que permiten sumar múltiples valores en paralelo. Además, dos registros SIMD de 16 bytes ( xmm0y xmm1) se usan en paralelo para que el paralelismo a nivel de instrucción de la CPU básicamente pueda ejecutar dos de estas instrucciones al mismo tiempo. Después de todo, son independientes entre sí. Al final, ambos registros se suman y luego se suman horizontalmente al resultado escalar.

Las CPU x86 mainstream modernas (no Atom de baja potencia) realmente pueden hacer 2 cargas de vectores por reloj cuando llegan a la caché L1d, y el paddqrendimiento también es de al menos 2 por reloj, con 1 ciclo de latencia en la mayoría de las CPU. Consulte https://agner.org/optimize/ y también estas preguntas y respuestas sobre los múltiples acumuladores para ocultar la latencia (de FP FMA para un producto de punto) y el cuello de botella en el rendimiento.

LLVM desenrolla algunos bucles pequeños cuando no se desenrolla por completo y aún utiliza múltiples acumuladores. Por lo tanto, los cuellos de botella de latencia y ancho de banda de front-end no son un gran problema para los bucles generados por LLVM, incluso sin un desenrollado completo.


¡Pero el desenrollado de bucle no es responsable de una diferencia de rendimiento del factor 80! Al menos no se desenrolla en bucle solo. Echemos un vistazo al código de evaluación comparativa real, que coloca un bucle dentro de otro:

const CAPACITY: usize = 239;
const IN_LOOPS: usize = 500000;

pub fn foo() -> usize {
    let mut arr = [0; CAPACITY];
    for i in 0..CAPACITY {
        arr[i] = i;
    }

    let mut sum = 0;
    for _ in 0..IN_LOOPS {
        let mut s = 0;
        for i in 0..arr.len() {
            s += arr[i];
        }
        sum += s;
    }

    sum
}

( En Godbolt Compiler Explorer )

El ensamblaje para CAPACITY = 240parece normal: dos bucles anidados. (Al comienzo de la función hay bastante código solo para inicializar, que ignoraremos). Sin embargo, para 239, ¡se ve muy diferente! Vemos que el ciclo de inicialización y el ciclo interno se desenrollaron: hasta ahora tan esperado.

¡La diferencia importante es que para 239, LLVM pudo descubrir que el resultado del bucle interno no depende del bucle externo! Como consecuencia, LLVM emite un código que básicamente solo ejecuta primero el bucle interno (calculando la suma) y luego simula el bucle externo sumando sumvarias veces.

Primero vemos casi el mismo ensamblaje que el anterior (el ensamblaje representa el bucle interno). Luego vemos esto (comenté para explicar la asamblea; los comentarios con *son especialmente importantes):

        ; at the start of the function, `rbx` was set to 0

        movq    rax, xmm1     ; result of SIMD summing up stored in `rax`
        add     rax, 711      ; add up missing terms from loop unrolling
        mov     ecx, 500000   ; * init loop variable outer loop
.LBB0_1:
        add     rbx, rax      ; * rbx += rax
        add     rcx, -1       ; * decrement loop variable
        jne     .LBB0_1       ; * if loop variable != 0 jump to LBB0_1
        mov     rax, rbx      ; move rbx (the sum) back to rax
        ; two unimportant instructions omitted
        ret                   ; the return value is stored in `rax`

Como puede ver aquí, se toma el resultado del bucle interno, se suma tan a menudo como el bucle externo habría corrido y luego regresado. LLVM solo puede realizar esta optimización porque entiende que el bucle interno es independiente del externo.

Esto significa que el tiempo de ejecución cambia de CAPACITY * IN_LOOPSaCAPACITY + IN_LOOPS . Y esto es responsable de la gran diferencia de rendimiento.


Una nota adicional: ¿puedes hacer algo al respecto? Realmente no. LLVM tiene que tener umbrales mágicos, ya que sin ellos, las optimizaciones de LLVM podrían tardar una eternidad en completarse en cierto código. Pero también podemos estar de acuerdo en que este código fue altamente artificial. En la práctica, dudo que ocurra una diferencia tan grande. La diferencia debido al desenrollamiento de bucle completo generalmente no es siquiera el factor 2 en estos casos. Así que no hay que preocuparse por los casos de uso real.

Como última nota sobre el código idiomático de Rust: arr.iter().sum()es una mejor manera de resumir todos los elementos de una matriz. Y cambiar esto en el segundo ejemplo no conduce a diferencias notables en el ensamblaje emitido. Debe usar versiones cortas e idiomáticas a menos que haya medido que perjudica el rendimiento.

Lukas Kalbertodt
fuente
2
@ lukas-kalbertodt gracias por la gran respuesta! ahora también entiendo por qué el código original que se actualizó sumdirectamente en un no local sse ejecutaba mucho más lento. for i in 0..arr.len() { sum += arr[i]; }
Guy Korland el
44
@LukasKalbertodt Algo más está sucediendo en LLVM al activar AVX2 no debería hacer una gran diferencia. Repro'd en óxido también
Mgetz
44
@Mgetz ¡Interesante! Pero no me parece demasiado loco hacer que ese umbral dependa de las instrucciones SIMD disponibles, ya que esto determina la cantidad de instrucciones en un bucle completamente desenrollado. Pero desafortunadamente, no puedo decir con certeza. Sería bueno tener un desarrollador LLVM respondiendo esto.
Lukas Kalbertodt
77
¿Por qué el compilador o LLVM no se dan cuenta de que todo el cálculo se puede hacer en tiempo de compilación? Hubiera esperado tener el resultado del bucle codificado. ¿O es el uso de Instantprevenir eso?
Nombre poco creativo
44
@JosephGarvin: Supongo que se debe a que el desenrollado completo permite que el pase de optimización posterior lo vea. Recuerde que los compiladores optimizadores aún se preocupan por compilar rápidamente, así como por hacer un asm eficiente, por lo que tienen que limitar la complejidad del peor caso de cualquier análisis que hagan para que no tome horas / días compilar un código fuente desagradable con bucles complicados . Pero sí, esta es obviamente una optimización perdida para el tamaño> = 240. Me pregunto si no optimizar los bucles dentro de los bucles es intencional para evitar romper puntos de referencia simples. Probablemente no, pero tal vez.
Peter Cordes
30

Además de la respuesta de Lukas, si desea utilizar un iterador, intente esto:

const CAPACITY: usize = 240;
const IN_LOOPS: usize = 500000;

pub fn bar() -> usize {
    (0..CAPACITY).sum::<usize>() * IN_LOOPS
}

Gracias @ Chris Morgan por la sugerencia sobre el patrón de rango.

El montaje optimizado es bastante bueno:

example::bar:
        movabs  rax, 14340000000
        ret
mja
fuente
3
O mejor aún, lo (0..CAPACITY).sum::<usize>() * IN_LOOPSque produce el mismo resultado.
Chris Morgan
11
En realidad, explicaría que el ensamblado no está haciendo el cálculo, pero LLVM ha calculado previamente la respuesta en este caso.
Josep
Estoy un poco sorprendido de que rustchaya perdido la oportunidad de hacer esta reducción de fuerza. Sin embargo, en este contexto específico, esto parece ser un ciclo de tiempo, y deliberadamente desea que no se optimice. El punto es repetir el cálculo ese número de veces desde cero y dividirlo por el número de repeticiones. En C, el idioma (no oficial) para eso es declarar el contador de bucle como volatile, por ejemplo, el contador BogoMIPS en el kernel de Linux. ¿Hay alguna manera de lograr esto en Rust? Puede haber, pero no lo sé. Llamar a un externo fnpodría ayudar.
Davislor
1
@Davislor: volatileobliga a que la memoria esté sincronizada. Aplicarlo al contador de bucle solo fuerza la recarga / almacenamiento real del valor del contador de bucle. No afecta directamente el cuerpo del bucle. Es por eso que una mejor manera de usarlo es normalmente asignar el resultado importante real volatile int sinko algo después del ciclo (si hay una dependencia transportada por el ciclo) o cada iteración, para permitir que el compilador optimice el contador del ciclo como quiera pero lo fuerce para materializar el resultado que desea en un registro para que pueda almacenarlo.
Peter Cordes
1
@Davislor: Creo que Rust tiene una sintaxis de asm en línea similar a GNU C. Puede usar asm en línea para obligar al compilador a materializar un valor en un registro sin obligarlo a almacenarlo. Usar eso en el resultado de cada iteración de bucle puede evitar que se optimice. (Pero también de auto-vectorización si no tienes cuidado). por ejemplo, "Escape" y el equivalente de "Clobber" en MSVC explica 2 macros (mientras pregunta cómo portarlas a MSVC, lo que no es realmente posible) y se vincula a la charla de Chandler Carruth donde muestra su uso.
Peter Cordes