通道重排

通道重排

學習練習

簡介

SIMD:跨 lane 操作

到目前為止,每個 SIMD 操作都是 逐 lane 進行的。 結果中 lane i 的值,是根據輸入中 lane i 的值計算出來的。

然而,在許多情況下,會需要在 lane 之間搬移值,或是希望這麼做。 例如,對我們需要進行的計算來說,lane 的順序可能是錯亂的。

有許多 SIMD 指令可以用不同的方式跨 lane 搬移資料。

重排

重排會重新排列暫存器中的位元組或 lane,每個目的 lane 的來源 lane 可以位於任何位置。 它們可以將同一個 lane 選到不同的目的地,因此也能用來廣播值。

重排指令會根據其大小和執行領域而有不同的行為。

使用立即值選擇 lane

指令 pshufd 會將其來源的四個 32 位元 lane 重新排列到目的。

選擇方式是 8 位元的立即值,讀取為四個 2 位元欄位,每個目的 lane 一個欄位。 每個欄位會選擇要將四個來源 lane 中的哪一個複製到該目的 lane:

欄位 lane 索引
00 0
01 1
10 2
11 3

立即值中欄位的位置(由右至左讀取)表示所選的 lane 要插入何處。 同一個來源 lane 可以被選擇多次,這就是將單一 lane 廣播到整個暫存器的方式:

; The bit fields are read right-to-left
pshufd xmm1, xmm0, 0b00_01_10_11 ; reverse: lane 0 takes source 0b11 (3), lane 1 takes source 0b10 (2), and so on
pshufd xmm3, xmm2, 0b00_00_00_00 ; broadcast source lane 0 into all four lanes
重排浮點數 lane

有兩個浮點數重排指令,遵循相同的一般語法:shuf + p + 大小後綴(s 或 d)。

它們也使用立即值來選擇 lane。 shufps 使用與先前整數重排相同的 2 位元欄位編碼。 然而,由於 128 位元運算元中只有 2 個 64 位元 lane,shufpd 只使用 1 位元欄位編碼。

這些指令與整數版本的不同之處在於,它們是從 兩個 運算元而非一個運算元中取得 lane:

  • 結果的低半部取自目的運算元。
  • 高半部取自來源運算元。
shufps xmm0, xmm1, 0b11_10_01_00 ; xmm0 = {xmm0[0], xmm0[1], xmm1[2], xmm1[3]}
shufps xmm2, xmm3, 0b00_00_00_00 ; xmm2 = {xmm2[0], xmm2[0], xmm3[0], xmm3[0]}
shufpd xmm4, xmm5, 0b0_1         ; xmm4 = {xmm4[1], xmm5[0]}

由於兩個運算元都會饋入結果,它們可以用來一步交錯兩個向量。 如果來源和目的都是同一個暫存器,則每個 lane 都會從中取得。

位元組重排

pshufb 是最通用的重排指令。 雖然 pshufb 和 pshufd 的名稱非常相似,只有大小後綴不同,但它們執行的操作卻大不相同。

首先,pshufd 使用立即值來選擇 lane 位置,而 pshufb 則在來源運算元中使用控制向量。 這個控制向量是一個 xmm 暫存器或 16 位元組的記憶體運算元。

對於 16 個目的 lane 中的每一個,控制向量的低四位元會給出來源位元組索引,範圍從 0 到 15。 如果控制向量的 lane i 含有來源位元組索引 j,則目的的第 j-th 個 lane 會被搬到第 i 個 lane。

這凸顯了這兩個指令的第二個差異。 pshufd 是從不同的來源運算元取得要重排的 lane,而 pshufb 則是在目的中就地進行重排。

section .rodata
align 16
reverse: db 15, 14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0

section .text
fn:
  pshufb xmm0, [rel reverse]
  ; xmm0[0] = xmm0[reverse[0]] (xmm0[15])
  ; xmm0[1] = xmm0[reverse[1]] (xmm0[14])
  ; ...
  ; in the end, the bytes in xmm0 are reversed

