为什么字节添加性能如此不可预测?

时间:2014-05-23 11:46:43

标签: java benchmarking

几小时前我回答了另一个Stack Overflow问题,结果非常令人惊讶。答案可以找到here。答案是/部分错误,但我觉得专注于字节添加。

严格来说,它实际上是字节到长的添加。

这是我一直使用的基准代码:

public class ByteAdditionBenchmark {
    private void start() {
        int[] sizes = {
            700_000,
            1_000,
            10_000,
            25_000,
            50_000,
            100_000,
            200_000,
            300_000,
            400_000,
            500_000,
            600_000,
            700_000,
        };

        for (int size : sizes) {
            List<byte[]> arrays = createByteArrays(size);
            //Warmup
            arrays.forEach(this::byteArrayCheck);
            benchmark(arrays, this::byteArrayCheck, "byteArrayCheck");
        }
    }

    private void benchmark(final List<byte[]> arrays, final Consumer<byte[]> method, final String name) {
        long start = System.nanoTime();
        arrays.forEach(method);
        long end = System.nanoTime();
        double nanosecondsPerIteration = (end - start) * 1d / arrays.size();
        System.out.println("Benchmark: " + name + " / iterations: " + arrays.size() + " / time per iteration: " + nanosecondsPerIteration + " ns");
    }

    private List<byte[]> createByteArrays(final int amount) {
        Random random = new Random();
        List<byte[]> resultList = new ArrayList<>();
        for (int i = 0; i < amount; i++) {
            byte[] byteArray = new byte[4096];
            byteArray[random.nextInt(4096)] = 1;
            resultList.add(byteArray);
        }
        return resultList;
    }

    private boolean byteArrayCheck(final byte[] array) {
        long sum = 0L;
        for (byte b : array) {
            sum += b;
        }
        return (sum == 0);
    }

    public static void main(String[] args) {
        new ByteAdditionBenchmark().start();
    }
}

这是我得到的结果:

  

基准:byteArrayCheck / iterations:700000 /每次迭代的时间:50.26538857142857 ns
  基准:byteArrayCheck / iterations:1000 /每次迭代的时间:20.12 ns
  基准:byteArrayCheck / iterations:10000 /每次迭代的时间:9.1289 ns
  基准:byteArrayCheck / iterations:25000 /每次迭代的时间:10.02972 ns
  基准:byteArrayCheck / iterations:50000 /每次迭代的时间:9.04478 ns
  基准:byteArrayCheck / iterations:100000 /每次迭代的时间:18.44992 ns
  基准:byteArrayCheck / iterations:200000 /每次迭代的时间:15.48304 ns
  基准:byteArrayCheck / iterations:300000 /每次迭代的时间:15.806353333333334 ns
  基准:byteArrayCheck / iterations:400000 /每次迭代的时间:16.923685 ns
  基准:byteArrayCheck / iterations:500000 /每次迭代的时间:16.131066 ns
  基准:byteArrayCheck / iterations:600000 /每次迭代的时间:16.435461666666665 ns
  基准:byteArrayCheck / iterations:700000 /每次迭代的时间:17.107615714285714 ns

据我所知,在开始吐出基准测试数据之前,JVM在首次700000次迭代后已经完全预热。

那怎么可能呢,尽管热身,但表现仍然难以预测?几乎直接在预热后,字节加法变得非常快,但在此之后它似乎再次收敛到每次加法的标称16 ns。

这些测试是在装有英特尔i7 3770时钟和16 GB RAM的PC上运行的,因此我不能超过700000次迭代。如果重要的话,它在Windows 8.1 64位上运行。

事实证明,根据raphw's suggestion,JIT正在优化所有内容。

因此我用以下内容替换了基准测试方法:

private void benchmark(final List<byte[]> arrays, final Predicate<byte[]> method, final String name) {
    long start = System.nanoTime();
    boolean someUnrelatedResult = false;
    for (byte[] array : arrays) {
        someUnrelatedResult |= method.test(array);
    }
    long end = System.nanoTime();
    double nanosecondsPerIteration = (end - start) * 1d / arrays.size();
    System.out.println("Result: " + someUnrelatedResult);
    System.out.println("Benchmark: " + name + " / iterations: " + arrays.size() + " / time per iteration: " + nanosecondsPerIteration + "ns");
}

这将确保无法优化它并且测试结果也显示出来(为清晰起见省略了结果打印):

  

基准:byteArrayCheck / iterations:700000 /每次迭代的时间:1658.2627914285715 ns
  基准:byteArrayCheck / iterations:1000 /每次迭代的时间:1241.706 ns
  基准:byteArrayCheck / iterations:10000 /每次迭代的时间:1215.941 ns
  基准:byteArrayCheck / iterations:25000 /每次迭代的时间:1332.94656 ns
  基准:byteArrayCheck / iterations:50000 /每次迭代的时间:1456.0361 ns
  基准:byteArrayCheck / iterations:100000 /每次迭代的时间:1753.26777 ns
  基准:byteArrayCheck / iterations:200000 /每次迭代的时间:1756.93283 ns
  基准:byteArrayCheck / iterations:300000 /每次迭代的时间:1762.9992266666666 ns
  基准:byteArrayCheck / iterations:400000 /每次迭代的时间:1806.854815 ns
  基准:byteArrayCheck / iterations:500000 /每次迭代的时间:1784.09091 ns
  基准:byteArrayCheck / iterations:600000 /每次迭代的时间:1804.6096366666666 ns
  基准:byteArrayCheck / iterations:700000 /每次迭代的时间:1811.0597585714286 ns

我会说这些结果在计算时间方面看起来更有说服力。但是,我的问题仍然存在。随机时间重复测试,相同的模式仍然是迭代次数较少的基准测试比迭代次数更多的基准测试更快,但它们似乎稳定在100,000次迭代或更低的位置。

解释是什么?

5 个答案:

答案 0 :(得分:19)

您的结果的原因是您实际上并不知道您在测量什么。 Java的即时编译器肯定会查看您的代码,而您可能只是在测量任何内容。

编译器非常聪明,可以确定您的List<byte[]>实际上并未用于任何事情。因此,它最终将从正在运行的应用程序中删除所有相关代码。因此,您的基准测试最有可能测量越来越空的应用程序。

所有这些问题的答案总是如此:在我们真正研究有效基准之前,不值得进行讨论。基准线束如JMH(我可以推荐)知道一个叫做黑洞的概念。黑洞意味着混淆即时编译器,以便认为计算值实际上用于某些东西,即使它不是。有了这样的黑洞,否则将被删除的代码将被保留。

本土基准测试的另一个典型问题是优化的循环。同样,即时编译器会注意到循环导致任何迭代的计算相同,因此将完全删除循环。使用(质量)基准测试工具,您只会建议运行多个循环而不是硬编码。这样,线束可以处理欺骗编译器。

用JMH写一个基准,你会发现你的测量时间会有很大差异。

关于您的更新:我只能重复一遍。永远不要相信未经证实的基准!查看JVM对代码执行的操作的一种简单方法是运行JITwatch。您的基准测试的主要问题是它忽略了JVM的分析。配置文件是JVM尝试记住代码属性的一种尝试,然后基于其优化。对于您的基准测试,您将不同运行的配置文件混合在一起。然后,JVM必须更新其当前的配置文件,并在运行时重新编译字节代码,这需要花费时间。

为了避免这个问题,像JMH这样的安全工具允许您为每个基准测试分配JVM新进程。这是我用一个利用的基准测量的东西:

Benchmark                    Mode   Samples         Mean   Mean error    Units
o.s.MyBenchmark.test100k     avgt        20     1922.671       29.155    ns/op
o.s.MyBenchmark.test10k      avgt        20     1911.152       13.217    ns/op
o.s.MyBenchmark.test1k       avgt        20     1857.205        3.086    ns/op
o.s.MyBenchmark.test200k     avgt        20     1905.360       18.102    ns/op
o.s.MyBenchmark.test25k      avgt        20     1832.663      102.562    ns/op
o.s.MyBenchmark.test50k      avgt        20     1907.488       18.043    ns/op

