# 2020q3 Homework3(quiz3) contributed by < `fennecJ` > ### 題目連結 > [2020q3 第 3 週測驗題](https://hackmd.io/@sysprog/2020-quiz3) ## 測驗 `1` ```cpp= int asr_i(signed int m, unsigned int n) { const int logical = (((int) -1) OP1) > 0; unsigned int fixu = -(logical & (OP2)); int fix = *(int *) &fixu; return (m >> n) | (fix ^ (fix >> n)); } ``` `OP1` = `>> 1` 這裡的作用是在判斷系統在進行右移運算時會補 1 或是補 0 ,若補 1 ,則表示系統進行 ASR 運算,此時 `logical` = 0 ,反之 `logical` = 1 只有負數的右移運算是未定義行為,因此要判斷輸入進來的數字是否為負數,所以 `OP2` = `m < 0` 所以 `logical` & `(m < 0)` 只會有四種狀態 | logical | m < 0 |fixu = -logical & (m < 0) | | -------- | -------- | -------- | | 0 |0 |0 | | 0 | 1 |0 | | 1 | 0 | 0 | | 1 | 1 | -1 | 其中,只有當程式偵測到系統並非使用 ASR 運算右移且輸入的數值 m 為負數時 fixu 才會是 -1 , fix 也是 -1 。而 -1 的二進位為 : `1111 1111 1111 1111 1111 1111 1111 1111` 而若 m 原本的二進位為 : `mmmm mmmm mmmm mmmm mmmm mmmm mmmm mmmm` 經過右移 n 位之後 ( 假設 n = 8 ) 會變成 : `0000 0000 mmmm mmmm mmmm mmmm mmmm mmmm` fix 右移 n 位為 `0000 0000 1111 1111 1111 1111 1111 1111` fix^(fix>>n) 為 `1111 1111 0000 0000 0000 0000 0000 0000` 觀察可發現此時 fix^(fix>>n) 恰能補足 m>>n 使其成為 ASR 運算。 而若 logical 和 m < 0 中有任何一個條件不滿足,則不需要去補足 m>>n , fix 的值也剛好會是 0 ,此時只有三種可能 : 1. 系統進行 ASR 右移運算,輸入負數:本來就是 ASR 運算,不需要調整 2. 系統進行 ASR 右移運算,輸入正數:本來就是 ASR 運算,且正數為已定義行為,不需要調整 3. 系統不進行 ASR 右移運算,輸入正數:正數為已定義行為,不需要調整 ## 測驗 `2` ```cpp= bool isPowerOfFour(int num) { return num > 0 && (num & (num - 1))==0 && !(__builtin_ctz(num) OPQ); } ``` OPQ = ? `& 0x1` 逐步檢視程式的運作邏輯: ```cpp (num & (num - 1)) ``` 此行確保數字轉為二進位只會有一個 1 ,即數字必為 2 的 n 次方 而要如何區隔 2 的次方和 4 的次方?觀察以下: | 次方 (n) | 2^n | 右起第一個1之後的0個數 | | ------------------------ | ------------------------- |:------------------------- | | <font color="#f00">**0** | <font color="#f00">**1** | <font color="#f00">**0** | | 1 | 2 | 1 | | <font color="#f00">**2** | <font color="#f00">**4** | <font color="#f00">**2** | | 3 | 8 | 3 | | <font color="#f00">**4** | <font color="#f00">**16** | <font color="#f00">**4** | | 5 | 32 | 5 | | <font color="#f00">**6** | <font color="#f00">**64** | <font color="#f00">**6** | | 7 | 128 | 7 | 可以發現 4 的次方以二進位表示後,右起第一個1之後的0個數一定會是偶數,因此最右邊的 bit 必為 0 ,以 ```cpp !(__builtin_ctz(num) & 0x1); ``` 排除掉 least bit 為1的結果 接下來嘗試降低 branch 的數量 觀察以下: | 次方 (n) | 2^n | leading zeros | | ------------------------ | ------------------------- |:------------------------- | | <font color="#f00">**0** | <font color="#f00">**1** | <font color="#f00">**31** | | 1 | 2 | 30 | | <font color="#f00">**2** | <font color="#f00">**4** | <font color="#f00">**29** | | 3 | 8 | 28 | | <font color="#f00">**4** | <font color="#f00">**16** | <font color="#f00">**27** | | 5 | 32 | 26 | | <font color="#f00">**6** | <font color="#f00">**64** | <font color="#f00">**25** | | 7 | 128 | 24 | 可以發現 4 的次方以二進位表示後,其 leading zeros 的個數為奇數,因此我們可以改以 ```cpp (__builtin_clz(num) & 0x1); ``` 取代 ```cpp !(__builtin_ctz(num) & 0x1); ``` 這樣做的好處是 ```cpp (__builtin_clz(num) & 0x1); ``` 要成立的話, num 必定為正數,因此可以把 num > 0 這個 branch 拔掉 最後程式變成: ```cpp= bool isPowerOfFour(int num) { return (num & (num - 1)) == 0 && (__builtin_clz(num) & 0x1); } ``` Leetcode 1009 : ```cpp= int bitwiseComplement(int N){ return N ? (0xFFFFFFFF >> __builtin_clz(N)) ^ N : 1; } ``` 由於本題限制輸入的 N 在 0~10^9 之間,因此可以直接使用右移,不用擔心系統是使用算術右移和邏輯右移。先做出一個和 N 的二進位表示時所需的最小長度等長,全為 1 的 mask ,在讓 N 和這個 mask 做 XOR 運算即可。只要對 0xFFFFFF 進行 __builtin_clz(N) 的右移就能得到這個 mask 。 Leetcode 41 : ```cpp= void swap(int* a,int* b){ (*a) ^= (*b); (*b) ^= (*a); (*a) ^= (*b); } int firstMissingPositive(int* nums, int numsSize){ for(int i = 0; i < numsSize; i++){ if(nums[i] < numsSize){ if(nums[i] != i+1 && nums[i] > 0){ int t = nums[i] - 1; swap(&(nums[i]),&(nums[t])); i--; } } } for(int i = 0; i < numsSize; i++){ if(nums[i] != i+1) return i+1; } return numsSize+1; } ``` 遍歷整個陣列,把遇到的數字依序放入陣列的位置 ( 1 放在 nums[0] , 2 放在 nums[1] 依此類推 ... ) ,最後再遍歷整個陣列一次,若遇到 nums[i] != nums[i+1] 則 i+1 即為題目所求。這題目前想不到用 clz 減少分支的做法。 ## 測驗 `3` LeetCode [1342. Number of Steps to Reduce a Number to Zero](https://leetcode.com/problems/number-of-steps-to-reduce-a-number-to-zero/) ```cpp int numberOfSteps (int num) { return num ? AAA + 31 - BBB : 0; } ``` 我們可以把題目理解為: 若 LSB = 0 則右移一位,否則減一使 LSB = 0 對任意數字,若其有 x 個位元是 1 ,則要有 x 步用來減 1 ,剩下的步數則用來右移。 我們可以用 __builtin_popcount(num) 求得 x 。 因為我們要右移的次數洽為使 MSB 移到 LSB 的步數。 對任意非零整數,使其 MSB 移到 LSB 所需的步數為 31 - __builtin_clz(num) 。 若 num 為 0 則 return 0 。 因此本題答案為 AAA = `__builtin_popcount(num)` BBB = `__builtin_clz(num)` 以其他程式碼實作,避免用到編譯器的擴充功能實現 branchless 之等價實作: ```cpp= const int tab32[32] = { 0, 1, 28, 2, 29, 14, 24, 3, 30, 22, 20, 15, 25, 17, 4, 8, 31, 27, 13, 23, 21, 19, 16, 7, 26, 12, 18, 6, 11, 5, 10, 9 }; int round_down_log2(uint32_t value) { value |= value >> 1; value |= value >> 2; value |= value >> 4; value |= value >> 8; value |= value >> 16; return tab32[((uint32_t)((value - (value >> 1))*0x077CB531U)) >> 27]; } int pct(uint32_t i) { i = i - ((i >> 1) & 0x55555555); i = (i & 0x33333333) + ((i >> 2) & 0x33333333); return (((i + (i >> 4)) & 0x0F0F0F0F) * 0x01010101) >> 24; } int numberOfSteps (int num){ return pct(num) + round_down_log2(num); } ``` 參考 [2017 年修課學生報告](https://hackmd.io/@3xOSPTI6QMGdj6jgMMe08w/Bk-uxCYxz) ,我們可用 De Bruijn sequence 實作出無條件捨去的 log2 運算,其中對 x 進行無條件捨去的 log2 運算可視為使 x MSB 移到 LSB 所需的步數。 參考 [Bit Twiddling Hacks](http://graphics.stanford.edu/~seander/bithacks.html#CountBitsSetNaive) 中的 Counting bits set, in parallel ,我們可以實作出 popcnt 。(原理可參考 [the bit twiddler](https://bits.stephan-brumme.com/countBits.html) ) 而所求為要減幾次 1 的次數 + MSB 移到 LSB 所需的步數,因此答案為 pct(num) + round_down_log2(num) ## 測驗 `4` 答案為 XXX = `(b)` `v` YYY = `(a)` `u << shift` ```cpp= #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(a,b)=2^{shift}*gcd(c,d) \\where \ a=2^{shift}*c\ ,b=2^{shift}*d$$ 程式先將兩數中 2 的 shift 次方的共同因數提出來,然後把任一邊的 2 的因數消掉,之後就是進行一般的輾轉相除法,再把最後的結果乘上 2 的 shift 次方。 ### 以 __builtin_ctz 改寫 GCD : ```cpp= #define min(x,y,r) \ r = y ^ ((x ^ y) & -(x < y)) #include <stdint.h> uint64_t gcd64(uint64_t u, uint64_t v) { if (!u || !v) return u | v; int shift; min(__builtin_ctz(u),__builtin_ctz(v),shift); u >>= __builtin_ctz(u); v >>= __builtin_ctz(v); do { v >>= __builtin_ctz(v); if (u < v) { v -= u; } else { uint64_t t = u - v; u = v; v = t; } } while (v); return u << shift; } ``` ### 效能比較 我先用亂數產生 100000 筆數據並使用 perf 觀察 gcd 程式的運作情形 其中 1 2 3 分別為題目原來的 gcd , 提出 2 的 shift 次方的 gcd 和 以 __builtin_ctz 改寫的 gcd ,以 -O3 編譯 1. ![](https://i.imgur.com/jfcqiXq.png) 2. ![](https://i.imgur.com/znB7KzF.png) 3. ![](https://i.imgur.com/jESQ7o3.png) 可以看到,以 __builtin_ctz 改寫的 gcd (3) 相較提出 2 的 shift 次方的 gcd (2) 在時間上快了約 28.6% , cycles 也少了約 4270 萬 而相較於題目原本的 gcd 寫法 (1) 則在時間上快了約 21.6% , cycles 也少了約 2978 萬 :::info 在使用不同數量的亂數測資時發生了一個現象,當測資的數量足夠大時,程式會產生大量的 cache-misses ,目前不知道該怎麼減少因為測資數量產生的 cache-misses ::: ## 測驗 `5` ### bit array 的應用: - 影像處理 - bloom filter - linux kernal(allocation of memory pages, inodes, disk sectors, etc.) ### 程式原理解釋 先看題目原本給的程式碼: ```cpp= #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; } ``` 這個程式在做的事情就是計算 bitmap 中有幾個 bit 是 1 ,把計算出來的總數存入 pos ,並把每個是 1 的 bit 的 index 存到 out 中。其中第 9 行的判斷式可改為先把 trailing zero 跳過,直接從第一個非零的 bit 開始記錄 index : ```cpp= #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 = KKK; int r = __builtin_ctzll(bitset); out[pos++] = k * 64 + r; bitset ^= t; } } return pos; } ``` 當我們把第一個非零 bit 算出來之後,就把那個 bit 變成 0 ,接下來繼續往下算。 數字總共只有偶數和奇數兩種,而我們知道電腦的二補數運算為 (假設 N 為整數) $$N = NOT(-N) + 1$$ 這個特性使得 $$ N \ AND\ (-N) $$ 所得的 binary 數必為除了 N 的第一個非 0 bit 為 1 外其餘皆為 0 因此當我們讓 $$ N \oplus (N\ AND \ -N) $$ 就能只把第一個非 0 bit 變成 0 其餘保持不變了。 因此本題答案為 KKK = `e` `bitset & -bitset` 設計實驗測試效能 : 首先我設了三個長度為 10000 的陣列 data0 data1 data2並用 perf 測試,其中 data0 所有元素皆為 0 , data1 所有元素皆為 0xffffffffffffffff , data2 則為亂數產生的 64 位整數 為了方便 perf 使用,我利用 argv 讀取外部傳入的參數 以下簡述參數的定義 : 執行時輸入兩個參數 [模式] [資料] 第一個參數 : 0 表示使用 naive 處理資料 1 表示使用 improved 處理 第二個參數 : 0 表示使用 data0 作為資料 1 表示使用 data1 作為資料 2 表示使用 data2 作為資料 程式本身以 O1 編譯 先測試 data0 ( 全元素為 0 ) : naive : ![](https://i.imgur.com/uJhsahX.png) improved : ![](https://i.imgur.com/s6g2TCk.png) 可以看到 improved 相較 naive 約快了 49.4% 接著測試 data1 ( 全元素為 0xffffffffffffffff ) : naive : ![](https://i.imgur.com/9sFk0no.png) improved : ![](https://i.imgur.com/XMQTKCy.png) 可以看到這時 naive 相較 improved 快了約 20% ,推測是因為一開始每個 bit 都是 1 ,用 ctz 不但不會省時間反而還多耗了一些時間成本。 最後測試 data2 : naive : ![](https://i.imgur.com/O9fHsF8.png) improved : ![](https://i.imgur.com/QTrPdTD.png) 可以看到 improved 相較 naive 約快了 50.5% 因此除非確定所用的資料中的 1 密度非常高,大多時候用 ctz 的寫法應能更有效率。