【问题标题】:Why does division by 3 require a rightshift (and other oddities) on x86?为什么在 x86 上除以 3 需要右移(和其他奇怪的东西)?
【发布时间】:2020-12-04 15:21:20
【问题描述】:

我有以下 C/C++ 函数:

unsigned div3(unsigned x) {
    return x / 3;
}

When compiled using clang 10-O3,这会导致:

div3(unsigned int):
        mov     ecx, edi         # tmp = x
        mov     eax, 2863311531  # result = 3^-1
        imul    rax, rcx         # result *= tmp
        shr     rax, 33          # result >>= 33
        ret

我的理解是:除以 3 相当于乘以乘法逆 3-1 mod 232 即 2863311531。

虽然有些东西我不明白:

  1. 为什么我们需要使用ecx/rcx?我们不能直接将raxedi 相乘吗?
  2. 为什么要在 64 位模式下进行乘法运算?将eaxecx 相乘不是更快吗?
  3. 为什么我们使用imul 而不是mul?我以为模算术都是无符号的。
  4. 最后的 33 位右移是怎么回事?我认为我们可以只删除最高的 32 位。

编辑 1

对于那些不明白我所说的 3-1 mod 232 的人,我在这里谈论的是乘法逆。 例如:

// multiplying with inverse of 3:
15 * 2863311531      = 42949672965
42949672965 mod 2^32 = 5

// using fixed-point multiplication
15 * 2863311531      = 42949672965
42949672965 >> 33    = 5

// simply dividing by 3
15 / 3               = 5

所以乘以 42949672965 实际上相当于除以 3。我假设 clang 的优化是基于模运算的,而实际上它是基于定点运算的。

编辑 2

我现在意识到乘法逆只能用于没有余数的除法。例如,将 1 乘以 3-1 等于 3-1,而不是零。只有定点算术具有正确的舍入。

不幸的是,clang 没有使用任何模运算,在这种情况下它只是一条 imul 指令,即使它可以。以下函数的编译输出与上述相同。

unsigned div3(unsigned x) {
    __builtin_assume(x % 3 == 0);
    return x / 3;
}

(关于适用于每个可能输入的精确除法的定点乘法逆的规范问答:Why does GCC use multiplication by a strange number in implementing integer division? - 不完全重复,因为它只涵盖数学,而不是一些实现细节,如寄存器宽度和 imul vs.数)

【问题讨论】:

  • 我认为是因为 ecx 和 edx 是 32 位寄存器,而 rax 和 rcx 是 64 位寄存器。
  • 您的所有问题都可以通过一个简单的语句来回答。编译器优化设计者认为这个序列是最有效和最优化的除法方式。通常,这些决策是根据一组标准做出的,例如最小化使用的指令数量,选择最短时间的指令,最小化数据移动量,尤其是在访问 RAM 时,努力优化 CPU 指令流水线的使用,以及其他速度考虑因素。
  • @J.Schultke 为了一探究竟,研究一下clang优化器的源码
  • 2863311531 等于 3^-1 << 33。你的乘法系数太大了2^33。因此,您不能只切掉高 32 位,而必须切掉低 33 位,这是由移位完成的。
  • 除法指令通常是处理器能做的最慢的事情。任何可以完成的替换通常都会赢得执行时间。如果1/n 可以在编译时计算,那么乘以1/n 通常会更好,而不是除以n。如果我不能依赖编译器为我解决问题,我会自己手动完成。

标签: c++ assembly compilation x86-64 integer-division


【解决方案1】:

最后的 33 位右移是怎么回事?我认为我们可以只删除最高的 32 位。

您必须更多地考虑0.3333333 而不是3^(-1) mod 3,其中. 之前的0 位于高32 位,3333 位于低32 位。 这个定点运算可以正常工作,但是结果明显移位到rax的上半部分,因此CPU必须在运算后再次将结果向下移位。

为什么我们使用 imul 而不是 mul?我以为模算术都是无符号的。

没有与IMUL 指令等效的MUL 指令。使用的IMUL 变体需要两个寄存器:

a <= a * b

没有MUL 指令可以做到这一点。 MUL 指令更昂贵,因为它们将结果作为 128 位存储在两个寄存器中。 当然你可以使用遗留指令,但这不会改变结果存储在两个寄存器中的事实。

