Grabduck

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

:

image

Речь пойдёт о далёком 2005 году, когда только-только вышла Civilization4 от Sid Meier. К тому времени я плотно висел в Civilization3, прошёл её раз дцать на самых разных картах, и тут вышла долгожданная четвёрка. Это были годы P3-512Mb для mid-end и P4-1Gb в hi-end. Только топовые конфиги в те годы имели два гига памяти на борту.

Civilization 4 вышла с графикой уровня года 2002-2003го, что в принципе нормально для мэинстрима тех времён, особенно учитывая что это пошаговая стратегия, а не шутер. Но жрала с течением игры до 900Mb оперативки, что приводило к жуткому свопу, особенно на больших картах, особенно к концу игры, особенно на ноутбуках. Народ недоумевал, я тоже. Учитывая, что в те же годы вышел Far Cry с куда более красивой графикой, и который вполне игрался на максимуме даже с 512Mb на борту, такое поведение Civilization 4 выглядело крайне странным. Захотелось разобраться и покарать…

Итак, я начал ковыряться. Первое подозрение пало на Python, т.к. Firaxis упоминала о его использовании как важной фиче на каждом шагу, а там и сборщики мусора, и неосвобождение памяти после пиковых нагрузок, и много разного веселья происходит. Это был кандидат номер один на обвинение в прожорливости. Был скачан исходник питоновской dll, в ней было добавлено логирование всех выделений памяти, dll была подсунута цивилизации вместо родной и… ничего интересного не проявилось. На питон уходило дай бог 25Mb памяти. Что-то другое драконовски поедало ресурсы.

image

Закралась идея утечки памяти в обычных C-шных выделялках памяти. Так как использовалась CRT (C runtime library) в DLL, был повешен ala- detours хук на все вызовы malloc, realloc, free и… тоже с нулевым результатом. Следом подумалось, что причиной всему фрагментация памяти на частых перевыделениях, может даже по вине питона. Написал свой менеджер, выделяющий всегда объём, кратный степени 2 — без толку. Цивилизация как ела по 800Mb, так и продролжала их жрать. Я немного впал в ступор — куда это девается столько памяти.

Повесил API хуки на все VirtualAlloc, с вычислениям CS:EIP caller'а, чтобы узнать civ4.exe или python24.dll выделяет б ольшую часть адресного пространства. И тут выяснилось, что съедает её d3d9.dll. Это уже стало интересней, с чего бы это ему жрать ресурсы, если всё (или почти всё) должно лежать в видеопамяти. После этого я начал хучить DirectX'овые вызовы — начиная от создания девайса, и вплоть до создания текстур, вершинных и индексных буферов.

При изучении, что и как делает Civilization4 выяснилось, что некоторую часть ресурсов она держит в D3DPOOL_DEFAULT (позже выяснилось что это были ресурсы графического интерфейса, написанного сторонней компанией Scaleform, эти ребята сейчас неплохо развились, т.к. их продуктами пользуются даже чуваки из CryTek). Всё остальное Civilization4 хранила в MANAGED пуле, оно и жрало по 500mb памяти.

Небольшое отступление по поводу MANAGED и DEFAULT пула для ресурсов в DirectX. Видеопамять может использоваться сразу несколькими приложениями, и наличие или отсутствие в ней нужной текстуры или вершинного/индексного буфера является критичным для способности что-то нарисовать. В случае DEFAULT пула в случае вытеснения видеоресурсов одного процесса другим, видеопамять тупо затирается, а последующие операции отрисовки с использованием потерянной области возвращают ошибку, мол «объект утерян». Реакцией на такую потерю должно быть восстановление объекта путём перечитывания текстуры с диска и пересозданием текстуры в видеопамяти.

В случае MANAGED пула механизм примерно такой же, но все текстуры и остальные ресурсы кэшируются DirectX'ом в оперативной памяти, делая реакцию на ошибки прозрачной — он сам восстановит копию в видеопамяти из оперативки в случае её затёртости другими программами, по аналогии как для обычной памяти «бэкапом» выступает своп-файл. Это упрощает жизнь программисту, так как ему больше не надо заботиться о перечитывании видеообъектов во время цикла отрисовки, но неправданно увеличивает потребление оперативной памяти, особенно когда игра б ольшую часть времени находится на переднем плане, ни с кем её не делит и если видеопамяти хватает с избытком.

Аналогия с обычной памятью и жёстким диском, к сожалению, до конца не работает. Объём жёсткого диска обычно раз в 100 больше объёма набортной памяти, поэтому нет ничего страшного в том, чтобы размер своп-файла равнялся объёму RAM. Соотношение же RAM и VIDEOMEM обычно порядка 4/1, а не 100/1. Поэтому MANAGED — это не продакшн решение, а скорее режим для ленивых, уместный для мелочей, которые много не весят. Если пихать всё подряд в MANAGED, будет как на картинке:

image

Все серьёзные движки работают с DEFAULT пулом, используя перечитывание объектов с диска или в крайнем случае используя своё кэширование в оперативной памяти, но только не MANAGED пул. Собственно, способность корректно обрабатывать ситуацию потери ресурсов при работе с DEFAULT пулом и является причиной (не)дружелбности игр к Alt-Tab.

