【问题标题】:How do I sum the four 2-bit bitfields in a single 8-bit byte?如何在一个 8 位字节中将四个 2 位位域相加?
【发布时间】:2013-07-26 15:41:56
【问题描述】:

我有四个 2 位位域存储在一个字节中。因此,每个位域可以表示 0、1、2 或 3。例如,以下是前 3 个位域为零的 4 个可能值:

00 00 00 00 = 0 0 0 0
00 00 00 01 = 0 0 0 1
00 00 00 10 = 0 0 0 2
00 00 00 11 = 0 0 0 3

我想要一种对四个位域求和的有效方法。例如:

11 10 01 00 = 3 + 2 + 1 + 0 = 6

现代 Intel x64 CPU 上的 8 位查找表需要 4 个周期才能从 L1 返回答案。似乎应该有某种方法可以比这更快地计算答案。 3 个周期为 6-12 个简单的位操作提供了大约空间。首先,在 Sandy Bridge 上,简单的 mask 和 shift 看起来需要 5 个周期:

假设位域为:d c b a,而掩码为:00 00 00 11

在 Ira 的帮助下澄清:这假定 abcd 是相同的,并且都设置为初始 byte。奇怪的是,我想我可以免费做到这一点。由于我每个周期可以加载 2 次,而不是加载一次 byte,我可以加载四次:ad 在第一个周期,bc 在第二个周期。后两个负载将延迟一个周期,但直到第二个周期我才需要它们。下面的拆分表示事物应该如何分成不同的循环。

a = *byte
d = *byte

b = *byte
c = *byte

latency

latency

a &= mask
d >>= 6

b >>= 2
c >>= 4
a += d

b &= mask
c &= mask

b += c

a += b

位域的不同编码实际上可以使逻辑更容易,只要它适合单个字节并以某种方式与该方案一对一映射。下降到组装也很好。目前的目标是 Sandy Bridge,但也可以瞄准 Haswell 或更远的地方。

应用和动机:我正在尝试使开源变量位解压缩程序运行得更快。每个位域代表后面四个整数中每一个的压缩长度。我需要总和才能知道我需要跳转多少字节才能到达下一组四个字节。当前循环需要 10 个周期,其中 5 个是我试图避免的查找。缩短一个周期将提高约 10%。

编辑:

最初我说“8 个周期”,但正如 Evgeny 在下面指出的那样,我错了。正如 Evgeny 所指出的,唯一一次间接 4 周期加载是在不使用索引寄存器的情况下从系统内存的前 2K 加载。可以在Intel Architecture Optimization Manual 第 2.12 节中找到正确的延迟列表

>    Data Type       (Base + Offset) > 2048   (Base + Offset) < 2048 
>                     Base + Index [+ Offset]
>     Integer                5 cycles               4 cycles
>     MMX, SSE, 128-bit AVX  6 cycles               5 cycles
>     X87                    7 cycles               6 cycles 
>     256-bit AVX            7 cycles               7 cycles

编辑:

我认为这就是 Ira 下面的解决方案将如何分解为循环。我认为它也需要 5 个周期的工作后负载。

a = *byte
b = *byte

latency

latency 

latency

a &= 0x33
b >>= 2

b &= 0x33
c = a

a += b
c += b

a &= 7
c >>= 4

a += c 

【问题讨论】:

  • 您的示例很奇怪:它假设原始数据字节已被神奇地复制到 a/b/c/d 中,并且必须为此付出代价。
  • 是的,这很奇怪,您指出这一点是正确的。这可能使这个例子比我希望的更难理解。但我认为我实际上可以神奇地做到这一点,而且不需要任何成本。文本中添加了说明。
  • “当前循环需要 8 个周期”怎么可能?它应该是 10 个周期:“4 周期加载 + 5 周期查表 + 1 周期指针更新”或“5 周期加载,基址 + 索引寻址模式 + 5 周期查表”(可以只进行 4 周期查表但是这个表应该在地址空间的前 2Kb 内开始,这不太可能)。
  • 我假设你知道这个字节是非零的。您可以在处理它之前通过测试来做到这一点。可以说,您可以在 计算总和之后进行测试,但关键是您必须进行测试才能离开循环。所以,也许问题在于求和,知道它是非零的。
  • 那么字节数就是位域值加1? “00 代表 1 个字节,11 代表 3 个字节”似乎是错误的。如果我理解你的话,我会期望 11 代表 4 个字节。我认为 Evgeny 是对的:为什么不发布整个循环?在现代 CPU 上计算周期非常困难。您是否尝试过使用替代解决方案修改循环以查看实际效果?

标签: c optimization x86 bit-manipulation bit-fields


【解决方案1】:

内置 POPCOUNT 指令有帮助吗?

n = POPCOUNT(byte&0x55);
n+= 2*POPCOUNT(byte&0xAA)

或许

  word = byte + ((byte&0xAA) << 8);
  n = POPCOUNT(word);

不确定总时间。 This discussion 说 popcount 有 3 个周期延迟,1 个吞吐量。


