Laden...

.NET 5: Aktueller Stand zu Intrinsics bzw. SIMD-Unterstützung?

Erstellt von MrSparkle vor 3 Jahren Letzter Beitrag vor 3 Jahren 1.065 Views
MrSparkle Themenstarter:in
5.657 Beiträge seit 2006
vor 3 Jahren
.NET 5: Aktueller Stand zu Intrinsics bzw. SIMD-Unterstützung?

Hallo,

ich hab ein bißchen den Überblick verloren. Vor einigen Jahren hatte ich mal die Ansage gelesen, daß es ein JitIntrinsicAttribute bzw. IntrinsicAttribute geben sollte, mit dem man Methoden für eine spezielle Optimierung durch den JIT-Kompiler markieren kann. Der sollte dann je nach verwendeter CPU SIMD-Anweisungen ausgeben, um den jeweils verfügbaren Befehlssatz (z.B. SSE) auszunutzen, und somit für eine bessere Performance zu sorgen.

Im Code der System.Numeric-Vector-Klassen habe ich das noch gefunden, allerdings ist das JitIntrinsicAttribute internal, und kann daher nicht für eigene Implementierungen verwendet werden.

Gefunden habe ich jetzt diese Anleitung: Hardware Intrinsics in .NET Core, wonach man die Optimierungen selbst für jeden SIMD-Befehlssatz selbst erstellen kann. Der Artikel ist aber auch schon über ein Jahr alt.

Die Idee, performance-relevante Methoden mit einem Attribut zu markieren, damit der JIT-Kompiler dort etwas optimieren kann, schien mir sehr vielversprechend. Aber jetzt alle wichtigen Methoden in allen relevanten Befehlssätzen neu zu implementieren müssen, scheint mir unverhältnismäßig mehr Aufwand zu sein.

Da ich viel mit 3D-Grafik zu tun habe, wäre die Kompilierung in CPU-spezifische Befehlssätze sehr sinnvoll. Und da ich meist mit eigenen Implementierungen von Vektor- und Matrix-Klassen arbeite, kann ich nicht (jedenfalls nicht immer) auf die Typen aus System.Numerics zurückgreifen.

Welche Möglichlichkeiten wird es denn in .NET 5 geben, um eigenen Code JIT-optimieren zu können? Vielleicht habe ich etwas übersehen?

Weeks of programming can save you hours of planning

6.911 Beiträge seit 2009
vor 3 Jahren

Hallo MrSparkle,

JitIntrinsicAttribute RyuJIT ist der aktuelle JIT-Compiler von .NET und wie so gut wie jeder Compiler arbeitet auch dieser in "Phasen". Ein der erste Phasen ist das "Importieren".

Mit dem JitIntrinsicAttribute (bzw. nach dem Umbenenne nur mehr IntrinsicsAttribute) wird dem RyuJIT mitgeteilt, dass der Typ od. die Methode welches so attributiert ist, speziell zu behandeln ist. D.h. es wird nicht der dort angegebene (IL-) Code verwendet, sondern der JIT importiert spezialisierten Code.

Das ist ein Workaround und nicht mehr, mit dem bestimmte Einschränkungen seitens C# / IL umgangen werden.

