如何将一个8位字节中的四个2位二进制字段相加?

13

我有四个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上花费5个周期:
假设位域是:d c b a,掩码是:00 00 00 11 通过Ira的帮助澄清:这假定、 和 是相同的,并且已经设置为初始的字节。奇怪的是,我认为我可以免费做到这一点。因为我每个周期可以进行2次加载,所以我只需加载四次“byte”:第一个周期加载a和d,第二个周期加载b和c。后两次加载将被延迟一个周期,但我不需要它们直到第二个周期。下面的拆分表示事物应该如何分成单独的周期。
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所指出的那样,只有在不使用索引寄存器从系统内存的前2K加载时才会有间接的4个周期负载。 可以在英特尔体系结构优化手册第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中,这必然会带来一些成本。 - Ira Baxter
我假设你知道字节是非零的。在处理之前,你可以通过测试来确认这一点。或许你可以在计算总和之后再进行测试,但关键是你必须进行测试以退出循环。所以,也许问题在于找到总和,并且知道它不为零。 - Ira Baxter
1
那么字节数是位域值加1吗?如果我理解正确,“00代表1个字节,11代表3个字节”似乎是错误的。如果我理解正确,我会期望11代表4个字节。我认为Evgeny是对的:为什么不发布整个循环呢?在现代CPU上计算周期非常困难。您是否尝试使用替代解决方案修改循环以查看实际效果? - Ira Baxter
Ira --- 又是我今晚犯的一个愚蠢的错误。11确实是4个字节,就像你所期望的那样。计算周期确实很难,但我应该坐下来正确地实现它。我大多数时候使用Intel的IACA(如果你不熟悉它,这是一个非常有用的工具)基于静态分析来计算周期,然后针对整个解码器进行运行时检查。是的,我已经实现了一堆这样的东西-就在昨晚我快要睡着之前,我想我成功地将popcnt()方法降到了9个循环周期。更多内容即将呈现。 - Nathan Kurz
在现代的Intel x64 CPU上,一个8位的查找表需要4个周期才能从L1返回一个答案。L1的延迟是4个周期,这是在什么情况下?你自己的逐周期分析显示两个加载操作完成的延迟只有1个周期。"似乎应该有一些方法可以比这更快地计算出答案来。"也许可以通过使用SSE寄存器作为16个条目的查找表来实现,但是四个周期并不多。 - Potatoswatter
显示剩余9条评论
6个回答

6

内置POPCOUNT指令是否有帮助?

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

或者,也许。
  word = byte + ((byte&0xAA) << 8);
  n = POPCOUNT(word);

时间总数不确定。 这个讨论 表示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

还有两个想法

一种可能的微优化方法,在两个指令中完成求和操作。

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>>16; ADD AL,AH的简单移位和加法替换字节级加总加法。

宏优化:
您提到将密钥用作查找洗牌指令表中的关键字。为什么不只需存储到下一个密钥的距离以及洗牌指令即可?为了存储更大的表格或可能将4位长度挤入洗牌键的未使用位3-6中,需要使用掩码来提取它。


3
聪明。第二个方案的一个变体:POPCOUNT(byte)+POPCOUNT(byte&AA)?至少两个POPCOUNT可以在某种程度上同时执行。如果POPCOUNT的延迟是3个周期,那么他将需要4-5个周期,因此这也不太可行。 - Ira Baxter
Popcount 对于更大的位集(比如,10 组 3 位、16 组 4 位或者如果你坚持的话,32 组 2 位)非常适用。虽然循环时间不会改善,但你摊销了成本,所以在更大的组上获得了明显的加速,这应该是倍数而不是百分比。 - Ira Baxter
好消息有两个:我能够使用IACA重现您的结果,而且我甚至可以通过仅使用64位寄存器将其降低到声称的3.15个周期,因此我可以跳过cdqemovsxd。 现在是坏消息:正如您可能预期的那样,这几乎肯定是IACA的错误。 由于负载需要5个周期,3.x似乎不可能。 如果我交换LEA和MOVZX的顺序(调整负载地址),则会得到预期的9个周期。 我认为您的用法是正确的,并担心该工具存在缺陷。 如果您想报告此问题,英特尔在其帮助论坛上非常负责任。 - Nathan Kurz
可能是IACA中的一个错误,但我可以始终获得报告吞吐量在低3左右和延迟约为8的变化。这让我想到,单次传递的吞吐量可能不是最好的指标,这可能是一个过于人工的测试用例。你说解码的延迟不重要,但至少需要将密钥传递给解码器,对吧?你不应该在你的测试用例中包含这个机制吗?微调需要添加更多指令的循环似乎有些奇怪。使用更多代码可以提供更多重新排列事物的机会... - AShelly
除了错误之外,IACA 的吞吐量是基于一个假设代码周围有一个稳定状态的循环。知道加载的延迟为5,而且循环的其余部分都依赖于加载,我确信报告的吞吐量是一个错误。是的,我可能应该从完整的循环开始。我希望更一般的位字段求和问题会更容易解决,但我不确定那是正确的选择。我在测试时已经添加了解码,并且到目前为止它还没有影响到周期计数。 - Nathan Kurz
显示剩余7条评论