更新:
我可能遗漏了一些关于如何运行 IACA 的重要事实,但是在 12-11 吞吐量范围内进行了几次实验后,我编译了以下内容:

 uint32_t decodeFast(uint8_t *in, size_t count) {
  uint64_t key1 = *in;
  uint64_t key2;
  size_t adv;
  while (count--){
     IACA_START;
     key2=key1&0xAA;
     in+= __builtin_popcount(key1);
     adv= __builtin_popcount(key2);
     in+=adv+4;
     key1=*in;
  }
  IACA_END;
  return key1;
}

gcc -std=c99 -msse4 -m64 -O3 test.c

得到了 3.55 个周期!?!:

Block Throughput: 3.55 Cycles       Throughput Bottleneck: InterIteration
|  Uops  |  0  - DV  |  1  |  2  -  D  |  3  -  D  |  4  |  5  |    |
---------------------------------------------------------------------
|   1    |           | 1.0 |           |           |     |     |    | popcnt edx,eax
|   1    | 0.9       |     |           |           |     | 0.1 | CP | and eax,0x55 
|   1    |           | 1.0 |           |           |     |     | CP | popcnt eax,eax
|   1    | 0.8       |     |           |           |     | 0.2 |    | movsxd rdx,edx
|   1    | 0.6       |     |           |           |     | 0.4 |    | add rdi, rdx
|   1    | 0.1       | 0.1 |           |           |     | 0.9 | CP | cdqe 
|   1    | 0.2       | 0.3 |           |           |     | 0.6 |    | sub rsi, 1
|   1    | 0.2       | 0.8 |           |           |     |     | CP | lea rdi,[rdi+rax+4] 
|   1    |           |     | 0.5   0.5 | 0.5   0.5 |     |     | CP | movzx eax,[rdi]
|   1    |           |     |           |           |     | 1.0 |    | jnz 0xffff

另外两个想法

在 2 条指令中求和的可能微优化

total=0;
PDEP(vals,0x03030303,*in);  #expands the niblets into bytes
PSADBW(total,vals) #total:= sum of abs(0-byte) for each byte in vals

每个的延迟应该是 3,所以这可能无济于事。也许按字节求和的加法可以用简单的移位代替,并沿着AX=total+total&gt;&gt;16; ADD AL,AH的行加法

宏观优化:
您提到使用密钥作为对 shuffle 指令表的查找。为什么不将与下一个键的距离与随机播放指令一起存储?要么存储一个更大的表,要么可能将 4 位长度压缩到 shuffle key 的未使用位 3-6 中,代价是需要掩码来提取它。

【讨论】:

  • 聪明。您的第二个变体:POPCOUNT(byte)+POPCOUNT(byte&AA)?至少两个 POPCOUNTS 可以在一定程度上并行执行。但是,如果 POPCount 是 3 个周期延迟,那么他正在查看 4-5 个周期,所以这也不太有效。
  • Popcount 非常适合较大的位集(例如,10 组 3 位、16 组 4 位,或者如果您坚持 32 组 2 位)。周期时间没有改善,但您将成本分摊到更大的组中,因此您获得明显的加速,应该是倍数而不是百分比。
  • 前两条好消息:我能够使用 IACA 重现您的结果,而且我什至能够通过仅使用 64 位寄存器将其降低到声称的 3.15 个周期,所以我可以跳过cdqemovsxd。现在是坏消息:正如您可能预期的那样,这几乎肯定是 IACA 的一个错误。由于负载需要 5,3.x 似乎是不可能的。如果我最后切换 LEA 和 MOVZX 的顺序(调整加载地址),我会得到预期的 9 个周期。我认为您的用法是正确的,并且担心该工具有问题。如果您想举报,英特尔会在他们的帮助论坛上积极响应。
  • 这可能是 IACA 中的一个错误,但我可以始终得到报告的吞吐量在低 3 和延迟 8 左右的变化。这让我认为单次通过的吞吐量可能不是最好的指标,这可能是一个过于人为的测试用例。您说解码的延迟无关紧要,但至少需要将密钥传递给解码器,对吗?您不应该在测试用例中包含该机制吗?对需要添加更多指令的循环进行微优化似乎很奇怪。使用更多代码提供了更多改组的机会...
  • 除错误外,IACA 的吞吐量基于假设代码循环的稳定状态。知道负载的延迟为 5,并且循环的其余部分取决于负载,我确信报告的吞吐量是一个错误。是的,我可能应该从完整的循环开始。我希望对位域求和的更一般的问题更容易解决,但我不确定这是正确的选择。我测试的时候一直在加回解码,目前还没有影响循环计数。
【解决方案2】:

其他答案提出了各种方法来将单个变量中的值相加(无需解包)。虽然这些方法提供了相当好的吞吐量(尤其是 POPCNT),但它们具有很大的延迟 - 要么是因为计算链长,要么是因为使用了高延迟指令。

最好使用普通的加法指令(一次将一对值相加),使用掩码和移位之类的简单操作将这些值彼此分开,并使用指令级并行性来有效地执行此操作。此外,字节中两个中间值的位置暗示了使用单个 64 位寄存器而不是内存的表查找变体。所有这些都可以加快计算四个总和的速度,并且只使用 4 或 5 个时钟。

