воскресенье, 15 мая 2022 г.

Как выбрать обертку над Vulkan

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

Опасных мест достаточно много:

  • Синхронизации на GPU. В простых проектах все должно ограничиться семафорами для синхронизации со свопчейном и барьерами внутри одной очереди, все остальное использовать опасно без чтения документации.
  • Синхронизации CPU с GPU. Тут ошибиться сложнее, но зато легко сделать долгие простои GPU в ожидании передачи данных по PCI.
  • Выравнивания. У разного железа разные требования к выравниванию буферов и прочих данных, их нужно получать в рантайме, поэтому если работает на одном железе, то нет никакой гарантии, что будет работать на другом.
  • Баги в драйверах. Один из важных показателей годного проекта - в нем есть фиксы для разных вендоров и версий драйверов, значит разработчик реально использует его на разном железе и исправляет все ошибки.
  • Валидация данных. Vulkan позволяет создавать ресурсы с неподдерживаемыми форматами, размерами и тд, где-то возвращается ошибка, где-то нет, что произойдет дальше знает только разработчик драйверов.
  • robustBufferAccess фича при создании девайса, при частом чтении из буфера в шейдере эта фича сильно замедляет код, хоть и делает выполнение более безопасным.

Типичные ошибки

Большинство малоизвестных проектов это копипаста с Vulkan tutorial, Vulkan samples и подобных, разработчик конечно же напишет что это самый быстрый фреймворк/движок (ведь он использует Vulkan), но реально он будет медленее аналога на GL, так как в GL внутри создается второй поток, а в Vulkan это надо делать самому, что не каждый осиливает.

После этого идут барьеры с ALL_COMMANDS и General layout, потому что так точно работает, а расставлять правильные барьеры слишком сложно. Сюда же относится MEMORY_READ в accessMask, что было в ранних версиях Vulkan samples и все еще там встречается, хотя в самой документации по Vulkan давно уже написано, что для свопчейна не надо использовать MEMORY_READ, так как там семафор создает нужную зависимость по данным.

Примеры

А теперь большинство "экспертов" по Vulkan должны напрячься)

Первое место - V-EZ от AMD, у них отсутствует проверка на переполнение инта и из-за этого теряется барьер, когда правая часть сравнения из-за переполнения вдруг становится меньше левой части. Это происходит когда диапазон буфера задается через (offset, VK_WHOLE_SIZE), с ненулевым смещением, и это часто применяется в примерах. PipelineBarrier.cpp, 136

 if (max - min < finalKey[2] + iter->first[2])


Следующим идет bgfx

Этот setMemoryBarrier() вызывается повсюду, флаги для accessMask не самые удачные, но пока не страшно (renderer_vk.cpp, 867).

void setMemoryBarrier(
      VkCommandBuffer _commandBuffer
    , VkPipelineStageFlags _srcStages
    , VkPipelineStageFlags _dstStages
    )
{
    VkMemoryBarrier mb;
    mb.sType = VK_STRUCTURE_TYPE_MEMORY_BARRIER;
    mb.pNext = NULL;
    mb.srcAccessMask = VK_ACCESS_MEMORY_WRITE_BIT;
    mb.dstAccessMask = VK_ACCESS_MEMORY_READ_BIT | VK_ACCESS_MEMORY_WRITE_BIT;

    vkCmdPipelineBarrier(
          _commandBuffer
        , _srcStages
        , _dstStages
        , 0
        , 1
        , &mb
        , 0
        , NULL
        , 0
        , NULL
        );
}

А теперь смотрим как барьер применяется:

vkCmdCopyBuffer(_commandBuffer, stagingBuffer, m_buffer, 1, &region);

setMemoryBarrier(
      _commandBuffer
    , VK_PIPELINE_STAGE_TRANSFER_BIT
    , VK_PIPELINE_STAGE_TRANSFER_BIT
    );

То есть каждое копирование идет синхронно, хотя большинство копирований могло быть распараллелено. При этом копирование не синхронизируется с последующим использованием, например как uniform buffer. Проблема не проявляется только благодаря драйверам, которые пока что позволяет делать такие барьеры.
В документации Vulkan сказано:
Visibility operations cause values available to a memory domain to become visible to specified memory accesses.
...
Once written values are made visible to a particular type of memory access, they can be read or written by that type of memory access.

