十六、代码校验(5)

本章概要

  • 基准测试
    • 微基准测试
    • JMH 的引入

基准测试

我们应该忘掉微小的效率提升,说的就是这些 97% 的时间做的事:过早的优化是万恶之源。—— Donald Knuth

如果你发现自己正在过早优化的滑坡上,你可能浪费了几个月的时间(如果你雄心勃勃的话)。通常,一个简单直接的编码方法就足够好了。如果你进行了不必要的优化,就会使你的代码变得无谓的复杂和难以理解。

基准测试意味着对代码或算法片段进行计时看哪个跑得更快,与下一节的分析和优化截然相反,分析优化是观察整个程序,找到程序中最耗时的部分。

可以简单地对一个代码片段的执行计时吗?在像 C 这样直接的编程语言中,这个方法的确可行。在像 Java 这样拥有复杂的运行时系统的编程语言中,基准测试变得更有挑战性。为了生成可靠的数据,环境设置必须控制诸如 CPU 频率,节能特性,其他运行在相同机器上的进程,优化器选项等等。

微基准测试

写一个计时工具类从而比较不同代码块的执行速度是具有吸引力的。看上去这会产生一些有用的数据。比如,这里有一个简单的 Timer 类,可以用以下两种方式使用它:

  1. 创建一个 Timer 对象,执行一些操作然后调用 Timerduration() 方法产生以毫秒为单位的运行时间。
  2. 向静态的 duration() 方法中传入 Runnable。任何符合 Runnable 接口的类都有一个函数式方法 run(),该方法没有入参,且没有返回。
import static java.util.concurrent.TimeUnit.*;

public class Timer {
    private long start = System.nanoTime();

    public long duration() {
        return NANOSECONDS.toMillis(System.nanoTime() - start);
    }

    public static long duration(Runnable test) {
        Timer timer = new Timer();
        test.run();
        return timer.duration();
    }
}

这是一个很直接的计时方式。难道我们不能只运行一些代码然后看它的运行时长吗?

有许多因素会影响你的结果,即使是生成提示符也会造成计时的混乱。这里举一个看上去天真的例子,它使用了 标准的 Java Arrays 库(后面会详细介绍):

import java.util.*;

public class BadMicroBenchmark {
    static final int SIZE = 250_000_000;

    public static void main(String[] args) {
        try { // For machines with insufficient memory
            long[] la = new long[SIZE];
            System.out.println("setAll: " + Timer.duration(() -> Arrays.setAll(la, n -> n)));
            System.out.println("parallelSetAll: " + Timer.duration(() -> Arrays.parallelSetAll(la, n -> n)));
        } catch (OutOfMemoryError e) {
            System.out.println("Insufficient memory");
            System.exit(0);
        }
    }
}

在这里插入图片描述

main() 方法的主体包含在 try 语句块中,因为一台机器用光内存后会导致构建停止。

对于一个长度为 250,000,000 的 long 型(仅仅差一点就会让大部分机器内存溢出)数组,我们比较了 Arrays.setAll()Arrays.parallelSetAll() 的性能。这个并行的版本会尝试使用多个处理器加快完成任务(尽管我在这一节谈到了一些并行的概念,但是在 并发编程 章节我们才会详细讨论这些 )。然而非并行的版本似乎运行得更快,尽管在不同的机器上结果可能不同。

BadMicroBenchmark.java 中的每一步操作都是独立的,但是如果你的操作依赖于同一资源,那么并行版本运行的速度会骤降,因为不同的进程会竞争相同的那个资源。

import java.util.*;

public class BadMicroBenchmark2 {
    static final int SIZE = 5_000_000;

