【轉載】C 語言有什麼奇技淫巧

学习,积累,成长發表於2024-06-10

快速範圍判斷

經常要批次判斷某些值在不在範圍內,如果 int 檢測是 [0, N) 的話:

if (x >= 0 && x < N) ...

眾所周知,現代 CPU 最佳化,減分支是重要手段,上述兩次判斷可以簡寫為:

if (((unsigned int)x) < N) ...

減少判斷次數。如果 int 檢測範圍是 [minx, maxx] 這種更常見的形式的話,怎麼辦呢?

if (x >= minx && x <= maxx) ...

可以繼續用位元或操作繼續減少判斷次數:

if (( (x - minx) | (maxx - x) ) >= 0) ...

如果語言警察們擔心有符號整數迴環是未定義行為的話,可以寫成這樣:

if ((int32_t)(((uint32_t)x - (uint32_t)minx) | ((uint32_t)maxx - (uint32_t)x)) > = 0) ...

效能相同,但避開了有符號整數迴環,改為無符號迴環,合併後轉為有符號判斷最高位。

第一個 (x - minx) 如果 x < minx 的話,得到的結果 < 0 ,即高位為 1,第二個判斷同理,如果超過範圍,高位也為 1,兩個條件進行位元或運算以後,只有兩個高位都是 0 ,最終才為真,同理,多個變數範圍判斷整合:

if (( (x - minx) | (maxx - x) | (y - miny) | (maxy - y) ) >= 0) ...

這樣本來需要對 [x, y] 進行四次判斷的,可以完全歸併為一次判斷,減少分支。

補充:加了個效能評測:

img

效能提升 37%。快速範圍判斷還有第二個效能更均衡的版本:

if ((unsigned)(x - minx) <= (unsigned)(maxx - minx)) ...

快速範圍判斷的原理和評測詳細見:《快速範圍判斷:再來一種新寫法》。

更好的迴圈展開

很多人提了 duff's device ,按照 gcc 和標委會喪心病狂的程度,你們用這些 just works 的程式碼,不怕哪天變成未定義行為給一股腦最佳化掉了麼?其實對於迴圈展開,可以有更優雅的寫法:

#define CPU_LOOP_UNROLL_4X(actionx1, actionx2, actionx4, width) do { \
    unsigned long __width = (unsigned long)(width);    \
    unsigned long __increment = __width >> 2; \
    for (; __increment > 0; __increment--) { actionx4; }    \
    if (__width & 2) { actionx2; } \
    if (__width & 1) { actionx1; } \
}   while (0)

送大家個代替品,CPU_LOOP_UNROLL_4X,用於四次迴圈展開,用法是:

CPU_LOOP_UNROLL_4X(
    {
        *dst++ = (*src++) ^ 0x80;
    },
    {
        *(uint16_t*)dst = (*(uint16_t*)src) ^ 0x8080;
        dst += 2; src += 2;
    },
    {
        *(uint32_t*)dst = (*(uint32_t*)src) ^ 0x80808080;
        dst += 4; src += 4;
    },
    w);

假設要對源記憶體地址內所有位元組 xor 0x80 然後複製到目標地址的話,可以向上面那樣進行迴圈展開,分別寫入 actionx1, actionx2, actionx4 即:單倍工作,雙倍工作,四倍工作。然後主體迴圈將用四倍工作的程式碼進行迴圈,剩餘長度用兩倍和單倍的工作拼湊出來。

現在的編譯器雖然能夠幫你展開一些迴圈,CPU 也能對短的緊湊迴圈有一定預測,但是做的都非常傻,大部分時候你用這樣的宏明確指定迴圈展開迴圈效果更好,你還可以再最佳化一下,主迴圈裡每回撥用兩次 actionx4,這樣還能少一半迴圈次數,剩餘的用其他拼湊。

這樣比 duff's device 這種飛線的寫法更規範,並且,duff's device 並不能允許你針對 “四倍工作”進行最佳化,比如上面 actionx4 部分直接試用 uint32_t 來進行一次性運算,在 duff's device 中並沒有辦法這麼做。

補充:《迴圈展開效能評測》:

img

效能提升 12% 。

整數快速除以 255

整數快速除以 255 這個事情非常常見,例如影像繪製/合成,音訊處理,混音計算等。網上很多位元技巧,卻沒有人總結過非 2^n 的快速除法方法,所以我自己研究了個版本:

#define div_255_fast(x)    (((x) + (((x) + 257) >> 8)) >> 8)

當 x 屬於 [0, 65536] 範圍內,該方法的誤差為 0。過去不少人簡略的直接用 >> 8 來代替,然而這樣做會有誤差,連續用 >>8 代替 / 255 十次,誤差就累計到 10 了。

上面的宏可以方便的處理 8-16 位整數的 /255 計算,經過測試 65536000 次計算中,使用 /255的時間是 325ms,使用div_255_fast的時間是70ms,使用 >>8 的時間是 62ms,div_255_fast 的時間代價幾乎可以忽略。

進一步可以用 SIMD 寫成:

