Меня всегда удивляло как разработчики умудряются размещать большой объем вычислений на относительно слабом железе, к каким трюкам и решениям прибегают, чтобы приложение работало быстро, это относится не только к игровым движкам, но и базам данных, системам управления и т.д., но так как моя область это все же игры и игровые движки, то рассказывать я буду про них. Особенно заметна эта разница была при портировании относительно свежих игр (поколение ps3+) на всякие портативные консоли вроде Nintendo Switch, Apple TV (это девайс тоже считается неплохой платформой, в плане что там есть платящая аудитория) и мобилки. И свитч и appletv по производительности не сильно далеко ушли от третьей плойки, и попытки перенести требовательные игры, рассчитанные как минимум на следующее (ps4) поколение консолей, приводят к значительным проблемам, которые непросто решаются. Игры это достаточно требовательный софт, зачастую с мягким реалтаймом, надо же выдавать приемлимый фпс - иначе играть будет больно, некомфортно и её никто не купит. Небольшим подспорьем при переносе на портативки и мобилки является их стабильное железо, хотя вот для мобилок я бы так не сказал, там целый зоопарк процов, видях и окружения. На консолях с этим все получше и спеки меняются раз в пару лет. Когда речь заходит о портировании игры - оптимизации можно разделить на несколько уровней: архитектура, алгоритмы и код.
Оптимизации на уровне архитектуры (~50% прироста)
Этот уровень отвечает за общую структуру игры и движка, его компоненты и взаимодействие между ними. Если архитектура изначально спроектирована неоптимально, это приводит к серьезным проблемам с производительностью на слабых устройствах. Неверный выбор системы управления памятью (читай кривые пулы и много копирования), избыточное количество алокаций или неправильная организация тредов (столлы и частые смены контекста выполнения) создают узкие и трудночинимые места, которые практически невозможно исправить на более поздних этапах разработки, не говоря уже об этапах постразработки - вроде портирования. И решенение проблем на этом уровне дает самый ощутимый буст перфа, но обычно для этого надо переписать полдвижка.Оптимизации на уровне алгоритмов и структур данных (~30%)
На этом уровне решается, какие алгоритмы и структуры данных использовать для выполнения конкретных задач. Хороший (это не значит самый быстрый или академический) или правильно подобранный алгоритм может значительно ускорить работу всей игры. Например, замена линейного поиска на бинарный поиск, использование хеш-таблиц вместо списков или применение структур данных с оптимизированной сложностью доступа (например, деревья поиска) может дать огромный прирост производительности. Но есть и обратные примеры, простой линейный поиск по массивам, которые укладываются в кеш проца значительно быстрее, чем использование хеш-мап, и даже самых оптимизированых но сложных решений.Оптимизации на уровне исходного кода + асм (~10%)
Это уже касается конкретных реализаций в коде, где микро-оптимизации направлены на время выполнения отдельных блоков кода или даже единичных операций, но чаще на использование памяти и доступ к данным. Хорошим примером тут будет устранение аллокаций памяти где это только возможно, разворачивание циклов, если компилятор «нешмогла» и много чего другого. В основном этот этап заключается в запуске профайлера и последующем долгом анализе колстеков, в попытке починить, то что уже сломано настолько, что стало видно в профайлере.Все остальное (~10%)
Что можно сказать об этой части? Обычно она включает в себя нормальные сборочные пайплайны, когда билд попадает к QA через час после комита и возвращается к автору через два если были ошибки. Кастомные инструменты отладки и игровые реплеи - современные инструменыт игровой разработки (BT - behavior tree, VS - visual scripts, blueprints, AS - animation scripts) очень сложно дебажить из под отладчика, а баги с ними связанные в 99% случаях не воспроизводятся, хотя конечно зависит от профессионализма QA. Эти улучшения могут не оказывать прямого влияния на производительность игры во время её работы, но способствуют более быстрому выявлению и устранению проблем, а когда разработчик не чинит баг, он делает фичу, т.е. непосредственно наносит добро проекту.
Программные игровые реплеи пожалуй лучшее средство отладки багов, что я видел за время разработки, это как time travel debugging, но не требует поддержки со стороны компилятора и намного дешевле в разработке.
Но вернемся к портированию игр. Как я уже сказал выше, измеения на уровнях архитектуры и алгоритмов оказывают самое большое влияние на производительность игры по сравнению с оптимизациями на уровне исходного кода. Даже идеально написанный цикл не сможет компенсировать неэффективный алгоритм или неверный выбор структуры данных и прежде чем тратить время на оптимизацию кода, стоит убедиться, что архитектура данных и алгоритм подобраны правильные.
Другой вопрос дадут ли ход таким изменениям? Скорее всего нет, менять архитектуру ни архитект проекта, ни техлид или техдир в 99.9999% не разрешат. Во первых - это не забота программиста, во-вторых такими решениями вы просто отбираете хлеб архитекта, и в-третьих изменяя существующую, работающую и отлаженную систему вы ломаете соседние, и когда эта бага всплывает, а она обязательно всплывет, никто сказать не может. Вот эта последняя причина и не дает пришедшему вчера разработчику переписать половину движка и сделать как правильно :) Остается довольствоваться изменениями на уровне кода-алгоритмов, что однако тоже дает возможность "причинить добро" в размер 10%+ к скорости работы.
Изоляция компонентов
Еще один важный аспект оптимизаций, особенно когда речь заходит о портировании — это изоляция компонентов движка и игры. Если компоненты хорошо изолированы друг от друга, это позволяет изменять их внутреннюю реализацию без влияния на остальную часть системы. Например, вы можете заменить неэффективный алгоритм сортировки на более производительный, не затрагивая интерфейс или вызывающий код. Или форкнуть рендер систему и допиливать её под конкретную консоль, не ломая основную ветку. Изолированные компоненты значительно упрощают как портирование так и саму разработку на всех уровнях - изменения становятся локализованными и менее рискованными.
Микро и мелкие оптимизации
Я говорю о мелких оптимизациях, которые чаще всего относятся к уровню исходного кода и затрагивают относительно небольшие участки программы и которые легко сделать и охватить в рамках одного код-ревью (до 100 изменений и до 10 файлов)
Оптимизация циклов: анализ и замена вложенных циклов на более эффективные.
Поиск и избавление от линейного поиска в коде или циклах
Устранение ненужных вызовов функций в критических местах кода.
Избавление от алокаций памяти в циклах или горячих функциях
Эти небольшие участки кода не будут влиять на перф игры на больших консолях или пк, прирост 1-2% никому не интересен, потому что требует много усилий и времени, но незаметен и соответственно менеджмент считает, что лучше потратить время на разработку фичи. Но на портативках даже такие «небольшие» изменения дают ощутимый прирост производительности, особенно если они применяются в горячих участках кода, которые вызываются многократно. Стоит отметить, что написание правильных и эффективных двух-трех строк кода может потребовать анализа, экспериментов и времени порядка нескольких дней. А еще тщательного профилирования и понимания поведения кэша процессора на конкретном участке логики.
Мифы о низкоуровневых оптимизациях
Существует распространенное заблуждение, что оптимизации на этом уровне требуют использования сложных и труднопонимаемых решений, таких как написание кода на асме, использования роксайнс алгоритмов или поголовного перевода в SIMD реализацию. Хорошо, что на практике это не всегда так и многие оптимизации получаются довольно простыми и интуитивно понятными. Например:
Избыточное копирования данных (передача таких аргументов в функции).
Временные объекты там, где можно прокинуть ссылку или указатель (строки).
Ненужные аллокации и переалокации памяти (строки, массивы, хранение объектов).
Лишние ветвления в "горячих" участках кода (nested if).
Сложный и запутанный лапшекод на 30 экранов (spagetti-logic)
Часто чистый и понятный код сам по себе работает быстро, потому что он проще для компилятора и оптимизатора. Но не всегда.
Дорогая проверка (история из одного проекта)
Уровни игры, где мне довелось участвовать в разработке под мобилки, описывались внутренним форматом, похожим на json, который требовал экранирования некоторых символов. Основная разработка велась на ПК конечно, и там проблем с перфом не было. Игра могла получать внешние изменения в нескольких форматах от разных серверов (json, bjson, xml, kvpatch) плюс пользователи могли сами создавать и делиться уровнями, компоновкой районов и отдельными зданиями (custom yaml). Весь обмен данными шел через сервера игры, которые присылали изменения и обновления в бинарном виде (экономия трафика) и для корректной обработки их необходимо было восстановить в текстовый вид, затем нормализовать до json'a, который уже надо было конвертнуть во внутренний формат, чтобы парсер уровня мог их прочитать. Уровни запрашивались каждый раз с сервера, чтобы пользователи не могли себе начитить деньжат, т.к. часть симуляции проводилось на локальном устройстве. Данные могли приходить в разных форматах из разных систем, и в процессе обработки 4 (четыре!) раза полностью проверялись на необходимость экранирования.
Это я к тому, что архитектурное изменение позволило бы убрать все ненужные действия, описанные ниже, но переписывать сервера, чтобы победить 2 секунды времени на загрузке уровня у конечного пользователя, вам дадут с очень небольшой вероятностью - считай никогда. Это в чистом виде архитектурная бага, которую своевременно не заметил системный архитект и которая протекла на уровень алгоритмов и кода.
В играх вообще очень много текстовых данных, они есть в конфигах, в локализациях, в названиях ресурсов. И строки часто представляются с помощью кавычек "
. Но что происходит, если сама строка содержит кавычки? В таком случае строку нужно экранировать. Например, символ кавычки "
или обратного слэша \
нужно заменить на \"
или \\
. Вы с легкостью напишете код на своем любимом языке для этой проверки. Большинство строк не нуждаются в экранировании. Поэтому полезно иметь возможность быстро проверить, требует ли строка экранирование. Алгоритм очень простой - функция принимает аргумент типа eastl::stringz
с именем v
, в стандарте с++14 еще не было string_view
но кастомные решения для non-zero-terminated строк уже были. Она проходит по каждому символу c
во входной строке v
.
bool need_escaping_symbol(eastl::stringz v) { // stringz -> string_view
for (char c : v) {
if ((uint8_t(c) < 32) | (c == '"') | (c == '\\')) {
return true;
}
}
return false;
}
Внутри цикла сначала используется сравнение (uint8_t(c) < 32)
, которое проверяет, меньше ли ASCII-значение символа 32. Это условие позволяло экранировать управляющие символы (такие как новая строка, табуляция и т.д.), формат имел некоторую специфику от json, поэтому их тоже надо было экранировать для правильной работы парсера.
Затем сравнение (c == '"')
проверяет, является ли символ двойной кавычкой, а (c == '\\')
проверяет, является ли символ обратным слэшем. Если хотя бы одно из этих условий истинно для какого-либо символа, функция возвращает true
, указывая, что символ требует экранирования. Если ни одно из условий не выполняется для всех символов, функция возвращает false
, указывая, что экранирование такой строки не требуется. Просто? Ну не сложно как минимум, и понятно даже джуну. На пк (в симуляции) этот код работал с хорошей скоростью и не требовал никаких изменений. На мобилках же ситуация была печальная, пустой уровень грузился 10+ секунд, из которых 2 секунды уходило на парсинг и применение текстовых конфигов. А с ростом числа объектов на уровне это время становилось все больше и больше и на полностью заполненных зданиями уровнях улетало где-то за 2 минуты на топовых во время разработки iPhone 5s, что конечно никак не могло понравиться QA отделу и пользователям. Пятнадцать секунд загрузки это был максимум на который мы могли рассчитывать для прохождения сертификации в стор.
iPhone 5S |
iPhone 6S |
Simulator |
Native build |
|
---|---|---|---|---|
Empty Level Time (parsing time) |
12s (3s) |
10s (2s) |
3s (0.152s) |
2s (0.112s) |
Full Level Time (parsing time) |
143s (23s) |
92s (16s) |
34s (1s) |
22s (1.199s) |
Тут можно отметить, что эта функция завершает выполнение, если обнаружился символ, требующий экранирования. Проблема такого подхода, что компилятор из-за вот этого внутреннего if
не может эти вычисления векторизовать. И этот ранний выход с условием получается дороже, чем просто пройтись по всей строке без условий.
bool linear_need_escaping_symbol(eastl::stringz v) {
bool b = false;
for (char c : v) {
b |= ((uint8_t(c) < 32) | (c == '"') | (c == '\\'));
}
return b;
}
iPhone 5S |
iPhone 6S |
PC |
|
---|---|---|---|
Naive |
0.055 GB/s |
0.075 Gb/s |
0.650 Gb/s |
No branches |
0.092 Gb/s |
0.112 Gb/s |
0.900 Gb/s |
Неплохо получить немного перфа просто избавившись от if
, но явно недостаточно, чтобы избавиться от назойливых репортов QA отдела. Зато мы можем сделать финт ушами и избавиться от QA арифметических операций и сравнений в коде, для этого надо создать таблицу, которая уже содержит все проверки для каждого возможного значения байта и флаг о том, требуется ли его экранирование или нет. Это требует немного больше усилий, но результат в итоге получился довольно быстрым, для проверки каждого символа достаточно одной загрузки из таблицы.
static constexpr eastl::sarray<uint8_t, 255> vflt_quotable_character =
[]() constexpr {
eastl::sarray<uint8_t, 255> result = {0};
for (int i = 0; i < 32; i++) {
result[i] = 1;
}
result['"'] = 1;
result['\\'] = 1;
return result;
} ();
bool table_need_escaping_symbol(eastl::stringz view) {
uint8_t needs = 0;
for (uint8_t c : view) {
needs |= vflt_quotable_character[c];
}
return needs;
}
iPhone 5S |
iPhone 6S |
PC |
|
---|---|---|---|
Naive |
0.055 GB/s |
0.075 Gb/s |
0.650 Gb/s |
No branches |
0.092 Gb/s |
0.112 Gb/s |
0.900 Gb/s |
Table |
0.215 Gb/s |
0.255 Gb/s |
1.700 Gb/s |
Можем ли мы сделать быстрее?
Да, если использовать SIMD-инструкции, NEON для мобилок или SSE для пк. А так как обычно строки не слишком длинные - 60% строк меньше 64 символов - это ключи, имена ресурсов, флаги и просто короткие строки, то общая идея состоит в том, чтобы загружать данные блоками по 16 байт и выполнять сравнения над этими байтами одновременно. Магии здесь нет, зачем сравнивать по одному символу если можно выполнять N сравнений одновременно, это просто физически должно работать быстрее. А что делать, если у нас меньше N символов или остался хвост? Ну тогда просто вернемся к уже существующему решению, которое и так показывает неплохие результаты. Для SSE решению будет примерно такое, для NEON будет похоже.
inline bool simd_need_escaping_symbol(eastl::stringz view) {
int size = view.size();
if (size < 16) {
return linear_need_escaping_symbol(view);
}
size_t i = 0;
__m128i r = _mm_setzero_si128();
for (; i + 15 < size; i += 16) {
__m128i word = _mm_loadu_si128((const __m128i *)(view.data() + i));
running = _mm_or_si128(r, _mm_cmpeq_epi8(word, _mm_set1_epi8(34)));
running = _mm_or_si128(r, _mm_cmpeq_epi8(word, _mm_set1_epi8(92)));
running = _mm_or_si128(r, _mm_cmpeq_epi8(_mm_subs_epu8(word, _mm_set1_epi8(31)),
_mm_setzero_si128()));
}
if (i < size) {
__m128i word = _mm_loadu_si128((const __m128i *)(view.data() + view.length() - 16));
running = _mm_or_si128(r, _mm_cmpeq_epi8(word, _mm_set1_epi8(34)));
running = _mm_or_si128(r, _mm_cmpeq_epi8(word, _mm_set1_epi8(92)));
running = _mm_or_si128(r, _mm_cmpeq_epi8(_mm_subs_epu8(word, _mm_set1_epi8(31)),
_mm_setzero_si128()));
}
return _mm_movemask_epi8(r) != 0;
}
iPhone 5S |
iPhone 6S |
PC |
|
---|---|---|---|
Naive |
0.055 GB/s |
0.075 Gb/s |
0.650 Gb/s |
No branches |
0.092 Gb/s |
0.112 Gb/s |
0.900 Gb/s |
Table |
0.215 Gb/s |
0.255 Gb/s |
1.700 Gb/s |
SIMD |
0.480 Gb/s |
0.580 Gb/s |
4.200 Gb/s |
iPhone 5S |
iPhone 6S |
Simulator |
Native build |
|
---|---|---|---|---|
Empty Level Time (parsing time) |
10s (3->1s) |
8s (2->0.5s) |
3s (0.152 -> 0.052s) |
2s (0.112->0.032s) |
Full Level Time (parsing time) |
128s (23->8s) |
74s (16->5s) |
33s (1->0.32s) |
21s (1.199->0.219s) |
Получилось очень даже неплохо, эту таску мы закрыли с чистой совестью, наступал новый 2016 год и мы спокойно пошли праздновать свои маленькие победы на копроратив. А рендер тим продолажала упаковывать слонов и что-то там оптимайзить, но это уже совсем другая история.
Всех с наступающим Новым Годом!
Комментарии (24)
Tantrido
21.12.2024 21:30Меня всегда удивляло как разработчики умудряются размещать большой объем вычислений на относительно слабом железе, к каким трюкам и решениям прибегают, чтобы приложение работало бысто, это относится не только к игровым движкам
Nintendo Switch - Постоянная внутренняя флеш-память: 32 ГБ (64 ГБ в Nintendo Switch OLED)
Оперативная память - 4 ГБ (LPDDR4)Это слабое железо?! Вот запихни это всё на БК-0010 с 64КБ памяти из них 32 КБ видеопамять!!! :D Вот что меня удивляло: ВОТ как ТУДА игры запихивали?! Причём не только "Клад", но и 3D игры, когда ты внутри авто и задача не впилиться в дерево! ;)
martyncev
21.12.2024 21:30Ошибаетесь) видеопамяти в БК было 16 кб, где каждый байт кодировал 4 пикселя) последние 16 кб там было ПЗУ с прошивкой и регистрами ввода-вввода)
onets
21.12.2024 21:30Недавно попалось видео, где чувак собрал пентиум 3 + жефорс 4400. Запускал старые игры и показывал фпс.
Так вот там как раз наоборот - жуткая угловатая графика, которая к тому же не выдает 30 фпс (гта 3 например).
https://m.youtube.com/watch?v=bMZh0W3xFGY
Как мы в это вообще играли - не представляю. И как вообще такая убогая графика могла так тормозить. И это еще было пред топовое железо.
Так что, как итог, я бы не сказал, что программисты впихивают невпихуемое в слабое железо. Сейчас железо далеко не слабое.
dyadyaSerezha
21.12.2024 21:30столлы и частые смены контекста выполнения
И как же вы столлы определяли?
dalerank Автор
21.12.2024 21:30Их на самом деле несколько видов, чисто gpu-stall, cpu-gpu, cpu-cpu, memory, let-stall и io-stall. Это ситуация в потоке выполнения когда сpu/gpu ожидает завершения некоторого действия, будь то вычисления или ресурс. Ну как определяли - открывали pix и смотрели глазами, через какое-то время фиксов таких вещей нарабатывается опыт и уже по картинке снапшота выцепляешь проблемные колстеки, дальше разбираешься что мешает.
OlegZH
21.12.2024 21:30А что будет, если применить здесь нейронные сети?
dalerank Автор
21.12.2024 21:30Оффтоп конечно, через какое-то время я стал определять код на ревью от своих джунов и мидлов, который они скомуниздили у чатика. Поначалу отнекивались многие, тогда просил рассказать что код делает, увы люди не всегда даже понимают что им там написали ;( т.е. моя НС натренировалась видеть паттерны другой нс, что забавно конечно
mynameco
21.12.2024 21:30зачем вообще на клиенте проверять данные, если они идут с сервака. и зачем в данных, вообще есть строки, которые нужны для экранирования.
dalerank Автор
21.12.2024 21:30Этот вопрос должен себе был задать архитект, но что-то пошло не так и ошибки архитектуры пришлось решать на уровне кода
feelamee
21.12.2024 21:30как будто проблема вообще в существовании функции.
Зачем вам проверять что ее нужно экранировать? Чтобы потом снова пройтись и сделать это самое экранирование?
Выходит два прохода вместо одного и это изменение вроде не такое масштабное для архитектуры.
Кстати, хотелось бы спросить т.к. не знаю.
Вы сказали что бранч мешает компилятору сделать векторизацию. А потом ручками написали simd. Что значит *векторизация* тогда? Может имеется ввиду то как процессор выполняет инструкции, а не как компилятор их генерит. Надо глянуть асм.
Кстати, интересно как атрибут
[[likely]]
повлиял бы на производительность. Если ли вообще смысл его применять. Я слышал что он просто игнорируется на многих архитектурахdalerank Автор
21.12.2024 21:30Надо всегда думать за рамками текущей задачи, смотреть как она повлияет на общую картину. Если сделать исправления по месту то это усложнит логику работы и замедлит общее время выполнения плюс операции с конфигами это почти всегда копирование памяти, а это дорого, очень дорого для мобилок. Для случая, который описан в баге мы подобрали оптимальный размер блока проверки 256 байт для мобилок и 1к для пк, проверка возвращала место первого вхождения символа для изменения, далее таска уходила в воркер, который занимался уже изменениями во всем конфиге, наложением патча на конфиг уровня и его конвертом в формат движка. К моменту когда заканчивали проверки файлов и загрузку базовых текстур для левела, уже были подготовлены конфиги в формате движка и рендер начинал их грузить. Если все это делать сразу тл получаем лишние секунды работы, когда их можно избежать
boldape
21.12.2024 21:30Я не автор поста, но делал примерно похожие оптимизации для расчета вэйвформ аудио трэков. Компилятор умеет векторизовывать код автоматически, но делает это весьма консервативно - не всегда, не самые последние инструкции и не всегда самые оптимальные, но тем не менее делает. У меня в коде стоит выбор как обрабатывать данные я использую энум вида Vectorization { Auto, Manual } как раз подчеркнуть тот факт что если вы в коде интрисики руками не пишите то код все равно может содержать симд инструкции.
Вот эта разбивка обработки на 2 части это вообще классика любой симд оптимизации, забавно что иногда умные люди забывают про хвост и там потом УБ вылезает. Вторая классическая оптимизация это ручная развертка цикла, но ее в примере нету. Компиляторы стали хороши в этом плане и ручная развертка работает быстрее чем сделанная компилятором реже чем 10 лет назад. Вероятно ее попробовали сделать она ничего не дала и ее отменили т.к. код получается ещё более трудно читаемым и с ещё одним хвостом, а выхлопа нету.
Совсем похожий код я делал симд поиск строк / оптимизацию в таглибе что бы тэги в мпзшках искать очень очень быстро. Главная сложность ссе довольно сильно отличается от неона и один и тот же алгоритм надо писать 2 раза.
LittleAlien
21.12.2024 21:30Если понимать, как работает векторизация, то можно "наивный" код довольно легко дотюнить до векторизуемого состояния. Получается короче интринсиков и совместимо с разными системами команд.
https://gcc.godbolt.org/z/jbrqGKxnx
(хвост проигнорирован для простоты)
AVaTar123
21.12.2024 21:30Наверное, ваш "поиск строк" использует также довольно быструю операцию сравнения. Если так, то может ли он ещё и длину совпадения в байтах от начала сравниваемых областей выдавать? Мне нужно сделать быстрое сравнение областей памяти с выдачей длины в байтах совпадения от их начала. В этом деле нужна помощь.
Deosis
21.12.2024 21:30которые присылали изменения и обновления в бинарном виде (экономия трафика) и для корректной обработки их необходимо было восстановить в текстовый вид, затем нормализовать до json'a, который уже надо было конвертнуть во внутренний формат, чтобы парсер уровня мог их прочитать.
Выглядит как проблема слишком большого количества уровней абстракции.
Насколько эффективнее было бы парсить уровень сразу из бинарных данных, присланных с сервера?
unreal_undead2
21.12.2024 21:30Интереснее вариант, когда изначально всё достаточно оптимально, но разрыв в производительности оригинального и целевого таргетов весьма существенный. Скажем, порт первого Doom под SNES (там ещё у автора не было оригинальных исходников и нормальной документации на чип в картридже, который давал хоть какую то производительность).
Nuflyn
21.12.2024 21:30Что ж... забавно выяснить, что в научных вычислениях люди пришли примерно к тем же выводам относительно перфоманса и его улучшения что и в геймдеве, ну потому что никому не хочется неделю ждать окончания расчета.
codecity
Это в идеальном мире, когда хотя бы более-менее разумно сделано. В реальном - может быть и 3000% и более.
pda0
Ну, если с квадратичной сложности переходить на логарифмическую, то запросто... :)