什么是验证层?

Vulkan API 的设计理念是尽量减少驱动程序开销,而这一目标的一个体现是,默认情况下 API 中的错误检查非常有限。即使是像将枚举设置为不正确的值或将空指针传递给必需参数这样简单的错误,通常也不会被显式处理,只会导致崩溃或未定义的行为。由于 Vulkan 要求你对所做的一切都非常明确,因此很容易犯许多小错误,例如使用新的 GPU 功能,却忘记在逻辑设备创建时请求它。

然而,这并不意味着这些检查不能添加到 API 中。Vulkan 引入了一个优雅的系统来实现这一点,称为验证层。验证层是可选组件,它们挂钩到 Vulkan 函数调用中以应用额外的操作。验证层中的常见操作包括

  • 对照规范检查参数值以检测误用
  • 跟踪对象的创建和销毁以查找资源泄漏
  • 通过跟踪调用来源的线程来检查线程安全性
  • 将每个调用及其参数记录到标准输出
  • 跟踪 Vulkan 调用以进行性能分析和重放

以下是诊断验证层中函数实现的一个示例

VkResult vkCreateInstance(
    const VkInstanceCreateInfo* pCreateInfo,
    const VkAllocationCallbacks* pAllocator,
    VkInstance* instance) {

    if (pCreateInfo == nullptr || instance == nullptr) {
        log("Null pointer passed to required parameter!");
        return VK_ERROR_INITIALIZATION_FAILED;
    }

    return real_vkCreateInstance(pCreateInfo, pAllocator, instance);
}

这些验证层可以自由堆叠,以包含你感兴趣的所有调试功能。你可以简单地为调试构建启用验证层,并为发布构建完全禁用它们,这样你就可以两全其美!

Vulkan 本身不附带任何内置的验证层,但 LunarG Vulkan SDK 提供了一组很好的层,用于检查常见错误。它们也是完全开源的,因此你可以查看它们检查哪些类型的错误并做出贡献。使用验证层是避免你的应用程序因意外依赖未定义的行为而在不同的驱动程序上崩溃的最佳方法。

验证层只有在安装到系统后才能使用。例如,LunarG 验证层仅在安装了 Vulkan SDK 的 PC 上可用。

Vulkan 以前有两种不同类型的验证层:实例特定和设备特定。其想法是实例层仅检查与全局 Vulkan 对象(如实例)相关的调用,而设备特定层仅检查与特定 GPU 相关的调用。设备特定层现在已被弃用,这意味着实例验证层适用于所有 Vulkan 调用。规范文档仍然建议你在设备级别也启用验证层以获得兼容性,这是某些实现所要求的。我们只需在逻辑设备级别指定与实例相同的层,我们将在稍后看到。

使用验证层

在本节中,我们将了解如何启用 Vulkan SDK 提供的标准诊断层。与扩展一样,验证层需要通过指定其名称来启用。所有有用的标准验证都捆绑到一个包含在 SDK 中的层中,该层称为 VK_LAYER_KHRONOS_validation

让我们首先向程序添加两个配置变量,以指定要启用的层以及是否启用它们。我选择根据程序是否在调试模式下编译来确定该值。NDEBUG 宏是 C++ 标准的一部分,意思是“非调试”。

const uint32_t WIDTH = 800;
const uint32_t HEIGHT = 600;

const std::vector<const char*> validationLayers = {
    "VK_LAYER_KHRONOS_validation"
};

#ifdef NDEBUG
    const bool enableValidationLayers = false;
#else
    const bool enableValidationLayers = true;
#endif

我们将添加一个新函数 checkValidationLayerSupport,用于检查所有请求的层是否可用。首先使用 vkEnumerateInstanceLayerProperties 函数列出所有可用的层。它的用法与 vkEnumerateInstanceExtensionProperties 函数的用法相同,后者在实例创建章节中讨论过。

bool checkValidationLayerSupport() {
    uint32_t layerCount;
    vkEnumerateInstanceLayerProperties(&layerCount, nullptr);

    std::vector<VkLayerProperties> availableLayers(layerCount);
    vkEnumerateInstanceLayerProperties(&layerCount, availableLayers.data());

    return false;
}