OP中建议的原始查表方法可能包括以下步骤:

  1. 从内存中加载四个值的字节(5 个时钟)
  2. 使用查找表计算值的总和(5 个时钟)
  3. 更新指针(1 个时钟)

64 位寄存器查找

下面的 sn-p 显示了如何在 5 个时钟内执行步骤 #2,并结合步骤 #2 和 #3 保持延迟仍然在 5 个时钟(可以优化为 4 个时钟,具有复杂寻址模式以用于内存负载):

p += 5 + (*p & 3) + (*p >> 6) +
  ((0x6543543243213210ull >> (*p & 0x3C)) & 0xF);

这里的常量“5”意味着我们跳过当前字节的长度以及对应于全零长度的4个数据字节。此 sn-p 对应于以下代码(仅限 64 位):

mov eax, 3Ch
and eax, ebx              ;clock 1
mov ecx, 3
and ecx, ebx              ;clock 1
shr ebx, 6                ;clock 1
add ebx, ecx              ;clock 2
mov rcx, 6543543243213210h
shr rcx, eax              ;clock 2..3
and ecx, Fh               ;clock 4
add rsi, 5
add rsi, rbx              ;clock 3 or 4
movzx ebx, [rsi + rcx]    ;clock 5..9
add rsi, rcx

我尝试使用以下编译器自动生成此代码:gcc 4.6.3、clang 3.0、icc 12.1.0。前两个没有做任何好事。但英特尔的编译器几乎完美地完成了这项工作。


使用 ROR 指令快速提取位域

编辑: Nathan 的测试显示以下方法存在问题。 Sandy Bridge 上的 ROR 指令使用两个端口,与 SHR 指令冲突。所以这段代码在 Sandy Bridge 上还需要 1 个时钟,这使得它不是很有用。可能它会在 Ivy Bridge 和 Haswell 上按预期工作。

没有必要使用 64 位寄存器的技巧作为查找表。相反,您可以将字节旋转 4 位,将两个中间值放置到第一个和第四个值的位置。然后你可以用同样的方式处理它们。这种方法至少有一个缺点。在 C 中表达字节旋转并不容易。我也不太确定这种旋转,因为在旧处理器上它可能会导致部分寄存器停顿。优化手册提示,对于 Sandy Bridge,如果操作源与目标相同,我们可以更新部分寄存器,而无需停顿。但我不确定我是否理解正确。而且我没有合适的硬件来检查这一点。无论如何,这是代码(现在它可能是 32 位或 64 位):

mov ecx, 3
and ecx, ebx              ;clock 1
shr ebx, 6                ;clock 1
add ebx, ecx              ;clock 2
ror al, 4                 ;clock 1
mov ecx, 3
and ecx, eax              ;clock 2
shr eax, 6                ;clock 2
add eax, ecx              ;clock 3
add esi, 5
add esi, ebx              ;clock 3
movzx ebx, [esi+eax]      ;clocks 4 .. 8
movzx eax, [esi+eax]      ;clocks 4 .. 8
add esi, eax

使用 AL 和 AH 之间的边界来解包位域

此方法与前一种方法的不同之处仅在于提取两个中间位域的方式。代替在 Sandy Bridge 上昂贵的 ROR,使用了简单的班次。此移位将第二个位域定位在寄存器 AL 中,将第三个位域 - 定位在 AH 中。然后用移位/掩码提取它们。和之前的方法一样,这里有部分寄存器停顿的可能性,现在是两条指令而不是一条。但很可能 Sandy Bridge 和更新的处理器可以毫不延迟地执行它们。

mov ecx, 3
and ecx, ebx              ;clock 1
shr ebx, 6                ;clock 1
add ebx, ecx              ;clock 2
shl eax, 4                ;clock 1
mov edx, 3
and dl, ah                ;clock 2
shr al, 6                 ;clock 2
add dl, al                ;clock 3
add esi, 5
add esi, ebx              ;clock 3
movzx ebx, [esi+edx]      ;clock 4..8
movzx eax, [esi+edx]      ;clock 4..8
add esi, edx

并行加载和计算总和

也没有必要加载 4 个长度的字节并按顺序计算总和。您可以并行执行所有这些操作。四个之和只有 13 个值。如果您的数据是可压缩的,您很少会看到这个总和大于 7。这意味着您可以将前 8 个最可能的字节加载到 64 位寄存器,而不是加载单个字节。你可以在计算四者的总和之前做到这一点。在计算总和时加载 8 个值。然后,您只需使用移位和掩码从该寄存器中获得正确的值。这个想法可以与任何计算总和的方法一起使用。在这里它与简单的表查找一起使用:

typedef unsigned long long ull;
ull four_lengths = *p;
for (...)
{
  ull preload = *((ull*)(p + 5));
  unsigned sum = table[four_lengths];
  p += 5 + sum;

  if (sum > 7)
    four_lengths = *p;
  else
    four_lengths = (preload >> (sum*8)) & 15;
}

使用正确的汇编代码,这只会给延迟增加 2 个时钟:移位和掩码。这给出了 7 个时钟(但仅限于可压缩数据)。

如果您将查表更改为计算,您可能会得到只有 6 个时钟的循环延迟:4 个用于将值相加并更新指针,2 个用于移位和掩码。有趣的是,在这种情况下,循环延迟仅由计算确定,而不取决于内存加载的延迟。


