64位中的整數優化
阿新 • • 發佈:2019-02-02
64位中的整數優化
在64位計算已越來越近的今天,越來越多的程式已開始考慮利用64位所帶來的強大優勢,其中,64位定址對那些需要處理大量資料的應用程式來說尤為重要,如:工程與科學計算程式、大型資料庫之類,現在已有許多的CPU及作業系統可本地支援64位計算,但它們帶來的最大好處也許還是巨大的定址空間,程式在其中可分配大於4GB的記憶體,更容易管理大檔案等等。如果要充分發揮64位CPU的威力,應用程式還必須要利用64位中更寬的機器字,在本文中,將集中討論64位上的程式效能優化。
64位的差異
不幸的是,如今的大多數軟體都沒有充分利用64位微處理器,以至於不能在64位模式下編譯和執行,從而,軟體被迫執行在32位相容模式中——真是對矽的浪費。此外,還有很多程式設計師在用C語言程式設計時“玩忽職守”,腦袋中想像著64位CPU,卻用32位系統的模式來程式設計,以下是常見的情況:
Ø 以為指標與int大小一樣。在64位系統中,sizeof(void *) == 8,而sizeof(int) == 4。如果忘記了這個,將導致不正確的賦值以致程式崩潰。
Ø 依賴於某一機器字架構的特定位元組序。
Ø 使用long型別並假定它總是與int有同樣大小。由此的直接賦值將導致數值截斷,並且問題很難察覺。
Ø 堆疊變數的對齊方式。在一些情況中,堆疊變數也許不是按8位元組邊界對齊,如果你把這些變數轉換成64位變數,在某些系統上,將會遇到一些麻煩。但是如果你在堆疊上放置一個64位變數(long或double),這保證是對齊的;還有,堆中分配的記憶體也是對齊的。
Ø 不同的對齊方式決定了結構與類的對齊。在64位架構上,結構成員通常對齊於64位邊界,當在通過IPC、網路、或磁碟共享二進位制資料時,就會有些問題;另外在包裝資料結構以便儲存資源時,沒有考慮到對齊方式,同樣也會有問題。
Ø 指標算術運算。當把一個64位指標像32位指標那樣遞增時(反之亦然),64位指標每次遞增8位元組,而32位指標每次遞增4位元組。
Ø 在缺少函式原型的情況下,返回值一般為int,這在某些時候也會導致數值截斷。
並行程式設計:充分利用每次迴圈
64位C語言程式設計的高效能關鍵所在,是更寬的整數與FPU暫存器。CPU暫存器位於“食物鏈”的頂層——也是計算機儲存器最昂貴部分所在,在64位CPU上,暫存器字寬通常為8位元組,而對應的是常見的128位或256位記憶體匯流排頻寬。
圖1表示了32位系統的典型操作,CPU一次只都處理4位元組記憶體中的資料。圖2顯示了有著更寬暫存器的64位系統,一次能處理8位元組。
圖1
圖2
例1在某一記憶體塊上進行XOR操作,其表示了一個基於整數的位集,你能在64位模式中對此進行優化。例2依賴於long long的C語言型別,但不會被某些編譯器所支援。正如你所看到的,此處沒有改變位集的總體大小,即使只花了較少的兩次操作來重組向量。例2有效地減少了迴圈的開銷,相當於帶係數2的迴圈展開,而只有一小點不利之處,就是它是純64位的,如果在32位系統上編譯,將會因為long大小的不同而給出錯誤的結果。
例1:
{
int a1[2048];
int a2[2048];
int a3[2048];
for (int i = 0; i < 2048; ++i)
{
a3[i] = a1[i] ^ a2[i];
}
}
例2:
{
long long a1[1024];
long long a2[1024];
long long a3[1024];
for (int i = 0; i < 1024; ++i)
{
a3[i] = a1[i] ^ a2[i];
}
}
你還能作進一步的修改,如例3所示,其利用了更寬的暫存器在32位和64位CPU上做同樣的工作,在做如此的型別轉換時,請注意指標對齊方式。如果你只是盲目地把int指標轉換為64位long指標,指標地址將不會是8位元組對齊的,在某些架構的機器上,還可能導致程式崩潰,或者帶來效能損失。例3中的程式碼是不安全的,因為放置在堆疊中的32位int變數有可能4位元組對齊,由此導致程式崩潰,如果在堆中分配(malloc),就能防止此類事情的發生。
例3:
{
int a1[2048];
int a2[2048];
int a3[2048];
long long* pa1 = (long long*) a1;
long long* pa2 = (long long*) a2;
long long* pa3 = (long long*) a3;
for (int i = 0; i < sizeof(a1) / sizeof(long long); ++i)
{
pa3[i] = pa1[i] ^ pa2[i];
}
}
位計數演算法
在位集計算中最重要的一個操作是在位串中計算1位的數量,預設的操作方法是把每一個整數分成4個字元,並在一張預先計算好的位計數表中依次查詢。這種線性操作的方法能用一種16位寬的表格加以改進,所付出的代價是表格可能要更大才行。此外,更大的表格很可能會產生一些額外的記憶體取數操作,由此影響CPU快取命中率,結果並沒有帶來想象中的效能提升。
作為另一個可供選擇的方法,例4就沒有使用查詢表,但卻一次平行計算兩個int。
例4:
int popcount(long long b)
{
b = (b & 0x5555555555555555LU) + (b >> 1 & 0x5555555555555555LU);
b = (b & 0x3333333333333333LU) + (b >> 2 & 0x3333333333333333LU);
b = b + (b >> 4) & 0x0F0F0F0F0F0F0F0FLU;
b = b + (b >> 8);
b = b + (b >> 16);
b = b + (b >> 32) & 0x0000007F;
return (int) b;
}
位串詞典比較
可進行64位優化的另一種程式是位集中的詞典比較,其最直接的實現是每次從位序列中取出兩個字,並一位一位地轉換進行比較,此迭代演算法具有O(N/2)的複雜度,此處的N是總的位數。例5中演示了兩個字的迭代比較法;此演算法並不能通過64位並行化處理極大地提高效能。然而,例6演示了另一種可供選擇的演算法,其複雜性與機器字(不是位)數除以2成正比,其在64位上極具潛力。
例5:
int bitcmp(int w1, int w2)
{
while (w1 != w2)
{
int res = (w1 & 1) - (w2 & 1);
if (res != 0)
return res;
w1 >>= 1;
w2 >>= 1;
}
return 0;
}
例6:
int compare_bit_string(int a1[2048], int a2[2048])
{
long long* pa1 = (long long*) a1;
long long* pa2 = (long long*) a2;
for (int i = 0; i < sizeof(a1) / sizeof(long long); ++i)
{
long long w1, w2, diff;
w1 = a1[i];
w2 = a2[i];
diff = w1 ^ w2;
if (diff)
{
return (w1 & diff & -diff) ? 1 : -1;
}
}
return 0;
}
面臨的問題
問題來了,為了64位,我們犯得著這樣嗎?當代的32位CPU都是超標量、推理性執行機器,具有在幾個執行區域中並行亂序地同時執行幾條指令的能力,而不需要程式設計師的干預;反觀64位處理器只展示了其具有同樣的效能——但只是在純64位中;話說回來,在如Intel Itanium(安騰處理器)特別強調並行程式設計的某些架構上,並且在編譯器級別顯式地進行了優化的情況下,程式程式碼適合於64位,並且已為64位優化就顯得尤為必要。
還有一點,程式的效能不只是受限於CPU的MHz表現,而且也受限於CPU的記憶體頻寬——其又受限於系統匯流排,總之,上述的演算法不可能總是表現出最高效能,這也是不可改變的一個事實,並且硬體設計師也非常瞭解。當然,我們也看到了雙通道記憶體控制器所帶來的效率上的提高,並且記憶體速度也在穩步向前發展,這在一定程度上減輕了系統匯流排成為瓶頸的可能性,面對當今發展越來越快的硬體,經過優化的64位演算法將會有更好的表現。
演算法優化及二進位制位距
另一個可進行64位優化之處是在位串中計算(二進位制)位距。二進位制位距常用於進行分類與查詢物件的相似之處的資料採集與AI(人工智慧)程式,其通常表示為二進位制描述符(位串)。此處優化的重點是位距演算法,因為它會在系統中每對物件之間重複執行。
最知名的位距度量演算法是加重平均位距,其是位中的一個最小數,並能被改變以轉換為另一個位串。換句話來說,你可以使用XOR位運算子來結合位串,並利用得到的結果計算出位數。
如例7中採用瞭如上演算法的程式,最明顯的優化之處是去掉了臨時位集,並同時計算XOR與總數量。而臨時檔案的產生是C++編譯器的“內部運動”,且因為記憶體的複製與重分配,降低了程式效率,請看例8:
例7:
#include <bitset>
using namespace std;
const unsigned BSIZE = 1000;
typedef bitset<BSIZE> bset;
unsigned int humming_distance(const bset& set1, const bset&set2)
{
bset xor_result = set1 ^ set2;
return xor_result.count();
}
例8:
{
unsigned int hamming;
int a1[2048];
int a2[2048];
long long* pa1;
long long* pa2;
pa1 = (long long*) a1; pa2 = (long long*) a2;
hamming = 0;
for (int i = 0; i < sizeof(a1) / sizeof(long long); ++i)
{
long long b;
b = pa1[i] ^ pa2[i];
b = (b & 0x5555555555555555LU) + (b >> 1 & 0x5555555555555555LU);
b = (b & 0x3333333333333333LU) + (b >> 2 & 0x3333333333333333LU);
b = b + (b >> 4) & 0x0F0F0F0F0F0F0F0FLU;
b = b + (b >> 8);
b = b + (b >> 16);
b = b + (b >> 32) & 0x0000007F;
hamming += b;
}
}
這種優化立竿見影地達到了幾個目標:與記憶體通訊量的減少、更好地複用了暫存器,當然,最重要的是64位並行處理(參見圖3)。此結果的本質是改進了CPU操作與記憶體負載的平衡,這是通過結合例3與例4的演算法來達到的。
圖3
這種優化技術還能作進一步的擴充套件,以用作任何“邏輯操作或位計數”的位距度量。其令人感興趣之處在於,如Tversky Index、Tanamoto、Dice、Cosine函式等等更復雜的度量方法,現在也能更容易表達了。
為了進一步加深理解,來看一下Tversky Index:
TI = BITCOUNT(A & B) / [a*(BITCOUNT(A-B) + b*BITCOUNT(B-A) + BITCOUNT(A & B)]
公式中包含了三個操作,BITCOUNT_AND(A, B)、BITCOUNT_SUB(A, B)、BITCOUNT_SUB(B, A)。三個操作能被結合成一條流水線操作,見圖4。這種技術改進了資料儲存位置,更好的複用了CPU快取,也意味著減少CPU延遲及提高程式效能,見例9:
例9:
{
double ti;
int a1[2048];
int a2[2048];
long long* pa1;
long long* pa2;
pa1 = (long long*) a1; pa2 = (long long*) a2;
ti = 0;
for (int i = 0; i < sizeof(a1) / sizeof(long long); ++i)
{
long long b1, b2, b3;
b1 = pa1[i] & pa2[i];
b2 = pa1[i] & ~pa2[i];
b3 = pa2[i] & ~pa1[i];
b1 = popcount(b1);
b2 = popcount(b2);
b3 = popcount(b3);
ti += double(b1) / double(0.4 * b2 + 0.5 * b3 + b1);
}
}
圖4
64位之後還有什麼?
上述的大多數演算法都能通過基於向量的指令加以編碼——如單指令多資料(SIMD)。帶有SIMD功能的CPU含有特殊的擴充套件暫存器(64位或128位)或執行單元,能一次載入幾個機器字,並對它們進行一些並行的操作。最流行的SIMD引擎是Intel的SSE;AMD的3DNow!;Motorola、Apple、IBM的AltiVec。SIMD暫存器與通用暫存器不同,它們不允許你執行諸如IF這樣的流量控制操作,這也使SIMD程式設計更加困難。不難看出,基於SIMD程式碼的可移植性也是有限的。可是,一個並行的64位優化演算法能在概念上很容易地被轉換為一個128位的SIMD演算法;請參見例10,其使用SSE2指令集實現了一個XOR演算法,此處使用了Intel C++編譯器的內部相容性。
例10:
void bit_xor(unsigned* dst, const unsigned* src, unsigned block_size)
{
const __m128i* wrd_ptr = (__m128i*)src;
const __m128i* wrd_end = (__m128i*)(src + block_size);
__m128i* dst_ptr = (__m128i*)dst;
do
{
__m128i xmm1 = _mm_load_si128(wrd_ptr);
__m128i xmm2 = _mm_load_si128(dst_ptr);
__m128i xmm1 = _mm_xor_si128(xmm1, xmm2);
__mm_store_si128(dst_ptr, xmm1);
++dst_ptr;
++wrd_ptr;
} while (wrd_ptr < wrd_end);
}
既然在64位上數值優化有這些好處,那你還等什麼呢?