Глубокое погружение в новый JIT-компилятор Java - Graal

Глубокое погружение в новый JIT-компилятор Java - Graal

1. обзор

В этом руководстве мы более подробно рассмотрим новый компилятор Java Just-In-Time (JIT), который называется Graal.

Мы увидим, что такое проектGraal, и опишем одну из его частей - высокопроизводительный динамический JIT-компилятор.

2. Что такое JIT-компилятор

Давайте сначала объясним, что делает JIT-компилятор.

When we compile our Java program (e.g., using the javac command), we’ll end up with our source code compiled into the binary representation of our code – a JVM bytecode. Этот байт-код проще и компактнее, чем наш исходный код, но обычные процессоры на наших компьютерах не могут его выполнить.

To be able to run a Java program, the JVM interprets the bytecode. Поскольку интерпретаторы обычно намного медленнее, чем собственный код, выполняемый на реальном процессоре,JVM can run another compiler which will now compile our bytecode into the machine code that can be run by the processor. Этот так называемый JIT-компилятор намного сложнее компилятораjavac и выполняет сложные оптимизации для генерации высококачественного машинного кода.

3. Более детальный взгляд на JIT-компилятор

Реализация JDK в Oracle основана на проекте OpenJDK с открытым исходным кодом. Сюда входятHotSpot virtual machine, доступные начиная с версии Java 1.3. Этоcontains two conventional JIT-compilers: the client compiler, also called C1 and the server compiler, called opto or C2.

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

3.1. Многоуровневая компиляция

Сегодня при установке Java используются оба JIT-компилятора при обычном выполнении программы.

Как мы упоминали в предыдущем разделе, наша программа Java, скомпилированнаяjavac, начинает выполнение в интерпретируемом режиме. JVM отслеживает каждый часто вызываемый метод и компилирует их. Для этого он использует C1 для компиляции. Но HotSpot все еще следит за будущими вызовами этих методов. Если количество вызовов увеличивается, JVM перекомпилирует эти методы еще раз, но на этот раз с использованием C2.

Это стратегия по умолчанию, используемая HotSpot, называетсяtiered compilation.

3.2. Серверный компилятор

Давайте теперь немного сосредоточимся на C2, поскольку это самый сложный из двух. C2 был чрезвычайно оптимизирован и создает код, который может конкурировать с C или быть даже быстрее. Сам компилятор сервера написан на определенном диалекте C.

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

4. Проект GraalVM

ProjectGraalVM - это исследовательский проект, созданный Oracle с целью полностью заменить HotSpot. Мы можем рассматривать Graal как несколько связанных проектов: новый JIT-компилятор для HotSpot и новую виртуальную машину polyglot. Он предлагает всеобъемлющую экосистему, поддерживающую большой набор языков (Java и другие языки на основе JVM; JavaScript, Ruby, Python, R, C / C ++ и другие языки на основе LLVM).

Мы, конечно, сосредоточимся на Java.

4.1. Graal - JIT-компилятор, написанный на Java

Graal is a high-performance JIT compiler.  Он принимает байт-код JVM и создает машинный код.

Есть несколько ключевых преимуществ написания компилятора на Java. Прежде всего, безопасность, то есть не сбои, а исключения, а не утечки памяти. Кроме того, у нас будет хорошая поддержка IDE, и мы сможем использовать отладчики, профилировщики или другие удобные инструменты. Кроме того, компилятор может быть независимым от HotSpot, и он сможет создавать более быструю JIT-скомпилированную версию самого себя.

Компилятор Graal был создан с учетом этих преимуществ. It uses the new JVM Compiler Interface – JVMCI to communicate with the VM. Чтобы включить использование нового JIT-компилятора, нам нужно установить следующие параметры при запуске Java из командной строки:

-XX:+UnlockExperimentalVMOptions -XX:+EnableJVMCI -XX:+UseJVMCICompiler

Это означает, чтоwe can run a simple program in three different ways: with the regular tiered compilers, with the JVMCI version of Graal on Java 10 or with the GraalVM itself.

4.2. Интерфейс компилятора JVM

