Это сообщение в блоге представляет собой сокращенную версию доклада, представленного на JVM LS 2018 [1].

Zing - это флагманская коммерческая JVM на стороне сервера Azul. У нас есть несколько ключевых функций, таких как C4 GC, технология ReadyNow практически без пауз, которая помогает устранить проблемы с прогревом. А в начале 2017 года мы выпустили Falcon, нашу высокоуровневую JIT на основе LLVM. Все действительно окончательные оптимизации реализованы для JIT-компилятора Falcon.

Начнем с того, что в спецификации языка Java (JLS) говорится о последних полях. В JLS есть несколько правил для полей final. Главный из них заключается в том, что поле, объявленное окончательным, инициализируется один раз и никогда не изменяется при нормальных обстоятельствах [2]. Это позволяет компилятору выполнять довольно агрессивные оптимизации, такие как перемещение считываний полей final через барьеры синхронизации и через неизвестные вызовы. Последнее поле можно кэшировать в регистре, и его не нужно перезагружать из памяти.

Далее, в спецификации JVM (JVMS) также есть пара глав о последних полях. Во-первых, если поле является окончательным, оно должно быть объявлено в текущем классе. Во-вторых, байт-код putfield должен присутствовать в методе init класса, в котором объявлено последнее поле, так что это находится в пределах лексической области действия метода init [3]. Однако виртуальные машины, как правило, имеют более мягкое правило по этому поводу, в частности, байт-код поля размещения может быть в любом методе этого класса, а не только в методе инициализации. Причина, по которой виртуальные машины, как правило, имеют смягченное правило, заключается в том, что они должны поддерживать реальные приложения, такие как десериализация. В-третьих, каждое поле имеет структуру field-info, и флаг ACC_FINAL в этой структуре установлен в значение true для последнего поля.

Есть два вида финальных полей. Первый - статический финал. Здесь сама переменная класса объявляется final и должна быть инициализирована в статическом инициализаторе (метод ‹Clinit›) определяющего класса. Преимущество статических финалов в том, что они не нуждаются в каком-либо механизме отслеживания зависимостей. По умолчанию им доверяют, за исключением определенных полей в классе java.lang.System [2].

Более сложный - это поля final экземпляра, и это нестатические переменные, объявленные final. Главное, чтобы в каждом конструкторе класса, в котором он объявлен, должен быть назначен пустой финал [4]. Причина, по которой instance final является сложным, заключается в том, что он не всегда final, даже если он так объявлен. В частности, мы можем изменить значение экземпляра final после создания несколькими способами, такими как JNI, unsafe, отражение, десериализация и MethodHandles. Отражение и десериализация используют unsafe в качестве основного механизма. Механизм поиска по умолчанию MH не позволяет изменять поля final, но unreflectSetter API специально предназначен для изменения полей final.

Как мы видим, по большей части мы можем JIT-компилировать код с включенной по-настоящему окончательной оптимизацией. Однако, если поле изменяется одним из способов, указанных выше, нам необходимо деоптимизировать код, который зависел от того, является ли поле окончательным, и перекомпилировать код с отключенной по-настоящему окончательной оптимизацией. Короче говоря, по-настоящему окончательная оптимизация - это спекулятивная или героическая оптимизация, для которой нужен собственный механизм отслеживания зависимостей.

Внедрение отслеживания зависимостей в Zing VM

Первым шагом является определение окончательности, и мы пошли дальше с действительно окончательным механизмом отслеживания для всего класса. Это означает, что все поля final в классе являются либо доверенными, либо ненадежными вместе.

У нас есть действительно последний бит для класса, и он устанавливается во время проверки. Во время проверки мы анализируем байтовые коды метода и видим, что он удовлетворяет правилу JLS, заключающемуся в том, что все нестатические поля final должны быть назначены в конструкторе класса, то есть в пределах лексической области действия метода ‹init›. Если это правило нарушается, истинный последний бит этого класса устанавливается в false.

