为什么通过成对计算来计算连续整数数组的乘积会更快?

时间:2016-08-22 19:12:28

标签: c# algorithm performance time

我试图创建自己的阶乘函数,当我发现计算速度是成对计算速度的两倍时。像这样:

1:2 * 3 * 4 ... 50000 * 50001 = 4.1秒

2组:(2 * 3)*(4 * 5)*(6 * 7)......(50000 * 50001)= 2.0秒

3组:(2 * 3 * 4)*(5 * 6 * 7)......(49999 * 50000 * 50001)= 4.8秒

这是我用来测试它的c#。

Stopwatch timer = new Stopwatch();
timer.Start();

// Seperate the calculation into groups of this size.
int k = 2;

BigInteger total = 1;

// Iterates from 2 to 50002, but instead of incrementing 'i' by one, it increments it 'k' times,
// and the inner loop calculates the product of 'i' to 'i+k', and multiplies 'total' by that result.
for (var i = 2; i < 50000 + 2; i += k)
{
    BigInteger partialTotal = 1;
    for (var j = 0; j < k; j++)
    {
        // Stops if it exceeds 50000.
        if (i + j >= 50000) break;
        partialTotal *= i + j;
    }
    total *= partialTotal;
}

Console.WriteLine(timer.ElapsedMilliseconds / 1000.0 + "s");

我在不同级别对此进行了测试,并将平均时间放在条形图中的几个测试中。我预计随着我增加团体数量会变得更有效率,但是3个效率最低,4个比1个组没有改善。

Bar Plot showing the calculation time with different group amounts.

Link to First Data

Link to Second Data

导致这种差异的原因是什么,是否有最佳的计算方法?

3 个答案:

答案 0 :(得分:7)

BigInteger具有31位或更少的数字的快速情况。当您进行成对乘法时,这意味着采用特定的快速路径,将值乘以单ulong并更明确地设置值:

public void Mul(ref BigIntegerBuilder reg1, ref BigIntegerBuilder reg2) {
  ...
  if (reg1._iuLast == 0) {
    if (reg2._iuLast == 0)
      Set((ulong)reg1._uSmall * reg2._uSmall);
    else {
      ...
    }
  }
  else if (reg2._iuLast == 0) {
    ...
  }
  else {
    ...
  }
}
public void Set(ulong uu) {
  uint uHi = NumericsHelpers.GetHi(uu);
  if (uHi == 0) {
    _uSmall = NumericsHelpers.GetLo(uu);
    _iuLast = 0;
  }
  else {
    SetSizeLazy(2);
    _rgu[0] = (uint)uu;
    _rgu[1] = uHi;
  }
  AssertValid(true);
}

这样的100%可预测分支对于JIT来说是完美的,并且这条快速路径应该得到极好的优化。 _rgu[0]_rgu[1]甚至可以内联。这非常便宜,因此有效地将实际操作的数量减少了两倍。

那么为什么一组三个这么慢?很明显它应该比k = 2慢;你有更少的优化乘法。更有趣的是它为什么比k = 1慢。这很容易解释为total的外部乘法现在击中了慢速路径。对于k = 2,通过将乘法的数量减半和数组的潜在内联减少,可以减轻这种影响。

然而,这些因素对k = 3没有帮助,事实上慢的情况会使k = 3更加伤害k = 3 if (reg1._iuLast == 0) { ... } else if (reg2._iuLast == 0) { Load(ref reg1, 1); Mul(reg2._uSmall); } else { ... } 案例中的第二次乘法命中此案例

  EnsureWritable(1);

  uint uCarry = 0;
  for (int iu = 0; iu <= _iuLast; iu++)
    uCarry = MulCarry(ref _rgu[iu], u, uCarry);

  if (uCarry != 0) {
    SetSizeKeep(_iuLast + 2, 0);
    _rgu[_iuLast] = uCarry;
  }

分配

EnsureWritable(1)

为什么这很重要?好吧,uint[] rgu = new uint[_iuLast + 1 + cuExtra]; 导致

rgu

因此3变为total长度。 public void Mul(ref BigIntegerBuilder reg1, ref BigIntegerBuilder reg2) 代码中的传递次数在

中确定
    for (int iu1 = 0; iu1 < cu1; iu1++) {
      ...
      for (int iu2 = 0; iu2 < cu2; iu2++, iuRes++)
        uCarry = AddMulCarry(ref _rgu[iuRes], uCur, rgu2[iu2], uCarry);
      ...
    }

作为

len(total._rgu) * 3

这意味着我们总共有len(total._rgu) * 1次操作。这还没有给我们任何东西! k = 1只有len(total._rgu) * 2次通过 - 我们只做了三次!

外环上实际上有一个优化,可以将其减少到 uint uCur = rgu1[iu1]; if (uCur == 0) continue;

if (reg1.CuNonZero <= reg2.CuNonZero) {
  rgu1 = reg1._rgu; cu1 = reg1._iuLast + 1;
  rgu2 = reg2._rgu; cu2 = reg2._iuLast + 1;
}
else {
  rgu1 = reg2._rgu; cu1 = reg2._iuLast + 1;
  rgu2 = reg1._rgu; cu2 = reg1._iuLast + 1;
}

然而,他们&#34;优化&#34;这种优化方式比以前伤害得更多:

k = 2

对于total,导致外部循环超过reg2,因为total不包含高概率的零值。这是因为partialTotal 方式k = 3更长,所以越少越好。对于EnsureWritable(1)total总是会产生一个空余空间,因为三个不超过15位的数字的乘法不能超过64位。这意味着,虽然我们仍然只为k = 2k = 3执行一次传递,但我们为k = 3执行两次

这开始解释为什么速度再次增加超过total:每次添加的通过次数增加的速度比添加次数减少的速度慢,因为你只需要为内部值增加~15位时间。内部乘法相对于大量total乘法而言是快速的,因此合并值所花费的时间越多,通过_rgu的时间就越多。此外,优化不那么频繁是一种悲观情绪。

它还解释了为什么奇数值需要更长时间:它们为ffdec_h264数组添加了额外的32位整数。如果~15位并非如此接近32的一半,那么这不会发生如此干净。

值得注意的是,有很多方法可以改进这些代码;这里的评论是关于为什么,而不是如何解决它。最简单的改进是将值放入堆中,并且一次只乘以两个最小值。

答案 1 :(得分:4)

进行BigInteger倍增所需的时间取决于产品的大小。

两种方法都采用相同的乘法次数,但如果将成对的乘数相乘,则产品的平均大小远小于将每个因子乘以所有较小因子的乘积时的平均大小。

如果你总是将两个尚未成倍增加的最小因素(原始因素或中间产品)相乘,那么你可以做得更好。直到你找到完整的产品。

答案 2 :(得分:-6)

我认为你有一个错误(&#39; +&#39;而不是&#39; *&#39;)。

partialTotal * = i + j;

很高兴检查您是否得到了正确的答案,而不仅仅是有趣的绩效指标。

但我很好奇是什么促使你尝试这一点。如果你确实找到了不同之处,我希望它与寄存器和/或内存分配的最优性有关。我希望它会是0-30%或类似的东西,而不是50%。