用户模式工作提交

重要

一些信息与预发布产品相关,在商业发行之前可能会发生实质性修改。 Microsoft 对此处提供的信息不提供任何明示或暗示的保证。

本文介绍在 Windows 11 版本 24H2 (WDDM 3.2) 中仍在开发的用户模式 (UM) 工作提交功能。 UM 工作提交使应用程序能够直接从用户模式将工作提交到 GPU,且延迟非常低。 目标是提高经常向 GPU 提交小工作负载的应用程序的性能。 此外,如果这些应用程序在容器或虚拟机 (VM) 中运行,则用户模式提交有望使它们受益匪浅。 这一好处是因为在 VM 中运行的用户模式驱动程序 (UMD) 可以直接向 GPU 提交工作,而不必向主机发送消息。

支持 UM 工作提交的 IHV 驱动程序和硬件必须继续支持传统的内核模式工作提交模型。 对于仅支持在最新主机上运行的传统 KM 队列的较旧来宾等方案,这种支持是必要的。

本文不讨论 UM 提交与 Flip/FlipEx 的互操作性。 本文中所述的 UM 提交仅限于呈现/计算类方案。 呈现管道现在仍基于内核模式提交,因为它依赖于本机受监视的围栏。 一旦完全实现了本机受监视的围栏和仅用于计算/呈现的 UM 提交,就可以考虑基于 UM 提交的呈现的设计和实现。 因此,驱动程序应该支持基于每个队列的用户模式提交。

门铃

大部分支持硬件计划的最新或即将推出的 GPU 也支持 GPU 门铃的概念。 门铃是一种机制,用于向 GPU 引擎指示新工作已在其工作队列中排队。 门铃通常在 PCIe BAR(基地址栏)或系统内存中注册。 每个 GPU IHV 都有自己的体系结构,用于确定门铃的数量、它们位于系统中的位置等。 Windows OS 使用门铃作为其设计的一部分来实现 UM 工作提交。

在高级别上,门铃有两种不同的模型,由不同的 IHV 和 GPU 实现:

  • 全局门铃

    在全局门铃模型中,跨上下文和进程的所有硬件队列共享一个全局门铃。 写入门铃的值告知 GPU 计划程序哪个特定的硬件队列和引擎有新的工作。 如果多个硬件队列主动提交工作并按相同的全局门铃,则 GPU 硬件使用轮询机制的形式来获取工作。

  • 专用门铃

    在专用门铃模型中,每个硬件队列都被分配了自己的门铃,每当有新的工作要提交给 GPU 时,门铃就会响起。 当门铃响起时,GPU 计划程序准确地知道哪个硬件队列提交了新工作。 在 GPU 上创建的所有硬件队列中共享的门铃数量有限。 如果创建的硬件队列数超过可用门铃的数量,则驱动程序需要断开较旧或最近使用最少的硬件队列的门铃的连接,并将其门铃分配给新创建的队列,从而有效地“虚拟化”门铃。

发现用户模式工作提交支持

DXGK_NODEMETADATA_FLAGS::UserModeSubmissionSupported

对于支持 UM 工作提交功能的 GPU 节点,KMD 的 DxgkDdiGetNodeMetadata 设置了 UserModeSubmissionSupported 节点元数据标志,该标志被添加到 DXGK_NODEMETADATA_FLAGS。 然后,OS 允许 UMD 仅在设置此标志的节点上创建用户模式提交 HWQueue 和门铃。

DXGK_QUERYADAPTERINFOTYPE::DXGKQAITYPE_USERMODESUBMISSION_CAPS

为查询特定于门铃的信息,OS 调用 KMD 的 DxgkDdiQueryAdapterInfo 函数,并使用 DXGKQAITYPE_USERMODESUBMISSION_CAPS 查询适配器信息类型。 KMD 通过填充 DXGK_USERMODESUBMISSION_CAPS 结构及其对用户模式工作提交的支持详细信息进行响应。

目前,唯一需要的上限是门铃内存大小(以字节为单位)。 Dxgkrnl 需要门铃内存大小,原因如下:

  • 在门铃创建期间 (D3DKMTCreateDoorbell),Dxgkrnl 向 UMD 返回 DoorbellCpuVirtualAddress。 在执行此操作之前,Dxgkrnl 首先需要在内部映射到一个虚拟页面,因为门铃尚未分配和连接。 分配虚拟页面需要门铃的大小。
  • 在门铃连接期间 (D3DKMTConnectDoorbell),Dxgkrnl 需要将 DoorbellCpuVirtualAddress 旋转到 KMD 提供的 DoorbellPhysicalAddress。 同样,Dxgkrnl 需要知道门铃大小。

