Sie sind auf Seite 1von 4

Know-how | Matrixmultiplikation

Andreas Stiller

Matrix

reloaded

Von Matrizen und magischen Compiler-Fähigkeiten

Programme, die mit üblichen Matrixroutinen arbeiten, können häufig ohne jegliche Änderung des Codes einfach durch die magischen Kräfte moderner Compiler um Größenordnungen schneller laufen. Nicht von solch läppischen Faktoren wie zwei oder drei ist dabei die Rede, sondern von hundert oder gar tausend.

W ie kommt es, dass dieser harmlose C99- Code zur Berechnung des Matrixpro-

duktes aus zwei quadratischen Matrizen mit Code von Compiler A gut tausendmal so schnell läuft 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 Stückchen mehr. So ein Un- terschied lässt sich nicht so schnell durch leistungsfähigere Hardware wettmachen. Auf einem aktuellen Notebook mit Haswell- Prozessor 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 hätten für solch

176

große Matrizen eh nicht gereicht …), son- dern um die aktuelle Version von Microsoft Visual Studio 2013 (Update 1 beziehungs- weise Update 2RC). Der Microsoft-Compiler muss sich in diesem Matrix-Quizduell aller- dings mit dem Intel-C/C++-Compiler 14 aus dem Composer 2013 messen. Beide dürfen dabei die höchsten Optimierungsstufen für die Zielrechner mit Haswell-Kernen (AVX2) fahren. Okay, der Intel-Compiler beherrscht ein paar Zaubertricks, sonst würde dieses Wunder nicht geschehen. Obiger Code ist allerdings etwas gruselig, und zwar gleich aus mehreren Gründen. Er verwendet global definierte Felder, auf die matrixmul_kji() zugreift – solche Konstruktionen lassen einem C++-Programmierer kalte Schauer den Rücken hinunterlaufen. Gruselig ist aber insbesondere die absichtlich bösartig gewählte Reihenfolge der Schleifen mit „k-j-i“; in dieser Form haben nämlich die Zu- griffe ohne intelligente Umsortierung ein ka- tastrophal schlechtes Cache-Verhalten. Das lässt sich mit einem Blick auf die in- nerste Schleife schon erahnen, wenn man weiß, dass C++-Compiler üblicherweise zweidimensionale Felder auf [row*dim+col] ablegen, was man als „row major“ bezeich- net. Dann liegen die Daten von aufeinan- derfolgenden Spalten kontinuierlich im Speicher, die von aufeinanderfolgenden Zeilen „hüpfen“ hingegen. Die innere Schleife läuft 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

© Copyright by Heise Zeitschriften Verlag

hüpfen jedoch Cache-feindlich quer durch den Speicher.

Naive Reihenfolge

Die Reihenfolge k-j-i ist also zugegebener- maßen herbe konstruiert, üblicherweise fin- det man die „natürliche“ oder „naive“ Schlei- fenreihenfolge i-j-k vor, so wie es die Multi- plikationsformel der Matrizenmultiplikation auch nahelegt. Die vergleichsweise uninte- ressante Routine zum Nullsetzen der Ergeb- nismatrix C vorab schenken wir uns aus Übersichts- und Platzgründen 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 Ein- sprung 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 unzähligen Programmbei- spielen und Tutorials, unter anderem auch in Entwicklungssystemen von Intel, Nvidia, AMD, Microsoft und so weiter. In denen geht es den Firmen ja häufig darum, die Vorzüge ihrer Bibliotheken oder ihrer Rechenbe- schleuniger in den Vordergrund zu stellen,

c’t 2014, Heft 12

Persönliches PDF für Thorsten Grahs move-csc aus 38108 Braunschweig

