Подтвердить что ты не робот

Почему производительность добавления байтов настолько непредсказуема?

Несколько часов назад я ответил на другой вопрос о переполнении Qaru и дал очень неожиданный результат. Ответ можно найти здесь. Ответ был/частично неправильным, однако я сфокусирован на добавлении байта.

Строго говоря, на самом деле это байтовое дополнение.

Это базовый код, который я использовал:

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

И вот результаты, которые я получаю:

Benchmark: byteArrayCheck/итерации: 700000/время на итерацию: 50.26538857142857 ns
Контрольный показатель: byteArrayCheck/итерации: 1000/время на итерацию: 20.12 ns
Контрольный показатель: byteArrayCheck/iterations: 10000/время на итерацию: 9.1289 ns
Контрольный показатель: byteArrayCheck/итерации: 25000/время на итерацию: 10.02972 ns
Контрольный показатель: byteArrayCheck/итерации: 50000/время на итерацию: 9.04478 нс
Контрольный показатель: byteArrayCheck/iterations: 100000/время на итерацию: 18.44992 ns
Benchmark: byteArrayCheck/iterations: 200000/время на итерацию: 15.48304 ns
Контрольный показатель: byteArrayCheck/iterations: 300000/время на итерацию: 15.806353333333334 ns
Контрольный показатель: byteArrayCheck/iterations: 400000/время на итерацию: 16.923685 ns
Контрольный показатель: byteArrayCheck/итерации: 500000/время на итерацию: 16.131066 ns
Benchmark: byteArrayCheck/iterations: 600000/время на итерацию: 16.435461666666665 ns
Benchmark: byteArrayCheck/iterations: 700000/время на итерацию: 17.107615714285714 ns

Насколько мне известно, JVM уже полностью разогревается после первых 700 000 итераций, прежде чем начнет выплевывать данные бенчмаркинга.

Как же может случиться, что, несмотря на разминку, производительность все еще непредсказуема? Как почти сразу после того, как добавление байта прогрева становится невероятно быстрым, но после этого он снова сходится к номинальному 16 ns за добавление снова.

Тестирование проводилось на ПК с поддержкой Intel i7 3770 и 16 ГБ оперативной памяти, поэтому я не могу выходить за рамки 700000 итераций. Он работает на 64-разрядной версии Windows 8.1, если это имеет значение.

Оказывается, что 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
Benchmark: byteArrayCheck/iterations: 1000/время на итерацию: 1241.706 ns
Benchmark: byteArrayCheck/iterations: 10000/время на итерацию: 1215.941 ns
Benchmark: byteArrayCheck/итерации: 25000/время на итерацию: 1332.94656 ns
Контрольный показатель: byteArrayCheck/iterations: 50000/время на итерацию: 1456.0361 ns
Benchmark: byteArrayCheck/iterations: 100000/время на итерацию: 1753.26777 ns
Контрольный показатель: byteArrayCheck/итерации: 200000/время на итерацию: 1756.93283 нс
Benchmark: byteArrayCheck/iterations: 300000/время на итерацию: 1762.9992266666666 ns
Benchmark: byteArrayCheck/iterations: 400000/время на итерацию: 1806.854815 ns
Контрольный показатель: byteArrayCheck/итерации: 500000/время на итерацию: 1784.09091 ns
Контрольный показатель: byteArrayCheck/iterations: 600000/время на итерацию: 1804.6096366666666 ns
Контрольный показатель: byteArrayCheck/итерации: 700000/время на итерацию: 1811.0597585714286 ns

Я бы сказал, что эти результаты выглядят намного более убедительными в отношении времени вычислений. Однако мой вопрос все еще стоит. При повторных тестах в случайные моменты один и тот же шаблон остается тем, что тесты с меньшим количеством итераций быстрее, чем те, у которых больше итераций, хотя они, похоже, стабилизируются на 100 000 итераций или где-то ниже.

Какое объяснение?

4b9b3361

Ответ 1

Причиной вашего результата является то, что вы на самом деле не знаете, что вы измеряете. 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();
    }
}

Ответ 2

В течение 1000 итераций вы просто измеряете накладные расходы на вызов метода, время измерения и т.д., что перевешивает время для выполнения фактической работы. Более 50 000 итераций, ваш процессор исходит из кеша L1 и замедляется. В зависимости от размера кэша вашего процессора у вас, вероятно, будет другое замедление на несколько миллионов итераций, когда данные больше не вписываются в кеш L2.

У вашего процессора 8 Мб кеша, поэтому при таком количестве итераций вы должны получить следующее замедление. Вы можете изменить тест, добавив, скажем, только каждый четвертый байт, и вы увидите, что ваше время не улучшается, потому что это не операции, а пропускная способность памяти, которая стоит времени.

Ответ 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

(я пропустил более высокие номера из-за недостаточной ОЗУ)

Это показывает, что theres фиксированные накладные расходы, которые становятся пренебрежимо малыми с большими числами, но также, что колебания в диапазоне от 10 до 20 наносекунд не имеют значения.


Я хочу подчеркнуть, что это все еще не является надежным эталоном (если он когда-либо может быть одним). Но это достаточно хорошо, чтобы указать, что ответ raphws имеет действительную точку.

Ответ 4

Это может быть много. Среди них: Windows и часы.

Windows. Даже если вы ничего не используете, система может решить, что для этого требуется, чтобы ваш код работал, чтобы отполировать некоторые графики или пылить некоторые давно забытые файлы.

Часы: он называется System.nanoTime(), но это не означает, что значения быстро меняются. Некоторое время назад я сделал тест на "System.currentTimeMillis()", и значение менялось только каждые 10 мкс.

Ответ 5

Как и многое в информатике, все зависит. Указание на работу с операционной системой Windows 7 может быть частью проблемы.

Реальность заключается в том, что все процессы на компьютере разделяют процессор (даже многоядерные процессоры). Таким образом, ваш процесс - это всего лишь один из десятков, возможно, сотни процессов, которые требуют времени на CPU. Вероятно, ваш процесс имеет более высокий приоритет, поэтому он будет тратить больше времени на процессор, чем сказать, процесс, который очищает файлы в фоновом режиме (опять же, указывается Dawnkeeper).

Что-то, что иногда связывает совместное использование CPU, - это процессы, которые участвуют в операции ввода-вывода. Всякий раз, когда что-то нужно печатать на экране или что-то делать с диска, оно МЕДЛЕННО. Каждый раз, когда процесс запускается с CPU, он выполняет одну из двух задач. Если это "хороший" процесс, он будет экономить там, где он есть, и выключить все и выйти. Если процесс задействован в операции ввода-вывода, это займет некоторое время. Другой вариант заключается в том, что этот процесс является "важным" и будет продолжать свою задачу до тех пор, пока он не достигнет хорошей точки для остановки. Это не похоже на то, когда кто-то говорит "Эй, мне нужно поговорить с тобой", и ты отвечаешь "Это видео YouTube закончится через 20 секунд, держись".

Надеюсь, это поможет. JVM - это еще один процесс в глазах компьютеров.

EDIT: вопросы с разъяснением - как вы обрабатываете эти заявления печати? Печатаются ли они на экране? Написано в файл? Хранится в памяти до тех пор, пока выполнение не будет завершено и THEN записано в файл?

EDIT 2: this может помочь вам изменить приоритет.