boost :: flat_map及其与map和unordered_map相比的性能


103

编程中的常识是,由于缓存命中,内存局部性可以大大提高性能。我最近发现了boost::flat_map哪个是基于矢量的地图实现。它似乎不像您典型的map/ 那样流行,unordered_map因此我无法找到任何性能比较。它如何比较?最佳的用例是什么?

谢谢!


需要特别注意的是boost.org/doc/libs/1_70_0/doc/html/boost/container/…声称随机插入需要对数时间,这意味着填充boost :: flat_map(通过插入n个随机元素)需要O(n log n ) 时间。从下面@ v.oddou的图中的图形可以明显看出,它是在说谎:随机插入为O(n),其中n个需要O(n ^ 2)时间。
唐·哈奇

@DonHatch如何在此处报告此消息:github.com/boostorg/container/issues?(它可能会提供比较次数的计数,但是,如果没有伴随着移动次数的计数,那的确会产生误导)
Marc Glisse

Answers:


188

我最近在公司中针对不同的数据结构进行了基准测试,因此我需要一言以蔽之。正确地进行基准测试非常复杂。

标杆管理

在网上,我们很少(如果有的话)找到精心设计的基准。直到今天,我只发现了以新闻工作者的方式完成的基准测试(相当快,并且涵盖了很多变量)。

1)您需要考虑缓存预热

大多数运行基准测试的人都害怕计时器的差异,因此他们将自己的东西运行数千次并占用全部时间,只是小心地将每次操作重复相同的数千次,然后才认为具有可比性。

事实是,在现实世界中这没有什么意义,因为您的缓存不会变热,并且您的操作可能只会被调用一次。因此,您需要使用RDTSC进行基准测试,并花一些时间调用它们一次。英特尔发表了一篇论文,描述了如何使用RDTSC(使用cpuid指令刷新管道,并在程序开始时对其进行至少3次调用以使其稳定)。

2) RDTSC精度测量

我也建议这样做:

u64 g_correctionFactor;  // number of clocks to offset after each measurement to remove the overhead of the measurer itself.
u64 g_accuracy;

static u64 const errormeasure = ~((u64)0);

#ifdef _MSC_VER
#pragma intrinsic(__rdtsc)
inline u64 GetRDTSC()
{
    int a[4];
    __cpuid(a, 0x80000000);  // flush OOO instruction pipeline
    return __rdtsc();
}

inline void WarmupRDTSC()
{
    int a[4];
    __cpuid(a, 0x80000000);  // warmup cpuid.
    __cpuid(a, 0x80000000);
    __cpuid(a, 0x80000000);

    // measure the measurer overhead with the measurer (crazy he..)
    u64 minDiff = LLONG_MAX;
    u64 maxDiff = 0;   // this is going to help calculate our PRECISION ERROR MARGIN
    for (int i = 0; i < 80; ++i)
    {
        u64 tick1 = GetRDTSC();
        u64 tick2 = GetRDTSC();
        minDiff = std::min(minDiff, tick2 - tick1);   // make many takes, take the smallest that ever come.
        maxDiff = std::max(maxDiff, tick2 - tick1);
    }
    g_correctionFactor = minDiff;

    printf("Correction factor %llu clocks\n", g_correctionFactor);

    g_accuracy = maxDiff - minDiff;
    printf("Measurement Accuracy (in clocks) : %llu\n", g_accuracy);
}
#endif

这是一个差异度量工具,它将采用所有测量值中的最小值,以避免不时获得-10 ** 18(64位第一个负值)。

注意使用内在函数而不是内联汇编。如今,编译器很少支持首次内联汇编,但更糟糕的是,编译器围绕内联汇编创建了一个完整的排序障碍,因为它无法静态分析内部,因此这是对真实世界中的东西进行基准测试的问题,尤其是在仅调用东西时一旦。因此,内在函数适用于此,因为它不会破坏编译器对指令的自由重排序。