D3DDDI_CREATEHWQUEUEFLAGS::UserModeSubmission in D3DKMTCreateHwQueue

UMD 设置添加到 D3DDDI_CREATEHWQUEUEFLAGS 的已添加 UserModeSubmission 标志,以便创建使用用户模式提交模型的 HWQueue。 使用此标志创建的 HWQueue 不能使用常规内核模式工作提交路径,并且必须依赖门铃机制在队列上提交工作。

用户模式工作提交 API

添加了以下用户模式 API,以支持用户模式工作提交。

  • D3DKMTCreateDoorbell 为 D3D HWQueue 创建一个门铃,用于用户模式工作提交。

  • D3DKMTConnectDoorbell 将先前创建的门铃连接到 D3D HWQueue,用于用户模式工作提交。

  • D3DKMTDestroyDoorbell 销毁先前创建的门铃。

  • D3DKMTNotifyWorkSubmission 通知 KMD 在 HWQueue 上提交了新工作。 此功能的重点是一个低延迟的工作提交路径,其中 KMD 不参与或不知道何时提交工作。 当在 HWQueue 上提交工作时需要通知 KMD 的情况下,此 API 非常有用。 驱动程序应在特定且不常见的方案中使用此机制,因为它涉及到每次提交工作时从 UMD 到 KMD 的往返,从而违背了低延迟用户模式提交模型的目的。

门铃内存和环形缓冲区分配的驻留模型

  • UMD 负责在创建门铃之前使环形缓冲区和环形缓冲区控制分配驻留。
  • UMD 管理环形缓冲区的生命周期和环形缓冲区控制分配。 即使相应的门铃被销毁,Dxgkrnl 也不会隐式地销毁这些分配。 UMD 负责分配和销毁这些分配。 但是,为了防止恶意用户模式程序在门铃处于活动状态时销毁这些分配,Dxgkrnl 确实会在门铃的生命周期内对这些分配进行引用。
  • Dxgkrnl 销毁环形缓冲区分配的唯一方案是在设备终止期间。 Dxgkrnl 销毁与设备关联的所有 HWQueue、门铃和环形缓冲区分配。
  • 只要环形缓冲区分配处于活动状态,环形缓冲区 CPUVA 始终有效,并且可供 UMD 访问,而与门铃连接状态无关。 也就是说,环形缓冲区驻留与门铃无关。
  • 当 KMD 进行 DXG 回调以断开门铃时(即,调用状态为 D3DDDI_DOORBELL_STATUS_DISCONNECTED_RETRY 的 DxgkCbDisconnectDoorbell),Dxgkrnl 将门铃 CPUVA 旋转到一个虚拟页面。 它不会收回或取消映射环形缓冲区分配。
  • 在任何设备丢失的情况下(TDR/GPU 停止/分页等),Dxgkrnl 会断开门铃并将状态标记为 D3DDDI_DOORBELL_STATUS_DISCONNECTED_ABORT。 用户模式负责销毁 HWQueue、门铃、环形缓冲区并重新创建它们。 此要求类似于在此方案中销毁和重新创建其他设备资源的方式。

硬件上下文挂起

当 OS 挂起硬件上下文时,Dxgkrnl 会保持门铃连接处于活动状态,并常驻环形缓冲区(工作队列)分配。 通过这种方式,UMD 可以继续排队工作到上下文;当上下文被挂起时,这项工作不会被安排。 一旦上下文被恢复和计划,GPU 的上下文管理处理器 (CMP) 就会观察新的写入指针和工作提交。

此逻辑类似于当前的内核模式提交逻辑,其中 UMD 可以使用挂起的上下文调用 D3DKMTSubmitCommandDxgkrnl 将这个新命令排队到 HwQueue,但直到以后才能被安排。

在硬件上下文挂起和恢复期间,会发生以下事件序列。

  • 挂起硬件上下文:

    1. Dxgkrnl 调用 DxgkddiSuspendContext
    2. KMD 从 HW 计划程序列表中删除上下文的所有 HWQueue。
    3. 门铃仍处于连接状态,环形缓冲区/环形缓冲区控制分配仍处于驻留状态。 UMD 可以将新命令写入此上下文的 HWQueue,但 GPU 不会处理它们,这类似于当前内核模式命令提交到挂起的上下文。
    4. 如果 KMD 选择破坏挂起的 HWQueue 的门铃,则 UMD 将断开其连接。 UMD 可以尝试重新连接门铃,KMD 将为该队列分配一个新的门铃。 其目的不是使 UMD 停滞,而是允许它继续提交工作;一旦上下文恢复,HW 引擎最终可以处理这些工作。
  • 恢复硬件上下文:

    1. Dxgkrnl 调用 DxgkddiResumeContext
    2. KMD 将上下文的所有 HWQueue 添加到 HW 计划程序的列表。

