std::bitset 的性能如何?

Posted

技术标签:

【中文标题】std::bitset 的性能如何?【英文标题】:What is the performance of std::bitset? 【发布时间】:2015-07-29 11:36:35 【问题描述】:

我最近在 Programmers 上提出了一个问题,关于在 std::bitset 上使用原始类型的手动位操作的原因。

从那次讨论中,我得出结论,主要原因是它的性能相对较差,尽管我不知道这种观点有任何衡量依据。所以下一个问题是:

使用std::bitset 对原语进行位操作可能会造成什么性能损失?

这个问题是故意宽泛的,因为在网上看了之后我什么都找不到,所以我会尽我所能。基本上,我正在寻找一种资源,该资源提供std::bitset 与“pre-bitset”替代方案的一些分析,以解决使用 GCC、Clang 和/或 VC++ 的一些常见机器架构上的相同问题。有一篇非常全面的论文试图回答位向量的这个问题:

http://www.cs.up.ac.za/cs/vpieterse/pub/PieterseEtAl_SAICSIT2010.pdf

不幸的是,它要么早于 std::bitset,要么被认为超出了范围,因此它专注于向量/动态数组实现。

我真的只是想知道std::bitset 是否比它打算解决的用例的替代方案更好。我已经知道它更容易并且更清晰比在整数上摆弄,但它是吗?

【问题讨论】:

***.com/questions/11712479/… 写你的问题所花费的时间不是和它一样多吗...? @TonyD 大约需要一天的时间才能在不同的架构上提出一个全面的测试套件,这在一般意义上是有用的,即使这样,这个过程也容易出错,因为我'不是专家。我认为询问其他地方是否已经存在这方面的研究是不合理的。 @TonyD 你是说这是一道作业题? 我认为@TonyD 的意思是这种情况 #3:关闭 -> 离题,因为... -> 要求我们推荐或查找书籍、工具、软件库、教程或其他离题的问题站点资源与 Stack Overflow 无关,因为它们往往会吸引固执己见的答案和垃圾邮件。相反,请描述问题以及迄今为止为解决该问题所做的工作。 【参考方案1】:

更新

自从我发布这篇文章以来已经有很多年了,但是:

我已经知道这比在 整数,但速度一样快吗?

如果您使用bitset 的方式确实比位摆弄更清晰、更干净,例如一次检查一位而不是使用位掩码,那么您将不可避免地失去按位计算的所有好处操作提供,例如能够检查是否一次针对掩码设置了 64 位,或者使用 FFS 指令快速确定在 64 位中设置了哪一位。

我不确定bitset 是否会以所有可能的方式使用(例如:按位使用operator&),但如果你使用它就像一个固定大小的布尔值数组这几乎是我经常看到人们使用它的方式,那么您通常会失去上述所有这些好处。不幸的是,我们无法获得使用operator[] 一次访问一个位的那种水平的表达能力,并让优化器找出所有按位操作以及 FFS 和 FFZ 等为我们进行的操作,至少自上次以来没有我检查的时间(否则bitset 将是我最喜欢的结构之一)。

现在,如果您打算将bitset<N> bits 与类似uint64_t bits[N/64] 互换使用,就像使用按位运算以相同方式访问两者一样,它可能是相当的(自从这篇古老的帖子以来就没有检查过)。但是,您首先会失去使用bitset 的许多好处。

for_each方法

我想,在过去我提出了一个for_each 方法来迭代vector<bool>dequebitset 之类的东西时,我遇到了一些误解。这种方法的要点是在调用函子时利用容器的内部知识更有效地迭代元素,就像一些关联容器提供自己的 find 方法而不是使用 std::find 来做得更好而不是线性时间搜索。

例如,如果您对这些容器有内部知识,则可以通过在 64 个连续索引被占用时使用 64 位掩码一次检查 64 个元素来遍历 vector<bool>bitset 的所有设置位,如果不是这种情况,同样使用 FFS 指令。

