英语

异构内存管理 (HMM)

提供基础架构和辅助工具,将非常规内存(例如板载 GPU 的设备内存)集成到常规内核路径中,其基础是用于此类内存的专用结构页(请参阅本文档的第 5 到 7 节)。

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

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

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

具有大量板载内存(数千兆字节)的设备(如 GPU)历来通过专用的特定于驱动程序的 API 管理其内存。这在设备驱动程序分配和管理的内存与常规应用程序内存(私有匿名内存、共享内存或常规文件支持的内存)之间产生了脱节。从这里开始,我将把这个方面称为分离地址空间。我使用共享地址空间来指代相反的情况:即,进程中的任何应用程序内存区域都可以被设备透明地使用。

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

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

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

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

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

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

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

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

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

另一个破坏因素是有限的带宽(PCIE 4.0 和 16 条通道约为 32 GB/秒)。这比最快的 GPU 内存(1 TB/秒)少 33 倍。最终的限制是延迟。从设备访问主内存的延迟比设备访问其自己的内存时高一个数量级。

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

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

共享地址空间和迁移

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

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

HMM 提供的第二个机制是一种新的 ZONE_DEVICE 内存,它允许为设备内存的每一页分配一个结构页。这些页面很特殊,因为 CPU 无法映射它们。但是,它们允许使用现有的迁移机制将主内存迁移到设备内存,并且一切看起来都像是从 CPU 角度换出到磁盘的页面。使用结构页可以最简单、最干净地与现有 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 将使用至少读取权限(即有效)错误地加载所有页面,并且对于 address == 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。invalidate_range_start() 回调传递一个 struct mmu_notifier_range,其中 event 字段设置为 MMU_NOTIFY_MIGRATE,并且 owner 字段设置为传递给 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_range() 函数使内存范围无法从用户空间访问。

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

内存 cgroup (memcg) 和 rss 统计

目前,设备内存的统计方式与 rss 计数器中的任何常规页面相同(如果设备页面用于匿名页面,则为匿名页面;如果设备页面用于文件支持的页面,则为文件;如果设备页面用于共享内存,则为 shmem)。这是一个经过深思熟虑的选择,旨在保持现有应用程序(可能在不知情的情况下开始使用设备内存)的正常运行。

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

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

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