生產線

生產線

學習練習

簡介

SIMD

**單指令多資料(SIMD)**是一種硬體層級的平行處理。 CPU 會在同一時間對多個元素執行相同的運算。

舉例來說,試想將 2 個各含 4 個 32 位元浮點數的陣列逐元素相加。 純量實作會發出 4 道獨立的addss指令,每個元素各一道。 SIMD 實作則使用單一個addps,平行完成全部 4 次加法。 這相當於每道指令完成 4 倍的工作量。

在 x86-64 組合語言中,SIMD 指令通常使用xmm暫存器來執行。 其中一些指令處理整數。 另一些則處理浮點數。

這些整數或浮點數稱為打包值。 每一個都佔用同一個暫存器內的一個空間,稱為通道。 SIMD 指令通常會平行處理兩個來源運算元中位於相同通道位置的值。 每次運算的結果會存入目的運算元中相同位置的通道。

通道並不是硬性限制。 它是每道指令對一串位元組所做的解讀。 一個 128 位元的運算元包含 16 個位元組。 視指令而定,這些位元組可以解讀為 16 個 _1 位元組_通道、8 個 _2 位元組_通道、4 個 _4 位元組_通道、2 個 _8 位元組_通道,甚至單一個 _16 位元組_通道。

舉例來說,addps指令會將打包的單精度(4 位元組)浮點數相加。 對這道指令而言,運算元中每個 4 位元組的區塊各自屬於不同的通道。 在一個 128 位元寬(16 位元組)的運算元中:

運算元 第 4 通道 第 3 通道 第 2 通道 第 1 通道
來源 1 1.5 2.1 6.2 3.4
+ + + + +
來源 2 0.5 8.26 -13.4 22.9
= = = = =
結果 2.0 10.36 -7.2 26.3

另一方面,在mulpd的情況下,一個 128 位元的運算元會被解讀為具有 2 個通道。 這是因為這道指令對雙精度(8 位元組)的值執行打包乘法:

運算元 第 2 通道 第 1 通道
來源 1 4.2 2.1
* * *
來源 2 1.0 92.4
= = =
結果 4.2 194.04

在這個概念中,我們會聚焦於一小部分的 SIMD 指令。 它們使用xmm暫存器來處理打包的浮點數值。

浮點數 SIMD 指令

大致說來,處理浮點數值的 SIMD 指令採用雙運算元形式。 它們的名稱遵循一定的結構:

  1. 所執行的運算,其名稱與非 SIMD(也稱為_純量_)的對應指令相同(例如add、mul等)。
  2. 一個後綴p,表示這道指令處理的是_打包_資料,位置就在原本s(代表_純量_資料)所在之處。
  3. 一個表示每個通道大小的後綴,其語意與非 SIMD 對應指令相同(s代表單精度,d代表雙精度)。

因此,打包名稱的取得方式,就是把助憶符中間代表純量的s換成代表打包的p。 運算名稱與大小後綴則維持不變:

純量 打包 意義
addss addps 加法,單精度(32 位元通道)
addsd addpd 加法,雙精度(64 位元通道)
mulss mulps 乘法,單精度
mulsd mulpd 乘法,雙精度
算術運算

算術 SIMD 指令遵循上述規則。 一些例子:

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)
精度之間的轉換

也有 SIMD 指令可以在打包的單精度與打包的雙精度浮點數之間進行轉換。 它們遵循相同的命名規則,把代表_純量_的s換成代表_打包_的p:

純量 打包 意義
cvtss2sd cvtps2pd 將單精度轉換為雙精度
cvtsd2ss cvtpd2ps 將雙精度轉換為單精度

這些指令印證了先前的論點:通道是一種解讀,而不是硬性限制。 來源與目的運算元有不同的通道大小,因此通道數量也不同。

對cvtps2pd而言,來源(ps)有 4 個單精度通道。 目的(pd)有 2 個雙精度通道。 只會讀取來源中較低的 2 個通道。 較高的 2 個通道會被忽略:

運算元 第 4 通道 第 3 通道 第 2 通道 第 1 通道
視為ps的來源 忽略 忽略 2.5 1.5
視為pd的目的 2.5 1.5

對cvtpd2ps而言,來源(pd)有 2 個雙精度通道。 目的(ps)有 4 個單精度通道。 來源的兩個通道都會被轉換,並寫入目的中較低的 2 個通道。 目的中較高的 2 個通道會設為零:

運算元 第 4 通道 第 3 通道 第 2 通道 第 1 通道
視為pd的來源 4.0 3.0
視為ps的目的 0 0 4.0 3.0

一些例子:

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)
記憶體運算元

