Optimization de la consommation: Multiplication de matrices
Nous allons travailler sur la multiplication de matrices denses. C’est un algorithme classique qui est utilisé dans de nombreux domaines comme l’apprentissage automatique, l’analyse de données, les moteurs 3D, les simulations physiques, etc.
Ici nous travaillerons avec des matrices carrées de taille \(n \times n\). Le produit de matrices \(C = A \times B\) est défini par
\(c_{ij} = \sum^{n}_{k=1} a_{ik}b_{kj}\)
avec i est l’indice de ligne et j l’indice de colonne de la matrice C.
Représentation des matrices en Rust
Pour représenter les matrices en Rust nous allons utiliser la structure suivante:
type Element = f64;
#[derive(Debug, PartialEq)]
pub struct Matrix {
n: usize,
values: Vec<Element>,
}
-
Element
est le type des valeurs à l’intérieur de la matrice, ici des nombres à virgule flottante stockés sur 64 bits (l’équivalent desdouble
en C). -
n
représente la taille d’un côté de la matrice -
values
est un vecteur qui contient les éléments de la matrice-
Implémentez pour la structure
Matrix
un constructeurfn new(n: usize, values: Vec<Element>) → Self
. Attention à bien vérifier grace à une assertion que le vecteurvalues
contient bien \(n^2\) valeurs.
-
Tip
|
N’hésitez pas à implémenter des tests automatiques pour vous aider à valider l’implémentation de chaque méthode tout au long du TP. |
Implémentation des traits Index et IndexMut
Une matrice est une structure à deux dimensions, mais nous allons la stocker sur le vecteur values
à une seule dimension. Il y a plusieurs ordres de stockage possibles. Ici, nous allons stocker les matrices dans l’ordre des lignes (row-major order).
Nous souhaitons cependant pouvoir accéder aux éléments en utilisant les indices ligne i
et colonne j
.
-
Implémentez les traits
std::ops::Index<(usize, usize)>
etstd::ops::IndexMut<(usize, usize)>
pour la structureMatrix
de manière à ce que l’on puisse accéder aux éléments de la matrice avec la syntaxe suivante,
#[test]
fn indexes() {
let mut m = Matrix::new(2, vec![1.0, 2.0, 3.0, 4.0]);
assert_eq!(m[(0, 0)], 1.0);
assert_eq!(m[(1, 0)], 3.0);
m[(1,0)] = 5.0;
assert_eq!(m[(1, 0)], 5.0);
}
Génération de matrices remarquables
-
Implémentez un deuxième constructeur
fn id(n:usize) → Self
qui retourne la matrice identité de dimension \(n\). -
Implémentez un troisième constructeur
fn random(n:usize) → Self
qui retourne une matrice de dimension \(n\) dont les éléments sont tirés aléatoirement d’une distribution uniforme sur \([-1.0, 1.0\)].
Installation de l’outil perf
Dans la suite du TP nous allons utiliser l’outil perf
pour faire diverses mesurer de performance. Pour installer l’outil perf
sous Ubuntu ou Debian vous pouvez exécuter les commandes suivantes:
sudo apt-get install --reinstall linux-tools-common linux-tools-generic linux-tools-`uname -r`
echo "-1" | sudo tee /proc/sys/kernel/perf_event_paranoid
Warning
|
Sur certaines machines il n’est pas possible d’installer l’outil perf . Le cas échéant, utilisez la commande time et au lieu de mesurer l’énergie consommée / le nombre de cache-misses, vous mesurerez le temps d’exécution. |
Multiplication matricielle naïve
-
Implémentez une méthode
fn multiply(a: &Matrix, b: &Matrix) → Matrix
qui retourne le résultat de la multiplication matricielle dea
parb
. Écrivez un code simple sans essayer à ce stade d’optimiser la fonction. Rajoutez un ou plusieurs tests pour tester que le résultat est correct. -
Modifiez le fichier
src/main.rs
de manière à pouvoir réaliser le produit de deux matrices aléatoires dont la taille est passée en ligne de commande. -
Utilisez l’outil
perf
pour estimer grâce aux compteurs RAPL la consommation énérgétique en Joules de votre programme pour une matrice de dimension 256x256.
$ cargo build
$ perf stat -e power/energy-pkg/,power/energy-ram/ ./target/debug/matrixmult 256
Tip
|
Lorsque vous faites des mesures, assurez vous que les valeurs mesurées sont significatives et reproductibles. Pour cela vous pouvez répéter la mesure plusieurs fois et vérifier que l’écart type est petit par rapport à la valeur mesurée. |
-
Compilez maintenant le programme en utilisant les optimisations du compilateur et mesurez l’énergie consommée à nouveau.
$ cargo build --release
$ perf stat -e power/energy-pkg/,power/energy-ram/ ./target/debug/matrixmult naive 256
Qu’observez vous ? Comment l’expliquez vous ?
-
Testez la version optimisée avec des dimensions plus grandes: 512, 768, 1024, 1280, 1536, 1792 ? Tracez la courbe de temps d’exécution et de consommation en fonction de
n
.
Comparez les avec les courbes ci-dessous obtenues sur un processeur à six coeurs i7-9850H CPU @ 2.60GHz, avec 12Mb de cache L3.
-
Quelles sont les caractéristiques de votre machine ? Vous pouvez utiliser les outils suivants:
-
lstopo
pour examiner la hiérarchie mémoire -
cat /proc/cpuinfo
pour avoir les caractéristiques du processeur
-
Cache blocking
Comme nous avons vu en cours, la multiplication de matrices peut présenter des problèmes d’accès au cache et à la mémoire. En effet, l’accès à la deuxième matrice n’est pas dans l’ordre des lignes mais dans l’ordre des colonnes. Les éléments d’une colonne ne sont donc pas contigus en mémoire.
Pour vérifier si notre implémentation souffre de problèmes de localité mémoire nous allons à nouveau utiliser l’outil perf
.
-
Mesurons tout d’abord le nombre d’accès mémoire au dernier niveau de cache pour la version naïve avec la commande suivante:
$ perf stat -e LLC-loads,LLC-stores ./target/release/matrixmult 1280
-
LLC-loads mesure le nombre de lectures depuis le dernier niveau de cache
-
LLC-stores mesure le nombre d’écritures sur le dernier niveau de cache
-
Implementez maintenant une version bloquée de la multiplication matricielle,
fn multiply_blocked(a: &Matrix, b: &Matrix) → Matrix
. Définissez une constanteBLOCK
dans la classeMatrix
pour stocker la taille du block que vous pouvez fixer à 64.
Tip
|
Vérifiez bien que la dimension de vos matrices est un multiple de la taille du block. |
-
Mesurez maintenant le nombre d’accès mémoires au dernier niveau de cache pour la version bloquée ?
-
Mesurez la consommation énergétique et le temps d’exécution pour la version bloquée et comparez les aux mesures de la version naïve ?
-
Que concluez vous ? Pourquoi la version bloquée se révèle plus efficace ?
Parallélisation
Si notre processeur possède plusieurs cœurs de calcul, nous pouvons paralléliser l’algorithme de manière à le rendre encore plus efficace. Pour cela nous allons nous appuyer sur la bibliothèque Rayon.
-
Implémentez une version parallèle du produit de matrices,
fn multiply_rayon(a: &Matrix, b: &Matrix) → Matrix
.-
Nous vous conseillons de paralléliser en découpant selon les lignes de
a
etc
(la matrice résultat). -
Rayon ajoute les méthodes
par_chunks(&self, chunk_size: usize)
etpar_chunks_mut(&mut self, chunk_size: usize)
aux itérateurs. Ces deux méthodes retournent des itérateursparallèles
. Les opérations sur les itérateurs seront distribuées sur différents threads. Vous pouvez donc utiliser ces deux méthodes pour répartir un calcul sur les lignes dec
et les lignes dea
. -
Pour itérer de manière synchronisé sur les lignes des deux itérateurs vous pouvez utiliser la méthode
zip
.
-
-
Mesurez l’énergie consommée par l’implémentation parallèle. Comparez les mesures aux expériences précédentes. Que concluez vous ?
-
Rajoutez du cache-blocking dans l’implémentation parallèle et mesurez l’effet sur la consommation.
Pour aller plus loin…
Le produit de matrices que nous avons implémenté est efficace mais il est possible de pousser les optimisations encore plus loin. Voici quelques références et pistes, si ce travail vous intéresse:
-
Le compilateur actuel Rust ne réussit pas à vectoriser correctement le produit de matrices. Néanmoins il est possible d’utiliser des (appels intrinsèques pour vectoriser manuellement et tirer parti des instructions SIMD du processeur.
Tip
|
La vectorisation automatique est un des points faibles de Rust. En raison de vérifications plus poussées, comme les débordement de tableaux, Rust n’arrive pas toujours à bien vectoriser une boucle. Au contraire des langages comme le C ou le Fortran, offrent moins de garanties sur la correction mémoire, mais vectorisent généralement mieux le code. |
-
Plutôt qu’utiliser des techniques de blocking, qui doivent être paramétrisées par une taille de bloc fixe; il est possible d’implementer la multiplication matricielle pour préserver la localité indépendamment de l’échelle. C’est ce qu’on appelle en anglais un algorithme cache-oblivious. Pour la multiplication de matrices, un tel algorithme peut être obtenu en réordonnant les éléments selon l’ordre donné par la courbe de Lebesgue. Cela permet d’obtenir des implémentations très efficaces pour des matrices dont la dimension est une puissance de deux.
-
La parallélisation que nous vous avons proposé se révèle très efficace. Néanmoins il est possible d’aller encore plus vite en utilisant une décomposition en blocs et la multiplication proposée par Strassen.
Crédits
-
Illustation d’ordre des lignes adapté de l’image de Cmglee, en CC BY-SA 4.0.