// (x + ((x + 257) >> 8)) >> 8
static inline __m128i _mm_fast_div_255_epu16(__m128i x) {
	return _mm_srli_epi16(_mm_adds_epu16(x, 
		_mm_srli_epi16(_mm_adds_epu16(x, _mm_set1_epi16(0x0101)), 8)), 8);
}

這樣可以同時對 8 對 16 bit 的整數進行 / 255 運算,照葫蘆畫瓢,還可以改出一個 / 65535 ,或者 / 32767 的版本來。

對於任意大於零的整數,他人總結過定點數的方法,x86 跑著一般,x64 下還行:

static inline uint32_t fast_div_255_any (uint32_t n) {
    uint64_t M = (((uint64_t)1) << 32) / 255;   // 用 32.32 的定點數表示 1/255
    return (M * n) >> 32;   // 定點數乘法:n * (1/255)
}

這個在所有整數範圍內都有效,但是精度有些不夠,所以要把 32.32 的精度換成 24.40 的精度,並做一些四捨五入和補位:

static inline uint32_t fast_div_255_accurate (uint32_t n) {
    uint64_t M = (((uint64_t)1) << 40) / 255 + 1;   // 用 24.40 的定點數表示 1/255
    return (M * n) >> 40;   // 定點數乘法:n * (1/255)
}

該方法能夠覆蓋所有 32 位的整數且沒有誤差,有些編譯器對於常數整除,已經可以生成類似 fast_div_255_accurate 的程式碼了,整數除法是現代計算機最慢的一項工作,動不動就要消耗 30 個週期,常數低的除法除了二次冪的底可以直接移位外,編譯器一般會用定點數乘法模擬除法。

編譯器生成的常數整除程式碼主要是使用了 64 位整數運算,以及乘法,略顯複雜,對普通 32 位程式並不是十分友好。因此如果整數範圍屬於 [0, 65536] 第一個版本代價最低。

且 SIMD 沒有除法,如果想用 SIMD 做除法的話,可用上面的兩種方法翻譯成 SIMD 指令。

255 快除法的《效能評測》:

img

提升一倍的效能。

PS:大部分時候當然選擇相信編譯器,提高可讀性,如果你只寫一些增刪改查,那怎麼漂亮怎麼寫就行;但如果你想寫極致效能的程式碼,你需要知道編譯器的最佳化是有限的窮舉,沒法應對無限的程式碼變化,上面三個就是例子,編譯器最佳化可以幫你,但沒法什麼都靠編譯器,歸根結底還是要了解計算機體系,這樣脫開編譯器,不用 C 語言,你也能寫出高效能程式碼。

PS:不要覺得喪心病狂,你們去看看 kernel 裡各處效能相關的程式碼,看看 pypy 如何最佳化 python 的雜湊表的,看看 jdk 的程式碼,這類最佳化比比皆是,其實寫多了你也不會覺得難解。

--

常數範圍裁剪

有時候你計算一個整數數值需要控制在 0 - 255 的範圍,如果小於 0 那麼等於零,如果大於 255,那麼等於 255,做一個裁剪工作,可以用下面的位運算:

static inline int32_t clamp_to_0(int32_t x) { 
	return ((-x) >> 31) & x; 
}
static inline int32_t clamp_to_255(int32_t x) {
	return (((255 - x) >> 31) | x) & 255;
}

這個方法可以裁剪任何 2^n - 1 的常數,比如裁剪 65535:

static inline int32_t clamp_to_65535(int32_t x) {
	return (((65535 - x) >> 31) | x) & 65535;
}

略加改變即可實現,沒有任何判斷,沒有任何分支。本技巧在不同架構下效能表現不一,具體看實測結果。

快速位掃描

假設你在設計一個容器,裡面的容量需要按 2 次冪增加,這樣對記憶體更友好些,即不管裡面存了多少個東西,容量總是:2, 4, 8, 16, 32, 64 的刻度變化,假設容量是 x ,需要找到一個二次冪的新容量,剛好大於等於 x 怎麼做呢?

static inline int next_size(int x) {
    int y = 1;
    while (y < x) y *= 2;
    return y;
}

一般會這樣掃描一下,但是最壞情況上面迴圈需要迭代 31 次,如果是 64 位系統,型別是 size_t 的話,可能你需要迭代 63 次,假設你做個記憶體分配,分配器大小是二次冪增長的,那麼每次分配都要一堆 for 迴圈來查詢分配器大小的話,實在太坑爹了,於是繼續位運算:

static inline uint32_t next_power_of_2(uint32_t x) {
    x--;
    x |= x >> 1; 
    x |= x >> 2; 
    x |= x >> 4; 
    x |= x >> 8; 
    x |= x >> 16; 
    x++
    return x;
}

以及:

static inline uint32_t next_power_of_2(uint64_t x) {
    x--;
    x |= x >> 1; 
    x |= x >> 2; 
    x |= x >> 4; 
    x |= x >> 8; 
    x |= x >> 16; 
    x |= x >> 32; 
    x++
    return x;
}

在不用 gcc 內建 __builtin_clz 函式或 bsr 指令的情況下,這是 C 語言最 portable 的方案。

原文連結:https://www.zhihu.com/question/27417946/answer/1253126563?utm_campaign=shareopn&utm_medium=social&utm_psn=1783075953661992960&utm_source=wechat_session

相關文章