執行人: LIAO-JIAN-PENG
專題解說錄影
Petakuo
能否解釋為何逐字處理輸入序列會容易遇到梯度消失和爆炸,以及控制門 (Gate) 具體解決此問題的運作方式?
LIAO-JIAN-PENG
已附上說明
marvin0102
transformer 在字串序列增加時,矩陣運算也會增加,運算效率與CNN RNN 相比,優勢在哪裡,可否舉例說明?
LIAO-JIAN-PENG
已附上說明
vestata
為什麼在 matmul 的 loop tiling 選擇 x=16? loop tiling 大小是如何選擇的呢?
主流的 CPU Cache 的 Cache Line 大小多為 64 Bytes
一個 float 是 4 bytes
因此一個 cache line 可以存放 16 個 float 數字 (64/4 = 16)
設定 x = 16 可以最大化 cache line 的利用率,減少 cache miss
參考:現代處理器設計: Cache 原理和實際影響
jserv
將 matmul.c 納入考量,這是針對現代 x86 處理器的 AVX 和 FMA 指令集擴展開發的快速矩陣乘法實作,程式碼精簡又性能表現優異。參照該專案的介紹文章 Beating NumPy's matrix multiplication in 150 lines of C code。
探討 llama.cpp 的效能表現,分析其效能瓶頸並嘗試改進
修正圖片的存取權限。
注意用語:
Strassen 矩陣乘法是一種高效的矩陣乘法演算法,它比傳統的矩陣乘法具有更低的時間複雜度
傳統的矩陣乘法對於兩個
在
雖然說一般矩陣會是剛好
進行效能比較和實作分析,從而歸納出提升矩陣乘法的手法
Implementation | Long description |
---|---|
Naive | Most obvious implementation |
Transposed | Transposing the second matrix for cache efficiency |
sdot w/o hints | Replacing the inner loop with BLAS sdot() |
sdot with hints | sdot() with a bit unrolled loop |
SSE sdot | vectorized sdot() with explicit SSE instructions |
SSE+tiling | sdot SSE sdot() with loop tiling |
OpenBLAS sdot | sdot() provided by OpenBLAS |
OpenBLAS sgemm | sgemm() provided by OpenBLAS |
float sdot_1(int n, const float *x, const float *y)
{
int i;
float s = 0.0f;
for (i = 0; i < n; ++i) s += x[i] * y[i];
return s;
}
在一些數值運算庫(如 BLAS,Basic Linear Algebra Subprograms),sdot
函數用於計算兩個向量的內積。例如,給定兩個向量 sdot
會返回以下結果:
float sdot_8(int n, const float *x, const float *y)
{
int i, n8 = n>>3<<3;
float s, t[8];
t[0] = t[1] = t[2] = t[3] = t[4] = t[5] = t[6] = t[7] = 0.0f;
for (i = 0; i < n8; i += 8) {
t[0] += x[i+0] * y[i+0];
t[1] += x[i+1] * y[i+1];
t[2] += x[i+2] * y[i+2];
t[3] += x[i+3] * y[i+3];
t[4] += x[i+4] * y[i+4];
t[5] += x[i+5] * y[i+5];
t[6] += x[i+6] * y[i+6];
t[7] += x[i+7] * y[i+7];
}
for (s = 0.0f; i < n; ++i) s += x[i] * y[i];
s += t[0] + t[1] + t[2] + t[3] + t[4] + t[5] + t[6] + t[7];
return s;
}
透過 Loop unrolling 的技巧,編譯器或組譯器可以預先計算出迴圈中每次反覆執行所需要的記憶體位置,並直接參照到矩陣的變數位置。這樣可以減少執行時的位址計算開銷,產生較高效能的機器碼。不過,Loop unrolling 也可能會導致程式碼大小增加,因此編譯器會根據情況決定是否應用這個技術。
避免濫用「優化」ㄧ詞,務必使用課程教材規範的術語。參見:
編譯器在最佳化階段會自動嘗試各種最佳化手法,包括 Loop unrolling,來提升程式的執行效能。然而,編譯器是否真正採用 Loop unrolling,端視迴圈的大小、複雜度、硬體特性等多種因素而定。對於一些簡單的迴圈,編譯器較有可能進行展開,但對於大型迴圈,展開可能會導致程式碼膨脹,因此編譯器可能不會採用此最佳化手法。
注意用語:
SSE(Streaming SIMD Extensions)是 Intel 處理器的一組指令集擴展,該指令集對於處理大量資料在相同操作(例如多媒體處理、科學計算)下特別有效。
SSE(Streaming SIMD Extensions)最初新增 8 個新的 128 位元暫存器,稱為 XMM0 到 XMM7。
XMM0 through 7 from Streaming SIMD Extensions wiki
float sdot_sse(int n, const float *x, const float *y)
{
int i, n8 = n>>3<<3;
__m128 vs1, vs2;
float s, t[4];
vs1 = _mm_setzero_ps(); // {0.f, 0.f, 0.f, 0.f}
vs2 = _mm_setzero_ps(); // {0.f, 0.f, 0.f, 0.f}
for (i = 0; i < n8; i += 8) {
__m128 vx1, vx2, vy1, vy2;
vx1 = _mm_loadu_ps(&x[i]); // load {x[0], x[1], x[2], x[3]}
vx2 = _mm_loadu_ps(&x[i+4]); // load {x[4], x[5], x[6], x[7]}
vy1 = _mm_loadu_ps(&y[i]); // load {y[0], y[1], y[2], y[3]}
vy2 = _mm_loadu_ps(&y[i+4]); // load {y[4], y[5], y[6], y[7]}
vs1 = _mm_add_ps(vs1, _mm_mul_ps(vx1, vy1)); // vs1 += vx1*vy1
vs2 = _mm_add_ps(vs2, _mm_mul_ps(vx2, vy2));
}
for (s = 0.0f; i < n; ++i) s += x[i] * y[i]; // Complete the remaining (insufficient 8) operations.
_mm_storeu_ps(t, vs1);
s += t[0] + t[1] + t[2] + t[3]; // {t[0], t[1], t[2], t[3]}
_mm_storeu_ps(t, vs2);
s += t[0] + t[1] + t[2] + t[3]; // {t[4], t[5], t[6], t[7]}
return s;
}
int i, j, ii, jj, x = 16, n_b_rows = n_a_cols;
float **m, **bT;
m = mat_init(n_a_rows, n_b_cols);
bT = mat_transpose(n_b_rows, n_b_cols, b);
for (i = 0; i < n_a_rows; i += x) {
for (j = 0; j < n_b_cols; j += x) {
// loop tiling
int je = n_b_cols < j + x? n_b_cols : j + x;
int ie = n_a_rows < i + x? n_a_rows : i + x;
for (ii = i; ii < ie; ++ii)
for (jj = j; jj < je; ++jj)
m[ii][jj] += sdot_sse(n_a_cols, a[ii], bT[jj]);
}
}
Loop tiling 將大迴圈分成多個小塊 (tile) 迴圈,有效的利用記憶體快取,提升資料在快取中的命中率。
$ lscpu
Architecture: x86_64
CPU op-mode(s): 32-bit, 64-bit
Address sizes: 39 bits physical, 48 bits virtual
Byte Order: Little Endian
CPU(s): 12
On-line CPU(s) list: 0-11
Vendor ID: GenuineIntel
Model name: Intel(R) Core(TM) i5-10400 CPU @ 2.90GHz
CPU family: 6
Model: 165
Thread(s) per core: 2
Core(s) per socket: 6
Socket(s): 1
Stepping: 3
CPU max MHz: 4300.0000
CPU min MHz: 800.0000
BogoMIPS: 5799.77
Virtualization: VT-x
L1d cache: 192 KiB (6 instances)
L1i cache: 192 KiB (6 instances)
L2 cache: 1.5 MiB (6 instances)
L3 cache: 12 MiB (1 instance)
NUMA node(s): 1
NUMA node0 CPU(s): 0-11
相較於 matmul 專案的只有單執行緒比較,在 matmul_bench 實作多執行緒的處理
使用更多的執行緒可以讓運算時間大幅縮短,以下為 6 個執行緒的結果
thread function: 矩陣 inL
分成數等份,從 i_start
到 i_end
的區間資料做平行處理。
for (unsigned long i=i_start; i<i_end; i++) {
for (unsigned long j=0; j<n; j++) {
float v = 0;
for (unsigned long k=0; k<n; k++) {
v += inL[i*n+k] * inR[k*n+j];
}
out[i*n+j] = v;
}
}
將向量總和操作放到迴圈外面,讓 out
,inR
,inL
皆能存取連續空間
for (unsigned long i=0; i<n; i++) {
for (int k=0; k<n; k++) {
float lik = inL[i*n+k];
for (int j=0; j<n; j++) {
out[i*n+j] += lik * inR[k*n + j];
}
}
}
使用 perf 分析 cache-misses, caceh-references, instructions, cycles 的指標,也觀察其中執行時間 sudo perf stat --repeat 5 -e cache-misses,cache-references,instructions,cycles ./matmul -n 2048 -a 1
,在矩陣相乘的部份使用的 outer function 的方式,測量後的 outer function 比 transposed 更快
Performance counter stats for './matmul -n 2048 -a 1' (5 runs):
562,795,487 cache-misses # 47.72% of all cache refs ( +- 3.09% )
1,179,384,120 cache-references ( +- 2.47% )
51,870,785,897 instructions # 1.40 insn per cycle ( +- 0.00% )
37,013,974,362 cycles ( +- 0.08% )
9.0445 +- 0.0109 seconds time elapsed ( +- 0.12% )
Performance counter stats for './matmul -n 2048 -a 8' (5 runs):
464,438,845 cache-misses # 40.17% of all cache refs ( +- 2.43% )
1,156,173,078 cache-references ( +- 0.38% )
60,405,629,722 instructions # 2.91 insn per cycle ( +- 0.00% )
20,736,333,706 cycles ( +- 0.13% )
5.0596 +- 0.0111 seconds time elapsed ( +- 0.22% )
GCC 提供了向量擴展(Vector Extensions),允許以相對較高階的方式使用向量指令,可以使用類似 C 語言的語法來操作向量,而編譯器會將這些操作轉換為目標架構的特定 SIMD 指令。
透過 __attribute__((vector_size(n)))
可以建立自訂大小的向量型態,例如 128-bit 和 256-bit 的向量,然後直接對這些向量進行運算,GCC 會自動將其轉換成 SIMD 指令。在以下範例中,展示 v4sf
(128-bit 單精度浮點數向量)和 v8sf
(256-bit 單精度浮點數向量)進行 SIMD 計算。
v4sf
(128-bit 向量)這段程式碼使用 128-bit 的向量型態 v4sf
來加速矩陣運算。每次迴圈會同時處理 4 個浮點數,減少處理次數。
typedef float v4sf __attribute__((vector_size (16)));
for (unsigned long i=i_start; i<i_end; i++) {
for (int k=0; k<n; k++) {
float lik = inL[i*n+k];
v4sf vlik = {lik,lik,lik,lik}; // process 4 instructions in 1 time
for (int j=0; j<n; j+=4) {
*(v4sf*)&out[i*n+j] += vlik * *(v4sf*)&inR[k*n + j];
}
}
}
v8sf
(256-bit 向量)v8sf
允許處理更大的向量,進一步增加處理的浮點數數量。在支援 AVX 的硬體上,可以用 256-bit 向量一次處理 8 個浮點數。
typedef float v8sf __attribute__((vector_size (32)));
v8sf vlik = {lik,lik,lik,lik,
lik,lik,lik,lik};
與 loop tiling 類似,loop tiling 是針對迴圈的操作,而 block matrix 是將大矩陣劃分成小矩陣(block matrix),並對這些小矩陣分別進行運算,兩者皆是改善記憶體的局部性(locality),減少了記憶體讀取的延遲,提高了計算效率,在實際應用中,可以根據具體的硬體配置選擇合適的分割大小 k,以達到最佳效能。
注意書寫規範:
for (i0=i_start; i0<i_end; i0+=block_size) {
for (int j0=0; j0<n; j0+=block_size) {
for (int k0=0; k0<n; k0+=block_size) {
// cut into blocks size, then do block matrix multiplication.
for (int bi=0; bi<block_size; bi++) {
int i = i0+bi;
for (int bk=0; bk<block_size; bk++) {
int k = k0+bk;
float lik = inL[i*n + k];
for (int bj=0; bj<block_size; bj++) {
int j = j0+bj;
out[i*n + j] += lik * inR[k*n + j]; // outer function
}
}
}
}
}
}
Transformer 跟過去的模型差在哪裡,解決什麼問題
Transformer(Attention is all you need)模型在 2017 年引入,為了解決機器翻譯中的長期依賴性問題,透過避免遞迴操作並使用平行化計算的方式減少訓練時間,提升訓練效率。其主要創新點如下:
以下說明 Transformer 如何透過自注意力機制一步到位地取得全局關聯性。對於每個詞嵌入向量
其中
使用 Query 向量和 Key 向量計算 Attention scores:
計算得到的 Attention score 矩陣是一個
在一次自注意力操作中,所有詞之間的關聯性都被計算出來。這意味著每個詞都能夠看到整個序列中的其他詞,從而捕捉到全局的關聯性。這與 CNN 逐層擴展視野的方法不同,Transformer 能在一步操作中完成全局關聯性的捕捉,且其中的計算可以被平行化。
RNN(遞迴神經網路)和 LSTM(長短期記憶網路)主要特性如下:
已知 RNN 模型主要是以序列處理,下一個輸入會是上一個輸出的結果,因此 RNN 的計算受限於時間,無法進行平行化。雖然透過局部平行化可以改善單個 RNN 節點的速度,但無法提升整體訓練速度。即使後續的 LSTM 和 GRU 透過門控機制解決長期依賴問題,它們依然是逐步序列處理,無法在根本上達到平行化的效益。
在 RNN 中,模型透過反向傳播更新參數。此過程中,梯度會反覆疊加並反向傳遞。當序列較長時,反向傳播的過程涉及將梯度多次相乘,尤其當 RNN 的隱藏狀態更新公式為:
這個更新公式中的權重矩陣
LSTM (Long Short-Term Memory) 和 GRU (Gated Recurrent Unit) 等架構透過控制門 (Gate) 的設計來解決梯度問題。具體來說,控制門透過引入額外的機制,讓模型能夠選擇性地保留或丟棄資訊,控制梯度的傳遞並保護長期記憶。以下是控制門的關鍵作用:
輸入門 (Input Gate):控制哪些新資訊需要被加入到記憶單元。輸入門會根據當前的輸入和先前的隱藏狀態生成一個範圍在 0 到 1 的控制信號,決定要寫入記憶的程度。
遺忘門 (Forget Gate):控制哪些資訊需要從記憶單元中移除。遺忘門會根據過去的狀態與當前輸入判斷應該遺忘的記憶比例。這樣可以避免無用資訊的累積,減少梯度爆炸的風險。
輸出門 (Output Gate):控制記憶單元中哪些資訊需要影響當前輸出的隱藏狀態。輸出門根據過去的記憶單元和當前的輸入決定應輸出的資訊,使得模型保留重要資訊。
CNN(卷積神經網路)在 NLP 中也被廣泛應用,尤其適合短文本處理。其主要特性如下:
相較之下,CNN 的計算更容易平行化,因為卷積操作本質上可以平行處理。然而,CNN 也有其局限性。CNN 模型中兩個重要元件:卷積 (Convolution) 和池化 (Pooling),都可以獨立操作,因而可以平行化。但隨著序列長度的增加,CNN 的運算成本也隨之倍增,主要有以下幾個原因:
局部視野:卷積核一次只能看到固定大小的窗口(例如 3 個或 5 個詞),這意味著需要多層卷積層來擴大視野以捕捉更長距離的依賴性。
層數增加:隨著序列長度增加,需要更多的卷積層來捕捉長距依賴性,這會導致模型變得更深,從而增加計算量和訓練時間。以下是層數增多的例子:
要捕捉長度為 100 的序列的長距離依賴性,需要多層卷積:
參考:Why does the transformer do better than RNN and LSTM in long-range context dependencies?
找出其中效能瓶頸的地方,可能是矩陣運算,或是記憶體 (特別是 data model)
llama.cpp 在導入 llamafile sgemm (commit 8cc91dc) 之前,使用 perf 去觀察 cpu-cycles
,faults
,cache-references
,cache-misses
sudo perf record -e cpu-cycles,faults,cache-references,cache-misses ./llama-bench -m models/tinyllama-1.1b-chat-v1.0.Q4_0.gguf
sudo perf report
llamafile sgemm 之前,瓶頸出現在 ggml_vec_dot
Matrix vector multiplication is an operation where latency (not throughput) is the bottleneck, and the bloat of fancy libraries has a measurable impact.
LLaMA Now Goes Faster on CPUs
| model | size | params | backend | threads | test | t/s |
| ------------------------------ | ---------: | ---------: | ---------- | ---------: | ---------- | ---------------: |
| llama 1B Q4_0 | 606.53 MiB | 1.10 B | CPU | 6 | pp 512 | 88.24 ± 2.15 |
| llama 1B Q4_0 | 606.53 MiB | 1.10 B | CPU | 6 | tg 128 | 36.09 ± 0.18 |
build: dbceec87 (2684)
[ perf record: Woken up 525 times to write data ]
[ perf record: Captured and wrote 132.627 MB perf.data (2888859 samples) ]
# Samples: 1M of event 'cpu-cycles'
# Overhead Command Shared Object Symbol
72.52% llama-bench llama-bench [.] ggml_vec_dot_q4_0_q8_0
13.96% llama-bench llama-bench [.] ggml_graph_compute_thread
3.48% llama-bench llama-bench [.] ggml_vec_dot_f16
3.07% llama-bench llama-bench [.] ggml_compute_forward_mul_mat
2.26% llama-bench llama-bench [.] ggml_vec_dot_q6_K_q8_K
1.06% llama-bench llama-bench [.] ggml_compute_forward_soft_max
0.30% llama-bench llama-bench [.] ggml_compute_forward_mul
llamafile sgemmg 之後
| model | size | params | backend | threads | test | t/s |
| ------------------------------ | ---------: | ---------: | ---------- | ---------: | ---------- | ---------------: |
| llama 1B Q4_0 | 606.53 MiB | 1.10 B | CPU | 6 | pp 512 | 85.39 ± 0.59 |
| llama 1B Q4_0 | 606.53 MiB | 1.10 B | CPU | 6 | tg 128 | 34.81 ± 0.18 |
build: dbceec87 (2684)
[ perf record: Woken up 547 times to write data ]
[ perf record: Captured and wrote 137.806 MB perf.data (3001985 samples) ]
# Samples: 1M of event 'cpu-cycles'
# Overhead Command Shared Object Symbol
#
41.44% llama-bench llama-bench [.] (anonymous namespace)::tinyBLAS_Q0_AVX2<block_q4_0, block_q8_0, float>::gemm4x3(int, int, int, int)
31.11% llama-bench llama-bench [.] (anonymous namespace)::tinyBLAS_Q0_AVX2<block_q4_0, block_q8_0, float>::gemm4x1(int, int, int, int)
14.65% llama-bench llama-bench [.] ggml_graph_compute_thread
4.13% llama-bench llama-bench [.] (anonymous namespace)::tinyBLAS<8, float __vector(8), float __vector(8), unsigned short, float, float>::gemm3x4(int, int, int, int)
2.97% llama-bench llama-bench [.] ggml_vec_dot_q6_K_q8_K
1.40% llama-bench llama-bench [.] ggml_compute_forward_soft_max
0.38% llama-bench llama-bench [.] (anonymous namespace)::tinyBLAS<8, float __vector(8), float __vector(8), unsigned short, float, float>::gemm4x1(int, int, int, int)
0.37% llama-bench llama-bench [.] ggml_compute_forward_mul
LLM通常使用全精度(float32)或半精度(float16)浮點數進行訓練。因此,量化過程尋找一種方法將 FP32 權重值的範圍(資料類型為 [min, max])表示為較低精確度值,例如 FP16 甚至 INT4(整數 4 位元)資料類型。典型的情況是從 FP32 到 INT8。除了降低模型載入及儲存的空間,同時也因為精度下降,進而讓計算速度有所提升
然而精度的下降意味著模型預測的準確度會有所影響,可以藉由 Perplexity 指標確認量化模型後的品質
這表示模型在給定序列
在 llama.cpp 中有探討不同大小的語言模型量化後的評估 k-quants #1684
將浮點數
計算極值的映射關係
藉由極值
最後 zero point z
表示原點,且 scale s
表示縮放數值
透過極值 zero point
及 scale
實際使用中
量化矩陣相乘過程,先知道矩陣相乘
矩陣相乘數學表示式
依照上面量化公式
從量化後的
以下數值在模型推算時是常數,可以事先計算好
而其中
經常用於深度模型的 ReLU
用於表示量化 ReLU 且更通用的定義
數學公式推導量化 ReLU
input tensor
output tensor
input tensor
x,即 output tensor
y,並計算 input tensor
output tensor
的參數 output tensor
output tensor
y。output tensor
動態量化是在神經網路推理過程中使用,主要目的是使用整數操作來代替浮點數操作,以提高計算效率和降低資源消耗。在動態量化中,網路的權重在推理運行前被量化為整數,但網路並不知道 output tensor
activation tensors
。而在執行階段中,只要一旦取得浮點數,即可以動態地計算出
動態量化過程會直接算出
優點:動態量化不需要任何預先校準資料,且是使用整數運算,動態量化仍能通過減少浮點運算來提升性能。
缺點:動態量化在計算 scale 和 zero points 時需要更多的運算,這可能在某些情況下增加計算負擔。
靜態量化與動態量化的不同之處在於,靜態量化在推理前已經預先計算好所有 activation tensor 的 scales 和 zero points。因此,在推理期間不需要再計算這些參數。activation tensor 可以以整數格式儲存在記憶體中,而無需從浮點數格式轉換。
注意用語
計算所有 activation tensor 的 scales 和 zero points 的方法非常簡單。給定一個浮點數神經網路,我們只需使用一些代表性的無標籤資料運行該神經網路,收集所有 activation layer 的分布統計數據。然後,我們可以使用這些分布統計資料,按照前面的數學公式計算 scales 和 zero points。
在推理期間,由於所有計算都是使用整數運算,因此推理最快。唯一的缺點是我們必須準備代表性的無標籤資料。如果資料不具有代表性,計算出的 scales 和 zero points 可能無法反映到真實情況,從而影響推理準確性。
量化感知訓練在模型訓練期間進行模擬量化操作,使得模型在訓練時就能適應量化後的精度變化。
這種量化技術不僅提高了模型的推理速度,還使得儲存需求更低,同時確保精度上有較小的損失。
探討其代價。