以8位编码3个base-6数字,用于解包性能

时间:2018-04-06 21:10:10

标签: c optimization bit-manipulation x86-64

我正在寻找一种高效解包(就生成代码中的少量基本ALU操作而言)编码3个base-6数字的方式(即[0,5范围内的3个数字] ])8位。一次只需要一个,所以除非解码所有三个的成本非常低,否则需要解码所有三个才能访问一个的方法可能并不好。

显而易见的方法当然是:

x = b%6;   //  8 insns
y = b/6%6; // 13 insns
z = b/36;  //  5 insns

指令计数是在x86_64上用gcc> = 4.8测量的,它知道如何避免div。

另一种方法(使用不同的编码)是:

b *= 6
x = b>>8;
b &= 255;

b *= 6
y = b>>8;
b &= 255;

b *= 6
z = b>>8;

这个编码对于许多元组有多个表示(它使用整个8位范围而不仅仅是[0,215])并且如果你想要所有3个输出则显得更有效,但如果你只想要一个输出则浪费。

有更好的方法吗?

目标语言是C,但我已经标记了此assembly,因为回答需要考虑将要生成的指令。

2 个答案:

答案 0 :(得分:2)

正如评论中所讨论的,如果LUT在缓存中保持热点,那么它将非常出色。 uint8_t LUT[3][256]需要缩放256的选择器,如果它不是编译时常量,则需要额外的指令。缩放216以更好地打包LUT只有1或2个指令更昂贵。 struct3 LUT[216]很好,结构中有一个3字节的数组成员。在x86上,这在位置相关的代码中编译得非常好,其中LUT基址可以是32位绝对值,作为寻址模式的一部分(如果表是静态的):

struct { uint8_t vals[3]; } LUT[216];
unsigned decode_LUT(uint8_t b, unsigned selector) {
    return LUT[b].vals[selector];
}

gcc7 -O3 on Godbolt for x86-64 and AArch64

    movzx   edi, dil
    mov     esi, esi                 # zero-extension to 64-bit: goes away when inlining.
    lea     rax, LUT[rdi+rdi*2]      # multiply by 3 and add the base
    movzx   eax, BYTE PTR [rax+rsi]  # then index by selector
    ret

愚蠢的gcc使用3分量LEA(3 cycle latency and runs on fewer ports)代替使用LUT作为实际负载的disp32(我认为没有额外的索引寻址模式延迟)。

如果您需要解码同一字节的多个组件,此布局还具有局部性的附加优势。

在PIC / PIE代码中,不幸的是,这需要2条额外的指令:

    movzx   edi, dil
    lea     rax, LUT[rip]           # RIP-relative LEA instead of absolute as part of another addressing mode
    mov     esi, esi
    lea     rdx, [rdi+rdi*2]
    add     rax, rdx
    movzx   eax, BYTE PTR [rax+rsi]
    ret

但这仍然很便宜,并且所有ALU指令都是单周期延迟。

你的第二个ALU解包策略很有希望。我首先想到的是,我们可以使用单个64位乘法来在同一个64位整数的不同位置获得b*6b*6*6b*6*6*6。 (b * ((6ULL*6*6<<32) + (36<<16) + 6)

但是每个乘法结果的高字节确实取决于在每次乘以6之后屏蔽回8位。(如果你能想到一种不需要的方法,一个倍数和移位将非常便宜,特别是在64位ISA,其中整个64位乘法结果在一个寄存器中。)

仍然,如果编译器避免使用部件,x86和ARM可以乘以6并在3个延迟周期中屏蔽,相同或更好的延迟,而不是乘法或更少的英特尔CPU,具有零延迟movzx r32, r8。与movzx相同的注册表。

add    eax, eax              ; *2
lea    eax, [rax + rax*2]    ; *3
movzx  ecx, al               ; 0 cycle latency on Intel
.. repeat for next steps

ARM / AArch64同样好,add r0, r0, r0 lsl #1乘以3。

作为选择三者之一的无分支方式,您可以考虑存储(从ah / ch / ...以获得免费转换)到数组,然后加载选择器作为索引。这会花费存储/重新加载延迟(约5个周期),但是对于吞吐量来说是便宜的并且避免了分支未命中。 (可能是16位存储然后重新加载字节,在加载地址中缩放选择器并加1以获得高字节,在ARM上的每个存储之前保存提取指令。)

这实际上是gcc在你这样写的时候发出的:

unsigned decode_ALU(uint8_t b, unsigned selector) {
    uint8_t decoded[3];
    uint32_t tmp = b * 6;
    decoded[0] = tmp >> 8;
    tmp = 6 * (uint8_t)tmp;
    decoded[1] = tmp >> 8;
    tmp = 6 * (uint8_t)tmp;
    decoded[2] = tmp >> 8;

    return decoded[selector];
}

    movzx   edi, dil
    mov     esi, esi
    lea     eax, [rdi+rdi*2]
    add     eax, eax
    mov     BYTE PTR -3[rsp], ah      # store high half of mul-by-6
    movzx   eax, al                   # costs 1 cycle: gcc doesn't know about zero-latency movzx?
    lea     eax, [rax+rax*2]
    add     eax, eax
    mov     BYTE PTR -2[rsp], ah
    movzx   eax, al
    lea     eax, [rax+rax*2]
    shr     eax, 7
    mov     BYTE PTR -1[rsp], al
    movzx   eax, BYTE PTR -3[rsp+rsi]
    ret

第一个商店的数据在输入第一个movzxor 5 if you include the extra 1c of latency for reading ah when it's not renamed separately on Intel HSW/SKL后的4个周期内准备就绪。接下来的两家商店相隔3个周期。

因此,如果selector = 0,总延迟是从b输入到结果输出的~10个周期。否则13或16个周期。

答案 1 :(得分:1)

在需要这样做的功能中测量许多不同的方法,实际的答案真的很无聊:无所谓。他们每次通话都运行大约50ns,其他工作占主导地位。因此,就我的目的而言,污染缓存和分支预测器的方法可能是最好的。这似乎是:

(b * (int[]){2048,342,57}[i] >> 11) % 6;

其中b是包含打包值的字节,i是所需值的索引。魔术常数342和57只是GCC分别乘以6和36生成的乘法常数,缩放到11的公共位。%6 /36中的最终i==2是假的({{ 1}})但分支以避免它似乎不值得。

另一方面,如果在没有接口约束的上下文中执行相同的工作以使每个查找具有周围的函数调用开销,我认为像Peter这样的方法将更可取。