将 BCD 打包成 DPD:如何改进这个 amd64 汇编例程?

Packing BCD to DPD: How to improve this amd64 assembly routine?

我正在编写一个在 BCD (4 bits per decimal digit) and Densely Packed Decimal (DPD) (10 bits per 3 decimal digits). DPD is further documented (with the suggestion for software to use lookup-tables) on Mike Cowlishaw's web site 之间进行转换的例程。


这个例程只需要它使用的寄存器的低 16 位,但为了缩短指令编码,我尽可能使用 32 位指令。是否与以下代码相关联的速度惩罚:

mov data,%eax # high 16 bit of data are cleared
...
shl %al
shr %eax

and [=11=]x888,%edi         #   = 0000 a000 e000 i000
imul [=11=]x0490,%di        #   = aei0 0000 0000 0000

其中 16 位 imul 的替代方案是 32 位 imul 和后续 and 或一系列 lea 指令和最终 and.

我例程中的全部代码可以在下面找到。由于我混合了字指令和双字指令,其中是否有任何性能比它更差的地方?

        .section .text
        .type bcd2dpd_mul,@function
        .globl bcd2dpd_mul

        # convert BCD to DPD with multiplication tricks
        # input abcd efgh iklm in edi
        .align 8
bcd2dpd_mul:
        mov %edi,%eax           #   = 0000 abcd efgh iklm
        shl %al                 #   = 0000 abcd fghi klm0
        shr %eax                #   = 0000 0abc dfgh iklm
        test [=12=]x880,%edi        # fast path for a = e = 0
        jz 1f

        and [=12=]x888,%edi         #   = 0000 a000 e000 i000
        imul [=12=]x0490,%di        #   = aei0 0000 0000 0000
        mov %eax,%esi
        and [=12=]x66,%esi          # q = 0000 0000 0fg0 0kl0
        shr ,%edi            # u = 0000 0000 0000 0aei
        imul tab-8(,%rdi,4),%si # v = q * tab[u-2][0]
        and [=12=]x397,%eax         # r = 0000 00bc d00h 0klm
        xor %esi,%eax           # w = r ^ v
        or tab-6(,%rdi,4),%ax   # x = w | tab[u-2][1]
        and [=12=]x3ff,%eax         #   = 0000 00xx xxxx xxxx
1:      ret

        .size bcd2dpd_mul,.-bcd2dpd_mul

        .section .rodata
        .align 4
tab:
        .short 0x0011 ; .short 0x000a
        .short 0x0000 ; .short 0x004e
        .short 0x0081 ; .short 0x000c
        .short 0x0008 ; .short 0x002e
        .short 0x0081 ; .short 0x000e
        .short 0x0000 ; .short 0x006e
        .size tab,.-tab

改进代码

在应用了答案和评论中的一些建议以及其他一些技巧之后,这是我改进的代码。

        .section .text
        .type bcd2dpd_mul,@function
        .globl bcd2dpd_mul

        # convert BCD to DPD with multiplication tricks
        # input abcd efgh iklm in edi
        .align 8
bcd2dpd_mul:
        mov %edi,%eax           #   = 0000 abcd efgh iklm
        shl %al                 #   = 0000 abcd fghi klm0
        shr %eax                #   = 0000 0abc dfgh iklm
        test [=13=]x880,%edi        # fast path for a = e = 0
        jnz 1f
        ret

        .align 8
1:      and [=13=]x888,%edi         #   = 0000 a000 e000 i000
        imul [=13=]x49,%edi         #   = 0ae0 aei0 ei00 i000
        mov %eax,%esi
        and [=13=]x66,%esi          # q = 0000 0000 0fg0 0kl0
        shr ,%edi             #   = 0000 0000 0ae0 aei0
        and [=13=]xe,%edi           #   = 0000 0000 0000 aei0
        movzwl lookup-4(%rdi),%edx
        movzbl %dl,%edi
        imul %edi,%esi          # v = q * tab[u-2][0]
        and [=13=]x397,%eax         # r = 0000 00bc d00h 0klm
        xor %esi,%eax           # w = r ^ v
        or %dh,%al              #   = w | tab[u-2][1]
        and [=13=]x3ff,%eax         #   = 0000 00xx xxxx xxxx
        ret

        .size bcd2dpd_mul,.-bcd2dpd_mul

        .section .rodata
        .align 4
lookup:
        .byte 0x11
        .byte 0x0a
        .byte 0x00
        .byte 0x4e
        .byte 0x81
        .byte 0x0c
        .byte 0x08
        .byte 0x2e
        .byte 0x81
        .byte 0x0e
        .byte 0x00
        .byte 0x6e
        .size lookup,.-lookup

