CG & C++ blog
57 subscribers
13 photos
2 files
129 links
Краткий обзор публикаций, презентаций, докладов по графике и C++
Download Telegram
(video) CppCon 2016: Timur Doumler “Want fast C++? Know your hardware!"
Рекомендации по оптимизации, ничего нового, но презентация хорошая.
21:52 - немного про префетчер.
23:00 - ассоциативность кэша, объясняет почему происходит резкое падение производительности при чтении с определенным шагом (256, 512 байт). Ссылается на статью Gallery of Processor Cache Effects.
28:18 - выравнивание данных.
34:20 - предсказание ветвлений.
44:10 - 'false sharing', частые синхронизации кэшей между ядрами ЦП.
48:10 - зависимость между данными в цикле.
#cpu_opt
Ghostwire Tokyo: DLSS vs. TSR vs. FSR
Сравнение разных техник сглаживания и апскейлинга.
На скриншотах много тонких линий, что создает проблемы для реконструкции.
Но нехватает тестов в динамике на мелкие движущиеся объекты, DLSS с этим плохо справляется и оставляет шлейфы.
#AA
Как у меня сделана проверка на корректность синхронизаций.

Для этого я написал логгер команд, который выдает читаемый лог вызовов Vulkan комманд и результат не меняется в зависимости от запусков, что позволяет следить за изменениями. Но все синхронизации придется один раз вручную проверить на корректность.
Пример лога
Исходники тут

Другой вариант - запустить vkconfig и включить полную валидацию синхронизаций - Synchronization preset.
Guide to Vulkan Synchronization Validation

Либо из кода через расширение VK_EXT_validation_features включить VK_VALIDATION_FEATURE_ENABLE_SYNCHRONIZATION_VALIDATION_EXT.
Так как VK_EXT_validation_features - расширение слоя валидации, то оно не указано в vkEnumerateInstanceExtensionProperties.

Пару лет назад я тестировал открытые движки и фреймворки на правильность синхронизаций и слой валидации легко находил ошибки. Из чего я сделал вывод, что никто не трогал настройки слоев валидации. Только мой FG автоматически расставлял синхронизации без ошибок.
#blog #vk
Еще в прошлом году планировал написать большую статью про архитектуру асинхронного движка и игры на нем, но сама архитектура часто корректировалась, так как у меня просто не было опыта в подобном.
Сейчас в планах выкладывать части статьи в виде коротких заметок, из чего потом соберу большую статью.
#blog
Профилирование.

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

С другой стороны без визуализации сложно понять что происходит внутри кадра и в каком порядке выполняются таски. При этом обычный профайлер начинает показывать время работы планировщика тасков, что замусоривает результаты, особенно на демках, где нет большой нагрузки на потоки.
#blog #engine
Запись команд на Vulkan и Metal.

В Vulkan все просто - есть буфер команд, он заполняется и отправляется на ГП.
В Metal немного сложнее - запись команд идет через энкодеры, переключение между которыми не бесплатное.
Каждый энкодер записывает команды, которые могут выполняться параллельно с другими энкодерами, это решает внутренний фреймграф.

У меня в движке за основу взяты энкодеры из Metal, получились контексты: DrawCtx, GraphicsCtx, ComputeCtx, TransferCtx, ASBuildCtx, RayTracingCtx.
Для Vulkan это также полезно, так как на мобильных архитектурах переключения `graphics -> compute -> graphic`s работают неоптимально и их надо избегать, также разные контексты содержат независимые друг от друга этапы, а значит они могут выполняться параллельно.

DrawCtx - только команды рисования, работает для одного сабпасса, соответствует MTLRenderCommandEncoder.
GraphicsCtx - только для манипуляций с рендер пассами, для Metal это вызовы MTLCommandBuffer::renderCommandEncoder и MTLCommandBuffer::parallelRenderCommandEncoder.
TransferCtx - команды копирования, соответствует MTLBlitCommandEncoder и MTLResourceStateCommandEncoder.
ASBuildCtx - построение и копирование ускоряющих структур, сответствует MTLAccelerationStructureCommandEncoder, для Vulkan все выполняется на этапе VK_PIPELINE_STAGE_2_ACCELERATION_STRUCTURE_BUILD_BIT_KHR.
RayTracingCtx - только команды трассировки, нет аналогов на Metal. Для Vulkan это этап VK_PIPELINE_STAGE_2_RAY_TRACING_SHADER_BIT_KHR и VK_PIPELINE_BIND_POINT_RAY_TRACING_KHR для пайплайнов и дескрипторов.

