← Модуль 8/ECS
EN
Модуль 8 · Технический deep-dive (reference)

ECS и data-oriented design

Почему «скучная» раскладка данных бьёт ООП на порядок. Формулы — нативный MathML (без библиотек).
reference~16 мин🏠 лаба
Суть за 20 секунд
ECS переворачивает ООП: сущности — просто ID, компоненты — плоские данные в плотных массивах, системы — циклы по этим массивам. Выигрыш не в «красоте архитектуры», а в cache locality: проход по непрерывным массивам превращает random pointer-chasing в линейный доступ к памяти — на современных CPU это 10–100×. Каноничный data-oriented design: проектируй под то, как железо реально читает память, а не под то, как удобно рисовать диаграмму классов.

Контекст: почему ООП упирается в память

Классический ООП-объект игрового мира — это Enemy с глубоким наследованием (Entity → Character → Enemy → Boss), виртуальными методами и набором полей, размазанных по объекту. Каждый такой объект живёт где-то в куче, выделенный отдельным new; ссылки на компоненты и соседей — это указатели в произвольные адреса. Когда update() идёт по списку из тысяч таких объектов, CPU прыгает по памяти случайным образом — и на каждом прыжке рискует промахнуться мимо кэша.

Это и есть «memory wall»: за последние десятилетия CPU стали в сотни раз быстрее, а латентность RAM — почти нет. Современное ядро тратит на промах в кэш порядка сотни циклов, простаивая. В горячем цикле обновления, где логика на сущность тривиальна (сложить вектор, проверить флаг), доминируют не вычисления, а ожидание данных. Виртуальная диспетчеризация добавляет своё (промах по таблице методов + барьер для предсказателя ветвлений), но корень — именно cache miss'ы от разбросанных по куче объектов. Абстракция тут не виновата; виновата раскладка.

Механизм: сущности, компоненты, системы

ECS разбирает объект на три ортогональные вещи:

Например, система движения — это буквально цикл «для всех с {Position, Velocity}: pos += vel · dt». Ключ — как хранятся компоненты, чтобы этот цикл шёл по непрерывной памяти. Два каноничных хранилища:

В обоих случаях горячий цикл читает данные подряд, а не прыгает по указателям. Именно это, а не «энтерпрайзная чистота», даёт ускорение.

Модель стоимости прохода

Время прохода системы по N сущностям грубо складывается из стоимости попаданий и промахов в кэш:

time≈N·(thit+mmiss·tmiss)

где thit — стоимость доступа при попадании в кэш, tmiss — штраф за промах (подтянуть строку из RAM), а mmiss — доля промахов (miss rate). У случайного pointer-chasing mmiss близок к 1 (почти каждый объект — новая строка кэша), у линейного прохода — близок к 0 (префетчер угадывает следующий адрес). Отсюда грубая оценка ускорения при переходе random → linear:

speedup≈tmissthit

Цифры, которые дают этому смысл: строка кэша — 64 байта (память тянется не байтами, а строками); доступ к L1 — порядка ~1 нс, к RAM — порядка ~100 нс. То есть отношение tmiss/thit — это и есть тот самый 10–100×, на который можно разогнать горячий цикл, просто переложив данные в непрерывные массивы. Никакой новой математики в системе не появилось — изменилось только движение данных.

ООП · куча (random) ECS · массивы (linear) прыжки по указателям → cache miss Position Velocity Health проход подряд → префетч прячет латентность
Одни и те же данные: слева — объекты по куче, доступ случайный; справа — компоненты в непрерывных колонках, доступ линейный.

🕹 В какие игры поиграть — и что заметить

ECS не «видно» в кадре — зато видно его следствие: способность держать десятки тысяч сущностей в горячем цикле. Четыре кейса от «посмотри на счётчик» до «разнеси сам» — и что заметить руками (от простого к сложному).

Factorio 2020 · data-oriented на масштабе

Десятки тысяч лент, манипуляторов и заводов обновляются каждый тик. Движок жёстко data-oriented: горячие циклы идут по плотным массивам, а не виртуальными вызовами на объект. Игра закэплена на 60 UPS (updates/sec); на мегабазе UPS проседает ниже 60, при этом ядро загружено заметно ниже 100% — потому что упор не в вычисления, а в кэш и пропускную способность RAM: известный потолок мегабаз — больше L3 и быстрее память дают больше завода до просадки. Это буквально «memory wall» из урока, вынесенный на счётчик.