То есть visibility operation сообщает драйверу, что для кэша (VK_ACCESS_*) доступны новые данные в глобальной памяти.

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


Более свежий проект - VulkanSceneGraph

Image.h, 75 - Поддержка нескольких девайсов сделана через vk_buffer<>, где может быть статичный массив из 4 элементов с отдельными данными для каждого девайса.

struct VulkanData
{
    VkImage image = VK_NULL_HANDLE;
    ref_ptr<DeviceMemory> deviceMemory;
    VkDeviceSize memoryOffset = 0;
    VkDeviceSize size = 0;
    ref_ptr<Device> device;
    bool requiresDataCopy = false;
    ModifiedCount copiedModifiedCount;

    void release();
};

vk_buffer<VulkanData> _vulkanData;

А теперь создание image под каждый девайс, Image.cpp, 167

auto& vd = _vulkanData[device->deviceID];
...
info.pQueueFamilyIndices = queueFamilyIndices.data();

Тут сразу много ошибок, первая - queueFamilyIndices могут не совпадать между девайсами, если все NV или AMD тогда это будет работать, но если это ноутбук с Intel и NV, то получим первый девайс с одной графической очередью и второй девайс с тремя разными очередями, в итоге - неопределенное поведение и крэш.

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


Я так и не нашел механизма отложенного удаления ресурсов, в bgfx он есть, а в VSG судя по всему нет.