【讨论】:

  • 谢谢。起初我没有想到 clang 可能在这里使用定点算术。我只是假设它会改用模运算。唯一悬而未决的问题是为什么 edi 不被用于乘法。
  • 我查了一下,在这种情况下与edi 相乘是合法的。 edi 在函数调用期间被保留,但在这种情况下,imul 无论如何都不会修改 edi。可能是因为rdi 可以填充超过 32 位的值,所以与rdi 相乘并不安全。
  • @J.Schultke 这似乎是合理的
  • 不直接使用rdi的原因是rdi作为参数接收时的高位不一定为0。清除寄存器高 32 位的最佳方法是使用 32 位移动指令,它会自动清除它们。编译器可以使用mov edi, edi。但是 rcx 是可用的,事实证明,在某些情况下,mov ecx, edi 确实是免费的(由寄存器重命名器处理),而 mov edi, edi 不一定是免费的。
  • @prl: mov-elimination 不会让它免费;延迟和后端执行单元并不是衡量指令成本的唯一指标。值得注意的是,前端带宽很容易成为高吞吐量代码的瓶颈。并且代码大小总是相关的。 Can x86's MOV really be "free"? Why can't I reproduce this at all? 所以是的,mov ecx, edi 在每个 CPU 上至少都一样好,但不是免费的。
【解决方案2】:
  1. 我们不能直接将 rax 与 edi 相乘吗?

我们不能imul rax, rdi 因为调用约定允许调用者将垃圾留在RDI 的高位;只有 EDI 部分包含该值。内联时这不是问题;编写一个 32 位寄存器确实隐式零扩展至完整的 64 位寄存器,因此编译器通常不需要额外的指令来对 32 位值进行零扩展。

(如果无法避免,零扩展到不同的寄存器会更好,因为 limitations on mov-elimination)。

从字面上理解您的问题,不,x86 没有任何乘法指令可以将其输入之一进行零扩展,以便您将 32 位和 64 位寄存器相乘。两个输入的宽度必须相同。

  1. 为什么要在 64 位模式下进行乘法运算?

(术语:所有这些代码都在 64 位模式下运行。你问为什么是 64 位 operand-size。)

可以 mul ediEAX 与 EDI 相乘,以获得跨 EDX:EAX 的 64 位结果拆分,但 mul edi 在 Intel CPU 上是 3 uop , 与具有快速 64 位 imul 的大多数现代 x86-64 CPU 相比。 (尽管imul r64, r64 在 AMD Bulldozer 系列和一些低功耗 CPU 上速度较慢。)https://uops.info/https://agner.org/optimize/(说明表和微架构 PDF) (有趣的事实:mul rdi 实际上在 Intel CPU 上更便宜,只有 2 微秒。也许与不必对整数乘法单元的输出进行额外拆分有关,例如 mul edi必须将 64 位低半乘法器输出分成 EDX 和 EAX 两半,但这对于 64x64 => 128 位 mul 很自然。)

您想要的部分也在 EDX 中,因此您需要另一个 mov eax, edx 来处理它。 (同样,因为我们正在查看函数的独立定义的代码,而不是在内联到调用者之后。)