da schadet es nicht, dass die Haupt-CPU mit dem Referenzcode möglichst alt aussieht. Man weiß nämlich seit es Caches gibt, dass auch die i-j-k-Reihenfolge äußerst un- glücklich ist, da zumindest eine Matrix (hier b) in der innersten Schleife über den „hüp- fenden“ Zeilenindex „k“ angesprochen wird. Das ist aber nicht nur suboptimal für die Cache-Ausnutzung, sondern erschwert auch die Vektorisierung für SIMD (SSE oder AVX). Immerhin, es „hüpft“ dabei nur noch eine Matrix – damit ist die Performance dieser Version schon etwa doppelt so hoch wie die obiger Worst-Case-Fassung, jedenfalls bei den Microsoft-Compilern bis hin zu Visual Studio 2012. Bei den 2013-Compilern ist der Zugewinn indes deutlich höher, die können schon ein bisschen Magie. Die weitaus bessere Wahl ist nämlich die Reihenfolge i-k-j.

void matrixmul_ikj () { // Nullsetzen von c for (int i = 0; i < DIM; i++) for (int k = 0; k < DIM; k++) for (int j = 0; j < DIM; j++) c[i][j] += a[i][k] * b[k][j];

}

In der inneren Schleife über j bleibt a[i][k] net- terweise konstant. So was merkt ein Compiler heutzutage selbst, da braucht ein Program- mierer den Faktor nicht mehr explizit vor die Schleife zu setzen. Wichtig ist vor allem, dass die Daten der beiden verbleibenden Elemen- te spaltenweise, also fortlaufend adressiert werden. Das ist prima, denn dann kann der Code die geladenen Cachelines gleich samt Nachbar (adjacent Cacheline) in einem Rutsch ausnutzen. Die Zugriffe der inneren Schleife laufen damit im sogenannten Strea- ming-Modus und das lohnt sich deutlich, spielt auf neueren Prozessoren mal eben Per- formancegewinne in der Größenordnung von Faktor 20, 30 und mehr ein. Unser Has- well-Kern erreicht bei ikj dann schon mal mit den Microsoft-Compilern knapp über 5 statt nur 0,12 GFlops, egal ob man zusätzlich das Architekturflag /arch:AVX gesetzt hat oder nicht.

Pyrrhus-Sieg

Seit Visual Studio 2012 beherrscht nun auch der Microsoft-Compiler Autovektorisierung. Der erste Versuch war allerdings noch äußerst rudimentär und schaffte nicht einmal solche einfachen Konstrukte wie die in obiger Routi- ne matrixmul_ikj(). Visual Studio 2013 ist jedoch ein gutes Stückchen weiter und vermag im- merhin diese Konstrukte für 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 für den Report bislang noch keinen eigenen Menüpunkt, den Report- wunsch muss man „zu Fuß“ unter den zusätz- lichen Optionen eintragen. Allein das Ganze stellte sich für AVX als Pyrrhus-Sieg heraus, denn der vektorisierte Code erwies sich nicht nur als nicht schneller, sondern war erheblich langsamer als der ska-

c’t 2014, Heft 12

lare. Dummerweise – man mag meinen:

typisch Microsoft – ist die Autovektorisierung standardmäßig bei /O2 immer eingeschaltet und ein generelles Abschaltflag wie Intel mit (/Qvec- hat Microsoft nicht spezifiziert. So kann man lediglich durch das Pragma #pragma loop(no_vector) vor allen in Frage kommenden Schleifen eine misslungene Autovektorisie- rung unterbinden. Der Verzicht führt im vor- liegenden Fall immerhin zu 50 Prozent schnel- lerem Code, sprich zu 6,5 GFlops. Mit SSE3 kommt der Autovektorisierer indes etwas bes- ser zurecht, hier ist der vektorisierte Code mit 5,4 GFlops um gut ein Viertel schneller als die skalare Fassung mit 3,2 GFlops. Die Autovektorisierung bei Microsoft steckt also noch in den Kinderschuhen, aber die neueren Visual-Studio-Versionen beherr- schen dafür was anders, was die 2012er Ver- sion noch nicht kannte, nämlich eine „Auto- permutation“. Der Compiler erkennt zumin- dest in einigen Fällen die unglückliche Rei- henfolge von i-j-k und dreht sie in i-k-j um – allerdings eigenmächtig und ohne jeden Re- port. Es kann also durchaus sein, dass Ihr alter Code, allein damit neu kompiliert, per- formancemäßig geradezu explodiert. Es muss aber nach unseren zahlreichen Experi- menten genau obiges Konstrukt mit globa- len Feldern sein, sonst erkennt der Permuta- tor das nicht. Daher der gruselige Code, doch wer programmiert so? Die Permutationen funktionieren zudem nur bei aktivem Autovektorisierer und mit der naiven Reihenfolge i-j-k sowie j-i-k. Oft findet man diese Version aber in minimal veränderter Fassung vor, etwa mit der zweckmäßig aussehenden Nullsetzung von c direkt vor der innersten Schleife:

void matrixmul_x () { //naiv2

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++) c[i][j] += a[i][k] * b[k][j];

}

}

