btHashMap vs std::unodered_map ——兩種hashmap的性能對比測試


本篇補上《bullet HashMap 內存緊密的哈希表》欠下的債(下面簡稱《btHashMap》)。
《btHashMap》一文只是從理論上分析了bullet hash map(btHashMap)和C++標准庫 hash map(std::unordered_map)的內存布局。btHashMapstd::unordered_map和多數語音環境的字典一樣,都被設計為能夠動態增長的容器;我在《btHashMap》斷言了——在size較小的時,btHashMap相對std::unordered_map有更好的性能;但並沒有指出——size在什么樣的數量級btHashMap會有更好的性能表現,以及這個數量可能和那些環境參數相關?本篇將用實驗(測試代碼)和數據(測試結果)回答這兩個 問題。

bullet源碼最近也遷移到github了,github連接:https://github.com/bulletphysics/bullet3

本文的全部測試代碼:
github.com: https://github.com/xusiwei/HashMapBenchmark
(備用https://code.csdn.net/xusiwei1236/bthashmapbenchmark

熱身,讓btHashMap為我所用

btHashMap是bullet項目的一部分,那么第一個問題來了——怎么把它用起來?

btHashMap的定義和聲明都位於src/LinearMath/btHashMap.h,根據源碼不難發現,它還依賴btAlignedObjectArray.h``btAlignedAllocator.h, btAlignedAllocator.cpp, btScalar.h。有了這些文件,btHashMap就可以正常工作了。下面從一個簡單的例子開始,看看如何把它用起來。

查看btHashMap的源碼,可以發現btHashMap的key依賴於有.getHash()得到hash值, 也可以發現btHashMap.h中定義了幾個用於做key的類,如btHashInt, btHashString

有了這些基礎,可以輕松的寫出一個demo,如下
warmUp.cpp:

// btHashMap warm up example, by xu, http://blog.csdn.net/xusiwei1236
#include "btHashMap.h"

#include <stdio.h>

int main()
{
btHashMap<btHashInt, btHashInt> btMap;

int k = 1234, v = 5678;
btMap.insert(btHashInt(k), btHashInt(v));

btHashInt* pVal = btMap.find(btHashInt(k));
if(pVal == NULL) {
printf("key: %d not found in btMap\n", k);
}
else {
printf("found key: %d, value: %d in btMap\n", k, v);
}

return 0;
}

現在,當前目錄下已有6個文件:

xusiwei1236@blog.csdn.net:~/data/test/btHashMap$ ls
btAlignedAllocator.cpp btAlignedAllocator.h btAlignedObjectArray.h btHashMap.h btScalar.h warmUp.cpp

嘗試編譯warmUp.cpp:

xusiwei1236@blog.csdn.net:~/data/test/btHashMap$ g++ warmUp.cpp 
/tmp/ccZ7i4Vi.o: In function `btAlignedAllocator<int, 16u>::deallocate(int*)':
warmUp.cpp:(.text._ZN18btAlignedAllocatorIiLj16EE10deallocateEPi[btAlignedAllocator<int, 16u>::deallocate(int*)]+0x18): undefined reference to `btAlignedFreeInternal(void*)'
/tmp/ccZ7i4Vi.o: In function `btAlignedAllocator<btHashInt, 16u>::deallocate(btHashInt*)':
warmUp.cpp:(.text._ZN18btAlignedAllocatorI9btHashIntLj16EE10deallocateEPS0_[btAlignedAllocator<btHashInt, 16u>::deallocate(btHashInt*)]+0x18): undefined reference to `btAlignedFreeInternal(void*)'
/tmp/ccZ7i4Vi.o: In function `btAlignedAllocator<btHashInt, 16u>::allocate(int, btHashInt const**)':
warmUp.cpp:(.text._ZN18btAlignedAllocatorI9btHashIntLj16EE8allocateEiPPKS0_[btAlignedAllocator<btHashInt, 16u>::allocate(int, btHashInt const**)]+0x25): undefined reference to `btAlignedAllocInternal(unsigned long, int)'
/tmp/ccZ7i4Vi.o: In function `btAlignedAllocator<int, 16u>::allocate(int, int const**)':
warmUp.cpp:(.text._ZN18btAlignedAllocatorIiLj16EE8allocateEiPPKi[btAlignedAllocator<int, 16u>::allocate(int, int const**)]+0x25): undefined reference to `btAlignedAllocInternal(unsigned long, int)'
collect2: ld returned 1 exit status

出現鏈接錯誤,btAlignedAllocator的成員函數沒有找到,因為btAlignedAllocator的成員函數是在.cpp中實現的,所以需要先單獨編譯,再進行鏈接:

xusiwei1236@blog.csdn.net:~/data/test/btHashMap$ g++ -c btAlignedAllocator.cpp
xusiwei1236@blog.csdn.net:~/data/test/btHashMap$ ls btAlignedAllocator.*
btAlignedAllocator.cpp btAlignedAllocator.h btAlignedAllocator.o
xusiwei1236@blog.csdn.net:~/data/test/btHashMap$ g++ -c warmUp.cpp
xusiwei1236@blog.csdn.net:~/data/test/btHashMap$ ls warmUp.*
warmUp.cpp warmUp.o
xusiwei1236@blog.csdn.net:~/data/test/btHashMap$ g++ warmUp.o btAlignedAllocator.o
xusiwei1236@blog.csdn.net:~/data/test/btHashMap$ ls
a.out

生成了a.out,運行:

xusiwei1236@blog.csdn.net:~/data/test/btHashMap$ ./a.out 
found key: 1234, value: 5678 in btMap

關於計時

對於程序的時間性能的度量,需要考慮使用實際使用時間(real time)還是進程的CPU時間( process CPU time)。二者的區別在於,實際時間包括了測試進程所用的CPU時間(還包括測試進程休眠、進程調度等時間)。
從實際用戶角度出發的測試場景,需要用實際時間;而對於細節性算法的測量,往往需要用進程的CPU時間來衡量(更能體現算法本身的優劣)。

獲得進程所占的CPU時間

C/C++ tip: How to measure CPU time for benchmarking(以下簡稱HMCT,此文詳細介紹了如何在常見的操作系統平台上獲得進程的CPU時間,並實現一個跨平台的CPU時間測量函數getCPUTime)中說到:

A process’s CPU time accumulates as the process runs and consumes CPU cycles. During I/O operations, thread locks, and other operations that cause the process to pause, CPU time accumulation also pauses until the process can again make headway.
一個進程的CPU時間累計了進程運行所消耗的CPU周期數(PS:周期,這種說法是針對頻率固定的CPU的,對當今主流的能夠變頻的CPU應該說時間)。I/O操作、線程鎖住(掛起)、其他引起進程掛起的操作期間CPU時間的累計都會暫停,直到進程再次執行。

這里簡單總結一下POSIX平台上都具有的兩個計時函數clockclock_gettime:

  • clock是ISO C89標准中規定的函數,它的聲明位於C標准庫的<time.h>(對應C++標准庫的<ctime>)里:
    clock_t clock ( void );
    主流的幾個平台上都有,但在不同的平台上,返回值意義、clock_t的實際類型可能略有不同,多數是:從程序啟動到調用clock()始終走過的滴答數,常量CLOCKS_PER_SEC定義了一秒內的滴答數。另外,《HMCT》指出Windows上,clock()返回的牆鍾時間,而非進程已啟動的時間。

  • clock_gettime是POSIX規定的,它的聲明一般也位於time.h里:
    int clock_gettime (clockid_t __clock_id, struct timespec *__tp);
    它相對clock的一個明顯的好處是可以得到更高的時間精度。所有POSIX兼容的OS上都有該函數和struct timesepc,但在不同的OS上對應的clockid參數有所區別,如Linux可用CLOCK_PROCESS_CPUTIME_IDclockid參數獲得進程的CPU時間。

clock()對應的tick數CLOCKS_PER_SEC在C89, C99標准都規定為1000,000,在glibc中也是該值, 理論上計時精度應該能夠達到 1ms,而我實際測得的計時精度能只有10ms(Ubuntu 12.04, 內核版本 3.11.0-26,gcc版4.6.3)。

《HMCT》還給出了一份可以兼容不同操作系統getCPUTime()的實現,函數接口聲明如下:
double getCPUTime();
這里不再列出(可以在原文中找到)。

timer

根據《HMCT》的getCPUTime(),包裝的一個用於計時的timer類(仿照boost::timer):

// modify from boost::timer, by xu, http://blog.csdn.net/xusiwei1236
class timer
{
public:
timer() { _start_time = getCPUTime(); }
void restart() { _start_time = getCPUTime(); }
double elapsed() const // return elapsed time in seconds
{ return getCPUTime() - _start_time; }

private:
double _start_time;
}; // timer

幾種應用場景

下面構造了幾個具體的測試場景,並逐步完善。雖然是yy, :-)

