contributed by <Yuessiah
, Lukechin
, HexRabbit
>
Count Leading Zeros (clz) 或名 Number of Leading Zeros (nlz) 為計算 2 進位數從 MSB 往右數遇到的第一個 之前所有 的數量, e.g. clz(0001010100011011
) = 。
GCC 內建了許多功能強大的函式,其中一項就是 clz。
可惜的是 __builtin_clz (unsigned int x)
對於 x
為 的情況未定義。
int __builtin_clz (unsigned int x)
Returns the number of leading 0-bits in x, starting at the most significant bit position. If x is 0, the result is undefined. [1]
本份作業[2]探討了幾個演算法,底下做些簡單的描述。
binary search 用分而治之的精神,
將問題分成兩個部份,然後令最接近解的部份為子問題,
子問題又能分成兩個部份,其中一個部份又能變為子問題,直到找到解。
假設一個數字 00001010100011011100001010100101
x = 00001010100011011100001010100101
, ,
...._______________^________________
x = 00000000000000000000101010001101
, ,
...._______________^_______^________
x = 00000000000000000000000000001010
, ,
...._______________^_______^___^____
x = 00000000000000000000000000001010
, ,
...._______________^_______^___^_^__
x = 00000000000000000000000000000010
, ,
...._______________^_______^___^_^^_
x = 00000000000000000000000000000001
, ,
, 最後return (n - r);
i.e. clz = 4.
作業說明上直接叫他 binary search 挺怪的,因為其他兩份 code 也是 binary search
於是本文件決定改成 bit mask 了
利用 mask 概念判定第一個 在哪出現。
以0x0FFFFFFF
為例,
由前面的操作,第一個 的位置一定會落在0x00FFFFFF ~ 0x0FFFFFFF
之間
從這個範圍剖一半,若發現 在右側代表 leading zeros 一定多,所以加上合理零的數量,並且做適當的位移;
若在左側則 leading zeros 較少,不做任何動作
只有 if 判斷跟 bit mask 寫法不同,但邏輯上是一樣的。
做法跟 iterative 的版本差不多,但此版本是尾端遞迴,有機會於編譯時優化。
Hash table
是以 key 透過 hash function 得到對應的 index,然後得到該 table 中 index 指向的內容。
De Bruijn sequence
是種很特別的數列,一個長度為 的數列能夠藉由本身長度為 的子數列,構造出 個互不相同的數字。
下面以一個長度為 的 De Bruijn sequence 做例子:
先將 value 變為除了第一個 ,其餘的 bit 都設為
接著用0x077CB531U
這個 De Bruijn sequence 乘上 value,再將 table 映射到的值回傳。
該方法的 hash function 就是前處理後的 value 與 0x077CB531U
相乘 再右位移 位。
tab32 還有個特別的性質,當我們要求 Count Trailing Zeros (ctz) 只需寫:
先將輸入 x
, MSB 至第一位 1
以後的位全變為 1
, e.g. 00100100 -> 00111111
再將 x
乘上 0x6EB14F9
,接著位移至剩下 位,查表並用 減去。
0x6EB14F9
能產出的值為 ~ 之間 種互不相同的數字,並且 0xFF
並沒有特別的意義。
完全未做優化的情況下,效能由優至劣排序依序是:
以 harley 為例,發現代碼被大量精簡 ( 而且 clz 的程式碼被 optimized out ),當然也沒有測量到正確的時間。
似乎是不可行,只好自己硬幹。
首先觀察到在 harley 的組語中,存在大量暫存器與記憶體互動的代碼,並且沒有複雜的條件分支
看到這坨組語第一個想到的是,大量記憶體操作不僅會有 cache miss 的問題,而且存取記憶體本來就相當耗費時間
根據下方表格,可以看出存取主記憶體跟暫存器操作的時間差了至少 200 倍(表中無 register 的條目)
嘗試以 inline asm 改寫成只有暫存器操作,降低 cache miss 以及存取記憶體帶來的延遲
實驗結果
修改過後的 harley 大致獲得約 39% 效能提昇(平均 82 cycle -> 50 cycle)
原版本編譯出的組語中同樣也是充滿大量記憶體操作,一樣修正為暫存器操作,
並且使用可以優化的尾端遞迴版本作為撰寫組語的模板。
可以明顯發現效率大幅提昇約 60% (平均 130 cycles -> 52 cycles )
查詢 intel 技術手冊[6],發現硬體居然直接支援計算 clz
同樣以 inline asm 實做
平均只需要 41 cycles,效能超越目前所有算法,可以看出硬體支援提供了相當大的優勢。
設兩數為 與 ,
在 32-bit 無符號數值範圍可由 >>
,得知
因為當 與 相等時, 為 ,在右邏輯位移 後,得 ;
與 不相等 則不為 ,位移後,得 。
在 H.264 中,提供了一種編碼/解碼的方法: Universal Variable-length coding ,這是基於 Exponential Golomb coding 的方法。數字的表示方法為下表:
POS | INT | Coded bitstream |
---|---|---|
1 | ||
2 | ||
3 | ||
4 | ||
5 | ||
6 | ||
7 | ||
8 | ||
9 | ||
10 | ||
11 |
當要解碼這些 Coded bitstream 時,我們會先計算出這串 bitstream 的 clz,再依照這串 bitstream是有號數還是無號數,分別以不同的流程進行解碼,而這當中便是 clz 的應用。
binary gcd 的精神就是當兩數為偶數時,必有一 因子。
且一數為奇數另一數為偶數,則無 因子。
於是我們可以改良為:
其中符號 是 right shift。
剩下的過程就直接採用 Euclidean algorithm 的作法即可。
著名的 猜想,
當一數為偶數時,將它除以 ;為奇數時,將它乘 並加上 ,
最終該數字會降為 。
我們可以採用以下流程驗證 猜想:
以二進制數來操作,
現假設一數為 101
,則按照以上流程:
只要將移除尾端 的步驟改用 ctz 實作就能加速。
直觀上我們可以知道 為
任兩數 , 只要相乘大於或等於 都為溢位,
將 拆成兩數,,利用得知兩數之 clz 相加為 ,當兩數之次方項相加大於或等於 時(即溢位),則使得兩數之 clz 相加小於或等於 。
任兩數 , 只要相乘小於 都未溢位,
同理,將 拆成兩數,,利用得知兩數之 clz 相加為 ,當兩數之次方項相加小於 時(即無溢位),則使得兩數之 clz 相加大於或等於 。
現在考慮 111..1
與 100..0
,取兩個形如 111..1
的數長度為 與 的數, 與兩個形如 100..0
的數長度為 與 的數,前者兩數相乘要比後者兩數相乘之 clz 還要少 1。
第 1. 式,由 並配合 得知若兩數之 clz 相加小於等於 則 overflow。
第 2. 式,由 得知兩數之 clz 相加大於等於 則不會 overflow。