Сначала через хуки на создание объектов я попробовал перевести всё в DEFAULT пул. Картинка посыпалась. Сначала не понял, в чём дело, потом сразу дошло… естественно 500Mb ни в какую видеопамять не влезет (в те годы топовые видяшки имели по 256Mb на борту). Стало понятно, почему они заюзали MANAGED пул — в видеопамять не влазили даже собственные графические объекты, даже не говоря о конкуренции за видеопамять с другими процессами при alt-tab!

Так же стало понятно почему своп никогда не утихал. В случае избытка видеопамяти managed часть из оперативки постепенно съехала бы в своп, вытесняясь реально используемыми страницами, и тормоза бы исчезли. Hо этого не происходило, потому что к этому managed кэшу постоянно шло обращение. В общем, понятно было всё. Коме одного. Far Cry хватало 512Mb RAM и 128Mb video без какого-то жуткого свопа. И это с той графикой! Civ4, если её перенести в наше время, имела графику уровня 2005го года при требовании 5Gb оперативки.

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

Проверка текстур привела к очередной неожиданности. Их было что-то около 50Mb на low и что-то около 120Mb на high. Куда же делись остальные 400mb? Начинаю логировать вообще всё что вызывается через Direct3D и нахожу… 400 мегабайт vertex buffer'ов! Vertex buffer — это данные о геометрии, трёхмерные модели юнитов, городов и ландшафта. В голову начинают лезть нехорошие мысли, что они прорисовали анимацию всех юнитов покадрово, и ничего с этим не сделать… для успокоения совести сортирую потребление памяти vertex buffer'ами по FVF (формат вершины — что в ней есть, а чего нет, к примеру координаты, освещённость, привязка к текстурам и т.п.). Находится несколько разновидностей вершинных буферов, все они много не едят, кроме одного, который жрёт аж 280Mb.

Как выяснить что это за буфер? При заполнении данных что текстур, что вершинных буферов делается сначала Lock, потом идёт заполнение данных, потом делается unlock. Туда и впаиваюсь — в unlock. Перед анлоком подмешиваю случайные величины в кооррдинаты вершин, и наблюдаю что поменялось. Ожидаю что у солдат начнут дёргаться руки-ноги. А вот хрен… земля поплыла! И тут на меня снисходит прозрение, почему оно тормозит к концу игры и чем больше карта, тем тормоза сильнее. Дело вовсе не в количестве разновидностей юнитов на карте. Дело в количестве обозримых тайлов карты! Ландшафт для каждой клетки игрового мира учитывает все близлежащие горы, реки, моря, типы поверхности (песок, трава, снег), и, таким образом, получается что на каждую клетку ландшафта влияют несколько соседних, что и затрудняет подготовить заранее все возможные геометрические конфигурации тайлов.

image

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

В качестве хэша была выбрано представление данных vertex buffer'а по основанию 5 на исходных байтах как цифрах. Это хорошо перемешало входные данные без коллизий, потому что 5 — простое число и не степень двойки и потому что умножение на 5 в процессоре реализуется как LEA EAX, [EAX*4+EAX], что шустро считается. Сам хэш был воткнут на тот же Unlock() vertex buffer'а. При unlock'е искался идентичный vertex buffer в хэшируемом кэше, и если он находился, данные анлокнутого буфера удалялись, а при рисовании через DrawIndexedPrimitive вместо моего IDirect3DVertexBuffer, который я подсовывал civ4.exe, использовался реальный буфер с закэшированными данными, один на все подобные буфера.

Пол дня я эту корягу писал из C++ шаблонов вперемешку с ассемблерными вставками и перехватом COM вызовов через detours хуки (патч vtbl почему-то не сработал), потом запустил… Вах! — потребление памяти снизилось с 800Mb до 300-400Mb! По меркам 2010го года — это равносильно снижению потребления памяти с 4х гигов до полутора.

Я наконец-то смог завершить начатую партию, не допивая чашку чая в течение каждого хода из-за дикого свопа. Выложил патч на civfanatics.com, народ был в восторге ( link). 150k скачиваний в первые дни только с civfanatics, и патч был перевыложен на самых разных fan сайтах. Что я тогда испытал — словами тяжело описать… это был кайф! наконец-то удалось исправить эту серьёзную проблему, без сорса, да еще и являющуюся не банальной утечкой памяти, а серьёзным архитектурным изъяном. Firaxis несколько месяцев не мог повторить сей подвих, даже с собственным кодом на руках. Публичная порка за lame coding удалась на славу.

image

P.S: Как обстоят дела в Civilization 5, не смотрел.

P.P.S: Пока проходил StarCraft 2, не мог не сравнивать графику / потребление-памяти с Crysis. К счастью для моего сна и, возможно, для Blizzard, в ноутбуке оказалось 4Gb оперативки. Поэтому неоправданное поедание памяти SC2 вызвало недоумение, а не своп. Попадись он мне на 2Gb железке, пришлось бы взяться за старое :)