# Linux 核心專題: 回顧 bitops 並改進
> 執行人: Shiritai
> [專題解說錄影](https://youtu.be/DpV40zL9ngY)
> [投影片](https://www.canva.com/design/DAFmsnSBE4I/Qunm7_g9jHdbrbL0XcNoOw/view?utm_content=DAFmsnSBE4I)
> [GitHub](https://github.com/Shiritai/linux-bitops-patch)
:::success
:question: 提問清單
* `__gen_fls` 與 `gen_fls` 只差在 `bit_len` 這項輸入。想問分開的好處?
> 有兩個好處。
> 1. 呼叫者不需要輸入 `bit_len` 參數,因為這可以從輸入型別直接推得,不過我想您想問的應該不是這點。
> 2. `bit_len` 這項輸入在 `__gen_fls` 中用上兩次,如下
> ```c
> #define __gen_fls(in_type, out_type, start_from, fn_name, bit_len) \
> static __always_inline out_type fn_name(in_type x) \
> { \
> /* ... */ \
> shift = bit_len >> 1; \
> bits = bit_len - 1 + start_from; \
> ```
> 如果不以一個參數包裝 `sizeof(in_type) * 8`,我們就需要在 `__gen_fls` 中寫兩遍 `sizeof(in_type) * 8`,這增加了維護程式碼的一咪咪成本。使用間接的巨集生成函式不僅避免這個成本,同時不影響編譯完的結果,所以我採用此方法。
>
> 換言之,增加一層間階層同時讓:
> 1. 呼叫者呼叫的更優雅
> 2. 實作者簡化實作邏輯
>
> 可謂兩邊都受惠 OwO
>
> 希望這樣有解答您的提問。[name=Shiritai]
:::
## 任務簡述
Linux 核心的 [include/asm-generic/bitops](https://github.com/torvalds/linux/tree/master/include/asm-generic/bitops) 存在若干實作不一致的狀況,本任務嘗試追蹤 git log 和 LKML,以理解發展脈絡,並嘗試用精簡有效的方式改寫,最後嘗試貢獻程式碼到 Linux 核心。
> [準備工作](https://hackmd.io/@Eroiko/linux2023-assessment)
## TODO: 理解 bitops 實作風格和目標
`bitops` 本身為 Linux kernel 中的一個套件,當中實作 `ffs` 系列函式,他們被用在核心的其餘諸多地方,這點可以簡單利用 vscode 對 `ffs`, `__ffs`, `fls`, `__fls`, `fls64`, `ffz` 這些函式進行搜尋便可得知。一個經典的應用是 CFS (completely fair scheduler) 的排程上,會呼叫 `bitops/sched.h` 中的 `sched_find_first_bit` 函式,後者又呼叫 `__ffs`。
由 `clz` 系列函式的實作為引子,經翻閱 Linux 原始碼中 `ffs` 系列函式後,整理其概況與問題,列敘如下。
### `ffs` 及 `__ffs` 加雙底線與否的不同
首先先釐清 `ffs` 及 `__ffs` (是否加雙底線) 的不同之處:參考 `bitops` 系列對外的介面: [`arch/arc/include/asm/bitops.h`](https://github.com/torvalds/linux/blob/master/arch/arc/include/asm/bitops.h) 中的註解得知
* `__XXX` 系列: 以 index 0 索引,範圍為 `0 ~ {length of input type - 1}`
* `XXX` 系列: 以 index 1 索引,範圍為 `1 ~ {length of input type}`
### 採用硬體實作與否
GCC 除了提供 `__builtin__c{l,t}z` 對硬體指令包裝的內建函式外,實際上也存在 `__builtin_ffs`。就算沒有此函式,用前兩個也足以解決 `ffs` 和 `fls` 的實作。不過並不是所有指令集都實作對應的硬體指令,所以才有 linux 核心中 `ffs` 系列的軟體實作。核心透過條件編譯決定最後應該採用哪種實作,而本子項目的重點將先放在軟體實作的部分。
接著來探討現有實作的問題。
### 詭異的實作不一致性與例外情況
凡是掛名 `__builtin_XXX` 的函式本身以 CPU 硬體指令實作,過濾例外情況 $0$ 作為輸入以避免 undefined behavior 是必備手續;同時使用 `ffs`, `fls` 時輸入 $0$ 本身就是極其不合理的事,此時檢查例外情況依然重要。
不過有趣的是,`bitops` 目錄下大家眾口一詞:有的說例外情況的判斷交由 caller 檢查,有的沒特別說 (只說其與 builtin ffs 有同樣的 routine),有的直接為 $0$ 為輸入定義對應輸出...
* 例外情況的判斷交由 caller 檢查
```c
// __ffs.h
/**
* __ffs - find first bit in word.
* @word: The word to search
*
* Undefined if no bit exists, so code should check against 0 first.
*/
static __always_inline unsigned long __ffs(unsigned long word)
```
* 沒特別說,但自主處理例外情況 (只說其與 `builtin ffs` 有同樣的 routine)
```c
// ffs.h
/**
* ffs - find first bit set
* @x: the word to search
*
* This is defined the same way as
* the libc and compiler builtin ffs routines, therefore
* differs in spirit from ffz (man ffs).
*/
static inline int ffs(int x)
{
int r = 1;
if (!x)
return 0;
...
```
* 為 $0$ 作為輸入時定義對應輸出
```c
/**
* fls - find last (most-significant) bit set
* @x: the word to search
*
* This is defined the same way as ffs.
* Note fls(0) = 0, fls(1) = 1, fls(0x80000000) = 32.
*/
static __always_inline int fls(unsigned int x)
```
所謂魔鬼藏在細節裡。這樣的不一致性往往導致開發時的不可控風險,屬於 bad smell 的一種。
### [`constant_fls`](https://github.com/torvalds/linux/blob/master/arch/arc/include/asm/bitops.h) 的存在意義
[`arch/arc/include/asm/bitops.h`](https://github.com/torvalds/linux/blob/master/arch/arc/include/asm/bitops.h) 中存在一函式 `constant_fls`,簽名與部分實作如下:
```c
static inline int constant_fls(unsigned int x)
{
int r = 32;
if (!x)
return 0;
if (!(x & 0xffff0000u)) {
x <<= 16;
...
}
```
其目的是利用 [gcc `__builtin_constant_p`](https://gcc.gnu.org/onlinedocs/gcc/Other-Builtins.html#index-_005f_005fbuiltin_005fconstant_005fp) 協助於編譯時期進行 [Constant folding (propagation)](https://en.wikipedia.org/wiki/Constant_folding),於[你所不知道的 C 語言:編譯器和最佳化原理篇](https://hackmd.io/@sysprog/c-compiler-optimization?type=view#%E4%BD%A0%E6%89%80%E4%B8%8D%E7%9F%A5%E9%81%93%E7%9A%84-C-%E8%AA%9E%E8%A8%80%EF%BC%9A%E7%B7%A8%E8%AD%AF%E5%99%A8%E5%92%8C%E6%9C%80%E4%BD%B3%E5%8C%96%E5%8E%9F%E7%90%86%E7%AF%87)介紹過其觀念。以下節錄 gcc 官方文件,說明 `__builtin_constant_p` 的使用:
> You can use the built-in function `__builtin_constant_p` to determine if a value is known to be constant at compile time and <font color=#cd5c5c>**hence that GCC can perform constant-folding on expressions involving that value**.</font> ...
>
> ... A **return of `0` does not indicate that the value is not a constant**, but merely that GCC cannot prove it is a constant with the specified value of the `-O` option.
>
> You may use this built-in function in either a macro or an inline function. **However, if you use it in an inlined function and pass an argument of the function as the argument to the built-in**, GCC never returns `1` when you call the inline function with a string constant or compound literal (see Compound Literals) and **does not return 1 when you <font color=#cd5c5c>pass a constant numeric value</font> to the inline function <font color=#cd5c5c>unless you specify the `-O` option**</font>.
表示當 `__builtin_constant_p` 用於 macro 或 inline function 時,傳入的參數為 常數數值,當開啟編譯器最佳化 `-O` 選項後即會進行 constant folding。
綜觀 `constant_fls` 函式,我們看不出其與 [`bitops/fls`](https://github.com/torvalds/linux/blob/master/include/asm-generic/bitops/fls.h) 有任何差別。這表示就算不實作 `constant_fls`,也能利用上述 `bitops/fls` 函式協助編譯器進行 constant folding。
那麼問題就出現了:
* `constant_fls` 實作的意義為何?
* 若調整 `bitops/flz` 或 `constant_fls` 的實作,對於 [`bitops.h` 中 `fls`](https://github.com/torvalds/linux/blob/master/arch/arc/include/asm/bitops.h)傳入參數為 `__builtin_constant_p(x) == 1` 時的效能是否有影響?即是否有可能影響 constant folding?
另附上唯一的呼叫者 [`bitops.h` 中 `fls`](https://github.com/torvalds/linux/blob/master/arch/arc/include/asm/bitops.h) 函式以供參考。
```c
/*
* fls = Find Last Set in word
* @result: [1-32]
* fls(1) = 1, fls(0x80000000) = 32, fls(0) = 0
*/
static inline __attribute__ ((const)) int fls(unsigned int x)
{
if (__builtin_constant_p(x))
return constant_fls(x);
return 32 - clz(x);
}
```
### 另一細節的不一致性
有一個小小細節是: 使用 `if-else` 實作系列函式的一個優點是可以略過部分不必要的操作,比如 `__fls` 函式: (這點 `__ffs` 亦同)
```c
static __always_inline unsigned long __fls(unsigned long word)
{
int num = BITS_PER_LONG - 1;
...
if (!(word & (~0ul << (BITS_PER_LONG-2)))) {
num -= 2;
word <<= 2;
}
if (!(word & (~0ul << (BITS_PER_LONG-1))))
num -= 1;
// bypass "word <<= 1" instruction here
return num;
}
```
但隔壁鄰居 `fls` (`ffs` 亦同) 可沒打算那樣做:
```c
static __always_inline int fls(unsigned int x)
{
int r = 32;
...
if (!(x & 0xc0000000u)) {
x <<= 2;
r -= 2;
}
if (!(x & 0x80000000u)) {
x <<= 1; // meaningless?!
r -= 1;
}
return r;
}
```
順帶一提,他的親戚 `constant_fls` (就是前述沒有存在意義的函式) 倒是考慮到了這點。
### 目標
透過觀察前述古怪現象,其中一個解決方法便是使用我分析 `clz` 軟體實作的成果: 以 button-up 的角度分析位元運算版實作,一步步將 Magic Number 抽象出來,在不斷維持原功能的相容性下擴展新功能。好處有以下:
* 程式碼簡潔
* 可以輕鬆維護並同步現有不同輸入輸出型別之 `f{f,l}s` 系列函式的軟體實作
* 統一 Undefined behavior 的處理方式
* 為未來 $128$ bits 版的實作乃至於其他型別的實作給出輕鬆擴展的解決方案
但若直接移植我的解決方案而不經任何調整,將會有以下缺點:
* 舊方案無法處理有無雙底線如 `__ffs` 等函式的實作
* 效能降低
對於第一項,我們可以調整函式的生成邏輯,增加可控變因來嘗試完成。對於第二項,目前將透過進一步理解編譯器的優化行為嘗試解決。
## TODO: 用一致的實作手段改寫 bitops 程式碼
### 擴展巨集的功能
透過定義額外兩個供其他開發者使用的巨集常數
```c
#define __FFLS_START_INDEX_FROM_ZERO 0
#define __FFLS_START_INDEX_FROM_ONE 1
```
我調整 `ffs` (`fls` 同理) 函式如下:
```c=
/**
* Hidden generator of ffs to hide evaluation of size of type
*/
#define __gen_ffs(in_type, out_type, start_from, fn_name, bit_len) \
static __always_inline out_type fn_name(in_type x) \
{ \
int bits, shift; \
in_type mask; \
if (!x) \
return 0; \
bits = start_from; \
shift = bit_len >> 1; \
mask = (in_type) ~(0xFFFFFFFFFFFFFFFF << shift); \
do { \
if (!(x & mask)) { \
bits += shift; \
x >>= shift; \
} \
shift >>= 1; \
mask >>= shift; \
} while (shift); \
return bits; \
}
/**
* gen_ffs() - Macro to generate function series of
* ffs series - find first (least-significant) bit set.
*
* Generated function parameter:
* @x: the object to search
*
* Macro arguments:
* @in_type: input type of ffs
* @out_type: output type of ffs
* @start_from: starting index of generated ffs to generate both
* ffs series (return 1 ~ 64) and __ffs series (return 0 ~ 63)
*
* Note if:
* start_from is __FFLS_START_INDEX_FROM_ZERO, then ffs(0) = 0,
* ffs(1) = 0, ffs(object leads with 1) = sizeof object - 1.
* start_from is __FFLS_START_INDEX_FROM_ONE, then ffs(0) = 0,
* ffs(1) = 1, ffs(object leads with 1) = sizeof object.
* @fn_name: Function name to be generated
*/
#define gen_ffs(in_type, out_type, start_from, fn_name) \
__gen_ffs(in_type, out_type, start_from, fn_name, (sizeof(in_type) * 8))
```
根據我所寫的 kernel doc,其他開發者可以為 `start_from` 參數指派 `__FFLS_START_INDEX_FROM_ZERO` 或 `__FFLS_START_INDEX_FROM_ONE` 調整其起始索引值,如此一來無論是 `__ffs` 抑或 `ffs` 都能以同樣的規格實作。
原理可見程式碼第 11 行,初始化 `bits` (輸出) 時採用不同的起始索引。由於編譯器能自動識別並簡化這些常數,因此這些調整對效能沒有影響。
### Loop unroll
#### 從一個 `x64` 組語小實驗開始
我嘗試將自己的舊版 `ffs` 巨集生成函式置於 `util.h` 轉為組語,其在 `main.c` 中被呼叫並印出結果,以下節錄實作:
<!-- 使用行數標注是因為下文敘述會用到 -->
```c=
// util.h
#define __gen_ffs(in_type, out_type, start_from, fn_name, bit_len) \
static __always_inline out_type fn_name(in_type x) \
{ \
int bits, shift; \
in_type mask; \
if (!x) \
return 0; \
bits = start_from; \
shift = bit_len >> 1; \
mask = (in_type) ~(_ONES << (bit_len >> 1)); \
while (shift) { \
if (!(x & mask)) { \
bits += shift; \
x >>= shift; \
} \
shift >>= 1; \
mask >>= shift; \
} \
return bits; \
}
#define gen_fls(in_type, out_type, start_from, fn_name) \
__gen_fls(in_type, out_type, start_from, fn_name, (sizeof(in_type) * 8))
// generate
gen_ffs(__unsigned_int, int, START_INDEX_FROM_ONE, my_ffs);
// main.c
int main(void) {
printf("%d\n", my_ffs((rand() % (__INT32_MAX__ - 1)) + 1));
}
```
`main.c` 中故意使用 `srand`、`time(NULL)` 和 `rand` 是為了不希望 compiler 將輸入設為常數;使用 `printf` 是希望 compiler 沒有機會把生成的 inline function 整個優化掉,強迫一定要取得結果並印出。
使用 `cc main.c -O -std=c99 -S` 帶優化的編譯,產生的組語節錄如下:
```python
...
movl $0, %edi
addl $1, %eax
je .L2 # if (!x) return 0;
movl $5, %esi
movl $16, %ecx
movl $1, %edi
movl $65535, %edx
jmp .L4
.L3:
sarl %ecx
shrl %cl, %edx
subl $1, %esi
je .L2
.L4:
testl %edx, %eax
jne .L3
addl %ecx, %edi
shrl %cl, %eax
jmp .L3
.L2:
movl %edi, %edx
leaq .LC0(%rip), %rsi
movl $1, %edi
movl $0, %eax
call __printf_chk@PLT
...
```
這裡可以觀察到一些有趣的現象,以下討論根據 [x86 cheat sheet](https://web.stanford.edu/class/cs107/resources/x86-64-reference.pdf)。
* `__gen_ffs` 中 `mask` 變數確實透過編譯器的協助轉為常數 `65535` (`0xffff`),這符合預期。
* 更進一步的,將上述 `util.h` 第 13 行改為 `mask = (in_type) ~(_ONES << shift);` 後編譯為組語,發現得到一樣的結果。
* 本來顧慮到不使用全部直接是常數的巨集產開下可能導致 gcc 不會正確預先求出 `mask` 常數,事實證明編譯器實在太聰明了!
* `my_ffs` (使用巨集生成的 `ffs` 函式) 組語迴圈也很有趣。
* 查詢 `x86` 的 [`test` 指令](https://en.wikipedia.org/wiki/TEST_(x86_instruction))發現,原來其非迴圈條件判斷,而是 bitwise AND 運算,對應第 13 行 `x & shift` 的部分。
* 原來編譯器自動幫我將 `while` 轉為 `do-while`,不得不說真的很聰明,直接體會到編譯器的強大。
#### 實作 Loop unroll
編譯器神通廣大,即使沒修過編譯器的我都聽過其中一個知名的功能:loop unrolling。透過將迴圈展開為只向下執行的程式碼,降低指令往前轉跳的 control hazard 和 instruction cache 成本。
參考[本討論串](https://stackoverflow.com/questions/24196076/is-gcc-loop-unrolling-flag-really-effective),其討論到適合使用 loop unrolling 的時機:
> 對次數少的小迴圈有效,反之則要付出大代價。
平常依靠編譯器自動最佳化即可,盡可能不要進行半吊子的優化,正如 Donald Knuth 所言:
> We should forget about small efficiencies, say about 97% of the time: premature optimization is the root of all evil.
明顯前述 `gen_ffs` 或者說 `my_ffs` 屬於適合 loop unrolling 的情況,於是我嘗試使用 [`-funroll-loops`](https://gcc.gnu.org/onlinedocs/gcc-4.5.2/gcc/Optimize-Options.html) 這個 gcc 編譯參數,其嘗試在編譯時期展開可預測迴圈執行次數的迴圈:
```bash
cc main.c -O -funroll-loops -std=c99 -S
```
(左: 原版,右:unroll loops 版)
![](https://hackmd.io/_uploads/ByRqh5_E2.png)
成功得到展開後的迴圈,而且由於將邏輯迴圈化之前的重複次數本來就很少,展開後才只多七行,除了預見效能的提升外想不到其他可能。
更令我驚訝的是,除了能正確的 unroll loop,針對整數輸入型別展開為 5 次 $(i.e. 16, 8, 4, 2, 1)$ 以外,編譯器甚至聰明到把最後一個 $1$ 的情況優化成不去對 `x` 做無謂的運算,也就是 `.L6` 中不存在對 `%eax (x)` 的右移指令。
換言之,若將我的巨集生成函式應用於 Linux Kernel `ffs/fls` 系列函式的實作,有可能不會影響其效能。實際上是真的不會,詳見[後續實驗](https://hackmd.io/@sysprog/ByS9w_lHh#%E6%95%88%E8%83%BD%E6%8E%A2%E8%A8%8E),但在此之前有個問題,就是如果需要調整 kernel 編譯時的編譯參數,恐怕會影響其他程式碼,我們應該限制 loop unroll 只作用在由我們巨集生成函式產生的迴圈上。
#### 精準最佳化:調整 compile 時特定位置採取的優化策略
同前述,我們希望 kernel 於編譯時,與 `ffs` 相關的部分採用 GCC 編譯時傳入 `unroll-loops` 參數的效果。
直接調整編譯參數有點不切實際,原因如下:
* 只希望 unroll 生成出來的函式,不希望影響到其他函式。
* ~~Linux 的 makefile 們 trace 起來好痛苦~~
另一方面,我想到 gcc 可能提供了額外的標記來解決問題,於是找到[本討論串](https://stackoverflow.com/questions/4071690/tell-gcc-to-specifically-unroll-a-loop),其介紹了兩種方法:
* [`pragma + GCC global optimize + push pop`](https://gcc.gnu.org/onlinedocs/gcc/Function-Specific-Option-Pragmas.html#Function-Specific-Option-Pragmas): 在函式前為 GCC 增加全域優化設定,函式後取消該新增。
> `pragma` 表示編譯提示。
```c
#pragma GCC push_options
#pragma GCC optimize ("unroll-loops")
// function with loops to be unrolled
#pragma GCC pop_options
```
* 新增 [`attribute`](https://gcc.gnu.org/onlinedocs/gcc/Function-Attributes.html): 在函式前加上
```c
__attribute__((optimize("unroll-loops")))
// function with loops to be unrolled
```
於是我嘗試實驗這兩者於以下編譯環境:
```bash
> gcc --version
gcc (Ubuntu 11.3.0-1ubuntu1~22.04) 11.3.0
```
發現後者沒有效果,前者雖然有,但不知為何在巨集生成函式下沒有效果。我嘗試過以下做法:
* 直接暴力放 `pragma`
會報錯,在 `define` 中不能用 `#...`,故 `#pragma` 不能使用。(語法錯誤)。
```c
#define ... \
#pragma GCC push_options \
#pragma GCC optimize ("unroll-loops") \
\
/* function with loops to be unrolled */ \
\
#pragma GCC pop_options
```
* 利用 C99 的功能: [`_Pragma` 運算子](https://gcc.gnu.org/onlinedocs/cpp/Pragmas.html)替代 `#pragma` 巨集
這下語法不會錯誤了。但奇妙的是,這樣寫編譯後發現沒有效果!
```c
#define ... \
_Pragma("GCC push_options") \
_Pragma("GCC optimize (\"unroll-loops\")") \
\
/* function with loops to be unrolled */ \
\
_Pragma("GCC pop_options")
```
於是我改為去掉最後一行 `_Pragma("GCC pop_options")`,編譯後發現竟然有效,但這樣調整全域優化設定非常不好,畢竟會影響到其他程式碼,我所追求的是只針對本函式的 loop unroll。
* 利用 GCC [Loop-Specific Pragmas](https://gcc.gnu.org/onlinedocs/gcc/Loop-Specific-Pragmas.html): 前述方法都是針對整個函式,經查找 GCC 規格書後發現還有此方法,透過標記在迴圈前一行告訴 GCC 要 unroll 幾次,語法為 `#pragma GCC unroll n`。
> 以下節錄 GCC 規格書,說明語法中 `n` 的意義:
> n is an integer constant expression specifying the unrolling factor. The values of 0 and 1 block any unrolling of the loop.
於是我結合前述 `_Pragma` 運算子將程式改為:
```c
#define ... \
... \
_Pragma("GCC unroll 6") \
do { \
... \
} while (shift); \
...
```
指定 $n = 6$ 是因為對於 $64$ 位元而言,迴圈將走訪 $shift = 2^5, 2^4, \sim, 2^0$ 這幾項可能。
:::warning
不過此時讀者可能會好奇,這樣不就限制我抽象為巨集生成函式的作法只能用於 $64$ bits?
答案是其實不會!
雖然規格書沒有詳細提及,不過我以實驗測試 unroll 次數自 0 至 100。結果表明,當可 unroll 的次數 $n_{avai}$ 小於指派的次數,結果只會展開 $n_{avai}$ 次。畢竟 GCC 原本的 `funroll-loops` 本來就有能力分析迴圈合理的展開次數。
更進一步的,若未來出現 $128$ 位元的處理器,只要將當中全一立即數和 unroll 次數向上調整即可,生成出的結果不會影響舊有實作。
:::
### 更進一步,實作 `clz/ffs` 系列函式全家桶
Linux kernel 中尚未針對 `c{l,t}z` 系列函式直接提供軟體實作。以下嘗試沿用 `f{f/s}s` 的成果,以接近相同的實作一口氣實作 `clz/ffs` 系列函式全家桶。
#### `clz/ffs` 全家桶說明
常見的 `clz` 系列函式有以下數個,當中 leading、tailing、first、last 的定義存在模糊空間,故標註其針對的是 MSB 抑或 LSB:
* count leading zeros (MSB)
* count tailing zeros (LSB)
* find first set (LSB)
* find last set (MSB)
* find first zero (LSB)
他們針對不同的
* 輸入
* 輸出
* 未定義行為
而有不同的實作。不過一個很有趣的現象是 `clz` 函式們彼此間的關係,以下式說明。
$$
\begin{cases}
clz(x) = 64 - fls(x), & x > 0 \\
ctz(x) + 1 = ffs(x), & x > 0 \\
ffz(x) = ffs(\neg x) - 1, & x \ne \rm{all\ ones} \\
\end{cases}
$$
可見這些函式實際上可能只需實作 `clz/ctz` 或 `ffs/fls` 即可,其他函式只需調整一些即可。
### 實作全家桶
在我的實作中,全家桶的關係如下圖:方形表對面向使用者的函式,橢圓表巨集生成函式 (`__ffs`、`__fls`、`ffz`、`constant_fls` 等請見 `bitops-patch` 子項目)。
```graphviz
digraph dg {
graph[rankdir=BT];
gen_ffs; gen_fls; gen_clz; gen_ctz;
ffs[shape=record]; fls[shape=record]; __ffs[shape=record];__fls[shape=record]; ffz[shape=record]; constant_fls[shape=record];
clz[shape=record]; ctz[shape=record];
__gen_clz -> __gen_fls;
gen_fls -> __gen_fls;
gen_ffs -> __gen_ffs;
ffs -> gen_ffs;
fls -> gen_fls;
__ffs -> gen_ffs;
__fls -> gen_fls;
gen_clz -> __gen_clz;
clz -> gen_clz;
ctz -> gen_ctz;
ffz -> __ffs;
constant_fls -> gen_fls;
gen_ctz -> __gen_ffs;
}
```
透過活用一開始設計來提供使用者客製化用的之巨集常數 `__FFLS_START_INDEX_FROM_{ZERO,ONE}`,以 `f{f,l}s` 實作 `c{l,t}z` 只在彈指之間。節錄實作如下:
```c
#define __FFLS_START_INDEX_FROM_ZERO 0
#define __FFLS_START_INDEX_FROM_ONE 1
// hidden implementation
#define __gen_ffs(in_type, out_type, start_from, fn_name, bit_len) \
... // just like ctz
// developers should call this
#define gen_ffs(in_type, out_type, start_from, fn_name) \
__gen_ffs(in_type, out_type, start_from, fn_name, (sizeof(in_type) * 8))
// hidden implementation
#define __gen_fls(in_type, out_type, start_from, fn_name, bit_len) \
... // just like clz
// developers should call this
#define gen_fls(in_type, out_type, start_from, fn_name) \
__gen_fls(in_type, out_type, start_from, fn_name, (sizeof(in_type) * 8))
// hidden implementation
#define __gen_clz(in_type, out_type, fn_name, bit_len) \
__gen_fls(in_type, out_type, \
__FFLS_START_INDEX_FROM_ONE, \
__fls_##fn_name, bit_len); \
static __always_inline out_type fn_name(in_type x) \
{ \
return bit_len - __fls_##fn_name(x); \
}
// developers should call this
#define gen_clz(in_type, out_type, fn_name) \
__gen_clz(in_type, out_type, fn_name, (sizeof(in_type) * 8))
// developers should call this
#define gen_ctz(in_type, out_type, fn_name) \
__gen_ffs(in_type, out_type, \
__FFLS_START_INDEX_FROM_ZERO, \
fn_name, (sizeof(in_type) * 8))
// hidden implementation
#define __gen_ffz(in_type, out_type, fn_name, bit_len) \
__gen_ffs(in_type, out_type, \
__FFLS_START_INDEX_FROM_ZERO, \
__ffs_##fn_name, bit_len); \
static __always_inline out_type fn_name(in_type x) \
{ \
return __ffs_##fn_name(~x); \
}
#define gen_ffz(in_type, out_type, fn_name) \
__gen_ffz(in_type, out_type, fn_name, (sizeof(in_type) * 8))
```
可以發現實作 `clz` 和 `ffz` 的過程中額外生成了幫手 `fls` (`__fls_##fn_name`)、`ffs` (`__ffs_##fn_name`) 函式,至於對於
* 程式碼多餘與否
* 機械碼長度
* 執行時期效能
等指標而言是不是一個好的做法,又是一個可以加以探討的話題。
### 效能探討
今以以下簡單的程式來測試,後者 (巨集生成迴圈版) 有兩個版本,一個是尚未使用 loop unroll 的版本,另一個有使用。
```c
// 原始版本
for (long long i = 1; i < 1000000; ++i) {
assert(__builtin_ffsll(i) == ffs_ll(i));
assert(__builtin_ffsll(i + __INT32_MAX__) ==
ffs_ll(i + __INT32_MAX__));
}
// 巨集生成迴圈版 (no loop unroll)
for (long long i = 1; i < 1000000; ++i) {
assert(__builtin_ffsll(i) == my_ffs_ll(i));
assert(__builtin_ffsll(i + __INT32_MAX__) ==
my_ffs_ll(i + __INT32_MAX__));
}
// 巨集生成迴圈版 (loop unroll)
for (long long i = 1; i < 1000000; ++i) {
assert(__builtin_ffsll(i) == my_ffs_lu_ll(i));
assert(__builtin_ffsll(i + __INT32_MAX__) ==
my_ffs_lu_ll(i + __INT32_MAX__));
}
```
實驗環境如下。
:::spoiler
```bash
$ gcc --version
gcc (Ubuntu 11.3.0-1ubuntu1~22.04.1) 11.3.0
$ 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): 8
On-line CPU(s) list: 0-7
Vendor ID: GenuineIntel
Model name: 12th Gen Intel(R) Core(TM) i3-12100
CPU family: 6
Model: 151
Thread(s) per core: 2
Core(s) per socket: 4
Socket(s): 1
Stepping: 5
CPU max MHz: 5500.0000
CPU min MHz: 800.0000
```
:::
以 `perf` 為測試工具,測試指令如下:
```bash
gcc main.c -std=c99 -O -o a -g3 && sudo perf stat --repeat 1000 ./a
```
分別測試上述三種不同的實作,得到如下結果:
|index|原始版本|巨集生成迴圈版<br>no loop unroll|巨集生成迴圈版<br>loop unroll|
|:-:|:-:|:-:|:-:|
|Execution time<br>(ms)|3.48|9.26|3.52|
|Branches|15.35M|32.34M|15.34M|
|Branch miss<br>(% of all branch)|0.18|0.16|0.19|
|IPC|3.65|2.89|3.62|
可見在尚未配合編譯器優化前的實作與原始相比,執行時間大約為原始的 2.6 倍,雖然 branch miss 比率較低,但總 branches 指令為原本的近兩倍 (這是高度可以預期的,因為每輪迴圈都需要一次結束判定),故 IPC 比原本低,影響執行時長。
而採用 loop unroll 編譯器精準優化後,執行時間為原本的 1.094 倍,幾乎不受影響,而 branches 數也說明 loop unroll 成功把迴圈結束判定的邏輯給展開去除,得到近乎一樣的結果,在 branch 數與 branch miss 相近的情況下,IPC 也很接近,執行時長自然相近。
實驗結論是:本專題的優化幾乎不會影響效能。
## TODO: 確認改寫的程式碼得以在 Linux 核心運作
> 應搭配 user-mode-linux 或 virtme (第 7 週教材) 來驗證
### 實作
在實作精準的 loop unroll 後,下一步是實作於 Linux kernel。
首先我建立新檔案 `ffls_gen.h`,加入前述 `ffs`、`fls` 巨集生成函式。接著修改 `ffs.h`、`fls.h`、`__ffs.h`、`__fls.h`、`fls64.h`、`bitops.h` 等檔案,調整實作如下兩例,其餘皆依樣畫葫蘆:
```c
// in __ffs.h
gen_ffs(unsigned long, unsigned long, __FFLS_START_INDEX_FROM_ZERO, __ffs);
// in ffs.h
gen_ffs(unsigned int, int, __FFLS_START_INDEX_FROM_ONE, ffs);
```
### 初步測試
為了測試方便,我先粗暴的調整 `__builtin-ffs.h`、`__builtin-__ffs.h` 等一系列採用硬體指令版的實作為與軟體版的相同,並以 User mode linux 編譯運行,得到無任何錯誤的編譯、運行結果。
<s>![](https://hackmd.io/_uploads/SyxJl4JB3.png)</s>
## TODO: 統計/預估藉由導入上述程式碼的修改後,現行 Linux 核心原始程式碼可減少幾行程式碼,及簡述提升程式碼維護的效益
### 減少程式碼行數
在 10 處重複實作的程式碼中 (不考慮註解的話):
* 實作巨集函式們於 `include/asm-generic/bitops/ffls_gen.h`: 增加 $53$ 行 (不算額外擴展而暫時沒用到的 `clz/ctz/ffz` 巨集,若連註解都算的話增加 $170$ 行)
* `tools/include/asm-generic/bitops` 和 `include/asm-generic/bitops` 下一樣的實作
* `__ffs.h`: 減少 $29 \times 2$ 行
* `__fls.h`: 減少 $28 \times 2$ 行
* `fls.h`: 減少 $28 \times 2$ 行
* `fls64.h`: 減少 $13 \times 2$ 行
* `include/asm-generic/bitops/ffs.h` 減少 $26$ 行
* `arch/arc/include/asm/bitops.h` 減少 $24$ 行
共計減少 $193$ 行程式碼 (連註解、暫時沒用到的 `clz/ctz/ffz` 巨集都算的話減少 $76$ 行)。
### 提升程式碼維護的效益
* 對於所有輸入輸出資料型別、長度、起始索引為零或一,統一其未定義行為,保證安全。
* `gen_ffs/gen_ctz` 產生的函式於輸入為 $0$ 時的未定義行為,本實作下輸出統一為 $0$
* `gen_fls` 產生的函式於輸入為 $0$ 時的未定義行為,本實作下輸出統一為 $0$
* (承上) `gen_clz` 輸入為 $0$ 時輸出為資料長度
* `gen_ffz` 輸入為全一時輸出為 $0$
* 針對位元運算迴避有號輸入型別可能產生的符號擴展,保證安全。
* 向舊有程式碼 (起始索引為 0 或 1,各式輸入輸出型別) 兼容的同時,在不用調整編譯參數的情況下擁有近乎相同的效能。
* 對未來比如 128 bits 的資料型別,僅需微調巨集展開次數與全一立即數,即可產生向長度 128 bits 以下兼容的程式碼,優雅。
* 要實作其他輸入輸出型別的 ffs/fls/ffz/cls/ctz 函式,每次僅需撰寫一行即可產生固定邏輯的函式,四兩撥千斤,優雅且利他。
我認為是值得出現在 linux kernel 的程式碼。
## TODO: 提供程式碼貢獻到 LKML
> To be continue... [name=Shiritai]