HyperAI
Back to Headlines

SIMD-Funktionen bieten Leistungsvorteile, aber praktische Anwendung ist kompliziert.

vor 2 Tagen

Die komplizierte Realität von SIMD-Funktionen SIMD (Single Instruction, Multiple Data) und Vektorisierung wurden bereits umfangreich auf diesem Blog diskutiert. Es war nur eine Frage der Zeit, bis SIMD-Funktionen ins Rampenlicht traten. In diesem Beitrag erkunden wir, was SIMD-Funktionen sind, wann sie nützlich sind und wie man sie effektiv deklariert und verwendet. Eine SIMD-Funktion verarbeitet mehr als ein einzelnes Datenstück. Nehmen wir das mathematische Sinus-Funktionsbeispiel: c double sin(double winkel); Diese Funktion nimmt einen double-Wert entgegen und gibt einen double-Wert zurück. Die vektorisierte Version, die vier Werte in einer Funktion verarbeitet, könnte so aussehen: c double[4] sin(double winkel[4]); oder, wenn man native AVX-SIMD-Typen verwendet: c __m256d sin(__m256d w); Das Kernkonzept hinter Vektorfunktionen ist es, die Leistung zu verbessern, indem mehrere Datenobjekte pro Funktionsaufruf verarbeitet werden, sei es durch manuell vektorisierte Funktionen oder durch vom Compiler generierte Vektorfunktionen. Warum braucht man überhaupt Vektorfunktionen? Der Begriff "Vektorfunktion" kann zwei verwandte Konzepte bezeichnen. In diesem Beitrag sprechen wir über Vektorfunktionen, die der Compiler automatisch zur Vektorisierung von Schleifen auswählen kann. Zum Beispiel könnte ein Compiler folgende Schleife nehmen: c for (size_t i = 0; i < n; i++) { res[i] = sin(in[i]); } und entweder die skalare Version von sin oder eine vektorisierte Version von sin aufrufen. Aus Leistungsgründen würde der Compiler in der Regel die vektorisierte Version wählen, könnte aber auch die skalare Version für wenige Iterationen verwenden, die nicht mit Vektorkode verarbeitet werden können. Deklarieren und definieren von Vektorfunktionen Es gibt verschiedene Möglichkeiten, Vektorfunktionen zu deklarieren und zu definieren. Ein kleiner Unterschied besteht darin, ob das Attribut/Pragma auf eine Funktionsdeklaration oder Definition angewendet wird. Eine Vektorfunktion könnte so aussehen: ```c pragma omp declare simd double sin(double v); ``` oder spezifisch für GCC: c __attribute__((simd)) double sin(double v); Die meisten Compiler behandeln OMP-Pragmas und ihre eigene Interne Implementierung ähnlich. Bei Verwendung von OpenMP-Pragmas muss man jedoch ein Compilierflag wie -fopenmp oder -fopenmp-simd für GCC und CLANG angeben. Wenn man eine SIMD-fähige Bibliothek für andere Nutzer bereitstellt, und nicht sicherstellen kann, dass -fopenmp-simd aktiviert ist, ist die beste Alternative, Compiler-Erweiterungen mit unterschiedlichen Erweiterungen für verschiedene Compiler zu verwenden. Hier ist ein Beispiel, wie dies in der GNU-C-Bibliothek implementiert wird: ```c if defined x86_64 && defined FAST_MATH if defined _OPENMP && _OPENMP >= 201307 / OpenMP-Fall. / define __DECL_SIMD_x86_64 _Pragma ("omp declare simd notinbranch") elif __GNUC_PREREQ (6,0) / Ohne OpenMP, GCC 6.0+ attribute((simd)) verwenden. / define DECL_SIMD_x86_64 __attribute((simd ("notinbranch"))) endif ``` Welche Arten von Funktionsparametern gibt es? Um eine Vektorfunktion zu deklarieren, könnte man zum Beispiel schreiben: ```c pragma omp declare simd double sin(double v); ``` Dies ist einfach: Die Eingabe ist ein Vektor von double-Werten, und das Ergebnis ist ebenfalls ein Vektor von double-Werten. Betrachten wir jedoch eine weitere Funktion: c // In einem flachen Bild summiere alle Werte in einer Spalte `spalte`. double summe_spalte(double const * const bild_ptr, size_t spalte, size_t breite, size_t hoehe) { double s = 0.0; for (size_t j = 0; j < hoehe; j++) { s += bild_ptr[j * breite + spalte]; } return s; } Diese Funktion nimmt einen Zeiger auf ein flaches Bild bild_ptr mit Breite und Höhe sowie die Spalte als Parameter und summiert alle Werte in dieser Spalte. Stellen wir uns vor, wir möchten eine vektorisierte Version dieser Schleife schreiben. Was genau wir benötigen, hängt stark vom Aufrufer ab. Hier ist ein Beispiel für den Aufrufer: c for (size_t i = 0; i < BREITE; i++) { summe_spalten0[i] = summe_spalte(bild_ptr, i, BREITE, HOEHE); } Man sieht, dass die Parameter bild_ptr, breite und hoehe für alle Spalten gleich sind, während der Parameter spalte N aufeinanderfolgende Werte (z.B. 0, 1, 2, 3) hat. Für jeden Parameter, außer dem Rückgabewert, kann man angeben, welche Art von Parameter es ist: ```c pragma omp declare simd uniform(bild_ptr, breite, hoehe) linear(spalte) double summe_spalte(double const * const bild_ptr, size_t spalte, size_t breite, size_t hoehe); ``` Es ist wichtig, dem Compiler genau mitzuteilen, welche Art von SIMD-Funktion man benötigt, da flexiblere Funktionen (mit variablen Parametern) oft langsamer sind. Inbranch- und Notinbranch-Attribute Um die Inbranch- und Notinbranch-Attribute zu illustrieren, modifizieren wir unsere ursprüngliche Schleife: c for (size_t i = 0; i < BREITE; i++) { if (summe_spalten0[i] == 0.0) { summe_spalten0[i] = summe_spalte(bild_ptr, i, BREITE, HOEHE); } } In diesem Beispiel wird summe_spalte nur dann aufgerufen, wenn summe_spalten0[i] true ist. Dies hat Auswirkungen auf die Vektorisierung, da der Compiler nun eine Mechanismus benötigt, um der Funktion mitzuteilen, in welchen Pfaden gearbeitet werden soll und in welchen nicht. Hier sind die entsprechenden Attribute: ```c pragma omp declare simd notinbranch double sin(double v); ``` Wenn man weder inbranch noch notinbranch angibt, erwartet der Compiler beide Versionen der Funktion und generiert sie bei Anwendung auf die Funktionsdefinition. Um dies zu vermeiden, kann man die Anzahl der Vektorversionen reduzieren, indem man inbranch/notinbranch und simdlen-Parameter verwendet. Überschreiben von Vektorfunktionen Die Hauptmotivation für das Überschreiben von Vektorfunktionen ist es, eine benutzerdefinierte vektorisierte Implementierung einer skalaren Funktion mit Vektormasken zu bereitstellen. Es gibt jedoch keinen standardisierten, sauberen OMP-Weg, um dies zu tun. Stattdessen muss man tiefer in die Compilerverwaltung und Vektordatenbusse eintauchen. Angenommen, man möchte die square-Funktion überschreiben, die so deklariert wurde: ```c pragma omp declare simd attribute((const, nothrow)) double square(double x); ``` Nach dieser Deklaration weiß der Compiler, dass Vektorfunktionen existieren, aber er kennt deren Implementierung nicht. Wenn man das Programm verlinken möchte, produziert es Fehler wegen fehlender Funktionen: sh $ g++ -g -O3 -DLIKWID_PERFMON -fopenmp-simd -ffast-math -mavx2 test.cpp -o test /usr/bin/ld: /tmp/ccIbzx6R.o: in function `main': 2025-07-simdattributes/test.cpp:42:(.text.startup+0xd7): undefined reference to `_ZGVdN4v__Z6squared' 2025-07-simdattributes/test.cpp:49:(.text.startup+0x137): undefined reference to `_ZGVdM4v__Z6squared' 2025-07-simdattributes/test.cpp:42:(.text.startup+0x247): undefined reference to `square(double)' 2025-07-simdattributes/test.cpp:49:(.text.startup+0x301): undefined reference to `square(double)' Der Compiler klagt über drei fehlende Funktionen: square(double), _ZGVdM4v__Z6squared (maskierte Version) und _ZGVdN4v__Z6squared (nicht maskierte Version). Um diese Fehler zu beheben, muss man die Implementierung dieser Funktionen in einer anderen Datei bereitstellen. In C++ definiert man die Vektorfunktionen in einer separaten Kompilationseinheit so: ```c double square(double x) { return x * x; } extern "C" __m256d _ZGVdN4v__Z6squared(__m256d x) { return _mm256_mul_pd(x, x); } extern "C" __m256d _ZGVdM4v__Z6squared(__m256d x, __m256d mask) { __m256d r = _mm256_mul_pd(x, x); return _mm256_blendv_pd(r, x, mask); } ``` Wenn man alle möglichen Implementierungen für eine Funktion sehen möchte, kann man sie auf Godbolt kopieren und sich ansehen, welche Funktionen dort definiert sind. Ein Beispiel hierfür ist verfügbar. Parametrisierung von Vektorfunktionen Im vorherigen Beispiel hatte die Vektorfunktion nur einen variablen Parameter, einen double-Wert. Was passiert, wenn eine Funktion mehrere Parameter hat, von denen manche variabel, gleichmäßig verteilt oder linear sind? Grundsätzlich werden variablen Parameter in Vektoren übergeben, während gleichmäßig verteilte und lineare Parameter in regulären Variablen übergeben werden. Eine Deklaration mit solchen Parametern könnte so aussehen: ```c pragma omp declare simd uniform(bild_ptr, breite, hoehe) linear(spalte) notinbranch attribute((pure, nothrow)) double summe_spalte(double const * const bild_ptr, size_t spalte, size_t breite, size_t hoehe); ``` Die entsprechende Vektordefinition wäre: c extern "C" __m256d _ZGVdN4uluu__Z10summe_spaltePKdmmm(double const * const bild_ptr, size_t spalte, size_t breite, size_t hoehe) { .... } Wenn die Deklaration einen Maskenparameter aufweist, weil sie im Bereich einer Bedingung steht, ist der Maskenparameter das letzte Argument des Aufrufs. Zum Beispiel: ```c pragma omp declare simd inbranch attribute((const, nothrow)) double square(double x); ``` entspricht der Definition: c extern "C" __m256d _ZGVdM4v__Z6squared(__m256d x, __m256d mask) { return _mm256_mul_pd(x, x); } Ein weiterer Sonderfall: Angenommen, man deklariert eine Funktion so: ```c pragma omp declare simd simdlen(8) notinbranch attribute((const, nothrow)) double square(double x); ``` Und man möchte sie für AVX kompilieren. Mit AVX kann man 4 double-Werte in einem 256-Bit-Register halten. Da die spezialisierte Version für 8 double-Werte in einer einzigen Vektorausführung steht, werden die ursprünglichen Werte in zwei Vektoregister gehalten. Die entsprechende Definition lautet: ```c struct __m256dx2 { __m256d v0; __m256d v1; }; __m256dx2 square2(__m256d x0, __m256d x1) { __m256dx2 res; res.v0 = _mm256_mul_pd(x0, x0); res.v1 = _mm256_mul_pd(x1, x1); return res; } ``` FunktionsInlining Man kann Vektorfunktionen nicht in der gleichen Kompilationseinheit deklarieren und überschreiben. Dies bedeutet, dass man Deklaration und Definition trennen muss, zumindest bei GCC. Inlining ist die Mutter aller Optimierungen und es wäre sehr nützlich, wenn man es verwenden könnte. Man kann jedoch Linkzeitoptimierungen verwenden, um die benutzerdefinierte Vektorfunktionsimplementierung zu inlinen. Dazu muss man mit dem -flto-Flag kompilieren und verlinken, wobei zu beachten ist, dass die Kompilier- und Verlinkzeit dabei erheblich länger sein kann. Compiler-Eigenheiten Beim Schreiben dieses Blogposts habe ich viel mit GCCs Vektorfunktionsfähigkeiten experimentiert, und diese Funktion scheint weit davon entfernt zu sein, perfekt zu sein. Zum Beispiel generiert GCC häufig eine skalare Version der Vektorfunktion, die N-mal wiederholt wird, was definitiv nicht das gewünschte Ergebnis ist. Zusammenfassung der Einschränkungen und Hürden Obwohl SIMD-Funktionen theoretisch ansprechende Leistungsvorteile bieten, gibt es in der Praxis einige Einschränkungen und Hürden, die ihre Effektivität reduzieren. Begrenzte Compilerunterstützung: Viele Compiler unterstützen Vektorfunktionen nicht. Zum Zeitpunkt des Schreibens (Juli 2025) tut dies clang 20 nicht, während GCC 15.1 Unterstützung bietet. Diese Pragmas werden in der Regel von Compilern für das Hochleistungsrechnen unterstützt, wie den Compilern von Cray und Intel. Begrenzte Nutzbarkeit: Der Grund für die geringe Compilerunterstützung ist die begrenzte Nutzbarkeit. Wenn man eine reguläre Funktion hat, die dem Compiler zugänglich ist (z.B. in einem Header), kann der Compiler diese Funktion leicht in der Schleife einbinden und die eingebundene Version vektorisieren. Nach dem Einbinden kann der Compiler weitere Optimierungen durchführen (z.B. das Laden von Konstanten außerhalb der Schleife). Bei Funktionsaufrufen sind solche Optimierungen nicht möglich. Funktionenaufrufe haben zudem die negative Eigenschaft, dass der Compiler nicht weiß, was nach dem Aufruf geschieht, weshalb er das Schlimmste annehmen muss. Dies führt dazu, dass viele nützliche Compileroptimierungen ausbleiben. Ineffiziente Implementierungen: Auf GCC wird eine compiler-generierte Version einer Vektorfunktion oft einfach nur eine skalare Version sein, die N-mal wiederholt wird. Dies ist definitiv nicht das, was man will! Überschreiben der compiler-generierten Implementierungen: Um dies zu beheben, muss man tief in die Compilerverwaltung und Vektordatenbusse eintauchen, um eine benutzerdefinierte Implementierung bereitzustellen. Dies ist komplex und erfordert detailliertes Wissen über die Compilerverwaltung. Trotz dieser Herausforderungen kann die Funktion in der Praxis nützlich sein, insbesondere in hochleistungsorientierten Bereichen. Libraries wie libmvec nutzen diese Funktionen bereits in der Produktion. Dennoch ist es bedauerlich, dass die effiziente Nutzung der Funktionen über verschiedene Compilers und Umgebungen hinweg schwierig ist. Kontaktieren Sie uns, wenn Sie ein Leistungsproblem in Ihrem Projekt besprechen möchten, oder wenn Sie sich selbst oder Ihr Team in Vektorisierung schulen lassen wollen. Folgen Sie uns auf LinkedIn, Twitter oder Mastodon, um sofort benachrichtigt zu werden, sobald neuer Inhalt verfügbar ist.

Related Links