本次期末專題的題目為嘗試在 pynq-z2 上實做出簡單的 single head attentation 的運算加速器。
### Back ground
詳細的運算過程可參考李弘毅教授的 [講解](https://youtu.be/ugWDIIOHtPA?t=524) 或這篇包含詳細計算過程的範例 [文章](https://towardsdatascience.com/illustrated-self-attention-2d627e33b20a) ,本文只簡單列出計算過程公式以便分析系統所需功能。
single head attentation 運算方式如下:
![](https://hackmd.io/_uploads/ryKNG1Lun.png)
$x^i$ is input sequnce (word)
$\alpha^i = Wx^i$ (embed any sequence $x^i$ into fixed len vector $\alpha^i$)
$q^i = W^q \alpha^i$
$k^i = W^k \alpha^i$
$v^i = W^v \alpha^i$
$\alpha_{i,j} = q^i \cdot k^j / \sqrt{d}$ , d = dimension of q and k, where (q, k) must share same dimension (otherwise they cannot perform inner product)
$\hat{\alpha_{i}} = softmax(\alpha_{i,0},\alpha_{i,1},...\alpha_{i,j})$
$b_{i,j} = \hat{\alpha_{i}} \cdot v^j$ , where b is output sequence.
以上列出了所有需要運算的公式,其中 $\cdot$ 表示內積,若沒有特別寫則皆為矩陣乘法,而上述所有內積如 $\alpha_{i,j}$ 和 $b_{i,j}$ 的計算實際上也可以改以矩陣乘法平行計算:
![](https://hackmd.io/_uploads/Hk3ZsJIdn.png)
也就是說,我們系統需要的核心功能有三個:
* 矩陣乘法運算
* 除法運算及開根號 (/$\sqrt{d}$)
* softmax
另外為了增加傳送資料的效率,我們的 data type 設為 int8 ,如此透過 AXI 傳資料到 bram 時(一次只能傳 32 + 4 bits, 4 for parity bits),每一次能傳 32/8 = 4 筆資料。我們將 vector size 定為 4 * 1 ,如此一次傳送便能傳一個 vector 。
由於我們的 data type 為 int8 ,但經過 softmax 後的 output 為 32 bits ,因此我們還需要實現 quantize 的功能,因此總共需要實現的功能有:
* 矩陣乘法運算
* 除法運算及開根號 (/$\sqrt{d}$)
* softmax
* quantize, float32 -> int8
* PL 和 PS 溝通的機制
除法運算和開根號這點,由於我們將 vector size 定為 4,因此除法 + 開根號相當於計算 $/\sqrt{4} = /2 = right\ shift\ 1$ ,也就是可以直接取 wire[MSB-1:1] 實現對應的功能。
矩陣乘法運算則是用 1 個 cycle 的組合電路達成,要注意的是為了能以 col 為單位取一個 vector 出來,我們所有矩陣乘法的 output matrix 都會是 transpose 過得結果(原本一次取 32 bits 取的會是 row )
softmax 經過我們的評估後最後決定將它交由軟體處理,一來是因為時間不夠,但 softmax 要以硬體實做,而且是浮點數的運算,但板子上的 dsp 只支援整數,用 LUT 實做的話可能會吃掉太多資源,二來是板子上的 arm cpu 有 FPU ,將很適合將浮點運算交給它處理。
### Simple quantize hardware
而關於 quantize ,一般來說 qunatize 的運算如下:
$$ x = S * (x_q - Z) $$
* x : quantize 前的數值 (float)
* S : scale factor (float)
* $x_q$ : quantize 後的數值 (int8)
* Z : 零點 (int8),會對應到 quantize 前的零點
根據以上公式,我們可以反推:
$$x_q = \lfloor x/S \rfloor + Z$$
而我們的 quantize ,是直接以硬體實做以下運算:
$x_q$ = $\lfloor x * 256 \rfloor$
即我們系統中的 S = $\dfrac{1}{256}$ , Z = 0 。
將 S 定為 $\dfrac{1}{256}$ ,是因為唯一需要 quantize 的部份是將 softmax 的 output 轉為 int8 ,而 softmax 的 output 範圍為 0 ~ 1 , int8 的範圍為 0 ~ 255 ,因此可以直接簡單乘上 255 (即 S = $\dfrac{1}{255}$ )將 float map 到 int8 的範圍,將其改為 $\dfrac{1}{256}$ , 是因為 256 是 2 次方數,可以更方便的以硬體進行對應的運算。
而 Z 會對應到 quantize 前的零點,因此:
$Z = \lfloor 0 * 256 \rfloor = 0$
我們來看看 IEEE 754 中對 float 儲存的規範如下:
![](https://hackmd.io/_uploads/rkKMb_Udn.png)
而其表示的數值為:
$value = -1^{sign} * (1.fraction) * 2 ^{exp - 127}$
由於 softmax 的 output 為 0 ~ 1 ,因此 sign bit 永遠為 0 ,而乘上 256 = $2^8$,相當於將 exp 數值 + 8,而要得到 exp ,我們只要取第 23 ~ 30 個 bit 的值減下 127 即可,之所以要減 127 ,是因為浮點數 exp field 會加上一個 127 的 bias 。
因此可得:
* exp_field = float_num[30:23]
* 256 = $2^8$
* resExp = exp + 8 - 127
因此我們要做的計算是 $1.frac * 2^{resExp} = 1.frac << resExp$
如此便能很簡單的實做出 quantize 硬體:
```verilog!
assign E = floatNum[30:23];
assign Mantisa = floatNum[22:0];
always @(*) begin
shift_delta = E + 8 - 127;
// Clac the shift delta based on IEEE 754
// Reasonable range of E: 120 ~ 126
// shift_delta = 1 ~ 7
// rare -> E = 127 (output of softmax = 1)
// shift_delta = 8, int_res is sat to 255
int_res = 0;
case (shift_delta)
0: int_res = 32'b1;
1: int_res = {30'b0 ,1'b1, Mantisa[22]};
2: int_res = {29'b0 ,1'b1, Mantisa[22:21]};
3: int_res = {28'b0 ,1'b1, Mantisa[22:20]};
4: int_res = {27'b0 ,1'b1, Mantisa[22:19]};
5: int_res = {26'b0 ,1'b1, Mantisa[22:18]};
6: int_res = {25'b0 ,1'b1, Mantisa[22:17]};
7: int_res = {24'b0 ,1'b1, Mantisa[22:16]};
8: int_res = 32'd255; // int_res is sat to 255
default: int_res = 32'b0; // if shift_delta < 0
endcase
result = int_res[7:0];
end
```
再來要處理的便是 PS 和 PL 要如何溝通,我們原先想用兩塊 bram ,一塊是 PS 寫 PL 讀,另一塊則是 PL 寫 PS 讀,但由於我們不熟悉 vivado 的 bram IP ,因此最後只有 PS 寫 PL 讀的 bram 有正常運作,後者我們最後改成拉 gpio 出來。
最終接線圖如下:
![](https://hackmd.io/_uploads/r1ck6O8uh.png)
### Softmax
基本上就是用軟體實做簡單的 softmax ,唯一要注意的地方是,由於我們輸入給 Softmax 的 input 是 int8 ,每個數值間的離散程度很大,現在看一下 softmax 的運算:
$$ \sigma (\mathbf {z}_{j} )={\frac {e^{z_{j}}}{\sum _{k=1}^{K}e^{z_{k}}}} $$
可以看到,當每個數值間的散程度很大時,會有一個最大值
$$ e^{z_{max}} >> e^{z_{j}}, j \neq max $$ ,這樣很容易造成只有一個數值是 1 ,其他數值為 0 的結果:
$$ \sigma (\mathbf {z}_{j} )= 1, j = max \\ =0, j \neq max $$
為了避免這個現象,我們在將資料送入 softmax 時會除上一個 softmax temperature T ,這個想法是源自進行 Knowledge distillation 時為了避免 int8 導致一樣的問題(只有一個數值為 1 其他為 0) 時的作法。
因此我們的 softmax 變成:
$$ \sigma (\mathbf {z}_{j} )={\frac {e^{z_{j}/T}}{\sum _{k=1}^{K}e^{z_{k}/T}}} $$
而我們的 T 設為 64 。
### result
最後試了幾筆測資,結果正確,但是我們發現一個嚴重的問題,就是我們用硬體做計算的計算速度沒有比用軟體還要快,甚至稍慢。
我們便著手研究問題出在哪裡,我們嘗試從分析 AXI 和 cpu 上 clk 的速度差異開始切入,首先, pynq-z2 上的 cpu clk rate 是 650MHz ,且有兩個 core ,而 axi bus 上的 clk rate 僅為 100MHz 。
最終我們發現最大的瓶頸是卡在 gpio 的傳輸上:
我們嘗試做了 10000 次連續的讀寫並計時花了多少時間,發現一次 gpio 的讀或寫會有相當於 2050 個 cpu clk 的 overhead 。
由於我們所有硬體上所有操作都需要透過 gpio 給指令,而一個 gpio 傳輸的 overhead 會用掉 ~ 2050 個 cpu clk ,因此會造成很大的時間浪費。
我們嘗試計算了兩者的時間差,卻發現算出來的結果和我們實際量出來的差了 32381 個 axi clk ,明顯不能算做誤差,但我們認為可能會有這樣情況的原因有:
* cpu 運作上是 pipeline ,我們不能把它當成一個 clk 一個指令進行比較
* cpu 除了執行計算的軟體外,也有一部份資源被用來維持 OS 的運作,因此會受影響。
* 計時的方式是透過軟體計時,可能有些許誤差 (對人來說幾毫秒的誤差可忽略,可是對高速運作的硬體幾毫秒就差了好幾萬個 clk 了)
而老師在這個基礎上又告訴我們幾個可能的原因:
* 我們的 axi 有很多 slave ,會造成 arbiter 選 slave 時的延遲變多
* 基本上 650MHz 是 PS 端的,為了維持系統運作,會比較準確沒錯,但 PL 端的 clk 就沒有那麼嚴格的限制了,雖然合成軟體上面標 100MHz ,但不一定會是真的 100MHz
以上種種原因造成我們算出來的 clk 和真實情況有所誤差。
最後附上我們專案的 [網址](https://drive.google.com/file/d/15OutuKDAI7VCaktTlF8DAooC1HaYPGcB/view?usp=drive_link) ,歡迎有興趣的人嘗試重現實驗。
### Misc
* 由於我們預設 input vector embedded 的方式為 one-hot encoding ,因此我們前兩級的矩陣乘法(得到 Q, K, V 以及計算 attention ) 並未針對 overflow 進行處理。
### Misc backup (disposed)
$q^j \cdot k^i / \sqrt{d}$ , d = dimension of q and k, where (q, k) must share same dimension (otherwise they cannot perform inner product)
當有一個字串 $x^i$ ,我們將該字串進行 embedding (即將其字串 mapping 為固定長度的 vector 中)後能得到 $\alpha^i$ ,之後將 $\alpha^i$ 透過矩陣乘法乘上對應的 $W^q 、 W^k 、 W^v$ 權重後可得對應之 $q^i$ 、 $k^i$ 、 $v^i$ 向量,之後我們在把 $q^i$ 、 $k^i$ 進行 attention 的計算(內積後除上 $\sqrt{d}$ ,d 為兩向量的 dimension),計算後得到單個純量 $\alpha_{j,i}$ ,最後將所有 $\alpha_{j,i}$ 經過 Softmax 後得到 $\hat{\alpha_{j,i}}$ ,將所有 $\hat{\alpha_{j,i}}$ 集合成一個 vector 後再和