pshufb 甚至比這更有彈性:它可以清除任何 lane。 如果控制向量的 lane i 的最高位元被設定,則目的的第 i-th 個 lane 會被清零。 這使得 pshufb 在單一指令中同時具備任意位元組排列和選擇性清除的功能。

由於 pshufb 以位元組為單位運作,它也可以用來重排不同大小的 lane,方法是將相鄰的 lane 分組在一起。 例如,它可以用來重排 dword:

section .rodata
align 16
input: dd 13, 25, 37, 49 ; each number takes 4 bytes
reverse_dwords: db 12, 13, 14, 15, 8, 9, 10, 11, 4, 5, 6, 7, 0, 1, 2, 3 ; aligned too, since input spans 16 bytes

section .text
fn:
  movdqa xmm0, [rel input] ; xmm0 = {13, 25, 37, 49}
  pshufb xmm0, [rel reverse_dwords]
  ; xmm0 = {49, 37, 25, 13}

交錯 lane

解包指令會從兩個運算元取得 lane,並將它們交織在一起,在兩者之間交替。 有整數和浮點數兩種變體,它們大致遵循相同的一般語法結構,但有兩個差異:

  1. 有一個 l 或 h 後綴,用來表示它是作用於每個運算元的低半部(l)或高半部(h)。
  2. 整數變體接受兩個大小後綴,第二個代表第一個的兩倍。 例如 bw 或 qdq(16 位元組的大小後綴是 dq,如 movdqu 所示)。
punpcklwd xmm0, xmm1 ; p + unpck + l + wd
; this interleaves the first 4 words of xmm0 and xmm1 into 4 dwords
; for each dword in the result, the first word is taken from xmm0 and the second, from xmm1
; xmm0 = {xmm0[0], xmm1[0], xmm0[1], xmm1[1], xmm0[2], xmm1[2], xmm0[3], xmm1[3]}

unpckhps xmm2, xmm3 ; unpck + h + p + s
; this interleaves the high 32-bit floats of xmm2 and xmm3
; xmm2 = {xmm2[2], xmm3[2], xmm2[3], xmm3[3]}

使用飽和運算縮窄

封裝指令朝相反方向運作,將兩個運算元的 lane 合併成目的中一半寬度的 lane。

這些指令沒有 p 前綴。 除此之外,語法結合了我們已經見過的元素:

  • 所執行的操作:pack。
  • 一個 s 或 u,分別表示輸出值是 帶號 或 不帶號。
  • 一個 s 表示 飽和,如同先前概念中看過的飽和運算。
  • 兩個大小後綴,第二個表示第一個的 一半 寬度(與 unpck 的行為相反)。

由於 封裝 操作是縮窄的,因此不需要 l 或 h 後綴:

packssdw xmm0, xmm1 ; pack + s (for signed) + s (for saturating) + dw (dword to word)
packuswb xmm2, xmm3 ; pack + u (for unsigned) + s (for saturating) + wb (word to byte)

這些指令是飽和的,也就是說,輸出值會被限制在較窄的範圍內。 它們的作用範圍是從 dword 到 word,以及從 word 到 byte。 沒有 qword 到 dword 的變體。

請注意,輸入一律被視為帶號。 型別(帶號或不帶號)是針對 輸出 的。 它表示要限制的範圍。 例如,packsswb 會限制在帶號位元組的範圍內,即 [-128, 127]。

結果的低 lane 來自目的運算元,高 lane 則來自來源運算元:

packusdw xmm0, xmm1   ; 8 words, each clamped to 0..65535
; xmm0 = {xmm0[0], xmm0[1], xmm0[2], xmm0[3], xmm1[0], xmm1[1], xmm1[2], xmm1[3]}

這些指令沒有浮點數的對應版本。

搬移個別 lane

