Поддержка многопоточности в Entity Component System

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

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

Проблемы

Синхронизация операций чтения / записи operation данных

Как решение, мы можем разделить данные на изолированные блоки и слать каждый блок в отдельный “worker”-поток - в этом случае мы можем обращаться к этим данным без дополнительной синхронизации. По этой же причине мы не можем создавать новые сущности и события добавления / удаления / изменения данных компонентов - синхронизация записи серьезно ударит по производительности.

Получение результатов работы потоков обратно в основной поток

Как решение, мы можем блокировать основной поток и ждать завершения работы все потоков. Это работает достаточно неплохо в текущей версии ECS фреймворка - многопоточная система может быть стандартной IEcsRunSystem-системой и работать как часть EcsSystems группы. Как дополнительная опция - синхронизация с основным потоком может быть выполнена позже через специальные методы синхронизации.

[Обновлено 28.11.2018] Поддержка многопоточности вынесена в отдельный опциональный репозиторий. Основной поток теперь является таким же “worker”-ом и требует принудительной синхронизации в пределах текущей системы для обеспечения целостности ECS-данных.

Количество данных не может быть обработано за одинаковое время с фиксированным размером блока данных для потока

Есть 2 случая:

  • “Worker” может обработать только фиксированное количество сущностей. Если “worker”-ов не хватает на обработку всех данных - менеджер задач блокирует основной поток и ждет, когда освободится очередной “worker” и нагружает его новыми данными. Это повторяется пока все данные не будут обработаны.
  • Все сущности делятся поровну между “worker”-ами. Это может быть полезным для уменьшения времени блокировки основного потока, но уменьшает контроль над времене исполнения каждого “worker”-а.

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

[Обновлено 28.11.2018] В текущей версии реализована смешанная схема. Каждый “worker” имеет минимальный размер блока данных, который он готов обработать. Как только количество данных перестает вмещаться в эти лимиты по количеству текущих “worker”-ов - данные начинают равномерно распределяться между всеми “worker”-ами.

Пример

Любая многпоточная ECS-система должна наследоваться от класса EcsMultiThreadSystem:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
class TestMultiThreadSystem : EcsMultiThreadSystem {
[EcsWorld]
EcsWorld _world;

[EcsFilterInclude (typeof (C12_1))]
EcsFilter _filter;

protected override EcsFilter GetFilter () {
return _filter;
}

protected override EcsWorld GetWorld () {
return _world;
}

protected override Action<EcsMultiThreadJob> GetWorker () {
return ProcessJob;
}

protected override int GetThreadsCount () {
return 4;
}

protected override int GetJobSize () {
return 100000;
}

protected override bool GetForceSyncState () {
return true;
}

static void ProcessJob (EcsMultiThreadJob job) {
for (var i = job.From; i < job.To; i++) {
var c = job.World.GetComponent<C12_1> (job.Entities[i]);
// processing.
}
}
}

Тесты производительности

Условия

3 системы с одинаковой логикой обработки каждой сущности:

  • Стандартная IEcsRunSystem-система
  • EcsMultiThreadSystem-система с 4 “worker”-ами
  • EcsMultiThreadSystem-система с 8 “worker”-ами

Результаты

  • Стандартная IEcsRunSystem-система: 532ms
  • EcsMultiThreadSystem-система с 4 “worker”-ами: 118ms
  • EcsMultiThreadSystem-система с 8 “worker”-ами: 34ms

Выглядит неплохо, но может стать лучше при условии распределения данных между “worker”-ами вместо работы исключительно с фиксированным блоком данных. Это поведение может быть изменено в будущем, не забывайте следить за обновлениями!