引擎 F 状态转换

在传统的内核模式工作提交中,Dxgkrnl 负责向 HWQueue 提交新命令,并监视来自 KMD 的完成中断。 因此,Dxgkrnl 可以完整地了解引擎何时处于活动状态和空闲状态。

在用户模式工作提交中,Dxgkrnl 使用 TDR 超时节奏监视 GPU 引擎是否正在进行。因此,如果值得在两秒的 TDR 超时时间之前启动到 F1 状态的转换,KMD 可以请求 OS 执行此操作。

为了促进这一做法,作出了以下改动:

  • DXGK_INTERRUPT_GPU_ENGINE_STATE_CHANGE 中断类型被添加到 DXGK_INTERRUPT_TYPE。 KMD 使用此中断通知 Dxgkrnl 引擎状态转换,这些转换需要 GPU 电源操作或超时恢复,例如 Active -> TransitionToF1Active -> Hung

  • EngineStateChange 中断数据结构被添加到 DXGKARGCB_NOTIFY_INTERRUPT_DATA

  • 添加了 DXGK_ENGINE_STATE 枚举来表示 EngineStateChange 的引擎状态转换。

当 KMD 引发 DXGK_INTERRUPT_GPU_ENGINE_STATE_CHANGE 中断时,EngineStateChange.NewState 设置为 DXGK_ENGINE_STATE_TRANSITION_TO_F1Dxgkrnl 会断开此引擎上 HWQueue 的所有门铃的连接,然后启动 F0 到 F1 电源组件转换。

当 UMD 试图在 F1 状态下向 GPU 引擎提交新工作时,需要重新连接门铃,这反过来又会导致 Dxgkrnl 启动转换回 F0 电源状态。

引擎 D 状态转换

在 D0 到 D3 设备电源状态转换期间,Dxgkrnl 挂起 HWQueue,断开门铃(将门铃 CPUVA 旋转到虚拟页面),并将 DoorbellStatusCpuVirtualAddress 门铃状态更新为 D3DDDI_DOORBELL_STATUS_DISCONNECTED_RETRY。

如果 UMD 在 GPU 处于 D3 时调用 D3DKMTConnectDoorbell,则会强制 Dxgkrnl 将 GPU 唤醒到 D0。 Dxgkrnl 还负责恢复 HWQueue,并将门铃 CPUVA 旋转到物理门铃位置。

事件发生的顺序如下。

  • 发生 D0 到 D3 GPU 断电:

    1. 对于 GPU 上的所有 HW 上下文,Dxgkrnl 调用 DxgkddiSuspendContext。 KMD 从 HW 计划程序列表中删除这些上下文。
    2. Dxgkrnl 断开所有门铃的连接。
    3. 如有必要,Dxgkrnl 可能会从 VRAM 中逐出所有环形缓冲区/环形缓冲区控制分配。 一旦所有上下文都被挂起并从硬件计划程序的列表中删除,硬件就不会引用任何被逐出的内存。
  • 当 GPU 处于 D3 状态时,UMD 会将新命令写入 HWQueue:

    1. UMD 看到门铃已断开连接,因此调用 D3DKMTConnectDoorbell
    2. Dxgkrnl 启动 D0 转换。
    3. Dxgkrnl 使所有环形缓冲区/环形缓冲区控制分配都驻留(如果它们被逐出)。
    4. Dxgkrnl 调用 KMD 的 DxgkddiCreateDoorbell 函数,以请求 KMD 为此 HWQueue 建立门铃连接。
    5. Dxgkrnl 为所有 HWContext 调用 DxgkddiResumeContext。 KMD 将相应的队列添加到 HW 计划程序列表。

用户模式工作提交的 DDI

KMD 实现的 DDI

为 KMD 添加了以下内核模式 DDI,以实现对用户模式工作提交的支持。

Dxgkrnl 实现的 DDI

DxgkCbDisconnectDoorbell 回调由 Dxgkrnl 实现。 KMD 可以调用此函数来通知 Dxgkrnl KMD 需要断开特定门铃的连接。

HW 队列进度围栏更改

