跳到主要内容

Khronos博客

Vulkan时间线符号

最初的Vulkan同步API依赖于两个单独的粗粒度原语:VkSemaphore和VkFence。这两个对象都是可重用的二进制状态对象,目的和行为略有不同。VkSemaphore允许应用程序跨设备队列同步操作。VkFence促进了设备到主机的同步。总之,它们使应用程序能够观察和控制命令缓冲区和其他队列命令的执行,但它们继承了当时底层操作系统和设备机制的各种限制,这使得它们有些难以使用。

新的时间线信号量同步API以VK_KHR_timeline_semaphore的形式发布,是Vulkan 1.2的核心功能,它定义了一个包含原始VkSemaphore和VkFence原语超集的原语,同时消除了以前API中许多最痛苦的限制。简而言之,时间轴信号量:

  • 是一个同步原语,其状态由单调递增的64位整数值组成
  • 使用单个原语启用设备和主机之间的全向同步
  • 允许等待-前-现场提交订单
  • 允许应用程序在某些情况下忽略信号操作
  • 无需在信号操作后重置,然后再使用
  • 每个信号操作允许多次等待操作

大多数Vulkan队列操作现在可以接受二进制或时间线信号,尽管窗口系统集成API目前是一个显著的例外。当在同一工作流中混合使用二进制和时间线信号量时,也需要注意一些重要的事项,如下所示。

时间线信号API

在深入研究这些定义特性的好处之前,让我们简要了解一下实现它们的API更改。时间线信号量是现有VkSemaphore对象的扩展,因此创建它们类似于创建常规或“二进制”VkSemashore对象:

Vk信号类型创建信息时间线创建信息;
时间线创建信息.sType=VK_STRUCTURE_TYPE_SEMAPHORE_TYPE_CREATE_INFO;
时间线创建信息.pNext= 无效的;
时间线CreateInfo.semaphoreType=VK_SEMAPHORE_TYPE_TIMELINE;
时间线创建信息初始值= 0;

VkSemaphoreCreateInfo创建信息;
创建信息类型=VK_STRUCTURE_TYPE_SEMAPHORE_CREATE_INFO;
创建信息.pNext= &timelineCreateInfo;
创建信息标志= 0;

Vk信号量时间线信号量;
vkCreateSemaphore(开发,&创建信息,无效的,&时间线信号);

请注意,可以为信号量状态定义任意初始值。

与二进制信号量一样,时间轴信号量可以在大多数队列操作完成后发出信号,并在开始大多数队列操作之前等待。定义了一个扩展结构,允许应用程序指定与时间线信号量等待和信号操作相关的附加状态:

常数 单位64_twaitValue(等待值)= 2;//等待信号量值>=2
常数 单位64_t信号值= ;//将信号量值设置为3

VkTimelineSemaphoreSubmitInfo timelineInfo;
时间线信息.sType=VK_结构_类型_ TIMELINE_SEMAPHORE_SUBMIT_INFO;
timelineInfo.pNext(时间线信息.下一步)= 无效的;
timelineInfo.wait信号量值计数= 1;
timelineInfo.pWait信号值= &waitValue;
timelineInfo.signal信号信号值计数= 1;
时间线信息.pSignal信号值= &信号值;

VkSubmitInfo提交信息;
提交信息类型=VK_结构_类型_提交_信息;
提交信息.下一步= &timelineInfo;
submitInfo.wait信号量计数= 1;
submitInfo.pWait信号= &时间线信号;
提交信息信号计数= 1;
提交信息.信号信号= &时间线信号;
提交信息命令缓冲区计数= 0;
提交信息.命令缓冲区= 0;

vkQueueSubmit(队列,1,&提交信息,VK_NULL_HANDLE);

此外,时间轴信号量可以直接从主机发出信号:

VkSemaphoreSignalInfo-signalInfo信号信息;
信号信息.类型=VK_结构_类型_半圆形_信号_信息;
信号信息.下一步= 无效的;
信号信息.信号量=时间线信号;
信号信息值= 2;

vk信号信号(dev,&signalInfo);

与VkFence一样,可以直接从主机线程等待它们:

常数 单位64_twaitValue(等待值)= 1;

