Почему ручное разбиение задач оказалось быстрее parallel().collect() в большинстве наших тестов.

Привет, Хабр!

Меня зовут Юрий, и уже десять лет я разрабатываю The Great Tribes — пошаговую 4X-стратегию, в которой игроку предстоит провести свою цивилизацию от первобытных племён до космической эпохи.

Игра создаётся на Java с использованием LWJGL и собственного игрового движка. Мы не используем Unity или Unreal Engine: за годы разработки у проекта сформировались собственная архитектура, система процедурной генерации мира и довольно специфические требования к обработке больших карт.

Сегодня хочу рассказать об одной небольшой, но интересной оптимизации.

Мы решили ускорить генерацию природных ресурсов, написали три реализации одного алгоритма и протестировали их на трёх компьютерах с процессорами AMD Ryzen.

Результаты оказались любопытными: более компактный вариант с parallel().collect() в большинстве измерений уступил реализации с ручным разбиением массива на части.

Но обо всём по порядку.

The Great Tribes — пошаговая 4X-стратегия, разрабатываемая на Java и собственном игровом движке.
The Great Tribes — пошаговая 4X-стратегия, разрабатываемая на Java и собственном игровом движке.

Задача: распределить ресурсы по огромной карте

В The Great Tribes игровой мир состоит из квадратных клеток. Карты генерируются процедурно и могут содержать сотни тысяч клеток.

Каждая клетка обладает характеристиками, влияющими на возможность размещения природных ресурсов: типом поверхности, рельефом, условиями местности и другими параметрами.

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

При этом ресурсы должны распределяться не просто случайно. Например, некоторые месторождения чаще встречаются в горной местности, другие — на равнинах или в определённых природных условиях.

Для каждого ресурса существует профиль генерации, определяющий пригодность клеток и распространённость месторождений.

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

Таким образом, наша задача сводится к следующему:

Из большого набора потенциальных мест размещения необходимо выбрать заданное количество кандидатов с учётом их весов вероятности, без повторного выбора одного и того же кандидата.

Пример процедурно сгенерированного мира. Характеристики местности влияют на вероятность появления природных ресурсов.
Пример процедурно сгенерированного мира. Характеристики местности влияют на вероятность появления природных ресурсов.

Алгоритм: взвешенная случайная выборка без возвращения

Для решения задачи мы используем алгоритм взвешенной случайной выборки без возвращения.

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

Затем рассчитывается случайный ключ:

float key = (float) (-Math.log(random) / weight);

Здесь random — псевдослучайное число из интервала (0, 1), а weight — вес кандидата.

Такая формула позволяет преобразовать взвешенную выборку в задачу поиска кандидатов с минимальными ключами.

Чем больше вес, тем выше вероятность получить небольшой ключ.

Для каждого ресурса нам требуется выбрать только K кандидатов. Поэтому хранить и сортировать все возможные варианты необязательно.

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

Очередь организована таким образом, чтобы на её вершине находился кандидат с наибольшим ключом среди выбранных — то есть худший из текущего набора.

Comparator<ResourceDepositCandidate> comparator =
    Comparator.comparingDouble(ResourceDepositCandidate::key).reversed();

При обработке нового кандидата выполняется простая проверка:

if (queue.size() < depositCount) {
  queue.add(candidate);
} else if (candidate.key() < queue.peek().key()) {
  queue.poll();
  queue.add(candidate);
}

Если очередь ещё не заполнена, кандидат добавляется.

Если очередь заполнена, новый кандидат заменяет худшего только тогда, когда его ключ меньше.

В результате память, необходимая для хранения выбранных кандидатов, ограничена величиной K, а стоимость обработки каждого подходящего кандидата составляет O(log K) в худшем случае.

Для N кандидатов общая вычислительная сложность отбора — O(N log K).

Важно отметить, что случайные значения в нашем алгоритме привязаны к параметрам кандидата. Это позволяет получать воспроизводимые результаты генерации при фиксированном seed.

Первая реализация: Sequential

Алгоритм проходил по массиву клеток, рассчитывал пригодность каждой клетки для текущего ресурса, формировал кандидатов и поддерживал очередь лучших результатов.

Основная схема выглядела следующим образом:

PriorityQueue<ResourceDepositCandidate> queue =
    new PriorityQueue<>(depositCount, comparator);