🎮 Сыграй: открой большое сохранение (или разрастись) и включи показ UPS/FPS (он сам всплывает при просадке). Гони фабрику в десятки тысяч активных сущностей и смотри: UPS падает ниже 60, а ядро не загружено на 100% — этот разрыв и есть латентность памяти, а не нехватка «гигагерц».

Minecraft блоки-массивы vs мобы-объекты

Блоки лежат плотными массивами на под-чанк (16×16×16, палитра состояний) — непрерывно, data-oriented → миллионы блоков дёшевы. А сущности (мобы) — объекты с тиком на каждого → пара сотен мобов стоит дороже, чем миллионы блоков. Один движок, две раскладки — наглядный «массивы данных» против «pointer-объектов» в одной игре.

🎮 Сыграй: построй гигантскую структуру в сотни тысяч блоков — идёт гладко. Теперь собери мобоферму на пару сотен мобов — TPS сервера (цель 20; смотри /tps или мод Spark) проседает. Та же игра: «плотные данные блоков» ≫ «объекты-сущности».

Overwatch 2016 · ECS в проде (эталон)

Шипнутая AAA на учебниковом ECS (Tim Ford, GDC 2017): ~103 типа компонентов, ~46 клиентских систем — и, ключевое, только 3 системы (движение, оружие, state-script) трогают неткод. ECS сжал нерешаемую на вид задачу сетевой синхронизации до трёх систем. «entity = id, component = данные, system = цикл» — один-в-один с уроком.

🎮 Посмотри: доклад GDC 2017 «Overwatch Gameplay Architecture and Netcode» — самый чистый разбор ECS в реальном продакшене; заметь, как разделение «компоненты-данные / системы-циклы» свело неткод к 3 системам из сотен.

Bevy / Unity DOTS ECS, потрогать самому

ECS по построению. Bevy (Rust, хранилища table + sparse-set), Unity DOTS (ECS + Burst + Job System). Демки «заспавнить N сущностей» дают крутить N в сотни тысяч и держать 60 fps там, где наивный GameObject/ООП умирает.

🎮 Запусти: возьми пример Bevy (cargo run --example many_cubes / many_sprites) или Unity DOTS-самплы; выкрути число сущностей и смотри, как держится 60 fps на counts, которые валят ООП. Это ECS-масштаб в твоих руках — и прямой мостик в лабу ниже.

Хардкор · теория и перформанс: иерархия памяти, промахи и speedupможно пропустить

Иерархия памяти

У CPU не «память», а пирамида с растущей латентностью и объёмом: регистры → L1 (~32–64 КБ, ~1 нс / ~4 цикла) → L2 (~256 КБ–1 МБ, ~3–4 нс) → L3 (несколько–десятки МБ, ~10–20 нс) → RAM (~100 нс / порядка сотни циклов). Память тянется строками по 64 байта: тронул один байт — приехала вся строка. Значит данные, которые читаются вместе, выгодно класть рядом — тогда одна загрузка строки покрывает сразу несколько следующих обращений.

Random против linear на уровне железа

При случайном доступе (pointer-chasing по объектам в куче) каждый объект почти гарантированно лежит в своей строке кэша → промах на объект → ядро сталлит ~100 циклов, ожидая RAM, и так на каждой сущности. При линейном проходе по плотному массиву включается аппаратный префетчер: он видит регулярный шаг, подтягивает следующие строки заранее, и латентность RAM прячется за вычислениями — пропускная способность приближается к ~1 элемент за такт. Та же логика, та же сложность O(N) — разница только в раскладке, и она измеряется порядками.

Откуда берётся speedup

Из cost-model прохода:

time≈N·(thit+mmiss·tmiss)

При random mmiss→1, время ≈ N·tmiss; при linear mmiss→0, время ≈ N·thit. Отношение и есть оценка ускорения:

speedup≈tmissthit≈1001ns

На практике не дотягивает до полных 100× (есть L2/L3, частичные попадания, накладные расходы итерации), но 10–100× на узких горячих циклах — реалистичный диапазон. Профилировать стоит по счётчику cache-misses (perf / Tracy), а не по «ощущению скорости».

Archetype vs sparse-set: tradeoff

  • Archetype — быстрая итерация (компоненты лежат колонками внутри архетипа, проход максимально линеен), но медленнее add/remove: добавил/убрал компонент — изменился набор → сущность физически переносится в другую таблицу архетипа (копирование всех её компонентов).
  • Sparse-set — быстрый add/remove (дописал/выкинул из плотного массива + правка индекса, без переноса набора), но чуть медленнее итерация: при пересечении нескольких компонентов есть индирекция через разреженный индекс и хуже плотность для многокомпонентных запросов.

