VM_BIND 锁定

本文档尝试描述正确实现 VM_BIND 锁定所需的内容,包括 userptr mmu_notifier 锁定。它还讨论了一些优化,以避免在最简单的实现中需要遍历所有 userptr 映射和外部/共享对象映射。此外,还有一个部分描述了实现可恢复页面错误所需的 VM_BIND 锁定。

DRM GPUVM 助手集

有一组助手用于实现 VM_BIND 的驱动程序,这组助手实现了本文档中描述的大部分(但不是全部)锁定。特别是,它目前缺少 userptr 实现。本文档不打算详细描述 DRM GPUVM 的实现,但在它自己的文档中进行了介绍。强烈建议任何实现 VM_BIND 的驱动程序使用 DRM GPUVM 助手,并在缺少通用功能时对其进行扩展。

术语

  • gpu_vm:带有元数据的虚拟 GPU 地址空间的抽象。通常每个客户端一个(DRM 文件私有),或者每个执行上下文一个。

  • gpu_vma:gpu_vm 内的 GPU 地址范围的抽象,带有相关的元数据。gpu_vma 的后备存储可以是 GEM 对象,也可以是匿名或页面缓存页面,这些页面也映射到进程的 CPU 地址空间中。

  • gpu_vm_bo:抽象 GEM 对象和 VM 的关联。GEM 对象维护一个 gpu_vm_bos 列表,其中每个 gpu_vm_bo 维护一个 gpu_vmas 列表。

  • userptr gpu_vma userptr:一个 gpu_vma,其后备存储是如上所述的匿名或页面缓存页面。

  • 重新验证:重新验证 gpu_vma 意味着使后备存储的最新版本常驻,并确保 gpu_vma 的页表项指向该后备存储。

  • dma_fence:一个 struct dma_fence,类似于一个 struct completion,它跟踪 GPU 活动。当 GPU 活动完成时,dma_fence 会发出信号。请参阅 dma-buf 文档DMA Fences 部分。

  • dma_resv:一个 struct dma_resv(又名预留对象),用于以多个 dma_fences 的形式跟踪 gpu_vm 或 GEM 对象上的 GPU 活动。dma_resv 包含一个 dma_fences 数组/列表,以及一个在向 dma_resv 添加其他 dma_fences 时需要持有的锁。该锁是一种允许以任意顺序对多个 dma_resv 进行死锁安全锁定的类型。请参阅 dma-buf 文档预留对象 部分。

  • exec 函数:exec 函数是一个重新验证所有受影响的 gpu_vma、提交 GPU 命令批处理并将表示 GPU 命令活动的 dma_fence 注册到所有受影响的 dma_resv 的函数。为完整起见,尽管本文档未涵盖,但值得一提的是,exec 函数也可能是某些驱动程序在计算/长时间运行模式下使用的重新验证工作程序。

  • 本地 对象:一个仅在单个 VM 中映射的 GEM 对象。本地 GEM 对象共享 gpu_vm 的 dma_resv。

  • 外部 对象:又名共享对象:一个可能由多个 gpu_vms 共享且其后备存储可能与其他驱动程序共享的 GEM 对象。

锁和锁定顺序

VM_BIND 的好处之一是本地 GEM 对象共享 gpu_vm 的 dma_resv 对象,因此共享 dma_resv 锁。因此,即使有大量的本地 GEM 对象,也只需要一个锁来使执行序列原子化。

