SIMD Programmierung mit GCC: Compiler Intrinsics

Einführung

In den letzten beiden Artikeln habe ich beschrieben, wie man die SIMD-Befehlssätze im GCC verfügbar macht, was der Compiler von sich aus schon automatisch vektorisieren kann und wie die expliziten Vektor-Erweiterungen generisch genutzt werden können. In diesem Artikel verlassen wir das Feld der prozessorunabhängigen Vektorprogrammierung und kümmern uns nur noch um Zielprozessoren mit SSE.

Was sind intrinsische Funktionen?

Intrinsische Funktionen sind zunächst mal nichts weiter als Funktionen, die der Compiler zur Verfügung stellt und deren Inhalt er besonders gut kennt. Für uns interessant ist der Fall, in dem der Compiler die einzelnen Operationen, die ein erweiterter Befehlssatz enthält, als jeweils eine intrinsische Funktion anbietet.

Auf den ersten Blick hört sich das ein wenig wie Inline-Assembler an, unterscheidet sich allerdings ganz grundlegend davon. Am wichtigsten ist der Unterschied, dass die von den intrinsischen Funktionen gekapselten Operationen am gesamten Optimierungsprozess des Compilers teilnehmen. Das heißt, die auf diese Weise eingefügten Instruktionen können von Dead Code Elimination, Dead Store Elimination, Instruction Scheduling u.ä. profitieren und im besten Fall wieder komplett entfallen. Im Gegensatz dazu wird Inline-Assembler 1:1 in das fertige Programm eingefügt, ohne irgendwelche Optimierungen. Dazu gehört auch, dass der Programmierer bei Inline-Assembler selbst für die Belegung der Register zuständig ist, während diese Arbeit bei Intrinsics vom Compiler erledigt wird.

Intrinsics fügen sich auch rein äußerlich besser in den Quelltext ein: Sie sehen aus wie ganz normale Funktionsaufrufe, man hat keine zweite Sprache, die plötzlich in der ersten auftaucht und man muss nicht die Eingabe- und Ausgabeoperanden und benutzten Register angeben.

Inline Assembler hat also nur dann wirkliche Vorteile, wenn man absolut sicher ist, dass man besseren Assembler-Code schreiben kann als der Compiler oder wenn man (aus welchen Gründen auch immer) exakt diesen speziellen Code benötigt. Beides sollte heute eigentlich nicht mehr auftreten.

Notwendige Header

Die intrinsischen Funktionen für SSE werden in mehreren Header-Files definiert, eine für jede Erweiterung. Die folgende Tabelle gibt einen Überblick, welcher Header die Intrinsics zu welcher Erweiterung bereitstellt:

mmintrin.h MMX
xmmintrin.h SSE
emmintrin.h SSE2
pmmintrin.h SSE3
tmmintrin.h SSSE3
smmintrin.h SSE4.1 und SSE4.2
ammintrin.h SSE4a
immintrin.h AVX und AVX2

Wer keinen Wert auf Minimalismus bei den Includes legt, kann alternativ x86intrin.h einbinden, die alle auf dem aktuellen Zielprozessor verfügbaren Intrinsics bereitstellt (außer den SIMD Erweiterungen kommt dann allerdings noch eine ganz Menge anderer Sachen dazu).

Neue Datentypen

