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

Всем привет! Я Андрей, разработчик 3D-графики в 2ГИС.

Если вы пишете рендер на C++ и у вас есть отдельный RenderThread, то, скорее всего, вы уже сталкивались с подобным: данные грузятся в фоне, а вот создание GPU‑ресурсов всё равно происходит в потоке отрисовки через прямое обращение к графическим API Vulkan, Metal, Direct3D 12. И пока RenderThread в длинном цикле создаёт буферы и текстуры, он не рисует — он стоит, пока не обработает список загруженных ресурсов.

У нас это выглядело так на типичной средней сцене: до появления первого кадра на поток отрисовки приходило 532 текстуры и 2713 буферов, которые удерживали 45 МБ и 20 МБ временной памяти в RAM соответственно. Всё это нужно было доставить до GPU, создать наборы буферов и текстур, далее всё освободить и только потом начинать собирать батчи и рисовать.

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

Как всё работало 

Старая схема выглядела так: в N потоках загрузки формировались данные для GPU, но только как данные в RAM. Дальше всё это уходило в RenderThread, где происходило создание GPU ресурсов через обращение к Metal/Vulkan: 

VertexBuffer 1..VertexBuffer n, IndexBuffer 1..IndexBuffer n, InstanceBuffer 1..InstanceBuffer n, Texture 1..Texture_n, MutableBuffer 1..MutableBuffer n. 

И только после этого — Paint.

При больших сценах и сотнях буферов и текстур итоговое время кадра становилось суммой: время загрузки + время компиляции ресурсов + время собственно рендеринга (Total Time = Loading Time + Compilation Time + Paint Time).

Также возрастала временная память в RAM - для хранения вершин, индексов и данных текстуры.

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

Textures 8 memory_size 1146880 Buffers 21 memory_size 42738

Textures 9 memory_size 1064960 Buffers 47 memory_size 279264

Textures 10 memory_size 1277952 Buffers 123 memory_size 5348

Textures 44 memory_size 3145728 Buffers 160 memory_size 4664524

Textures 68 memory_size 5013504 Buffers 139 memory_size 1334601

Полная статистика до появления первого кадра:

Total Textures 532 memory_size 45072384

Total Buffers 2713 memory_size 20396940

То есть 532 текстуры с временной памятью 45 МБ и 2713 буферов с временной памятью 20 МБ — и всё это до первого кадра. В режиме навигатора эти значения могут быть ещё выше.

Отсюда две ключевые проблемы:

  1. Большое временное выделение памяти в RAM в виде всплеска.

  2. CPU Stall в RenderThread при длинных циклах компиляции буферов и текстур.

Мы задались вопросом: зачем вообще держать такую временную память, если ресурс можно создавать сразу на потоках загрузки?

Подход к решению

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

Новая схема работы: в каждом из Loading Threads создаются и компилируются VertexBuffer, IndexBuffer, Instance Buffer, Texture — там же, где они и загрузились. RenderThread получает уже готовые GPU‑ресурсы и занимается только Paint.

Total Time New = Loading Time + Compilation Time / Num_Threads + Paint Time

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

С таким решением хотелось увидеть следующие результаты:

  1. Уменьшение времени загрузки буферов и текстур: Total Time New < Total Time old.

  2. Отсутствие CPU Stall в RenderThread — теперь он будет только рисовать по большей части.

  3. Отсутствие роста всплесков временной памяти.

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

Реализация для Metal

Для начала мы решили сделать реализацию для Metal, потому что там всё проще. У Metal есть MTLCommandQueue, которая выдаёт command buffer многопоточно. Можно взять command buffer и для текстуры выделить MTLBlitCommandEncoder, который записывает данные, далее быстро освобождаем память при завершении копирования.

Детали реализации:

  1. MTCommandQueue является Thread Safe, поэтому нужен отдельный MTLCommandBuffer для каждого потока для загрузки текстур через MTLBlitCommandEncoder.

  2. Для буферов и текстур нужен свой MTLHeap.

  3. Чтобы не использовать мьютексы, решено хранить MTCommandBuffer и списки MTLHeap для буферов и текстур в специальном RawList c синхронизацией через SpinLock. Реализиция RawList + SpinLock была написана моим коллегой Володей, за что ему большое спасибо.

  4. Сбор MTLHeap текстур и буферов в общий массив в RenderThread — для вызова MTLRenderEncoder::useHeaps(нужно для работы с ArgumentBuffers)

  5. Нужно удалять MTLHeap у которых нулевой usedSize, для переиспользования памяти.

  6. Нужно учесть техническую особенность операционной системы iOS/iPadOS - при уходе устройства в фон, например кратковременное нажатие на кнопку питания, все MTLCommandBuffer’s должны быть закомичены, на эту операцию выделяется ограниченное время. Для решения проблемы по сигналу ухода в фон, сбрасывался флаг разрешения коммита для текущих буферов, для всех закоммиченных буферов производилось ожидание в методе MTLCommandBuffer::waitUntilScheduled. При выходе из фона незакоммиченные ранее буферы отправлялись на исполнение и продолжалась работа.

