# 2022q1 Homework3 (fibdrv)
contributed by < `jim12312321` >
> [作業說明](https://hackmd.io/@sysprog/linux2022-fibdrv)
## 實驗環境
```shell
$ gcc --version
gcc (Ubuntu 11.2.0-7ubuntu2) 11.2.0
$ lscpu
Architecture: x86_64
CPU op-mode(s): 32-bit, 64-bit
Byte Order: Little Endian
Address sizes: 39 bits physical, 48 bits virtual
CPU(s): 20
On-line CPU(s) list: 0-19
Thread(s) per core: 1
Core(s) per socket: 14
Socket(s): 1
NUMA node(s): 1
Vendor ID: GenuineIntel
CPU family: 6
Model: 154
Model name: 12th Gen Intel(R) Core(TM) i7-12700H
Stepping: 3
CPU MHz: 2700.000
CPU max MHz: 6000.0000
CPU min MHz: 400.0000
BogoMIPS: 5376.00
Virtualization: VT-x
L1d cache: 336 KiB
L1i cache: 224 KiB
L2 cache: 8.8 MiB
NUMA node0 CPU(s): 0-19
```
## 自我檢查清單
- [x] 研讀上述 ==Linux 效能分析的提示== 描述,在自己的實體電腦運作 GNU/Linux,做好必要的設定和準備工作 $\to$ 從中也該理解為何不希望在虛擬機器中進行實驗;
- [x] 研讀上述費氏數列相關材料 (包含論文),摘錄關鍵手法,並思考 [clz / ctz](https://en.wikipedia.org/wiki/Find_first_set) 一類的指令對 Fibonacci 數運算的幫助。請列出關鍵程式碼並解說
- [ ] 複習 C 語言 [數值系統](https://hackmd.io/@sysprog/c-numerics) 和 [bitwise operation](https://hackmd.io/@sysprog/c-bitwise),思考 Fibonacci 數快速計算演算法的實作中如何減少乘法運算的成本;
- [ ] 研讀 [KYG-yaya573142 的報告](https://hackmd.io/@KYWeng/rkGdultSU),指出針對大數運算,有哪些加速運算和縮減記憶體操作成本的舉措?
- [ ] `lsmod` 的輸出結果有一欄名為 `Used by`,這是 "each module's use count and a list of referring modules",但如何實作出來呢?模組間的相依性和實際使用次數 ([reference counting](https://en.wikipedia.org/wiki/Reference_counting)) 在 Linux 核心如何追蹤呢?
> 搭配閱讀 [The Linux driver implementer’s API guide » Driver Basics](https://www.kernel.org/doc/html/latest/driver-api/basics.html)
- [x] 注意到 `fibdrv.c` 存在著 `DEFINE_MUTEX`, `mutex_trylock`, `mutex_init`, `mutex_unlock`, `mutex_destroy` 等字樣,什麼場景中會需要呢?撰寫多執行緒的 userspace 程式來測試,觀察 Linux 核心模組若沒用到 mutex,到底會發生什麼問題。嘗試撰寫使用 [POSIX Thread](https://en.wikipedia.org/wiki/POSIX_Threads) 的程式碼來確認。
---
## 前置準備工作
:::spoiler 排除干擾效能的因素
- 孤立特定的 cpu
- 在 /etc/default/grub 中用 sudo 權限編輯,並在 `GRUB_CMDLINE_LINUX` 後加入 `isolcpus=0` ,以空出第 0 個 cpu
```
GRUB_CMDLINE_LINUX="isolcpus=0"
```
- 更新 grub (也要使用 sudo 權限)
```
$sudo update-grub
```
- 重開機
- 大功告成!
```
pid 1's current affinity list: 1-19
```
- 抑制 address space layout randomization
```
$ sudo sh -c "echo 0 > /proc/sys/kernel/randomize_va_space"
```
- 設定 scaling_governor 為 performance
- 寫腳本,檔名為 `performance.sh`
```
for i in /sys/devices/system/cpu/cpu*/cpufreq/scaling_governor
do
echo performance > ${i}
done
```
- 執行腳本
```
$ sudo sh performance.sh
```
- 關閉 turbo mode
```
$ sudo sh -c "echo 1 > /sys/devices/system/cpu/intel_pstate/no_turbo"
```
- 把上述設定全部整合在 `performance.sh` 中,便於後續重新執行。
:::
---
## 初探 fibdrv
- 將 [K04: fibdrv](https://hackmd.io/@sysprog/linux2022-fibdrv) 中提到可以測量時間的方法加進 fibdrv.c
- 需注意不須使用 `DEFINE_KTIME` 進行初始化,於這個 [PATCH](https://github.com/spotify/linux/commit/272705c5979c114e63dbfcd28ea15093038a4c42) 時,`DEFINE_KTIME` 已被移除。
- 修改 Makefile 會造成錯誤的指令刪除
```
@diff -u out scripts/expected.txt
```
- 這道指令雖然能讓我們看到輸出檔案跟 `scripts/expected.txt` 的差別,但是 `scripts/expected.txt` 中的資料也只是完全沒修改時會印出的內容罷了,留著這到指令會造成`make check` 時錯誤(我不知道為什麼,不使用 `make check` 單獨使用這道指令時又沒問題!)且會讓版面很亂,故刪除。
- 將 `./client` 指定在特定的 CPU 運行並紀錄結果
```
$ sudo taskset -c 0 ./client >./output/output.txt
```
- 在 `client.c` 中加入對 userspace time 的測量
- 耗費時間散佈圖
![](https://i.imgur.com/DadvDrq.png)
---
## 摘錄 Fibonacci 數關鍵手法,並思考 clz / ctz 一類的指令對 Fibonacci 數運算的幫助
### 費式數列 - 基本定義
$F_n=\left\{
\begin{aligned}
0, n=0 \\
1, n=1 \\
F_{n-2}+F_{n-1},n>1
\end{aligned}
\right.$
因此由費式數列的基本定義可以得知,除了前兩項,費式數列的算法為 $F_n = F_{n-2}+F_{n-1}$ 。
### fast Doubling
更詳細的推導在 [K04: fibdrv](https://hackmd.io/@sysprog/linux2022-fibdrv#-%E8%B2%BB%E6%B0%8F%E6%95%B8%E5%88%97) 中有說明,這邊就不重複,僅列出結論。
$F(2k)=F(k)[2F(k+1)-F(k)]$
$F(2k+1)=F(k+1)^2+F(k)^2$
這表示我們可以用 $F(k)$ 和 $F(k+1)$ 求出 $F(2k)$ 和 $F(2k+1)$ ,舉例來說:
>用 fast doubling 求出 F(10):
>此時我們可知 $k=5$ ,表示 F(10) 可透過 F(5) 和 F(6) 求出,而 F(5) 和 F(6) 又分別可透過 F(2),F(3) 和 F(3),F(4) 求出...,以此類推,最後我們可以得知要求出 F(10) ,可透過 F(0),F(1),F(2),F(3),F(4),F(5),F(6) 求出答案。
>比起原先定義中從 F(0) 一路算到 F(8),F(9) 才能求出 F(10) 明顯更快。
所以費式數列的定義可進一步變成:
$F_n=\left\{
\begin{aligned}
0, n=0 \\
1, n=1 \\
F(k)[2F(k+1)-F(k)],n\ is\ even\ and\ k = \dfrac{n}{2} \\
F(k+1)^2+F(k)^2,n\ is\ odd\ and\ k = \lfloor\dfrac{n}{2}\rfloor
\end{aligned}
\right.$
### 考量到硬體加速的 $F(n)$
>1. 省略 $F(0)$,直接從 $F(1)$ 開始;
>2. clz/[ffs](https://www.kernel.org/doc/htmldocs/kernel-api/API-ffs.html): 先去除數字 MSB 起算的開頭 0 位元,因為真正的數字不包含 leading 0s,所以計算它也沒用,因此需要 [clz](https://en.wikipedia.org/wiki/Find_first_set#CLZ) 計算有多少 leading 0s
* 遇到 `0` $\rightarrow$ 進行 fast doubling,也就是求 $F(2n)$ 和 $F(2n+1)$
* 遇到 `1` $\rightarrow$ 進行 fast doubling,也就是先求 $F(2n)$ 和 $F(2n+1)$,再求 $F(2n+2)$
關於 clz / ctz 一類的指令如何在運算費式數列中加速,如同上面 [K04: fibdrv](https://hackmd.io/@sysprog/linux2022-fibdrv#-%E8%B2%BB%E6%B0%8F%E6%95%B8%E5%88%97) 中的說明,可以先去除 leading 0s ,直接從 first set bit 開始算起。
- 耗費時間圖
- n $\rightarrow$ normal, f $\rightarrow$ fast doubling
- k $\rightarrow$ kernel space, u $\rightarrow$ user space
![](https://i.imgur.com/MocOn6k.png)
從前 92 個的計算結果看來,有無進行 fast doubling 的差異不大。
:::spoiler normal fib seq 程式碼
```c=
static long long fib_sequence(long long k)
{
/* a before b */
long long a,b;
a = 0;
b = 1;
for (int i = 1; i <= k; i++) {
a += b;
_swap(a,b);
}
return a;
}
```
:::
:::spoiler fast doubling fib seq 程式碼
```c=
static long long fast_fib_seq(long long k)
{
if(k==0)
return 0;
/*a before b*/
long long a,b,tmp1,tmp2;
a = 0;
b = 1;
int idx = __builtin_clzll(k)+1;
for(;idx<=MAX_LL;idx++){
tmp1 = a*(2*b-a);
tmp2 = b*b + a*a;
a = tmp1;
b = tmp2;
if(_get_bit(k,(MAX_LL-idx))){ // current bit == 1
tmp1 = a+b;
a = b;
b = tmp1;
}
}
return a;
}
```
:::
---
## 減少乘法運算的成本
以下是 fast doubling 的虛擬碼 - from [K04: fibdrv](https://hackmd.io/@sysprog/linux2022-fibdrv)
```c
Fast_Fib(n)
a = 0; b = 1; // m = 0
for i = (number of binary digit in n) to 1
t1 = a*(2*b - a);
t2 = b^2 + a^2;
a = t1; b = t2; // m *= 2
if (current binary digit == 1)
t1 = a + b; // m++
a = b; b = t1;
return a;
```
### 嘗試改寫平方運算
可以發現其中大部分的乘法運用都是在做平方運算,所以如果能針對平方運算做優化,則理論上能減少整體運算成本。
```c=
#define MAX_INT 32
#define SET_BIT(x,n) (x | 1 << n)
#define CHECK_BIT(x,n) (x >> n & 1)
/* return a*a
* main idea is using 多項式平方.
* (a+b+c)^2 = a^2 + b^2 + c^2 + 2ab + 2ac + 2bc.
*/
int square_opt(int a){
int ret = 0;
// idx == int index of first set
int fs_idx = MAX_INT -__builtin_clz(a)-1;
int cnt = fs_idx;
do{
if(CHECK_BIT(a,cnt)){
ret += SET_BIT(0,cnt<<cnt);
for(int cur = cnt+1;cur <= fs_idx;cur++){ // handle 2ab
if(CHECK_BIT(a,cur))
ret += SET_BIT(0,cnt+cur+1); // 2ab
}
}
}while(--cnt >= 0);
return ret;
}
```
簡單測試 (使用 [Online C Compiler](https://www.onlinegdb.com/online_c_compiler) 和 `<time.h>` 進行測量)
```
using operator "*" :
5656 * 5656 = 31990336
normal square : 100 ns.
using square_opt:
5656 * 5656 = 31990336
opt square : 472 ns.
```
目前的結果看來是沒有優化成功的,反而成果越糟 :cry: 繼續思考有沒有改進空間。
於是我換了一個思路,利用將十進位換成二進位 (e.g. $5=2^2+2^0$),然後再利用乘法分配律相加。
```c=
/*
* a * a
* = a * (2^n + ... + 2 ^k)
* = a << n + ... + a << k
*/
int square_opt_v2(int a){
int ret = 0;
int cnt = MAX_INT-__builtin_clz(a)-1;
do{
if(CHECK_BIT(a,cnt)){
ret += a << cnt;
}
}while(--cnt >= 0);
return ret;
}
```
而經過和上面一樣的測試環境和方法,以下是得到的結果。
```
using operator "*" :
5656 * 5656 = 31990336
cost time :108 ns
using square_opt_v2 :
5656 * 5656 = 31990336
cost time :167 ns
```
可以看到成果更接近單純使用 `*` 進行次方運算的速度了,但整體來說還是略慢。
我覺得 v2 的效能損耗主要在第 11 行中的 `if(CHECK_BIT(a,cnt))` ,如果能改成 branchless 應該有機會進一步增進效能,因此基於 v2 的 v3 (branchless 的 v2) 如下:
```c=
#define MASK(x,n) (-CHECK_BIT(x,n))
int square_opt_v3(int a){
int ret = 0;
int cnt = MAX_INT-__builtin_clz(a)-1;
do{
ret += (a << cnt) & MASK(a,cnt);
}while(--cnt >= 0);
return ret;
}
```
利用 `CHECK_BIT(x,n)` 的輸出只有 0 或 1 的特性,製作 MASK 達成 branchless 。
而以下是當前的測試成果:
```c=
using operator "*" :
5656 * 5656 = 31990336
cost time :166 ns
using square_opt_v3 :
5656 * 5656 = 31990336
cost time :153 ns
```
終於有機會比用 `*` 還快了 :tada:
接著需要經過測試才可以知道是不是穩定的表現更好。
- `*` 與 `square_opt_v3` 差異圖
其中 diff 為調整前減去調整後,意即值大於 0 表示有改進,反之小於 0 表示比原先表現更差。
> 計算 123456 ^ 2 1000 次並紀錄每次的 diff
![](https://i.imgur.com/Eh9MUYb.png)
可以從目前的測試結果看到, v3 依舊無法比原先的乘法算的更快,也沒有任何測試中能得到比原先表現更好的數據。
### 嘗試改寫乘法運算
看著程式碼發呆一下後,突然發現其實我在實作的 `square_opt` 轉個彎後其實就是在實作二進位的乘法!因此我稍微改一下,附上程式跟測試結果圖。
- 程式碼
```c=
/* return a * b */
long long mul_opt(long long a,long long b){
long long ret = 0;
long long cnt = MAX_LL-__builtin_clzll(a)-1;
do{
ret += (b << cnt) & MASK(a,cnt);
}while(--cnt >= 0);
return ret;
}
```
- `*` 與 `mul_opt` 差異圖
> 計算 123456 * 654321 1000 次並紀錄每次的 diff
![](https://i.imgur.com/bv9mK5z.png)
還是無法增進效能,甚至也還無法逼近原先的效能。
---
## 擴增費式數列的可運算範圍 <br>- 加法
### 實作成果
- 可運算最大值
目前到 500-th 都是正確的
```
sudo taskset -c 0 ./client >./output/output.txt
...
Reading from /dev/fibonacci at offset 499, returned the sequence 86168291600238450732788312165664788095941068326060883324529903470149056115823592713458328176574447204501. cost time in kernel: 1682468 ns,cost time in userspace: 1682894 ns.
Reading from /dev/fibonacci at offset 500, returned the sequence 139423224561697880139724382870407283950070256587697307264108962948325571622863290691557658876222521294125. cost time in kernel: 1691187 ns,cost time in userspace: 1691595 ns.
...
```
- 耗費時間散佈圖 (log)
![](https://i.imgur.com/C3YXr7V.png)
### 發想與細節
實作程式碼 : [My fibdrv](https://github.com/jim12312321/fibdrv)
在看到[作業說明](https://hackmd.io/@sysprog/linux2022-fibdrv#%E8%A8%88%E7%AE%97-F93-%E5%8C%85%E5%90%AB-%E4%B9%8B%E5%BE%8C%E7%9A%84-Fibonacci-%E6%95%B8---%E4%BD%BF%E7%94%A8%E6%95%B8%E5%AD%97%E5%AD%97%E4%B8%B2%E4%B8%A6%E5%A5%97%E7%94%A8-quiz2-SSO-Small-String-Optimization)後,覺得[大數實作 (by AdrianHuang)](https://github.com/AdrianHuang/fibdrv/tree/big_number) 的作法很有意思因此想自己嘗試做做看利用數字字串相加的功能。
- 結構體
```cpp=
typedef struct fib_node {
char data[256];
} fib_node;
```
就是個單純拿來放數字的結構(現在想想好像用 char 的二維陣列就能實作了)。
當初看到[大數實作 (by AdrianHuang)](https://github.com/AdrianHuang/fibdrv/tree/big_number) 中的結構體 `xs` 很複雜,想簡化成簡單明瞭的形式。本結構體一樣能達到擴增到費氏數列第 500 個的功能,因此我不太清楚當初 `xs` 為什麼要這樣設計。
- 字串加法
```cpp=
#include <linux/string.h>
/*
* add string a and b mathematically.
* add the char in a and b backward,and record carry if necessary.
* reverse the answer in the end.
*/
static void string_add(char *a, char *b, char *out)
{
short tmp = 0, carry = 0;
long len_a = strlen(a), len_b = strlen(b);
long len_max = _max(len_a, len_b);
for (int i = 0; i < len_max; i++) {
// cppcheck-suppress shiftTooManyBitsSigned
tmp = ((len_a - i - 1) >> 63) ? 0 : (a[len_a - i - 1] - '0');
// cppcheck-suppress shiftTooManyBitsSigned
tmp += ((len_b - i - 1) >> 63) ? 0 : (b[len_b - i - 1] - '0');
if (carry)
tmp += carry;
if (tmp >= 10) {
tmp -= 10;
carry = 1;
} else {
carry = 0;
}
out[i] = tmp + '0';
}
if (carry) {
out[len_max] = carry + '0';
len_max++;
}
out[len_max] = '\0';
_reverse(out);
}
```
發想︰
看到[大數實作 (by AdrianHuang)](https://github.com/AdrianHuang/fibdrv/tree/big_number) 的實作中要 reverse 很多次且要確保 a 恆大於 b ,我想要把這些步驟省下來,因此直接從 a 和 b 的尾端加回來,最後再將輸出 reverse 即可,且透過 `_max` 讓 a 不一定要恆大於 b 也能運算,其他邏輯跟[大數實作 (by AdrianHuang)](https://github.com/AdrianHuang/fibdrv/tree/big_number) 相異不大。
實作細節︰
1. 將 a 和 b 從後往前逐字元相加
2. 將得出字串反轉得到答案
---
## 擴增費式數列的可運算範圍 <br>- fast doubling
### 實作成果
- 可運算最大值
目前到 500-th 都是正確的
```
sudo taskset -c 0 ./client >./output/output.txt
...
Reading from /dev/fibonacci at offset 499, returned the sequence 86168291600238450732788312165664788095941068326060883324529903470149056115823592713458328176574447204501. cost time in kernel: 619861 ns,cost time in userspace: 620154 ns.
Reading from /dev/fibonacci at offset 500, returned the sequence 139423224561697880139724382870407283950070256587697307264108962948325571622863290691557658876222521294125. cost time in kernel: 609854 ns,cost time in userspace: 610124 ns.
...
```
- 耗費時間散佈圖 (log)
![](https://i.imgur.com/LfrwSGR.png)
- 與用加法實作費氏數列的差異 (log)
![](https://i.imgur.com/U2DEghH.png)
可以看到利用 fast doubling 後越是後面的費氏數列運算的越快。
### 發想與細節
實作程式碼 : [My fast fibdrv](https://github.com/jim12312321/fibdrv/blob/master/fast_fibdrv.c)
在 fast doubling 實作中,實作邏輯就按照[作業說明](https://hackmd.io/@sysprog/linux2022-fibdrv#-%E8%B2%BB%E6%B0%8F%E6%95%B8%E5%88%97)中提供的虛擬碼。
- 十進位轉二進位
在 fast doubling 中需要將 `k` 轉成 2 進位後從高位逐位判斷,在本實作中用 stack 儲存十進位轉二位的位數。
```c=180
/*
* convert decimal to binary.
* store the results in stack.
*/
static void d2b(long long a)
{
push(a & 1);
a >>= 1;
if (a > 0)
d2b(a);
}
```
- 字串減法
```c=115
/*
* out = a - b
* Make sure that a is always greater than b.
*/
static void string_sub(char *a, char *b, char *out)
{
// cppcheck-suppress variableScope
short tmp = 0, borrow = 0;
for (int i = 0; i < strlen(a); i++) {
// cppcheck-suppress shiftTooManyBitsSigned
tmp = ((strlen(a) - i - 1) >> 63) ? 0 : (a[strlen(a) - i - 1] - '0');
if (borrow < 0)
tmp -= 1;
// cppcheck-suppress shiftTooManyBitsSigned
tmp -= ((strlen(b) - i - 1) >> 63) ? 0 : (b[strlen(b) - i - 1] - '0');
if (tmp < 0) {
tmp += 10;
borrow = -1;
} else {
borrow = 0;
}
if ((i + 1 == strlen(a)) && tmp == 0)
break;
out[i] = tmp + '0';
}
if (tmp != 0) {
out[strlen(a)] = '\0';
} else {
out[strlen(a) - 1] = '\0';
}
_reverse(out);
}
```
實作方式基本跟字串加法相同,與加法不同的是在加法中要紀錄進位 `carry` ,而在減法中要紀錄借位 `borrow` 。
- 字串乘法
```c=148
/*
* Make sure out have been initialized with 0 before.
*/
static void string_mul(char *a, char *b, char *out)
{
short tmp = 0, carry = 0;
for (int ib = 0; ib < strlen(b); ib++) {
carry = 0;
for (int ia = 0; ia < strlen(a); ia++) {
tmp = b[strlen(b) - ib - 1] - '0';
tmp *= a[strlen(a) - ia - 1] - '0';
tmp += out[ia + ib] - '0';
if (carry)
tmp += carry;
if (tmp >= 10) {
carry = tmp / 10;
tmp %= 10;
} else {
carry = 0;
}
out[ia + ib] = tmp + '0';
}
out[ib + strlen(a)] = carry + '0';
}
/*len(a)-1+len(b)-1+1*/
tmp = strlen(a) + strlen(b) - 1;
if (carry)
tmp++;
out[tmp] = '\0';
_reverse(out);
}
```
整體作法就是直式乘法的思維,須注意 `out` 要先將裡面的值初始為全為 0 的字串,這樣在抓取先前預算過的值才不會出錯。 (`tmp += out[ia + ib] - '0';`)
---
## 大數運算的加速策略與運算邏輯
### [sysprog21/bignum](https://github.com/sysprog21/bignum)
在老師實作的大數運算中主要由 apm 和 bn 所組成,其中 apm 負責處理高精度運算而 bn 則是利用 apm 中提供的各項基礎運算,包裝成更高階的 API 以利使用。
#### Arbitrary-Precision arithmetic (apm)
高精度運算是利用數字陣列來模擬大數運算,舉例來說:
> uint8_t 的 MAX 是 255 ,那如果要表達 300 怎麼辦?
> 1. 令一個 `uint8_t *digit` 的數字陣列來儲存(同時也可以指定此陣列的 size,本例中假設 size = 3)
> 2. 已知 $300 = 0\times256^2+1\times256^1+44\times256^0$
> 3. 則 300 可以用 digit[2] = 0,digit[1] = 1,digit[0] = 44 表示
>
> 視覺化表示就是
>\begin{array}{rrrr}
> 300: & 00000000 & 00000001 & 00101100 \\
> digit: & digit[2] & digit[1] & digit[0] \\
> value: & 0 & 1 & 44 \\
>\end{array}
>最後要輸出給使用者看的時候要再經過格式轉換,也就是 [format.c](https://github.com/sysprog21/bignum/blob/master/format.c) 在做的事。
- 加法/減法
在加減法中,運算方法其實與正常數值運算雷同,根據指定的 size 從低位元組運算至高位元組並記錄 carry/borrow 。
以加法為例:
> size = 1
> 240 + 50 = 290
>
> \begin{array}{rrr}
> 240 + 50: & & \\
> & 1111 & 0000 \\
> + & 0011 & 0010 \\ \hline
> 290: & 0010 & 0010 \\
> \end{array}
>
> carry = 1
- 乘法
演算法採用 Karatsuba algorithm ,要運算 $U*V$ 時,首先會先將 U 和 V 視為 $U = U_1*2^N+U_0$, $V = V_1*2^N+V_0$,再進行乘法運算。
$U*V = (2^{2N}+2^N)U_1V_1+2^N(U_1-U_0)(V_0-V_1)+(2^N+1)U_0V_0$
而在 APM 中數值的表達方式,本身就符合 $U = U_1*2^N+U_0$ 的形式。
>舉例來說:
>假設 4 個 bits 為一組,則
>\begin{array}{rrr}
> 28 & & \\
> = & \ \ 0001 &\ \ 1100 \\
> = & 1 * 2^4 & +12 \\
>\end{array}
在乘法中首先會設定一個閾值 (`KARATSUBA_MUL_THRESHOLD`),若 $\lfloor\dfrac{size}{2}\rfloor$ 小於閾值則執行一般直式乘法把 $U_1V_1$, $U_0V_0$ 和 $(U_1-U_0)(V_0-V_1)$ 分別算出來並放在對應的位置上進行加減法運算,即可得到兩數乘積,否則繼續依據閾值拆分。
> 舉例來說:
> 假設 4 個 bits 為一組且 `KARATSUBA_MUL_THRESHOLD` 為 1
> 要運算 $7*28$,則
> $size = 2$,$\ \ 7=0*2^4+7$,$\ \ 28=1*2^4+12$
>$\rightarrow$ $U_1V_1 = 0$,$\ \ U_0V_0 = 84$, $\ \ (U_1-U_0)(V_0-V_1) = -77$
>(為求簡潔,以下皆用 10 進位取代 2 進位,$0010\Rightarrow/2$)
>\begin{array}{rrrr|r}
> 7 * 28 & & & & \\
> & & /5 & /4 & 84 \\
> + & /5 & /4 & & (2^4+1) * 84 \\
> - & /4 & /13 & & - 2^4 * 77 \\ \hline
> & /0 & /12 & /4 & 196 \\
>\end{array}
>\begin{array}{rr}
> 196 & & \\
> = & \ \ 1100 &\ \ 0010 \\
> = & 12 * 2^4 & +4 \\
>\end{array}
上述例子中 28 和 7 分別被拆成兩項,若是進行正常的大數乘法(逐 digit 相乘相加),則需要 $2^2$,考慮兩個大數且都被需拆 $N$ 次,則需要 $(N+1)^2$ 次乘法運算。
而使用 Karatsuba algorithm 則會需要 $3*N$ 次乘法運算。
因此使用 Karatsuba algorithm 對於大數運算能減少許多乘法運算帶來的成本。
#### bn
在 bn 中進一步組合 APM 中寫好的高精度運算函式成為面向使用者的 API 。
除此之外,也將在 apm 運算中常用的 digit, size 等封裝成 bn 結構體。
```c=
typedef struct {
apm_digit *digits; /* Digits of number. */
apm_size size; /* Length of number. */
apm_size alloc; /* Size of allocation. */
unsigned sign : 1; /* Sign bit. */
} bn, bn_t[1];
```
### [KYG-yaya573142 的報告](https://hackmd.io/@KYWeng/rkGdultSU)
#### 加速運算
- 改寫 fast donbling 的運算式
將原先老師提供的 fast doubling (簡化中間過程),以下簡稱為 ver1:
:::info
$\begin{split}
\begin{bmatrix}
F(2n+1) \\
F(2n)
\end{bmatrix} &=
\begin{bmatrix}
1 & 1 \\
1 & 0
\end{bmatrix}^{2n}
\begin{bmatrix}
F(1) \\
F(0)
\end{bmatrix}\\ \\ &=
\begin{bmatrix}
F(n+1)^2 + F(n)^2\\
F(n)F(n+1) + F(n-1)F(n)
\end{bmatrix}
\end{split}$
$\Downarrow$
\begin{split}
F(2k) & =F(k)[2F(k+1)-F(k)] \\
F(2k+1) & =F(k+1)^2+F(k)^2 \\
\end{split}
:::
使用不同的 Q-Matrix 改寫成 (簡化中間過程),以下簡稱為 ver2:
:::info
$\begin{split}
\begin{bmatrix}
F(2n-1) \\
F(2n)
\end{bmatrix} &=
\begin{bmatrix}
0 & 1 \\
1 & 1
\end{bmatrix}^{2n}
\begin{bmatrix}
F(0) \\
F(1)
\end{bmatrix}\\ \\ &=
\begin{bmatrix}
F(n)^2 + F(n-1)^2\\
F(n)F(n) + F(n)F(n-1)
\end{bmatrix}
\end{split}$
$\Downarrow$
\begin{split}
F(2k-1) & =F(k)^2 + F(k-1)^2 \\
F(2k) & =F(k)[2F(k-1) + F(k)] \\
\end{split}
:::
在 [KYG-yaya573142 的報告](https://hackmd.io/@KYWeng/rkGdultSU)聲稱 `ver2` 可以少掉一次迴圈及避免使用減法,避免使用減法這點很好理解,`ver2` 的確沒有減法,又因為在 [bn_sub](https://github.com/KYG-yaya573142/fibdrv/blob/optimize-bn/bn_kernel.c#L296) 的實現方法為反轉 sign bit 達成減法效果,因此減少減法,的確會達到優化的目的。
而針對少掉一次迴圈的部分我存有一些疑惑,首先為了在同一個基準上比較,首先應該將 ver1 原先的實現方法也加上 `__bulitin_clz` ,以下為簡易測試函式。
```c=
unsigned int fib_fdoubling_ver1(unsigned int n)
{
if (n < 2) { //Fib(0) = 0, Fib(1) = 1
return n;
}
unsigned int f1 = 0; //F(k)
unsigned int f2 = 1; //F(k+1)
unsigned int k1;
unsigned int k2;
for (unsigned int i = 1U << (31 - __builtin_clz(n)); i; i >>= 1) {
/* F(2k) = F(k) * [ 2 * F(k+1) – F(k) ] */
k1 = f1 * (2 * f2 - f1);
/* F(2k+1) = F(k)^2 + F(k+1)^2 */
k2 = f1*f1 + f2*f2;
if (n & i) {
f1 = k2;
f2 = k1;
f2 += k2;
} else {
f1 = k1;
f2 = k2;
}
}
return f1;
}
unsigned int fib_fdoubling_ver2(unsigned int n)
{
if (n < 2) { //Fib(0) = 0, Fib(1) = 1
return n;
}
unsigned int f1 = 0; // F(k-1)
unsigned int f2 = 1; // F(k)
unsigned int k1;
unsigned int k2;
for (unsigned int i = 1U << (30 - __builtin_clz(n)); i; i >>= 1) {
/* F(2k-1) = F(k)^2 + F(k-1)^2 */
k1 = f2*f2 + f1*f1;
/* F(2k) = F(k)[2 * F(k-1) + F(k)] */
k2 = f2 * (2 * f1 + f2);
if (n & i) {
f1 = k2;
f2 = k1;
f2 += k2;
} else {
f1 = k1;
f2 = k2;
}
}
return f2;
}
```
其中關鍵的原因在於 `ver1` 的 $F(2k)$ 更新方式需要透過 $F(k)*value$ ,因此必須從 n 的 last set bit 開始迴圈,才可運算正確的結果,若一樣用 `1U << (30 - __builtin_clz(n))` ,則會因為一開始 $F(k)$ 為 0 進而無法正確更迭費式數列的值。
若要使用與 `ver2` 相同的迴圈起點, `1U << (30 - __builtin_clz(n))` (換句話說就是從 n 的 last set bit 的下一個 bit 開始),則就必須滿足以下任一條件:
1. 由於跳過 last set bit ,因此必須更改 `f1` 與 `f2` 的值
2. 推導其他運算 fast doubling 的算式
在 `ver2` 中利用改變 Q-Matrix 使得運算 $F(2k)$ 時不會受到 $F(k)$ 為 0 的影響(條件 (2) 的解法)。
但從條件 (1) 著手一樣可以正確處理費式數列的運算,已知由於 $F(k)$ 為 0 會造成運算錯誤,因此只要將其改成 1 後續即可正確運算 (如同以下的 `ver1_1`)。
```diff=
+ unsigned int fib_fdoubling_ver1_1(unsigned int n)
- unsigned int fib_fdoubling_ver1(unsigned int n)
{
...
+ unsigned int f1 = 1;
- unsigned int f1 = 0;
...
+ for (unsigned int i = 1U << (30 - __builtin_clz(n)); i; i >>= 1) {
- for (unsigned int i = 1U << (31 - __builtin_clz(n)); i; i >>= 1) {
...
}
```
另外 [bn_fib_fdoubling](https://github.com/KYG-yaya573142/fibdrv/blob/optimize-bn/bn_kernel.c#L379) 中本來就有對 n == 1 or 0 的情況做特殊處理,所以更改 `f1` 的值也不用擔心 n == 0 時會有錯誤的回傳值。
```c=
void bn_fib_fdoubling(bn *dest, unsigned int n)
{
...
if (n < 2) { // Fib(0) = 0, Fib(1) = 1
dest->number[0] = n;
return;
}
...
}
```
最後,我認為在 [KYG-yaya573142 的報告](https://hackmd.io/@KYWeng/rkGdultSU)造成時間小幅度減少 3% 的原因,主要是來自引入 `ver2` 的算法後,必須搭配使用 `clz` 避免運算結果出錯,而引入 `clz` 便會在 n 值越小的時候,減少越多次迴圈運算,達成整體效能的優化,而非因為減少一次迴圈及取代減法。
- 改善 `bn_do_add` 的實作
透過將原先一次將 `c->number[0 ... c->size -1]` 全部運算的迴圈改成兩個迴圈,變成一個負責 `c->number[0 ... bsize -1]` ,若 `asize > bsize` 則再利用一個迴圈將 `c->number[bsize ... asize-1]` 算完。
不過我認為改善後的實作仍有改進空間,目前想到的有兩點:
1. $asize \geq bsize$
在改善後的實作中須確保 $asize \geq bsize$ 才可以後續運算,因此在 `bn_do_add` 中使用以下指令確保 $asize \geq bsize$ 恆成立。
```c=
if (a->size < b->size) // a->size >= b->size
SWAP(a, b);
```
但有沒有可能基於費式數列的特性,本來就可以確保 $asize \geq bsize$ 恆成立?
先來看看 `bn_add` 會使用在什麼地方,除了 `bn_sub` 中用來實現減法外,其餘都使用在計算費式數列中,讓我們列出 `bn_fib_fdoubling` 中跟 `bn_add` 有關的操作。
>1. bn_add(k1, f2, k1); // k1 = 2 * F(k-1) + F(k)
>2. bn_add(k2, k1, f1); // f1 = k1 + k2 = F(2k-1) now
>3. bn_add(f1, f2, f2); // f2 = F(2k+2)
接著將三個會用到 `bn_add` 的時機分開討論,先講 2 和 3 ,他們很直覺,在第 2 點中此時的 k1 和 k2 分別為 $F(k)^2$ 和 $F(k-1)^2$ ,由費式數列的單調遞增特性可知 $k1 \geq k2$ 恆成立,第三點中也可以很輕易的判斷執行 `bn_add` 時 $f1 \geq f2$ 恆成立。
而在第 1 點中, k1 和 f2 分別為 $2*F(k-1)$ 和 $F(k)$,接著可以經過一些簡單的推導得知,$k1 \geq f2$ 恆成立。
\begin{split}
2 * F(k-1) &\ \ \geq &\ \ F(k) \\
F(k-1) + F(k-1) &\ \ \geq &\ \ F(k-1)+F(k-2) \\
F(k-1) &\ \ \geq &\ \ F(k-2) \\
\end{split}
在確保所有使用 `bn_do_add` 的操作都可以確保 $a \geq b$ 恆成立後,便可以改寫 `bn_do_add`。
```diff=
/* |c| = |a| + |b|
+ * make sure asize always bigger than or equal to bsize
*/
static void bn_do_add(const bn *a, const bn *b, bn *c)
{
...
- if (a->size < b->size) // a->size >= b->size
- SWAP(a, b);
...
}
```
然後改寫 `bn_fib_fdoubling` 中的 `bn_add` (原本的寫法就已經符合 $a \geq b$ 因此僅加上註解)。
```diff=
void bn_fib_fdoubling(bn *dest, unsigned int n)
{
...
+ // k1 size always bigger than f2 size
bn_add(k1, f2, k1); // k1 = 2 * F(k-1) + F(k)
...
+ // k2 size always bigger than k1 size
bn_add(k2, k1, f1); // f1 = k1 + k2 = F(2k-1) now
if (n & i) {
...
+ // f1 size always bigger than f2 size
bn_add(f1, f2, f2); // f2 = F(2k+2)
}
}
...
}
```
2. 修改第二個迴圈
第二個迴圈主要的功能為將 a 中剩下的數值複製到 c 中並且加上 carry 。但考慮到進位的次數與 `asize - bsize` 的差值會隨著費式數列越大而越大,因此可以再拆解第二個迴圈,讓第二個迴圈負責處理 carry 而第三個負責處理剩下的 a->number 複製到 c->number 。
```diff=
/* |c| = |a| + |b| */
static void bn_do_add(const bn *a, const bn *b, bn *c)
{
...
bn_data carry;
carry = _add_partial(a->number, b->number, bsize, c->number);
if (asize != bsize) { // deal with the remaining part if asize > bsize
+ int i = bsize;
+ for (; i < asize & !!carry; i++) { // !!carry == 0 if carry == 0, == 1 if others
- for (int i = bsize; i < asize; i++) {
bn_data tmp1 = a->number[i];
carry = (tmp1 += carry) < carry;
c->number[i] = tmp1;
}
+ for (; i < asize; i++) {
+ c->number[i] = a->number[i];
+ }
}
if (carry) {
c->number[asize] = carry;
++(c->size);
}
}
```
#### 縮減記憶體操作成本
- 降低 malloc 與 memcpy 的使用
原先的實作(節錄片段)
```c=
void bn_fib_fdoubling(bn *dest, unsigned int n)
{
...
/* F(2k) = F(k) * [ 2 * F(k+1) – F(k) ] */
bn_cpy(k1, f2); // k1 = F(k+1)
bn_lshift(k1, 1); // k1 = 2 * F(k+1)
bn_sub(k1, f1, k1); // k1 = 2 * F(k+1) - F(k)
bn_mult(k1, f1, k1); // k1 = F(k) * [ 2 * F(k+1) - F(k) ]
...
bn_cpy(f1,k1); // f1 = k1
...
}
```
可以發現為了將運算結果都儲存在 `k1` 中,因此每次運算都會遇到以下問題:
1. 資料複製
2. 乘法運算時 `c == a or c == b` 動態配置暫存記憶體
而改進的做法便是重構算式,將 `k2` 也拿來儲存計算結果,從而避免問題 (2) ,再利用 `bn_swap` 省去 `memcpy` 的運用解決問題 (1) 。
```c=
void bn_fib_fdoubling(bn *dest, unsigned int n)
{
...
/* F(2k) = F(k) * [ 2 * F(k+1) – F(k) ] */
/* F(2k+1) = F(k)^2 + F(k+1)^2 */
bn_lshift(f2, 1, k1);// k1 = 2 * F(k+1)
bn_sub(k1, f1, k1); // k1 = 2 * F(k+1) – F(k)
bn_mult(k1, f1, k2); // k2 = k1 * f1 = F(2k)
bn_mult(f1, f1, k1); // k1 = F(k)^2
bn_swap(f1, k2); // f1 <-> k2, f1 = F(2k) now
...
}
```
- 引入 memory pool 的概念
在 `bn` 中加入 `capacity` 儲存實際配置的長度,而 `size` 變成當前使用的大小,進而減少頻繁呼叫 `realloc` 所產生的效能損耗。
另外在 [KYG-yaya573142 的報告](https://hackmd.io/@KYWeng/rkGdultSU)中提到
>至少能正確計算至第 100000 項
是因為 unsigned int 能表達的數值上界為 4294967295($2^{32}-1$,在 LP64 或 LLP64 系統),因此能表達的費式數列上界為 $2^{32*(2^{32}-1)}$ ,考慮可能不需要計算到這麼後面的費式數列值,還可以引入 bit field 的技巧將 size,capacity 和 sign 整合。
```diff
typedef struct _bn {
unsigned int *number; /* ptr to number */
- unsigned int size; /* length of number */
- unsigned int capacity; /* total allocated length, size <= capacity */
- int sign;
+ unsigned int size: SIZE;
+ unsigned int capacity: CAPACITY;
+ unsigned int sign: SIGN;
} bn;
```
更進一步的節省 `bn` 的空間,不過這樣改寫的話原先很多函式都要一並改寫,整體利弊尚未可知。
- 因 word size 制宜
根據當前系統不同的 word size 調整 `bn` 中各項屬性的資料型態,不僅能使 `bn` 能夠在不同系統運行,同時也確保當使用 64-bit 的 CPU 時能夠增加計算效能與儲存空間。
:::warning
TODO:
針對 [KYG-yaya573142 的報告](https://hackmd.io/@KYWeng/rkGdultSU)減少大數運算的成本中的以下章節撰寫報告
- [ ] 改善 bn_mult 的效能
- [ ] 引入 bn_sqr
- [ ] 實作 Karatsuba algorithm
:::
---
## mutex 在 fibdrv 中的應用場景與移除時會發生的事情
參考了 [KYG-yaya573142 的報告](https://hackmd.io/@KYWeng/rkGdultSU)撰寫一個多執行緒程式來測試。
```c=
void *worker(void *arg){
int fd = open("/dev/fibonacci",O_RDWR);
if (fd < 0){
perror("Failed to open character device");
goto end;
}
int idx = *((int *)arg);
char buf[100];
for (int offset = 80; offset <=92; offset++){
lseek(fd,offset,SEEK_SET);
long long result = read(fd,buf,0);
printf("thread %d F(%d): %lld\n",idx,offset,result);
}
end:
close(fd);
}
int main(){
pthread_t test[2];
int idx[2] = {1,2};
pthread_create(&test[0],NULL,worker,&idx[0]);
pthread_create(&test[1],NULL,worker,&idx[1]);
for(int i = 0;i < 2; i++)
pthread_join(test[i],NULL);
return 0;
}
```
- 未移除 mutex
在原先的程式中,利用 `mutex_trylock` 和 `mutex_unlock` 來避免程式進入 critical section ,從而保證每個執行緒的內容正確。而其中使用的 `mutex_trylock` 當發現該區段已經被 lock 時,會直接返回,屬於非阻斷式 I/O 。
而這樣的特點也可以經由實驗發現,當反覆嘗試原本的 fibdrv 後,會出現兩種結果,一種是出現錯誤訊息的,另一種是兩個執行緒都成功的完成任務。
```
$ sudo ./thread
thread 1 F(80): 23416728348467685
Failed to open character device: Device or resource busy
thread 1 F(81): 37889062373143906
thread 1 F(82): 61305790721611591
thread 1 F(83): 99194853094755497
thread 1 F(84): 160500643816367088
```
```
$ sudo ./thread
thread 1 F(80): 23416728348467685
thread 1 F(81): 37889062373143906
thread 1 F(82): 61305790721611591
...
thread 2 F(80): 23416728348467685
thread 2 F(81): 37889062373143906
thread 2 F(82): 61305790721611591
...
```
第一種情況發生的原因是因為兩個執行緒針對 fibdrv 的讀寫順序是相互交雜的,因此當 fibdrv 已經被 lock 時,另一個執行緒嘗試 trylock 失敗,因此印出錯誤訊並結束該執行緒。
而第二種狀況則是運氣好,兩個執行緒的讀寫沒有交雜,因此兩個執行緒都完成各自的任務。
- 將 trylock 改成 lock
在改成使用 `mutex_lock` 後,當執行緒程式存取 mutex 時,若該區段已被 lock ,則會持續等待直到 unlock ,屬於阻斷式 I/O 。
```
$ sudo ./thread
thread 1 F(80): 23416728348467685
thread 1 F(81): 37889062373143906
thread 1 F(82): 61305790721611591
...
thread 2 F(80): 23416728348467685
thread 2 F(81): 37889062373143906
thread 2 F(82): 61305790721611591
...
```
- 去除 mutex
去除 mutex 後, thread 將可以依照原先的順序讀取,不用等待其他 thread 釋出程式驅動。
```
$ sudo ./thread
thread 2 F(80): 23416728348467685
thread 2 F(81): 37889062373143906
thread 2 F(82): 61305790721611591
thread 2 F(83): 99194853094755497
thread 1 F(80): 23416728348467685
thread 1 F(81): 37889062373143906
thread 1 F(82): 61305790721611591
thread 2 F(84): 160500643816367088
thread 2 F(85): 259695496911122585
thread 1 F(83): 99194853094755497
thread 1 F(84): 160500643816367088
```
可以看到 thread 1 和 2 交互讀取 fibdrv ,不過有趣的是可以發現其中每個費氏數列的值並沒有出錯,這是因為在原先撰寫的測試程式中, file descriptor 並不是共享資源, thread 間沒有共用同一個 fd 自然也不會有衝突。
在把 fd 改為全域變數之後,可以發現 thread 所得到的值出現錯誤,意味著進入了 critical section 。
```
$ sudo ./thread
thread 1 F(80): 23416728348467685
thread 1 F(81): 37889062373143906
thread 1 F(82): 61305790721611591
...
thread 1 F(89): 1779979416004714189
thread 2 F(80): 1779979416004714189
thread 2 F(81): 37889062373143906
thread 2 F(82): 61305790721611591
```
不過猜測由於測試程式僅有兩個執行緒,因此在進行 lseek 和 read 的操作時,要出現如 thread 1 lseek 但還沒 read 時, offset 已經被 tread 2 改變造成結果不正確的 race condition 反而機率不大,如果採用像 [ KYG-yaya573142 的報告](https://hackmd.io/@KYWeng/rkGdultSU)中用 sleep 的方法,就可以很明顯的看到效果。
```
$ sudo ./thread
thread 1 F(80): 23416728348467685
thread 2 F(80): 37889062373143906
thread 1 F(81): 37889062373143906
thread 1 F(82): 61305790721611591
thread 2 F(81): 99194853094755497
thread 1 F(83): 61305790721611591
thread 1 F(84): 160500643816367088
thread 2 F(82): 259695496911122585
```
或是單純的增加 thread 也可以較容易觀察到錯誤(增加到 20 個 thread)
```
sudo ./thread
...
thread 18 F(83): 99194853094755497
thread 6 F(84): 61305790721611591
thread 3 F(84): 160500643816367088
thread 19 F(80): 23416728348467685
...
```
---
## 額外議題
### xs 的設計與記憶體開銷
在實作大數加法的時候,我直接利用 char* 來儲存數字,企圖"簡化" xs 的架構來達到同樣可以大數運算的效果,但就在多看幾次 [SSO (Small/Short String Optimization)](http://wdv4758h.github.io/notes/cpp/string-optimization.html) 和聽老師上課後才稍微理解為何 xs 要這樣設計。
- xs 的架構
```c=
typedef union {
/* allow strings up to 15 bytes to stay on the stack
* use the last byte as a null terminator and to store flags
* much like fbstring:
* https://github.com/facebook/folly/blob/master/folly/docs/FBString.md
*/
char data[16];
struct {
uint8_t filler[15],
/* how many free bytes in this stack allocated string
* same idea as fbstring
*/
space_left : 4,
/* if it is on heap, set to 1 */
is_ptr : 1, is_large_string : 1, flag2 : 1, flag3 : 1;
};
/* heap allocated */
struct {
char *ptr;
/* supports strings up to 2^MAX_STR_LEN_BITS - 1 bytes */
/* MAX_STR_LEN_BITS is 54 in this case,
* so xs supports strings up to 2^54 -1 bytes */
size_t size : MAX_STR_LEN_BITS,
/* capacity is always a power of 2 (unsigned)-1 */
capacity : 6;
/* the last 4 bits are important flags */
};
} xs;
```
可以看到 xs 在佔用 16 bytes 且小字串的情況下,可以利用 15 bytes 儲存字串資料,利用 space_left 來計算剩餘可用的 bytes ,且 15 bytes 可用 4 個 bits 就可以儲存資料剩餘大小 ($2^4-1 = 15$) 。最後剩下 4 個 bits 的 `is_ptr` 和 `is_large_string` 可以分別對應到 [FBString](https://github.com/facebook/folly/blob/main/folly/docs/FBString.md) 中提到的中字串與大字串,而 `flag2` 與 `flag3` 我認為只是把剩下的 bits 定義完,實際上並不會用到。
至於在處理中字串與大字串時則利用 `heap allocated` 的 struct 來表達。
- xs 記憶體開銷
xs 這樣的設計使得在小字串時可以直接用 stack 中的記憶體,避免動態配置新記憶體,空間利用上也更完善,直接在原先配置的空間中紀錄了 size, capaity 和盡可能的儲存字串資料。
---