TYVM 清楚地很好地注释了代码,顺便说一句。这使得弄清楚发生了什么以及这些位的去向变得非常容易。我以前从未听说过 DPD,所以从未注释的代码和维基百科文章中弄清楚它会很糟糕。


相关陷阱是:

  • 在 Intel CPUs 上避免使用 16 位操作数大小的立即数指令。 (LCP 摊位)
  • 避免在 Intel pre-IvyBridge 上仅写入低 8 位或 16 位后读取完整的 32 位或 64 位寄存器。 (部分注册额外的 uop)。 (如果你修改像 AH 这样的 upper8 reg,IvB 仍然会减速,但 Haswell 也将其删除)。根据 Agner Fog 的说法,这不仅仅是 只是 一个额外的 uop:the penalty on Core2 is 2 to 3 cycles。我可能测量错了,但在 SnB 上似乎没那么糟糕。

有关详细信息,请参阅 http://agner.org/optimize/

除此之外,使用操作数大小前缀混合某些指令以使其成为 16 位没有一般问题。


你也许应该将它写成内联汇编,而不是被调用的函数。您只使用几个寄存器,快速路径情况下的指令很少。


我查看了代码。我没有考虑用截然不同的逻辑实现相同的结果,只是在优化你已有的逻辑。


可能的代码建议:切换分支,使快速路径具有未采用的分支。实际上,在这种情况下,它可能不会产生任何差异,或者可能会改进慢速路径代码的对齐。

.p2align 4,,10   # align to 16, unless we're already in the first 6 bytes of a block of 16
bcd2dpd_mul:
        mov %edi,%eax           #   = 0000 abcd efgh iklm
        shl %al                 #   = 0000 abcd fghi klm0
        shr %eax                #   = 0000 0abc dfgh iklm
        test [=10=]x880,%edi        # fast path for a = e = 0
        jnz .Lslow_path
        ret

.p2align 4    # Maybe fine-tune this alignment based on how the rest of the code assembles.    
.Lslow_path:

        ...
        ret

有时复制 return 指令比绝对最小化代码大小更好。不过,在这种情况下,比较和分支是函数的第 4 微指令,因此采用的分支不会阻止在第一个时钟周期中发出 4 微指令,并且正确预测的分支仍会发出 return在第二个时钟周期。


对于 table 源代码,您应该使用 32 位 imul。 (请参阅下一节关于对齐 table 以便读取额外的 2B 是可以的)。 32 位 imul 在 Intel SnB 系列微架构上是一个 uop 而不是两个。 low16 中的结果应该是相同的,因为无法设置符号位。 upper16 在 ret 之前被最后的 and 清零,并且不会以任何方式使用 upper16 中的垃圾很重要。

不过,您的 imul 与立即操作数是有问题的。

它在 Intel 上解码时导致 LCP 停止,它写入一个寄存器的低 16 位,稍后以全宽度读取。如果不屏蔽它的 upper16 成为一个问题(因为它被用作 table 索引)。它的操作数足够大,将垃圾放入 upper16,所以它确实需要被丢弃。

我认为你的做法对于某些架构来说是最佳的,但事实证明 imul r16,r16,imm16 本身在除了 VIA Nano、AMD K7 之外的所有架构上都比 imul r32,r32,imm32 慢(速度更快)比 imul32) 和 Intel P6(从 32 位/64 位模式使用它会导致 LCP 停顿,并且部分调节速度变慢是一个问题)。

在英特尔 SnB 系列 CPUs 上,其中 imul r16,r16,imm16 是两个微指令,imul32/movzx 会更好,除了代码大小之外没有任何缺点。在 P6 系列 CPUs(即 PPro 到 Nehalem)上,imul r16,r16,imm16 是一个 uop,但那些 CPUs 没有 uop 缓存,因此 LCP 停顿可能很关键(除了可能 Nehalem 在一个紧密的循环中调用它,适合 28 uop 循环缓冲区)。对于那些 CPUs,从 partial-reg stall 的角度来看,显式 movzx 可能更好。 Agner Fog 说了一些关于在 CPU 插入合并 uop 时有一个额外的循环,这可能意味着一个单独发布额外 uop 的循环。

在 AMD K8-Steamroller 上,imul imm16 是 2 m-ops 而不是 imul imm32 的 1 m-ops,所以 imul32/movzx 大约等于 imul16。他们不会遇到 LCP 停顿或部分调节问题。

在 Intel Silvermont 上,imul imm16 是 2 微指令(每 4 个时钟吞吐量一个),而 imul imm32 是 1 微指令(每 1 个时钟吞吐量一个)。在 Atom(Silvermont 的有序前身)上也是如此:imul16 是一个额外的 uop,而且速度要慢得多。在大多数其他微体系结构上,吞吐量并不差,只是延迟。

