Fließband

Fließband

Lernübung

Einführung

SIMD

Single instruction, multiple data (SIMD) ist eine Form der Parallelität auf Hardwareebene. Die CPU führt dieselbe Berechnung gleichzeitig über mehrere Elemente aus.

Betrachte zum Beispiel das elementweise Summieren zweier Arrays aus vier 32-Bit-Gleitkommazahlen. Eine skalare Implementierung würde vier separate addss-Befehle absetzen, einen pro Element. Eine SIMD-Implementierung verwendet ein einzelnes addps, um alle vier Additionen parallel auszuführen. Das ergibt die vierfache Arbeit pro Befehl.

In x86-64-Assembly werden SIMD-Befehle normalerweise mit xmm-Registern ausgeführt. Einige dieser Befehle arbeiten mit Ganzzahlen. Andere arbeiten mit Gleitkommazahlen.

Diese Ganzzahlen oder Gleitkommazahlen heißen packed. Jede belegt innerhalb desselben Registers einen Platz, eine sogenannte Lane. SIMD-Befehle arbeiten typischerweise parallel mit Werten an derselben Lane-Position in zwei Quelloperanden. Das Ergebnis jeder Operation wird in der Lane an derselben Position im Zieloperanden gespeichert.

Eine Lane ist keine feste Grenze. Sie ist eine Interpretation, die jeder Befehl einer Bytefolge gibt. Ein 128-Bit-Operand enthält 16 Bytes. Je nach Befehl können diese Bytes als 16 1-Byte-Lanes, 8 2-Byte-Lanes, 4 4-Byte-Lanes, 2 8-Byte-Lanes oder sogar als eine einzige 16-Byte-Lane interpretiert werden.

Zum Beispiel addiert der Befehl addps gepackte Gleitkommawerte einfacher Genauigkeit (4 Byte). Für diesen Befehl ist jeder 4-Byte-Block in einem Operanden eine andere Lane. In einem 128 Bit breiten (16 Byte großen) Operanden:

Operand 4. Lane 3. Lane 2. Lane 1. Lane
Quelle 1 1.5 2.1 6.2 3.4
+ + + + +
Quelle 2 0.5 8.26 -13.4 22.9
= = = = =
Ergebnis 2.0 10.36 -7.2 26.3

Andererseits würde ein 128-Bit-Operand im Fall von mulpd als 2 Lanes interpretiert werden. Der Grund ist, dass dieser Befehl eine gepackte Multiplikation mit Werten doppelter Genauigkeit (8 Byte) durchführt:

Operand 2. Lane 1. Lane
Quelle 1 4.2 2.1
* * *
Quelle 2 1.0 92.4
= = =
Ergebnis 4.2 194.04

In diesem Konzept konzentrieren wir uns auf eine kleine Auswahl von SIMD-Befehlen. Sie arbeiten mit gepackten Gleitkommawerten unter Verwendung von xmm-Registern.

SIMD-Befehle für Gleitkommazahlen

Faustregel: SIMD-Befehle, die mit Gleitkommawerten arbeiten, haben eine Zwei-Operanden-Form. Ihr Name folgt einer bestimmten Struktur:

  1. Die ausgeführte Operation, mit demselben Namen wie ihr Nicht-SIMD-Gegenstück (auch skalar genannt) (z. B. add, mul usw.).
  2. Ein Suffix p, das angibt, dass der Befehl mit gepackten Daten arbeitet, an derselben Position, die zuvor ein s (für skalare Daten) einnahm.
  3. Ein Suffix, das die Größe jeder Lane angibt, mit derselben Semantik wie ihr Nicht-SIMD-Gegenstück (s für einfache Genauigkeit und d für doppelte Genauigkeit).

Der gepackte Name entsteht daher aus dem skalaren Namen, indem man das s (scalar) in der Mitte des Mnemonic durch ein p (packed) ersetzt. Der Operationsname und das Größen-Suffix bleiben unverändert:

skalar packed Bedeutung
addss addps addieren, einfache Genauigkeit (32-Bit-Lanes)
addsd addpd addieren, doppelte Genauigkeit (64-Bit-Lanes)
mulss mulps multiplizieren, einfache Genauigkeit
mulsd mulpd multiplizieren, doppelte Genauigkeit
Arithmetik

Arithmetische SIMD-Befehle folgen der obigen Regel. Einige Beispiele:

addps xmm0, xmm1 ; xmm0 = xmm0 + xmm1 (each 32-bit float at the same position)
subpd xmm2, xmm3 ; xmm2 = xmm2 - xmm3 (each 64-bit float at the same position)
mulps xmm4, xmm5 ; xmm4 = xmm4 * xmm5 (each 32-bit float at the same position)
divpd xmm6, xmm7 ; xmm6 = xmm6 / xmm7 (each 64-bit float at the same position)
Konvertierung zwischen Genauigkeiten

Es gibt auch SIMD-Befehle, um zwischen gepackten Gleitkommawerten einfacher und gepackten Gleitkommawerten doppelter Genauigkeit zu konvertieren. Sie folgen derselben Namensregel, wobei jedes s für scalar durch ein p für packed ersetzt wird:

skalar packed Bedeutung
cvtss2sd cvtps2pd einfache Genauigkeit in doppelte Genauigkeit umwandeln
cvtsd2ss cvtpd2ps doppelte Genauigkeit in einfache Genauigkeit umwandeln

Diese Befehle veranschaulichen den früheren Punkt, dass eine Lane eine Interpretation und keine feste Grenze ist. Die Quell- und Zieloperanden haben unterschiedliche Lane-Größen und daher unterschiedliche Lane-Anzahlen.

Bei cvtps2pd hat die Quelle (ps) 4 Lanes einfacher Genauigkeit. Das Ziel (pd) hat 2 Lanes doppelter Genauigkeit. Nur die unteren 2 Lanes der Quelle werden gelesen. Die oberen 2 werden ignoriert:

Operand 4. Lane 3. Lane 2. Lane 1. Lane
Quelle als ps ignoriert ignoriert 2.5 1.5
Ziel als pd 2.5 1.5

Bei cvtpd2ps hat die Quelle (pd) 2 Lanes doppelter Genauigkeit. Das Ziel (ps) hat 4 Lanes einfacher Genauigkeit. Beide Quell-Lanes werden konvertiert und in die unteren 2 Lanes des Ziels geschrieben. Die oberen 2 Lanes des Ziels werden auf null gesetzt:

Operand 4. Lane 3. Lane 2. Lane 1. Lane
Quelle als pd 4.0 3.0
Ziel als ps 0 0 4.0 3.0

Einige Beispiele:

cvtps2pd xmm0, xmm1 ; xmm0 = xmm1 as 2 doubles (low 2 lanes of xmm1 are read)
cvtpd2ps xmm0, xmm1 ; xmm0 = xmm1 as 2 floats (in low 2 lanes; upper 2 zeroed)
Speicheroperanden

Die obigen SIMD-Befehle wurden alle mit Register-zu-Register-Operanden gezeigt. Sie können auch einen Speicheroperanden als Quelle nehmen (aber nicht als Ziel).

Die Anzahl der aus dem Speicher gelesenen Bytes wird durch den Befehl bestimmt. Zum Beispiel liest addps 16 Bytes (4 Gleitkommazahlen einfacher Genauigkeit). cvtps2pd hingegen liest nur 8 Bytes (2 Gleitkommazahlen einfacher Genauigkeit), weil das die gesamte Eingabe ist, die er benötigt.

addps    xmm0, [rsi]      ; xmm0 = xmm0 + 4 floats at [rsi]
mulpd    xmm1, [rsi + 16] ; xmm1 = xmm1 * 2 doubles at [rsi + 16]
cvtps2pd xmm2, [rsi + 8]  ; xmm2 = (2 floats at [rsi + 8]) converted to 2 doubles

Wenn der Speicheroperand 16 Bytes breit ist, muss er 16-Byte-ausgerichtet sein. Ein ausgerichteter Speicherort ist einer, dessen Adresse ein Vielfaches der Größe der Operation ist (in diesem Fall 16 Bytes).

Um ausgerichteten Speicher zu deklarieren, kannst du die Direktive align in den Abschnitten .data und .rodata verwenden, oder alignb im Abschnitt .bss:

section .rodata
align 16
mem1: dd 4.0, 3.5, 2.0, -1.0

section .data
align 16
mem2: dq 6.0, 2.1

section .bss
alignb 16
mem3: resb 16

section .text
fn:
    movaps xmm0, oword [rel mem1] ; loads 4 packed 32-bit floats in xmm0
    movapd xmm1, oword [rel mem2] ; loads 2 packed 64-bit floats in xmm1
    movaps oword [rel mem3], xmm0 ; stores 4 packed 32-bit floats in mem3
    ret

Speicheroperanden kleiner als 16 Bytes, wie die 8-Byte-Quelle von cvtps2pd, haben keine Ausrichtungsanforderung.