VkSemaphoreWaitInfo等待信息;
等待信息.sType=VK_结构_类型_半圆形_等待_信息;
等待信息.p下一步= 无效的;
等待信息.flags= 0;
等待信息.semaphoreCount= 1;
waitInfo.p信号= &时间线信号;
waitInfo.p值= &waitValue;

vkWaitSemaphores(设备,
&waitInfo,UINT64_MAX);

它们的当前值也可以直接从主机线程查询:

单位64_t价值;
vkGetSemaphoreCounterValue(dev,timelineSemaphore,&值);

使应用程序能够在忙等待和阻塞等待算法中结合时间轴信号量。

使时间线信号发挥作用

有了新的API,让我们看一个完整的工作流示例,其中使用了时间轴信号量所公开的许多新功能:

#包含<线程>
#包括<vulkan/vulkanCore.h>

//单个Vulkan设备对象。
外部Vk设备开发;

//来自VkDevice的三个独立Vulkan队列<dev>。
外部VkQueue队列1;
外部VkQueue队列2;
外部VkQueue队列3;

//一个时间线信号量对象。
VkSemaphore时间线;

静止的 空隙 螺纹1()
{
常数 单位64_t等待值1= 0;//无需等待。值始终>=0。
常数 单位64_t信号值1= 5;//解除线程2的CPU工作阻塞。

VkTimelineSemaphoreSubmitInfo timelineInfo1;
timelineInfo1.s类型=VK_结构_类型_ TIMELINE_SEMAPHORE_SUBMIT_INFO;
timelineInfo1.p下一步= 无效的;
时间线信息1.wait信号值计数= 1;
timelineInfo1.p等待信号值= &waitValue1;
timelineInfo1.signal信号信号值计数= 1;
timelineInfo1.p信号信号值= &信号值1;

VkSubmitInfo信息1;
信息1.类型=VK_STRUCTURE_TYPE_SUBMIT_INFO;
信息1.p下一步= &timelineInfo1;
info1.wait信号量计数= 1;
信息1.pWaitSemaphores= &时间轴;
信息1.信号信号计数= 1;
info1.p信号信号= &时间轴;
// ... 在此处排队初始设备工作。
info1.command缓冲区计数= 0;
info1.p命令缓冲区= 0;

vkQueueSubmit(队列1,1,&信息1,VK_NULL_HANDLE);
}
静止的
 空隙 螺纹2()
{
//等待线程1的设备工作完成。
常数 单位64_t等待值2= 4;

VkSemaphoreWaitInfo等待信息;
等待信息.sType=VK_结构_类型_半圆形_等待_信息;
等待信息.p下一步= 无效的;
等待信息.flags= 0;
等待信息.semaphoreCount= 1;
waitInfo.p信号= &时间轴;
等待信息.pValues= &waitValue2;

vkWaitSemaphores(设备,
&waitInfo,UINT64_MAX);

// ... 根据线程1在此处的设备工作,执行一些CPU工作。

//解锁线程3的设备工作。
VkSemaphoreSignalInfo-signalInfo信号信息;
信号信息.类型=VK_结构_类型_半圆形_信号_信息;
信号信息.下一步= 无效的;
信号信息.信号量=时间轴;
信号信息值= 7;

vkSignalSemaphore(dev,
&signalInfo);
}

静止的 空隙 螺纹3()
{
常数 单位64_t等待值3= 7;//等待线程2的CPU工作完成。
常数 单位64_t信号值3= 8;//所有工作完成的信号。

VkTimelineSemaphoreSubmitInfo timelineInfo3;
timelineInfo3.sType(时间线信息3.sType)=VK_结构_类型_ TIMELINE_SEMAPHORE_SUBMIT_INFO;
timelineInfo3.p下一步= 无效的;
timelineInfo3.wait信号量值计数= 1;
timelineInfo3.p等待信号值= &waitValue3;
timelineInfo3.signal信号信号值计数= 1;
timelineInfo3.p信号信号值= &信号值3;

VkSubmitInfo信息3;
信息3.s类型=VK_结构_类型_提交_信息;
信息3.p下一步= &timelineInfo3;
信息3.wait信号量计数= 1;
信息3.pWaitSemaphores= &时间轴;
信息3.signalSemaphoreCount= 1;
info3.p信号信号= &时间轴;
// ... 在此,根据线程2的CPU工作将设备工作排队。
info3.命令缓冲区计数= 0;
info3.p命令缓冲区= 0;

vkQueueSubmit(队列3,1,&信息3,VK_NULL_HANDLE);
}