因此,如果您愿意增加以字节为单位的代码大小以提高速度,您应该使用 32 位 imulmovzwl %di, %edi。在某些架构上,这将与 imul imm16 的速度大致相同,而在其他架构上,它会快 很多。在 AMD 推土机系列上可能会稍微差一点,它显然不太擅长同时使用两个整数执行单元,因此 EX1 的 2 m-op 指令可能比两个 1 m-op 指令更好,其中一个是它们仍然是 EX1-only 指令。如果您关心,请对此进行基准测试。


tab对齐到至少一个32B边界,所以你的32位imulor可以从其中任何2B对齐的条目进行4B加载而无需跨越缓存-线边界。未对齐的访问对所有最近的 CPUs(Nehalem 和更高版本,以及最近的 AMD)没有惩罚,只要它们不跨越两个缓存行。

进行从 table 32 位读取的操作避免了英特尔 CPU 的部分寄存器惩罚。 AMD CPUs 和 Silvermont 不单独跟踪部分寄存器,因此即使只写到 low16 的指令也必须等待寄存器其余部分的结果。这会阻止 16 位 insns 破坏依赖链。 Intel P6 和 SnB 微架构系列跟踪部分 regs。 Haswell 做完整的双重簿记或其他事情,因为在需要合并时没有惩罚,比如在你移动 al 之后,然后移动 eax。 SnB 将在那里插入一个额外的 uop,并且在执行此操作时可能会有一个或两个周期的惩罚。我不确定,也没有测试过。但是,我没有找到避免这种情况的好方法。

shl %al 可以替换为 add %al, %al。那可以 运行 在更多端口上。可能没有区别,因为 port0/5(或 Haswell 及更高版本的 port0/6)可能未饱和。它们对位的影响相同,但设置标志的方式不同。否则它们可能会被解码为相同的 uop。


更改:将 pext/pdep / vectorize 版本拆分为单独的答案,部分原因是它可以有自己的评论线程。

(我将 BMI2 版本拆分为一个单独的答案,因为它最终可能会完全不同)


在看到你用 imul/shr 做了什么以获得 table 索引后,我可以看到你可以在哪里使用 BMI2 pextr 来替换 and/imul/shr,或 BMI1 bextr 仅替换 shr(允许使用 imul32 而不是 imul16,因为您只需提取所需的位,而不需要从 upper16 移零)。有 AMD CPUs 有 BMI1,但即使是 steamroller 也没有 BMI2。 Intel与Haswell同时推出了BMI1和BMI2

您可以使用 64 位 pextr 一次处理两个或四个 16 位字。但不适用于整个算法:您不能进行 4 次并行 table 查找。 (AVX2 VPGATHERDD 在这里不值得使用。)实际上,您可以使用 pshufb 来实现索引高达 4 位的 LUT,见下文。

次要改进版本:

.section .rodata
  # This won't won't assemble, written this way for humans to line up with comments.

extmask_lobits:     .long           0b0000 0111 0111 0111
extmask_hibits:     .long           0b0000 1000 1000 1000

# pext doesn't have an immediate-operand form, but it can take the mask from a memory operand.
# Load these into regs if running in a tight loop.

#### TOTALLY UNTESTED #####
.text
.p2align 4,,10
bcd2dpd_bmi2:
#       mov   %edi,%eax           #   = 0000 abcd efgh iklm
#       shl   %al                 #   = 0000 abcd fghi klm0
#       shr   %eax                #   = 0000 0abc dfgh iklm

        pext  extmask_lobits, %edi, %eax
                                #   = 0000 0abc dfgh iklm
        mov   %eax, %esi        # insn scheduling for 4-issue front-end: Fast-path is 4 fused-domain uops
          # And doesn't waste issue capacity when we're taking the slow path.  CPUs with mov-elimination won't waste execution units from issuing an extra mov
        test  [=10=]x880, %edi        # fast path for a = e = 0
        jnz .Lslow_path
        ret

.p2align 4
.Lslow_path:
        # 8 uops, including the `ret`: can issue in 2 clocks.

        # replaces and/imul/shr
        pext  extmask_hibits, %edi, %edi #u= 0000 0000 0000 0aei
        and   [=10=]x66, %esi                # q = 0000 0000 0fg0 0kl0
        imul  tab-8(,%rdi,4), %esi       # v = q * tab[u-2][0]
        and   [=10=]x397, %eax               # r = 0000 00bc d00h 0klm
        xor   %esi, %eax                 # w = r ^ v
        or    tab-6(,%rdi,4), %eax       # x = w | tab[u-2][1]
        and   [=10=]x3ff, %eax               #   = 0000 00xx xxxx xxxx
        ret

当然,如果使它成为一个内联汇编,而不是一个独立的函数,你会变回快速路径分支到最后,慢速路径通过。而且你也不会浪费 space 对齐填充中间函数。