for (Cell cell : cells) {
  float cellWeight = profile.getCellWeight(cell);
  if (cellWeight <= 0.0f) {
    continue;
  }

  for (ResourceDepositLevel level :
      ResourceDepositLevel.values()) {
    int levelWeight =
        profile.getLevelWeight(cell, level);

    if (levelWeight <= 0) {
      continue;
    }

    float weight = cellWeight * levelWeight;
    float random = getResourceRandom(cell, depositType, level);
    float key = (float) (-Math.log(random) / weight);

    ResourceDepositCandidate candidate =
        new ResourceDepositCandidate(
            cell,
            level,
            key,
            weight
        );

    if (queue.size() < depositCount) {
      queue.add(candidate);
    } else if (key < queue.peek().key()) {
      queue.poll();
      queue.add(candidate);
    }
  }
}

Примечание: здесь и далее приведены сокращённые фрагменты, иллюстрирующие алгоритм. В реальном коде используются дополнительные проверки и параметры профилей генерации.

Полный исходный код метода из The Great Tribes — настоящая реализация, участвовавшая в тестах.
  private ResourceSelectionResult selectRandomDepositsSequential(
      Cell[] cells, ResourceGenerationProfile profile) {
    ResourceDepositType depositType = profile.getDepositType();
    int suitableCellCount = 0;
    for (Cell cell : cells) {
      float cellWeight = profile.getCellWeight(cell);
      if (cellWeight <= 0.0f) {
        continue;
      }
      suitableCellCount++;
    }

    int depositCount = Math.round(suitableCellCount * profile.getMapRatio());
    if (depositCount <= 0) {
      return new ResourceSelectionResult(List.of(), suitableCellCount);
    }

    // В peek() находится худший из выбранных кандидатов, то есть кандидат с самым большим key.
    PriorityQueue<ResourceDepositCandidate> selectedCandidates =
        new PriorityQueue<>(
            depositCount, Comparator.comparingDouble(ResourceDepositCandidate::key).reversed());

    for (Cell cell : cells) {
      float cellWeight = profile.getCellWeight(cell);
      if (cellWeight <= 0.0f) {
        continue;
      }

      Map<ResourceDepositLevel, Integer> levelWeights = profile.getDepositLevelWeights(cell);

      for (ResourceDepositLevel depositLevel : ResourceDepositLevel.values()) {
        int levelWeight = levelWeights.getOrDefault(depositLevel, 0);
        if (levelWeight <= 0) {
          continue;
        }
        float weight = cellWeight * levelWeight;
        float random = getResourceRandom(cell, depositType, depositLevel, CHANNEL_PLACEMENT);
        float key = (float) (-Math.log(random) / weight);
        if (selectedCandidates.size() < depositCount) {
          selectedCandidates.add(new ResourceDepositCandidate(cell, depositLevel, key, weight));
          continue;
        }

        // Чем меньше key, тем лучше кандидат.
        if (key >= selectedCandidates.peek().key()) {
          continue;
        }

        selectedCandidates.poll();
        selectedCandidates.add(new ResourceDepositCandidate(cell, depositLevel, key, weight));
      }
    }
    return new ResourceSelectionResult(new ArrayList<>(selectedCandidates), suitableCellCount);
  }

Последовательная реализация достаточно проста, не требует синхронизации и создаёт всего одну очередь кандидатов.

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

Возник вопрос: насколько быстрее можно выполнить эту работу параллельно?

Вторая реализация: Parallel с ручным разбиением массива

Первый параллельный вариант основан на достаточно очевидной идее: разделить массив клеток на несколько частей и обработать их независимо.

Количество частей мы ограничили количеством доступных логических процессоров:

int processors = Runtime.getRuntime().availableProcessors();
int chunkCount = Math.min(processors, cells.length);
int chunkSize = (cells.length + chunkCount - 1) / chunkCount;

После этого каждая часть обрабатывается в отдельной параллельной задаче:

List<PriorityQueue<ResourceDepositCandidate>> results =
    IntStream.range(0, chunkCount)
        .parallel()
        .mapToObj(chunkIndex -> {
          int fromIndex = chunkIndex * chunkSize;
          int toIndex = Math.min(fromIndex + chunkSize, cells.length);
          return processChunk(
              cells,
              fromIndex,
              toIndex,
              profile,
              depositCount
          );
        })
        .toList();