使用以下锁和锁定顺序

  • gpu_vm->lock(可选地是一个 rwsem)。保护 gpu_vm 的数据结构,用于跟踪 gpu_vmas。它还可以保护 gpu_vm 的 userptr gpu_vmas 列表。使用 CPU mm 类比,这将对应于 mmap_lock。rwsem 允许多个读取器同时遍历 VM 树,但这种并发性的好处很可能因驱动程序而异。

  • userptr_seqlock。此锁在读取模式下用于 gpu_vm 的 userptr 列表上的每个 userptr gpu_vma,在 mmu 通知程序失效期间在写入模式下使用。这不是一个真正的 seqlock,但在 mm/mmu_notifier.c 中被描述为“类似于 seqcount 的冲突重试读取端/写入端‘锁’。但是,这允许多个写入端同时持有它...”。读取端临界区由 mmu_interval_read_begin() / mmu_interval_read_retry() 包围,如果持有写入端,则 mmu_interval_read_begin() 会进入睡眠状态。写入端由核心 mm 在调用 mmu 间隔失效通知程序时持有。

  • gpu_vm->resv 锁。保护 gpu_vm 的需要重新绑定的 gpu_vmas 列表,以及所有 gpu_vm 的本地 GEM 对象的驻留状态。此外,它通常保护 gpu_vm 的逐出和外部 GEM 对象列表。

  • gpu_vm->userptr_notifier_lock。这是一个 rwsem,在执行期间以读取模式获取,在 mmu 通知程序失效期间以写入模式获取。userptr 通知程序锁是每个 gpu_vm 的。

  • gem_object->gpuva_lock 此锁保护 GEM 对象的 gpu_vm_bos 列表。这通常与 GEM 对象的 dma_resv 锁相同,但某些驱动程序以不同的方式保护此列表,请参阅下文。

  • gpu_vm 列表 自旋锁。在某些实现中,它们需要能够更新 gpu_vm 的逐出和外部对象列表。对于这些实现,在操作列表时会获取自旋锁。但是,为了避免与 dma_resv 锁发生锁定顺序冲突,在迭代列表时需要一个特殊的方案。

gpu_vm_bos 和 gpu_vmas 的保护和生命周期

GEM 对象的 gpu_vm_bos 列表和 gpu_vm_bo 的 gpu_vmas 列表受 gem_object->gpuva_lock 保护,它通常与 GEM 对象的 dma_resv 相同,但如果驱动程序需要从 dma_fence 发出信号的临界区内访问这些列表,则可以选择使用单独的锁来保护它,该锁可以从 dma_fence 发出信号的临界区内锁定。然后,此类驱动程序需要额外注意在迭代 gpu_vm_bo 和 gpu_vma 列表时需要从循环中获取哪些锁,以避免锁定顺序冲突。

DRM GPUVM 助手集提供 lockdep 断言,即在相关情况下持有此锁,并且还提供了一种方法来使其自身了解实际使用了哪个锁: drm_gem_gpuva_set_lock()

每个 gpu_vm_bo 都持有一个对底层 GEM 对象的引用计数指针,每个 gpu_vma 都持有一个对 gpu_vm_bo 的引用计数指针。当迭代 GEM 对象的 gpu_vm_bos 列表和 gpu_vm_bo 的 gpu_vmas 列表时,必须不要删除 gem_object->gpuva_lock,否则,附加到 gpu_vm_bo 的 gpu_vmas 可能会在没有通知的情况下消失,因为这些没有引用计数。驱动程序可以实现自己的方案来允许这样做,但代价是增加额外的复杂性,但这不在本文档的范围之内。

在 DRM GPUVM 实现中,每个 gpu_vm_bo 和每个 gpu_vma 都持有对 gpu_vm 本身的引用计数。因此,为了避免循环引用计数,gpu_vm 的 gpu_vma 的清理工作不能在 gpu_vm 的析构函数中完成。驱动程序通常会实现一个 gpu_vm 关闭函数来进行此清理。gpu_vm 关闭函数会中止使用此 VM 的 gpu 执行,取消映射所有 gpu_vma 并释放页表内存。

本地对象的重新验证和驱逐

请注意,在下面给出的所有代码示例中,我们都使用了简化的伪代码。特别是,dma_resv 死锁避免算法以及为 dma_resv 栅栏保留内存的部分被省略了。

重新验证

使用 VM_BIND 时,当 gpu 使用 gpu_vm 执行时,所有本地对象都需要常驻内存,并且这些对象需要设置有效的 gpu_vma 指向它们。因此,通常每个 gpu 命令缓冲区的提交之前都会有一个重新验证部分

dma_resv_lock(gpu_vm->resv);

// Validation section starts here.
for_each_gpu_vm_bo_on_evict_list(&gpu_vm->evict_list, &gpu_vm_bo) {
        validate_gem_bo(&gpu_vm_bo->gem_bo);

        // The following list iteration needs the Gem object's
        // dma_resv to be held (it protects the gpu_vm_bo's list of
        // gpu_vmas, but since local gem objects share the gpu_vm's
        // dma_resv, it is already held at this point.
        for_each_gpu_vma_of_gpu_vm_bo(&gpu_vm_bo, &gpu_vma)
               move_gpu_vma_to_rebind_list(&gpu_vma, &gpu_vm->rebind_list);
}