Auch hier wieder: Pech gehabt, solche kom- plizierten Dinge verwirren den Permutator, er kann die Reihenfolge nicht mehr optimie- ren und die Performance bleibt massiv hinter den Möglichkeiten zurück. Mit Routinen, die auf globale Felder zu- greifen wie matrixmul_ikj(), programmieren wie gesagt nur alte C-Dinosaurier. Zumindest sollte man Matrizen als Parameter mit sta- tisch festgelegten Typen übergeben können:

matrixmulS_ikj( const mat a, const mat b, mat c, int dim)

Statisch festgelegte Typen machen es dem Compiler einfach, weil er schon zur Compile- Zeit Überblick über die Dimensionen hat. Das vereinfacht nicht nur Autovektorisierung und -parallelisierung, er kann dann auch häufig schnellere Zugriffe durch Einsatz spe- zieller Befehle (Scaled Index Base) bewirken. Den Microsoft-Compiler überfordert die komplizierte Konstruktion aber bereits. Dass der Autovektorisierer aufgibt, ist nach dem

© Copyright by Heise Zeitschriften Verlag

Persönliches PDF für Thorsten Grahs move-csc aus 38108 Braunschweig

Know-how | Matrixmultiplikation

oben Genannten allerdings eher von Vorteil, jedenfalls bei AVX. Aber leider passt auch der Autopermutator. Die naive ijk-Reihenfolge sackt dann dramatisch auf 0,27 GFlops ab. Die übliche Routine für Matrixmultiplika- tion von dicht besetzten quadratischen Ma- trizen wird ohnehin meist anders aussehen, nämlich dynamisch mit Pointern:

void matrixmulP_ikj(double* a, double * b, double* c, int dim) {

// Nullsetzen von c for (int i = 0; i < dim; i++) for (int k = 0; k < dim; k++) for (int j = 0; j < dim; j++) c[i*dim + j] += a[i*dim + k] * b[k*dim + j];

}

Aber das ist zu schwere Kost für den Micro- soft-Compiler, selbst wenn man ihm weitere Hilfestellung gibt, etwa durch Ersatz der Variablen dim durch einen festen Wert DIM und durch den Aufruf:

void matrixmulPR_ikj (

const double*

restrict

a,

const double*

restrict

b,

double*

restrict c, int dim)

Matrixmultiplikation

Sie ist das „Arbeitspferd“ im High Perfor- mance Computing, die Matrixmultiplika- tion, die in den Mathematik-Bibliotheken (BLAS) meist unter SGEMM (Single Preci- sion) und DGEMM (Double Precision) zu finden ist. Auch bei grafischen Berech- nungen, beim Rendern, in der Finanz- analyse und in vielen anderen Bereichen wird sie häufig benötigt. Die klassische Berechnungsformel für jedes Element der Ergebnismatrix sieht so aus:

n

c ij = a ik · b kj

k=1

Weil sie hochoptimiert in allen BLAS-

