執行人: david965154
專題解說影片
56han
無法顯示圖片,可能原因
已補上
探究 Linux 核心原始程式碼內的位元操作,知曉其原理並羅列後續改進的機會。
針對 Linux v6.8+ (Ubuntu Linux 24.04 搭配的 Linux 核心版本)
Linux 核心的 include/asm-generic/bitops 存在若干實作不一致的狀況,本任務嘗試追蹤 git log 和 LKML,以理解發展脈絡,並嘗試用精簡有效的方式改寫,最後嘗試貢獻程式碼到 Linux 核心。
自 shiritai
的文件內提到
ffs
及__ffs
加雙底線與否的不同
首先先釐清ffs
及__ffs
(是否加雙底線) 的不同之處:參考bitops
系列對外的介面:arch/arc/include/asm/bitops.h
中的註解得知
__XXX
系列: 以 index 0 索引,範圍為0 ~ {length of input type - 1}
XXX
系列: 以 index 1 索引,範圍為1 ~ {length of input type}
為何會有 0 及 1 開始的問題 ?
讓我們先來看以下兩種 __ffs
實作:
上面第一個並沒有說明若輸入 word 為 0 的情況該如何處理,但透過程式碼可以看出就是回傳 word ( 0 ),而下面則明確表示 word 為 0 是例外情況,需自行處理,若強行輸入函式將會出錯。
bottom up trace 第一個 __ffs
在 Operating Systems 2 - Synchronization in Linux Kernel 裡提到
There are also nonatomic version of those functions, which names starts with __ (double underscore) prefix. The kernel also provides two function that search for the first set or cleared bit starting at a given address. Those are called respectively find_first_bit() and find_first_zero_bit(). If only one word (32 or 64 bits) should be searched, then the faster functions __ffs() and __ffz() can be applied.
這些 double underscore prefix 的函式是 nonatomic version
而在 Leading Underscores in Linux Kernel Programming 中提到
Identifiers starting with a double underscore are reserved by the C programming language specification. This means, it is illegal to define your own identifiers starting with a double underscore. (The same applies to identifiers starting with a single underscore followed by a capital letter.)
So, technically, this means that the source code of the Linux kernel is not standards-compliant C code.
However, the Linux kernel is very low-level code and it can only be compiled with specific versions of a small number of C compilers, therefore, the Linux kernel developers know exactly which __foo identifiers are used by the compiler and which they can use themselves.
So, the Linux kernel uses identifiers starting with double underscore for the same reason that the C standard forbids them: to make sure, there can be no conflicts with user-defined identifiers.
For example, some Linux kernel header files end up being included in the low-level system libraries (GNU libc, musl, dietlibc, etc.), and using identifiers that are explicitly reserved guarantees that this will not lead to conflicts.
也就是供內部執行並避免命名衝突。
事實上第一種實作的 __ffs
內的檢查 word 是否為 0 是不必要的,因為其呼叫的 ffs
已檢查。下一段中會列舉出 Linux v6.8+ 內include/asm-generic/bitops 內 9 種 ffs
、 fls
實作
在以下列出各種實作方法後可以大致歸納出:
第一種是可以接受輸入 word 為 0 的情況,那麼有個問題是第一個的回傳值範圍是 0-31 ,若 word 為 0 將會回傳 0 ,那麼這個 0 代表的到底是 first set bit 在第 0 位還是 word 為 0 ?
這樣其實可以大致想出 __
prefix 因為內部使用,在使用前已經避免輸入為 0 的情況因此可以直接使用,而 no-__
prefix 供外部使用可以直接處理輸入為 0 的情況。
因此會分出 0 based 及 1 based 的原因是在於,當使用者在使用 no __
prefix 的函式時可以明確知道其輸入的變數為 0 ,而 __
prefix 則無法得知此一資訊,甚至會因為不知道 0 是代表其 set bit 的位置還是輸入就是 0 。
針對 Linux v6.8+
在目前 Linux 核心版本 6.9.3 中,include/asm-generic/bitops 共有 9 種 ffs
、 fls
實作。
如下:
include/asm-generic/bitops/__ffs.h
!
將 if
判斷式內寫得更簡潔。include/asm-generic/bitops/__fls.h
include/asm-generic/bitops/builtin-__ffs.h
__builtin_ctzl
計算 tail zero
,即等同 __ffs
(這裡是有 prefix __
的 __builtin_ctzl
,是 zero based )include/asm-generic/bitops/builtin-__fls.h
__fls
使用 __builtin_clzl
(這裡雖然是有 prefix __
的 __builtin_clzl
,但它居然是 one based !! ) 先計算 leading zero
再透過一個 word size bits 減去 leading zero
及 1 得到 fls
__builtin_ffs
達成,但具有 prefix __
的 __builtin_ffs
,但它居然是 one based !!x >>= 1;
是可以被省略的。為什麼是 32 bits ???
Linux 核心支援的硬體架構從 32 位元開始,且其 64 位元架構支援依循 LP64
這裡處理無號整數 ( unsigned integer ) ,為何上一種是有號?
我想不太出來為何這裡為什麼會是 ffs
接收一有號整數,而 fls
接收一無號整數,並沒有經過比較運算,以二進制表示的話無論是否 unsigned 皆相同,並沒有必要以不同方式接收。
fls
, 64 bits 版本使用 __fls
再加 1 。其中
__builtin_XXX
的函式本身以 CPU 硬體指令實作,過濾例外情況 0 作為輸入以避免 undefined behavior 是必備手續
這幾個版本完全看不出兩者的差別是 nonatomic 及 atomic (不可被分割的操作就是 atomic operation,可以免去使用 spin lock 以防止資料寫入讀取衝突)
6 有對 x 為 0 的情況作處理,而 5 則無。
單單僅是要計算 last set bit 及 first set bit ,就用了多達 9 種函式,這尚未包含其它處理器使用機械碼的版本,如 x86, csky, m68k, s390 等,以 arch/csky/include/asm/bitops.h 為例:
以組合語言撰寫:
整合這些複雜的 ffs 及 fls 成一範式將可以使
f{f,l}s
系列函式的軟體實作前篇執行人 Shiritai
完成了此一範式並透過理解編譯器優化改善執行上效能的不足。
在重現前為避免隱藏成本 (編譯時間、 code size ) 過高,我閱讀了 Linux kernel coding style 裡的 The inline disease
15) The inline disease
There appears to be a common misperception that gcc has a magic “make me faster” speedup option called inline. While the use of inlines can be appropriate (for example as a means of replacing macros, see Chapter 12), it very often is not. Abundant use of the inline keyword leads to a much bigger kernel, which in turn slows the system as a whole down, due to a bigger icache footprint for the CPU and simply because there is less memory available for the pagecache. Just think about it; a pagecache miss causes a disk seek, which easily takes 5 milliseconds. There are a LOT of cpu cycles that can go into these 5 milliseconds.
A reasonable rule of thumb is to not put inline at functions that have more than 3 lines of code in them. An exception to this rule are the cases where a parameter is known to be a compiletime constant, and as a result of this constantness you know the compiler will be able to optimize most of your function away at compile time. For a good example of this later case, see the kmalloc() inline function.
Often people argue that adding inline to functions that are static and used only once is always a win since there is no space tradeoff. While this is technically correct, gcc is capable of inlining these automatically without help, and the maintenance issue of removing the inline when a second user appears outweighs the potential value of the hint that tells gcc to do something it would have done anyway.
簡單來說,裡面提到 inline
可以加快運行速度,但如果太多函式使用 inline
會導致核心過大而導致 cache 佔用空間更大,這會直接增大 cache miss 機會,根本得不償失,因此根據經驗,使用 inline
的函式需少於 3 行,而例外情況是函式內大多為常數,因為編譯器可以很好的最佳化這種函式,而上面的版本 1, 2, 7, 8 正使用了大量的常數而使其突破了此限制。不過在 linux/include/linux/rbtree.h 裡的函式 rb_find_add
似乎無視此限制,況且裡面呼叫了其他函式。因為我並不太清楚這其中明確的 inline
使用與否的界線,所以這裡暫時不去處理,但會盡量簡化其程式碼。
因此裡頭有些 inline 沒發揮作用
Jserv
因此以下我改以使用大量常數版本的實作
Linux 預設使用的 O2
最佳化選項,以下為原先作者版本產出之 Assembly code
原版程式碼使用 loop unrolling
可以看到原版程式碼因為僅使用少數的常數操作可能會導致較不適合使用 inline ,除了先檢查編譯時是否為已知常數也可直接修改其程式碼。我更新為以下,直接免除了需要 loop unroll 的操作,並且是在使用大量常數的情況下使用 inline ,也使用了原本就用的 ffs/fls 操作,使維護者無需再費心重新理解其演算法。另外為了處理 128 bits 或者更大的的整數類型,我在前面使用了 while 迴圈,後面多個 if 判斷式則是因為 loop unroll ,若超出 32 bits 才進入 while 迴圈。
我的版本可直接使用
得到原始版本經過 loop unroll 一模一樣的版本,但無須再經過 loop unroll 的操作。
若不使用常數操作而並合併入 while 迴圈 ( 也較原先版本更簡短 )
直接使用
機器碼則會與原先版本未經 loop unroll 相同,因此不併入並直接將迴圈展開會較佳。
接下來, Shiritai
僅希望在特定位置採用 loop unroll ,而採用了之後的 Assembly code 如下:
可以看到與修改編譯參數的結果相同,但我的版本仍無需做這些操作。
以下為 ffs/fls
、ffz
、clz/ctz
的圖示,可以發現其實目的差不多,並且僅差一位
若使用 ffs/fls、clz/ctz 函式,將會得到 (1 based):
透過以上關係, Shiritai
認為可以將五種函式結合,先以相同方式實作 fls
以下為 Shiritai
筆記中提供的示意圖及實作
via 和 through 不該翻譯為「通過」,否則無法區分 "pass" 的翻譯,可改說「藉由」或「經由」。
不過事實上 Linux kernel 內的 clz/ctz
已經找不到軟體實作,皆是前綴 __builtin
的硬體實作及因為 CLZ 操作可以通過 簡單的硬體電路實現,因此在硬體設計中增加這一指令的成本較低而成為組合語言的指令之一,再去強迫他使用軟體實作非常不實際且多餘,因此我將 clz/ctz
移去,並且因為 ffz
在 Linux 核心內程式碼僅短短一行,相較合併的版本,我認為原先版本更簡潔,因此以上部份部分我不打算納入改進,而單純嘗試合併一開始的 ffs/fls
(前綴 builtin_
可除外,因為效能會差非常多,並且原程式碼非常簡潔,約為 1 ~ 2 行)。
最後我決定僅合併 __ffs
/__fls
/ffs
/fls
四種
如 Linux 核心模組, UML (見建構 User-Mode Linux 的實驗環境) 或 virtme,比對產生的機械碼 (至少是 x86-64 和 Arm64),確保提出的改進方案在「行為」層面相容於原有的實作
作法:
ffs/fls
皆改為使用上方 header file 產生的版本dmesg
查看開機訊息可以看到並沒有出現錯誤,不過也有可能是因為根本沒被測試到,因為 ffs/fls
並沒有出現在 linux/lib
內 prefix test
的 .c
檔中,且即使我修改的內容有誤,其也沒有發出編譯錯誤的訊息。而因為其功能太過基本,另外寫一個函式測試又顯得多餘,因此嘗試掛載使用 ffs/fls
函式的檔案並檢查是否正常執行。
以下為測試模組,不過並沒有測試到非常完全
Makefile
編譯過程輸出
以下為掛載後輸出,同一行中兩個值前者為修改後的版本,後者為 arch/x86/include/asm/bitops.h 內的版本,可以看到修改後的版本也更名為 generic_
,這在較新的 Linux 版本已被實作以避免重複定義函式的問題,透過輸出結果可以看到 arch/x86/include/asm/bitops.h 內 __ prefix 的版本並無法處理輸入為 0 的數值,若直接在前面檢查輸入為 0 時回傳 0 也會導致無區分以下第一及第二個測試的情況,驗證了前所述關於 0 及 1 開始的問題。
如果知道是否有前綴 __
的差別的話會想為什麼會出現 __fls : 34, 34 fls : 31, 31
,不是僅差在是否從 1 開始而不該差到這麼大 ? 可以看到上面第 8 種實作,其只接受 unsigned int
作為輸入,因此輸入會被強制轉型成 32 位元大小的值而使其值最大只能到 32 。
現比對產生機械碼是否相容,為避免互相影響,將兩種實作分成 init
及 exit
兩邊執行,再將機械碼輸出。
command 是「命令」,而非「指令」。
執行命令 指令 objdump -d fflstest.ko
反組譯 .ko
比對產生的機械碼
可以看到基本完全相同。不過若將上方迴圈執行的版本反組譯後將會是使用 tzcnt
、 bsr
及 bsf
等相關執行指令。
x86 and amd64 instruction reference 給出的指令描述
TZCNT — Count the Number of Trailing Zero Bits
TZCNT counts the number of trailing least significant zero bits in source operand (second operand) and returns the result in destination operand (first operand). TZCNT is an extension of the BSF instruction. The key difference between TZCNT and BSF instruction is that TZCNT provides operand size as output when source operand is zero while in the case of BSF instruction, if source operand is zero, the content of destination operand are undefined. On processors that do not support TZCNT, the instruction byte encoding is executed as BSF.
Searches the source operand (second operand) for the most significant set bit (1 bit). If a most significant 1 bit is found, its bit index is stored in the destination operand (first operand). The source operand can be a register or a memory location; the destination operand is a register. The bit index is an unsigned offset from bit 0 of the source operand. If the content source operand is 0, the content of the destination operand is undefined.
In 64-bit mode, the instruction’s default operation size is 32 bits. Using a REX prefix in the form of REX.R permits access to additional registers (R8-R15). Using a REX prefix in the form of REX.W promotes operation to 64 bits. See the summary chart at the beginning of this section for encoding data and limits.
Searches the source operand (second operand) for the least significant set bit (1 bit). If a least significant 1 bit is found, its bit index is stored in the destination operand (first operand). The source operand can be a register or a memory location; the destination operand is a register. The bit index is an unsigned offset from bit 0 of the source operand. If the content of the source operand is 0, the content of the destination operand is undefined.
In 64-bit mode, the instruction’s default operation size is 32 bits. Using a REX prefix in the form of REX.R permits access to additional registers (R8-R15). Using a REX prefix in the form of REX.W promotes operation to 64 bits. See the summary chart at the beginning of this section for encoding data and limits.
以上皆為原先 x86 支援的硬體指令,可以推測是因為非迴圈版本輸入值為常數,編譯器可以針對此做最佳化,而迴圈版本輸入值隨著迴圈執行次數變動而無法針對已知常數做最佳化而造成以上差異,不過仍可推測兩者其實相同因此改進是可相容的。
2024q1 第 7 週測驗題 第一題使用 SWAR 技巧的 memchr 改進。
我事先閱讀了 arthurchang09
的筆記
2022q1 Homework5 (quiz8)
原先他提出了使用組語加快執行時間的方法,不過後來因以下原因而改為 C 語言版本
David Laight 將我的程式碼改寫,整題而言較為簡潔易懂,沒有組合語言。也解決 target character 為負時會出錯的問題。
後續不知道為什麼沒有下文,不過最後程式碼應該是以類似 quiz7 形式定案,而 quiz7 類似於以下內文中提到的 glibc-memchr.c 內的寫法。
2022q1 Homework5 (quiz8) answer
由於 Jserv 老師提供的程式碼沒有太多改進空間,因此我嘗試以在不影響程式碼可讀性的前提下盡量減少 code size 及提升效率。
簡單介紹概念:
UNALIGNED(X)
: 要使用 SWAR
須確保存放記憶體位置是有對齊的,即須照著每 sizeof(long)
個記憶體位置放置一個(在這裡是一個字) ,參照 你所不知道的 C 語言:記憶體管理、對齊及硬體特性。
TOO_SMALL(len)
: 可以在中間程式碼看到 mask |= mask << 16;
這些設計 mask
的程式碼,假設在 sizeof(long) == 64
的程式碼, 8 byte
經過 mask
的操作將會變成擁有 8 個指定相同字元 (1 char 1 byte
) 的 long size
mask
,如下:
#define DETECT_CHAR(X, mask) DETECT_NULL(*X ^ mask)
: 這裡就是透過 *X ^ mask
尋找是否有對應的字符,若有則不為 0
,表示已經找到了。
若有經過 SWAR
技巧處理的情況下,最後僅需尋找該位置 long size
的 src
,若 len
過小或 UNALIGNED
的情況下則需逐字符 iteration
尋找。
改動:
macro
(此為 .c
檔,不太確定 macro 至於此是否合適,可以看到原本的 .c
檔並沒有 macro
,同時我使用在 glibc-memchr.c 內看到的程式去改善,如下),同時也合併一些程式碼進迴圈以減少行數。if else
macro 去執行,我不太確定這是否會增加 cycle 數,但是省去後會減少 code size ,因此我選擇移除,並改以 glibc-memchr.c 內的寫法,直接將 mask (0x01010101) 事先處理好,避免在迴圈內做無關迴圈的操作。LBLOCKSIZE
,事先宣告 byte_per_long
為 sizeof(long)
,減少調用 sizeof()
次數。問題:
會不會有前面項 UNALIGNED
,後面 ALIGNED
?
會不會有前面項 ALIGNED
,後面 UNALIGNED
?
更甚會不會有前面項 ALIGNED
,後面 UNALIGNED
,後面又變成 ALIGNED
?
等情況
如果有的話會不會造成改動後的效果變差?
我自己認為如果問題是存在的話,那麼原本的 memchr
也會有出現問題,前面發現 UNALIGNED(src)
不成立,但 len > byte_per_long
,執行到一半卻出現 UNALIGNED
,那麼將會有問題。
最後版本:
結果:
memchr_opt2
為我再修改後的程式碼, memchr_opt
則為 quiz8 內的版本,可以看到的確有微幅提升效能
後來透過參考 arthurchang09
筆記中的
裡面說到
下圖為 memchr_opt 和 glibc-memchr.c 的執行時間比較,測量方式如 memchr 與 strchr 比較 所述,有趣的是兩者執行的時間幾乎相同。
筆記也說到
效能分析之模式參考 https://gist.github.com/nico/2624336 ,測量方式依舊使用 struct timespec ,測量執行兩百次之時間再除以兩百得到執行一次之時間。
的確,若照著筆記提供的程式碼做比較 glibc-memchr 與 memchr-opt 效能會較相近
開啟 O2
選項編譯
實際使用 https://gist.github.com/nico/2624336 方法測試,同樣開啟 -O2
選項編譯,並執行 5 次
卻發現 glibc 內的版本的效能其實是非常些微的更佳,由於測試方法大致相同:
給定一連續記憶體,將其初始化為全 0 ,並在第 k (0-1000) 個位置將其設置為 4 ,並使用不同實作去找出 4 的位置。
我認為是優化使機械碼更好的原因
若直接去看 glibc-memchr.c 內的程式碼,可以看到他寫得相對較為複雜,但開啟 O2
優化,卻發現優化的非常好,反而減少了 10 行,但是是用了什麼方法達成呢?
經過檢查後發現位在最後版本第 10 行的 unaligned 記憶體處理迴圈內的 if(!len)
是關鍵,原本想說儘早發現 len 已經為 0 可以提早離開函式可以避免接下來不必要的判斷式,不過似乎使編譯器比較不容易優化,因此我將其移除統一於最後回傳 NULL ,果然將機械碼減少的比 glibc 的版本更佳,且避免使用乘法及餘數運算。
不過經過調整後發現 glibc 內的還是些微的較快一些,而且在 O1 最佳化時都會較差,而一改成 O2 或以上就能反超。不過因為也有反過來的情況,因為影響的情況眾多,可能需要透過 linux 內建測試函式檢查。
我有試著學 glibc 將 12 行判斷是內的操作全部提出判斷式外,如此一來執行時間會更相近,不過想了一下發現提出後只要 len
此時是小於 byte_per_long
的情況,那麼這幾段操作完全不必要,由於沒辦法得知進入的記憶體位置是否為對其狀態,因此維持原版及早離開函示或避免不必要的操作會比較好。
檢視開平方根的快速計算教材並確認內容正確,倘若有疑慮則提出討論並重新實驗
這裡我添加了快速反平方根的 magic number 尋找方法
見 從 √2 的存在談開平方根的快速運算