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:

[email protected]:~/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

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章