混音台

混音台

學習練習

簡介

SIMD:整數

先前的主題介紹了 packed 浮點數值:多個數字存放在同一個 xmm 暫存器中,以 lane 為單位平行運算。 同樣的 xmm 暫存器也可以用來存放 packed 的_整數_。

語法

SIMD 浮點數模型的大部分內容,都可以原封不動地套用到 packed 整數上:

  • 一個 128 位元的暫存器會切分成多個 lane
  • 指令會平行作用於相同位置的 lane
  • 記憶體運算元遵循相同的 16 位元組對齊規則

不過,語法上有些微差異:

  1. 一個_前綴_ p,用來表示該指令處理的是 packed 資料。
  2. 執行的運算,名稱與其非 SIMD(也稱為 scalar)的對應版本相同(例如 add、mul 等)。
  3. 一個後綴,用來表示每個 lane 的大小。

packed 整數有四種 lane 大小,每種都有自己的後綴:

lane 寬度 位元組 128 位元中的 lane 數 後綴
byte 1 16 b
word 2 8 w
dword 4 4 d
qword 8 2 q

例如:

指令 意義
paddb packed add,8 位元 lane(16 個 lane)
paddw packed add,16 位元 lane(8 個 lane)
paddd packed add,32 位元 lane(4 個 lane)
paddq packed add,64 位元 lane(2 個 lane)

有些指令輸入的 lane 是一種大小,輸出的卻是另一種大小。 它們遵循同樣的一般慣例,但帶有_兩個_大小後綴。 第一個表示輸入 lane 的大小,第二個表示輸出 lane 的大小:

指令 意義
pmovsxwd packed movsx,從 16 位元 lane 轉為 32 位元 lane
pmuldq packed mul,從 32 位元 lane 轉為 64 位元 lane

記憶體搬移

有兩個名稱帶有 integer 的指令會在 xmm 暫存器和記憶體之間複製 128 位元:

指令 說明
movdqa 將 packed 整數複製到或複製自_已對齊_的位置
movdqu 將 packed 整數複製到或複製自_未對齊_的位置

它們的行為和 movaps 與 movups 一樣:movdqa 在未對齊的位址上會觸發 fault,而 movdqu 則接受任何位址。 這四個指令都會複製 128 位元,而不會解讀其內容。 名稱帶有 integer 的那一對,只是慣例上搭配整數資料使用,並非强制要求。

Note

這裡的 dq 代表 double-qword,也就是 128 位元(16 位元組)。

加法/減法

加法和減法遵循命名規則:

paddb xmm0, xmm1 ; 16 lanes: each  8-bit, xmm0 += xmm1
paddw xmm2, xmm3 ;  8 lanes: each 16-bit, xmm2 += xmm3

psubd xmm4, xmm5 ;  4 lanes: each 32-bit, xmm4 -= xmm5
psubq xmm6, xmm7 ;  2 lanes: each 64-bit, xmm6 -= xmm7

沒有另外區分有號和無號的版本。 在二補數中,無論 lane 被解讀為有號還是無號,加法和減法都會產生相同的位元,所以一個指令就能同時服務兩種情況。 要如何解讀由你決定,就和 scalar 的 add 與 sub 一模一樣。

這些指令在溢位時會環繞(wrap around),就和它們的 scalar 對應版本一樣。 一個 8 位元的 lane 存放的是對 256 取模的值,所以 200 + 100 的 paddb 會產生 300 - 256 = 44,並捨棄放不下的位元。

請注意,那些多出來的位元不會進位到下一個 lane。 每個 lane 都是與其他 lane 分開運算的,即使它們共用同一個暫存器也一樣。

飽和加法/減法

整數 SIMD 多了一種浮點數 SIMD 沒有的運算:飽和(saturating)加減法,它會_箝制_(clamp)而非環繞。 結果高於 lane 的範圍時,會變成該 lane 能容納的最大值;結果低於範圍時,則會變成最小值。

飽和版本會在大小後綴前插入 s(有號)或 us(無號):

指令 意義
paddsb add,飽和,有號 8 位元 lane
paddusb add,飽和,無號 8 位元 lane
psubsw sub,飽和,有號 16 位元 lane
psubusw sub,飽和,無號 16 位元 lane

