Dégradation des performances de la multiplication masortingcielle de masortingces à simple précision ou à double précision sur une machine multicœur

METTRE À JOUR

Malheureusement, à cause de mon oubli, j’avais une version plus ancienne de MKL (11.1) liée à numpy. La version plus récente de MKL (11.3.1) donne les mêmes performances en C et lorsqu’il est appelé à partir de python.

Ce qui obscurcissait les choses, même si relier explicitement les bibliothèques partagées compilées au MKL le plus récent et y faire pointer des variables LD_ *, puis importer import numpy en python, faisait en quelque sorte que python appelait les anciennes bibliothèques MKL. Seulement en remplaçant dans le dossier lib python tous les libmkl _ *. Ainsi, avec le MKL plus récent, je pouvais faire correspondre les performances dans les appels python et C.

Informations de fond / bibliothèque.

La multiplication des masortingces a été effectuée via les appels de bibliothèque MKL d’Intel de sgemm (simple précision) et de dgemm (double précision), via la fonction numpy.dot. L’appel réel des fonctions de la bibliothèque peut être vérifié avec, par exemple, oprof.

En utilisant ici 2×18 processeurs centraux E5-2699 v3, soit un total de 36 cœurs physiques. KMP_AFFINITY = scatter. Courir sur Linux.

TL; DR

1) Pourquoi numpy.dot, même s’il appelle les mêmes fonctions de bibliothèque MKL, est deux fois plus lent au mieux par rapport au code C compilé?

2) Pourquoi, via numpy.dot, les performances diminuent avec l’augmentation du nombre de cœurs, alors que le même effet n’est pas observé dans le code C (appel des mêmes fonctions de bibliothèque).

Le problème

J’ai observé que la multiplication masortingcielle des nombres simples / doubles de précision dans numpy.dot, ainsi que l’appel direct de cblas_sgemm / dgemm à partir d’une bibliothèque partagée compilée C entraînaient une performance nettement inférieure à celle d’un appel MKL cblas_sgemm / dgemm identique au C code.

import numpy as np import mkl n = 10000 A = np.random.randn(n,n).astype('float32') B = np.random.randn(n,n).astype('float32') C = np.zeros((n,n)).astype('float32') mkl.set_num_threads(3); %time np.dot(A, B, out=C) 11.5 seconds mkl.set_num_threads(6); %time np.dot(A, B, out=C) 6 seconds mkl.set_num_threads(12); %time np.dot(A, B, out=C) 3 seconds mkl.set_num_threads(18); %time np.dot(A, B, out=C) 2.4 seconds mkl.set_num_threads(24); %time np.dot(A, B, out=C) 3.6 seconds mkl.set_num_threads(30); %time np.dot(A, B, out=C) 5 seconds mkl.set_num_threads(36); %time np.dot(A, B, out=C) 5.5 seconds 

En procédant exactement comme ci-dessus, mais avec la double précision A, B et C, vous obtenez: 3 kernelx: 20, 6 kernelx: 10, 12 kernelx: 5s, 18 kernelx: 4,3s, 24 kernelx: 3s, 30 kernelx: 2,8 s, 36 kernelx: 2,8s.

Le surcroît de vitesse pour les points flottants à simple précision semble être associé aux erreurs de cache. Pour 28 core run, voici la sortie de perf. Pour une simple précision:

 perf stat -e task-clock,cycles,instructions,cache-references,cache-misses ./ptestf.py 631,301,854 cache-misses # 31.478 % of all cache refs 

Et double précision:

 93,087,703 cache-misses # 5.164 % of all cache refs 

C bibliothèque partagée, compilée avec

 /opt/intel/bin/icc -o comp_sgemm_mkl.so -openmp -mkl sgem_lib.c -lm -lirc -O3 -fPIC -shared -std=c99 -vec-report1 -xhost -I/opt/intel/composer/mkl/include #include  #include  #include "mkl.h" void comp_sgemm_mkl(int m, int n, int k, float *A, float *B, float *C); void comp_sgemm_mkl(int m, int n, int k, float *A, float *B, float *C) { int i, j; float alpha, beta; alpha = 1.0; beta = 0.0; cblas_sgemm(CblasRowMajor, CblasNoTrans, CblasNoTrans, m, n, k, alpha, A, k, B, n, beta, C, n); } 