JVMCI является частью OpenJDK начиная с JDK 9, поэтому мы можем использовать любой стандартный OpenJDK или Oracle JDK для запуска Graal.

На самом деле JVMCI позволяет нам исключить стандартную многоуровневую компиляцию и подключить наш новый компилятор (т.е. Graal) без необходимости изменять что-либо в JVM.

Интерфейс довольно прост. Когда Graal компилирует метод, он передает байт-код этого метода в качестве входных данных в JVMCI '. На выходе мы получим скомпилированный машинный код. И вход, и выход являются просто байтовыми массивами:

interface JVMCICompiler {
    byte[] compileMethod(byte[] bytecode);
}

В реальных сценариях нам обычно требуется дополнительная информация, такая как количество локальных переменных, размер стека и информация, собранная в результате профилирования в интерпретаторе, чтобы мы знали, как код работает на практике.

По сути, при вызовеcompileMethod () интерфейсаJVMCICompiler нам нужно будет передать объектCompilationRequest. Затем он вернет метод Java, который мы хотим скомпилировать, и в этом методе мы найдем всю необходимую информацию.

4.3. Грааль в действии

Сам Graal выполняется виртуальной машиной, поэтому сначала он будет интерпретирован и JIT-компилирован, когда станет горячим. Давайте посмотрим на пример, который также можно найти вGraalVM’s official site:

public class CountUppercase {
    static final int ITERATIONS = Math.max(Integer.getInteger("iterations", 1), 1);

    public static void main(String[] args) {
        String sentence = String.join(" ", args);
        for (int iter = 0; iter < ITERATIONS; iter++) {
            if (ITERATIONS != 1) {
                System.out.println("-- iteration " + (iter + 1) + " --");
            }
            long total = 0, start = System.currentTimeMillis(), last = start;
            for (int i = 1; i < 10_000_000; i++) {
                total += sentence
                  .chars()
                  .filter(Character::isUpperCase)
                  .count();
                if (i % 1_000_000 == 0) {
                    long now = System.currentTimeMillis();
                    System.out.printf("%d (%d ms)%n", i / 1_000_000, now - last);
                    last = now;
                }
            }
            System.out.printf("total: %d (%d ms)%n", total, System.currentTimeMillis() - start);
        }
    }
}

Теперь скомпилируем и запустим:

javac CountUppercase.java
java -XX:+UnlockExperimentalVMOptions -XX:+EnableJVMCI -XX:+UseJVMCICompiler

Это приведет к выводу, подобному следующему:

1 (1581 ms)
2 (480 ms)
3 (364 ms)
4 (231 ms)
5 (196 ms)
6 (121 ms)
7 (116 ms)
8 (116 ms)
9 (116 ms)
total: 59999994 (3436 ms)

Мы видим, чтоit takes more time in the beginning. Время разогрева зависит от различных факторов, таких как количество многопоточного кода в приложении или количество потоков, используемых виртуальной машиной. Если ядер меньше, время прогрева может быть больше.

Если мы хотим увидеть статистику компиляций Graal, нам нужно добавить следующий флаг при выполнении нашей программы:

-Dgraal.PrintCompilation=true

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

4.4. Сравнение с компилятором верхнего уровня

Давайте теперь сравним приведенные выше результаты с выполнением той же программы, скомпилированной компилятором верхнего уровня. Для этого нам нужно указать виртуальной машине не использовать компилятор JVMCI:

java -XX:+UnlockExperimentalVMOptions -XX:+EnableJVMCI -XX:-UseJVMCICompiler
1 (510 ms)
2 (375 ms)
3 (365 ms)
4 (368 ms)
5 (348 ms)
6 (370 ms)
7 (353 ms)
8 (348 ms)
9 (369 ms)
total: 59999994 (4004 ms)

Мы можем видеть, что между отдельными временами есть меньшая разница. Это также приводит к более короткому начальному времени.

4.5. Структура данных за Graal

Как мы говорили ранее, Graal в основном превращает байтовый массив в другой байтовый массив. В этом разделе мы сосредоточимся на том, что стоит за этим процессом. Следующие примеры основаны наChris Seaton’s talk at JokerConf 2017.