Ein Beispiel dazu ist die Implementierung von Span<T>.
Aktuell ist es nicht möglich in einer ref struct ein Feld zu haben das ebenfalls eine ref ist (sollte aber mit C# 10 kommen).
Daher wurde mittels eines ByReference diese Beschränkung umgangen und um möglichst effizienten Maschinencode erzeugen zu können benötigt der RyuJIT eine Sonderbehandlung und genau diese wird mit Intrinsic angegeben.

Da Intrinsic ein Implementierungsdetail ist, wurde es auch als internal deklariert.

Das Bestreben vom .NET-Team ist sowohl C# als auch ggf. IL / allgemeiner: die Plattform so weiter zu entwickeln dass dieser Workaround nicht nötig ist. (Da mit C# geschriebener Code wesentlich portabler ist als Spezialisierungen in C++).

Anm.: CoreRT war / ist hierzu ein "Versuchsfeld" das Vieles direkt in C# implementierte und auf native Implementierungen -- sofern möglich -- verzichtet. Teile davon werden nach und nach zu .NET (Core) portiert.

IntrinsicAttribute geben sollte, mit dem man Methoden für eine spezielle Optimierung durch den JIT-Kompiler markieren kann

Ich denke das wurde so um 2015 verlautbart und war wohl eher ein Wunsch als Wirklichkeit.
Angelehnt war das vermutlich an die "auto vectorziation" Möglichkeiten moderner nativer Compiler (wie gängige C/C++ Compiler), die aber auch nicht jeden Code behandeln können, sondern eher nur einfachen schleifenbasierten Code durch Abrollen (Unrolling) und Anwendung von SIMD-Registern.

Einem JIT steht wegen dem "just in time" diese Möglichkeit nicht zur Verfügung, da die auto-vectorization zeitintensiv ist und somit in der knappen dem JIT zur Verfügung stehenden Zeit nicht durchführbar ist.

Nun hat der aktuelle RyuJIT ab .NET Core 2.1 die Möglichkeit der "Tiered Compilation", d.h. es wird mehrstufig kompiliert. In Tier-0 gibt es kaum Optimierungen, so dass der Programmstart zügig voranschreiten kann. Wird eine Methode mehrmals aufgerufen, so dass sie "heiß" wird und wenn diese Aufrufe nach der Startphase ist, so wird der Code dieser Methode mit mehr Optimierungen in Tier-1 kompiliert.
Hier gibt es aktuell verschiedene Diskussionen über weitere Möglichkeiten um noch "optimaleren" Code zu generieren. Dazu zählen Profilinformationen die in Tier-0 gesammelt werden, eine weitere Optimierungsstufe mit Tier-2 usw. Konkret ist hier noch nichts.

Für einen hypothetischen Tier-2 könnte auto-vectorization angewandt werden.
Aber wie vorhin erwähnt klappt das eigentlich nur für simple Schleifen recht gut. Sobald Tensoren höherer Stufe behandelt werden, fehlt dem Compiler einfach das Wissen über die genaue Intention was der Programmierer will und wie die Speicherzugriffe, etc. genau zu erfolgen. Um das zu lösen gibt es mMn zwei Möglichkeiten:* eine angepasstere Programmiersprache für Tensor-Rechnung (und / oder Fortran verwenden 😉)

  • den maschinennahen Teil mit HW-Intrinsics (HW...hardware) selbst programmieren

Auch wenn aktuelle C/C++ Compiler auto-vectorization, etc. können, werden "kritische Bereiche" nach wie vor mit Intrinsic (händisch) programmiert, da dies schon ein großer Schritt nach vorne vom Programmieren in Assembler ist.
C# / .NET wird hier keine Sonderrolle einnehmen können, noch dazu mit "time constraints", welchen der JIT -- unabhängig vom Tiering -- unterliegt.

Das Versehen einer Methode od. eine Types mit einem Attribut ist bei Weitem nicht ausreichend um spezielle Optimierungen zu ermöglichen bzw. würde das Ergebnis wohl nie so gut werden, als wenn der Programmierer, der die Aufgabe (hoffentlich) genau versteht, selbt optimierten Code schreibt. Dazu wird das Thema einfach schnell sehr komplex, da auch die super-skalaren CPUs mit ihren Pipelines, CPU-Caches, jede Menge Latenzen usw. einspielen. Ohne Profiler auf Maschinencode-Ebene und Betrachtem vom erzeugten Maschinencode geht es hier nicht mehr und das kann mühselig werden (außer man empfindet daran Gefallen 😉).

Ich würde diese Vorstellung als Zukunftsmusik abtun, die in den nächsten Jahren in .NET wohl nicht spielen wird und in nativen Sprachen wohl auch nur leisen Töne von sich geben wird.

Analog dazu gibt es aktuell auch kaum ein Projekt das wirklich GPU-Code aus herkömmlichen Code automatisch erzeugen kann, der "sauber" läuft und an händisch geschriebenen (z.B. CUDA) GPU-Code herankommt.

Der von dir verlinkte Blog-Beitrag über die HW-Intrinsics ist zwar ein Jahr alt, aber vom Inhalt her aktuell.
Die HW-Intrinsics sind Teil der .NET-API-Landschaft und da ändert sich dann nichts mehr (genauso wie ArrayList seit .NET 1.0 immer noch dabei ist).
Ein paar Verwendungsmuster haben sich herauskristallisiert, aber im Wesentlichen passt der Inhalt dieses Blogs.

alle wichtigen Methoden in allen relevanten Befehlssätzen neu zu implementieren müssen, scheint mir unverhältnismäßig mehr Aufwand zu sein.

Es gibt 2 Arten von vektor-gestütztem Code in .NET* Vector2, Vector3, Vector3 und Vector<T> aus System.Numerics

  • Vector128, etc. aus System.Runtime.Intrinsics

Grob gesprochen sind letztere nur Typen, welche dann mittels den Methoden aus Sse2, Avx2, AdvSimd usw. bearbeitet werden.
D.h. auch dass es je nach Zielplattform und CPU-Unterstützung getestet werden muss um so unterschiedliche Code-Pfade haben zu können. Einen Software-Fallback, falls es keine Unterstützung gibt, sollte auch vorhanden sein.
(Außer du weißt dass der Code eh nur auf einer bekannten Plattform z.B. auf einer Intel i7 CPU läuft, dann kannst du diese Test sparen).

Die Typen aus System.Numerics bieten Methoden zum Bearbeiten an und übernehmen die "Unterstützungs"-Test für dich und haben auch einen Software-Fallback implementiert.
Allerdings ist -- zumindest aktuell* -- der erzeugte Maschinencode mit den HW-Intrinsics meist besser. Zudem können mit den HW-Intrinsics, hier am Beispiel von x64 erklärt, bei Vektoren zuerst "AVX-Schritten" (256 bit), dann der Rest in "SSE-Schritten" (128 bit) und der Rest skalar verarbeitet werden, währen die SN-Vektoren nur eine der Vektor-Größen (entweder AVX falls unterstützt von der CPU, sonst SSE) kennen.

Ein Vorteil der HW-Intrinsics ist auch, dass es viele Referenz-Implementierungen für Problem in C++ mit intrinsics gibt und diese sind somit einfacher zu C# zu portieren.

Klar erhöht sich der Aufwand so (fast immens), aber die Ziel-Gruppe der HW-Intrinsics ist auch nicht das breite Publikum sondern eher die Nische, welche "bestmögliche Geschwindigkeit" haben will (und dazu zählen die .NET Innerein selbst).

* es ist geplant die Implementierungen von System.Numerics-Vektoren neu zu implementieren auf Basis der HW-Intrinsics, die erst später Einzug in .NET hielten als die SN-Vektoren die schon länger da sind

Da ich viel mit 3D-Grafik zu tun habe, wäre die Kompilierung in CPU-spezifische Befehlssätze sehr sinnvoll.

Schau dir dazu einmal Vector3 und Vector4 genauer an.
Die wurden für solche Szenarien eingeführt und haben viele Anleihen von DirectX-Math (od. wie das genau heißt) genommen.
Diese Vektoren können auch als Felder in eigenen Typen verwendet werden (die HW-Intrinsics Vektoren übrigens auch).
Auf ein Umschreiben / Anpassen deines Codes wirst du da nicht herumkommen (wie erwähnt schaffen das nicht einmal native Compiler die genügend Zeit dafür haben).

um eigenen Code JIT-optimieren zu können?

Im Release-Build führt der JIT seit jeher Optimierungen durch.
(Ich weiß aus dem Kontext, dass du nicht danach gefragt hast).

mfG Gü

Stellt fachliche Fragen bitte im Forum, damit von den Antworten alle profitieren. Daher beantworte ich solche Fragen nicht per PM.

"Alle sagten, das geht nicht! Dann kam einer, der wusste das nicht - und hat's gemacht!"

MrSparkle Themenstarter:in
5.657 Beiträge seit 2006
vor 3 Jahren

Danke für die ausführliche Antwort. Das war der interessanteste (und wahrscheinlich längste) Beitrag seit längerem hier im Forum.

Ich hab mich noch weiter zum Thema belesen, danke für die passenden Stichpunkte dazu. Die Vector-Klassen aus Systems.Numerics scheint die mit Abstand performanteste Implementierung zu sein, die es zur Zeit für .NET gibt. Leider baut der größte Teil meines 3D-Codes auf eigenen Vektor-Implementierungen auf, die entstanden ist, als es Systems.Numerics noch nicht gab. Da müßte ich also meine gesamte Code-Basis überarbeiten bzw. neu implementieren. Dazu kommt, daß mein Code überall Fließkommazahlen mit doppelter Genauigkeit verwendet, und Systems.Numerics nur einfache. 32-Bit reicht für das Rendering völlig aus, aber bei der Erstellung von Geometrie und Texturen können damit sichtbare Artefakte entstehen.

Meine Vektor-Typen mit expliziten SIMD-Anweisungen nachträglich auszustatten, ist dann auch nicht so vielversprechend, wie es zunächst klingt. Hier mal als Beispiel das Kreuzprodukt von zwei Vektoren:


public static Vector3 Cross(Vector3 left, Vector3 right)
{
	return new Vector3(
		left.Y * right.Z - left.Z * right.Y,
		left.Z * right.X - left.X * right.Z,
		left.X * right.Y - left.Y * right.X´);
}

Wenn man das als SSE umschreibt, dauert die Berechnung 11 anstatt 12 Befehlszyklen. Da muß man schon eine Menge Vektoren multiplizieren, bevor man mal eine Sekunde Rechenzeit spart. Und dann hat man Code, den man ein paar Wochen später schon nicht mehr versteht 😃

Wäre zwar schön gewesen, wenn der JIT-Kompiler das irgendwie automatisch optimieren könnte, aber wenn ich Code manuell optimieren muß, kann ich sicherlich an anderen Stellen wesentlich mehr Performance rausholen. War aber trotzdem sehr interessant, mit Assembler habe ich mich seit über 20 Jahren nicht mehr beschäftigt.

Weeks of programming can save you hours of planning

6.911 Beiträge seit 2009
vor 3 Jahren

Hallo MrSparkle,

auf eigenen Vektor-Implementierungen

Angenommen diese eigenen Vektoren wären structs. Dann ist es auch möglich diese in einen HW-Vector zu lesen. Z.B. (ab .NET Core 3.1):


using System.Runtime.CompilerServices;
using System.Runtime.Intrinsics;

public static class MyVectorExtensions
{
    public static Vector256<double> ToVector256(this My3dVector vector)
    {
        return Unsafe.As<My3dVector, Vector256<double>>(ref vector);
    }
}


public struct My3dVector
{
    public double X;
    public double Y;
    public double Z;
}

(Gleiches ginge auch für Vector3)

Unsafe.As<TFrom, TTo> entspricht einem reinterpret_cast in C++.
Achtung! Hier ist nicht nur die Klasse Unsafe, sondern auch die Verwendung.* wird Unsafe verwendet, so kann der JIT keine Typprüfungen durchführen

  • im Beispiel hier kann der Vector256<double> 4 Elemente aufnehmen, die Struct definiert aber nur 3, daher ist das 4. Vector256<double>-Element Müll
  • wegen letztem Punkt wird über die Grenzen der Struct hinausgelesen und das kann zu einer AccessViolation führen -- es ist zwar sehr unwahrscheinlich dass das tatsächlich passieren mag, aber es soll dennoch darauf hingewiesen werden

Würde ein Vector128<double> verwendet werden, so hätten nur 2 doubles-Platz und somit kann die gesamte Struct nicht gelesen werden.

Diese Art des Speichern von 3d-Vektoren wird Array of Structures genannt und ist eher ungünstig, da entweder zuviel od. zuwenig gelesen wird.
Auch sind etliche Operationen nur unhandlich durchzuführen.

Daher gibt es auch die Möglichkeit der Structure of Arrays, welche diese Nachteile nicht haben und meist günstigere Implementierungen nach sich ziehen.
Allerdings bedeutet das oft auch, dass wesentliche Teile vom Code angepasst werden müssten.

Wenn man das als SSE umschreibt

Mit dem verlinkten Artikel musst du aufpassen, denn da wird float verwendet (zu erkennen an der s postfix der Befehlen (s...single, d...double)).
Für double müsste dann AVX (256bit) verwendet werden und da kann es mit dem mischen (shuffle) problematisch werden, denn AVX ist "eigentlich" nur 2xSSE und d.h. ein Shuffle über die "Lanes" geht nur mittels "Permute" 😉 (ich will damit ausdrücken: es wird komplizierter).

Das Kreuzprodukt ist aber (ähnlich wie Matrizenmultiplikation) ohnehin ein schwieriges Beispiel. Aber bei deinen Anwendungen wohl eine sehr häufig verwendet Operation.

mfG Gü

Stellt fachliche Fragen bitte im Forum, damit von den Antworten alle profitieren. Daher beantworte ich solche Fragen nicht per PM.

"Alle sagten, das geht nicht! Dann kam einer, der wusste das nicht - und hat's gemacht!"