英文

异构内存管理 (HMM)

提供基础设施和帮助程序,以将非传统内存(如板载 GPU 的设备内存)集成到常规内核路径中,其基石是此类内存的专用结构页(请参阅本文档的第 5 至 7 节)。

HMM 还为 SVM(共享虚拟内存)提供可选的帮助程序,即允许设备以与 CPU 一致的方式透明地访问程序地址,这意味着 CPU 上的任何有效指针对于设备来说也是有效指针。这对于简化高级异构计算的使用变得强制性,在高级异构计算中,GPU、DSP 或 FPGA 用于代表进程执行各种计算。

本文档分为以下几个部分:在第一部分中,我阐述了使用特定于设备的内存分配器相关的问题。在第二部分中,我阐述了许多平台固有的硬件限制。第三部分概述了 HMM 设计。第四部分解释了 CPU 页表镜像的工作方式以及 HMM 在此上下文中的用途。第五部分讨论了设备内存如何在内核内部表示。最后,最后一部分介绍了一种新的迁移帮助程序,允许利用设备 DMA 引擎。

使用设备特定内存分配器的问题

具有大量板载内存(数 GB)的设备(如 GPU)历来通过专用驱动程序特定 API 管理其内存。这会在设备驱动程序分配和管理的内存与常规应用程序内存(私有匿名、共享内存或常规文件支持的内存)之间创建断开连接。从这里开始,我将此方面称为拆分地址空间。我使用共享地址空间来指代相反的情况:即,任何应用程序内存区域都可以被设备透明地使用。

发生拆分地址空间是因为设备只能访问通过设备特定 API 分配的内存。这意味着程序中的所有内存对象从设备的角度来看并不相同,这使得依赖于广泛库的大型程序变得复杂。

具体来说,这意味着想要利用 GPU 等设备的代码需要在通用分配的内存(malloc、mmap private、mmap share)和通过设备驱动程序 API 分配的内存之间复制对象(这最终还是会得到 mmap,但设备文件的 mmap)。

对于平面数据集(数组、网格、图像等),这不太难实现,但对于复杂数据集(列表、树等),很难正确实现。复制复杂数据集需要重新映射其每个元素之间的所有指针关系。这是容易出错的,并且由于重复的数据集和地址,程序变得更难调试。

拆分地址空间还意味着库无法透明地使用它们从核心程序或另一个库获得的数据,因此每个库可能必须使用设备特定内存分配器复制其输入数据集。大型项目会受到此影响,并因各种内存复制而浪费资源。

复制每个库 API 以接受由每个设备特定分配器分配的输入或输出内存不是一个可行的选择。这将导致库入口点中的组合爆炸。

最后,随着高级语言结构(在 C++ 中,但在其他语言中也是如此)的进步,编译器现在可以在没有程序员知识的情况下利用 GPU 和其他设备。一些编译器识别的模式只能使用共享地址空间来完成。对于所有其他模式,使用共享地址空间也更合理。

I/O 总线,设备内存特性

由于一些限制,I/O 总线会损害共享地址空间。大多数 I/O 总线只允许从设备到主内存的基本内存访问;即使是缓存一致性通常也是可选的。从 CPU 访问设备内存的限制更多。通常情况下,它不是缓存一致的。

如果我们只考虑 PCIE 总线,那么设备可以访问主内存(通常通过 IOMMU)并与 CPU 保持缓存一致性。但是,它只允许设备对主内存执行有限的一组原子操作。在另一个方向上更糟:CPU 只能访问有限范围的设备内存,并且无法对其执行原子操作。因此,从内核的角度来看,设备内存不能被认为是与常规内存相同的。

另一个不利因素是带宽有限(使用 PCIE 4.0 和 16 通道约为 32GBytes/s)。这比最快的 GPU 内存(1 TBytes/s)少 33 倍。最终的限制是延迟。从设备访问主内存的延迟比设备访问其自身内存的延迟高一个数量级。

一些平台正在开发新的 I/O 总线或 PCIE 的添加/修改,以解决其中一些限制(OpenCAPI、CCIX)。它们主要允许 CPU 和设备之间的双向缓存一致性,并允许架构支持的所有原子操作。可悲的是,并非所有平台都遵循这一趋势,一些主要的架构没有解决这些问题的硬件解决方案。

因此,为了使共享地址空间有意义,我们不仅必须允许设备访问任何内存,还必须允许在设备使用任何内存时将其迁移到设备内存(在此期间阻止 CPU 访问)。

共享地址空间和迁移