for_each_gpu_vma_on_rebind_list(&gpu vm->rebind_list, &gpu_vma) {
        rebind_gpu_vma(&gpu_vma);
        remove_gpu_vma_from_rebind_list(&gpu_vma);
}
// Validation section ends here, and job submission starts.

add_dependencies(&gpu_job, &gpu_vm->resv);
job_dma_fence = gpu_submit(&gpu_job));

add_dma_fence(job_dma_fence, &gpu_vm->resv);
dma_resv_unlock(gpu_vm->resv);

设置单独的 gpu_vm 重新绑定列表的原因是,可能存在未映射缓冲区对象但也需要重新绑定的 userptr gpu_vma。

驱逐

对其中一个本地对象的驱逐将类似于以下内容

obj = get_object_from_lru();

dma_resv_lock(obj->resv);
for_each_gpu_vm_bo_of_obj(obj, &gpu_vm_bo);
        add_gpu_vm_bo_to_evict_list(&gpu_vm_bo, &gpu_vm->evict_list);

add_dependencies(&eviction_job, &obj->resv);
job_dma_fence = gpu_submit(&eviction_job);
add_dma_fence(&obj->resv, job_dma_fence);

dma_resv_unlock(&obj->resv);
put_object(obj);

请注意,由于该对象是 gpu_vm 本地的,它将共享 gpu_vm 的 dma_resv 锁,因此 obj->resv == gpu_vm->resv。标记为驱逐的 gpu_vm_bo 被放置在 gpu_vm 的驱逐列表上,该列表受 gpu_vm->resv 保护。在驱逐期间,所有本地对象都持有其 dma_resv 锁,并且由于上述相等性,保护 gpu_vm 驱逐列表的 gpu_vm 的 dma_resv 也被锁定。

使用 VM_BIND 时,gpu_vma 在驱逐之前不需要取消绑定,因为驱动程序必须确保驱逐 blit 或复制将等待 GPU 空闲或依赖于所有先前的 GPU 活动。此外,GPU 随后任何尝试通过 gpu_vma 访问已释放内存的操作都将先执行一个新的 exec 函数,其中包含一个重新验证部分,该部分将确保所有 gpu_vma 都被重新绑定。在重新验证时持有对象 dma_resv 的驱逐代码将确保新的 exec 函数不会与驱逐竞争。

驱动程序的实现方式可以是,在每个 exec 函数上,仅选择一部分 vma 进行重新绑定。在这种情况下,所有被选择进行重新绑定的 vma 都必须在提交 exec 函数工作负载之前取消绑定。

使用外部缓冲区对象进行锁定

由于外部缓冲区对象可能被多个 gpu_vm 共享,因此它们不能与单个 gpu_vm 共享其预留对象。相反,它们需要有自己的预留对象。因此,使用一个或多个 gpu_vma 绑定到 gpu_vm 的外部对象被放置在每个 gpu_vm 的列表上,该列表受 gpu_vm 的 dma_resv 锁或gpu_vm 列表自旋锁之一保护。一旦 gpu_vm 的预留对象被锁定,就可以安全地遍历外部对象列表并锁定所有外部对象的 dma_resv。但是,如果使用列表自旋锁,则需要使用更精细的迭代方案。

在驱逐时,外部对象绑定到的所有 gpu_vm 的 gpu_vm_bo 都需要放置在其 gpu_vm 的驱逐列表上。但是,当驱逐外部对象时,通常不持有该对象绑定到的 gpu_vm 的 dma_resv。只能保证持有该对象的私有 dma_resv。如果在驱逐时有 ww_acquire 上下文,我们可以获取这些 dma_resv,但这可能会导致昂贵的 ww_mutex 回滚。一个简单的选择是将已驱逐的 gem 对象的 gpu_vm_bo 标记为一个 evicted 布尔值,在下次需要遍历相应的 gpu_vm 驱逐列表之前检查该布尔值。例如,在遍历外部对象列表并锁定它们时。那时,gpu_vm 的 dma_resv 和对象的 dma_resv 都被持有,并且标记为驱逐的 gpu_vm_bo 可以添加到 gpu_vm 的已驱逐 gpu_vm_bo 列表中。evicted 布尔值由对象的 dma_resv 正式保护。

