ECS и data-oriented design
Контекст: почему ООП упирается в память
Классический ООП-объект игрового мира — это Enemy с глубоким наследованием (Entity → Character → Enemy → Boss), виртуальными методами и набором полей, размазанных по объекту. Каждый такой объект живёт где-то в куче, выделенный отдельным new; ссылки на компоненты и соседей — это указатели в произвольные адреса. Когда update() идёт по списку из тысяч таких объектов, CPU прыгает по памяти случайным образом — и на каждом прыжке рискует промахнуться мимо кэша.
Это и есть «memory wall»: за последние десятилетия CPU стали в сотни раз быстрее, а латентность RAM — почти нет. Современное ядро тратит на промах в кэш порядка сотни циклов, простаивая. В горячем цикле обновления, где логика на сущность тривиальна (сложить вектор, проверить флаг), доминируют не вычисления, а ожидание данных. Виртуальная диспетчеризация добавляет своё (промах по таблице методов + барьер для предсказателя ветвлений), но корень — именно cache miss'ы от разбросанных по куче объектов. Абстракция тут не виновата; виновата раскладка.
Механизм: сущности, компоненты, системы
ECS разбирает объект на три ортогональные вещи:
- Entity — просто
id(частоu32+ поколение). Никаких данных и методов, чистый ключ. - Component — плоский
structданных:Position{x,y},Velocity{dx,dy},Health{hp}. Без поведения, без указателей наружу. - System — функция, которая пробегает по всем сущностям, у которых есть нужный набор компонентов
{A, B}, и обновляет их пачкой.
Например, система движения — это буквально цикл «для всех с {Position, Velocity}: pos += vel · dt». Ключ — как хранятся компоненты, чтобы этот цикл шёл по непрерывной памяти. Два каноничных хранилища:
- Archetype (архетипы): сущности группируются по набору компонентов. Все сущности с ровно
{Position, Velocity}лежат в одной непрерывной таблице (структура массивов: колонка позиций, колонка скоростей). Итерация системы — линейный проход по этим колонкам. Так устроены Unity DOTS и Flecs (а также один из режимов Bevy). - Sparse-set (разреженные множества): на каждый тип компонента — свой плотный массив значений плюс разреженный индекс
entity_id → позиция в плотном массиве. Компоненты одного типа лежат непрерывно; итерация по одному компоненту линейна, а пересечение нескольких — по меньшему набору. Так устроены EnTT и режим sparse-set в Bevy.
В обоих случаях горячий цикл читает данные подряд, а не прыгает по указателям. Именно это, а не «энтерпрайзная чистота», даёт ускорение.
Модель стоимости прохода
Время прохода системы по N сущностям грубо складывается из стоимости попаданий и промахов в кэш:
где thit — стоимость доступа при попадании в кэш, tmiss — штраф за промах (подтянуть строку из RAM), а mmiss — доля промахов (miss rate). У случайного pointer-chasing mmiss близок к 1 (почти каждый объект — новая строка кэша), у линейного прохода — близок к 0 (префетчер угадывает следующий адрес). Отсюда грубая оценка ускорения при переходе random → linear:
Цифры, которые дают этому смысл: строка кэша — 64 байта (память тянется не байтами, а строками); доступ к L1 — порядка ~1 нс, к RAM — порядка ~100 нс. То есть отношение tmiss/thit — это и есть тот самый 10–100×, на который можно разогнать горячий цикл, просто переложив данные в непрерывные массивы. Никакой новой математики в системе не появилось — изменилось только движение данных.
🕹 В какие игры поиграть — и что заметить
ECS не «видно» в кадре — зато видно его следствие: способность держать десятки тысяч сущностей в горячем цикле. Четыре кейса от «посмотри на счётчик» до «разнеси сам» — и что заметить руками (от простого к сложному).
Десятки тысяч лент, манипуляторов и заводов обновляются каждый тик. Движок жёстко data-oriented: горячие циклы идут по плотным массивам, а не виртуальными вызовами на объект. Игра закэплена на 60 UPS (updates/sec); на мегабазе UPS проседает ниже 60, при этом ядро загружено заметно ниже 100% — потому что упор не в вычисления, а в кэш и пропускную способность RAM: известный потолок мегабаз — больше L3 и быстрее память дают больше завода до просадки. Это буквально «memory wall» из урока, вынесенный на счётчик.
🎮 Сыграй: открой большое сохранение (или разрастись) и включи показ UPS/FPS (он сам всплывает при просадке). Гони фабрику в десятки тысяч активных сущностей и смотри: UPS падает ниже 60, а ядро не загружено на 100% — этот разрыв и есть латентность памяти, а не нехватка «гигагерц».
Блоки лежат плотными массивами на под-чанк (16×16×16, палитра состояний) — непрерывно, data-oriented → миллионы блоков дёшевы. А сущности (мобы) — объекты с тиком на каждого → пара сотен мобов стоит дороже, чем миллионы блоков. Один движок, две раскладки — наглядный «массивы данных» против «pointer-объектов» в одной игре.
🎮 Сыграй: построй гигантскую структуру в сотни тысяч блоков — идёт гладко. Теперь собери мобоферму на пару сотен мобов — TPS сервера (цель 20; смотри /tps или мод Spark) проседает. Та же игра: «плотные данные блоков» ≫ «объекты-сущности».
Шипнутая 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 системам из сотен.
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 прохода:
При random mmiss→1, время ≈ N·tmiss; при linear mmiss→0, время ≈ N·thit. Отношение и есть оценка ускорения:
На практике не дотягивает до полных 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»: умная штука нужна тогда, когда того требует паттерн доступа, а не потому что она звучит инженерно солидно.
Бэкенд / БД: 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; проектируй движение данных, не только логику.
Position+Velocity у N сущностей, ты меняешь только раскладку — SoA / AoS / объекты-в-куче — и смотришь, сколько строк кэша реально тянется и во сколько раз это медленнее. Тот самый random→linear, но глазами и ползунком. Открыть лабу →
cache-misses и время прохода. Затем сломай локальность нарочно: вставь в горячий компонент жирное неиспользуемое поле (раздуй struct) и сними профиль ещё раз — то, что в лабе нарисовано упрощённой моделью, тут видно реальным счётчиком промахов на твоём железе. Папка labs/lab-08-mini-ecs/.Почему ООП «медленный» для игр — дело же не в самих классах?
Archetype vs sparse-set — когда что выбирать?
Godot использует ECS?
ECS всегда быстрее ООП?
Bevy, Unity DOTS, Flecs — чем отличаются как примеры?
- Mike Acton, «Data-Oriented Design and C++» (CppCon) — манифест подхода: проектируй под железо, а не под удобство.
- Richard Fabian, «Data-Oriented Design» (book) — систематически про раскладку данных и cache locality.
- Документация Bevy ECS и Flecs — два живых взгляда на archetype/sparse-set и запросы.
- Lab 08 —
labs/lab-08-mini-ecs/: собрать mini-ECS и снять промахи кэша через Tracy.