benchmark 1 單詞統計.

“單詞統計”————此測試來源於《編程珠璣》,讀取一個文本文件,並對文件中的單詞出現的頻率進行統計。
這里對其稍作修改,留了統計過程(含插入、查找操作),刪除了輸出整個map(迭代操作),核心部分偽代碼(python-style):

for word in text:
if dict.find(word):
dict[word] += 1
else:
dict.insert(word, 0)

btHashMapstd::unordered_map在find和insert的參數和返回值上略有不同:
這是std::unordered_map版的:

        // C++11 auto key word, to indicates std::unordered_map<std::string, int>::iterator
auto pos = dict.find(word);
if(pos != dict.end()) { // found
pos->second++;
}
else { // not found
dict.insert(std::make_pair(word, 1));
}

這是btHashMap版的:

        btHashString key(word); // btHashMap not supprt std::string.
btHashInt* val = btDict.find(key);
if(val != NULL) {
val->setUid1(val->getUid1() + 1);
}
else {
btDict.insert(key, btHashInt(1));
}

btHashMap的完整測試:

void btBench(const char* text, int length)
{
btHashMap<btHashString, btHashInt> btDict;

int count = 0;
int cursor = 0;
char word[256];

timer t;
do {
cursor += took(&text[cursor], word, NULL); // took next word.
if(!word[0]) break; // no more word.

count++;

btHashString key(word);
btHashInt* val = btDict.find(key); // lookup
if(val != NULL) { // found
val->setUid1(val->getUid1() + 1);
}
else { // not found
btDict.insert(key, btHashInt(1));
}
}while(cursor < length);
double timeUsed = t.elapsed();

printf("%9s: time used: %.3f, word tooks: %d\n", __func__, timeUsed, count);
}

