?

Log in

No account? Create an account

elizarov


Блог Романа Елизарова


Previous Entry Share Next Entry
Детали реализации имеют значение или direct ByteBuffer vs heap ByteBuffer
elizarov

При написании кода для максимальной производительности особое внимание необходимо уделять используемым библиотекам, даже если эти библиотеки являются частью вашего языка программирования. Необходимо подробно изучать не только контракты, описанные в их API, но и детали их реализации. Для высокопроизводительного кода детали реализации играют огромное значение.

В предыдущей записи про оптимизацию кода, я сравнивал класс ByteBuffer с int[]. Для этого сравнения я использовал прямой буфер (direct buffer), получаемый вызовом метода allocateDirect. Это тот самый буфер, который позволяет вынести данные за пределы кучи в языке Java. А что будет если заменить его на буфер в куче (heap buffer), получаемый вызовом метода allocate?

Проверить это не сложно. Достаточно заменить в одной строчке кода allocateDirect на allocate и произвести измерения. Результаты одного из прогонов моей простенькой тестовой программы, в сравнении с предыдущими результатами для прямого буфера, выглядят так (в таблице показано время в наносекундах, затрачиваемое в расчете на одну итерацию цикла сложения целых чисел из буфера):

РазмерViaByteBuffer2 (direct)ViaByteBuffer3 (heap)
1 0000.779.61
10 0000.779.60
100 0000.869.55
1 000 0000.919.68
10 000 0000.949.76

Скорость работы упала более чем в 10 раз. Чем это можно объяснить? Объяснение легко и просто увидеть, если посмотреть на детали реализации класса ByteBuffer. Внутри методов allocateDirect и allocate создаются конкретные реализации — DirectByteBuffer и HeapByteBuffer соответственно. Посмотрим на реализацию метода getInt в классе DirectByteBuffer:

    public int getInt(int i) { 
        return getInt(ix(checkIndex(i, (1 << 2)))); 
    } 
 
    private int getInt(long a) { 
        if (unaligned) { 
            int x = unsafe.getInt(a); 
            return (nativeByteOrder ? x : Bits.swap(x)); 
        } 
        return Bits.getInt(a, bigEndian); 
    } 

    private long ix(int i) { 
        return address + (i << 0); 
    } 
 
    // В классе Buffer
    final int checkIndex(int i, int nb) {
        if ((i < 0) || (nb > limit - i)) 
            throw new IndexOutOfBoundsException(); 
        return i; 
    } 

Видим достаточно прямолинейный код, который проверяет границы, вычисляет адрес, и, при естественном порядке байт и возможности платформы работать с целыми числами независимо от выравнивания, достает целое число напрямую через unsafe.getInt — этот вызов является встроенным (intrinsic) в HotSpot и транслируется в соответствующую инструкцию процессора.

А что происходит в классе HeapByteBuffer? Давайте посмотрим:

    public int getInt(int i) { 
        return Bits.getInt(this, ix(checkIndex(i, 4)), bigEndian); 
    } 

    // В классе Bits
    static int getInt(ByteBuffer bb, int bi, boolean bigEndian) { 
        return bigEndian ? getIntB(bb, bi) : getIntL(bb, bi) ; 
    } 

    static int getIntL(ByteBuffer bb, int bi) { 
        return makeInt(bb._get(bi + 3), 
                       bb._get(bi + 2), 
                       bb._get(bi + 1), 
                       bb._get(bi    )); 
    } 
 
    static private int makeInt(byte b3, byte b2, byte b1, byte b0) { 
        return (((b3       ) << 24) | 
                ((b2 & 0xff) << 16) | 
                ((b1 & 0xff) <<  8) | 
                ((b0 & 0xff)      )); 
    } 

    // Снова в классе HeapByteBuffer
    byte _get(int i) {
        return hb[i]; 
    } 

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

Можно ли из этого сделать вывод, что прямой буфер работает быстрей, чем буфер в куче? Конечно нет! Мы всего лишь увидели, что метод getInt в прямом буфере работает быстрей, однако это ничего не говорит о скорости работы других операций. При написания высокопроизводительного кода, важны детали реализации именно тех методов, которые вы используете, и конкретный способ их применения для решения вашей задачи.

UPDATE: А еще имеет значение что и как замеряется. Подробности в заметке "О замерах времени работы кода или знай что замеряешь часть первая"


  • 1
Как писать быстрые сетевые протоколы я тоже как нибудь расскажу. Там действительно потребуются совсем другие буфера. Но сделать универсально быстрое решение не получится. Для сервера приложений типа jboss нужен будет один дизайн. А чтобы держать одновременно тысячи соединений и передавать по ним миллионы котировок в секунду - другой.

  • 1