整数 主要的()
{
//创建时间线信号量对象
Vk信号类型创建信息时间线创建信息;
时间线创建信息.sType=VK_STRUCTURE_TYPE_SEMAPHORE_TYPE_CREATE_INFO;
时间线创建信息.pNext= 无效的;
时间线CreateInfo.semaphoreType=VK_SEMAPHORE_TYPE_TIMELINE;
时间线创建信息初始值= 0;

VkSemaphoreCreateInfo创建信息;
创建信息类型=VK_STRUCTURE_TYPE_SEMAPHORE_CREATE_INFO;
创建信息.pNext= &timelineCreateInfo;
创建信息标志= 0;

vkCreateSemaphore(开发,&创建信息,无效的,&时间轴);

//使用时间轴信号量生成三个自由运行的CPU线程
标准::线t1(螺纹1);
标准::线t2(螺纹2);
标准::线t3(螺纹3);

//使用时间线信号量等待设备和CPU工作空闲
常数 单位64_twaitValue(等待值)= 8;

VkSemaphoreWaitInfo等待信息;
等待信息.sType=VK_结构_类型_半圆形_等待_信息;
等待信息.p下一步= 无效的;
等待信息.flags= 0;
等待信息.semaphoreCount= 1;
waitInfo.p信号= &时间轴;
等待信息.pValues= &waitValue;

vkWaitSemaphores(设备,
&waitInfo、UINT64_MAX);

//销毁timeline信号量对象
vkDestroy信号(开发、时间线、,无效的);

//清理CPU线程。
t3.接头();
t2.接头();
t1.接头();

返回 0;
}

请注意,此示例中生成的三个线程在主机上以任意顺序运行。时间轴信号量中的等待-前置信号支持使得主机端同步的缺乏成为可能:无论提交顺序如何,时间轴信号的行为,以及设备的工作顺序,都得到了很好的定义。

此外,此示例包含thread2()中的CPU工作,它由thread1()和thread3()中提交的设备工作在两侧互锁,展示了时间线信号量的设备->主机和主机->设备同步功能。正如这里所展示的,在从主机发出信号时,必须小心确保信号量的状态得到很好的定义。虽然主机和设备信号操作都是原子操作,但相对于其他主机或设备信号操作,没有隐式的顺序保证。这与单调性要求相结合,意味着应用程序必须确保任何先前或同时提交的设备信号操作都是相对于主机信号显式排序的,这里的情况是由于先前的主机等待和随后的设备等待。此外,如果多个线程或进程要对给定的时间轴信号量执行主机信号操作,则应用程序必须确保其执行以类似的定义良好的顺序进行,以确保单调的值更新。

该示例还演示了时间线信号量状态的灵活性。虽然应用程序必须确保该值单调增加(即,该值决不能减少),但它在其他方面是任意的,允许它实现简单同步以外的目的。例如,应用程序可能会发现使用信号量状态跟踪单调的时间戳值很有用。

最后,请注意使用时间轴信号量本身来确定何时可以安全地销毁时间轴信号对象。在Vulkan 1.2之前,需要单独的VkFence对象来执行此任务。

虽然时间轴信号量通常不需要主机端同步Vulkan设备工作提交,但许多开发人员在使用它们时会遇到一个缺点:Vulkan's窗口系统集成API尚不支持时间轴信号,时间轴信号量的等待-前-信号行为不会被二进制信号量对象继承。为了说明这个限制的全部影响,让我们再次看一下上面的示例代码,但要包含一个表示步骤。所需的附加代码以红色显示如下:

#包含<线程>
#包含<原子>
#包括<vulkan/vulkanCore.h>

//单个Vulkan设备对象。
外部Vk设备开发;

//来自VkDevice的三个独立Vulkan队列<dev>。
外部VkQueue队列1;
外部VkQueue队列2;
外部VkQueue队列3;
外部VkQueue队列4;

//交换链数据
外部VkSwapchainKHR交换链;
外部 单位32_tswapchainImageIndex;

