貢獻者: jserv, TibaChang, Tcc0403
本文探討最大公因數 (Greatest Common Divisor, GCD) 特性,考慮微處理器架構實作帶來的效能衝擊,並探討如何運用 Linux 核心提供的 perf 工具進行效能分析並改進。
Greatest Common Divisor 中譯「最大公因數」,簡稱 GCD。
給定整數 \(a, b\),二者各自有自己的因數[1],取相同的因數中最大的數,即最大公因數 \(\gcd(a, b)\)
\(\%\) 是取餘數運算,表示存在 \(k\) 滿足 \(0 \leq b\% a = b - k\cdot a < a\)
歐幾里得法,也就是「輾轉相除法」
給定整數 \(a, b\) 求出 \(\gcd(a, b)\)
令 \(a_0=a, b_0=b\),根據上述 GCD 性質:
\[
\begin{split}
\gcd(a_0, b_0) &= \gcd(b_0 \% a_0 = \underline{b_1}, a_0) \\
\gcd(\underline{b_1}, a_0) &= \gcd(a_0 \% b_1 = \underline{a_1}, b_1) \\
\gcd(\underline{a_1}, b_1) &= \gcd(b_1 \% a_1 = b_2, a_1) \\
&\vdots \\
\gcd(a_i, b_i) &= \gcd(b_i \% a_i = a_{i+1}, b_i) \\
&\vdots \\
\gcd(\mathbf{0}, b_n) &= |b_n|
\end{split}
\]
展示動畫
綜合上面過程,可得程式碼實作:
對於所有整數 \(a, b\),存在整數 \(x, y\) 使得 \(ax + by = \gcd(a, b)\)
給定整數 \(a, b\) 求出整數 \(x, y\) 滿足 \(ax + by = \gcd(a, b)\)
令 \(g = \gcd(a, b)\),配合上述 Bezout's Theorem 可得:
\[
0\cdot x_n + g\cdot y_n = g
\]
此處 \(x_n \in \mathbb{Z}, y_n = 1\)
即 \(x_n\) 為任意整數
而根據 GCD 性質 \(\gcd(a_i, b_i) = \gcd(b_i\%a_i, a_i)\)
以及 \(b_i \% a_i = b_i - \lfloor{b_i\over a_i}\rfloor \cdot a_i\)
得到
\[
\begin{split}
g &= x_j\cdot (b_i \% a_i) &+ y_j &\cdot a_i \\
&= x_j\cdot (b_i - \lfloor{b_i\over a_i}\rfloor \cdot a_i) &+ y_j &\cdot a_i \\
&= x_j \cdot b_i &+ (y_j - \lfloor{b_i\over a_i}\rfloor \cdot x_j) &\cdot a_i
\end{split}
\]
也就是說
\[
g = a_i \cdot x_{j-1} + b_i \cdot y_{j-1} \text{ for }\begin{cases}
x_{j-1} = y_j - \lfloor{b_i\over a_i}\rfloor \cdot x_j \\
y_{j-1} = x_j
\end{cases}
\]
綜合上述過程:
延伸閱讀:
回顧歐幾里得法:
示範 GCD(48, 40)
的計算過程
用 C 語言實現歐幾里得法: (v1)
以 Intel C compiler 產生的機械碼來說: (Pentium 4)
資料來源: The Software Optimization Cookbook: High Performance Recipes for IA-32 Platforms, 2nd Edition
針對 2004 年的個人電腦
指令 | 數量 | 延遲 | cycle count |
---|---|---|---|
減法 | 5 | 1 | 5 |
比較 | 5 | 1 | 5 |
分支 | 14 | 1 | 14 |
其他 | 0 | 1 | 0 |
24 |
改成 mod 操作的 C 語言程式如下: (v2)
以 Intel C compiler 產生的機械碼來說:
指令 | 數量 | 延遲 | cycle count |
---|---|---|---|
mod (整數除法) | 2 | 68 | 136 |
比較 | 3 | 1 | 3 |
分支 | 3 | 1 | 3 |
其他 | 6 | 1 | 6 |
148 |
若可排除整數除法和減法的負擔,得到以下實作: (v3)
可得到更快的實作:
上述都是針對 2004 年的個人電腦所得的實驗結果。
採用組合語言撰寫 (GCD 的部份),由於 Cortex-A8 不支援 idiv (要在 ARM Cortex-A15 或 A7 以上才有),因此改用以 Arm 組合語言實作
Assembly
Euclidean(thumb) V.S. mod(thumb) V.S. mod_minus(thumb)
不符合預期的原因推估
Euclidean(arm) V.S. mod(arm) V.S. mod_minus(arm)
比較
binary GCD 的精神就是當兩數為偶數時,必有一 \(2\) 因子。
\[\text{gcd}(x, y) = 2·\text{gcd}(\frac{x}{2}, \frac{y}{2})\]
且一數為奇數另一數為偶數,則無 \(2\) 因子。
\[
\text{gcd}(x, y) = \text{gcd}(x, \frac{y}{2})\]
於是我們可以改良為:
\[
\text{even_factor} = \text{min}(\text{ctz}(x), \text{ctz}(y)) \\
\text{gcd}(x, y) = \text{even_factor}·\text{gcd}((x\gg \text{even_factor}), (y\gg \text{even_factor}))
\]
其中符號 \(\gg\) 是 right shift。
剩下的過程就直接採用 Euclidean algorithm 的作法即可。
注意,僅在微處理器具備有效的 ctz
指令時,Binary GCD 才會展現相較於 Euclidean 的優勢,這也是為何 Linux 核心提供二種 gcd 實作。
延伸閱讀:
it requires GCC or Clang, a 64-bit x86 architecture, and a target CPU that supports the BMI2 opcodes (i.e. Haswell or later, in the line of Intel CPU).
考慮以下實作:
GCD 演算法
以下分段說明:
\(\to\) 對應至 GCD 演算法第 2 點
若 u 和 v 中其中一數為 0 ,則直接回傳另一數
x | 0
會得到 x
\(\to\) 對應至 GCD 演算法第 3 點
先取出u
和 v
之間屬於 \(2^n\) 的公因數,shift
即為最大的 \(n\)
\(\to\) 對應至 GCD 演算法第4 點
當 u
為偶數時,可以除以 \(2\)
\(\to\) 其中 while(!(v &1)) v /= 2;
對應至 GCD 演算法第 5 點
可以透過迴圈不變性 (loop invariant) 來幫助解讀程式
u
為被除數v
為除數當除數 v
為 0 時結束迴圈,被除數 u
為進入迴圈前 u
和 v
的最大公因數
事實上 u
在迴圈前不一定是被除數,因為先前透過 GCD 演算法第 4 點去除不必要的計算
最後回傳最大公因數時,乘上原先取出屬於 \(2^n\) 的公因數,可以透過左移達成
__builtin_ctz
改寫程式已知除法運算會耗費較多時間,我們可以利用 __builtin_ctz
(編譯器會產生對應到現代微處理器的專門指令) 搭配上述針對二進位特性調整的演算法,減少除法的使用
將一百萬組由 rand
函式生成的亂數傳入 gcd64
函式執行,並藉由 Linux 核心提供的 perf 工具來分析:
由上圖可以發現,花費 CPU 週期數最多的段落為迴圈當中的 v /= 2
運算,佔據約 28%
以下為改寫過後的程式
透過 perf 分析前後差異
將同樣一百萬筆由 rand
函式所產生的亂數傳入 gcd64
和 gcd64_ctz
函式比較,發現改寫過後的版本花費時間幾乎是原來的一半。
透過 perf 分析改寫程式
佔據花費時間最多的為分支指令 (如 jne),其次是迴圈內部的減法運算
整數 \(n\) 的因數為可整除 \(n\) 的數 ↩︎