并行加载和计算总和(确定性方法)

可以以确定的方式并行执行加载和求和。加载两个 64 位寄存器,然后使用 CMP+CMOV 选择其中一个是一种可能性,但与顺序计算相比,它不会提高性能。其他可能性是使用 128 位寄存器和 AVX。在 128 位寄存器和 GPR/内存之间迁移数据会增加大量延迟(但如果我们每次迭代处理两个数据块,则可能会消除一半的延迟)。此外,我们需要对 AVX 寄存器使用字节对齐的内存加载(这也会增加循环延迟)。

这个想法是在 AVX 中执行所有计算,除了应该从 GPR 完成的内存加载。 (有一种替代方法可以在 AVX 中执行所有操作并在 Haswell 上使用广播+添加+收集,但它不太可能更快)。此外,将数据加载到一对 AVX 寄存器(每次迭代处理两个数据块)应该是有帮助的。这允许加载操作对部分重叠并抵消一半的额外延迟。

首先从寄存器中解压缩正确的字节:

vpshufb xmm0, xmm6, xmm0      ; clock 1

将四个位域相加:

vpand xmm1, xmm0, [mask_12]   ; clock 2 -- bitfields 1,2 ready
vpand xmm2, xmm0, [mask_34]   ; clock 2 -- bitfields 3,4 (shifted)
vpsrlq xmm2, xmm2, 4          ; clock 3 -- bitfields 3,4 ready
vpshufb xmm1, xmm5, xmm1      ; clock 3 -- sum of bitfields 1 and 2
vpshufb xmm2, xmm5, xmm2      ; clock 4 -- sum of bitfields 3 and 4
vpaddb xmm0, xmm1, xmm2       ; clock 5 -- sum of all bitfields

然后更新地址并加载下一个字节向量:

vpaddd xmm4, xmm4, [min_size]
vpaddd xmm4, xmm4, xmm1       ; clock 4 -- address + 5 + bitfields 1,2
vmovd esi, xmm4               ; clock 5..6
vmovd edx, xmm2               ; clock 5..6
vmovdqu xmm6, [esi + edx]     ; clock 7..12

然后再次重复相同的代码,只使用xmm7 而不是xmm6。在加载xmm6 时,我们可以处理xmm7

这段代码使用了几个常量:

min_size = 5, 0, 0, ...
mask_12 = 0x0F, 0, 0, ...
mask_34 = 0xF0, 0, 0, ...
xmm5 = lookup table to add together two 2-bit values

按照此处所述实现的循环需要 12 个时钟来完成并一次“跳转”两个数据块。这意味着每个数据块有 6 个周期。这个数字可能过于乐观了。我不太确定 MOVD 只需要 2 个时钟。此外,还不清楚执行未对齐内存加载的 MOVDQU 指令的延迟是多少。我怀疑当数据跨越缓存线边界时 MOVDQU 具有非常高的延迟。我想这意味着平均增加 1 个延迟时钟。所以每个数据块大约 7 个周期是更现实的估计。


使用蛮力

每次迭代只跳转一两个数据块很方便,但不会充分利用现代处理器的资源。经过一些预处理后,我们可以实现直接跳转到下一个对齐的 16 字节数据中的第一个数据块。预处理应该读取数据,计算每个字节的四个字段的总和,使用这个总和计算到下一个四字节字段的“链接”,最后沿着这些“链接”到下一个对齐的 16 字节块.所有这些计算都是独立的,可以使用 SSE/AVX 指令集以任何顺序计算。 AVX2 的预处理速度会快两倍。

  1. 使用 MOVDQA 加载 16 或 32 字节数据块。
  2. 将每个字节的 4 个位域相加。为此,使用两条 PAND 指令提取高 4 位和低 4 位半字节,使用 PSRL* 移位高半字节,使用两个 PSHUFB 求每个半字节的总和,并使用 PADDB 将两个总和相加。 (6 微秒)
  3. 使用 PADDB 计算到下一个四字段字节的“链接”:将常量 0x75、0x76、... 添加到 XMM/YMM 寄存器的字节。 (1 uop)
  4. 按照 PSHUFB 和 PMAXUB 的“链接”(PMAXUB 的更昂贵的替代方案是 PCMPGTB 和 PBLENDVB 的组合)。 VPSHUFB ymm1, ymm2, ymm2 几乎完成了所有工作。它将“越界”值替换为零。然后VPMAXUB ymm2, ymm1, ymm2 恢复原始“链接”来代替这些零。两次迭代就足够了。每次迭代后,每个“链接”的距离都是两倍大,所以我们只需要 log(longest_chain_length) 次迭代。例如,最长的链 0->5->10->15->X 一步压缩为 0->10->X,两步压缩为 0->X。 (4 微秒)
  5. 使用 PSUBB 从每个字节中减去 16,并且(仅适用于 AVX2)使用 VEXTRACTI128 将高 128 位提取到单独的 XMM 寄存器中。 (2 微秒)
  6. 现在预处理已经完成。我们可以跟随“链接”到下一个 16 字节数据中的第一个数据块。这可以通过 PCMPGTB、PSHUFB、PSUBB 和 PBLENDVB 完成。但是,如果我们为可能的“链接”值分配范围0x70 .. 0x80,则单个 PSHUFB 将正常工作(实际上是一对 PSHUFB,在 AVX2 的情况下)。值 0x70 .. 0x7F 从下一个 16 字节寄存器中选择正确的字节,而值 0x80 将跳过接下来的 16 个字节并加载字节 0,这正是需要的。 (2 uops,延迟 = 2 个时钟)

