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

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