Полный исходный код метода из The Great Tribes» — настоящая реализация, участвовавшая в тестах.
  private ResourceSelectionResult selectRandomDepositsParallel(
      Cell[] cells, ResourceGenerationProfile profile) {

    ResourceDepositType depositType = profile.getDepositType();
    int suitableCellCount = 0;

    for (Cell cell : cells) {
      if (profile.getCellWeight(cell) > 0.0f) {
        suitableCellCount++;
      }
    }

    int depositCount =
        Math.round(suitableCellCount * profile.getMapRatio());

    if (depositCount <= 0) {
      return new ResourceSelectionResult(
          List.of(),
          suitableCellCount);
    }

    int processors = Runtime.getRuntime().availableProcessors();
    int chunkCount = Math.min(processors, cells.length);
    int chunkSize = (cells.length + chunkCount - 1) / chunkCount;
    List<List<ResourceDepositCandidate>> chunkResults =
        IntStream.range(0, chunkCount)
            .parallel()
            .mapToObj(
                chunkIndex -> {
                  int fromIndex = chunkIndex * chunkSize;
                  int toIndex =
                      Math.min(fromIndex + chunkSize, cells.length);

                  return selectRandomDepositsChunk(
                      cells,
                      fromIndex,
                      toIndex,
                      profile,
                      depositType,
                      depositCount);
                })
            .toList();

    PriorityQueue<ResourceDepositCandidate> selectedCandidates =
        new PriorityQueue<>(
            depositCount,
            Comparator.comparingDouble(
                    ResourceDepositCandidate::key)
                .reversed());

    for (List<ResourceDepositCandidate> chunkCandidates : chunkResults) {
      for (ResourceDepositCandidate candidate : chunkCandidates) {

        if (selectedCandidates.size() < depositCount) {
          selectedCandidates.add(candidate);
          continue;
        }

        if (candidate.key() >= selectedCandidates.peek().key()) {
          continue;
        }

        selectedCandidates.poll();
        selectedCandidates.add(candidate);
      }
    }

    return new ResourceSelectionResult(
        new ArrayList<>(selectedCandidates),
        suitableCellCount);
  }

  private List<ResourceDepositCandidate> selectRandomDepositsChunk(
      Cell[] cells,
      int fromIndex,
      int toIndex,
      ResourceGenerationProfile profile,
      ResourceDepositType depositType,
      int depositCount) {

    PriorityQueue<ResourceDepositCandidate> selectedCandidates =
        new PriorityQueue<>(
            depositCount,
            Comparator.comparingDouble(
                    ResourceDepositCandidate::key)
                .reversed());

    for (int i = fromIndex; i < toIndex; i++) {
      Cell cell = cells[i];

      float cellWeight = profile.getCellWeight(cell);

      if (cellWeight <= 0.0f) {
        continue;
      }

      Map<ResourceDepositLevel, Integer> levelWeights =
          profile.getDepositLevelWeights(cell);

      for (ResourceDepositLevel depositLevel :
          ResourceDepositLevel.values()) {

        int levelWeight =
            levelWeights.getOrDefault(depositLevel, 0);

        if (levelWeight <= 0) {
          continue;
        }

        float weight = cellWeight * levelWeight;
        float random =
            getResourceRandom(
                cell,
                depositType,
                depositLevel,
                CHANNEL_PLACEMENT);
        float key =
            (float) (-Math.log(random) / weight);

        if (selectedCandidates.size() < depositCount) {
          selectedCandidates.add(
              new ResourceDepositCandidate(
                  cell,
                  depositLevel,
                  key,
                  weight));
          continue;
        }

        if (key >= selectedCandidates.peek().key()) {
          continue;
        }

        selectedCandidates.poll();
        selectedCandidates.add(
            new ResourceDepositCandidate(
                cell,
                depositLevel,
                key,
                weight));
      }
    }

    return new ArrayList<>(selectedCandidates);
  }

Каждая задача формирует собственную PriorityQueue, содержащую не более K лучших кандидатов из своей части массива.

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

Здесь есть важный математический момент.

Если кандидат не вошёл в число K лучших кандидатов своей части массива, он не сможет войти и в глобальный Top-K.

Ведь в его собственной части уже существует как минимум K кандидатов с меньшими ключами.

Следовательно, для построения глобального результата достаточно объединить локальные Top-K.

Это позволяет избежать хранения всех кандидатов и необходимости синхронизировать доступ к общей очереди.

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

Но затем мы решили попробовать другой подход.

Третья реализация: ParallelOptimized

Мой коллега Геннадий предложил использовать стандартные возможности Stream API и отказаться от ручного разбиения массива на части.

Вместо этого мы применили трёхаргументный collect() к параллельному потоку.

Основная идея:

PriorityQueue<ResourceDepositCandidate> result =
    Arrays.stream(cells)
        .parallel()
        .collect(
            () -> new PriorityQueue<>(depositCount, comparator),
            (queue, cell) -> {
              // Вычисление кандидатов для клетки.
              // Добавление подходящих кандидатов
              // в локальную очередь Top-K.
            },
            (left, right) -> {
              // Объединение двух локальных Top-K.
              for (ResourceDepositCandidate candidate : right) {
                if (left.size() < depositCount) {
                  left.add(candidate);
                } else if (candidate.key() < left.peek().key()) {
                  left.poll();
                  left.add(candidate);
                }
              }
            }
        );
Полный исходный код метода из The Great Tribes» — настоящая реализация, участвовавшая в тестах.
private ResourceSelectionResult selectRandomDepositsParallelOptimized(
      Cell[] cells, ResourceGenerationProfile profile) {

    ResourceDepositType depositType = profile.getDepositType();

    // 1. Быстрый параллельный подсчет подходящих ячеек
    int suitableCellCount = (int) Arrays.stream(cells)
        .parallel()
        .filter(cell -> profile.getCellWeight(cell) > 0.0f)
        .count();

    int depositCount = Math.round(suitableCellCount * profile.getMapRatio());
    if (depositCount <= 0) {
      return new ResourceSelectionResult(List.of(), suitableCellCount);
    }

    Comparator<ResourceDepositCandidate> candidateComparator =
        Comparator.comparingDouble(ResourceDepositCandidate::key).reversed();

    // 2. Параллельный сбор без использования накладных flatMap-стримов
    PriorityQueue<ResourceDepositCandidate> selectedCandidates = Arrays.stream(cells)
        .parallel()
        .collect(
            // Supplier: у каждого потока своя локальная очередь
            () -> new PriorityQueue<>(depositCount, candidateComparator),

            // Accumulator: обычные циклы внутри потока (работает быстрее, чем flatMap)
            (queue, cell) -> {
              float cellWeight = profile.getCellWeight(cell);
              if (cellWeight <= 0.0f) {
                return;
              }

              Map<ResourceDepositLevel, Integer> levelWeights = profile.getDepositLevelWeights(cell);

              for (ResourceDepositLevel depositLevel : ResourceDepositLevel.values()) {
                int levelWeight = levelWeights.getOrDefault(depositLevel, 0);
                if (levelWeight <= 0) {
                  continue;
                }
                float weight = cellWeight * levelWeight;
                float random = getResourceRandom(cell, depositType, depositLevel, CHANNEL_PLACEMENT);
                float key = (float) (-Math.log(random) / weight);

                // Локальный отбор топ-кандидатов в потоке
                if (queue.size() < depositCount) {
                  queue.add(new ResourceDepositCandidate(cell, depositLevel, key, weight));
                } else if (key < queue.peek().key()) {
                  queue.poll();
                  queue.add(new ResourceDepositCandidate(cell, depositLevel, key, weight));
                }
              }
            },

            // Combiner: безопасное слияние локальных очередей разных потоков в одну
            (queue1, queue2) -> {
              for (ResourceDepositCandidate candidate : queue2) {
                if (queue1.size() < depositCount) {
                  queue1.add(candidate);
                } else if (candidate.key() < queue1.peek().key()) {
                  queue1.poll();
                  queue1.add(candidate);
                }
              }
            }
        );

    return new ResourceSelectionResult(new ArrayList<>(selectedCandidates), suitableCellCount);
  }

В данном случае Stream API самостоятельно организует разделение массива на задачи и объединение частичных результатов.

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

Синхронизация общей очереди по-прежнему не требуется.

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

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

На первый взгляд всё выглядело довольно привлекательно.

Но возникает вопрос: означает ли более компактная реализация более высокую производительность?

Для ответа мы провели серию тестов.

Методика тестирования

Мы решили проверить все три реализации на нескольких компьютерах.

В эксперименте участвовали:

Участник

Процессор

Ядра / потоки

Оперативная память

Юрий

AMD Ryzen 7 5800X

8 / 16

32 ГБ

Геннадий

AMD Ryzen 5 5600X

6 / 12

16 ГБ

Олег

AMD Ryzen 7 5700X

8 / 16

32 ГБ DDR4-3600

Все три процессора относятся к архитектуре AMD Zen 3.

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

Для оценки производительности мы использовали пять размеров карт The Great Tribes. Игровой мир состоит из кластеров, каждый из которых содержит 16 клеток.

