Для некоторых оптимизаций требуются сложные структуры данных и тысячи строк кода. В других же случаях серьёзный прирост производительности даёт минимальное изменение: иногда нужно лишь поставить ноль. Это похоже на старую байку о котельщике, который знает правильное место для удара молотком, а потом выставляет клиенту счёт: $0,50 за удар по клапану и $999,50 за знание, куда бить.
Я лично встречал несколько ошибок производительности, которые исправлялись вводом одного нуля, и в этой статье хочу поделиться двумя историями.
Важность измерения
Во времена оригинальной Xbox я помог оптимизировать множество игр. В одной из них профилировщик указал на функцию матричного преобразования, которая потребляла 7% времени CPU — самый большой скачок на графике. Поэтому я прилежно приступил к работе по оптимизации этой функции.
Видно, что я не первый пытался это сделать. Функцию уже переписали на ассемблере. Я нашёл несколько потенциальных улучшений в языке ассемблера и попытался измерить их эффект. Это важный шаг, иначе легко заняться «оптимизацией», которая ничего не изменит или даже ухудшит ситуацию.
Однако измерение оказалось затруднено. Я запускал игру, немного играл с параллельным профилированием, а затем изучал профиль: стал ли код быстрее. Казалось, что есть какое-то небольшое улучшение, но нельзя было сказать наверняка.
Так что я применил научный метод. Написал коллекцию тестов для управления старой и новой версиями кода, чтобы точно измерить различия в производительности. Это не заняло много времени: как и ожидалось, новый код оказался примерно на 10% быстрее старого.
Но выясмнилось, что 10% ускорения — это ерунда.
Гораздо интереснее, что внутри теста код выполнялся примерно в 10 раз быстрее, чем в игре. Вот это было захватывающее открытие.
После проверки результатов я некоторое время смотрел в пустоту, но потом меня осенило.
Роль кэширования
Чтобы дать разработчикам игр полный контроль и максимальную производительность, игровые приставки позволяют выделять память с различными атрибутами. В частности, оригинальный Xbox позволяет выделять некэшируемую память. Этот тип памяти (фактически, тип тега в таблицах страниц) полезен при записи данных для GPU. Поскольку память не кэшируется, запись почти сразу пойдёт в RAM без задержек и загрязнения кэша при «нормальном» мэппинге.
Таким образом, некэшируемая память — важная оптимизация, но её следует использовать осторожно. В частности, крайне важно, чтобы игры никогда не пытались читать из некэшируемой памяти, иначе их производительность серьёзно снизится. Даже относительно медленному CPU на 733 МГц в оригинальном Xbox нужны свои кэши, чтобы обеспечить достаточную производительность при чтении данных.
Теперь становится понятно, что происходит. Судя по всему для этой функции данные выделяются в некэшируемой памяти, отсюда низкая производительность. Небольшая проверка подтвердила эту гипотезу, так что пришло время исправить проблему. Я нашёл строку, где выделяется память, дважды щёлкнул по значению флага, и указал ноль.
Вместо примерно 7% процессорного времени функция стала потреблять около 0,7% и больше не представляла проблемы.
По итогам недели мой отчёт выглядел примерно так: «39,999 часа исследований, 0,001 часа программирования — огромный успех!»
Разработчикам обычно не нужно беспокоиться о случайном выделении некэшируемой памяти: в большинстве операционных систем эта опция не доступна в пользовательском пространстве стандартными методами. Но если вам интересно, насколько некэшируемая память способна замедлить работу программы, попробуйте флаги PAGE_NOCACHE или PAGE_WRITECOMBINE в VirtualAlloc.
0 ГиБ лучше, чем 4 ГиБ
Хочу рассказать вам ещё одну историю. Она о баге, который нашёл я, а исправил кто-то другой. Пару лет назад я заметил, что дисковый кэш на моём ноутбуке слишком часто прочищается. Я отследил, что это происходит при достижении рубежа 4 ГиБ, и в итоге оказалось, что драйвер моего нового HDD для бэкапов устанавливает SectorSize в 0xFFFFFFFF (или −1) при указании на неизвестный размер сектора. Ядро Windows интерпретирует это значение как 4 ГиБ и выделяет соответствующий блок памяти, что и стало причиной проблемы.
У меня нет контактов в Western Digital, но можно с уверенностью предположить, что они исправили эту ошибку, заменив константу 0xFFFFFFFF (или −1) на ноль. Один введённый символ — и решена серьёзная проблема производительности.
(Подробнее об этом исследовании читайте в статье «Замедление Windows: изучение и идентификация»)
Наблюдения
- В обоих случаях проблема связана с кэшированием
- Решающим стало использование профилировщика для точного определения проблемы
- Если патч не проверен измерениями, то он не обязательно поможет
- Я мог бы написать о многих других таких случаях, но они либо слишком секретны, либо слишком скучны
- Правильное решение не обязательно должно быть сложным. Иногда огромное улучшение даёт небольшое изменение. Нужно только знать, в каком месте
Мне случалось оптимизировать код, расскоментив #define и путём других тривиальных изменений. Расскажите в комментариях, если у вас есть такие истории.
Автор: m1rko