以下是基于提到的JMH的基准测试的源代码:

@State(Scope.Benchmark)
public class MyBenchmark {

    private List<byte[]> input1k, input10k, input25k, input50k, input100k, input200k;

    @Setup
    public void setUp() {
        input1k = createByteArray(1_000);
        input10k = createByteArray(10_000);
        input25k = createByteArray(25_000);
        input50k = createByteArray(50_000);
        input100k = createByteArray(100_000);
        input200k = createByteArray(200_000);
    }

    private static List<byte[]> createByteArray(int length) {
        Random random = new Random();
        List<byte[]> resultList = new ArrayList<>();
        for (int i = 0; i < length; i++) {
            byte[] byteArray = new byte[4096];
            byteArray[random.nextInt(4096)] = 1;
            resultList.add(byteArray);
        }
        return resultList;
    }

    @GenerateMicroBenchmark
    @BenchmarkMode(Mode.AverageTime)
    @OutputTimeUnit(TimeUnit.NANOSECONDS)
    @OperationsPerInvocation(1_000)
    public boolean test1k() {
        return runBenchmark(input1k, this::byteArrayCheck);
    }

    @GenerateMicroBenchmark
    @BenchmarkMode(Mode.AverageTime)
    @OutputTimeUnit(TimeUnit.NANOSECONDS)
    @OperationsPerInvocation(10_000)
    public boolean test10k() {
        return runBenchmark(input10k, this::byteArrayCheck);
    }

    @GenerateMicroBenchmark
    @BenchmarkMode(Mode.AverageTime)
    @OutputTimeUnit(TimeUnit.NANOSECONDS)
    @OperationsPerInvocation(25_000)
    public boolean test25k() {
        return runBenchmark(input25k, this::byteArrayCheck);
    }

    @GenerateMicroBenchmark
    @BenchmarkMode(Mode.AverageTime)
    @OutputTimeUnit(TimeUnit.NANOSECONDS)
    @OperationsPerInvocation(50_000)
    public boolean test50k() {
        return runBenchmark(input50k, this::byteArrayCheck);
    }

    @GenerateMicroBenchmark
    @BenchmarkMode(Mode.AverageTime)
    @OutputTimeUnit(TimeUnit.NANOSECONDS)
    @OperationsPerInvocation(100_000)
    public boolean test100k() {
        return runBenchmark(input100k, this::byteArrayCheck);
    }

    @GenerateMicroBenchmark
    @BenchmarkMode(Mode.AverageTime)
    @OutputTimeUnit(TimeUnit.NANOSECONDS)
    @OperationsPerInvocation(200_000)
    public boolean test200k() {
        return runBenchmark(input200k, this::byteArrayCheck);
    }

    private static boolean runBenchmark(List<byte[]> arrays, Predicate<byte[]> method) {
        boolean someUnrelatedResult = false;
        for (byte[] array : arrays) {
            someUnrelatedResult |= method.test(array);
        }
        return someUnrelatedResult;
    }

    private boolean byteArrayCheck(final byte[] array) {
        long sum = 0L;
        for (byte b : array) {
            sum += b;
        }
        return (sum == 0);
    }

    public static void main(String[] args) throws RunnerException {
        new Runner(new OptionsBuilder()
                .include(".*" + MyBenchmark.class.getSimpleName() + ".*")
                .forks(1)
                .build()).run();
    }
}

答案 1 :(得分:2)

对于1000次迭代,您只是测量方法调用的开销,测量时间等,这超过了实际工作的时间。超过50,000次迭代,您的处理器耗尽L1缓存并减慢速度。根据处理器的高速缓存大小,当数据不再适合L2高速缓存时,您可能会在几百万次迭代中再次出现减速。

