如何阻止 LLVM 将位掩码转换为分支?

Del*_*mer 13 c++ llvm compiler-optimization

对于上下文,假设我正在编写一个字节码解释器,其中指令遵循两种格式之一:

\n
+---+--------+------+------+----------------+\n| 1 | opcode | treg | areg |     unused     |\n+---+--------+------+------+----------------+\n32  31       20     16     12               0\n\n+---+--------+------+-----------------------+\n| 0 | opcode | treg |          imm          |\n+---+--------+------+-----------------------+\n32  31       20     16                      0\n
Run Code Online (Sandbox Code Playgroud)\n

对于每个操作码,有两个选项来提供其第二个操作数:它可以是寄存器或立即值。该选择由指令字的位 31 执行。如果 MSB 为 0 \xe2\x80\x94,则第二个操作数是寄存器的内容,由指令字的位 [12:16] 索引。如果 MSB 为 1 \xe2\x80\x94,则第二个操作数是指令本身的位 [0:16],符号扩展为完整机器字。

\n

检索第二个操作数的简单算法只需根据 MSB 在两个单独的解析器之间切换:

\n
reg_t decode_arg2(uint32_t instr, reg_t const* regs) {\n    if (instr & (1 << 31)) {\n        return regs[(instr >> 12) & 15];\n    } else {\n        return (int16_t)instr;\n    }\n}\n
Run Code Online (Sandbox Code Playgroud)\n

另一种可能性是使用无分支算法,我们将两个可能的值加载到临时变量中,然后使用位魔法选择其中一个:

\n
reg_t decode_arg2_branchless(uint32_t instr, reg_t const* regs) {\n    reg_t mask = (int32_t)instr >> 31;\n    reg_t reg = regs[(instr >> 12) & 15];\n    reg_t imm = (int16_t)instr;\n    return (reg & mask) | (imm & ~mask);\n}\n
Run Code Online (Sandbox Code Playgroud)\n

使用 GCC 编译这两个函数并在人工压力测试中比较它们的性能后,无分支版本比分支版本快约 2.5 倍。因此,根据这一观察,希望位魔法保留在最终实现中似乎是合理的。

\n

然而,当我们用 Clang 编译相同的代码时,它会尝试巧妙地使用无分支版本 \xe2\x80\x94,它正确地检测到这两个函数本质上执行相同的操作,但同样的知识使其发出具有完整分支的代码对于这两种情况。在 Godbolt 上,使用 Clang 16.0.0-O2生成此程序集:

\n
decode_arg2(unsigned int, long const*):\n        test    edi, edi\n        js      .LBB0_1\n        movsx   rax, di\n        ret\n.LBB0_1:\n        shr     edi, 12\n        and     edi, 15\n        mov     rax, qword ptr [rsi + 8*rdi]\n        ret\ndecode_arg2_branchless(unsigned int, long const*):\n        test    edi, edi\n        jns     .LBB1_1\n        shr     edi, 12\n        and     edi, 15\n        mov     rax, qword ptr [rsi + 8*rdi]\n        ret\n.LBB1_1:\n        movsx   rax, di\n        ret\n
Run Code Online (Sandbox Code Playgroud)\n

似乎没有明显的方法来阻止 Clang 这样做,所以这就是主要问题出现的地方:如何阻止 Clang 将位掩码转换为完整分支?

\n
\n

到目前为止我自己发现的一些花絮:

\n
    \n
  • 在 Godbolt 上,您可以在“LLVM Opt Pipeline”窗口中详细查看每个优化过程。使用这个,我们可以看到:

    \n
      \n
    1. 最初,InstCombinePass检测我们使用的掩码模式并将其替换为select内在函数(对应于三元运算符)。
    2. \n
    3. 然后,将X86 DAG->DAG Instruction Selection更改selectCMOV。这不是我们最初写的,但应该没问题。
    4. \n
    5. 最后,X86 cmov Conversion出于某种原因,认为这CMOV实际上并不适合这种情况,并将其扩展为真正的分支。
    6. \n
    \n

    因此,如果我们可以禁用这些通道之一,问题就应该得到解决。但是,我没有找到禁用特定优化通道的官方方法。即使有一个,我希望它能够适用于单个功能的规模(这样它们仍然可以在其他地方工作,在那里它们更有可能受益),并通过整个程序优化“生存”在链接阶段。

    \n
  • \n
  • 一些消息来源建议用 标记分支条件__builtin_unpredictable,以阻止X86 cmov Conversion扩大选择范围。由于源代码中没有实际的分支,我什至不知道将其放在哪里。将其挂在mask\ 的值上似乎没有效果。

    \n
  • \n
  • 确实可以将函数体硬编码为内联汇编,但我认为这是最后的手段,只有在没有其他方法使 LLVM 发出我想要的代码时才使用。

    \n
  • \n
  • 如果用算术替换按位运算,如下所示:

    \n
    decode_arg2(unsigned int, long const*):\n        test    edi, edi\n        js      .LBB0_1\n        movsx   rax, di\n        ret\n.LBB0_1:\n        shr     edi, 12\n        and     edi, 15\n        mov     rax, qword ptr [rsi + 8*rdi]\n        ret\ndecode_arg2_branchless(unsigned int, long const*):\n        test    edi, edi\n        jns     .LBB1_1\n        shr     edi, 12\n        and     edi, 15\n        mov     rax, qword ptr [rsi + 8*rdi]\n        ret\n.LBB1_1:\n        movsx   rax, di\n        ret\n
    Run Code Online (Sandbox Code Playgroud)\n

    它仍然比按位版本慢大约 15%(再次使用 GCC 进行测试),但它现在已经足够模糊,可以让 LLVM 使用实际的imuladd“插值”按原样转换代码。因此,这种方法可以用作内联汇编之后的下一个最佳方法。尽管如此,还是有 15% 的速度下降,而且这是在我的特定 CPU 上(所以在其他 CPU 上情况可能会更糟);并且 LLVM 的未来版本也有可能开始检测这种模式。

    \n
  • \n
\n
\n

如果您想自己测试性能,这里是我使用的源代码:

\n
reg_t decode_arg2_interp(uint32_t instr, reg_t const* regs) {\n    reg_t t = instr >> 31;\n    reg_t reg = regs[(instr >> 12) & 15];\n    reg_t imm = (int16_t)instr;\n    return reg * t + imm * (1 - t);\n}\n
Run Code Online (Sandbox Code Playgroud)\n

编译它g++ -DBRANCH decode.cpp -o decode_branch -O2(替换-DBRANCH为适当的变体)并运行time ./decode_branch。请注意,要获取实际-DBIT版本,您需要专门使用 GCC \xe2\x80\x94,因为这是这个问题的主题,Clang (LLVM) 在优化过程中将其替换为等效的-DBRANCH.

\n

Hom*_*512 3

这是一种有效的方法,不依赖于特定于 x86 的行为(例如廉价的原子加载),并且据我估计很有可能继续工作:添加asm引用reg变量的空语句。

这似乎工作正常并生成良好、简短的代码。

reg_t decode_arg2(uint32_t instr, reg_t const* regs) {
    reg_t reg = regs[(instr >> 12) & 15];
    asm("" : "+r"(reg));
    reg_t imm = (int16_t)instr;
    return (int32_t) instr < 0 ? reg : imm;
}
Run Code Online (Sandbox Code Playgroud)

使用 clang-16 生成的程序集-O2

        mov     eax, edi
        shr     eax, 12
        and     eax, 15
        mov     rcx, qword ptr [rsi + 8*rax]
        movsx   rax, di
        test    edi, edi
        cmovs   rax, rcx
        ret
Run Code Online (Sandbox Code Playgroud)

只要 clang 没有获得检查内联汇编的能力,我认为我们就没有问题。我对其工作原理的假设(有更好洞察力的人可能会纠正我):与具有分支的版本相比,通过内存负载的依赖链非常长。通过添加一些程序集,我们可以防止 clang 评估依赖链。

在查看了其他编译​​目标(例如 RISC-V)之后,即使具有显式屏蔽的代码版本也会创建分支,并将代码与asm volatilejust进行比较asm,我认为这实际上只是对依赖链的评估。

  • 我很惊讶 `asm` (不是 `asm volatile`)适用于此,但它适用于 GCC 和 clang https://godbolt.org/z/KTjYx375G。如果没有“asm 易失性”,如果结果未使用,则可以优化 asm 语句,例如三元组的“imm”一侧。它仍然会让编译器忘记它对值的了解,例如阻止常量传播,并要求编译器在进一步使用该 C++ 变量时在寄存器中具体化该变量。所以我想这确实有效。是的,如果 clang 正在做的话,它很有可能击败了深度链分析。 (2认同)