Track
/
x86-64 Assembly
x86-64 Assembly
/
Esercizi
/
Linea di produzione
Linea di produzione

Linea di produzione

Esercizio di apprendimento

Introduzione

SIMD

Single instruction, multiple data (SIMD) è una forma di parallelismo a livello hardware. La CPU esegue lo stesso calcolo su più elementi contemporaneamente.

Consideriamo, ad esempio, la somma elemento per elemento di due array di quattro numeri in virgola mobile a 32 bit. Un'implementazione scalare emetterebbe quattro istruzioni addss separate, una per elemento. Un'implementazione SIMD usa un solo addps per eseguire tutte e quattro le addizioni in parallelo. Così si ottiene quattro volte il lavoro per istruzione.

Nell'assembly x86-64, le istruzioni SIMD vengono in genere eseguite usando i registri xmm. Alcune di queste istruzioni operano su numeri interi. Altre operano su numeri in virgola mobile.

Quei numeri interi o in virgola mobile sono detti impacchettati. Ognuno occupa uno spazio, detto corsia, all'interno dello stesso registro. Le istruzioni SIMD in genere operano in parallelo su valori che si trovano nella stessa posizione di corsia in due operandi sorgente. Il risultato di ogni operazione viene memorizzato nella corsia che occupa la stessa posizione nell'operando di destinazione.

Una corsia non è un limite rigido. È un'interpretazione che ogni istruzione dà a una sequenza di byte. Un operando a 128 bit contiene 16 byte. A seconda dell'istruzione, quei byte possono essere interpretati come 16 corsie da 1 byte, 8 corsie da 2 byte, 4 corsie da 4 byte, 2 corsie da 8 byte o persino una singola corsia da 16 byte.

Ad esempio, l'istruzione addps somma valori in virgola mobile impacchettati a precisione singola (4 byte). Per questa istruzione, ogni blocco di 4 byte in un operando è una corsia diversa. In un operando largo 128 bit (16 byte):

operando 4ª corsia 3ª corsia 2ª corsia 1ª corsia
sorgente 1 1.5 2.1 6.2 3.4
+ + + + +
sorgente 2 0.5 8.26 -13.4 22.9
= = = = =
risultato 2.0 10.36 -7.2 26.3

D'altra parte, un operando a 128 bit verrebbe interpretato come avente 2 corsie nel caso di mulpd. Questo perché l'istruzione esegue una moltiplicazione impacchettata su valori a precisione doppia (8 byte):

operando 2ª corsia 1ª corsia
sorgente 1 4.2 2.1
* * *
sorgente 2 1.0 92.4
= = =
risultato 4.2 194.04

In questo concetto ci concentreremo su una piccola selezione di istruzioni SIMD. Esse operano su valori in virgola mobile impacchettati usando i registri xmm.

Istruzioni SIMD per numeri in virgola mobile

Come regola generale, le istruzioni SIMD che operano su valori in virgola mobile assumono una forma a due operandi. Il loro nome segue una certa struttura:

  1. L'operazione eseguita, con lo stesso nome della sua controparte non SIMD (detta anche scalare) (ad es. add, mul, ecc.).
  2. Un suffisso p per indicare che l'istruzione opera su dati impacchettati, nella stessa posizione prima occupata da una s (per dati scalari).
  3. Un suffisso per indicare la dimensione di ogni corsia, con la stessa semantica della sua controparte non SIMD (s per la precisione singola e d per la precisione doppia).

Il nome impacchettato si ottiene quindi dal nome scalare sostituendo la s (scalare) al centro del mnemonico con una p (impacchettato). Il nome dell'operazione e il suffisso di dimensione restano invariati:

scalare impacchettato significato
addss addps somma, precisione singola (corsie a 32 bit)
addsd addpd somma, precisione doppia (corsie a 64 bit)
mulss mulps moltiplicazione, precisione singola
mulsd mulpd moltiplicazione, precisione doppia
Aritmetica

Le istruzioni SIMD aritmetiche seguono la regola precedente. Alcuni esempi:

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)
Conversione tra precisioni

Esistono anche istruzioni SIMD per convertire tra valori in virgola mobile impacchettati a precisione singola e impacchettati a precisione doppia. Seguono la stessa regola di denominazione, con ogni s per scalare sostituita da una p per impacchettato:

