Для тюнинга C#-кода, который, как известно, выполняется на CPU, существует достаточно много инструментов профилирования, например можно использовать NProf, он распространяется свободно.
Мы рассмотрим случай, когда появляется необходимость профилировать именно GPU, а не CPU. Тут ситуация немного сложнее, особенно если вас интересует результат на XBox.
Для работы нам понадобится –
1. Более-менее точный счетчик кадров. Можно использовать тот, что был рассмотрен в предыдущей статье.
2. Базовые знания принципов работы GPU. Главный принцип тут, пожалуй, это то, что GPU и CPU работают параллельно.
Самый доступный способ выявления мест «затыков» быстродействия состоит в выборочном комментировании участков «рисующего» кода вашей игры. Если закомментированный код после перекомпиляции и запуска достаточно сильно повлиял на счетчик FPS, показав ощутимый прирост, вероятнее всего этот участок и есть проблемное место.
Всякий раз, когда CPU встречает инструкцию отрисовки примитивов (Draw***Primitives(…)), никакого рисования реально не происходит. Единственное что произойдет, так это то, что очередная инструкция будет добавлена в буфер команд для последующей отправки на GPU. Вы можете проверить это, используя любой CPU профайлер. Посмотрите, сколько процессорного времени займет выполнение инструкции отрисовки одного миллиона примитивов, а затем сравните это со временем «отрисовки» всего десяти примитивов. Разница будет равна погрешности таймера вашего профайлера.
Тот же результат будет и для всех других инструкций связанных с GPU (очистка кадра, установка рендерстейтов и текстур, смена вершинных и индексных буферов…)
Во время работы метода device.Present() (он будет вызван из метода EndDraw() класса Game) – все накопившиеся в буфере инструкции отправляются на GPU для выполнения. Перед отправкой, все GPU инструкции транслируются в его формат.
Таким образом, когда ваша игра заканчивает формировать первый кадр и переходит ко второму, GPU только начинает свою работу. На рисунке видно что «реальная отрисовка» первого кадра будет произведена во время формирования второго…
Эта картинка показывает идеально настроенный цикл рендера, когда время формирования кадра на CPU равно времени его отрисовки на GPU. В реальном же мире такое достаточно трудно осуществимо и очень часто формирование кадра на CPU занимает больше времени, чем его последующая отрисовка на GPU. Т.е. GPU часть времени простаивает, ожидая, когда закончится формирование очередного кадра для него. Так же часто бывает и обратная ситуация, когда простаивает уже CPU, дожидаясь, когда будет отрисован предыдущий кадр…
На этом изображении представлена ситуация простоя GPU. Эта ситуация еще называется "CPU bound". Что означает – ограниченность, зависимость от CPU. Видеокарта способна на большее, а процессор настолько занят, что не успевает «загрузить» все её мощности.
Есть интересный момент по поводу данной ситуации – если мы начнем оптимизировать отрисовку, т.е. работу GPU то на FPS это никак не повлияет. После оптимизации, GPU начнет тратить меньше времени на свою работу и больше времени на ожидание…
Настоящего увеличения быстродействия тут можно добиться, оптимизируя работу именно CPU. Если же показания FPS вас устраивают, можно попробовать еще загрузить GPU, стараясь при этом не трогать CPU. Это даст большее количество графических эффектов практически бесплатно.
Теперь рассмотрим "GPU bound" ситуацию. Процессор способен на большее, а видеокарта от перегрузки не успевает вовремя дать сигнал о том, что отрисовка предыдущего кадра закончена. CPU вынужден простаивать, так как он не может отправить новые команды для GPU пока тот не освободится.
В этой ситуации всё наоборот по сравнению с предыдущей. Оптимизация работы CPU никак не повлияет на показания FPS, ограничивающим фактором является видео, а не проц. Если FPS вас устраивает то можно забесплатно добавить, например больше физики или АИ в вашу игру.
Рассмотрев все вышеизложенное становиться ясно, что для успешного увеличения FPS нужно сначала разобраться в какой ситуации мы находимся – «CPU bound» или «GPU bound». При незнании этого можно впустую потратить много времени оптимизируя «не тот» процессор.
Как уже говорилось, по завершении формирования кадра все инструкции преобразуются в понятный для GPU формат, а затем отправляются ему для выполнения. Если у вас всего пара десятков инструкций которые рисуют пару сотен тысяч примитивов, то эта ситуация будет очень легка для выполнения CPU но трудна для отрисовки на GPU. Время на трансляцию инструкций тоже идет в зачет и тут оно значительно меньше времени реального их выполнения. С другой стороны, если инструкций достаточно много, например одна тысяча, но каждая такая инструкция рисует всего десяток примитивов, то GPU отрисует их существенно быстрее, чем CPU сможет их транслировать.
Небольшой пример -
Даже если ваш игровой цикл не содержит ничего кроме отрисовки ста тысяч примитивов, при этом, сами примитивы статичны, нет никакой физики или АИ, нет вообще никаких других расчетов на CPU. Кроме того, процесс отрисовки примитивов происходит всего по 10 штук за раз, т.е. тут мы получим 10 тысяч вызовов Draw***Primitives(,,, 10) – ваш FPS будет необоснованно низким.
Судя по разным источникам, хорошей практикой является отрисовка 1000 примитивов за один раз. Это называется батчинг (batch – группа, пакет) – способ достижения баланса между временем трансляции GPU инструкций и временем их выполнения.
Таким образом, игровой цикл нашего примера должен содержать 100 дипов (DrawIndexedPrimitives(..)) по одной тысяче примитивов каждый. Добиться этого можно путем слияния мелких групп примитивов в более крупные группы.
Детали трансляции инструкций в GPU формат.
Вот что, по сути, происходит внутри игрового цикла:
Ваш Draw метод порождает дипы, каждый из которых сохраняется в буфере
Метод Draw заканчивается
XNA (Game.EndDraw()) вызывает метод GraphicsDevice.Present
Далее происходит вызов натив метода IDirect3DDevice::Present
DirectX рантайм преобразует все накопившиеся инструкции и вызывает драйвер
Драйвер видеокарты транслирует их в еще более низкоуровневый формат понятный для GPU
Используя CPU профайлер, можно узнать следующее:
Время выполнения метода MyGame.Update
Время выполнения метода MyGame.Draw
Время выполнения метода GraphicsDevice.Present
Как мы уже говорили, в методе Present выполняется трансляция инструкций и замерив время выполнения этого метода мы могли бы увидеть насколько сложен для CPU процесс трансляции инструкций текущего кадра. Однако, время выполнения Present это не только трансляция, но и кое-что еще…
Вот некоторые причины из за которых Present может оказаться медленным:
Если ваша игра является GPU bound, Present будет ждать пока GPU завершит предыдущий кадр
Если у вас включена вертикальная синхронизация (GraphicsDeviceManager.SynchronizeWithVerticalRetrace), Present будет ждать обратного хода луча монитора
Получается, что узнать что то ценное из времени выполнения Present - достаточно трудно. Позже мы еще вернемся к этому вопросу…
Есть еще одна невидимая на первый взгляд причина, почему Present может иногда не соответствовать тому количеству инструкций, которое было получено в результате работы метода Draw. Внутренний буфер команд для GPU имеет ограниченный размер, и если он полностью заполняется, происходит немедленная конвертация инструкций и дальнейшая их передача. Тут происходит то же самое, что и в Present методе, если GPU свободен все пойдет гладко, если занят - ваш метод Draw «замрет» посередине своей работы. Однако, Present будет выполнять уже меньший объем работ…
Предположим что, кроме всего прочего, ваш цикл отрисовки еще и меняет рендерстейты раз эдак под 1000 за кадр. В таком случае может возникнуть ситуация заполнения буфера команд, и замерив время переключения первой 1000-чи рендерстейтов вы можете удивиться почему 1001-ое переключение выполнялось в разы дольше…
Производители видеокарт стараются оптимизировать свои драйверы. Однако, такая оптимизация чаще всего влияет только на CPU bound игры, так как основные потери в драйвере это трансляция в самый низкоуровневый GPU формат, за которую отвечает центральный процессор. Т.е. более оптимальные драйвера снимают часть нагрузки на главный проц. Производительность же самого GPU не так сильно зависит от драйвера как от архитектуры и возможностей видеосистемы.
Так как на рынке присутствуют видеокарты разных производителей, у каждого из которых свой драйвер, то с уверенностью можно предположить, что одни из них более подходят для CPU bound игр, из за того что у них более оптимизированный драйвер, а другие более подходят для GPU bound игр, из за того что их GPU инструкции более низкоуровневые, подобные инструкции GPU любит больше всего, отчего и сам работает быстрее, но зато оптимизировать такие трансляции становиться сложнее. В общем, создание хорошо сбалансированных игр, работающих одинаково быстро на любом железе – дело веселое…
Как определить, во что упирается быстродействие вашей игры, в GPU или в CPU…
Сначала убедитесь, что в конструкторе главного класса вашей игры есть такой код -
1
2
3
4 graphics = new GraphicsDeviceManager(this);
this.IsFixedTimeStep=false;
graphics.SynchronizeWithVerticalRetrace = false;
Выделенные строки дадут возможность счетчику FPS работать более точно.
Теперь, запустив тестируемую игру под профайлером можно увидеть время выполнения трех основных методов игрового цикла - Update, Draw и Present. Время выполнения Draw и Present само по себе мало о чем может сказать, но вот если у вас на Update тратится больше чем на все остальное – скорее всего ваша игра будет CPU bound, слишком много ресурсов уходит на обновление состояния ваших игровых объектов.
Что произойдет с вашим FPS если вставить нижеприведенный код в метод Update?
Thread.Sleep(1);
Изменился ли счетчик кадров? Если нет, ваша игра подходит под категорию GPU bound. Усыпляя главный поток игрового цикла на 1 мс, мы делаем искусственную паузу для CPU, и если эта пауза никак не влияет на FPS, значит проц и так простаивал, ожидая GPU…
Теперь, постепенно увеличивая время засыпания, до тех пор, пока FPS не меняется, можно определить, сколько свободных миллисекунд у вас есть, эти же миллисекунды будут и показателем перегруженности GPU…
Если усыпление основного потока все же меняет FPS, возможны две ситуации – CPU bound или игра хорошо сбалансирована. Попробуем разобраться точнее…
Простейший способ уменьшить нагрузку на CPU это полностью пропустить выполнение метода Update, особенно хорошо это работает когда Update достаточно «тяжелый». Попробуйте добавить нижеприведенный код в Update:
Код
protected override void Update(GameTime gameTime)
{
if (currentKeyboardState.IsKeyDown(Keys.PageUp))
Thread.Sleep(1);
if (currentKeyboardState.IsKeyDown(Keys.PageDown))
return;
....
}
Запустив игру и выбрав момент когда FPS особенно низок, можно нажимая клавиши PageUp и PageDown, соответственно усыплять CPU и/или освобождать его от выполнения Update, полностью пропуская последний.
Если усыпление CPU не меняет FPS скорее всего ваша игра - GPU bound.
Если освобождение от Update увеличивает FPS очевидно, что игра ваша - CPU bound.
Если же, пропуск Update не несет эффекта, но при этом усыпление проца замедляет FPS, вероятнее всего ваша игра неплохо сбалансирована.
Немного подробнее про внутреннее устройство GPU
Современные графические процессоры содержат в себе достаточно большое количество параллельно работающих блоков, все переданные на вход GPU инструкции разделяются на категории, каждую из которых может отдельно обрабатывать специально выделенный для этого блок.
Попробуем точнее разобраться, в чем именно может быть причина "GPU bound" игр.
Каждый блок GPU отвечает за свою стадию процессинга инструкций и данных, есть семь основных типов блоков, каждый тип блока может иметь несколько одинаковых экземпляров, каждый из которых так же может работать параллельно (например известно, что NVidia 7600 GT имеет 5 блоков вершинных шейдеров и 12 пиксельных…)
Примерно так выглядит диаграмма блоков процессора GeForce 7600 GT ...
Основные типы блоков:
Блок формирования вершин - vertex fetch (читает вершины из памяти)
Блок вершинных шейдеров - vertex shader (обрабатывает полученные вершины)
Блок растеризации - rasterizer (формирует полигоны на экране)
Блок пиксельных шейдеров - pixel shader (вычисляет цвет каждого пикселя полученных полигонов)
Блок чтения текстур - texture fetch (извлекает отдельные пиксели из текстур переданных в шейдер)
Блок проверки глубины - depth/stencil (обновляет буфер глубины и трафарета)
Блок финальной композиции - framebuffer (хранит конечный цвет каждого пикселя, позволяя альфа блендинг)
Любой из этих блоков может замедлять работу всех остальных. Знать какой именно блок перегружен нам необходимо для выполнения более тонкой оптимизации. Здесь действуют те же самые принципы, что и при оптимизации CPU/GPU, ведь большинство блоков GPU работают параллельно еще и между собой. Предположим, слабое место у нас это вершинный шейдер (слишком тяжелый), в таком случае оптимизация работы других блоков ничего не даст, и смотреть нужно именно на вершинный шейдер.
Какие факторы влияют на быстродействие каждого из блоков?
vertex fetch (чтение вершин)
общее количество вершин
размер каждой вершины
очередность вершин сильно влияет на количество кеш промахов
vertex shader (обработка вершин)
количество вершин
число инструкций у вершинного шейдера
очередность индексов вершин так же влияет на количество кеш промахов
rasterizer (растеризация)
число отрисовываемых пикселей
число аргументов, передаваемых из вершинного в пиксельный шейдер, которые необходимо интерполировать (текстурные координаты, например)
pixel shader (обработка пикселей)
количество пикселей
число инструкций у пиксельного шейдера
texture fetch (чтение текстур)
размер читаемой текстуры (разрешение)
число запросов к ней
размер пикселей текстуры (формат текстуры)
текстуры с mipmap уровнями гораздо реже создают кеш промахи
текстуры форматов DXT* имеют меньший размер, чем остальные
тип фильтрации
анизотропная фильтрация самая дорогая
трилинейная фильтрация немного медленнее билинейной
билинейная и точечная фильтрация практически равнозначны
depth/stencil (буфер глубины и трафарета)
размер буфера
любой вид мультисемплинга заметно все замедляет
режим буфера «только чтение» будет быстрее режима «чтение/запись»
framebuffer (буфер финальной композиции)
размер буфера (разрешение)
любой режим мультисемплинга так же заметно все замедляет
формат буфера (размер каждого пикселя)
режим «чтение/запись», используемый при альфаблендинге будет заметно медленнее режима «только чтение» когда все полигоны не содержат прозрачности
Для определения проблематичного блока, нужно найти способ максимально разгрузить каждый из блоков по очереди, при этом стараясь не менять нагрузку основного CPU.
Попробуйте запустить вашу игру в низком разрешении, скажем 100x50. Это не меняет нагрузку на CPU, на блок чтения вершин и на блок вершинных шейдеров.
Если уменьшение разрешения не повлияло на FPS, значит ограничивающий фактор у вас не связан с блоками работающими на уровне пикселей, под подозрением остаются блоки процессинга вершин (чтение и обработка). Попробуйте уменьшить количество полигонов в ваших моделях или упростите вершинный шейдер. Кроме этого может помочь использование упакованных векторов в элементах вершин. Например, для текстурных координат часто бывает достаточно точности HalfVector2 вместо обычного Vector2. Так же существуют утилиты, позволяющие оптимизировать порядок верши и индексов для геометрии, это даст намного меньше кеш промахов при их отрисовке. Встроенный в XNA контент пайплайн сам выполняет такую оптимизацию.
Идем далее, если уменьшение разрешения финального кадра увеличивает FPS - очевидно, что проблематичными являются блоки работающие с пикселями, а не с вершинами. Попробуйте установить значение SamplerStates[n].MipMapLevelOfDetailBias равным 4 или 5. При использовании текстур с мипмапами (а делать это желательно всегда), этот трюк немного изменит алгоритм выборки нужного мип уровня, вы увидите что текстуры ваши стали более размытыми, разрешение мип уровней текстур в разы меньше оригинальной текстуры, а более мелкие текстуры позволяют экономить на количестве гоняемых по шине данных (блок чтения текстур). Если размытые текстуры показывают прирост в FPS, значит вы ограничены полосой пропускания данных для текстур (texture fetch). Тут можно попробовать использовать текстуры в формате DXT* или самим уменьшить их размер и общее количество (задача скорее для текстурщика чем для программиста).
Следующий шаг, попробуйте изменить ваш пиксельный шейдер, оставив в нем всего одну строку, возвращающую какой-либо константный цвет. Это повлияет на два блока – чтение текстур и обработка пикселей. Та как с блоком чтения текстур мы более-менее разобрались в предыдущем параграфе, можно предположить, что увеличение FPS на этом шаге, при отсутствии результата с трюком для мип уровней, будет свидетельствовать об ограниченности именно в блоке пиксельных шейдеров.
Шаг за шагом мы могли полностью или частично исключить из подозрений некоторые блоки GPU. Что у нас осталось? Блок растеризации, блок буфера глубины и блок финальной композиции.
Попробуйте включить режим мультисемплинга, если FPS не изменился, слабое место – блок растеризации (достаточно редко бывает).
Далее, если у вас буфер финального кадра имеет формат больше чем 16 бит на пиксель, попробуйте использовать более легкий формат (если конечно ваши графические эффекты это позволяют), например SurfaceFormat.Bgr565. Если скорость возросла, значит вы ограничены скоростью чтения/записи фреймбуфера. Для оптимизации - смотрите вышеперечисленные факторы, влияющие на скорость работы этого блока, возможно у вас слишком навороченный альфаблендинг.
Единственное что у нас осталось – блок буфера глубины и трафарета, если не один из вышеперечисленных блоков не указал на слабое место скорости работы вашей игры, значит, остается только этот блок…
Еще пара деталей про GPU.
Что бы достичь максимально синхронной параллельной работы различных блоков GPU, необходимо понимать, что данные между блоками передаются небольшими частями, причем, если при передаче от CPU к GPU данные приходят целиком, то уже внутри GPU они будут разделены на более мелкие части. Между каждым внутренним блоком GPU есть буфер, в котором аккумулируются транзитные данные, причем эти буферы заметно меньше чем в случае транзита от CPU к GPU. Размера этих внутренних промежуточных буферов хватает лишь на несколько сотен вершин или пикселей.
На практике это означает что по мере отрисовки финального кадра, проблематичные блоки могут меняться, в зависимости от обрабатываемых данных. Например, при отрисовке ландшафта, скорее всего, все будет упирается в блок чтения текстур (ландшафт у вас содержит несколько слоев текстур высокого разрешения), а во время отрисовки скин анимированных персонажей, вероятно, что слабым местом будут блоки вершинных шейдеров, ну и на финальном этапе, во время пост процессинга, при использовании bloom эффекта, все будет упираться в блоки пиксельных шейдеров. Беда в том, что это только предположения …
Это один из самых важных моментов мешающих достижению баланса между GPU и CPU. В предыдущем примере мы разделили весь наш «рисующий» код на три более-менее независимые части. Но мы не можем так просто узнать, чем ограничена каждая из этих частей, так как нам будет проблематично изолировать работу этих частей от остальных. К примеру, для изучения скорости отрисовки ландшафта, мы могли бы закомментировать части кода рисующие все остальное (персонажи и постпроцессинг), что на первый взгляд, позволит нам изучить производительность ландшафта отдельно. Но, неприятность в том, что после таких изменений вашего метода Draw, нужно корректно изменить и метод Update, иначе «не рисуемые» персонажи, которые все еще обновляются в методе Update, приведут к тому, что игра из GPU bound станет CPU bound. Так как баланс нарушен. А в CPU bound играх очень сложно оптимизировать работу GPU, ведь видео перестает быть ограничивающим фактором…
Гораздо лучшей техникой для такой ситуации, будет не комментирование частей лишнего для нас кода, а наоборот - добавление в цикл, нужной нам части кода. Поместите код отрисовки ландшафта в цикл из ста итераций, FPS резко упадет, если же нет, значит ваш ландшафт занимает ничтожную часть всего времени финального кадра. Теперь, если скорость упала, мы можем быть полностью уверены, что она практически напрямую зависит от ландшафта. Этот трюк позволяет искусственно занизить значимость не интересных нам частей сцены, и мы можем использовать техники исследования и увеличения производительности, рассмотренные нами ранее в главе про устройство GPU.
Одно замечание – под кодом отрисовки ландшафта понимается именно код отрисовки, если ваш ландшафт каким-либо образом обновляет себя перед отрисовкой, например geomipmapping, отделите обновление от рисования, и в цикл помещайте только рисование, иначе баланс GPU-CPU опять будет нарушен. Наша задача тут - искусственно перегрузить GPU по возможности не трогая CPU…
Предположим, после всех манипуляций, нам удалось узнать сколько времени занимает отрисовка каждой части сцены по отдельности, но потом, когда мы все снова соединим вместе, мы можем заметить, что реальное время отрисовки сцены, не равно сумме времён всех её частей. Такое происходит потому, что многие типы блоков GPU работают параллельно с другими типами блоков. Финальное время кадра, чаще всего меньше суммарного. К примеру, из 10 персонажей, которые нам нужно нарисовать на сцене, последние девять могут зависеть от блока вершинных шейдеров, отрисовка первого персонажа тоже имеет такую зависимость, но вследствие того, что персонажи у нас рисуются после ландшафта, вершины первого из них начнут обрабатываться, когда еще будут дорисовываться последние несколько сотен пикселей ландшафта (Помните про внутренние транзитные буферы между блоками GPU?) и зависимость от вершинных шейдеров у первого персонажа не будет так сильно влиять на общее быстродействие. Это дает экономию финального времени кадра.
В заключение хочется привести пример из реальной жизни. Одна команда программистов, работающая над своей игрой, постоянно замечала необоснованно низкий уровень FPS. Главная сцена их игры содержала достаточно детализированный ландшафт, отрисовка которого и занимала большую часть времени. Для исправления ситуации, они разработали свою хитрую систему определения видимости. Эта система разделяла весь ландшафт на сектора и помечала те из них, что попадали в камеру. Затем рисовались только видимые части, причем для этих частей еще и рассчитывался уровень детализации, что помогало разгрузить GPU.
Но, как потом оказалось, беда была в том, что их игра относилась к категории CPU bound…
Им порекомендовали отключить систему определения видимости и рисовать весь ландшафт целиком как один большой меш. Это заметно увеличило FPS.
Увидев, что потенциал роста FPS еще есть, они решили, что раз отключение их системы видимости дало такой положительный результат, то она и является слабым местом. Все дружно взялись её еще больше оптимизировать, придумывая новые алгоритмы определения видимости и детализации, от чего код этой системы не становился проще, хотя, заметно сильнее разгрузить GPU им все же удалось. В конечном итоге они получили более низкий FPS, чем был у них при использовании самой первой версии этой системы.
В чём была их ошибка? Они не учли тот факт что GPU и CPU работают параллельно. Для оптимизации GPU, они усложнили систему видимости, что увеличило нагрузку на CPU при расчетах, плюс отрисовка ландшафта небольшими частями давала большее количество трансляций инструкций в драйвере. В итоге, результат оптимизации был отрицательный, ведь финальная скорость у них зависела вовсе не от GPU…