箝制的範圍是對應大小與有號性的整數所能表示的最大範圍。 以 byte 為例:

  • 無號 byte 會箝制到 [0, 255]:200 + 100 的 paddusb 會得到 255,而 5 - 10 的 psubusb 會得到 0。
  • 有號 byte 會箝制到 [-128, 127]:100 + 50 的 paddsb 會得到 127。

當一個 lane 存放的是有界數量時,飽和就很重要,例如像素的色版或音訊取樣。 環繞會讓過亮的像素變暗,而箝制則會讓它維持在最大亮度,這才是你想要的結果。

Note

飽和加減法只適用於 byte 和 word lane,不適用於 dword 或 qword。

乘法

兩個 N 位元的值相乘,可能產生 2N 位元的乘積,但目的地的 lane 只有 N 位元寬。 SIMD 運算的處理方式,是明確指定要保留乘積的哪一半,也就是低的 N 位元或高的 N 位元。

以 16 位元 lane 為例,會用到三個指令:

指令 意義
pmullw mul,16 位元 lane,保留每個乘積的低 16 位元
pmulhw mul,16 位元 lane,保留高 16 位元,運算元為有號
pmulhuw mul,16 位元 lane,保留高 16 位元,運算元為無號

乘積的低 16 位元,無論運算元被解讀為有號還是無號都相同,所以只有一個 pmullw。

然而,高 16 位元會依結果的有號性而有所不同。 這就是為何取高半部的乘法要分成有號和無號兩種形式:

  1. pmulhw,用於有號乘法。
  2. pmulhuw,多一個 u,用於無號乘法。

注意語法:

  1. 一個 p,代表 packed 整數。
  2. 執行的運算,即 mul。
  3. 一個 h,表示選取結果中較高(「high」)的位元。
  4. 一個選用的 u,表示結果應被解讀為無號(也就是不做符號延伸)。
  5. 最後是大小後綴 w,表示這是 word(16 位元)運算。

word 的 packed 乘法完全遵循上述規則。 dword(32 位元)乘法也遵循這個規則,但只有選取低半部的版本:pmulld。

dword 乘法沒有選取高位元的版本。 不過,有幾個版本會將乘法_加寬_,把_偶數索引_的 lane(也就是位置 0 和 2 的 lane)完整的 64 位元乘積存起來:

指令 意義
pmuludq 將偶數索引的 32 位元元素以無號方式相乘,得到 2 個完整的 64 位元乘積
pmuldq 將偶數索引的 32 位元元素以有號方式相乘,得到 2 個完整的 64 位元乘積

注意語法使用了 dq,無號乘法時前面可能還有一個 u。 這是因為這些指令接受 dword lane,並輸出 qword lane。

除法

沒有 packed 整數除法。 需要它的程式碼會把數值轉成浮點數、相除,再轉換回來。

加寬整數

movsx 和 movzx 都有 packed 的對應版本。 它們遵循我們前面提過、輸入與輸出 lane 大小不同的指令所用的相同語法:

pmovsxwd xmm0, xmm1   ; 4 words -> 4 dwords, sign-extended
pmovzxbw xmm0, xmm1   ; 8 bytes -> 8 words, zero-extended

請注意,lane 的數量是由較大的(輸出)寬度決定的。 指令會從來源的低位部分讀取那麼多個 lane。 這和我們在單精度與雙精度浮點數之間的 packed cvt 指令所見到的語意相同。

整數與浮點數之間的轉換

有些指令可以在 32 位元有號整數與浮點數 lane 之間轉換。 它們遵循 cvt 與 cvtt 指令的慣用語法,但 packed 整數不是用 si,而是用 dq 來表示:

cvtdq2ps xmm0, xmm1 ; convert 32-bit signed integers in xmm1 to 32-bit floats in xmm0
cvtdq2pd xmm2, xmm3 ; convert 32-bit signed integers in xmm3 to 64-bit floats in xmm2
cvtps2dq xmm4, xmm5 ; convert 32-bit floats in xmm5 to 32-bit signed integers in xmm4
Caution

