--- tags: linux2022 --- # 2022q2 Homework (fibdrv) contributed by <`ray90514`> ## 實驗環境 ```shell $ gcc --version gcc (Ubuntu 9.3.0-17ubuntu1~20.04) 9.3.0 Copyright (C) 2019 Free Software Foundation, Inc. This is free software; see the source for copying conditions. There is NO warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. $ lscpu Architecture: x86_64 CPU op-mode(s): 32-bit, 64-bit Byte Order: Little Endian Address sizes: 43 bits physical, 48 bits virtual CPU(s): 8 On-line CPU(s) list: 0-7 Thread(s) per core: 2 Core(s) per socket: 4 Socket(s): 1 NUMA node(s): 1 Vendor ID: AuthenticAMD CPU family: 23 Model: 24 Model name: AMD Ryzen 5 PRO 3500U w/ Radeon Vega Mobile Gfx Stepping: 1 Frequency boost: enabled CPU MHz: 1675.446 CPU max MHz: 2100.0000 CPU min MHz: 1400.0000 BogoMIPS: 4192.05 Virtualization: AMD-V L1d cache: 128 KiB L1i cache: 256 KiB L2 cache: 2 MiB L3 cache: 4 MiB NUMA node0 CPU(s): 0-7 ``` ### 排除干擾效能分析的因素 ```shell echo 0 > /proc/sys/kernel/randomize_va_space for i in /sys/devices/system/cpu/cpu*/cpufreq/scaling_governor do echo performance > ${i} done ``` 執行的時候使用 `taskset -c 1` ### 用統計手法去除極端值 每一組測試總共執行 50 次,去掉最大值和最小值後剩下的取平均。 ## 大數運算 使用 `unsigned long long` 的動態陣列儲存大數,並且有紀錄陣列長度跟最大長度的變數。 ```c struct BigN { int len; int max_len; unsigned long long *digits; }; ``` 將大數傳遞給 user 的時候只要根據 `len` 傳遞對應大小的 `digits` 。 ### init ```c static struct BigN *init_BigN(int digits_num) { struct BigN *n = kmalloc(sizeof(struct BigN), GFP_KERNEL); if (!n) return NULL; n->digits = kmalloc(digits_num * sizeof(unsigned long long), GFP_KERNEL); if (!n->digits) { kfree(n); return NULL; } n->len = 1; n->max_len = digits_num; for (int i = 0; i < digits_num; i++) n->digits[i] = 0ULL; return n; } ``` 對 `BigN` 初始化, `digits_num` 是陣列的大小。 ### free ```c static void free_BigN(struct BigN *n) { kfree(n->digits); kfree(n); } ``` 釋放 `BigN` 的空間。 ### swap ```c static inline void swap_BigN(struct BigN *dst, struct BigN *src) { struct BigN temp = *dst; *dst = *src; *src = temp; } ``` 參考 [bignum](https://github.com/0xff07/bignum/blob/fibdrv/bignum.c) ,可以省下複製大數的時間。 ### add ```c static void add_BigN(struct BigN *output, const struct BigN *x, const struct BigN *y) { int max_len = max(x->len, y->len); unsigned long long carry = 0; for (int i = 0; i < max_len; i++) { unsigned long long result = x->digits[i] + carry; carry = x->digits[i] > ~carry || result > ~y->digits[i]; result += y->digits[i]; output->digits[i] = result; } if (output->len < output->max_len) { output->digits[max_len] = carry; output->len = max_len + carry; } } ``` 兩個大數相加,將結果存進 `output` 。具體作法是由低位將兩數相加,如果有 overflow 就將 `carry` 加至下一位的結果中。 `output` 可以與要做運算的兩數是相同的位址。 這裡有個細節是因為還有 `carry` 要補上去,可能造成額外的 overflow ,所以要做兩次判斷。 ### sub ```c static void sub_BigN(struct BigN *output, const struct BigN *x, const struct BigN *y) { unsigned long long borrow = 0; for (int i = 0; i < x->len; i++) { unsigned long long result = x->digits[i] - borrow; borrow = borrow > x->digits[i] || result < y->digits[i]; result -= y->digits[i]; output->digits[i] = result; } output->len = x->len; while (output->len > 1 && output->digits[output->len - 1] == 0) output->len--; } ``` 兩個大數相減,將結果存進 `output` , `output` 可以與要計算的兩數位址相同。具體作法是從低位開始將兩數相減,如果有需要借位的情況,就將下一位的結果減一。這裡跟加法一樣,前一位的借位可能造成額外的借位,所以要判斷兩次,最後調整 `output` 的長度至第一個不為 0 的位數。有個要注意的地方是 `x` 的值必須大於等於 `y` 。 ### lshift ```c static void lshift_BigN(struct BigN *output) { unsigned long long right = 0; for (int i = 0; i < output->len; i++) { unsigned long long left = (output->digits[i] & ~(~0ULL >> 1)) > 0; output->digits[i] = output->digits[i] << 1 | right; right = left; } if (output->len < output->max_len) { output->digits[output->len] = right; output->len += right; } } ``` 將大數向左位移一位,相當於乘以二,不保證超出 `max_len` 的結果。具體作法是對陣列每個數做位移,將最高位的值放到下一個數的最低位。 ### mul ```c static void mul_BigN(struct BigN *output, const struct BigN *x, const struct BigN *y, struct BigN *carry) { output->len = x->len + y->len; carry->len = output->len; if (output->len > output->max_len) { output->digits[0] = 0; output->len = 0; return; } for (int i = 0; i < output->len; i++) { output->digits[i] = 0; carry->digits[i] = 0; } for (int i = 0; i < x->len; i++) { for (int j = 0; j < y->len; j++) { unsigned __int128 result = (unsigned __int128) x->digits[i] * y->digits[j]; /* add the lower of result */ carry->digits[i + j + 1] += output->digits[i + j] > (unsigned long long) (~result & ~0ULL); output->digits[i + j] += result & ~0ULL; /* add the upper of result */ result >>= 64; if (i + j + 2 < output->max_len) carry->digits[i + j + 2] += output->digits[i + j + 1] > (unsigned long long) ~result; output->digits[i + j + 1] += result; } } add_BigN(output, output, carry); output->len -= output->digits[output->len - 1] == 0; } ``` 兩數相乘,將結果存入 `output` ,這裡 `output` 不能與兩數的位址相同。具體作法使用最慢但比較直觀的直式乘法,`x[i] * y[j]` 的結果累加至 `output[i + j]` ,為了避免補 `carry` 時要一直判斷有沒有 overflow ,將 overflow 的 `carry` 存至別的數,最後再一次加起來。 ### add_constant ```c static void add_constant_BigN(struct BigN *output, unsigned long long c) { unsigned long long carry = output->digits[0] > ~c; output->digits[0] += c; for (int i = 1; carry && i < output->len; i++) { carry = output->digits[i] > ~1ULL; output->digits[i]++; } if (output->len < output->max_len) { output->digits[output->len] = carry; output->len += carry; } } ``` 加一個常數至大數裡,處理進位即可。 ### sub_constant ```c static void sub_constant_BigN(struct BigN *output, unsigned long long c) { unsigned long long borrow = c > output->digits[0]; output->digits[0] -= c; for (int i = 1; borrow && i < output->len; i++) { borrow = 1ULL > output->digits[i]; output->digits[i]--; } while (output->len > 1 && output->digits[output->len - 1] == 0) output->len--; } ``` 將大數減一個常數,處理借位即可。 ### 轉換成十進位 user 取得大數後,因為 `printf` 無法直接輸出,我的想法是先將陣列轉成方便輸出的形式,原本儲存的值改儲存`unsigned long long` 範圍內最大的 $10$ 的冪次方也就是 $10^{19}$ ,這樣就可以直接輸出。 ```c #define MAX_P10 10000000000000000000ULL void print_fib(int n, unsigned long long *digits, int len) { unsigned __int128 value = 0; printf("Reading from " FIB_DEV " at offset %d, returned the sequence ", n); for (int i = 0; i < len; i++) { value = 0; digits[len] = 0; for (int j = len - 1; j >= i; j--) { value = value << 64 | digits[j]; digits[j + 1] = value / MAX_P10; value = value % MAX_10P; } digits[i] = value; len += digits[len] > 0; } printf("%llu", digits[len - 1]); for (int i = len - 2; i >= 0; i--) { printf("%019llu", digits[i]); } printf("\n"); } ``` 中間那段程式碼在做進位轉換,先是對大數除以 $10^{19}$ ,第 n 次除法的餘數為轉換後第 n 位的值,然後商成為下一輪的被除數,直到商為 0 。對大數的除法是採用長除法,在這段程式碼,餘數直接放在當前的位置,商放在高一位的位置,這樣不用額外的空間。 以上這些大數的運算比較針對 Fibonacci Number 的計算,所以會有些限制,但都符合之後計算的需求。 ## Fibonacci Number ### 所需空間 Fibonnaci Number 有一個近似值的公式 $0.4472135955 * 1.61803398875^n$ 利用這個公式我們可以近似出所需的空間。 - 二進位用 `unsigne long long` 儲存 : `2 + 7 * n / 640` - 十進位用 `unsigne long long` 儲存 : `2 + 21 * n / 1900` 為了運算方便有多加一,如果要更準確一點的話使用以下的值 $log(F(n)) = 0.20898764025 * n, log_2(F(n)) = 0.694241914 * n$ ### 迴圈 比較直觀的作法是根據定義 `F(n) = F(n - 1) + F(n - 2), F(0) = 0, F(1) = 1` 寫出一個迴圈的版本。 ```c static struct BigN *fib_sequence_iterative(long long k) { int digits_num = 2 + k * 7 / 640; struct BigN *f_n_prev = init_BigN(digits_num); struct BigN *f_n = init_BigN(digits_num); if(!(f_n && f_n_next)) return NULL; f_n_prev->digits[0] = 0ULL; f_n->digits[0] = 1ULL; if(k == 0) f_n->digits[0] = 0ULL; for (long long i = 2; i <= k; i++) { add_BigN(f_n_prev, f_n_prev, f_n); swap_BigN(f_n_prev, f_n); } free_BigN(f_n_prev); return f_n; } ``` 因為 `add_BigN` 可以輸出至與輸入相同的大數,將結果儲存在算 F(n + 1) 時不需要用到的原本的 `f_n_prev` ,最後將已經是 F(n) 的 `f_n_prev` 跟 F(n - 1) 的 `f_n` 交換,這樣就不需要額外的空間。 ### fast doubling 利用 F(n) 與 F(n + 1) 可以算出 F(2n) 與 F(2n + 1) 的值,公式如下。 $F(2n) = F(n) * [2F(n + 1) - F(N)]$ $F(2n + 1) = F(n + 1)^2 + F(N)^2$ ```c static struct BigN *fib_sequence_test(long long k) { int digits_num = 2 + k * 7 / 640; struct BigN *a = init_BigN(digits_num); struct BigN *b = init_BigN(digits_num); struct BigN *aa = init_BigN(digits_num); struct BigN *bb = init_BigN(digits_num); struct BigN *carry = init_BigN(digits_num); unsigned long long i = 1ULL << fls(k) >> 1; if (!(a && b && aa && bb && carry)) return NULL; a->digits[0] = 0ULL; b->digits[0] = 1ULL; while (i) { mul_BigN(aa, a, a, carry); mul_BigN(bb, a, b, carry); lshift_BigN(bb); sub_BigN(a, bb, aa); mul_BigN(bb, b, b, carry); add_BigN(b, bb, aa); if (k & i) { add_BigN(a, a, b); // m++ swap_BigN(a, b); } i >>= 1; } free_BigN(b); free_BigN(aa); free_BigN(bb); free_BigN(carry); return a; } ``` `a` 為 F(n) , `b` 為 F(n + 1) ### fast doubling 與迴圈版本的比較 ![](https://i.imgur.com/URKao21.png) 每隔 60 測試一次,總共測試 100 次 由圖可以很明顯看出迴圈的執行時間遠大於 fast doubling ,接下來會對 fast doubling 進行修改。 ### 改進 1 在之前的程式碼中每一輪都會算出 F(n) 和 F(n + 1) ,就連最後一輪也是,但實際上只需要 F(n) 或是 F(n + 1) 就好,因此對最後一輪做特別的處理。 ```c while (i > 1) { mul_BigN(aa, a, a, carry); mul_BigN(bb, a, b, carry); lshift_BigN(bb); sub_BigN(a, bb, aa); mul_BigN(bb, b, b, carry); add_BigN(b, bb, aa); if (k & i) { add_BigN(a, a, b); // m++ swap_BigN(a, b); } i >>= 1; } /* last round */ if (k & i) { mul_BigN(aa, a, a, carry); mul_BigN(bb, b, b, carry); add_BigN(a, aa, bb); } else { lshift_BigN(b); sub_BigN(b, b, a); mul_BigN(aa, b, a, carry); swap_BigN(aa, a); } ``` 接下來比較兩者的差異,每隔 99 測試一次,總共測試 100 次。 ![](https://i.imgur.com/ZuR243w.png) 從上面的程式碼可以看出 k 為奇數的時候可以省下一次的乘法運算,為偶數時可以省下兩次乘法運算,省下的乘法運算也是計算中最耗時的最後一輪,因此對比原本的寫法可以改進約 20% 和 43% 。奇數與偶數差了一次乘法運算造成了時間呈鋸齒狀。 ### 改進 2 參考自 [斐波那契数列第一千万项怎么求](https://zhuanlan.zhihu.com/p/98064307) ,使用 [Catalan's identity](https://en.wikipedia.org/wiki/Cassini_and_Catalan_identities) 改寫原本的公式 $F(n) * F(n + 1) = F(n + 1)^2 - F(n)^2 - (-1)^n$ $F(2n) = 2[F(n + 1)^2 - F(n)^2] - F(n)^2 -2(-1)^n$ 這樣就可以省下一次乘法,程式碼如下 ```c while (i > 1) { mul_BigN(aa, a, a, carry); mul_BigN(bb, b, b, carry); sub_BigN(a, bb, aa); lshift_BigN(a); (k & (i << 1)) ? add_constant_BigN(a, 2) : sub_constant_BigN(a, 2); sub_BigN(a, a, aa); add_BigN(b, aa, bb); if (k & i) { add_BigN(a, a, b); // m++ swap_BigN(a, b); } i >>= 1; } /* last round */ if (k & i) { mul_BigN(aa, a, a, carry); mul_BigN(bb, b, b, carry); add_BigN(a, aa, bb); } else { lshift_BigN(b); sub_BigN(b, b, a); mul_BigN(aa, b, a, carry); swap_BigN(aa, a); } ``` ![](https://i.imgur.com/ufzbUNO.png) 對比了前一個改進的寫法加速約 20% 。使用 `perf record` 查看結果如下 ``` 97.43% test [kernel.kallsyms] [k] mul_BigN.isra.0 0.70% test [kernel.kallsyms] [k] add_BigN.isra.0 0.35% test [kernel.kallsyms] [k] sub_BigN.isra.0 0.25% test [kernel.kallsyms] [k] init_BigN 0.14% test [kernel.kallsyms] [k] lshift_BigN ``` 很明顯的運算的瓶頸在乘法上面,若要再改進的話要從 `mul_BigN` 下手。 ### 改進 3 參考 [KYG-yaya573142 的報告](https://hackmd.io/@KYWeng/rkGdultSU) ,使用 inline asm 直接使用乘法結果的高位和低位。與前次改進比較結果如下圖,加速了約 10% 。 ```c for (int i = 0; i < x->len; i++) { for (int j = 0; j < y->len; j++) { unsigned long long high, low; __asm__("mulq %3" : "=a"(low), "=d"(high) : "%0"(x->digits[i]), "rm"(y->digits[j])); /* add the lower of result */ carry->digits[i + j + 1] += output->digits[i + j] > ~low; output->digits[i + j] += low; /* add the upper of result */ if (i + j + 2 < output->max_len) carry->digits[i + j + 2] += output->digits[i + j + 1] > ~high; output->digits[i + j + 1] += high; } } ``` ![](https://i.imgur.com/QH4N9ig.png) ### 改進 4 實作 [Karatsuba algorithm](https://en.wikipedia.org/wiki/Karatsuba_algorithm) ,假設 AB 和 CD 相乘,乘積可以看成 AC BC + AD BD 三部分,中間的部分可以改寫為 (A + B)(C + D) - AC - BD ,這樣可以少一次 O(n / 2) 的乘法,除此之外 AC BD (A + B)(C + D) 也可以用同樣的方法算乘積,以下是遞迴至某個長度的兩數相乘就改用原本乘法的比較。 - threshold = 1 ![](https://i.imgur.com/wbKNihE.png) - threshold = 8 ![](https://i.imgur.com/PS8F8XD.png) - threshold = 64 ![](https://i.imgur.com/nedIW3I.png) - threshold = 640 ![](https://i.imgur.com/ohGxfKf.png) 由以上圖可以看出 threshold 為 8 和 64 的效果較好,太大或太小的效果較差。