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 раз??? Мартин, да ты что, В общем, я решил что мое время настало: я скопировал бенчмарки Мартина, и начал их копать...
...Копать пришлось недолго, изюминка лежала на поверхности: во время выполнения бенчмарка Мартин включил и время инициализации "хранилища". В случае с 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% :)
>> Кроме того, хотелось потестить еще одну, свою реализацию хранилища: на базе long[]. Она давно напрашивалась — фактически, каждый раз, когда я слышу про off heap мне хочется спросить, пробовали ли авторы реализовать все то же самое, только не над off heap memory, а сначала над обычным byte[]/int[]/long[]?
ОтветитьУдалитьНу что ж не спросил-то :)
Как ты заметил в начале своего повествования off-heap в основном призван радикально снизить нагрузку именно на GC, а не ускорить доступ к данным. В существующих фреймворках упор в рекламе делается именно на это.
Готов ли ты поручиться за реакцию GC на 10 массивов по 2G, находящихся в old gen? Если у тебя CMS, то тебе надо его очень аккуратно тюнить, чтобы FULL GC ненароком не произошло. А после того как ты его затюнил, нужно следить, чтобы вносимые изменения вдруг не нарушили тонкий балланс. Если же ты их поместил в off-heap, то можно сидеть и в ус не дуть.
Опять же идея off-heap вовсе не нова, и она родилась когда GC был не ровень нынешнему. И если под рукой нет опробованного решения с off-heap и есть время повозиться над задачей размещения большого обема данных в памяти, то я бы сейчас тоже наверное выбрал путь обычных массивов в хипе.
Так как раз у вас особо не было резона спрашивать -- насколько я понимаю, у вас вне кучи хранится большой объем преимущественно бинарных данных. И вы с ним особой обработки не делаете -- просто кладете туда блобы, и берете оттуда блобы чтобы отправлять клиенту. Здесь off-heap вполне логично выглядит, хотя мне и было бы интересно, насколько ему проиграет аккуратно написанное in-heap хранилище с хорошо затюненым GC.
ОтветитьУдалитьУ меня же вопросы возникают например когда меня на jug спрашивают, можно ли хранить кольцо дизраптора off-heap.
>Готов ли ты поручиться за реакцию GC на 10 массивов по 2G, находящихся в old gen?
Я не настолько хорошо знаю GC, чтобы предсказывать как он себя поведет. Это Володя у нас спец, но он в отпуске :) У меня просто общие соображения: если дефрагментация памяти уже завершилась, то marking-GC на каждый объект достаточно найти первую же ведущую на него ссылку. То есть размер объекта его не волнует -- 10 массивов по 16Гб каждый для него эквивалентны 10 обычным Long-ам.
Я видел использование off-heap в разных модулях и там use case'ы совершенно разные. Есть такой где хранятся блобы, есть такой где храняться много мелких объектов с разными полями (причем этот кейс более часто у меня встечался). А в одном из модулей, где надо было хранить массивы байтов как раз, при попытки использовать offheap GC начало жутко колбасить, так как паттерн работы был такой, что из off-heap постоянно затягивали массив байтов в heap и с ним что-то делали и выкидывали, в итоге перешли на храненилище в хипе в том модуле.
ОтветитьУдалитьВ off-heap круто хранить бинарные данные, если они лежат в DirectByteBuffer и ты прямо его используешь, чтобы отправлять данные в сокет, тогда ты их через хип вообще протаскивать не будешь. Ну или как-нибудь хитро их копировать туда напрямую через тот же Unsafe.copyMemory, правда, сам я так ни разу не пробывал, только теоретизирую %) Хотя конечной такой компонент - простой раздачик бинарных данных по сети, на java особого смысла, наверное, писать нет, опять же…
Да, я согласен, в целом. В общем-то, нужно просто разделять задачи. Есть задача создания компактных структур данных в яве. Есть задача хранения больших массивов неструктурированной информации. Эти задачи сводятся к тому, что нужно что-то вроде (void*) -- нетипизированной памяти. И вот эта задача в первом приближении вполне решается через byte[]/long[].
ОтветитьУдалитьЕсть уже следующий уровень, когда по каким-то причинам такое решение не устраивает. Например, упаковка/распаковка становится дорогой, или нужны барьеры памяти на запись (это Мартин привел как аргумент), или нужно эффективное взаимодействие с внешними устройствами. Тут, опять же, все еще можно остаться в пределах хипа, если хранить данные как byte[], а обращаться к ним в нужных местах через Unsafe. Тут и быстрое копирование, и нетипизированный доступ, и отсутствие проверок границ, и барьеры, и прочие плюшки.
И только если и этого еще не хватает -- можно уже смотреть в сторону совсем off-heap решения
Этот комментарий был удален автором.
ОтветитьУдалитьПостеснялся мэтру так говорить. Просто написал, в чем он допустил неточность.
ОтветитьУдалить>> Это когда DirectByteBuffer/Unsafe используют для организации данных вне java heap. Предположительно, это должно радикально снизить нагрузку на GC, да и, в некоторых случаях, сам доступ к данным ускорить.
ОтветитьУдалитьDirectByteBuffer можно быстрее выкинуть клиенту по сети по сравнению с хипом если это кому-то актуально
Это, так скажем, "прямое" его назначение. А вот использование его для эмуляции структур, для снижения GC -- это косвенное, обычно именно его имеют в виду под off-heap. Потому что для ускорения ввода-вывода "оффхиповость" не принципиальна, это деталь реализации. Можно ведь было бы не писать, что DirectBB использует данные вне кучи, просто упомянуть, что для ввода-вывода он может быть эффективнее. А вот для off-heap решений эта самая оффхиповость принципиальна :)
ОтветитьУдалитьЗаинтересовали =) Повторил тест у себя и как водится, нашел ошибку в тесте.
ОтветитьУдалитьВ реализации LongArrayTradeArray.get(int index) ты забыл помножить индекс на длину записи. То есть у тебя
flyweight.setBaseIndex( index );
а должно быть
flyweight.setBaseIndex( index * getObjectSizeInLongs() );
за счет этого ты эффективно обращаешься из этого теста к в 6 раз меньшему объему памяти, соответственно кеш проца работает лучше. За счет этого и выигрыш.
У меня получился на варианте с long[] проигрыш в 10% по сравнению с unsafe.
Ну и запускал я это на 64-bit server VM - там unsafe правильнее работает.
@oleg
ОтветитьУдалитьДа, позор на мои седины. Хотя очень удачно, что итоговый вывод принципиально не изменился -- Unsafe хоть и вырвался вперед, но лишь на полшишечки. С другой стороны -- так ситуация даже более понятна, я ведь так и не придумал, что там такое может наоптимизировать JIT для long[] чтобы он стал быстрее.
P.S. А может, это я специально ошибку допустил, чтобы проверить внимательность своих читателей, а? :)