使用未对齐缓冲区进行矢量化:使用VMASKMOVPS:根据未对齐计数生成掩码?或者根本不使用那个insn

Pet*_*des 10 x86 assembly gcc sse avx

gcc 5.3 with -O3 -mavx -mtune=haswellfor x86-64使得代码处理可能错位的代码变得非常笨重,例如:

// convenient simple example of compiler input
// I'm not actually interested in this for any real program
void floatmul(float *a) {
  for (int i=0; i<1024 ; i++)
    a[i] *= 2;
}
Run Code Online (Sandbox Code Playgroud)

clang使用未对齐的加载/存储指令,但是gcc执行标量intro/outro和对齐的向量循环:它剥离了第一个最多7个未对齐的迭代,将其完全展开为一个序列

    vmovss  xmm0, DWORD PTR [rdi]
    vaddss  xmm0, xmm0, xmm0      ; multiply by two
    vmovss  DWORD PTR [rdi], xmm0
    cmp     eax, 1
    je      .L13
    vmovss  xmm0, DWORD PTR [rdi+4]
    vaddss  xmm0, xmm0, xmm0
    vmovss  DWORD PTR [rdi+4], xmm0
    cmp     eax, 2
    je      .L14
    ...
Run Code Online (Sandbox Code Playgroud)

这似乎很可怕,尤其是.对于具有uop缓存的CPU.我报告了一个关于此的gcc错误,并建议gcc在剥离未对齐迭代时可以使用的更小/更好的代码.不过,它可能仍然不是最优的.

这个问题是关于AVX实际上最佳的问题.我问的是gcc和其他编译器可以/应该使用的一般情况解决方案.(我没有找到任何gcc邮件列表点击讨论这个,但没有花很长时间.)


可能会有多个答案,因为最佳选择-mtune=haswell可能与-mtune=bdver3(压路机)的最佳选择不同.然后就是允许指令集扩展时最佳的问题(例如AVX2用于256b整数,BMI1用于在较少的指令中将计数转换为位掩码).

我知道Agner Fog的优化装配指南,第13.5节访问未对齐数据和部分向量.他建议使用未对齐的访问,在开始和/或结束时进行重叠写入,或者从对齐的访问中对数据进行混洗(但PALIGNR只需要imm8计数,因此需要2x pshufb/ por).他打折VMASKMOVPS并没有用,可能是因为它对AMD的表现有多糟糕.我怀疑如果调整英特尔,那值得考虑.如何生成正确的掩码并不明显,因此问题标题.


事实证明,简单地使用未对齐的访问会更好,就像clang那样.对于短缓冲区,对齐的开销可能会从避免主循环的cacheline拆分中获得任何好处.对于大缓冲区,主内存或L3作为瓶颈可能会隐藏高速缓存行拆分的惩罚.如果有人有实验数据支持他们调整的任何真实代码,那也是有用的信息.


VMASKMOVPS确实看起来可用于英特尔目标.(SSE版本很糟糕,有一个隐含的非时间提示,但AVX版本没有.甚至还有一个新的内在函数,以确保你没有获得128b操作数的SSE版本:) _mm128_maskstore_psAVX版本是哈斯韦尔只有一点点慢:

  • 作为负载,3 uops/4c延迟/ 1-per-2c吞吐量.
  • 作为256b存储,4 uops/14c延迟/ 1-per-2c吞吐量.
  • 作为128b存储,4 uops/13c延迟/ 1-per-1c吞吐量.

AMD CPU上的商店形式仍然非常缓慢,Jaguar(每22c输出1个)和Bulldozer系列:Steamroller上的每16c 1个(Bulldozer上类似),或者Piledriver上每1~180c吞吐量1个.

但是如果我们想要使用VMASKMOVPS,我们需要一个在每个元素中设置高位的向量,这个向量应该实际加载/存储.PALIGNR和PSRLDQ(用于全向量的向量)仅采用编译时常量计数.

请注意,其他位无关紧要:它不必是全1,因此将一些设置位分散到元素的高位是有可能的.

Pet*_*des 5

将VMOVMASKPS的掩码从窗口加载到表中.AVX2或AVX1带有一些额外的指令或更大的表格.

掩码还可用于ANDPS寄存器中,需要对每个元素进行一次精确计数.正如Stephen Canon在对OP的评论中指出的那样,流水线负载可以允许重叠的未对齐存储甚至可以像我选择的示例一样进行就地重写功能,因此VMASKMOVPS不是最佳选择.


这应该适用于英特尔CPU,尤其是英特尔CPU.Haswell和后来的AVX2.

Agner Fog获取pshufb掩码的方法实际上提供了一个非常有效的想法:从表中获取一个未对齐的负载来获取数据窗口.而不是一个巨大的掩码表,使用索引作为对内存中的数据进行字节移位的方法.