Контексты можно использовать только внутри таска RenderTask, последовательность выглядит так:
1. Создается массив командных буферов - CommandBatch.
2. Создается RenderTask, для него выделяется уникальный индекс в массиве команд.
3. Внутри таска создается контекст, он принимает ссылку на таск.
4. Записываются команды.
5. В конце, командный буфер передается в CommandBatch.
6. Когда все командные буферы записаны и переданны в CommandBatch, они отправляются на ГП (queue submit).

Аналогично работает параллельное рисование через DrawTask и DrawCommandBatch.

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

Интерфейсы контекстов тут
#blog #engine
Низкоуровневый рендер в движке.

При планировании архитектуры был выбор из двух вариантов:
1. Привязка к кадрам. Все команды отправляются для конкретного кадра, даже async compute не может выполняться несколько кадров - при двойной буферизации 2й кадр будет ждать завершения всех команд. Но это дает и преимущества - кадр более предсказуемый, что дает и более стабильное время кадра.
2. Без привязки к кадрам. Это позволяет делать долгие асинхронные вычисления или копирования, но делает поведение менее предсказуемым.

Я выбрал вариант с привязкой к кадрам, таким образом:
* Ресурсы не удаляются сразу, а с задержкой в 2 кадра.
* Используется общий staging buffer, выделенная память гарантированно валидна в пределах кадра, это упростило работу с память. Например в при чтении из видеопамяти в ITransferContext::ReadbackImage ().
* Ограничен максимальный размер staging buffer на кадр, так чтобы все данные успели передаться по шине PCI-E за время кадра. Таким образом ГП не простаивает в ожидании данных с ЦП и время кадра более стабильное.

RenderTask - используется для ассинхронной записи командного буфера.
DrawTask - используется для ассинхронной записи вторичного командного буфера.
CommmandBatch - хранит масив командных буферов и семафоров для синхронизации с другими батчами и с ЦП. Аналогичен одному вызову vkQueueSubmit.
DrawCommandBatch - хранит массив вторичных командных буферов, которые затем выполняются в IGraphicsContext::ExecuteSecondary().
RenderTaskScheduler - переключает кадры и управляет батчами команд: создает их и отправляет на ГП, когда они заполнятся.
RenderGraph - сделан поверх предыдущих классов, добавляет отслеживание состояния ресурсов и синхронизациями между батчами.

#blog #engine
Синхронизации без рендерграфа.

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

Первым проходом должно быть обновление данных на ГП.
Синхронизация Host_Write -> CopySrc|IndexBuffer|VertexBuffer|UniformBuffer нужна при записи данных на стороне ЦП.
Все копирования и обновление юниформ должно быть в этом проходе, чтоб избавиться от лишних синхронизаций в других проходах, зачем это нужно я объяснял тут (п.3).

Далее идут проходы рисования и вычислений.
Предполагается, что все неизменяемые ресурсы уже находятся в том состоянии, в котором они используются в дескрипторах.
Остаются синхронизации для изменяемых ресурсов (Attachment, StorageImage, StorageBuffer), их легко отслеживать и синхронизировать вручную, для проверки корректности есть способы.

Синхронизации между очередями.
Есть 2 подхода:
1. Сделать ресурсы общими для всех очередей (VK_SHARING_MODE_CONCURRENT), тогда достаточно сделать синхронизации семафорами, чтобы избежать одновременной записи или чтения и записи (data race), в движке это делается через CommandBatch::AddInputDependency (CommandBatch &). Минус этого подхода - на AMD на общих ресурсах не включается компресия рендер таргетов, что снижает производительность.
пример: Async compute + shared resources

2. Явно передавать ресурсы между очередями (queue ownership transfer). Внутри рендер таска это сложнее отслеживать, поэтому такие барьеры удобнее вынести в интерфейс CommandBatch, так появился метод CommandBatch::DeferredBarriers() и initial, final параметры при создании рендер таска. Теперь управление перемещением ресурсов происходит на этапе планирования батчей команд.
пример: Async compute + queue ownership transfer