Bibliotheken zu finden ist, braucht man sich eigentlich keine großen Gedanken im ihre Implementierung zu machen. Zudem gehören SGEMM/DGEMM zum Standard-Lieferumfang der GPUs und Rechenbeschleuniger, mit Routinen in

OpenCL, CUDA, AMP

Effizienz in zweistellige Teraflops-Berei-

che eindringen.

Aber die klassischen Routinen eignen sich auch sehr gut, um die Optimierungs- fähigkeiten moderner Compiler aufzuzei- gen. Werden konstante Werte aus Schlei- fen herausgezogen, Indizes der Felder geschickt berechnet, Reihenfolgen von Schleifen zweckmäßig getauscht, Auto- vektorisierung und/oder Autoparallelisie- rung vorgenommen – oder werden die Routinen gar durch Einsprünge in hoch- optimierte Bibliotheken ersetzt?

wo sie mit hoher

177

Know-how | Matrixmultiplikation

Während Microsofts Compiler des VS2013 im Report zumeist Auskunft gibt, warum er nicht autovektorisieren konnte, findet man bei Intel viele erfolg- reiche Vektorisierungen, oft auch mit Änderung der Schleifenreihenfolge (Permuted Loop).

restrict vor dem

Feldnamen versichert dem Compiler, dass er kein Alias gibt, sich die Felder also nicht überschneiden – so wie es bei FORTRAN grundsätzlich vorausgesetzt wird. Mit const verrät man dem Compiler zusätzlich, dass diese beiden Felder in der Routine unverän- dert bleiben. Aber auch damit klappt’s nicht. Moderne Compiler multiplizieren übri- gens nicht mehr tatsächlich i*dim+j, sondern werten in der Regel die Schleifen aus und in- krementieren die Pointer entsprechend. Alte handoptimierte Fassungen, die das zu Fuß mit zusätzlichen Variablen bewirkten, irritie- ren die Compiler oft nur, sodass manche ma- gische Optimierung dann nicht greift. Auch selbstfabriziertes Loop Unrolling ist heutzu- tage eher kontraproduktiv. Man sollte aber mangels ausreichender Permutationsfähigkeit der Microsoft-Compi- ler von vornherein die richtige Reihenfolge ikj wählen. Damit ist man ein gutes Stück weiter, allerdings mit dem Optimierungs- latein noch längst nicht am Ende. Das zeigt eine Flut von Veröffentlichungen zu dem Thema in den letzten 20 Jahren, von denen viele von verschiedenen Tricks zur Optimie- rung der Datenwiederverwendung (data reuse) in den Caches handeln. Obige Routinen haben nämlich eine deut- lich verbesserungsfähige Data-Reuse-Quote. Zur deren Optimierung muss man den Job geschickt in geeignete Blöcke oder Kacheln (Tiling) aufteilen, die dann optimal in die Ca- ches passen.

Das aus C99 stammende

Kacheln

In der einfachsten Form des Tiling wird nur die mittlere Schleife „gekachelt“, indem man sie auf zwei Schleifen aufteilt. Die erste läuft mit Schrittweite n, die zweite über die Zwi- schenwerte. Der beste Wert von n hängt von der Größe des L1-Cache ab, beim Haswell hat sich n=16 gut bewährt, 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 Microsoft- Compiler noch nicht einmal Fused Multiply

178

der Microsoft- Compiler noch nicht einmal Fused Multiply 178 Add (FMA) verwendet. Das bietet der Has-

Add (FMA) verwendet. Das bietet der Has- well-Prozessor gleich in zwei parallelen Pipe- lines an – jedenfalls, wenn man keinen abge- speckten Celeron oder Pentium besitzt. Der Visual-Studio-2013-Compiler kennt zwar das Architekturflag /arch:AVX2 – aber der Auto- vektorisierer 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 Vek- torisierung und optimalem Data Reuse 16ˇdoppeltgenaue 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 Data- Reuse-Quote noch etwas steigern.

Parallel