(std的類似,略)

測試程序通過命令行傳入一個文本文件名,先將整個文件讀入內存,在依次抽取單詞進行單詞統計,具體代碼見benchmark.cpp。
用牛津詞典作為輸入,測得的一組數據如下:

 stdBench: time used: 0.196, word tooks: 695882
btBench: time used: 0.061, word tooks: 695882

統計了69萬多個單詞,btHashMap明顯快於std::unordered_map

由於std::map的接口和std::unordered_map相同,換成std::map的測試結果:

 stdBench: time used: 0.687, word tooks: 695882
btBench: time used: 0.061, word tooks: 695882

可以發現, std::map如預想的比std::unordered_map慢,因為std::map的底層實現是紅黑樹,find/insert的平均復雜度都是O(log2 n);而std::unordered_map是哈希表,find/insert的平均復雜度都是O(1).

實際上,此例中對於同一單詞,在兩個版本的find所用的hash算法並不相同(btHashMap用的是btHashString::getHash, std::unordered_map用的是std::hash<std::string>)。

benchmark 2 隨機數統計

在benchmark 1中,考慮到兩個hash map用的字符串hash算法不同,對測得的結果可能略有影響(其實影響很小)。這里索性將string換成int做key,這樣測出的性能參數就和hash算法無關了。
那么問題來了————數據從哪來,當然可以從命令行讀入,但那讓人感覺太low了。
干脆用隨機數來干,可以用從一個seed開始,生成兩次隨機序列(rand能夠保證生成的是同一個隨機序列)。
btHashMap版(std::unordered_map類似,不貼了)

