【问题标题】:Why is pow(int, int) so slow?为什么 pow(int, int) 这么慢?
【发布时间】:2017-04-25 15:53:42
【问题描述】:

我一直在进行一些项目 Euler 练习,以提高我对 C++ 的了解。

我写了以下函数:

int a = 0,b = 0,c = 0;

for (a = 1; a <= SUMTOTAL; a++)
{
    for (b = a+1; b <= SUMTOTAL-a; b++)
    {
        c = SUMTOTAL-(a+b);

        if (c == sqrt(pow(a,2)+pow(b,2)) && b < c)
        {
            std::cout << "a: " << a << " b: " << b << " c: "<< c << std::endl;
            std::cout << a * b * c << std::endl;
        }
    }
}

计算时间为 17 毫秒。

但是,如果我换行

if (c == sqrt(pow(a,2)+pow(b,2)) && b < c)

if (c == sqrt((a*a)+(b*b)) && b < c)

计算发生在 2 毫秒内。 pow(int, int) 是否有一些明显的实现细节我遗漏了,这使得第一个表达式的计算速度如此之慢?

【问题讨论】:

  • a*a 可能是 1 条指令。 pow 至少是一个函数调用,加上函数所做的任何工作。
  • 计算时间为 17 毫秒。 -- 首先,pow 是一个浮点函数。其次,仅当您正在运行优化的构建时,发布一个函数需要多少时间才有意义。如果您正在运行未优化的“调试”构建,那么时间毫无意义。最后但同样重要的是,don't use pow if the exponent is an integer
  • 这个review 可能对你很感兴趣。它既是库调用,又是 ringo 所说的“强大”功能​​。
  • 如果你使用c*c = a*a + b*b 可能会更快:乘法,尤其是整数乘法,比平方根快。但只有c*c 不溢出才是正确的。
  • @RoelSchroeven 但是如果c*c 溢出,那么a*a + b*b 也会溢出(假设它们实际上是相等的),所以它可能并不重要。

标签: c++ performance pow cmath


【解决方案1】:

pow() 使用实浮点数并在后台使用公式

pow(x,y) = e^(y log(x))

计算x^yint 在调用 pow 之前转换为 double。 (log是自然对数,基于e)

x^2 使用pow() 因此比x*x 慢。