Man kann das Ganze aber auch noch paralle- lisieren. Microsofts 2013-Compiler bieten hierfür als neues Feature Autoparallelisie- rung 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 än- dern darf, braucht man sie auch nicht, denn die Parallelisierung kann man mit einem ein- zigen richtig platzierten OpenMP-Pragma bequem selbst veranlassen – so man über eine kommerzielle Version des Compilers verfügt und nicht nur über die kostenlose Ex- pressversion, der Microsoft weder Autoparal- lelisierung noch OpenMP gegönnt hat. Der eingesetzte Haswell-Prozessor bietet vier physische Kerne auf, das müsste eigent- lich die Performance vervierfachen können – aber nur dann, wenn sich die Kerne bei ihren Zugriffen nicht ins Gehege kommen. Ganz

© Copyright by Heise Zeitschriften Verlag

schafft OpenMP das ohne weitere Maßnah- men nicht, aber immerhin ist eine Verdreifa- chung auf 29 Gflops drin.

Intels Zauberkünste

Der Trick, den Intel verwendet und der die eingangs erwähnte gigantische Beschleuni- gung bewirkt, müsste Autobibliothekisie- rung heißen. Diese Zauberei wird mit /Qopt- matmul /Qopenmp aktiviert oder auch bereits mit /Qpar in den hohen Optimierungsstufen /O3 oder /fast. Dann versucht der Compiler, lohnenswerte Matrixmultiplikationen im Code selbstständig zu erkennen und durch Einsprünge in die Math Kernel Library (MKL) zu ersetzen. Die Erkennungslogik ist aller- dings äußerst sensibel, kleine Variationen des Codes und sie gibt auf. Aber die hier ge- zeigten 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 erwähnt 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_AFFINI- TY=scatter setzten, sonst schwankt das Resul- tat erheblich. Häufig findet man jedoch einen etwas all- gemeiner gehaltenen Aufruf, so wie bei DGEMM der verbreiteten BLAS-Bibliotheken (Basic Linear Algebra Subprogram) auch. Die sogenannte „leading dimension“ der Felder kann man nämlich größer wählen, als man es für die jeweilige Matrix braucht. Dadurch las- sen sich Alignment-Konflikte in den Caches vermeiden, was ebenfalls der Performance zugutekommt. Daher sieht der DGEMM-Auf- ruf für jede der drei übergebenen Matrizen eine leading dimension vor (lda, ldb, ldc), in unserem vereinfachten Fall reicht eine na- mens dim0.

c’t 2014, Heft 12

Persönliches PDF für Thorsten Grahs move-csc aus 38108 Braunschweig

Know-how | Matrixmultiplikation

Doppeltgenaue Matrixmultiplikation und Compiler-Künste zwischen 0,12 und 140 GFlops

 

Adressierung

Reihen-

MVS2013 Update 2RC, C++ 18.00.30324

 

Intel Composer 2013 C++14.0.2.176

 

folge

/O2

/O2 /arch:AVX2

/O3 /Qipo /QxSSE4.2

/O3 /Qipo /QxCORE-AVX2

 

(novec)

(auto)

(novec)

(auto)

/Qvec-

(auto)

/Qvec-

(auto)

/Qopt-matmul /QopenMP

statisch global, dim fix

jki

0,12

0,12

0,12

0,12

2,5

8,5

8,1

19,8

110–140

statisch global, dim fix

ijk

3,2 1

3,2 1

6,5 1

6,5 1

2,8

8,5

8,1

20,3

110–140

statisch global, dim fix

ikj

3,2

5,4

6,5

5,1

5,7

8,5

8,1

20,3

110–140

mit typisierten Parametern

jki

0,12

0,12

0,12

0,12

2,5

8,5

8,6

19,5

110–140

mit typisierten Parametern

ijk

0,26

0,26

0,26

0,26

2,8

8,5

8,6

19,8

110–140

mit typisierten Parametern

ikj

3

3

5

5

5,7

8,5

8,6

20,2

110–140

via Pointer, dim=dim0 fix

jki

0,12

0,12

0,12

0,12

5,9

11,1

8,7

16,8

110–140

via Pointer, dim=dim0 fix

ijk

0,26

0,26

0,27

0,27

5,9

11,1

8,7

16,8

110–140

via Pointer, dim=dim0 fix

ikj

3

3

6,4

6,4

5,9

11,1

8,7

16,8

110–140

via Pointer, dim=dim0 variable

jki

0,12

0,12

0,12

0,12

5,9

11,1

6,4

7,5

110–140

via Pointer, dim=dim0 variable

ijk

0,26

0,26

0,27

0,27

5,9

11,1

6,4

7,5

110–140

via Pointer, dim=dim0 variable

ikj

3

3

6,4

6,4

5,9

11,1

6,4

7,5

110–140

via Pointer, dim, dim0 variable

jki

0,12

0,12

0,12

0,12

0,12

0,12

0,12

0,12

0,12

via Pointer, dim, dim0 variable

ijk

0,26

0,26

0,27

0,27

0,25

0,25

0,23

0,23

0,23

via Pointer, dim, dim0 variable

ikj

3

3

6,4

6,4

5,5

5,5

6,6

6,6

6,6

Tiling

ikj

3,5

4,5

8,7

4,5

3,2

6,2

3,2

9,5

9,5

Tiling openMP

ikj

8,4

15,3

28,9

15,7

21,4

38,4

23,7

62

62

mkl 1C

37

37

37

37

37

37

37

37

37

mkl 4C pointer beliebig

140

140

140

140

140

140

140

140

140

1 mit MVS2012 nur 0,26 GFlops

dunkel unterlegt: autovektorisiert

 

Werte in GFlops unter Windows 8.1 auf Haswell Core i7-4750HQ

 

