加载字节时,Clang 不会将高位清零。这是错误还是故意的选择?

Clang does not zero the upper bits when loading a byte. Is this a bug or a deliberate choice?

例如,有了这个功能,

void mask_rol(unsigned char *a, unsigned char *b) {
    a[0] &= __rolb(-2, b[0]);
    a[1] &= __rolb(-2, b[1]);
    a[2] &= __rolb(-2, b[2]);
    a[3] &= __rolb(-2, b[3]);
    a[4] &= __rolb(-2, b[4]);
    a[5] &= __rolb(-2, b[5]);
    a[6] &= __rolb(-2, b[6]);
    a[7] &= __rolb(-2, b[7]);
}

gcc 产生,

mov     edx, -2
mov     rax, rdi

movzx   ecx, BYTE PTR [rsi]
mov     edi, edx
rol     dil, cl
and     BYTE PTR [rax], dil
...

虽然我不明白为什么它会填充 dxax,但这是来自 clang.

mov     cl, byte ptr [rsi]
mov     al, -2
rol     al, cl
and     byte ptr [rdi], al
...

它不会像 gcc 那样做看似不必要的 mov,但它也不关心使用 movzx.

清除高位

据我所知,gccmovzx的原因是为了去除脏高位的虚假依赖,但也许clang也有不做的理由,所以我 运行 一个简单的基准测试,这就是结果。

$ time ./rol_gcc
 2161860550

real    0m0.895s
user    0m0.877s
sys     0m0.002s

$ time ./rol_clang
 3205979094

real    0m1.328s
user    0m1.311s
sys     0m0.001s

至少在这种情况下,clang的做法似乎是错误的。

这显然是 clang 的错误,还是在某些情况下 clang 的方法可以产生更高效的代码?


基准代码

#include <stdio.h>
#include <x86intrin.h>

__attribute__((noinline))
static void mask_rol(unsigned char *a, unsigned char *b) {
    a[0] &= __rolb(-2, b[0]);
    a[1] &= __rolb(-2, b[1]);
    a[2] &= __rolb(-2, b[2]);
    a[3] &= __rolb(-2, b[3]);
    a[4] &= __rolb(-2, b[4]);
    a[5] &= __rolb(-2, b[5]);
    a[6] &= __rolb(-2, b[6]);
    a[7] &= __rolb(-2, b[7]);
}

static unsigned long long rdtscp() {
    unsigned _;
    return __rdtscp(&_);
}

int main() {
    unsigned char a[8] = {0}, b[8] = {7, 0, 6, 1, 5, 2, 4, 3};
    unsigned long long c = rdtscp();
    for (int i = 0; i < 300000000; ++i) {
        mask_rol(a, b);
    }
    printf("%11llu\n", rdtscp() - c);
    return 0;
}

clang/LLVM 通常对虚假依赖不计后果。它试图避免在循环中创建 loop-carried dep 链 在单个函数 中,我认为,但是你已经通过制作这个小的 frequently-called 函数 frequently-called 击败了它 noinline.

避免 xor-zeroing 整数或向量 reg 的整个指令有时可能值得冒险,但为 mov al 节省 1 个字节的代码而不是 movzx eax 似乎不值得风险。多年来,所有 x86 CPUs 都拥有高效的 movzx 加载。

- a non-inline function call creates a loop-carried dep chain due to clang's cavalier attitude towards false dependencies. In that case on XMM registers, rather than scalar int where P6-family partial register renaming would actually break the false dep, also on Sandybridge. But not on Haswell and later, which doesn't rename low8 separately from full registers:

几乎重复

所以是的,这是一个 clang missed-optimization 错误,或者它的启发式方法没有得到回报的情况。我很好奇在 不需要 的代码中,clang 始终使用 movzx 进行窄负载会产生多大的差异(正面或负面),以避免 loop-carried 错误的依赖关系。

如果在不同的 CPU 类型上所有的缺点都很微小,或者至少被避免像这样的减速的巨大优点所平衡,Clang 可能应该改变这一点。 (通过不需要加载+合并,只需加载,在 RS 中需要更少的 back-end uops 占用 space,从而提高性能。现代英特尔将 mov al, mem 解码为 micro-fused 加载+ ALU.)

或者如果由于某种原因 always-movzx 策略总体上不是更好,它仍然应该在这个长的 non-looping 深度链中的某处使用一个,比如每个中间至少一个AL 和 CL,创建更多的 ILP,即使函数只运行一次。 And/or 交替使用 AL 和 DL 之类的。 (clang 13 令人惊讶地使用 DL 作为最后一个字节,但 AL 用于前 7 个字节:https://godbolt.org/z/7PYWGxsse - 在以后的问题中,最好包含您自己的编译器资源管理器 link,其版本/选项与什么匹配你测试过。)


While I don't understand why it is filling dx and ax

看起来 GCC 正在重用相同的 -2 常量,使用 mov edi, edx(2 个字节)八次而不是 mov edi, -2(5 个字节)八次。也许 code-size 不是原因,因为 GCC 正常会花费 code-size 来保存指令。 IDK.

此外,GCC 的寄存器分配有时 sub-optimal 围绕 hard-register 约束,例如函数参数和 return 值。所以是的,它只是在浪费指令将传入指针复制到 RAX。该函数没有 return 它。 dil 是寄存器循环的愚蠢选择;当 aldl 不需要时,它需要一个 REX 前缀。