Почему нечетная разница в производительности между ByteBuffer.allocate() и ByteBuffer.allocateDirect()
Я работаю над некоторыми SocketChannel
-До- SocketChannel
код, который лучше всего работает с прямым байтовым буфером - долгоживущий и большой (от десятков до сотен мегабайт на соединение). При этом хеширование точной структуры цикла с помощью FileChannel
s, я запустил несколько микро-тестов на ByteBuffer.allocate()
против ByteBuffer.allocateDirect()
спектакль.
В результатах был сюрприз, который я не могу объяснить. На приведенном ниже графике очень четко выражен обрыв в 256 КБ и 512 КБ для ByteBuffer.allocate()
реализация переноса - производительность падает на ~50%! Там также, кажется, есть меньшая скала производительности для ByteBuffer.allocateDirect()
, (Ряд%-gain помогает визуализировать эти изменения.)
Размер буфера (в байтах) в зависимости от времени (MS)
Почему нечетная разница в производительности между ByteBuffer.allocate()
а также ByteBuffer.allocateDirect()
? Что именно происходит за занавесом?
Это очень хорошо, может быть, зависит от оборудования и ОС, так что вот эти детали:
- MacBook Pro с двухъядерным процессором Core 2
- SSD-накопитель Intel X25M
- OSX 10.6.4
Исходный код, по запросу:
package ch.dietpizza.bench;
import static java.lang.String.format;
import static java.lang.System.out;
import static java.nio.ByteBuffer.*;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.UnknownHostException;
import java.nio.ByteBuffer;
import java.nio.channels.Channels;
import java.nio.channels.ReadableByteChannel;
import java.nio.channels.WritableByteChannel;
public class SocketChannelByteBufferExample {
private static WritableByteChannel target;
private static ReadableByteChannel source;
private static ByteBuffer buffer;
public static void main(String[] args) throws IOException, InterruptedException {
long timeDirect;
long normal;
out.println("start");
for (int i = 512; i <= 1024 * 1024 * 64; i *= 2) {
buffer = allocateDirect(i);
timeDirect = copyShortest();
buffer = allocate(i);
normal = copyShortest();
out.println(format("%d, %d, %d", i, normal, timeDirect));
}
out.println("stop");
}
private static long copyShortest() throws IOException, InterruptedException {
int result = 0;
for (int i = 0; i < 100; i++) {
int single = copyOnce();
result = (i == 0) ? single : Math.min(result, single);
}
return result;
}
private static int copyOnce() throws IOException, InterruptedException {
initialize();
long start = System.currentTimeMillis();
while (source.read(buffer)!= -1) {
buffer.flip();
target.write(buffer);
buffer.clear(); //pos = 0, limit = capacity
}
long time = System.currentTimeMillis() - start;
rest();
return (int)time;
}
private static void initialize() throws UnknownHostException, IOException {
InputStream is = new FileInputStream(new File("/Users/stu/temp/robyn.in"));//315 MB file
OutputStream os = new FileOutputStream(new File("/dev/null"));
target = Channels.newChannel(os);
source = Channels.newChannel(is);
}
private static void rest() throws InterruptedException {
System.gc();
Thread.sleep(200);
}
}
4 ответа
Как работает ByteBuffer и почему прямые (байтовые) буферы теперь действительно полезны.
Сначала я немного удивлен, что это не общеизвестно, но терпеть меня
Прямые байтовые буферы выделяют адрес вне кучи Java.
Это крайне важно: все функции ОС (и родной C) могут использовать этот адрес без блокировки объекта в куче и копирования данных. Краткий пример копирования: для отправки любых данных через Socket.getOutputStream(). Write(byte[]) нативный код должен "заблокировать" byte[], скопировать его вне кучи Java и затем вызвать функцию ОС, например отправить. Копирование выполняется либо в стек (для меньшего байта []), либо через malloc/free для больших. DatagramSockets ничем не отличаются, и они также копируют - за исключением того, что они ограничены 64 КБ и размещены в стеке, что может даже убить процесс, если стек потока не достаточно велик или имеет глубокую рекурсию. примечание: блокировка не позволяет JVM/GC перемещать / перераспределять объект вокруг кучи
Таким образом, с введением NIO идея заключалась в том, чтобы избежать копирования и множества потоковых конвейеров / косвенных указаний. Часто существует 3-4 буферизованных типа потоков, прежде чем данные достигнут своего места назначения. (да, Польша уравновешивает (!) красивым выстрелом) Благодаря использованию прямых буферов java может напрямую связываться с нативным кодом C без какой-либо блокировки / копирования. следовательно sent
Функция может взять адрес буфера, добавить позицию, и производительность будет практически такой же, как и у нативного C. Это касается прямого буфера.
Основная проблема с прямыми буферами - они дороги в распределении и дороги в освобождении и довольно громоздки в использовании, ничего подобного byte[].
Непрямой буфер не предлагает истинную сущность, которую делают прямые буферы - то есть прямой мост к нативной / операционной системе, вместо этого они легковесны и имеют одинаковый API - и даже больше, они могут wrap byte[]
и даже их массив поддержки доступен для прямого манипулирования - что не любить? Ну, они должны быть скопированы!
Так как же Sun/Oracle обрабатывает непрямые буферы, так как OS/native не может использовать их - наивно. Когда используется непрямой буфер, должна быть создана прямая противоположная часть. Реализация достаточно умна, чтобы использовать ThreadLocal
и кешировать несколько прямых буферов через SoftReference
* чтобы избежать изрядной стоимости создания. Наивная часть приходит при их копировании - пытается скопировать весь буфер (remaining()
) каждый раз.
А теперь представьте: 512 КБ непрямого буфера идет в буфер 64 КБ, буфер сокета не займет больше его размера. Таким образом, в первый раз 512 КБ будут скопированы из непрямого в поток-локальный-прямой, но только 64 КБ будут использованы. В следующий раз будет скопировано 512-64 КБ, но будет использовано только 64 КБ, а в третий раз будет скопировано 512-64*2 КБ, но будет использовано только 64 КБ, и так далее... и это оптимистично, что всегда сокет буфер будет пуст полностью. Таким образом, вы не только копируете n
КБ всего, но n
× n
÷ m
(n
= 512, m
= 16 (среднее пространство, оставленное буфером сокета)).
Копирующая часть является общим / абстрактным путем ко всему непрямому буферу, поэтому реализация никогда не знает целевой емкости. Копирование кешей, а что нет, уменьшение пропускной способности памяти и т. Д.
* Примечание по кешированию SoftReference: это зависит от реализации GC и может варьироваться. GC от Sun использует свободную память кучи для определения срока службы SoftRefences, что приводит к некоторому неловкому поведению при их освобождении - приложению необходимо снова выделить ранее кэшированные объекты, то есть большее выделение (прямые байтовые буферы занимают незначительную часть в куче, поэтому по крайней мере, они не влияют на дополнительную очистку кэша, но влияют вместо этого)
Мое эмпирическое правило - объединенный прямой буфер размером с буфер чтения / записи сокета. ОС никогда не копирует больше, чем необходимо.
Этот микро-бенчмарк является в основном тестом пропускной способности памяти, ОС будет иметь файл целиком в кеше, поэтому она в основном тестирует memcpy
, Как только буферы заканчиваются из кэша L2, падение производительности должно быть заметно. Кроме того, выполнение такого эталонного теста приводит к увеличению и накоплению затрат на сбор ГХ. (rest()
не будет собирать мягкие ссылки на ByteBuffers)
Буферы локального распределения потоков (TLAB)
Интересно, будет ли буфер локального выделения потока (TLAB) во время теста около 256К. Использование TLAB оптимизирует выделение из кучи, так что непрямые выделения <=256К быстро.
Обычно это делается для того, чтобы дать каждому потоку буфер, который используется этим потоком исключительно для распределения. Вы должны использовать некоторую синхронизацию, чтобы выделить буфер из кучи, но после этого поток может выделить из буфера без синхронизации. В JVM горячей точки мы называем их буферами локального распределения потоков (TLAB). Они хорошо работают.
Большие выделения в обход TLAB
Если моя гипотеза о 256КБ TLAB верна, то информация, представленная в этой статье позже, предполагает, что, возможно,>256К ассигнований для больших непрямых буферов обходят TLAB. Эти распределения идут прямо в кучу, требуя синхронизации потоков, что приводит к снижению производительности.
Распределение, которое не может быть сделано из TLAB, не всегда означает, что поток должен получить новый TLAB. В зависимости от размера выделения и неиспользуемого пространства, оставшегося в TLAB, виртуальная машина может принять решение просто выполнить выделение из кучи. Это распределение из кучи потребовало бы синхронизации, но также получило бы новый TLAB. Если распределение считается большим (некоторая значительная часть текущего размера TLAB), распределение всегда будет выполняться из кучи. Это сократило потери и изящно обработало намного большее, чем среднее распределение.
Настройка параметров TLAB
Эта гипотеза может быть проверена с использованием информации из более поздней статьи, указывающей, как настроить TLAB и получить диагностическую информацию:
Чтобы поэкспериментировать с определенным размером TLAB, необходимо установить два флага -XX, один для определения начального размера и один для отключения изменения размера:
-XX:TLABSize= -XX:-ResizeTLAB
Минимальный размер tlab устанавливается с -XX:MinTLABSize, который по умолчанию равен 2 КБ. Максимальный размер - это максимальный размер целочисленного массива Java, который используется для заполнения нераспределенной части TLAB, когда происходит очистка GC.
Диагностические параметры печати
-XX:+PrintTLAB
Печатает в каждом мусоре одну строку для каждого потока (начинается с "TLAB: gc thread: " без ") и одну итоговую строку.
Я подозреваю, что эти колени из-за отключения через кэш процессора. Реализация "непрямого" буфера read()/write() "отсутствует" в кеше из-за дополнительной копии буфера памяти по сравнению с реализацией "прямого" буфера read()/write().
Есть много причин, почему это могло произойти. Без кода и / или более подробной информации о данных мы можем только догадываться, что происходит.
Некоторые догадки:
- Возможно, вы достигли максимального количества байтов, которые могут быть прочитаны за один раз, таким образом, IOwaits становится выше или потребление памяти увеличивается без уменьшения циклов.
- Возможно, вы достигли критического предела памяти или JVM пытается освободить память перед новым распределением. Попробуйте поиграть с
-Xmx
а также-Xms
параметры - Возможно, HotSpot не может / не будет оптимизировать, потому что количество вызовов некоторых методов слишком мало.
- Может быть, существуют ОС или аппаратные условия, которые вызывают такую задержку
- Может быть, реализация JVM просто глючит;-)