屏蔽LSB-第一个字节顺序(因为它们存储在内存中),而不是{X3,X2,X1,X0}矢量中元素的常用表示法.如上所述,它们与对齐的窗口对齐,包括内存中输入数组的开始/结束.

  • start misalign count = 0:mask = all-ones(Aligned case)
  • start misalign count = 1:mask = {0,-1,-1,-1,-1,-1,-1,-1}(在第一个32B中跳过一个)
  • ...
  • start misalign count = 7:mask = {0, 0, 0, 0, 0, 0, 0,-1}(在第一个32B中跳过除了一个之外的所有)

  • end misalign count = 0:没有尾随元素.mask = all-ones(对齐的情况).
    这是奇怪的情况,与count = 1不相似.这个特殊情况的一些额外指令值得避免额外的循环迭代和使用全零掩码的清理.

  • end misalign count = 1:一个尾随元素.mask ={-1, 0, 0, 0, 0, 0, 0, 0}
  • ...
  • 结束错位数= 7:七个尾随元素.mask ={-1,-1,-1,-1,-1,-1,-1, 0}

未经测试的代码,假设存在错误

section .data
align 32  ; preferably no cache-line boundaries inside the table

; byte elements, to be loaded with pmovsx. all-ones sign-extends
    DB  0,  0,  0,  0,  0,  0,  0,  0
masktable_intro:                      ; index with 0..-7
    DB -1, -1, -1, -1, -1, -1, -1, -1
masktable_outro:                      ; index with -8(aligned), or -1..-7
    DB  0,  0,  0,  0,  0,  0,  0,  0

; the very first and last 0 bytes are not needed, since we avoid an all-zero mask.


section .text
global floatmul   ; (float *rdi)
floatmul:
    mov    eax, edi
    and    eax, 0x1c            ; 0x1c = 7 << 2 = 0b11100

    lea    rdx, [rdi + 4096 - 32]  ; one full vector less than the end address (calculated *before* masking for alignment).
                ;;  replace 4096 with rsi*4 if rsi has the count (in floats, not bytes)

    and    rdi, ~0x1c           ; Leave the low 2 bits alone, so this still works on misaligned floats.

    shr    eax, 2               ; misalignment-count, in the range [0..7]

    neg        rax
    vpmovsxbd  ymm0, [masktable_intro + rax]  ; Won't link on OS X: Need a separate LEA for RIP-relative

    vmaskmovps ymm1, ymm0, [rdi]
    vaddps     ymm1, ymm1, ymm1   ; *= 2.0
    vmaskmovps [rdi], ymm0, ymm1

    ;;; also prepare the cleanup mask while the table is still hot in L1 cache

    ; if the loop count known to be a multiple of the vector width,
    ; the alignment of the end will be the same as the alignment of the start
    ; so we could just invert the mask
    ; vpxor    xmm1, xmm1, xmm1     ; doesn't need an execution unit
    ; vpcmpeqd ymm0, ymm1, ymm0

    ; In the more general case: just re-generate the mask from the one-past-the-end addr
    mov    eax, edx
    xor    ecx, ecx      ; prep for setcc
    and    eax, 0x1c     ; sets ZF when aligned
    setz   cl            ; rcx=1 in the aligned special-case, else 0
    shr    eax, 2
    lea    eax, [rax + rcx*8]   ; 1..7, or 8 in the aligned case
    neg    rax
    vpmovsxbd  ymm0, [masktable_outro + rax]


.loop:
    add      rdi, 32
    vmovups  ymm1, [rdi]    ; Or vmovaps if you want to fault if the address isn't 4B-aligned
    vaddps   ymm1, ymm1, ymm1   ; *= 2.0
    vmovups  [rdi], ymm1
    cmp      rdi, rdx           ; while( (p+=8) < (start+1024-8) )
    jb    .loop        ; 5 fused-domain uops, yuck.

    ; use the outro mask that we generated before the loop for insn scheduling / cache locality reasons.

    vmaskmov ymm1, ymm0, [rdi]
    vaddps     ymm1, ymm1, ymm1   ; *= 2.0
    vmaskmovps [rdi], ymm0, ymm1
    ret


    ; vpcmpeqd ymm1, ymm1, ymm1   ; worse way to invert the mask: dep-chain breaker but still needs an execution unit to make all-ones instead of all-zeros.
    ; vpxor    ymm0, ymm0, ymm1
Run Code Online (Sandbox Code Playgroud)

这确实需要来自表的负载,该表可以在L1高速缓存中丢失,并且需要15B的表数据.(如果循环计数也是可变的,则为24B,我们必须单独生成结束掩码).

无论哪种方式,经过4个指令产生的偏差数和对齐起始地址,让面膜只需要一个单一的 vpmosvsxbd指令.(ymm,mem形式不能微融合,所以它是2 uops).这需要AVX2.


没有AVX2:

  • 2x vpmovsxbd分为两个128b寄存器([masktable_intro + rax][masktable_intro + rax + 4])
  • vinsertf128

或者:(更多的insn,更多的shuffle-port压力,但负载端口压力更小)

  • vpmovsxbw进入128b寄存器
  • vpunpcklwd/vpunpckhwd分为两个xmm regs(两个src1 = src2)
  • vinsertf128

要么:

  • vmovdqu来自60个DWORDs(DD)表而不是Bytes(DB).这实际上会保存相对于AVX2的insn:address & 0x1c是索引,而不需要右移2.整个表仍然适合缓存行,但没有空间可以使用算法的其他常量.

