Project Valhalla: эпичный квест Java за перфомансом
Project Valhalla, по моему скромному мнению, это самое важное и в то же время самое сложное нововведение в JVM, улучшающее производительность.
Бэкграунд: как модель памяти Java влияет на перфоманс
Рассмотрим, в чем разница между хранением массива примитивов int[]
и массива объектов Integer[]
:
Каждый объект в Java помимо полезной информации содержит еще и т.н. метаданные (или заголовок/хедер).
Для случая с примитивами int[]
у нас есть только 1 объект - собственно массив. Его ячейки хранятся последовательно в памяти. Для случая с типами-обертками Integer[]
у нас уже 5 объектов: массив и сами полезные данные отдельно. "Указатели" на объекты все еще хранятся последовательно в массиве, а вот полезные данные раскиданы где-то в памяти, и их хранение "рядом" в общем случае не гарантировано.
Это играет против перфоманса:
Доступ к полезной информации для типов-оберток Integer требует больше операций с памятью
Чтение данных из памяти происходит минимально возможными порциями - т.н. cacheline. Для большинства современных CPU размер этой порции равен 64 байтам. То есть если мы хотим прочитать значение типа int (4 байта) из определенной ячейки массива, то мы прочитаем еще несколько соседних ячеек. Для массива типов-оберток та же самая ситуация, но мы уже прочитаем из массива не саму полезную информацию, а "указатели", которые там хранятся. А это уже не так хорошо.
Хедер с метаданными для каждого объекта занимает примерно 96-128 бит (после реализации Project Lilliput будет меньше). Поэтому для хранения целого числа (4 байта) в типе-обертке мы тратим 12-16 байт сверху. Помимо всего прочего, эти метаданные загружаются в кэши CPU, поэтому там остается меньше места для более полезных данных.
На момент релиза Java в 1995 году это было приемлемо, т.к. в те времена CPU и память работали со сравнимыми скоростями.
Однако сегодня, когда CPU значительно быстрее, чем память, такое расточительное отношение к последней становится бутылочным горлышком перфоманса.
Что за Project Valhalla?
Задуманный еще в 2014 (что дает представление, насколько сложна реализация), JEP-401 призван дать возможность создавать кастомные примитивные или "value" объекты. Эти объекты должны представлять собой плоскую структуру, подобно рассмотренному выше массиву int[]
, а не дерево "указателей". Разумеется, это не бесплатно, но это не тема данной статьи.
Рассмотрим, в чем разница по организации памяти, на примере класса Point
// Before
public class Point {
private final int x;
private final int y;
}
// After
public primitive class Point {
private final int x;
private final int y;
}
Значительно улучшается локальность памяти. Теперь, когда мы хотим просмотреть все точки, строка кэша, которая будет выбрана при доступе к p[0].x
, будет содержать некоторые ячейки соседние ячейки p [0].y
, p[1].x
и т. д.
Также видно, что у нас теперь только 1 хедер с метаданными вместо четырех.
В Вальхаллу!
Мне очень хотелось протестировать эти примитивные классы в коллекциях, но, поскольку Project Valhalla в настоящее время находится в стадии раннего доступа, такая фича пока недоступна.
Проверим 2 сценария:
Сортировка миллиона Point по Х и Y
Подсчет суммы миллиона Point, используя иммутабельный аккумулятор
Окружение для теста:
Java 22-valhalla
64 GB RAM
Intel(R) Core(TM) i7–11850H
GitHub: https://github.com/tomerr90/ProjectValhalla/tree/main
Benchmark Mode Cnt Score Error Units
Valhalla.sort avgt 25 7251.158 ± 302.564 us/op
Valhalla.sortPrimitive avgt 25 747.968 ± 25.046 us/op
Valhalla.acc avgt 25 3512.221 ± 160.482 us/op
Valhalla.accPrimitive avgt 25 280.603 ± 4.226 us/op
Сортировка быстрее в 9.7 раз, а аккумулирование в 12.5!
Лично меня впечатляют не эти числа сами по себе, а тот факт, что такая старая платформа, как Java, все еще способна на фундаментальные изменения с заметным эффектом. Возможно, старую собаку таки можно научить новым фокусам.