上述的 SIMD 指令都是以暫存器對暫存器運算元來示範。 它們也可以接受記憶體運算元作為來源(但不能作為目的)。

從記憶體讀取的位元組數量由指令決定。 舉例來說,addps會讀取 16 個位元組(4 個單精度浮點數)。 相較之下,cvtps2pd只讀取 8 個位元組(2 個單精度浮點數),因為這就是它所需的全部輸入。

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

當記憶體運算元寬達 16 位元組時,就必須是_16 位元組對齊_。 所謂對齊的記憶體位置,是指其位址為該運算大小的倍數(在此例中為 16 位元組)。

若要宣告對齊的記憶體,你可以在 .data 與 .rodata 區段上使用align指示詞,或在 .bss 區段上使用alignb:

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

小於 16 位元組的記憶體運算元,例如cvtps2pd的 8 位元組來源,則沒有對齊要求。

載入與儲存值

有四道指令會複製 128 位元的打包浮點數值。 它們作用於兩個xmm暫存器之間,或一個xmm暫存器與一個記憶體位置之間:

指令 說明
movaps 從_對齊_的記憶體位置複製打包的單精度資料,或複製到該位置
movups 從_未對齊_的記憶體位置複製打包的單精度資料,或複製到該位置
movapd 從_對齊_的記憶體位置複製打包的雙精度資料,或複製到該位置
movupd 從_未對齊_的記憶體位置複製打包的雙精度資料,或複製到該位置

上述所有指令都可以用來在兩個xmm暫存器之間複製值。

如果運算涉及記憶體位置,movaps與movapd會要求該位置必須_對齊_。 若給定未對齊的位址,movaps與movapd會引發錯誤。 它們同時也扮演安全檢查的角色,確認你的資料配置正如你所想。

另一方面,movups與movupd則可搭配任何記憶體位置使用,無論是否對齊。

在 NASM 中,16 位元組值的大小前綴是oword。

SIMD 擴充

數十年來,SIMD 一直是 x86-64 生態系中積極發展的目標。 架構的每一次擴充,都會帶來新指令,或是舊指令的新用途。

特別是 AVX 與 AVX2 這兩個擴充,帶來了一些相關的變革。

首先,支援稱為ymm的 256 位元 SIMD 暫存器。 它們會擴充對應的xmm暫存器,就像rax包含eax一樣。

其次,它們為大多數處理xmm(以及ymm)暫存器的指令,加入了三運算元變體的支援。 這不僅包括 SIMD 指令,也包括處理純量浮點數值的指令。 這些變體沿用其 SSE 對應指令的相同標記法,並在前面加上一個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

第三,SIMD 記憶體運算元所要求的對齊變得寬鬆了。 只有像vmovaps這樣明確對齊的搬移指令,才會要求這種對齊。

在這個 track 中,我們限定只使用到先前 SSE 擴充為止的指令與暫存器。 這足以傳授基礎知識,並確保與各式各樣的 CPU 相容。

不過,test-runner 完整支援 AVX 與 AVX2,甚至支援屬於 AVX-512 的較新擴充。 你可以自由使用它們。

Note

使用 SIMD 變體時保持一致,是很好的做法。 盡量不要為同一組指令混用 SSE 與 AVX 變體。

如果你的程式碼庫混用了這些變體,那麼在完成 AVX 的工作之後、使用 SSE 之前,最好先使用vzeroupper。 這會打斷相依鏈,並避免 CPU 在 AVX 與 SSE 狀態之間轉換時產生代價高昂的停滯。

說明

你正在為汽車零件工廠現場的資料系統撰寫程式碼。引擎與底盤零組件會依序經過一連串的加工站。每個加工站都會產生量測資料,這些資料必須先經過合併、比較、校正或正規化,才能進入下一道程序。你在這裡實作的函式,每個加工站各一個。

Note

這個練習的運算應該使用 SIMD 指令完成,而不是純量運算。

1. 合併兩條產線的產量

工廠現場有兩條平行運作的組裝線。每到班次結束時,兩條產線都會以單精度數字回報 4 個零件箱的產量。主管的儀表板會顯示每個零件箱合併後的產量,因此兩份報告會逐箱相加。這三個緩衝區都由回報系統管理,而且一律是 16 位元組對齊。

定義一個函式sum_yields,它會接收line_a與line_b每個零件箱的產量,並把逐箱相加的結果寫入result。

引數依序為:

  1. line_a:16 位元組對齊的記憶體位址,存放 4 個 32 位元浮點數
  2. line_b:16 位元組對齊的記憶體位址,存放 4 個 32 位元浮點數
  3. result:16 位元組對齊的記憶體位址,函式會在這裡寫入 4 個 32 位元浮點數