GCC 8.3 及更早版本确实使用 32 位 mul 而不是 64 位 imul (https://godbolt.org/z/5qj7d5)。当 Bulldozer 系列和旧的 Silvermont CPU 更相关时,这对-mtune=generic 来说并不疯狂,但是对于最近的 GCC,这些 CPU 已经过去了,它的通用调优选择反映了这一点。不幸的是,GCC 还浪费了一条 mov 指令将 EDI 复制到 EAX,使得这种方式看起来更糟:/

# gcc8.3 -O3  (default -mtune=generic)
div3(unsigned int):
        mov     eax, edi                 # 1 uop, stupid wasted instruction
        mov     edx, -1431655765         # 1 uop  (same 32-bit constant, just printed differently)
        mul     edx                      # 3 uops on Sandybridge-family
        mov     eax, edx                 # 1 uop
        shr     eax                      # 1 uop
        ret
                                  # total of 7 uops on SnB-family

mov eax, 0xAAAAAAAB / mul edi 只会是 6 uops,但仍然比:

# gcc9.3 -O3  (default -mtune=generic)
div3(unsigned int):
        mov     eax, edi                # 1 uop
        mov     edi, 2863311531         # 1 uop
        imul    rax, rdi                # 1 uop
        shr     rax, 33                 # 1 uop
        ret
                      # total 4 uops, not counting ret

很遗憾,64 位 0x00000000AAAAAAAB 不能表示为 32 位符号扩展立即数,因此 imul rax, rcx, 0xAAAAAAAB 不可编码。这意味着0xFFFFFFFFAAAAAAAB

  1. 为什么我们使用 imul 而不是 mul?我以为模算术都是无符号的。

它没有签名。输入的符号只影响结果的高半部分,但imul reg, reg 不会产生高半部分。只有 mulimul 的单操作数形式是 NxN => 2N 的完全乘法,因此只有它们需要单独的有符号和无符号版本。

只有imul 具有更快、更灵活的仅低半形式。关于imul reg, reg 的唯一签名是它根据低半部分的签名溢出设置OF。不值得花更多的操作码和更多的晶体管来拥有一个 mul r,r,它与 imul r,r 的唯一区别是 FLAGS 输出。

英特尔的手册 (https://www.felixcloutier.com/x86/imul) 甚至指出它可以用于未签名的事实。

  1. 最后的 33 位右移是怎么回事?我认为我们可以只删除最高的 32 位。

不,没有乘数常数可以为每个可能的输入 x 提供准确的正确答案,如果您以这种方式实现它。“as-if”优化规则不允许近似值,只有为程序使用的每个输入产生完全相同的可观察行为的实现。在不知道x 的完整范围之外的unsigned 的值范围的情况下,编译器没有该选项。 (-ffast-math 仅适用于浮点数;如果您想要更快的整数数学近似值,请手动编码,如下所示):

请参阅Why does GCC use multiplication by a strange number in implementing integer division?,了解有关编译器用于按编译时间常数进行精确除法的定点乘法逆方法的更多信息。

有关此在一般情况下工作的示例,请参阅我对Divide by 10 using bit shifts? 上提出的答案的编辑

// Warning: INEXACT FOR LARGE INPUTS
// this fast approximation can just use the high half,
// so on 32-bit machines it avoids one shift instruction vs. exact division
int32_t div10(int32_t dividend)
{
    int64_t invDivisor = 0x1999999A;
    return (int32_t) ((invDivisor * dividend) >> 32);
}

它的第一个错误答案(如果你从 0 向上循环)是 div10(1073741829) = 107374183,而 1073741829/10 实际上是 107374182。(它向上舍入而不是像 C 整数除法应该的那样朝 0。)


从您的编辑中,我看到您实际上是在谈论使用乘法结果的 low 一半,这显然适用于一直到 UINT_MAX 的精确倍数。

正如您所说,当除法有余数时,它完全失败,例如16 * 0xaaaaaaab = 0xaaaaaab0 截断为 32 位时,而不是 5

unsigned div3_exact_only(unsigned x) {
    __builtin_assume(x % 3 == 0);  // or an equivalent with if() __builtin_unreachable()
    return x / 3;
}

是的,如果该数学计算成功,编译器使用 32 位 imul 实现它是合法且最佳的。他们不寻找这种优化,因为它很少是一个已知的事实。 IDK 如果值得添加编译器代码甚至寻找优化,就编译时间而言,更不用说开发人员时间的编译器维护成本了。这不是运行时成本的巨大差异,而且几乎不可能。不过还不错。

div3_exact_only:
    imul  eax, edi, 0xAAAAAAAB        # 1 uop, 3c latency
    ret

但是,您可以在源代码中自己执行此操作,至少对于像 uint32_t 这样的已知类型宽度:

uint32_t div3_exact_only(uint32_t x) {
    return x * 0xaaaaaaabU;
}

【讨论】:

  • 这是一个非常好的和详细的答案。不过,除以 10 不是问题。您可以乘以3435973837 并右移35,这样就可以为所有 32 位数字提供足够的精度。甚至Wikipedia article on division 也有这个例子。
  • clang 和 GCC 都可以为任何常量除数生成这些简化的除法,所以你真的不需要自己实现这个。事后看来,乘法逆方法永远行不通,因为它永远无法将 1 变为 0。
  • @J.Schultke:是的,当然 is 是 10 的定点乘法逆元(编译器使用),但是您的模块化方法(仅依赖于模 2由于寄存器宽度,免费^32)不起作用。这就是我试图解释为什么编译器不只是将乘法的高半部分或低半部分与魔术常数相乘的原因。如果您想要更快的近似值,但并不完全适用于所有输入,您只需要做一些特殊的事情。
  • @J.Schultke:添加了更新以阐明我的意思。该手动移位版本不会在 64 位机器上保存任何内容,其中编译器可能会使用 64 位 imul 并且无论如何都要移位,但在 32 位机器上它会让编译器直接使用没有shrmul r32 的高半结果为1。
【解决方案3】:

如果你看看我对上一个问题的回答:

Why does GCC use multiplication by a strange number in implementing integer division?

它包含指向解释这一点的 pdf 文章的链接(我的回答澄清了这篇 pdf 文章中没有很好解释的内容):

https://gmplib.org/~tege/divcnst-pldi94.pdf

请注意,某些除数需要额外一位精度,例如 7,乘法器通常需要 33 位,乘积通常需要 65 位,但可以通过单独处理 2^32 位来避免这种情况如我之前的回答和下面所示,还有 3 条额外说明。

如果更改为,请查看生成的代码

unsigned div7(unsigned x) {
    return x / 7;
}

所以为了解释这个过程,让 L = ceil(log2(divisor))。对于上述问题,L = ceil(log2(3)) == 2。右移计数最初为 32+L = 34。

要生成具有足够位数的乘数,会生成两个潜在的乘数:mhi 将是要使用的乘数,移位计数将为 32+L。

mhi = (2^(32+L) + 2^(L))/3 = 5726623062
mlo = (2^(32+L)        )/3 = 5726623061

然后检查是否可以减少所需的位数:

while((L > 0) && ((mhi>>1) > (mlo>>1))){
    mhi = mhi>>1;
    mlo = mlo>>1;
    L   = L-1;
}
if(mhi >= 2^32){
    mhi = mhi-2^32
    L   = L-1;
    ; use 3 additional instructions for missing 2^32 bit
}
... mhi>>1 = 5726623062>>1 = 2863311531
... mlo>>1 = 5726623061>>1 = 2863311530  (mhi>>1) > (mlo>>1)
... mhi    = mhi>>1 = 2863311531
... mlo    = mhi>>1 = 2863311530
... L = L-1 = 1
... the next loop exits since now (mhi>>1) == (mlo>>1)

所以乘数是 mhi = 2863311531,移位计数 = 32+L = 33。

在现代 X86 上,乘法和移位指令是恒定时间,因此将乘法器 (mhi) 减少到小于 32 位是没有意义的,因此将上面的 while(...) 更改为 if(.. .).

在 7 的情况下,循环在第一次迭代时退出,并且需要 3 条额外的指令来处理 2^32 位,因此 mhi 为

L = ceil(log2(7)) = 3
mhi = (2^(32+L) + 2^(L))/7 = 4908534053
mhi = mhi-2^32 = 613566757
L = L-1 = 2
...                 visual studio generated code for div7, input is rcx
mov eax, 613566757
mul ecx
sub ecx, edx                   ; handle 2^32 bit
shr ecx, 1                     ; ...
lea eax, DWORD PTR [edx+ecx]   ; ...
shr eax, 2

如果需要余数,则可以使用以下步骤:

mhi and L are generated based on divisor during compile time
...
quotient  = (x*mhi)>>(32+L)
product   = quotient*divisor
remainder = x - product

【讨论】:

    【解决方案4】:

    x/3 大约是 (x * (2^32/3)) / 2^32。所以我们可以执行一次 32x32->64 位乘法,取高 32 位,得到大约 x/3。

    有一些错误,因为我们不能精确地乘以 2^32/3,只能乘以这个数字四舍五入为整数。我们使用 x/3 ≈ (x * (2^33/3)) / 2^33 获得更高的精度。 (我们不能使用 2^34/3,因为它 > 2^32)。事实证明,这足以在所有情况下准确地获得 x/3。如果输入是 3k 或 3k+2,您可以通过检查公式是否给出 k 的结果来证明这一点。

    【讨论】:

      猜你喜欢
      • 2011-01-27
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 2016-01-29
      • 1970-01-01
      • 2012-08-25
      • 2014-05-26
      • 2017-12-08
      相关资源
      最近更新 更多