但迭代器设计必须在operator++ 中执行这种类型的标量逻辑,这将不可避免地不得不做一些更昂贵的事情,这只是在这些特殊情况下设计迭代器的性质。 bitset 完全缺乏迭代器,这通常使人们想要使用它来避免处理按位逻辑,以使用 operator[] 在顺序循环中单独检查每个位,只想找出设置了哪些位。这也远没有for_each 方法实现的效率那么高。

双/嵌套迭代器

上面提出的for_each 容器特定方法的另一种替代方法是使用双/嵌套迭代器:即指向不同类型迭代器的子范围的外部迭代器。客户端代码示例:

for (auto outer_it = bitset.nbegin(); outer_it != bitset.nend(); ++outer_it)

     for (auto inner_it = outer_it->first; inner_it != outer_it->last; ++inner_it)
          // do something with *inner_it (bit index)

虽然不符合标准容器中现在可用的平面类型迭代器设计,但这可以进行一些非常有趣的优化。举个例子,想象一下这样的情况:

bitset<64> bits = 0x1fbf; // 0b1111110111111;

在这种情况下,外部迭代器只需进行几次按位迭代 ((FFZ/or/complement),即可推断出要处理的第一个位范围是位 [0, 6),此时我们可以通过内部/嵌套迭代器非常便宜地迭代该子范围(它只会增加一个整数,使++inner_it 仅相当于++int)。然后,当我们增加外部迭代器时,它可以非常快速地再次使用一些按位指令确定下一个范围将是 [7, 13)。在我们遍历那个子范围之后,我们就完成了。再举一个例子:

bitset<16> bits = 0xffff;

在这种情况下,第一个和最后一个子范围将是 [0, 16),并且 bitset 可以通过单个按位指令确定,此时我们可以遍历所有设置位,然后我们就完成了。

这种类型的嵌套迭代器设计可以很好地映射到 vector&lt;bool&gt;dequebitset 以及人们可能创建的其他数据结构,例如展开列表。

我这么说的方式不仅仅只是简单的推测,因为我有一组类似于 deque 的数据结构,它们实际上与 vector 的顺序迭代相当(随机的仍然明显慢-access,特别是如果我们只是存储一堆原语并进行琐碎的处理)。然而,为了在顺序迭代中达到与vector 相当的时间,我不得不使用这些类型的技术(for_each 方法和双/嵌套迭代器)来减少每次迭代中进行的处理和分支的数量。否则我无法与时代抗衡,否则仅使用平面迭代器设计和/或operator[]。而且我当然并不比标准库的实现者聪明,但我想出了一个类似deque 的容器,它可以更快地顺序迭代,这强烈地向我表明这是迭代器的标准接口设计的问题在优化器无法优化的这些特殊情况下会带来一些开销。

旧答案

我是那些会给你类似性能答案的人之一,但我会尽量给你一些比"just because"更深入的东西。这是我通过实际的分析和时间来发现的,而不仅仅是不信任和偏执。

bitsetvector&lt;bool&gt; 的最大问题之一是,如果您想像使用布尔数组一样使用它们,它们的界面设计“太方便”了。优化器非常擅长消除您建立的所有结构,以提供安全性、降低维护成本、减少更改的侵入性等。他们在选择指令和分配最少数量的寄存器方面做得特别好,以使此类代码运行速度与不太安全、不太容易维护/更改的替代方案。

以效率为代价使 bitset 接口“过于方便”的部分是随机访问 operator[] 以及 vector&lt;bool&gt; 的迭代器设计。当您在索引n 访问其中一个时,代码必须首先确定第 n 位属于哪个字节,然后是该位的子索引。第一阶段通常涉及对左值的除法/右移以及模/位运算,这比您尝试执行的实际位运算成本更高。

vector&lt;bool&gt; 的迭代器设计面临着类似的尴尬困境,它要么必须每迭代 8 次以上就分支到不同的代码,要么支付上述那种索引成本。如果前者完成,它会使迭代之间的逻辑不对称,并且迭代器设计往往会在那些罕见的情况下受到性能影响。举例来说,如果 vector 有自己的 for_each 方法,则可以通过仅将位与 vector&lt;bool&gt; 的 64 位掩码掩码来一次遍历 64 个元素的范围,如果所有位被设置而不单独检查每个位。它甚至可以使用FFS 一次性计算出范围。迭代器设计往往不可避免地必须以标量方式进行或存储更多状态,每次迭代都必须进行冗余检查。

对于随机访问,优化器似乎无法优化掉这种索引开销,以确定在不需要时访问哪个字节和相对位(可能有点过于依赖运行时),并且您往往会看到显着的性能提升使用更多的手动代码处理位,并具有关于它正在处理的字节/字/双字/四字的高级知识。这有点不公平的比较,但std::bitset 的困难在于,在代码提前知道要访问哪个字节的情况下,没有办法进行公平的比较,而且通常情况下,你往往有这个信息提前。这是随机访问情况下的苹果与橙子的比较,但您通常只需要橙子。

如果接口设计涉及bitset,其中operator[] 返回一个代理,则可能不会出现这种情况,需要使用双索引访问模式。例如,在这种情况下,您可以通过使用模板参数写入 bitset[0][6] = true; bitset[0][7] = true; 来访问位 8,以指示代理的大小(例如 64 位)。一个好的优化器可能能够采用这样的设计,并通过将其转换为:bitset |= 0x60;

另一个可能有帮助的设计是如果bitsets 提供了for_each_bit 类型的方法,将位代理传递给您提供的函子。这实际上可能可以与手动方法相媲美。

std::deque 也有类似的接口问题。对于顺序访问,它的性能不应该比std::vector 慢得多。然而不幸的是,我们使用operator[] 顺序访问它,该operator[] 专为随机访问或通过迭代器而设计,并且双端队列的内部代表根本不能非常有效地映射到基于迭代器的设计。如果 deque 提供了自己的 for_each 类型的方法,那么它可能会开始更接近 std::vector's 顺序访问性能。这些是一些罕见的情况,其中 Sequence 接口设计带有一些优化器通常无法消除的效率开销。通常,优秀的优化器可以在生产构建中使便利性免于运行时成本,但不幸的是,并非在所有情况下都如此。

对不起!

也很抱歉,回想起来,除了bitset 之外,我还谈到了vector&lt;bool&gt;deque。这是因为我们有一个代码库,其中使用这三个,特别是迭代它们或使用它们进行随机访问,通常是热点。

苹果变橘子

正如旧答案中所强调的,将bitset 的直接用法与具有低级按位逻辑的原始类型进行比较是在将苹果与橙子进行比较。这不像bitset 的实现效率很低。如果您确实需要使用随机访问模式访问一堆位,由于某种原因或其他原因,一次只需要检查和设置一个位,那么它可能是理想的实现此目的。但我的观点是,我遇到的几乎所有用例都不需要这样做,当不需要时,涉及按位运算的老派方法往往效率更高。

【讨论】:

在我的测试中 (www.plflib.org/colony.htm) 如果您使用的是迭代器而不是 [ ] 运算符,则双端队列的迭代速度与向量非常相似。此外,不幸的是,为 bitset 所做的声明从未附带基准测试。逻辑是合理的,但我看到的与 bitset 实现的唯一比较得出了非常不同的结果:www.cs.up.ac.za/cs/vpieterse/pub/PieterseEtAl_SAICSIT2010.pdf 棘手的部分是这些基准测试也可能有很大的不同:gotw.ca/gotw/054.htm(虽然很旧)。视情况而定,取决于输入因素、内存、硬件、供应商实施等。我试图解决的更多是概念层面的问题。一个双端队列不提供连续的要求,并且可能由多个块组成——它自然会遵循 STL 兼容的迭代器设计需要在增量/减量运算符中进行分支(这有多便宜/多贵,但有人可能会说它在概念上更多比增加/减少指针/索引更昂贵)。 通过直接针对双端队列内部实现的“for_each”设计,分支成本大大降低。 bitset/vector 的比较并没有像论文引用的 Qt 版本那样与其他比较,而只是针对 C 中常见的那种按位逻辑代码。虽然我通常建议选择最简单的务实方法支持最低维护成本的版本,然后重复分析和测量,并根据需要进行优化(并且始终测量这些优化以确保它们确实有所作为)。 我不认为将事物表述为概念真的有帮助 - 我的意思是,我知道分支不会显着影响迭代,因为分支预测现在在 CPU 上非常好。我自己的容器,colony,使用多个块,但它不会显着影响迭代。另外我认为(?)您可能会将您对迭代器的理解误认为是不使用容器内部的东西——它们确实如此。因此,无论您使用的是 for_each 还是带有迭代器的 for 循环,都没有关系,无论您使用的是迭代器。无论如何,bool 似乎击败了 std::bitset,如下所示。 另一件事是当分支预测器成功时分支开销很便宜(这很常见),但它并不是完全免费的。当您谈论仅以只读方式访问元素时,例如如果您将单个 if 语句引入 std::vector's operator[],通常它会慢 2 倍到 10 倍(2x 是乐观的)。根据您在循环中执行的操作,即使慢 10 倍也可能“相对便宜”,但它实际上确实会使容器访问自身的速度慢 2 到 10 倍。【参考方案2】:

针对顺序和随机访问对 std::bitset 与 bool 数组进行了简短的测试分析 - 你也可以:

#include <iostream>
#include <bitset>
#include <cstdlib> // rand
#include <ctime> // timer

inline unsigned long get_time_in_ms()

    return (unsigned long)((double(clock()) / CLOCKS_PER_SEC) * 1000);



void one_sec_delay()

    unsigned long end_time = get_time_in_ms() + 1000;

    while(get_time_in_ms() < end_time)
    
    




int main(int argc, char **argv)

    srand(get_time_in_ms());

    using namespace std;

    bitset<5000000> bits;
    bool *bools = new bool[5000000];

    unsigned long current_time, difference1, difference2;
    double total;

    one_sec_delay();

    total = 0;
    current_time = get_time_in_ms();

    for (unsigned int num = 0; num != 200000000; ++num)
    
        bools[rand() % 5000000] = rand() % 2;
    

    difference1 = get_time_in_ms() - current_time;
    current_time = get_time_in_ms();

    for (unsigned int num2 = 0; num2 != 100; ++num2)
    
        for (unsigned int num = 0; num != 5000000; ++num)
        
            total += bools[num];
        
       

    difference2 = get_time_in_ms() - current_time;

    cout << "Bool:" << endl << "sum total = " << total << ", random access time = " << difference1 << ", sequential access time = " << difference2 << endl << endl;


    one_sec_delay();

    total = 0;
    current_time = get_time_in_ms();

    for (unsigned int num = 0; num != 200000000; ++num)
    
        bits[rand() % 5000000] = rand() % 2;
    

    difference1 = get_time_in_ms() - current_time;
    current_time = get_time_in_ms();

    for (unsigned int num2 = 0; num2 != 100; ++num2)
    
        for (unsigned int num = 0; num != 5000000; ++num)
        
            total += bits[num];
        
       

    difference2 = get_time_in_ms() - current_time;

    cout << "Bitset:" << endl << "sum total = " << total << ", random access time = " << difference1 << ", sequential access time = " << difference2 << endl << endl;

    delete [] bools;

    cin.get();

    return 0;

请注意:总和的输出是必要的,因此编译器不会优化 for 循环 - 如果不使用循环的结果,有些人会这样做。

在具有以下标志的 GCC x64 下:-O2;-Wall;-march=native;-fomit-frame-pointer;-std=c++11; 我得到以下结果:

布尔数组: 随机访问时间 = 4695,顺序访问时间 = 390

位集: 随机访问时间 = 5382,顺序访问时间 = 749

【讨论】:

单个数据点无法让您评估渐近成本。它是线性的吗?二次方?还有什么?【参考方案3】:

除了其他答案所说的访问性能之外,还可能存在大量空间开销:典型的bitset&lt;&gt; 实现只是使用最长的整数类型来支持它们的位。因此,下面的代码

#include <bitset>
#include <stdio.h>

struct Bitfield 
    unsigned char a:1, b:1, c:1, d:1, e:1, f:1, g:1, h:1;
;

struct Bitset 
    std::bitset<8> bits;
;

int main() 
    printf("sizeof(Bitfield) = %zd\n", sizeof(Bitfield));
    printf("sizeof(Bitset) = %zd\n", sizeof(Bitset));
    printf("sizeof(std::bitset<1>) = %zd\n", sizeof(std::bitset<1>));

在我的机器上产生以下输出:

sizeof(Bitfield) = 1
sizeof(Bitset) = 8
sizeof(std::bitset<1>) = 8

如您所见,我的编译器分配了高达 64 位来存储单个位,使用位域方法,我只需要四舍五入到八位。

如果您有很多小位集,空间使用中的八倍可能会变得很重要。

【讨论】:

【参考方案4】:

这里不是一个很好的答案,而是一个相关的轶事:

几年前,我在开发实时软件时遇到了调度问题。有一个模块超出了时间预算,这非常令人惊讶,因为该模块只负责将位映射和打包/解包到 32 位字。

原来该模块正在使用 std::bitset。我们将其替换为手动操作,执行时间从 3 毫秒减少到 25 微秒。这是一个重大的性能问题,也是一项重大改进。

关键是,这个类引起的性能问题可能是非常真实的。

【讨论】:

那是什么编译器? msvc 12 我认为来自 Visual Studio 2008【参考方案5】:

反问:为什么std::bitset 是这样写的? 答:不是。

另一个反问:有什么区别:

std::bitset<128> a = src;
a[i] = true;
a = a << 64;

std::bitset<129> a = src;
a[i] = true;
a = a << 63;

答案:性能相差50倍http://quick-bench.com/iRokweQ6JqF2Il-T-9JSmR0bdyw

你需要非常小心你的要求,bitset 支持很多东西,但每个都有自己的成本。通过正确处理,您将拥有与原始代码完全相同的行为:

void f(std::bitset<64>& b, int i)

    b |= 1L << i;
    b = b << 15;

void f(unsigned long& b, int i)

    b |= 1L << i;
    b = b << 15;

两者都生成相同的程序集:https://godbolt.org/g/PUUUyd(64 位 GCC)

另一件事是bitset 更便携,但这也有成本:

void h(std::bitset<64>& b, unsigned i)

    b = b << i;

void h(unsigned long& b, unsigned i)

    b = b << i;

如果 i &gt; 64 则位设置为零,如果是无符号,我们有 UB。

void h(std::bitset<64>& b, unsigned i)

    if (i < 64) b = b << i;

void h(unsigned long& b, unsigned i)

    if (i < 64) b = b << i;

通过检查防止 UB 生成相同的代码。

另一个地方是set[],第一个是安全的,意味着你永远不会得到UB,但这会花费你一个分支。 [] 有 UB 如果你使用了错误的值,但使用 var |= 1L&lt;&lt; i; 快。当然,如果std::bitset 不需要比系统上可用的最大 int 更多的位,因为否则您需要拆分值才能在内部表中获得正确的元素。 std::bitset&lt;N&gt; 大小 N 的平均值对于性能非常重要。如果大于或小于最佳值,您将支付它的成本。

总的来说,我发现最好的方法是使用类似的东西:

constexpr size_t minBitSet = sizeof(std::bitset<1>)*8;

template<size_t N>
using fasterBitSet = std::bitset<minBitSet * ((N  + minBitSet - 1) / minBitSet)>;

这将消除修剪超出位的成本:http://quick-bench.com/Di1tE0vyhFNQERvucAHLaOgucAY

【讨论】:

minBitSet * ((N + minBitSet - 1) / minBitSet) == N + minBitSet - 1 @AlQafir / 导致值被裁剪,这意味着这个等式不正确。左边总是minBitSet * k,其中两个数字都是整数,但右边可以有任何你想要的值,比如13 + 32 - 1。我想要32 * k 现在我知道你在那里做了什么。感谢您的解释!

以上是关于std::bitset 的性能如何?的主要内容,如果未能解决你的问题,请参考以下文章

[C++] 用bitset代替bool数组的性能测试以及bitset的基本使用

如何将任何用户定义的类型转换为 std::bitset?

如何编写适用于 32 位和 64 位的 std::bitset 模板

如何打印大于“unsigned long long”的“std::bitset”的十进制值?

为啥 std::bitset 不带有迭代器?

为啥 std::bitset 的位顺序相反? [复制]