В 2016 году EA закрывает свой последний оплот разработки в Питере, и народ начал разбредаться по разным студиям и командам. Тогда мировой игрострой ещё благосклонно смотрел на обладателей красных паспортов, и несколько моих бывших коллег «со связями» организовали мини‑галеру и умудрились получить контракт на поддержку движка для прототипа новой игры Arkane. Причем схема взаимодействия оказалась была достаточно мутная, из‑за нежелания Остинцев работать напрямую с пост‑СНГ конторами, поэтому основной контракт у них был через каких‑то корейцев за три рубля, которые делегировали часть работы французкой конторе за рубль, а те наняли каких‑то ребят за десять копеек из восточной Европы, ну вы поняли каких:)

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

С этого момента и началось моё знакомство с идеями, которые мы внутри команды прозвали «арканутым C++», и с их реальным применением в разработке игр. Я и раньше сталкивался с жёсткими требованиями к разработке на плюсах на своей доигростростроевской работе, но за шесть лет как‑то раздобрел и перестал сильно жестить с оптимизацией, так что знакомство с командой из Остина вызвало стойкое дежавю разработки начала нулевых.

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


C++ язык многоуровневый, и он умеет в исключения, жонглирует RTTI, подкупает лёгкостью виртуальных функций и прозрачностью шаблонов, владеет пятью стилями кунг‑фу умных указателей, а ещё, видимо, имеет контейнер на любой случай жизни. Однако в хотпасе эта универсальность быстро превращается в проблемы, которые приходится решать рано или поздно и все сводится к нескольким простым вещам (эта функция выделяет память? Здесь может вылететь ошибка? Кому принадлежит объект? Откуда взялся malloc, когда внутрь передали готовый буфер?), заставляя вас писать максимально быстрый код, и заметьте я не сказал современный...

Команда из Остина взяла «тепленький, с пылу с жару» на тот момент C++14 и запретила значительную его часть, в результате чего получился скорее не новый язык, а архитектурная дисциплина с аллергией на аллокации во время кадра (zero frame allocations) и с предсказуемой ценой вызова, причём compile‑time часть языка осталась почти без ограничений, тогда как рантайм, наоборот, зажали до максимально предсказуемого подмножества.

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

В крупном проекте почти никогда не существует единого C++, потому что есть C++ инструментов, где важнее скорость разработки и есть C++ загрузчика, где можно один раз выделить гигабайт временной памяти и забыть. Еще есть C++ игровой логики, где удобный shared_ptr может быть вполне нормальным выбором, и есть хотпас рендера, где тот же shared_ptr добавляет атомики, отдельный control block и непонятный момент освобождения памяти, который зависит от последнего неизвестного владельца и, вообщето, arkanec++ предназначен только для последней области.

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

Архитектура движка оставляла от С++ почти все, что работало на этапе компиляции, вроде if constexpr, std::span, std::string_view, и source_location, которые приветствовались, ибо помогаои компилятору и команде проверить больше до запуска (Я буду писать общеизвестные аналоги, чтобы не уходить в дебри собственных велосипедов, благо что все эти идеи так или иначе доехали до станадарта).

Исключения и RTTI запрещаются, потому что добавляют поведение, которое сложнее увидеть в точке вызова, да шаблонное колдовство тоже не приветствуется, но уже по причине, что код выполняется быстро, зато компилируется вечность. Вечность в нашем случае означала почти четыре часа компиляции движка и игры с нуля (сам движок + уровни игры + шейдеры), если репа была пустая и порядка пятнадцати минут рекомпила, если что‑то тронуть в условно «системных файлах». Но в домашних условиях движок обычно не собирали, забирая уже готовые бандлы с билдфермы.

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

Ошибка бывает либо рабочей, либо последней

Все проблемы в runtime делились на два уровня, первый описывал ожидаемые неприятности окружающего мира, когда файла нет, соединение оборвалось, данные повреждены или закончился локальный аллокатор. Да, бывало что и закончился, но программа при этом не сломана, просто обстоятельства вызова оказались неудачными, поэтому вызывающий код получает свою ошибку вида Result<T, E> и решает, что делать дальше, как‑то так:


```
файл не найден             -> Result
сокет закрыт               -> Result
плохой пользовательский ввод -> Result
bounded pool исчерпан       -> Result

индекс вне массива          -> panic
сломанный FSM               -> panic
разыменование пустого значения-> panic
память совсем кончилась     -> panic
```

