Viele Jahre sind vergangen, seit ich die Anweisungen MMX, SSE und später AVX auf Intel-Prozessoren kennengelernt habe. Zu einer Zeit wirkten sie wie eine Art Magie vor dem Hintergrund des x86-Assemblers, der lange Zeit etwas Alltägliches gewesen war. Sie haben mich so begeistert, dass ich vor ein paar Jahren die Idee hatte, meinen eigenen Software-Renderer für ein berühmtes Spiel zu schreiben. Das, was diese Anweisungen versprach, versprach mir dies. Irgendwann habe ich sogar darüber nachgedacht, es zu schreiben. Das Schreiben von Text erwies sich jedoch als viel komplizierter als Code.
Zu dieser Zeit wollte ich Probleme mit der Unterstützung auf verschiedenen Prozessoren vermeiden. Ich wollte in der Lage sein, meinen Renderer auf die maximal verfügbare Menge zu überprüfen. Ich habe immer noch Freunde mit alten AMD-Prozessoren, und ihre Obergrenze war SSE3. Aus diesem Grund habe ich mich damals entschlossen, mich auf maximal SSE3 zu beschränken. Es gab also eine vektormathematische Bibliothek, die in SSE etwas weniger als vollständig implementiert war, mit einer seltenen Aufnahme vor SSE3. Irgendwann fragte ich mich jedoch, welche maximale Leistung ich für eine Reihe kritischer Vektormathematikoperationen aus dem Prozessor herausholen könnte. Eine solche Operation besteht darin, 4 Matrizen mit 4 zu multiplizieren.

