В новом переводе от команды Spring АйО рассказывается, как сократить время разогрева JVM с помощью ahead-of-time компиляции (в рамках Project Leyden), а также объясняется, почему традиционные GC-барьеры мешают гибкому выбору сборщика мусора.

Статья содержит интересное решение — GC-независимые барьеры загрузки, которые можно «пропатчить» в рантайме под конкретный GC, обеспечивая совместимость без переписывания кода.


Привет! Меня зовут Пол, и я недавно завершил магистратуру по инженерии программного обеспечения распределённых систем. Для своей дипломной работы я сотрудничал с командами разработки сборщика мусора HotSpot и компиляторами Oracle в стокгольмском офисе.

Комментарий от эксперта Spring АйО, Михаила Поливахи

Мало кто знает, но в Стокгольме в Швеции есть ряд университетов, гдев коллоборации с Oracle проводится большое количество ресерча на тему GC и .т.п. Мы редко, но, тем не менее, публикуем материал оттуда, чтобы сообщество знало о новых событиях в мире GC и Java. Дайте знать если тема передовых ресерчей интересна в целом.

В этом посте я хочу представить свою работу и осветить следующие темы:

  • процесс «разогрева» и как Project Leyden пытается его улучшить;

  • что такое загрузки (loads) и почему они иногда требуют барьеров сборщика мусора;

  • какие именно ассемблерные инструкции барьеров используются в Z Garbage Collector;

  • как создавать барьеры, не зависящие от конкретного сборщика мусора.

Комментарий от эксперта Spring АйО, Михаила Поливахи

Под load имеется в виду закгрузка поля на стек из heap, например вот это load:

public class LoadExample {

  private Boolean varInHeap;

  public void execute() {
    boolean local = varInHeap; // Вот это вот load
    if (local) {
      executeInternal();
    }
  } 
}

Разогрев

Подобно тому, как самолёт раскручивает двигатели перед взлётом, код на Java в HotSpot JVM проходит процесс, называемый разогревом, прежде чем начнёт исполняться с максимальной скоростью. Во время разогрева приложения интерпретируются и/или профилируются, чтобы определить, как их скомпилировать с помощью JIT в оптимизированный машинный код. Это контрастирует с ahead-of-time компиляцией (например, в C++), где код приложения оптимизируется во время компиляции и не требует разогрева при выполнении. Однако в этом случае невозможно использовать оптимизации, зависящие от поведения во время исполнения, как это делается в HotSpot JVM.

К счастью, можно совместить оба подхода. Проект Leyden исследует способы сокращения времени разогрева путём ahead-of-time компиляции. Обучающий прогон запускает приложение заранее, что позволяет сразу использовать разогретый машинный код при боевых запусках. Это позволяет избежать разогрева в продакшене и значительно увеличить пропускную способность.

Комментарий от эксперта Spring АйО, Михаила Поливахи

Иными словами, сначала делается training run, по результатам этого training run формируется, например, AOT Cache (см JEP 483, JEP 515 и др.) и потом эти собранные эвристики используются при уже полноценном запуске приложения в production. Это лишь пример, Project Leyden в рамках OpenJDK в целом предлогает ещё много чего полезного.

Постановка проблемы

Одним из серьёзных недостатков текущей ahead-of-time компиляции (AOT) является необходимость использования одного и того же сборщика мусора (GC) как на этапе обучения, так и в продакшене. Это ограничивает гибкость на этапе исполнения, ведь разные сборщики мусора имеют разные преимущества и недостатки. Причина такой зависимости — барьеры GC — фрагменты вспомогательных инструкций, вставляемые вокруг операций с памятью. Эти инструкции необходимы для корректной работы сборщиков мусора!

Комментарий от эксперта Spring АйО, Михаила Поливахи

Как правило, различают load и write барьеры в рамках GC -  соотвественно, барьеры на чтение данных их heap и барьеры на запись данных. Они нужны для синхронизации между потоками мутаторов (потоки приложения, которы как раз активно читают и пишут в heap), и потоком/потоками GC.

В традиционных Serial/Parallel GC, которые делают полноценный STW, в барьерах нужды нет, т.к.  все этапы сборки полностью происходят под паузой. 

