关于算法:Java中的“快速”整数幂

“Fast” Integer Powers in Java

简短的回答:糟糕的标杆管理方法。你以为我现在已经明白了。

问题是"找到一个快速计算X ^ y的方法,其中X和Y是正整数"。典型的"快速"算法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
public long fastPower(int x, int y) {
  // Replaced my code with the"better" version described below,
  // but this version isn't measurably faster than what I had before
  long base = x; // otherwise, we may overflow at x *= x.
  long result = y % 2 == 1 ? x : 1;
  while (y > 1) {
    base *= base;
    y >>= 1;
    if (y % 2 == 1) result *= base;
  }

  return result;
}

我想看看这比调用math.pow()或使用简单的方法(比如x乘以y)快多少,比如:

1
2
3
4
5
6
7
public long naivePower(int x, int y) {
  long result = 1;
  for (int i = 0; i < y; i++) {
    result *= x;
  }
  return result;
}

编辑:好吧,有人向我指出(正确地)我的基准代码没有消耗结果,这完全把一切都抛到一边。一旦我开始使用这个结果,我仍然看到幼稚的方法比"快速"方法快25%。

原文:

I was very surprised to find that the naive approach was 4x faster than the"fast" version, which was itself about 3x faster than the Math.pow() version.

我的测试是使用10000000个测试(然后是1亿个,只是为了确保JIT有时间预热),每个测试都使用随机值(防止调用被优化掉),2<=x<=3,25<=y<=29。我选择了一个很窄的值范围,它不会产生大于2^63的结果,但会偏向于较大的指数,以试图给"快速"版本带来优势。我正在预先生成10000个伪随机数,以从计时中消除这部分代码。

我理解,对于小指数来说,幼稚的版本可能更快。"fast"版本有两个分支,而不是一个分支,通常执行的算术/存储操作是原始分支的两倍,但我预计对于大指数,这仍然会导致fast方法在最佳情况下节省一半的操作,在最坏情况下几乎相同。

有人知道为什么天真的方法会比"快速"版本快得多,即使数据偏向于"快速"版本(即更大的指数)?在运行时,代码中额外的分支是否解释了这么大的差异?

基准代码(是的,我知道我应该为"官方"基准使用一些框架,但这是一个玩具问题)-更新为预热,并使用结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
PowerIf[] powers = new PowerIf[] {
  new EasyPower(), // just calls Math.pow() and cast to int
  new NaivePower(),
  new FastPower()
};

Random rand = new Random(0); // same seed for each run
int randCount = 10000;
int[] bases = new int[randCount];
int[] exponents = new int[randCount];
for (int i = 0; i < randCount; i++) {
  bases[i] = 2 + rand.nextInt(2);
  exponents[i] = 25 + rand.nextInt(5);
}

int count = 1000000000;

for (int trial = 0; trial < powers.length; trial++) {
  long total = 0;
  for (int i = 0; i < count; i++) { // warm up
    final int x = bases[i % randCount];
    final int y = exponents[i % randCount];
    total += powers[trial].power(x, y);
  }
  long start = System.currentTimeMillis();
  for (int i = 0; i < count; i++) {
    final int x = bases[i % randCount];
    final int y = exponents[i % randCount];
    total += powers[trial].power(x, y);
  }
  long end = System.currentTimeMillis();
  System.out.printf("%25s: %d ms%n", powers[trial].toString(), (end - start));
  System.out.println(total);
}

产生输出:

1
2
3
4
5
6
                EasyPower: 7908 ms
-407261252961037760
               NaivePower: 1993 ms
-407261252961037760
                FastPower: 2394 ms
-407261252961037760

使用随机数和试验的参数确实会改变输出特性,但试验之间的比率始终与所示的一致。