Laden und Speichern von Werten

Es gibt vier Befehle, die 128 Bits gepackter Gleitkommawerte kopieren. Sie arbeiten zwischen zwei xmm-Registern oder zwischen einem xmm-Register und einem Speicherort:

Befehl Beschreibung
movaps kopiert gepackte Daten einfacher Genauigkeit von einem ausgerichteten Speicherort oder dorthin
movups kopiert gepackte Daten einfacher Genauigkeit von einem nicht ausgerichteten Speicherort oder dorthin
movapd kopiert gepackte Daten doppelter Genauigkeit von einem ausgerichteten Speicherort oder dorthin
movupd kopiert gepackte Daten doppelter Genauigkeit von einem nicht ausgerichteten Speicherort oder dorthin

Alle diese Befehle können verwendet werden, um Werte zwischen zwei xmm-Registern zu kopieren.

Wenn die Operation einen Speicherort einbezieht, verlangen movaps und movapd, dass er ausgerichtet ist. movaps und movapd lösen einen Fehler aus, wenn ihnen eine nicht ausgerichtete Adresse übergeben wird. Sie dienen zugleich als Sicherheitsprüfung, dass dein Datenlayout wirklich das ist, was du denkst.

Andererseits können movups und movupd mit jedem Speicherort verwendet werden, ob ausgerichtet oder nicht.

Das Größenpräfix für einen 16-Byte-Wert in NASM ist oword.

SIMD-Erweiterungen

SIMD ist seit Jahrzehnten ein Ziel aktiver Entwicklung im x86-64-Ökosystem. Jede Erweiterung der Architektur bringt neue Befehle oder neue Verwendungen für alte Befehle.

Insbesondere die Erweiterungen AVX und AVX2 bringen einige relevante Änderungen.

Erstens die Unterstützung für 256-Bit-SIMD-Register, genannt ymm. Sie erweitern die entsprechenden xmm-Register, so wie rax das eax enthält.

Zweitens enthalten sie Unterstützung für 3-Operanden-Varianten für die meisten Befehle, die mit xmm- (und ymm-)Registern arbeiten. Das umfasst nicht nur SIMD-Befehle, sondern auch solche, die mit skalaren Gleitkommawerten arbeiten. Diese Varianten folgen derselben Notation wie ihr SSE-Äquivalent, mit einem vorangestellten v:

vaddsd xmm0, xmm1, xmm2   ; xmm0 = xmm1 + xmm2 (scalar sum of 64-bit floats)
vmulps xmm1, xmm3, xmm4   ; xmm1 = xmm3 * xmm4 (packed multiplication of 32-bit floats)
vmovupd ymm5, yword [mem] ; this copies 32 bytes from the location at mem to ymm5

Drittens ist die für SIMD-Speicheroperanden erforderliche Ausrichtung gelockert. Nur ausdrücklich ausgerichtete Move-Befehle wie vmovaps verlangen diese Ausrichtung.

In diesem Track haben wir uns auf Befehle und Register bis zu den vorherigen SSE-Erweiterungen beschränkt. Das reicht aus, um die Grundlagen zu vermitteln, und gewährleistet Kompatibilität mit einer breiten Palette von CPUs.

Der Test-Runner unterstützt jedoch AVX und AVX2 vollständig und sogar die neueren Erweiterungen, die Teil von AVX-512 sind. Du kannst sie gerne verwenden.

Note

Es ist gute Praxis, bei der Verwendung von SIMD-Varianten konsistent zu sein. Versuche, SSE- und AVX-Varianten für dieselben Befehle nicht zu vermischen.

Wenn deine Codebasis diese Varianten vermischt, ist es gute Praxis, vzeroupper zu verwenden, nachdem du deine Arbeit mit AVX beendet hast und bevor du SSE verwendest. Das durchbricht Abhängigkeitsketten und vermeidet einen kostspieligen Stillstand, wenn die CPU zwischen AVX- und SSE-Zustand wechselt.

Anleitung

Du schreibst Code für das Datensystem in der Fertigungshalle eines Werks, das Automobilteile herstellt. Motor- und Fahrgestellkomponenten durchlaufen eine Reihe von Bearbeitungsstationen. Jede Station erzeugt Messwerte, die kombiniert, verglichen, kalibriert oder normalisiert werden müssen, bevor der nächste Arbeitsschritt läuft. Du implementierst hier für jede Station eine Funktion.

Note

Die Berechnungen in dieser Übung sollten mit SIMD-Befehlen durchgeführt werden, nicht mit skalaren Operationen.