接下来,检查 validationLayers 中的所有层是否都存在于 availableLayers 列表中。你可能需要包含 <cstring> 以使用 strcmp

for (const char* layerName : validationLayers) {
    bool layerFound = false;

    for (const auto& layerProperties : availableLayers) {
        if (strcmp(layerName, layerProperties.layerName) == 0) {
            layerFound = true;
            break;
        }
    }

    if (!layerFound) {
        return false;
    }
}

return true;

我们现在可以在 createInstance 中使用此函数

void createInstance() {
    if (enableValidationLayers && !checkValidationLayerSupport()) {
        throw std::runtime_error("validation layers requested, but not available!");
    }

    ...
}

现在在调试模式下运行程序,并确保错误不会发生。如果发生错误,请查看 FAQ。

最后,修改 VkInstanceCreateInfo 结构体实例化,以包含验证层名称(如果已启用)

if (enableValidationLayers) {
    createInfo.enabledLayerCount = static_cast<uint32_t>(validationLayers.size());
    createInfo.ppEnabledLayerNames = validationLayers.data();
} else {
    createInfo.enabledLayerCount = 0;
}

如果检查成功,则 vkCreateInstance 永远不应返回 VK_ERROR_LAYER_NOT_PRESENT 错误,但你应该运行程序以确保如此。

消息回调

默认情况下,验证层会将调试消息打印到标准输出,但我们也可以通过在程序中提供显式回调来自己处理它们。这也将允许你决定要查看哪种类型的消息,因为并非所有消息都是(致命)错误。如果你现在不想这样做,则可以跳到本章的最后一节。

为了在程序中设置回调以处理消息和相关详细信息,我们必须使用 VK_EXT_debug_utils 扩展程序设置一个带有回调的调试信使。

我们首先创建一个 getRequiredExtensions 函数,该函数将根据是否启用验证层返回所需的扩展列表

std::vector<const char*> getRequiredExtensions() {
    uint32_t glfwExtensionCount = 0;
    const char** glfwExtensions;
    glfwExtensions = glfwGetRequiredInstanceExtensions(&glfwExtensionCount);

    std::vector<const char*> extensions(glfwExtensions, glfwExtensions + glfwExtensionCount);

    if (enableValidationLayers) {
        extensions.push_back(VK_EXT_DEBUG_UTILS_EXTENSION_NAME);
    }

    return extensions;
}

GLFW 指定的扩展始终是必需的,但调试信使扩展是有条件添加的。请注意,我在此处使用了 VK_EXT_DEBUG_UTILS_EXTENSION_NAME 宏,它等于文字字符串“VK_EXT_debug_utils”。使用此宏可以避免拼写错误。

我们现在可以在 createInstance 中使用此函数

auto extensions = getRequiredExtensions();
createInfo.enabledExtensionCount = static_cast<uint32_t>(extensions.size());
createInfo.ppEnabledExtensionNames = extensions.data();

运行程序以确保你没有收到 VK_ERROR_EXTENSION_NOT_PRESENT 错误。我们实际上不需要检查此扩展是否存在,因为它应该由验证层的可用性隐含。

现在让我们看看调试回调函数是什么样的。添加一个新的静态成员函数,名为 debugCallback,其原型为 PFN_vkDebugUtilsMessengerCallbackEXTVKAPI_ATTRVKAPI_CALL 确保该函数具有 Vulkan 调用它的正确签名。

static VKAPI_ATTR VkBool32 VKAPI_CALL debugCallback(
    VkDebugUtilsMessageSeverityFlagBitsEXT messageSeverity,
    VkDebugUtilsMessageTypeFlagsEXT messageType,
    const VkDebugUtilsMessengerCallbackDataEXT* pCallbackData,
    void* pUserData) {

    std::cerr << "validation layer: " << pCallbackData->pMessage << std::endl;

    return VK_FALSE;
}