По поводу барьеров и работы параллельного GC рекомендую посмотреть доклад Алексея Шипилева: https://www.youtube.com/watch?v=CnRtbtis79U

P.S: Хотя с точки зрения компьютерных наук "load барьер" или в целом "memory fence" это не совсем то же самое, что и read барьер в GC в Java, но именно в контексте литературы, которая посвящена GC в Java, вы в 99% случаев можете понимать под терминами read барьер и load барьер одно и то же.

Важно понимать, что где, когда и как вставлять такие барьеры полностью зависит от конкретной операции с памятью и от используемого GC. В результате код, скомпилированный с учётом одного GC, не может быть безопасно использован с другим.

В своей работе я сосредоточился на одной конкретной операции с памятью: доступ к полям (например, int foo = point.x; представляет собой доступ к полю x). Такие обращения называются загрузками (loads), и далее я буду использовать именно этот термин. Цель моего исследования заключалась в том, чтобы унифицировать моменты и способы вставки барьеров, сделав их независимыми от GC. Как и в большинстве задач компьютерных наук, здесь приходится идти на компромиссы, поэтому основной вопрос исследования звучал так: “Как производительность кода с GC-независимыми барьерами соотносится с производительностью кода с барьерами, специфичными для конкретного GC?”

Реализация

В рамках дипломной работы я ограничился анализом следующих сборщиков мусора: Serial, Parallel, G1 и ZGC, все на архитектуре AArch64 (архитектура моего ноутбука). Я сосредоточился только на JIT-компиляторе C2, поскольку он генерирует долгоживущий машинный код, который и используется в Project Leyden.

Комментарий от эксперта Spring АйО, Михаила Поливахи

Понимайте под AArch64 просто 64-битный ARM проц, просто более официальное название.

В этом разделе я буду рассматривать только некодированные указатели (uncompressed Oops) — это единственный режим, поддерживаемый всеми четырьмя упомянутыми сборщиками мусора.

Ответ на вопрос «когда»

В момент загрузки (доступа к полю) могут выполняться различные операции — это зависит от типа ссылки. Если читается поле объекта, это считается сильной ссылкой, а доступ классифицируется как сильная загрузка. В Java также существуют слабые ссылки, при доступе к которым происходят слабые загрузки. Если на объект ссылаются только слабые ссылки, он может быть собран сборщиком мусора. Это полезно, например, для реализации кэшей, как в WeakHashMap.

В текущей реализации, при любой загрузке — сильной или слабой — может быть сгенерирован барьер загрузки перед разыменованием поля. Условие, при котором это происходит, приведено в таблице ниже:

GC

Генерирует барьер при сильной загрузке?

Генерирует барьер при слабой загрузке?

Serial

❌ Нет

❌ Нет

Parallel

❌ Нет

❌ Нет

G1

❌ Нет

✅ Да

Z

✅ Да

✅ Да

Вывод: как для сильных, так и для слабых загрузок, по крайней мере один GC вставляет барьер. Следовательно, GC-независимый барьер загрузки должен вставляться при каждой сильной и слабой загрузке.

Ответ на вопрос «как»

ℹ️ В этом разделе рассматриваются команды на ассемблере архитектуры AArch64. Для понимания сути не обязательно разбираться, почему эти инструкции делают то, что делают — важно лишь понимать сами инструкции и их операнды.

Главная идея — использовать один и тот же набор инструкций для всех GC (отдельно для сильных и слабых загрузок), а затем «пропатчить» (изменить) операнды в этих инструкциях во время первого выполнения, чтобы они соответствовали требованиям конкретного GC.

Скомпилированный в HotSpot машинный код размещается в памяти, и каждая инструкция на AArch64 занимает 32 бита.

Комментарий от эксперта Spring АйО, Михаила Поливахи

Кстати это одно из ключевых различий ARM64 от x86_64 в целом, мы об этом говорили на подкасте. Фиксированная длина инструкции в ARM64 в 32 бита порой дает большие преимущества в перформансе, но это отдельная, большая глава.

Чтобы пропатчить инструкцию по известному адресу, можно привести её к 32-битному целому без знака и изменить с помощью побитовых операций. Эта техника патчинга не является новой — сборщик мусора ZGC уже использует её в своих барьерах.

