Beruflich Dokumente
Kultur Dokumente
Know-how | Matrixmultiplikation
Andreas Stiller
Matrix
reloaded
Von Matrizen und magischen Compiler-Fhigkeiten
Programme, die mit blichen Matrixroutinen arbeiten, knnen hufig ohne jegliche
nderung des Codes einfach durch die magischen Krfte moderner Compiler um
Grenordnungen schneller laufen. Nicht von solch lppischen Faktoren wie zwei
oder drei ist dabei die Rede, sondern von hundert oder gar tausend.
ie kommt es, dass dieser harmlose C99Code zur Berechnung des Matrixproduktes aus zwei quadratischen Matrizen mit
Code von Compiler A gut tausendmal so
schnell luft wie mit dem von Compiler B?
#define DIM 1024
typedef double mat[DIM][DIM];
mat a,b,c;
void matrixmul_kji () {
for (int i = 0; i < DIM; i++)
for (int j = 0; j < DIM; j++)
c[i][j] = 0.0;
for (int k = 0; k < DIM; k++)
for (int j = 0; j < DIM; j++)
for (int i = 0; i < DIM; i++)
c[i][j] += a[i][k] * b[k][j];
}
//Hauptroutine mit Initialisierung
//der Matrizen und Aufruf
Ja, Sie haben recht gelesen: Faktor 1000 oder
sogar noch ein Stckchen mehr. So ein Unterschied lsst sich nicht so schnell durch
leistungsfhigere Hardware wettmachen.
Auf einem aktuellen Notebook mit HaswellProzessor Core i7- 4750HQ unter Windows
8.1 braucht der Code des ersten Compilers
17 Sekunden (entsprechend 0,13 GFlops),
der des zweiten nur 15 Millisekunden (138
GFlops).
Dabei handelt es sich bei ersterem nicht
etwa um einen veralteten Compiler aus der
DOS-Grabbelkiste (640 KByte htten fr solch
groe Matrizen eh nicht gereicht ), sondern um die aktuelle Version von Microsoft
Visual Studio 2013 (Update 1 beziehungsweise Update 2RC). Der Microsoft-Compiler
muss sich in diesem Matrix-Quizduell allerdings mit dem Intel-C/C++-Compiler 14 aus
dem Composer 2013 messen. Beide drfen
dabei die hchsten Optimierungsstufen fr
die Zielrechner mit Haswell-Kernen (AVX2)
fahren. Okay, der Intel-Compiler beherrscht
ein paar Zaubertricks, sonst wrde dieses
Wunder nicht geschehen.
Obiger Code ist allerdings etwas gruselig,
und zwar gleich aus mehreren Grnden. Er
verwendet global definierte Felder, auf die
matrixmul_kji() zugreift solche Konstruktionen
lassen einem C++-Programmierer kalte
Schauer den Rcken hinunterlaufen. Gruselig
ist aber insbesondere die absichtlich bsartig
gewhlte Reihenfolge der Schleifen mit
k-j-i; in dieser Form haben nmlich die Zugriffe ohne intelligente Umsortierung ein katastrophal schlechtes Cache-Verhalten.
Das lsst sich mit einem Blick auf die innerste Schleife schon erahnen, wenn man
wei, dass C++-Compiler blicherweise
zweidimensionale Felder auf [row*dim+col]
ablegen, was man als row major bezeichnet. Dann liegen die Daten von aufeinanderfolgenden Spalten kontinuierlich im
Speicher, die von aufeinanderfolgenden
Zeilen hpfen hingegen. Die innere
Schleife luft nun sowohl bei Feld c als auch
bei Feld a ber den Zeilenindex i. Dabei ist
zwar der Faktor b[k][j] konstant, die Zugriffe
auf die anderen beiden Matrizenelemente
Naive Reihenfolge
Die Reihenfolge k-j-i ist also zugegebenermaen herbe konstruiert, blicherweise findet man die natrliche oder naive Schleifenreihenfolge i-j-k vor, so wie es die Multiplikationsformel der Matrizenmultiplikation
auch nahelegt. Die vergleichsweise uninteressante Routine zum Nullsetzen der Ergebnismatrix C vorab schenken wir uns aus
bersichts- und Platzgrnden im Folgenden
so was kann man auch effizient mit memset()
erledigen. Muss man aber nicht, intelligente
Compiler entdecken das ohnehin selbst und
ersetzen die Nullsetzschleife durch einen Einsprung in die Laufzeitbibliothek.
void matrixmul_ijk () { //naiv
// Nullsetzen von c
for (int i = 0; i < DIM; i++)
for (int j =0; j < DIM; j++)
for (int k = 0; k < DIM; k++)
c[i][j] += a[i][k] * b[k][j];
}
Wo man auch hinschaut, landauf, landab,
berall sind solche Routinen mit Reihenfolge
i-j-k zu finden, in unzhligen Programmbeispielen und Tutorials, unter anderem auch in
Entwicklungssystemen von Intel, Nvidia,
AMD, Microsoft und so weiter. In denen geht
es den Firmen ja hufig darum, die Vorzge
ihrer Bibliotheken oder ihrer Rechenbeschleuniger in den Vordergrund zu stellen,
176
ct 2014, Heft 12
Know-how | Matrixmultiplikation
Pyrrhus-Sieg
Seit Visual Studio 2012 beherrscht nun auch
der Microsoft-Compiler Autovektorisierung.
Der erste Versuch war allerdings noch uerst
rudimentr und schaffte nicht einmal solche
einfachen Konstrukte wie die in obiger Routine matrixmul_ikj(). Visual Studio 2013 ist jedoch
ein gutes Stckchen weiter und vermag immerhin diese Konstrukte fr SSE3 oder AVX
zu vektorisieren. Den Vektorisierungserfolg
kann man sich durch einen Report ber die
Option /Qvec-report:1 anzeigen lassen. Im Visual
Studio gibt es fr den Report bislang noch
keinen eigenen Menpunkt, den Reportwunsch muss man zu Fu unter den zustzlichen Optionen eintragen.
Allein das Ganze stellte sich fr AVX als
Pyrrhus-Sieg heraus, denn der vektorisierte
Code erwies sich nicht nur als nicht schneller,
sondern war erheblich langsamer als der ska-
ct 2014, Heft 12
Matrixmultiplikation
Sie ist das Arbeitspferd im High Performance Computing, die Matrixmultiplikation, die in den Mathematik-Bibliotheken
(BLAS) meist unter SGEMM (Single Precision) und DGEMM (Double Precision) zu
finden ist. Auch bei grafischen Berechnungen, beim Rendern, in der Finanzanalyse und in vielen anderen Bereichen
wird sie hufig bentigt. Die klassische
Berechnungsformel fr jedes Element
der Ergebnismatrix sieht so aus:
n
177
Know-how | Matrixmultiplikation
Add (FMA) verwendet. Das bietet der Haswell-Prozessor gleich in zwei parallelen Pipelines an jedenfalls, wenn man keinen abgespeckten Celeron oder Pentium besitzt. Der
Visual-Studio-2013-Compiler kennt zwar das
Architekturflag /arch:AVX2 aber der Autovektorisierer offenbar noch nicht. Zudem
wird die Option im Visual-Studio-Men erst
ab dem neuen Upgrade 2 angeboten.
Rein theoretisch sind beim Haswell mit
seinen beiden FMA-Pipelines bei voller Vektorisierung und optimalem Data Reuse
16doppeltgenaue Flops pro Takt drin, beim
Grundtakt von 2,8 GHz also 44,8 GFlops pro
Kern. Microsofts Compiler kommt demnach
im besten Fall auf knapp 20 Prozent Effizienz.
Mit viel Aufwand kann man nun die DataReuse-Quote noch etwas steigern.
Kacheln
In der einfachsten Form des Tiling wird nur
die mittlere Schleife gekachelt, indem man
sie auf zwei Schleifen aufteilt. Die erste luft
mit Schrittweite n, die zweite ber die Zwischenwerte. Der beste Wert von n hngt von
der Gre des L1-Cache ab, beim Haswell hat
sich n=16 gut bewhrt, das bringt dann mit
dem Microsoft-Compiler immerhin weitere
40 Prozent auf 8,7 GFlops.
void matrixmul_ikjB(int n, int dim) {
// Nullsetzen von c
for (int k0 = 0; k0 < dim; k0 += n)
for (int i = 0; i < dim; i++)
for (int k = k0; k < min (dim,k0 + n); k++)
#pragma loop (no_vector)
for (int j = 0; j < dim; j++)
c[i][j] += a[i][k] * b[k][j]; }
Umgerechnet auf den effektiven Takt von
2, 8 GHz sind das 3,1 Flops pro Takt. Gar nicht
so schlecht, wenn man bedenkt, dass das die
skalare Performance ist, und der MicrosoftCompiler noch nicht einmal Fused Multiply
Parallel
Man kann das Ganze aber auch noch parallelisieren. Microsofts 2013-Compiler bieten
hierfr als neues Feature Autoparallelisierung via /Qpar an, aber die steckt offenbar
noch tiefer in den Kinderschuhen als die
Autovektorisierung. Jedenfalls funktionierte
sie mit keiner der zahlreich ausprobierten
Matrixmultiplikationsvarianten.
Aber wenn man den Code minimal ndern darf, braucht man sie auch nicht, denn
die Parallelisierung kann man mit einem einzigen richtig platzierten OpenMP-Pragma
bequem selbst veranlassen so man ber
eine kommerzielle Version des Compilers
verfgt und nicht nur ber die kostenlose Expressversion, der Microsoft weder Autoparallelisierung noch OpenMP gegnnt hat.
Der eingesetzte Haswell-Prozessor bietet
vier physische Kerne auf, das msste eigentlich die Performance vervierfachen knnen
aber nur dann, wenn sich die Kerne bei ihren
Zugriffen nicht ins Gehege kommen. Ganz
schafft OpenMP das ohne weitere Manahmen nicht, aber immerhin ist eine Verdreifachung auf 29 Gflops drin.
Intels Zauberknste
Der Trick, den Intel verwendet und der die
eingangs erwhnte gigantische Beschleunigung bewirkt, msste Autobibliothekisierung heien. Diese Zauberei wird mit /Qoptmatmul /Qopenmp aktiviert oder auch bereits
mit /Qpar in den hohen Optimierungsstufen
/O3 oder /fast. Dann versucht der Compiler,
lohnenswerte Matrixmultiplikationen im
Code selbststndig zu erkennen und durch
Einsprnge in die Math Kernel Library (MKL)
zu ersetzen. Die Erkennungslogik ist allerdings uerst sensibel, kleine Variationen
des Codes und sie gibt auf. Aber die hier gezeigten einfachen Routinen, sowohl die mit
den statischen Feldern als auch die mit den
dynamischen Pointern gem matrixmulP_xxx
schafft sie locker das Ergebnis liegt dann
wie eingangs erwhnt bei bis zu 140 GFlops
auf dem Quad-Core mit 2,8 GHz Grundtakt.
Bei Prozessoren mit Hyper-Threading sollte
man die Environment-Variable set KMP_AFFINITY=scatter setzten, sonst schwankt das Resultat erheblich.
Hufig findet man jedoch einen etwas allgemeiner gehaltenen Aufruf, so wie bei
DGEMM der verbreiteten BLAS-Bibliotheken
(Basic Linear Algebra Subprogram) auch. Die
sogenannte leading dimension der Felder
kann man nmlich grer whlen, als man es
fr die jeweilige Matrix braucht. Dadurch lassen sich Alignment-Konflikte in den Caches
vermeiden, was ebenfalls der Performance
zugutekommt. Daher sieht der DGEMM-Aufruf fr jede der drei bergebenen Matrizen
eine leading dimension vor (lda, ldb, ldc), in
unserem vereinfachten Fall reicht eine namens dim0.
178
ct 2014, Heft 12
Know-how | Matrixmultiplikation
Reihenfolge
jki
ijk
ikj
jki
ijk
ikj
jki
ijk
ikj
jki
ijk
ikj
jki
ijk
ikj
ikj
ikj
(auto)
0,12
6,51
5,1
0,12
0,26
5
0,12
0,27
6,4
0,12
0,27
6,4
0,12
0,27
6,4
4,5
15,7
37
140
/Qopt-matmul /QopenMP
110140
110140
110140
110140
110140
110140
110140
110140
110140
110140
110140
110140
0,12
0,23
6,6
9,5
62
37
140
Alternativen
Es gibt ein Reihe Alternativen zu den hier
vorgestellten klassischen Routinen. Fr verteiltes Rechnen (etwa via MPI) ist der Cannon-Algorithmus besser geeignet, der aber
lediglich die Indizierung etwas variiert. Mathematisch interessanter sind andere Ideen.
ct 2014, Heft 12
Vor rund 45 Jahren hat der deutsche Mathematiker Volker Strassen gezeigt, dass man
eine 2x2-Multiplikation statt klassisch mit 8
Multiplikationen und 4 Additionen auch mit
7 Multiplikationen und 18 Additionen (spter
dann auch mit nur 15) hinbekommen kann.
Der Algorithmus lsst sich rekursiv auf grere Matrizen anwenden und so ab
2048x2048 braucht man bei ihm sogar insgesamt weniger Gleitkommaoperationen als
bei der normalen Matrixmultiplikation.
Whrend bei jener die sogenannte Komplexitt mit der Dimension kubisch ansteigt,
betrgt der Exponent bei Straen nmlich
nur noch 2,81. Findige Mathematiker haben
das Verfahren im Laufe der Zeit noch deutlich verbessert, quasi einen Wettbewerb zur
Minimierung des Komplexittsfaktors begonnen. Derzeit liegt der Rekord von Franois le Gall bei 2,3728639 (April 2014). Le Gall
hat auerdem aufgezeigt, dass man mit
Quantenalgorithmen noch schneller sein
kann. Aber all diese schnen Algorithmen
sind weitgehend akademisch und, wenn
berhaupt, nur fr sehr groe Matrizen geeignet. Sie lassen sich zudem schwer implementieren, insbesondere vektorisieren und
in FMA-Pipelines packen, fr Caches optimieren und so weiter. Vor allem aber leiden sie
unter einer geringeren numerischen Stabilitt, sodass die Ergebnisse ungenauer sind.
Neben den hier betrachteten normalen
vollbesetzten machen verstrkt auch die
dnnbesetzten Matrizen (sparse matrices)
von sich reden. Ihre Elemente sind berwiegend null, fr sie gibt es dann vllig andere
Herausforderungen, Algorithmen und Optimierungen. Sie bilden die Grundlage des
nchsten groen HPC-Benchmarks, der dem
Linpack zur Seite gestellt werden soll. Mit
ihnen und dem neuen Benchmark beschftigen wir uns in einer der nchsten Ausgaben.
(as) c
179