3)参数

最后一个问题是人们通常会测试场景的变化是否太少。容器性能受以下因素影响:

  1. 分配者
  2. 包含类型的大小
  3. 包含类型的复制操作,分配操作,移动操作,构造操作的实施成本。
  4. 容器中元素的数量(问题的大小)
  5. 类型具有琐碎的3.-运算
  6. 类型是POD

要点1很重要,因为容器确实会不时分配,并且如果它们使用CRT“ new”或某些用户定义的操作(例如池分配或自由列表或其他)进行分配,这将很重要。

对于对pt 1感兴趣的人,请加入有关系统分配器性能影响的gamedev神秘线程

第二点是因为某些容器(例如A)会浪费时间在周围复制东西,而类型越大,开销也越大。问题在于,当与另一个容器B进行比较时,对于小型容器,A可能胜过B;对于大型容器,A可能会输。

点3与点2相同,不同之处在于它将成本乘以某个加权因子。

第4点是一个大O问题,也有缓存问题。对于少数类型,某些复杂性较差的容器可以大大优于低复杂度的容器(例如mapvs. vector,因为它们的缓存局部性很好,但会map分散内存)。然后在某个交叉点,它们将丢失,因为所包含的整体大小开始“泄漏”到主内存并导致高速缓存未命中,再加上渐近复杂性可以开始显现的事实。

第5点是关于编译器能够在编译时清除空的或琐碎的内容。这可以极大地优化某些操作,因为容器是模板化的,因此每种类型都有自己的性能概况。

第6点与第5点相同,POD可以受益于以下事实:复制构造只是一个简单的过程,对于某些情况,某些容器可以使用部分模板专门化或SFINAE根据T的特征选择算法,从而具有特定的实现。

关于平面图

显然,平面图是一个排序的矢量包装器,例如Loki AssocVector,但是C ++ 11附带了一些补充的现代化功能,它们利用移动语义来加速单个元素的插入和删除。

这仍然是有序的容器。大多数人通常不需要订购部分,因此存在unordered..

您是否考虑过可能需要flat_unorderedmap?一个google::sparse_map或多个类似的东西-一个开放地址哈希图。

开放地址哈希映射的问题在于,rehash它们必须将周围的所有内容复制到新的扩展平面上,而标准的无序映射只需要重新创建哈希索引,而分配的数据则保留在原处。当然,缺点是内存像地狱一样分散。

在开放地址哈希图中重新哈希的标准是当容量超过存储桶矢量的大小乘以负载因子时。

典型的负载系数为0.8; 因此,您需要注意的是,如果可以在填充哈希映射之前对其进行预调整大小,请始终将其预调整为:intended_filling * (1/0.8) + epsilon这将为您提供一个保证,即在填充过程中,您无需虚假地重新哈希和重新复制所有内容。

封闭地址映射(std::unordered..)的优点是您不必关心那些参数。

但是boost::flat_map是有序向量;因此,它将始终具有log(N)渐近复杂度,它不如开放地址哈希图(摊销的恒定时间)好。您也应该考虑这一点。

基准结果

此测试涉及不同的映射(int键和__int64/ somestruct为值)和std::vector

测试类型信息:

typeid=__int64 .  sizeof=8 . ispod=yes
typeid=struct MediumTypePod .  sizeof=184 . ispod=yes

插入

编辑:

我以前的结果包含一个错误:他们实际测试了有序插入,这对于平面图表现出非常快的行为。
我将这些结果留在本页的后面,因为它们很有趣。
这是正确的测试: 随机插入100

随机插入10000

我已经检查了实现,这里的平面图中没有实现延迟排序之类的东西。每个插入都是即时进行排序的,因此此基准展现了渐近趋势:

map:O(N * log(N))
hashmaps:O(N)
vector and flatmaps:O(N * N)

