AVX-512 和分支

AVX-512 and Branching

我对屏蔽在理论上对分支的作用感到困惑。假设我有一个 Skylake-SP(哈,我希望..),我们忽略了编译器功能,这只是理论上可能的:

如果分支条件依赖于静态标志,并且所有分支都将数组设置为计算结果,假设编译器无论如何都不会将其优化为两个单独的循环,它可以矢量化吗?

do i = 1, nx
  if (my_flag .eq. 0) then
    a(i) = b(i) ** 2
  else
    a(i) = b(i) ** 3
  end if
end do

如果仅作为分支的子集设置有问题的值,它可以向量化吗?

do i = 1, nx
  if (my_flag .eq. 0) then
    a(i) = b(i) ** 2
  end if
end do

如果分支条件本身依赖于向量数据,它可以向量化吗?

do i = 1, nx
  if (c(i) > 0) then
    a(i) = b(i) ** 2
  else
    a(i) = b(i) ** 3
  end if
end do

是的,SSE2/SSE4.1(对于 blendps)/AVX/AVX-512 中的任何一个都可以实现高效的 asm,对于你的所有循环,编译器可以 auto-vectorize在实践中,但 gcc7.2 / clang5.0 / ICC18 都错过了优化。

根据 Skylake-AVX512 的静态分析(见下文),最终循环的高效展开实现可以 运行 每 1.25 个时钟周期(加上循环开销取决于你展开多少)。实际上,每个向量 1.33 或 1.5 个时钟周期可能是可以实现的,如果 您的数据在 L1D 缓存中很热。否则你很容易在 L2 带宽上遇到瓶颈,因为你为每个存储向量 64B 存储加载 2x 64B。

对于循环的 C 版本,gcc、clang 和 ICC 都auto-vectorize 或多或少像我手工做的那样:参见源代码 + asm on the Godbolt compiler explorer.

我必须使用 -ffast-math 和 gcc 才能达到 auto-vectorize。 IDK 为什么它没有意识到它可以安全地 auto-vectorize 而不会违反严格的 FP 规则。

Clang 似乎正在分别评估 tmp*tmptmp*tmp*tmp,并混合这两个结果而不是有条件地进行第二次乘法。

gcc 既进行乘法运算又使用单独的 movaps 以另一种方式合并 因为它不知道如何反转条件。

ICC 使用 KNOTW 反转条件,然后像我一样用 merge-masking 进行第二次乘法运算。

更改代码以在 if 分支而不是 else 分支中执行额外的乘法运算(**3 而不是 **2)所有 3 个编译器都生成更好的代码 ,而它们的每个 missed-optimizations 都不会以另一种方式分支。 (仍然遗漏了 gcc 的优化,但 ICC 和 clang 看起来很可靠,两者本质上都在做我的 hand-written 代码所做的相同事情。)

ICC 选择仅 auto-vectorize 使用 256b 向量。也许它默认这样做是为了避免降低最大涡轮时钟速度?也许可以选择使用 full-width 向量? gcc 8.0 快照也这样做,但 gcc7.2 使用 ZMM 向量。


AVX-512 掩码寄存器和 merge-masking 使它更加高效,但是在很长一段时间内,同时进行两种方式然后混合一直是 SIMD(甚至 non-SIMD 无分支代码)的事情.例如要根据向量比较结果有条件地添加,使用该向量比较结果作为 AND 掩码以保留一些元素不变,并将其他元素设为零。

0 是加法恒等式:x + 0 = x。所以如果掩码是 all-zero,x + (y&mask) 就是 no-op,如果掩码是 all-one,那么它就是 x+y。参见 How to use if condition in intrinsics。 (有趣的技巧:使用 packed-compare 结果 作为 整数 -1 或 0,因此您可以计算匹配但减去 compare-mask)。

乘法没那么简单,因为1是乘法恒等式,但你可以通过混合来解决。

assuming the compiler does not optimize this to two separate loops anyways, can it vectorize?

在第一种情况下,如果编译器没有将条件提升到循环之外并进行两次循环,您应该会对编译器不满意。特别是在第二种情况下,它只需要一个循环,因为如果条件为假,则不会修改数组。