在 UM 工作提交模型中运行的硬件队列仍有一个单调递增的进度围栏值的概念,UMD 在命令缓冲区完成后生成和写入该值。 为了使 Dxgkrnl 知道特定硬件队列是否具有挂起的工作,UMD 需要在将新的命令缓冲区追加到环形缓冲区并使其对 GPU 可见之前更新排队进度围栏值。 CreateDoorbell.HwQueueProgressFenceLastQueuedValueCPUVirtualAddress 是最新排队值读取/写入用户模式进程映射。

UMD 必须确保在新提交对 GPU 可见之前立即更新排队值。 以下步骤是推荐的操作顺序。 它们假定 HW 队列处于空闲状态,最后一个完成的缓冲区的进度围栏值为 N

  • 生成一个新的进度围栏值 N+1
  • 填写命令缓冲区。 命令缓冲区的最后一个指令是写入 N+1 的进度围栏值。
  • 通过将 (HwQueueProgressFenceLastQueuedValueCPUVirtualAddress) 设置为 N+1,通知 OS 新排队的值。
  • 通过将命令缓冲区添加到环形缓冲区,使命令缓冲区对 GPU 可见。
  • 按门铃。

正常和异常进程终止

以下事件序列发生在正常进程终止期间。

对于设备/上下文的每个 HWQueue:

  1. Dxgkrnl 调用 DxgkDdiDisconnectDoorbell 以断开门铃的连接。
  2. Dxgkrnl 等待最后一个排队的 HwQueueProgressFenceLastQueuedValueCPUVirtualAddress 在 GPU 上完成。 环形缓冲区/环形缓冲区控制分配保持驻留。
  3. Dxgkrnl 的等待已得到满足,现在可以销毁环形缓冲区/环缓冲区控制分配以及门铃和 HWQueue 对象。

以下事件序列发生在异常进程终止期间。

  1. Dxgkrnl 将设备标记为出错。

  2. 对于每个设备上下文,Dxgkrnl 调用 DxgkddiSuspendContext 以挂起上下文。 环形缓冲区/环形缓冲区控制分配仍然驻留。 KMD 抢占上下文,并将其从其 HW 运行列表中删除。

  3. 对于上下文的每个 HWQueue,Dxglrnl

    a. 调用 DxgkDdiDisconnectDoorbell 以断开门铃的连接。

    b. 销毁环形缓冲区/环形缓冲区控制分配,以及门铃和 HWQueue 对象。

伪代码示例

UMD 中的工作提交伪代码

以下伪代码是 UMD 使用门铃 API 创建工作并将工作提交给 HWQueue 的模型的基本示例。 请将 hHWqueue1 视为使用现有 D3DKMTCreateHwQueue API 创建并具有 UserModeSubmission 标志的 HWQueue 的句柄。

// Create a doorbell for the HWQueue
D3DKMT_CREATE_DOORBELL CreateDoorbell = {};
CreateDoorbell.hHwQueue = hHwQueue1;
CreateDoorbell.hRingBuffer = hRingBufferAlloc;
CreateDoorbell.hRingBufferControl = hRingBufferControlAlloc;
CreateDoorbell.Flags.Value = 0;

NTSTATUS ApiStatus =  D3DKMTCreateDoorbell(&CreateDoorbell);
if(!NT_SUCCESS(ApiStatus))
  goto cleanup;

assert(CreateDoorbell.DoorbellCPUVirtualAddress!=NULL && 
      CreateDoorbell.DoorbellStatusCPUVirtualAddress!=NULL);

// Get a CPUVA of Ring buffer control alloc to obtain write pointer.
// Assume the write pointer is at offset 0 in this alloc
D3DKMT_LOCK2 Lock = {};
Lock.hAllocation = hRingBufferControlAlloc;
ApiStatus = D3DKMTLock2(&Lock);
if(!NT_SUCCESS(ApiStatus))
  goto cleanup;

UINT64* WritePointerCPUVirtualAddress = (UINT64*)Lock.pData;

// Doorbell created successfully. Submit command to this HWQueue

