【问题标题】:fastest way to write a bitstream on modern x86 hardware在现代 x86 硬件上写入比特流的最快方法
【发布时间】:2011-08-07 22:57:33
【问题描述】:

在 x86/x86-64 上写入比特流的最快方法是什么? (码字

通过编写比特流,我指的是将可变比特长度符号连接到连续内存缓冲区的过程。

目前我有一个标准容器,其中有一个 32 位中间缓冲区可以写入

void write_bits(SomeContainer<unsigned int>& dst,unsigned int& buffer, unsigned int& bits_left_in_buffer,int codeword, short bits_to_write){
    if(bits_to_write < bits_left_in_buffer){
        buffer|= codeword << (32-bits_left_in_buffer);
        bits_left_in_buffer -= bits_to_write;

    }else{
        unsigned int full_bits = bits_to_write - bits_left_in_buffer;
        unsigned int towrite = buffer|(codeword<<(32-bits_left_in_buffer));
        buffer= full_bits ? (codeword >> bits_left_in_buffer) : 0;
        dst.push_back(towrite);
        bits_left_in_buffer = 32-full_bits;
    }
}

有谁知道任何不错的优化、快速说明或其他可能有用的信息?

干杯,

【问题讨论】:

  • 您能用文字解释一下这段代码的目的是什么吗? (即,“写比特流”是什么意思?)
  • @Oli Charlesworth,比特流是一系列不同比特长度的整数,没有填充。通常,整数已被编码,以便它们的长度是隐含的,例如霍夫曼编码,因此读者可以在没有任何其他信息的情况下提取原始值。
  • (1) 这些位串是可变长度的吗?这些位串的长度是否有任何可预测性? (2) 您是否尝试过在 x86-64 上使用 64 位整数?
  • High-speed software implementation of Huffman coding Kawahara, M.;邱奕仁;伯杰,T.

标签: c++ optimization x86 bit-manipulation


【解决方案1】:

我没有时间为你写(不太确定你的示例是否真的足够完整)但如果你必须,我可以考虑

  • 为各种输入/输出位移偏移量使用转换表;这种优化对于n 位的固定单元是有意义的(n 足够大(8 位?)以预期性能提升) 本质上,你可以做到

    destloc &= (lookuptable[bits_left_in_buffer][input_offset][codeword]);
    

免责声明:这是非常草率的伪代码,我只是希望它传达了我对查找表的想法,以防止移位算法

  • 用汇编编写它(我知道 i386 有 XLAT,但话又说回来,一个好的编译器可能已经使用了类似的东西) ;此外,XLAT 似乎仅限于 8 位和 AL 寄存器,因此它并不是真正通用的

更新

警告:请务必使用分析器并测试优化的正确性和速度。鉴于参考的位置,使用查找表可能会导致较差的性能。因此,您可能需要更改单个内核上的位流线程(设置线程亲和性)才能获得好处,并且您可能必须使查找表大小适应处理器的二级缓存。

另外,如果您知道可以使用某些功能,请查看SIMDSSE4GPU (CUDA) 指令集。

【讨论】:

  • 为特定指令集添加了对库的引用
  • SSE 对比特流的东西没有用处,因为它没有适当的矢量化移位(它只能将 128 位值移位整个字节,并且只能在整个寄存器中移位相同数量的打包类型)
  • 查找表很有趣。根据我的经验,“就地”处理往往比现代机器上的查找表(用于低级操作)更快,所以我不相信。我一定会试一试,看看它的表现如何。
  • @Markus:SSE 没用除非你能够将它与查找表的想法结合起来,以防止(低距离)位移。此外,我对一次流式传输的比特数量没有假设。
  • 另外,刚刚从recent answer on SO收集到this useful link
【解决方案2】:

我曾经写过一个非常快的实现,但它有几个限制:当你写和读比特流时,它可以在 32 位 x86 上工作。我这里不检查缓冲区限制,我分配了更大的缓冲区,并不时从调用代码中检查。

unsigned char* membuff; 
unsigned bit_pos; // current BIT position in the buffer, so it's max size is 512Mb

// input bit buffer: we'll decode the byte address so that it's even, and the DWORD from that address will surely have at least 17 free bits
inline unsigned int get_bits(unsigned int bit_cnt){ // bit_cnt MUST be in range 0..17
    unsigned int byte_offset = bit_pos >> 3;
    byte_offset &= ~1;  // rounding down by 2.
    unsigned int bits = *(unsigned int*)(membuff + byte_offset);
    bits >>= bit_pos & 0xF;
    bit_pos += bit_cnt;
    return bits & BIT_MASKS[bit_cnt];
};

// output buffer, the whole destination should be memset'ed to 0
inline unsigned int put_bits(unsigned int val, unsigned int bit_cnt){
    unsigned int byte_offset = bit_pos >> 3;
    byte_offset &= ~1;
    *(unsigned int*)(membuff + byte_offset) |= val << (bit_pos & 0xf);
    bit_pos += bit_cnt;
};

【讨论】:

  • 看起来 get_bits 和 put_bits 都有一次不超过 17 位的限制。
  • @Mark 是的。或 25 位,不四舍五入。但是 17 对我来说已经足够了,所以我更喜欢偶数地址。
  • 你通过不舍入来保存指令,那不是更快吗?我怀疑使用 L1 缓存不会对未对齐的内存操作造成任何损失。
  • 在大多数硬件上,未对齐访问仍然存在潜在惩罚,但 x86 的趋势是降低惩罚。许多当前硬件对(非 SIMD)未对齐读取没有任何惩罚,只要它们不跨越缓存线,并且如果它跨越缓存线则有小惩罚(但如果你没有加载,你可能不会观察到它-有限)。
【解决方案3】:

您可能必须等到 2013 年才能掌握真正的硬件,但 "Haswell" new instructions 将带来适当的矢量化移位(即能够将每个向量元素移位另一个向量中指定的不同数量)到 x86/AVX .不确定细节(有足够的时间弄清楚),但这肯定会大大提高比特流构造代码的性能。

【讨论】:

  • 除非我弄错了,否则它并没有你想象的那么有用。如果您从调用put_bits 方法开始,您如何对其进行矢量化?您可以将传递给每个代码的位推入一个向量(虽然这已经很昂贵),但是您如何从那里转到位流?您可以进行所有矢量化移位,但是您需要将矢量水平或折叠在一起。总的来说,即使有可变的班次,你如何向量化它对我来说并不是很明显。 FWIW,如果您可以很好地对其进行矢量化,则可以在没有 HSW 的情况下使用乘法来模拟移位。
  • 对于一般问题,它没有多大帮助。但是,使用矢量化移位、屏蔽分散写入,您可以制作出相当整洁的 n 路并行比特流。对于非混叠符号大小的大区域,您可以获得相当合理的 SIMD 通道交错输出紧凑性。
【解决方案4】:

一般来说很难回答,因为它取决于许多因素,例如您正在阅读的位大小的分布、客户端代码中的调用模式以及硬件和编译器。一般来说,从比特流中读取(写入)的两种可能方法是:

  1. 当您需要更多位时,使用 32 位或 64 位缓冲区并有条件地从底层数组读取(写入)它。这就是您的 write_bits 方法所采用的方法。
  2. 在每次比特流读取(写入)时从底层数组中无条件读取(写入),然后对结果值进行移位和屏蔽。

(1) 的主要优点包括:

  • 仅以对齐方式从底层缓冲区读取所需的最少次数。
  • 快速路径(不读取数组)稍微快一些,因为它不必执行读取和相关的寻址数学运算。
  • 该方法可能会更好地内联,因为它没有读取 - 例如,如果您有多个连续的 read_bits 调用,编译器可能会结合大量逻辑并生成一些非常快速的代码。李>

(2) 的主要优点是它是完全可预测的 - 它不包含不可预测的分支。

仅仅因为(2)只有一个优势并不意味着它更糟:这种优势很容易压倒其他一切。

特别是,您可以根据两个因素分析算法可能的分支行为:

  • bitsteam 需要多久从底层缓冲区读取一次?
  • 在需要读取之前调用次数的可预测性如何?

例如,如果您在 50% 的时间内读取 1 位,50% 的时间读取 2 位,则在需要底层读取之前,您将执行 64 / 1.5 = ~42 读取(如果您可以使用 64 位缓冲区)。这有利于方法 (1),因为即使预测错误,也很少读取底层证券。另一方面,如果您通常读取 20+ 位,您将每隔几次调用从底层读取。这可能有利于方法 (2),除非底层读取的模式是非常可预测的。例如,如果您总是读取 22 和 30 位之间的数据,您可能总是需要 恰好 次调用来耗尽缓冲区并读取底层的1 数组。所以分支将被很好地预测并且 (1) 将保持快速。

同样,这取决于您如何调用这些方法,以及编译器如何内联和简化代码。特别是如果您使用编译时常量大小重复调用方法,则可以进行很多简化。当 codeword 在编译时已知时,几乎没有任何简化可用。

最后,您可以通过提供更复杂的 API 来提高性能。这主要适用于实施选项 (1)。例如,您可以提供ensure_available(unsigned size) 调用,以确保至少有size 位(通常限制缓冲区大小)可供读取。然后,您可以使用不检查缓冲区大小的 unchecked 调用读取最多该位数。这可以通过强制缓冲区填充到可预测的计划来帮助您减少错误预测,并允许您编写更简单的未经检查的方法。


1 这完全取决于您的“从底层读取”例程的编写方式,因为这里有几个选项:有些总是填充到 64 位,有些填充到 57 到 64 位之间-位(即读取整数个字节),有些可能填充在 32 或 33 和 64 位之间(例如您的示例读取 32 位块)。

【讨论】:

  • 不错的答案。在我的情况下,符号大小的范围在 1-32 位之间,基于熵编码的残差,它有点遵循逆指数函数分布。我发现,上面建议的样式似乎在大量输入中具有性能优势。
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2012-06-01
  • 1970-01-01
  • 2011-05-13
  • 1970-01-01
  • 2021-06-14
相关资源
最近更新 更多