为什么斐波那契数列的这些动态编程实现中的一种比另一种更快?

时间:2018-07-28 02:40:42

标签: c++ fibonacci gmp

我最近一直在为自己的娱乐研究和测试各种斐波那契算法,并偶然地或多或少地提出了经典O(n)时间和O(1)空间动态编程实现的替代实现。

请考虑以下两个功能:

BigInt fib_dp_classic(int n) {
  if (n == 0) {
    return 0;
  }

  BigInt x = 0, y = 1, z;
  for (int i = 2; i <= n; ++i) {
    z = x + y;
    x = y;
    y = z;
  }

  return y;
}

BigInt fib_dp_mod(int n) {
  BigInt x = 0, y = 1, z = 1;
  for (int i = 0; i < n; ++i) {
    switch (i % 3) {
      case 0:
        y = x + z;
        break;
      case 1:
        z = x + y;
        break;
      case 2:
        x = y + z;
        break;
    }
  }

  switch (n % 3) {
    case 0:
      return x;
      break;
    case 1:
      return y;
      break;
    case 2:
      return z;
      break;
  }
}

在我的机器上,使用fib_dp_classic计算百万分之一的斐波那契数需要花费6.55秒,而使用fib_dp_mod需要花费2.83秒,即使打开-O3也不会改变太多。关于mod版本为什么更快,我真的没有什么好主意。是因为经典版本中的额外商店说明比第二个版本中的mod贵吗?我的理解是,编译器应将所有三个变量都放入两个版本的寄存器中,而计算mod实际上是相当昂贵的;不是吗?

实际上,我只是将两者都通过compiler explorer进行了设置,并且一旦启用优化,它们都仅使用寄存器。当然,这只是使用整数,而不是我实际用于基准测试的基于GMP的bigint。是否有一些奇怪的GMP实施细节可能是这里引起的?

更新:我什至都跟踪malloc()是否可能是罪魁祸首,fib_dp_classic使用130个系统调用(对于n = 1000000),而fib_dp_mod使用133。所以仍然没有真正的线索...

更新2:是的,缓冲区副本是罪魁祸首(正如geza所指出的),我因没有意识到而愚蠢。这是两个替代版本及其基准测试结果:

BigInt fib_dp_move(int n) {
  if (n == 0) {
    return 0;
  }

  BigInt x = 0, y = 1, z;
  for (int i = 2; i <= n; ++i) {
    z = std::move(x) + y;
    x = std::move(y);
    y = std::move(z);
  }

  return y;
}

这需要2.84秒,因此与mod版本相当,因为它消除了不必要的副本。

BigInt fib_dp_swap(int n) {
  if (n == 0) {
    return 0;
  }

  BigInt x = 0, y = 1, z;
  for (int i = 2; i <= n; ++i) {
    z = x + y;
    swap(x, y);
    swap(y, z);
  }

  return y;
}

(来自geza)也运行了2.84秒,因此再次与mod版本等效,因为它以基本上相同的方式消除了副本,只是调用swap()而不是使用move语义。

3 个答案:

答案 0 :(得分:8)

您的第一个函数对"user": list(g) 使用一个加法和3个副本-所有这些操作都非常耗时。第二个功能使用一个加法和一个复制操作-这是节省的地方,您可以使用[ { "group": "fans", "user": [ { "first": "John", "last": "Smith" }, { "first": "Alice", "last": "White" } ] }, { "group": "students", "user": [ { "first": "Ben", "last": "Smith" }, { "first": "Joan", "last": "Carpenter" } ] } ] 保存2个复制操作。

答案 1 :(得分:3)

在这种情况下,将GMP版本与简单的int版本进行比较是没有意义的。 Fib(1000000)是一个〜86KB的数字,与简单的int相比,它的运行速度快得多。对于int,在某些情况下,副本基本上可以是自由操作,而对于GMP编号,它涉及86KB缓冲区的副本。

(请注意,当然,副本不会总是为86KB。开始时为〜0KB,但是随着例程的进行,它会增长到86KB。随着数字的增加,例程变得越来越慢,所以大部分时间都花在数字很大的时候)

假设质量为BigInt,我们在每次迭代中都有以下操作计数:

  • fib_dp_classic:添加1个,复制2个
  • fib_dp_mod:1个添加

如您所见,经典版本会额外复制2个副本(请注意,在质量实现中,x=y+z不包含副本)。副本的速度与添加的速度相同(我的意思是,添加的速度可能比副本慢2到3倍)。因此,这解释了经典例程的〜2.3倍速度降低。

请注意,如果BigInt是使用引用计数的实现,则x=y操作基本上可以是自由操作,因为它不需要复制,只需增加一个计数器(在在这种情况下,经典例程的速度将与mod 1相同。)

最后一点:如果可以使用非复制swap操作,大概可以加快经典版本的速度:

BigInt fib_dp_swap(int n) {
  if (n == 0) {
    return 0;
  }

  BigInt x = 0, y = 1, z;
  for (int i = 2; i <= n; ++i) {
    z = x + y;
    x.swap(y); // Note the 2 swaps here
    y.swap(z);
  }

  return y;
}

使用gmpxx,该例程与mod版本同时运行,并且更加简单。

这是因为swap操作可能比副本便宜得多。交换只需要交换BigInt内部的指针,而一个副本则需要一个〜86KB的内存副本。

答案 2 :(得分:0)

在经典情况下是一种数据依赖的情况,每次迭代需要两个周期,因为

z = x + y;
x = y; // depends on y from previous loop.
y = z; // depends on z

在mod情况下,每个迭代仅取决于前一个迭代,因此每个循环可以执行一次。

此外,%3并不是真正的模3,而是一些编译器魔术,使之变得简单。实际上,您可以通过编写来进一步优化它

  for (int i = 0; i < n; i+=3) {
      y = x + z;
      z = x + y;
      x = y + z;
    }
  }

额外的计算成本将保存在额外的控制逻辑中。