exec 函数变为

dma_resv_lock(gpu_vm->resv);

// External object list is protected by the gpu_vm->resv lock.
for_each_gpu_vm_bo_on_extobj_list(gpu_vm, &gpu_vm_bo) {
        dma_resv_lock(gpu_vm_bo.gem_obj->resv);
        if (gpu_vm_bo_marked_evicted(&gpu_vm_bo))
                add_gpu_vm_bo_to_evict_list(&gpu_vm_bo, &gpu_vm->evict_list);
}

for_each_gpu_vm_bo_on_evict_list(&gpu_vm->evict_list, &gpu_vm_bo) {
        validate_gem_bo(&gpu_vm_bo->gem_bo);

        for_each_gpu_vma_of_gpu_vm_bo(&gpu_vm_bo, &gpu_vma)
               move_gpu_vma_to_rebind_list(&gpu_vma, &gpu_vm->rebind_list);
}

for_each_gpu_vma_on_rebind_list(&gpu vm->rebind_list, &gpu_vma) {
        rebind_gpu_vma(&gpu_vma);
        remove_gpu_vma_from_rebind_list(&gpu_vma);
}

add_dependencies(&gpu_job, &gpu_vm->resv);
job_dma_fence = gpu_submit(&gpu_job));

add_dma_fence(job_dma_fence, &gpu_vm->resv);
for_each_external_obj(gpu_vm, &obj)
       add_dma_fence(job_dma_fence, &obj->resv);
dma_resv_unlock_all_resv_locks();

相应的共享对象感知驱逐将如下所示

obj = get_object_from_lru();

dma_resv_lock(obj->resv);
for_each_gpu_vm_bo_of_obj(obj, &gpu_vm_bo)
        if (object_is_vm_local(obj))
             add_gpu_vm_bo_to_evict_list(&gpu_vm_bo, &gpu_vm->evict_list);
        else
             mark_gpu_vm_bo_evicted(&gpu_vm_bo);

add_dependencies(&eviction_job, &obj->resv);
job_dma_fence = gpu_submit(&eviction_job);
add_dma_fence(&obj->resv, job_dma_fence);

dma_resv_unlock(&obj->resv);
put_object(obj);

在未持有 dma_resv 锁的情况下访问 gpu_vm 的列表

某些驱动程序在访问 gpu_vm 的驱逐列表和外部对象列表时会持有 gpu_vm 的 dma_resv 锁。但是,有些驱动程序需要在未持有 dma_resv 锁的情况下访问这些列表,例如由于 dma_fence 发信号关键路径内的异步状态更新。在这种情况下,可以使用自旋锁来保护列表的操作。但是,由于在迭代列表时需要为每个列表项获取更高层次的休眠锁,因此已经迭代过的项目需要临时移动到私有列表,并在处理每个项目时释放自旋锁

由于额外的锁定和原子操作,那些可以避免在 dma_resv 锁之外访问 gpu_vm 列表的驱动程序可能也想避免此迭代方案。特别是,如果驱动程序预计会有大量列表项。对于预计列表项数量较少、列表迭代不经常发生或者每次迭代都有显著额外成本的列表,与此类迭代相关的原子操作开销很可能可以忽略不计。请注意,如果使用此方案,则必须确保此列表迭代受外部锁或信号量的保护,因为列表项在迭代期间会被临时从列表中取出,并且值得一提的是,本地列表 still_in_list 也应被视为受 gpu_vm->list_lock 保护,因此也可能同时从本地列表和列表迭代中删除项目。

请参考DRM GPUVM 锁定部分及其内部的get_next_vm_bo_from_list()函数。

userptr gpu_vmas