HMM 旨在提供两个主要功能。第一个功能是通过在设备页表中复制 CPU 页表来共享地址空间,以便对于进程地址空间中的任何有效主内存地址,相同的地址指向相同的物理内存。

为了实现这一点,HMM 提供了一组帮助程序来填充设备页表,同时跟踪 CPU 页表更新。设备页表更新不像 CPU 页表更新那么容易。要更新设备页表,您必须分配一个缓冲区(或使用一组预分配的缓冲区)并在其中写入 GPU 特定命令以执行更新(取消映射、缓存无效和刷新等)。这不能通过所有设备的通用代码来完成。因此,HMM 提供了帮助程序来分解所有可以分解的内容,同时将硬件特定细节留给设备驱动程序。

HMM 提供的第二个机制是一种新的 ZONE_DEVICE 内存,允许为设备内存的每个页面分配一个 struct page。这些页面很特别,因为 CPU 无法映射它们。但是,它们允许使用现有的迁移机制将主内存迁移到设备内存,并且从 CPU 的角度来看,一切看起来都像是已交换到磁盘的页面。使用 struct page 可以最简单、最干净地与现有的 mm 机制集成。在这里,HMM 只提供帮助程序,首先是为设备内存热插拔新的 ZONE_DEVICE 内存,其次是执行迁移。关于迁移什么和何时迁移的策略决策留给设备驱动程序。

请注意,任何 CPU 访问设备页面都会触发页面错误并迁移回主内存。例如,当支持给定 CPU 地址 A 的页面从主内存页面迁移到设备页面时,任何 CPU 访问地址 A 都会触发页面错误并启动迁移回主内存。

通过这两个功能,HMM 不仅允许设备镜像进程地址空间并保持 CPU 和设备页表同步,还可以通过迁移设备正在积极使用的数据集部分来利用设备内存。

地址空间镜像实现和 API

地址空间镜像的主要目标是允许将 CPU 页表范围复制到设备页表中;HMM 帮助保持两者同步。想要镜像进程地址空间的设备驱动程序必须从注册 mmu_interval_notifier 开始

int mmu_interval_notifier_insert(struct mmu_interval_notifier *interval_sub,
                                 struct mm_struct *mm, unsigned long start,
                                 unsigned long length,
                                 const struct mmu_interval_notifier_ops *ops);

在 ops->invalidate() 回调期间,设备驱动程序必须执行更新操作到范围(将范围标记为只读,或完全取消映射等)。设备必须在驱动程序回调返回之前完成更新。

当设备驱动程序想要填充虚拟地址范围时,可以使用

int hmm_range_fault(struct hmm_range *range);

如果请求写入访问,它将触发缺失或只读条目的页面错误(请参阅下文)。页面错误使用通用 mm 页面错误代码路径,就像 CPU 页面错误一样。使用模式是

