Unser Beispiel multipliziert zwei 700x700 Matrizen (3.9MB pro Matrix) oder 500x500 Matrizen (2MB/Matrix). Im Prinzip ist dieses Problem extrem gut parallelisierbar. Jedes der 490.000/250.000 Elemente der Ergebnismatrix lässt sich unabhängig von den anderen berechnen, und auch die Berechnung eines Elements ist gut parallelisierbar: Alle verwendeten Elemente der Operandenmatrizen können parallel geladen werden, und alle Elementmultiplikationen können parallel ausgeführt werden; das Aufsummieren der Produkte kann über einen Baum mit einer Tiefe von ld(n)=10 bzw. 9 erfolgen. Das Problem ist daher eher, die Berechnung so zu organisieren, dass die begrenzten Hardwareresourcen möglichst effektiv eingesetzt werden, insbesondere bei den Speicherzugriffen. main.c enthält den Code drumherum, und der bleibt in allen Versionen gleich. mm1.c enthält die erste Version der Matrix-Multiplikation selbst. Sie ist von der Beschreibung der Matrix-Multiplikation in der Wikipedia abgeleitet.
Wir bauen die erste Version mit
make mm1und koennen ein Profil wie folgt machen:
perf record -e cycles mm1 700 perf reportDas Profil zeigt, dass über 99% der Zeit in matmul verbracht wird und nur 0.06% in main, daher konzentrieren wir uns auf matmul. Wir sehen den Assemblercode, in dem die meisten Zyklen ausgeführt werden, mit
perf annotateDas zeigt uns (Ausschnitt):
0.01 |50: movsd (%rax),%xmm0 0.19 | add $0x8,%rax 0.01 | mulsd (%rdx),%xmm0 88.83 | add %r10,%rdx 0.22 | cmp %rdi,%rax 0.01 | addsd %xmm0,%xmm1 10.64 | jne 50 0.01 | movsd %xmm1,(%r8,%r11,1) 0.08 | add $0x8,%r11Bis inkl. dem Befehl
jne
ist das die innere Schleife.
Der entsprechende C-Code ist:
for (k=0, r=0.0; k<m; k++) r += a[i*m+k]*b[k*p+j];Man sieht hier, dass der Compiler schon eine Menge wegoptimiert hat. Die Zuordnung der Zyklen zu den Befehlen ist allerdings eher unwahrscheinlich, da der add-Befehl schnell ist und auf nichts warten muss, und der jne-Befehl sehr gut vorhersagbar ist. Schauen wir uns daher an, wie diverse performance-counter-Resultate ausschauen:
make perf MM="mm1 700"Die Ergebnisse sind:
mm1 700 7956199520 cycles 2429183981 instructions # 0.31 insns per cycle 510799 branch-misses 31866923 offcore_response_all_data_rd_llc_miss_dram 691609751 L1-dcache-loads 448132119 L1-dcache-load-misses # 64.80% of all L1-dcache hits 48390933 LLC-loads 82544 LLC-prefetches 691323402 dTLB-loads 331808798 dTLB-load-misses # 48.00% of all dTLB cache hits
Für 500x500 Matrizen schaut das Ergebnis so aus:
mm1 500 573085436 cycles 889598768 instructions # 1.55 insns per cycle 264337 branch-misses 2909385 offcore_response_all_data_rd_llc_miss_dram 252395886 L1-dcache-loads 111869333 L1-dcache-load-misses # 44.32% of all L1-dcache hits 15836588 LLC-loads 65097 LLC-prefetches 251700823 dTLB-loads 2638938 dTLB-load-misses # 1.05% of all dTLB cache hitsInsbesondere ist die Anzahl der Befehle pro Zyklus in der 700x700-Variante wesentlich kleiner, was vermuten läßt, dass diese Variante einen grossen Teil der Zeit auf Hauptspeicherzugriffe wartet.
Die 500x500-Variante dagegen führt eine Iteration der inneren Schleife dagegen in ca. 4 Zyklen durch, sodass hier wohl die Latenz des addsd-Befehls von 4 Zyklen entscheidend ist: Dieser Befehl hängt vom Resultat des gleichen Befehls in der vorigen Iteration ab.
Das ist ein einfacher Fall einer recurrence, einer Kette von Datenabhängigkeiten in einer Schleife, durch die ein Befehl vom selben Befehl in der vorigen Iteration abhängt. Bei Schleifen mit vielen Iterationen bestimmt die Recurrence mit der längsten Gesamtlatenz die minimalen Zyklen pro Iteration (bei wenigen Iterationen kann die out-of-order execution noch etwas ausgleichen). Die anderen Recurrences in dieser Schleife, je eine pro add-Befehl, haben nur einen Zyklus Latenz.
Bevor wir uns dem Problem der Speicheroptimierung zuwenden (die das Programm etwas unhandlicher machen wird), eliminieren wir zunächst die recurrence durch addsd (und betrachten zunächst nur die 500x500-Variante): Statt ein Element des Resultats auf einmal auszurechnen, berechnen wir alle Elemente der ersten Zeile des Resultats stückweise.
Als ersten Schritt speichern wir das Zwischenresultat nicht in r, sondern im Zielelement von c:
c[i*p+j] += a[i*m+k]*b[k*p+j];Dadurch wird im Ursprungsprogramm die recurrence zwar noch länger (und das Programm langsamer), aber man kann die Instanzen dieser Anweisung jetzt beliebig umordnen: Diejenigen, die auf verschiedene Elemente von c zugreifen, sowieso, und diejenigen, die auf das selbe Element zugreifen, aufgrund des Assoziativgesetzes der Addition (das gilt für Gleitkommaaddition nur näherungsweise, aber da wir schon im Originalprogramm nur eine Näherung des genauen Resultats produzieren, ist das in diesem Fall akzeptabel).
Dazu tauschen wir im Prinzip die innere und die mittlere Schleife aus, und müssen die Zwischenergebnisse der Berechnung dann im Speicher zwischenspeichern. Das Ergebnis ist mm2.c. Die innere Schleife schaut, mit -O2 compiliert, jetzt so aus:
for (j=0; j<p; j++) c[i*p+j] += a[i*m+k]*b[k*p+j]; 5.35 |70: movsd 0x0(%rbp,%rsi,8),%xmm0 22.22 | mulsd (%r10,%rax,8),%xmm0 11.32 | addsd (%rdx,%rax,8),%xmm0 35.22 | movsd %xmm0,(%rdx,%rax,8) 23.27 | add $0x1,%rax 2.20 | cmp %rax,%r9 | jne 70Die Performance-counter-Resultate sind:
mm2-O2 500 392351753 cycles 888069827 instructions # 2.26 insns per cycle 263770 branch-misses 1538561 offcore_response_all_data_rd_llc_miss_dram 377404137 L1-dcache-loads 15921657 L1-dcache-load-misses # 4.22% of all L1-dcache hits 479594 LLC-loads 15398976 LLC-prefetches 377392786 dTLB-loads 20231 dTLB-load-misses # 0.01% of all dTLB cache hitsDie Zahl der Befehle hat sich zwar kaum geändert (beide inneren Schleifen haben 7 Befehle/Iteration), die Zahl der Zyklen ist aber um den Faktor 1.39 gesunken, und die innere Schleife braucht jetzt ca. 3 Zyklen/Iteration. Weiters ist noch die Anzahl der D-cache misses um den Faktor 7 gesunken: der Zugriff auf die Elemente von b erfolgt jetzt sequentiell, sodass die Cache lines (64 Bytes, also 8 Elemente) gut ausgenutzt werden, im Gegensatz zu mm1.
In dieser Version ist jede Iteration der inneren Schleife
unabhängig von den anderen, ideale Voraussetzungen für
Parallelisierung und Vektorisierung. Und mit -O3 vektorisiert gcc-5.4
den Code automatisch. Da der Prozessor AVX (256-bit Vektoren)
unterstützt, aktivieren wir das auch gleich mit (gcc -O3
-mavx
).
6.43 |1d7: vmovup (%r15,%rsi,1),%xmm0 11.24 | add $0x1,%r11 2.41 | mov -0x68(%rbp),%rax 3.41 | vinser $0x1,0x10(%r15,%rsi,1),%ymm0,%ymm0 15.26 | vmulpd %ymm1,%ymm0,%ymm0 5.42 | vaddpd (%rax,%rsi,1),%ymm0,%ymm0 35.74 | mov -0x70(%rbp),%rax 3.82 | vmovap %ymm0,(%rax,%rsi,1) 12.25 | add $0x20,%rsi 2.61 | cmp -0x60(%rbp),%r11 | jb 1d7Auch wenn der Code nicht besonders toll ist, ist durch die Vektorisierung die Anzahl der Iterationen um den Faktor 4 gesunken, und der Code entsprechend schneller:
mm2 500 180113115 cycles 365281243 instructions # 2.03 insns per cycle 264519 branch-misses 738836 offcore_response_all_data_rd_llc_miss_dram 192374945 L1-dcache-loads 15905883 L1-dcache-load-misses # 8.27% of all L1-dcache hits 1303267 LLC-loads 14905503 LLC-prefetches 192376408 dTLB-loads 15181 dTLB-load-misses # 0.01% of all dTLB cache hitsDiese Auto-Vektorisierung hat den Code um den Faktor 2.12 schneller gemacht. Die Anzahl der D-cache misses ist gleich geblieben (es werden ja immer noch die Zeilen der Vektoren sequentiell gelesen), aber da die Anzahl der Loads reduziert wurde, ist der Anteil der misses jetzt größer.
Eventuell können wir durch manuelle Vektorisierung mit Hilfe der GNU C Vector Extensions die Geschwindigkeit noch steigern: mm3.c. Dabei verwenden wir Vektoren mit vier doubles, passend zu den AVX-Befehlen, bei größeren Vektor-Längen generiert gcc schlechten Code. Das Ergebnis schaut besser aus:
|80: vbroad (%r12,%rsi,8),%ymm0 11.02 | vmulpd (%r9,%rax,1),%ymm0,%ymm0 41.63 | vaddpd (%rdx,%rax,1),%ymm0,%ymm0 25.92 | vmovap %ymm0,(%rdx,%rax,1) 13.67 | add $0x20,%rax 7.35 | cmp %rax,%rdi | jne 80benötigt deutlich weniger Befehle, und läuft auch etwas (Faktor 1.06) schneller.
mm3 500 170711879 cycles 231813320 instructions # 1.36 insns per cycle 264335 branch-misses 532507 offcore_response_all_data_rd_llc_miss_dram 96216732 L1-dcache-loads 15952462 L1-dcache-load-misses # 16.58% of all L1-dcache hits 3279321 LLC-loads 13719589 LLC-prefetches 99373015 dTLB-loads 31561 dTLB-load-misses # 0.03% of all dTLB cache hitsBei diesem Code fällt auf, dass der erste Befehl (Zugriff auf das Element von a) in jeder Iteration das gleiche Ergebnis liefert, was der Compiler aber offenbar nicht optimiert. Also machen wir das händisch: mm4.c.
double aik = a[i*m+k]; for (j=0; j<p; j++) c[i*p+j] += aik*b[k*p+j]; |a0: vmulpd (%rsi,%rax,1),%ymm1,%ymm0 59.48 | vaddpd (%rdx,%rax,1),%ymm0,%ymm0 21.77 | vmovap %ymm0,(%rdx,%rax,1) 17.46 | add $0x20,%rax 0.65 | cmp %rax,%r9 | jne a0Der resultierende Code führt zwar weniger Befehle aus, ist aber nicht nennenswert schneller:
mm4 500 168654314 cycles 201357678 instructions # 1.19 insns per cycle 263821 branch-misses 587776 offcore_response_all_data_rd_llc_miss_dram 65201141 L1-dcache-loads 15945752 L1-dcache-load-misses # 24.46% of all L1-dcache hits 2832521 LLC-loads 14149188 LLC-prefetches 65208359 dTLB-loads 34264 dTLB-load-misses # 0.05% of all dTLB cache hitsEventuell läuft der Code schon in Begrenzungen wie z.B. die Bandbreite zum L2 oder L3-cache.
Schauen wir uns jetzt wieder den Fall 700x700 an:
mm4 700 522556081 cycles 539783665 instructions # 1.03 insns per cycle 506069 branch-misses 26036094 offcore_response_all_data_rd_llc_miss_dram 176699150 L1-dcache-loads 43675603 L1-dcache-load-misses # 24.72% of all L1-dcache hits 26831980 LLC-loads 27657539 LLC-prefetches 176682383 dTLB-loads 729519 dTLB-load-misses # 0.41% of all dTLB cache hitsmm4 ist für diesen Fall 15 mal schneller als mm1 (für den 500x500-Fall 3 mal). Schon mm2-O2 (keine Vektorisierung) ist 7 mal so schnell. Da sowohl mm1 als auch alle folgenden Versionen in der mittleren und inneren Schleife die b-Matrix komplett lesen (bei den anderen Matrizen greifen sie nur auf eine einzige Zeile oder Spalte zu), ist die Anzahl der Hauptspeicherzugriffe nicht so weit auseinander, wie man an den offcore_response_all_data_rd_llc_miss_dram-Resultaten sieht. [Die Beschreibung dieses Events ist: "Counts all demand & prefetch data reads that miss the LLC and the data returned from dram". Zum Ermitteln dieses Events verwendete ich ocperf.py, das mehr Events kennt als perf.]
Daher sind die Hauptspeicherzugriffe nicht für den großen Geschwindigkeitsunterschied zwischen mm1 und mm4 bei 700x700 verantwortlich.
Was sonst erklärt den Unterschied? Ein auffälliger Unterschied zwischen mm1 und mm4 ist die grosse Anzahl der dTLB misses in mm1 700 (einer alle 24 Zyklen), während bei mm1 500 viel weniger dTLB misses passieren. mm1 hat auch einen höheren Anteil an L1 cache misses, aber wenn wir uns den 500x500-Fall anschauen, hat das offenbar in diesem Programm keine grosse Auswirkung, weil die Latenz der Ladebefehle sich nicht im kritischen Berechnungspfad befindet. Wenn ein dTLB miss ~20 Zyklen kostet (was durchaus realistisch ist), erklärt das den Unterschied schon.
Warum hat mm1 bei 700x700 so viele TLB misses, bei 500x500 nicht, und warum haben die anderen Versionen auch wenige TLB misses? mm1 700x700 greift in der inneren Schleife auf die Elemente eine Spalte der b-Matrix zu, greift also mit einer Schrittweite von 700*8=5600 Bytes auf diese Elemente zu; da die Seitengröße 4096 beträgt, braucht jeder Elementzugriff einen eigenen TLB-Eintrag. Da die Ivy Bridge-CPU 512 L2-TLB-Einträge hat, werden diese TLB-Einträge bei 700x700 nicht wiederverwendet (capacity miss), bei 500x500 dagegen schon.
mm2 und folgende greifen auf die Daten sequentiell zu, was überhaupt günstig für den TLB ist: jeder TLB-Eintrag deckt 512 sequentielle Elemente ab.
Bei meinen ersten Versuchen erhielt ich immer die langsamen Resultate für mm1 700, später dann oft 4 mal schnellere Resultate mit deutlich weniger dTLB misses. Das ist vermutlich auf die Verwendung von großen Seiten durch das Betriebssystem zurückzuführen. Als ich das mit
#als root echo never > /sys/kernel/mm/transparent_hugepage/enabledabgestellt habe, ergaben sich immer die langsamen Resultate. Umgekehrt kann man bei Problemen mit TLB misses auf verschiedene Arten große Seiten explizit verwenden, sodass man nicht auf Glück angewiesen ist, um sie zu bekommen.
Der nächste Schritt (mm5.c) reduziert die Anzahl der Lese- und Schreib-Operationen auf c, indem die mittlere Schleife um den Faktor 4 entrollt wird, aber das Unrolling auf den Inhalt der inneren Schleife angewandt wird (man kann es auch als Kombination von Loop Interchange und Loop Unrolling sehen):
for (k=0; k<m; k+=4) { double aik0 = a[i*m+k+0]; double aik1 = a[i*m+k+1]; double aik2 = a[i*m+k+2]; double aik3 = a[i*m+k+3]; for (j=0; j<p; j++) { v4d r; r = aik0*b[(k+0)*p+j]; r += aik1*b[(k+1)*p+j]; r += aik2*b[(k+2)*p+j]; r += aik3*b[(k+3)*p+j]; c[i*p+j] += r; } } 14.58 | f0: vmulpd (%r9,%rax,1),%ymm4,%ymm1 7.87 | vmulpd (%rsi,%rax,1),%ymm5,%ymm0 5.54 | vaddpd %ymm0,%ymm1,%ymm0 11.66 | vmulpd (%rdi,%rax,1),%ymm3,%ymm1 8.75 | vaddpd %ymm0,%ymm1,%ymm0 14.87 | vmulpd (%r10,%rax,1),%ymm2,%ymm1 13.12 | vaddpd %ymm0,%ymm1,%ymm0 16.62 | vaddpd (%rdx,%rax,1),%ymm0,%ymm0 4.66 | vmovap %ymm0,(%rdx,%rax,1) | add $0x20,%rax | cmp %rax,%r8 2.04 | jne f0Das Ergebnis ist um den Faktor 1.41 schneller:
mm5 500 119689365 cycles 106239905 instructions # 0.89 insns per cycle 76017 branch-misses 714395 offcore_response_all_data_rd_llc_miss_dram 41773156 L1-dcache-loads 16121145 L1-dcache-load-misses # 38.59% of all L1-dcache hits 8485471 LLC-loads 11168943 LLC-prefetches 41766213 dTLB-loads 31007 dTLB-load-misses # 0.07% of all dTLB cache hitsUm festzustellen, wieviel durch Speicherzugriffsoptimierung maximal zu holen ist, oder ob wir schon an den Grenzen des CPU-Kerns anstehen, wird mit limit1.c eine Variante getestet, bei dem die innere Schleife immer auf den gleichen Speicherbereich zugreift, und damit im L1-Cache bleibt (bei den von uns verwendeten Grössen). Dabei ergeben sich relativ grosse Schwankungen, eines der besseren Resultate ist:
limit1 620 98537134 cycles 212526062 instructions # 2.16 insns per cycle 110825 branch-misses 65077 offcore_response_all_data_rd_llc_miss_dram 78562067 L1-dcache-loads 799246 L1-dcache-load-misses # 1.02% of all L1-dcache hits 88883 LLC-loads 23823 LLC-prefetches 78560372 dTLB-loads 7452 dTLB-load-misses # 0.01% of all dTLB cache hitsDa wir hier dieselbe innere Schleife, aber eine andere Größe verwenden als bei mm5 500, vergleicht man am besten die insns per cycle (IPC). Hier sieht man, dass Speicheroptimierungen einiges bringen können.
Aber bevor wir uns dem zuwenden, optimieren wir die innere Schleife noch weiter (limit2.c); bei der aktuellen inneren Schleife werden immer 256bits aus b geladen, multipliziert, addiert, und zu c dazugezählt. Wenn man mit Werten von a aus zwei Zeilen multiplizieren würde (also die äussere Schleife um den Faktor 2 entrollen würde), könnte man die geladenen Werte aus b wiederverwenden und so die Anzahl der Loads reduzieren:
double ai0k0 = a[(i+0)*m+k+0]; double ai0k1 = a[(i+0)*m+k+1]; double ai0k2 = a[(i+0)*m+k+2]; double ai0k3 = a[(i+0)*m+k+3]; double ai1k0 = a[(i+1)*m+k+0]; double ai1k1 = a[(i+1)*m+k+1]; double ai1k2 = a[(i+1)*m+k+2]; double ai1k3 = a[(i+1)*m+k+3]; for (j=0; j<p; j++) { v4d bk0j = b[(k+0)*p+j]; v4d bk1j = b[(k+1)*p+j]; v4d bk2j = b[(k+2)*p+j]; v4d bk3j = b[(k+3)*p+j]; v4d ci0j = ai0k0*bk0j+ai0k1*bk1j+ai0k2*bk2j+ai0k3*bk3j; v4d ci1j = ai1k0*bk0j+ai1k1*bk1j+ai1k2*bk2j+ai1k3*bk3j; c[(i+0)*p+j] += ci0j; c[(i+1)*p+j] += ci1j; } 5.54 | b0: vmovap (%rsi,%rax,1),%ymm0 1.48 | vmovap (%r8,%rax,1),%ymm1 4.43 | vmulpd %ymm11,%ymm0,%ymm2 | vmovap (%rdi,%rax,1),%ymm13 4.43 | vmovap (%r10,%rax,1),%ymm12 0.37 | vmulpd %ymm10,%ymm1,%ymm3 8.86 | vmulpd %ymm6,%ymm1,%ymm1 0.37 | vaddpd %ymm3,%ymm2,%ymm3 3.32 | vmulpd %ymm9,%ymm13,%ymm2 | vaddpd %ymm2,%ymm3,%ymm3 4.06 | vmulpd %ymm8,%ymm12,%ymm2 3.69 | vaddpd %ymm2,%ymm3,%ymm3 21.03 | vaddpd (%rdx,%rax,1),%ymm3,%ymm3 7.01 | vmovap %ymm3,(%rdx,%rax,1) 2.95 | vmulpd %ymm7,%ymm0,%ymm3 | vmulpd %ymm5,%ymm13,%ymm0 7.38 | vaddpd %ymm1,%ymm3,%ymm2 | vaddpd %ymm0,%ymm2,%ymm1 1.85 | vmulpd %ymm4,%ymm12,%ymm0 | vaddpd %ymm0,%ymm1,%ymm0 11.44 | vaddpd (%rcx,%rax,1),%ymm0,%ymm0 4.43 | vmovap %ymm0,(%rcx,%rax,1) 5.17 | add $0x20,%rax | cmp %rax,%r9 | jne b0Das Ergebnis ist eine weitere leichte (Faktor 1.12) Verbesserung (diesmal wieder die Zyklen vergleichen, die Größe ist gleich, dafür die Schleife verschieden):
limit2 620 87871273 cycles 204003567 instructions # 2.32 insns per cycle 62162 branch-misses 66178 offcore_response_all_data_rd_llc_miss_dram 52552935 L1-dcache-loads 1441947 L1-dcache-load-misses # 2.74% of all L1-dcache hits 86066 LLC-loads 23600 LLC-prefetches 48666220 dTLB-loads 6135 dTLB-load-misses # 0.01% of all dTLB cache hitsDa mm5 offenbar für 500x500 und 700x700 vom Speichersystem deutlich gebremst wird, werden Speicheroptimierungen voraussichtlich etwas bringen.
Bei der Matrizenmultiplikation wird ja je nach Organisation auf die diversen Elemente der Matrix mehrfach im Speicher zugegriffen. Bei der in mm5 700 gewählten Organisation wird auf jedes Element von a ein mal, auf jedes Element von b 700 mal, und auf jedes Element von c 700/4 mal zugegriffen, wobei bei c die 175 Zugriffe auf eine Zeile erfolgen, bevor die nächste Zeile drankommt (zeitliche Lokalität).
In limit2 wird dagegen ein Element von b mit zwei Elementen von a multipliziert, und eine darauf aufbauende Organisation würde die Anzahl der Speicherzugriffe auf b halbieren. Wenn man das ins Extrem treibt, und jedes Element von b nur einmal aus dem Speicher lädt (indem die i-Schleife zur inneren Schleife wird), muss man dafür jedes Element von a 700 mal aus dem Speicher laden (und zwar noch in einer Cache- und TLB-feindlichen Art), und auch jedes Element von c muss 700 mal aus dem Speicher geladen werden und wieder zurückgeschrieben werden, ebenfalls in einer Cache-feindlichen Art.
Durch einfachen loop interchange geht es also nicht, man muss auch Elemente von a bzw. c wiederverwenden, bevor sie aus dem Cache fliegen. Wir können r Elemente von a mit s Elementen von b Multiplizieren, sodass diese r+s Elemente in einen bestimmten Cache-Level passen, und dabei r*s der notwendigen Multiplikationen erledigen (also r-mal weniger Hauptspeicherzugriffe auf b nötig sind als bei mm5); durch geeignete Wahl dieser Elemente kann man auch die Zugriffe auf c gering halten.
Man kann dieses Cache-Blocking auf die Cache-Größe abstimmen, was angesichts von drei Cache-Levels auf der Zielmaschine recht kompliziert wäre. Daher verwenden wir als Alternative einen rekursiven Algorithmus, der in jedem Schritt eine Dimension der Matrizen halbiert und damit die Wiederverwendung auf vielen Ebenen betreibt, von denen einige auf die Cache-Größen passen werden.
Wikipedia empfielt dafür, dass man in allen drei Indizes die Größe halbiert. Ich entschied mich jedoch dafür, lieber die innere (j-)Schleife in voller Länge zu erhalten, weil für unsere Problemgrößen die Daten für eine Iteration (700*4 Elemente, also 22400 Bytes) noch in den L1-Cache passen und der Overhead der Rekursion in dieser Dimension voraussichtlich eine größere Rolle spielen würde. Daher halbiert meine Lösung in jedem Schritt nur den Bereich der Indizes i und k. Das Ergebnis (bei Verwendung der inneren Schleife von mm5/limit1) ist mm6.c.
mm6 500 80226640 cycles 114881301 instructions # 1.43 insns per cycle 94443 branch-misses 185095 offcore_response_all_data_rd_llc_miss_dram 42020170 L1-dcache-loads 5943632 L1-dcache-load-misses # 14.14% of all L1-dcache hits 750481 LLC-loads 1217332 LLC-prefetches 42012886 dTLB-loads 17517 dTLB-load-misses # 0.04% of all dTLB cache hitsDurch Verwendung der inneren Schleife von limit2 ergibt sich mm7.c und folgendes Ergebnis:
mm7 500 65259545 cycles 118610774 instructions # 1.82 insns per cycle 47196 branch-misses 173685 offcore_response_all_data_rd_llc_miss_dram 26424090 L1-dcache-loads 6118630 L1-dcache-load-misses # 23.16% of all L1-dcache hits 904561 LLC-loads 1307343 LLC-prefetches 26417507 dTLB-loads 14207 dTLB-load-misses # 0.05% of all dTLB cache hitsDas ist 8.8 mal schneller als mm1. Bei 700x700 ist das Ergebnis 44 mal schneller als mm1:
mm7 700 181139530 cycles 314590731 instructions # 1.74 insns per cycle 89830 branch-misses 607075 offcore_response_all_data_rd_llc_miss_dram 70001888 L1-dcache-loads 24912916 L1-dcache-load-misses # 35.59% of all L1-dcache hits 3827399 LLC-loads 4830259 LLC-prefetches 70032725 dTLB-loads 37095 dTLB-load-misses # 0.05% of all dTLB cache hitsDiese für das Speichersystem optimierten Varianten sind auch besser fuer die Parallelisierung geeignet. Statt diese Programme jetzt tatsächlich zu parallelisieren, schauen wir einfach, wie lange sie brauchen, wenn man 2 bzw. 4 von ihnen auf der Zielhardware (mit 2 Cores und (per Hyperthreading) 4 threads laufen läßt, z.B. mit diesem Befehl:
perf stat -r 100 -e cycles -e instructions mm5 700 >/dev/null 2>/dev/null & perf stat -r 90 -e cycles -e instructions mm5 700Für 700x700-Matrizen ergibt das folgende Zyklenzahlen pro Ausführung:
1 2 4 gleichzeitige Ausführungen 403.330.094 846.376.818 1.882.081.370 mm5 700 180.441.876 204.305.921 406.841.878 mm7 700Eine Zweifach-Parallelisierung von mm5 würde sich auf dieser Hardware offenbar nicht auszahlen, weil sich die Laufzeit jedes Threads mehr als verdoppeln würde. Bei Vierfach entsprechend. Bei mm7 ist von einer Zweifach-Parallelisierung dagegen eine gute Beschleunigung zu erwarten; Hyperthreading würde zwar hier nichts bringen, aber auch nicht schaden (doppelt so viele Threads, die halb so schnell wären).
Noch ein Vergleich mit zwei ernsthaften Implementierungen der BLAS-Routine DGEMM:
atlas 700 220709112 cycles 622853379 instructions # 2.82 insns per cycle 58938 branch-misses 1047615 offcore_response_all_data_rd_llc_miss_dram 185087891 L1-dcache-loads 5254856 L1-dcache-load-misses # 2.84% of all L1-dcache hits 388303 LLC-loads 1772143 LLC-prefetches 185111573 dTLB-loads 26917 dTLB-load-misses # 0.01% of all dTLB cache hits openblas 700 #nur ein core und thread 117681171 cycles 289004803 instructions # 2.46 insns per cycle 28695 branch-misses 520477 offcore_response_all_data_rd_llc_miss_dram 50593095 L1-dcache-loads 11867478 L1-dcache-load-misses # 23.46% of all L1-dcache hits 7422120 LLC-loads 7992466 LLC-prefetches 50578398 dTLB-loads 42115 dTLB-load-misses # 0.08% of all dTLB cache hitsAtlas ist um den Faktor 1.22 langsamer als mm7, OpenBLAS dagegen um den Faktor 1.54 schneller. Bei den Events gibt es auch Unterschiede, die auf deutliche Unterschiede in der Implementierung deuten.
Openblas kann auch mehrere Threads nutzen (mit der environment
variable OMP_NUM_THREADS
, oder durch Abschalten von
threads):
Openblas 700 0.067s 1 core 1 thread 0.075s 1 core 2 threads 0.042s 2 cores 1 thread/core 0.048s 2 cores 2 threads/coreDie Verwendung eines zweiten Cores resultiert in einer Beschleunigung um den Faktor 1.6, die Verwendung von Hyperthreading allerdings in einer Verlangsamung; offenbar nutzt schon ein Thread die CPU gut aus, und zwei auf dem gleichen Core steigen sich gegenseitig auf die Füße.
![]() | Name | Last modified | Size | Description |
---|---|---|---|---|
![]() | Parent Directory | - | ||
![]() | Makefile | 2021-02-11 00:42 | 1.3K | |
![]() | atlas | 2021-02-10 17:40 | 17K | |
![]() | blas | 2021-02-11 14:12 | 24M | |
![]() | limit1 | 2021-02-10 17:40 | 17K | |
![]() | limit1.c | 2017-11-16 14:24 | 563 | |
![]() | limit1.o | 2021-02-10 17:40 | 1.3K | |
![]() | limit1a.c | 2017-11-15 13:25 | 454 | |
![]() | limit1a.o | 2021-02-10 17:40 | 1.6K | |
![]() | limit2 | 2021-02-10 17:40 | 17K | |
![]() | limit2.c | 2017-11-15 19:09 | 865 | |
![]() | limit2.o | 2021-02-10 17:40 | 1.4K | |
![]() | limit2a.c | 2017-11-15 19:02 | 455 | |
![]() | limit2a.o | 2021-02-10 17:40 | 1.6K | |
![]() | limit3 | 2021-02-10 17:40 | 17K | |
![]() | limit3.c | 2017-11-16 11:44 | 945 | |
![]() | limit3.o | 2021-02-10 17:40 | 1.4K | |
![]() | limit3a.c | 2017-11-15 19:02 | 455 | |
![]() | limit3a.o | 2021-02-10 17:40 | 1.6K | |
![]() | main.c | 2017-12-01 19:40 | 1.3K | |
![]() | main.o | 2021-02-10 17:40 | 3.6K | |
![]() | mainblas.c | 2017-12-03 18:56 | 1.4K | |
![]() | mainblas.o | 2021-02-10 17:40 | 3.7K | |
![]() | matmul.500 | 2017-11-07 15:42 | 1.9M | |
![]() | matmul.700 | 2017-11-05 23:58 | 3.7M | |
![]() | matmul.out | 2024-10-28 14:44 | 122M | |
![]() | mm1 | 2021-02-10 17:40 | 17K | |
![]() | mm1.c | 2017-11-06 12:15 | 268 | |
![]() | mm1.o | 2021-02-10 17:40 | 1.5K | |
![]() | mm2 | 2021-02-10 17:40 | 17K | |
![]() | mm2-O2 | 2021-02-10 17:40 | 17K | |
![]() | mm2-O2.o | 2021-02-10 17:40 | 1.6K | |
![]() | mm2.c | 2017-11-07 16:21 | 296 | |
![]() | mm2.o | 2021-02-10 17:40 | 2.0K | |
![]() | mm3 | 2021-02-10 17:40 | 17K | |
![]() | mm3.c | 2017-11-07 17:28 | 420 | |
![]() | mm3.o | 2021-02-10 17:40 | 1.6K | |
![]() | mm4 | 2021-02-10 17:40 | 17K | |
![]() | mm4.c | 2017-11-07 17:46 | 452 | |
![]() | mm4.o | 2021-02-10 17:40 | 1.6K | |
![]() | mm5 | 2021-02-10 17:40 | 17K | |
![]() | mm5.c | 2017-11-15 12:40 | 694 | |
![]() | mm5.o | 2021-02-10 17:40 | 1.8K | |
![]() | mm6 | 2021-02-10 17:40 | 17K | |
![]() | mm6.c | 2017-11-16 13:49 | 1.6K | |
![]() | mm6.o | 2021-02-10 17:40 | 2.4K | |
![]() | mm7 | 2021-02-10 17:40 | 17K | |
![]() | mm7.c | 2017-11-17 19:35 | 1.9K | |
![]() | mm7.o | 2021-02-10 17:40 | 2.5K | |
![]() | mm8 | 2021-02-10 17:40 | 17K | |
![]() | mm8.c | 2017-11-17 19:54 | 2.1K | |
![]() | mm8.o | 2021-02-10 17:40 | 2.4K | |
![]() | mm9 | 2021-02-10 17:40 | 17K | |
![]() | mm9.c | 2017-11-16 13:47 | 1.9K | |
![]() | mm9.o | 2021-02-10 17:40 | 2.5K | |
![]() | mma | 2021-02-10 17:40 | 17K | |
![]() | mma.c | 2021-02-08 14:45 | 2.6K | |
![]() | mma.o | 2021-02-10 17:40 | 2.4K | |
![]() | mmb | 2021-02-11 23:57 | 9.9K | |
![]() | mmb.c | 2021-02-10 15:35 | 1.4K | |
![]() | mmb.o | 2021-02-10 17:40 | 2.3K | |
![]() | mmbj.o | 2021-02-10 17:40 | 1.5K | |
![]() | mmbj.s | 2021-02-10 15:35 | 2.0K | |
![]() | mmc | 2021-02-11 23:57 | 9.9K | |
![]() | mmc.o | 2021-02-11 14:08 | 1.5K | |
![]() | mmc.s | 2021-02-11 00:42 | 2.0K | |
![]() | perf.data | 2021-02-10 16:38 | 447K | |
![]() | perf.data.old | 2017-12-03 19:03 | 112K | |