J'ai observé sur un système que std::fill
sur un grand std::vector
était significativement et constamment plus lent lors de la définition d'une valeur constante 0
par rapport à une valeur constante 1
ou une valeur dynamique :
5.8 Gio/s vs 7.5 Gio/s
Cependant, les résultats sont différents pour des tailles de données plus petites, où fill(0)
est plus rapide :
Avec plus d'un thread, à une taille de données de 4 Gio, fill(1)
montre une pente plus élevée, mais atteint un pic beaucoup plus bas que fill(0)
(51 Gio/s vs 90 Gio/s) :
Cela soulève la question secondaire, pourquoi la bande passante maximale de fill(1)
est beaucoup plus basse.
Le système de test pour cela était un CPU Intel Xeon E5-2680 v3 double socket réglé à 2,5 GHz (via /sys/cpufreq
) avec 8x16 Gio DDR4-2133. J'ai testé avec GCC 6.1.0 (-O3
) et le compilateur Intel 17.0.1 (-fast
), les deux donnent des résultats identiques. GOMP_CPU_AFFINITY=0,12,1,13,2,14,3,15,4,16,5,17,6,18,7,19,8,20,9,21,10,22,11,23
était défini. Strem/add/24 threads obtient 85 Gio/s sur le système.
J'ai pu reproduire cet effet sur un système de serveur double socket Haswell différent, mais pas sur une autre architecture. Par exemple, sur Sandy Bridge EP, les performances de la mémoire sont identiques, tandis que dans le cache fill(0)
est bien plus rapide.
Voici le code pour reproduire :
#include
#include
#include
#include
#include
using value = int;
using vector = std::vector;
constexpr size_t write_size = 8ll * 1024 * 1024 * 1024;
constexpr size_t max_data_size = 4ll * 1024 * 1024 * 1024;
void __attribute__((noinline)) fill0(vector& v) {
std::fill(v.begin(), v.end(), 0);
}
void __attribute__((noinline)) fill1(vector& v) {
std::fill(v.begin(), v.end(), 1);
}
void bench(size_t data_size, int nthreads) {
#pragma omp parallel num_threads(nthreads)
{
vector v(data_size / (sizeof(value) * nthreads));
auto repeat = write_size / data_size;
#pragma omp barrier
auto t0 = omp_get_wtime();
for (auto r = 0; r < repeat; r++)
fill0(v);
#pragma omp barrier
auto t1 = omp_get_wtime();
for (auto r = 0; r < repeat; r++)
fill1(v);
#pragma omp barrier
auto t2 = omp_get_wtime();
#pragma omp master
std::cout << data_size << ", " << nthreads << ", " << write_size / (t1 - t0) << ", "
<< write_size / (t2 - t1) << "\n";
}
}
int main(int argc, const char* argv[]) {
std::cout << "size,nthreads,fill0,fill1\n";
for (size_t bytes = 1024; bytes <= max_data_size; bytes *= 2) {
bench(bytes, 1);
}
for (size_t bytes = 1024; bytes <= max_data_size; bytes *= 2) {
bench(bytes, omp_get_max_threads());
}
for (int nthreads = 1; nthreads <= omp_get_max_threads(); nthreads++) {
bench(max_data_size, nthreads);
}
}
Résultats présentés compilés avec g++ fillbench.cpp -O3 -o fillbench_gcc -fopenmp
.
0 votes
Quelle est la
taille des données
lorsque vous comparez le nombre de fils d'exécution ?1 votes
@GavinPortwood 4 Gio, donc en mémoire, pas en cache.
0 votes
Ensuite, il doit y avoir un problème avec le deuxième graphique, l'évolutivité faible. Je ne peux pas imaginer qu'il faille plus de deux threads environ pour saturer la bande passante mémoire pour une boucle avec des opérations intermédiaires minimales. En fait, vous n'avez pas identifié le nombre de threads où la bande passante atteint sa saturation, même avec 24 threads. Pouvez-vous montrer qu'elle se stabilise à un certain nombre fini de threads ?
0 votes
@GavinPortwood Sur ce système, il est conforme à d'autres chiffres de référence que la bande passante est saturée à ~7 des 12 cœurs pour un socket. Voir par exemple les chiffres du stream, où il y a un facteur d'environ 5 entre un seul cœur et tous les cœurs. Ce que je ne peux pas expliquer facilement, c'est le comportement du deuxième socket (13-24 threads). Je m'attendais à une pente et une saturation similaires à celles du premier socket (1-12 threads). Je suppose que cela a quelque chose à voir avec une distribution asymétrique des threads.
0 votes
@GavinPortwood J'ai relancé les expériences avec des paramètres d'affinité différents (répartis sur les deux sockets) et mis à jour l'image. Vous voyez mieux la saturation. Mais le motif principal reste que
fill(1)
a une pente plus élevée mais une bande passante maximale beaucoup plus faible quefill(0)
.2 votes
Je soupçonne que la mise à l'échelle anormale dans votre expérience originale (sur le deuxième socket) est liée à une allocation de mémoire non homogène et à la communication QPI résultante. Cela peut être vérifié avec les PMU "uncore" d'Intel (je pense)
0 votes
Je commence lentement à examiner votre question stackoverflow.com/q/43343231/2542702
1 votes
FWIW - vous avez trouvé la différence de code dans votre réponse et je pense que Peter Cordes a la réponse ci-dessous: que
rep stosb
utilise un protocole non-RFO qui divise par deux le nombre de transactions nécessaires pour effectuer un remplissage. Le reste du comportement découle principalement de cela. Il y a un autre inconvénient que le codefill(1)
a : il ne peut pas utiliser les magasins AVX 256 bits parce que vous ne spécifiez pas-march=haswell
ou autre chose, il doit donc revenir à un code de 128 bits.fill(0)
qui appellememset
bénéficie de l'avantage delibc
dispatching qui appelle la version AVX sur votre plate-forme.0 votes
Vous pourriez essayer avec l'argument
-march
lors de la compilation pour faire une comparaison un peu plus juste : cela aidera surtout pour les petits tampons qui rentrent dans un certain niveau de cache, mais pas pour les copies plus grandes.0 votes
@BeeOnRope
-march=native
donne une bouclevmovdq
, qui semble uniquement améliorer les performances de L1, bien que pas tout à fait au niveau derep stos
.0 votes
Droite - mais utilisait-il des registres
ymm
ouxmm
? C'est la différence clé (256 bits contre 128 bits). Je suppose que vos résultats ont du sens - je pense que le L2 a une bande passante de 32 octets par cycle, ce qui semblerait nécessiter des opérations d'écriture de 32 octets (au maximum 1 par cycle) pour le saturer, mais sans opérations d'écriture non temporelles la bande passante est divisée par deux entre les opérations d'écriture réelles et les demandes RFO, donc 16 octets de lectures sont "suffisants" pour saturer même le L2 (le même raisonnement s'applique plus ou moins pour le L3). Le L1, par contre, peut soutenir 32 octets d'écriture par cycle, donc 256 bits gagnent là.0 votes
C'était
ymm
, j'ai ajouté les résultats à ma réponse, en incluant également intrinsèque non-temporel.