Intel Discrete GPU - Memory - Map / Page Fault
一块显存分配完成后,通常要根据它的使用场景来决定是否经过 CPU 或 GPU 的访问,一旦涉及到某一方的访问,那么必然要在访问前做地址映射,所谓映射在系统底层的视角来看的话,或许也可以称之为对应页表的填充。
当前独立显存加入到整个系统后,访存的链路将变得稍显复杂。
CPU / GPU 均可以访问系统内存和独立显存,独立显存通过 PCIE 接入总线后,在 CPU 的视角中,仅是多了份总线地址空间,CPU 可通过 MMU 直接访问。
独立显存在 GPU 板载集成,可通过内部总线直接访问。需要注意的是,GPU 也存在类 MMU 的 IP,它一般是厂商自己制造,在 Intel 独显中,它被称之为 GAM;
而 GPU 访问系统内存时,则需要经过自身的 GAM 与系统中外设专用的 SMMU 才能到达目标系统内存,这其中就涉及到两层页表。
大致访存链路如下,

理清大致的系统结构后,我们步入软件的世界,来看看 Xe 驱动中是怎样将显存映射到 CPU / GPU 的,
先从 CPU 映射开始看。
CPU MAP
当 Kernel 中完成一次显存 BO 分配时,还要与 DRM 子系统进行关联后,才能被 CPU MAP 操作时被找到,其关联的桥梁为drm_vma_offset_node,这个 offset 是针对一个drm_device而言,也就是在 Xe 驱动中,这个vma offset空间只有一个,一块 BO 插入到空间的接口为drm_vma_offset_add;
除此之外,BO 分配完成后还会通过drm_gem_handle_create创建 Handle 并将其传回来用户空间,用户空间将这个 Handle 作为 fd 来找到 BO;需要注意的是,Handle 的作用域只有 Xe 的内部,DRM 查找 BO 要根据vma offset;
用户空间仅能与 DRM 子系统直接对接,那么,CPU 映射一块显存就需要两步,第一步是通过 fd 转换为vma offset,第二步是通过 offset 继而 mmap;
以下是 Mesa 项目中对 BO 分配及 CPU MAP 的一个示例流程,
struct drm_xe_gem_create gem_create = {
.size = size_in_bytes,
.cpu_caching = DRM_XE_GEM_CPU_CACHING_WB,
.placement = 1u << ec->devinfo->mem.sram.mem.instance,
};
intel_ioctl(ec->fd, DRM_IOCTL_XE_GEM_CREATE, &gem_create);
struct drm_xe_gem_mmap_offset mm = {
.handle = gem_create.handle,
};
intel_ioctl(ec->fd, DRM_IOCTL_XE_GEM_MMAP_OFFSET, &mm);
bo->handle = gem_create.handle;
bo->map = mmap(NULL, size_in_bytes, PROT_READ | PROT_WRITE,
MAP_SHARED, ec->fd, mm.offset);
映射流程会通过 DRM 最终到达 Xe mmap 中,Xe 中没有做过多的操作,重点在于将对应 vma 的 ops 配置为xe_gem_vm_ops,其 fault 成员为xe_gem_fault;
CPU Fault
当 CPU 访问所映射的 BO 空间后将会触发 Page Fault,那么xe_gem_fault将会被调用,首先内部会通过 vma 中关联的信息取出 BO,
先通过ttm_io_prot取出 BO 分配时所配置的 Cache 策略,然后根据 BO 的存在位置做出不同的操作流程,
BO 存在于系统内存,则
- 要通过
ttm_tt_populate确认 BO 内存为 Pin 住状态,其中可能涉及内存 swap - 通过
ttm->pages[page_offset]取出内存对应 PFN
BO 存在于独立显存,刚
- pgprot_decrypted ??TODO
- 通过
ttm_bo_io_mem_pfn(bo, page_offset)取出内存对应 PFN
最终,我们已经拿到的内存对应的 PFN 和 PROT,那么则可以通过vmf_insert_pfn_prot来完成 CPU 侧的映射页表填充。
CPU 缺页这里还存在个小优化,它是 prefault,因为显存的分配和使用往往是大小对应的,我们可以假设一块 BO 在 CPU 需要访问时会被大面积访问到,那到预先 Page Fault 的确能够节省很多开销。在 Xe 驱动中,num_prefault的大小默认配置为TTM_BO_VM_NUM_PREFAULT = 16;
GPU MAP
GPU 映射显存的过程要比 CPU 映射复杂些,因为在显存分配章节我们曾提过,BO 的背后所代表的实际显存,可能不仅仅是 Xe 内部分配的,它还可能是 DMA-BUF 或 USER-PTR,然而 CPU 映射这两种情况的话再普通不过,我们没有提及。但在 GPU 映射的场景,它们的映射与 Xe 驱动的实现息息相关。
BO 背后显存实际类型共有 VRAM / SRAM / DMA-BUF / USER-PTR, 我们在上面提到过,除 VRAM 类型外,像其它三种类似都会存储在系统内存中,而 GPU 到达系统内存的映射不仅需要 GPU 内部的映射,还要外系统 SMMU 的映射。
先来看看三种类型的 SMMU 映射时机是什么阶段,
- SRAM - 在 BO 初次创建在 validate 后的 move 流程中,会通过
xe_tt_map_sg接口,并将所得到的 sg 保存至xe_tt->sg - DMA-BUF - 这里指的是从外部引入到 Xe 的 DMA-BUF,应用层通过调用
fd to handle后,Xe 驱动中的xe_gem_prime_import会被触发,在 Xe 中随即要创建 BO 与 DMA-BUF 进行关联,这个 BO 比较特殊,其 type 为ttm_bo_type_sg,且ttm_tt的 flags 带有TTM_TT_FLAG_EXTERNAL属性,最重要的是,这个 BO 不会再另开辟新的内存空间;触发 SMMU 映射时机的话,与下面的 USER-PTR 类型一起提及; - USER-PTR - GPU 映射 BO 的入口为
XE_VM_BIND,关于 DMA-BUF 与 USER-PTR 的 SMMU 侧映射均在 vm bind 流程中体现;其中通过xe_vma_userptr_pin_pages借助 hmm 主动缺页补足 USER-PTR 所对应的全部虚拟空间,再通过xe_build_sg完成映射;而针对 DMA-BUF 类型,在 vm bind 过程,会通过vm_bind_ioctl_ops_lock_and_prep来做资源准备,其中会再次通过 validate 确认 BO 资源可用,这一次的调用中xe_bo_move_dmabuf将被涉及,其中就通过dma_buf_map_attachment完成了对 DMA-BUF 的 SMMU 侧映射;
几种涉及 SMMU 映射类型的流程,现在已大致表述完成。其实 vm bind 过程中还有一个很重要的概念没有被提及,它是xe_vma,一段地址空间的映射需要 VA 和 PA,xe_vma的作用是管理其中的 VA;其核心成员为drm_gpuva,它作为与 DRM 子系统连接的桥梁。
需要注意的是,xe_vma在这里只负责管理 VA,其中并不包含 VA 的分配,因为在 Xe GPU 软件实现中,VA 的分配是在用户态驱动来完成。在 Mesa Xe 用户驱动实现中,VA 往往通过util_vma_heap_alloc_addr来分配。
每一个xe_vma会通过vm_bind_ioctl_ops_parse解析用户下发的请求所构建成的drm_gpuva_ops并创建,因为xe_vma承载着实际显存的 Bind 使命,它要与所有使用到的显存都存在联系,