5
其他答案提出了各种将单个变量中的值相加的方法(无需解包)。虽然这些方法在吞吐量上表现良好(特别是使用POPCNT),但它们具有很大的延迟,要么是因为长计算链,要么是因为使用高延迟指令。
最好使用普通的加法指令(一次将一对值相加),使用简单的操作如掩码和移位来分割这些值,并使用指令级并行性来高效地完成此操作。同时,在字节中两个中间值的位置提示了一种使用单个64位寄存器而不是内存的表查找变体。所有这些都可以加快计算四个值的总和并仅使用4或5个时钟周期。
原始的表查找方法建议在OP中包含以下步骤:
1. 从内存加载带有四个值的字节(5个时钟周期) 2. 使用查找表计算值的总和(5个时钟周期) 3. 更新指针(1个时钟周期)
64位寄存器查找
以下代码段展示了如何在5个时钟周期内执行第2步,并在保持延迟为5个时钟周期的情况下组合第2步和第3步(可以通过复杂的寻址模式优化到4个时钟周期以进行内存加载):
p += 5 + (*p & 3) + (*p >> 6) +
  ((0x6543543243213210ull >> (*p & 0x3C)) & 0xF);

这里的常数“5”表示我们跳过当前字节和4个数据字节,这些字节对应于全零长度。该代码片段对应于以下代码(仅限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 上需要多一次时钟,使它不是很有用。可能它在 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加载内存以外。 (在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位半字节,使用PSRL*移动高半字节,使用两个PSHUFB找到每个半字节的和,并使用PADDB添加两个和。(6 uops)
  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 uops)
  5. 使用PSUBB从每个字节中减去16,(仅适用于AVX2)使用VEXTRACTI128将高128位提取到单独的XMM寄存器中。(2 uops)
  6. 现在预处理完成。我们可以跟随“链接”到下一个16字节数据块中的第一个数据块。这可能需要使用PCMPGTB、PSHUFB、PSUBB和PBLENDVB来完成。但是,如果我们为可能的“链接”值分配范围0x70 .. 0x80,一个PSHUFB就可以正确地完成工作(实际上,在AVX2的情况下是一对PSHUFB)。值0x70 .. 0x7F选择下一个16字节寄存器中的正确字节,而值0x80将跳过下一个16字节并加载字节0,这正是所需的。(2 uops, latency = 2 clocks)

这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位查找表 :-} - Ira Baxter
是的,64位查找表非常好,我之前没有考虑过。仅使用汇编不是问题--编译器很难产生我想要的代码,所以我可能会经常使用内联汇编。关于可压缩性:小于7的和更可能出现的情况将_不会_发生。压缩后的数字可能已经进行了“增量压缩”,表示原始数字之间的差异。这些差异的平均值,因此平均和,事先是未知的。你有很多想法:我会开始逐个尝试。 - Nathan Kurz
@NathanKurz:无分支将增加CMP+CMOV的延迟,这意味着多3个周期,总共9个周期。与我的第一和第二个建议相同。顺便说一句,如果您的测量结果正确,这意味着优化手册是错误的,并且SB上的基址+索引寻址仅使用4个周期而不是5个周期。然后,我的第一和第二个建议可以通过同时更新指针和加载来进行优化,从而缩短到8个周期。我认为使用AVX/SSE向量是一条死路,因为相应的指令具有较大的延迟。 - Evgeny Kluev
Evgeny - 我所说的无分支不是指CMOV,而是需要一种没有不可预测分支的解决方案。同意在这里使用CMOV没有帮助。我不确定测量结果,但确定它们按照指定顺序变快,并且IACA报告给出的数字是正确的。但是你为什么认为这意味着加载需要4个周期?我再次计算了一下,认为5个周期适用于所有加载,一旦考虑到推测执行在减量完成之前继续循环。此外,你考虑的是哪个AVX/SSE延迟? - Nathan Kurz
根据我在原始答案中引用的手册,我认为[base]、[base + index]和[base + index + offset]所需时间相同:GPR/8B需要5个周期,XMM/16B需要6个周期,YMM/32B需要7个周期。在Sandy Bridge和Haswell上,英特尔手册称从XMM/YMM到GPR的数据需要2个周期,但仅需要1个周期从GPR加载XMM/YMM(表C-12a)。Agner的文档说两者都是1,但我认为他错了。这可能有点过头了,但我认为我们需要加载所有16个字节。我很确定这是Sandy Bridge E5-1620。虽然不是我的机器,但我是否可以看看是否能让您访问它? - Nathan Kurz
显示剩余8条评论