Основная задача компилятора - действовать в соответствии с нашей программой. Это означает, что он должен символизировать его соответствующей структурой данных. Graal uses a graph for such a purpose, the so-called program-dependence-graph.

В простом сценарии, где мы хотим добавить две локальные переменные, то естьx + y,we would have one node for loading each variable and another node for adding them. Рядом с нимwe’d also have two edges representing the data flow:

image

The data flow edges are displayed in blue. Они указывают, что когда загружаются локальные переменные, результат переходит в операцию сложения.

Теперь введемanother type of edges, the ones that describe the control flow. Для этого мы расширим наш пример, вызывая методы для получения наших переменных вместо их прямого чтения. Когда мы делаем это, нам нужно отслеживать методы, вызывающие порядок. Обозначим этот порядок красными стрелками:

image

Здесь мы видим, что узлы на самом деле не изменились, но мы добавили ребра потока управления.

4.6. Актуальные графики

Мы можем исследовать реальные графики Грааля сIdealGraphVisualiser. Для его запуска мы используем командуmx igv . Нам также необходимо настроить JVM, установив флаг-Dgraal.Dump.

Давайте посмотрим на простой пример:

int average(int a, int b) {
    return (a + b) / 2;
}

Это очень простой поток данных:

image

На приведенном выше графике мы видим четкое представление о нашем методе. Параметры P (0) и P (1) переходят в операцию сложения, которая входит в операцию деления с константой C (2). Наконец, результат возвращается.

Теперь изменим предыдущий пример, чтобы он был применим к массиву чисел:

int average(int[] values) {
    int sum = 0;
    for (int n = 0; n < values.length; n++) {
        sum += values[n];
    }
    return sum / values.length;
}

Мы видим, что добавление цикла привело нас к гораздо более сложному графу:

image

Что мы можем заметитьhere are:

  • узлы начала и конца цикла

  • узлы, представляющие чтение массива и чтение длины массива

  • данные и управляющие потоки, как и прежде.

This data structure is sometimes called a sea-of-nodes, or a soup-of-nodes. Мы должны упомянуть, что компилятор C2 использует аналогичную структуру данных, поэтому это не что-то новое, разработанное исключительно для Graal.

Следует помнить, что Graal оптимизирует и компилирует нашу программу, изменяя вышеупомянутую структуру данных. Мы можем понять, почему написать JIT-компилятор Graal на Java было действительно хорошим выбором:a graph is nothing more than a set of objects with references connecting them as the edges. That structure is perfectly compatible with the object-oriented language, which in this case is Java.

4.7. Опережающий режим компилятора

Также важно отметить, чтоwe can also use the Graal compiler in the Ahead-of-Time compiler mode in Java 10. Как мы уже говорили, компилятор Graal был написан с нуля. Он соответствует новому чистому интерфейсу, JVMCI, который позволяет нам интегрировать его с HotSpot. Однако это не означает, что компилятор привязан к нему.

Одним из способов использования компилятора является использование подхода, основанного на профиле, для компиляции только горячих методов, ноwe can also make use of Graal to do a total compilation of all methods in an offline mode without executing the code. Это так называемая «предварительная компиляция»JEP 295,, но мы не будем здесь углубляться в технологию компиляции AOT.

Основная причина, по которой мы будем использовать Graal таким образом, заключается в том, чтобы ускорить время запуска до тех пор, пока не вступит во владение обычный подход Tiered Compilation в HotSpot.

5. Заключение

В этой статье мы рассмотрели функциональные возможности нового JIT-компилятора Java как части проекта Graal.

Сначала мы описали традиционные JIT-компиляторы, а затем обсудили новые возможности Graal, особенно новый интерфейс JVM-компилятора. Затем мы показали, как работают оба компилятора, и сравнили их производительность.

После этого мы поговорили о структуре данных, которую Graal использует для управления нашей программой, и, наконец, о режиме компилятора AOT как еще одном способе использования Graal.

Как всегда, исходный код можно найтиover on GitHub. Помните, что JVM должна быть настроена с определенными флагами - которые были описаны здесь.