到目前為止,我們一直使用 movq/movd 在 SIMD 暫存器和通用暫存器之間搬移資料。 這些指令只能寫入或讀取 SIMD 暫存器的低 lane,而且在寫入時,會清除所有其他 lane。

有些指令可以對任何 lane 執行相同操作,而不僅限於第一個 lane,並且不會動到其他 lane:

  • 插入指令會將通用暫存器或記憶體中的一個元素寫入由立即值選擇的 lane。
  • 擷取指令會從一個 lane 讀取到通用暫存器中。

兩者都遵循整數語法:p + insr/extr + 大小後綴(b、w、d 或 q)。

在兩種情況下,通用暫存器通常都是 32 位元寬。 只有 pinsrq 和 pextrq 需要 64 位元運算元。 記憶體運算元的大小一律與操作大小相同:pinsrb/pextrb 為 8 位元,pinsrw/pextrw 為 16 位元,依此類推。

pinsrb xmm0, eax, 5        ; replace byte 5 of xmm0 with the low byte of eax
pextrb byte [rdx], xmm0, 5 ; copy byte 5 of xmm0 into the memory location indicated by rdx

請注意,這些指令和 movd、movq 很像,只是搬移原始位元組。 這表示它們也可以用於儲存在記憶體或通用暫存器中的浮點數值。

說明

你負責撰寫軟體影像管線的內層迴圈。這個階段會在紋理和圖層送到螢幕之前,先準備紋理並合成圖層。 這條管線以區塊為單位處理像素,對整個區塊套用相同的運算。

一個像素由四個 1 位元組的通道組成:紅色、綠色、藍色和 alpha,順序就是這樣(RGBA)。 一個區塊是 4 個像素,所以總共是 16 位元組。

你有五個任務。

你會透過記憶體位址接收運算元,並透過結果位址寫出答案。 這個練習裡的所有記憶體位址都是 16 位元組對齊。

Note

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

1. 將影像轉換成顯示順序

紋理是以 RGBA 儲存的,但這條管線要繪製的幀緩衝區預期每個像素都是 BGRA 順序:紅色和藍色通道互換,綠色和 alpha 通道維持原位。 一張影像會以一連串區塊的形式送達,而每個區塊都以相同的方式轉換。

實作 to_display_order 函式,它會把整張影像從 RGBA 轉換成 BGRA,一次處理一個區塊。 你應該把重新排列通道的控制遮罩定義成記憶體中打包好的常數,並在每個區塊重複使用。

這個函式接受下列引數,順序如下:

  • result:緩衝區的記憶體位址,轉換後的區塊會寫入其中,每個區塊 16 位元組。
  • pixels:來源區塊的記憶體位址,每個區塊 4 個像素,每個像素 4 位元組,以 RGBA 順序排列。
  • block_count:區塊的數量,一律大於 0。
pixels      = {200, 64, 32, 255, 10, 20, 30, 40, 0, 0, 0, 255, 12, 34, 56, 78,
               1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16} // 2 blocks
block_count = 2
result      = {32, 64, 200, 255, 30, 20, 10, 40, 0, 0, 0, 255, 56, 34, 12, 78,
               3, 2, 1, 4, 7, 6, 5, 8, 11, 10, 9, 12, 15, 14, 13, 16}

這個函式沒有回傳值。

2. 以單一顏色填滿區域

為了清除某個區域,或是塗滿一段平坦的範圍,管線會把單一顏色寫進該區域的每個像素。

實作 fill_region 函式,它會用同一個顏色的複本,填滿由 block_count 個區塊組成的區域。

這個函式接受下列引數,順序如下:

  • result:緩衝區的記憶體位址,填滿後的區塊會寫入其中,每個區塊 16 位元組。
  • color:一個像素的記憶體位址,4 位元組,以 RGBA 順序排列。
  • block_count:要填滿的區塊數量,一律大於 0。