以上流程,完成了 GPU 映射显存时的部分准备工作。现在开始 GPU 自身页表的映射构建流程,上面有提及,这一块的流程可称之为vm bind;
Vm bind 首先要解析出用户的 bind 请求,单次请求中的 Bind 显存数可以是多个,除 USER-PTR 外,用户均需要通过 Gem Handle 指定所需 Bind 显存目标。
以下是 Mesa 项目中对 BO 做 GPU MAP 的一个示例流程,
struct drm_xe_vm_bind_op bind_ops[] = {
{
.op = DRM_XE_VM_BIND_OP_MAP,
.obj = ec->bo.batch.handle,
.addr = ec->bo.batch.addr,
.range = EXECUTOR_BO_SIZE,
.pat_index = ec->devinfo->pat.cached_coherent.index,
},
};
struct drm_xe_sync bind_syncs[] = {
{
.type = DRM_XE_SYNC_TYPE_SYNCOBJ,
.handle = sync_handles[0],
.flags = DRM_XE_SYNC_FLAG_SIGNAL,
},
};
struct drm_xe_vm_bind bind = {
.vm_id = ec->xe.vm_id,
.num_binds = ARRAY_SIZE(bind_ops),
.vector_of_binds = (uintptr_t)bind_ops,
.num_syncs = 1,
.syncs = (uintptr_t)bind_syncs,
};
intel_ioctl(ec->fd, DRM_IOCTL_XE_VM_BIND, &bind);
千呼万唤,基于以上前置工作,可以开始最重要的映射 GPU 内部页表映射环节。
映射流程可拆解为两步,
-
第一步是 PTE Entry 的构建 - Entry 值的构建在 Xe 驱动中称之为 encode,这一过程主要在
xe_pt_stage_bind中来完成。Encode 之初,会根据所映射的内存类型对 pte 赋予一些 default 值,如XE_PPGTT_PTE_DM等,其代表映射的内存在 VRAM 上。之后通过xe_pt_walk_range调用pt_entry回调来完成每一阶页表的填充,如 PDE / PTE 等,在 Xe 驱动中这个回调为xe_pt_stage_bind_entry;在页表创建的过程中,如果 PDE 需要新建则通过
pde_encode_bo构造 PDE Value 并通过xe_pt_insert_entry插入至上级页表项中,这个 insert 操作可能不能即刻执行的,需要考虑 GPU 的异步性,所以可能会延后更新。PTE 的构建通过pte_encode_vma来完成,除了填充 PA 外,还补充了一些页表项属性,如XE_PAGE_PRESENT/XE_PAGE_RW等。关于 PA 的获取,我们已经了解显存多种类型,那么 PA 的获取方式要根据类型进行区分,来看看 Xe 中是如何实现的,
if (xe_vma_is_userptr(vma)) xe_res_first_sg(to_userptr_vma(vma)->userptr.sg, 0, xe_vma_size(vma), &curs); else if (xe_bo_is_vram(bo) || xe_bo_is_stolen(bo)) xe_res_first(bo->ttm.resource, xe_vma_bo_offset(vma), xe_vma_size(vma), &curs); else xe_res_first_sg(xe_bo_sg(bo), xe_vma_bo_offset(vma), xe_vma_size(vma), &curs); -
第二步便是刚刚提到的页表项 Entry 的更新 - 这一块的操作在
xe_migrate_update_pgtables所涵盖,其中包含通过xe_migrate_update_pgtables_cpu通过 CPU 立即写入页表,但它的前提是所更新的 GPU 任务的 vm 中没有任务依赖在 pending,通过xe_pt_vm_dependencies判断;其次是通过 GPU 来更新页表,实现的接口是__xe_migrate_update_pgtables,涉及内容较多,这一块的细节我们后续在 Migrate 章节分析;
GPU Fault
Intel GPU 的运行模式一般分为两类,
- Excution List - 内核驱动直接的控制面更广,可以直接影响 GPU 的任务抢占
- GUC(GPU uController) - 在内核驱动与 GPU 硬件之间引入一个协处理器,协处理器需要固件来运行,它作为驱动的辅助作用于硬件之上,能够有更高的效率来控制任务的抢占和 HW Fence 的处理
GPU 缺页的场景仅在 GUC && USM 模式(USM 介绍详见 - 链接)下生效,其核心实现为handle_pagefault,
其流程大致如下,

- 如果 USER-PTR 有发生 Invalide 则需要 repin
- 通过 validate 和 rebind 重新 bind vma,其中 validate 可能涉及 swap 和 move,这两点的原理实现后面章节详细分析
有没有想过xe_vma_userptr_check_repin是怎样判断出 USER-PTR 对应的内存有被 Invalidate 的?其实这是一个 MMU 的在 Invalidate 时的一个 Notify,Xe 驱动中有注册vma_userptr_invalidate回调,如果有发生 invalidate 则 seq 会有更新。