【问题标题】:What is vmovdqu doing here?vmovdqu 在这里做什么?
【发布时间】:2018-05-16 16:36:27
【问题描述】:

我有一个如下所示的 Java 循环:

public void testMethod() {
    int[] nums = new int[10];
    for (int i = 0; i < nums.length; i++) {
        nums[i] = 0x42;
    }
} 

我得到的程序集是这样的:

0x00000001296ac845: cmp    %r10d,%ebp
0x00000001296ac848: jae    0x00000001296ac8b4
0x00000001296ac84a: movl   $0x42,0x10(%rbx,%rbp,4)
0x00000001296ac852: inc    %ebp               
0x00000001296ac854: cmp    %r11d,%ebp
0x00000001296ac857: jl     0x00000001296ac845  

0x00000001296ac859: mov    %r10d,%r8d
0x00000001296ac85c: add    $0xfffffffd,%r8d
0x00000001296ac860: mov    $0x80000000,%r9d
0x00000001296ac866: cmp    %r8d,%r10d
0x00000001296ac869: cmovl  %r9d,%r8d
0x00000001296ac86d: cmp    %r8d,%ebp
0x00000001296ac870: jge    0x00000001296ac88e
0x00000001296ac872: vmovq  -0xda(%rip),%xmm0                                                    
0x00000001296ac87a: vpunpcklqdq %xmm0,%xmm0,%xmm0
0x00000001296ac87e: xchg   %ax,%ax

0x00000001296ac880: vmovdqu %xmm0,0x10(%rbx,%rbp,4)  
0x00000001296ac886: add    $0x4,%ebp          
0x00000001296ac889: cmp    %r8d,%ebp
0x00000001296ac88c: jl     0x00000001296ac880  

如果我的理解是正确的,那么第一个程序块就是执行nums[i] = 0x42; 的程序块。在第三块,有vmovdqu

vmovdqu 指令将值从整数向量移动到未对齐的内存位置。

但是,我仍然不完全理解 vmovdqu 在我的循环上下文中所做的事情。

第三段汇编代码到底在做什么?

完整的代码在这里:https://pastebin.com/cT5cJcMS

【问题讨论】:

  • @Sneftel 啊,为了简洁起见,我省略了它。我将添加实际的汇编代码。 :)
  • 这看起来确实是自动矢量化的结果,但你真的应该发布整个代码,缺少几个重要部分。
  • @MatteoItalia 用完整代码的链接更新了帖子:)
  • 另外,它真的只从这段代码之前的0xda字节加载数据吗?通常将代码和数据保存在同一页面中没有任何优势,因为现代 x86 使用单独的 iTLB 和 dTLB。使用 AVX2,我会将0x42 放入寄存器并使用vmovd %eax, %xmm0 / vpbroadcastd %xmm0, %xmm0(或vpshufd),而不是从内存中加载常量。并且在内存中复制0x42 两次是没有意义的,除非他们的 JIT 优化器只是在洗牌时很糟糕。我想我们确实必须考虑这样一个事实,即 JIT 必须快速编译并且不需要花费时间来制作更优化的代码。
  • 最糟糕的是,如果new 成功,nums.length 应该是编译时常量,那么您可以用 2 vmovdqu xmm + 1 vmovq xmm 填充数组(完全展开环形)。我想知道使用10 作为循环绑定是否有帮助?或者由于没有返回nums,所以整个函数应该只是一个ret,或者内联后什么都没有。

标签: assembly x86 avx java-bytecode-asm jvm-hotspot


【解决方案1】:

您的 JIT 编译器自动矢量化您的循环,每次 asm 迭代存储 4 个 ints。

但它使 asm 过于复杂并且错过了许多优化。我想知道这是否可能只是 JIT 编译器决定完全执行之前的第一阶段代码生成优化?

您的代码不会返回nums,因此它在创建后立即被销毁。内联后,你的函数应该优化到完全没有指令。或者作为一个独立的函数,应该只是一个ret。分配内存然后让它被垃圾回收并不是优化器需要保留的可观察到的副作用。

那么,如果new 成功,那么nums.length 将是10。所以代码可以很简单

# %rbx holds a pointer to the actual data storage for nums[]
vbroadcastss  -0x????(%rip),%xmm0      # broadcast-load the 0x42 constant into xmm0
vmovdqu       %xmm0,   (%rbx)          # nums[0..3] : 16 bytes
vmovdqu       %xmm0, 16(%rbx)          # nums[4..7] : 16 bytes
vmovq         %xmm0, 32(%rbx)          # nums[8..9] : 8 bytes

