为什么字节相加的性能如此不可预测



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

严格来说,它实际上是字节对长加法。

这是我一直在使用的基准代码:
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/time per iteration: 50.26538857142857 ns
基准测试:byteArrayCheck/iterations: 1000/time per iteration: 20.12 ns
基准测试:byteArrayCheck/iterations: 10000/time per iteration: 9.1289 ns
基准测试:byteArrayCheck/iterations: 25000/time per iteration: 10.02972 ns
基准测试:byteArrayCheck/iterations: 50000/time per iteration: 9.04478 ns
基准测试:byteArrayCheck/iterations: 100000/time per iteration: 18.44992 ns
基准测试:byteArrayCheck/iterations: 200000/每次迭代时间:15.48304 ns
基准测试:byteArrayCheck/iterations: 300000/time per iteration: 15.806353333333334 ns
基准测试:byteArrayCheck/iterations: 400000/time per iteration: 16.923685 ns
基准测试:byteArrayCheck/iterations: 500000/time per iteration: 16.131066 ns
基准测试:byteArrayCheck/iterations: 600000/time per iteration: 16.435461666666665 ns
基准测试:byteArrayCheck/iterations: 700000/time per iteration: 17.107615714285714 ns

据我所知,在开始输出基准测试数据之前,JVM已经在最初的700,000次迭代之后完全热身好了。

为什么在热身之后,表现仍然不可预测呢?几乎在热身之后,字节的加法就变得非常快,但在那之后,它似乎又收敛到名义上的每次加法16 ns。

测试是在一台英特尔i7 3770处理器和16gb内存的PC上运行的,因此我不能超过700000次迭代。如果重要的话,它运行在Windows 8.1 64位上。

根据raphw的建议,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/time per iteration: 1658.2627914285715 ns
基准测试:byteArrayCheck/iterations: 1000/time per iteration: 1241.706 ns
基准测试:byteArrayCheck/iterations: 10000/time per iteration: 1215.941 ns
基准测试:byteArrayCheck/iterations: 25000/time per iteration: 1332.94656 ns
基准测试:byteArrayCheck/iterations: 50000/time per iteration: 1456.0361 ns
基准测试:byteArrayCheck/iterations: 100000/次迭代:1753.26777 ns
基准测试:byteArrayCheck/iterations: 200000/time per iteration: 1756.93283 ns
基准测试:byteArrayCheck/iterations: 300000/time per iteration: 1762.9992266666666 ns
基准测试:byteArrayCheck/iterations: 400000/time per iteration: 1806.854815 ns
基准测试:byteArrayCheck/iterations: 500000/time per iteration: 1784.09091 ns
基准测试:byteArrayCheck/iterations: 600000/time per iteration: 1804.6096366666666 ns
基准测试:byteArrayCheck/iterations: 700000/time per iteration: 1811.0597585714286 ns

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

解释是什么?

结果的原因是您实际上并不知道您在测量什么。Java的即时编译器肯定会检查你的代码,而你可能什么都没测量。

编译器足够聪明,可以找出您的List<byte[]>实际上没有用于任何事情。因此,它最终将从正在运行的应用程序中删除所有相关代码。因此,您的基准测试很可能测量的是一个越来越空的应用程序。

所有这些问题的答案总是:在我们实际查看有效的基准之前,不值得进行讨论。像JMH这样的基准测试工具(我可以推荐它)知道一个叫做黑洞的概念。黑洞是为了混淆即时编译器,以便认为计算值实际上用于某些东西,即使它不是。有了这样的黑洞,否则被擦除为no-op的代码将保留。

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

用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();
    }
}

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

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

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

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的答案有一个有效的点。

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

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

时钟:它被称为System.nanoTime(),但这并不意味着值变化得那么快。不久前,我对'System.currentTimeMillis()'做了一个测试,值每隔10分钟才改变一次。

就像计算机科学中的许多事情一样,这取决于情况。正如Dawnkeeper指出的,在windows 7操作系统下工作可能是问题的一部分。

实际情况是计算机上的所有进程共享CPU(甚至是多核CPU)。因此,您的进程只是需要占用CPU时间的数十甚至数百个进程中的一个。你的进程可能有更高的优先级,所以它会花费更多的时间在CPU上,比如,清理后台文件的进程(同样,由Dawnkeeper指出)。

有时会使CPU共享复杂化的是涉及I/O的进程。每当需要打印到屏幕或从磁盘获取内容时,它就会变慢。每当一个进程被从CPU中踢出去时,它会做两件事中的一件。如果这是一个"不错"的过程,它会保存它所在的位置,关闭所有东西,然后离开。如果进程涉及I/O,这将花费一些时间。另一种选择是,进程是"重要的",并将继续其任务,直到它达到一个好的点停止。这就像有人说"嘿,我需要和你谈谈",而你的回答是"这个YouTube视频20秒后就结束了,等一下"。

我希望这对你有帮助。JVM在计算机眼中只是另一个正在运行的进程。

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

编辑2:这可能有助于您更改优先级。

相关内容

  • 没有找到相关文章

最新更新