4

考虑以下事项:

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

应该有9条机器指令,其中许多指令是并行执行的。(OP的第一个尝试是9条指令加上一些未提及的移动操作。)
经检查,这似乎有太多的串行依赖关系,无法获得胜利。
编辑:关于二进制操作具有破坏性以及LEA避免这种情况的讨论,让我想到如何使用LEA来组合多个操作数和常数乘法。上述代码尝试通过右移来实现正确归一化答案,但我们可以通过乘法来左归一化答案。有了这个见解,这段代码可能会起作用:
     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',除非你有其他方式的备用副本,否则会增加另一个周期。 - Nathan Kurz
我对x86汇编代码非常熟悉。通常,二进制操作会破坏其中一个输入操作数。然而,LEA指令允许您添加两个操作数(加上一些常量乘法),并将答案放置在第三个寄存器中。不幸的是,在这里没有使用该指令的机会。 - Ira Baxter
我甚至在另一个窗口为另一个问题优化ADD到LEA,甚至没有想到在这里应用它!虽然我看不出有什么好的方法可以利用它,但肯定值得探索。 - Nathan Kurz
1
因此,在广泛实施的情况下,同一周期计数,每个周期处理的数据更多。等到您的版本对任何重要的用户集合都有影响时,Haswell(以及AMD的回应)将会普及。您应该瞄准这一点,而不是针对人们已经拥有的东西。当前机器将能够很好地运行您简单的实现;Haswell 机器将运行得更好 :-} - Ira Baxter
1
@NathanKurz:虽然这是一个老问题,但值得指出的是,您不需要AVX2来处理更宽的数据,只需要两个XMM向量即可。vpshufb ymm本质上是两个内部通道的洗牌,因此如果您想要的是2x vpshufb xmm,那么它并不比后者更强大。因此,无论您是否拥有AVX2、AVX1或SSSE3,您都可能希望在并行计算前四个2位字段的总和以及所有8位字段的总和时获得用于加载第二个16B向量(或使用AVX2时,用于vinserti128 ymm,mem,1高半部分)的偏移量,并索引256个条目的洗牌控制向量两次进行加载。 - Peter Cordes
显示剩余3条评论

3

我不知道这可能需要多少个周期,我可能完全错了,但是可以使用32位乘法进行5个简单的操作求和:

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

第一个乘法重复位模式。
abcdefgh -> abcdefghabcdefghabcdefgh

第一个 bitand 操作每 6 个比特保留一对:

abcdefghabcdefghabcdefgh -> 0000ef0000cd0000ab0000gh

第二个乘法将位模式相加(只考虑yyyy)
                     0000ef0000cd0000ab0000gh
             + 0000ef0000cd0000ab0000gh000000
       + 0000ef0000cd0000ab0000gh000000000000
 + 0000ef0000cd0000ab0000gh000000000000000000
 --------------------------------------------
   ..................00yyyy00................

最后两次运算将yyyy向右移位,并切掉左侧部分。主要问题在于这些运算是连续的...
编辑:或者将整个内容向左移动10位并删除最后一位。
unsigned int sum = (((byte * 0x4040400) & 0x30C30C00) * 0x41041) >> 28;

优美而有前途的方法,但我不确定我们能否使其快速。'MUL'和'IMUL'(无符号和有符号乘法)的延迟为3个周期。因此,如果我们有两个串行乘法,我认为我们已经达到了6个周期。我还不确定'zzz'在暗示什么,但这可能与此有关。他分别加载寄存器的高8位和低8位,这可能是创建类似排列的另一种方式。或者也可能不是。 - Nathan Kurz