使用 pi 代表 packed 整數的指令會寫入舊式的 mmx 暫存器,在 x86-64 上基本上已被淘汰。 請改用 dq 形式,它使用的是 xmm 暫存器。

前面針對浮點數與整數之間 scalar 轉換所做的說明,在這裡同樣適用。 浮點數會依一個稱為 MXCSR 的特殊暫存器進行捨入,而你在函式進入時無法假設它的模式。 有一個 cvtt 版本(多一個 t),它一律會截斷結果。

也可以先使用 round,讓 packed 浮點數值處於已知的狀態再進行轉換。 捨入控制值和 scalar 的 round 相同,packed 浮點數值的語法也一樣:

roundps xmm0, xmm1, 1 ; xmm0 = floor(xmm1), packed 32-bit floats
roundpd xmm2, xmm3, 2 ; xmm2 = ceil(xmm3), packed 64-bit floats

說明

你正在為錄音室裡一台小型混音控台撰寫數位核心。

聲音會以取樣的形式源源不絕地送進來。 每個取樣都是一個數字,描述喇叭單體在某個瞬間的位置。 混音控台一次會處理一整個取樣區塊,對每個取樣套用相同的運算。

你有 5 個任務,每個任務都對記憶體中的一個取樣區塊進行操作。 你會透過記憶體位址接收運算元,並透過結果位址寫回答案。

Note

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

1. 混合兩條音軌

錄音室有時會把不同樂器錄在不同的音軌上。 若要透過同一組喇叭播放,你必須把這 2 條音軌相加成一條。

實作mix_tracks函式,它會逐個取樣把兩條音軌相加。 每條音軌都是一個包含 8 個帶正負號 16 位元取樣的區塊。 相加結果若超出帶正負號 16 位元的範圍,必須進行飽和處理,而不是溢位環繞。

這個函式的引數依序如下:

  • result:用來寫入 8 個混合後取樣的緩衝區的 16 位元組對齊記憶體位址。
  • track_a:第一條音軌的 16 位元組對齊記憶體位址,內含 8 個帶正負號的 16 位元取樣。
  • track_b:第二條音軌的 16 位元組對齊記憶體位址,內含 8 個帶正負號的 16 位元取樣。
track_a = {10000, 30000, 20000, -30000, -20000, -100,  100, 0}
track_b = { 5000,  5000, 20000,  -5000, -20000,  100, -100, 0}
result  = {15000, 32767, 32767, -32768, -32768,    0,    0, 0}

這個函式沒有回傳值。

2. 移除漏音

當 2 支麥克風同時錄音時,每一支都會隱約收到另一個音源的微弱複本。 這個不想要的複本稱為_漏音_。 一旦量測出這個漏音,就必須把它減掉,才能清理這條音軌。

實作remove_bleed函式,它會逐個取樣把漏音區塊從音軌區塊中減掉。 每個區塊都是 8 個帶正負號的 16 位元取樣,而且相減時必須進行飽和處理。

這個函式的引數依序如下:

  • result:寫入 8 個清理後取樣的記憶體位址(不保證對齊)。
  • track:錄下來的音軌的 16 位元組對齊記憶體位址,內含 8 個帶正負號的 16 位元取樣。
  • bleed:要移除的漏音的記憶體位址,內含 8 個帶正負號的 16 位元取樣(不保證對齊)。
track  = {10000, -30000, 5000, 32767, 0,  200, -200, 15000}
bleed  = { 2000,   5000, 5000,    -5, 0,  300,  300,  5000}
result = { 8000, -32768,    0, 32767, 0, -100, -500, 10000}

這個函式沒有回傳值。

3. 合併音量表

每個聲道上頭都有一個音量表,那是一條小小的光條,亮度顯示該聲道有多大聲。 面板上有一個主音量表,它的亮度應該等於兩個聲道加起來的亮度。 你要把這 2 個音量表區塊相加來驅動它。

注意,亮的音量表會維持在最亮,而不會繞回全暗,所以相加時要進行飽和處理。