Fonction wrapper Python, appelant la bibliothèque compilée ci-dessus:

 def comp_sgemm_mkl(A, B, out=None): lib = CDLL(omplib) lib.cblas_sgemm_mkl.argtypes = [c_int, c_int, c_int, np.ctypeslib.ndpointer(dtype=np.float32, ndim=2), np.ctypeslib.ndpointer(dtype=np.float32, ndim=2), np.ctypeslib.ndpointer(dtype=np.float32, ndim=2)] lib.comp_sgemm_mkl.restype = c_void_p m = A.shape[0] n = B.shape[0] k = B.shape[1] if np.isfortran(A): raise ValueError('Fortran array') if m != n: raise ValueError('Wrong masortingx dimensions') if out is None: out = np.empty((m,k), np.float32) lib.comp_sgemm_mkl(m, n, k, A, B, out) 

Cependant, les appels explicites d’un binary compilé en C, appelé cblas_sgemm / cblas_dgemm de MKL, avec des tableaux alloués via malloc en C, offrent des performances presque deux fois supérieures par rapport au code python, à savoir l’appel numpy.dot. En outre, l’effet de la dégradation des performances avec un nombre croissant de cœurs n’est PAS observé. Les meilleures performances ont été de 900 ms pour la multiplication masortingcielle simple précision et ont été obtenues en utilisant les 36 cœurs physiques via mkl_set_num_cores et en exécutant le code C avec numactl –interleave = all.

Peut-être des outils ou des conseils sophistiqués pour profiler / inspecter / comprendre davantage cette situation? Tout matériel de lecture est également très apprécié.

UPDATE Suivant les conseils de @Hristo Iliev, exécuter numactl –interleave = all ./ipython ne modifiait pas les timings (dans le bruit), mais améliorait les exécutions binarys en C pur.

Je soupçonne que cela est dû à la planification des threads malheureuse. J’ai pu reproduire un effet similaire au vôtre. Python fonctionnait à environ 2,2 s, tandis que la version C montrait d’énormes variations par rapport aux 1,4-2,2 s.

Application: KMP_AFFINITY=scatter,granularity=thread Cela garantit que les 28 threads s’exécutent toujours sur le même thread de processeur.

Réduit les deux durées de fonctionnement à environ 1,24 s plus stable pour C et à environ 1,26 s pour Python.

Il s’agit d’un système Xeon E5-2680 v3 à double socket et 28 cœurs.

Il est intéressant de noter que, sur un système Haswell à 24 cœurs à double socket et à double cœur, Python et C ont des performances presque identiques, même sans affinité de fil / épinglage.

Pourquoi python affecte-t-il la planification? Bien, je suppose qu’il ya plus d’environnement d’exécution autour En bout de ligne, si vous ne fixez pas vos performances, les résultats ne seront pas déterministes.

De plus, vous devez prendre en compte le fait que le moteur d’exécution Intel OpenMP génère un thread de gestion supplémentaire pouvant confondre le planificateur. Il y a plus de choix d’épinglage, par exemple KMP_AFFINITY=compact – mais pour une raison quelconque, cela est totalement foiré sur mon système. Vous pouvez append une ,verbose à la variable pour voir comment le moteur d’exécution épingle vos threads.

likwid-pin est une alternative utile offrant un contrôle plus pratique.

En général, la simple précision devrait être au moins aussi rapide que la double précision. La double précision peut être plus lente car:

  • Vous avez besoin de plus de bande passante mémoire / cache pour une double précision.
  • Vous pouvez créer des ALU qui ont un débit plus élevé pour la simple précision, mais cela ne s’applique généralement pas aux processeurs, mais plutôt aux GPU.

Je penserais qu’une fois que vous aurez éliminé l’anomalie de performance, cela se reflétera dans vos chiffres.

Lorsque vous augmentez le nombre de threads pour MKL / * gemm, pensez à

  • La bande passante en mémoire / en cache partagé peut devenir un goulot d’étranglement, ce qui limite l’évolutivité
  • Le mode Turbo diminuera effectivement la fréquence de base lorsqu’on augmentera l’utilisation. Ceci s’applique même lorsque vous utilisez la fréquence nominale: Sur les processeurs Haswell-EP, les instructions AVX imposeront une “fréquence de base AVX” plus faible – mais le processeur est autorisé à dépasser ce seuil lorsque le nombre de cœurs utilisés est réduit / que la marge thermique est disponible et même en général plus pour un court laps de temps. Si vous voulez des résultats parfaitement neutres, vous devrez utiliser la fréquence de base AVX, qui correspond à 1,9 GHz. Il est documenté ici et expliqué dans une image .

Je ne pense pas qu’il existe un moyen très simple de mesurer l’impact d’une mauvaise planification sur votre application. Vous pouvez exposer cela avec perf trace -e sched:sched_switch et il existe un logiciel pour visualiser cela, mais cela perf trace -e sched:sched_switch une courbe d’apprentissage élevée. Et encore une fois – pour une parsing de performance parallèle, vous devriez quand même avoir les threads épinglés.