这 6 个步骤的说明不需要按顺序排列。例如,第 5 步和第 2 步的说明可能彼此相邻。每个步骤的指令应处理流水线不同阶段的 16/32 字节块,如下所示:步骤 1 处理块i,步骤 2 处理块i-1,步骤 3,4 处理块i-2,等等。

整个循环的延迟可能是 2 个时钟(每 32 字节数据)。但这里的限制因素是吞吐量,而不是延迟。当使用 AVX2 时,我们需要执行 15 个微指令,这意味着 5 个时钟。如果数据不可压缩且数据块很大,则每个数据块大约需要 3 个时钟。如果数据是可压缩的并且数据块很小,这会为每个数据块提供大约 1 个时钟。 (但由于 MOVDQA 延迟为 6 个时钟,为了每 32 个字节获得 5 个时钟,我们需要两次重叠加载并在每个循环中处理两倍的数据)。

预处理步骤独立于步骤#6。所以它们可能在不同的线程中执行。这可能会将每 32 字节数据的时间减少到 5 个时钟以下。

【讨论】:

  • 我喜欢 64 位查找表:-}
  • 是的,64位查找表很棒,我没有考虑过。仅汇编是没有问题的——让编译器产生我想要的东西是如此困难,我可能会使用大量的内联汇编。关于可压缩性:not 总和小于 7 的可能性更大。压缩后的数字很可能已经“delta 压缩”,并表示原始数字之间的差异。这种差异的平均幅度以及平均总和是无法提前知道的。你有很多想法:我会开始研究它们。
  • 我认为按照您的第三条建议的解决方案需要是无分支的。我一直在使用非常相似的方法,但加载的是 16 字节向量而不是 8 字节子集。然后问题变成了如何计算总和并从向量中提取单个“关键”字节,速度比两次查找(10 个周期)要快。到目前为止,我可以匹配这 10 个但不能做得更好:从带有“key”、PSHUFB、MOVD xmm->key 的表中查找 shuffle 操作数。我会将其作为答案提交,以便正确格式化。
  • @NathanKurz:无分支将增加 CMP+CMOV 到延迟,这意味着多 3 个周期,总共 9 个周期。等于我的第一个和第二个建议。顺便说一句,如果您的测量是正确的,这意味着优化手册是错误的,并且 SB 上的基址 + 索引寻址仅使用 4 个周期而不是 5 个。然后我的第一个和第二个建议可能会优化为 8 个周期,更新指针并加载同时。而且我认为使用 AVX/SSE 向量是一个死胡同,因为相应的指令有很大的延迟。
  • Evgeny - 我所说的无分支不是指 CMOV,只是它需要是一个没有不可预测分支的解决方案。同意 CMOV 在这里没有帮助。不确定测量值,但可以确定它们按指示的顺序变得更快,并且 IACA 报告给定的数字。但是为什么你认为这意味着负载需要 4 个周期?我刚刚又数了一下,并认为 5 个周期适合所有这些周期,一旦一个考虑到在减量完成之前继续循环的推测执行。另外,您认为哪种 AVX/SSE 延迟?
【解决方案3】:

考虑

 temp = (byte & 0x33) + ((byte >> 2) & 0x33);
 sum = (temp &7) + (temp>>4);

应该是 9 条机器指令,其中许多是并行执行的。 (OP的第一次尝试是 9 条指令加上一些未提及的动作)。

经过检查,这似乎有太多的串行依赖项 成为一个胜利者。

编辑:关于二元操作具有破坏性的讨论,LEA 避免了这种情况, 让我开始思考如何使用 LEA 组合多个操作数, 和乘以常数。上面的代码试图 right 规范化 答案右移,但我们可以通过乘法对答案进行左归一化。 有了这种洞察力,这段代码可能会起作用:

     mov     ebx,  byte      ; ~1: gotta start somewhere
     mov     ecx, ebx        ; ~2: = byte
     and     ebx, 0xCC       ; ~3: 2 sets of 2 bits, with zeroed holes
     and     ecx, 0x33       ; ~3: complementary pair of bits
     lea     edx, [ebx+4*ecx] ; ~4: sum bit pairs, forming 2 4-bit sums
     lea     edi, [8*edx+edx] ; ~5: need 16*(lower bits of edx)
     lea     edi, [8*edx+edi] ; ~6: edi = upper nibble + 16* lower nibble
     shr     edi, 4           ; ~7: right normalized
     and     edi, 0x0F        ; ~8: masked