Механизм отслеживания зависимостей (рис. 1) дает представление о процессе записи и признания зависимостей недействительными. Допустим, метод, использующий последнее поле, компилируется JIT. Оптимизатор сначала проверяет, действительно ли установлен окончательный бит в классе, в котором есть это поле, и действительно ли поле является окончательным. Затем нам нужно записать эту зависимость, потому что оптимизация верна только до тех пор, пока зависимость истинна. Затем делаем оптимизацию. Когда мы регистрируем метод, нам нужно установить зависимости вместе с методом, а затем метод будет установлен.

В то же время или позже, допустим, что последнее поле будет изменено. Первый шаг - нам нужно установить для действительно последнего бита этого класса, который содержит последнее поле, значение false, чтобы никакие дальнейшие оптимизации не использовали этот факт, потому что он больше не является истинным. Затем нам нужно увидеть, существуют ли какие-либо другие методы, зависящие от того, что это поле является final, и если есть другие зависимые методы, нам нужно деоптимизировать эти методы. Это классическая деоптимизация, используемая для таких вещей, как CHA. После деоптимизации этих методов мы напишем поле. Деоптимизация происходит по установленному методу. Кроме того, может возникнуть гонка между записью зависимостей и аннулированием зависимости, поэтому непосредственно перед установкой зависимостей нам нужно проверить, истинен ли по-настоящему последний бит этого класса. Если это не так, то нам нужно выбросить компиляцию и начать повторную компиляцию.

Мы добавили проверки deopt в соответствующие хуки, такие как unsafe, JNI, methodHandles. На этих хуках нам нужно пройти по зависимым методам, чтобы проверить, действительно ли у этого метода есть окончательная зависимость. Если у нас есть хотя бы один метод, который имеет действительно окончательную зависимость, нам нужно деоптимизировать этот метод. Здесь важно отметить, что операции записи должны ждать, пока мы не завершим деоптимизацию.

Поистине окончательная оптимизация

Следующая часть - это действительно окончательная оптимизация, которую мы реализовали для Falcon, нашего компилятора на основе JIT LLVM. LLVM - это набор библиотек компилятора, который работает с промежуточным представлением, называемым LLVM IR [5]. LLVM IR - это типизированный IR на основе SSA.

В виртуальной машине Java у нас есть компилятор C1, который действует как компилятор нижнего уровня и выполняет профилирование. В этот микс мы добавляем наш интерфейс для байт-кода, который по сути является парсером. Он преобразует байт-код в LLVM IR. Оптимизатор LLVM присутствует за пределами этой виртуальной машины, и здесь мы передаем этот начальный IR, сгенерированный интерфейсом байт-кода, оптимизатору LLVM.

Оптимизатор LLVM пытается оптимизировать исходный IR. В процессе он задает ВМ определенные вопросы, и ВМ отвечает ответами. Оптимизатор использует эту информацию и лучше оптимизирует код. Он генерирует окончательный IR, который передается в серверную часть X86 и преобразуется в сборку. Затем метод устанавливается.

Конечная оптимизация полей - это постоянное сворачивание констант времени компиляции, затем исключение избыточной нагрузки конечных полей; это происходит через магазины без псевдонима, через барьеры синхронизации и через неизвестные вызовы.

Начнем с примера сворачивания констант, показанного на рис. 3. Это псевдокод с обозначениями JMH. Класс TrulyFinal имеет конечный примитив i, которому присвоено значение 10. Нас интересует тест производительности test, который загружает i из статического конечного объекта tf.

Метод (псевдо) LLVM IR test превращается в:

%ref = load i8* , i8** @known_value
%addr = getelementptr i8, i8* %ref, i32 16
%i = load i32, i32* (cast<i8* to i32*> %addr)
ret i32 %i