第一个参数指定消息的严重性,它是以下标志之一

  • VK_DEBUG_UTILS_MESSAGE_SEVERITY_VERBOSE_BIT_EXT:诊断消息
  • VK_DEBUG_UTILS_MESSAGE_SEVERITY_INFO_BIT_EXT:信息性消息,例如资源的创建
  • VK_DEBUG_UTILS_MESSAGE_SEVERITY_WARNING_BIT_EXT:关于行为的消息,该行为不一定是错误,但很可能是你的应用程序中的错误
  • VK_DEBUG_UTILS_MESSAGE_SEVERITY_ERROR_BIT_EXT:关于无效行为的消息,可能会导致崩溃

此枚举的值设置方式使你可以使用比较操作来检查消息是否等于或比某个严重性级别更严重,例如

if (messageSeverity >= VK_DEBUG_UTILS_MESSAGE_SEVERITY_WARNING_BIT_EXT) {
    // Message is important enough to show
}

messageType 参数可以具有以下值

  • VK_DEBUG_UTILS_MESSAGE_TYPE_GENERAL_BIT_EXT:发生了一些与规范或性能无关的事件
  • VK_DEBUG_UTILS_MESSAGE_TYPE_VALIDATION_BIT_EXT:发生了一些违反规范或表明可能错误的事情
  • VK_DEBUG_UTILS_MESSAGE_TYPE_PERFORMANCE_BIT_EXT:Vulkan 的潜在非最佳使用

pCallbackData 参数引用一个 VkDebugUtilsMessengerCallbackDataEXT 结构体,其中包含消息本身的详细信息,最重要的成员是

  • pMessage:作为空终止字符串的调试消息
  • pObjects:与消息相关的 Vulkan 对象句柄数组
  • objectCount:数组中对象的数量

最后,pUserData 参数包含一个指针,该指针在回调的设置期间指定,并允许你将自己的数据传递给它。

回调返回一个布尔值,指示是否应中止触发验证层消息的 Vulkan 调用。如果回调返回 true,则调用将中止并显示 VK_ERROR_VALIDATION_FAILED_EXT 错误。这通常仅用于测试验证层本身,因此你应始终返回 VK_FALSE

现在剩下的就是告诉 Vulkan 关于回调函数。也许有点令人惊讶的是,即使是 Vulkan 中的调试回调也使用需要显式创建和销毁的句柄进行管理。这样的回调是调试信使的一部分,你可以拥有任意多个。在此句柄的类成员中,在 instance 下方添加一个成员

VkDebugUtilsMessengerEXT debugMessenger;

现在添加一个函数 setupDebugMessenger,以便在 initVulkan 中在 createInstance 之后调用

void initVulkan() {
    createInstance();
    setupDebugMessenger();
}

void setupDebugMessenger() {
    if (!enableValidationLayers) return;

}

我们需要填写一个结构体,其中包含有关信使及其回调的详细信息

VkDebugUtilsMessengerCreateInfoEXT createInfo{};
createInfo.sType = VK_STRUCTURE_TYPE_DEBUG_UTILS_MESSENGER_CREATE_INFO_EXT;
createInfo.messageSeverity = VK_DEBUG_UTILS_MESSAGE_SEVERITY_VERBOSE_BIT_EXT | VK_DEBUG_UTILS_MESSAGE_SEVERITY_WARNING_BIT_EXT | VK_DEBUG_UTILS_MESSAGE_SEVERITY_ERROR_BIT_EXT;
createInfo.messageType = VK_DEBUG_UTILS_MESSAGE_TYPE_GENERAL_BIT_EXT | VK_DEBUG_UTILS_MESSAGE_TYPE_VALIDATION_BIT_EXT | VK_DEBUG_UTILS_MESSAGE_TYPE_PERFORMANCE_BIT_EXT;
createInfo.pfnUserCallback = debugCallback;
createInfo.pUserData = nullptr; // Optional

messageSeverity 字段允许你指定希望回调调用的所有严重性类型。我在此处指定了除 VK_DEBUG_UTILS_MESSAGE_SEVERITY_INFO_BIT_EXT 之外的所有类型,以便接收有关可能问题的通知,同时忽略详细的常规调试信息。

