contributed by < freshLiver
>
每次分成高低位元兩部份,用二元搜尋檢查最高位元的 1 是在哪個部份,總共重複 次,即上方程式碼的 8 至 30 行的部份。
而若是單純重複五次的話,有些操作會是多餘的,例如第 29 行的位移就是多餘的,第五次檢查的結果(第 28 行)也可以直接與第四次的結果透過 |
相加,因此最後可以將:
簡化成:
因此 EXP1 應為 r | shift | x > 1
。
TODO : x > 1
改為 x >> 1
的表現
比較需要注意的是,若是 的話,結果應為 ;但若是 恰好為 的話,結果則應為 ,因此在一開始就先進行 x--
讓 的特例消失,最後再透過 +1
補回即可。
在 x == 0
時,只需要一個位元即可儲存,在這種情況下 x
不需要進行 --
,因此最後的 (r | shift | x > 1)
部份會是 0,但與其他情況相同,會進行無條件進位,因此恰好可以處理 x == 0
的狀況。
若 x
為 0 的結果需要是 0 的話,則連最後的無條件進位都不需要,因此需要額外使用變數儲存原始的 x
(或是不直接對 x
操作,而是對另一變數進行位移檢查):
-HUGE_VAL
在 log(x)
的 Man Page 中有提到當 x
為 0 時的處理方式:
If x is zero, then a pole error occurs, and the functions return
-HUGE_VAL
,-HUGE_VALF
, or-HUGE_VALL
, respectively.
而關於這 HUGE_VAL
的說明可以從 n1570 的 Annex F.10 Mathematics <math.h>
找到:
- The Standard C macro HUGE_VAL and its float and long double analogs, HUGE_VALF and HUGE_VALL, expand to expressions whose values are positive infinities.
可以看到這三個巨集分別用來表示 double
、float
、long double
三種長度的浮點數型別的「無窮大」,因此 -HUGE_VAL
就代表了三種浮點數型別的「負無窮大」。而由於這題的參數是 32 位元的整數,所以這邊選擇以 logf
作為參考,改以 float
作為回傳型別,並在 x
為 0 時回傳 HUGE_VALF
。
而為了以 Branchless 的方式在 x
為 0 時回傳 HUGE_VALF
,這個實作以 若 x 為 0 時 fls 應為 0 的實作為基礎進行調整:
首先將 result
初始化為 -HUGE_VALF
,並利用另一個 32 位元的整數 resultMask
儲存 -!x
與 result
對應的 32 位元記憶體內容進行 AND 運算的結果:
x == 0
時: resultMask == -HUGE_VALF
x != 0
時: resultMask == 0
接著計算 ilog2
的結果(x
為 0 時 result
也為 0)並以 float
型別儲存,此時可以發現:
x == 0
時:result == 0f
、resultMask == -HUGE_VALF
x != 0
時:result == ceil_ilog2(x)
、resultMask == 0
因此最後只要將 result
的記憶體內容與 resultMask
進行 OR 運算,並以 float
形式回傳即可以 Branchless 的方式在 x
為零時回傳 -HUGE_VALF
。
使用
可以找到 ilog
相關的巨集定義,然後看到找到
出現在 drivers/net/ethernet/mellanox/mlx4/mlx4_en.h 中。而它使用到的 ilog
以及 roundup_pow_of_two
都是定義在 include/linux/log2.h 中。
首先先看 ilog
的部份:
可以看到它使用了 __builtin_constant_p(n)
來判斷 n
在編譯期間是否為常數,若是的話就會使用 (n) < 2 ? 0 : 63 - __builtin_clzll(n)
,推測是編譯器在編譯時期就可以直接計算出結果。
而為了驗證這個推測,可以利用前面提到的 GCC 的 __builtin_constant_p
來做簡單的測試:
這個測試程式可以用來檢查 ilog2
的結果是否能夠在編譯時期得知,其中 ilog2
的實作為:
為了方便進行測試,將 n
不為常數時的實作改用其他方式實作,但不影響這次測試的結果。
而測試結果則為:
可以看到當 ilog2(n)
的 n
以常數或是 static
變數帶入時,可以看到 ilog(n)
的結果確實可以在編譯時期就知道結果,與推測相符。
但比較意外的是以 const
修飾的變數 var1
反而沒辦法在編譯時期就得知結果。
TODO
若是非常數的話則會根據 32 或是 64 位元使用對應的 ilog2
函式處理:
可以看到這邊直接使用了 fls
來找最高位的 1,且可以從 #ifndef
的使用以及註解可以看到,若是硬體架構能夠提供比 fls
還要快速的計算方式的話,就會使用對應的 ilog
方式處理,而有提供的架構可以用以下方式尋找:
可以發現 x86
並未出現在上面的結果中,因此會使用上方的實作用 fls
進行計算,而 fls
則在 x86 架構中有提供更快的處理方式,可以在 arch/x86/include/asm/bitops.h 看到它的實作是使用 BSLR
指令處理。
嘗試到上方列出的 arm
架構的目錄下透過 $ grep -ir --include="*.[ch]" "ilog2"
尋找 ilog2
的實作,但沒有找到 ilog2
的相關實作。
但在 arch
目錄下尋找的話,則會發現反而是上方沒有列出的 powerpc
架構下有提供相關實作,定義在 arch/powerpc/boot/ops.h 中。
BSF (Bit Scan Forward)
與 ffs
的作用相同,是從最低位元開始沼地一個 1 的位置,而 BSR (Bit Scan Reverse)
指令則是從最高位元開始找,即與 fls
的作用相同。
比較特別的部份是當 x
為 0 時 fls
,的輸出應為 0,然而根據 Commit Message 的說明,Intel64 與 AMD64 的 BSR
指令在 x
為 0 時會有不同的表現:
Intel64:
在 Intel® 64 and IA-32 Architectures Software Developer's Manual Volume 2A: Instruction Set Reference, A-L 的 3.2 INSTRUCTIONS (A-L) 中 BSR
說明了 x
為 0 時目標暫存器的值是未定義:
If the content source operand is 0, the content of the destination operand is undefined.
The ZF flag is set to 1 if the source operand is 0;
AMD64:
然而在 AMD 對 BSR
的指令說明文件中 則寫到當 x
為 0 時目標暫存器的值不會被修改。
If the second operand contains 0, the instruction sets ZF to 1 and does not change the contents of the destination register.
而在這則 Commit 前的實作是:
這個實作只區別了是否有 CMOVZ
指令能夠快速在 ZF
Flag 為 1 時(即 x
為 0 時)將輸出的變數 r
設為 -1;若沒有 CMOVZ
的話則需要使用更多指令才能將 r
設為 -1。
而在該 Commit 訊息中有提到:
The Intel x86_64 specification, however, says that the result of BSR/BSF in such a case is undefined. That said, when queried, one of the Intel CPU architects said that the behaviour on all Intel CPUs is that:
with BSRQ/BSFQ, the 64-bit destination register is written with its original value if the source is 0, thus, in essence, giving the effect we want. And,
with BSRL/BSFL, the lower half of the 64-bit destination register is written with its original value if the source is 0, and the upper half is cleared, thus giving us the effect we want (we return a 4-byte int).
Further, it was indicated that they (Intel) are unlikely to get away with changing the behaviour.
CS:APP 3.4.2 WLQ suffix
從這個說明可以看到在 x86_64 架構下,不論是對 64 位元資料或是 32 位元資料操作,其實都不會修改到輸出的 64 或 32 位元暫存器的內容,因此若是在一開始就將目標暫存器設為 -1 的話,就可以避免 CMOVZ
這個 branch。
從該 Patch 的討論來看,這則 Commit 訊息中的 one of the Intel CPU architects 看起來是有人直接詢問 Intel 的架構師。
因此在這個 Patch 中進行了以下修改:
可以看到當架構為 x86_64 時,透過 GNU C Extension 中 Extended Asm 中 InputOperands 的 constraint 特性:
Input constraints can also be digits (for example, "0"). This indicates that the specified input must be in the same place as the output constraint at the (zero-based) index in the output constraint list.
這會讓變數 tmp
直接使用第 0 個 Output Operand ,也就是變數 r
,對應的暫存器,即相當於將變數 r
的初始值設為 -1,因此不需要使用 CMOVZ
進行額外確認。
這個 Commit 是另外用了一個變數儲存 -1 作為 r
的初始值,但在之後的 Commit 指出了指令的結果以及回傳值都是 32 位元,因此可以不用另外用一個 long 型別的變數 tmp
儲存 -1,直接寫成
就可以了。
在 64 位元的 fls
實作中:
可以看到它是直接將儲存結果的變數 bitops
初始值設為 -1,而不像 fls
是使用 Input Constraint 設定初始值,不太明白為什麼 fls
不使用相同的方式就好。
而在該 Commit 訊息中也有簡單的測試了使用這個特性後的效能改進,可以看到修改後(第三個結果)的執行時間只有原本(第二個結果)的一半左右:
而若硬體架構沒有支援 fls
的話,則會使用 include/asm-generic/bitops/ 中 fls.h
以及 fls64.h
各自的實作,其中 32 位元版本的 fls
實作如下:
概念上與這題使用的技巧相似,但是位移方向相反,當最高位元的 1 不在高位元部份時,就左移掉高位元部份。而 64 位元版本則根據 BITS_PER_LONG
分成兩種實作方式:
當 BITS_PER_LONG
為 32 位元時,就先檢查最高位的 1 在哪側決定是否要 + 32
,接著再以 32 位元版本的 fls
找出實際位置。而當 BITS_PER_LONG
為 64 位元時,則是只檢查 x
為 0 的特例,其他狀況則是用 __fls()
計算最高位 1 的位置,並將結果加一使 fls64()
回傳的 Index 是從 1 開始數。
fls64.h
中只有 #include <asm/types.h>
這個不包含 fls
以及 __fls
的標頭檔,且根據 Commit 訊息,它是因為 __u32
與 __u64
未被宣告才被加入的,最初的實作甚至連 include 都沒有,看不出來這邊的 fls
以及 __fls
是使用哪裡的實作。
而 roundup_pow_of_two
也與 ilog2
相似,會先檢查參數在編譯期間是否為常數,並採取不同的策略:
當 n
為常數時,會使用前面說明的 ilog2
巨集,並用了與這題相同的技巧(先找 n - 1
的 ilog
再將結果加一),最後再進行左移即可得到 ,即首個大於等於 n
的 2 的冪。
而在 n
不為常數時則會呼叫 __roundup_pow_of_two
這個函式:
其中的 fls_long
則是定義在 include/linux/bitops.h 中,會根據 long
型別的長度判斷要用 32 函式 64 位元的 fls
實作:
型別 size
是固定的,應該也可用 #ifdef
判斷 BITS_PER_LONG
來決定要用哪個時長度的。
drivers/net/ethernet/mellanox/mlx4/mlx4_en.h
如同 LWN - ACCESS_ONCE() 這篇文章中的說明,在某些情況下,編譯器可能會為了最佳化調整某些程式碼的執行順序,但在這在某些情況下會出現問題,例如這題的題目敘述的情形,因此需要有些機制來保證某些值不會被改變。
而根據 6.7.3 Type qualifiers 中註腳 134 的說明:
- A volatile declaration may be used to describe an object corresponding to a memory-mapped input/output port or an object accessed by an asynchronously interrupting function. Actions on objects so declared shall not be ‘‘optimized out’’ by an implementation or reordered except as permitted by the rules for evaluating expressions.
可以看到有被 volatile
修飾過的變數不應在編譯器最佳化時被重排或是移除。
但若是一變數在宣告時就指定了 volatile
的話,可能會造成即使是沒有問題的部份也不能被編譯器最佳化,因此在 Linux 核心中使用了 ACCESS_ONCE 這個巨集來「暫時」將變數轉成 volatile
,以讓特定部份的程式碼可以避免被重排,其實作如下:
很單純的,就是將一變數的地址取出,並轉形成對應型別的 volatile
版本後再取值使用。
然而根據 LWN - ACCESS_ONCE() and compiler bugs 以及這則 Commit 訊息中的說明,這種寫法的 volatile
會在 x
不為 scalar type 時被編譯器忽略,造成實際執行情況可能會不符合預期。
因此在該 Commit 以及其後的幾個相關 Commit 中對 ACCESS_ONCE
巨集做了些修正:
READ_ONCE
以及 ASSIGN_ONCE
的實作 [Commit]為方便解讀,上方的巨集實作為原實作格式化後的結果。
__read_once_size
__assign_once_size
分別是:data_access_exceeds_word_size
ACCESS_ONCE
限定 Scalar Type 使用 [Commit]透過另外建立一個與參數 x
相同的變數 __var
並將其賦值為 0 來確保 x
的型別為 Scalar Type。
__maybe_unused
是被定義在 include/linux/compiler_attributes.h
的一個巨集,實際的定義為 __attribute__((__unused__))
。
而 __attribute__(())
是 GNU C Extension 之一,根據文件的說明:
This attribute, attached to a variable, means that the variable is meant to be possibly unused. GCC will not produce a warning for this variable.
可以知道它只是為了避免編譯器產生「無用變數」的警示訊息。
而在這個實作中使用這個 attribute 的原因是 __var
只是用來檢查型別,實際上並未用到,即編譯器會警告的「無用變數」。