Lors de l'exécution d'une boucle de somme sur un tableau dans Rust, j'ai remarqué une énorme baisse de performances lorsque CAPACITY
> = 240.CAPACITY
= 239 est environ 80 fois plus rapide.
Existe-t-il une optimisation de compilation spéciale que 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());
}
arrays
performance
rust
llvm-codegen
Guy Korland
la source
la source
Réponses:
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
)Ce code simple produira à peu près l'assemblage auquel on s'attendrait: une boucle additionnant des éléments. Cependant, si vous passez
240
à239
, l'assemblage émis diffère beaucoup. Voyez-le sur Godbolt Compiler Explorer . Voici une petite partie de l'assemblage:C'est ce qu'on appelle le déroulement de 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 passer au début de la boucle .
Au cas où vous vous poseriez la question: les
paddq
instructions similaires sont des instructions SIMD qui permettent de résumer plusieurs valeurs en parallèle. De plus, deux registres SIMD de 16 octets (xmm0
etxmm1
) 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
paddq
débit 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 également ce Q&A sur plusieurs accumulateurs pour masquer la latence (de FP FMA pour un produit scalaire) et le goulot d'étranglement sur le débit à la place.LLVM petites boucles ne se déroule un peu quand il est pas tout à fait dérouler, et utilise encore 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 boucler le déroulement seul. Jetons un coup d'œil au code de référence réel, qui place la boucle dans une autre:
( Sur Godbolt Compiler Explorer )
L'assemblage pour
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 intérieure ne dépend pas de la boucle extérieure! En conséquence, LLVM émet du code qui, en gros, n'exécute d'abord que la boucle interne (calcul de la somme), puis simule la boucle externe en additionnant plusieurs
sum
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'assemblage; les commentaires avec
*
sont particulièrement importants):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 l'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 terminer sur certains codes. Mais nous pouvons également convenir que ce code était hautement artificiel. En 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.
Une dernière remarque sur le code Rust idiomatique:
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 conduit pas à des différences notables dans l'assemblage émis. Vous devez utiliser des versions courtes et idiomatiques, sauf si vous avez mesuré que cela nuit aux performances.la source
sum
directement sur un locals
ne fonctionnait pas beaucoup plus lentement.for i in 0..arr.len() { sum += arr[i]; }
Instant
empêcher cela?En plus de la réponse de Lukas, si vous souhaitez utiliser un itérateur, essayez ceci:
Merci @Chris Morgan pour la suggestion sur le modèle de plage.
L' assemblage optimisé est assez bon:
la source
(0..CAPACITY).sum::<usize>() * IN_LOOPS
ce qui donne le même résultat.rustc
manquer l'occasion de faire cette réduction de force. Dans ce contexte spécifique, cependant, cela semble être une boucle de synchronisation, et vous voulez délibérément qu'elle ne soit pas optimisée. Le tout est de répéter le calcul ce nombre de fois à partir de zéro et de le diviser par le nombre de répétitions. En C, l'idiome (non officiel) pour cela est de déclarer le compteur de boucle commevolatile
, par exemple le compteur BogoMIPS dans le noyau Linux. Existe-t-il un moyen d'y parvenir à Rust? Il y en a peut-être, mais je ne le sais pas. Appeler un externefn
peut aider.volatile
force cette mémoire à être synchronisée. Son application au compteur de boucle force uniquement le rechargement / stockage réel de la valeur du compteur de boucle. Cela n'affecte pas directement le corps de la boucle. C'est pourquoi une meilleure façon de l'utiliser est normalement d'attribuer le résultat important réel àvolatile int sink
quelque chose après la boucle (s'il y a une dépendance portée par la boucle) ou à chaque itération, pour permettre au compilateur d'optimiser le compteur de boucle comme il le souhaite mais le forcer pour matérialiser le résultat souhaité dans un registre afin qu'il puisse le stocker.