异构内存管理(HMM) 【ChatGPT】

异构内存管理(HMM)

HMM提供基础设施和辅助工具,将非常规内存(例如设备内存,如GPU板载内存)整合到常规内核路径中,其核心是为此专门设计的用于此类内存的struct page(请参见本文档的第5至7节)。

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

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

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

  • I/O总线、设备内存特性
  • 共享地址空间和迁移
  • 地址空间镜像实现和API
  • 利用default_flags和pfn_flags_mask
  • 从核心内核角度表示和管理设备内存
  • 到设备内存和从设备内存的迁移
  • 独占访问内存
  • 内存cgroup(memcg)和rss计数

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

像GPU这样拥有大量内置内存(数十GB)的设备在历史上一直通过专门的驱动程序API来管理它们的内存。这导致了设备驱动程序分配和管理的内存与常规应用程序内存(私有匿名内存、共享内存或常规文件支持的内存)之间存在脱节。从这里开始,我将把这个方面称为分离的地址空间。我使用共享地址空间来指代相反的情况,即任何应用程序内存区域都可以被设备透明地使用。

分离的地址空间是因为设备只能访问通过特定设备API分配的内存。这意味着程序中的所有内存对象在设备的视角下并不相等,这增加了依赖广泛的库的大型程序的复杂性。

具体来说,这意味着希望利用GPU等设备的代码需要在通用分配的内存(如malloc、mmap私有、mmap共享)和通过设备驱动程序API分配的内存之间复制对象(最终仍然会使用mmap,但是设备文件的)。对于平面数据集(数组、网格、图像等),这并不难实现,但对于复杂数据集(列表、树等),要做到正确却很困难。复制复杂数据集需要重新映射其各个元素之间的指针关系。这很容易出错,导致程序变得更难调试,因为存在重复的数据集和地址。

分离的地址空间还意味着库无法透明地使用它们从核心程序或其他库获取的数据,因此每个库可能需要使用特定设备内存分配器来复制其输入数据集。大型项目因此受到影响,并因多次内存复制而浪费资源。

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

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

下面是ChatGPT总结的:

  1. 内存分配的分离性:设备(如GPU)通常通过专门的驱动程序特定API来管理内存,导致设备分配的内存与应用程序内存(私有匿名内存、共享内存或常规文件支持的内存)之间存在不连贯性,即分离的地址空间。这使得在依赖广泛的库的大型程序中,所有内存对象在设备角度上并不相等,从而增加了复杂性。
  2. 数据复制的复杂性:为了利用GPU等设备,代码需要在通用分配的内存(如malloc、mmap私有、mmap共享)和通过设备驱动程序API分配的内存之间进行对象复制。对于简单的数据集(数组、网格、图像等),这并不难实现,但对于复杂的数据集(列表、树等),重新映射所有元素之间的指针关系是困难的,容易出错,导致程序变得更难调试。
  3. 库的透明数据使用:分离的地址空间意味着库无法透明地使用来自核心程序或其他库的数据,因此每个库可能需要使用设备特定内存分配器来复制其输入数据集。这导致大型项目受到影响,并因多次内存复制而浪费资源。
  4. 库API的复制:复制每个库API以接受由每个设备特定分配器分配的内存作为输入或输出并不可行,这将导致库入口点的组合爆炸。
  5. 高级语言构造的发展:随着高级语言构造(如C++等)的发展,编译器现在可以利用GPU和其他设备而无需程序员的知识。一些编译器识别的模式只能通过共享地址空间实现。对于其他模式来说,使用共享地址空间也更为合理。

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

I/O总线由于一些限制而削弱了共享地址空间的功能。大多数I/O总线只允许设备对主内存进行基本的内存访问,甚至缓存一致性通常也是可选的。CPU对设备内存的访问更加有限,往往不具备缓存一致性。

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

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

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

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

下面是ChatGPT总结的:
I/O总线由于一些限制而削弱了共享地址空间:

  1. 基本内存访问限制:大多数I/O总线只允许设备对主内存进行基本的内存访问,甚至缓存一致性通常也是可选的。CPU对设备内存的访问更加有限,往往不具备缓存一致性。
  2. PCIE总线的限制:PCIE总线允许设备访问主内存并与CPU具有缓存一致性,但只允许设备对主内存进行有限的原子操作,而CPU对设备内存的访问也受限制,无法执行原子操作。因此,从内核角度来看,设备内存不能被视为与常规内存相同。
  3. 带宽和延迟限制:I/O总线的带宽有限,且访问主内存的延迟比设备访问自己的内存的延迟高一个数量级。这些限制对共享地址空间造成了影响。