//一个时间线信号量对象。
VkSemaphore时间线;

//vkQueuePresent()的一个二进制信号量对象
VkSemaphore二进制;

//轨道信号提交计数。
标准::原子_uint64_t提交计数(0);

静止的 空隙 螺纹1()
{
常数 单位64_t等待值1= 0;//无需等待。值始终>=0。
常数 单位64_t信号值1= 5;//解除阻止线程2的CPU工作。

Vk时间线信号提交信息时间线信息1;
timelineInfo1.s类型=VK_结构_类型_ TIMELINE_SEMAPHORE_SUBMIT_INFO;
timelineInfo1.p下一步= 无效的;
timelineInfo1.wait信号量值计数= 1;
timelineInfo1.p等待信号值= &waitValue1;
timelineInfo1.signal信号信号值计数= 1;
timelineInfo1.p信号信号值= &信号值1;

VkSubmitInfo信息1;
信息1.键入=VK_结构_类型_提交_信息;
信息1.p下一步= &timelineInfo1;
info1.wait信号量计数= 1;
信息1.pWaitSemaphores= &时间轴;
信息1.信号信号计数= 1;
info1.p信号信号= &时间轴;
// ... 在此处排队初始设备工作。
info1.command缓冲区计数= 0;
info1.p命令缓冲区= 0;

vkQueueSubmit(队列1,1,&信息1,VK_NULL_HANDLE);
submitCount++;
}

静止的 空隙 螺纹2()
{
//等待线程1的设备工作完成。
常数 单位64_t等待值2= 4;

Vk信号等待信息等待信息;
等待信息.sType=VK_结构_类型_半圆形_等待_信息;
等待信息.p下一步= 无效的;
等待信息.flags= 0;
等待信息.semaphoreCount= 1;
waitInfo.p信号= &时间线;
waitInfo.p值= &waitValue2;

vkWaitSemaphores(设备,&waitInfo,UINT64_MAX);

// ... 根据线程1在此处的设备工作,执行一些CPU工作。

//解开线程3的设备工作。
VkSemaphoreSignalInfo-signalInfo信号信息;
信号信息.类型=VK_结构_类型_半圆形_信号_信息;
信号信息.下一步= 无效的;
信号信息.信号量=时间轴;
信号信息值= 7;

vk信号信号(dev,&signalInfo);
submitCount++;
}

静止的 空隙 螺纹3()
{
常数 单位64_t等待值3= 7;//等待线程2的CPU工作完成。
常数 单位64_t信号值3= 8;//预演工作完成的信号。

const uint64_t信号信号值[2]={
signalValue3,//“timeline”的值
0//已忽略,二进制信号量。
};


VkTimelineSemaphoreSubmitInfo timelineInfo3;
timelineInfo3.sType(时间线信息3.sType)=VK_结构_类型_ TIMELINE_SEMAPHORE_SUBMIT_INFO;
timelineInfo3.p下一步= 无效的;
timelineInfo3.wait信号量值计数= 1;
时间线信息3.p等待信号值= &waitValue3;
timelineInfo3.signalSemaphoreValueCount=2;
timelineInfo3.pSignalSemaphoreValues=信号信号信号值;

 常数Vk信号量信号量[2] = {
时间线, //跟踪时间线信号灯工作完成
二元的//取消阻止演示文稿
};

VkSubmitInfo信息3;
信息3.s类型=VK_结构_类型_提交_信息;
信息3.p下一步= &timelineInfo3;
信息3.wait信号量计数= 1;
信息3.pWaitSemaphores= &时间轴;
信息3.signalSemaphoreCount=2;
info3.pSignalSemaphores=信号信号;
// ... 在此,根据线程2的CPU工作将设备工作排队。
信息3.命令缓冲区计数= 0;
info3.p命令缓冲区= 0;

vkQueueSubmit(队列3,1,&信息3,VK_NULL_HANDLE);
submitCount++;
}