你的处理器有8MB缓存,所以在这个迭代次数你应该得到下一个减速。您可以通过仅添加说出每四个字节来更改测试,并且您将看到您的时间没有改善,因为它不是操作,而是内存带宽花费时间。

答案 2 :(得分:2)

对基准方法进行简单的更改会产生巨大的差异:

private void benchmark(final List<byte[]> arrays, final Predicate<byte[]> method, final String name) {
    long start = System.nanoTime();
    arrays.forEach(a -> { if(method.test(a)) System.out.println(); });
    long end = System.nanoTime();
    double nanosecondsPerIteration = (end - start) * 1d / arrays.size();
    System.out.println("Benchmark: " + name + " / iterations: " + arrays.size() + " / time per iteration: " + nanosecondsPerIteration + "ns");
}

这里,结果实际上是从JVM的角度使用的。在我的机器上获得原始代码的大致相同值时,我得到了更改后的结果:

Benchmark: byteArrayCheck / iterations: 300000 / time per iteration: 1447.9460033333332ns
Benchmark: byteArrayCheck / iterations: 1000 / time per iteration: 3801.986ns
Benchmark: byteArrayCheck / iterations: 10000 / time per iteration: 3319.9504ns
Benchmark: byteArrayCheck / iterations: 25000 / time per iteration: 1929.62352ns
Benchmark: byteArrayCheck / iterations: 50000 / time per iteration: 1943.07152ns
Benchmark: byteArrayCheck / iterations: 100000 / time per iteration: 1928.07745ns
Benchmark: byteArrayCheck / iterations: 200000 / time per iteration: 1915.344575ns
Benchmark: byteArrayCheck / iterations: 300000 / time per iteration: 1918.1994833333333ns
Benchmark: byteArrayCheck / iterations: 400000 / time per iteration: 1913.248085ns

(由于RAM不足,我跳过了更高的数字)

它表明,固定的开销在数字较大的情况下可以忽略不计,而且在10到20纳秒范围内的波动也无关紧要。


我想强调的是,这仍然不是一个可靠的基准(如果有的话)。但足够好表示raphw’s answer有一个有效点。

答案 3 :(得分:1)

这可能是很多事情。其中:Windows和时钟。

Windows :即使您没有运行任何其他内容,系统也可能会认为它需要运行您的代码的核心来润滑某些图形或者粉碎一些长期遗忘的文件。

时钟:它被称为System.nanoTime(),但这并不意味着值会快速变化。前一段时间我在'System.currentTimeMillis()'上做了一个测试,并且每10毫秒只更改一次。

答案 4 :(得分:1)

像计算机科学中的很多东西一样,它取决于。像Dawnkeeper指出的那样使用Windows 7操作系统可能是问题的一部分。

现实情况是,计算机上的所有进程共享CPU(甚至是多核CPU)。因此,您的流程只是数十个,也许是数百个需要CPU时间的流程之一。您的进程可能具有更高的优先级,因此它将花费更多时间在CPU上,而不是在后台清理文件的过程(再次,由Dawnkeeper指出)。

有时会影响CPU共享的是涉及I / O的进程。每当需要打印到屏幕上或从磁盘上获取某些内容时,它就会变慢。每次从CPU启动进程时,它都会执行以下两项操作之一。如果它是一个“好的”过程,它将保存它的位置并关闭所有内容并退出。如果该过程涉及I / O,则需要一些时间。另一个选择是该过程是“重要的”并将继续其任务,直到它达到一个好的停止点。当有人说“嘿,我需要和你说话”并且你回答“这个YouTube视频将在20秒内结束,坚持”时,这就不同了。

我希望有所帮助。 JVM只是计算机眼中的另一个过程。

编辑:澄清问题 - 您如何处理这些印刷声明?它们是否被打印到屏幕上?写入文件?存储在内存中,直到执行完成然后写入文件?

编辑2:this可以帮助您更改优先级。