void Buffer::VulkanData::release()
{
    if (buffer)
    {
        vkDestroyBuffer(*device, buffer, device->getAllocationCallbacks());
    }


Есть вспомогательная функция в Buffer.cpp, 128, которая создает буфер и память без всяких аллокаторов, сразу же вызывается vkAllocateMemory() что очень медленно, к тому же количество аллокаций может быть ограниченно, а сама аллокация может быть кратна 64Кб, что создаст большие пустоты.

ref_ptr<Buffer> vsg::createBufferAndMemory(Device* device, VkDeviceSize size, VkBufferUsageFlags usage, VkSharingMode sharingMode, VkMemoryPropertyFlags memoryProperties)
{
    auto buffer = vsg::Buffer::create(size, usage, sharingMode);
    buffer->compile(device);

    auto memRequirements = buffer->getMemoryRequirements(device->deviceID);
    auto memory = vsg::DeviceMemory::create(device, memRequirements, memoryProperties);

Тут есть и потенциальная ошибка - VkMemoryRequirements может содержать слишком маленькое выравнивание (например на AMD там часто 4 байта, хотя много где требуется 16 или 256), ошибка не проявляется потому что выделяется новая память, а вот при использовании кастомного аллокатора (например VMA) может проявиться.

Функция Buffer::bind() (Buffer.cpp, 76) биндит буфер в память и опять же никаких валидаций, что эта память вообще совместима с буфером и что смещение имеет правильное выравнивание.

И только в некоторых местах учитывается выравнивание (BufferInfo.cpp, 211):

VkDeviceSize alignment = 4;
if (usage == VK_BUFFER_USAGE_UNIFORM_BUFFER_BIT) alignment = device->getPhysicalDevice()->getProperties().limits.minUniformBufferOffsetAlignment;

И опять ошибка - usage это битовое поле, его нельзя проверять через ==, кроме юниформ буфера есть и другие выравнивания, но видимо конкретно в этом месте создается host visible uniform buffer, поэтому код можно считать правильным. Хотя само использование host visible памяти в шейдере не очень хорошо для стабильного FPS.

Для сравнения как выглядит рассчет выравнивания в DiligentEngine (BufferVkImpl.cpp, 293):

VkDeviceSize RequiredAlignment = MemReqs.alignment;
if ((m_Desc.BindFlags & BIND_RAY_TRACING) != 0)
{
    // geometry.triangles.vertexData.deviceAddress must be aligned to the size in bytes of the smallest component of the format in vertexFormat (which is 4 bytes).
    // geometry.triangles.indexData.deviceAddress must be aligned to the size in bytes of the type in indexType (which is 4 bytes).
    // if geometry.triangles.transformData.deviceAddress is not 0, it must be aligned to 16 bytes.
    // geometry.aabbs.data.deviceAddress must be aligned to 8 bytes.
    const VkDeviceSize ReadOnlyRTBufferAlign = 16u;
    const VkDeviceSize ScratchBufferAlign    = PhysicalDevice.GetExtProperties().AccelStruct.minAccelerationStructureScratchOffsetAlignment;
    RequiredAlignment                        = std::max(RequiredAlignment, std::max(ScratchBufferAlign, ReadOnlyRTBufferAlign));
    VERIFY_EXPR(RequiredAlignment % MemReqs.alignment == 0);
}

const bool AlignToNonCoherentAtomSize = (m_Desc.CPUAccessFlags & (CPU_ACCESS_READ | CPU_ACCESS_WRITE)) != 0 && (m_MemoryProperties & MEMORY_PROPERTY_HOST_COHERENT) == 0;
if (AlignToNonCoherentAtomSize)
{
    // From specs:
    //  If the device memory was allocated without the VK_MEMORY_PROPERTY_HOST_COHERENT_BIT set,
    //  these guarantees must be made for an extended range: the application must round down the start
    //  of the range to the nearest multiple of VkPhysicalDeviceLimits::nonCoherentAtomSize,
    //  and round the end of the range up to the nearest multiple of VkPhysicalDeviceLimits::nonCoherentAtomSize.
    RequiredAlignment = std::max(RequiredAlignment, DeviceLimits.nonCoherentAtomSize);
    MemReqs.size      = AlignUp(MemReqs.size, DeviceLimits.nonCoherentAtomSize);
}

Или как у меня в движке:

case EBufferUsage::UniformTexel :
case EBufferUsage::StorageTexel :       align = Max( align, limits.minTexelBufferOffsetAlignment );     break;
case EBufferUsage::TransferSrc :        break;
case EBufferUsage::TransferDst :        break;
case EBufferUsage::Uniform :            align = Max( align, limits.minTexelBufferOffsetAlignment );     break;
case EBufferUsage::Storage :            align = Max( align, limits.minStorageBufferOffsetAlignment );   break;
case EBufferUsage::Index :              break;
case EBufferUsage::Vertex :             break;
case EBufferUsage::Indirect :           break;
case EBufferUsage::ASBuild_ReadOnly :   align = Max( align, 16_b );                                     break;
case EBufferUsage::ASBuild_Scratch :    align = Max( align, as_props.minAccelerationStructureScratchOffsetAlignment ); break;
case EBufferUsage::ShaderBindingTable : align = Max( align, rt_props.shaderGroupHandleAlignment );      break;
case EBufferUsage::ShaderAddress :      break;

А еще надо незабыть про bufferImageGranularity, когда разные типы ресурсов используются в одном аллокаторе.


Продолжаю с VSGAccelerationGeometry.cpp, 60, тут для построения ускоряющей структуры создается host visible buffer, что во-первых не рекомендуюется, так как построение идет на GPU и доступ к host памяти медленный, а во-вторых я не представляю как можно одновременно использовать host visible и VK_BUFFER_USAGE_SHADER_DEVICE_ADDRESS_BIT, разве что это host visible device local то есть unified memory. Третье - вместо VK_BUFFER_USAGE_VERTEX_BUFFER_BIT должно быть VK_BUFFER_USAGE_ACCELERATION_STRUCTURE_BUILD_INPUT_READ_ONLY_BIT, и четвертое - нигде не используется VkMemoryAllocateFlagsInfo с флагом VK_MEMORY_ALLOCATE_DEVICE_ADDRESS_BIT, что требуется при создании буфера с DeviceAddress, так что трассировка лучей в VSG ни разу не запускалась.

auto vertexBufferInfo = vsg::createHostVisibleBuffer(context.device, vertexDataList, VK_BUFFER_USAGE_VERTEX_BUFFER_BIT | VK_BUFFER_USAGE_SHADER_DEVICE_ADDRESS_BIT, VK_SHARING_MODE_EXCLUSIVE);


Багов еще много, так что продолжение следует.

Комментариев нет:

Отправить комментарий