嗯,很有趣,但仍然没有成功。 3 个时钟不是很长:-{

【讨论】:

  • 创新,谢谢!正如您所怀疑的那样,我认为依赖关系会减慢它的速度。关于副本:在汇编级别(这将编译到)“and”、“add”和“shift”都对其操作数之一具有破坏性(相当于 &=、+=、>>=)。这意味着如果您需要 'a' 和 'b' 保持不变,则 'sum = a + b' 真正实现为 'c = a; c += b' 或'c = b; c += a',除非您通过其他方式有备用副本,否则添加另一个循环。
  • 我对 x86 汇编代码非常熟悉。通常,二元运算对输入操作数之一具有破坏性。但是,LEA 指令允许您添加两个操作数(其中包含一些常数乘法)并将答案放在第三个寄存器中。不幸的是,这里没有机会使用它。
  • 我什至在不同的窗口中针对不同的问题优化 ADD to LEA,甚至没有想过在这里应用它!虽然我看不到使用它的好方法,但当然值得探索。
  • 因此,在越来越广泛的实现中,相同的周期数,每个周期处理更多。当您的版本对任何重要的用户群很重要时,Haswell(和 AMD 的响应)将会广泛传播。你应该瞄准它,而不是人们已经拥有的东西。当前的机器将在您的简单实施中运行良好; Haswell 机器会运行得更好:-}
  • @NathanKurz:老问题,但值得指出的是,您不需要 AVX2 来处理更广泛的数据,只需两个 XMM 向量。 vpshufb ymm 无论如何都是两个通道内洗牌,所以它并不比 2x vpshufb xmm 更强大,如果这就是你想要的。因此,无论您有 AVX2、AVX1 还是 SSSE3,您都可能希望将前四个 2 位字段与所有 8 位相加并行相加,以获得加载 16B 的第二个向量的偏移量(或使用 AVX2,对于 vinserti128 ymm, mem, 1 high一半),并索引 256 条目的随机控制向量表两次加载。
【解决方案4】:

我不知道它可能需要多少个周期,我可能完全不知道,但可以使用 32 位乘法对 5 个简单的运算求和:

unsigned int sum = ((((byte * 0x10101) & 0xC30C3) * 0x41041) >> 18) & 0xF;

第一次乘法重复位模式

abcdefgh -> abcdefghabcdefghabcdefgh

第一位并且每6位保持一对:

abcdefghabcdefghabcdefgh -> 0000ef0000cd0000ab0000gh

第二次乘法对位模式求和(仅对 yyyy 感兴趣)

                     0000ef0000cd0000ab0000gh
             + 0000ef0000cd0000ab0000gh000000
       + 0000ef0000cd0000ab0000gh000000000000
 + 0000ef0000cd0000ab0000gh000000000000000000
 --------------------------------------------
   ..................00yyyy00................

最后 2 个操作将 yyyy 向右移动并剪切左侧部分

主要问题是操作是顺序的......

编辑

或者只是将整个内容向左平移 10 位并删除最后一位:

unsigned int sum = (((byte * 0x4040400) & 0x30C30C00) * 0x41041) >> 28;

【讨论】:

  • 美丽而有前途的方法,但我不确定我们是否可以快速实现。 “MUL”和“IMUL”(无符号和有符号乘法)具有 3 个周期的延迟。因此,如果我们有两个串行乘法,我认为我们已经处于 6 个周期。我还不确定“zzz”是什么意思,但它可能与此有关。他分别加载寄存器的高 8 位和低 8 位,这可能是创建类似排列的另一种方式。也可能不是。
【解决方案5】:

这里有很多很棒的想法,但在讨论中很难找到它们。让我们使用这个答案来提供最终解决方案及其时间安排。请随时编辑此帖子并添加您自己的帖子以及时间。如果不确定底部代码中的时间粘贴,我会测量它。 x64 程序集最好。我会很高兴地编译 C,但如果不进行大量调整,很少会在这种优化级别上获得好的结果。

概述

重新表述问题以将其置于适当的上下文中:目标是快速解码以“Varint-GB”(或 Group Varint)已知的整数压缩格式。在其他地方,它在paper by Daniel Lemire and Leo Boytsov. 中进行了描述。我在这篇论文的第一个版本中制作了标准的“显然作者是个白痴”风格的 cmets,Daniel(论文的主要作者,而不是一个白痴)狡猾地拉拢我来帮助编写后续代码.

标准 Varint(又名 VByte)在每个字节的开头都有一个标志,用于确定它是否是整数的结尾,但这解析起来很慢。这个版本有一个单字节“密钥”,然后是 4 个压缩整数的有效载荷。密钥由 4 个 2 位字段组成,每个字段代表后面压缩整数的字节长度。每个可以是 1 字节 (00)、2 字节 (01)、3 字节 (10) 或 4 字节 (11)。因此,每个“块”的长度为 5 到 17 个字节,但始终编码相同数量 (4) 的 32 位无符号整数。

Sample Chunk:
  Key:  01 01 10 00  
  Data: [A: two bytes] [B: two bytes] [C: three bytes] [D: one byte]
Decodes to: 00 00 AA AA   00 00 BB BB   00 CC CC CC  00 00 00 DD

键是 16 字节混洗模式表的索引,实际解码是通过使用 PSHUFB 将数据字节混洗到正确的间距来完成的。