我们只讨论第 3 种情况,因为只有这种情况编译器不应该只提升条件。 (如果你的编译器感觉很笨,它可以使用这个版本和 all-zero 的 loop-invariant 掩码或其他版本的 all-one 掩码)。

if (c(i) > 0)

所以我们需要从 c 加载一个元素向量并与零进行比较。 AVX512 可以为 16 single-precision float 的向量执行此操作,其中一条指令带有掩码寄存器目标和内存源操作数。

; with zmm0 = 0.0 in all elements, from vxorps xmm0,xmm0,xmm0 outside the loop.
vcmpps    k1, zmm0, [rdx],  _CMP_NLT_UQ     ; !(0 < c(i))

我知道(从已经写下一部分)我想要 k1 对于 c(i) > 0 条件为假的元素为真。只有第二个向量操作数可以是内存而不是寄存器,所以我不得不反转它并使用 not-less-than 而不是 not-greater-than。 (而且我不能只使用 >= 而不是 <,因为那样会将无序情况(一个或两个 NaN)放在错误的类别中。FP 比较有 4 个可能的结果:above/below/equal/unordered,所以你必须为所有 4 种情况选择一个谓词来做你想做的事情(即源代码所说的,如果你是编译器)。如果你用 -ffast-math 编译,编译器可以忽略NaN 的可能性。

如果需要把两个条件链接在一起,AVX512compare-into-mask指令可以掩码写入掩码的操作,用zero-masking或merge-masking.

vcmpltps    k1,        zmm1, zmm2       ; k1 = zmm1<zmm2
vcmpltps    k2{k1}{z}, zmm3, zmm4       ; k2 = (zmm3<zmm4) & (zmm1<zmm2)

k2 在 zmm3k1 为零的任何地方都是 0,因为我们使用 k1 作为 zero-mask.


  if (c(i) > 0) then
    a(i) = b(i) ** 2
  else
    a(i) = b(i) ** 3
  end if

这里的common subexpressionb(i) * b(i)。我们可以通过再乘以 b(i) 一次得到 b(i)**3

vmovups    zmm1, [rsi]       ; load a vector from b(i)
vmulps     zmm2, zmm1, zmm1  ; zmm2 = zmm1*zmm1 = b(i)**2

AVX-512 可以基于掩码进行合并,作为(几乎)任何其他指令的一部分。

vmulps     zmm2{k1}, zmm2, zmm1  ; zmm2 *= zmm1   for elements where k1 is true

vmovups    [rdi], zmm2           ; store all 16 elements into a(i)

顺便说一句,AVX512 有 merge-masking 个商店。以前的 SIMD 指令集将从 [rdi] 加载,混合,然后存储回[rdi]。这意味着您可以使用 per-element 条件比使用 AVX1/AVX2 更有效地实现您的第二个循环(有时不修改 a(i))。


将这些放在一起:(NASM 语法)

 ; x86-64 System V calling convention
 ; args: rdi = a() output array.
 ;       rsi = b() input array
 ;       rdx = c() array to be tested for positive numbers
 ;       rcx = count (in elements)
 ; preferably all 64-byte aligned, but will work slowly if some aren't
 ; rcx must be >= 16, and a multiple of 16, because I didn't write any cleanup code

global square_or_cube
square_or_cube: 

    vxorps     xmm0,  xmm0,xmm0

 .loop:                          ; do {
    vcmpps     k1, zmm0, [rdx], 21    ; _CMP_NLT_UQ  ; !(0 < c(i))

    vmovups    zmm1, [rsi]            ; load a vector from b(i)
    vmulps     zmm2,     zmm1, zmm1   ; zmm2 = zmm1*zmm1 = b(i)**2

    vmulps     zmm2{k1}, zmm2, zmm1   ; zmm2 *= zmm1   for elements where k1 is true, otherwise unmodified.
    vmovups    [rdi], zmm2            ; store all 16 elements into a(i)

    ; TODO: unroll some and/or use indexed addressing mode tricks to save instructions
    add         rdi, 64      ; pointer increments
    add         rsi, 64
    add         rdx, 64

    sub         rcx, 16         ;  count -= 16 
    ja        .loop             ; } while(count>0);

I analyzed this with IACA(省略了 pointer-increment 指令来模拟展开和更聪明的 asm 技巧)。根据 IACA,即使 merge-masking vmulps 也是单个 uop,memory-source 指令 micro-fuses 也是 front-end 的单个 uop。 (商店也是。)这是我所希望的,IACA 的输出在这种情况下看起来是正确的,尽管我无法访问 SKL-SP 硬件上的性能计数器来检查。

$ iaca.sh -arch SKX avx512-conditional
Intel(R) Architecture Code Analyzer Version - 2.3 build:246dfea (Thu, 6 Jul 2017 13:38:05 +0300)
Analyzed File - avx512-conditional
Binary Format - 64Bit
Architecture  - SKX
Analysis Type - Throughput

Throughput Analysis Report
--------------------------
Block Throughput: 1.50 Cycles       Throughput Bottleneck: FrontEnd

Port Binding In Cycles Per Iteration:
---------------------------------------------------------------------------------------
|  Port  |  0   -  DV  |  1   |  2   -  D   |  3   -  D   |  4   |  5   |  6   |  7   |
---------------------------------------------------------------------------------------
| Cycles | 1.5    0.0  | 0.0  | 1.0    1.0  | 1.0    1.0  | 1.0  | 1.5  | 1.0  | 1.0  |
---------------------------------------------------------------------------------------

N - port number or number of cycles resource conflict caused delay, DV - Divider pipe (on port 0)
D - Data fetch pipe (on ports 2 and 3), CP - on a critical path
F - Macro Fusion with the previous instruction occurred
* - instruction micro-ops not bound to a port
^ - Micro Fusion happened
# - ESP Tracking sync uop was issued
@ - SSE instruction followed an AVX256/AVX512 instruction, dozens of cycles penalty is expected
X - instruction not supported, was not accounted in Analysis

| Num Of |                    Ports pressure in cycles                     |    |
|  Uops  |  0  - DV  |  1  |  2  -  D  |  3  -  D  |  4  |  5  |  6  |  7  |    |
---------------------------------------------------------------------------------
|   2^   |           |     | 1.0   1.0 |           |     | 1.0 |     |     | CP | vcmpps k1, zmm0, zmmword ptr [rdx], 0x15
|   1    |           |     |           | 1.0   1.0 |     |     |     |     |    | vmovups zmm1, zmmword ptr [rsi]
|   1    | 1.0       |     |           |           |     |     |     |     | CP | vmulps zmm2, zmm1, zmm1
|   1    | 0.5       |     |           |           |     | 0.5 |     |     | CP | vmulps zmm2{k1}, zmm2, zmm1
|   2^   |           |     |           |           | 1.0 |     |     | 1.0 |    | vmovups zmmword ptr [rdi], zmm2
|   1    |           |     |           |           |     |     | 1.0 |     |    | sub rcx, 0x10
|   0F   |           |     |           |           |     |     |     |     |    | jnbe 0xffffffffffffffdd
Total Num Of Uops: 8

AVX-512 实际上有 vfpclassps (C/C++ intrinsic [_mm512_fpclass_ps_mask]4, asm documentation with a table in the related vfpclasspd (packed double)) 根据你选择的谓词对 FP 值进行分类。它可能比对恰好为零的另一个寄存器进行完全比较更有效。
(实际上,根据 IACA,它不是。InstLatx64 电子表格将两者列为 3 个周期延迟。Agner Fog 在 Skylake-S 上对 AVX2 cmpps 的测量(非 AVX512 桌面芯片) 显示 4 个周期,所以奇怪的是 AVX512 版本在生成 mask-register 结果而不是向量时延迟较低。

我希望结果只对正数为假,我认为 vfpclassps 可以通过设置几乎所有谓词位来获得 -Inf、有限负数、安静和信号 NaN、-0.0 来做到这一点, 和 +0.0.

vfpclassps    k1, [rdx], 0x1 | 0x2 | 0x4 | 0x10 | 0x40 | 0x80     ; QNaN | -0.0 | +0.0 | -Infinity | Negative (finite) | SNaN
; k1 = a 16-bit bitmap of which elements (from memory at [rdx]) need an extra multiply

vpfclassps 很有趣,因为它可以让你区分 +0.0 和 -0.0,就像你可以通过检查二进制表示中的符号位一样(就像你可以使用 AVX2 vblendps 来使用将符号位作为 blend-control,而不先进行比较)。

此外,在这种情况下,它在循环外保存了一条指令,设置了 all-zeros 的寄存器。


相关:AVX512 具有乘以 2**floor(x) (vscalefpd) 的指令,但不能将数字乘以任意幂(整数或其他)。 Xeon Phi has AVX512ER,这给了你 2**x 的快速近似值(没有 flooring x),但是我们也不能在这里直接使用指数函数,并且 SKL-SP 没有AVX512ER 无论如何。


NASM 宏 IACA_start / end:

我写这些是基于iaca_marks.h C/C++ header.

%if 1
%macro  IACA_start 0
     mov ebx, 111
     db 0x64, 0x67, 0x90
%endmacro
%macro  IACA_end 0
     mov ebx, 222
     db 0x64, 0x67, 0x90
%endmacro
%else
%define IACA_start
%define IACA_end
%endif

将它们环绕在您要分析的任何代码周围。


循环内loop-invariant条件的条件分支

编译器可以在循环内分支。 IDK 如果有的话会编写这样的代码,但他们当然可以。

; rdi = destination
; rsi = source
; edx = condition
; rcx = element count
global square_or_cube
square_or_cube: 

 .loop:                          ; do {
    vmovups    zmm1, [rsi]            ; load a vector from b(i)
    vmulps     zmm2, zmm1, zmm1   ; zmm2 = zmm1*zmm1 = b(i)**2

    test       edx,edx
    jz        .only_square        ; test-and-branch to conditionally skip the 2nd multiply
    vmulps     zmm2, zmm2, zmm1   ; zmm2 *= zmm1
   .only_square:

    vmovups    [rdi], zmm2        ; store all 16 elements into a(i)

    add         rdi, 64      ; pointer increments
    add         rsi, 64

    sub         rcx, 16         ;  count -= 16 
    ja        .loop             ; } while(count>0);

注意:这个答案主要讨论了一个非常具体的内存访问问题,当涉及到向量化时,它主要在概念层面上应用于将一系列标量访问转换为数组进入向量化访问而不假设底层数组的哪些部分被映射。在像 Fortran 这样的语言中,语言本身的语义可以保证数组是连续映射的,或者在进入循环之前进行边界检查可能足以避免下面提到的问题。

一般而言,不应将此答案视为对矢量化的良好处理,当然也不适用于 Fortran。 中对矢量化问题进行了更全面的处理,其中还专门针对 AVX-512。


矢量化条件的一个经常被忽视的问题是,编译器可以通过混合或其他元素方面的预测技术对您感兴趣的类型的条件循环进行矢量化,仅当它们可以证明矢量化访问的元素与标量逐元素实现中访问的元素相同。如果指令集不提供按元素方式执行矢量加载的方式来满足此条件,或者如果编译器无法使用它们,这会有效地阻止矢量化。

换句话说,如果通过循环体的所有路径都访问 相同 元素,编译器通常只能使用纯矢量加载完全矢量化。

根本原因是编译后的代码不得访问原始代码语义未访问的元素,即使它们后来"blended away"因为这样做可能会导致故障!如果指令集不提供指令以有条件地访问内存中的元素并抑制未选择元素的错误,这将是优化的重大障碍。

在您给出的示例中,这意味着 (1) 和 (3) 可以矢量化 "without hoisting the condition" 而 (2) 不能,因为 (2) 访问 a[i]b[i] 仅在 if 主体中,但如果未执行 if 则不会。当然,在 myflag == false 的情况下,真正的编译器只会将一个微不足道的标志检查出循环,根本不会执行循环,所以这不是一个很好的例子。

让我们看看包含所有示例的几个案例。首先,我们需要一个无法升起的旗帜——让我们使用一个 bool 值的数组。所以一个有趣的有点通用的循环有一个输出数组 a,两个输入数组 bc 以及一个标志数组 f 可能看起来像:

do i = 1, nx
  if (f(i) > 0) then
    a(i) = g(b(i), c(i));
  else
    a(i) = h(b(i), c(i));
  end if
end do

根据对应于每个元素的标志 f(i),我们将函数 gh 应用于输入元素 b(i)c(i) .根据我上面的条件,只有当 gh 实际上访问 bc 的相同元素时,我们才能矢量化 。 =57=]

让我们继续看上面的两个实际工作示例:

void example1(bool* f, int* __restrict__ a, int* __restrict__ b, int* __restrict__ c, size_t n) {
    for (size_t i = 0; i < n; i++) {
        if (f[i]) {
            a[i] = b[i];
        } else {
            a[i] = c[i];
        }
    }
}

void example2(bool* f, int* __restrict__ a, int* __restrict__ b, int* __restrict__ c, size_t n) {
    for (size_t i = 0; i < n; i++) {
        if (f[i]) {
            a[i] = b[i] + c[i] ;
        } else {
            a[i] = b[i] - c[i] * 2 + 1 ;
        }
    }
}

两者具有相同的基本形式,但哪个更难向量化?第一种是根据标志直接分配 b[i]c[i]。第二个是 both b[i]c[i] 的更复杂的函数,它们在两条路径上有很大不同。

第二个更容易矢量化,因为它无条件地访问 b[i]c[i]。事实上,gcc 由于某种原因无法对其中任何一个进行矢量化。 clang 只矢量化第二个。有点令人惊讶的是 icc 设法向量化 both - 因为它足够聪明,可以使用 vpmaskmovd 这是一个屏蔽负载,可以抑制卸载元素的故障。

您可以检查 generated assembly on godbolt

我最初开始回答这个问题的想法是,访问不同的数组元素目前是当前编译器无法逾越的矢量化障碍,但那是因为我通常不检查 iccicc 以这种方式使用蒙面动作对我来说实际上是个新闻。所以障碍是存在的,但至少一些编译器可以解决它2.

作为开发人员,您通常知道这两个数组都是完全可访问的,因此可以安全地访问 [0, n) 范围内的 bc 的所有元素,并且它将其传达给编译器会很好。我已经尝试添加无条件的虚拟语句,如 b[i] = b[i]; c[i] = c[i];... + c[i] * 0,它们应该编译为空,但至少允许编译器在语义上看到所有元素都被访问。 do 确实 "compile away" 但代码生成没有改进:没有发生额外的向量化。可能在矢量化分析完成之前,它们已经在编译过程的早期被消除,因此信息丢失到矢量化器。

除了不是免费且不完全通用的掩码移动指令之外,还有其他方法可以改善这种情况吗?好吧,编译器可以利用其对平台内存保护模型的了解。例如,一旦访问了 x86 上 4K 页面中的任何字节,就可以自由读取该页面上的所有其他字节。可以想象一个复杂的实现,它以安全的标量代码开始,但一旦写入两个数组 "noticed" 就切换到页面剩余部分的矢量化循环。

如果数组访问对齐,可以使用类似的技巧:向量化循环可以检查标志数组是否一致为 0 或一致为 1,如果不是,则使用直接的无条件未屏蔽读取实现是安全的,否则它会回到更谨慎的实施。这种转变显然只有在掩模很少均匀或几乎总是均匀的情况下才有利可图3,因此在实践中可能不太可能实施。


2 至少如果 AVX 可用:如果将第一个示例限制为 AVX 之前的指令,icc 仍然无法对第一个示例进行矢量化,因为那是 vpmaskmovd/qvmaskmovps/pd 被引入。

3 因为在那种情况下,如果您已经确定蒙版是统一的,您可以通过 if 的选定边无条件地实施操作没有任何 masking/blending 取决于它是 uniform-0 还是 uniform-1。所以你最终得到三个内部实现的循环:全零标志情况、全一标志情况和混合标志情况,当下一个标志向量与当前循环不同时,它们之间会跳转.