Pouvez-vous faire confiance au code optimisé par le compilateur ?

Annuaire d'articles

Introduction

Deuxièmement, regardez le problème du point de vue du compilateur

3. Rapprochez le code du compilateur

4. Impossibilité et possibilité

5. SIMD

Introduction

Je me demande si vous connaissez le flux de données multiples à flux d'instruction unique, ce que nous entendons souvent à propos du SIMD (données multiples à instruction unique) ? Il s'agit d'une technologie qui utilise un seul contrôleur pour contrôler plusieurs processeurs et effectue la même opération sur chacune d'elles d'un ensemble de données en même temps, réalisant ainsi un parallélisme spatial. En ce qui concerne le principe de fonctionnement du SIMD, nous pouvons le comprendre à trois niveaux :

  • Le compilateur est suffisamment intelligent pour vectoriser automatiquement tout le code.
  • La faible capacité du compilateur à vectoriser automatiquement est sensible à des modifications de code sans rapport, les développeurs doivent donc écrire manuellement des instructions SIMD explicites.
  • L'écriture manuscrite SIMD doit être répétée pour chaque architecture de processeur différente. À l’heure actuelle, le compilateur en tant qu’outil peut écrire du code vectoriel fiable sous une forme adaptée à la vectorisation grâce à de meilleures instructions et contraintes.

Dans notre travail quotidien, nos scénarios de développement se situent généralement aux deuxième et troisième niveaux, nécessitant un compilateur pour optimiser le modèle. Ci-dessous, je discute avec vous d'un cadre général pour l'optimisation du compilateur dans des langages statiques tels que Rust ou C++, et de la manière dont ce cadre peut être appliqué à la vectorisation automatique.

Deuxièmement, regardez le problème du point de vue du compilateur

