我是否应该期望GMP的mpf_class比原始数据类型double慢得多?

时间:2019-01-18 17:28:40

标签: c++ performance gmp

我正在编写一个C ++程序来生成Mandelbrot集缩放。我所有的复数最初都是两个double(一个是实数部分,一个是复数部分)。这工作得很快。对于我生成的图像类型,每帧15秒。

由于缩放效果,我想为更放大的帧提高精度,因为这些帧在min_xmax_x之间有很小的差异。我希望GMP可以帮助我解决这个问题。

现在,它要慢得多;每帧15:38分钟。图像的设置与以前相同,算法也相同。唯一发生变化的是,我使用mpf_class作为需要精确的小数(即仅是复数)。为了比较性能,我使用与double相同的精度:mpf_set_default_prec(64);

GMP是否会更改mpf_class的精度以满足表达式的需要?换句话说,如果我有两个64位mpf_class对象,并使用它们进行计算并将结果存储在另一个mpf_class中,精度是否有可能提高?我认为,随着时间的流逝,这会破坏性能,但是我不确定这是导致我出现问题的原因。

我的问题:这种性能下降仅仅是GMP和其他任意精度库的本质吗?您会提出什么建议?

编辑1
我(即一直都是)使用-O3标志进行优化。我还进行了一项测试,以验证GMP不会自动提高mpf_class对象的精度。因此,对于性能急剧下降的原因仍然存在疑问。

编辑2
作为说明示例,我将以下代码编译为g++ main.cpp -lgmp -lgmpxx,如下所示,然后将每个double替换为mpf_class一次。使用double,它运行了12.75秒,而使用mpf_class,它运行了24:54分钟。当它们具有相同的精度时,为什么会这样?

#include <gmpxx.h>

double linear_map(double d, double a1, double b1, double a2, double b2) {
  double a = (d-a1)/(b1-a1);
  return (a*(b2-a2)) + (a2);
}

int iterate(double x0, double y0) {
  double x, y;
  x = 0;
  y = 0;
  int i;
  for (i = 0; i < 1000 && x*x + y*y <= 65536; i++) {
    double xtemp = x*x - y*y + x0;
    y = 2*x*y + y0;
    x = xtemp;
  }
  return i;
}

int main() {
  mpf_set_default_prec(64);
  for (int j = 0; j < 3200; j++) {
    for (int i = 0; i < 3200; i++) {
      double x = linear_map(i, 0, 3200, -2, 1);
      double y = linear_map(j, 0, 3200, -1.5, 1.5);
      iterate(x, y);
    }
  }
  return 0;
}

1 个答案:

答案 0 :(得分:2)

如评论中所述,完全可以从GMP之类的库中获得这种减速。

构建double乘法是当今CPU和编译器最优化的领域之一; CPU具有多个执行单元,这些执行单元通常在编译器的帮助下设法并行执行多个浮点运算,这些编译器会尝试对循环进行自动向量化(尽管这不适用于您的情况,因为最内层的循环与上一个迭代)。

另一方面,在多个动态精度库(例如GMP)中,每个操作都需要大量工作-即使要检查两个操作数是否具有相同/正确的精度,也要检查多个分支以及计算算法实现是通用的,并且针对“更高的精度”端进行了定制,这意味着它们并未针对您当前的用例进行特别优化(以与double相同的精度使用);此外,GMP值可以在创建时确实分配内存,这是另一项昂贵的操作。

