# Linux 核心專題: llama.cpp 效能分析 > 執行人: vestata > [專題解說影片](https://youtu.be/WM6qmB4tQJs) ### Reviewed by `yu-hsiennn` > 整個程式運作都的計算熱點會是矩陣乘法 指的是 整個程式運作的計算熱點都會是矩陣乘法? 還有 `熱點` 指的是什麼? > [Wikipedia: hot spot](https://en.wikipedia.org/wiki/Hot_spot_(computer_programming)) 我翻譯為「熱點」,解釋為程式執行過程中大部分時間消耗的區域,〈[LLaMA Now Goes Faster on CPUs](https://justine.lol/matmul/)〉中使用的描述是 [Wikipedia: Bottleneck](https://en.wikipedia.org/wiki/Bottleneck_(software)) 但是我覺得在這邊使用熱點比較符合。 ### Reviewed by `yenslife` 既然知道 `((1 << 3) - 1)` 表示 7,那為什麼一開始不要直接寫 7 就好?我剛剛在猜會不會與操作成本有關,但看到之後開發者修改成 -8 就更不懂了。 > 使用 `((1 << 3) - 1)` 表示為 7 而不是直接使用 7 的具體原因...我還不知道,從第一個版本 [commit 26c0846](https://github.com/ggerganov/llama.cpp/commit/26c084662903ddaca19bef982831bfb0856e8257#diff-6d9ce99fcb6f51ff76f59e479f6e6fc0bb62edef7442805d7a5bb15b23996b5d) 便是使用 `((1 << 3) - 1)` 延用到現在,我認為是為了增加程式可解讀性。若有發現我會再更新。 > 可以參考我之後的在 [Quantization](https://hackmd.io/8VsaB9JESziJyp5lfmSvRQ#Quantization) 下面的補充,其中 $\Delta$ 是其步長,一般會定義為 $\Delta = 2^{(b-1)}-1$ ,在 `q4_0` 也就是 `((1 << 3) - 1)` ,但是因為使用二補數,所以 4 位元表示範圍應該是 -8~7。我們現在令 `n = 30000, max = 65504(FP16 表示的最大值)` 作為舉例,分別進行 -8 與 7 的量化與解量化為 > 7:((30000 / (65504 / 7) + 8.5) - 8) * (65504 / 7) = 34678.85714285714 > -8:((30000 / (65504 / -8) + 8.5) - 8) * (65504 / -8) = 25905.999999999996 誤差分別為 -4678.857 與 4094,使用 -8 有較小的誤差。 ### Reviewed by `jserv` 將 [matmul.c](https://github.com/salykova/matmul.c) 納入考量,這是針對現代 x86 處理器的 AVX 和 FMA 指令集擴展開發的快速矩陣乘法實作,程式碼精簡又性能表現優異。參照該專案的介紹文章 [Beating NumPy's matrix multiplication in 150 lines of C code](https://salykova.github.io/matmul-cpu)。 ## 任務簡介 探討 llama.cpp 的效能表現,分析其效能瓶頸並嘗試改進 ## TODO: 讀懂〈[LLaMA Now Goes Faster on CPUs](https://justine.lol/matmul/)〉並重現實驗 > 不同 quantization 所產生的精度差異,如何實驗它對模型的影響? :::danger 改進你的漢語表達。 ::: [llamafile](https://github.com/Mozilla-Ocho/llamafile) 是為了簡化使用 LLM 所生的專案。結合了 [llama.cpp](https://github.com/ggerganov/llama.cpp) 與 [Cosmopolitan Libc](https://github.com/jart/cosmopolitan) 使配置與運行壓縮到單一可執行檔中。 :::danger 注意用語: * file 是「檔案」,而非「文件」(document) * thread 是「執行緒」,而非「線程」 > 好的,收到 ::: ### Quantization 〈[LLaMA Now Goes Faster on CPUs](https://justine.lol/matmul/)〉這篇文章是中使用不同的量化手法進行效能的比較,主要針對 prompt (tok/sec) 即讀取 token 的速度,與 eval (tok/sec) 輸出的 token 速度進行比較。一般訓練好並開放的大型語言模型權重 `.pth` 是使用 BF16 的資料型態,在 [llama.cpp](https://github.com/ggerganov/llama.cpp) 需要將 `.pth` 權重轉成 `.gguf` 的資料型態。 `.gguf` 是 [llama.cpp](https://github.com/ggerganov/llama.cpp) 的作者所開發的資料格式,可以使用在其相關的專案中,如以下圖例: ![image](https://hackmd.io/_uploads/r1bLTzDSR.png) > https://huggingface.co/docs/hub/gguf :::info TODO gguf/tensor 的差異 補 gguf 的 `mmap()` ::: 依據〈[Quantizing deep convolutional networks for efficient inference: A whitepaper](https://arxiv.org/pdf/1806.08342)〉 這篇論文,可以將量化的分為兩種 Affine Quantizer 與 Symmetric Quantizer 兩種。 ##### Symmetric quantize \begin{equation} x_{\text{int}} = \text{round}\left(\frac{x}{\Delta}\right) \end{equation} \begin{equation} x_Q = \text{clamp}\left(-\frac{N_{\text{levels}}}{2}, \frac{N_{\text{levels}}}{2} - 1, x_{\text{int}}\right) \quad \text{if signed} \end{equation} \begin{equation} x_Q = \text{clamp}(0, N_{\text{levels}} - 1, x_{\text{int}}) \quad \text{if un-signed} \end{equation} 其中 \begin{equation*} \text{clamp}(a, b, x) = \begin{cases} a & \text{if } x \leq a \\ x & \text{if } a \leq x \leq b \\ b & \text{if } x \geq b \end{cases} \end{equation*} s 為scale factor,z 為 zero point 此方法會對應到等等的 `Q8_0` 手法。 ##### Affine Quantizer 量化: \begin{equation} x_{\text{int}} = \text{round}\left(\frac{x}{\Delta}\right) + z \end{equation} \begin{equation} x_Q = \text{clamp}(0, N_{\text{levels}} - 1, x_{\text{int}}) \end{equation} 解量化: $x_{float}=(x_Q-z)\Delta$ 此方法會對應到等等的 `Q4_0`、`Q4_1` 手法。 #### 量化手法介紹 手法通常分為 `Qn_0`,`Qn_1`,`Qn_k`。 - [ ] `Qn_0` 將權重縮小到 n 位元表示,一次進行一個 block,大小為 32,選擇每一 block 局部最大值進行縮放,區間為 0 到 最大值之間。 - [ ] `Qn_1` 將權重縮小到 n 位元表示,一次進行一個 block,大小為 32,選擇每一 block 局部最大值與最小值進行縮放,區間為 最小值 到 最大值 之間。 - [ ] `Qn_k` :::info TODO ::: `Q4_0` 會儲存在一個 `block_q4_0` 結構體中,其大小會是 32,其中 `d` 是他的縮放比例(scalar),`qs` 是一個 `uint8_t` 陣列用來儲存兩個 4 位元的,在量化的過程中,原本 BF16 的模型權重以 `block_q4_0` 為單位去做儲存: ```c #define QK4_0 32 typedef struct { ggml_half d; // delta is uint16 uint8_t qs[QK4_0 / 2]; // nibbles / quants } block_q4_0; ``` 量化函式 `quantize_row_q4_0_reference()` 是負責量化的程式,其中發現很有趣的地方。以下是量化函式中的細節 `const float d = max / -8;` 是一個在 [commit dd0eabc](https://github.com/ggerganov/llama.cpp/commit/dd0eabc049fb1efc631cab8eb0a646808d704e18) 的變更,將 `((1 << 3) - 1)` 改為 -8 因為這邊是為了將原本 FP16 量化到 4 個位元,所以 4 位元的二的補數表示範圍為 -8 到 7。為了善用更大的分母所以使用 -8,其所導致的整個存放在 `.gguf` 的權重變成負數,在 perplexity 的表現上勝於 `((1 << 3) - 1)` 的方法,見 [#729](https://github.com/ggerganov/llama.cpp/pull/729)。 而在後面要將其放進 4 位元之中,將原本權重參數乘上縮放比例 `id`,但是後面的順序的擺放是將 `x[i*qk + 0 + j]` 與 `x[i*qk + qk/2 + j]` 擺在一起。這是在 [commit b9fd7ee](https://github.com/ggerganov/llama.cpp/commit/b9fd7eee57df101d4a3e3eabc9fd6c2cb13c9ca1) 的變更。 ```diff y[i].d = d; - for (int l = 0; l < QK4_0; l += 2) { - const float v0 = x[i*QK4_0 + l + 0]*id; - const float v1 = x[i*QK4_0 + l + 1]*id; + for (int j = 0; j < qk/2; ++j) { + const float x0 = x[i*qk + 0 + j]*id; + const float x1 = x[i*qk + qk/2 + j]*id; - const uint8_t vi0 = MIN(15, (int8_t)roundf(v0) + 8); - const uint8_t vi1 = MIN(15, (int8_t)roundf(v1) + 8); + const uint8_t xi0 = MIN(15, (int8_t)(x0 + 8.5f)); + const uint8_t xi1 = MIN(15, (int8_t)(x1 + 8.5f)); - assert(vi0 < 16); - assert(vi1 < 16); - - pp[l/2] = vi0 | (vi1 << 4); + y[i].qs[j] = xi0; + y[i].qs[j] |= xi1 << 4; } - - memcpy(y[i].qs, pp, sizeof(pp)); } ``` 這個變更讓我困惑很久,但是這必須提到 `llama.cpp` 的開發規則,在此專案之中的定義的矩陣相乘是 $C^T=AB^T \Leftrightarrow C = BA^T$ ,這與傳統的矩陣相乘記憶體儲存手法不同。 ![image](https://hackmd.io/_uploads/ry1NAY_UC.png) > [README.md](https://github.com/ggerganov/llama.cpp/blob/master/README.md#coding-guidelines) 在這邊補充一下,一般我們在做矩陣相乘的時候 $C = AB$ 會遇到的狀態會是 $A,B$ 在記憶體的排列是為 row-major。當處理 $A$ 時,可以利用其連續記憶體的特性,減少到主記憶體的成本,這樣能夠利用 L1 cache。然而,$B$ 的矩陣乘法需要每一 column 的資料,這與 row-major 記憶體排列剛好相反,所以 $C = AB$ 這個乘法最大的效能瓶頸會是 $B$ 對主記憶體成本。有很多矩陣乘法手段可以更好的利用 L1 cache,一個簡單的方法是對 $B$ 做轉置,詳細可以參考我對[矩陣相乘的筆記](https://hackmd.io/@tony268596/Hy4EWvXM0)。 所以對於 `quantize_row_q4_0_reference()` 的變更就是為了符合在 $C = BA^T$ 的運算規則之下。對其中一個矩陣雖然因為 L1 cache 的優勢,但是以演算法的角度尚需要 $O(n^3)$ 的成本。因此,llama.cpp 原本的作法是將量化後的 `uint` 以相鄰的方式排列,但之後需要額外的轉置成本。改動後,量化時便將其以矩陣轉置 $A^T$ 的排序放進記憶體中,以減少後續的轉置成本。 ### Perplexity 經過量化數值的精確度一定會喪失,只是不同的量化手段會有不同精確度喪失,但是這精確度喪失會大型語言模型會有什麼影響呢?回到模型的根本,是使用模型權重去選擇生成下一個最有可能出現的 token,我們可以針對生出的 token 進行評估。在 llama.cpp 專案之中的方法是使用 perplexity。其定義如下: $$\text{PPL}(X) = \exp \left\{ -\frac{1}{t} \sum_{i}^t \log p_\theta (x_i \mid x_{<i}) \right\} $$ :::danger 闡述其特性、考量因素,以及對應的限制。 ::: 給定特定的文章,這邊使用的是 [WikiText-2](https://paperswithcode.com/dataset/wikitext-2) 在給定的文章中,模型使用 $X_{<i}$ 的已知 token 去推斷 $X_i$ 的 token,$t$ 表示為 token 數,所以 perplexity 越低,表示模型的預測結果與實際 token 序列越接近。 ![image](https://hackmd.io/_uploads/H1xvZ8nL0.png =70%x) 文章中使用不同的 quantization 手法,有 `Q4_0`,`Q8_0`,`FP16`。以 `Q4_0` 為例,以下是主要實作的程式: ### 重現實驗 使用的測試工具是 llama-bench,這是在專案之中被廣泛的使用的測試工具,分別會計算輸入 token(prompt) 與輸出 token(eval) 的時間。主要的評估函式是 `test_promt()` 與 `test_gen()` 。其中 `test_promt()` 是以 `rand()` 去生成隨機數字,計算模型在消化 n_prompt 的隨機數字需要多少時間。 ```c while (n_processed < n_prompt) { int n_tokens = std::min(n_prompt - n_processed, n_batch); tokens[0] = n_processed == 0 && llama_add_bos_token(model) ? llama_token_bos(model) : std::rand() % n_vocab; for (int i = 1; i < n_tokens; i++) { tokens[i] = std::rand() % n_vocab; } llama_decode(ctx, llama_batch_get_one(tokens.data(), n_tokens, n_past + n_processed, 0)); n_processed += n_tokens; } ``` 在 `test_gen()` 則是計算模型每次處理一個 `rand()` 隨機數,進行 n_gen 次所花費的時間。 ```c for (int i = 0; i < n_gen; i++) { llama_decode(ctx, llama_batch_get_one(&token, 1, n_past + i, 0)); token = std::rand() % n_vocab; } ``` 測試設備: ```bash Architecture: x86_64 CPU op-mode(s): 32-bit, 64-bit Address sizes: 46 bits physical, 48 bits virtual Byte Order: Little Endian CPU(s): 24 On-line CPU(s) list: 0-23 Vendor ID: GenuineIntel Model name: 13th Gen Intel(R) Core(TM) i7-13700 CPU family: 6 Model: 183 Thread(s) per core: 2 Core(s) per socket: 16 Socket(s): 1 Stepping: 1 CPU max MHz: 5200.0000 CPU min MHz: 800.0000 BogoMIPS: 4224.00 Virtualization features: Virtualization: VT-x Caches (sum of all): L1d: 640 KiB (16 instances) L1i: 768 KiB (16 instances) L2: 24 MiB (10 instances) L3: 30 MiB (1 instance) ``` 我這邊選擇兩個版本一個是 [commit dbceec8](https://github.com/ggerganov/llama.cpp/commit/dbceec87c0221ec952e69448df6a71f1372a7487) 使用舊版向量內積,與 [commit 74f33ad](https://github.com/ggerganov/llama.cpp/commit/74f33adf5f8b20b08fc5a6aa17ce081abe86ef2f),此版本是 2024-5-23 是我拿來做測試的版本,使用的是 sgemm 新的矩陣乘法模組(llamafile 的乘法模組)。我將以這兩個做實驗比較。在 x86 機器中使用 `make` 即可完成編譯,其中執行緒的數量皆是使用預設。 :::danger 補上對應的超連結。 > 已補上 ::: | prompt (tok/sec) | eval (tok/sec) | model | weights data type | hardware | software | | ---------------- | -------------- | -------------- | ----------------- | ----------- | ------------------- | | 255.90 ± 12.60 | 26.29 ± 0.56 | TinyLlama 1.1B | q8_0 | Raptor Lake | llama.cpp 2024-5-23 | | 129.67 ± 2.42 | 22.70 ± 0.61 | TinyLlama 1.1B | q8_0 | Raptor Lake | llama.cpp old | | 199.98 ± 33.37 | 14.96 ± 0.36 | TinyLlama 1.1B | FP16 | Raptor Lake | llama.cpp 2024-5-23 | | 48.99 ± 0.19 | 12.97 ± 0.27 | TinyLlama 1.1B | FP16 | Raptor Lake | llama.cpp old | 在 `TinyLlama 1.1B` 權重之下 `q8_0`,`FP16` 使用 llamafile 加速後,在 `prompt (tok/sec)` 上的性能分別提升了 197% 和 414%,有顯著的進步。,在 `eval (tok/sec)` 的表現上則相近。 | prompt (tok/sec) | eval (tok/sec) | model | weights data type | hardware | software | | ---------------- | -------------- | ---------- | ----------------- | ----------- | ------------------- | | 25.08 ± 1.10 | 2.05 ± 0.08 | mistral-7b | q8_0 | Raptor Lake | llama.cpp 2024-5-23 | | 19.40 ± 0.75 | 3.55 ± 0.02 | mistral-7b | q8_0 | Raptor Lake | llama.cpp old | | 41.51 ± 0.68 | 4.21 ± 0.05 | mistral-7b | FP16 | Raptor Lake | llama.cpp 2024-5-23 | | 6.86 ± 0.07 | 1.84 ± 0.05 | mistral-7b | FP16 | Raptor Lake | llama.cpp old | 在 `mistral-7b` 權重之下 `q4_0`,`q8_0`,`FP16` 使用 llamafile 加速後,在 `prompt (tok/sec)` 上的性能分別提升了 440%、%、413% 和 364%,有顯著的進步。,在 `eval (tok/sec)` 的表現上則相近。 :::danger 討論其精確度的犧牲狀況。 ::: <!-- #### llamafile | model_filename | model | size | params | backend | threads | test | t/s | | ---------------------------------------:| ------------- | ----------:| ------:| ------- | -------:| -----:| ------:| | TinyLlama-1.1B-Chat-v1.0.Q4_0.llamafile | llama 1B Q4_0 | 606.53 MiB | 1.10 B | CPU | 8 | pp512 | 397.62 | | TinyLlama-1.1B-Chat-v1.0.Q4_0.llamafile | llama 1B Q4_0 | 606.53 MiB | 1.10 B | CPU | 8 | tg16 | 53.83 | | TinyLlama-1.1B-Chat-v1.0.Q8_0.llamafile | llama 1B Q8_0 | 1.09 GiB | 1.10 B | CPU | 8 | pp512 | 327.43 | | TinyLlama-1.1B-Chat-v1.0.Q8_0.llamafile | llama 1B Q8_0 | 1.09 GiB | 1.10 B | CPU | 8 | tg16 | 30.37 | | TinyLlama-1.1B-Chat-v1.0.F16.llamafile | llama 1B F16 | 2.05 GiB | 1.10 B | CPU | 8 | pp512 | 317.98 | | TinyLlama-1.1B-Chat-v1.0.F16.llamafile | llama 1B F16 | 2.05 GiB | 1.10 B | CPU | 8 | tg16 | 16.65 | | model_filename | model | size | params | backend | threads | test | t/s | | ---------------------------------------:| ------------- | ---------:| ------:| ------- | -------:| -----:| -----:| | mistral-7b-instruct-v0.2.Q4_0.llamafile | llama 7B Q4_0 | 3.83 GiB | 7.24 B | CPU | 8 | pp512 | 44.02 | | mistral-7b-instruct-v0.2.Q4_0.llamafile | llama 7B Q4_0 | 3.83 GiB | 7.24 B | CPU | 8 | tg16 | 8.28 | | mistral-7b-instruct-v0.2.Q8_0.llamafile | llama 7B Q8_0 | 7.17 GiB | 7.24 B | CPU | 8 | pp512 | 42.52 | | mistral-7b-instruct-v0.2.Q8_0.llamafile | llama 7B Q8_0 | 7.17 GiB | 7.24 B | CPU | 8 | tg16 | 4.42 | | mistral-7b-instruct-v0.2.F16.llamafile | llama 7B F16 | 13.49 GiB | 7.24 B | CPU | 8 | pp512 | 33.71 | | mistral-7b-instruct-v0.2.F16.llamafile | llama 7B F16 | 13.49 GiB | 7.24 B | CPU | 8 | tg16 | 2.41 | --> <!-- 此外我還在 ARM 架構之下的 `Apple M1 Pro` 進行測試。 `Apple M1 Pro` | prompt (tok/sec) | eval (tok/sec) | model | weights data type | hardware | software | | ---------------- | -------------- | -------------- | ----------------- | ----------- | ------------------- | | 398 | 53 | TinyLlama 1.1B | q4_0 | M1 Pro | llamafile-0.8 | | 72 | 50 | TinyLlama 1.1B | q4_0 | M1 Pro | llama.cpp 2024-5-23 | | 327 | 30 | TinyLlama 1.1B | q8_0 | M1 Pro | llamafile-0.8 | | 72 | 30 | TinyLlama 1.1B | q8_0 | M1 Pro | llama.cpp 2024-5-23 | | 318 | 17 | TinyLlama 1.1B | FP16 | M1 Pro | llamafile-0.8 | | 72 | 16 | TinyLlama 1.1B | FP16 | M1 Pro | llama.cpp 2024-5-23 | #### llama.cpp | model | size | params | backend | ngl | test | t/s | | ------------- | ----------:| ------:| ------- | ---:| -----:| --------------:| | llama 1B Q4_0 | 606.53 MiB | 1.10 B | Metal | 99 | pp512 | 1678.39 ± 9.74 | | llama 1B Q4_0 | 606.53 MiB | 1.10 B | Metal | 99 | tg16 | 131.38 ± 12.51 | | llama 1B Q8_0 | 1.09 GiB | 1.10 B | Metal | 99 | pp512 | 1650.29 ± 6.80 | | llama 1B Q8_0 | 1.09 GiB | 1.10 B | Metal | 99 | tg16 | 102.91 ± 0.76 | | llama 1B F16 | 2.05 GiB | 1.10 B | Metal | 99 | pp512 | 1812.96 ± 4.95 | | llama 1B F16 | 2.05 GiB | 1.10 B | Metal | 99 | tg16 | 66.19 ± 0.22 | #### llamafile | model_filename | model | size | params | backend | threads | test | t/s | | ---------------------------------------:| ------------- | ----------:| ------:| ------- | -------:| -----:| ------:| | TinyLlama-1.1B-Chat-v1.0.Q4_0.llamafile | llama 1B Q4_0 | 606.53 MiB | 1.10 B | CPU | 8 | pp512 | 314.38 | | TinyLlama-1.1B-Chat-v1.0.Q4_0.llamafile | llama 1B Q4_0 | 606.53 MiB | 1.10 B | CPU | 8 | tg16 | 61.18 | | tinyLlama-1.1B-Chat-v1.0.Q8_0.llamafile | llama 1B Q8_0 | 1.09 GiB | 1.10 B | CPU | 8 | pp512 | 359.48 | | tinyLlama-1.1B-Chat-v1.0.Q8_0.llamafile | llama 1B Q8_0 | 1.09 GiB | 1.10 B | CPU | 8 | tg16 | 73.94 | | TinyLlama-1.1B-Chat-v1.0.F16.llamafile | llama 1B F16 | 2.05 GiB | 1.10 B | CPU | 8 | pp512 | 362.40 | | TinyLlama-1.1B-Chat-v1.0.F16.llamafile | llama 1B F16 | 2.05 GiB | 1.10 B | CPU | 8 | tg16 | 48.06 | --> ## TODO: 影響 LLaMA 推理速度的因素 > 找出其中效能瓶頸的地方,可能是矩陣運算,或是記憶體 (特別是 data model),予以量化,善用 perf 和 Ftrace 等工具 ### 矩陣相乘 使用 perf 依照 `cpu-cycles`,`faults`,`cache-references`,`cache-misses` 進行比較 ```bash $ 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 ``` 在 llama.cpp 併入 llamafile 的 sgemm 之前,也就是 [commit dbceec8](https://github.com/ggerganov/llama.cpp/commit/dbceec87c0221ec952e69448df6a71f1372a7487) 的 backend cpu 實作手法,整個程式運作都的計算熱點會是矩陣乘法: ``` # Samples: 1M of event 'cpu_core/cpu-cycles/' 53.96% ,llama-bench,llama-bench ,[.] ggml_graph_compute_thread 37.68% ,llama-bench,llama-bench ,[.] ggml_vec_dot_q4_0_q8_0 1.86% ,llama-bench,llama-bench ,[.] ggml_vec_dot_q6_K_q8_K 1.60% ,llama-bench,llama-bench ,[.] ggml_vec_dot_f16 0.66% ,llama-bench,llama-bench ,[.] ggml_compute_forward_mul_mat # Samples: 699K of event 'cpu_atom/cpu-cycles/' 78.01% ,llama-bench,llama-bench ,[.] ggml_vec_dot_q4_0_q8_0 6.88% ,llama-bench,llama-bench ,[.] ggml_vec_dot_f16 6.21% ,llama-bench,llama-bench ,[.] ggml_graph_compute_thread 2.67% ,llama-bench,llama-bench ,[.] ggml_vec_dot_q6_K_q8_K 0.95% ,llama-bench,llama-bench ,[.] ggml_compute_forward_mul_mat 0.74% ,llama-bench,llama-bench ,[.] ggml_compute_forward_soft_max ``` ### 矩陣計算底層程式 在 llama.cpp 中有 gg 作者所撰寫的手法是簡單的將矩陣轉為內積去做計算,由上述提到的 $C=BA^T$ 並且在量化的時候便已經將 $A$ 向量化排列在連續記憶體之中。有很多開源的 BLAS libraries 有很純熟的矩陣相乘手法,但是放在已經向量化的場景反而會降低速度,因為這些 BLAS libraries 有很多複雜的手段會增加許多延遲,是對應各層面的矩陣相乘場景,而 gg 作者的向量內積是為了這個專案特別設計的。 其中 `ggml_vec_dot_q4_0_q8_0()` 是實作整個向量內積的底層程式。但是細看程式碼,先以矩陣相乘的演算法的角度(SIMD 等技巧先忽略),我們可以發現這是一個 q4_0 乘上 q8_0 的混合精度乘法: ```c void ggml_vec_dot_q4_0_q8_0(int n, float * restrict s, size_t bs, const void * restrict vx, size_t bx, const void * restrict vy, size_t by, int nrc) { const int qk = QK8_0; const int nb = n / qk; const block_q4_0 * restrict x = vx; const block_q8_0 * restrict y = vy; // scalar float sumf = 0.0; for (int i = 0; i < nb; i++) { int sumi = 0; for (int j = 0; j < qk/2; ++j) { const int v0 = (x[i].qs[j] & 0x0F) - 8; const int v1 = (x[i].qs[j] >> 4) - 8; sumi += (v0 * y[i].qs[j]) + (v1 * y[i].qs[j + qk/2]); } sumf += sumi*GGML_FP16_TO_FP32(x[i].d)*GGML_FP16_TO_FP32(y[i].d); // multipyy the scalars } *s = sumf; } ``` 此類函式是整個專案中負責最重要的矩陣計算,所以 gg 作者希望可以將這段程式最佳化。它負責計算 `z = x * y`,其中 `x` 為量化完的數值,`y` 是 FP32,`z` 為 FP32。 這邊就令人好奇為什麼是 q4_0 * q8_0 的混合精度乘法呢?為什麼不是直覺的 q4_0 * q4_0 呢? 在一開始的確是使用 `ggml_vec_dot_q4_0()` 這個函式。 ```c typedef struct { ggml_half d; // delta uint8_t qs[QK4_0 / 2]; // nibbles / quants } block_q4_0; ``` ```c const block_q4_0 * restrict x = vx; const block_q4_0 * restrict y = vy; float sumf = 0.0; ... for (int i = 0; i < nb; i++) { const float d0 = x[i].d; const float d1 = y[i].d; const uint8_t * restrict p0 = x[i].qs; const uint8_t * restrict p1 = y[i].qs; int sumi = 0; for (int j = 0; j < QK/2; j++) { const uint8_t v0 = p0[j]; const uint8_t v1 = p1[j]; const int8_t i0 = (int8_t) (v0 & 0xf) - 8; const int8_t i1 = (int8_t) (v0 >> 4) - 8; const int8_t i2 = (int8_t) (v1 & 0xf) - 8; const int8_t i3 = (int8_t) (v1 >> 4) - 8; sumi += i0*i2 + i1*i3; } sumf += d0 * d1 * sumi; ``` 在 caller `ggml_compute_forward_mul_mat` 發現原先在計算 z = x * y 時,會先將 y (FP32) 做一次 q4_0 的量化。 ```c if (params->type == GGML_TASK_INIT) { char * wdata = params->wdata; const size_t row_size = ne10*GGML_TYPE_SIZE[type]/GGML_BLCK_SIZE[type]; for (int64_t i13 = 0; i13 < ne13; ++i13) { for (int64_t i12 = 0; i12 < ne12; ++i12) { for (int64_t i11 = 0; i11 < ne11; ++i11) { quantize_row_q((float *)((char *) src1->data + i13*nb13 + i12*nb12 + i11*nb11), (void *) wdata, ne10); wdata += row_size; } } } ``` 在這邊可以看到原本在計算 `y` 的步驟是: <!-- ```graphviz digraph S{ rankdir=LR node[shape=box] x[label="x\nQ4_0"] y[label="y\nFP32"] z[label="z\nFP32"] tmp[label="=", color=white] z->tmp->x->y[style=invis] subgraph{ subgraph { rank=same; y; tmp2 [label="y\nQ4_0"]; tmp3 [label="y\nint8_t"] } y->tmp2->tmp3 subgraph { x tmp4 [style=invis] mul [label="*"] } x->tmp4->mul } } ``` --> ``` z(FP32) = x(Q4_0) * y(FP32) ^ | | | | |------這個 Q4_0 量化過程會導致精度的損失 | | | | x(int8_t) y(Q4_0) | | | | | |------內積時還是要將 4 bit 擴充成 int8_t 做計算 | | | --------------- * ---------- y(int8_t) ``` 改為 ``` z(FP32) = x(Q4_0) * y(FP32) ^ | | | | |------ 改為 Q8_0 的量化 | | | | x(int8_t) y(Q8_0) | | | | | | | | | --------------- * ---------- y(int8_t) ``` 這個步驟在每一個向量相乘都會發生,且因為他是一個底層運算函式,不斷的做量化會不斷放大誤差,導致模型的 perplexity 居高不下。 >But at the end, avoiding quantization of intermediate results will always be more important than any improvements to model weight quantization. 這是在此專案場景矩陣相乘遇到的困難點,所以才衍生出之後的 `ggml_vec_dot_q4_0_q8_0()`,降低了量化過程中所帶來的精度喪失。在之後底層運算的實作皆是使用這個向量乘法演算法。 :::danger 建立數學模型,探討量化過程的精確度損失和補償方案。 數學分析! ::: ```c void ggml_vec_dot_q4_0_q8_0(int n, float * restrict s, size_t bs, const void * restrict vx, size_t bx, const void * restrict vy, size_t by, int nrc) { const int qk = QK8_0; const int nb = n / qk; assert(n % qk == 0); #if defined(__ARM_FEATURE_MATMUL_INT8) assert((nrc == 2) || (nrc == 1)); #else assert(nrc == 1); #endif UNUSED(nrc); UNUSED(bx); UNUSED(by); UNUSED(bs); const block_q4_0 * restrict x = vx; const block_q8_0 * restrict y = vy; // scalar float sumf = 0.0; for (int i = 0; i < nb; i++) { int sumi = 0; for (int j = 0; j < qk/2; ++j) { const int v0 = (x[i].qs[j] & 0x0F) - 8; const int v1 = (x[i].qs[j] >> 4) - 8; sumi += (v0 * y[i].qs[j]) + (v1 * y[i].qs[j + qk/2]); } sumf += sumi*GGML_FP16_TO_FP32(x[i].d)*GGML_FP16_TO_FP32(y[i].d); } *s = sumf; } ``` ### 矩陣計算 thread 執行緒的工作分為三種,`GGML_TASK_TYPE_INIT`,`GGML_TASK_TYPE_COMPUTE`,`GGML_TASK_TYPE_FINALIZE`,分別處理工作的初始化、矩陣的計算以及不同執行緒結果的收尾。 在這邊使用的同步機制是 spinlock,執行緒會上鎖並分頭計算不同區塊的矩陣。這邊並行程式是為了處理底層計算的部份,因此沒有使用 futexes 或是 semaphores,因為 Linux 核心的排程會降低計算效能。 ```c static void ggml_graph_compute_thread_sync_node(int * node_n, struct ggml_compute_state * state, const bool do_yield) { // wait for other threads to finish const int last_node_n = * node_n; while (true) { if (do_yield) { sched_yield(); } * node_n = atomic_load(&state->shared->node_n); if (* node_n != last_node_n) break; } } ``` 首先紀錄 node_n 編號。這段程式會一直查看佇列中是否有新的工作,使用 atomic_load 查看若是 node_n 值有所不同,表示其他執行緒已更新了節點,可以開始處理其他節點了。 如果設定 `do_yeild` 為真它會放棄目前 CPU 然後移到排程佇列最末端,讓其他執行緒優先執行。 :::warning 搭配 taskset 觀察多執行緒程式的執行。 ::: #### `ggml_graph_compute_thread()` 此函式負責的處理並行矩陣計算所要的執行緒所使用的所有動作,逐一解釋: 翻閱 `atomic_fetch_sub()` man page 說明如下: >The atomic_fetch_sub() macro subtracts the value operand from atomic variable object and returns the original contents of the atomic variable. ```c if (atomic_fetch_sub(&state->shared->n_active, 1) == 1) ``` 所以如果 `atomic_fetch_sub()` 回傳是 1 的話,一方面執行 n_active 執行緒數減一,一方面表示這是矩陣乘法多執行緒中的最後一個執行緒,其他工作的執行緒已完成,最後一個執行緒會有一個收尾的動作。以下這一段是處理收尾的主要程式: ```c if (node_n != -1) { /* FINALIZE */ struct ggml_tensor * node = cgraph->nodes[node_n]; if (GGML_OP_HAS_FINALIZE[node->op]) { params.nth = ggml_get_n_tasks(node, n_threads, state->shared->n_threads); ggml_compute_forward(&params, node); } ggml_graph_compute_perf_stats_node(node, state->shared); } ``` `node_n` 初始化為 -1。調用 `ggml_get_n_tasks()` (存放所有動作會需要多少執行緒)來獲取現在的動作會需要多少執行緒。並使用 `ggml_compute_forward()` 完成最後的計算。 ## TODO: 探討 llamafile 相較於 llama.cpp 進行哪些調整,得以加速? > 重現實驗並分析 sgemm.cpp 是 llamafile 的矩陣計算模組,大幅提昇 prompt evaluation 的速度,這個矩陣計算模組也併入 llam.cpp 之中,是目前 llam.cpp 的預設計算模組。 在我使用原本 `vec_dot()` 版本進行效能測試時它預設開啟的執行緒數量是 12,但是當我到 [commit 8cc91dc](https://github.com/ggerganov/llama.cpp/commit/8cc91dc63c0df397d644a581b2cbeea74eb51ae0) 測試發現這邊預設的執行緒是 8。後來發現這邊也有緣由。在原本的版本使用 `get_num_physical_cores()` 去判斷現在的設備有多少邏輯核心,如果邏輯核心數超過 4 個便會使用 `邏輯核心數 / 2` 個執行緒。 以下是我的設備為例有 24 核,所以在原本向量矩陣相乘的方法將 12 配置給執行中的程式。 而 sgemm.cpp 改成 8 是因為 `13th Gen Intel(R) Core(TM) i7-13700` 其中 24 個核心包含 8 個雙執行緒的效能核心與 8 個單執行緒的效率核心,希望避免將大量的矩陣計算移到效率核心去做計算。 [執行緒選取的更動](https://github.com/ggerganov/llama.cpp/commit/8cc91dc63c0df397d644a581b2cbeea74eb51ae0#diff-201cbc8fd17750764ed4a0862232e81503550c201995e16dc2e2766754eaa57a) 這邊有對核心的選取,我對這邊核心相關的程式細節不了解(`cpuid()`, `is_hybrid_cpu()`, `is_running_on_efficiency_core()`),希望知道人可以補充。這邊使用 `pthread_setaffinity_np()` 將設定在效能核心上。 ```c int get_math_cpu_count() { #if defined(__x86_64__) && defined(__linux__) int cpu_count = sysconf(_SC_NPROCESSORS_ONLN); if (cpu_count < 1) { return get_num_physical_cores(); } if (is_hybrid_cpu()) { cpu_set_t affinity; if (!pthread_getaffinity_np(pthread_self(), sizeof(affinity), &affinity)) { int result = count_math_cpus(cpu_count); pthread_setaffinity_np(pthread_self(), sizeof(affinity), &affinity); if (result > 0) { return result; } } } #endif return get_num_physical_cores(); } ``` > On success, these functions return 0; on error, they return a nonzero error number. > [name=man page] ### 矩陣相乘 llama.cpp 支援許多種 backend,也就是多種矩陣相乘的計算模組,原有支援 `CPU`,`OpenCL`,`Vulkan`,`Kompute`,`Metal`,`GPU BLAS`,`BLAS`。在 [commit 8cc91dc](https://github.com/ggerganov/llama.cpp/commit/8cc91dc63c0df397d644a581b2cbeea74eb51ae0) 加入 sgemm.cpp 這個乘法模組。文章中有提到其在 `Q8` 與 `FP16` 有最好的效果,那可以從其計算熱點觀察。 ```c 66.22% llama-bench llama-bench [.] void (anonymous namespace)::tinyBLAS_Q0_AVX<block_q8_0, block_q8_0, float>::gemm<4, 1>(long, long, long, long) ◆ 20.80% llama-bench llama-bench [.] void (anonymous namespace)::tinyBLAS_Q0_AVX<block_q8_0, block_q8_0, float>::gemm<4, 2>(long, long, long, long) 6.46% llama-bench llama-bench [.] ggml_graph_compute_thread ▒ 1.43% llama-bench llama-bench [.] void (anonymous namespace)::tinyBLAS<8, float __vector(8), float __vector(8), unsigned short, float, float>::gemm<4, 3>(long, long, long, long) ▒ 1.29% llama-bench llama-bench [.] ggml_vec_dot_f16 ▒ 0.56% llama-bench llama-bench [.] ggml_compute_forward_soft_max ▒ 0.56% llama-bench llama-bench [.] ggml_compute_forward_mul_mat ``` gg 作者的 `vec_dot()` 向量化方法目前最佳化 token 生成的速度,但是在 llam.cpp 中還是遇到瓶頸,那便是消化輸入的 token 的速度。畢竟在要總結大量文字或是在 LLaVa 消化圖片時還是需要依靠一個完整的矩陣相乘。這也是為什麼現在依舊有許多外部 BLAS Library。 可以觀察到這邊運用到很多像是之前的 `vec_dot_q8_0_q8_0()` 的函式,其實兩者的實作方式有些許相似,以下解釋 sgemm 是怎麼運作的。 ### sgemm.cpp 這個矩陣模組的核心是 `mnpack()` 這一個函式,其作用是將矩陣分解成更小的塊。依據這些分解的小區塊再進入 `gemm` 去運算。 ```cpp class tinyBLAS { public: tinyBLAS(int k, const TA *A, int lda, const TB *B, int ldb, TC *C, int ldc, int ith, int nth) : A(A), B(B), C(C), k(k), lda(lda), ldb(ldb), ldc(ldc), ith(ith), nth(nth) { } void matmul(int m, int n, int task) { if (task == GGML_TASK_TYPE_COMPUTE) mnpack(0, m, 0, n); } private: NOINLINE void mnpack(int m0, int m, int n0, int n) { int mc, nc, mp, np; if (m - m0 <= 0 || n - n0 <= 0) return; if (VECTOR_REGISTERS >= 32 && n - n0 >= 5 && m - m0 >= 5) { mc = 5; nc = 5; gemm5x5(m0, m, n0, n); } else if (n - n0 >= 4 && m - m0 >= 3) { mc = 3; nc = 4; gemm3x4(m0, m, n0, n); } else if (n - n0 >= 4) { mc = 1; nc = 4; gemm1x4(m0, m, n0, n); } else if (m - m0 >= 4) { mc = 4; nc = 1; gemm4x1(m0, m, n0, n); } else { mc = 1; nc = 1; gemm1x1(m0, m, n0, n); } mp = m0 + (m - m0) / mc * mc; np = n0 + (n - n0) / nc * nc; mnpack(mp, m, n0, np); mnpack(m0, mp, np, n); mnpack(mp, m, np, n); } ``` 這邊 `m` 代表行數 `n` 代表列數。在 `mnpack()` 之中會使用遞迴方式分配每一個矩陣計算到它相對應 `llmmnxn` 去做計算。(以上的程式是第一次合併進 llama.cpp 的版本 [commit 8cc91dc](https://github.com/ggerganov/llama.cpp/commit/8cc91dc63c0df397d644a581b2cbeea74eb51ae0) 中的版本,後續不斷有新的 `llmmnxn` 不斷更新)。 那這樣的分配的確增加原本一個一個 `block_qn_0` 去做計算更快。它做的更動還有加入更多的 SIMD 指令,若是以演算法的角度去看 `vec_dot()` 也是基本的 $O(n^3)$ 做計算: ```c template <typename T> void LLMM(int m, int n, int k, const T *A, int lda, const T *B, int ldb, T *C, int ldc) { for (int i = 0; i < m; ++i) for (int j = 0; j < n; ++j) { T d = 0; for (int l = 0; l < k; ++l) d += A[lda * i + l] * B[ldb * j + l]; C[ldc * j + i] = d; } } ``` 在 llam.cpp 中使用的技巧是將最裡面的迴圈使用 SIMD 指令展開。 ```c for (int i = 0; i < m; ++i) for (int j = 0; j < n; ++j) { __m256 c0 = _mm256_setzero_ps(); __m256 c1 = _mm256_setzero_ps(); for (int l = 0; l < k; l += 16) { c0 = _mm256_fmadd_ps(_mm256_loadu_ps(A + lda * i + l + 0), _mm256_loadu_ps(B + ldb * j + l + 0), c0); c1 = _mm256_fmadd_ps(_mm256_loadu_ps(A + lda * i + l + 8), _mm256_loadu_ps(B + ldb * j + l + 8), c1); } C[ldc * j + i] = hsum(c0) + hsum(c1); } ``` 但實際上效能提升有限,因為編譯器在最佳化時,即使原本沒有 SIMD 的程式,也會做類似的處理。而在 sgemm.cpp 中,中間層的迴圈使用 SIMD 展開。以 `gemm1x4` 為範例,直接將三層迴圈中的中下兩層直接使用 SIMD 展開: ```c NOINLINE void gemm1x4(int m0, int m, int n0, int n) { BEGIN_KERNEL(1, 4) __m256 c0 = _mm256_setzero_ps(); __m256 c1 = _mm256_setzero_ps(); __m256 c2 = _mm256_setzero_ps(); __m256 c3 = _mm256_setzero_ps(); const TB *Bp0 = B + ldb * (j + 0); const TB *Bp1 = B + ldb * (j + 1); const TB *Bp2 = B + ldb * (j + 2); const TB *Bp3 = B + ldb * (j + 3); const TA *Ap = A + lda * i; for (int l = 0; l < k; ++l) { float da0 = unhalf(Ap[l].d); __m256i f = load(Ap + l); __m256i u = _mm256_sign_epi8(f, f); // make all the number >=0 __m256 d0 = _mm256_set1_ps(unhalf(Bp0[l].d) * da0); // b.scalar * a.scalar __m256 d1 = _mm256_set1_ps(unhalf(Bp1[l].d) * da0); // make it a vector __m256 d2 = _mm256_set1_ps(unhalf(Bp2[l].d) * da0); __m256 d3 = _mm256_set1_ps(unhalf(Bp3[l].d) * da0); __m256 g0 = updot(u, _mm256_sign_epi8(load(Bp0 + l), f)); __m256 g1 = updot(u, _mm256_sign_epi8(load(Bp1 + l), f)); __m256 g2 = updot(u, _mm256_sign_epi8(load(Bp2 + l), f)); __m256 g3 = updot(u, _mm256_sign_epi8(load(Bp3 + l), f)); c0 = madd(d0, g0, c0); c1 = madd(d1, g1, c1); c2 = madd(d2, g2, c2); c3 = madd(d3, g3, c3); } C[ldc * (j + 0) + i] = hsum(c0); C[ldc * (j + 1) + i] = hsum(c1); C[ldc * (j + 2) + i] = hsum(c2); C[ldc * (j + 3) + i] = hsum(c3); END_KERNEL() } ``` 假設在 `Q8_0` 中,以 usigned int8 儲存,一次計算 256 個位元,會處理 256 / 8 = 32 筆資料,而 gemm1x4 就會處理 32 * 128 大小的矩陣。以此類推,尚有 `gemm4x3()`,`gemm4x1()`,`gemm1x4()`,`gemm1x1()`。可以說 sgemm 便是將大矩陣不斷分割成小矩陣進行 SIMD 並行指令計算。這也是得益於大型語言模型穩定的矩陣大小,讓針對性的此專案的矩陣相乘得以發展。 ## TODO: 選定 data type 並聚焦在 matmul 實作 > w/ SIMD, OpenMP, oneMKL, OpenBLAS, …) 的效益 => 我們需要有個 wrapper/tester,得以涵蓋現有實作並整合進 llama.cpp,在 Linux 平台驗證,perf (搭配第六次作業提到的效能分析方式,排除系統干擾) ### 實驗暫存 `make LLAMA_NO_ACCELERATE=1` tiny-llama q4_0 | model | size | params | backend | threads | test | t/s | | ------------- | ----------:| ------:| ------- | -------:| -----:| --------------:| | llama 1B Q4_0 | 606.53 MiB | 1.10 B | CPU | 8 | pp512 | 235.26 ± 13.62 | | llama 1B Q4_0 | 606.53 MiB | 1.10 B | CPU | 8 | tg16 | 43.79 ± 4.99 | ### SIMD ### OpenMP OpenMP 在 llama.cpp 的實作在於矩陣相乘 backend 使用 [BLIS](https://github.com/flame/blis) 這一個專案中 在編譯 BLIS 的時候使用 `make LLAMA_BLIS=1` 可以順利編譯,但是使用 `./llama-bench` 出現 `./llama-bench: error while loading shared libraries: libblis.so.4: cannot open shared object file: No such file or directory` ### oneMKL ### OpenBLAS `make LLAMA_OPENBLAS=1` tiny-llama q4_0 | model | size | params | backend | threads | test | t/s | | ------------- | ----------:| ------:| ------- | -------:| -----:| ------------:| | tinyllama 1B Q4_0 | 606.53 MiB | 1.10 B | BLAS | 8 | pp512 | 72.94 ± 0.08 | | tinyllama 1B Q4_0 | 606.53 MiB | 1.10 B | BLAS | 8 | tg16 | 50.04 ± 0.89 | ### CUDA 這邊順便附上在 NVIDIA GeForce GTX 1070 的實驗,使用 `make LLAMA_CUDA=1` 去編譯,會出現 error,跟著他的步驟去 Nvidia 官往查詢顯示卡的計算能力 https://developer.nvidia.com/cuda-gpus ,這邊看到 GTX 1070 的計算能力為 6.1,便使用命令 `export CUDA_DOCKER_ARCH=compute_61`,即可完成編譯。 `ggml_cuda_init: GGML_CUDA_FORCE_MMQ: no ggml_cuda_init: CUDA_USE_TENSOR_CORES: yes ggml_cuda_init: found 1 CUDA devices: Device 0: NVIDIA GeForce GTX 1070, compute capability 6.1, VMM: yes` | model | size | params | backend | test | t/s | | ------------- | ----------:| ------:| ------- | -----:| --------------:| | tinyllama 1B Q4_0 | 606.53 MiB | 1.10 B | CUDA | pp512 | 505.89 ± 77.20 | | tinyllama 1B Q4_0 | 606.53 MiB | 1.10 B | CUDA | tg16 | 24.76 ± 0.33 | ## TODO: 提出更快的 matmul > 搭配作業系統核心層級的調整,讓運算能佔用更多 CPU 資源 ## 數學分析 探討量化過程的精確度損失 令 n 為原始數值,max 為該區塊的最大值 以`q4_0` 為例: 整個量化與解量化所產生的誤差為: $n-((n/\frac{max}{-8}+8.5)-8)*\frac{max}{-8}$ $=n-(\frac{-8}{max}+8.5-8)*\frac{max}{-8}$ $=n-(\frac{-8}{max}+0.5)*\frac{max}{-8}$ $=n-(n+\frac{max}{-8})$ $=\frac{max}{-8}$ 在 `q4_0 * q4_0` 時 令前項為 $n_1$、block 中的最大值為 $max_1$,後項為 $n_2$、最大值為 $max_2$ 其相乘所帶來的誤差為: $n_1*n_2-((n_1/\frac{max_1}{-8}+8.5)-8)*((n_2/\frac{max_2}{-8}+8.5)-8)*(\frac{max_1}{-8}*\frac{max_2}{-8})$ $=n_1*n_2-(\frac{-8n_1}{max_1}+0.5)*(\frac{-8n_2}{max_2}+0.5)*(\frac{max_1}{-8}*\frac{max_2}{-8})$ $=n_1*n_2-(\frac{64n_1n_2}{max1max2}+\frac{-4n_1}{max_1}+\frac{-4n_2}{max_2}+0.25)*(\frac{max_1}{-8}*\frac{max_2}{-8})$ $=n_1*n_2-n_1*n_2+\frac{max_2*n_1}{16}+\frac{max_1*n_2}{16}+\frac{max_1*max_2}{64}$ $=\frac{max_1*n_2}{16}+\frac{max_2*n_1}{16}+\frac{max_1*max_2}{64}$ 在 `q4_0 * q8_0` 時,誤差為: 令前項為 $n_1$、block 中的最大值為 $max_1$,後項為 $n_2$、最大值為 $max_2$ $n_1*n_2-((n_1/\frac{max_1}{-8}+8.5)-8)(round(n_2/\frac{max}{127}))*(\frac{max_1}{-8}*\frac{max_2}{127})$ $=n_1*n_2-(\frac{-8n_1}{max_1}+0.5)*(round(\frac{127n_2}{max_2}))*(\frac{max_1}{-8}*\frac{max_2}{127})$ $=n_1*n_2-(\frac{-8n_1}{max_1}+0.5)*(\lfloor\frac{127n_2}{max_2}+0.5\rfloor)*(\frac{max_1}{-8}*\frac{max_2}{127})$ $=n_1*n_2-(\frac{max_1}{-8}*(\frac{-8n_1}{max_1}+0.5))*(\frac{max_2}{127}*\lfloor\frac{127n_2}{max_2}+0.5\rfloor)$ $=n_1*n_2-(n_1+\frac{max_1}{-16})*(\frac{max_2}{127}*(\frac{127n_2}{max_2}\pm1))$ 使用 $\pm1$ 表示 $n_2>0,n_2<0$ 的最大修正 $=n_1*n_2-(n_1+\frac{max_1}{-16})*((n_2\pm\frac{max_2}{127}))$ $=n_1*n_2-n_1*n_2\pm\frac{max_2*n_1}{127}-\frac{max_1*n_2}{16}\pm\frac{max_1*max_2}{-2032}$ $=\pm\frac{max_2*n_1}{127}-\frac{max_1*n_2}{16}\pm\frac{max_1*max_2}{-2032}$ 使用代數 $k\leq\frac{127n_2}{max_2}+0.5<k+1$ ? 為何 `q8_0` 做量化使用 Symmetric quantize,而不是跟 `q4_0` 一樣使用 Affine Quantizer? ## 參考 [#909](https://github.com/ggerganov/llama.cpp/issues/909) [#896](https://github.com/ggerganov/llama.cpp/pull/896)