使用 pextr and/or pdep 的范围可能更大,以实现更多功能的其余部分。


我在考虑如何使用 BMI2 做得更好。我认为我们可以从打包成 64b 的四个短裤中得到多个 aei 选择器,然后使用 pdep 将它们存放在不同字节的低位。然后 movq 到向量寄存器,在那里你将它用作 pshufb 的随机控制掩码来进行多个 4 位 LUT 查找。

所以我们可以一次从 60 个 BCD 位变为 50 个 DPD 位。 (使用 shrd 在寄存器之间移动位以处理 loads/stores 到字节可寻址内存。)

实际上,48 个 BCD 位(4 组,每组 12 位)-> 40 个 DPD 位可能 很多 更容易,因为您可以将其解压缩为 4 组 16 位64b 整数寄存器,使用 pdep。处理 5 组的选择器很好,您可以使用 pmovzx 解包,但处理其余数据需要在向量寄存器中进行位改组。即使是缓慢的 AVX2 可变移位 insn 也不会让这件事变得容易。 (尽管考虑如何使用 BMI2 实现这一点可能很有趣,但对于仅使用 SSSE3(即每个相关的 CPU)或 SSE4.1 的 CPUs 的大幅加速。)

这也意味着我们可以将两个 4 组的集群放入 128b 寄存器的低半部分和高半部分,以获得更多的并行性。

作为奖励,48 位是字节的整数,因此从 BCD 数字缓冲区读取不需要任何 shrd insns 将最后 64b 中剩余的 4 位转换为低 4 位对于下一个。或者当 4 个被忽略的位是 64b 的低 4 位或高 4 位时,两个偏移 pextr 掩码可以工作……无论如何,我认为一次做 5 个组是不值得考虑的。

完整的 BMI2/AVX pshufb LUT 版本(可向量化)

数据移动可能是:

ignored | group 3        | group 2        | group 1        |  group 0
16bits  | abcd efgh iklm | abcd efgh iklm | abcd efgh iklm | abcd efgh iklm

         3   2   1 | 0
pext -> aei|aei|aei|aei  # packed together in the low bits

          2  |      1            |        0
pdep ->  ... |0000 0000 0000 0aei|0000 0000 0000 0aei  # each in a separate 16b word

movq -> xmm vector register.
 (Then pinsrq another group of 4 selectors into the upper 64b of the vector reg).  So the vector part can handle 2 (or AVX2: 4) of this at once

vpshufb xmm2 -> map each byte to another byte (IMUL table)
vpshufb xmm3 -> map each byte to another byte (OR table)


Get the bits other than `aei` from each group of 3 BCD digits unpacked from 48b to 64b, into separate 16b words:

                  group 3       | group 2             | group 1             |  group 0
pdep(src)-> 0000 abcd efgh iklm | 0000 abcd efgh iklm | 0000 abcd efgh iklm | 0000 abcd efgh iklm

 movq this into a vector reg (xmm1).  (And repeat for the next 48b and pinsrq that to the upper64)

VPAND  xmm1, mask  (to zero aei in each group)

 Then use the vector-LUT results:
VPMULLW xmm1, xmm2 -> packed 16b multiply, keeping only the low16 of the result

VPAND   xmm1,  mask
VPXOR   xmm1, something
VPOR    xmm1, xmm3

movq / pextrq back to integer regs

pext to pack the bits back together
  You don't need the AND 0x3ff or equivalent:
  Those bits go away when you pext to pack each 16b down to 10b

shrd or something to pack the 40b results of this into 64b chunks for store to memory.
  Or: 32b store, then shift and store the last 8b, but that seems lame
  Or: just do 64b stores, overlapping with the previous.  So you write 24b of garbage every time.  Take care at the very end of the buffer.

使用 128b SSE 指令的 AVX 3 操作数版本以避免需要 movdqa 不覆盖 pshufb 的 table。只要你从不 运行 256b AVX 指令,你就不需要搞乱 vzeroupper。不过,如果您使用任何向量指令,您也可以使用所有向量指令的 v (VEX) 版本。在 VM 中,您可能 运行 正在虚拟 CPU 上使用 BMI2 但不支持 AVX,所以这是有可能的。检查两个 CPU 功能标志仍然是一个好主意,而不是在看到 BMI2 时假设 AVX(即使这对于当前存在的所有物理硬件都是安全的)。


这开始看起来非常有效。在矢量 regs 中做 mul/xor/and 的东西可能是值得的,即使你没有 BMI2 pext/pdep 来做 packing/unpacking。我想您可以使用现有的非 BMI 标量路由之类的代码来获取选择器,并且 mask/shift/or 可以将非选择器数据构建为 16b 块。或者 shrd 用于将数据从一个 reg 转移到另一个?