【问题标题】:Why is byte addition performance so unpredictable?为什么字节加法性能如此不可预测?
【发布时间】:2014-05-23 11:46:43
【问题描述】:

几个小时前,我回答了另一个 StackOverflow 问题,结果令人惊讶。答案可以在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 / 迭代次数:700000 / 每次迭代的时间:50.26538857142857 ns
基准测试:byteArrayCheck / 迭代次数:1000 / 每次迭代时间:20.12 ns
基准测试:byteArrayCheck / 迭代次数:10000 / 每次迭代的时间:9.1289 ns
基准测试:byteArrayCheck / 迭代次数:25000 / 每次迭代的时间:10.02972 ns
基准测试:byteArrayCheck / 迭代次数:50000 / 每次迭代的时间:9.04478 ns
基准测试:byteArrayCheck / 迭代次数:100000 / 每次迭代的时间:18.44992 ns
基准测试:byteArrayCheck / 迭代次数:200000 / 每次迭代的时间:15.48304 ns
基准测试:byteArrayCheck / 迭代次数:300000 / 每次迭代的时间:15.806353333333334 ns
基准测试:byteArrayCheck / 迭代次数:400000 / 每次迭代的时间:16.923685 ns
基准测试:byteArrayCheck / 迭代次数:500000 / 每次迭代时间:16.131066 ns
基准:byteArrayCheck / 迭代:600000 / 每次迭代的时间:16.435461666666665 ns
基准测试:byteArrayCheck / 迭代次数:700000 / 每次迭代的时间:17.107615714285714 ns

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

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

测试是在配备 Intel i7 3770 主频和 16 GB RAM 的 PC 上运行的,因此我的迭代次数不能超过 700000 次。如果重要的话,它在 Windows 8.1 64 位上运行。

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

因此,我将基准方法替换为以下内容:

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 / 迭代:700000 / 每次迭代的时间:1658.2627914285715 ns
基准测试:byteArrayCheck / 迭代次数:1000 / 每次迭代的时间:1241.706 ns
基准测试:byteArrayCheck / 迭代次数:10000 / 每次迭代的时间:1215.941 ns
基准测试:byteArrayCheck / 迭代次数:25000 / 每次迭代的时间:1332.94656 ns
基准测试:byteArrayCheck / 迭代次数:50000 / 每次迭代的时间:1456.0361 ns
基准测试:byteArrayCheck / 迭代次数:100000 / 每次迭代的时间:1753.26777 ns
基准测试:byteArrayCheck / 迭代次数:200000 / 每次迭代的时间:1756.93283 ns
基准测试:byteArrayCheck / 迭代次数:300000 / 每次迭代的时间:1762.9992266666666 ns
基准测试:byteArrayCheck / 迭代次数:400000 / 每次迭代的时间:1806.854815 ns
基准测试:byteArrayCheck / 迭代次数:500000 / 每次迭代的时间:1784.09091 ns
基准测试:byteArrayCheck / 迭代次数:600000 / 每次迭代的时间:1804.6096366666666 ns
基准:byteArrayCheck / 迭代:700000 / 每次迭代的时间:1811.0597585714286 ns

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

解释是什么?

【问题讨论】:

  • 您正在为每一轮基准测试创建新数组。因此垃圾收集器可能会对结果产生影响。
  • @Robert 不幸的是,我不能很好地纠正它,因为我没有空间将所有内容存储在内存中。并且遗漏一些数据可能会影响测试。
  • @skiwi 如果你打电话给System.gc()会发生什么?我知道它不能保证 gc,但是从 Jon Skeet 的观察之一(找不到问题......)无论如何它几乎总是发生。你也应该能够设置一个 JVM 标志,当 gc 发生时打印出来
  • 使用适当的基准测试框架来防止大量不确定的事情,或者这根本是无效或严重的。从调查 JMH 或 Caliper 开始。
  • 你有一个 64kbyte L1 缓存,从外观上看,这是造成差异的原因。

标签: java benchmarking


【解决方案1】:

产生结果的原因是您实际上并不知道自己在测量什么。 Java 的即时编译器肯定会查看您的代码,而您可能没有测量任何东西。

编译器足够聪明,可以确定您的List&lt;byte[]&gt; 实际上并没有用于任何事情。因此,它最终会从您正在运行的应用程序中删除所有相关代码。因此,您的基准测试很可能衡量的是越来越空的应用程序。

所有此类问题的答案始终是:在我们真正查看有效基准之前,不值得讨论。诸如JMH(我可以推荐)之类的基准测试工具知道一个称为黑洞的概念。黑洞旨在混淆即时编译器,以便认为计算值实际上用于某事,即使它不是。有了这样的黑洞,否则将保留作为无操作擦除的代码。

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

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

关于您的更新:我只能重复一遍。永远不要相信未经利用的基准!运行 JITwatch 是了解 JVM 对您的代码所做的事情的一种简单方法。您的基准测试的主要问题是它忽略了 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();
    }
}

