为什么我不能在 32 位和 64 位 JVM 上观察到相同的性能改进?

Why can’t I observe the same performance improvement on both JVMs 32 and 64bits?

我正在测试两种不同的方法(primes()primesOpt())来使用 Java 8 IntStream 收集前 N 个质数。我从 Java 8 in Action. You can get the source code from this gist Primes.java and this pom.xml 的第 6 章中获取了这些示例,以使用 Maven 和 JMH 集成来构建它。 (你可以将 pom.xml 复制到项目文件夹,将 Primes.java 复制到 src\main\java\primes 并使用命令构建它:mvn clean install。之后你可以 运行 基准测试: java -jar target\benchmarks.jar).

第一个示例(primes() 方法)是一种将 N 个素数收集到 List<Integer> 中的简单算法。第二种方法(primesOpt() 方法)是一种增强方法,它只测试除以前面的质数。

我用 JMH 测试了这两个实现,以计算 List<Integer> 个素数,最大值为 10,000:

@Benchmark
public int testPrimes() {
    return primes(10_000).size();
}

@Benchmark    
public int testPrimesOpt() {
    return primesOpt(10_000).size();
}

而且我根据 JVM 架构获得了不同的加速。在 JVM 64 位中,我观察到 primesOpt() 比标准版本 primes() 加速了 25%,而对于 JVM 32 位,没有加速。

JRE 1.8 的结果。0_91-b14 64 位:

Benchmark              Mode  Cnt    Score    Error  Units
Primes.testPrimes     thrpt   50  269,278 ± 15,922  ops/s
Primes.testPrimesOpt  thrpt   50  341,861 ± 25,413  ops/s

JRE 1.8 的结果。0_91-b14 32 位:

Benchmark              Mode  Cnt    Score   Error  Units
Primes.testPrimes     thrpt  200  105,388 ± 2,741  ops/s
Primes.testPrimesOpt  thrpt  200  103,015 ± 2,035  ops/s

这些测试是在具有双核 Intel I7 Cpu 的机器上执行的,具有超线程,导致 2 个内核和 4 个硬件线程。此外,系统有 4GB 的 RAM。使用的 JVM 版本是 Windows 7 上的 1.8.0_91-b14、运行ning。基准测试以最小堆大小 1024MB(对应于 -Xms1024M)执行。在测量期间没有其他活动 运行ning。

你知道为什么我不能在 JVM 32 位上观察到素数算法优化版本的相同性能改进吗?

primes()方法实现:

public static boolean isPrime(int n) {
    int root = (int) Math.sqrt(n);
    return IntStream
        .rangeClosed(2, root)
        .noneMatch(div -> n%div == 0);
}
public static List<Integer> primes(int max) {
    return IntStream
        .range(2, max)
        .filter(Primes::isPrime)
        .collect(ArrayList::new, ArrayList::add, ArrayList::addAll);
}

primesOpt()方法实现:

public static boolean isPrimeOpt(List<Integer> primes, int n) {
    int root = (int) Math.sqrt(n);
    return takeWhile(primes, root)
        .stream()
        .noneMatch(div -> n%div == 0);
}

public static List<Integer> takeWhile(List<Integer> src, int max) {
    int i;
    for(i = 0; i < src.size() && src.get(i) <= max; i++) {}
    return src.subList(0, i);
}

public static List<Integer> primesOpt(int max) {
    ArrayList<Integer> res = new ArrayList<>();
    return IntStream
        .range(2, max)
        .filter(n -> Primes.isPrimeOpt(res, n))
        .collect(() -> res, ArrayList::add, (l1, l2) -> {});
}

我无法重现您的结果,但通常情况下,性能会因环境因素而有很大差异。在您的代码中,takewhile 方法强制处理 boxed Integer 值,而未优化的变体 isPrime 正在处理 int 值只有.

您要求的素数越多,这种权衡就会得到回报,即如果扫描第一个 10_000 个数字显示矛盾的结果,请尝试 100_0001_000_000。装箱开销在最坏的情况下是线性的,一个体面的 JVM 可能会将其变成次线性甚至恒定的开销,而将除法限制为实际素数的改进应该高于线性,因为素数的密度随着数字的增加而下降。

因此,也许您使用的 64 位 JVM 在处理装箱值时具有更高的开销,但我认为,使用更高的 max 进行测试也会揭示您的优化变体的优势——除非 JVM 知道显着降低除法运算成本的技巧。


但不容忽视的是,您的优化变体在多个方面存在问题。您正在将供应商 () -> res 传递给 collect,这违反了合同,因为它在每次评估时都没有 return 一个新容器,并且 Collector 和前面使用的 Predicate 之间存在干扰filter步。

这表明尝试优化基于 Stream 的解决方案可能不会有任何结果。与以下直接方法进行比较:

public static List<Integer> primesBest(int max) {
    BitSet prime=new BitSet();
    prime.set(1, max>>1);
    for(int i=3; i<max; i+=2)
        if(prime.get((i-1)>>1))
            for(int b=i*3; b<max; b+=i*2) prime.clear((b-1)>>1);
    return IntStream.concat( IntStream.of(2),
        prime.stream().map(i->i+i+1)).boxed().collect(Collectors.toList());
}

它避免了所有除法和装箱操作的“缺点”,即不使用 Stream 操作进行值选择,而仅用于创建最终 List<Integer>。在我的机器上,它比 10_000 元素的优化变体快 10 倍,1_000_000 元素快 50 倍。这表明 10%、20% 甚至两三倍的性能差异不值得任何讨论。

虽然我不明白如何使用 Stream API 来表达这个算法。底线可能是并非每个操作都受益于 Stream API.