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

2022-09-02 21:18:23

几个小时前,我回答了另一个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 / 迭代次数: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.80635333333334 ns
基准:byteArrayCheck /迭代次数:400000 / 每次迭代时间:16.923685 ns
基准:字节数组检查/迭代:500000 /每次迭代时间:16.131066 ns
基准:字节数组检查/迭代:600000 /每次迭代时间:16.43546166666666 ns
基准:字节数组检查/迭代:700000 /每次迭代时间:17.107615714285714 ns

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

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

这些测试是在具有Intel i7 3770库存时钟和16 GB RAM的PC上运行的,因此我无法超过700000次迭代。如果这很重要,它运行在Windows 8.1 64位上。

事实证明,JIT正在优化一切,正如raphw的建议一样。

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

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.999226666666 ns
基准:byteArrayCheck / 迭代:400000 / 每次迭代时间:1806.854815 ns
基准:byteArrayCheck / 迭代:500000 / 每次迭代时间:1784.09091 ns
基准:字节数组检查/迭代:600000 /每次迭代时间:1804.6096366666666 ns
基准:字节数组检查/迭代:700000 /每次迭代时间:1811.0597585714286 ns

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

这是什么解释?


答案 1

你的结果的原因是你实际上并不知道你在测量什么。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();
    }
}

答案 2

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

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


推荐