整数 主要的()
{
//创建时间线信号量对象
Vk信号类型创建信息时间线创建信息;
时间线创建信息.sType=VK_STRUCTURE_TYPE_SEMAPHORE_TYPE_CREATE_INFO;
时间线创建信息.pNext= 无效的;
时间线CreateInfo.semaphoreType=VK_SEMAPHORE_TYPE_TIMELINE;
时间线创建信息初始值= 0;

VkSemaphoreCreateInfo创建信息;
创建信息类型=VK_STRUCTURE_TYPE_SEMAPHORE_CREATE_INFO;
创建信息.pNext= &timelineCreateInfo;
创建信息标志= 0;

vkCreateSemaphore(dev,&创建信息,无效的,&时间轴);

//创建二进制信号量对象
VkSemaphoreCreateInfo二进制文件CreateInfo;
二进制CreateInfo.sType=VK_STRUCTURE_TYPE_SEMAPHORE_CREATE_INFO;
二进制CreateInfo.pNext=空;
二进制创建信息标志=0;

vkCreateSemaphore(dev,&binaryCreateInfo,NULL,&binary);

//使用时间轴信号量生成三个自由运行的CPU线程
标准::线t1(螺纹1);
标准::线t2(螺纹2);
标准::线t3(螺纹3);

//等待提交二进制信号量的所有依赖项
虽然(提交计数<);

//呈现结果
VkPresentInfoKHR presentInfo;
presentInfo.sType=VK_STRUCTURE_TYPE_PRESENT_INFO_KHR;
presentInfo.pNext=空;
presentInfo.wait信号计数=1;
presentInfo.pWaitSemaphores=&binary;
演示信息.swapchainCount=1;
presentInfo.pSwapchains=&swapchain;
presentInfo.pImageIndices=&swapchainImageIndex;
presentInfo.pResults=空;
vkQueuePresentKHR(queue4,&presentInfo);

//使用时间线信号量等待设备和CPU工作空闲
常数 单位64_twaitValue(等待值)= 8;

VkSemaphoreWaitInfo等待信息;
等待信息.sType=VK_结构_类型_半圆形_等待_信息;
等待信息.p下一步= 无效的;
等待信息.flags= 0;
等待信息.semaphoreCount= 1;
waitInfo.p信号= &时间轴;
等待信息.pValues= &waitValue;

vkWaitSemahores(开发,&waitInfo,UINT64_MAX);

//销毁timeline信号量对象
vkDestroy信号(开发、时间线、,无效的);

//清理CPU线程。
t3.接头();
t2.接头();
t1.接头();

返回 0;
}

注意,现在需要应用程序来确保全部的它的工作在提交等待二进制信号量的演示工作之前已经提交。这是因为二进制信号量等待不仅需要提交其相应的信号,还需要提交任何可能阻止该信号操作的工作。因此,除非必要,否则不鼓励混合二进制和时间线信号量,如上所述。

聪明的读者可能会想,为什么这里没有使用第二个时间轴信号量对象而不是C++11原子来跟踪主机端的工作提交。这是为了说明另一点:虽然时间轴信号量信号和等待本身是原子操作,但时间轴信号API没有公开增量或减量操作。因此,使用时间线信号量会对主机线程施加不必要的执行顺序约束。另一种选择是仅在主线程中的主机等待操作之后提交表示请求。虽然这将保留主机工作线程的自由运行特性,但它将在主主机线程和设备之间引入一个新的序列化点,从而降低总体并行性。

虽然API中仍存在一些不方便的限制,但时间线信号量编程模型应大大减少主机端同步的需要,以及Vulkan应用程序需要跟踪的同步对象的数量,从而减少主机端暂停和应用程序复杂性,这反过来会提高性能和质量。因此,Vulkan工作组强烈鼓励所有开发人员为所有粗粒度同步目的切换到时间轴信号量。为了帮助促进这种转换,同时承认需要在现场支持较旧的Vulkan实现,还根据许可的Apache 2.0许可证提供了时间轴信号量API作为Vulkan1.1层的实现,作为Vulkan-ExtensionLayer(Vulkan扩展层)项目。

应用程序可以将此代码直接合并到其项目中,将其作为一个层与应用程序一起发布,或者根据需要以任何其他方式在较旧的实现上模拟Vulkan 1.2时间轴信号量API,从而允许它们在所有环境中编程为单个同步API。

如SIGGRAPH 2019 Khronos Birds of A Feather会议所示,还可以幻灯片和视频形式对时间轴信号的特性和限制进行更高层次的介绍:

评论