Некоторые считают 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-«дорожки». Это значит, что какие-то частицы останутся необработанными в текущем виде.

С этим можно справиться двумя способами:

  1. Продолжать использовать векторную обработку, добавляя паддинг в пустые ячейки вектора.

  2. Завести отдельный невекторизованный цикл для обработки «хвостика» — оставшихся частиц.

Я выбрал второй вариант, но не буду показывать его здесь ради краткости.

Это работает, и вот результат — 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)


  1. Jijiki
    02.11.2025 09:46

    вы показали классный пример, помню как я за бенниБокс повторял это на свинге ), чтобы отрисовать летящие точки

    на LWJGL3(там все пакеты настроены на яве) там была бы 1 частица ) просто по вектору позиций идём кидаем трансформы и рисуем цвета(еще пред фишку показал синк матрикс стартуем с 2д и далее просто ставим координаты) - это пред инстансинг по сути тот же инстанс, но снаружи

    еще можно попробовать в дополнении к векторизации собрать билд через graalvm

    просто javafx например на фрибсд нету, и грааля тоже

    и какое-нибудь дерево бинарное например BVH и всё что можно докинуть на минмакс -аабб я только тестил


  1. vic_1
    02.11.2025 09:46

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


  1. ant1free2e
    02.11.2025 09:46

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


  1. ant1free2e
    02.11.2025 09:46

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


  1. SWATOPLUS
    02.11.2025 09:46

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


  1. goremukin
    02.11.2025 09:46

    Интересно какая разница в потреблении памяти?


  1. Siemargl
    02.11.2025 09:46

    Сравнивать специфические оптимизации на редком в области вычислений процессоре это конечно сектор-приз. Множит смысл статьи на ноль.