在 x86 程序集中存储大量布尔值的最佳方法是什么?

问题描述

最近我一直在处理充满布尔值的大型数组。目前,我使用 .bss 指令将它们存储在 .space 部分,该指令允许我创建字节数组。但是,由于我只需要存储布尔值,因此我希望逐位读取和写入数组中的数据。

目前,我能想到的最好方法是使用 .space 指令和所需存储空间的 1/8,并使用 ((1 << k) & n) 公式获取和设置单个位,其中 { {1}} 是位,k 是数据,但这看起来相当笨重,我想知道是否有更优雅的解决方案?谢谢。 (最好使用 AT&T 语法)

解决方法

对于稀疏位集(全部为真或全部为假,只有少数例外),您可以使用任何集合数据结构(包括哈希表)存储一组索引。您当然可以在 asm 中手动实现任何算法,就像在 C 中一样。可能有一些更专业的数据结构适合各种用途/用例。


对于“普通”布尔数组,您的两个主要选项是

  • 每字节解包 1 个 bool,值为 0 / 1,如 C bool arr[size]
    (在 .bss 或动态分配中,无论您想放在哪里,与任何字节数组相同)。

    占用压缩位数组的 8 倍空间(因此缓存占用空间),但非常易于使用。对于随机访问特别有效,尤其是写入,因为您可以存储一个字节而不会打扰其邻居。 (不必读取/修改/写入包含的字节或双字)。

    除了缓存占用导致更多缓存未命中,如果它加上您的其余数据不适合任何级别的缓存,较低的密度也不利于搜索、popcounting、复制或设置/清除一系列元素.

    如果可以在写入数组的代码中保存指令,则可以允许 0 / 非 0 而不是 0 / 1。但是,如果您想比较两个元素或计算真值或其他任何内容,则在阅读时可能会花费指令。请注意,大多数 C/C++ ABI 对 bool 严格使用 0 / 1 个字节,并将保存 bool2 传递给 C 函数 could make it crash

  • 打包 1 个布尔值,如 C++ std::vector<bool>。 (除了你当然可以将它存储在任何你想要的地方,不像 std::vector 总是动态分配)。

    Howard Hinnant 的文章 On vector<bool> 讨论了位数组擅长的一些事情(具有适当优化的实现),例如搜索 true 可以一次检查整个块,例如qword 搜索一次 64 位,AVX vptest 一次 256 位。 (然后 tzcntbsf 当您找到非零块时,或多或少与字节元素相同:Efficiently find least significant set bit in a large array?)。所以比字节数组快 8 倍(即使假设缓存命中率相等),除了一些额外的工作,如果使用 SIMD 向量化,在向量中找到正确的字节或双字后找到元素内的位。与仅使用 vpslld $7,%ymm0,%ymm0vpmovmskb %ymm0,%eax / bsf %eax,%eax 的字节数组将字节转换为位图并进行搜索。


x86 位数组又名位串指令:mem 操作数慢

x86 确实有位数组指令,如 bt(位测试)和 bts(位测试和设置),还有重置(清除)和补码(翻转) ,但它们是 slow with a memory destination 和寄存器位索引;手动索引正确的字节或双字并加载它实际上更快,然后使用 bts %reg,%reg 并存储结果。 Using bts assembly instruction with gcc compiler

# fast version:
# set the bit at index n (RSI) in bit-array at RDI
   mov  %esi,%edx           # save the original low bits of the index
   shr  $5,%rsi             # dword index = bit-index / 8 / 4
   mov  (%rdi,%rsi,4),%eax   # load the dword containing the bit
   bts  %edx,%eax           # eax |= 1 << (n&31)   BTS reg,reg masks the bit-index like shifts
   mov  %eax,(%rdi,%rsi)   # and store it back

这有效地将位索引分为双字索引和双字内位索引。双字索引是通过移位显式计算的(并转换回字节偏移量,以使用缩放索引寻址模式对齐双字)。作为 bts %reg,%reg 如何屏蔽计数的一部分,隐式计算双字内位索引。

(如果您的位数组肯定小于 2^32 位(512 MiB),您可以使用 shr $5,%esi 节省一个字节的代码大小,丢弃位索引的高 32 位。)

这会在 CF 中保留旧位的副本,以防万一。 bts reg,reg 在 Intel 上为单 uop,在 AMD 上为 2 uop,因此与手动执行 mov $1,%reg / shl / or 相比绝对值得。

在现代 Intel CPU(https://uops.info/https://agner.org/optimize/)上只有 5 个 uops,而 bts %rsi,(%rdi) 的 10 个 uops 做完全相同的事情(但不需要任何 tmp注册)。

你会注意到我只使用了双字块,不像在 C 中你经常看到使用 unsigned longuint64_t 块的代码所以搜索可以快速进行。但是在 asm 中,对同一内存使用不同大小的访问是零问题,除非您先进行窄存储然后进行宽负载,否则存储转发停顿。更窄的 RMW 操作实际上更好,因为这意味着对不同位的操作可以更紧密地结合在一起,而不会实际创建错误的依赖关系。如果这是一个主要问题,您甚至可以使用字节访问,除了 bts 和朋友只能降低到 16 位 word 操作数大小,因此您必须手动 and $7,%reg从位索引中提取字节内位。

例如像这样阅读:

# byte chunks takes more work:
   mov  %esi,%edx      # save the original low bits
   shr  $3,%rsi        # byte index = bit-index / 8
   movzbl (%rdi,%rsi),%eax   # load the byte containing the bit
   and  $7,%edx
   bt   %edx,%eax      # CF = eax & 1 << (n&7)
   # mov  %al,%rsi)   if you used BTS,BTR,or BTC 

字节加载最好使用 movzx(又名 AT&T movzbl)完成,以避免写入部分寄存器。


如果您需要原子地设置位(例如在多线程程序中),您可以lock bts %reg,mem,或者您可以在寄存器中生成1<<(n&31)并{ {1}} 如果您不关心旧值是什么。 lock or %reg,mem 无论如何都很慢并且是微编码的,所以如果你需要原子性,你可以直接使用它而不是试图避免疯狂的 CISC 位数组语义。

在多线程情况下,更有理由考虑使用每字节 1 个布尔值,这样您就可以只使用普通的 lock bts(保证原子性并且不会干扰其邻居:Can modern x86 hardware not store a single byte to memory? ),而不是原子 RMW。