EDOCX1的0个方面有两个问题:

  • 最好用EDCOX1〔2〕代替EDCOX1〔1〕;按位运算更快。
  • 您的代码总是递减y并执行额外的乘法,包括y是偶数的情况。最好把这部分放在else条款中。
  • 不管怎样,我想你的基准测试方法并不完美。4x性能差异听起来很奇怪,如果看不到完整的代码就无法解释。

    在应用了上述改进之后,我已经使用JMH基准验证了fastPower确实比naivePower快,系数为1.3x到2x。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    package bench;

    import org.openjdk.jmh.annotations.*;

    @State(Scope.Benchmark)
    public class FastPow {
        @Param("3")
        int x;
        @Param({"25","28","31","32"})
        int y;

        @Benchmark
        public long fast() {
            return fastPower(x, y);
        }

        @Benchmark
        public long naive() {
            return naivePower(x, y);
        }

        public static long fastPower(long x, int y) {
            long result = 1;
            while (y > 0) {
                if ((y & 1) == 0) {
                    x *= x;
                    y >>>= 1;
                } else {
                    result *= x;
                    y--;
                }
            }
            return result;
        }

        public static long naivePower(long x, int y) {
            long result = 1;
            for (int i = 0; i < y; i++) {
                result *= x;
            }
            return result;
        }
    }

    结果:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    Benchmark      (x)  (y)   Mode  Cnt    Score   Error   Units
    FastPow.fast     3   25  thrpt   10  103,406 ± 0,664  ops/us
    FastPow.fast     3   28  thrpt   10  103,520 ± 0,351  ops/us
    FastPow.fast     3   31  thrpt   10   85,390 ± 0,286  ops/us
    FastPow.fast     3   32  thrpt   10  115,868 ± 0,294  ops/us
    FastPow.naive    3   25  thrpt   10   76,331 ± 0,660  ops/us
    FastPow.naive    3   28  thrpt   10   69,527 ± 0,464  ops/us
    FastPow.naive    3   31  thrpt   10   54,407 ± 0,231  ops/us
    FastPow.naive    3   32  thrpt   10   56,127 ± 0,207  ops/us

    注:整数乘法运算速度非常快,有时甚至比额外的比较还要快。不要期望在long中使用合适的值进行巨大的性能改进。在指数较大的BigInteger上,快速功率算法的优势将更加明显。

    更新

    由于作者发布了基准,我必须承认令人惊讶的性能结果来自于常见的基准测试陷阱。我在保留原始方法的同时改进了基准,现在它表明fastPower确实比naivePower快,见这里。

    改进版本中的关键更改是什么?

  • 应在不同的JVM实例中分别测试不同的算法,以防止剖面污染。
  • 必须多次调用基准,以允许正确的编译/重新编译,直到达到稳定状态。
  • 一个基准测试应该放在一个单独的方法中,以避免堆栈上的替换问题。
  • 由于Hotspot不自动执行此优化,因此y % 2替换为y & 1
  • 最小化了主基准循环中不相关操作的影响。
  • 手动编写微基准是一项困难的任务。这就是为什么强烈建议使用适当的基准框架(如JMH)的原因。


    如果没有能力回顾和复制你的基准,那么尝试分解你的结果是没有意义的。这可能是由于输入选择不当、基准测试错误(例如在一个测试之前运行另一个测试(从而给JVM时间"预热")等原因造成的。请分享您的基准代码,而不仅仅是您的结果。

    我建议在你的测试中包括番石榴的EDOCX1(SRC),这是一种广泛使用和良好的基准测试方法。虽然您可能能够用某些输入击败它,但在一般情况下,您不太可能改进它的运行时(如果可以,他们很乐意听到)。

    不出意料的是,Math.pow()比仅正整数算法的性能更差。看看"快速"与"幼稚"的实现,很明显,这很大程度上取决于你选择的输入,正如迈克·卡默曼所建议的那样。对于小值的y,"幼稚"的解决方案显然要做的工作更少。但是对于较大的值,我们使用"快速"实现节省了大量迭代。


    在我看来,这个问题的第一个fastPower(base, exponent)是错误的,如果没有给出错误的结果。(下面的intPower()的第一个版本是错误的,因为除了稍微误导基准结果之外,给出了错误的结果。)由于评论"格式化能力",另一种通过平方来表示求幂的形式作为答案来争论:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    static public long intPower(int base, int exponent) {
        if (0 == base
            || 1 == base)
            return base;
        int y = exponent;
        if (y <= 0)
            return 0 == y ? 1 : -1 != base ? 0 : y % 2 == 1 ? -1 : 1;
        long result = y % 2 == 1 ? base : 1,
            power = base;
        while (1 < y) {
            power *= power;
            y >>= 1; // easier to see termination after Type.SIZE iterations
            if (y % 2 == 1)
                result *= power;
        }
        return result;
    }

    如果使用微基准(整数求幂的典型用法是什么?)如果使用框架,请进行适当的预热。千万不要把时间花在微基准测试结果上,因为每个选项的计时运行时间少于5秒。

    另一种选择来源于Guava的LongMath.pow(b, e)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    public long power(int base, int k) {
        for (long accum = 1, b = base ;; k >>>= 1)
            switch (k) {
            case 0:
                return accum;
            case 1:
                return accum * b;
            default:
                if ((k&1) != 0) // guava uses conditional multiplicand
                    accum *= b;
                b *= b;
            }
    }


    while循环运行log2(y)次,而for循环运行y次,因此根据您的输入,一个运行得比另一个快。

    while循环(最坏情况)运行:

  • 比较(while条件)
  • 模,
  • 比较,最坏的情况三更多的OPS:
  • 乘法运算
  • 位移位指定
  • 又一次乘法,最后,
  • 减量
  • 而幼稚的for循环运行:

  • 比较(for条件)
  • 乘法,和
  • 增量(for迭代器)
  • 因此,对于小值的y,您会期望简单的循环更快,因为for循环中更少的操作比"快速"方法的log2减少更好,只要这些额外操作所损失的时间大于log2减少y所获得的时间。