scalare impacchettato significato
cvtss2sd cvtps2pd converte da precisione singola a precisione doppia
cvtsd2ss cvtpd2ps converte da precisione doppia a precisione singola

Queste istruzioni illustrano il punto precedente: una corsia è un'interpretazione, non un limite rigido. Gli operandi sorgente e di destinazione hanno dimensioni di corsia diverse, quindi hanno un numero di corsie diverso.

Per cvtps2pd, la sorgente (ps) ha 4 corsie a precisione singola. La destinazione (pd) ha 2 corsie a precisione doppia. Vengono lette solo le 2 corsie inferiori della sorgente. Le 2 superiori vengono ignorate:

operando 4ª corsia 3ª corsia 2ª corsia 1ª corsia
sorgente come ps ignorata ignorata 2.5 1.5
destinazione come pd 2.5 1.5

Per cvtpd2ps, la sorgente (pd) ha 2 corsie a precisione doppia. La destinazione (ps) ha 4 corsie a precisione singola. Entrambe le corsie della sorgente vengono convertite e scritte nelle 2 corsie inferiori della destinazione. Le 2 corsie superiori della destinazione vengono impostate a zero:

operando 4ª corsia 3ª corsia 2ª corsia 1ª corsia
sorgente come pd 4.0 3.0
destinazione come ps 0 0 4.0 3.0

Alcuni esempi:

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)
Operandi in memoria

Le istruzioni SIMD viste sopra sono state mostrate tutte con operandi da registro a registro. Possono anche accettare un operando in memoria come sorgente (ma non come destinazione).

Il numero di byte letti dalla memoria è determinato dall'istruzione. Ad esempio, addps legge 16 byte (4 numeri in virgola mobile a precisione singola). cvtps2pd, invece, legge solo 8 byte (2 numeri in virgola mobile a precisione singola), perché è tutto l'input di cui ha bisogno.

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

Quando l'operando in memoria è largo 16 byte, deve essere allineato a 16 byte. Una locazione di memoria allineata è una locazione il cui indirizzo è un multiplo della dimensione dell'operazione (16 byte in questo caso).

Per dichiarare memoria allineata, puoi usare la direttiva align nella sezione .data e .rodata, oppure alignb, nella sezione .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

Gli operandi in memoria più piccoli di 16 byte, come la sorgente da 8 byte di cvtps2pd, non hanno requisiti di allineamento.

Caricamento e memorizzazione di valori

Ci sono quattro istruzioni che copiano 128 bit di valori in virgola mobile impacchettati. Operano tra due registri xmm, oppure tra un registro xmm e una locazione di memoria:

istruzione descrizione
movaps copia dati impacchettati a precisione singola da, o verso, una locazione di memoria allineata
movups copia dati impacchettati a precisione singola da, o verso, una locazione di memoria non allineata
movapd copia dati impacchettati a precisione doppia da, o verso, una locazione di memoria allineata
movupd copia dati impacchettati a precisione doppia da, o verso, una locazione di memoria non allineata

Tutte queste istruzioni possono essere usate per copiare valori tra due registri xmm.

Se l'operazione coinvolge una locazione di memoria, movaps e movapd richiedono che sia allineata. movaps e movapd generano un fault se ricevono un indirizzo non allineato. Servono anche da controllo di sicurezza: verificano che il layout dei dati sia quello che pensi.

D'altra parte, movups e movupd possono essere usate con qualsiasi locazione di memoria, allineata o meno.

Il prefisso di dimensione per un valore da 16 byte in NASM è oword.

Estensioni SIMD

SIMD è oggetto di sviluppo attivo nell'ecosistema x86-64 da decenni. Ogni estensione dell'architettura porta nuove istruzioni o nuovi usi per istruzioni esistenti.

In particolare, le estensioni AVX e AVX2 introducono alcuni cambiamenti rilevanti.

Primo, il supporto per i registri SIMD a 256 bit, chiamati ymm. Estendono i corrispondenti registri xmm, più o meno come rax contiene eax.