类似地,messageType 字段允许你过滤要通知回调的消息类型。我在此处简单地启用了所有类型。如果某些类型对你没有用,你可以随时禁用它们。

最后,pfnUserCallback 字段指定指向回调函数的指针。你可以选择性地将指针传递给 pUserData 字段,该字段将通过 pUserData 参数传递给回调函数。例如,你可以使用它来传递指向 HelloTriangleApplication 类的指针。

请注意,配置验证层消息和调试回调的方式还有很多,但这对于本教程入门来说是一个很好的设置。有关可能性的更多信息,请参阅 扩展规范

此结构体应传递给 vkCreateDebugUtilsMessengerEXT 函数以创建 VkDebugUtilsMessengerEXT 对象。不幸的是,由于此函数是扩展函数,因此不会自动加载。我们必须使用 vkGetInstanceProcAddr 自己查找其地址。我们将创建我们自己的代理函数,在后台处理此问题。我已将其添加到 HelloTriangleApplication 类定义的上方。

VkResult CreateDebugUtilsMessengerEXT(VkInstance instance, const VkDebugUtilsMessengerCreateInfoEXT* pCreateInfo, const VkAllocationCallbacks* pAllocator, VkDebugUtilsMessengerEXT* pDebugMessenger) {
    auto func = (PFN_vkCreateDebugUtilsMessengerEXT) vkGetInstanceProcAddr(instance, "vkCreateDebugUtilsMessengerEXT");
    if (func != nullptr) {
        return func(instance, pCreateInfo, pAllocator, pDebugMessenger);
    } else {
        return VK_ERROR_EXTENSION_NOT_PRESENT;
    }
}

vkGetInstanceProcAddr 函数将在无法加载函数时返回 nullptr。我们现在可以调用此函数来创建扩展对象(如果可用)

if (CreateDebugUtilsMessengerEXT(instance, &createInfo, nullptr, &debugMessenger) != VK_SUCCESS) {
    throw std::runtime_error("failed to set up debug messenger!");
}

倒数第二个参数再次是我们设置为 nullptr 的可选分配器回调,除此之外,参数非常简单明了。由于调试信使特定于我们的 Vulkan 实例及其层,因此需要将其明确指定为第一个参数。稍后你还将在其他对象中看到此模式。

VkDebugUtilsMessengerEXT 对象也需要通过调用 vkDestroyDebugUtilsMessengerEXT 来清理。与 vkCreateDebugUtilsMessengerEXT 类似,该函数需要显式加载。

CreateDebugUtilsMessengerEXT 下方创建另一个代理函数

void DestroyDebugUtilsMessengerEXT(VkInstance instance, VkDebugUtilsMessengerEXT debugMessenger, const VkAllocationCallbacks* pAllocator) {
    auto func = (PFN_vkDestroyDebugUtilsMessengerEXT) vkGetInstanceProcAddr(instance, "vkDestroyDebugUtilsMessengerEXT");
    if (func != nullptr) {
        func(instance, debugMessenger, pAllocator);
    }
}

确保此函数是静态类函数或类外部的函数。然后我们可以在 cleanup 函数中调用它

void cleanup() {
    if (enableValidationLayers) {
        DestroyDebugUtilsMessengerEXT(instance, debugMessenger, nullptr);
    }

    vkDestroyInstance(instance, nullptr);

    glfwDestroyWindow(window);

    glfwTerminate();
}

调试实例创建和销毁

尽管我们现在已将带有验证层的调试添加到程序中,但我们尚未涵盖所有内容。vkCreateDebugUtilsMessengerEXT 调用需要已创建有效的实例,并且必须在销毁实例之前调用 vkDestroyDebugUtilsMessengerEXT。这目前使我们无法调试 vkCreateInstancevkDestroyInstance 调用中的任何问题。