Второй уровень это уже нарушение контракта, например выход за границу массива, невозможное состояние автомата, разыменование пустого значения или глобальный OOM, и поскольку продолжать работу после такого события опасно, вызывается единый panic(), пишется диагностика и игра убивается, приводя QA в неописуемый восторг и желание оформить баженцию в Jira.

Но проблема была не в самом Result, которых программисты понаписали уже несколько сотен видов под любые случаи, а в запрете проигнорировать результат, для чего тип помечался [[nodiscard]], ошибка распространялась ранним возвратом, а успешный путь оставался прижатым к левому краю без пирамиды из if.

пирамида if                          ранний возврат

do_a()                               r = do_a()
  if ok:                               if fail: return Err
    do_b()                             r = do_b()
      if ok:                           if fail: return Err
        do_c()                         r = do_c()
          if ok:                       if fail: return Err
            success                    success
          else: Err
        else: Err
      else: Err

happy path уезжает вправо            happy path остаётся слева

У стандартного std::expected сейчас почти та же модель, но тогда команда хотела контролировать всё самостоятельно, вследствие чего попытка взять отсутствующее значение шла через собственный panic() с аналогом source_location, а требование обработать результат висело на всём типе независимо от реализации стандартной библиотеки.

Слово «требование» здесь надо понимать в контексте использования, поскольку на[[nodiscard]] обычный компилятор выдаёт только ворнинг, а не физически запрещает сборку, поэтому там это дело подшаманили ассертов и заодно все ворнинги пометили как ошибки, ну чтобы вы с ними гарантированно проект собрать не могли.

std::expected                         Result

.value()                              .value()
    |                                     |
    v                                     v
std::terminate / abort                panic(source_location)
(зависит от STL и флагов)             файл + строка известны

[[nodiscard]]                         [[nodiscard]]
на отдельных методах                  на всём типе
не везде                              всегда, независимо от STL

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

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

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

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

Собственно у ребят из команды, которые принимали ревью, это тоже было одним из главным пунктиком при анализе, ибо заразно как ковид. Но... тут я могу только похлопать, в движке они смогли довести число аллокаций за кадр где‑то до двадцати, т.е два десятка аллокаций на фрейме... не тысяч, десятков еща раз повторю. Чем очень гордились и тыкали бывших симсоводов носиком, когда те таки умудрились пропихнуть парочку новых через ревью, причём все крупные и происходящие на старте кадра.

Заплатить за это пришлось громадным стеком под двадцать мегабайт для главного потока(+ 2Мб на воркерах * число ядер на машине) и жёстким расталкиванием всего, что только можно, по статик кешам, буферам и пулам. Так что любое покушение на рантайм‑выделение памяти просто не проходило мерж‑тесты и не позволяло отправить изменение на ревью.

Правило формулировалось так, что глобальные new и delete запрещены, а любой тип, которому надо выделять память, принимает аллокатор явно. Системные аллокаторы сейчас очень неплохи, а реализации вроде mimalloc, rpmalloc и jemalloc вообще способны перемалывать огромное количество выделений памяти достаточно быстро, но даже там проблемы все равно есть, просто смешаются в несколько другие области. Глобальная куча точно вам не ответит, какой подсистеме принадлежит память, каков её бюджет и когда всё выделенное можно освободить, частично или полностью. Да и с разделением на домены там серьезные проблемы.

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

                      общий системный heap
                             |
        кто выделил? сколько можно? когда чистить?
                             |
                             ?

FrameArena               StagePool               LevelHeap
    |                        |                       |
частицы                 пакеты и очереди         данные уровня
команды кадра           лимиты                   отдельный пик
временные данные        локальный OOM            очистка целиком
    |                        |                       |
reset каждый кадр       сброс и пересборка       unload уровня

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