Правило: много структурных изменений (часто add/remove компонентов, спавн/деспавн) → sparse-set; стабильный набор и доминирует проход по горячим компонентам → archetype. Реальные движки часто гибридны или дают выбор хранилища на тип компонента.

Хардкор · дизайн: когда НЕ нужен ECSможно пропустить

ECS — не бесплатный апгрейд, а размен. За cache locality платишь сложностью:

  • Тяжелее рассуждать. Логика сущности размазана по системам и компонентам; «что происходит с этим врагом» больше не один класс, а пересечение нескольких систем. Отладка и онбординг дороже.
  • Хуже для one-off логики. Уникальное поведение единственного босса или скриптовой сцены в ECS выражается криво — это не «много однотипных сущностей в горячем цикле», а частный случай, которому система не нужна.
  • Оверкилл на малом N. Для игры с ~50 сущностями cache locality не выигрывает ничего: всё и так помещается в кэш, а сложность ECS — чистый налог.

Суждение. ECS окупается на масштабе: тысячи–десятки тысяч сущностей и горячие update-циклы, где доминирует проход по данным (RTS, bullet-hell, симуляции, частицы, крупные открытые миры). Для нарративной игры с малым N нод-дерево / ООП проще и достаточно — и это не компромисс, а правильный выбор.

Важно: Godot построен на scene-tree из нод, а не на ECS — и это сделано намеренно. Для подавляющего большинства игр (включая нарративные и средние по числу объектов) дерево нод читается проще и его более чем хватает; Godot 4 завёл отдельные серверы и data-oriented куски под нагруженные подсистемы (рендер, физика), но полного ECS не вводил. Это прямой пример «is the clever thing worth it»: умная штука нужна тогда, когда того требует паттерн доступа, а не потому что она звучит инженерно солидно.

Аналогия
ООП-объекты — это книги, разбросанные по всей библиотеке: за каждой нужной идёшь отдельно, через весь зал, и большую часть времени просто шагаешь между полками. ECS — это те же данные одним непрерывным списком на одном столе: читаешь сверху вниз, не вставая. Содержание то же — но «ходьба между полками» (латентность памяти) исчезает, и остаётся только полезная работа.
Почему это важно
Это, возможно, глубочайший урок перф-инженерии: на современном железе узкое место — обычно память, а не CPU. Проектируй под движение данных — под то, как процессор реально читает память, — и алгоритм той же сложности ускоряется на порядки без единой новой строчки математики. Мета-суждение то же, что и везде в курсе: тянись к ECS, когда того требует паттерн доступа, а не потому что модно. Самый простой инструмент, который справляется с задачей, обычно и есть правильный.
🔁 За пределами игр — куда это переносится
ECS — это data-oriented design: проектируй под то, как железо реально читает память (раскладка > абстракция). Фундаментальный перф-урок:

Бэкенд / БД: columnar-хранилища (Parquet, ClickHouse, vectorized-движки) — те же SoA-массивы под кэш и SIMD; OLAP бьёт строковые БД ровно поэтому.

ML / AI: раскладка тензоров — это ECS: contiguous memory, struct-of-arrays, почему батчи и непрерывная память критичны; data-loading как «memory wall»-bottleneck; весь high-perf ML (JAX/Mojo) — про доступ к памяти, не про «больше FLOPs».

HPC / системы: SIMD-векторизация, cache-oblivious алгоритмы, false sharing — та же борьба за локальность.

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