警告:此后,的2个测试std::mapflat_maps都存在错误,并且实际上测试了有序插入(相对于其他容器的随机插入。是的,这很令人困惑,抱歉):
无保留地混合插入100个元素

我们可以看到有序插入会导致后推,而且速度非常快。但是,从我的基准测试的非图表结果中,我也可以说,这对于反向插入而言并不是绝对的最佳。在10k个元素上,可以在预先保留的向量上获得完美的反向插入最优性。这给了我们300万个周期;我们在此处观察到4.8M的有序插入flat_map(因此为最佳的160%)。

无保留地混合插入10000个元素 分析:请记住,这是向量的“随机插入”,因此,每次插入都要向上(一个元素一个元素)移动一半(平均)数据,因此产生了10亿个庞大的周期。

随机搜索3个元素(时钟重新归一化为1)

大小= 100

在100个元素的容器中进行rand搜索

大小= 10000

在10000个元素的容器中进行rand搜索

迭代

尺寸超过100(仅限MediumPod类型)

超过100个中型豆荚的迭代

超过10000(仅限MediumPod类型)

超过10000个中型豆荚的迭代

最终盐粒

最后,我想回到“基准测试§3Pt1”(系统分配器)上。在最近的一项实验中,我围绕着我开发的开放地址哈希图的性能进行了研究,在某些std::unordered_map用例上(在此讨论),我测量出Windows 7和Windows 8之间的性能差距超过3000%。
这使我想警告读者有关以上结果(它们是在Win7上完成的):您的行驶里程可能会有所不同。

最好的祝福


1
哦,在这种情况下是有道理的。Vector的固定摊销时间保证仅在最后插入时适用。由于必须将插入点之后的所有内容向前移动,因此在随机位置进行插入应该平均每个插入O(n)。因此,我们希望您的基准中的二次行为会迅速爆炸,即使对于较小的N也是如此。例如,AssocVector样式实现可能会将排序推迟到需要查找之前,例如,而不是在每次插入后进行排序。不看基准就很难说。
Billy ONeal 2014年

1
@BillyONeal:啊,我们和一位同事一起检查了代码,发现了罪魁祸首,我的“随机”插入命令是有序的,因为我使用了std :: set来确保插入的键是唯一的。这是很明显的不礼貌,但我通过random_shuffle进行了修复,现在正在重建,并且一些新结果将在编辑后立即出现。因此,在当前状态下的测试证明,“有序插入”是该死的。
v.oddou 2014年

3
“英特尔有论文”← 这里
同构性

5
也许我缺少明显的东西,但我不明白为什么随机搜索是慢flat_mapstd::map-是任何人都无法解释这一结果呢?
boycy '17

1
我将其解释为这次boost实现的特定开销,而不是flat_mapas作为容器的固有特性。因为Aska::版本比std::map查找要快。证明还有优化的空间。预期的性能在渐近上是相同的,但是由于缓存的局部性,可能会稍微好一些。使用高尺寸集时,它们应会聚。
v.oddou

6

从文档看来,这类似于Loki::AssocVector我是一个相当沉重的用户。由于它基于向量,因此具有向量的特征,即:

  • 只要size增长超过,迭代器就会失效capacity
  • 当它超出范围时capacity,需要重新分配和移动对象,即,除非endcapacity > size
  • 查找比std::map由于缓存局部性而更快,这是一种二进制搜索,其性能与std::map其他地方相同
  • 使用较少的内存,因为它不是链接的二叉树
  • 除非您强行告诉它,否则它永远不会缩小(因为这会触发重新分配)

最好的用法是当您事先知道元素的数量(以便可以reserve提前)时,或者插入/删除很少但查找频繁时。迭代器失效使它在某些用例中有点麻烦,因此就程序正确性而言它们是不可互换的。


1
false :)上面的测量显示,对于查找操作而言,映射的速度比flat_map快,我想boost ppl需要修复实现,但理论上您是对的。
NoSenseEtAl 2014年
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.