Поскольку LLVM IR основан на типе IR, у нас есть такие типы, как i32, которые представляют 32-байтовые целые числа. Наш синтаксический анализатор байт-кода также помечает константы времени компиляции, то есть переменные static final как known_value. Затем мы получаем доступ к 16 байтам от known_value, а затем мы загружаем i и возвращаем результат.

Оптимизатор LLVM видит это известное_значение и следующую за ним нагрузку, поэтому он спрашивает виртуальную машину: «Можем ли мы получить результат загрузки этого известного значения?» ВМ отвечает: «Да, 10». Итак, оптимизатор использует этот результат и упрощает окончательный IR, чтобы:

ret i32 10

Обратный вызов виртуальной машины - это полный черный ящик для оптимизатора LLVM. ВМ оперирует переданными аргументами и записывает действительно окончательную зависимость, о которой мы говорили ранее. В этом случае обратный вызов - GET RESULT OF KNOWN VALUE LOAD(KV ID, OFFSET, SIZE). В обратном вызове мы получаем объект, соответствующий KVID, и получаем поле на основе указанного OFFSET. После проверки того, является ли поле окончательным (и записи этой действительно окончательной зависимости), мы получаем постоянное значение, соответствующее этому полю.

Следующий пример, который мы рассмотрим, - это избыточная загрузка конечных полей в постоянной области. На рис. 4 у нас есть псевдокод, в котором интересующим тестом является функция test. У нас есть загрузка instance final объекта utf и загрузка final примитива var внутри него. Мы снова перезагружаем тот же var, но загрузки разделяются хранилищем на volatile переменную k. Поскольку летучие вещества создают барьеры памяти, у нас есть строгий порядок загрузки и сохранения до и после барьера памяти.

Это test LLVM IR:

%ref = load i8*, i8** %addr
%addr = getelementptr i8, i8* %ref, i32 16
%var = load i32, i32* (cast<i8* to i32*> %addr)
%kref = load i8*, i8** %addr_k
%addr = getelementptr i8, i8* %kref, i32 816
fence
store i32 3, i32* (cast<i8* to i32*> %addr)
%refdup = load i8*, i8** %addr
%addr1 = getelementptr i8, i8* %refdup, i32 16
%tmp = load i32, i32* (cast<i8* to i32*> %addr1)
%res = sub i32 %var, %tmp
ret %res

У нас есть загрузка объекта utf, который не является known_value. Затем мы загружаем var в 16 байтах из этого объекта. В LLVM IR барьеры памяти представлены с помощью инструкции fence. Мы сохраняем переменную k, а затем снова загружаем объект utf и var и вычитаем обе нагрузки var. Поскольку и объект, и примитив, загруженные из этого объекта, действительно являются окончательными, мы знаем, что результат должен быть нулевым. Как оптимизатор это понимает?

Сначала оптимизатор проверяет, что объект уже инициализирован, а затем спрашивает виртуальную машину о свойстве вызывающего объекта, поскольку интересующая нас область - это вызывающий объект, который является тестовой функцией. Он спрашивает виртуальную машину: «Изменено ли поле ref в тестовой функции?». Мы анализируем тип поля, смещение, размер и вызывающего. ВМ отвечает: «Нет, не изменено», что означает, что теперь нам разрешено избавиться от второй загрузки этого ссылочного поля refdup. IR сокращается до:

%ref = load i8*, i8** %addr
%addr = getelementptr i8, i8* %ref, i32 16
%var = load i32, i32* (cast<i8* to i32*> %addr)
%kref = load i8*, i8** %addr_k
%addr = getelementptr i8, i8* %kref, i32 816
fence
store i32 3, i32* (cast<i8* to i32*> %addr)
%addr1 = getelementptr i8, i8* %ref, i32 16
%tmp = load i32, i32* (cast<i8* to i32*> %addr1)
%res = sub i32 %var, %tmp
ret %res

