--- tags: linux2022 --- # 2022q1 Homework2 (quiz2) contributed by ??? > [第 2 周測驗題](https://hackmd.io/@sysprog/linux2022-quiz2) ### 測驗`1` 兩個無號整數取平均值 #### 說明及補完程式碼 * 考慮以下對二個無號整數取平均值的程式碼: ```c #include <stdint.h> uint32_t average(uint32_t a, uint32_t b) { return (a + b) / 2; } ``` 這個直覺的解法會有 overflow 的問題,若我們已知 `a`, `b` 數值的大小,可用下方程式避免 overflow: ```c #include <stdint.h> uint32_t average(uint32_t low, uint32_t high) { return low + (high - low) / 2; } ``` 接著我們可改寫為以下等價的實作: ```c #include <stdint.h> uint32_t average(uint32_t a, uint32_t b) { return (a >> 1) + (b >> 1) + (EXP1); } ``` 其中 `EXP1` 為 `a`, `b`, `1` (數字) 進行某種特別 bitwise 操作,限定用 OR, AND, XOR 這三個運算子。 兩個整數 `a`,`b` 做 `/` 運算,在 C 語言當中為整數除法,e.g. 5 / 2 = 2. 而對兩數分別作 `>>` 運算,形同對兩數作 `/ 2` 運算。因此若是遇到奇數,作除法後小數會被捨去。本來 `(a+b)/ 2` 只捨去一次,現在分別對`a`,`b` 作除法,最多可能被捨去兩次,因此 `EXP1` 是為了補足被捨去超過一次的情況。 在 `a/2 + b/2` 之下,探討 `a`,`b` 的奇偶: |a 的最右邊bit|b 的最右邊bit|小數被捨去次數 |期望補上的值| |---|---|---|---| |0|0|0|0| |1|0|1|0| |0|1|1|0| |1|1|2|1| 期望補上的值,可以看出是 `a 的最右邊bit` `&` `b 的最右邊bit` ,取得 `a 的最右邊bit` 為 `a & 1` ,因此 ==EXP1== = `(a & 1) & (b & 1)` = `a & b & 1` >參考 [laneser](https://hackmd.io/@laneser/linux2022-quiz2) * 我們再次改寫為以下等價的實作: ```c uint32_t average(uint32_t a, uint32_t b) { return (EXP2) + ((EXP3) >> 1); } ``` 其中 `EXP2` 和 `EXP3` 為 `a` 和 `b` 進行某種特別 bitwise 操作,限定用 OR, AND, XOR 這三個運算子。 `EXP2` 和 `EXP3` 必定包含 `a` 和 `b` 字元 參考你所不知道的 [C 語言:數值系統篇](https://hackmd.io/@sysprog/c-numerics#%E7%AE%97%E8%A1%93%E5%AE%8C%E5%85%A8%E5%8F%AF%E7%94%A8%E6%95%B8%E4%BD%8D%E9%82%8F%E8%BC%AF%E5%AF%A6%E5%81%9A)其中的「算術完全可用數位邏輯」節,`a + b` 可拆成兩個運算,`a & b` 算的是進位(carry),`a ^ b` 算的是相加但不進位,可用以下真值表說明: |a|b|a&b|a^b |-|-|-|-| |0|1|0|1| |1|0|0|1| |0|0|0|0| |1|1|1|0| 因此 `a + b` = `((a & b) << 1) + (a ^ b)`,而根據題目,要求的是 `(a + b)/2` ,所以將上述式子` / 2`:`(((a & b) << 1) + (a ^ b)) / 2` = `(((a & b) << 1) + (a ^ b)) >> 1` = `(a & b) + ((a ^ b) >> 1)`,所以 ==EXP2== = `a & b` , ==EXP3== = `a ^ b` :::warning TODO: * 比較上述實作在編譯器最佳化開啟的狀況,對應的組合語言輸出,並嘗試解讀 (可研讀 CS:APP 第 3 章) * 研讀 Linux 核心原始程式碼 include/linux/average.h,探討其 Exponentially weighted moving average (EWMA) 實作,以及在 Linux 核心的應用 > 移動平均(Moving average),又稱滾動平均值、滑動平均,在統計學中是種藉由建立整個資料集合中不同子集的一系列平均數,來分析資料點的計算方法。 移動平均通常與時間序列資料一起使用,以消除短期波動,突出長期趨勢或周期。短期和長期之間的閾值取決於應用,移動平均的參數將相應地設置。例如,它通常用於對財務數據進行技術分析,如股票價格、收益率或交易量。它也用於經濟學中研究國內生產總值、就業或其他宏觀經濟時間序列。 ::: --- ### 進入測驗 `2` 前,先來看取絕對值與 min / MAX 問題 參考[解讀計算機編碼](https://hackmd.io/@sysprog/binary-representation#%E5%B8%B8%E6%95%B8%E6%99%82%E9%96%93%E5%AF%A6%E4%BD%9C)其中的「常數時間實作」篇,提到**絕對值**的 bit-wise 操作: $$ abs(x) = \begin{cases} x & \text{if } x \geq 0 \newline (\sim x) + 1 =\ \sim (x-1)& \text{if } x < 0 \end{cases} $$ 對應的實作可以為 (注意此實作 `x` 不能為 `INT32_MIN` ): ```c #include <stdint.h> int32_t abs(int32_t x) { int32_t mask = (x >> 31); return (x + mask) ^ mask; //if x < 0, return ~(x-1), x 對 -1 作 ^, 形同 x 反轉位元 } ``` 另外也提到**給定兩個有號整數,找出其中較小的數值**(注意這實作僅在 `INT32_MIN <= (a - b) <= INT32_MAX` 才成立): ```c #include <stdint.h> int32_t min(int32_t a, int32_t b) { int32_t diff = a - b; return b + (diff & (diff >> 31)); } ``` 若 `a - b = diff < 0`,則 `a` 較小,`diff >> 31` 為 `0xffffffff`,與 `diff` `&` 運算後仍為 `diff`,回傳 `b + diff = a` 若 `a - b = diff > 0`,則 `b` 較小,`diff >> 31` 後為`0x00000000`,與 `diff` `&` 運算後為 `0x00000000`,回傳 `b + 0x00000000 = b` --- ### 測驗`2` 找 MAX #### 說明及補完程式碼 * 改寫[〈解讀計算機編碼〉](https://hackmd.io/@sysprog/binary-representation#%E5%B8%B8%E6%95%B8%E6%99%82%E9%96%93%E5%AF%A6%E4%BD%9C)一文的「不需要分支的設計」一節提供的程式碼 min,我們得到以下實作 (max): ```c #include <stdint.h> uint32_t max(uint32_t a, uint32_t b) { return a ^ ((EXP4) & -(EXP5)); } ``` 其中 `EXP4` 為 `a` 和 `b` 進行某種特別 bitwise 操作,限定用 OR, AND, XOR 這三個運算子。 `EXP5` 為 `a` 和 `b` 的比較操作,亦即 logical operator,限定用 >, <, ==, >=, <=, != 這 6 個運算子。 `EXP4`, `EXP5` 必定包含 `a` 和 `b` 字元。 `EXP5` 前有個負號,直覺 `EXP5` 應為 `0` 或 `1`,而若要找較大者,必定是比較 `a`, `b` 大小,假設 ==EXP5== = `a < b`,再探討下述兩種情況: `a > b`:應該回傳 `a = a ^ 0`(a 對 0 xor,形同無作用),所以 `0 = (EXP4) & -(EXP5) = (EXP4) & 0` --- 一式。 `a < b`:應該回傳 `b = a ^ (a ^ b )`(b 對 a 作兩次 xor 形同沒作用),所以 `a ^ b = (EXP4) & -(EXP5) = (EXP4) & -1` --- 二式。 解二式 ==EXP4== = `a ^ b`。代入一式 `(a ^ b) & 0` 也確實等於 `0` 。 因此答案為: ```c return a ^ ((a ^ b) & -(a < b)); ``` :::warning TODO: * 針對 32 位元無號/有號整數,撰寫同樣 branchless 的實作 不論是有號或無號上述的方法都可以使用,只需要將型別改成有號即可:`uint32_t` -> `int32_t` ::: --- ### 測驗`3` GCD #### 說明及補完程式碼 * 考慮以下 64 位元 GCD (greatest common divisor, 最大公因數) 求值函式: ```c #include <stdint.h> uint64_t gcd64(uint64_t u, uint64_t v) { if (!u || !v) return u | v;//回傳非 0 的參數 /*輾轉相除法概念*/ while (v) { uint64_t t = v; v = u % v; u = t; } return u; } ``` * 註:GCD 演算法 1. If both $x$ and $y$ are $0$, gcd is $0$. $gcd(0,0)=0$, $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∗gcd(\dfrac{x}{2},\dfrac{y}{2})$, because $2$ is a common divisor. Multiplication with $2$ can be done with bitwise 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 #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 (COND); return RET; } ``` * 第一個 if 為上述註的第一及第二點。 * 執行 for loop 的條件為 `u`,`v` 兩數皆為偶數(`u`, `v` 任一數二進制的最低位元為 1,條件便無法成立),若皆為偶數兩數都 `/= 2`。此為上述註的第三點。 * while loop 執行的為上述註的第四點。($x$, $y$ 對應 `u`,`v`) * do while loop 當中,第一個 while loop 執行的是註的第五點。接下來的 if else 為輾轉相除法的實作,結束條件為當較小的數為 0 的時後,這裡較小的數為 `v`,因此 ==COND== = `v`,且當較小數為 0 時,最大公因數即為另一較大的數。但需要注意的是,在執行上面的 for loop 時,有計算`多少 2 的冪` = `shift` ,使得 $2^{shift}$ 為公因數, 因此 ==RET== = `u << shift`。 #### 在 x86_64 上透過 `__builtin_ctz` 改寫 GCD,分析對效能的提升 參考你所不知道的 [C 語言:數值系統篇](https://hackmd.io/@sysprog/c-numerics#Count-Leading-Zero)其中的「Count Leading Zero」節,`__builtin_ctz` 算的是一數的二進制表示法中,從最低位開始遇到第一個 `1` 之前,共有幾個 `0`。 知道一數的 ctz(count tailing zero)後,便可以知道其中的因數為 `2的多少次冪`。 以相同上述實作的邏輯改寫後: ```c= #include <stdint.h> uint64_t mygcd64(uint64_t u, uint64_t v) { if (!u || !v) return u | v; int ctz_u, ctz_v, shift; ctz_u = __builtin_ctz(u); ctz_v = __builtin_ctz(v); if (ctz_u > ctz_v) { shift = ctz_u - (ctz_u - ctz_v); } else { shift = ctz_v - (ctz_v - ctz_u); } u = u >> shift; v = v >> shift; if (ctz_u = __builtin_ctz(u)) u = u >> ctz_u; do { if (ctz_v = __builtin_ctz(v)) v = v >> ctz_v; if (u < v) { v -= u; } else { uint64_t t = u - v; u = v; v = t; } } while(v); return u << shift; } ``` * 第 10 到第 16 行,做的是上份實作的 for loop 的功能,算出公因數:` 2 的多少次冪`。 * 第 18 及 21 行,執行的是上份實作兩個 while loop 的功能,若有偶數,將 2 的因數除掉。 * 參考[Linux效能分析工具:Perf](https://hackmd.io/@sysprog/gnu-linux-dev/https%3A%2F%2Fhackmd.io%2Fs%2FB11109rdg),根據以下 `main function` : ```c int main() { uint64_t u = 64, v = 52; printf("%ld\n",gcd64(u,v)); //printf("%ld\n",mygcd64(u,v)); return 0; } ``` 改寫**前**實作效能如下: ```shell $ perf stat --repeat 5 -e cache-misses,cache-references,instructions,cycles ./quiz > Performance counter stats for './quiz' (5 runs): 22,466 cache-misses # 35.419 % of all cache refs ( +- 4.84% ) 63,429 cache-references ( +- 1.88% ) 825,880 instructions # 0.56 insn per cycle ( +- 0.40% ) 1,474,393 cycles ( +- 3.79% ) 0.0007654 +- 0.0000326 seconds time elapsed ( +- 4.26% ) ``` 改寫**後**實作效能如下: ```shell $ perf stat --repeat 5 -e cache-misses,cache-references,instructions,cycles ./mine > Performance counter stats for './mine' (5 runs): 23,806 cache-misses # 36.953 % of all cache refs ( +- 2.95% ) 64,423 cache-references ( +- 5.30% ) 830,068 instructions # 0.69 insn per cycle ( +- 0.50% ) 1,199,807 cycles ( +- 5.34% ) 0.001786 +- 0.000135 seconds time elapsed ( +- 7.53% ) ``` 兩者數據結果差不多,接著把執行次數提升到 100,並多偵測 branch event: ```shell $ perf stat --repeat 100 -e cache-misses,cache-references,instructions,cycles,branch-misses,branch-instructions ./quiz > Performance counter stats for './quiz' (100 runs): 22,362 cache-misses # 40.160 % of all cache refs ( +- 1.78% ) 55,682 cache-references ( +- 0.76% ) 830,198 instructions # 0.62 insn per cycle ( +- 0.10% ) 1,339,491 cycles ( +- 1.86% ) 6,600 branch-misses # 3.91% of all branches ( +- 0.39% ) 168,835 branch-instructions ( +- 0.10% ) 0.0007404 +- 0.0000235 seconds time elapsed ( +- 3.17% ) ``` ```shell $ perf stat --repeat 100 -e cache-misses,cache-references,instructions,cycles,branch-misses,branch-instructions ./mine > Performance counter stats for './mine' (100 runs): 22,054 cache-misses # 40.183 % of all cache refs ( +- 1.38% ) 54,885 cache-references ( +- 0.61% ) 830,690 instructions # 0.65 insn per cycle ( +- 0.12% ) 1,270,811 cycles ( +- 1.71% ) 6,630 branch-misses # 3.92% of all branches ( +- 0.42% ) 168,972 branch-instructions ( +- 0.11% ) 0.0006431 +- 0.0000144 seconds time elapsed ( +- 2.23% ) ``` 兩份實作 cache miss 約 40.1%,branch miss 約 3.9%,數據結果差異不大,改寫後的程式尚須精進。 :::warning TODO: * 為何改寫後效能仍尚未改進? * Linux 核心中也內建 GCD (而且還不只一種實作),例如 lib/math/gcd.c,請解釋其實作手法和探討在核心內的應用場景。 ::: --- ### 測驗`4` 計算 bitmap 中有多少個1, 及分別在第幾位 #### 說明及補完程式碼 在影像處理中,[bit array](https://en.wikipedia.org/wiki/Bit_array) (也稱 bitset) 廣泛使用,考慮以下程式碼: ```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; } ``` 此段程式中,`pos` 代表整個 `bitmap` 中有多少個 `1`。`out` 紀錄的是每個`為 1 的 bit` 在 `bitmap` 的第幾位,這裡可以看出即使 `bitmap` 為 64 * `bitmapsize`,在儲存空間上仍是一維的。 考慮 GNU extension 的 [`__builtin_ctzll`](https://gcc.gnu.org/onlinedocs/gcc/Other-Builtins.html) 的行為是回傳由低位往高位遇上連續多少個 `0` 才碰到 `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 = EXP6; int r = __builtin_ctzll(bitset); out[pos++] = k * 64 + r; bitset ^= t; } } return pos; } ``` 第 9 行的作用是僅保留最低位元的 1,並紀錄到 `t` 變數。舉例來說,若目前 bitset 為 $000101_b$,那 `t` 就會變成 $000001_b$,接著就可以透過 `XOR` 把該位的 `1` 清掉,其他保留 (此為 XOR 的特性)。 若 bitmap 越鬆散 (即 1 越少),於是 **improved** 的效益就更高。 Hint:需考慮到 `-bitwise` 的特性 根據此目的,可以先算出 `bitset` 的 ctz,把 `1L` 左移 ctz 個,如此可以得出只保留最低位 `1` 的 `bitset`。因此 ==EXP6== = `1L << __builtin_ctzll(bitset)`。 但若要利用 `-bitwise` 的特性,需要換個作法:若把 `bitset 取負`,與 `bitset` 差異為除了最低位的 `1` 以及更低位的 `0` 相同之外,其他位皆相反。因此若要得到只保留最低位 `1` 的 `bitset`,把 `bitset & -bitset` 即可。==EXP6== = `bitset & -bitset`。 第 12 行做的便是把 `bitset` 最低位 `1` 消除,讓下一個迴圈再次去找最低位 `1` 在哪。 :::warning TODO: * 這樣的程式碼用在哪些真實案例中 * 設計實驗,檢驗 ctz/clz 改寫的程式碼相較原本的實作有多少改進?應考慮到不同的 [bitmap density](http://www.vki.com/2013/Support/docs/vgltools-3.html) * 思考進一步的改進空間 * 閱讀 [Data Structures in the Linux Kernel](https://0xax.gitbooks.io/linux-insides/content/DataStructures/linux-datastructures-3.html) 並舉出 Linux 核心使用 bitmap 的案例 :::