Eigentlich habe ich beschlossen, dieses Geschäft eher zur Unterhaltung zu betreiben. Ich habe bereits geschrieben und verwende Matrixmultiplikation für mein Software-Rendering auf SSE, und es scheint mir genug zu sein. Aber dann habe ich mich entschlossen zu sehen, wie viele Maßnahmen ich im Prinzip aus der Multiplikation von 2 float4x4-Matrizen herausholen kann. Bei meiner aktuellen SSE sind dies 16 Taktzyklen. Richtig, der kürzliche Übergang zu
IACA 3 zeigte 19, da ich anfing, 1 * für einige Anweisungen anstelle von 0 * zu schreiben. Anscheinend war es früher nur ein Fehler im Analysegerät.
Kurz über die verwendeten Dienstprogramme
Für die Codeanalyse habe ich das berühmte Dienstprogramm
Intel Architecture Code Analyzer verwendet . Für die Analyse verwende ich mindestens die Haswell-Architektur (HSW) mit Unterstützung für AVX2. Das Schreiben von Code ist auch sehr bequem:
Intel Intrinsics Guide und
Intel Optimierungshandbuch .
Für die Montage verwende ich MSVS 2017 Community von der Konsole aus. Ich schreibe den Code in der Version mit intrinsischen. Sie schreiben einmal und normalerweise funktioniert es sofort auf verschiedenen Plattformen. Darüber hinaus unterstützt der x64 VC ++ - Compiler keinen Inline-Assembler, aber ich möchte, dass er auch unter x64 funktioniert.
Da dieser Artikel in der SIMD-Programmierung bereits etwas über dem Anfängerniveau liegt, werde ich keine Register, Anweisungen beschreiben, schöne Bilder zeichnen (oder rasieren) und versuchen, das Programmieren mit SIMD-Anweisungen zu lernen. Die Intel-Website ist voll von hervorragender, klarer und detaillierter Dokumentation.
Ich wollte alles einfacher machen ... Aber es stellte sich wie immer heraus
Hier beginnt der Moment, der sowohl die Implementierung als auch den Artikel erheblich verkompliziert. Deshalb werde ich ein wenig darauf eingehen. Es ist für mich nicht interessant, eine Matrixmultiplikation mit einem Standardzeilenlayout von Elementen zu schreiben. Wer brauchte es, und so studierten sie an Universitäten oder allein. Unser Ziel ist die Produktivität. Erstens habe ich vor langer Zeit zum Spaltenlayout gewechselt. Mein Software-Renderer basiert auf der OpenGL-API. Um unnötige Transpositionen zu vermeiden, habe ich begonnen, Elemente in Spalten zu speichern. Dies ist auch wichtig, da die Matrixmultiplikation nicht so kritisch ist. Gut multiplizierte 2-5-10 Matrizen. Und alle. Und dann multiplizieren wir die fertige Matrix mit Tausenden oder Millionen von Eckpunkten. Und diese Operation ist viel kritischer. Sie können natürlich jedes Mal transponieren. Aber warum, wenn dies vermieden werden kann.
Aber zurück zu den Matrizen ausschließlich. Wir haben die Speicherung in Spalten festgelegt. Es kann jedoch noch komplizierter sein. Für mich ist es bequemer, die älteren Elemente von Vektoren und Matrixzeilen in SIMD-Registern so zu speichern, dass
x im höchsten Float (Index 3) und
w im untersten (Index 0) liegt. Hier müssen wir uns anscheinend wieder zurückziehen, warum.
Die Sache ist, dass Sie in einem Software-Renderer in einem Vektor die
w- Komponente häufiger manipulieren müssen (
1 / z wird dort gespeichert), und dies ist sehr bequem über die
_ss- Version der Operation (Operationen ausschließlich mit der Komponente im unteren Float des
xmm- Registers), ohne
x, y, zu berühren
. z . Daher wird im SSE-Register der Vektor in einer verständlichen Reihenfolge
x, y, z, w und im Speicher in umgekehrter Reihenfolge
w, z, y, x gespeichert.
Ferner werden alle Multiplikationsoptionen auch durch einzelne Funktionen implementiert. Dies geschieht, weil ich sie verwende, um die gewünschte Option abhängig von der Art der unterstützten Anweisungen zu ersetzen.
Hier gut beschrieben.Wir implementieren die Grundfunktionalität
Multiplikation mit Schleifen, Zeile geordnet
Option für das Linienlayout von Elementenfor (int i = 0; i < 4; ++i) { for (int j = 0; j < 4; ++j) { r[i][j] = 0.f; for (int k = 0; k < 4; ++k) { r[i][j] += m[i][k] * n[k][j]; } } }
Hier ist alles einfach und klar. Für jedes Element machen wir 4 Multiplikationen und 3 Additionen. Insgesamt sind dies 64 Multiplikationen und 48 Additionen. Und dies ohne Berücksichtigung des Lesens der Datensatzelemente.
Kurz gesagt, alles ist traurig. Für diese Option hat IACA für den internen Zyklus Folgendes ausgegeben:
3,65 Taktzyklen für die x86-Assembly und 2,97 Takte für die x64-Assembly . Fragen Sie nicht, warum Bruchzahlen. Weiß nicht. IACA 2.1 litt nicht darunter. In jedem Fall sollten diese Zahlen mit ungefähr 4 * 4 * 4 = 64 multipliziert werden. Selbst wenn Sie x64 nehmen, ergibt sich ein Ergebnis von ungefähr 192 Takten. Es ist klar, dass dies eine grobe Schätzung ist. Ich sehe keinen Sinn darin, die Leistung für diese Option genauer zu bewerten.
Looping-Implementierung, Spalte bestellt
transponierte Matrix, Zeilen- und Spaltenindizes neu anordnen for (int i = 0; i < 4; ++i) { for (int j = 0; j < 4; ++j) { r[j][i] = 0.f; for (int k = 0; k < 4; ++k) { r[j][i] += m[k][i] * n[j][k]; } } }
Zyklusmultiplikation, SIMD-orientierter Speicher
Das Speichern von Zeilen in umgekehrter Reihenfolge im Speicher wird hinzugefügt for (int i = 0; i < 4; ++i) { for (int j = 0; j < 4; ++j) { r[j][3-i] = 0.f; for (int k = 0; k < 4; ++k) { r[j][3-i] += m[k][3-i] * n[j][3-k]; } } }
Diese Implementierung vereinfacht das Verständnis dessen, was im Inneren geschieht, etwas, reicht aber eindeutig nicht aus.
Hilfsklassen
Um das Verstehen und Schreiben von Referenz- und Debugging-Code zu vereinfachen, ist es zweckmäßig, einige Hilfsklassen zu implementieren. Nichts weiter, alles dient nur zum Verständnis. Ich stelle fest, dass die Implementierung vollwertiger Vektor- und Matrixklassen eine separate schwierige Frage ist und nicht im Thema dieses Artikels enthalten ist.
Vektor- und Matrixklassen struct alignas(sizeof(__m128)) vec4 { union { struct { float w, z, y, x; }; __m128 fmm; float arr[4]; }; vec4() {} vec4(float a, float b, float c, float d) : w(d), z(c), y(b), x(a) {} static bool equ(float const a, float const b, float t = .00001f) { return fabs(ab) < t; } bool operator == (vec4 const& v) const { return equ(x, vx) && equ(y, vy) && equ(z, vz) && equ(w, vw); } }; struct alignas(sizeof(__m256)) mtx4 {
Referenzfunktion für Tests
Da die akzeptierte Reihenfolge der Elemente in der Matrix das Verständnis erheblich erschwert, wird uns auch die
verständliche Referenzfunktion nicht stören, die in zukünftigen Implementierungen zeigen wird, dass alles korrekt funktioniert. Wir werden nachfolgende Ergebnisse damit vergleichen.
Um es zu erstellen, nehmen Sie einfach den Zyklus und erweitern Sie ihn void mul_mtx4_mtx4_unroll(__m128* const _r, __m128 const* const _m, __m128 const* const _n) { mtx4 const& m = **reinterpret_cast<mtx4 const* const*>(&_m); mtx4 const& n = **reinterpret_cast<mtx4 const* const*>(&_n); mtx4& r = **reinterpret_cast<mtx4* const*>(&_r); r._00 = m._00*n._00 + m._01*n._10 + m._02*n._20 + m._03*n._30; r._01 = m._00*n._01 + m._01*n._11 + m._02*n._21 + m._03*n._31; r._02 = m._00*n._02 + m._01*n._12 + m._02*n._22 + m._03*n._32; r._03 = m._00*n._03 + m._01*n._13 + m._02*n._23 + m._03*n._33; r._10 = m._10*n._00 + m._11*n._10 + m._12*n._20 + m._13*n._30; r._11 = m._10*n._01 + m._11*n._11 + m._12*n._21 + m._13*n._31; r._12 = m._10*n._02 + m._11*n._12 + m._12*n._22 + m._13*n._32; r._13 = m._10*n._03 + m._11*n._13 + m._12*n._23 + m._13*n._33; r._20 = m._20*n._00 + m._21*n._10 + m._22*n._20 + m._23*n._30; r._21 = m._20*n._01 + m._21*n._11 + m._22*n._21 + m._23*n._31; r._22 = m._20*n._02 + m._21*n._12 + m._22*n._22 + m._23*n._32; r._23 = m._20*n._03 + m._21*n._13 + m._22*n._23 + m._23*n._33; r._30 = m._30*n._00 + m._31*n._10 + m._32*n._20 + m._33*n._30; r._31 = m._30*n._01 + m._31*n._11 + m._32*n._21 + m._33*n._31; r._32 = m._30*n._02 + m._31*n._12 + m._32*n._22 + m._33*n._32; r._33 = m._30*n._03 + m._31*n._13 + m._32*n._23 + m._33*n._33; }
Der klassische Algorithmus ist hier klar dargestellt, es ist schwierig, einen Fehler zu machen (aber Sie können :-)). Darauf gab die IACA Folgendes heraus:
x86 - 69,95 Maßnahmen, x64 - 64 Maßnahmen . Hier sind ungefähr 64 Zyklen und wir werden uns die Beschleunigung dieses Vorgangs in Zukunft ansehen.
SSE-Implementierung
Klassischer SSE-Algorithmus
Warum klassisch? Weil es seit langem in der Implementierung von
FVec als Teil von MSVS ist. Zunächst werden wir schreiben, wie wir Matrixelemente in SSE-Registern darstellen. Hier sieht es schon einfacher aus. Nur eine transponierte Matrix.
// 00, 10, 20, 30 // m[0] - SIMD / 01, 11, 21, 31 // m[1] 02, 12, 22, 32 // m[2] 03, 13, 23, 33 // m[3]
Wir nehmen den
Abrollcode der obigen Variante. Irgendwie ist er für SSE unfreundlich. Die erste Gruppe von Zeilen besteht aus den Ergebnissen für die Spalte der resultierenden Matrix:
r._00, r._01, r._02, r._03 . Wir haben diese Spalte, aber wir brauchen eine Zeile. Ja, und
m ,
n sehen für Berechnungen unpraktisch aus. Daher ordnen wir die Zeilen des Algorithmus so an, dass das Ergebnis
r zeilenweise ist.
// , r[0] r00 = m00*n00 + m01*n10 + m02*n20 + m03*n30; r10 = m10*n00 + m11*n10 + m12*n20 + m13*n30; r20 = m20*n00 + m21*n10 + m22*n20 + m23*n30; r30 = m30*n00 + m31*n10 + m32*n20 + m33*n30; // , r[1] r01 = m00*n01 + m01*n11 + m02*n21 + m03*n31; r11 = m10*n01 + m11*n11 + m12*n21 + m13*n31; r21 = m20*n01 + m21*n11 + m22*n21 + m23*n31; r31 = m30*n01 + m31*n11 + m32*n21 + m33*n31; // , r[2] r02 = m00*n02 + m01*n12 + m02*n22 + m03*n32; r12 = m10*n02 + m11*n12 + m12*n22 + m13*n32; r22 = m20*n02 + m21*n12 + m22*n22 + m23*n32; r32 = m30*n02 + m31*n12 + m32*n22 + m33*n32; // , r[3] r03 = m00*n03 + m01*n13 + m02*n23 + m03*n33; r13 = m10*n03 + m11*n13 + m12*n23 + m13*n33; r23 = m20*n03 + m21*n13 + m22*n23 + m23*n33; r33 = m30*n03 + m31*n13 + m32*n23 + m33*n33;
Das ist aber schon viel besser. Was sehen wir eigentlich? Entsprechend den Spalten des Algorithmus in jeder Gruppe haben wir die Zeilen der Matrix
m beteiligt:
m [0] = {00,10,20,30}, m [1] = {01,11,21,31}, m [2] = {02,12,22,32}, m [3] = {03,13,23,33},
die mit dem gleichen Element der Matrix
n multipliziert werden. Zum Beispiel ist es für die erste Gruppe:
n._00, n._10, n._20, n._30 . Und die Elemente der Matrix
n für jede Gruppe von Zeilen des Algorithmus liegen wieder in einer Zeile der Matrix.
Dann ist alles einfach: Wir nehmen einfach die Zeilen der Matrix
m als Index, aber für die Elemente von
n nehmen wir ihre Zeile und streuen sie durch den
Shuffle- Befehl auf alle 4 Elemente des Registers, um sie mit der Zeile der Matrix
m im Register zu multiplizieren. Für das Element
n._00 (denken
Sie daran, dass seine Verschiebung im Register den Index 3 hat)
lautet dies beispielsweise:
_mm_shuffle_ps (n [0], n [0], _MM_SHUFFLE (3,3,3,3))
In vereinfachter Form sieht der Algorithmus folgendermaßen aus:
// n[0]={00,10,20,30} r[0] = m[0] * n00 + m[1] * n10 + m[2] * n20 + m[3] * n30; // n[1]={01,11,21,31} r[1] = m[0] * n01 + m[1] * n11 + m[2] * n21 + m[3] * n31; // n[2]={02,12,22,32} r[2] = m[0] * n02 + m[1] * n12 + m[2] * n22 + m[3] * n32; // n[3]={03,13,23,33} r[3] = m[0] * n03 + m[1] * n13 + m[2] * n23 + m[3] * n33;
Grundlegende SSE-Implementierung void mul_mtx4_mtx4_sse_v1(__m128* const r, __m128 const* const m, __m128 const* const n) { r[0] = _mm_add_ps( _mm_add_ps( _mm_mul_ps(m[0], _mm_shuffle_ps(n[0], n[0], _MM_SHUFFLE(3,3,3,3))), _mm_mul_ps(m[1], _mm_shuffle_ps(n[0], n[0], _MM_SHUFFLE(2,2,2,2)))), _mm_add_ps( _mm_mul_ps(m[2], _mm_shuffle_ps(n[0], n[0], _MM_SHUFFLE(1,1,1,1))), _mm_mul_ps(m[3], _mm_shuffle_ps(n[0], n[0], _MM_SHUFFLE(0,0,0,0))))); r[1] = _mm_add_ps( _mm_add_ps( _mm_mul_ps(m[0], _mm_shuffle_ps(n[1], n[1], _MM_SHUFFLE(3,3,3,3))), _mm_mul_ps(m[1], _mm_shuffle_ps(n[1], n[1], _MM_SHUFFLE(2,2,2,2)))), _mm_add_ps( _mm_mul_ps(m[2], _mm_shuffle_ps(n[1], n[1], _MM_SHUFFLE(1,1,1,1))), _mm_mul_ps(m[3], _mm_shuffle_ps(n[1], n[1], _MM_SHUFFLE(0,0,0,0))))); r[2] = _mm_add_ps( _mm_add_ps( _mm_mul_ps(m[0], _mm_shuffle_ps(n[2], n[2], _MM_SHUFFLE(3,3,3,3))), _mm_mul_ps(m[1], _mm_shuffle_ps(n[2], n[2], _MM_SHUFFLE(2,2,2,2)))), _mm_add_ps( _mm_mul_ps(m[2], _mm_shuffle_ps(n[2], n[2], _MM_SHUFFLE(1,1,1,1))), _mm_mul_ps(m[3], _mm_shuffle_ps(n[2], n[2], _MM_SHUFFLE(0,0,0,0))))); r[3] = _mm_add_ps( _mm_add_ps( _mm_mul_ps(m[0], _mm_shuffle_ps(n[3], n[3], _MM_SHUFFLE(3,3,3,3))), _mm_mul_ps(m[1], _mm_shuffle_ps(n[3], n[3], _MM_SHUFFLE(2,2,2,2)))), _mm_add_ps( _mm_mul_ps(m[2], _mm_shuffle_ps(n[3], n[3], _MM_SHUFFLE(1,1,1,1))), _mm_mul_ps(m[3], _mm_shuffle_ps(n[3], n[3], _MM_SHUFFLE(0,0,0,0))))); }
Jetzt ändern wir die Elemente
n im Algorithmus in die entsprechende
Zufallswiedergabe , Multiplikation mit
_mm_mul_ps , die Summe mit
_mm_add_ps , und fertig. Es funktioniert. Der Code sieht jedoch viel schlechter aus als der Algorithmus selbst. Zu diesem Code gab IACA Folgendes aus:
x86 - 18.89, x64 - 16 Zyklen . Dies ist viermal schneller als der vorherige. Im SSE-Register 4. Float. Fast lineare Beziehung.
Dekorieren Sie die SSE-Implementierung
Trotzdem sieht es im Code schrecklich aus. Wir werden versuchen, dies zu verbessern, indem wir ein wenig syntaktischen Zucker schreiben.
Operatoren und Verbesserer Der Compiler kann diese Funktionen perfekt einbinden (wenn auch manchmal ohne __forceinline).
Der Code dreht sich also ... void mul_mtx4_mtx4_sse_v2(__m128* const r, __m128 const* const m, __m128 const* const n) { r[0] = m[0]*shuf<3>(n[0]) + m[1]*shuf<2>(n[0]) + m[2]*shuf<1>(n[0]) + m[3]*shuf<0>(n[0]); r[1] = m[0]*shuf<3>(n[1]) + m[1]*shuf<2>(n[1]) + m[2]*shuf<1>(n[1]) + m[3]*shuf<0>(n[1]); r[2] = m[0]*shuf<3>(n[2]) + m[1]*shuf<2>(n[2]) + m[2]*shuf<1>(n[2]) + m[3]*shuf<0>(n[2]); r[3] = m[0]*shuf<3>(n[3]) + m[1]*shuf<2>(n[3]) + m[2]*shuf<1>(n[3]) + m[3]*shuf<0>(n[3]); }
Und so ist es schon viel besser und lesbarer. Zu diesem Zweck erzielte IACA ungefähr das erwartete Ergebnis:
x86 - 19 (und warum nicht fraktioniert?), X64 - 16 . Tatsächlich hat sich die Leistung nicht geändert, aber der Code ist viel schöner und verständlicher.
Wenig Beitrag zur zukünftigen Optimierung
Lassen Sie uns eine weitere Verbesserung auf der Ebene einer Funktion einführen, die kürzlich in der Eisenversion erschien. Operation
Multiple-Add (fma) .
fma (a, b, c) = a * b + c .
Implementierung mit mehreren Adds __m128 mad(__m128 const a, __m128 const b, __m128 const c) { return _mm_add_ps(_mm_mul_ps(a, b), c); }
Warum ist das notwendig? Zunächst für die zukünftige Optimierung. Zum Beispiel können Sie
mad im fertigen Code einfach durch dieselben Makros durch
fma ersetzen, wie Sie
möchten . Aber wir werden jetzt den Grundstein für die Optimierung legen:
Variante mit Mehrfachaddition void mul_mtx4_mtx4_sse_v3(__m128* const r, __m128 const* const m, __m128 const* const n) { r[0] = mad(m[0], shuf<3>(n[0]), m[1]*shuf<2>(n[0])) + mad(m[2], shuf<1>(n[0]), m[3]*shuf<0>(n[0])); r[1] = mad(m[0], shuf<3>(n[1]), m[1]*shuf<2>(n[1]) + mad(m[2], shuf<1>(n[1]), m[3]*shuf<0>(n[1])); r[2] = mad(m[0], shuf<3>(n[2]), m[1]*shuf<2>(n[2])) + mad(m[2], shuf<1>(n[2]), m[3]*shuf<0>(n[2])); r[3] = mad(m[0], shuf<3>(n[3]), m[1]*shuf<2>(n[3])) + mad(m[2], shuf<1>(n[3]), m[3]*shuf<0>(n[3])); }
IACA:
x86 - 18,89, x64 - 16 . Wieder fraktioniert. Trotzdem führt IACA manchmal zu seltsamen Ergebnissen. Der Code hat sich nicht so sehr geändert. Wahrscheinlich noch ein bisschen schlimmer. Aber die Optimierung erfordert manchmal solche Opfer.
Wir gehen zum Speichern durch _mm_stream über
Verschiedene Optimierungshandbücher empfehlen erneut, den Cache nicht für Massenspeichervorgänge zu ziehen. Dies ist normalerweise gerechtfertigt, wenn Sie Scheitelpunkte verarbeiten, die Tausende oder mehr betragen. Aber für Matrizen ist das vielleicht nicht so wichtig. Ich werde es jedoch trotzdem hinzufügen.
Stream-Speicheroption void mul_mtx4_mtx4_sse_v4(__m128* const r, __m128 const* const m, __m128 const* const n) { _mm_stream_ps(&r[0].m128_f32[0], mad(m[0], shuf<3>(n[0]), m[1]*shuf<2>(n[0])) + mad(m[2], shuf<1>(n[0]), m[3]*shuf<0>(n[0]))); _mm_stream_ps(&r[1].m128_f32[0], mad(m[0], shuf<3>(n[1]), m[1]*shuf<2>(n[1])) + mad(m[2], shuf<1>(n[1]), m[3]*shuf<0>(n[1]))); _mm_stream_ps(&r[2].m128_f32[0], mad(m[0], shuf<3>(n[2]), m[1]*shuf<2>(n[2])) + mad(m[2], shuf<1>(n[2]), m[3]*shuf<0>(n[2]))); _mm_stream_ps(&r[3].m128_f32[0], mad(m[0], shuf<3>(n[3]), m[1]*shuf<2>(n[3])) + mad(m[2], shuf<1>(n[3]), m[3]*shuf<0>(n[3]))); }
Hier hat sich im Laufe der Zeit nichts geändert. Den Empfehlungen zufolge berühren wir den Cache jetzt jedoch nicht mehr.
AVX-Implementierung
Basis-AVX-Option

Als nächstes fahren wir mit der nächsten Stufe der Optimierung fort. Der 4. Float ist im SSE-Register enthalten, und im AVX ist er bereits 8. Das heißt, es besteht eine theoretische Chance, die Anzahl der ausgeführten Operationen zu reduzieren und die Produktivität zu steigern, wenn nicht um die Hälfte, dann mindestens um das 1,5-fache. Aber irgendetwas sagt mir, dass mit dem Übergang zu AVX nicht alles so einfach sein wird. Können wir die notwendigen Daten aus Doppelregistern erhalten?
Versuchen wir es herauszufinden. Wieder schreiben wir unseren oben verwendeten Multiplikationsalgorithmus aus. Sie können dies nicht tun, aber es ist bequemer, mit dem Code umzugehen, wenn sich alles in der Nähe befindet und Sie keine halbe Seite nach oben scrollen müssen.
// : 00, 10, 20, 30, 01, 11, 21, 31, 02, 12, 22, 32, 03, 13, 23, 33 // SSE: r0 = m0*n00 + m1*n10 + m2*n20 + m3*n30 r1 = m0*n01 + m1*n11 + m2*n21 + m3*n31 r2 = m0*n02 + m1*n12 + m2*n22 + m3*n32 r3 = m0*n03 + m1*n13 + m2*n23 + m3*n33
Am Ausgang erwarten wir das Ergebnis in
ymm = {r0: r1} und
ymm = {r2: r3} . Wenn unser Algorithmus in der SSE-Version auf Spalten verallgemeinert wurde, müssen wir ihn jetzt auf Zeilen verallgemeinern. Es funktioniert also nicht, wie bei der SSE-Option zu handeln.
Wenn wir die Matrix
m in den Registern
ymm betrachten , erhalten wir
ymm = {m0: m1} bzw.
ymm = {m2: m3} . Bisher hatten wir nur Matrixspalten im Register und jetzt Spalten und Zeilen.
Wenn Sie versuchen, wie zuvor zu handeln, müssen Sie
ymm = {m0: m1} mit dem Register
ymm = {n00, n00, n00, n00}: {n10, n10, n10, n10} multiplizieren . Da sich
n00 und
n01 in derselben Zeile der Matrix
n befinden , ist es nach dem verfügbaren Satz von AVX-Befehlen teuer, sie um
ymm zu
streuen . Sowohl
Shuffle als auch
Permute arbeiten getrennt für jeden der beiden Viere eines Schwimmers (hoch und niedrig
xmm ) in
ymm- Registern.
Wenn wir
ymm aus der Matrix
n nehmen , erhalten wir beide Elemente
n00 und
n10 im höchsten von 2
xmm innerhalb des
ymm- Registers.
{n00, n10, n20, n30}: {n01, n11, n21, n31} . Normalerweise liegt der Index für vorhandene Anweisungen zwischen 0 und 3. Die Adressen schweben nur innerhalb eines
xmm- Registers von zwei innerhalb des
ymm- Registers. Es ist nicht möglich,
n10 billig vom älteren
xmm auf das jüngere zu übertragen. Und dann muss dieser Fokus mehrmals wiederholt werden. Wir können einen solchen Verlust von Maßnahmen nicht ertragen. Es ist notwendig, sich etwas anderes auszudenken.
Früher haben wir Spalten verallgemeinert, jetzt aber Zeilen. Deshalb werden wir versuchen, einen etwas anderen Weg zu gehen. Wir müssen das Ergebnis in
{r0: r1} erhalten . Dies bedeutet, dass der Algorithmus nicht in separaten Zeilen des Algorithmus verbessert werden muss, sondern in zwei gleichzeitig. Und hier wird das Minus in der Arbeit von
Shuffle und
Permute ein Plus für uns sein. Wir schauen uns an, was wir in den
ymm- Registern haben werden, wenn wir die Matrix
n betrachten .
n0n1 = {00, 10, 20, 30} : {01, 11, 21, 31} n2n3 = {02, 12, 22, 32} : {03, 13, 23, 33}
Ja, wir bemerken, dass wir in verschiedenen
xmm- Teilen des
ymm- Registers die Elemente
00 und
01 haben . Sie können durch Registrierung mit dem Befehl
permute in
{_00, _00, _00, _00} multipliziert werden: {_ 01, _01, _01, _01} , wobei nur ein Index 3 für beide
xmm- Teile angegeben wird. Genau das brauchen wir. In der Tat werden die Koeffizienten auch in verschiedenen Zeilen verwendet. Erst jetzt muss im entsprechenden
ymm- Register für die Multiplikation
{m0: m0} beibehalten werden, dh die duplizierte erste Zeile der Matrix
m .
Also malen wir den Algorithmus detaillierter. Wir lesen die doppelten Zeilen der Matrix
m in
ymm- Registern:
mm[0] = {m0:m0} mm[1] = {m1:m1} mm[2] = {m2:m2} mm[3] = {m3:m3}
Und dann berechnen wir die Multiplikation wie folgt:
r0r1 = mm[0] * {n00,n00,n00,n00:n01,n01,n01,n01} + // permute<3,3,3,3>(n0n1) mm[1] * {n10,n10,n10,n10:n11,n11,n11,n11} + // permute<2,2,2,2>(n0n1) mm[2] * {n20,n20,n20,n20:n21,n21,n21,n21} + // permute<1,1,1,1>(n0n1) mm[3] * {n30,n30,n30,n30:n31,n31,n31,n31} // permute<0,0,0,0>(n0n1) r2r3 = mm[0] * {n02,n02,n02,n02:n03,n03,n03,n03} + // permute<3,3,3,3>(n2n3) mm[1] * {n12,n12,n12,n12:n13,n13,n13,n13} + // permute<2,2,2,2>(n2n3) mm[2] * {n22,n22,n22,n22:n23,n23,n23,n23} + // permute<1,1,1,1>(n2n3) mm[3] * {n32,n32,n32,n32:n33,n33,n33,n33} // permute<0,0,0,0>(n2n3)
Wir schreiben klarer um:
r0r1 = mm[0]*n0n1<3,3,3,3>+mm[1]*n0n1<2,2,2,2>+mm[2]*n0n1<1,1,1,1>+mm[3]*n0n1<0,0,0,0> r2r3 = mm[0]*n2n3<3,3,3,3>+mm[1]*n2n3<2,2,2,2>+mm[2]*n2n3<1,1,1,1>+mm[3]*n2n3<0,0,0,0>
Oder in vereinfachter Form:
r0r1 = mm[0]*n0n1<3> + mm[1]*n0n1<2> + mm[2]*n0n1<1> + mm[3]*n0n1<0> r2r3 = mm[0]*n2n3<3> + mm[1]*n2n3<2> + mm[2]*n2n3<1> + mm[3]*n2n3<0>
Alles scheint klar zu sein.
Es bleibt nur eine Implementierung zu schreiben void mul_mtx4_mtx4_avx_v1(__m128* const r, __m128 const* const m, __m128 const* const n) { __m256 mm0 = _mm256_set_m128(m[0], m[0]); __m256 mm1 = _mm256_set_m128(m[1], m[1]); __m256 mm2 = _mm256_set_m128(m[2], m[2]); __m256 mm3 = _mm256_set_m128(m[3], m[3]); __m256 n0n1 = _mm256_load_ps(&n[0].m128_f32[0]); __m256 y1 = _mm256_permute_ps(n0n1, 0xFF);
Hier sind die interessanten Zahlen von IACA:
x86 - 12.53, x64 - 12 . Obwohl ich natürlich besser wollte. Etwas verpasst.
AVX-Optimierung plus syntaktischer Zucker
Es scheint, dass im obigen Code AVX nicht in vollem Umfang genutzt wurde. Wir stellen fest, dass wir anstelle von zwei identischen Zeilen im
ymm- Register
Broadcast verwenden können, der das
ymm- Register mit zwei identischen
xmm- Werten füllen kann. Fügen Sie auf dem Weg auch etwas „syntaktischen Zucker“ für die AVX-Funktionen hinzu.
Erweiterte AVX-Implementierung __m256 operator + (__m256 const a, __m256 const b) { return _mm256_add_ps(a, b); } __m256 operator - (__m256 const a, __m256 const b) { return _mm256_sub_ps(a, b); } __m256 operator * (__m256 const a, __m256 const b) { return _mm256_mul_ps(a, b); } __m256 operator / (__m256 const a, __m256 const b) { return _mm256_div_ps(a, b); } template <int i> __m256 perm(__m256 const v) { return _mm256_permute_ps(v, _MM_SHUFFLE(i, i, i, i)); } template <int a, int b, int c, int d> __m256 perm(__m256 const v) { return _mm256_permute_ps(v, _MM_SHUFFLE(a, b, c, d)); } template <int i, int j> __m256 perm(__m256 const v) { return _mm256_permutevar_ps(v, _mm256_set_epi32(i, i, i, i, j, j, j, j)); } template <int a, int b, int c, int d, int e, int f, int g, int h> __m256 perm(__m256 const v) { return _mm256_permutevar_ps(v, _mm256_set_epi32(a, b, c, d, e, f, g, h)); } __m256 mad(__m256 const a, __m256 const b, __m256 const c) { return _mm256_add_ps(_mm256_mul_ps(a, b), c); } void mul_mtx4_mtx4_avx_v2(__m128* const r, __m128 const* const m, __m128 const* const n) { __m256 const mm[] { _mm256_broadcast_ps(m+0), _mm256_broadcast_ps(m+1), _mm256_broadcast_ps(m+2), _mm256_broadcast_ps(m+3) }; __m256 const n0n1 = _mm256_load_ps(&n[0].m128_f32[0]); _mm256_stream_ps(&r[0].m128_f32[0], mad(perm<3>(n0n1), mm[0], perm<2>(n0n1)*mm[1])+ mad(perm<1>(n0n1), mm[2], perm<0>(n0n1)*mm[3])); __m256 const n2n3 = _mm256_load_ps(&n[2].m128_f32[0]); _mm256_stream_ps(&r[2].m128_f32[0], mad(perm<3>(n2n3), mm[0], perm<2>(n2n3)*mm[1])+ mad(perm<1>(n2n3), mm[2], perm<0>(n2n3)*mm[3])); }
Und hier sind die Ergebnisse schon interessanter. IACA produziert Zahlen:
x86 - 10, x64 - 8.58 , was viel besser aussieht, aber immer noch nicht zweimal.
AVX + FMA Option (endgültig)
Machen wir noch einen Versuch. Jetzt wäre es logisch, den FMA-Befehlssatz erneut abzurufen, da er nach AVX zu den Prozessoren hinzugefügt wurde. Ändern Sie einfach einzelne
Mul + Add für eine Operation. Obwohl wir immer noch die Anweisung der Multiplikation verwenden, um dem Compiler mehr Möglichkeiten zur Optimierung und dem Prozessor die parallele Ausführung von Multiplikationen zu geben. Normalerweise schaue ich mir den generierten Code im Assembler an, um sicherzustellen, welche Option besser ist.
In diesem Fall müssen wir
a * b + c * d + e * f + g * h berechnen. Sie können diese Stirn tun:
fma (a, b, fma (c, d, fma (e, f, g * h))) . Wie wir jedoch sehen, ist es hier unmöglich, eine Operation durchzuführen, ohne die vorherige abzuschließen. Dies bedeutet, dass wir nicht in der Lage sein werden, gepaarte Multiplikationen durchzuführen, wie dies die SIMD-Pipeline ermöglicht. Wenn wir die Berechnungen
fma (a, b, c * d) + fma (e, f, g * h) leicht transformieren, werden wir sehen, dass wir die Berechnungen parallelisieren können.
Führen Sie zuerst zwei unabhängige Multiplikationen und dann zwei unabhängige
fma- Operationen durch.
AVX + FMA-Implementierung __m256 fma(__m256 const a, __m256 const b, __m256 const c) { return _mm256_fmadd_ps(a, b, c); } void mul_mtx4_mtx4_avx_fma(__m128* const r, __m128 const* const m, __m128 const* const n) { __m256 const mm[]{ _mm256_broadcast_ps(m + 0), _mm256_broadcast_ps(m + 1), _mm256_broadcast_ps(m + 2), _mm256_broadcast_ps(m + 3) }; __m256 const n0n1 = _mm256_load_ps(&n[0].m128_f32[0]); _mm256_stream_ps(&r[0].m128_f32[0], fma(perm<3>(n0n1), mm[0], perm<2>(n0n1)*mm[1])+ fma(perm<1>(n0n1), mm[2], perm<0>(n0n1)*mm[3])); __m256 const n2n3 = _mm256_load_ps(&n[2].m128_f32[0]); _mm256_stream_ps(&r[2].m128_f32[0], fma(perm<3>(n2n3), mm[0], perm<2>(n2n3)*mm[1])+ fma(perm<1>(n2n3), mm[2], perm<0>(n2n3)*mm[3])); }
IACA:
x86 - 9,21, x64 - 8 . Jetzt ist es sehr gut. Jemand wird wahrscheinlich sagen, was noch besser gemacht werden kann, aber ich weiß nicht wie.
Benchmarks
Ich stelle sofort fest, dass diese Zahlen nicht als die ultimative Wahrheit angesehen werden sollten. Selbst mit einem festen Test schwimmen sie innerhalb bestimmter Grenzen. Und noch mehr verhalten sie sich auf verschiedenen Plattformen unterschiedlich.
Nehmen Sie bei jeder Optimierung Messungen speziell für Ihren Fall vor.Inhaltsverzeichnis
- Funktion: Funktionsname. Das Ende auf s - funktioniert mit einem Streaming, anders normal mov (ohne Streaming). Zur Verdeutlichung hinzugefügt, da dies wichtig genug ist.
- IACA-Zyklen: Anzahl der von IACA berechneten Ticks pro Funktion
- Gemessene Zyklen: gemessene Anzahl von Messungen (weniger ist mehr)
- IACA-Beschleunigung: Anzahl der Kennzahlen in einer Nulllinie / Anzahl der Kennzahlen in einer Zeile
- Gemessene Beschleunigung: Anzahl der Takte in der Nulllinie / Anzahl der Takte in der Linie (je mehr desto besser)
Für loop_m wurden die Ticks aus dem Artikel mit 64 multipliziert. Dies ist ein sehr ungefährer Wert. Tatsächlich stellte sich heraus, dass dies so war.i3-3770:
i7-8700K:
Testcode in der Quelle. Wenn es vernünftige Vorschläge gibt, wie man sie verbessern kann, schreibe in die Kommentare.BONUS aus dem Bereich der Fiktion
Eigentlich ist es aus dem Bereich der Fiktion, denn wenn ich Prozessoren mit Unterstützung für AVX512 gesehen habe, dann vielleicht auf den Bildern. Ich habe jedoch versucht, den Algorithmus zu implementieren. Hier werde ich nichts erklären, eine komplette Analogie zu AVX + FMA. Der Algorithmus ist der gleiche, nur weniger Operationen.Wie sie sagen, lasse ich es einfach hier __m512 operator + (__m512 const a, __m512 const b) { return _mm512_add_ps(a, b); } __m512 operator - (__m512 const a, __m512 const b) { return _mm512_sub_ps(a, b); } __m512 operator * (__m512 const a, __m512 const b) { return _mm512_mul_ps(a, b); } __m512 operator / (__m512 const a, __m512 const b) { return _mm512_div_ps(a, b); } template <int i> __m512 perm(__m512 const v) { return _mm512_permute_ps(v, _MM_SHUFFLE(i, i, i, i)); } template <int a, int b, int c, int d> __m512 perm(__m512 const v) { return _mm512_permute_ps(v, _MM_SHUFFLE(a, b, c, d)); } __m512 fma(__m512 const a, __m512 const b, __m512 const c) { return _mm512_fmadd_ps(a, b, c); } void mul_mtx4_mtx4_avx512(__m128* const r, __m128 const* const m, __m128 const* const _n) { __m512 const mm[]{ _mm512_broadcast_f32x4(m[0]), _mm512_broadcast_f32x4(m[1]), _mm512_broadcast_f32x4(m[2]), _mm512_broadcast_f32x4(m[3]) }; __m512 const n = _mm512_load_ps(&_n[0].m128_f32[0]); _mm512_stream_ps(&r[0].m128_f32[0], fma(perm<3>(n), mm[0], perm<2>(n)*mm[1])+ fma(perm<1>(n), mm[2], perm<0>(n)*mm[3])); }
Die Zahlen sind fantastisch: x86 - 4.79, x64 - 5.42 (IACA mit SKX-Architektur). Dies trotz der Tatsache, dass der Algorithmus 64 Multiplikationen und 48 Additionen hat.PS-Code aus dem Artikel
Dies ist meine erste Erfahrung beim Schreiben eines Artikels. Vielen Dank für Ihre Kommentare. Sie helfen, den Code und den Artikel besser zu machen.