int driver_populate_range(...)
{
     struct hmm_range range;
     ...

     range.notifier = &interval_sub;
     range.start = ...;
     range.end = ...;
     range.hmm_pfns = ...;

     if (!mmget_not_zero(interval_sub->notifier.mm))
         return -EFAULT;

again:
     range.notifier_seq = mmu_interval_read_begin(&interval_sub);
     mmap_read_lock(mm);
     ret = hmm_range_fault(&range);
     if (ret) {
         mmap_read_unlock(mm);
         if (ret == -EBUSY)
                goto again;
         return ret;
     }
     mmap_read_unlock(mm);

     take_lock(driver->update);
     if (mmu_interval_read_retry(&ni, range.notifier_seq) {
         release_lock(driver->update);
         goto again;
     }

     /* Use pfns array content to update device page table,
      * under the update lock */

     release_lock(driver->update);
     return 0;
}

driver->update 锁是驱动程序在其 invalidate() 回调中使用的同一把锁。在调用 mmu_interval_read_retry() 之前必须持有该锁,以避免与并发 CPU 页表更新发生任何竞争。

利用 default_flags 和 pfn_flags_mask

hmm_range 结构有两个字段,default_flags 和 pfn_flags_mask,它们指定整个范围的错误或快照策略,而不是必须为 pfns 数组中的每个条目设置它们。

例如,如果设备驱动程序想要至少具有读取权限的范围的页面,则它设置

range->default_flags = HMM_PFN_REQ_FAULT;
range->pfn_flags_mask = 0;

并如上所述调用 hmm_range_fault()。这将填充范围内所有至少具有读取权限的页面的错误。

现在假设驱动程序想要做同样的事情,但范围内的一个页面除外,它希望具有写入权限。现在驱动程序设置

range->default_flags = HMM_PFN_REQ_FAULT;
range->pfn_flags_mask = HMM_PFN_REQ_WRITE;
range->pfns[index_of_write] = HMM_PFN_REQ_WRITE;

这样,HMM 将至少以读取(即有效)权限填充所有页面,对于地址 == range->start + (index_of_write << PAGE_SHIFT),它将以写入权限填充错误,即,如果 CPU pte 没有设置写入权限,则 HMM 将调用 handle_mm_fault()。

hmm_range_fault 完成后,标志位设置为页表的当前状态,即如果页面可写,则设置 HMM_PFN_VALID | HMM_PFN_WRITE。

从核心内核的角度表示和管理设备内存

已经尝试了几种不同的设计来支持设备内存。第一个设计使用设备特定的数据结构来保存有关迁移内存的信息,并且 HMM 将自身挂钩在 mm 代码的各个位置,以处理对设备内存支持的地址的任何访问。事实证明,这最终复制了 struct page 的大多数字段,并且还需要更新许多内核代码路径才能理解这种新型内存。

大多数内核代码路径从不尝试访问页面后面的内存,而只关心 struct page 内容。因此,HMM 切换到直接对设备内存使用 struct page,这使得大多数内核代码路径没有意识到差异。我们只需要确保没有人尝试从 CPU 端映射这些页面。

往返于设备内存的迁移

由于 CPU 无法直接访问设备内存,因此设备驱动程序必须使用硬件 DMA 或设备特定加载/存储指令来迁移数据。 migrate_vma_setup()migrate_vma_pages()migrate_vma_finalize() 函数旨在使驱动程序更易于编写并集中驱动程序之间的通用代码。

在将页面迁移到设备私有内存之前,需要创建特殊的设备私有 struct page。这些将用作特殊的“交换”页表条目,以便 CPU 进程如果尝试访问已迁移到设备私有内存的页面,则会发生错误。

可以使用以下命令分配和释放它们

struct resource *res;
struct dev_pagemap pagemap;

res = request_free_mem_region(&iomem_resource, /* number of bytes */,
                              "name of driver resource");
pagemap.type = MEMORY_DEVICE_PRIVATE;
pagemap.range.start = res->start;
pagemap.range.end = res->end;
pagemap.nr_range = 1;
pagemap.ops = &device_devmem_ops;
memremap_pages(&pagemap, numa_node_id());

memunmap_pages(&pagemap);
release_mem_region(pagemap.range.start, range_len(&pagemap.range));

还有 devm_request_free_mem_region()devm_memremap_pages()、devm_memunmap_pages() 和 devm_release_mem_region(),当资源可以绑定到 struct device 时。

总体迁移步骤类似于迁移系统内存中的 NUMA 页面(请参阅 页面迁移),但这些步骤在设备驱动程序特定代码和共享通用代码之间拆分

  1. mmap_read_lock()

    设备驱动程序必须将 struct vm_area_struct 传递给 migrate_vma_setup(),因此 mmap_read_lock() 或 mmap_write_lock() 需要在迁移期间持有。

  2. migrate_vma_setup(struct migrate_vma *args)

    设备驱动程序初始化 struct migrate_vma 字段并将指针传递给 migrate_vma_setup()args->flags 字段用于过滤应迁移的源页面。例如,设置 MIGRATE_VMA_SELECT_SYSTEM 将仅迁移系统内存,设置 MIGRATE_VMA_SELECT_DEVICE_PRIVATE 将仅迁移驻留在设备私有内存中的页面。如果设置了后一个标志,则 args->pgmap_owner 字段用于标识驱动程序拥有的设备私有页面。这避免了尝试迁移驻留在其他设备中的设备私有页面。目前,只有匿名私有 VMA 范围可以迁移到或从系统内存和设备私有内存迁移。

    migrate_vma_setup() 执行的第一个步骤之一是使用 mmu_notifier_invalidate_range_start(()mmu_notifier_invalidate_range_end() 调用围绕页表遍历来填充 args->src 数组迁移的 PFN,从而使其他设备的 MMU 失效。invalidate_range_start() 回调传递一个 struct mmu_notifier_range,其中 event 字段设置为 MMU_NOTIFY_MIGRATEowner 字段设置为传递给 migrate_vma_setup()args->pgmap_owner 字段。这允许设备驱动程序跳过失效回调,并且仅失效实际迁移的设备私有 MMU 映射。这在下一节中有更多解释。

    在遍历页表时,pte_none()is_zero_pfn() 条目会导致存储在 args->src 数组中的有效“零”PFN。这使驱动程序可以分配设备私有内存并清除它,而不是复制零页面。系统内存或设备私有 struct 页面的有效 PTE 条目将被 lock_page() 锁定,从 LRU 中隔离(如果是系统内存,因为设备私有页面不在 LRU 上),从进程中取消映射,并且特殊的迁移 PTE 将插入到原始 PTE 的位置。migrate_vma_setup() 还会清除 args->dst 数组。

  3. 设备驱动程序分配目标页面并将源页面复制到目标页面。

    驱动程序检查每个 src 条目以查看是否设置了 MIGRATE_PFN_MIGRATE 位,并跳过未迁移的条目。设备驱动程序还可以选择通过不填充该页面的 dst 数组来跳过迁移页面。

    然后,驱动程序分配设备私有 struct 页面或系统内存页面,使用 lock_page() 锁定页面,并使用以下内容填充 dst 数组条目

    dst[i] = migrate_pfn(page_to_pfn(dpage));
    

    现在驱动程序知道此页面正在迁移,它可以使设备私有 MMU 映射失效并将设备私有内存复制到系统内存或另一个设备私有页面。核心 Linux 内核处理 CPU 页表失效,因此设备驱动程序只需使其自己的 MMU 映射失效。

    驱动程序可以使用 migrate_pfn_to_page(src[i]) 获取源的 struct page,并将源页面复制到目标,或者如果指针为 NULL,则清除目标设备私有内存,这意味着源页面未填充在系统内存中。

  4. migrate_vma_pages()

    此步骤是实际“提交”迁移的位置。

    如果源页面是 pte_none()is_zero_pfn() 页面,则这是将新分配的页面插入 CPU 页表的位置。如果 CPU 线程在同一页面上发生错误,则可能会失败。但是,页表已锁定,并且只会插入一个新页面。如果设备驱动程序丢失了竞争,它将看到 MIGRATE_PFN_MIGRATE 位被清除。

    如果源页面被锁定、隔离等,则现在源 struct page 信息将复制到目标 struct page,从而完成 CPU 端的迁移。

  5. 设备驱动程序更新仍在迁移的页面的设备 MMU 页表,回滚未迁移的页面。

    如果 src 条目仍具有 MIGRATE_PFN_MIGRATE 位集,则设备驱动程序可以更新设备 MMU,如果设置了 MIGRATE_PFN_WRITE 位,则可以设置写入启用位。

  6. migrate_vma_finalize()

    此步骤将特殊的迁移页表条目替换为新页面的页表条目,并释放对源和目标 struct page 的引用。

  7. mmap_read_unlock()

    现在可以释放锁。

独占访问内存

某些设备具有诸如原子 PTE 位之类的功能,这些功能可用于实现对系统内存的原子访问。为了支持对共享虚拟内存页面的原子操作,此类设备需要访问该页面,该访问是 CPU 的任何用户空间访问所独有的。make_device_exclusive() 函数可用于使用户空间无法访问内存范围。

这会将给定范围内的页面的所有映射替换为特殊的交换条目。任何访问交换条目的尝试都会导致错误,通过将条目替换为原始映射来解决该错误。驱动程序会收到 MMU 通知程序已更改映射的通知,此后它将不再具有对该页面的独占访问权限。保证独占访问权限持续到驱动程序释放页面锁和页面引用为止,此时 CPU 对该页面的任何错误都可能按所述进行。

内存 cgroup (memcg) 和 rss 记账

目前,设备内存被视为 rss 计数器中的任何常规页面(如果设备页面用于匿名页面,则为匿名页面;如果设备页面用于文件支持的页面,则为文件页面;如果设备页面用于共享内存,则为 shmem)。这是一个故意的选择,目的是保持现有应用程序在不知道设备内存的情况下开始使用设备内存,使其运行不受影响。

一个缺点是 OOM killer 可能会杀死使用大量设备内存而不是大量常规系统内存的应用程序,因此不会释放太多系统内存。我们希望在决定以不同方式计算设备内存之前,收集更多关于应用程序和系统在设备内存存在下在内存压力下做出反应的真实世界经验。

对于内存 cgroup 也做出了相同的决定。设备内存页面根据常规页面将被计算到的同一内存 cgroup 进行计算。这简化了往返于设备内存的迁移。这也意味着从设备内存到常规内存的迁移不会失败,因为它会超出内存 cgroup 限制。一旦我们获得更多关于设备内存如何使用及其对内存资源控制影响的经验,我们可能会重新审视此选择。

请注意,设备内存永远不会被设备驱动程序或通过 GUP 锁定,因此此类内存在进程退出时始终是空闲的。或者在共享内存或文件支持的内存的情况下,当最后一个引用被删除时。