    public static void main(String[] args) {
        long[] la = new long[SIZE];
        Random r = new Random();
        System.out.println("parallelSetAll: " + Timer.duration(() -> Arrays.parallelSetAll(la, n -> r.nextLong())));
        System.out.println("setAll: " + Timer.duration(() -> Arrays.setAll(la, n -> r.nextLong())));
        SplittableRandom sr = new SplittableRandom();
        System.out.println("parallelSetAll: " + Timer.duration(() -> Arrays.parallelSetAll(la, n -> sr.nextLong())));
        System.out.println("setAll: " + Timer.duration(() -> Arrays.setAll(la, n -> sr.nextLong())));
    }
}

在这里插入图片描述

SplittableRandom 是为并行算法设计的,它当然看起来比普通的 RandomparallelSetAll() 中运行得更快。 但是看上去还是比非并发的 setAll() 运行时间更长,有点难以置信(也许是真的,但我们不能通过一个坏的微基准测试得到这个结论)。

这只考虑了微基准测试的问题。Java 虚拟机 Hotspot 也非常影响性能。如果你在测试前没有通过运行代码给 JVM 预热,那么你就会得到“冷”的结果,不能反映出代码在 JVM 预热之后的运行速度(假如你运行的应用没有在预热的 JVM 上运行,你就可能得不到所预期的性能,甚至可能减缓速度)。

优化器有时可以检测出你创建了没有使用的东西,或者是部分代码的运行结果对程序没有影响。如果它优化掉你的测试,那么你可能得到不好的结果。

一个良好的微基准测试系统能自动地弥补像这样的问题(和很多其他的问题)从而产生合理的结果,但是创建这么一套系统是非常棘手,需要深入的知识。

JMH 的引入

截止目前为止,唯一能产生像样结果的 Java 微基准测试系统就是 Java Microbenchmarking Harness,简称 JMH。本书的 build.gradle 自动引入了 JMH 的设置,所以你可以轻松地使用它。

你可以在命令行编写 JMH 代码并运行它,但是推荐的方式是让 JMH 系统为你运行测试;build.gradle 文件已经配置成只需要一条命令就能运行 JMH 测试。

JMH 尝试使基准测试变得尽可能简单。例如,我们将使用 JMH 重新编写 BadMicroBenchmark.java。这里只有 **@State ** 和 **@Benchmark ** 这两个注解是必要的。其余的注解要么是为了产生更多易懂的输出,要么是加快基准测试的运行速度(JMH 基准测试通常需要运行很长时间):

// validating/jmh/JMH1.java
package validating.jmh;
import java.util.*;
import org.openjdk.jmh.annotations.*;
import java.util.concurrent.TimeUnit;

@State(Scope.Thread)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MICROSECONDS)
// Increase these three for more accuracy:
@Warmup(iterations = 5)
@Measurement(iterations = 5)
@Fork(1)
public class JMH1 {
    private long[] la;
    
    @Setup
    public void setup() {
        la = new long[250_000_000];
    }
    
    @Benchmark
    public void setAll() {
        Arrays.setAll(la, n -> n);
    }
    
    public void parallelSetAll() {
        Arrays.parallelSetAll(la, n -> n);
    }
}

“forks” 的默认值是 10,意味着每个测试都运行 10 次。为了减少运行时间,这里使用了 **@Fork ** 注解来减少这个次数到 1。我还使用了 **@Warmup ** 和 **@Measurement ** 注解将它们默认的运行次数从 20 减少到 5 次。尽管这降低了整体的准确率,但是结果几乎与使用默认值相同。可以尝试将 **@Warmup @Measurement ** 和 **@Fork ** 都注释掉然后看使用它们的默认值,结果会有多大显著的差异;一般来说,你应该只能看到长期运行的测试使错误因素减少,而结果没有多大变化。

需要使用显式的 gradle 命令才能运行基准测试(在示例代码的根目录处运行)。这能防止耗时的基准测试运行其他的 gradlew 命令:

gradlew validating:jmh

这会花费几分钟的时间,取决于你的机器(如果没有注解上的调整,可能需要几个小时)。控制台会显示 results.txt 文件的路径,这个文件统计了运行结果。注意,results.txt 包含这一章所有 jmh 测试的结果:JMH1.javaJMH2.javaJMH3.java