Лучше всего GC-независимые барьеры иллюстрируются на примере. Возьмём сильные загрузки, поскольку ZGC — единственный сборщик мусора, который требует барьер при таких загрузках. Поэтому барьер ZGC используется в качестве шаблона для GC-независимого барьера. В «сыром» виде такой барьер подходит для ZGC, а затем, во время выполнения, Serial, Parallel и G1 патчат инструкции так, чтобы они ничего не делали.

Вот как выглядит сильный барьер загрузки в ZGC. Объектная ссылка — это окрашенный указатель, находящийся в регистре x0.

Комментарий от эксперта Spring АйО, Михаила Поливахи

Если с концептуальной точки зрения, то "colored pointer" или окрашенный указатель это техника, при которой сам машинный поинтер (на 64 битной машине он будет, соответственно, 64 бита) содержит не просто ссылку на виртуальное адресное пространство в памяти, где лежит объект, а ещё и содержит в себе некоторую метаинформацию, анпример в младших битах поинтера.

В ZGC, например, эта метаинформация: 

  • Касаемая маркировки (помаркан ли уже объект?) 

  • Указывает ли объект на акуальную копию через forwarding поинтер

и т.д. Эта техника вообще нужна для перформанса, т.к. bitwise операции на машинном слове CPU это операция экстримально быстрая.

tbz  w0, Z_MASK, #0x8     
b    Z_SLOW_PATH          
lsr  x0, x0, #16          

Если некоторый бит адреса — определяемый маской Z_MASK — равен нулю, выполнение перескакивает следующую инструкцию b, и она не исполняется. Эта следующая инструкция осуществляет переход на так называемый slow path — термин, используемый в сборщиках мусора для обозначения обязательных, но медленных операций по учёту и обслуживанию памяти.
Наконец, из окрашенного указателя удаляются 16 младших битов — метаданных, — в результате чего остаётся только чистый адрес.

Сборщики мусора Serial, Parallel и G1 должны гарантировать, что этот барьер не выполняет никаких действий. Им не требуется выполнять учёт памяти при сильных загрузках. Это достигается путём патчинга операндов инструкций таким образом, чтобы они не производили реальной работы. В результате получается следующий код:

tbz  wzr, Z_MASK, #0x8
b    Z_SLOW_PATH
lsr  x0, x0, #0

Вместо того чтобы проверять значение регистра w0 (в котором хранится адрес), первая инструкция tbz заменяется на версию, работающую с регистром wzr — это нуль-регистр, значение которого всегда равно нулю. Поэтому инструкция всегда перескакивает следующую команду b, и, соответственно, медленный путь ZGC никогда не выполняется.

Адрес объекта всё ещё находится в регистре x0, но так как у остальных сборщиков мусора нет окрашенных указателей, то сдвиг вправо (lsr) патчится на значение 0, то есть адрес остаётся неизменным.

В итоге путём патчинга операндов мы добились независимости барьеров загрузки от конкретного сборщика мусора для сильных загрузок!
Те же идеи применимы и к слабым загрузкам (опущены здесь для краткости), где инструкции представляют собой гибрид подходов G1 и ZGC.

Результаты производительности

Добавление набора лишних инструкций для сборщиков мусора, которым они не нужны, должно негативно сказаться на производительности, верно?
Неудивительно, что да. Ниже представлена таблица с наихудшими случаями регрессии по времени исполнения и latency на 90-м перцентиле (P90):

GC

Регрессия по времени исполнения

Регрессия по latency P90

Serial

17%

15%

Parallel

19%

17%

G1

17%

20%

Z

23%

39%

Однако эти результаты нельзя считать провальными! Во многих экспериментах регрессия составляла лишь несколько процентов. Более того, интерпретируемый код в целом на порядок медленнее скомпилированного, так что в конечном итоге остаётся чистый выигрыш от подхода с AOT и нейтральными барьерами.

Выводы

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


Присоединяйтесь к русскоязычному сообществу разработчиков на Spring Boot в телеграм — Spring АйО, чтобы быть в курсе последних новостей из мира разработки на Spring Boot и всего, что с ним связано

Комментарии (0)