1. Kombiniere die Ausbeuten zweier Linien

In der Fertigungshalle laufen zwei parallele Fließbänder. Am Ende jeder Schicht melden beide Linien ihre Ausbeute für 4 Behälter als Gleitkommazahlen einfacher Genauigkeit. Das Dashboard des Vorarbeiters zeigt die kombinierte Ausbeute pro Behälter, also werden die beiden Meldungen Behälter für Behälter addiert. Alle drei Buffer werden vom Meldesystem verwaltet und sind immer auf 16 Byte ausgerichtet.

Definiere eine Funktion sum_yields, die aus den Ausbeuten pro Behälter von line_a und line_b die behälterweise Summe nach result schreibt. Die Argumente sind, in dieser Reihenfolge:

  1. line_a: eine auf 16 Byte ausgerichtete Speicheradresse mit 4 32-Bit-Gleitkommazahlen
  2. line_b: eine auf 16 Byte ausgerichtete Speicheradresse mit 4 32-Bit-Gleitkommazahlen
  3. result: eine auf 16 Byte ausgerichtete Speicheradresse, an die die Funktion 4 32-Bit-Gleitkommazahlen schreibt

Diese Funktion hat keinen Rückgabewert. Stattdessen wird das Ergebnis im übergebenen Buffer (result) gespeichert:

line_a = {10.0, 20.0, 30.0, 40.0};
line_b = { 5.0, 15.0, 25.0, 35.0};
sum_yields(line_a, line_b, result);
// => result == {15.0, 35.0, 55.0, 75.0};

2. Berechne die skalierte Abweichung

Zwischen den Arbeitsgängen vermisst eine Messstation bearbeitete Teile und vergleicht sie mit den Sollmaßen. Die Messwerte fließen von einem Präzisionsmessgerät in einen Buffer, der immer auf 16 Byte ausgerichtet ist. Die Sollwerte stammen aus dem Arbeitsauftrag, der keine garantierte Ausrichtung hat. Die Abweichung wird mit einem Empfindlichkeitsfaktor pro Achse multipliziert, bevor sie an den nachgelagerten Toleranzprüfer weitergegeben wird. In diesem Schritt liest der nachgelagerte Prüfer die Abweichung direkt aus einem SIMD-Register statt aus dem Speicher.

Definiere eine Funktion scaled_deviation, die anhand von measured, target und sensitivity die skalierte Abweichung nach result schreibt. Die Argumente sind, in dieser Reihenfolge:

  1. measured: eine auf 16 Byte ausgerichtete Speicheradresse mit 2 64-Bit-Gleitkommazahlen
  2. target: eine Speicheradresse mit 2 64-Bit-Gleitkommazahlen (Ausrichtung nicht garantiert)
  3. sensitivity: eine Speicheradresse mit 2 64-Bit-Gleitkommazahlen (Ausrichtung nicht garantiert)
  4. result: eine Speicheradresse für einen Buffer, in den die Funktion 2 64-Bit-Gleitkommazahlen schreibt (Ausrichtung nicht garantiert)

Die skalierte Abweichung ist (measured - target) * sensitivity, Lane für Lane.

Diese Funktion hat keinen Rückgabewert. Stattdessen wird das Ergebnis im übergebenen Buffer (result) als Paar von 64-Bit-Gleitkommazahlen gespeichert.

measured    = {10.5, 20.5};
target      = {10.0, 20.0};
sensitivity = { 2.0,  4.0};
scaled_deviation(measured, target, sensitivity, result);
// => result == {1.0, 2.0};

3. Kalibriere eine Charge

Bevor Teile in den Endprüfbereich gelangen, werden die Maßwerte einer Charge aus 4 Teilen in doppelte Genauigkeit umskaliert und um eine bekannte Temperaturdrift korrigiert.

Die 4 Rohwerte kommen als Gleitkommazahlen einfacher Genauigkeit an. Für jede Zeile aus 2 Messwerten berechnet die Kalibrierung reference / (raw - offset) und multipliziert dieses Ergebnis anschließend mit einem konstanten Umrechnungsfaktor von 0.5.

Beachte, dass der Referenzwert und der Offset für beide Zeilen der Charge gleich sind. Der Umrechnungsfaktor ist bei jeder Kalibrierung gleich. Statt ihn als Argument zu übergeben, solltest du ihn als gepackte 16-Byte-Konstante im Speicher deklarieren.

