- Ein Experiment, das beim Kopieren von Strings ASCII-Großbuchstaben mit AVX-512-BW in 64-Byte-Blöcken in Kleinbuchstaben umwandelt, um SIMD-Leistung auch bei kleinen Strings herauszuholen
- Der Kern der Implementierung ist eine Maskenoperation, die nur an den Positionen
'a' - 'A'addiert, an denen jedes Byte zwischen'A'und'Z'liegt - Kurze Strings und die verbleibenden Endstücke langer Strings werden mit masked load/store verarbeitet, um die bei SIMD-Code häufigen Kosten für kleine Reststücke zu senken
- Gemessen auf Clang 16, Debian 11 und einem AMD Ryzen 9 7950X bei etwa 1MiB Kopiermenge in 1-Byte- bis 1KiB-Chunks gehört
tolower64im Vergleichsfeld durchgehend zu den schnelleren Varianten - Auf Zen 4 zeigte sich AVX-512-BW als gut passend für String-Verarbeitung, während ARM SVE und die RISC-V Vector Extension nicht direkt im Detail überprüft werden konnten
Mit AVX-512-BW ein 64-Byte-tolower() bauen
- Ziel ist es, einen
tolower()-Kernel per SIMD zu implementieren, der beim Kopieren eines Strings ASCII-Großbuchstaben in Kleinbuchstaben umwandelt - AVX-512-BW ist eine Erweiterung, die Operationen auf Byte- und Word-Ebene unterstützt und auf aktuellen AMD-Zen-Prozessoren genutzt werden kann
- AVX-512 ist in mehrere Erweiterungen aufgeteilt, was die Unterstützung komplex macht
- Besonders auf Intel-Seite gilt die Unterstützung als uneinheitlich
- ARM SVE bietet ebenfalls masked load/store auf Byte-Ebene, was gut zur String-Verarbeitung passt
- Verfügbar auf aktuellen großen ARM-Neoverse-Kernen, etwa bei Amazon Graviton
- Auf Apple Silicon ist es nicht verfügbar
- Die RISC-V Vector Extension folgt einem ähnlichen Stil wie ARM SVE und ist auf mehreren kleinen Single-Board-Computern nutzbar
Funktionsweise von tolower64()
tolower64()ist ein AVX-512-basierter Kernel, der 64 Byte auf einmal verarbeitet- Zuerst werden Referenzwerte in ein Vektorregister mit 64 Bytes geladen
'A''Z''a' - 'A'
- Der Eingabezeichenvektor
cwird mit'A'und'Z'verglichen, um jeweils 64-Bit-Masken zu erzeugen- Positionen, an denen
c >= 'A'gilt - Positionen, an denen
c <= 'Z'gilt
- Positionen, an denen
- Beide Masken werden mit
_kand_mask64()kombiniert, um eineis_upper-Maske zu erzeugen, die nur Positionen mit Großbuchstaben markiert - Abschließend wird
_mm512_mask_add_epi8()angewendet- Für Bytes, bei denen
is_upperfalse ist, bleibt der ursprüngliche Wertcerhalten - Für Bytes, bei denen
is_uppertrue ist, wird darausc + ('a' - 'A')
- Für Bytes, bei denen
Verarbeitung langer und kurzer Strings
- Der Großteil langer Strings wird mit gewöhnlichem unaligned Vector load/store verarbeitet
_mm512_loadu_epi8()tolower64()_mm512_storeu_epi8()
- Für kurze Strings und die letzten verbleibenden Stücke langer Strings wird masked unaligned load/store verwendet
- Die Maske wird so erzeugt, dass nur die unteren
len-Bits gesetzt sinduint64_t len_bits = (~0ULL) >> (64 - len)- Übergabe an ein SIMD-Maskenregister mit
_cvtu64_mask64(len_bits)
_mm512_maskz_loadu_epi8()füllt Positionen mit deaktivierter Maske im Zielregister mit 0_mm512_mask_storeu_epi8()speichert nur an Positionen mit aktivierter Maske- Dieser Ansatz ist der Schlüssel für die schnelle Verarbeitung kleiner String-Fragmente
Benchmark-Bedingungen und Vergleichskandidaten
- Der Benchmark lief mit Clang 16, Debian 11 und einem AMD Ryzen 9 7950X
- Gemessen wurde eine Kopiermenge von etwa 1MiB, wobei die Chunk-Länge von 1 Byte bis 1KiB variiert wurde
- Um unterschiedliche Ausrichtungen von Quell- und Ziel-String zu berücksichtigen, wurde zwischen den Strings ein Abstand von einigen Bytes eingefügt; diese Bytes wurden nicht in die 1MiB-Messmenge einbezogen
- Da der Ryzen 9 7950X pro Kern 1MiB L2-Cache hat, ist zu erwarten, dass jeder Testlauf bis in den L3-Cache hineinreicht
- Jede Funktion wurde separat kompiliert, um Interferenzen durch Inlining und Codebewegung zu vermeiden
- In realem Code ist es allerdings wahrscheinlicher, dass man Inlining eher fördert als verhindert
Ergebnis: die gleichmäßige Leistung von tolower64
- Das pinke
tolower64liegt insgesamt durchgehend nahe an den schnellsten Testfunktionen- Bei einer Länge von 65 Byte gibt es einen kleinen Einbruch beim Übergang zum zweiten Vektor
- Danach steigt die Leistung schnell wieder an, ohne tiefe Leistungstäler, was zeigt, dass masked load/store bei kurzen String-Stücken effektiv ist
- Das grüne
copybytes64ist einememcpy-Variante, die AVX-512 auf ähnliche Weise nutzt- Es ist nicht wesentlich schneller als
tolower64 - Aktuelles Clang erkennt die Bedeutung dieser Funktion und schreibt sie vollständig um, daher wurde sie mit Clang 11 kompiliert
- Es ist nicht wesentlich schneller als
- Das orange
copybytes1ist eine Byte-für-Byte-memcpy-Variante- Kompiliert mit Clang 11
- Zeigt, dass die Auto-Vektorisierungs-Heuristik von Clang 11 bei String-Stücken unter 256 Byte relativ schwach ist
- Das rote
tolowerist die langsame Baseline, die das Standard-tolower()aus<ctype.h>aufruft - Das violette
tolower1ist ein Byte-für-Byte-tolower(), kompiliert mit Clang 16- Die Auto-Vektorisierung von Clang 16 ist deutlich besser als bei Clang 11
- Es bleibt langsamer als die handgeschriebene Version und erzeugt wesentlich komplexeren Code
- Die Verarbeitung kurzer String-Stücke ist nicht so gut wie bei
tolower64, weshalb der Leistungsgraph spitz und unruhig verläuft
- Das braune
tolower8ist das SWAR-tolower()aus dem vorherigen Beitrag- Clang versucht zwar Auto-Vektorisierung, aber wegen der Komplexität der Funktion fällt das Ergebnis schwach aus
- Obwohl mit Clang 16 kompiliert, zeigt sich ein Leistungseinbruch bei 256 Byte im Stil von Clang 11
- Das blaue
memcpyruftmemcpyaus glibc auf- Anfangs ist es schnell, fällt dann aber in einen Bereich auf etwa die Hälfte der Geschwindigkeit von
copybytes64 - Die Ursache konnte nicht geklärt werden
- Anfangs ist es schnell, fällt dann aber in einen Bereich auf etwa die Hälfte der Geschwindigkeit von
Fazit und Code
- AVX-512-BW eignet sich sehr gut für Strings, insbesondere für kurze Strings
- Auf Zen 4 ist es sehr schnell, und die Intrinsic-Funktionen sind vergleichsweise leicht zu verwenden
- Das auffälligste Merkmal ist die gleichmäßige Leistung
- Die Leistungstäler, die bei Auto-Vektorisierung durch den Wechsel auf Skalarcode für kleine String-Stücke entstehen, sind kaum zu sehen
- Mangels bequemem Zugang zu Hardware mit ARM-SVE- oder RISC-V-Vector-Extension-Unterstützung konnten diese beiden Erweiterungen nicht im Detail untersucht werden
- Der Code ist im Git-Repository der Website verfügbar
1 Kommentare
Hacker-News-Kommentare
Der Trick „unsafe read beyond of death“ gilt im Speichermodell von Rust und LLVM als undefiniertes Verhalten, selbst wenn die Hardware ihn zulässt.
Wie bei anderem undefiniertem Verhalten kann der Compiler bei der Optimierung davon ausgehen, dass „so etwas nicht passiert“, was zu unerwarteten Ergebnissen führen kann; um das zu umgehen, muss man Inline-Assembly verwenden.
https://github.com/ogxd/gxhash/issues/82
Ein Load nach dem Muster „Werte außerhalb des Allokationsbereichs werden als unbestimmte Elemente gelesen, und nur wenn die Hardware es nicht mag, ist es undefiniertes Verhalten“ scheint nicht schwer zu unterstützen zu sein; intern könnte er auch einfach ein Alias für den entsprechenden Assembly-Aufruf sein.
Noch besser wäre es, wenn nach jeder Allokation —
malloc, Stack, Konstanten usw. — mindestens etwa 64 Byte an fault-freien Adressen garantiert wären, aber das wäre deutlich komplizierter, weil mehrere Komponenten zusammenarbeiten müssten.Bei einem Custom Allocator ist das trivial, aber dann lässt sich SIMD-Code nur schwer auf Daten außerhalb des eigenen Heaps anwenden, und man bleibt an eine winzige Möglichkeit eines Segfaults gebunden.
Auch Sanitizer oder Valgrind wären weiterhin nützlich, weil Werte außerhalb des Bereichs als unbestimmte Werte verfolgt werden können und erst bei tatsächlicher Verwendung einen Fehler auslösen können.
Mich würde interessieren, was passiert, wenn man aus einer nicht gemappten Page oder geschütztem Speicher liest; da ich den Code nicht gesehen habe, weiß ich nicht, ob die Alignment-Garantien das verhindern.
Undefiniertes Verhalten ist ein Fachbegriff aus dem C-Standard, daher ist schon die Verallgemeinerung merkwürdig; ANSI C erlaubt eine solche Annahme nicht ausdrücklich, und ISO C ist zwar offener, rechtfertigt diese Annahme aber auch nicht konkret.
Erklärungen der Art „UB = der Compiler darf annehmen, dass es nicht passieren kann“ halte ich für ziemlich unehrliche Panikmache.
Beim Blick auf den sauberen und performanten Code im Artikel frage ich mich, wie AMDs AVX512-Implementierung und Intels geplantes AVX10 miteinander konkurrieren werden.
Der Kern von AVX10 scheint darin zu liegen, Intels P-Core/E-Core-Situation zu lösen; AMD scheint dagegen je nach Situation mit der Vollbreiten-Implementierung in Zen 5 oder der 256-Bit-Zweifachverarbeitung in Zen 4 und Zen-5-Mobile einen besseren Ansatz zu wählen, während die API nahtlos bleibt.
Auch die großen Performance-Gewinne im Artikel stammen alle von Zen-4-Kernen, und weil AVX512 viele Vorteile hat, ist es frustrierend, dass Intel es durch Marktsegmentierung so stark eingeschränkt und damit die Einführung in allgemeinem Client-Code praktisch verhindert hat.
Der Markt hat Codepfad-Verzweigungen pro CPU immer wieder abgelehnt, und bei wichtigen SIMD-Implementierungen zählt in der Praxis der kleinste gemeinsame Nenner.
AVX10.1/256 und AVX512VL haben eine gemeinsame Teilmenge; wenn genug Zeit vergangen ist und die meisten CPUs sie unterstützen, werden die Leute genau darauf abzielen.
AMD wird weiterhin einfache Siege in einigen Benchmark-Apps holen, die für AVX512 aktualisiert wurden, aber wenn Intel an seinem AVX10-Plan festhält, wird AMD vermutlich ebenfalls flächendeckend auf eine zweifach verarbeitende SIMD-Pipeline setzen, um AVX10/256 effizient zu unterstützen und zugleich AVX512-Kompatibilität zu behalten.
Intel hat in den letzten zehn Jahren viele schlechte Entscheidungen getroffen, aber den Markt über den Befehlssatz zu fragmentieren war eine der schlimmsten. Damit haben sie den Schwung und das Interesse an neueren Innovationen selbst abgewürgt; Funktionen wie Maskenoperationen sind viel wichtiger als die Breite an sich, daher hoffe ich, dass AVX10/256 in die gesamte Produktpalette kommt.
Der Begriff hat eine bestimmte Bedeutung, die nicht zum tatsächlichen Verhalten passt.
Zen 4 dekodiert ZMM-Registeroperationen lediglich in mehrere Mikro-Operationen und schedult sie auf freie 256-Bit-Einheiten; Full-Width-512-Bit-Shuffles werden durch dedizierte Hardware speziell behandelt, um teure Emulation zu vermeiden.
Deshalb verhält sich Zen 4 mit seinen vier 256-Bit-SIMD-Einheiten wie ein leistungsfähiger 2×512-Bit-Kern, und diese Implementierung ist keineswegs eine billige Lösung, sondern dürfte die bisher beste Form in Consumer-Hardware sein.
Sie hatten bereits jahrelang Zeit, das zu beheben, und es ist ärgerlich, dass die Unterstützung durch AMD wegen des Marktanteils trotzdem nicht zur Adoption führt; AVX10 wird Intel leider wohl ermöglichen, die Welt noch länger auszubremsen.
Auf dem Desktop möchte ich bessere Kerne, mehr Kerne und einen gut standardisierten Befehlssatz sehen, der nützliche Funktionen wie breites SIMD, float16 und gather/scatter freischaltet; AMD macht das ziemlich gut.
Intel dagegen setzt schwache Kerne neben ordentliche Kerne, beschneidet die ordentlichen Kerne, um zu den schwachen zu passen, bringt über mehrere Generationen CPUs mit derselben Kernzahl heraus, lässt es durch schwache Kerne so aussehen, als gäbe es viele Kerne, veröffentlicht so viele Befehlsvarianten, dass sich kaum ein nützlicher gemeinsamer Nenner bilden kann, und lässt sogar die Befehlssatzunterstützung fallen, die sie selbst in Aussicht gestellt hatten.
Meine Präferenz bei Desktop-Herstellern war in den 90ern Intel, Anfang der 2000er AMD, Ende der 2000er und in den 2010ern Intel, und jetzt wieder AMD. Ich bin gespannt, was Intel tun wird, um außer der Behinderung des Gegners wieder Boden gutzumachen; Konkurrenz muss weiter bestehen, damit sich keine Seite zu sehr ausruht.
Material, das man sich zum Spaß anschauen kann: http://www.unicode.org/Public/3.1-Update1/CaseFolding-4.txt
Zum Beispiel wird
"straße".upper()zu'STRASSE'Außerdem geht ohne Angabe einer Locale bei der Hin- und Rückumwandlung des punktlosen i aus Turksprachen in Groß-/Kleinbuchstaben etwas kaputt:
'ı'.upper().lower()wird zu'i'Es gibt mehrere ASCII-Protokolle ohne Beachtung von Groß-/Kleinschreibung, und sie tauchen häufig in den Hot Paths vieler Server auf
Für Erstere reicht normalerweise reines ASCII in einer 8-Bit-Codierung, Letzteres wird kompliziert
DNS-Adressen sind ein einfaches Beispiel: Technisch können sie fast jedes Unicode-Zeichen enthalten, für die tatsächliche DNS-Auflösung werden sie aber in eine sehr eingeschränkte ASCII-Teilmenge umgewandelt, und dieser Auflösungsprozess unterscheidet nicht zwischen Groß- und Kleinschreibung
Natürlich gibt es auch Programmiersprachen, die alle Schriftsysteme von Unicode unterstützen und deren Identifier trotzdem nicht zwischen Groß- und Kleinschreibung unterscheiden. Falls du damit zu tun hast: mein Beileid
maßezuMASSEwird: Im Deutschen gibt es auch das große Eszett ẞEs ist noch nicht weit verbreitet und es gibt nur wenige unterstützende Fonts, aber theoretisch existiert es inzwischen
Ich vermute, die Erklärung zur „Maskenaddition“ im Artikel ist falsch
Müsste nicht addiert werden, wenn
is_upperfalse ist, und unverändert kopiert werden, wenn es true ist?to_upperverkehrt herum ist und eigentlichto_lowerheißen sollteDanke, dass du auf die verwirrende Stelle hingewiesen hast; Artikel und Code habe ich korrigiert
tolowerGroßes A ist
0x40, Kleinbuchstaben liegen bei0x60, daher muss das Addieren von 0x20 passieren, wennis_uppertrue istSolche SWAR-Optimierungen sind oft nur dann nützlich, wenn der String an einer 8-Byte-Adresse ausgerichtet ist
Wendet man SWAR-Algorithmen auf nicht ausgerichtete Strings an, werden sie häufig langsamer als der ursprüngliche Algorithmus
Teilt man das Ganze in drei Schritte auf – Anfang bis zur ausgerichteten Adresse verarbeiten, den ausgerichteten Hauptteil verarbeiten und den Rest von weniger als 8 Byte verarbeiten –, kommen noch mehr Instruktionen hinzu
Einen ähnlichen Fall mit Benchmarks zur falschen Behauptung,
utf8.IsValidin Go sei schneller, gibt es hier: https://github.com/sugawarayuuta/charcoal/pull/1Speicheroperationen bleiben immer ausgerichtet und haben die volle Vektorgröße, aber man kann nur die gültigen Elemente maskieren
Selbst wenn eine maskierte Vektor-Speicheroperation nicht ausgerichtet ist und über nicht gemappte oder geschützte Pages hinweggeht, tritt kein Fault auf, solange die betreffende Lane durch die Maske deaktiviert ist
Für Operationen wie
strlen(), bei denen die Länge vorher nicht bekannt ist, gibt es außerdem spezielle Load-Instruktionen, die die Vektorlänge direkt vor dem ersten Element verkürzen, das einen Fault auslösen würdeMaskierte Addition sieht elegant aus. Es wäre schön, wenn man in .NET-Intrinsics die Maskenregister von AVX512 direkt manipulieren könnte, aber derzeit muss man sich auf „erkannte Idiome“ verlassen.
Analysiert man die vom Autor mit GCC erzeugte Kernschleife mit uiCA (CQA/MAQAO) für Ice Lake, kommt man auf etwa 32 B/Zyklus; auf 3 GHz umgerechnet wären das fast 96 GiB/s, wenn man annimmt, dass es keinen Speicher-Flaschenhals gibt. Natürlich ist bei solchen Algorithmen der Speicherzugriff immer der Flaschenhals.
Ganz nah an optimaler Ausnutzung scheint es allerdings nicht zu sein; mit Clang kommt man durch besser aufgelöstes Unrolling und eine bessere Instruktionsauswahl auf 42,67 B/Zyklus. Auch der L2-Cache dürfte es schwer haben, einen solchen Durchsatz zu halten, aber interessant ist schon, dass die Groß-/Kleinschreibung von Strings mittlerer Länge ungefähr in der Zeit fertig ist, in der das Licht des Bildschirms die Hornhaut erreicht.
Vor ein paar Monaten habe ich in C# etwas Ähnliches für ASCII-Groß-/Kleinschreibung innerhalb von UTF-8 implementiert: https://github.com/U8String/U8String/blob/main/Sources/U8Str...
Da kurze Strings die meisten Codebasen dominieren, ist eine entrollte Konvertierung für Längen unterhalb der Vektorlänge wichtig, und
switchwird zu einer Sprungtabelle mit branchless Fall-through kompiliert.Derzeit nutze ich nur bis 256 Bit, weil das bei CPUs wie Zen 3 oder 4, die nur 256×4-SIMD-Einheiten haben, bereits sättigt. Einen direkten Vergleich mit der C-Version gibt es hier: https://godbolt.org/z/eTGYhTPan
Unter AVX512 sollte mit
vpternlogdwohl auch eine Umwandlung mit 3 Instruktionen möglich sein; als ich AVX512-Hardware nutzen konnte, hat .NET das mit 256-Bit-Breite + AVX512VL so optimiert, aber derzeit lässt sich das merkwürdigerweise nicht mit 512-Bit-Breite reproduzieren.Man sieht dort auch einen fehlgeschlagenen SWAR-Versuch auf der
switch-Dispatch-Seite; ich frage mich, welche Lizenz der Artikel hat. Wenn es die Testsuite besteht, würde ich es gern übernehmen.Wenn man die Struktur der beiden Compiler bedenkt, ist das nachvollziehbar, aber manchmal verbessert es das Ergebnis und manchmal verschlechtert es es.
Vor ein paar Jahren habe ich an einem stark vektorisierten Projekt gearbeitet, das mit beiden compiliert werden musste, und am Ende hielten wir neben der C-Referenzversion Inline-Assembly für bestimmte Targets und
.S-Dateien im Repository vor.Das Makefile wurde unübersichtlich, und weil sogar Benchmarks in die Testsuite mussten, war der Wartungsaufwand hoch. Daraus habe ich geschlossen, dass man sehr vorsichtig sein sollte, Intrinsics als Low-Level-Mittel zu verwenden, das besser sein soll als Auto-Vektorisierung.
Beispiel: Unter https://godbolt.org/z/T4Pjhrz5d war die GCC-Ausgabe wie erwartet, die Clang-Ausgabe dagegen überraschend und tatsächlich langsamer. In einer Schleife sind es laut uiCA 7 Zyklen gegenüber 4 Zyklen bei GCC, und das zeigte sich auch in einem realen App-Benchmark, in dem diese Funktion in einem Brute-Force-Algorithmus Milliarden Mal ausgeführt wird.
Beim Blick in die LLVM-Codebasis erinnere ich mich auch an ein Problem, bei dem Clang 16 wegen eines internen Refactorings manche maskierten AVX-512-Instruktionen unter Umständen gar nicht ausgeben konnte.
Ich hatte nicht auf maximal mögliche Performance gezielt; anfangs wollte ich nur sehen, ob es überhaupt funktioniert, und dass der erste Versuch ziemlich gut ausfiel, war ein Bonus.
Mein Hauptinteresse gilt Strings, die kürzer als ein Vektorregister sind, und dem Entfernen der Täler im Durchsatzdiagramm.
Wenn man dem Code-Link am Ende des Blogposts folgt, findet man die Lizenzinformationen; abgesehen von dem ursprünglich für BIND geschriebenen MPL-2.0-Teil ist es 0BSD oder MIT-0.
(x >= 'a' && x <= 'z')in die Form(x - 'a') < ...um und spart damit eine Instruktion.Wegen merkwürdiger Opcode-Encoding-Details kann in manchen Fällen sogar ein Register-Load entfallen.
Ich weiß nicht, was swar ist.
Gemeint ist üblicherweise eine Technik, bei der man mehrere Elemente in ein einzelnes Register packt und es auch ohne explizite SIMD-Instruktionen faktisch wie SIMD nutzt.
Wenn man zum Beispiel in ein 64-Bit-Register eine 31-Bit- und eine 32-Bit-Zahl packt und 1 Bit für den Carry übrig lässt, kann man mit einer einzigen 64-Bit-Addition zwei Additionen ausführen.
In Spielen wurden solche Tricks für Grafik verwendet, indem RGB(A)-Werte in 32-Bit-Integer gepackt wurden; auch ScummVM hat Code, der zwei 16-Bit-RGB-Pixel innerhalb eines 32-Bit-Werts interpoliert, also insgesamt 6 Komponenten: https://github.com/scummvm/scummvm/blob/master/graphics/scal...
Seit Unicode aufgekommen ist, ist das Konzept von Groß- und Kleinbuchstaben zu einem Sumpf geworden.
Wenn man es richtig machen will, braucht man viele Daten.
Wenn man an einer Aufgabe arbeitet, bei der es davon abhängt, ob ASCII-
tolowerrechtzeitig fertig wird, sollte man besser etwas Grundlegendes am Spielfeld und an den Voraussetzungen ändern.Ich habe früher einmal schwarze Ränder um Bilder gelegt, um das Problem von SIMD-Lesezugriffen über das Pufferende hinaus vollständig zu vermeiden.
Das funktionierte sehr gut, und in Sachen Geschwindigkeit konnte es einige OpenCV-Implementierungen schlagen, aber man hat die Eingaben nicht immer so vollständig unter Kontrolle.
Ich frage mich, ob es schon so ausprobiert wurde. Das Ergebnis der Auto-Vektorisierung sieht ziemlich sauber aus.
https://godbolt.org/z/1c5joKK5n
tolower1. Siehe die Bulletpoints unter dem Diagramm.