因为输出是绝对时间,所以在不同的机器和操作系统上结果各不相同。重要的因素不是绝对时间,我们真正观察的是一个算法和另一个算法的比较,尤其是哪一个运行得更快,快多少。如果你在自己的机器上运行测试,你将看到不同的结果却有着相同的模式。

我在大量的机器上运行了这些测试,尽管不同的机器上得到的绝对值结果不同,但是相对值保持着合理的稳定性。我只列出了 results.txt 中适当的片段并加以编辑使输出更加易懂,而且内容大小适合页面。所有测试中的 Mode 都以 avgt 展示,代表 “平均时长”。Cnt(测试的数目)的值是 200,尽管这里的一个例子中配置的 Cnt 值是 5。Unitsus/op,是 “Microseconds per operation” 的缩写,因此,这个值越小代表性能越高。

我同样也展示了使用 warmups、measurements 和 forks 默认值的输出。我删除了示例中相应的注解,就是为了获取更加准确的测试结果(这将花费数小时)。结果中数字的模式应该仍然看起来相同,不论你如何运行测试。

下面是 JMH1.java 的运行结果:

Benchmark Score

JMH1.setAll 196280.2

JMH1.parallelSetAll 195412.9

即使像 JMH 这么高级的基准测试工具,基准测试的过程也不容易,练习时需要倍加小心。这里测试产生了反直觉的结果:并行的版本 parallelSetAll() 花费了与非并行版本的 setAll() 相同的时间,两者似乎都运行了相当长的时间。

当创建这个示例时,我假设如果我们要测试数组初始化的话,那么使用非常大的数组是有意义的。所以我选择了尽可能大的数组;如果你实验的话会发现一旦数组的大小超过 2亿5000万,你就开始会得到内存溢出的异常。然而,在这么大的数组上执行大量的操作从而震荡内存系统,产生无法预料的结果是有可能的。不管这个假设是否正确,看上去我们正在测试的并非是我们想测试的内容。

考虑其他的因素:

C:客户端执行操作的线程数量

P:并行算法使用的并行数量

N:数组的大小:**10^(2_k)_,通常来说,k=1…7 足够来练习不同的缓存占用。

Q:setter 的操作成本

这个 C/P/N/Q 模型在早期 JDK 8 的 Lambda 开发期间浮出水面,大多数并行的 Stream 操作(parallelSetAll() 也基本相似)都满足这些结论:**N_Q_(主要工作量)对于并发性能尤为重要。并行算法在工作量较少时可能实际运行得更慢。

在一些情况下操作竞争如此激烈使得并行毫无帮助,而不管 **N_Q_ 有多大。当 C 很大时,P 就变得不太相关(内部并行在大量的外部并行面前显得多余)。此外,在一些情况下,并行分解会让相同的 C 个客户端运行得比它们顺序运行代码更慢。

基于这些信息,我们重新运行测试,并在这些测试中使用不同大小的数组(改变 N):

// validating/jmh/JMH2.java
package validating.jmh;
import java.util.*;
import org.openjdk.jmh.annotations.*;
import java.util.concurrent.TimeUnit;

@State(Scope.Thread)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MICROSECONDS)
@Warmup(iterations = 5)
@Measurement(iterations = 5)
@Fork(1)
public class JMH2 {

    private long[] la;

    @Param({
            "1",
            "10",
            "100",
            "1000",
            "10000",
            "100000",
            "1000000",
            "10000000",
            "100000000",
            "250000000"
    })
    int size;

    @Setup
    public void setup() {
        la = new long[size];
    }

    @Benchmark
    public void setAll() {
        Arrays.setAll(la, n -> n);
    }

    @Benchmark
    public void parallelSetAll() {
        Arrays.parallelSetAll(la, n -> n);
    }
}

**@Param ** 会自动地将其自身的值注入到变量中。其自身的值必须是字符串类型,并可以转化为适当的类型,在这个例子中是 int 类型。

下面是已经编辑过的结果,包含精确计算出的加速数值:

JMH2 BenchmarkSizeScore %Speedup
setAll10.001
parallelSetAll10.0360.028
setAll100.005
parallelSetAll103.9650.001
setAll1000.031
parallelSetAll1003.1450.010
setAll10000.302
parallelSetAll10003.2850.092
setAll100003.152
parallelSetAll100009.6690.326
setAll10000034.971
parallelSetAll10000020.1531.735
setAll1000000420.581
parallelSetAll1000000165.3882.543
setAll100000008160.054
parallelSetAll100000007610.1901.072
setAll10000000079128.752
parallelSetAll10000000076734.6711.031
setAll250000000199552.121
parallelSetAll250000000191791.9271.040
可以看到当数组大小达到 10 万左右时,parallelSetAll() 开始反超,而后趋于与非并行的运行速度相同。即使它运行速度上胜了,看起来也不足以证明由于并行的存在而使速度变快。

setAll()/parallelSetAll() 中工作的计算量起很大影响吗?在前面的例子中,我们所做的只有对数组的赋值操作,这可能是最简单的任务。所以即使 N 值变大,**N_Q_ 也仍然没有达到巨大,所以看起来像是我们没有为并行提供足够的机会(JMH 提供了一种模拟变量 Q 的途径;如果想了解更多的话,可搜索 Blackhole.consumeCPU)。

我们通过使方法 f() 中的任务变得更加复杂,从而产生更多的并行机会:

// validating/jmh/JMH3.java
package validating.jmh;
import java.util.*;
import org.openjdk.jmh.annotations.*;
import java.util.concurrent.TimeUnit;

@State(Scope.Thread)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MICROSECONDS)
@Warmup(iterations = 5)
@Measurement(iterations = 5)
@Fork(1)
public class JMH3 {
    private long[] la;

    @Param({
            "1",
            "10",
            "100",
            "1000",
            "10000",
            "100000",
            "1000000",
            "10000000",
            "100000000",
            "250000000"
    })
    int size;

    @Setup
    public void setup() {
        la = new long[size];
    }

    public static long f(long x) {
        long quadratic = 42 * x * x + 19 * x + 47;
        return Long.divideUnsigned(quadratic, x + 1);
    }

    @Benchmark
    public void setAll() {
        Arrays.setAll(la, n -> f(n));
    }

    @Benchmark
    public void parallelSetAll() {
        Arrays.parallelSetAll(la, n -> f(n));
    }
}

f() 方法提供了更加复杂且耗时的操作。现在除了简单的给数组赋值外,setAll()parallelSetAll() 都有更多的工作去做,这肯定会影响结果。

JMH2 BenchmarkSizeScore %Speedup
setAll10.012
parallelSetAll10.0470.255
setAll100.107
parallelSetAll103.8940.027
setAll1000.990
parallelSetAll1003.7080.267
setAll1000133.814
parallelSetAll100011.74711.391
setAll1000097.954
parallelSetAll1000037.2592.629
setAll100000988.475
parallelSetAll100000276.2643.578
setAll10000009203.103
parallelSetAll10000002826.9743.255
setAll1000000092144.951
parallelSetAll1000000028126.2023.276
setAll100000000921701.863
parallelSetAll100000000266750.5433.455
setAll2500000002299127.273
parallelSetAll250000000538173.4254.272

可以看到当数组的大小达到 1000 左右时,parallelSetAll() 的运行速度反超了 setAll()。看来 parallelSetAll() 严重依赖数组中计算的复杂度。这正是基准测试的价值所在,因为我们已经得到了关于 setAll()parallelSetAll() 间微妙的信息,知道在何时使用它们。

这显然不是从阅读 Javadocs 就能得到的。

大多数时候,JMH 的简单应用会产生好的结果(正如你将在本书后面例子中所见),但是我们从这里知道,你不能一直假定 JMH 会产生好的结果。 JMH 网站上的范例可以帮助你开始。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

一只小熊猫呀

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值