Так как у нас несколько потоков загрузки и «лейблинга» (работа со спрайтами текстовыми метками), можно создать несколько специальных структур данных, предельное число которых равно количеству ядер центрального процессора. Каждая структура данных содержит свой MTLHeap и хранится в специально разработанном потокобезопасном стеке, реализованном на атомарных переменных. Сам потокобезопасный стек не занимается выделением или освобождением памяти, а вся рутина по аллокации и деаллокации хранимых структур ложится на вызывающий код, поэтому мы называем такие структуры узлами. Если при обращении к стеку извлечь очередной узел не получилось, это значит, что потоку его необходимо создать. Там же при создании узла создаётся и MTLTextureDescriptor, чтобы не аллоцировать объекты несколько раз. После этого выполняем MTLBlitCommandEncoder::copyFromBuffer, и возвращаем узел назад в потокобезопасный стек — и всё, последующий поток может извлечь уже созданный узел для того, чтобы переиспользовать его.

С буферами ситуация аналогичная, только проще — там не нужен CommandBuffer, но пока не сделаем их MTLStorageModePrivate.

Особенности Vulkan

Для Vulkan всё сложнее.

Буферы — просто. Библиотека Vulkan Memory Allocator является Thread Safe для некоторых функций. Достаточно иметь свои списки буферов, используя аналогично Metal RawList + SpinLock, аллоцировать память, создать буфер и скопировать туда данные. Нужно также удалять пустые буферы для переиспользования памяти.

Текстуры — значительно сложнее. Для копирования нужно три этапа:

По аналогии с Metal нужен VkCommandBuffer + VkCommandPool для каждого потока. Но эти операции можно сделать в потоке загрузки только частично/опционально: не на всех устройствах есть несколько VkQueue с поддержкой VK_QUEUE_GRAPHICS_BIT.

Если отдельной графической очереди нет, возникает неэффективная схема: пришла текстура → нужно попросить поток рендера перевести layout → затем копировать → затем снова перевести layout, чтобы читать в шейдере. Мы будем ждать и передавать данные между потоками, а смысл задачи — грузить сразу на потоках загрузки.

Альтернатива — использовать SecondaryCommandBuffer для перевода режима использования текстур, исполняя записанные команды через vkCmdExecuteCommands в RenderThread. Но такое решение достаточно усложнит VulkanRender, и мы снова упираемся в RenderThread. Более того, не все устройства имеют отдельную очередь с VK_QUEUE_TRANSFER_BIT — в таком случае исполнение vkCmdExecuteCommands не имеет смысла и не даст экономии выделения временной памяти, добавит дополнительную  синхронизация между RendeThread и потоками загрузки.

Сравнение архитектур Metal и Vulkan

После того как обе реализации были продуманы, стало хорошо видно, насколько по-разному Metal и Vulkan относятся к идее многопоточной загрузки ресурсов. И разница эта начинается на самом базовом уровне — на уровне очередей.

В Metal MTCommandQueue является Thread Safe: она может раздавать MTLCommandBuffer в разных потоках и исполнять их. Это изначально заложено в дизайне Metal API, и именно поэтому вся задача решилась относительно просто — достаточно было завести отдельный command buffer на поток и не забыть про финализацию.

В Vulkan ситуация обратная: VkQueue не ThreadSafe, и для разных потоков нужны отдельные очереди — отдельные VK_QUEUE_GRAPHICS_BIT и/или VK_QUEUE_TRANSFER_BIT. Если таких очередей на устройстве нет, приходится опционально использовать Secondary Command Buffer и передавать его на vkCmdExecuteCommands в RenderThread — то есть возвращаться ровно к той зависимости, от которой мы и хотели уйти.

Отсюда и главное расхождение — в загрузке текстур. В Metal это простейшая загрузка за один этап с немедленным освобождением временной памяти, и никакого ограничения на число MTLCommandBuffer нет. В Vulkan загрузка текстур сложнее именно из-за разной реализации очередей: без отдельных очередей, с использованием Secondary Command Buffer, память всё равно будет удержана для RenderThread — то есть экономии во временной памяти не будет вовсе. При этом число доступных очередей фиксировано и ограничено реализацией, так что универсального решения тут не построить.

А вот с буферами всё сходится: и в Vulkan, и в Metal мы получаем ровно то, что задумывали — загрузку буферов на потоке загрузки с освобождением временной памяти. Именно поэтому буферная часть и стала похожей в обоих API.

Единственное, что остается специфичным для Metal, — финализация. Перед отрисовкой нужно получать список MTLHeap в RenderThread, собирая все созданные в LabelingThreads и LoadingThreads, и вызывать MTLRenderCommandEncoder::useHeaps. В Vulkan такого шага нет — ресурсы привязаны к дескрипторам, и дополнительной сборки не требуется.

В итоге

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