🏠 Лаба — пощупать cache locality
Интерактивная лаба без кода: одна система-проход читает Position+Velocity у N сущностей, ты меняешь только раскладку — SoA / AoS / объекты-в-куче — и смотришь, сколько строк кэша реально тянется и во сколько раз это медленнее. Тот самый random→linear, но глазами и ползунком. Открыть лабу →
Лучший момент: на AoS подними «размер структуры» — util падает со 100% до 25%, а время растёт ровно во столько же; в OOP-куче поймай красные промахи, которые префетч не прячет.
🔧 Запусти и поковыряй — на домашнем компе
Во что поиграть и что заметить — выше (🕹). Здесь — для тех, кто хочет увидеть промахи кэша на своём железе:
🔧 Поковырять (debug) ~3 ч, Rust + Tracy
Собери минимальный archetype-ECS на Rust (~300 строк): entity = id, компоненты колонками по архетипам, одна система-проход. Рядом — наивный «вектор boxed-объектов» с теми же данными. Прогони оба на десятках тысяч сущностей под Tracy и сравни не «ощущение», а счётчик cache-misses и время прохода. Затем сломай локальность нарочно: вставь в горячий компонент жирное неиспользуемое поле (раздуй struct) и сними профиль ещё раз — то, что в лабе нарисовано упрощённой моделью, тут видно реальным счётчиком промахов на твоём железе. Папка labs/lab-08-mini-ecs/.
🧪 Потестить (глазами perf-инженера) ~20 мин
Профайл-ревью: открой любой профиль (Tracy / perf) и ищи классические анти-паттерны локальности — random pointer-chasing в горячем цикле, AoS там, где система читает 2 поля из 20, false sharing между потоками, лишний churn (add/remove компонентов гоняет сущности между архетипами). Каждый — кандидат на «переложить данные», а не «оптимизировать математику».
Чеклист: собрал mini-ECS; снял cache-misses на колонках vs boxed-объектах; раздул struct и увидел рост времени; нашёл хотя бы один анти-паттерн локальности в профиле.
Связи
пересечение
DOOM / BSP — та же идея «правильная структура данных и предвычисление важнее грубой силы»: BSP заранее раскладывает геометрию под быстрый обход, ECS — данные под быстрый проход.
пересечение
Pathfinding — flow fields и навигация на масштабе: та же дисциплина раскладки данных под горячий цикл (поле направлений вместо тысяч независимых A*-поисков).
пересечение
Классика vs ML — «самый простой инструмент, который справляется» как суждение: ECS, как и ML-фича, окупается только когда задача его реально требует.
Вопросы пытливого ума
Почему ООП «медленный» для игр — дело же не в самих классах?
Верно, абстракция тут ни при чём. Тормозят две конкретные вещи: cache miss'ы от pointer-chasing (объекты разбросаны по куче, проход по списку прыгает по случайным адресам, ядро сталлит ~100 циклов на промах) и виртуальная диспетчеризация (промах по таблице методов + барьер для предсказателя ветвлений). И то, и другое — следствие раскладки и косвенности, а не «классов как идеи». ECS убирает обе причины, складывая данные в плотные массивы и заменяя виртуальные вызовы прямыми циклами.
Archetype vs sparse-set — когда что выбирать?
По паттерну изменений. Archetype даёт самую быструю итерацию (компоненты колонками внутри архетипа), но add/remove компонента переносит сущность в другую таблицу (копирование) — бери, когда набор компонентов стабилен и доминирует проход по горячим данным. Sparse-set даёт дешёвый add/remove (правка плотного массива + индекса, без переноса) ценой чуть более медленной итерации через индирекцию — бери при частых структурных изменениях (спавн/деспавн, тогглы компонентов). Многие движки гибридны или дают выбрать хранилище на каждый тип компонента.
Godot использует ECS?
Нет. Godot построен на scene-tree из нод, и это намеренное решение: для большинства игр дерево нод читается проще и его с запасом хватает. Godot 4 добавил отдельные серверы и data-oriented куски под нагруженные подсистемы (рендер, физика), но полного ECS не вводил. Если упрёшься в десятки тысяч однотипных сущностей в горячем цикле — можно прикрутить сторонний ECS поверх (например, через GDExtension), но «из коробки» Godot — это ноды, и для нарративной/средней игры это правильно.
ECS всегда быстрее ООП?
Нет. ECS выигрывает только когда итерация доминирует и N большое — тогда линейный проход по плотным массивам бьёт random-доступ на порядок. Но если у тебя высокий churn (постоянные add/remove компонентов гоняют сущности между архетипами) или просто крошечный N (всё помещается в кэш и так), ECS становится медленнее или попросту бессмысленным — остаётся только его сложность как налог. Правило: сперва убедись, что узкое место — проход по данным, и только потом тянись к ECS.
Bevy, Unity DOTS, Flecs — чем отличаются как примеры?
Unity DOTS (ECS + Burst + Job System) — archetype-хранилище, агрессивная векторизация и многопоточность, заточен под массовые симуляции в Unity. Flecs — самостоятельная C/C++ ECS-библиотека, archetype-ориентированная, с богатой системой запросов, отношений и иерархий. Bevy — ECS-ядро движка на Rust, поддерживает оба хранилища (table/archetype и sparse-set) с выбором на тип компонента, плюс автопараллелизм систем по доступам. EnTT (C++) — каноничный sparse-set. Различия по сути сводятся к выбору хранилища (archetype ↔ sparse-set), языку/эргономике и тому, насколько движок берёт на себя параллелизм.
Что почитать