Последнее время я часто сталкиваюсь с идеей off-heap storage в java. Это когда
DirectByteBuffer/Unsafe используют для организации данных вне java heap. Предположительно, это должно радикально снизить нагрузку на GC, да и, в некоторых случаях, сам доступ к данным ускорить. Сам я к этой идее относился довольно скептически: на мой взгляд у нее довольно узкая область применимости, и усилиями разработчиков JVM она становится с каждым годом еще уже. Мне кажется, многие, кто смотрит в сторону off-heap просто еще не разобрались толком, что можно делать на чистой java. Но пока это были все теоретические предположения — с
Unsafe я проводил лишь один небольшой эксперимент, и, хоть предположения он, косвенно, и подтвердил, но одного эксперимента для выводов явно недостаточно. А сделать серьезный бенчмарк как-то было лениво.
И вот судьба подбросила мне шанс :) Если вы читаете мой блог, вы наверняка смутно помните Мартина Томпсона, автора Disruptor-а. Он, как это не удивительно, тоже тот еще графоман, и ведет свой блог. Вот сегодня, собственно, появилась интересная статья
Compact Off-Heap Structures/Tuples In Java. Я не буду пересказывать статью, чай, английский все знают, сами почитайте. Вкратце, Мартин сравнивает организацию массива структур (т.е. объектов только с примитивными полями) на чистой яве, и выделяя через
Unsafe.allocateMemory кусок off-heap memory, и сохраняя туда данные в C-стиле через соответствующие методы
Unsafe.putLong/putInt....
И в его бенчмарках разница получается просто невероятная: off heap подход более чем в 40 раз быстрее! Мой червячок сомнения на этом месте внезапно вырос до 40-ка метрового глиста: нет, я догадываюсь, что
Unsafe может дать какой-то прирост в определенных случаях, но в 40 раз??? Мартин, да ты что,
охуелбелены объелся? Да, java-объекты "толще" чем C-структуры, да, они менее локально расположены в памяти — но
в 40 раз хуже? Масла в огонь подлило то, что Мартин уже не первый раз демонстрирует в блоге бенчмарки, написанные "на отъебись".
В общем, я решил что мое время настало: я скопировал бенчмарки Мартина, и начал их копать...
...Копать пришлось недолго, изюминка лежала на поверхности: во время выполнения бенчмарка Мартин включил и время инициализации "хранилища". В случае с off-heap это был просто
Unsafe.allocateMemory(2.5Gb) + запись данных, а в случае с plain java это была аллокация массива на 50М элементов,
и аллокация 50 миллионов объектов (+ запись данных, конечно)! Да, конечно, аллокация в яве очень быстрая — но если что-то даже очень быстрое повторить 50 миллионов раз... Кроме того, очевидно, ~2Гб аллоцируемых объектов не вместятся в молодое поколение. Значит прямо параллельно с аллокацией будет запускаться minor GC, копировать объекты из поколения в поколение, переводить их в old gen, дефрагментировать память... В общем, я убрал инициализацию из измеряемого участка, и разница
внезапно драматически сократилась — навскидку где-то до 30-40%.
На этом можно было бы успокоиться, но мне показалось, что я вижу еще несколько грязных моментов в дизайне бенчмарков, и мне хотелось их исправить. Кроме того, хотелось потестить еще одну, свою реализацию хранилища: на базе
long[]. Она давно напрашивалась — фактически, каждый раз, когда я слышу про off heap мне хочется спросить, пробовали ли авторы реализовать все то же самое, только не над off heap memory, а сначала над обычным byte[]/int[]/long[]? Так можно было бы сравнить, какой прирост мы получаем от уменьшения уровня абстракции (отказ от объектов с именованными полями доступных по ссылке, в пользу прямой адресации ячеек какого-то примитивного типа, лежащих последовательным куском в памяти), а какой — от того, что сами ячейки мы располагаем за пределами управляемой кучи.
В общем, я переписал бенчмарки на свой лад, и начал играться. И обнаружилось много вкусного :)
Во-первых, Мартин, похоже, недостаточно долго гонял бенчмарки. Я, как это часто делаю, проводил измерения в двух вложенных циклах: на каждой итерации внешнего цикла я пересоздаю хранилище заново, и делаю с этим хранилищем несколько итераций внутреннего цикла. На каждой итерации внутреннего цикла я сначала записываю какие-то данные в хранилище, потом их же оттуда считываю. Таким образом, у меня получалось на каждый запуск JVM 13 раз пересозданное хранилище, и по 7 прогонов с каждым экземпляром. Я не выделяю отдельно прогрев — просто вывожу все результаты, и смотрю, с какой итерации они более-менее стабилизируются. У меня они стабилизировались с 20 итерации (со 100М записей, против 50М у Мартина). А в оригинальных бенчмарках всего-то было по 5 итераций. Есть хороший шанс, что Мартин тестировал какой-то полу-интерпретируемый код.
Во-вторых, в случае с обычными джава-объектами (в управляемой куче) первая итерация с пересозданным хранилищем часто обрабатывается медленнее, до 1.5-2 раз, чем последующие (даже когда, по всем признакам, JIT уже завершил свое темное дело). Что это такое я точно сказать не могу, но моя версия — это недозавершенный GC. То есть параллельно с первым прогоном на новом хранилище еще идут фоновые сборки, доперемещающие какие-то объекты между поколениями, да еще есть дефрагментация старого поколения. Очевидно, если объекты туда-сюда перемещаются, работа с ними будет медленнее. Ко второму прогону все уже стабилизируется, объекты плотно упакованы в old gen, и работа с ними становится быстрее. Опять же, поскольку Мартин создавал новое хранилище на
каждый прогон, то в случае с in-heap хранилищем он, похоже, измерял его производительность как раз в таком переходном режиме.
Теперь что у меня получилось в итоге. Измерялось время (ms), уходящее на обработку 100M записей. Три участника забега: DirectMemory (aka off-heap), Pojo (plain java), LongArray (на базе long[]). Два режима: чтение и запись. Оркестр, фанфары...
| Direct | Pojo | LongArray |
| Read | 278±113 | 376±161 | 234±95 |
| Write | 639±270 | 753±320 | 325±133 |
Любопытно. Нет, то, что невероятное преимущество off-heap развеялось как дым — лично для меня не особо удивительно. Примерно в 1.5 раз быстрее на чтение, примерно на 20% на запись — это звучит более-менее разумно. Это легко понять, объяснить и даже предсказать, если вспомнить, что java-объект будет по размеру где-то на 1/3 больше, чем его off-heap коллега — за счет object header и всего подобного. Больше размер, больше памяти сканировать, здесь все понятно.
А вот для меня оказалось очень интересно (и неожиданно) что
long[] хранилище обставило с внушительным разрывом всех остальных. Я ожидал, что pojo будет отставать от
Unsafe на 30-50%, а
long[]-based будет немного отставать от
Unsafe (за счет range-check, и за счет конверсий
int/char <-> long). Но, похоже, я недооценил во-первых насколько лихо JIT сейчас расправляется с range check. Во-вторых — мое предположение, что JIT, сообразив, что я гоняю его линейно по массиву, что-то еще наоптимизировал из этого. То ли просто loop unrolling, то ли префетчинг. Вроде бы, правда, префетчинг JIT не вставляет, полагается на аппаратный, но чем черт не шутит?
UPD: Как мне совершенно правильно указал в комментариях Олег (а люди с другими именами — куда смотрели, а?) я в реализации LongArrayTradeArray спорол хуйнюдопустил непростительную ошибку с вычислением индекса в массиве. Скорректированная версия дает несколько другие результаты:
| Direct | Pojo | LongArray |
| Read | 278±113 | 376±161 | 273±114 |
| Write | 639±270 | 753±320 | 333±140 |
В итоге общий вывод мало изменяется — все равно long[] в одном порядке с DirectMemory. Но непонятно откуда взявшийся прирост при этом пропадает (Олег сообщает, что на его JVM long[] даже на 10% медленнее direct). С одной стороны расклад становится понятнее, никаких сюрпризов. С другой — оригинальный срыв покровов, конечно, выглядел интереснее. Эх, я начинаю понимать Мартина... :)
Тут хочется добавить, что массив
long[] — это один-единственный объект, так что, очевидно, он мало напряжет GC. Правда, это верно только для old gen — потому что в young gen работает копирующий GC, которому важен
размер объекта, а не только количество объектов/ссылок — но для больших структур данных это, опять же, не очень актуально, очевидно они будут использоваться достаточно долго, чтобы твердо укорениться в old gen (кроме того, действительно
большие структуры данных уже изначально не поместятся в young gen, и будут сразу аллоцированы в old generation). То есть такой подход, использующий обычный java-массив, и по нагрузке на GC не должен уступать off heap подходу. Остается еще тема с размером доступной памяти — массивы-то в яве ограничены 2G элементов. И если вам нужно больше 16Гб (2G*long) данных одним куском — вам нужно либо таки идти за пределы кучи, либо вводить дополнительный уровень косвенности, собирая структуру из нескольких массивов (как это делают в
HugeCollections) — это, очевидно, несколько просадит производительность.
P.S. Код бенчмарков
здесь, если кому вдруг интересно
P.P.S. Да, а Мартин — он, похоже, так троллит и пиарится. Вряд ли инженер его уровня не знает, как писать правильные бенчмарки. Скорее, ему просто впадлу все это расписывать в блоге, а 40 раз звучит ярче, чем 30% :)