Secondo, includono il supporto per varianti a 3 operandi per la maggior parte delle istruzioni che operano sui registri xmm (e ymm). Questo vale non solo per le istruzioni SIMD, ma anche per quelle che operano su valori in virgola mobile scalari. Queste varianti seguono la stessa notazione della loro equivalente SSE, con una v aggiunta davanti:

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

Terzo, i requisiti di allineamento degli operandi SIMD in memoria sono più rilassati. Solo i trasferimenti esplicitamente allineati, come vmovaps, richiedono questo allineamento.

In questo percorso ci siamo limitati a istruzioni e registri fino alle precedenti estensioni SSE. Questo basta per insegnare le basi e garantisce la compatibilità con una vasta gamma di CPU.

Tuttavia, il test-runner supporta pienamente AVX e AVX2, e persino le estensioni più recenti che fanno parte di AVX-512. Sei libero di usarle.

Note

È buona pratica mantenere coerenza nell'uso delle varianti SIMD. Cerca di non mescolare varianti SSE e AVX per le stesse istruzioni.

Se il tuo codice mescola queste varianti, è buona pratica usare vzeroupper dopo aver finito di lavorare con AVX e prima di usare SSE. Questo rompe le catene di dipendenza ed evita uno stallo costoso quando la CPU passa tra lo stato AVX e lo stato SSE.

Istruzioni

Stai scrivendo il codice per il sistema dati di un reparto produttivo di componenti automobilistici. Componenti di motori e telai passano attraverso una serie di stazioni di lavorazione. Ogni stazione produce misurazioni che devono essere combinate, confrontate, calibrate o normalizzate prima che parta la fase successiva. Le funzioni che implementerai qui sono una per ogni stazione.

Note

I calcoli in questo esercizio dovrebbero essere eseguiti usando istruzioni SIMD, non operazioni scalari.

1. Combina le rese di due linee

Il reparto produttivo gestisce due linee di assemblaggio parallele. Alla fine di ogni turno, entrambe le linee riportano le proprie rese per 4 contenitori di componenti come numeri a precisione singola. Il pannello di controllo del supervisore mostra la resa combinata per contenitore, quindi i due rapporti vengono sommati contenitore per contenitore. Tutti e tre i buffer sono gestiti dal sistema di rendicontazione e sono sempre allineati a 16 byte.

Definisci una funzione sum_yields che, date le rese per contenitore di line_a e line_b, scrive in result la somma contenitore per contenitore. Gli argomenti, nell'ordine, sono:

  1. line_a: indirizzo di memoria allineato a 16 byte che contiene 4 numeri in virgola mobile a 32 bit
  2. line_b: indirizzo di memoria allineato a 16 byte che contiene 4 numeri in virgola mobile a 32 bit
  3. result: indirizzo di memoria allineato a 16 byte in cui la funzione scriverà 4 numeri in virgola mobile a 32 bit

Questa funzione non ha valore restituito: il risultato viene invece memorizzato nel buffer passato (result):

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. Calcola lo scostamento scalato

Tra un'operazione e l'altra, una stazione di campionamento misura i componenti lavorati e li confronta con le dimensioni target. I valori misurati arrivano da un calibro di precisione in un buffer che è sempre allineato a 16 byte. I valori target provengono dal fascicolo dell'ordine di lavoro, che non ha garanzie di allineamento. Lo scostamento viene moltiplicato per un fattore di sensibilità per asse prima di essere passato al controllo di tolleranza a valle. In questa fase, il controllo a valle legge lo scostamento direttamente da un registro SIMD anziché dalla memoria.

Definisci una funzione scaled_deviation che, dati measured, target e una sensitivity, scrive lo scostamento scalato in result. Gli argomenti, nell'ordine, sono:

  1. measured: indirizzo di memoria allineato a 16 byte che contiene 2 numeri in virgola mobile a 64 bit
  2. target: indirizzo di memoria che contiene 2 numeri in virgola mobile a 64 bit (allineamento non garantito)
  3. sensitivity: indirizzo di memoria che contiene 2 numeri in virgola mobile a 64 bit (allineamento non garantito)
  4. result: indirizzo di memoria di un buffer in cui la funzione scriverà 2 numeri in virgola mobile a 64 bit (allineamento non garantito)

Lo scostamento scalato è (measured - target) * sensitivity, lane per lane.