共享地址空间和迁移

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

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

HMM提供的第二个机制是一种新类型的ZONE_DEVICE内存,允许为每个设备内存页面分配一个struct page。这些页面是特殊的,因为CPU无法映射它们。但是,它们允许使用现有的迁移机制将主内存迁移到设备内存,从CPU的角度来看,一切都像是将页面从内存交换到磁盘。使用struct page可以最容易、最清晰地与现有的内存管理机制集成。同样,HMM只提供辅助函数,第一个是为设备内存热插拔新的ZONE_DEVICE内存,第二个是执行迁移。何时以及何种数据迁移的策略决策留给了设备驱动程序。

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

有了这两个功能,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;
     }

     /* 使用pfns数组内容来更新设备页表,
      * 在更新锁的保护下 */

     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));

当资源可以与struct device相关联时,还有devm_request_free_mem_region()、devm_memremap_pages()、devm_memunmap_pages()和devm_release_mem_region()。

总体迁移步骤与在系统内存中迁移NUMA页面的步骤类似(参见页面迁移),但步骤在设备驱动程序特定代码和共享公共代码之间分割:

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

  • 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范围迁移到系统内存和设备私有内存中。

    在遍历页表时,使用mmu_notifier_invalidate_range_start()和mmu_notifier_invalidate_range_end()调用使其他设备的MMU无效,并在页表遍历周围使用这些调用来填充args->src数组中的PFN以进行迁移。invalidate_range_start()回调使用设置为MMU_NOTIFY_MIGRATE的event字段和设置为传递给migrate_vma_setup()的args->pgmap_owner字段的owner字段传递了一个struct mmu_notifier_range。这使得设备驱动程序可以跳过无效回调,并仅使实际迁移的设备私有MMU映射无效。下一节将更详细地解释这一点。

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

  • 设备驱动程序分配目标页面并将源页面复制到目标页面。
    驱动程序检查每个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页面,并将源页面复制到目标页面,或者清除目标设备私有内存(如果指针为NULL,则表示源页面未在系统内存中填充)。

  • migrate_vma_pages()
    这一步是实际“提交”迁移的地方。

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

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

  • 设备驱动程序更新仍在迁移的设备MMU页表,回滚未迁移的页面。
    如果src条目仍具有MIGRATE_PFN_MIGRATE位设置,则设备驱动程序可以更新设备MMU并在MIGRATE_PFN_WRITE位设置时设置写使能位。

  • migrate_vma_finalize()
    这一步将特殊的迁移页表条目替换为新页面的页表条目,并释放源struct页面和目标struct页面的引用。

  • mmap_read_unlock()
    现在可以释放锁定。

独占访问内存

一些设备具有原子PTE位等功能,可用于实现对系统内存的原子访问。为了支持对共享虚拟内存页面的原子操作,这样的设备需要独占访问该页面,而不受CPU用户空间访问的影响。make_device_exclusive_range()函数可用于使内存范围对用户空间不可访问。

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

memory cgroup(memcg)和rss计数

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

一个缺点是OOM killer可能会杀死使用大量设备内存但不多常规系统内存的应用程序,因此不会释放太多系统内存。在决定对设备内存进行不同计算之前,我们希望在设备内存存在的情况下,收集更多关于应用程序和系统在内存压力下的实际反应的经验。

对于内存cgroup也做出了相同的决定。设备内存页面计入与常规页面相同的内存cgroup。这简化了对设备内存的迁移。这也意味着从设备内存迁移回常规内存不会失败,因为这将超出内存cgroup的限制。一旦我们对设备内存的使用以及其对内存资源控制的影响有了更多经验,我们可能会重新考虑这个选择。

请注意,设备内存永远不会被设备驱动程序或通过GUP固定,因此在进程退出时,这样的内存始终是自由的。或者在共享内存或文件支持内存的情况下,当最后一个引用被丢弃时。

posted @ 2023-12-12 22:27  摩斯电码  阅读(169)  评论(0编辑  收藏  举报