Размер карты

Размер в кластерах

Количество клеток

TINY

64 × 40

40 960

SMALL

96 × 60

92 160

AVERAGE

128 × 80

163 840

GREAT

160 × 100

256 000

HUGE

192 × 120

368 640

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

Это создаёт достаточно большой объём вычислений, чтобы оценить эффективность параллельной обработки на практике.

Перед измерениями выполнялся прогрев JVM.

Каждая реализация тестировалась многократно. Всего мы собрали 225 измерений.

Измерялось суммарное время генерации всех профилей ресурсов.

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

Например, один из запусков на карте TINY мог занять существенно больше времени, чем соседние запуски того же алгоритма.

Медиана позволяет уменьшить влияние подобных выбросов.

Почему мы не использовали JMH?

JMH — специализированный инструмент для измерения производительности Java-кода.

Он предоставляет более строгие средства контроля прогрева, измерений и выполнения тестов.

Однако нас интересовала прежде всего производительность реального этапа генерации ресурсов внутри игрового движка.

Поэтому мы проводили измерения непосредственно в игре.

Это не делает эксперимент полноценным микробенчмарком. На результаты по-прежнему могли влиять сборщик мусора, операционная система, состояние процессора и другие факторы.

Тем не менее одинаковые входные данные, предварительный прогрев JVM и повторные измерения позволили получить полезную практическую картину.

Масштабы процедурной генерации The Great Tribes. Карта размера HUGE содержит 368 640 клеток, каждая из которых обладает собственными характеристиками, влияющими в том числе на распределение природных ресурсов.
Масштабы процедурной генерации The Great Tribes. Карта размера HUGE содержит 368 640 клеток, каждая из которых обладает собственными характеристиками, влияющими в том числе на распределение природных ресурсов.

Результаты: Ryzen 7 5800X

Начнём с моего компьютера.

Размер карты

Sequential

Parallel

ParallelOptimized

TINY

55

38

41

SMALL

120

67

71

AVERAGE

240

151

153

GREAT

469

215

276

HUGE

600

365

315

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

Ручное разбиение оказалось быстрее в четырёх случаях из пяти.

Особенно заметной была разница на карте GREAT: последовательная реализация выполнялась за 469 мс, а параллельная — за 215 мс.

Это ускорение примерно в 2,18 раза.

Однако на самой большой карте HUGE победил ParallelOptimized: 315 мс против 365 мс.

Уже на этом этапе стало понятно, что однозначного победителя для всех размеров карт может не быть.

Результаты: Ryzen 5 5600X

Следующим тестирование провёл Геннадий.

Размер карты

Sequential

Parallel

ParallelOptimized

TINY

67

77

61

SMALL

134

82

84

AVERAGE

273

151

167

GREAT

536

330,5

353

HUGE

786

475

489

На этом компьютере ручное разбиение снова победило в четырёх случаях из пяти.

Интересно, что на маленькой карте TINY оно оказалось даже медленнее последовательной реализации.

Это вполне объяснимо: параллельная обработка имеет собственные накладные расходы, которые не всегда успевают окупиться на небольшом объёме работы.

На больших картах преимущества параллелизма проявились значительно сильнее.

Результаты: Ryzen 7 5700X

Третью серию тестов провёл Олег.

Размер карты

Sequential

Parallel

ParallelOptimized

TINY

69

39

52

SMALL

137

70

81

AVERAGE

290

192

168

GREAT

460

211

304

HUGE

591

360

454

И снова ручное разбиение победило в четырёх случаях из пяти.

Особенно интересен результат на карте GREAT:

  • Sequential — 460 мс;

  • Parallel — 211 мс;

  • ParallelOptimized — 304 мс.

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

А относительно последовательной реализации ускорение составило примерно 2,18 раза.

Общие результаты

Сведём результаты двух параллельных реализаций в одну таблицу.

Размер

5800X

5600X

5700X

TINY

38 / 41

77 / 61

39 / 52

SMALL

67 / 71

82 / 84

70 / 81

AVERAGE

151 / 153

151 / 167

192 / 168

GREAT

215 / 276

330,5 / 353

211 / 304

HUGE

365 / 315

475 / 489

360 / 454

Формат значений: Parallel / ParallelOptimized. Время в миллисекундах.

Итого:

12 побед у Parallel против 3 побед у ParallelOptimized.

В большинстве случаев ручное разбиение массива на части оказалось эффективнее.