高架:

  • 整数运算:在开始时使用5个uop来获取索引并对齐起始指针.7 uops获取结束掩码的索引.如果循环元素计数是向量宽度的倍数,则除了简单地使用未对齐之外,总共有12个GP寄存器微指令.

  • AVX2:两个2-fused-domain-uop vector insns从GP reg中的[0..7]索引转到YMM reg中的掩码.(一个用于开始掩码,一个用于结束掩码).使用24B表,在具有字节粒度的8B窗口中访问.

  • AVX:六个1-fused-domain-uop向量insn(三个在开始时,三个在结束时).对于表的RIP相对寻址,这些指令中的四个将是[base+index]并且将不会微融合,因此额外的两个整数insn可能更好.

循环内的代码被复制3次.


TODO:写一个动态生成掩码的另一个答案,可能是64b reg中的字节,然后将其解压缩到256b.也许有一个位移,或BMI2的BZHI(-1,计数)?

  • 关于未分类方法与各种对齐方法的主题,它可能(像往常一样)归结为您对输入的期望.通常我_expect_数据将被对齐(例如,因为人们正在使用对齐它的分配器,或者其他) - 但我不能排除通用共享库中未对齐输入的可能性.在这种情况下,未对齐的方法可能占主导地位,因为您实际上并未支付高速缓存行交叉的未对齐惩罚(对于在某些较旧的CPU上使用未对齐的`mov*'指令,您仍可能会受到惩罚). (2认同)

Pet*_*des 3

仅限 AVX:开始/结束时未对齐访问,流水线加载以避免就地重写时出现问题。

感谢@StephenCanon 指出,这比VMASKMOVPS任何VMASKMOVPS可以帮助循环未对齐缓冲区的方法都要好。

对于编译器作为循环转换的期望可能有点过高,尤其是。因为显而易见的方式可能会让 Valgrind 不高兴(见下文)。

section .text
global floatmul   ; (float *rdi)
floatmul:

    lea    rdx, [rdi + 4096 - 32]  ; one full vector less than the end address (calculated *before* masking for alignment).
                ;;  replace 4096 with rsi*4 if rsi has the count (in floats, not bytes)

    vmovups  ymm0, [rdi]        ; first vector
    vaddps   ymm0, ymm0, ymm0   ; *= 2.0
    ; don't store yet

    lea    rax, [rdi+32]
    and    rax, ~0x1c           ; 0x1c = 7 << 2 = 0b11100  ; clear those bits.
    vmovups  ymm1, [rax]        ; first aligned vector, for use by first loop iteration

    vmovups  [rdi], ymm0        ; store the first unaligned vector
    vmovups  ymm0, [rdx]        ; load the *last* unaligned vector
    
.loop:
      ;; on entry: [rax] is already loaded into ymm1
    vaddps   ymm1, ymm1, ymm1   ; *= 2.0
    vmovups  [rax]              ; vmovaps would fault if p%4 != 0
    add      rax, 32
    vmovups  ymm1, [rax]
    cmp      rax, rdx           ; while( (p+=8) < (endp-8) );
    jb  .loop

    ; discard ymm1.  It includes data from beyond the end of the array (aligned case: same as ymm0)

    vaddps   ymm0, ymm0, ymm0   ; the last 32B, which we loaded before the loop
    vmovups  [rdx], ymm0
    ret

    ;   End alignment:
    ; a[] = XXXX XXXX ABCD E___    _ = garbage past the end
    ;                  ^rdx
    ;       ^rax ^rax ^rax ^rax(loop exit)

    ;   ymm0 = BCDE
    ;   ymm1 loops over ..., XXXX, ABCD, E___
    ;   The last load off the end of the array includes garbage
    ;     because we pipeline the load for the next iteration
Run Code Online (Sandbox Code Playgroud)

在循环开始时从数组末尾进行加载似乎有点奇怪,但希望它不会混淆硬件预取器,或者减慢从内存获取数组开头的速度。

高架:

  • 总共 2 个额外的整数 uop(用于设置对齐起始)。我们已经在正常循环结构中使用结束指针,因此这是免费的。

  • 循环体的 2 个额外副本(加载/计算/存储)。(第一次和最后一次迭代被剥离)。


在自动向量化时,编译器可能不会高兴地发出这样的代码。 Valgrind 将报告超出数组边界的访问,并通过单步执行和解码指令来查看它们正在访问的内容。因此,仅与数组中的最后一个元素保持在同一页(和缓存行)内是不够的。另请注意,如果输入指针不是 4B 对齐,我们可能会读入另一个页面并出现段错误。

为了让 Valgrind 满意,我们可以提前停止两个向量宽度的循环,以执行数组未对齐的最后一个向量宽度的特殊情况加载。这将需要额外复制一次循环体(在本例中微不足道,但故意这样做是微不足道的)。或者可以通过让介绍代码跳到循环中间来避免流水线操作。(不过,对于微指令缓存来说,这可能不是最佳选择:(部分)循环体可能会在微指令缓存中出现两次。)

TODO:编写一个中途跳转到循环的版本。