#blog #engine
(video) Optimizing Binary Search - Sergey Slotin - CppCon 2022

3:50 - вариант с ветвлением, как в stl. Так было сделано для совместимости со всеми итераторами.
8:30 - детально разбирается почему ветвления это плохо.
12:29 - вариант без ветвлений, работает в 2 раза быстрее на небольших массивах.

Для большинства этого должно хватить, а дальше начинается магия оптимизаций.
#cpp #cpu_opt
RDNA3 Instruction Set Architecture

* (1.2.2.1) Локальный кэш (LDS) содержит 64 атомика для быстрого доступа в преелах work-group.
* (1.2.2.2) Глобальный кэш (GDS) содержит 2 атомика для быстрого доступа.
* (2.1) wave64 (subgroupSize=64) вызывает все инструкции дважды.
* (2.3) CU и WGP режимы работы для work-group. Один WGP может содержать waves с обоими режимами работы, видимо это нужно для лучшей производительности графики при комбинации вершинного шейдера и меш шейдеров.
* (6.2) Некоторые константы заданы специальными константами, например: 0.5, 1.0, 2.0, 4.0, 1.0/(2*Pi).
* (7.9) WMMA инструкция для умножения матриц fp16 16х16.
* (10.4) Есть поддержка 16 битных деривативов. Есть 64 битные атомарные операции над текстурами.

#amd_gpu
Синхронизации с помощью рендерграфа.

Реализация более простая чем в FG, тут не поддерживается перестановка команд, но и лучше чем в DE, так как совместим с многопоточностью.

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

Этап планирования.
Точно также создаются батчи, но теперь через builder паттерн, где можно указать какие ресурсы будут использоваться в батче и их начальное/конечное состояние. Если ресурс используется только в одной ГП-очереди (VkQueue), то указывать его не обязательно. Но если ресурс используется в нескольких очередях, то требуется явно добавить его в батч, тогда внутри вставятся все необхрдимые синхронизации.
Для каждого рендер таска также можно указать начальное и конечное состояние ресурса, это позволит оптимизировать синхронизации между тасками.

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

пример: async compute + RG
#blog #engine
Презентация с кратким обзором большинства алгоритмов компьютерной графики:
https://www.cl.cam.ac.uk/teaching/1718/AdvGraph/Printable%20(6-up).pdf
Есть все: трассировка, маршинг, SDF, матрицы, АА, постобработка, VR.
Выравнивание данных в Vulkan/Metal.

В Vulkan все выравнивания надо получать в рантайме и под каждый девайс.
В Metal выравнивание указано в документации и привязано к GPUFamily, которое дополнительно разделяется на Common/Apple/Mac со своими требованиями к выравниванию.

В движке выравнивания вынесены в DeviceProperties для использования в рантайме и DeviceLimits для использования в компайлтайме.

DeviceLimits содержит максимальное выравнивание для большинства ГП. В Metal все выравнивания - константы, поэтому тут просто, а для Vulkan эти значения проверяются через vulkan.gpuinfo, а также при старте движка сравниваются с текущими выравниваниями.
Это позволяет на этапе компиляции ресурсов и кода рассчитать размер буфера или задать все смещения константами, что упрощает проверки и дает гарантии, что код будет одинаково работать на разных устройствах.
А еще это дает микрооптимизации на вычисление и доступ к памяти, для тех, кто считает такты, но в некоторых случаях расплачиваться приходится большим расходом памяти на ГП.

#blog #engine
Часто вижу в примерах по Vulkan код вида:

GetMemoryTypeIndex( mem_req.memoryTypeBits, DEVICE_LOCAL );
Это пошло с первых примеров, тогда на дискретных ГП были такие типы памяти: DEVICE_LOCAL, HOST_COHERENT, HOST_CACHED.
Но с тех времен кое-что изменилось - добавили DEVICE_LOCAL | HOST_COHERENT память для прямого доступа со стороны ЦП, обычно эта память ограничена в 256 Мб, что достаточно для юниформ буферов. Но эта же память может использоваться для staging буферов и всех DEVICE_LOCAL буферов, если этот тип памяти окажется в списке раньше других. То есть сейчас все работает как раньше только потому, что новый тип памяти добавили в конец списка и перебор до него не доходит.

Правильнее передавать больше флагов:

uint GetMemoryTypeIndex (uint memoryTypeBits, VkMemoryPropertyFlags includeFlags, VkMemoryPropertyFlags optFlags, VkMemoryPropertyFlags excludeFlags)
Пример использования:

GetMemoryTypeIndex( mem_req.memoryTypeBits, include: HOST_VISIBLE, opt: HOST_COHERENT, exclude: DEVICE_LOCAL );
Возвращает:
1. HOST_VISIBLE | HOST_COHERENT - с optFlags.
2. HOST_VISIBLE | HOST_CACHED - без optFlags.

#blog #vk
Проверка ошибок.

CHECK_ERR( expr, [opt] result ) - разворачивается в if ( not (expr) ) return result.

CHECK_TE( expr ) - работает только внутри AsyncTask::Run(), помечает таск как отмененный и выходит из функции. Такой же результат даст и исключение внутри таска.

CHECK_PE( expr ) - используется внутри Promise<> и возвращает специальное значение CancelPromise, которое помечает промис/таск как отмененный и у зависящих от него промисов выполнится функция, переданная в Promise::Except( fn ).

CHECK_CE( expr ) - используется внутри корутин (coroutine) и также помечает корутину/таск как отмененный.

CHECK_THROW( expr, [opt] exception ) - разворачивается в if ( not (expr) ) throw exception. Чаще всего используется чтобы прокинуть исключение в скрипты.

CATCH_ERR( code, [opt] result ) - возвращает 'result', если 'code' бросает исключение. Используется для оборачивания stl вызовов, которые могут бросить исключение.

Метка [opt] означает опциональный параметр, по-умолчанию возвращается false или пустой объект {}.

Все проверки можно было заменить одной CHECK_THROW, но исключения дают меньшую производительность, что не так важно, так как случается редко. Также хочется оставить возможность отказаться от исключений, поэтому используются специализированные макросы. Например wasm по-умолчанию выключает исключения при -O1 и выше для наилучшей производительности.

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

В Vulkan один из распространенных форматов на мониторе - RGBA16F + VK_COLOR_SPACE_EXTENDED_SRGB_LINEAR.
В документации про него нет информации, но удалось найти аналогичный формат в EGL, для которого есть документация: EGL_EXT_gl_colorspace_scrgb_linear.
В нем сказано, что этот формат обратно совместим с sRGB на диапазоне (0,1), но также позволяет использовать и значения более 1.
Цвет (1.0, 1.0, 1.0) соответствует яркости в 80 нит, что очень мало, зато доступен диапазаон до 125.0, что будет соответствовать 10 000 нит.

У меня монитор на 1000 нит и экспериментально выянилось, что цвета сохраняются до 12.0-13.0, после чего плавно уходят в белый цвет. Чисто белый появляется после 500.0.
Какие преимущества это дает:
* намного больший диапазон цветов.
* точное управление яркостью, то есть 1000 нит в игре будет в реальности светить в игрока на 1000 нит.
* можно отказаться от тонемапинга, если не использовать значения за пределами 500.0 или если нужно выводить физически корректную картинку.
Есть и минусы:
* нужно знать яркость монитора для правильного тонемапинга или экспозиции.
* много разных HDR форматов и под каждый нужно настраивать рендер.
* скриншоты от сторонних программ чиают только диапазон (0,1).

Поддержка в игрых и программах.
За 2 года пользования монитором я ни разу не смог воспользоваться HDR режимом. В играх этот режим помечался как неподдерживаемый, возможно больше рассчитывали на HDR TV под консоли, где другой режим.
Только в Doom Enternal есть возможность включить HDR, но картинка была в диапазоне (0,1), а это очень тусклые цвета, то есть они это не тестировали.
Без HDR режима монитор сам подстраивал яркость, что давало хорошую имитацию HDR.
#HDR
Фото монитора.
Выводится чистый красный цвет от 0 до 1000.
UI выводится в диапазоне (0,1) и выглядит очень тускло.
Формат: RGBA16F + VK_COLOR_SPACE_EXTENDED_SRGB_LINEAR.
#HDR
HDR color grading and display in Frostbite
Как переделали Frostbite для поддержки множества HDR дисплеев.
#HDR
Vulkanised 2023
Выложили видео с конференции.
Доклады выглядят узкоспециализированно, поэтому пользы не много.
#news