在这里完全展开循环是最有意义的;设置循环计数器等比几个商店需要更多的指令和代码大小。特别是当大小不是向量宽度的倍数时,无论如何都必须对最后一个部分向量进行特殊处理。

顺便说一句,如果您的大小是 11 而不是 10,您可以进行 8 + 4 字节存储,或者部分重叠的 16 字节存储,例如16 字节的vmovdqu 存储到(%rbx)16(%rbx)28(%rbx),覆盖nums[7..11]。在数组末尾结束的最终未对齐向量是手动向量化时的常用策略(或在 glibc 对memcpy 的小缓冲区处理中),但即使提前编译器似乎也不会使用它。


其他明显错过的优化:

  • vmovq 加载 + vpunpcklqdq 广播。在 AVX 可用的情况下,vbroadcastss 是迄今为止从内存广播 32 位常量的最佳方式。一条不需要 ALU uop 的指令。也许 JIT 编译器实际上并不知道新的 AVX 指令?

  • mov %r10d,%r8d + add $-3,%r8d:这显然应该是lea -3(%r10), %r8d

不清楚%ebp的起始值应该是多少;如果 JVM 在某处对缓冲区的块进行切片,因此 RBX 不是数组的基础,那么在标量循环之前 EBP 可能不是 0? IDK 为什么标量循环的循环边界在寄存器中,而不是立即数。

将静态数据与代码放在同一页面中很奇怪(-0xda(%rip) 仍在同一页面中)。没有大的损失,但这意味着同一页面需要在 iTLB 和 dTLB 中,因此与使用单独页面相比,您覆盖的总代码 + 数据更少。不过,对于 2M 大页面来说,这并不是什么大问题。共享的 2 级 TLB 是受害者缓存 (IIRC),因此填充它的 iTLB 未命中可能不会帮助vmovq 负载获得 TLB 命中。它可能会进行第二页遍历。


我不知道为什么即使是像 gcc 和 clang 这样的优秀的提前 C 编译器也会使这变得如此复杂,因为在一个未知对齐和长度的数组上进行循环。

void set42(int *nums, unsigned long int len) {
    for (unsigned long int i=0 ; i<len ; i++ ) {
        *nums++ = 0x42;
    }
}

对于没有循环展开的 128 位向量,这是我要手动执行的操作(并且乐观地假设它不值得达到对齐边界,例如您的 JIT,以及 clang 和 gcc8及以上):

# x86-64 System V calling convention: int*nums in RDI,  len in RSI
set42:
    cmp           $4, %rsi
    jb          .Lsmall_count

    lea          -16(%rdi, %rsi,4), %rdx    # pointer to end-16, the start of the final vector store
    vbroadcastss constant(%rip), %xmm0
 .p2align 4
 .Lvector:                          # do {
    vmovdqu      %xmm0, (%rdi)
    add          $16, %rdi          # nums += 4 elements
    cmp          %rdx, %rdi
    jb           .Lvector           # while(nums < end-16);

    # only reached for sizes >= 16 bytes so we can always store a full possibly-overlapping final vector
    # for len = 16, this results in 2 stores to the same address, but that's cheaper than extra branches even if len=16 is common
    vmovdqu      %xmm0, (%rdx)   # final potentially-overlapping vector
    ret

.Lsmall_count:
    test         %rsi,%rsi
    jz          .Ldone
   # some compilers will fully unroll this with a chain of branches
   # maybe worth doing if small inputs are common
  .Lscalar:                       # do {
    movl        0x42, (%rdi)
    add         $4, %rdi          # *num++ = 0x42;
    dec         %rsi
    jnz                           # }while(--len);
  # a more sophisticated cleanup strategy using SIMD is possible, e.g. 8-byte stores,
  # but I haven't bothered.

.Ldone:
    ret

请注意,对于len&gt;=4,顶部有一个直通分支,然后只有循环分支。总开销为 1 个宏融合 cmp/jcc、1 个广播负载和 1 个lea。循环为 3 微指令,采用非索引寻址模式。

AFAIK,编译器不知道如何有效地使用可能重叠的最后一个向量。大多数时候它比标量清理要好得多。请注意,对于 len=4(16 字节),我们执行相同的向量存储两次。但是对于 len=8(32 字节),循环在第一次迭代后退出,所以我们仍然只进行 2 次存储。即,对于向量宽度的任何精确倍数不是 1,我们不会进行重叠存储。对于 len=4 和 len=8 以相同的方式进行分支实际上对于分支预测非常有用。


即使是优秀的提前 C 编译器也会使这变得超级复杂,as you can see on the Godbolt compiler explorer。 clang 的一些复杂性来自于展开更多; clang6.0 展开了很多次。 (我选择了编译器版本和选项,使代码最简单。gcc7.3 和 clang6.0 为此发出了更大的函数。)

