執行人: ericlai1021
專題解說錄影
擴充 fibdrv,強化其並行處理能力,預計達成:
基於先前作業已完成部份,請參閱 ericlai1021-fibdrv ,先以 perf stat
分析程式碼,作為後續比對的基準
此部份為了方便後續做實驗比較,程式皆在 user space 執行
接下來使用 perf record
量測 call graph (省略部份內容)
bn_to_string
,由此可見大數由二進制轉換成十進制的成本非常高,更不用說考慮到執行在 kernel space 時 copy_to_user
的成本,因此改善此部份勢必具有明顯的效能增益bn_mult
,這部份的實作為參考傳統乘法器原理,因此會有大量的加法以及左移運算,需要提出一個更高效能的計算方法主函式內採用 fast_doubling
演算法實作大數運算,然而 fast_doubling
的特色為會紀錄第 2n
項以及第 2n + 1
項的結果,若要進一步加速此運算則可以採用 Q-Matrix 搭配 Exponentiation by squaring
的技巧
所謂的 Q-Matrix 可以進一步將費氏數列改寫成以下形式:
Exponentiation by squaring 則可以進一步將 的計算改寫成:
對應的程式碼如下:
實驗分析
Q-Matrix 的效能明顯比 Fast Doubling 來的差,其實不難理解是因為 Fast Doubling 其實就是 Q-Matrix 的改良,而直接使用 Q-Matrix 計算的話反而會額外多出許多計算量
整理後可得
使用上述式子實作比起作業說明的 範例實作 可以減少一次迴圈的計算以及省去掉減法的運算
bn_cpy
來更新變數的數值,其實可以藉由 bn_swap
以及改變各函式儲存結果的位置來達到同樣的目的,因此就將所有的 bn_cpy
去除改用 bn_swap
以降低複製資料造成的成本bn_swap
的實作如下一開始的想法是將兩個 bn *
型態的位址互換即可
結果發現根本沒有互換成功,於是先將 a
與 b
的位址印出來看,發現在呼叫 bn_swap
函式後 a
與 b
的位址並無交換,但在 bn_swap
函式內兩個指標位址確實有交換,研讀課程教材 〈你所不知道的C語言:指標篇〉後才了解到原來 C 語言在函式呼叫皆是 call-by-value ,上述程式的行為是呼叫 bn_swap
時會傳遞 ptrA
與 ptrB
的位址,而 bn_swap
函式會產生 bn *
型態變數 a
與 b
分別將 ptrA
與 ptrB
的位址存在其中,示意圖如下
執行 bn_swap
函式後會變成下圖所示
若要正確交換,則必須要使用「指標的指標」技巧,因為 bn
資料結構中 number
紀錄的是指標,所以可以透過以下方式將兩個 bn
型態變數的內容互換,而不會改變儲存在 heap 中的數值
C 語言沒有 "call-by-reference",只有數值傳遞 (包含指標在內,都是數值),請研讀〈你所不知道的C語言:指標篇〉,用精準的描述。
已修正認知
示意圖如下
執行 bn_swap
函式後會變成下圖所示
實驗結果如下 (v1 綠線為修改的 fast doubling 並將所有 bn_cpy
換成 bn_swap
)
完全出乎意料之外的竟然沒有改善,為此我反覆測驗程式並檢查程式碼是否正確,但最終的結果確實如此,於是我就去查看兩個實驗的 call graph
從 call graph 就可以看出來原來是因為大數的乘法運算太花費時間,兩個實驗都有 94% 左右的時間在執行 bn_mult
,所以才會導致上述所作的改善微乎其微
bn_mult
的效能原本的 bn_mult
實作是參考傳統硬體乘法器,如此次來假設乘數有 x 個位元,則 worst case 就會需要執行 x 次的 bn_add
以及 x-1 次的 bn_lshift
,可想而知這對大數運算非常不適合,因此初步參考 KYG-yaya573142 的作法,概念即為直式乘法,將兩個大數的數字陣列依序兩兩相乘,接著將結果直接疊加到輸出的變數
實驗結果 (v2 綠線)
接著進一步發現第三層迴圈的用意為將每一輪相乘的結果疊加到輸出的變數,其實只需要將相乘的結果與輸出變數相加,再用一個變數 carry
儲存溢位的部份加到下一輪的相加結果就好
實驗結果 (v3 綠線)
原先 bn
結構體中數字陣列 number
的資料型態是 unsigned int
,為了能充分利用 64 位元處理器的特性改使用 uint64_t
確保每次的記憶體存取都是一個 word 的大小。
bn
結構體的資料型態,以便於根據不同 word 大小切換定義__int128
實作實驗結果如下 (v4 綠線)
參考 bignum/apm_internal.h 當中乘法運算使用內嵌組合語言 (inline assembly) 來直接取得乘法運算的高位與低位,直接使用一樣的方式實作乘法,取代原本使用的 __int128
由於我目前還無法完全理解 bignum/apm.c 的實作,所以就初步按照自己目前對程式碼的理解進行實作
實驗結果如下 (v5 綠線)
結果看起來差異不大,將範圍放大至10000項來看
結果顯示我的實作效能較差,所以看起來差異真的是在處理如何將乘積疊加到輸出變數那邊,於是我先做實驗驗證看看
實驗結果如下 (v6 綠線)
上述實作是利用無號整數不會 overflow 的特性,舉例假設有兩個 4 bits 的整數 a
與 b
且兩個數皆為 4 bits 可表示最大數值 (即 15, 二進制表示成 1111),則 a + b
等於 30, 二進制表示成 11110 ,但因為是無號整數,所以只會保留 0~3 bit 的值,也就是 1110,這裡就可以看出若兩數值相加後小於其中任一數值,就表示發生 overflow 且只要 overflow 溢位的數值一定是 1 。
藉由上述特性就可以避免使用 __int128
(bn_data_tmp
) 進行計算以節省多餘的記憶體開銷以及資料型態轉換成本。
bn_add
的效能原先的實作會在每次迴圈判斷需要相加的數值,這麼做的優點是只需寫一個迴圈就能完成計算,但缺點是每次迴圈都有兩個 branch 要判斷。參考 KYG-yaya573142 的實作改為使用兩個迴圈進行計算,第一個迴圈先計算兩者皆有資料的範圍,再於第二個迴圈處理 carry 與剩餘的資料範圍。另外,利用上一個改善方案所提及的無號整數不會 overflow 的特性,可以進一步可以避免使用 __int128
(bn_data_tmp
) 進行計算
因為要先計算兩者皆有的資料範圍,所以要先找出兩者當中範圍較小者,但這裡的做法是假設 a
的範圍比 b
大,所以若遇到 a
的範圍比 b
小的情況就必須要將兩者互換,這裡值得注意的地方是原先實作的 bn_swap
函式為交換兩者的內容,但加法運算為了確保兩個輸入變數 (即 a
與 b
) 的內容不會被更改,因此將兩者皆宣告成 const
,如此一來就不能使用原先的 bn_swap
,參考 bignum/apm_internal.h 當中的做法,透過定義一個巨集,交換指定的二個變數的數值。
為了讓加法運算遇到 a == c
或 b == c
依舊能夠正確計算,必須要在 bn_resize
之前將 a
跟 b
的大小 (size) 暫存起來。
為了凸顯 bn_add
對效能的影響,這裡改為使用迭代的方法計算費氏數列
實驗結果如下 (v1 綠線)
bn_sqr
考慮上述 的計算過程,會發現數值 、 與 各會重複一次,利用此特性,先計算對角線任一邊的數值,接著再將數值總和乘二,最後再加上對角線上的 、 與 。藉由此法,平方運算的成本可由本來的 次乘法降為 次乘法
實作參考 KYG-yaya573142 及 bignum/sqr.c
實驗結果如下 (v7 綠線)
將範圍擴大至第 20000 項就可以明顯看出改善
觀察 bignum/mul.c 及 bignum/sqr.c 皆有使用 Karatsuba 演算法來加速乘法與平方運算,因此接下來一樣實作該演算法來提升效能
先放上 v7 版本與 bignum 的效能差異來觀察後續改進的成效
v7 版本的 call graph (只擷取部份內容)
Karatsuba 的概念是將 、 以第 位數為界,拆成兩半 、、、,把這他們視為較小的數相乘,然後再透過左移補回 、 損失的位數,以二進位為例:
則 可以化為:
上述算法計算 、、 需要 4 次乘法,我們還可以透過以下技巧縮減為 3 次乘法:
觀察 展開的結果
移項之後,我們就能利用 、、 來計算
最後計算 便能得到 相乘的結果,且 可以用左移運算代替。
再舉個例子,假設所採用的處理器只支援 8 位元乘法,當 、 超過 8 位元時,可以透過分治法實作 Karatsuba。、、、 的位元數超出處理器的乘法的位數時,就把他們再切為 、、、、…,再使用 Karatsuba 計算。以下以兩個 16 位元數值相乘變成 32 位元來演示
由上圖可以看出計算 、、 時,透過分治法將 、、、 切成更小的數字執行乘法運算。最後再用左移與加法計算 即可求得結果。
至此可透過分治法,運用 Karatsuba 演算法計算任意位數的大數。
實作參考 KYG-yaya573142 與 bignum/mul.c
首先來看 bn_mult
函式的修改
do_mult_base
並定義切分界線 KARATSUBA_MUL_THRESHOLD
(bignum 範例程式定為 32),因為 do_mult_karatsuba
函式假設 與 為相同 size
,因此判斷 的 size
若小於 KARATSUBA_MUL_THRESHOLD
則執行一般的乘法運算 (即 do_mult_base
函式),否則執行 do_mult_karatsuba
函式do_mult_karatsuba
後若 的 size
大於 則要將 加到 ,實作概念為判斷 asize
與 bsize
的差若大於等於 bsize
,則使用 Karatsuba 乘法計算,直到兩者的差小於 bsize
則使用一般乘法計算接著看 do_mult_karatsuba
函式
(half_size + 1)
的空間存放,相乘後的 size
會來到 (even_size + 2)
也就是 (size + 1)
, 可想而知這樣對空間的使用效率不佳,因此可以進一步將 的計算改寫成 c[0..even_size-1]
及 c[even_size..2*even_size-1]
,因此只需要將該部份取出並累加到 即可c[0..even_size-1]
的變數 tmp
已經不需要使用,因此這邊可以將 存放到 tmp[0..half_size-1]
存放到 tmp[half_size..even_size-1]
, 減少了額外配置空間的成本carry
加到 a[0..even_size-1]
b[0..even_size-1]
,但若 與 皆具奇數 size
, 舉例來說 、 ,則 為a[0..even_size-1]
b[0..even_size-1]
,接下來要將剩餘部份加回去 ,因此我們需要加上 a[size-1]
b[0..size-2]
以及b[size-1]
a[0..size-1]
實驗結果如下 (v8 藍線)
實作參考 KYG-yaya573142 與 bignum/sqr.c
基本上跟 karatsuba 乘法運算一樣,差別就在平方運算的乘數與被乘數一樣,因此不需要去額外處理兩者 size
不同的情況。
bn_sqr
函式內判斷 src
的 size
是否小於 KARATSUBA_SQR_THRESHOLD
(bignum bignum 範例程式定為 64),小於則執行 do_sqr_base
函式(一般的平方運算),否則執行 do_sqr_karatsuba
函式do_sqr_karatsuba
函式實作與 do_mult_karatsuba
基本一樣,因此以下只講不同的地方實驗結果如下 (v9 藍線)
圖中設定的閾值與 bignum 一樣,經實驗驗證放大或縮小閾值並不會顯著提升效能
v9 版本的 call graph (只擷取部份內容)
可見使用 Karatsuba 演算法後乘法與平方運算的時間佔比合計從 v7 版本的 99.32% 降低為 95.4% ,但仍然是程式執行時間佔比最高的運算,其中可以看到使用 Karatsuba 演算法後 call graph 出現多次相同函式的遞迴呼叫,如此可以看出 Karatsuba 演算法的實作主要依賴遞迴方式實現
bn_to_string
原始 bn_to_string
的實作原理是不斷將大數除以 10 取餘數,並將大數更新為商數,直到大數為零,此作法時間複雜度為 , digit 為大數的二進制位元數、size 為大數的大小、 為大數,參考 KYG-yaya573142 的實作方式從大數的 MSB 起逐位元將數值累加到字串當中,此作法的時間複雜度為
實驗結果如下 (v1 綠線)
參考 bignum/format.c 的實作方式,先定義 max_radix
為 ,相當於 uint64_t
可表示的數值範圍中最大的 10 的冪的值,每次迴圈藉由將大數除以 max_radix
獲得一個 uint64_t
可表示的 10 進制的值,再用一般 2 進制轉 10 進制的方法將數值存放到字串當中
目前看到 bignum/format.c 裡計算 的部份 (即 apm_string_size 函式),當中如果 radix 不為 2 的冪時,結果是回傳 (radix_sizes[radix] * (size * APM_DIGIT_SIZE)) + 2
,可以理解為了要無條件進位所以 +1 ,但為什麼會是 +2 呢?
目前的猜測是因為要彌補乘以
radix_sizes[radix]
所產生的誤差ericlai1021
radix_size
為表示 1 Byte 的數值所需的 10 進制位數, BN_WSIZE
為一個 word 的大小 (單位為 Byte
),最後加上一個字元的大小存放字串結尾符號 \0
bn_to_string
函式傳入大數的數字陣列開頭指標、大數的 size
及輸出的字串的開頭指標,並回傳轉換後的字串開頭指標bn_to_string
函式
MAX_RADIX
(即 ) 取得餘數為 uint64_t
可表示的大數轉換成 10 進制的數值,將大數更新為商數;接著將餘數透過單精度運算取得 10 進制的個別位數bn_ddivi
函式
size
及 MAX_RADIX
,將大數除以 MAX_RADIX
,商數更新為新的大數,並回傳餘數divq
,此指令的輸入為被除數的高位與低位以及除數,並輸出商數與餘數實驗結果如圖 (v2 綠線)
放上與 bignum 的比較
使用 python 撰寫一驗證程式 verify.py ,確保程式至少可以正確計算到第一百萬項
先附上最終的 程式碼 ,後續會逐步更新至 GitHub
copy_to_user
傳送的資料量原先對 fibdrv 呼叫 read(fd, buf, size)
時,會在 kernel space 將計算好的大數結構體 bn
轉換成十進制表示存放於字串當中,並透過 copy_to_user
將該字串從 kernel space 複製到 user space,但由於轉換後的字串每個字元只會存放 0~9 其中一個數值,因此光是傳遞一個字元就會浪費 4 位元的空間,為了減少空間的浪費,可以將大數轉換成十進制的操作 (即 bn_to_string
函式) 搬到 user space 來執行,讓 copy_to_user
直接傳遞大數結構體當中的二進制數值。
參考 作業說明 當中的實作,先計算大數的 leading zeros ,接著呼叫 copy_to_user
時不傳送全為 0 的位元組。
bn_clz
函式搭配 GCC 內建函式 __builtin_clzll
來計算大數的 leading zero bits 數量,其中 >> 3
右移操作計算 leading zeros 的位元組數量。針對 little-endian 架構,非零的位元組會被存在較低的記憶體位址,因此呼叫 copy_to_user
時只需要傳送 數字陣列總 byte 數 - leading zero byte
就可以不傳送全為 0 的位元組。
將複製的 byte 數量作為 read
的回傳值傳回 user
在 user space 中使用 memcpy
將 buf
字串內容複製到數字陣列後就可以執行 bn_to_string
函式將此數字陣列轉換成十進制字串表示
實驗結果如下 (計算到第 10 萬項,時間為 copy_to_user
函式執行的時間)
bn_to_string
函式執行在 kernel space ,因此 copy_to_user
會傳送轉換後的字串bn_to_string
函式執行在 user space , copy_to_user
會直接傳送大數的數字陣列copy_to_user
直接傳送大數的數字陣列確實可以有效節省空間預期引入 Linux 核心的 hlist
系列 API 儲存已經計算過的值,目前實作以 中的 n 作為 key
Linux 核心的 hash table 實作中,用以處理 hash 數值碰撞的 hlist_node
:
pprev
宣告成指標的指標為了方便之後刪除節點的操作,詳細解說請參閱 Linux 核心的 hash table 實作示意圖如下 :
新增一個自定義結構 hdata_node
嵌入 hlist_node
並包含一個指向大數結構體 bn
的指標,用以儲存 value
將 當中的 n 作為 hashtable 的 key ,value 則指向計算後的 bn
結構體
呼叫 fib_read
函式時先判斷 hashtable 該 key 值是否有值,若有值則直接從 hashtable 中取用,因為這裡 hashtable 設計為將 的 n 作為 key 值,所以不會發生 collision。
is_in_ht(offset)
函式判斷 hashtable 中的第 offset 個位址當中是否有值
hlist_empty
函式判斷該 key 值對應到的 list 是否有值執行 client
,測試程式為先計算 至 ,接著再反著計算回來,使用 printk
測試是否有正確運行
在使用 rmmod
卸載 fibdrv
模組時會呼叫帶有 __exit
macro 的函式, kernel 會將這個函式放入 read-only 的 __exit
section 中,可參閱 linux/init.h
因此,需要在帶有 __exit
macro 的 exit_fib_dev
函式當中對 hashtable 進行記憶體釋放
release_memory
函式當中使用 hlist_for_each_entry_safe
走訪hashtable 當中的 list 的每個節點,將該節點中的大數結構體釋放並將該節點從 list 當中移除
實驗結果如下
量測計算 至 ,並從 至 的時間
100001
至 200002
為由 至 的時間測量在前半部份,也就是計算 至 時,因為 hashtable 當中都沒有值,所以會如同原先的實作一樣,而後半部份因為 hashtable 中都已經有儲存計算過的值,所以會直接從 hashtable 中取用。
將後半部份的資料獨立出來看,可以看到整體趨勢已經是常數時間了
概念如同上述作法,將 hashtable 機制引入到 bn_fib_fast
函式內,考慮 fast doubling 的特性,紀錄第 N 個和第 2N 個 Fibonacci 數
先用 dmesg
觀察執行過程
實驗結果如下 (計算至第十萬項)