web-dev-qa-db-fra.com

Pourquoi y a-t-il un impact important sur les performances lors du bouclage sur un tableau avec 240 éléments ou plus?

Lors de l'exécution d'une boucle de somme sur un tableau dans Rust, j'ai remarqué une baisse considérable des performances lorsque CAPACITY> = 240. CAPACITY = 239 est environ 80 fois plus rapide.

Existe-t-il une optimisation de compilation spéciale Rust fait pour les tableaux "courts"?

Compilé avec 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());
}
225
Guy Korland

Résumé : en dessous de 240, LLVM déroule complètement la boucle intérieure et cela lui permet de remarquer qu'il peut optimiser la boucle de répétition, cassant votre référence.



Vous avez trouvé un seuil magique au-dessus duquel LLVM arrête d'effectuer certaines optimisations . Le seuil est de 8 octets * 240 = 1920 octets (votre tableau est un tableau de usize s, donc la longueur est multipliée par 8 octets, en supposant un processeur x86-64). Dans cette référence, une optimisation spécifique - effectuée uniquement pour la longueur 239 - est responsable de l'énorme différence de vitesse. Mais commençons lentement:

(Tout le code de cette réponse est compilé avec -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
}

Ce code simple produira à peu près l'assemblage auquel on s'attendrait: une boucle additionnant des éléments. Cependant, si vous changez 240 En 239, L'assemblage émis diffère beaucoup. Voir sur Godbolt Compiler Explorer . Voici une petite partie de l'Assemblée:

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

C'est ce qu'on appelle le déroulement de la boucle: LLVM colle le corps de la boucle beaucoup de temps pour éviter d'avoir à exécuter toutes ces "instructions de gestion de boucle", c'est-à-dire incrémenter la variable de boucle, vérifier si la boucle est terminée et le saut au début de la boucle.

Au cas où vous vous poseriez la question: les instructions paddq et similaires sont des instructions SIMD qui permettent de résumer plusieurs valeurs en parallèle. De plus, deux registres SIMD de 16 octets (xmm0 Et xmm1) Sont utilisés en parallèle afin que le parallélisme au niveau des instructions du CPU puisse essentiellement exécuter deux de ces instructions en même temps. Après tout, ils sont indépendants les uns des autres. Au final, les deux registres sont additionnés puis additionnés horizontalement au résultat scalaire.

Les processeurs x86 grand public modernes (pas Atom de faible puissance) peuvent vraiment faire 2 charges vectorielles par horloge lorsqu'ils atteignent le cache L1d, et le débit paddq est également d'au moins 2 par horloge, avec une latence de 1 cycle sur la plupart des CPU. Voir https://agner.org/optimize/ et aussi ce Q&A sur plusieurs accumulateurs pour masquer la latence (de FP FMA pour un produit scalaire) et goulot d'étranglement sur le débit à la place.

LLVM ne déroule pas de petites boucles certains quand ce n'est pas complètement déroulant, et utilise toujours plusieurs accumulateurs. Donc, généralement, la bande passante frontale et les goulots d'étranglement de latence back-end ne sont pas un problème énorme pour les boucles générées par LLVM même sans déroulement complet.


Mais le déroulement de boucle n'est pas responsable d'une différence de performance de facteur 80! Du moins pas le déroulement de boucle seul. Jetons un coup d'œil au code de référence réel, qui place la boucle dans une autre:

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
}

( Sur Godbolt Compiler Explorer )

L'assemblage de CAPACITY = 240 Semble normal: deux boucles imbriquées. (Au début de la fonction, il y a pas mal de code juste pour l'initialisation, que nous ignorerons.) Pour 239, cependant, cela semble très différent! On voit que la boucle d'initialisation et la boucle interne se sont déroulées: jusqu'ici si attendues.

La différence importante est que pour 239, LLVM a pu comprendre que le résultat de la boucle interne ne dépend pas de la boucle externe! En conséquence , LLVM émet du code qui n'exécute en premier lieu que la boucle interne (calcul de la somme), puis simule la boucle externe en additionnant sum plusieurs fois!

D'abord, nous voyons presque le même assemblage que ci-dessus (l'assemblage représentant la boucle intérieure). Ensuite, nous voyons cela (j'ai commenté pour expliquer l'Assemblée; les commentaires avec * Sont particulièrement importants):

        ; 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`

Comme vous pouvez le voir ici, le résultat de la boucle interne est pris, additionné aussi souvent que la boucle externe aurait été exécutée puis retournée. LLVM ne peut effectuer cette optimisation que parce qu'il a compris que la boucle interne est indépendante de la boucle externe.

Cela signifie que le temps d'exécution passe de CAPACITY * IN_LOOPS À CAPACITY + IN_LOOPS. Et cela est responsable de l'énorme différence de performances.


Une note supplémentaire: pouvez-vous faire quelque chose à ce sujet? Pas vraiment. LLVM doit avoir de tels seuils magiques car sans eux, les optimisations LLVM pourraient prendre une éternité pour se terminer sur un certain code. Mais nous pouvons également convenir que ce code était hautement artificiel. Dans la pratique, je doute qu'une telle différence se produise. La différence due au déroulement complet de la boucle n'est généralement même pas le facteur 2 dans ces cas. Vous n'avez donc pas à vous soucier des cas d'utilisation réels.

Comme dernière remarque sur l'idiomatique Rust code: arr.iter().sum() est une meilleure façon de résumer tous les éléments d'un tableau. Et changer cela dans le deuxième exemple ne mène à différences notables dans l'assemblage émis. Vous devez utiliser des versions courtes et idiomatiques à moins que vous ne mesuriez que cela nuit aux performances.

349
Lukas Kalbertodt

En plus de la réponse de Lukas, si vous souhaitez utiliser un itérateur, essayez ceci:

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

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

Merci @Chris Morgan pour la suggestion sur le modèle de plage.

Assembly optimisé est assez bon:

example::bar:
        movabs  rax, 14340000000
        ret
29
mja