vec_t Data = *input
vec_t ShuffleKey = decodeTable[key]     
VEC_SHUFFLE(Data, ShuffleKey) // PSHUFB
*out = Data

实际上,通常还有一个“增量解码”步骤,因为原始整数通常是通过压缩整数之间的“增量”(差异)而不是整数本身来缩小的。但是解码例程的延迟通常并不重要,因为下一次迭代不依赖于它。

问题重述

这里指定的问题是从一个“键”跳到下一个。由于这里对解码的数据没有依赖关系(仅对密钥),我将忽略实际的解码,只专注于读取密钥的循环。该函数接受一个指向键的指针和一个计数 n,并返回第 n 个键。

11 个周期

“基本”方法是使用以键为索引的“高级”偏移量查找表。在 offsetTable 中查找 256 个键中的任何一个,以获得预先计算的 (sum + 1) 偏移量。将其添加到当前输入位置,然后读取下一个键。根据英特尔的 IACA,此循环在 Sandy Bridge 上需要 11 个周期(在 Sandy Bridge 上也需要一个周期)。

uint32_t decodeBasic(uint8_t *in, size_t count) {
    uint64_t key, advance;
    for (size_t i = count; i > 0; i--) {
        key = *in;
        advance = offsetTable[key];
        in += advance;
    }
    return key;
}

0000000000000000 <decodeBasic>:
   0:   test   %rsi,%rsi
   3:   je     19 <decodeBasic+0x19>
   5:   nopl   (%rax)
   8:   movzbl (%rdi),%eax
   b:   add    0x0(,%rax,8),%rdi
  13:   sub    $0x1,%rsi
  17:   jne    8 <decodeBasic+0x8>
  19:   repz retq 

Block Throughput: 11.00 Cycles       Throughput Bottleneck: InterIteration
   0  - DV  |  1  |  2  -  D  |  3  -  D  |  4  |  5  |    |
--------------------------------------------------------------
|           |     | 1.0   1.0 |           |     |     | CP | movzx eax, byte ptr [rdi]
| 0.3       | 0.3 |           | 1.0   1.0 |     | 0.3 | CP | add rdi, qword ptr [rax*8]
|           |     |           |           |     | 1.0 |    | sub rsi, 0x1
|           |     |           |           |     |     |    | jnz 0xffffffffffffffe7

10 个周期

从那里,我们可以通过重新安排循环来减少 10 个循环,以便我们添加更新输入指针并同时开始加载下一个键。您可能会注意到我必须使用内联汇编来“鼓励”编译器生成我想要的输出。我还将开始删除外循环,因为它(通常)保持不变。

key = *in;
advance = offsetTable[key]; 
for (size_t i = count; i > 0; i--) {
    key = *(in + advance);
    ASM_LEA_ADD_BASE(in, advance);
    advance = offsetTable[key];
}

Block Throughput: 10.00 Cycles       Throughput Bottleneck: InterIteration
|  0  - DV  |  1  |  2  -  D  |  3  -  D  |  4  |  5  |    |
------------------------------------------------------------
|           |     | 1.0   1.0 |           |     |     | CP | movzx eax, byte ptr [rdi+rdx*1]
| 0.5       | 0.5 |           |           |     |     |    | lea rdi, ptr [rdi+rdx*1]
|           |     |           | 1.0   1.0 |     |     | CP | mov rdx, qword ptr [rax*8]
|           |     |           |           |     | 1.0 |    | sub rsi, 0x1
|           |     |           |           |     |     |    | jnz 0xffffffffffffffe2

9 个周期

我之前曾尝试使用 POPCNT,但没有来自 Ira 和 AShelly 的建议、提示和想法,我运气不佳。但是把这些部分放在一起,我想我有一些东西可以在 9 个周期内运行循环。我已经将它放入实际的解码器中,Ints/s 的数量似乎与此一致。这个循环本质上是在汇编中,因为我无法让编译器做我想做的事情,更不用说多个编译器了。

[编辑:AShelly 的每条评论删除了额外的 MOV]

uint64_t key1 = *in;
uint64_t key2 = *in;
for (size_t i = count; i > 0; i--) {
    uint64_t advance1, advance2;
    ASM_POPCOUNT(advance1, key1);
    ASM_AND(key2, 0xAA);

    ASM_POPCOUNT(advance2, key2);
    in += advance1;

    ASM_MOVE_BYTE(key1, *(in + advance2 + 4));
    ASM_LOAD_BASE_OFFSET_INDEX_MUL(key2, in, 4, advance2, 1);        
    in += advance2;
 }


Block Throughput: 9.00 Cycles       Throughput Bottleneck: InterIteration
|  0  - DV  |  1  |  2  -  D  |  3  -  D  |  4  |  5  |    |
------------------------------------------------------------
|           | 1.0 |           |           |     |     | CP | popcnt r8, rax
| 1.0       |     |           |           |     |     | CP | and rdx, 0xaa
|           |     |           |           |     | 1.0 | CP | add r8, rdi
|           | 1.0 |           |           |     |     | CP | popcnt rcx, rdx
|           |     | 1.0   1.0 |           |     |     | CP | movzx rax, byte ptr [rcx+r8*1+0x4]
|           |     |           | 1.0   1.0 |     |     | CP | mov rdx, qword ptr [r8+rcx*1+0x4]
| 1.0       |     |           |           |     |     |    | lea rdi, ptr [rcx+r8*1]
|           |     |           |           |     | 1.0 |    | dec rsi
|           |     |           |           |     |     |    | jnz 0xffffffffffffffd0