這個函式沒有回傳值,結果會改為儲存在傳入的緩衝區(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. 計算縮放後的偏差

在兩道工序之間,抽樣站會量測加工後的零件,並與目標尺寸比較。量測值會從精密量規串流進一個一律為 16 位元組對齊的緩衝區。目標值則來自工單文件,不保證對齊。偏差在傳給下游的公差檢查器之前,會先乘上每個軸向的靈敏度係數。在這個階段,下游檢查器會直接從 SIMD 暫存器讀取偏差,而不是從記憶體讀取。

定義一個函式scaled_deviation,它會接收measured、target與sensitivity,並把縮放後的偏差寫入result。

引數依序為:

  1. measured:16 位元組對齊的記憶體位址,存放 2 個 64 位元浮點數
  2. target:存放 2 個 64 位元浮點數的記憶體位址(不保證對齊)
  3. sensitivity:存放 2 個 64 位元浮點數的記憶體位址(不保證對齊)
  4. result:函式會寫入 2 個 64 位元浮點數的緩衝區記憶體位址(不保證對齊)

縮放後的偏差是(measured - target) * sensitivity,採逐通道計算。

這個函式沒有回傳值,結果會以一對 64 位元浮點數的形式,儲存在傳入的緩衝區(result)中。

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. 校正批次

在零件進入最終檢驗區之前,一批 4 個零件的尺寸讀數會先重新縮放為雙精度,並針對已知的溫度漂移進行修正。

這 4 筆原始讀數會以單精度數字送達。校正會針對每列 2 筆讀數計算reference / (raw - offset),然後把這個結果乘上一個固定不變的轉換係數0.5。

請注意,同一批次的兩列都會使用相同的 reference 與 offset。轉換係數在每次校正時都相同,因此與其把它當成引數接收,你應該在記憶體中將它宣告為一個 16 位元組的 packed 常數。

定義一個函式calibrate_batch,它會接收一批raw、一個reference、一個offset,以及一個result緩衝區,並把校正後的值寫入result。

引數依序為:

  1. raw:存放 4 個 32 位元浮點數的記憶體位址(不保證對齊)
  2. reference:16 位元組對齊的記憶體位址,存放 2 個 64 位元浮點數
  3. offset:16 位元組對齊的記憶體位址,存放 2 個 64 位元浮點數
  4. result:16 位元組對齊的記憶體位址,函式會在這裡寫入 4 個 64 位元浮點數(共 32 位元組)

這個函式應該針對批次中的兩列,分別執行下列步驟:

  1. 把該列的 2 個單精度原始值轉換為雙精度。
  2. 從轉換後的這一對值減去offset。
  3. 用reference除以剛才的結果。
  4. 把每個 64 位元浮點數逐通道乘上固定的轉換係數0.5。
  5. 把這 2 個 64 位元浮點數儲存到result對應的 16 位元組。

這個函式沒有回傳值,結果會儲存在傳入的緩衝區(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. 沿產線正規化分數

在產線末端,每個零件都有一個品質分數,在回報之前需要先正規化。每個分數會乘上它自己的對應零件增益,再除以每個軸向的生產比例。增益緩衝區會為每個零件各存放一個增益。

生產比例在整批執行中都是相同的,並以一個存放 2 個 64 位元浮點數的記憶體位址傳入。分數會原地更新:原本存放原始分數的同一個緩衝區,之後會存放正規化後的分數。

定義一個函式normalize_scores,它會接收scores陣列、gains陣列、scale,以及一個數量n,並在原地正規化這些分數。

引數依序為:

  1. scores:16 位元組對齊的記憶體位址,存放n個 64 位元浮點數(會原地修改)
  2. gains:16 位元組對齊的記憶體位址,存放n個 64 位元浮點數
  3. scale:16 位元組對齊的記憶體位址,存放 2 個 64 位元浮點數
  4. n:一個 64 位元的正偶數(最小值2),代表要正規化的分數數量

這兩個比例通道會分別套用到每一對中的兩個元素。第一個通道會縮放每一對的第一個元素(位於偶數位置的通道),第二個通道則縮放第二個元素(位於奇數位置的通道)。每個分數會乘上對應的增益,再除以適當的比例通道:score * gain / scale_lane。

這個函式沒有回傳值,結果會儲存在輸入緩衝區(scores)中,位置與被正規化的對應score相同:

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};
透過 GitHub 編輯 連結會在新視窗或分頁中開啟
x86-64 Assembly Exercism

準備好開始 生產線 了嗎?

註冊 Exercism,透過 22 個概念130 個練習 和真人引導來學習並精通 x86-64 Assembly,全部免費。