我最近在Programmers上提出了一个关于在std::bitset
上使用原始类型的手动位操作的原因的问题。
从讨论中我得出的结论是,主要原因是其表现相对较差,尽管我并不知道这种观点有任何可靠的依据。接下来的问题是:
这个问题有意广泛,因为在网上看后我找不到任何东西,所以我会拿走我能得到的东西。基本上我是在使用GCC,Clang和/或VC ++在一些常见的机器架构上提供 http://www.cs.up.ac.za/cs/vpieterse/pub/PieterseEtAl_SAICSIT2010.pdf 不幸的是,它要么超出范围 我真的只想知道std::bitset
vs'pre-bitset'替代相同问题的资源之后。有一篇非常全面的论文试图回答这个问题的位向量:std::bitset
,要么被认为超出了范围std::bitset
,因此它专注于向量/动态数组实现。std::bitset
是否更好而不是它要解决的用例的替代方案。我已经知道更容易和更清晰而不是对整数进行bit-fiddling,但是它是 fast 吗?
答案 0 :(得分:22)
<强>更新强>
自从我发布这张照片以来已经很久了,但是:
我已经知道它比在小提琴上更容易和更清晰 整数,但它是否一样快?
如果你使用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>
方法来迭代deque
,bitset
和find
等内容时,我会遇到一些误解。这种方法的关键是利用容器的内部知识在调用仿函数时更有效地迭代元素,就像一些关联容器提供自己的std::find
方法而不是使用vector<bool>
一样。比线性时间搜索更好。
例如,如果您具有这些容器的内部知识,则可以通过使用64位掩码一次检查64个元素来迭代bitset
或operator++
的所有设置位。连续索引被占用,并且在不是这种情况下同样使用FFS指令。
但是,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;
虽然不符合现在标准容器中可用的平面类型的迭代器设计,但这可以允许一些非常有趣的优化。举个例子,想象一下这样的情况:
++inner_it
在这种情况下,外部迭代器可以通过几次按位迭代((FFZ /或/补码)推断出要处理的第一个比特范围是位[0,6],此时我们可以通过内部/嵌套迭代器非常便宜地遍历该子范围(它只会增加一个整数,使++int
等同于bitset<16> bits = 0xffff;
)。然后当我们递增外部迭代器时,它可以非常快速地再次使用一些按位指令确定下一个范围是[7,13]。在我们遍历该子范围后,我们已经完成了。以此为例:
[0, 16)
在这种情况下,第一个和最后一个子范围将是vector<bool>
,并且bitset可以确定使用单个按位指令,此时我们可以遍历所有设置位,然后我们可以重做。
这种类型的嵌套迭代器设计可以很好地映射到deque
,bitset
和deque
以及人们可能创建的其他数据结构,如展开列表。
我说的方式超出了扶手椅的推测范围,因为我有一组类似于vector
的数据结构,它们实际上与vector
的顺序迭代相同(仍然随机访问速度明显变慢,特别是如果我们只是存储一堆基元并进行简单的处理)。但是,为了实现顺序迭代的for_each
可比时间,我不得不使用这些类型的技术(operator[]
方法和双/嵌套迭代器)来减少每个技术的处理和分支数量。迭代。我只能使用平面迭代器设计和/或deque
,无法与时俱进。而且我当然不比标准库实现者更聪明,但想出了一个"just because"
类容器,它可以按顺序迭代得更快,这强烈暗示我这是一个问题在这种情况下,迭代器的标准接口设计在优化器无法优化的特殊情况下会产生一些开销。
旧答案
我是其中一位会给你类似表现答案的人,但我会尝试给你一些比bitset
更深入的东西。这是我通过实际剖析和时间来看到的,而不仅仅是不信任和偏执。
vector<bool>
和operator[]
最大的问题之一是他们的界面设计过于方便&#34;如果你想像一系列布尔一样使用它们。优化器可以很好地消除您建立的所有结构,从而提供安全性,降低维护成本,减少更改干扰等等。通过选择指令并分配最少数量的寄存器,使这些代码运行速度与不那么安全,不那么容易维护/改变替代方案。
使bitset接口的部分过于方便&#34;以效率为代价的是随机访问vector<bool>
以及n
的迭代器设计。当您在索引vector<bool>
访问其中一个时,代码必须首先确定第n位属于哪个字节,然后查找该位内的位的子索引。第一阶段通常涉及对左值和模/位的分割/缩小,这比您尝试执行的实际位操作更昂贵。
vector
的迭代器设计面临着类似的尴尬困境,它要么必须每次迭代8次,要么必须分支到不同的代码中,或者支付上述的那种索引成本。如果前者完成,它会使迭代中的逻辑不对称,并且迭代器设计往往会在极少数情况下受到性能影响。举例来说,如果for_each
有自己的vector<bool>
方法,你可以通过仅针对{{1的64位掩码屏蔽位来一次性迭代一系列64个元素。如果设置了所有位而没有单独检查每个位。它甚至可以使用FFS一次性计算出范围。迭代器设计往往不得不以标量方式进行,或者存储更多状态,每次迭代都必须进行冗余检查。
对于随机访问,优化器似乎无法优化掉这个索引开销,以确定在不需要时访问哪个字节和相对位(可能有点过于依赖于运行时),并且随着对其工作的字节/字/双字/ q字的高级知识的顺序,通过更多的手动代码处理位,往往会看到显着的性能提升。这有点不公平的比较,但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[]
来顺序访问它,并且deques的内部代表不能非常有效地映射到基于迭代器的设计。如果deque提供了自己的for_each
种方法,那么它可能会开始更接近std::vector's
顺序访问性能。这些是一些罕见的情况,其中Sequence接口设计带来了优化器通常无法消除的一些效率开销。通常,优秀的优化器可以在生产构建中节省运行时的成本,但遗憾的是并非在所有情况下都是如此。
<强>抱歉!强>
也很抱歉,回想起来,除了vector<bool>
之外,我还在谈论deque
和bitset
这篇文章。这是因为我们有一个代码库,使用这三个代码库,特别是通过它们进行迭代或使用随机访问,通常是热点。
苹果到橘子
正如旧答案中所强调的那样,将bitset
的直接用法与具有低级按位逻辑的基本类型进行比较是将苹果与橙子进行比较。它不像bitset
实现的效率非常低效。如果你真的需要访问具有随机访问模式的一堆比特,由于某种原因,由于某种原因需要检查和设置一次,那么它可能理想地实现了这样的目的。但我的观点是,我遇到的几乎所有用例都不需要这样,而且当不需要时,涉及按位操作的旧学校方式往往效率更高。
答案 1 :(得分:12)
是否对std :: bitset vs 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
答案 2 :(得分:4)
除了其他答案所说的访问性能之外,还可能存在大量的空间开销:典型的bitset<>
实现只使用最长的整数类型来支持它们的位。因此,以下代码
#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位来存储单个位,使用位域方法,我只需要四舍五入。
如果你有很多小的位集,那么空间使用的因子8会变得很重要。
答案 3 :(得分:3)
这里不是一个好的答案,而是一个相关的轶事:
几年前,我正在研究实时软件,我们遇到了调度问题。有一个模块超过了时间预算,这是非常令人惊讶的,因为模块只负责一些映射和打包/解压缩到32位字的位。
事实证明该模块使用的是std :: bitset。我们用手动操作替换它,执行时间从3毫秒减少到25微秒。这是一个重大的性能问题和显着的改进。
关键是,这个课程引起的性能问题可能非常真实。
答案 4 :(得分:3)
反问:为什么std::bitset
是用这种无效的方式写的?
答:不是。
另一个反问:两者之间有什么区别?
std::bitset<128> a = src;
a[i] = true;
a = a << 64;
和
std::bitset<129> a = src;
a[i] = true;
a = a << 63;
答案:性能差异http://quick-bench.com/iRokweQ6JqF2Il-T-9JSmR0bdyw的50倍
您需要非常小心自己的要求,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 > 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<< i;
的速度很快。当然,std::bitset
不需要比系统上可用的最大int位数更多的位,因为否则,您需要split值才能在内部表中获取正确的元素。这意味着std::bitset<N>
大小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位的修整成本