color       = {18, 52, 86, 120}
block_count = 2
result      = {18, 52, 86, 120, 18, 52, 86, 120, 18, 52, 86, 120, 18, 52, 86, 120,
               18, 52, 86, 120, 18, 52, 86, 120, 18, 52, 86, 120, 18, 52, 86, 120}

這個函式沒有回傳值。

3. 交織兩條掃描線

兩個單通道的圖層需要合併到一個緩衝區,並讓它們的取樣值交錯排列。 結果會交替出現第一個圖層的一個取樣值,然後是第二個圖層的一個取樣值。

實作 weave_scanlines 函式,它會把兩列各 16 個取樣值,交織成一列 32 個取樣值。

這個函式接受下列引數,順序如下:

  • result:緩衝區的記憶體位址,32 個交錯後的取樣值會寫入其中。
  • first:第一列的記憶體位址,16 個取樣值,每個都是 8 位元的值。
  • second:第二列的記憶體位址,16 個取樣值,每個都是 8 位元的值。
first  = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15}
second = {100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, 115}
result = {0, 100, 1, 101, 2, 102, 3, 103, 4, 104, 5, 105, 6, 106, 7, 107,
          8, 108, 9, 109, 10, 110, 11, 111, 12, 112, 13, 113, 14, 114, 15, 115}

這個函式沒有回傳值。

4. 打包增亮後的列

增亮的階段會以 16 位元的工作精度縮放每個取樣值,因此過亮的取樣值可以超過 255,而差值可以掉到 0 以下。 最後的階段會把這些工作值縮窄回 8 位元的取樣值以供顯示:低於 0 的值鉗位到 0,高於 255 的值鉗位到 255。

實作 pack_samples 函式,它會依序把兩組各 8 個工作值,縮窄成一列 16 個取樣值。

這個函式接受下列引數,順序如下:

  • result:緩衝區的記憶體位址,16 個鉗位後的取樣值會寫入其中,每個都是 8 位元的值。
  • first:前 8 個工作值的記憶體位址,每個都是有號 16 位元整數。
  • second:接下來 8 個工作值的記憶體位址,每個都是有號 16 位元整數。
first  = {300, -5, 128, 255, 0, 400, 64, 200}
second = {255, 256, -1, 100, 50, 1000, 7, 0}
result = {255, 0, 128, 255, 0, 255, 64, 200,   255, 255, 0, 100, 50, 255, 7, 0}

這個函式沒有回傳值。

5. 將座標拆分成 x 和 y

紋理和頂點資料經常以交錯的形式送達,每個點的 x 和 y 都打包在一起。 不過,為了提升處理效率,常常需要把它們分開:所有的 x 值放在同一個向量裡,所有的 y 值放在另一個向量裡。

實作 split_coordinates 函式,它會把四個交錯的 (x, y) 點,拆分成一個 x 座標向量和一個 y 座標向量。

這個函式接受下列引數,順序如下:

  • xs:緩衝區的記憶體位址,4 個 x 座標會寫入其中,共 4 個 32 位元浮點數。
  • ys:緩衝區的記憶體位址,4 個 y 座標會寫入其中,共 4 個 32 位元浮點數。
  • first:前兩個點的記憶體位址,4 個 32 位元浮點數,格式為 {x0, y0, x1, y1}。
  • second:接下來兩個點的記憶體位址,4 個 32 位元浮點數,格式為 {x2, y2, x3, y3}。
first  = {0.0, 0.5, 1.0, 1.5} // {x0, y0, x1, y1}
second = {2.0, 2.5, 3.0, 3.5} // {x2, y2, x3, y3}
xs     = {0.0, 1.0, 2.0, 3.0} // {x0, x1, x2, x3}
ys     = {0.5, 1.5, 2.5, 3.5} // {y0, y1, y2, y3}

這個函式沒有回傳值。

透過 GitHub 編輯 連結會在新視窗或分頁中開啟
x86-64 Assembly Exercism

準備好開始 通道重排 了嗎?

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