根据相关cmets进行编辑

  • 使用 pow 即使是整数指数也可能产生不正确的结果 (PaulMcKenzie)
  • 除了使用 double 类型的数学函数之外,pow 是一个函数调用(而 x*x 不是)(jtbandes
  • 事实上,许多现代编译器会使用常量整数参数优化 pow,但不应依赖这一点。

【讨论】:

  • 不仅速度较慢,而且您可能得到不精确的答案,即使对于整数基数和指数也是如此。
  • @YanZhou -- 它不会总是给出准确的结果,否则this would never have been asked
  • @PaulMcKenzie 正如我所说,信誉良好的 libm 就是这种情况。不是每个 libm 都给出准确的。据我所知,AMD libm、Intel libimf、OpenLibm、BSD libm 及其衍生品(如 macOS 中的那个)都会给你pow(5, 2) == 25,你引用的例子。 GNU libc 是 linux 上使用最广泛的,但它并没有成为黄金标准
  • @PaulMcKenzie 一个更正,GNU libc 也应该给出相同的结果。您引用的帖子可能是在 Windows 上使用 GCC+Code Blocks,MS libm 的信誉较差。
  • 我的观点是,C++ 标准中没有任何内容可以保证pow 给出准确的结果,即使是整数指数也是如此。使用查找表或其他确保不存在舍入错误的方法,让生活快乐并计算 pow,无论使用何种库。
【解决方案2】:

您选择了一种最慢的检查方法

c*c == a*a + b*b   // assuming c is non-negative

编译为三个整数乘法(其中一个可以提升到循环之外)。即使没有pow(),您仍然会转换为double 并取平方根,这对吞吐量来说很糟糕。 (还有延迟,但现代 CPU 上的分支预测 + 推测执行意味着延迟不是这里的一个因素)。

英特尔 Haswell 的 SQRTSD 指令每 8-14 个周期 (source: Agner Fog's instruction tables) 的吞吐量为 1,因此即使您的 sqrt() 版本使 FP sqrt 执行单元保持饱和,它仍然比我得到的 gcc 慢 4 倍发射(下)。


您还可以优化循环条件以在条件的b &lt; c 部分变为假时跳出循环,因此编译器只需执行该检查的一个版本。

void foo_optimized()
{ 
  for (int a = 1; a <= SUMTOTAL; a++) {
    for (int b = a+1; b < SUMTOTAL-a-b; b++) {
        // int c = SUMTOTAL-(a+b);   // gcc won't always transform signed-integer math, so this prevents hoisting (SUMTOTAL-a) :(
        int c = (SUMTOTAL-a) - b;
        // if (b >= c) break;  // just changed the loop condition instead

        // the compiler can hoist a*a out of the loop for us
        if (/* b < c && */ c*c == a*a + b*b) {
            // Just print a newline.  std::endl also flushes, which bloats the asm
            std::cout << "a: " << a << " b: " << b << " c: "<< c << '\n';
            std::cout << a * b * c << '\n';
        }
    }
  }
}

这会编译(使用 gcc6.2 -O3 -mtune=haswell)以使用此内部循环进行编码。完整代码见the Godbolt compiler explorer

# a*a is hoisted out of the loop.  It's in r15d
.L6:
    add     ebp, 1    # b++
    sub     ebx, 1    # c--
    add     r12d, r14d        # ivtmp.36, ivtmp.43  # not sure what this is or why it's in the loop, would have to look again at the asm outside
    cmp     ebp, ebx  # b, _39
    jg      .L13    ## This is the loop-exit branch, not-taken until the end
                    ## .L13 is the rest of the outer loop.
                    ##  It sets up for the next entry to this inner loop.
.L8:
    mov     eax, ebp        # multiply a copy of the counters
    mov     edx, ebx
    imul    eax, ebp        # b*b
    imul    edx, ebx        # c*c
    add     eax, r15d       # a*a + b*b
    cmp     edx, eax  # tmp137, tmp139
    jne     .L6
 ## Fall-through into the cout print code when we find a match
 ## extremely rare, so should predict near-perfectly

在 Intel Haswell 上,所有这些指令都是 1 uop。 (并且 cmp/jcc 将宏融合成比较和分支微指令。)所以这是 10 个融合域微指令,which can issue at one iteration per 2.5 cycles

Haswell 以每个时钟一次迭代的吞吐量运行 imul r32, r32,因此内部循环内的两次乘法不会在每 2.5c 两次乘法时使端口 1 饱和。这为吸收来自 ADD 和 SUB 窃取端口 1 的不可避免的资源冲突留出了空间。

我们甚至没有接近任何其他执行端口瓶颈,因此前端瓶颈是唯一的问题,这应该在 Intel Haswell 及更高版本上以每 2.5 个周期运行一次迭代 .

循环展开可以帮助减少每次检查的微指令数。例如使用lea ecx, [rbx+1] 为下一次迭代计算 b+1,因此我们可以在不使用 MOV 的情况下使用imul ebx, ebx 使其成为非破坏性的。


强度降低也是可能的:给定b*b,我们可以尝试在没有IMUL 的情况下计算(b-1) * (b-1)(b-1) * (b-1) = b*b - 2*b + 1,所以也许我们可以做一个lea ecx, [rbx*2 - 1],然后从b*b 中减去它。 (没有寻址模式是减法而不是加法。嗯,也许我们可以将-b 保存在寄存器中,并计数到零,所以我们可以使用lea ecx, [rcx + rbx*2 - 1] 来更新ECX 中的b*b,给定@987654344 @ 在 EBX 中)。

除非您实际上在 IMUL 吞吐量上遇到瓶颈,否则这最终可能会占用更多微指令而不是胜利。看看编译器在 C++ 源代码中强度降低的效果如何可能会很有趣。


您也可以使用 SSE 或 AVX 对其进行矢量化,并行检查 4 或 8 个连续的 b 值。由于命中非常罕见,您只需检查 8 个中的任何一个是否命中,然后在极少数情况下找出匹配的一个。

有关更多优化内容,另请参阅 标签 wiki。

【讨论】:

  • 怎么样:for (int a = 1...) { int aSquare = a * a; --- 将乘法拉出循环并为每个循环保存一个循环。从您引用的程序集中,编译器没有看到那个。
  • @LorenPechtel:编译器已经提升了a*a(并将其保存在r15d 中)。两个 IMUL 用于 bc,它们都在循环内发生变化。我尝试手动执行此操作,但 asm 根本没有改变。我想不出任何巧妙的手动转换来消除对这样一个简单方程相关的两个变量进行两次单独乘法的冗余。
  • @LorenPechtel:在 asm 中添加了手写的 cmets,以节省人们试图弄清楚编译器做了什么的时间 :)
  • @étale-cohomology:自动矢量化之所以有效,是因为if() 很少被采用,而编译器并不知道这一点。如果很少有没有元素的完整向量采用if(),那么进行向量化计算很容易造成损失。 (虽然不是重做计算以查看要打印的元素,您可以只从 PMOVMSKB 扫描位图......)无论如何,是的,自动矢量化可以工作,但是由于条件行为,这个问题对于编译器来说并不简单。编译器技术还没有达到我希望他们这样做的地步。
  • @Fang:C 绝对不是这样。所有优化编译器只看到实现代码所需的数据移动。如果有的话,限制变量的范围(并为单独的循环使用单独的变量)将帮助编译器看到它们是独立的,并且在循环之后死了。
猜你喜欢
  • 2011-01-24
  • 2018-08-25
  • 2023-01-20
  • 2016-04-10
  • 2021-12-13
  • 2011-01-11
  • 2016-02-26
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多