18 октября 2012 г.

Off-heap: троллинг 70-го уровня

Последнее время я часто сталкиваюсь с идеей 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% :)

10 комментариев:

  1. >> Кроме того, хотелось потестить еще одну, свою реализацию хранилища: на базе 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 и есть время повозиться над задачей размещения большого обема данных в памяти, то я бы сейчас тоже наверное выбрал путь обычных массивов в хипе.

    ОтветитьУдалить
  2. Так как раз у вас особо не было резона спрашивать -- насколько я понимаю, у вас вне кучи хранится большой объем преимущественно бинарных данных. И вы с ним особой обработки не делаете -- просто кладете туда блобы, и берете оттуда блобы чтобы отправлять клиенту. Здесь off-heap вполне логично выглядит, хотя мне и было бы интересно, насколько ему проиграет аккуратно написанное in-heap хранилище с хорошо затюненым GC.

    У меня же вопросы возникают например когда меня на jug спрашивают, можно ли хранить кольцо дизраптора off-heap.

    >Готов ли ты поручиться за реакцию GC на 10 массивов по 2G, находящихся в old gen?
    Я не настолько хорошо знаю GC, чтобы предсказывать как он себя поведет. Это Володя у нас спец, но он в отпуске :) У меня просто общие соображения: если дефрагментация памяти уже завершилась, то marking-GC на каждый объект достаточно найти первую же ведущую на него ссылку. То есть размер объекта его не волнует -- 10 массивов по 16Гб каждый для него эквивалентны 10 обычным Long-ам.

    ОтветитьУдалить
  3. Я видел использование off-heap в разных модулях и там use case'ы совершенно разные. Есть такой где хранятся блобы, есть такой где храняться много мелких объектов с разными полями (причем этот кейс более часто у меня встечался). А в одном из модулей, где надо было хранить массивы байтов как раз, при попытки использовать offheap GC начало жутко колбасить, так как паттерн работы был такой, что из off-heap постоянно затягивали массив байтов в heap и с ним что-то делали и выкидывали, в итоге перешли на храненилище в хипе в том модуле.

    В off-heap круто хранить бинарные данные, если они лежат в DirectByteBuffer и ты прямо его используешь, чтобы отправлять данные в сокет, тогда ты их через хип вообще протаскивать не будешь. Ну или как-нибудь хитро их копировать туда напрямую через тот же Unsafe.copyMemory, правда, сам я так ни разу не пробывал, только теоретизирую %) Хотя конечной такой компонент - простой раздачик бинарных данных по сети, на java особого смысла, наверное, писать нет, опять же…

    ОтветитьУдалить
  4. Да, я согласен, в целом. В общем-то, нужно просто разделять задачи. Есть задача создания компактных структур данных в яве. Есть задача хранения больших массивов неструктурированной информации. Эти задачи сводятся к тому, что нужно что-то вроде (void*) -- нетипизированной памяти. И вот эта задача в первом приближении вполне решается через byte[]/long[].

    Есть уже следующий уровень, когда по каким-то причинам такое решение не устраивает. Например, упаковка/распаковка становится дорогой, или нужны барьеры памяти на запись (это Мартин привел как аргумент), или нужно эффективное взаимодействие с внешними устройствами. Тут, опять же, все еще можно остаться в пределах хипа, если хранить данные как byte[], а обращаться к ним в нужных местах через Unsafe. Тут и быстрое копирование, и нетипизированный доступ, и отсутствие проверок границ, и барьеры, и прочие плюшки.

    И только если и этого еще не хватает -- можно уже смотреть в сторону совсем off-heap решения

    ОтветитьУдалить
  5. Этот комментарий был удален автором.

    ОтветитьУдалить
  6. Постеснялся мэтру так говорить. Просто написал, в чем он допустил неточность.

    ОтветитьУдалить
  7. >> Это когда DirectByteBuffer/Unsafe используют для организации данных вне java heap. Предположительно, это должно радикально снизить нагрузку на GC, да и, в некоторых случаях, сам доступ к данным ускорить.


    DirectByteBuffer можно быстрее выкинуть клиенту по сети по сравнению с хипом если это кому-то актуально

    ОтветитьУдалить
  8. Это, так скажем, "прямое" его назначение. А вот использование его для эмуляции структур, для снижения GC -- это косвенное, обычно именно его имеют в виду под off-heap. Потому что для ускорения ввода-вывода "оффхиповость" не принципиальна, это деталь реализации. Можно ведь было бы не писать, что DirectBB использует данные вне кучи, просто упомянуть, что для ввода-вывода он может быть эффективнее. А вот для off-heap решений эта самая оффхиповость принципиальна :)

    ОтветитьУдалить
  9. Заинтересовали =) Повторил тест у себя и как водится, нашел ошибку в тесте.
    В реализации LongArrayTradeArray.get(int index) ты забыл помножить индекс на длину записи. То есть у тебя
    flyweight.setBaseIndex( index );
    а должно быть
    flyweight.setBaseIndex( index * getObjectSizeInLongs() );

    за счет этого ты эффективно обращаешься из этого теста к в 6 раз меньшему объему памяти, соответственно кеш проца работает лучше. За счет этого и выигрыш.

    У меня получился на варианте с long[] проигрыш в 10% по сравнению с unsafe.

    Ну и запускал я это на 64-bit server VM - там unsafe правильнее работает.

    ОтветитьУдалить
  10. @oleg

    Да, позор на мои седины. Хотя очень удачно, что итоговый вывод принципиально не изменился -- Unsafe хоть и вырвался вперед, но лишь на полшишечки. С другой стороны -- так ситуация даже более понятна, я ведь так и не придумал, что там такое может наоптимизировать JIT для long[] чтобы он стал быстрее.

    P.S. А может, это я специально ошибку допустил, чтобы проверить внимательность своих читателей, а? :)

    ОтветитьУдалить