UINT64 DoorbellStatus = 0;
do
{
  // first connect the doorbell and read status
  ApiStatus = D3DKMTConnectDoorbell(hHwQueue1);
  D3DDDI_DOORBELL_STATUS DoorbellStatus = *(UINT64*(CreateDoorbell.DoorbellStatusCPUVirtualAddress));

  if(!NT_SUCCESS(ApiStatus) ||  DoorbellStatus == D3DDDI_DOORBELL_STATUS_DISCONNECTED_ABORT)
  {
    // fatal error in connecting doorbell, destroy this HWQueue and re-create using traditional kernel mode submission.
    goto cleanup_fallback;
  }

  // update the last queue progress fence value
  *(CreateDoorbell.HwQueueProgressFenceLastQueuedValueCPUVirtualAddress) = new_command_buffer_progress_fence_value;

  // write command to ring buffer of this HWQueue
  *(WritePointerCPUVirtualAddress) = address_location_of_command_buffer;

  // Ring doorbell by writing the write pointer value into doorbell address. 
  *(CreateDoorbell.DoorbellCPUVirtualAddress) = *WritePointerCPUVirtualAddress;

  // Check if submission succeeded by reading doorbell status
  DoorbellStatus = *(UINT64*(CreateDoorbell.DoorbellStatusCPUVirtualAddress));
  if(DoorbellStatus == D3DDDI_DOORBELL_STATUS_CONNECTED_NOTIFY)
  {
      D3DKMTNotifyWorkSubmission(CreateDoorbell.hDoorbell);
  }

} while (DoorbellStatus == D3DDDI_DOORBELL_STATUS_DISCONNECTED_RETRY);

破坏 KMD 中的门铃伪代码

以下示例说明 KMD 需要如何“虚拟化”并在使用专用门铃的 GPU 上的 HWQueue 之间共享可用门铃。

KMD 的 VictimizeDoorbell() 函数的伪代码:

  • KMD 决定连接到需要破坏并断开连接的 PhysicalDoorbell1 的逻辑门铃 hDoorbell1
  • KMD 调用 DxgkrnlDxgkCbDisconnectDoorbellCB(hDoorbell1->hHwQueue)
    • Dxgkrnl 将此门铃的 UMD 可见 CPUVA 旋转到虚拟页面,并将状态值更新为 D3DDDI_DOORBELL_STATUS_DISCONNECTED_RETRY。
  • KMD 重新获得控制权,并进行实际的破坏/断开连接。
    • KMD 破坏 hDoorbell1,并将其与 PhysicalDoorbell1 断开连接。
    • PhysicalDoorbell1 可供使用

现在,请考虑以下方案:

  1. PCI BAR 中有一个物理门铃,内核模式 CPUVA 等于 0xfeedfeee。 为 HWQueue 创建的门铃对象被分配这个物理门铃值。

    HWQueue KMD Handle: hHwQueue1
    Doorbell KMD Handle: hDoorbell1
    Doorbell CPU Virtual Address: CpuVirtualAddressDoorbell1 =>  0xfeedfeee // hDoorbell1 is mapped to 0xfeedfeee
    Doorbell Status CPU Virtual Address: StatusCpuVirtualAddressDoorbell1 => D3DDDI_DOORBELL_STATUS_CONNECTED
    
  2. OS 为不同的 HWQueue2 调用 DxgkDdiCreateDoorbell

    HWQueue KMD Handle: hHwQueue2
    Doorbell KMD Handle: hDoorbell2
    Doorbell CPU Virtual Address: CpuVirtualAddressDoorbell2 => 0 // this doorbell object isn't yet assigned to a physical doorbell  
    Doorbell Status CPU Virtual Address: StatusCpuVirtualAddressDoorbell2 => D3DDDI_DOORBELL_STATUS_DISCONNECTED_RETRY
    
    // In the create doorbell DDI, KMD doesn't need to assign a physical doorbell yet, 
    // so the 0xfeedfeee doorbell is still connected to hDoorbell1
    
  3. OS 在 hDoorbell2 上调用 DxgkDdiConnectDoorbell

    // KMD needs to victimize hDoorbell1 and assign 0xfeedfeee to hDoorbell2. 
    VictimizeDoorbell(hDoorbell1);
    
    // Physical doorbell 0xfeedfeee is now free and can be used vfor hDoorbell2.
    // KMD makes required connections for hDoorbell2 with HW
    ConnectPhysicalDoorbell(hDoorbell2, 0xfeedfeee)
    
    return 0xfeedfeee
    
    // On return from this DDI, *Dxgkrnl* maps 0xfeedfeee to process address space CPUVA i.e:
    // CpuVirtualAddressDoorbell2 => 0xfeedfeee
    
    // *Dxgkrnl* updates hDoorbell2 status to connected i.e:
    // StatusCpuVirtualAddressDoorbell2 => D3DDDI_DOORBELL_STATUS_CONNECTED
    ``
    
    

如果 GPU 使用全局门铃,则不需要此机制。 相反,在本例中,hDoorbell1hDoorbell2 都将被分配相同的 0xfeedfeee 物理门铃。