Некоторые считают Java раздутым монстром, а Rust — чемпионом производительности. Но что, если взглянуть на современную Java с Vector API и многопоточностью?
В новом переводе от команды Spring АйО посмотрим на запуск масштабной симуляции частиц и сравним результаты. Правда ли, что бывалая Java всё ещё умеет удивлять?
Сравнение performance-а языков всегда было холиварной темой. Рекомендуем расценивать статью как приглашение к конструктивной дискуссиии, а не как призыв к конкретному действию.
Java — язык, который все любят хейтить. Уже давно за ним закрепилась репутация застоявшегося, неповоротливого, раздутого монстра, используемого только теми, у кого нет другого выбора, потому что какая-то корпоративная махина в своё время купилась на хайп 90-х. Но действительно ли всё это актуально сегодня? Закостенел ли Java в своём неизменном объектно-ориентированном подходе, обречённый сойти на нет? Или же старый пёс всё-таки выучил парочку новых команд?
Попробуем решить задачу: использовать все новые приемы из какого-то абстрактного учебника про Java для симуляции как можно большего количеств�� частиц, задействуя только CPU, и, может быть, все-таки обойти Rust?
Вы можете скачать JAR-файл здесь, если хотите пропустить текстовую часть. Исходный код также доступен на GitHub. Чтобы запустить JAR с поддержкой Vector API, используйте следующую команду:
java --add-modules jdk.incubator.vector --enable-preview -jar ParticleSim.jar
Комментарий от Евгения Сулейманова и Михаила Поливахи
На самом деле, друзья, есть разница между incubating фичами и preview фичами (хотя, на самом деле, она местами размытая, но, тем не менее, формальное разделение есть), и векторный API является именно incubating фичей, а не preview, поэтому добавления incubating JDK модуля должно быть достаточно.
Разминка
У Java всегда было особое место в моём сердце — это был первый язык, в который я погрузился всерьёз. Во многом потому, что в моем университете он использовался как стандарт де-факто. С тех пор прошло уже больше десяти лет. Время жестоко.
Я занимался разработкой игр на Java и помню, как писал свою первую многопоточную симуляцию частиц, а также клон Minecraft, несмотря на то, что сам в Minecraft никогда не играл. Было круто. По сегодняшним меркам — больно, но тогда это было весело. Перенесёмся на десятилетие вперёд, и вот я читаю про модный инкубаторский SIMD API.
«SIMD? В Java?» — говорю я себе. — «Рак на горе уже свистнул?»
Да, рак свистнул и в Java появился SIMD API, который скрывает всю сложность SIMD за увлекательным интерфейсом. Для тех, кто не в теме: загуглите, что такое SIMD, или спросите у ИИ.
Для ленивых: SIMD — это набор векторных инструкций для процессора, который может обрабатывать сразу несколько чисел за один такт.
Комментарий от Михаила Поливахи
Строго говоря, речь не только про числа, а про потенциально любые данные, но на практике довольно часто когда люди говорят про SIMD инструкции имеют в виду именно числа с плавающей запятой, например.
Хотя многие компиляторы достаточно продвинуты, чтобы делать это автоматически, это происходит не всегда гарантировано и может быть не самым оптимальным решением.
Проблема с SIMD в том, что каждая архитектура процессора поддерживает свой набор векторных инструкций в зависимости от аппаратного обеспечения. Некоторые — только 128-битные инструкции (то есть четыре 32-битных числа с плавающей запятой). Другие — 256 или даже 512 бит. Это означает, что часто приходится писать один и тот же код несколько раз — под каждый набор инструкций — или использовать библиотеку, в которой это уже реализовано. Но в чём тогда веселье?
Java — особенная. Написал один раз — работает везде, по крайней мере, так утверждают.
Чтобы понять, насколько хорошо работает SIMD API в Java, мне нужна точка отсчёта. К счастью, я недавно писал многопоточные симуляции частиц с SIMD в Rust и Swift. Делал что-то похожее на JavaScript и Go, но это медленные языки, у которых нет нативного SIMD API.
Комментарий от Михаила Поливахи
Для Go, скорее всего, скоро будет внедрятся нативная поддержка SIMD. За этой активностью можно следить в данном тиките: https://github.com/golang/go/issues/73787
Хотя, может, у V8 что-то и есть, если хорошо приглядеться.
Кроме того, в Java есть прикольные лямбды и многопоточные итераторы, которые теоретически должны упростить работу с потоками. Прекрасно.
Позже я доберусь до целевого API, но сначала нужно понять, как рисовать пиксели на экране в Java.
Рисование в Java
Я хочу сохранить структуру проекта такой же, как в версиях на Rust и Swift. Симуляция будет двухмерной, с возможностью применять гравитацию к точке на экране, притягивая частицы. Каждая частица — это один пиксель, и использование GPU будет сведено к минимуму.
Итак, как рисовать пиксели в окне на Java?
В Rust мне пришлось использовать библиотеку для управления окнами, которая абстрагировала работу с ОС. Swift работает исключительно в мире Тима Эппла (покойся с миром), так что нативных API от Apple было достаточно. У Java ж�� есть собственный набор стандартных API, которые работают на всех операционных системах. Они не используют нативный внешний вид ОС, но, по крайней мере, работают.
В последний раз, когда я имел дело с Java, для интерфейса использовалась встроенная библиотека Swing, которую якобы должна была заменить JavaFX. Сегодня... JavaFX «вроде бы» считается стандартом, но для этого нужно скачивать JAR-файл и вручную добавлять его в Maven или Gradle...
Значит, будет Swing. Долой Maven и Gradle. Java, я тебя люблю, но ты серьёзно? Всё ещё нет стандартного пакетного менеджера? Почему нельзя просто сделать что-то вроде jfaster add jfx? Ну такое.
Хотя, если честно, это всё равно не имело бы особого значения, потому что даже с JavaFX, чтобы рисовать пиксели на экране, нужно использовать так называемый BufferedImage. BufferedImage — как звучит, так и работает: массив пикселей в оперативной памяти, который можно отрисовать на экран. Идеально.
Не буду утомлять вас boilerplate кодом Swing. Суть в том, что создаётся JFrame, в который добавляются UI-компоненты. Для логики вы создаёте объекты, которые расширяют или реализуют API Swing — классический ООП-стиль. В моём случае есть кастомная панель (сама симуляция частиц), которую я добавляю в JFrame.
В Java появился модный новый main, который не требует класса, но, увы, с Swing он не заработал. Что ж, бывает.
public class ParticleSim {
public static void main(String[] args) {
new ParticleSim().createAndShowGUI();
}
private void createAndShowGUI() {
JFrame frame = new JFrame("Sips Java");
frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
int width = 1200;
int height = 800;
ParticlePanel particlePanel = new ParticlePanel(width, height);
frame.add(particlePanel);
frame.pack(); // resize child components
frame.setLocationRelativeTo(null); // center
frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); // won't actual close without this
frame.setVisible(true); // ya i know, default is false
particlePanel.startSimulation();
}
}
ParticlePanel получился длинным, многословным и типично Java`овым. Изначально я запускал логику прямо в потоке обработки событий, перегружая метод paint у JPanel и используя Timer, который вызывал метод tick, запускающий симуляцию. Вот общая схема.
Предупреждаю: код длинный. Потому что Java.
// imports
public class ParticleSim {
public static void main(String[] args) {
new ParticleSim().createAndShowGUI();
}
private void createAndShowGUI() {
JFrame frame = new JFrame("Vector API Particle Sim (Requires flags)");
frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
int width = 1600;
int height = 900;
ParticlePanel particlePanel = new ParticlePanel(width, height);
frame.add(particlePanel);
frame.pack();
frame.setLocationRelativeTo(null);
frame.setVisible(true);
particlePanel.startSimulation();
}
}
class ParticlePanel extends JPanel implements ActionListener, MouseListener, MouseMotionListener {
private static final int NUM_PARTICLES = 80_000_000;
private static final int UPDATE_RATE = 1000 / 60;
private float[] positionsX = new float[NUM_PARTICLES];
private float[] positionsY = new float[NUM_PARTICLES];
private float[] velocitiesX = new float[NUM_PARTICLES];
private float[] velocitiesY = new float[NUM_PARTICLES];
private final BufferedImage image;
private final byte[] pixelArray;
private final int panelWidth;
private final int panelHeight;
// other input variables
public ParticlePanel(int width, int height) {
// setup state
image = new BufferedImage(width, height, BufferedImage.TYPE_BYTE_GRAY);
initializeParticles();
// setup input and time tracking
}
private void initializeParticles() {
// init particles
}
public void startSimulation() {
timer.start();
}
@Override
public void actionPerformed(ActionEvent e) {
if (isTicking) {
return;
}
isTicking = true;
long now = System.nanoTime();
float deltaTime = (now - lastTickTime) / 1_000_000_000.0f;
lastTickTime = now;
updatePhysics(deltaTime);
renderToPixelArray();
repaint();
frames++;
isTicking = false;
}
private void updatePhysics(float deltaTime) {
// update logic
}
private void renderToPixelArray() {
// render
}
@Override
protected void paintComponent(Graphics g) {
super.paintComponent(g);
g.drawImage(image, 0, 0, this);
}
// input overloads
}
Большая часть — это стандартный Java Swing код. Интереснее обстоит дело с рендерингом: я отображаю частицы в плоский буфер пикселей.
private void renderToPixelArray() {
final int w = panelWidth;
final int h = panelHeight;
final byte empty = 0;
Arrays.fill(pixelArray, empty);
for (int i = 0; i < NUM_PARTICLES; i++) {
int px = (int) positionsX[i];
int py = (int) positionsY[i];
int index = py * w + px;
// stay in bounds.
if (px < 0 || px >= w || py < 0 || py >= h) {
continue;
}
int lu = pixelArray[index] & 0xFF;
lu = Math.min(255, lu + 1);
pixelArray[index] = (byte) lu;
}
}
Но по-настоящему интересная часть — это updatePhysics, ведь именно там происходит вся магия.
Что ты такое?
Нужно помнить, что у SIMD может быть разная ширина векторных регистров в зависимости от архитектуры процессора. Идеально — использовать максимально возможную ширину. В SIMD API Java это реализовано через понятие species — «вид» для определённого типа данных, например float или int. Вы можете загружать и выгружать данные в векторы, ориентируясь на размер вида.
Вот как это выглядит:
private static final VectorSpecies<Float> F_SPECIES = FloatVector.SPECIES_PREFERRED;
private static final int LANE_SIZE = F_SPECIES.length();
private static final FloatVector PULL_VEC = FloatVector.broadcast(F_SPECIES, 500f);
Это позволяет писать код, который в целом не зависит от конкретного железа, на котором он работает. PREFERRED — это значение, которое, по задумке, должно давать наилучшую производительность. Есть также MAX, который выбирает максимально возможный размер, но это не всегда эффективно. На моём M1 оба варианта дают одинаковый результат — 4 float (то есть 128 бит). Останусь на PREFERRED.
Не знаю почему, но меня до слёз веселит само название species. Я только и вижу перед собой, как огромный комитет специалистов часами приходит к консенсусу именно по этому термину.
Многопоточность + SIMD
Я немного забежал вперёд и решил сразу подключить многопоточность, используя современный стриминговый API в Java. Идея в том, чтобы создать IntStream.range, который разбивает частицы на чанки в зависимости от количества SIMD-«дорожек» (lanes), а затем обрабатывает их в параллельном режиме.
final int w = this.panelWidth;
final int h = this.panelHeight;
final float wFloat = (float) w;
final float hFloat = (float) h;
final FloatVector DT_VEC = FloatVector.broadcast(F_SPECIES, deltaTime);
// var works too
final var MOUSE_X_VEC = FloatVector.broadcast(F_SPECIES, (float) mousePosition.x);
final var MOUSE_Y_VEC = FloatVector.broadcast(F_SPECIES, (float) mousePosition.y);
// more vectors
final int VECTOR_CHUNKS = NUM_PARTICLES / LANE_SIZE;
final int SCALAR_START_INDEX = VECTOR_CHUNKS * LANE_SIZE;
IntStream.range(0, VECTOR_CHUNKS).parallel().forEach(chunkIndex -> {
int i = chunkIndex * LANE_SIZE;
var px = FloatVector.fromArray(F_SPECIES, positionsX, i);
var py = FloatVector.fromArray(F_SPECIES, positionsY, i);
var vx = FloatVector.fromArray(F_SPECIES, velocitiesX, i);
var vy = FloatVector.fromArray(F_SPECIES, velocitiesY, i);
if (mouseIsPressed) {
var dx = MOUSE_X_VEC.sub(px);
var dy = MOUSE_Y_VEC.sub(py);
var distSq = dx.mul(dx).add(dy.mul(dy));
var gravityMask = distSq.compare(GT, MIN_DIST_SQ_VEC);
if (gravityMask.anyTrue()) {
var dist = distSq.sqrt();
var forceX = dx.div(dist).mul(PULL_SCALED_VEC);
var forceY = dy.div(dist).mul(PULL_SCALED_VEC);
vx = vx.add(forceX, gravityMask);
vy = vy.add(forceY, gravityMask);
}
}
vx = vx.mul(FRICTION_DT_VEC);
vy = vy.mul(FRICTION_DT_VEC);
px = px.add(vx.mul(DT_VEC));
py = py.add(vy.mul(DT_VEC));
var maskLeftX = px.compare(LT, ZERO_VEC);
var maskRightX = px.compare(GT, W_VEC);
var maskBounceX = maskLeftX.or(maskRightX);
vx = vx.blend(vx.mul(BOUNCE_MULTIPLIER_VEC), maskBounceX);
px = px.blend(ZERO_VEC, maskLeftX);
px = px.blend(W_VEC, maskRightX);
var maskTopY = py.compare(LT, ZERO_VEC);
var maskBottomY = py.compare(GT, H_VEC);
var maskBounceY = maskTopY.or(maskBottomY);
vy = vy.blend(vy.mul(BOUNCE_MULTIPLIER_VEC), maskBounceY);
py = py.blend(ZERO_VEC, maskTopY);
py = py.blend(H_VEC, maskBottomY);
px.intoArray(positionsX, i);
py.intoArray(positionsY, i);
vx.intoArray(velocitiesX, i);
vy.intoArray(velocitiesY, i);
});
Комментарий от Евгения Сулейманова
Просто как напоминание - параллельные стримы добавляют накладные. Лучше использовать единый подход: один ExecutorService + переиспользуемые задачи.
Эргономика многопоточности здесь очень похожа на то, что я видел в Rust и Swift. А главное — она работает.
Идентификатор var делает Java чуть более приятной для глаз, но мои руки по привычке тянутся к явным типам.
Теперь, внимательный читатель наверняка уже заметил одну проблему: количество частиц не всегда будет точно делиться на размер SIMD-«дорожки». Это значит, что какие-то частицы останутся необработанными в текущем виде.
С этим можно справиться двумя способами:
Продолжать использовать векторную обработку, добавляя паддинг в пустые ячейки вектора.
Завести отдельный невекторизованный цикл для обработки «хвостика» — оставшихся частиц.
Я выбрал второй вариант, но не буду показывать его здесь ради краткости.
Это работает, и вот результат — 20 миллионов частиц. Обратите внимание: чтобы пиксель стал полностью белым, в одной и той же точке должно оказаться 255 частиц.
[ здесь будет гифка https://www.youtube.com/watch?v=6Om6cSgqAII ]
Скорость оставляет желать лучшего — с трудом удаётся выжать 20 кадров в секунду. И выглядит всё это не особенно впечатляюще. Я догадываюсь, почему.
Большой рефакторинг
Одна из причин медленной работы — это не сама симуляция, а рендеринг. По сути, доступ к буферу пикселей происходит случайным образом. Именно на этом этапе тратилась большая часть времени в версиях на Rust и Swift, и Java тут не исключение. Оптимизировать это особо не получится — можно лишь распределить нагрузку между большим количеством потоков. Это ускорит процесс, но не решит проблему доступа к памяти с нарушением локальности кэша.
Есть и другие проблемы. Во-первых, цикл рендеринга работает в потоке обработки событий. Так быть не должно — от этого нужно избавиться. Да, это добавит немного сложности в обработке пользовательского ввода. Кроме того, выглядит симуляция пока довольно скучно.
В версиях на Rust и Swift я окрашивал пиксели, масштабируя RGB-компоненты на основе расстояния по осям X/Y относительно ширины и высоты окна. Здесь же, чтобы немного разнообразить визуал, я собираюсь назначить каждой частице свой уникальный цвет. Позже я сделаю их ещё более красочными. Также хочется добавить поддержку изменения размера окна, панорамирования и возможность снижать скорость частиц — как часть улучшения качества жизни (QoL).
Пропущу промежуточные этапы и покажу, к чему я пришёл.
Во-первых, уводим логику рендеринга из потока событий.
Для этого воспользуюсь одной из новых фишек Java: передачей функции (лямбды) в объект потока (Thread).
public void startSimulation() {
if (!running) {
running = true;
gameLoopThread = new Thread(this::gameLoop);
gameLoopThread.start();
}
}
Игровой цикл будет использовать while-цикл с короткими паузами (sleep) перед каждой попыткой перерисовки. Я хочу опрашивать пользовательский ввод из потока обработки событий между этими короткими паузами, а не только в момент отрисовки. Это позволяет сделать симуляцию более отзывчивой.
Однако есть и такие события, которые должны обрабатываться синхронно с частотой рендеринга, например панорамирование — ведь при нём нужно учитывать значение time delta.
private void gameLoop() {
lastTickTime = System.nanoTime();
while (running) {
long now = System.nanoTime();
long timeElapsed = now - lastTickTime;
this.processInputRequests();
if (timeElapsed >= NS_PER_TICK) {
float deltaTime = timeElapsed / (float) NS_PER_SECOND;
lastTickTime = now;
// little gross this
for (var key : this.keysPressed) {
float speed = 500;
if (this.velInputMap.containsKey(key)) {
this.panDeltaInput.x += velInputMap.get(key).x * speed * deltaTime;
this.panDeltaInput.y += velInputMap.get(key).y * speed * deltaTime;
}
}
long tickStart = System.nanoTime();
tick(deltaTime);
long tickEnd = System.nanoTime();
long tickDuration = (tickEnd - tickStart);
long renderStart = System.nanoTime();
render();
Graphics2D g = (Graphics2D) getGraphics();
g.drawImage(image, 0, 0, this);
g.dispose();
Toolkit.getDefaultToolkit().sync();
frames++;
long renderEnd = System.nanoTime();
long renderDuration = (renderEnd - renderStart);
// log frame time info
} else {
try {
Thread.sleep(1);
} catch (InterruptedException e) {
// handle error
}
}
}
}
Ключевая строка здесь —
if (timeElapsed >= NS_PER_TICK)
Она подсказывает, пора ли рендерить следующий кадр. Такой подход позволяет выходить за пределы 60 FPS на особенно мощных машинах или при небольшом количестве частиц.
Вы могли заметить, что теперь я рисую изображение сразу после обновления пикселей. Я отключил пассивный рендеринг Swing, так как теперь использую активный рендеринг. Так проще и предсказуемее.
Дальше — функция tick.
private void tick(float deltaTime) {
final int vectorizedEndIndex = (NUM_PARTICLES / LANE_SIZE) * LANE_SIZE;
final int chunkSize = vectorizedEndIndex / CPU_COUNT;
final var futures = new ArrayList<Future<?>>(CPU_COUNT);
// copy input data
final int panDx = this.panDeltaInput.x;
final int panDy = this.panDeltaInput.y;
final float vScale = this.isSlowDownRequested ? this.inputVelScale : 0f;
// reset for input thread, access is synced
this.panDeltaInput.x = 0;
this.panDeltaInput.y = 0;
for (int i = 0; i < CPU_COUNT; i++) {
int start = i * chunkSize;
int end = (i == CPU_COUNT - 1) ? vectorizedEndIndex : start + chunkSize;
ParticleUpdateTask task = tasks[i];
task.updateParams(i, start, end, this, deltaTime, panDx, panDy, vScale);
futures.add(executorService.submit(task));
}
for (Future<?> future : futures) {
try {
future.get();
} catch (InterruptedException | ExecutionException e) {
e.printStackTrace();
}
}
}
Здесь стоит отметить несколько важных моментов.
Во-первых, позаимствовав подход из Rust, чтобы избежать синхронизации и блокировок между потоками, я копирую последние данные пользовательского ввода до запуска «горячего» симуляционного цикла. Большинство значений ввода синхронизируются между главным и потоком обработки событий, и это нормально, поскольку этот путь не прогрет. А вот для обновления частиц — копирование данных обязательно.
Во-вторых, при профилировании приложения я обнаружил, что около 20% времени уходит на внутренние механизмы управления потоками в Java. Это вполне ожидаемо. И в Swing, и в Rust, и в Go есть издержки при использовании «умных» многопоточных итераторов — как по памяти, так и по времени. Быстрее — создать пул рабочих потоков и переиспользовать их.
Это также хороший повод использовать Future из стандартной библиотеки Java для простой асинхронной координации. Глубоко в API я здесь не лезу, но попробовать было интересно.
Код ParticleUpdateTask в целом остался прежним, с парой изменений. Ниже — самые важные части.
class ParticleUpdateTask implements Runnable {
// boiler plate variables, constructor, and param update function
@Override
public void run() {
// many local variables
for (int i = startIndex; i < vectorEndIndex; i += LANE_SIZE) {
FloatVector px = FloatVector.fromArray(F_SPECIES, positionsX, i);
FloatVector py = FloatVector.fromArray(F_SPECIES, positionsY, i);
FloatVector vx = FloatVector.fromArray(F_SPECIES, velocitiesX, i);
FloatVector vy = FloatVector.fromArray(F_SPECIES, velocitiesY, i);
if (mouseIsPressed) {
FloatVector dx = MOUSE_X_VEC.sub(px);
FloatVector dy = MOUSE_Y_VEC.sub(py);
FloatVector distSq = dx.mul(dx).add(dy.mul(dy));
var gravityMask = distSq.compare(GT, minPullDist);
if (gravityMask.anyTrue()) {
FloatVector dist = distSq.sqrt();
FloatVector forceX = dx.div(dist).mul(gf);
FloatVector forceY = dy.div(dist).mul(gf);
vx = vx.add(forceX, gravityMask);
vy = vy.add(forceY, gravityMask);
}
}
px = px.add(vx.mul(deltaTime)).add(ox);
py = py.add(vy.mul(deltaTime)).add(oy);
vx = vx.mul(FRICTION_DT_VEC);
vy = vy.mul(FRICTION_DT_VEC);
px.intoArray(positionsX, i);
py.intoArray(positionsY, i);
vx.intoArray(velocitiesX, i);
vy.intoArray(velocitiesY, i);
}
// non vectorized version
var pixels = panel.threadPixelBuffers[id];
Arrays.fill(pixels, 0);
for (int i = startIndex; i < endIndex; i++) {
int px = (int) Math.min(Math.max(positionsX[i], 0), w - 1);
int py = (int) Math.min(Math.max(positionsY[i], 0), h - 1);
int index = py * w + px;
pixels[index] = colors[i];
}
}
}
Я убрал отскоки частиц от краёв экрана и немного подчистил SIMD-код. Но самое важное — я дал каждому потоку локальный пиксельный буфер, в который он рисует частицы.
Я пробовал оптимизировать обработку координат — clamping и индексацию буфера пикселей — с помощью SIMD, но это оказалось медленнее. Также экспериментировал с использованием более продвинутых SIMD-функций из Java API, например fma, но и это дало отрицательный эффект на п��оизводительность.
Финальное серьёзное изменение — это накопление локальных буферов пикселей от рабочих потоков напрямую в BufferedImage.
private void render() {
int[] buff = ((DataBufferInt) image.getRaster().getDataBuffer()).getData();
Arrays.fill(buff, 0);
final int PIXEL_COUNT = buff.length;
IntStream.range(0, CPU_COUNT).parallel().forEach(chunkIndex -> {
int chunkSize = PIXEL_COUNT / CPU_COUNT;
int start = chunkIndex * chunkSize;
int end = (chunkIndex == CPU_COUNT - 1) ? PIXEL_COUNT : start + chunkSize;
for (int i = start; i < end; i++) {
int color = 0;
for (int localIndex = 0; localIndex < CPU_COUNT; localIndex++) {
int col = threadPixelBuffers[localIndex][i];
if (col != 0) {
color = col;
break;
}
}
buff[i] = (0xFF << 24) | color;
}
});
}
Я использую стратегию «последний, кто пишет — побеждает» для локальных потоковых пиксельных буферов и «первый, кто пишет — побеждает» при их слиянии. Да, можно было бы дать всем потокам писать в один и тот же буфер, но это приводит к проблемам с когерентностью кэша. Выделение отдельного буфера каждому воркеру даёт более стабильную производительность, хотя и увеличивает потребление памяти.
Кстати, о памяти. Java стартует с довольно высоким потреблением — свыше 300 МБ на 1 миллион частиц. Однако, при масштабировании она ведёт себя почти идентично Rust'у. На 100 миллионов частиц — производительность примерно та же, что и в Rust, просто с фиксированным оверхедом в те самые 300 МБ.
А как насчёт производительности?
Прежде чем перейти к ней…
Идеально размещённые пиксели
Я добавил массив colors, в котором хранится цвет каждой частицы. Хотелось бы заполнить его чем-то интересным. Один друг предложил идею — окрашивать частицы по углу относительно центра экрана, как будто создавая цветовое колесо. Мне это показалось крутым, но захотелось сделать это в пространстве OKLAB.
Я мало что понимаю в цветовых форматах, и у меня нет времени глубоко в них погружаться. Поэтому я просто попросил ИИ нагенерить код, который рассчитывает OKLAB-оттенок на основе угла частицы к центру. Правильно ли это работает? Сомневаюсь. Я бы предпочёл использовать библиотеку... но, сами понимаете — Gradle.
Также я добавил несколько других способов начального размещения частиц:
– Один — в виде круга (этот код я написал сам);
– Другой — с N случайно размещёнными точками, где цвет частицы зависит от расстояния до этих точек (этот вариант написал не я, но выглядит интересно).
Вот демка:
Мне кажется, выглядит потрясающе. Правой кнопкой мыши можно панорамировать, или использовать WASD. Пробел замедляет частицы. Мне нравится с этим возиться. Если бы я потратил хотя бы часть времени на разбор цветовой модели OKLAB, вместо того чтобы просто играться с симуляцией, — наверное, справился бы и без помощи ИИ.
Есть причина, почему я выбрал цвет частиц как 32-битное целое число, а не просто байт или флаг вкл/выкл.
А что если… раскрасить частицы на основе изображения, заданного пользователем? Можно масштабировать изображение вверх или вниз в зависимости от числа частиц, аккуратно распределяя лишние частицы по уже занятым пикселям. Звучит круто, да?
Ну так вот — нажмите клавишу 4 и выберите картинку.
А вот — 20 миллионов частиц, и одно из моих любимых изображений:
Круто. Изображение автоматически масштабируется вверх или вниз в зависимости от количества частиц. Это — 4K-картинка, увеличенная до соответствия 20 миллионам частиц, что почти в три раза больше её исходного размера.
Так и хочется добавить зум… Но, пожалуй, пора остановиться.
Итак, что с производительностью?
Насколько же быстра Java?
Сравнивать буду с Rust — на данный момент это чемпион. Но стоит понимать: каждая реализация не является точной копией один-в-один. Rust, например, записывает всего 1 байт на пиксель, но при рендеринге узким местом становится не скорость памяти, а случайный доступ к пиксельному буферу.
Кроме того, Vector API в Java всё ещё находится в статусе "incubator", так что есть шанс, что со временем производительность улучшится.
Вот результаты на M1 Air:
Язык |
Кол-во частиц |
Время всего (мс) |
Рендер (мс) |
Симуляция (Tick, мс) |
Rust |
1 млн |
~8.8 |
7.7 |
1.1 |
Java |
1 млн |
~7 |
4.5 |
2.5 |
Rust |
10 млн |
~8.1 |
1.9 |
6.2 |
Java |
10 млн |
~18.2 |
3.7 |
14.4 |
Rust |
20 млн |
~14.5 |
1.58 |
12.79 |
Java |
20 млн |
~24.7 |
2.8 |
21.8 |
Rust |
50 млн |
~36.2 |
1.8 |
34.4 |
Java |
50 млн |
~67.8 |
2.7 |
65 |
Rust |
100 млн |
~68.7 |
1.7 |
67 |
Java |
100 млн |
~118.8 |
2.7 |
116 |
Rust |
200 млн |
~144.3 |
1.8 |
142.5 |
Java |
200 млн |
~216.5 |
2.5 |
214.5 |
Что мы тут видим?
Rust — стабильно в 2 раза быстрее Java на всех масштабах. Обе версии заполняют буфер пикселей в tick, поэтому время рендера почти не меняется с ростом числа частиц. Это просто накопление буферов и вывод изображения на экран — процесс почти константный.
Интересно, что при 1 миллионе частиц у Rust рендер “кажется” медленнее, и это воспроизводилось стабильно. Почему — сказать трудно.
Память и управление
Rust выделяет память гораздо быстрее — Java делает это на куче. Можно, конечно, использовать off-heap память, которая создаётся в 2–3 раза быстрее, но на практике производительность при доступе к ней оказалась чуть хуже, чем при использовании обычной кучи. Разница — всего несколько процентов, но она стабильна, особенно при чтении.
Выводы
Java проделала огромный путь с тех пор, как я работал с ней в последний раз. Она всего в 2 раза медленнее Rust'а, и при этом без borrow checker’а!
Но, увы, проблема Java — не язык, а экосистема. Если бы я сегодня снова писал игру, меня остановила бы не Java, а то, насколько болезненно настроить сборку, подключить нужные библиотеки, разобраться с JAR’ами, DLL’ами, Vorbis, OpenGL и всем этим бардаком. Так было и в прошлом — с тех пор ничего не изменилось.
Можно ли научить старого пса новым командам? Да. Но если пёс всё ещё тусуется в пыльном, пустом, безтравном парке для выгула собак, без мячиков и обновлений, никто и не увидит, чему он там научился.
И всё же — Java остаётся псом, который навсегда в моём сердце. Хороший мальчик.

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

vic_1
02.11.2025 09:46кесарю кесарево, java - это энтерпрайз, там она хороша и никакой раст там не нужен. Попробуйте налабать на расте какой нибудь банковский проект и сравните насколько позже вы выкатите mvp по сравнению с java

ant1free2e
02.11.2025 09:46возможно, никому кроме автора не интересно выполнять GPU задачи на CPU в "классической" библиотеке для рисования кнопачек, ну или и в самом деле .parallel() тут не к месту употреблен и нерфит перформанс в разделении огромной динамической коллекции с неконстантной скоростью доступа. А какие потоки там были использованы, может все считалось на одном ядре виртуальными? А может jdk в макоси на проприетарном арм в simd что-то делает не совсем оптимально? Если бы мы это знали, но мы этого не знаем.. Поэтому психически здоровые люди проверяют на разных платформах/библиотеках эквивалентный код, а не вот это вот все. Ну или они доводят расследование до конца показывая откуда велось нападение. Неужели было настолько стыдно показать код на раст?

ant1free2e
02.11.2025 09:46интересно, что он не приводя детальных метрик все таки сделал противоречащий здравому смыслу вывод о более быстром относительно хипа нативном выделении памяти. Возможно он все таки использовал compile time массивы фиксированного размера в раст и динамические в джаве? Вообще, это похоже на проекционное мнение про "закостеленый" ооп дизайн супротив "прогрессивного" процедурного кода языка без классов. Или я просто :sarcasm там не разглядел?

SWATOPLUS
02.11.2025 09:46Одна из важных претензий к быстроте Java / C# это время старта приложения. Значительное время занимает загрузка runtime и jit-компиляция. В статье про это ни слова, а ведь это живые деньги например в serverless.

Siemargl
02.11.2025 09:46Сравнивать специфические оптимизации на редком в области вычислений процессоре это конечно сектор-приз. Множит смысл статьи на ноль.
Jijiki
вы показали классный пример, помню как я за бенниБокс повторял это на свинге ), чтобы отрисовать летящие точки
на LWJGL3(там все пакеты настроены на яве) там была бы 1 частица ) просто по вектору позиций идём кидаем трансформы и рисуем цвета(еще пред фишку показал синк матрикс стартуем с 2д и далее просто ставим координаты) - это пред инстансинг по сути тот же инстанс, но снаружи
еще можно попробовать в дополнении к векторизации собрать билд через graalvm
просто javafx например на фрибсд нету, и грааля тоже
и какое-нибудь дерево бинарное например BVH и всё что можно докинуть на минмакс -аабб я только тестил