void matrixmul_P0_ikj(double* a, double * b, double* c, int dim, int dim0) { // Nullsetzen von c for (int i = 0; i < dim; i++) for (int k = 0; k < dim; k++) for (int j = 0; j < dim; j++) c[i*dim0 + j] += a[i*dim0 + k] * b[k*dim0 + j]; }

Intels Erkennungslogik zeigt sich nun aber

recht sperrig. Sie mag es nur, wenn die lea- ding dimension zur Compile-Zeit feststeht, sonst gibt sie aus unerklärlichen Gründen

restrict und const setzt.

Dummerweise geben dann auch Autovekto- risierer und -Permutierer auf – die Perfor- mance fällt ins Bodenlose. Ohne Änderung des Codes etwa durch zu- sätzliche Pragmas kommt man hier nicht wei- ter, aber dann kann man auch gleich selbst den MKL-Aufruf ohne jegliche Automagie ein- setzen. Dazu ist lediglich im Code #include "mkl.h" einzubinden und im Visual Studio die Option „Use Intel MKL“ auf parallel zu stellen. So läuft es dann auch mit beliebig gewählten leading dimensions mit voller Performance:

auf, auch wenn man

void matrixmulDGEMM(const double *a, const double *b,

double

restrict *c, int dim, int dim0) {

cblas_dgemm( CblasRowMajor, CblasNoTrans, CblasNoTrans, dim, dim, dim, // K 1.0, //alpha a, dim0, b, dim0, 0.0, //beta c, dim0 );

}

Der MKL-Aufruf lohnt sich übrigens schon bei kleinen Matrizen bereits ab 8 x 8. Setzt man die MKL-Option auf „Sequen- tial“ oder entzieht man per set OMP_NUM_ THREADS=1 dem Programm die anderen Kerne,

c’t 2014, Heft 12

so zeigt sich die rohe MKL-Leistung auf nur einem Kern – und die ist bei der gewählten Dimension von 1024 x 1024 und mit Has- well-Optimierung (/QxCORE_AVX2) mit 37 GFlops fast doppelt so hoch wie das beste Resultat obiger Routinen. Das sind 13,2 Flops pro Takt bei 2,8 GHz oder 82,5 Prozent Effizienz. Die üblichen Routinen kommen auf bis zu 21 GFlops, wenn Intels Autovektorisierer und Autopermutierer kräftig mithelfen und man mit zur Laufzeit feststehenden Dimensionen arbeitet. Bei variablem dim sind es aber mit knapp über 16 GFlops immer noch erheblich mehr, als der Microsoft-Compiler im besten Fall herauszaubern kann. Man sieht die Wirkung des Autovektorisie- rers am Report, wenn „LOOP WAS VECTO- RIZED“ oder „PERMUTED LOOP WAS VECTO- RIZED“ an den entscheidenden Stellen auf- taucht. Fast immer kann der Compiler die beste Reihenfolge wählen. Hin und wieder war aber die Vektorisierung unter dem Archi- tekturflag /QxCORE-AVX2 unterschiedlich. Nicht immer verwendete der Compiler FMA. Dann wird der Code etwa um 20 Prozent langsamer als nötig. Es gibt aber noch zahlreiche Prag- mas, mit denen man Intels automagische Fähigkeiten, vor allem bei der Autoparallelisie- rung, unterstützen kann. Wenn man den „GAP“-Adviser einschaltet, bekommt man zahlreiche Hinweise, wo man etwa #pragma pa- rallel setzen sollte oder wo man ihm per #pragma min loopcount (n) mitteilen kann, dass die Schleife hier mindestens n Iterationen aufweisen wird.

Alternativen

Es gibt ein Reihe Alternativen zu den hier vorgestellten klassischen Routinen. Für ver- teiltes Rechnen (etwa via MPI) ist der Can- non-Algorithmus besser geeignet, der aber lediglich die Indizierung etwas variiert. Ma- thematisch interessanter sind andere Ideen.

© Copyright by Heise Zeitschriften Verlag

Persönliches PDF für Thorsten Grahs move-csc aus 38108 Braunschweig

Vor rund 45 Jahren hat der deutsche Mathe- matiker Volker Strassen gezeigt, dass man eine 2x2-Multiplikation statt klassisch mit 8 Multiplikationen und 4 Additionen auch mit 7 Multiplikationen und 18 Additionen (später dann auch mit nur 15) hinbekommen kann. Der Algorithmus lässt sich rekursiv auf grö- ßere Matrizen anwenden und so ab 2048ˇxˇ2048 braucht man bei ihm sogar ins- gesamt weniger Gleitkommaoperationen als bei der normalen Matrixmultiplikation. Während bei jener die sogenannte Kom- plexität mit der Dimension kubisch ansteigt, beträgt der Exponent bei Straßen nämlich nur noch 2,81. Findige Mathematiker haben das Verfahren im Laufe der Zeit noch deut- lich verbessert, quasi einen Wettbewerb zur Minimierung des Komplexitätsfaktors be- gonnen. Derzeit liegt der Rekord von Fran- çois le Gall bei 2,3728639 (April 2014). Le Gall hat außerdem aufgezeigt, dass man mit Quantenalgorithmen noch schneller sein kann. Aber all diese schönen Algorithmen sind weitgehend akademisch und, wenn überhaupt, nur für sehr große Matrizen ge- eignet. Sie lassen sich zudem schwer imple- mentieren, insbesondere vektorisieren und in FMA-Pipelines packen, für Caches optimie- ren und so weiter. Vor allem aber leiden sie unter einer geringeren numerischen Stabili- tät, sodass die Ergebnisse ungenauer sind. Neben den hier betrachteten normalen „vollbesetzten“ machen verstärkt auch die „dünnbesetzten“ Matrizen (sparse matrices) von sich reden. Ihre Elemente sind überwie- gend null, für sie gibt es dann völlig andere Herausforderungen, Algorithmen und Opti- mierungen. Sie bilden die Grundlage des nächsten großen HPC-Benchmarks, der dem Linpack zur Seite gestellt werden soll. Mit ihnen und dem neuen Benchmark beschäf- tigen wir uns in einer der nächsten Aus-

gaben.

(as) c

179