但是,如果你仔细阅读 扩展文档,你将看到有一种方法可以专门为这两个函数调用创建一个单独的调试实用程序信使。它要求你只需在 VkInstanceCreateInfopNext 扩展字段中传递指向 VkDebugUtilsMessengerCreateInfoEXT 结构体的指针。首先将信使创建信息的填充提取到一个单独的函数中

void populateDebugMessengerCreateInfo(VkDebugUtilsMessengerCreateInfoEXT& createInfo) {
    createInfo = {};
    createInfo.sType = VK_STRUCTURE_TYPE_DEBUG_UTILS_MESSENGER_CREATE_INFO_EXT;
    createInfo.messageSeverity = VK_DEBUG_UTILS_MESSAGE_SEVERITY_VERBOSE_BIT_EXT | VK_DEBUG_UTILS_MESSAGE_SEVERITY_WARNING_BIT_EXT | VK_DEBUG_UTILS_MESSAGE_SEVERITY_ERROR_BIT_EXT;
    createInfo.messageType = VK_DEBUG_UTILS_MESSAGE_TYPE_GENERAL_BIT_EXT | VK_DEBUG_UTILS_MESSAGE_TYPE_VALIDATION_BIT_EXT | VK_DEBUG_UTILS_MESSAGE_TYPE_PERFORMANCE_BIT_EXT;
    createInfo.pfnUserCallback = debugCallback;
}

...

void setupDebugMessenger() {
    if (!enableValidationLayers) return;

    VkDebugUtilsMessengerCreateInfoEXT createInfo;
    populateDebugMessengerCreateInfo(createInfo);

    if (CreateDebugUtilsMessengerEXT(instance, &createInfo, nullptr, &debugMessenger) != VK_SUCCESS) {
        throw std::runtime_error("failed to set up debug messenger!");
    }
}

我们现在可以在 createInstance 函数中重用它

void createInstance() {
    ...

    VkInstanceCreateInfo createInfo{};
    createInfo.sType = VK_STRUCTURE_TYPE_INSTANCE_CREATE_INFO;
    createInfo.pApplicationInfo = &appInfo;

    ...

    VkDebugUtilsMessengerCreateInfoEXT debugCreateInfo{};
    if (enableValidationLayers) {
        createInfo.enabledLayerCount = static_cast<uint32_t>(validationLayers.size());
        createInfo.ppEnabledLayerNames = validationLayers.data();

        populateDebugMessengerCreateInfo(debugCreateInfo);
        createInfo.pNext = (VkDebugUtilsMessengerCreateInfoEXT*) &debugCreateInfo;
    } else {
        createInfo.enabledLayerCount = 0;

        createInfo.pNext = nullptr;
    }

    if (vkCreateInstance(&createInfo, nullptr, &instance) != VK_SUCCESS) {
        throw std::runtime_error("failed to create instance!");
    }
}

debugCreateInfo 变量放置在 if 语句外部,以确保它在 vkCreateInstance 调用之前不会被销毁。通过以这种方式创建额外的调试信使,它将在 vkCreateInstancevkDestroyInstance 期间自动使用,并在之后清理。

测试

现在让我们故意犯一个错误,看看验证层的实际效果。暂时删除 cleanup 函数中对 DestroyDebugUtilsMessengerEXT 的调用并运行你的程序。程序退出后,你应该看到类似这样的内容

如果你没有看到任何消息,请检查你的安装

如果你想查看哪个调用触发了消息,你可以在消息回调中添加断点并查看堆栈跟踪。

配置

验证层行为的设置远不止 VkDebugUtilsMessengerCreateInfoEXT 结构体中指定的标志。浏览到 Vulkan SDK 并转到 Config 目录。在那里,你将找到一个 vk_layer_settings.txt 文件,其中说明了如何配置层。

要为自己的应用程序配置层设置,请将该文件复制到项目的 DebugRelease 目录,并按照说明设置所需的行为。但是,在本教程的其余部分中,我将假设你使用的是默认设置。

在本教程中,我将故意犯几个错误,向你展示验证层在捕获错误方面的帮助程度,并教你了解确切了解你在 Vulkan 中所做的事情的重要性。现在是时候查看系统中的 Vulkan 设备了。

C++ 代码