为了说明现代处理器中移动部件的复杂性,我对这个例程的变体有过一次有趣的经历。如果我通过用 and (mov rax, 0xAA; and rax, qword ptr [r8+rcx*1+0x4]) 指定内存位置来将第二行 mov raxand rax, 0xaa 组合在一起,我最终会得到一个运行 30% 摇摆不定的例程。我认为这是因为有时导致循环的初始条件会导致加载/和的“和”微操作在整个循环的 POPCNT 之前运行。

8 个周期

有人吗?

叶夫根尼

这是我实施 Evgeny 解决方案的尝试。我还不能把它降到 9 个周期,至少对于 IACA 的 Sandy Bridge 模型(到目前为止它是准确的)。我认为问题在于,虽然 ROR 的延迟为 1,但在 P1 或 P5 上需要两个微操作。要获得 1 的延迟,两者都必须可用。其他只是一个微操作,因此延迟始终为 1。AND、ADD 和 MOV 可以在 P0、P1 或 P5 上发出,但 SHR 不能在 P1 上发出。我可以通过添加一些额外的垃圾操作来接近 10 个周期,以防止 ADD 和 AND 取代 SHR 或 ROR,但我不确定如何低于 10。

Block Throughput: 10.55 Cycles       Throughput Bottleneck: InterIteration
|  0  - DV  |  1  |  2  -  D  |  3  -  D  |  4  |  5  |    |
------------------------------------------------------------
|           |     | 1.0   1.0 |           |     |     | CP | movzx eax, byte ptr [esi+0x5]
|           |     |           | 1.0   1.0 |     |     | CP | movzx ebx, byte ptr [esi+0x5]
| 0.2       | 0.6 |           |           |     | 0.3 |    | add esi, 0x5
| 0.3       | 0.3 |           |           |     | 0.3 |    | mov ecx, 0x3
| 0.2       | 0.2 |           |           |     | 0.6 |    | mov edx, 0x3
| 1.4       |     |           |           |     | 0.6 | CP | ror al, 0x4
| 0.1       | 0.7 |           |           |     | 0.2 | CP | and ecx, ebx
| 0.6       |     |           |           |     | 0.4 | CP | shr ebx, 0x6
| 0.1       | 0.7 |           |           |     | 0.2 | CP | add ebx, ecx
| 0.3       | 0.4 |           |           |     | 0.3 | CP | and edx, eax
| 0.6       |     |           |           |     | 0.3 | CP | shr eax, 0x6
| 0.1       | 0.7 |           |           |     | 0.2 | CP | add eax, edx
| 0.3       | 0.3 |           |           |     | 0.3 | CP | add esi, ebx
| 0.2       | 0.2 |           |           |     | 0.6 | CP | add esi, eax

【讨论】:

  • ASM_COPY(key2, 0xAA) 有什么用?看起来 key2 被覆盖了 3 行后没有使用。
  • 好收获。那是我提到的将 mov/and 组合成 and reg, ptr [mem] 的摇摆不定的版本的遗留物。此版本不需要它。我会删除。
  • 我试图在 Linux 上使用 IACA 和 gcc 来试验这个。你介意分享你的ASM_... 宏吗?我不能完全得到相同的输出。
  • AShelly - 我应该提到,虽然我已经能够让 GCC 和 ICC 生成带有宏的程序集,但我通常无法获得完全正确的 IACA_START 和 IACA_END无需手动编辑“output.s”的位置。将“-save-temps”添加到您的编译器选项以保存它,然后使用“gcc -c output.s -o output.o”重新组装,然后在其上运行“iaca.sh”。但是一旦你这样做了,跳过我的 ASM 宏并直接编辑“output.s”文件可能会更简单。
  • offsetTable 可以是一个由uint8_t 组成的表,它会加载movzx。除了节省 L1d 缓存占用空间(和一点点代码大小)之外,这并不能使它更快。此外,这种未链接目标文件的反汇编令人困惑,因为[rax*8] 寻址模式省略了[offsetTable + rax*8] 部分。对于尚未由链接器填充的占位符 disp32=0如何解码它([rax*8] 确实需要占位符 disp32=0),但您可以使用 objdump -drwC 来获取符号信息,-Mintel 获取 Intel 语法。 (但很可能现在不值得更新:/)
【解决方案6】:
  mov al,1
  mov ah,2
  mov bl,3
  mov bh,4
  add ax,bx
  add al,ah

【讨论】:

  • 我添加了一个编辑以要求澄清,但尚未通过。我研究了你的例子,但不确定如何处理结果。你能澄清一下吗?是否与 aka.nice 提议的内容有关?
猜你喜欢
  • 2012-08-21
  • 2023-02-24
  • 2020-10-17
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2012-11-16
  • 1970-01-01
  • 2021-04-05
相关资源
最近更新 更多