Mit einigen der Erweiterungen wurden neue Datentypen eingeführt. Die MMX-Datentypen überspringe ich hier einfach und fange direkt mit SSE an. In SSE gibt es nur einen wichtigen neuen Datentyp: __m128. Dieser ist darauf ausgelegt, die vier Single-Precision floats aufzunehmen, die in ein SSE-Register passen und ist definiert als float __attribute__((vector_size(16)), kann also auch direkt mit den GCC Vektor-Erweiterungen benutzt werden (d.h. heißt auch Array-Indizierung ab GCC 4.8). Mehr Datentypen sind für SSE nicht erforderlich, weil die ursprünglichen SSE Instruktionen nur für einfach genaue Fließkommazahlen ausgelegt waren.

Mit SSE2 kam die Möglichkeit, auch doubles und verschiedene Integertypen zu verarbeiten, entsprechend gibt es für SSE2 zwei weitere neue Datentypen: __m128d, der zwei doubles hält und __m128i, der eine entsprechende Anzahl der verschiedenen Integertypen aufnehmen kann (also alle Standard-Integertypen von 8 bis 64 Bit, sowohl mit als auch ohne Vorzeichen).

Die restlichen SSE-Versionen bringen erstmal keine weiteren Datentypen; erst mit AVX kommen dann __m256, __m256d und __m256i, die exakt die gleiche Bedeutung haben wie ihre 128-Bit SSE-Gegenstücke, aber doppelt so viele Komponenten speichern (und selbstverständlich nur mit AVX-Intrinsics benutzt werden können, nicht mit SSE-Intrinsics).

Benennung der Intrinsics

Die einzelnen intrisischen Funktionen sind nach den neuen SSE-Instruktionen benannt. Sie haben alle den Präfix _mm_ für SSE und _mm256_ für AVX. SSE2 bietet z.B. eine Instruktion ADDPS an, die zu einem XMM-Register ein anderes XMM-Register oder einen __m128d an einer angegebenen Speicheradresse addiert. Dabei wird das XMM-Register als Vektor aus zwei doubles interpretiert und die Addition erfolgt elementweise. XMM-Register sind die neuen 128-Bit großen Register, auf denen die SSE-Befehle arbeiten.

Diese ADDPS Instruktion ist als Intrinsic (vereinfacht) deklariert als:

__m128 _mm_add_ps (__m128 __A, __m128 __B);

Im Gegensatz zu der Instruktion arbeitet sie also nicht mit Registern oder Adressen (wir erinnern uns, die Registerverwaltung übernimmt der Compiler), sondern bekommt zwei __m128-Vektoren übergeben und gibt ebenfalls einen __m128-Vektor zurück. So lässt sich die ADDPS-Instruktion wie jede andere C-Funktion aufrufen und das Ergebnis weiterverarbeiten.

Da die Namen der Intrinsics von Intel stammen, stehen sie übrigens in Intels Software Developer’s Manual [1] bei der Beschreibung der Instruktionen selbst (zusammen mit der Angabe, ab welcher SSE-Version sie verfügbar sind). Ausgenommen davon sind die SSE4a Instruktionen, die Intel nicht implementiert.

Einfaches Beispiel

Eigentlich haben wir jetzt alle Informationen zusammen, die wir brauchen, um die Intrinsics auch tatsächlich zu benutzen. Deshalb soll ein kleines Beispiel reichen, um das zu illustrieren:

#include <emmintrin.h>

int main() {
    __m128 a = {1.0f, 2.0f, 3.0f, 4.0f};
    __m128 b = {2.0f, 3.0f, 4.0f, 5.0f};
    __m128 c = _mm_add_ps(a,b);

    return 0;
}

Was macht der Compiler daraus, wenn wir das mit eingeschalteten Optimierungen übersetzen? Folgendes:

main:
    xor eax, eax 
    ret 

Frage: Wo ist jetzt das ADDPS, dass wir mühsam eingefügt haben?
Antwort: Es wurde wegoptimiert! Wir verwenden c nicht weiter, also braucht der Compiler auch den ganzen Code davor nicht wirklich umsetzen. Mit Inline-Assembler wäre das nicht möglich gewesen, reichen wir spaßeshalber c noch an eine (nicht-inline-) Funktion foo weiter, kriegen wir folgendes:

main:
    // Stack-Management entfernt
    movaps  xmm0, XMMWORD PTR .LC1[rip]
    addps   xmm0, XMMWORD PTR .LC0[rip]
    call    foo 
    xor     eax, eax 
    ret 

Da ist unser ADDPS, außerdem vorher ein MOVAPS, das in diesem Fall den Vektor b in das Register xmm0 lädt, bevor ADDPS den Vektor a dazu addiert.

Übrigens: Obwohl der Code mit -O3 übersetzt wurde, hat der Compiler es nicht geschafft, die Addition zur Compile-Zeit durchzuführen. Wenn wir statt _mm_add_ps die GCC Vector Extensions und den einfachen Additions-Operator benutzen, berechnet der GCC den Vektor zur Compile-Zeit und lädt ihn nur aus dem Speicher (das ADDPS wird dann wegoptimiert!). Es lohnt sich also, zumindest bei Berechnungen mit vielen Konstanten mal auf den generierten Assembler-Code zu schauen und ihn mit dem Code zu vergleichen, der bei Verwendung der Vector Extensions erzeugt wird.

Schlusswort

Das war der letzte Einführungs-Artikel zur SIMD-Programmierung mit dem GCC. Es wird in Zukunft sicher noch einige Artikel zu SIMD geben, aber die Einführung in das Thema ist hiermit offiziell beendet. Ich werde nicht über Inline-Assembler oder Compiler-Builtins schreiben. Über Assembler hab ich oben schon was gesagt und die Builtins haben den Nachteil, dass sie auf einem anderen Compiler nicht funktionieren werden, während die Intrinsics, da von Intel vorgegeben, auf allen Compilern gleich zu benutzen sind. Das gleiche gilt übrigens für Auto-Vektorisierung: Das machen eigentlich alle modernen C- und C++-Compiler; die GCC Vektor-Extensions werden (meines Wissens nach) nur von GCC und clang unterstützt. Es empfiehlt sich daher immer, abzuwägen zwischen Auto-Vektorisierung (relativ wenig Flexibilität, aber geringster Programmieraufwand und größte Portierbarkeit), Vector Extensions (zwischen Ziel-Prozessoren portierbar, aber nicht zwischen Compilern, gleichzeitig mehr Flexibilität als Auto-Vektorisierung) und Intrinsics (zwischen Compilern portierbar, aber nicht zwischen Prozessoren mit unterschiedlichen Erweiterungen, dafür maximale Flexibilität).

Außerdem schadet ein gewisses Verständnis für Assembler nicht. Auch wenn man selbst keinen Assembler-Code schreiben kann, lohnt es sich doch, ihn zumindest lesen und verstehen zu können. Auf diese Weise lässt sich entscheiden, ob eine Änderung am Code auch tatsächlich einen positiven Effekt auf das Endergebnis hat oder nur den Code verkompliziert (wie im obigen Beispiel mit konstanten Vektoren).

Ob dieser “positive Effekt” sich dann auch tatsächlich auf die Ausführungsgeschwindigkeit des Programms auswirkt, kann im Zweifelsfall allerdings nur ein Benchmark mit verschiedenen Versionen beantworten. Außerdem gilt natürlich wie immer: Nicht voreilig optimieren. Es ist immer einfacher, ein funktionierendes Programm schnell zu machen, als ein schneller Programm dazu zu bringen, richtig zu funktionieren.

Referenzen

[1] http://www.intel.com/content/www/us/en/processors/architectures-software-developer-manuals.html

Tags: , , , , ,

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s

%d bloggers like this: