C++ 程序集为啥不是由 g++ 向量化的“source 4 bytes to dest 4 bytes”赋值?

Posted

技术标签:

【中文标题】C++ 程序集为啥不是由 g++ 向量化的“source 4 bytes to dest 4 bytes”赋值?【英文标题】:C++ assembly why isn't this "source 4 bytes to dest 4 bytes" assignment vectorized by g++?C++ 程序集为什么不是由 g++ 向量化的“source 4 bytes to dest 4 bytes”赋值? 【发布时间】:2022-01-24 04:19:14 【问题描述】:
#include <stdio.h>
#include <iostream>
#include <random>
using namespace std;

volatile int res = 0;

void copy(char* __restrict__ dst, char* __restrict__ src) 
    dst[0] = src[0];
    dst[1] = src[1];
    dst[2] = src[2];
    dst[3] = src[3];


void copyOffset(char* __restrict__ dst, char* __restrict__ src, size_t offset) 
    dst[0] = src[offset + 0];
    dst[1] = src[offset + 1];
    dst[2] = src[offset + 2];
    dst[3] = src[offset + 3];


void copyAsInt(char *dst, char *src) 
    *((int*)dst) = *((int*)src);


//----
void copy16(char* __restrict__ dst, char* __restrict__ src) 
    dst[0] = src[0];
    dst[1] = src[1];
    dst[2] = src[2];
    dst[3] = src[3];
    dst[4] = src[4];
    dst[5] = src[5];
    dst[6] = src[6];
    dst[7] = src[7];
    dst[8] = src[8];
    dst[9] = src[9];
    dst[10] = src[10];
    dst[11] = src[11];
    dst[12] = src[12];
    dst[13] = src[13];
    dst[14] = src[14];
    dst[15] = src[15];    


void copyOffset16(char* __restrict__ dst, char* __restrict__ src, size_t offset) 
    dst[0] = src[offset + 0];
    dst[1] = src[offset + 1];
    dst[2] = src[offset + 2];
    dst[3] = src[offset + 3];
    dst[4] = src[offset + 4];
    dst[5] = src[offset + 5];
    dst[6] = src[offset + 6];
    dst[7] = src[offset + 7];
    dst[8] = src[offset + 8];
    dst[9] = src[offset + 9];
    dst[10] = src[offset + 10];
    dst[11] = src[offset + 11];
    dst[12] = src[offset + 12];
    dst[13] = src[offset + 13];
    dst[14] = src[offset + 14];
    dst[15] = src[offset + 15];    


int main() 
    char *a = new char[1001], *b = new char[16];
    
    //--- which pair of statements below is unsafe or not equal each other?
    copyOffset(b, a, 20);
    res = b[rand() % 4]; // use b[] for something to prevent optimization

    copy(b, &a[20]);
    res = b[rand() % 4];

    //--- non 4 bytes aligned
    copyOffset(b, a, 18);
    res = b[rand() % 4];

    copy(b, &a[18]);
    res = b[rand() % 4];

    //---
    copyOffset16(b, a, 26);
    res = b[rand() % 16];

    copy(b, &a[26]);
    res = b[rand() % 16];

    return 1;

我正在尝试复制 4 个字节(确保分配源和目标)。但是,源地址可能不是 4 字节对齐的。要复制 4 个字节,我希望编译器发出一个复制 DWORD 指令,如 copyAsInt()。我正在使用-O3 -mavx 标志,并使用godbolt 和gcc 11.2 来查看汇编代码。

正如预期的那样,函数copy() 被转换为与copyAsInt() 相同。但是,由于某种原因,函数 copyOffset() 被翻译为分别复制每个字节。

copy(char*, char*):
        mov     eax, DWORD PTR [rsi]
        mov     DWORD PTR [rdi], eax
        ret
copyOffset(char*, char*, unsigned long):
        movzx   eax, BYTE PTR [rsi+rdx]
        mov     BYTE PTR [rdi], al
        movzx   eax, BYTE PTR [rsi+1+rdx]
        mov     BYTE PTR [rdi+1], al
        movzx   eax, BYTE PTR [rsi+2+rdx]
        mov     BYTE PTR [rdi+2], al
        movzx   eax, BYTE PTR [rsi+3+rdx]
        mov     BYTE PTR [rdi+3], al
        ret

同时,函数 copy16() 和 copyOffset16() 都按预期进行了矢量化。

copy16(char*, char*):
        vmovdqu xmm0, XMMWORD PTR [rsi]
        vmovdqu XMMWORD PTR [rdi], xmm0
        ret
copyOffset16(char*, char*, unsigned long):
        vmovdqu xmm0, XMMWORD PTR [rsi+rdx]
        vmovdqu XMMWORD PTR [rdi], xmm0
        ret

那么为什么编译器不优化copyOffset() 以使用mov DWORD?另外,main() 中是否有任何不安全或可能出现意外行为的语句对?

编辑:切换到 x86-64 gcc(主干)会导致 gcc 发出预期的指令。所以我猜这种行为只是由于编译器启发式的。

【问题讨论】:

TL:DR:这是 GCC11 中错过的优化,已在主干中修复。还有一个在 clang 中错过的优化,通过在 AL 中使用 byte-mov 使情况变得更糟,导致错误的依赖。当前接受的答案几乎在它提出的每个单独的点上都是错误的,包括它链接的 Agner 指令表中的数字; IMO 您应该不接受它,以免误导未来的读者。 【参考方案1】:

因为这样更有效。 XMM (SSE) 或任何其他 SIMD 指令通常往往非常繁重,因此具有非常高的延迟。编译器会考虑到这一点。

这种对 SSE/AVX 的痴迷确实被夸大了。 SSE/AVX 在非常具体的优化中可能很有用,但在 99% 的编译器使用它们的情况下,它们实际上是无效的。

轶事证据,但我曾经使用“-march=x86_64”重新编译了一个非常大的优化二进制文件,它删除了几乎所有 SIMD 扩展,并且性能实际上比使用“-march=native”更好。

一个简单的 MOVB(字节)操作具有 1 个周期的延迟,并且可以在 Intel Skylake 上同时执行多达 4 个操作。所以里面的8条指令都可以在2个周期内有效执行。

MOVUPS 指令有大约 5 个周期延迟,最大吞吐量为 2(同时),具体取决于平台,您需要其中两个:从内存到 %xmm0 和从 %xmm0 到内存,总共从 5到 10 个周期。

当然,由于管道行为、微操作、端口、L1/2/3 缓存等原因,事情比这要复杂得多,但这是解释编译器在做什么的第一次尝试。

参考:https://www.agner.org/optimize/instruction_tables.pdf

代码:https://godbolt.org/z/3KjxfdW3W

【讨论】:

但它不需要使用大的 SIMD 指令。为什么它不使用简单的移动 DWORD 指令,例如在 copyAsInt() 中? 实际上 GCC 完全按照您所说的进行。 godbolt.org/z/zf5h9xrqz 这是不同启发式公式的问题,我敢打赌。 是的,这是正确的 - 适用于 x86,不适用于 ARM 等其他架构 @HuyLe:要在 C 中安全地表达未对齐的严格别名安全加载或存储,请使用 memcpy 或 GNU C typedef uint32_t aliasing_unaliged_uint32 __attribute__((aligned(1), may_alias)),如 Why does unaligned access to mmap'ed memory sometimes segfault on AMD64? 和 Why does glibc's strlen need to be so complicated to run quickly? 如果它是真的,这个答案甚至没有意义,GCC 对复制的 4 个字节的行为奇怪的代码,所以在那里使用向量加载/存储没有意义,进行 DWORD 加载/存储是有意义的,这就是 GCC 未能做到的。

以上是关于C++ 程序集为啥不是由 g++ 向量化的“source 4 bytes to dest 4 bytes”赋值?的主要内容,如果未能解决你的问题,请参考以下文章

为啥这个 C 向量循环不自动向量化?

如何向矢量化数据集添加特征?

为啥 C++ 友元类只需要在其他命名空间中进行前向声明?

为啥库需要硬编码矢量化而不是编译器自动矢量化

为啥 python 不能矢量化 map() 或列表推导

C++向量化双循环