Supposons que a1
, b1
, c1
y d1
pointent vers la mémoire vive, et mon code numérique comporte la boucle centrale suivante.
const int n = 100000;
for (int j = 0; j < n; j++) {
a1[j] += b1[j];
c1[j] += d1[j];
}
Cette boucle est exécutée 10 000 fois via une autre boucle extérieure. for
boucle. Pour l'accélérer, j'ai changé le code en :
for (int j = 0; j < n; j++) {
a1[j] += b1[j];
}
for (int j = 0; j < n; j++) {
c1[j] += d1[j];
}
Compilé le Microsoft Visual C++ 10.0 avec une optimisation complète et ESS2 activé pour 32 bits sur un Intel Core 2 Duo (x64), le premier exemple prend 5,5 secondes et l'exemple à double boucle ne prend que 1,9 seconde.
Le désassemblage de la première boucle se présente essentiellement comme suit (ce bloc est répété environ cinq fois dans le programme complet) :
movsd xmm0,mmword ptr [edx+18h]
addsd xmm0,mmword ptr [ecx+20h]
movsd mmword ptr [ecx+20h],xmm0
movsd xmm0,mmword ptr [esi+10h]
addsd xmm0,mmword ptr [eax+30h]
movsd mmword ptr [eax+30h],xmm0
movsd xmm0,mmword ptr [edx+20h]
addsd xmm0,mmword ptr [ecx+28h]
movsd mmword ptr [ecx+28h],xmm0
movsd xmm0,mmword ptr [esi+18h]
addsd xmm0,mmword ptr [eax+38h]
Chaque boucle de l'exemple de la double boucle produit ce code (le bloc suivant est répété environ trois fois) :
addsd xmm0,mmword ptr [eax+28h]
movsd mmword ptr [eax+28h],xmm0
movsd xmm0,mmword ptr [ecx+20h]
addsd xmm0,mmword ptr [eax+30h]
movsd mmword ptr [eax+30h],xmm0
movsd xmm0,mmword ptr [ecx+28h]
addsd xmm0,mmword ptr [eax+38h]
movsd mmword ptr [eax+38h],xmm0
movsd xmm0,mmword ptr [ecx+30h]
addsd xmm0,mmword ptr [eax+40h]
movsd mmword ptr [eax+40h],xmm0
La question s'est avérée sans intérêt, car le comportement dépend fortement de la taille des tableaux (n) et de la mémoire cache de l'unité centrale. Donc, s'il y a un intérêt supplémentaire, je reformule la question :
-
Pourriez-vous nous éclairer sur les détails qui conduisent aux différents comportements du cache, tels qu'illustrés par les cinq régions du graphique suivant ?
-
Il pourrait également être intéressant de souligner les différences entre les architectures CPU/cache, en fournissant un graphique similaire pour ces CPU.
Voici le code complet. Il utilise TBB Tick_Count
pour une résolution plus élevée, qui peut être désactivée en ne définissant pas le paramètre TBB_TIMING
Macro :
#include <iostream>
#include <iomanip>
#include <cmath>
#include <string>
//#define TBB_TIMING
#ifdef TBB_TIMING
#include <tbb/tick_count.h>
using tbb::tick_count;
#else
#include <time.h>
#endif
using namespace std;
//#define preallocate_memory new_cont
enum { new_cont, new_sep };
double *a1, *b1, *c1, *d1;
void allo(int cont, int n)
{
switch(cont) {
case new_cont:
a1 = new double[n*4];
b1 = a1 + n;
c1 = b1 + n;
d1 = c1 + n;
break;
case new_sep:
a1 = new double[n];
b1 = new double[n];
c1 = new double[n];
d1 = new double[n];
break;
}
for (int i = 0; i < n; i++) {
a1[i] = 1.0;
d1[i] = 1.0;
c1[i] = 1.0;
b1[i] = 1.0;
}
}
void ff(int cont)
{
switch(cont){
case new_sep:
delete[] b1;
delete[] c1;
delete[] d1;
case new_cont:
delete[] a1;
}
}
double plain(int n, int m, int cont, int loops)
{
#ifndef preallocate_memory
allo(cont,n);
#endif
#ifdef TBB_TIMING
tick_count t0 = tick_count::now();
#else
clock_t start = clock();
#endif
if (loops == 1) {
for (int i = 0; i < m; i++) {
for (int j = 0; j < n; j++){
a1[j] += b1[j];
c1[j] += d1[j];
}
}
} else {
for (int i = 0; i < m; i++) {
for (int j = 0; j < n; j++) {
a1[j] += b1[j];
}
for (int j = 0; j < n; j++) {
c1[j] += d1[j];
}
}
}
double ret;
#ifdef TBB_TIMING
tick_count t1 = tick_count::now();
ret = 2.0*double(n)*double(m)/(t1-t0).seconds();
#else
clock_t end = clock();
ret = 2.0*double(n)*double(m)/(double)(end - start) *double(CLOCKS_PER_SEC);
#endif
#ifndef preallocate_memory
ff(cont);
#endif
return ret;
}
void main()
{
freopen("C:\\test.csv", "w", stdout);
char *s = " ";
string na[2] ={"new_cont", "new_sep"};
cout << "n";
for (int j = 0; j < 2; j++)
for (int i = 1; i <= 2; i++)
#ifdef preallocate_memory
cout << s << i << "_loops_" << na[preallocate_memory];
#else
cout << s << i << "_loops_" << na[j];
#endif
cout << endl;
long long nmax = 1000000;
#ifdef preallocate_memory
allo(preallocate_memory, nmax);
#endif
for (long long n = 1L; n < nmax; n = max(n+1, long long(n*1.2)))
{
const long long m = 10000000/n;
cout << n;
for (int j = 0; j < 2; j++)
for (int i = 1; i <= 2; i++)
cout << s << plain(n, m, j, i);
cout << endl;
}
}
Il montre les FLOP/s pour différentes valeurs de n
.
4 votes
Il pourrait s'agir du système d'exploitation qui ralentit la recherche de la mémoire physique à chaque fois que l'on y accède et qui dispose d'une sorte de cache en cas d'accès secondaire au même bloc de mémoire.
8 votes
Compilez-vous avec des optimisations ? Cela ressemble à beaucoup de code asm pour O2...
1 votes
J'ai demandé ce qui semble être une question similaire il y a quelque temps. Ce document ou les réponses qu'il contient pourraient contenir des informations intéressantes.
0 votes
Dommage que vous n'ayez pas indiqué les adresses des codes. C'est aussi très important.
73 votes
Pour être précis, ces deux extraits de code ne sont pas équivalents en raison du chevauchement potentiel des pointeurs. En C99, le
restrict
pour de telles situations. Je ne sais pas si MSVC a quelque chose de similaire. Bien sûr, si c'était le cas, le code SSE ne serait pas correct.0 votes
@user578832 Je viens de voir la modification de votre question. Donnez-moi un peu de temps pour répondre à votre nouvelle question avec les 5 régions.
0 votes
@user578832 Juste pour info. Il y a 9 éditions de la question. A la prochaine édition, elle passera en Community Wiki. Donc ne faites plus d'édition. (sauf si c'est ce que vous voulez)
9 votes
Cela peut avoir un rapport avec l'aliasing de la mémoire. Avec une boucle,
d1[j]
peut être aliasé aveca1[j]
de sorte que le compilateur peut renoncer à certaines optimisations de la mémoire. Cela ne se produit pas si vous séparez les écritures en mémoire en deux boucles.0 votes
Ces graphiques ont été réalisés à la main en utilisant Excel et les données produites à partir du code que j'ai posté ici ( pastebin.com/ivzkuTzG ). Mais après un certain temps, je suis passé à gnuplot, parce qu'il peut être utilisé de manière plus automatique.
1 votes
Je parie
L1D
,L1D_CACHE_ST
,L2_RQSTS
yL2_DATA_RQSTS
Les compteurs de performance seraient révélateurs. Voir aussi Événements Intel Core i7 (Nehalem) .0 votes
Peut-être avec
restrict
le compilateur aurait séparé les boucles de lui-même. La séparation des boucles est l'affaire des optimiseurs.0 votes
Cela dépendra beaucoup de la configuration des caches du processeur et des préfabricateurs matériels, ainsi que de la bande passante de la RAM du système... Sur certaines architectures, l'une peut fonctionner mieux que l'autre. Mon exposé pourrait vous être utile, car il traite de ces questions. Il aborde également le sujet du SIMD. youtube.com/watch?v=Nsf2_Au6KxU De plus, votre code n'est pas SIMDisé : le compilateur ne génère que des instructions SSE scalaires. Si vous contraignez le compilateur à vectoriser (je ne le fais jamais), ou si vous utilisez simplement les instructions intrinsèques (beaucoup plus difficiles à apprendre au départ), il se peut que votre code soit encore plus rapide.
0 votes
@user510306 : Les deux sorties asm ne font que load/load+add/store.
restrict
aurait pu activer l'autovectorisation pour les deux versions, ainsi que le pipelining logiciel (en effectuant les chargements plusieurs instructions avant l'utilisation, de sorte que le tampon de réorganisation n'est pas aussi encombré d'instructions en attente de données provenant des chargements). Quoi qu'il en soit, l'utilisation derestrict
serait une excellente idée, mais en dehors des effets de cache, les deux versions asm devraient avoir les mêmes performances.0 votes
J'ai essayé ceci pour illustrer ce qui se passe : @[ ideone.com/aJgwsp ] ... Essayez d'imprimer l'assemblage pour le comparer à ce que vous avez ici. -R
0 votes
Petite parenthèse : certains compilateurs sont capables de décomposer les boucles de cette manière, ce que l'on appelle la "fission de boucle". fr.wikipedia.org/wiki/Loop_fission_and_fusion
0 votes
C'est pourquoi Halide est si pratique : il peut essayer différents "horaires" sans modifier l'algorithme mathématique et en expérimenter rapidement d'autres, au lieu de restructurer complètement le code. (Je viens de voir la présentation à CPPCON-20)