Еще естьstd::pmr, (сейчас есть, в 2016 был только пропозал и стыренные у boost`a inplace реализации), но у стандартной реализации memory_resource будет виртуальный вызов на каждую аллокацию (это не видно на фоне цены аллокации в обычном коде), а аллокатор как параметр шаблона можно встроить на этапе компиляции.

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

Сторонняя библиотека всё равно вызовет malloc

Как ни старайтесь, но полный контроль памяти останется очередной красивой идеей и будет работать только до подключения первой сторонней библиотеки, из‑за чего в репозитории встречались переписанные или адаптированные версии OpenSSL, ICU, zlib и даже отдельных частей платформенных либ из SDK. Не берусь судить, насколько это красиво и удобно для сопровождения, но в игровые тулы со своим самоваром не ходят, как говорится, поэтому оставлю решение на совести техлюдей студии.

Хорошо, если у библиотеки есть официальные хуки, вроде zalloc и zfree у zlib или функций настройки аллокатора у OpenSSL, тогда можно их сразу затюнить на свой аллоактор, а при отсутствии хуков зависимость можно вынести в отдельный worker или процесс с ограниченным бюджетом памяти, переведя общение на сообщения.

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

игра
  |
  | request
  v
очередь сообщений --> изолированный worker --> сторонняя библиотека
  ^                         |
  | result                  +-- heap limit
  |                         +-- watchdog
  `-------------------------+-- restart

И пару раз это действительно спасало, например стандартный платформенный модуль стора для xbox понемногу подъедал память во время работы, поэтому его посадили в изолированный worker‑процесс с кучей около мегабайта + буфер (буфер был нужен если гадкий модуль всеже чтото успел записать за границу данных), и когда бюджет заканчивался воркер стандартно крашился, после чего watchdog пересоздавал worker и всё начиналось заново.

Не делайте так... это какая‑то черная магия, если честно и подобная схема работает только при контролируемом завершении воркера либо при настоящей изоляции в отдельном процессе, иначе вместо архитектуры получится лотерея с use‑after‑free. Собственно лотерея там и случилась, поэтому перед релизом буфер немного увечили, потому что пару раз запись перехлеставала даже через него.

Можно использовать любой контейнер, если он вектор

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

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

AoS

[pos vel hp type name][pos vel hp type name][pos vel hp type name]
 \____________ cache line ____________/
       нужны только pos и vel,
       остальное приехало зря

SoA

[pos][pos][pos][pos][pos][pos][pos][pos]
[vel][vel][vel][vel][vel][vel][vel][vel]
[hp ][hp ][hp ][hp ][hp ][hp ][hp ][hp ]

cache line содержит только данные текущего прохода

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

Да и часть алгоритмов STL тоже были переписаны, а сортовsort`a реально было штук десять и у каждого отдельный комментарий когда его лучше использовать и с каким контейнером.

iostream, конечно же, был выкинут вообще весь и замены ему не было. Выкинули из‑за размера, статической инициализации и тяжёлого интерфейса и вообще работа с потоками вывода была очень и очень ограничена, а вместо это использовались аналоги format и print на статических буферах.

Что однако породило другую проблему, потому что некоторые оставленные части STL умеют сообщать об ошибках только через исключения, и тот‑же vector::push_back может получить bad_alloc, или конструктор thread и mutex::lock могут получить system_error.

При выключенных исключениях эти пути обычно заканчиваются terminate или abort, причём точное поведение зависит от реализации, локальное исчерпание аллокатора, которое собственный контейнер мог бы вернуть как Result, внутри std::vector внезапно становится panic и крашит игру. Можно конечно написать собственный и собственный vector, и собственный mutex и собственное форматирование, но тогда архитектурно игра незаметно превращается в собственную стандартную библиотеку, которую тоже кто‑то должен поддерживать.

Лучше ли это обычного С++?

Не лучше, просто другой, и я знаю нескольких людей из автомобильного софта, которые и не переставали жить в таком С++, и там это, думаю, оправдано, ибо ценой ошибки вполне может стать чья‑то головушка. Просто следует помнить, что use‑after‑free магическим образом не вылечится, а собственный аллокатор не помешает команде стабильно клепать баги с записью за конец буфера, да и плотная хешмапа не сделает гонку по данным менее болезненной, и вообще писать обычные ошибки это так по‑человечески.

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

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

Для рендера, физики, микшера и job system это оправданно, но тащить её в редактор, импортёр FBX, launcher и всю игровую логику уже за гранью добра и зла, и больше похоже на религиозный культ, от которой проект больше терял, чем получал. В Остине построили свой маленький и злой ++C внутри большого и пушистого С++, и этот внутренний язык запрещал худшие по времени пути программы, но требовал шумных API, собственных контейнеров и постоянного обучения новых разработчиков, так что время кадра покупалось временем жизни программиста.

Для девяти проектов из десяти такое надругательство над языком окажется ненужным, дорогим и откровенно вредным, зато оставшемуся одному она поможет выпустить игру, которая лагает незаметно для вас, как сказал бы доктор Кармак — «все лагают».

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

Пошёл бы я ещё раз в проект с арканутым C++? Скорее нет, ибо UE в умелых руках работает не сильно медленнее, но экономит сотню‑другую часов своей обычностью... но с другой стороны, где еще увидишь насколько далеко может зайти студия в погоне за перфом.

А что бы делать правильный С++ приходите на вебинар PVS. Обсудим известные идиомы.