Tout d’abord, comprenons comment le compilateur considère le code. Au vu des nombreuses similitudes structurelles, on peut se référer à la spécification WebAssembly (https://webassembly.github.io/spec/core/) pour comprendre la spécification de base du compilateur en termes d'optimisation. Une unité d'optimisation n'est souvent qu'une fonction, comme le montre l'extrait de code simple ci-dessous :

fn sum(xs: &[i32]) -> i32 {
let mut total = 0;
for i in 0..xs.len() {
total = total.wrapping_add(xs[i]);
}
total
}
在一些伪IR(指令寄存器)中,上述代码表现为:
fn sum return i32 {
param xs_ptr: ptr
param xs_len: size
local total: i32
local i: size = 0
local x: i32
local total: i32 = 0
loop:
branch_if i >= xs_len :ret
load x base=xs_ptr offset=i
add total x
add i 1
goto :loop
ret:
return total
}

Deux entités caractéristiques importantes apparaissent ici :

  • Comme la mémoire du programme est un tableau d’octets approximatif, les compilateurs ne peuvent souvent pas très bien déduire ce qu’il y a en mémoire. Et comme ils sont partagés par toutes les fonctions, différentes fonctions peuvent interpréter différemment le contenu de la mémoire.
  • En tant que variables locales sous forme d'entiers, elles obéissent à des propriétés mathématiques raisonnables pour le compilateur.

Par exemple, si le compilateur voit une boucle comme celle-ci :

param n: u32
local i: u32 = 0
local total: u32
local tmp
loop:
branch_if i >= n :ret
set tmp i
mul tmp 4
add t tmp
goto :loop
ret:
return total

Il peut en déduire que dans chaque boucle, tmp peut contenir i * 4, puis l'optimiser pour :

param n: u32
local i: u32 = 0
local total: u32
local tmp = 0
loop:
branch_if i >= n :ret
add t tmp
add tmp 4  # replace multiplication with addition
goto :loop
ret:
return total

Cependant, si l’on fait le même calcul, mais avec tous les nombres en mémoire, il sera difficile pour le compilateur de déduire l’exactitude de la conversion. Que se passe-t-il si le magasin pour n et total se chevauchent réellement et que tmp chevauche certaines données qui ne figurent pas dans la fonction actuelle ?

En fait, les instructions de chargement et de stockage peuvent être utilisées comme pont entre les variables mathématiques locales et les octets de mémoire. Autrement dit, l'instruction de chargement prend une séquence d'octets en mémoire, interprète les octets comme un entier et stocke cet entier dans une variable locale. L’exécution de l’instruction store est exactement le contraire. En chargeant quelque chose de la mémoire vers le local, le compilateur acquiert la capacité de raisonner avec précision. Ainsi, le compilateur n'a pas besoin de suivre le contenu de base de la mémoire, il doit simplement vérifier qu'à un moment donné, la charge depuis la mémoire est correcte. Comme vous pouvez le voir, le compilateur ne peut réellement déduire qu'une seule fonction à la fois, et uniquement des variables locales au sein de cette fonction.

3. Rapprochez le code du compilateur

En fournissant plus de contexte au compilateur, nous pouvons réaliser deux tâches d'optimisation principales :

La première optimisation de base est l'inline. Il utilise l'appelé pour remplacer un appel spécifique. Selon cela, les variables locales de l'appelant et de l'appelé se trouvent dans le même cadre et le compilateur peut les optimiser ensemble.

Regardons un morceau de code Rust :

fn sum(xs: &[i32]) -> i32 {

let mut total = 0;
for i in 0..xs.len() {
total = total.wrapping_add(xs[i]);
}
total
}

L'expression xs[i] est en fait un appel de fonction. Les fonctions d'indexation effectuent une vérification des limites avant d'accéder aux éléments du tableau. Après l'avoir intégré dans sum, le compilateur peut voir son code et l'éliminer. Après tout, après l'inline, les fonctions ont tendance à gérer le cas général et à utiliser suffisamment de contraintes sur un site d'appel spécifique pour éliminer divers cas extrêmes.

La deuxième optimisation de base est le remplacement scalaire des agrégats (SROA). Nous utilisons load pour éviter de raisonner sur la mémoire et raisonner plutôt sur les locaux.

Par exemple, considérons une fonction comme celle-ci :

fn permute(xs: &mut Vec<i32>) {
...
}

Le compilateur reçoit un pointeur vers de la mémoire. Cette mémoire contenant une structure complexe (ie : ptr, len, capacité triple), il est difficile de raisonner sur l'évolution de cette structure. Pour cela, le compilateur peut charger la structure depuis la mémoire et utiliser un ensemble de variables locales scalaires pour effectuer l'agrégation de substitution :

fn permute(xs: &mut Vec<i32>) {
local ptr: ptr
local len: usize
local cap: usize
load ptr xs.ptr
load len xs.len
load cap xs.cap
...
store xs.ptr ptr
store xs.len len
store xs.cap cap
}

De cette façon, le compilateur retrouve sa capacité de raisonnement. Bien que similaire à l'inline, SROA est principalement destiné à la mémoire, pas au code.

4. Impossibilité et possibilité

Les principaux avantages de l’utilisation du modèle de compilateur sont :

  • Optimiser par fonction
  • Des appels de fonctions en ligne peuvent être effectués
  • Bon pour découvrir les relations entre les variables locales et réorganiser le code en conséquence
  • Capacité à effectuer un raisonnement limité sur la mémoire (c'est-à-dire décider quand un chargement ou un stockage est approprié).

Bien entendu, nous devons décrire quel code peut être optimisé de manière fiable et quel code ne peut pas être optimisé pour tenir compte des abstractions à coût nul. Pour que l'inline soit activée, le compilateur doit savoir quelle fonction a été réellement appelée. S'il s'agit d'un appel direct, le compilateur essaiera de l'inline ; s'il s'agit d'un appel indirect (c'est-à-dire : appel via un pointeur de fonction ou via une table de fonctions virtuelle), alors dans des circonstances normales, le compilateur ne pourra pas l'inline avec ça. Pour les appels indirects, le compilateur peut aussi parfois déduire la valeur du pointeur et dévirtualiser l'appel. Cependant, cela repose souvent sur des optimisations réussies réalisées ailleurs.

C'est pourquoi dans Rust, chaque fonction a un type unique de taille nulle (taille zéro), et il n'y a pas de représentation d'exécution (runtime). Son approche statique garantit que le compilateur peut toujours s'intégrer dans le code afin que le coût de l'abstraction soit nul, après tout, tout compilateur d'optimisation le "fondra" à zéro.

Bien entendu, les langages de niveau supérieur peuvent choisir de toujours utiliser des pointeurs de fonction pour représenter les fonctions. En fait, dans de nombreux cas, le code qu’ils génèrent est tout aussi optimisable. Cependant, rien n'indique dans le code source qu'il s'agit d'un cas optimisable (le pointeur réel est connu au moment de la compilation) ou d'une véritable situation d'appel dynamique. Ainsi, l'utilisation de Rust garantit que la distinction entre optimisable et potentiellement optimisable se reflète dans le langage source, voir l'extrait de code suivant :

// Compiler is guaranteed to be able to inline call to `f`.
fn call1<F: Fn()>(f: F) {
f()
}
// Compiler _might_ be able to inline call to `f`.
fn call2(f: fn()) {
f()
}

Comme vous pouvez le voir dans le code ci-dessus, sa première règle est de rendre la plupart des appels résolubles de manière statique pour permettre l'inline. Les pointeurs de fonction et la répartition dynamique empêchent l'inline. De plus, l'adressage indirect en mémoire entraînera également les problèmes suivants pour le compilateur :

struct Foo {
bar: Bar,
baz: Baz,
}

Évidemment, la structure Foo ci-dessus est complètement transparente pour le compilateur. Cependant, modifions-le légèrement comme suit :

struct Foo {
bar: Box<Bar>,
baz: Baz,
}

Les résultats ci-dessus sont moins clairs. Autrement dit, la mémoire occupée par Foo n'est généralement pas transférée vers la mémoire occupée par Bar. De plus, dans de nombreux cas, le compilateur peut raisonner sur "non garanti" par boîte étant donné l'unicité.

L'extrait de code suivant montre comment une carte est signée et définie.

#[inline]
fn map<B, F>(self, f: F) -> Map<Self, F>
where
Self: Sized,
F: FnMut(Self::Item) -> B,
{
Map::new(self, f)
}

Un autre point important concernant la mémoire est que, en général, le compilateur ne peut pas modifier la disposition globale. SROA peut charger certaines structures de données dans un ensemble de variables locales, puis remplacer la représentation d'un "pointeur et d'un index" par une "paire de pointeurs". En fin de compte, cependant, SROA devra matérialiser « un pointeur et un index » et stocker cette représentation en mémoire. En effet, la disposition de la mémoire est partagée entre toutes les fonctions, de sorte que les fonctions ne peuvent pas spécifier unilatéralement une représentation plus optimale.

En résumé, les compilateurs peuvent mieux raisonner sur le code simplement en étant capables de le « voir ». Par conséquent, assurez-vous que la plupart des appels peuvent être intégrés au moment de la compilation.

5. SIMD

Appliquons le cadre général évoqué précédemment pour fournir aux compilateurs un code optimisable pour la vectorisation automatique. Vous trouverez ci-dessous notre fonction optimisée pour calculer le préfixe commun le plus long entre deux tranches d'octets.

use std::iter::zip;
// 650 milliseconds
fn common_prefix(xs: &[u8], ys: &[u8]) -> usize {
let mut result = 0;
for (x, y) in zip(xs, ys) {
if x != y { break; }
result += 1
}
result
}

Si vous disposez déjà d'un modèle auto-vectorisé ou si vous avez examiné le résultat de l'assembly, vous constaterez que les fonctions qui ne traitent qu'un octet à la fois sont très lentes. Comment pouvons-nous résoudre ce problème ?

Étant donné que SIMD peut traiter plusieurs valeurs en même temps, nous espérons que le compilateur pourra comparer plusieurs octets en même temps. Par exemple, nous traitons d'abord 16 octets à la fois via le segment de code suivant, puis traitons le reste séparément pour rendre la structure plus explicite :

// 450 milliseconds
fn common_prefix(xs: &[u8], ys: &[u8]) -> usize {
let chunk_size = 16;
let mut result = 0;
'outer: for (xs_chunk, ys_chunk) in
zip(xs.chunks_exact(chunk_size), ys.chunks_exact(chunk_size))
{
for (x, y) in zip(xs_chunk, ys_chunk) {
if x != y { break 'outer; }
result += 1
}
}
for (x, y) in zip(&xs[result..], &ys[result..]) {
if x != y { break; }
result += 1
}
result
}

En fait, l’amélioration de la vitesse du code ci-dessus est loin d’être suffisante. Plus précisément, SIMD exige que toutes les valeurs d'un bloc soient traitées en parallèle de la même manière. Dans le code ci-dessus, nous avons utilisé un break. Cela signifie que le traitement de la nième paire d'octets dépend de la n-1ème paire. Nous pouvons vérifier que des blocs entiers d'octets correspondent en désactivant le court-circuit. Bien sûr, peu nous importe quel octet spécifique présente une incompatibilité :

// 80 milliseconds
fn common_prefix3(xs: &[u8], ys: &[u8]) -> usize {
let chunk_size = 16;
let mut result = 0;
for (xs_chunk, ys_chunk) in
zip(xs.chunks_exact(chunk_size), ys.chunks_exact(chunk_size))
{
let mut chunk_equal: bool = true;
for (x, y) in zip(xs_chunk, ys_chunk) {
// NB: &, unlike &&, doesn't short-circuit.
chunk_equal = chunk_equal & (x == y);
}
if !chunk_equal { break; }
result += chunk_size;
}
for (x, y) in zip(&xs[result..], &ys[result..]) {
if x != y { break; }
result += 1
}
result
}

À ce stade, la vectorisation a démarré avec succès et a réduit le temps d’exécution de près d’un ordre de grandeur. Nous pouvons désormais utiliser des itérateurs pour la compression.

// 80 milliseconds
fn common_prefix5(xs: &[u8], ys: &[u8]) -> usize {
let chunk_size = 16;
let off =
zip(xs.chunks_exact(chunk_size), ys.chunks_exact(chunk_size))
.take_while(|(xs_chunk, ys_chunk)| xs_chunk == ys_chunk)
.count() * chunk_size;
off + zip(&xs[off..], &ys[off..])
.take_while(|(x, y)| x == y)
.count()
}

De toute évidence, le code à ce stade est très différent de celui lorsque nous avons commencé. Ainsi, au lieu de nous fier aveuglément aux optimisations du compilateur, nous devons savoir dans quelles circonstances des optimisations spécifiques sont effectuées pour les déclencher selon la manière dont le code est écrit. Par exemple, avec SIMD, nous devons exprimer les algorithmes en termes de blocs d’éléments de traitement. Et au sein de chaque bloc, nous devons nous assurer qu’il n’y a pas de branches afin que tous les éléments soient traités de la même manière.

Je suppose que tu aimes

Origine blog.csdn.net/wangonik_l/article/details/132318512
conseillé
Classement