double btBench(int seed, long tests)
{
btHashMap<btHashInt, btHashInt> dict;

srand(seed); // setup random seed.

timer t;
for(long i = 0; i < tests; ++i) {
int r = rand(); // generate random int.

// lookup in the hash map.
btHashInt* val = dict.find(btHashInt(r));

if(val != NULL) { // found, update directly.
val->setUid1(val->getUid1() + 1);
}
else { // not found, insert <key, 1>
dict.insert(key, btHashInt(1));
}
}
return t.elapsed();
}

測試程序通過命令行參數傳入測試次數,打印測試次數和時間,具體代碼見benchmarkII.cpp.
如下命令,執行多次測試程序,並傳入不通測試次數,得到結果:

$ for ((i = 2048; i <= 2**27; i *= 2)); do ./b2 $i; done
2048 0.001 0.000
4096 0.004 0.001
8192 0.005 0.002
16384 0.006 0.004
32768 0.022 0.007
65536 0.023 0.014
131072 0.054 0.029
262144 0.126 0.065
524288 0.289 0.175
1048576 0.509 0.434
2097152 1.056 1.038
4194304 2.182 2.314
8388608 4.549 4.935
16777216 9.307 10.411
33554432 20.002 21.396
67108864 41.641 47.422
134217728 90.582 104.393

從這組數據可以看到,i<=2097152(2^21),btHashMap都是要比std::unordered_map表現的要好的。下面分析為什么當size達到一定數量的時候,btHashMap的性能表現就不如std::unordered_map了。

btHashMapstd::unordered_map的rehash成本分析

首先明確一下,rehash並不是所有的hash table都需要的,它只在可以動態增長的hash table上需要,多數語音環境的hashmap是可以動態增長的,也就是需要rehash的。
當hash map的當前所持有的內存不足以放下新的元素時,就需要從新申請更多的內存,並維護好原有的邏輯關系,這就是哈希表的rehash。

從上面的測試結果數據可以看到,當size較大時btHashMap的表現要差於std::unordered_map。這是因為二者內存布局設計上的差異,導致size較大時二者的rehash開銷的不同。
回顧一下,std::unordered_map的內存布局是”教科書式”的——一個叫做buckets的表頭,每個slot下面掛着哈希值等於其index的所有

benchmark 3 find/insert單獨測試

benchmark 2中,測試過程中find/insert是交叉的,且難以預測。所以這里再做一個單純性的find/insert性能測試:

btHashMap<btHashInt, btHashInt> btDict;

double btInsertBench(int seed, long tests)
{
srand(seed);

timer t;
for(long i = 0; i < tests; ++i) {
btDict.insert(btHashInt(rand()), btHashInt(1));
}
return t.elapsed();
}

double btFindBench(int seed, long tests)
{
srand(seed);

timer t;
for(long i = 0; i < tests; ++i) {
btDict.find(btHashInt(rand()));
}
return t.elapsed();
}

測試時,先用btInsertBench向btDict中填充數據,再用btFindBenchfind性能數據。

測量單位,前面的測試都在用總時間作為“速度”的度量,這里換個更加直觀的速率單位: OPS(operation per second):

    double stdInsertTime = stdInsertBench(seed, tests);
double stdFindTime = stdFindBench(seed, tests);

double btInsertTime = btInsertBench(seed, tests);
double btFindTime = btFindBench(seed, tests);

// printf("%11d\t% 5.3f\t% 5.3f\t% 5.3f\t% 5.3f\n", tests, stdInsertTime, stdFindTime, btInsertTime, btFindTime); // in total times
printf("%11ld\t%11ld\t%11d\t%.0f\t%.0f\t%.0f\t%.0f\n",
tests, stdDict.size(), btDict.size(), tests/stdInsertTime, tests/stdFindTime, tests/btInsertTime, tests/btFindTime); // in OPS

參考

轉載請注明出處(http://blog.csdn.net/xusiwei1236)及原文鏈接,歡迎評論或email交流觀點。

C/C++ tip: How to measure CPU time for benchmarking


注意!

本站转载的文章为个人学习借鉴使用,本站对版权不负任何法律责任。如果侵犯了您的隐私权益,请联系我们删除。



 
粤ICP备14056181号  © 2014-2020 ITdaan.com