2

这里有很多好的想法,但是在讨论中很难找到它们。让我们使用这个答案来提供最终方案以及它们的时间安排。请随意编辑此帖并添加您自己的时间安排。如果不确定时间,请将代码粘贴在底部,我会进行测量。x64汇编语言最好。我可以愉快地编译C语言,但是在这个优化级别上很少有好的结果,需要大量调整。

概述

重新构思问题,将其放入适当的背景下:目标是快速解码称为“Varint-GB”(或组Varint)的整数压缩格式。它在其他地方被描述,在Daniel Lemire和Leo Boytsov的文章中。我对第一个版本的评论进行了评论,“显然作者是个白痴”,但是丹尼尔(本文的主要作者,不是那么愚蠢)狡猾地让我帮助编写后续内容。

标准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

实际上,通常还需要进行“delta解码”步骤,因为原始整数通常通过压缩整数之间的“delta”(差异),而不是整数本身来使其变小。但解码例程的延迟通常并不重要,因为下一次迭代不依赖于它。
问题的重新表述
这里指定的问题是从一个“关键字”跳到下一个。由于此处对解码数据没有任何依赖(仅对关键字有依赖),因此我将忽略实际解码并只集中于循环读取关键字。该函数接受一个关键字指针和计数n,并返回第n个关键字。
11个周期
“基本”方法是使用一个查找表,其中包含以关键字为索引的“advance”偏移量。查找offsetTable中的任何256个关键字以获取预先计算的(总和+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和AShelley的建议、提示和想法,我没有什么运气。但是将这些部分组合在一起,我认为我有一个可以在9个周期内运行循环的东西。我已经把它放到实际的解码器中,每秒整数的数量似乎与此一致。由于我无法让编译器按照我想要的方式执行操作,更不用说多个编译器了,所以这个循环本质上是汇编语言。

[编辑:根据AShelley的评论删除了额外的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

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

8个周期

有人知道原因吗?

Evgeny

这是我尝试实现Evgeny解决方案的结果。我还没有能够将其降至9个周期,至少对于IACA的Sandy Bridge模型(迄今为止准确)而言如此。我认为问题在于,虽然ROR的延迟为1,但在P1或P5上需要两个微操作。要获得延迟为1,必须同时可用。其他操作只有一个微操作,因此始终具有延迟为1。AND、ADD和MOV可以在P0、P1或P5上发出,但SHR不能在P1上发出。通过添加一些额外的无用操作,可以防止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行后被覆盖了,却没有被使用。 - AShelly
好的,很棒。那是我提到的摇摆版本中剩下的部分,将mov/and组合成了and reg,ptr [mem]。这个版本不需要它。我会把它删除。 - Nathan Kurz
我正在尝试在Linux上使用gcc和IACA进行实验。你介意分享一下你的“ASM_…”宏吗?我无法得到相同的输出。 - AShelly
AShelly - 我应该提到,虽然我已经能够让GCC和ICC生成带有宏的汇编代码,但通常情况下,如果没有手动编辑“output.s”,我就无法在恰当的位置放置IACA_START和IACA_END。将“-save-temps”添加到编译器选项中以保存此内容,然后使用“gcc -c output.s -o output.o”重新汇编,然后在其上运行“iaca.sh”。但是一旦你这样做了,直接编辑“output.s”文件可能会更简单。 - Nathan Kurz
offsetTable 可以是一个 uint8_t 表,使用 movzx 加载。这并不会使它更快,除了节省 L1d 缓存占用(和一点代码大小)。此外,未链接的目标文件的反汇编很令人困惑,因为 [rax*8] 寻址模式省略了 [offsetTable + rax*8] 部分。带有占位符 disp32=0,尚未由链接器填充,这就是如何解码它的方式(而 [rax*8] 确实需要一个占位符 disp32=0),但您可以使用 objdump -drwC 获取符号信息,并使用 -Mintel 获取 Intel 语法。(但现在可能不值得更新 :/) - Peter Cordes

0
  mov al,1
  mov ah,2
  mov bl,3
  mov bh,4
  add ax,bx
  add al,ah

我添加了一个编辑以请求澄清,但尚未得到回复。我已经按照您的示例进行了操作,但不确定如何处理结果。您能否澄清一下?这是否与aka.nice提出的相关? - Nathan Kurz

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接