Definiere eine Funktion calibrate_batch, die aus einer raw-Charge, einem reference, einem offset und einem result-Buffer die kalibrierten Werte nach result schreibt. Die Argumente sind, in dieser Reihenfolge:

  1. raw: eine Speicheradresse mit 4 32-Bit-Gleitkommazahlen (Ausrichtung nicht garantiert)
  2. reference: eine auf 16 Byte ausgerichtete Speicheradresse mit 2 64-Bit-Gleitkommazahlen
  3. offset: eine auf 16 Byte ausgerichtete Speicheradresse mit 2 64-Bit-Gleitkommazahlen
  4. result: eine auf 16 Byte ausgerichtete Speicheradresse, an die die Funktion 4 64-Bit-Gleitkommazahlen schreibt (insgesamt 32 Byte)

Für jede der beiden Zeilen der Charge soll die Funktion:

  1. Die 2 Rohwerte der Zeile einfacher Genauigkeit in doppelte Genauigkeit umwandeln.
  2. offset vom umgewandelten Paar subtrahieren.
  3. reference durch das Ergebnis dividieren.
  4. Jede 64-Bit-Gleitkommazahl Lane für Lane mit dem festen Umrechnungsfaktor 0.5 multiplizieren.
  5. Die 2 64-Bit-Gleitkommazahlen in die entsprechenden 16 Byte von result schreiben.

Diese Funktion hat keinen Rückgabewert. Stattdessen wird das Ergebnis im übergebenen Buffer (result) gespeichert:

raw       = {10.0, 20.0, 25.0, 50.0};
reference = {100.0, 100.0};
offset    = {  5.0,  10.0};
calibrate_batch(raw, reference, offset, result);
// => result == {10.0, 5.0, 2.5, 1.25};

4. Normalisiere die Qualitätswerte entlang einer Linie

Am Ende der Linie hat jedes Teil einen Qualitätswert, der normalisiert werden muss, bevor er gemeldet wird. Jeder Qualitätswert wird mit seinem eigenen Verstärkungsfaktor pro Teil multipliziert und dann durch eine Produktionsskalierung pro Achse dividiert. Der gains-Buffer enthält einen Verstärkungsfaktor pro Teil.

Die Produktionsskalierung ist für den gesamten Durchlauf gleich und wird als Speicheradresse mit zwei 64-Bit-Gleitkommazahlen übergeben. Die Qualitätswerte werden in-place aktualisiert: Derselbe Buffer, der die Rohwerte enthielt, enthält nun die normalisierten Qualitätswerte.

Definiere eine Funktion normalize_scores, die anhand eines scores-Arrays, eines gains-Arrays, einer scale und einer Anzahl n die Qualitätswerte in-place normalisiert. Die Argumente sind, in dieser Reihenfolge:

  1. scores: eine auf 16 Byte ausgerichtete Speicheradresse mit n 64-Bit-Gleitkommazahlen (wird in-place verändert)
  2. gains: eine auf 16 Byte ausgerichtete Speicheradresse mit n 64-Bit-Gleitkommazahlen
  3. scale: eine auf 16 Byte ausgerichtete Speicheradresse mit 2 64-Bit-Gleitkommazahlen
  4. n: eine positive gerade 64-Bit-Ganzzahl (mindestens 2), die Anzahl der zu normalisierenden Qualitätswerte

Die beiden Lanes der Skalierung gelten für die beiden Elemente jedes Paares. Die erste Lane skaliert das erste Element jedes Paares (Lanes an geraden Positionen), die zweite Lane skaliert das zweite (Lanes an ungeraden Positionen). Jeder Qualitätswert wird mit seinem zugehörigen Verstärkungsfaktor multipliziert und dann durch die passende Skalierungs-Lane dividiert: score * gain / scale_lane.

Diese Funktion hat keinen Rückgabewert. Stattdessen wird das Ergebnis im Eingabe-Buffer (scores) gespeichert, an derselben Position wie der entsprechende score, der normalisiert wurde:

n      = 4
scores = {10.0, 20.0, 30.0, 40.0};
gains  = { 2.0,  1.5,  1.0,  0.5};
scale  = { 2.0,  4.0};
normalize_scores(scores, gains, scale, n);
// => scores == {10.0, 7.5, 15.0, 5.0};
Über GitHub bearbeiten Der Link öffnet sich in einem neuen Fenster oder Tab
x86-64 Assembly Exercism

Bereit, mit Fließband zu starten?

Melde dich bei Exercism an, um x86-64 Assembly mit 22 Konzepte130 Übungen und echtem menschlichen Mentoring zu lernen und zu meistern, alles kostenlos.