При этом оба параллельных алгоритма на больших картах обычно значительно превосходили последовательную реализацию.

Почему ParallelOptimized оказался медленнее?

Самый интересный вопрос — почему реализация с parallel().collect() уступила ручному разбиению?

Однозначного ответа у нас пока нет, но есть несколько предположений.

1. Разное количество промежуточных задач

В ручном варианте количество частей массива ограничено числом доступных логических процессоров.

Например, на Ryzen 7 5800X это обычно 16 частей.

В варианте с parallel().collect() Stream API самостоятельно определяет разбиение массива на задачи.

Количество промежуточных аккумуляторов может отличаться от числа логических процессоров.

При определённых условиях это увеличивает накладные расходы на создание и объединение частичных результатов.

2. Дополнительные выделения памяти

Каждый аккумулятор создаёт собственную PriorityQueue.

При этом очередь инициализируется с вместимостью depositCount.

Если таких очередей создаётся много, это может приводить к дополнительным выделениям памяти и увеличению нагрузки на сборщик мусора.

В ручной реализации число локальных очередей ограничено количеством chunks.

3. Объединение результатов

В обоих вариантах необходимо объединять локальные Top-K.

Но организация этого объединения отличается.

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

В варианте с collect() объединение частичных результатов организует Stream API.

В зависимости от количества задач и размера локальных очередей стоимость объединения может оказаться различной.

4. Неравномерная загрузка задач

Есть и аргумент в пользу parallel().collect().

При ручном разбиении массива мы заранее назначаем каждой задаче определённый диапазон клеток.

Однако стоимость обработки разных клеток может отличаться.

Например, для одних клеток вес ресурса равен нулю, и они отбрасываются практически сразу. Для других необходимо обработать несколько уровней залегания.

Если сложность обработки клеток распределена неравномерно, динамическое разбиение Stream API потенциально может обеспечить лучшую балансировку нагрузки.

Возможно, именно этим частично объясняются отдельные победы ParallelOptimized.

Но это пока гипотеза.

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

Что в итоге осталось в игре

После сравнения результатов мы решили оставить selectRandomDepositsParallel() — реализацию с ручным разбиением массива на chunks.

Она показала хорошие результаты на всех трёх компьютерах и оказалась быстрее альтернативной параллельной реализации в большинстве тестов.

При этом мы не считаем parallel().collect() плохим решением.

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

Просто в конкретной задаче генерации ресурсов The Great Tribes вариант с явно ограниченным количеством частичных задач оказался предпочтительнее.

Заключение

Работая над этой оптимизацией, мы ещё раз убедились в нескольких вещах.

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

Во-вторых, более компактный код не обязательно выполняется быстрее. Стандартные механизмы Java хорошо подходят для многих задач, но конкретная организация вычислений может существенно влиять на результат.

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

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

В нашем случае три компьютера с процессорами одной архитектуры продемонстрировали похожую общую тенденцию, но отдельные результаты заметно различались.

На этом оптимизацию генерации ресурсов мы пока считаем завершённой.

Хотя, конечно, всегда остаётся соблазн написать четвёртый вариант и снова всё измерить. :)

Спасибо Геннадию за альтернативную реализацию и участие в тестировании, а Олегу — за дополнительную серию замеров.

Надеюсь, наш опыт окажется полезным другим Java-разработчикам.

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


Немного о проекте

The Great Tribes — пошаговая 4X-стратегия, которую мы разрабатываем на Java с использованием LWJGL и собственного движка.

Проект находится в активной разработке.

Если вам интересны глобальные стратегии, процедурная генерация миров и нестандартные игровые системы, буду рад видеть вас на странице игры в Steam.


Комментарии (2)


  1. panzerfaust
    09.10.2026 07:11

    Чем дальше в лес, тем больше понимаешь, что часть популярных тулов из стандартной либы джавы хоть и хорошо затюнены, но все еще по сути универсальные. А универсальный всегда хуже специализированного.

    Я в рамках одной задачи сначала заменил стандартную TreeMap на самописный SkipList, а потом вообще на операции с массивом примитивов. Получил ускорение на порядок что по памяти, что по CPU.


    1. Zemlaynin Автор
      09.10.2026 07:11

      Согласен. :) В задачах, где можно обойтись массивом примитивов, TreeMap скорее всего окажется в проигрышном положении, но универсальные структуры хороши именно своей универсальностью. Когда задача конкретная и известны требования и нагрузки, специализированное решение может дать существенный прирост.