Linux 核心原始程式碼蘊含多項巧思,本文討論其針對整數除法的實作手法。
在數學和電腦科學中,取整函數的作用將實數映射到相近整數,常見手法有「下取整數」(floor)和「上取整數」(ceiling)。其中,上取整函數即為「取頂符號」,在數學記作 ,在電腦科學記作 ceil(x)
,表示不小於 的整數中最小的數值。例如:。
為了避免後續討論出現歧意,我們先回顧 C 語言在除法運算子 /
對整數型態的數值運算定義。C99 規格書中 6.5.5 Multiplicative operators 第 6 點指出 C 語言中除法 /
運算子,在兩整數相除後,其小數部分是直接捨去。這個做法也被稱為截斷至 (truncation toward zero)。例如: 與 。
When integers are divided, the result of the / operator is the algebraic quotient with any fractional part discarded.87)
(87) This is often called "truncation toward zero"
為了避免後續討論混淆一般數學中的除法 (結果可能有餘數) 與該 C 語言除法運算子作用 (結果為整數),在本文強調以下用語:
/
運算子計算則二個整數 和 用文字表示數學除法或是除法運算子作用寫為:
數學除法 | 除法運算子 |
---|---|
除以 | 整數除以 |
除 | 整數除 |
此處整數除並非整除,使用「整數除」一詞在此僅用於明確表示與數學除法的差異,並非用於解釋兩數其一為另一數的因數。
給定二個整數 和 ,如何將 除以 後進行向上取整呢?直觀的作法如下:
但在 Linux 核心原始程式碼中,相同目的的程式碼會用以下形式的巨集呈現: (檔案: include/linux/math.h 和 include/uapi/linux/const.h)
該巨集的使用前提是, 和 都是正整數,否則會失敗。該手法結合除法和向上取整運算的數學原理與 C 語言整數除法的處理邏輯。
接下來解析該操作:對一個數值進行上取整會得到什麼?當數字有非零的小數部分時,會得到下一個整數;否則,得到相同的數字。將 除以 會得到一個非零小數結果,當且僅當被除數 不是除數 的整數倍;換言之,餘數不為 0
。
關於說明上取整函式、餘數和商數 (quotient) 之間的關係,可見以下案例:
觀察上方案例得知,將除法結果做為上取整函數的輸入值,當餘數為非 時,需要對商數加 。為此,我們需要在被除數上加某個數值,使餘數為 時,商數不變,而餘數為非 時,商數增加 ,該數值就是 。以下觀察當我們將 加到被除數 的過程。
由於餘數 永遠不會大於除數 ,亦即
加上 後,得到新的餘數 :
考慮 有以下二種狀況。
情況 1
當 為 時,加上 後,新的餘數 變成 。由於這是整數除法, 為 ,因此商數不變。
情況 2
當 為非 時,由於 至少為 ,加入 使 至少為 ,最多為 ,這小於 。所以,加入 剛好在商數上加 ,因為 。
從歐幾里德除法思考,假設 為被除數, 為除數同時不為 ,且 皆為整數,則歐幾里德除法告訴我們,存在唯一的二整數,其一為商數 ,其一為整數餘數 :
由於該巨集的使用限制 固定為正數,所以不等式寫為
現加入一整數 至等式兩側則
對等式兩側除以
使用原式計算的話, 經由整數除法得到該原式結果為 。
如上段討論情況 1 與情況 2 ,對等式右側第二項除式 討論以下二種情況:
情況 1
若 為 ,則第二項除式為 , 在 C 語言整數除法中,該整數除式 會被截斷至 。
情況 2
若 不為 ,因為 為整數,則 至少為 ,藉由該整數餘數不等式 第二項除式滿足不等式 ,於是在 C 語言整數除法中,該整數除式 會被截斷至 。
對應的圖解:
因此, 是恰到好處的數值,它能在餘數為 時,使商數保持不變,並在餘數為非 時,將商數增加 。這就是 DIV_ROUND_UP
的原理,Linux 核心開發者改寫二個分支為不需要分支的精簡實作,展現數學之美。
至於整數除法的四捨五入,可參見 DIV_ROUND_CLOSEST
巨集 (檔案: include/linux/math.h):
DIV_ROUND_CLOSEST
巨集執行除法並將結果四捨五入到最接近的整數,可處理正負數的被除數和除數。原理如下:
該巨集使用 來對正數結果進行四捨五入,並避免在正負號不同和無號整數類型時出現錯誤結果。
do_div
巨集do_div
是 Linux 核心原始程式碼裡頭的巨集,進行整數除法操作,將商數儲存在給定的變數中,並將餘數儲存在另一個變數。
巨集定義如下 (檔案: include/asm-generic/div64.h)
參數說明:
n
:要進行除法的 64 位元整數。base
:除數,一個 32 位元整數。巨集的返回值是 除以 的餘數。用法:
num
被 1000 除,商數則儲存在 num
中,餘數儲存在 remainder
中。
使用 do_div
巨集的好處是,可一次得到商數和餘數,且運算過程不依賴暫存變數,更重要的是,do_div
巨集可消除非預期的連結錯誤:針對特定的 Arm 處理器,某些 GCC Toolchain 會將 (當被除數是) 64 位元除法轉換為 __aeabi_uldivmod
呼叫 (這是 libgcc 的一部分),即使除數是 32 位元,這仍然會執行不必要的 64 位元的除法操作。由於效率原因,Linux 核心中不實作這類 64 位元除法,導致連結時出現未定義符號的引用錯誤。因此,對 64 位元除法應使用 do_div
巨集。
注意:上述巨集定義是針對 64 位元的處理器架構,而針對 32 位元的處理器架構,Linux 核心另行提供其他的實作方式,以避免非預期的效能議題,可參見 [PATCH 2/5] do_div(): generic optimization for constant divisor on 32-bit machines。
do_div()
巨集針對 Linux 核心原始程式碼,後者幾乎不使用 FPU 指令,但正如 Daniel Lemire 教授在 Fast exact integer divisions using floating-point operations 一文指出,整數除法改用 FPU 運算,可帶來更好的效能表現,也就是說,do_div()
巨集不見得適用於使用者層級的程式碼。
除法運算的成本往往高於加法和乘法,特別在某些硬體平台缺乏整數除法指令 (如 Arm Cortex-A8) 的狀況,就不得不仰賴高成本的除法的模擬運算,而即使 Intel 公司出品的 Core 系列處裡器 Sandy Bridge 微架構中,針對 64 位元整數的除法運算,會佔用 40 到 103 個處理器週期,運算成本仍屬沈重。
來源: Agner Fog’s instruction tables,第 180 頁
既然除法運算的成本居高不下,於是工程人員就著手改進整數除法的策略。其一是將除法改為乘法運算,例如 在分子和分母分別乘上 後,得到等價的 ,其中對 2 的 N 次方 (power of 2) 進行除法,在無號整數的操作就等同於右移 (shift) N 個位元,又當 (除數) 的數值是已知的狀況下,可預先計算,也就是說, 可轉換為乘法和位移操作,進而減少運算成本。不過,顯然 無法總是用整數來表達 (僅在 是 2 的冪時才有效),因此,我們可先估算,並將差值補償回去。
重新檢視 ,當我們想將 除以 時,就相當於乘以 ,所以我們可將 改為 ,我們得到整數的部分 (即 ),和小數部分 (即 ),後者乘以 即是 ,也就是餘數。下方程式碼展示這概念:
其中 __uint128_t
是 GCC extension,用以表示 128 位元的無號數值。
模除 的數學推導如下:
其中 對應到 M
無條件捨去的除法運算再補 對應到上取整數, 故
由 Meta 公司維護的 jemalloc 是個高效的記憶體配置器 (memory allocator,即 malloc 和 free 函式對應的實作),特別在多核多執行緒的環境有優異表現,在其原始程式碼 include/jemalloc/internal/div.h 和 src/div.c 也運用類似的技巧:已知 但 要取多少,方可滿足我們要的準確度呢? src/div.c 提供證明。
假設 為整數
證明
證明
成立的條件為
以下是改寫 jemalloc 快速除法的精簡實作:
使用方式:
比較 jemalloc 的快速除法與通用除法運算
-O0
到 -O3
測試結果:
-O0
-O1
-O2
-O3
可見 jemalloc 的快速除法的確有效益。
儘管電腦採用二進位,但由於需要讓執行結果被人類所識別,因此不乏會有 mod 10 和 div 10 等操作,我們可考慮以下在單一函式取得商數和餘數、不依賴除法和乘法指令的實作:
以下先假設 ,隨後我們會看到為何有這個數值,並推廣到 。
首先 uint32_t x = (in | 1) - (in >> 2);
等價於
經過 uint32_t q = (x >> 4) + x;
後,等價於
因為已假設 ,所有的 q = (q >> 8) + x;
都可忽略;這是因右移 8 位元,也就是除以 128 後會為 ,僅剩 + x
有值,故可以直接略過該四行敘述,令 q
等於上式。
為了簡化說明,假設 q
為偶數;那麼右移 3 位元,也就是除以 8 後
由於 ,後項可忽略,得前項,即是 div
;而由於 ,可得
(q & ~0x7)
可以視作右移 3 位後再左移 3 位,效果如同 。
當 時,q = (q >> 8) + x;
會被觸發,使得其值域再次回到 ,故上述依然成立。
Linux 核心的二進位到十進位轉換程式碼,使用一些技巧,包括針對受限範圍使用較小的常數,以及一個 200 位元組的 mod-100-to-2-digits 查表方式。
以下是相關程式碼: (部分刪減,檔案: lib/vsprintf.c)
以下內容部分取自 Ideal divisors: when a division compiles down to just a multiplication
既然除法指令是 CPU 最耗時的指令之一。因此,編譯器最佳化的手段就是將已知常數的整數除法,改寫為乘法和位移。然而,有時編譯器甚至不需要位移,這樣的除數在本文稱為理想除數,與費馬數有關。
對於 32 位元無號整數,考慮二個理想除數 和 。對於 64 位元無號整數,考慮二個不同的理想除數 和 ,後者分別是 和 的因數,且都是質數。於是:
和
在這些表達式中,乘法是完全乘法(得到 128 位元的結果)。看似有 64 位元的位移,但實際上編譯後,位移就消失。不是所有編譯器都能做到這點,不過 GCC 和 Clang 可以。以下是 GCC 在 x86-64 平台上編譯 n / 274177
和 n / 67280421310721
時產生的組合語言輸出:
在 Arm64 平台上也有類似的結果:
那麼餘數呢?優秀的編譯器會先計算商數,再藉由乘法和減法來推導餘數,不過在許多情況下,計算餘數比計算商數的代價更高。
某些情況下,你可更高效計算餘數。論文〈Faster Remainder by Direct Computation〉提到額外的技巧:直接計算餘數,只要二個乘法:
和
換句話說,以下二個 C 函式完全等效:
儘管第二個函式看似較冗長且複雜,但它通常會編譯成更高效的機械碼,只涉及二個乘法,通常比編譯器產生的最佳化指令更高效。
延伸閱讀: Integer Division by Constants: Optimal Bounds / fast_division