Questa funzione non ha valore restituito: il risultato viene invece memorizzato nel buffer passato (result) come coppia di numeri in virgola mobile a 64 bit.

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. Calibra un lotto

Prima che i componenti entrino nella baia di ispezione finale, le letture dimensionali di un lotto di 4 componenti vengono riscalate in doppia precisione e corrette per la deriva di temperatura nota.

Le 4 letture grezze arrivano come numeri a precisione singola. Per ogni riga di 2 letture, la calibrazione calcola reference / (raw - offset) e poi moltiplica questo risultato per un fattore di conversione costante di 0.5.

Nota che il riferimento e l'offset sono gli stessi per entrambe le righe del lotto. Il fattore di conversione è lo stesso in ogni calibrazione, quindi invece di riceverlo come argomento dovresti dichiararlo come costante impacchettata di 16 byte in memoria.

Definisci una funzione calibrate_batch che, dati un lotto raw, un reference, un offset e un buffer result, scrive i valori calibrati in result. Gli argomenti, nell'ordine, sono:

  1. raw: indirizzo di memoria che contiene 4 numeri in virgola mobile a 32 bit (allineamento non garantito)
  2. reference: indirizzo di memoria allineato a 16 byte che contiene 2 numeri in virgola mobile a 64 bit
  3. offset: indirizzo di memoria allineato a 16 byte che contiene 2 numeri in virgola mobile a 64 bit
  4. result: indirizzo di memoria allineato a 16 byte in cui la funzione scriverà 4 numeri in virgola mobile a 64 bit (32 byte in totale)

La funzione deve, per ciascuna delle due righe del lotto:

  1. Convertire i 2 valori grezzi a precisione singola della riga in doppia precisione.
  2. Sottrarre offset dalla coppia convertita.
  3. Dividere reference per il risultato.
  4. Moltiplicare lane per lane ogni numero in virgola mobile a 64 bit per il fattore di conversione fisso di 0.5.
  5. Memorizzare i 2 numeri in virgola mobile a 64 bit nei corrispondenti 16 byte di result.

Questa funzione non ha valore restituito: il risultato viene invece memorizzato nel buffer passato (result):

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. Normalizza i punteggi lungo una linea

Alla fine della linea, ogni componente ha un punteggio di qualità che deve essere normalizzato prima di essere riportato. Ogni punteggio viene moltiplicato per il proprio guadagno per componente, poi diviso per una scala di produzione per asse. Il buffer dei guadagni contiene un guadagno per componente.

La scala di produzione è la stessa per l'intera esecuzione e viene passata in un indirizzo di memoria che contiene due numeri in virgola mobile a 64 bit. I punteggi vengono aggiornati in place: lo stesso buffer che conteneva i punteggi grezzi ora contiene i punteggi normalizzati.

Definisci una funzione normalize_scores che, dati un array scores, un array gains, una scale e un conteggio n, normalizza i punteggi in place. Gli argomenti, nell'ordine, sono:

  1. scores: indirizzo di memoria allineato a 16 byte che contiene n numeri in virgola mobile a 64 bit (modificati in place)
  2. gains: indirizzo di memoria allineato a 16 byte che contiene n numeri in virgola mobile a 64 bit
  3. scale: indirizzo di memoria allineato a 16 byte che contiene 2 numeri in virgola mobile a 64 bit
  4. n: un numero intero positivo pari a 64 bit (minimo 2), il numero di punteggi da normalizzare

Le due lane della scala si applicano ai due elementi di ciascuna coppia. La prima lane scala il primo elemento di ogni coppia (lane in posizioni pari), la seconda scala il secondo (lane in posizioni dispari). Ogni punteggio viene moltiplicato per il guadagno corrispondente, poi diviso per la lane della scala appropriata: score * gain / scale_lane.

Questa funzione non ha valore restituito: il risultato viene invece memorizzato nel buffer di input (scores), nella stessa posizione del score corrispondente che è stato normalizzato:

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};
Modifica tramite GitHub Il link si apre in una nuova finestra o scheda
x86-64 Assembly Exercism

Vuoi iniziare Linea di produzione?

Iscriviti a Exercism per imparare e padroneggiare x86-64 Assembly con 22 concetti130 esercizi e il mentoring di persone reali, tutto gratis.