gcc7 和更早的版本是标量直到对齐边界,然后使用对齐的向量存储。如果您预计指针经常错位,这可能会很好,但通常情况下保存指令以使对齐的情况更便宜是很好的,而且对错位存储的惩罚很低。

【讨论】:

    【解决方案2】:

    优化器已选择向量化您的循环,每次“迭代”设置 4 个值。 (vmovdqu 之前的指令是相当不透明的,但大概它会将0x42 喷射到XMM0 的所有通道中。)“未对齐”变体是必要的,因为不能保证数组在内存中是 SIMD 对齐的(之后总而言之,它存储的是int32s,而不是int32x4s)。

    【讨论】:

    • 好的,你能告诉我第一个循环的需要是什么吗? (组装的第一块):)
    • 矢量化循环一次只能设置四个元素。第一个块拾取“备件”,因此剩余的元素数量是 4 的倍数。
    • 没错。请注意,调用所有这些机制来设置十个元素是非常愚蠢的。这样做只是因为它不够聪明,无法确定nums.length 将始终为 10。
    • 我不会这样称呼它。身体不会被多次复制。矢量化是一个更好的词。
    • @LittleChild:矢量化是循环展开 + 与 SIMD 并行进行多次迭代。如果您展开更多并且每次 asm 循环迭代执行多个向量,通常只会将其称为“展开”。
    【解决方案3】:

    编译器已展开循环以启用矢量化。

    // 10d holds the length of the array and ebp holds the loop index.
    0x00000001296ac845: cmp    %r10d,%ebp
    // This branch is only taken when the loop index `i` is larger or equal to `nums.length`.
    0x00000001296ac848: jae    0x00000001296ac8b4
    // Performs a single iteration.
    0x00000001296ac84a: movl   $0x42,0x10(%rbx,%rbp,4)
    // Increment the loop index.
    0x00000001296ac852: inc    %ebp
    // r11d contains some constant. This is just to ensure that the number of any remaining iterations is multiple of 4.             
    0x00000001296ac854: cmp    %r11d,%ebp
    // This branch is NOT taken (falls through) only when either zero iterations are left of when the number of remaining iterations is a multiple of 4.
    0x00000001296ac857: jl     0x00000001296ac845  
    // These instructions make sure that the loop index does not overflow.
    0x00000001296ac859: mov    %r10d,%r8d
    0x00000001296ac85c: add    $0xfffffffd,%r8d
    0x00000001296ac860: mov    $0x80000000,%r9d
    0x00000001296ac866: cmp    %r8d,%r10d
    0x00000001296ac869: cmovl  %r9d,%r8d
    // The next two instructions check whether there are any remaining iterations.
    0x00000001296ac86d: cmp    %r8d,%ebp
    0x00000001296ac870: jge    0x00000001296ac88e
    // If we reach here, the number of remaining iterations must be a multiple of 4.
    // Initialize xmm0 with 4 copies of 0x42.
    0x00000001296ac872: vmovq  -0xda(%rip),%xmm0                                                    
    0x00000001296ac87a: vpunpcklqdq %xmm0,%xmm0,%xmm0
    // This is a NOP just to align the loop on a 64-byte cache line boundary for performance.
    0x00000001296ac87e: xchg   %ax,%ax
    // Vectorized 4 iterations of the loop.
    0x00000001296ac880: vmovdqu %xmm0,0x10(%rbx,%rbp,4)  
    0x00000001296ac886: add    $0x4,%ebp          
    0x00000001296ac889: cmp    %r8d,%ebp
    0x00000001296ac88c: jl     0x00000001296ac880
    // All iterations have been executed at this point.
    

    【讨论】:

    • 我假设 JIT 只会尝试将循环分支对齐到 8 或 16 个字节。碰巧它也是一个 64 字节的边界。一般来说,将循环对齐到 64 字节是非常不寻常和浪费的。 (我有时会在微基准测试中这样做,只是为了双重确保前端没有任何混淆因素,但普通编译器不会。)
    • @PeterCordes 是的,我不确定 64 字节对齐是否是故意的。
    • 我已经足够接近,可以确定这不是故意的:P 现代 CPU 上的循环缓冲区使小循环的对齐变得不重要。 (虽然带有更新微码的 Skylake 没有循环缓冲区,Kaby Lake 也没有,因此将一个小循环分成两个 uop 缓存行可以使其以一半的速度发出。不过,gcc 最多对齐 16 个字节,然后仅当它需要少于 10 个字节的填充或类似的东西时。)
    猜你喜欢
    • 1970-01-01
    • 2011-01-28
    • 2012-03-20
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多