實作combine_meters函式,它會逐個取樣把兩個音量表區塊相加。 這個函式的引數依序如下:

  • result:寫入 16 個合併後數值的 16 位元組對齊記憶體位址。
  • meter_a:第一個聲道音量表區塊的 16 位元組對齊記憶體位址,內含 16 個緊密排列的無正負號 8 位元數值。
  • meter_b:第二個聲道音量表區塊的 16 位元組對齊記憶體位址,內含 16 個緊密排列的無正負號 8 位元數值。

每個區塊是 16 個無正負號的 8 位元數值。 相加時要飽和在無正負號的範圍內。

meter_a = {200, 100,  50, 255, 0, 128, 10, 240, 1, 2, 3, 4, 5, 6, 7, 8}
meter_b = {100, 100, 200,   1, 0, 128, 10,  20, 1, 2, 3, 4, 5, 6, 7, 8}
result  = {255, 200, 250, 255, 0, 255, 20, 255, 2, 4, 6, 8, 10, 12, 14, 16}

這個函式沒有回傳值。

4. 套用淡出

歌曲不該突然中斷,而是要一路淡出到結束。 你手上有一個_增益係數_區塊,每個取樣對應一個係數,這些係數會一路遞減。

每個係數都是一個以65536為分母的分數。 例如,某個取樣的值是16384,它實際上代表16384 / 65536,也就是1 / 4。 最大的可用數值是32767,只比二分之一小一點。

由於65536是2^16,把取樣乘上它的係數,再保留乘積的_高半部_,就會得到sample * gain / 65536。

實作apply_fade函式,它會依係數縮放每個取樣。 這個函式的引數依序如下:

  • result:寫入所有縮放後取樣的 16 位元組對齊記憶體位址。
  • track:音軌的 16 位元組對齊記憶體位址,內含n個帶正負號的 16 位元取樣。
  • gains:增益係數的 16 位元組對齊記憶體位址,內含n個帶正負號的 16 位元數值,每個數值的範圍是0到32767。
  • n:取樣的數量,永遠不為零且是8的倍數,型別為 64 位元無正負號整數。

注意,track是一個陣列,內含n個帶正負號的 16 位元取樣,其中n永遠不為零,且一定是8的倍數。 對每個取樣來說,縮放後的結果是帶正負號乘積sample * gain的_高_16 位元。

track  = {20000, 20000, 20000, 20000, -20000, -20000, -20000, -20000}
gains  = {32767, 24576, 16384,  8192,  32767,  24576,  16384,   8192}
result = { 9999,  7500,  5000,  2500, -10000,  -7500,  -5000,  -2500}
Note

保留高半部時,捨入方向是朝負無窮大。 這就是為什麼20000 * 32767縮放後會得到9999,而-20000 * 32767會縮放成-10000,而不是-9999。

這個函式沒有回傳值。

5. 衰減音軌

鼓手錄得太大聲了。 為了把整條音軌壓下來,你要把每個取樣除以一個常數。

實作attenuate_track函式。 這個函式的引數依序如下:

  • result:寫入結果的 16 位元組對齊記憶體位址,結果為 32 位元帶正負號整數。
  • samples:取樣的 16 位元組對齊記憶體位址,內含n個帶正負號的 16 位元數值。
  • divisor:16 位元組對齊的記憶體位址,內含除數的 4 份相同複本,內容為 32 位元整數。
  • n:取樣的數量,永遠不為零且是4的倍數,型別為 64 位元無正負號整數。

注意,samples是一個陣列,內含n個帶正負號的 16 位元取樣,其中n永遠不為零,且一定是4的倍數。

除數會以同一個 32 位元整數的4份複本提供,事先複製好,讓它能一次填滿全部 4 個資料通道。

整數 SIMD 沒有緊密排列的除法,所以這項工作會改用緊密排列的浮點數來做,再轉回緊密排列的 32 位元整數,存入result。 轉換回來時要朝零截斷,所以商為2.3時會變成2,商為-2.3時會變成-2。

samples = {300, 150, 90, -120, 100, 50, -50, 7}
divisor = {3, 3, 3, 3}
result  = {100,  50, 30,  -40,  33, 16, -16, 2}

這個函式沒有回傳值。

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

準備好開始 混音台 了嗎?

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