Затем оптимизатор рекурсивно спрашивает виртуальную машину о второй загрузке примитива final var. «Изменена ли var в тестовой функции». ВМ снова отвечает: «Нет, не изменено». Это указывает на то, что вторая var нагрузка также является избыточной. Наконец, у нас остался код, в котором все, что у нас есть, - это сохранение в поле volatile и возврат нуля, поэтому мы смогли избавиться от четырех загрузок в IR:

%kref = load i8*, i8** %addr_k
%addr = getelementptr i8, i8* %kref, i32 816
fence
store i32 3, i32* (cast<i8* to i32*> %addr)
ret i32 0

Обратный вызов виртуальной машины для этой оптимизации - IS MODIFIED IN REGION(TYPE, OFFSET, SIZE, REGION). REGION здесь test. В реализации обратного вызова на стороне виртуальной машины мы проверяем, является ли REGION динамической областью конструктора, который может изменять klass, соответствующий TYPE. Если это не так, мы продолжаем, извлекаем поле из этого смещения и проверяем, является ли это поле постоянным. Затем мы записываем зависимость, если это поле является окончательным, и говорим, что, поскольку это последнее поле, а область не является конструктором, она не изменяется в этой области.

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

Представление

Давайте рассмотрим реальный пример микробенчмарка JMH и то, как он работает с виртуальной машиной Zing с действительно окончательной оптимизацией. На рис. 5 мы видим окончательный массив sumMeUpFinal, который суммирован в тесте sumFinal. Внутри цикла в sumFinal у нас есть непостоянная нагрузка для mask на каждой итерации цикла. У нас также есть проверка на null для последнего массива и инвариантная загрузка length массива sumMeUpFinal. На виртуальной машине без действительно окончательной оптимизации нам придется выполнять нулевую проверку и загрузку длины на каждой итерации цикла. Поскольку Zing VM поддерживает действительно окончательную оптимизацию, мы можем поднять нулевую проверку и загрузку длины этого массива из цикла через инвариантное движение кода цикла, и мы можем избавиться от всех этих инструкций в петля.

При запуске на skylake Intel (R) Xeon (R) CPU E3–1220 v5 @ 3,00 ГГц, Zing 18.04 дает 2,5-кратное усиление по сравнению с Zing с отключенной по-настоящему окончательной оптимизацией.

Мы также провели эксперименты, чтобы увидеть, как действительно окончательная оптимизация работает в реальных приложениях. На рис. 6 показаны результаты на машинах Skylake и Broadwell по более чем 90 тестам (точки на графике представляют тесты). Тестами являются Specjvm, Dacapo, Kafka и некоторые тесты приложений. Базовый уровень на уровне 100% представляет собой действительно окончательную отключенную оптимизацию. Зеленая точка представляет собой выигрыш, который мы видим при включении действительно окончательной оптимизации. Синие и зеленые точки, расположенные рядом с базовой линией, по сути, отличаются друг от друга. Ключевым моментом здесь является то, что у нас есть около 27% тестов с приростом от 2 до 11% с включенной действительно окончательной оптимизацией.

Таким образом, окончательные нестатические поля не всегда являются окончательными, и из-за этого нам нужен механизм отслеживания и аннулирования зависимостей. У нас есть оптимизации для полей final, которые реализованы как для эталонного финала, так и для примитивного финала. Мы видим нетривиальный выигрыш в тестах микротестов и в реальных приложениях с действительно окончательной оптимизацией, и выигрыш, который мы видим, обычно достигается за счет постоянного сворачивания и лучшего сглаживания.

использованная литература

[1] https://www.youtube.com/watch?v=2HfnaXND7-M&frags=pl%2Cwn

[2] https://docs.oracle.com/javase/specs/jls/se8/html/jls-17.html#jls-17.5

[3] https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-6.html#jvms-6.5.putfield

[4] https://docs.oracle.com/javase/specs/jls/se7/html/jls-8.html#jls-8.3.1.2

[5] https://llvm.org