Профилирование.
Тут все сильно отличается, с одной стороны разбивая код на таски я получил возможность легко написать свой профайлер, достаточно вставить замеры до и после вызова таска. Также каждый таск подписан, что упрощает привязку данных профайлера к коду, не нужно вставлять макросы в функции или использовать VTune API.
С другой стороны без визуализации сложно понять что происходит внутри кадра и в каком порядке выполняются таски. При этом обычный профайлер начинает показывать время работы планировщика тасков, что замусоривает результаты, особенно на демках, где нет большой нагрузки на потоки.
#blog #engine
Тут все сильно отличается, с одной стороны разбивая код на таски я получил возможность легко написать свой профайлер, достаточно вставить замеры до и после вызова таска. Также каждый таск подписан, что упрощает привязку данных профайлера к коду, не нужно вставлять макросы в функции или использовать VTune API.
С другой стороны без визуализации сложно понять что происходит внутри кадра и в каком порядке выполняются таски. При этом обычный профайлер начинает показывать время работы планировщика тасков, что замусоривает результаты, особенно на демках, где нет большой нагрузки на потоки.
#blog #engine
Запись команд на Vulkan и Metal.
В Vulkan все просто - есть буфер команд, он заполняется и отправляется на ГП.
В Metal немного сложнее - запись команд идет через энкодеры, переключение между которыми не бесплатное.
Каждый энкодер записывает команды, которые могут выполняться параллельно с другими энкодерами, это решает внутренний фреймграф.
У меня в движке за основу взяты энкодеры из Metal, получились контексты: DrawCtx, GraphicsCtx, ComputeCtx, TransferCtx, ASBuildCtx, RayTracingCtx.
Для Vulkan это также полезно, так как на мобильных архитектурах переключения `graphics -> compute -> graphic`s работают неоптимально и их надо избегать, также разные контексты содержат независимые друг от друга этапы, а значит они могут выполняться параллельно.
Контексты можно использовать только внутри таска
1. Создается массив командных буферов -
2. Создается
3. Внутри таска создается контекст, он принимает ссылку на таск.
4. Записываются команды.
5. В конце, командный буфер передается в
6. Когда все командные буферы записаны и переданны в
Аналогично работает параллельное рисование через
Софтварный командный буфер.
В Vulkan и Metal командный буфер может использоваться только в одном потоке. Если не ограничивать количество потоков рендера, то может создаться много командных буферов, и это особенно накладно, если записывается всего несколько команд. Намного удобнее записать команды в память и в одном потоке записать их в нативный командный буфер.
Интерфейсы контекстов тут
#blog #engine
В 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, выделенная память гарантированно валидна в пределах кадра, это упростило работу с память. Например в при чтении из видеопамяти в
* Ограничен максимальный размер staging buffer на кадр, так чтобы все данные успели передаться по шине PCI-E за время кадра. Таким образом ГП не простаивает в ожидании данных с ЦП и время кадра более стабильное.
#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
Синхронизации без рендерграфа.
Когда рендер кадра состоит из заранее известных проходов, то управлять состояниями ресурсов можно и вручную.
Первым проходом должно быть обновление данных на ГП.
Синхронизация
Все копирования и обновление юниформ должно быть в этом проходе, чтоб избавиться от лишних синхронизаций в других проходах, зачем это нужно я объяснял тут (п.3).
Далее идут проходы рисования и вычислений.
Предполагается, что все неизменяемые ресурсы уже находятся в том состоянии, в котором они используются в дескрипторах.
Остаются синхронизации для изменяемых ресурсов (Attachment, StorageImage, StorageBuffer), их легко отслеживать и синхронизировать вручную, для проверки корректности есть способы.
Синхронизации между очередями.
Есть 2 подхода:
1. Сделать ресурсы общими для всех очередей (VK_SHARING_MODE_CONCURRENT), тогда достаточно сделать синхронизации семафорами, чтобы избежать одновременной записи или чтения и записи (data race), в движке это делается через
пример: Async compute + shared resources
2. Явно передавать ресурсы между очередями (queue ownership transfer). Внутри рендер таска это сложнее отслеживать, поэтому такие барьеры удобнее вынести в интерфейс
пример: Async compute + queue ownership transfer
#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
Синхронизации с помощью рендерграфа.
Реализация более простая чем в FG, тут не поддерживается перестановка команд, но и лучше чем в DE, так как совместим с многопоточностью.
Контекст для записи команд.
Работает поверх существующих контекстов, о которых уже рассказывал. Добавленно только отслеживание состояний ресурсов в пределах
Этап планирования.
Точно также создаются батчи, но теперь через builder паттерн, где можно указать какие ресурсы будут использоваться в батче и их начальное/конечное состояние. Если ресурс используется только в одной ГП-очереди (VkQueue), то указывать его не обязательно. Но если ресурс используется в нескольких очередях, то требуется явно добавить его в батч, тогда внутри вставятся все необхрдимые синхронизации.
Для каждого рендер таска также можно указать начальное и конечное состояние ресурса, это позволит оптимизировать синхронизации между тасками.
Данный рендерграф нужен для прототипирования и в случаях, когда проходы рендера задаются в более высокоуровневом коде, например в скриптах. В таких случаях потери от использования рендерграфа не так важны как скорость разработки.
пример: async compute + RG
#blog #engine
Реализация более простая чем в FG, тут не поддерживается перестановка команд, но и лучше чем в DE, так как совместим с многопоточностью.
Контекст для записи команд.
Работает поверх существующих контекстов, о которых уже рассказывал. Добавленно только отслеживание состояний ресурсов в пределах
RenderTask
и автоматическое перемещение их в нужное состояние. Начальное и конечное состояние ресурса это либо дефолтное как в FG, либо задается вручную на этапе планирования рендер графа, иногда это добавляет ненужные синхронизации, но потери на них минимальные, если не приводят к декомпресии рендер таргетов.Этап планирования.
Точно также создаются батчи, но теперь через builder паттерн, где можно указать какие ресурсы будут использоваться в батче и их начальное/конечное состояние. Если ресурс используется только в одной ГП-очереди (VkQueue), то указывать его не обязательно. Но если ресурс используется в нескольких очередях, то требуется явно добавить его в батч, тогда внутри вставятся все необхрдимые синхронизации.
Для каждого рендер таска также можно указать начальное и конечное состояние ресурса, это позволит оптимизировать синхронизации между тасками.
Данный рендерграф нужен для прототипирования и в случаях, когда проходы рендера задаются в более высокоуровневом коде, например в скриптах. В таких случаях потери от использования рендерграфа не так важны как скорость разработки.
пример: async compute + RG
#blog #engine
Выравнивание данных в Vulkan/Metal.
В Vulkan все выравнивания надо получать в рантайме и под каждый девайс.
В Metal выравнивание указано в документации и привязано к GPUFamily, которое дополнительно разделяется на Common/Apple/Mac со своими требованиями к выравниванию.
В движке выравнивания вынесены в DeviceProperties для использования в рантайме и
Это позволяет на этапе компиляции ресурсов и кода рассчитать размер буфера или задать все смещения константами, что упрощает проверки и дает гарантии, что код будет одинаково работать на разных устройствах.
А еще это дает микрооптимизации на вычисление и доступ к памяти, для тех, кто считает такты, но в некоторых случаях расплачиваться приходится большим расходом памяти на ГП.
#blog #engine
В Vulkan все выравнивания надо получать в рантайме и под каждый девайс.
В Metal выравнивание указано в документации и привязано к GPUFamily, которое дополнительно разделяется на Common/Apple/Mac со своими требованиями к выравниванию.
В движке выравнивания вынесены в DeviceProperties для использования в рантайме и
DeviceLimits
для использования в компайлтайме. DeviceLimits
содержит максимальное выравнивание для большинства ГП. В Metal все выравнивания - константы, поэтому тут просто, а для Vulkan эти значения проверяются через vulkan.gpuinfo, а также при старте движка сравниваются с текущими выравниваниями.Это позволяет на этапе компиляции ресурсов и кода рассчитать размер буфера или задать все смещения константами, что упрощает проверки и дает гарантии, что код будет одинаково работать на разных устройствах.
А еще это дает микрооптимизации на вычисление и доступ к памяти, для тех, кто считает такты, но в некоторых случаях расплачиваться приходится большим расходом памяти на ГП.
#blog #engine
Проверка ошибок.
Метка
Все проверки можно было заменить одной
В итоге, даже в сложных случаях, таких как промисы и корутины, удалось сделать проверку ошибок и корректный выход из функции без использования исключений.
#blog #engine
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
По документации Vulkan в некоторых случаях содержимое ресурсов может быть потеряно:
* Барьер с
* Ресурс с
* Аттачменты рендер пасса с
* Несколько ресурсов используют одну память, но только один ресурс содержит валидные данные.
* Неиспользуемые аттачменты в сабпасе рендерпасса, если не используется как
Это нужно чтобы драйвер выбирал более быстрое поведение, например сжатый рендер таргет (delta color compression) при смене лейаута на
На тайловых архитектурах
Таким образом легко допустить ошибку и продолжить использовать содержимое текстуры, тогда как на другом драйвере или железе поведение изменится.
Для этих случаев у меня есть самописный слой валидации, который принудительно чистит содержимое ресурсов случайными значениями, чтобы поведение было одинаковым на всех системах.
#blog #vk #engine
* Барьер с
oldLayout = UNDEFINED
.* Ресурс с
exclusive sharing
используется в разных очередях без queue ownership transfer
(нужно всегда явно передавать ресурс из одной очереди в другую, что иногда неудобно).* Аттачменты рендер пасса с
loadOp = DONT_CARE
и storeOp = DONT_CARE
.* Несколько ресурсов используют одну память, но только один ресурс содержит валидные данные.
* Неиспользуемые аттачменты в сабпасе рендерпасса, если не используется как
preserve attachment
.Это нужно чтобы драйвер выбирал более быстрое поведение, например сжатый рендер таргет (delta color compression) при смене лейаута на
GENERAL
тратит дополнительное время на расжатие, а oldLayout = UNDEFINED
позволяет это пропустить, потеряв содержимое текстуры, но, например, при такой смене лейаутов: UNDEFINED (было SHADER_READ) -> SHADER_READ
, содержимое не изменится.На тайловых архитектурах
loadOp = DONT_CARE
и storeOp = DONT_CARE
позволяют не загружать данные из глобальной памяти в кэш тайла, но на других архитектурах данные никуда не загружаются, поэтому содержимое сохраняется.Таким образом легко допустить ошибку и продолжить использовать содержимое текстуры, тогда как на другом драйвере или железе поведение изменится.
Для этих случаев у меня есть самописный слой валидации, который принудительно чистит содержимое ресурсов случайными значениями, чтобы поведение было одинаковым на всех системах.
#blog #vk #engine