Le faux partage, ou False Sharing, est un obstacle à l'échelonnabilité résultant du fait que, dans un ordinateur à plusieurs coeurs ou à plusieurs processeurs, il peut arriver que la Cache d'un coeur contienne des données qui peuvent être modifiées par un autre processeur ou par un autre coeur.
Les données sont lues en antémémoire par petits blocs qu'on nomme des Cache Lines; le faux partage résulte d'une modification à une Cache Line forçant sa mise à jour dans les antémémoires d'autres coeurs ou d'autres processeurs, ce qui entraîne un ralentissement inutile et très coûteux.
Cet exemple est directement inspiré d'une présentation de Scott Meyers : https://www.youtube.com/watch?v=WDIkqP4JbkE (les diapositives sont disponibles sur http://www.aristeia.com/TalkNotes/ACCU2011_CPUCaches.pdf si vous êtes intéressé(e)s)
Pour illustrer la situation, voici un exemple que j'ai construit pour mes étudiant(e)s. L'exemple utilise des mécanismes de C++ 17, mais ces mécanismes ne sont pas nécessaires au propos (ils font simplement du code plus agréable à écrire, à lire, et – dans certains cas – plus rapides à exécuter).
Cette illustration fait une tâche simple, soit compter le nombre d'entiers impairs dans un conteneur. J'utilise plusieurs conteneurs, chacun avec ses propres caractéristiques en termes d'accès à la mémoire, mais l'idée générale est la même dans chaque cas.
Cette comptabilité est faite en parallèle, avec un nombre paramétrable de fils d'exécution. Ainsi, il est possible d'examiner les gains – ou pas! – en termes de vitesse d'exécution selon la quantité de parallélisme impliquée.
Enfin, les calculs sont faits à l'aide de deux tests : test_0, dans lequel un faux partage survient, et test_1 qui, à l'aide d'une très légère modification par rapport à test_0, élimine le faux partage et accélère la majorité des cas de tests. La raison pour laquelle certains tests ne sont pas accélérés sera expliquée plus en détail ci-dessous.
Vous trouverez deux blocs de tests : un bloc interactif (que j'utilise en classe, pour prédire les résultats en collaboration avec mes étudiant(e)s) et un bloc non-interactif. Le propos est le même dans les deux cas.
Le code implémentant cet exemple suit.
L'exemple reposera strictement sur des outils standards. J'ai utilisé des using namespace globaux au fichier alléger l'écriture, même si ce n'est pas exactement la pratique la plus élégante qui soit. |
|
Pour éviter les critiques de gens qui pensent que les conteneurs standards sont lents (soupir!), j'ai codé un petit « conteneur » qui ne fait absolument rien d'autre qu'entreposer un tableau allouée dynamiquement (mais sans capacité de redimensionnement) et d'en gérer automatiquement la mémoire. Cette classe, nommée simili_tableau<T,N>, sera comparée aux conteneurs standards utilisés lors des tests, un peu plus bas. |
|
Pour alléger le code de nos tests, j'ai écrit des fonctions pour construire et initialiser les conteneurs qui seront testés :
Note que, bien que j'aie pris soin d'intialiser efficacement les conteneurs, le temps d'initialisation ne sera pas comptabilisé par les tests qui suivent. |
|
Le test nommé test_0 est modélisé par un foncteur applicable à un conteneur de type C non-modifiable nommé vals, et a pour rôle de compter les entiers impairs dans vals à l'aide de n fils d'exécution. La valeur de n est paramétrable à la construction du test_0, mais correspond par défaut au nombre estimé de coeurs à notre disposition, tel que retourné par thread::hardware_concurrency(). La stratégie générale est de lancer n-1 fils d'exécution concurrents, chacun opérant sur environ vals.size()/n éléments. Les vals.size()/n éléments résiduels sont traités par le fil d'exécution initial. Un vector<int> nommé impairs permet à chaque fil d'exécution i d'entreposer dans impairs[i] le nombre d'impairs qu'il aura calculé Le faux partage tient au fait que, bien qu'aucune condition de course ne survienne dans ce programme, les différents éléments du vecteur impairs se trouvent placés de manière contiguë en mémoire, et partagent donc leurs Cache Lines. Conséquemment, chaque écriture dans impairs[i] risque de forcer un rafraîchissement de la Cache Line d'autres fils d'exécution (tout particulièrement le fil i-1 et le fil i+1 si ceux-ci existent), ce qui entraîne de multiples interférences entre fils d'exécution et ralentit celle-ci de manière difficile à prévoir a priori. |
|
La résolution du faux partage dans test_1 tient au recours à une variable locale, nimpairs, sur laquelle la comptabilité est faite tout au long du test, pour ne mener qu'à une seule écriture dans impairs[i] à la toute fin. Ce petit changement, à lui seul, entraîne des gains de vitesse et de prévisibilité significatifs. Pour le reste, test_1 est identique à test_0. |
|
Le code utilisé pour tester le temps d'exécution des tests qui suivent tient à la fonction tester() proposé à droite. Vous trouverez plus d'informations à son sujet dans ../AuSecours/Mesurer-le-temps.html, mais l'idée est de saisir le temps avant et après exécution de f(args), et de retourner une paire comprenant le résultat de f(args) et le temps écoulé. |
|
La fonction attendre() est ... triviale. Elle sert dans les tests interactifs pour bloquer l'exécution avant de procéder à un test, tout simplement. |
|
La fonction tests_interactifs() réalise une série de tests, avec ou sans faux partage, et avec divers conteneurs de diverses tailles, attendant avant chaque test que l'usager soit prêt (ce qui est utile en pour les démonstrations en classe). Les tests réalisés le sont sur :
J'ai utilisé des IIFE dans ces tests. Voir ../Divers--cplusplus/Lambda-expressions.html pour plus de détails. |
|
Pour les tests non-manuels, j'utilise une fonction test<NTESTS>(cr,te,nom) qui encapsule l'accumulation de NTESTS tests te sur les données générées par cr, et utilise nom pour fins d'affichage. |
|
Les tests non-interactifs font, en quelque sorte, la même chose que les tests interactifs, mais à l'aide de nthr fils d'exécution. En pratique, nthr variera de 1 à thread::hardware_concurrency() (voir plus bas). Chaque test sera fait NTESTS fois. |
|
La fonction tests_blocs() appelle tests_blocs(i) pour un nombre i de fils d'exécution variable. |
|
Enfin, main() fait à la fois les tests interactifs et les tests non-interactifs. |
|
Quelques remarques rapides. Présumant que vous ayez mis le code de test à l'épreuve :
Un exemple connexe (mais simplifié) en C# peut être exprimé assez simplement aussi. Pour une version avec faux-partage :
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Threading;
public class Program
{
static (T, long) Tester<T>(Func<T> f)
{
var sw = new Stopwatch();
sw.Start();
T rés = f();
sw.Stop();
return (rés, sw.ElapsedMilliseconds);
}
static T Cumuler<T, C>(C coll, Func<T, T, T> cumul, T init) where C : IEnumerable<T>
{
foreach (var elem in coll)
init = cumul(init, elem);
return init;
}
// suppose tab.Length >>> nthrs pour simplicité
static int CompterImpairs(short[] tab, int nthrs)
{
Thread[] th = new Thread[nthrs];
int[] nbImpairs = new int[nthrs];
int tailleBloc = tab.Length / nthrs;
for (int i = 0; i < th.Length - 1; ++i)
{
var début = i * tailleBloc;
var fin = (i + 1) * tailleBloc;
var indice = i;
th[i] = new Thread(() =>
{
for (int j = début; j != fin; ++j)
if (tab[j] % 2 != 0)
++nbImpairs[indice];
});
}
// partie résiduelle (des fois, ça n'arrive pas juste)
th[th.Length - 1] = new Thread(() =>
{
for (int j = (th.Length - 1) * tailleBloc;
j != tab.Length; ++j)
if (tab[j] % 2 != 0)
++nbImpairs[th.Length - 1];
});
foreach (var thr in th) thr.Start();
foreach (var thr in th) thr.Join();
return Cumuler(nbImpairs, (x, y) => x + y, 0);
}
static short[] CréerTableau(int taille)
{
short[] tab = new short[taille];
for (int i = 0; i != tab.Length; ++i)
tab[i] = (short)(i * 2 + 1); // des impairs
return tab;
}
public static void Main()
{
const int NB_THR_MAX = 10;
var tab = CréerTableau(25_000 * 25_000);
Console.WriteLine("Version avec faux-partage...");
for (int i = 1; i <= NB_THR_MAX; ++i)
{
var (rés, dt) = Tester(() => CompterImpairs(tab, i));
Console.WriteLine($"\tNb fils : {i} ; {rés} impairs en {dt} ms");
}
}
}
... et pour une version évitant le faux-partage :
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Threading;
public class Program
{
static (T, long) Tester<T>(Func<T> f)
{
var sw = new Stopwatch();
sw.Start();
T rés = f();
sw.Stop();
return (rés, sw.ElapsedMilliseconds);
}
static T Cumuler<T, C>(C coll, Func<T, T, T> cumul, T init) where C : IEnumerable<T>
{
foreach (var elem in coll)
init = cumul(init, elem);
return init;
}
// suppose tab.Length >>> nthrs pour simplicité
static int CompterImpairs(short[] tab, int nthrs)
{
Thread[] th = new Thread[nthrs];
int[] nbImpairs = new int[nthrs];
int tailleBloc = tab.Length / nthrs;
for (int i = 0; i < th.Length - 1; ++i)
{
var début = i * tailleBloc;
var fin = (i + 1) * tailleBloc;
var indice = i;
th[i] = new Thread(() =>
{
int n = 0;
for (int j = début; j != fin; ++j)
if (tab[j] % 2 != 0)
++n;
nbImpairs[indice] = n;
});
}
// partie résiduelle (des fois, ça n'arrive pas juste)
th[th.Length - 1] = new Thread(() =>
{
int n = 0;
for (int j = (th.Length - 1) * tailleBloc;
j != tab.Length; ++j)
if (tab[j] % 2 != 0)
++n;
nbImpairs[th.Length - 1] = n;
});
foreach (var thr in th) thr.Start();
foreach (var thr in th) thr.Join();
return Cumuler(nbImpairs, (x, y) => x + y, 0);
}
static short[] CréerTableau(int taille)
{
short[] tab = new short[taille];
for (int i = 0; i != tab.Length; ++i)
tab[i] = (short)(i * 2 + 1); // des impairs
return tab;
}
public static void Main()
{
const int NB_THR_MAX = 10;
var tab = CréerTableau(25_000 * 25_000);
Console.WriteLine("Version sans faux-partage...");
for (int i = 1; i <= NB_THR_MAX; ++i)
{
var (rés, dt) = Tester(() => CompterImpairs(tab, i));
Console.WriteLine($"\tNb fils : {i} ; {rés} impairs en {dt} ms");
}
}
}
Quelques liens suivent pour enrichir le propos.