vmovdqu 在这里做什么?

What is vmovdqu doing here?

我有一个看起来像这样的 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   [=11=]x42,0x10(%rbx,%rbp,4)
0x00000001296ac852: inc    %ebp               
0x00000001296ac854: cmp    %r11d,%ebp
0x00000001296ac857: jl     0x00000001296ac845  

0x00000001296ac859: mov    %r10d,%r8d
0x00000001296ac85c: add    [=11=]xfffffffd,%r8d
0x00000001296ac860: mov    [=11=]x80000000,%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    [=11=]x4,%ebp          
0x00000001296ac889: cmp    %r8d,%ebp
0x00000001296ac88c: jl     0x00000001296ac880  

如果我的理解是正确的,第一个汇编块就是nums[i] = 0x42;。在第三块中,有 vmovdqu 其中

The vmovdqu instruction moves values from an integer vector to an unaligned memory location.

但是,我仍然不完全理解 vmovdqu 在我的循环上下文中做了什么。

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

完整代码可在此处获得:https://pastebin.com/cT5cJcMS

优化器已选择向量化您的循环,为每个 "iteration" 设置 4 个值。 (vmovdqu 之前的指令相当不透明,但大概是将 0x42 拼写到 XMM0 的所有通道中。)"unaligned" 变体是必要的,因为数组不能保证在内存中对齐 SIMD(毕竟,它存储 int32s,而不是 int32x4s)。

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

// 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   [=10=]x42,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    [=10=]xfffffffd,%r8d
0x00000001296ac860: mov    [=10=]x80000000,%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    [=10=]x4,%ebp          
0x00000001296ac889: cmp    %r8d,%ebp
0x00000001296ac88c: jl     0x00000001296ac880
// All iterations have been executed at this point.

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

但是它使 asm 变得过于复杂并且错过了 很多 的优化。 我想知道这是否只是第一阶段的代码- gen 在 JIT 编译器决定完全优化之前?

您的代码没有 return 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 微指令的指令。也许 JIT 编译器实际上并不知道新的 AVX 指令?

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

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

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


我不知道为什么即使像 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           , %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          , %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         , %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>=4,顶部有一个 fall-through 分支,然后只有循环分支。总开销为 1 个宏融合 cmp/jcc、1 个广播负载和 1 个 lea。循环为 3 微指令,采用非索引寻址模式。

据我所知,编译器不知道如何有效地使用可能重叠的最后一个向量。在大多数情况下,它比标量清理要好得多。请注意,对于 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 和更早的版本去标量直到对齐边界,然后使用对齐的向量存储。如果您 期望 指针经常未对齐,这可能会很好,但是在通常情况下保存指令以使对齐的情况更便宜是好的,并且对未对齐存储的惩罚很低。