userptr gpu_vma 是一种 gpu_vma,它不是将缓冲区对象映射到 GPU 虚拟地址范围,而是直接映射 CPU mm 范围内的匿名或文件页缓存页。一种非常简单的方法是在绑定时使用 pin_user_pages() 固定页面,并在取消绑定时取消固定它们,但这会创建一个拒绝服务向量,因为单个用户空间进程能够固定系统内存中的所有页面,这是不可取的。(对于特殊用例,并假设适当的记账,固定可能仍然是理想的功能)。在一般情况下,我们需要做的是获取对所需页面的引用,确保在 CPU mm 取消映射页面之前使用 MMU 通知器通知我们,如果这些页面没有以只读方式映射到 GPU,则将其标记为脏,然后删除引用。当 MMU 通知器通知我们 CPU mm 即将删除页面时,我们需要通过等待 MMU 通知器中的 VM 空闲来停止 GPU 对页面的访问,并确保在下次 GPU 尝试访问 CPU mm 范围中当前存在的任何内容之前,我们从 GPU 页表中取消映射旧页面并重复获取新页面引用的过程。(请参阅下面的通知器示例)。请注意,当核心 mm 决定清理页面时,我们会收到这样一个取消映射 MMU 通知,并且可以在下次 GPU 访问之前再次将页面标记为脏。我们还会收到类似的 NUMA 记账 MMU 通知,GPU 驱动程序实际上并不需要关心这些通知,但到目前为止,事实证明很难排除某些通知。

pin_user_pages() 文档中描述了使用 MMU 通知器进行设备 DMA(和其他方法)的方法。

现在,不幸的是,在 dma_resv 锁下不能使用 get_user_pages() 获取 struct page 引用的方法,因为这会违反 dma_resv 锁与在解析 CPU 缺页时获取的 mmap_lock 的锁定顺序。这意味着 gpu_vm 的 userptr gpu_vma 列表需要由外部锁保护,在我们的示例中,该外部锁是 gpu_vm->lock

userptr gpu_vma 的 MMU 区间序列锁以以下方式使用

// Exclusive locking mode here is strictly needed only if there are
// invalidated userptr gpu_vmas present, to avoid concurrent userptr
// revalidations of the same userptr gpu_vma.
down_write(&gpu_vm->lock);
retry:

// Note: mmu_interval_read_begin() blocks until there is no
// invalidation notifier running anymore.
seq = mmu_interval_read_begin(&gpu_vma->userptr_interval);
if (seq != gpu_vma->saved_seq) {
        obtain_new_page_pointers(&gpu_vma);
        dma_resv_lock(&gpu_vm->resv);
        add_gpu_vma_to_revalidate_list(&gpu_vma, &gpu_vm);
        dma_resv_unlock(&gpu_vm->resv);
        gpu_vma->saved_seq = seq;
}

// The usual revalidation goes here.

// Final userptr sequence validation may not happen before the
// submission dma_fence is added to the gpu_vm's resv, from the POW
// of the MMU invalidation notifier. Hence the
// userptr_notifier_lock that will make them appear atomic.

add_dependencies(&gpu_job, &gpu_vm->resv);
down_read(&gpu_vm->userptr_notifier_lock);
if (mmu_interval_read_retry(&gpu_vma->userptr_interval, gpu_vma->saved_seq)) {
       up_read(&gpu_vm->userptr_notifier_lock);
       goto retry;
}

job_dma_fence = gpu_submit(&gpu_job));

add_dma_fence(job_dma_fence, &gpu_vm->resv);

for_each_external_obj(gpu_vm, &obj)
       add_dma_fence(job_dma_fence, &obj->resv);

dma_resv_unlock_all_resv_locks();
up_read(&gpu_vm->userptr_notifier_lock);
up_write(&gpu_vm->lock);

mmu_interval_read_begin()mmu_interval_read_retry() 之间的代码标记了我们称为 userptr_seqlock 的读取侧临界区。实际上,gpu_vm 的 userptr gpu_vma 列表会被循环遍历,并且会检查所有其 userptr gpu_vma,尽管我们这里只显示了一个。

userptr gpu_vma MMU 失效通知器可能会从回收上下文中调用,并且为了再次避免锁定顺序冲突,我们无法从其中获取任何 dma_resv 锁或 gpu_vm->lock。

