--- tags: linux2022 --- # 第 1,2 週課堂問答簡記 ## `Uduru0522` Linux 核心的 hash table 實作中,用以處理 hash 數值碰撞的 `hlist_node`: ```cpp struct hlist_node { struct hlist_node *next, **pprev; }; ``` **其中 `pprev` 為何要宣告為==指標的指標==?** 探討典型 doubly-linked list delete 會產生的問題。 考慮我們使用典型的 doubly-linked list: ```c struct dll_node { struct dll_node *next, *prev; } ``` > `dll` 是 doubly-linked list 的簡稱 於是資料結構會展現如下圖: ```graphviz digraph G { rankdir = LR; splines = false; node[shape = "record"] list_head[label = "<m>list_head | <n>first"] node_1[label = "<m>dll_node_1 | {<p>prev | <n>next}", group = list]; node_2[label = "<m>dll_node_2 | {<p>prev | <n>next}", group = list]; node_3[label = "<m>dll_node_3 | {<p>prev | <n>next}", group = list]; NULL_1, NULL_2[shape = plaintext, label = "NULL", group = list] {rank = "min" list_head NULL_1} list_head -> node_1:m; node_1:p -> NULL_1 node_1:n -> node_2:m; node_2:p -> node_1:m; node_2:n -> node_3:m; node_3:p -> node_2:m; node_3:n -> NULL_2; } ``` 注意: + 除了末端,第一個節點的 `prev` 指標內容也是 `NULL`。 + `prev` 及 `next` 皆指向相鄰的**節點本身**。 嘗試撰寫 `delete_node():` ```c void dll_delete_node(struct list_head *l, struct dll_node *n) { struct dll_node *prev = n->prev, *next = n->next; if (next) next->prev = prev; // Delete and update where list_head points to, // which requires the list_head to be passed in. if (!prev) { l->first = next; } else { prev->next = next; } } ``` 可發現當我們要移除第一個節點時,必須做出額外的操作來更新 `list_head` 指向的節點,於是除了移除的目標之外,還必須傳入 `list_head`。 當我們用指標的指標改寫上述程式碼,將原本的 `struct dll_node *prev` 變更為 `struct dll_node **pprev`: ```c struct hlist_node { struct hlist_node *next, **pprev; } ``` 則會形成以下的結構: ```graphviz digraph G { rankdir = LR; splines = false; node[shape = "record"] list_head[label = "<m>list_head | <n>first"] node_1[label = "<m>hlist_node_1 | {<p>pprev | <n>next}", group = list]; node_2[label = "<m>hlist_node_2 | {<p>pprev | <n>next}", group = list]; node_3[label = "<m>hlist_node_3 | {<p>pprev | <n>next}", group = list]; NULL[shape = plaintext, label = "NULL", group = list] {rank = "min" list_head} list_head -> node_1 -> node_2 -> node_3[ weight = 10, style = invis ] list_head:n -> node_1:m; node_1:p -> list_head:n; node_1:n -> node_2:m; node_2:p -> node_1:n; node_2:n -> node_3:m; node_3:p -> node_2:n; node_3:n -> NULL; } ``` 注意: + 僅有末端指標內容是 `NULL`。 + `next` 指向相鄰的**節點本身**,而 `pprev` 指向**指著自身節點的指標**。 則我們便可藉由下方程式碼來撰寫 `delete_node()`: ```c void hlist_delete_node(struct hlist_node *n) { struct hlist_node *next = n->next; **pprev = n->pprev; // Since there is always a pointer which points to node n, // no special case is needed to deal with. *pprev = next; if (next) next->pprev = pprev; } ``` 跟典型 doubly-linked list 的實作做比較,由於該實作的 `prev` 指標必須指向 `struct dll_node` 型別,導致第一個節點會出現指向 `NULL` 的狀況;而將其替換成指標的指標後,便可順利消除這個特例。 至此,我們理解到,hash list 是為雜湊表特製的鏈結串列,儘管雙向鏈結串列亦可實作雜湊表,但由於後者開頭和結尾需要各用 2 個指標,當 bucket 很大時,會增加記憶體開銷。 相較於 `list_head` 的兩個指標,`hlist_head` 僅需要保存一個指標,`hlist_node` 的 `next` 指向下個節點,但 `pprev`(指標的指標) 並不指向上個節點,而是指向上個節點的 `next` 指標,這樣設計的好處是,當要刪除的節點是首個節點,即可藉由 `*pprev = next` 直接修改指標的指向。 延伸閱讀: * [核心基礎設施: `hlist_head`/`hlist_node` 結構解析](http://linux.laoqinren.net/kernel/hlist/) * [hlist 資料結構圖示說明](https://zhuanlan.zhihu.com/p/82375193) --- ## `Eddielin0926` ### quiz2-4 先觀察原本函式的功能 - `bitmap` 是要處理的資料,以 64 bits 為區間 - `bitmapsize` 是總共有要處的資料大小 - `out` 是結果存放的位置 在第一層迴圈中 `bitset` 會儲存目前處理資料,第二層的迴圈目的是要找到資料的位元是 1 的位置並且去除掉 1 後,將位置記錄在 `out` 中。 ```c #include <stddef.h> size_t naive(uint64_t *bitmap, size_t bitmapsize, uint32_t *out) { size_t pos = 0; for (size_t k = 0; k < bitmapsize; ++k) { uint64_t bitset = bitmap[k]; size_t p = k * 64; for (int i = 0; i < 64; i++) { if ((bitset >> i) & 0x1) out[pos++] = p + i; } } return pos; } ``` 嘗試一個簡單的[例子](https://onlinegdb.com/qxgIaXhvp) ``` bitmap[0] = 0x11 bitmapsize = 1 ``` 我們可以得到 `bitmap` 中 1 的數量以及 1 的位置 ``` pos = 2 out = {0, 4} ``` 接下來透過 [`__builtin_ctzll`](https://gcc.gnu.org/onlinedocs/gcc/Other-Builtins.html) 將函式改寫 `__builtin_ctzll` 用來找到由低位往高位的第一個 1 中間會有幾個 0,因此可以用來取代重複判斷的 `if ((bitset >> i) & 0x1)` ,就能減少分支的次數,在硬體有支援的情況下可以加速運算,再來看 `while` 的條件是 `bitset != 0`,加上 `__builtin_ctzll` 的特性,因此我們可以知道,當我們找到 `bitset` 的 1 時,要將 1 去處除掉,才有辦法利用 `__builtin_ctzll` 計算 1 的位置。 ```c #include <stddef.h> size_t improved(uint64_t *bitmap, size_t bitmapsize, uint32_t *out) { size_t pos = 0; uint64_t bitset; for (size_t k = 0; k < bitmapsize; ++k) { bitset = bitmap[k]; while (bitset != 0) { uint64_t t = bitset & -bitset; int r = __builtin_ctzll(bitset); out[pos++] = k * 64 + r; bitset ^= t; } } return pos; } ``` 當我們在思考如何去除掉某個位元的 1 時,第一個想法可能會是與一個在對應位置的 0 做 bitwise AND ```c bitset &= ~(1 << r) ``` <!-- ``` bitset = ((bitset >> (r + 1)) << r) ``` --> 但在這邊的做法利用了**二補數**來去除最低位元的 1。 ```c bitset ^= bitset & -bitset ``` 在我們剛剛的計算中,我們目標都是 `bitset` 中最低位的 1,例如 XXX**1**00。 當我們對 `bitset` 做二補數計算時,最低位的 1 左邊的位元會是原本的補數,右邊則會與原本一樣。 $$ 0110100_2 \xrightarrow{取二補數} 1001100_2 \\ 0110100_2 \land 1001100_2 = 0000100_2 \\ 0110100_2 \oplus 0000100_2 = 0110000_2 $$ 與原本的 `bitset` 做 XOR 就能將最右邊的 1 變成 0 了。 ### quiz2-1 對二個無號整數取平均值 ```c #include <stdint.h> uint32_t average(uint32_t a, uint32_t b) { return (a >> 1) + (b >> 1) + (a & b & 1); } ``` `a >> 1` 和 `b >> 1` 代表將 a 和 b 個別除以 2,`a & b & 1` 用來檢查兩個數是不是都是奇數。 另一個對二個無號整數取平均值的實作。 ```c uint32_t average(uint32_t a, uint32_t b) { return (a & b) + ((a ^ b) >> 1); } ``` 我認為可以想像成全加器除二。 ![](https://hackmd.io/_uploads/B1un5A2ec.png) 原本是 `(a + b) >> 1`,將 carry 提出來後就變成 `(a & b) + ((a ^ b) >> 1)`。 --- ## `william40614`, `millaker` 先分析原程式,參考〈[解讀計算機編碼](https://hackmd.io/@sysprog/binary-representation)〉一文的「不需要分支的設計」一節提供的程式碼 `min`,改寫為以下程式碼: (其中 `EXP4` 和 `EXP5` 尚未實作) ```c #include <stdint.h> uint32_t max(uint32_t a, uint32_t b) { return a ^ ((EXP4) & -(EXP5)); } ``` 運用 [The XOR trick](https://florian.github.io/xor-trick/) 提及的規律: * `a ^ a = 0` * `a ^ 0 = a` * `a ^ ANY ^ ANY = a` 關於 `EXP5`,只要 `a < b` 成立,也就是 `1`, 加上負號後得到 `-1` (`0xffffffff`),原本的表示式成為: * `-1 & (a ^ b) = (a ^ b)` * `a ^ a ^ b = 0 ^ b = b` 可推得 max 是 b 。相反地,當 `a > b` 成立,後面一整項為 `0`,回傳值就會是 `a`。 --- ## `Tcc0403` 第 2 周測驗 3 ### 分析程式碼 ```c #include <stdint.h> uint64_t gcd64(uint64_t u, uint64_t v) { if (!u || !v) return u | v; int shift; for (shift = 0; !((u | v) & 1); shift++) { u /= 2, v /= 2; } while (!(u & 1)) u /= 2; do { while (!(v & 1)) v /= 2; if (u < v) { v -= u; } else { uint64_t t = u - v; u = v; v = t; } } while (v); return u<<shift; } ``` GCD 演算法 1. If both x and y are 0, gcd is zero $gcd(0, 0) = 0$. 2. $gcd(x, 0) = x$ and $gcd(0, y) = y$ because everything divides 0. 3. If x and y are both even, $gcd(x, y)=2 \times gcd(\dfrac{x}{2},\dfrac{y}{2})$ because $2$ is a common divisor. Mulitplication with $2$ can be done with betwise shift operator. 4. If x is even and y is odd, $gcd(x, y) = gcd(\dfrac{x}{2}, y)$. 5. On similar lines, if x is odd and y is even, then $gcd(x, y) = gcd(x, \dfrac{y}{2})$. It is because $2$ is not a common divisor. 以下分段說明: ```c if (!u || !v) return u | v; ``` $\to$ 對應至 GCD 演算法第 2 點 * $gcd(x,0)=x$ and $gcd(0, y)=y$ because everything divides 0. 若 u 和 v 中其中一數為 0 ,則直接回傳另一數 > `x | 0` 會得到 x ```c int shift; for (shift = 0; !((u | v) & 1); shift++) { u /= 2, v /= 2; } ``` $\to$ 對應至 GCD 演算法第 3 點 * If x and y are both even, $gcd(x, y) = 2 \times gcd(\dfrac{x}{2},\dfrac{y}{2})$ because $2$ is a common divisor. Mulitplication with $2$ can be done with betwise shift operator. 先取出`u` 和 `v` 之間屬於 $2^n$ 的公因數,`shift` 即為最大的 $n$ ```c while (!(u & 1)) u /= 2; ``` $\to$ 對應至 GCD 演算法第4 點 * If x is even and y is odd, $gcd(x, y) = gcd(\dfrac{x}{2}, y)$. 當 `u` 為偶數時,可以除以 $2$ ```c do { while (!(v & 1)) v /= 2; if (u < v) { v -= u; } else { uint64_t t = u - v; u = v; v = t; } } while (v); ``` $\to$ 其中 `while(!(v &1)) v /= 2;` 對應至 GCD 演算法第 5 點 * On similar lines, if x is odd and y is even, then $gcd(x, y) = gcd(x, \dfrac{y}{2})$. It is because $2$ is not a common divisor. 可以透過迴圈不變性 (loop invariant) 來幫助解讀程式 * `u` 為被除數 * `v` 為除數 當除數 `v`為 0 時結束迴圈,被除數 `u` 為進入迴圈前 `u` 和 `v` 的最大公因數 :::warning 事實上 `u` 在迴圈前不一定是被除數,因為先前透過 GCD 演算法第 4 點去除不必要的計算 ::: ```c return u << shift; ``` 最後回傳最大公因數時,乘上原先取出屬於 $2^n$ 的公因數,可以透過左移達成 ### 透過 `__builtin_ctz` 改寫程式 已知除法運算會耗費較多時間,我們可以利用 `__builtin_ctz` (編譯器會產生對應到現代微處理器的專門指令) 搭配上述針對二進位特性調整的演算法,減少除法的使用 改寫之前先透過 perf 分析程式,將一百萬組由 `rand` 函式生成的亂數傳入 `gcd64` 函式執行 ```shell $ gcc -g3 -O0 gcd.c -o gcd $ perf record ./gcd $ perf annotate ``` ![](https://hackmd.io/_uploads/BksxZ3fl9.png) 由上圖可以發現,花費 CPU 週期數最多的段落為迴圈當中的 `v /= 2` 運算,佔據約 28% 以下為改寫過後的程式 ```c uint64_t gcd64_ctz(uint64_t u, uint64_t v) { if (!u || !v) return u | v; int shift = __builtin_ctzll(u | v); u = u >> __builtin_ctzll(u); do { v = v >> __builtin_ctzll(v); if (u < v) { v -= u; } else { uint64_t t = u - v; u = v; v = t; } } while (v); return u << shift; } ``` 透過 perf 分析前後差異 ```shell $ gcc -g -O0 gcdcmp.c -o gcdcmp $ perf record ./gcdcmp $ perf report ``` ![](https://hackmd.io/_uploads/Sk_53nGxc.png) 將同樣一百萬筆由 `rand` 函式所產生的亂數傳入 `gcd64` 和 `gcd64_ctz` 函式比較,發現改寫過後的版本花費時間幾乎是原來的一半 ### 改進空間 透過 perf 分析改寫程式 ```shell $ gcc -g -O0 gcd_ctz.c -o gcd_ctz $ perf record ./gcd_ctz $ perf annotate ``` ![](https://hackmd.io/_uploads/r1YR03zxq.png) 佔據花費時間最多的為分支指令 (如 [jne](https://www.aldeid.com/wiki/X86-assembly/Instructions/jnz)),其次是迴圈內部的減法運算 --- ## `kdnvt` 為什麼有 `list_for_each_safe` 還需要 `list_for_each`? `list_for_each` 是 `list.h` 中定義的巨集,用來走訪 linked list 中的各個節點。 以下是實作方式: ``` #define list_for_each(pos, head) \ for (pos = (head)->next; !list_is_head(pos, (head)); pos = pos->next) ``` 程式的邏輯十分單純,判斷 `pos` 是不是等於 `head` 來當作結束條件,並在每次迴圈結尾讓 `pos = pos->next` 來走訪。但如果在走訪的過程中,改變了 `pos->next` ,就可能造成不可預期的行為。因此有了 `list_for_each_safe` 。 `list_for_each_safe` 會用到的地方,主要在走訪的過程中,會更改到當前節點(如將當前節點移出)的時候。 想要知道它是如何在更改節點的時候,仍然可以正確的走訪各個節點,就是直接看這個巨集的實作細節: ``` #define list_for_each_safe(pos, n, head) \ for (pos = (head)->next, n = pos->next; \ !list_is_head(pos, (head)); \ pos = n, n = pos->next) ``` 可以看到除了當前的節點 `pos` ,它還用了另一個指標 `n` 去指向下一個節點。當目前的節點執行完了,當會在迴圈的結尾讓 `pos = n` ,再讓 `n` 指向 `pos->next` 。如此一來,就算 `pos` 在走訪的過程被移出,也有 `n` 這個指標去暫存下一個節點的位址,所以可以安全的走訪所有節點。 若沒有要改變節點的連接方式,如 `q_size` 中走訪各節點被計算總數,則使用 `list_for_each` 就好。如果在這種情況下還使用 `list_for_each_safe` ,則要額外準備一個指標去暫存下一個節點,然而這個指標在走訪個過程中其實是不需要的。 ## `mickey30606` 如何將以下程式碼,修改成取得最接近且小於等於 2 的冪的值。 ```clike uint64_t next_pow2(uint64_t x) { x |= x >> 1; x |= x >> 1; x |= x >> 1; x |= x >> 1; x |= x >> 1; x |= x >> 1; x |= x >> 1; x |= x >> 8; x |= x >> 16; x |= x >> 32; return x+1; } ``` - 方法一:我們只需要將目前最高位的 1 後面位元全部都變成 0 就可以得到我們想要的結果,而原本程式就已經將 `x` 後面位元全部都變成 1 ,所以我們只需要將 `x` 往右推移 1 並與自己進行 XOR 即可得到答案。 ```clike uint64_t next_pow2(uint64_t x) { x |= x >> 1; x |= x >> 2; x |= x >> 4; x |= x >> 8; x |= x >> 16; x |= x >> 32; return x ^ (x >> 1); } ``` - 方法二:使用 `__builtin_clzl` 進行改寫,取得最高位的 1 前面有幾個 bit 之後,用 1 進行推移就可以得到答案。 ```clike uint64_t next_pow2(uint64_t x) { return 1 << (63 - __builtin_clzl(x)); } ```