Написать качественную обертку над 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
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, ®ion); setMemoryBarrier( _commandBuffer , VK_PIPELINE_STAGE_TRANSFER_BIT , VK_PIPELINE_STAGE_TRANSFER_BIT );
В документации Vulkan сказано:
...
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.
В итоге подобный код оказывается сильно медленее аналога на 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, когда разные типы ресурсов используются в одном аллокаторе.
Продолжаю с VSG, AccelerationGeometry.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);
Багов еще много, так что продолжение следует.
Комментариев нет:
Отправить комментарий