bool gpu_vma_userptr_invalidate(userptr_interval, cur_seq)
{
        // Make sure the exec function either sees the new sequence
        // and backs off or we wait for the dma-fence:

        down_write(&gpu_vm->userptr_notifier_lock);
        mmu_interval_set_seq(userptr_interval, cur_seq);
        up_write(&gpu_vm->userptr_notifier_lock);

        // At this point, the exec function can't succeed in
        // submitting a new job, because cur_seq is an invalid
        // sequence number and will always cause a retry. When all
        // invalidation callbacks, the mmu notifier core will flip
        // the sequence number to a valid one. However we need to
        // stop gpu access to the old pages here.

        dma_resv_wait_timeout(&gpu_vm->resv, DMA_RESV_USAGE_BOOKKEEP,
                              false, MAX_SCHEDULE_TIMEOUT);
        return true;
}

当此失效通知器返回时,GPU 不再可以访问 userptr gpu_vma 的旧页面,并且需要在新的 GPU 提交成功之前重新进行页面绑定。

高效的 userptr gpu_vma exec_function 迭代

如果 gpu_vm 的 userptr gpu_vma 列表变得很大,则在每个 exec 函数上迭代完整的 userptr 列表以检查每个 userptr gpu_vma 的保存序列号是否过时是低效的。一个解决方案是将所有失效的 userptr gpu_vma 放在单独的 gpu_vm 列表上,并且仅在每个 exec 函数上检查此列表上存在的 gpu_vma。然后,此列表将非常适合自旋锁迭代部分中描述的自旋锁锁定方案,因为在 mmu 通知器中,我们在此处将失效的 gpu_vma 添加到列表中,因此不可能获取任何外部锁,例如 gpu_vm->lockgpu_vm->resv 锁。请注意,如该部分所述,在迭代时仍需要获取 gpu_vm->lock 以确保列表完整。

如果像这样使用失效的 userptr 列表,则 exec 函数中的重试检查会简单地变为对失效列表是否为空的检查。

绑定和取消绑定时的锁定

在绑定时,假设一个 GEM 对象由 gpu_vma 支持,每个 gpu_vma 都需要与一个 gpu_vm_bo 关联,而该 gpu_vm_bo 又需要添加到 GEM 对象的 gpu_vm_bo 列表,并且可能添加到 gpu_vm 的外部对象列表中。这被称为链接 gpu_vma,通常需要持有 gpu_vm->lockgem_object->gpuva_lock。在取消链接 gpu_vma 时,应持有相同的锁,这确保了当迭代 gpu_vmas 时,无论是在 gpu_vm->resv 下还是在 GEM 对象的 dma_resv 下,只要迭代所在的锁不被释放,gpu_vmas 就会保持存活。对于 userptr gpu_vmas,类似地,在 vma 销毁期间需要持有外部的 gpu_vm->lock,否则,当在上一节描述的无效 userptr 列表上迭代时,没有任何东西能保持这些 userptr gpu_vmas 存活。

用于可恢复页错误的页表更新的锁定

对于可恢复的页错误,我们需要确保两件重要的事情

  • 当我们把页面返回给系统/分配器以重用时,应该没有剩余的 GPU 映射,并且任何 GPU TLB 都必须被刷新。

  • gpu_vma 的取消映射和映射不得竞争。

由于 GPU pte 的取消映射(或清理)通常发生在难以甚至不可能获取任何外部级别锁定的地方,我们必须引入一个在映射和取消映射时都持有的新锁,或者查看我们在取消映射时确实持有的锁,并确保它们在映射时也被持有。对于 userptr gpu_vmas,在 mmu 无效通知器中进行清理时,userptr_seqlock 以写入模式持有。因此,如果在映射期间以读取模式持有 userptr_seqlock 以及 gpu_vm->userptr_notifier_lock,它就不会与清理竞争。对于 GEM 对象支持的 gpu_vmas,清理将在 GEM 对象的 dma_resv 下进行,并确保在为指向 GEM 对象的任何 gpu_vma 填充页表时也持有 dma_resv,这将同样确保我们没有竞争。

如果映射的任何部分在 dma-fence 下以异步方式执行,并且这些锁被释放,则清理需要在开始修改页表之前等待该 dma-fence 在相关锁下发出信号。

由于以释放页表内存的方式修改页表结构也可能需要外部级别的锁,因此 GPU pte 的清理通常只专注于将页表或页目录条目清零和刷新 TLB,而页表内存的释放则被推迟到取消绑定或重新绑定时。