【讨论】:

  • 我不相信你是完全正确的。 List&lt;byte[]&gt; 实际上是用来做某事的——计算 sum,然后打印出来,所以你描述的第一个优化不会发生。
  • 这就是为什么基准测试通常很棘手。除非您使用特定于机器的硬件接近代码(如汇编)编写它们,否则您真的不知道您在进行基准测试。尝试通过分析器运行您的基准测试代码,您会看到。
  • @user3580294:在整个代码中,sum 打印在哪里?实际上,sum 根本没有使用。
  • @user3580294 不,它从未真正使用过。您的所有呼叫站点都是单态的。这使得 JIT 编译器非常容易确定生成的 List 永远不会离开 start 方法。它永远不会被持久化、打印、序列化或类似的。它将进行简单的逃逸分析并删除整个列表创建。一个黑洞会为你模拟这个属性。
  • 糟糕,它没有打印出来。它 用于布尔检查,但似乎没有使用。我的错。
【解决方案2】:

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

您的处理器具有 8MB 缓存,因此在该迭代次数下,您应该会获得下一次减速。您可以通过仅每四个字节添加一次来更改测试,您会发现您的时间并没有改善,因为花费时间的不是操作而是内存带宽。

【讨论】:

    【解决方案3】:

    对基准方法的简单更改会产生巨大的影响:

    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
    

    (由于内存不足,我跳过了较大的数字)

    它表明,有一个固定的开销随着更大的数字变得可以忽略不计,而且,10 到 20 纳秒范围内的波动是无关紧要的。


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

    【讨论】:

    • 我刚刚更新了我的问题以包含此修复程序。然而,对我来说,迭代次数较少的基准测试仍然比迭代次数较多的基准要快得多。无论您的情况多么有趣,时间都更加不可预测。
    • @skiwi:正如您从我的打印输出中看到的那样,我的预热时间较小(因为我的 RAM 较小),并且您已将方法更改为使用 for-loop 而不是forEach(lambda)。较小数组的差异似乎是我为 gc 添加的暂停的副作用(我猜,暂停后线程在不同的核心上运行并且必须重新填充缓存)。将代码更改为更接近您的代码后,我得到的值几乎与您相同。
    • 剩下的主要问题是 JVM 的分析。对于每个新基准,您可以混合不同尺寸的配置文件。为了克服这个问题,您需要为任何运行分叉一个不同的 JVM 进程。我写了一个基准来解决这个问题。请参阅我的更新答案。
    【解决方案4】:

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

    Windows:即使您没有运行其他任何东西,系统也可能会决定它需要运行您的代码的核心来润色一些图形或清除一些长期被遗忘的文件。

    时钟:它叫做System.nanoTime(),但这并不意味着值变化那么快。不久前,我对“System.currentTimeMillis()”进行了测试,该值仅每 10 毫秒更改一次。

    【讨论】:

    • 结果的模式(显然不是完全相同的数字)在许多随机计时的基准测试中是一致的。
    • 此外,OP 正在运行多核处理器并且代码似乎没有线程化这一事实意味着操作系统极不可能将计时线程关闭以进行随机工作。
    • 如果你真的每次都得到相同的结果,那就太奇怪了。根据发生的情况,您应该在每次运行时随机获得更高的结果,并且更高样本数的结果应该更稳定,因为更长的运行时间消除了计时器分辨率的错误。
    • 此外,由于计时器颗粒度,低样本数的结果应该会导致每个样本的执行时间更短。
    • 您自己发现了为什么您永远不应该使用System.currentTimeMillis 进行基准测试的原因(其中之一,还有更多)。 nanoTime 使用高性能计数器(完全不同的 API)并且比 10 毫秒更准确。
    【解决方案5】:

    就像计算机科学中的许多事情一样,这取决于。 Dawnkeeper 指出,使用 Windows 7 操作系统可能是问题的一部分。

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

    有时会增加 CPU 共享的因素是参与 I/O 的进程。每当需要打印到屏幕上或从磁盘中获取某些内容时,它都是缓慢的。每次进程从 CPU 中启动时,它都会做两件事之一。如果这是一个“不错”的过程,它将保存在原处并关闭所有内容并退出。如果进程涉及 I/O,这将需要一些时间。另一种选择是该过程是“重要的”,并将继续其任务,直到它达到停止的好点。这与有人说“嘿,我需要和你谈谈”而你回答“这个 YouTube 视频将在 20 秒后结束,等等”时没有什么不同。

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

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

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

    【讨论】:

    • 这个程序的重要部分涉及“慢” I/O,但绝不是任何形式、形状或形式。
    • 如果您查看代码,您可以看到在每一轮基准测试完成后打印完成,在任何时间之外。
    • 在这种情况下,我会说差异与 CPU 调度程序的工作方式有关。您可以做一些事情来使您的 JVM 线程具有更高的优先级。看看手动设置线程优先级高低之间的时间会很有趣
    • 尝试最大化你的进程的优先级,看看你是否得到相同的结果。
    • sevenforums.com/tutorials/… 这可以帮助您设置优先级。
    猜你喜欢
    • 2012-07-01
    • 1970-01-01
    • 1970-01-01
    • 2023-03-10
    • 1970-01-01
    • 2021-03-27
    • 1970-01-01
    • 2013-02-03
    • 2020-11-13
    相关资源
    最近更新 更多