我接受了您的程序,并对其进行了少许修改,以使其在使用的类型上具有参数性(使用#define),将采样正方形的边减少(从3200减少到800,以使测试更快)并添加返回值iterate的累加器,以在最后输出它,以检查各个版本之间是否一切工作均相同,并确保优化程序不会完全放弃循环。 / p>

我的机器上的double版本大约需要0.16秒,并且撞上了探查器,在火焰图中显示出完全平坦的轮廓;一切都在iterate中发生。

flamegraph of double-based version

与预期一样,GMP版本需要45秒(300倍;您谈到的速度降低了60倍,但您正在与未优化的基本情况进行比较),并且变化更大:

flamegraph of GMP-based version

和以前一样,iterate一直占用大量时间(因此就优化而言,我们可以完全忽略linear_map)。所有这些“塔”都是对GMP代码的调用; __gmp_expr<...>的内容并不是特别相关-它们只是模板样板,可以在没有太多临时变量的情况下评估复杂的表达式,并且可以完全内联。大部分时间都花在那些塔的顶部,在那里进行实际的计算。

实际上,最终大部分时间都花在了GMP原语和内存分配上:

caller/callee times for GMP-based version

鉴于我们无法接触GMP内部,我们唯一能做的就是更加谨慎地使用它,因为每次GMP操作的确很昂贵。

实际上,请记住,尽管编译器可以避免为double值多次计算相同的表达式,但对于GMP值却不能做到相同,因为它们都有副作用(内存分配,外部函数调用),并且过于复杂而无法对其进行检查。在您的内部循环中,我们有:

  double x, y;
  x = 0;
  y = 0;
  int i;
    for (i = 0; i < 1000 && x*x + y*y <= 65536; i++) {
      T xtemp = x*x - y*y + x0;

({T是我正在使用的通用类型,定义为doublempf_class

此处,您在每次迭代中两次计算x*xy*y。我们可以将其优化为:

T x = 0, y = 0, xsq, ysq;
for(i = 0; i < 1000; i++) {
  xsq = x*x;
  ysq = y*y;
  if(xsq+ysq > 65536) break;
  T xtemp = xsq - ysq + y0;

使用此修改重新运行GMP版本,我们将时间缩短到38秒,提高了18%。

请注意,我们将xsqysq置于循环之外,以避免在每次迭代时重新创建它们;这是因为,与double对象(最终只是寄存器空间,或者更糟的是堆栈空间,它们都是空闲的并由编译器静态处理)不同,mpt_class对象不是免费的。每次重新创建,正如上面的探查器跟踪中内存分配功能的突出所暗示的那样;我并不完全了解GMP C ++包装器的内部工作原理,但是我怀疑它具有类似于std::vector的优化-赋值时,已分配的值将能够在分配时回收其空间,而无需再次分配

因此,我们甚至可以将xtemp的定义提升到循环之外

int iterate(T x0, T y0) {
  T x = 0, y = 0, xsq , ysq, xtemp;
  int i;
  for (i = 0; i < 1000; i++) {
    xsq = x*x;
    ysq = y*y;
    if(xsq+ysq > 65536) break;
    xtemp = xsq - ysq + y0;
    y = 2*x*y + y0;
    x = xtemp;
  }
  return i;
}

将运行时间缩短至33秒,比原始时间少27%。

flamegraph of GMP-based optimized version

firegraph与以前的相似,但看起来更加紧凑-我们消除了一些“非页内”浪费的时间,仅保留了计算的核心。最重要的是,查看最热门的热点,我们确实可以看到乘法转换为减法运算,malloc / free失去了几个位置。

caller/callee times of GMP-based optimized version


从纯黑匣子的角度来看,我认为无法对此进行更多优化。如果要进行这些计算,恐怕没有简单的方法可以使用GMP的mpf_class来更快地执行它们。此时,您应该:

  • 删除GMP并使用其他一些库,在固定大小的情况下具有更好的性能;我怀疑这里会有收获-即使只是避免完全分配并且内联计算是一个大胜利;
  • 开始应用一些algorithmic optimizations;无论您最终决定使用哪种数据类型,这些都将大大缩短运行时间。

注释

  • 完整的代码(在不同的迭代中)可以在https://bitbucket.org/mitalia/mandelbrot_gmp_test/
  • 中找到
  • 在i7-6700上运行的64位Linux上,使用优化级别为-O3的g ++ 7.3完成的所有测试
  • 使用来自Linux-tools 4.15的perf record和调用堆栈捕获执行
  • 分析; KDAB Hotspot 1.1.0
  • 生成的图形和表格