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

Пока я тестировал производительность чтения прямого java.nio.ByteBuffer, я заметил, что абсолютное чтение в среднем в 2 раза быстрее, чем относительное чтение. Кроме того, если я сравниваю исходный код относительного vs абсолютного чтения, код почти такой же, за исключением того, что относительное чтение поддерживает и внутренний счетчик. Интересно, почему я вижу такую ​​значительную разницу в скорости?

Ниже приведен исходный код моего теста JMH:

public class DirectByteBufferReadBenchmark {

    private static final int OBJ_SIZE = 8 + 4 + 1;
    private static final int NUM_ELEM = 10_000_000;

    @State(Scope.Benchmark)
    public static class Data {

        private ByteBuffer directByteBuffer;

        @Setup
        public void setup() {
            directByteBuffer = ByteBuffer.allocateDirect(OBJ_SIZE * NUM_ELEM);
            for (int i = 0; i < NUM_ELEM; i++) {
                directByteBuffer.putLong(i);
                directByteBuffer.putInt(i);
                directByteBuffer.put((byte) (i & 1));
            }
        }
    }



    @Benchmark
    @BenchmarkMode(Mode.Throughput)
    @OutputTimeUnit(TimeUnit.SECONDS)
    public long testReadAbsolute(Data d) throws InterruptedException {
        long val = 0l;
        for (int i = 0; i < NUM_ELEM; i++) {
            int index = OBJ_SIZE * i;
            val += d.directByteBuffer.getLong(index);
            d.directByteBuffer.getInt(index + 8);
            d.directByteBuffer.get(index + 12);
        }
        return val;
    }

    @Benchmark
    @BenchmarkMode(Mode.Throughput)
    @OutputTimeUnit(TimeUnit.SECONDS)
    public long testReadRelative(Data d) throws InterruptedException {
        d.directByteBuffer.rewind();

        long val = 0l;
        for (int i = 0; i < NUM_ELEM; i++) {
            val += d.directByteBuffer.getLong();
            d.directByteBuffer.getInt();
            d.directByteBuffer.get();
        }

        return val;
    }

    public static void main(String[] args) throws Exception {
        Options opt = new OptionsBuilder()
            .include(DirectByteBufferReadBenchmark.class.getSimpleName())
            .warmupIterations(5)
            .measurementIterations(5)
            .forks(3)
            .threads(1)
            .build();

        new Runner(opt).run();
    }
}

И это результаты моего теста:

Benchmark                                        Mode  Cnt   Score   Error  Units
DirectByteBufferReadBenchmark.testReadAbsolute  thrpt   15  88.605 ± 9.276  ops/s
DirectByteBufferReadBenchmark.testReadRelative  thrpt   15  42.904 ± 3.018  ops/s

Тест проводился на MacbookPro (2,2 ГГц Intel Core i7, 16 Гбит DDR3) и JDK 1.8.0_73.

UPDATE

Я запускаю тот же тест с JDK 9-ea b134. Оба теста показывают увеличение скорости на 10%, но разница в скорости между ними остается одинаковой.

# JMH 1.13 (released 45 days ago)
# VM version: JDK 9-ea, VM 9-ea+134
# VM invoker: /Library/Java/JavaVirtualMachines/jdk-9.jdk/Contents/Home/bin/java
# VM options: <none>


Benchmark                                        Mode  Cnt    Score    Error  Units
DirectByteBufferReadBenchmark.testReadAbsolute  thrpt   15  102.170 ± 10.199  ops/s
DirectByteBufferReadBenchmark.testReadRelative  thrpt   15   45.988 ±  3.896  ops/s

Ответы

Ответ 1

JDK 8 действительно создает худший код для цикла с относительным доступом ByteBuffer.

JMH имеет встроенный профилировщик perfasm, который печатает сгенерированный код сборки для самых горячих регионов. Я использовал его для сравнения скомпилированных testReadAbsolute vs. testReadRelative, и вот основные отличия:

  • Относительно getLong / getInt/ get обновить поле позиции ByteBuffer. VM не оптимизирует эти обновления: на каждой итерации цикла записано 3 записи памяти.

  • position проверка диапазона не устраняется: условные ветки на каждой итерации цикла остались в скомпилированном коде.

  • Поскольку избыточные обновления полей и проверки диапазона делают тело цикла более длинным, VM разворачивает только 2 итерации цикла. Скомпилированная версия для цикла с абсолютным доступом имеет 16 итераций, развернутых.

testReadAbsolute скомпилирован очень хорошо: основной цикл просто читает 16 длин, суммирует их и перескакивает на следующую итерацию, если index < 10_000_000 - 16. Состояние directByteBuffer не обновляется. Однако JVM не настолько умна для testReadRelative: кажется, что он не может оптимизировать доступ к полю объекта снаружи.

В JDK 9 было много работы по оптимизации ByteBuffer. Я выполнил тот же тест на JDK 9-ea b134 и подтвердил, что testReadRelative не имеет избыточных записей в памяти и проверки диапазона. Теперь он работает почти так же быстро, как testReadAbsolute.

// JDK 1.8.0_92, VM 25.92-b14

Benchmark                                        Mode  Cnt   Score   Error  Units
DirectByteBufferReadBenchmark.testReadAbsolute  thrpt   10  99,727 ± 0,542  ops/s
DirectByteBufferReadBenchmark.testReadRelative  thrpt   10  47,126 ± 0,289  ops/s

// JDK 9-ea, VM 9-ea+134

Benchmark                                        Mode  Cnt    Score   Error  Units
DirectByteBufferReadBenchmark.testReadAbsolute  thrpt   10  109,369 ± 0,403  ops/s
DirectByteBufferReadBenchmark.testReadRelative  thrpt   10   97,140 ± 0,572  ops/s

UPDATE

Чтобы помочь компилятору JIT с оптимизацией, я ввел локальную переменную

ByteBuffer directByteBuffer = d.directByteBuffer

в обоих тестах. В противном случае уровень косвенности не позволяет компилятору исключить обновления полей ByteBuffer.position.