英语

Hugetlbfs 预留

概述

HugeTLB 页面 中所述,大页通常会预先分配供应用程序使用。如果 VMA 指示要使用大页,则这些大页会在任务的地址空间中的缺页错误时实例化。如果在发生缺页错误时不存在大页,则该任务会收到 SIGBUS 信号,并且通常会不幸地死亡。在添加大页支持后不久,就确定最好在 mmap() 时检测到大页短缺。想法是,如果没有足够的大页来覆盖映射,则 mmap() 将失败。这首先是通过在 mmap() 时简单地检查代码来确定是否有足够的空闲大页来覆盖映射来完成的。像内核中的大多数事物一样,代码随着时间的推移而发展。但是,基本思想是在 mmap() 时“预留”大页,以确保该映射中的缺页错误可以使用大页。以下描述试图描述在 v4.10 内核中如何完成大页预留处理。

受众

此描述主要针对正在修改 hugetlbfs 代码的内核开发人员。

数据结构

resv_huge_pages

这是全局(每个 hstate)的预留大页计数。预留的大页仅可用于预留它们的任务。因此,通常可用的大页数量计算为(free_huge_pages - resv_huge_pages)。

预留映射

预留映射由结构描述

struct resv_map {
        struct kref refs;
        spinlock_t lock;
        struct list_head regions;
        long adds_in_progress;
        struct list_head region_cache;
        long region_cache_count;
};

系统中每个大页映射都有一个预留映射。resv_map 中的 regions 列表描述了映射中的区域。区域描述为

struct file_region {
        struct list_head link;
        long from;
        long to;
};

文件区域结构的“from”和“to”字段是大页在映射中的索引。根据映射的类型,reserv_map 中的区域可能指示该范围存在预留,或者不存在预留。

MAP_PRIVATE 预留的标志

这些标志存储在预留映射指针的底部位中。

#define HPAGE_RESV_OWNER    (1UL << 0)

表示此任务是与映射关联的预留的所有者。

#define HPAGE_RESV_UNMAPPED (1UL << 1)

表示最初映射此范围(并创建预留)的任务由于 COW 失败而从此任务(子任务)中取消映射了一个页面。

页面标志

PagePrivate 页面标志用于指示当释放大页时必须恢复大页预留。更多详细信息将在“释放大页”部分中讨论。

预留映射位置(私有或共享)

大页映射或段可以是私有的,也可以是共享的。如果是私有的,则通常仅对单个地址空间(任务)可用。如果是共享的,则可以将其映射到多个地址空间(任务)。对于两种类型的映射,预留映射的位置和语义显着不同。位置差异为

  • 对于私有映射,预留映射悬挂在 VMA 结构上。具体来说,vma->vm_private_data。此预留映射是在创建映射(mmap(MAP_PRIVATE))时创建的。

  • 对于共享映射,预留映射悬挂在 inode 上。具体来说,inode->i_mapping->private_data。由于共享映射始终由 hugetlbfs 文件系统中的文件支持,因此 hugetlbfs 代码会确保每个 inode 都包含一个预留映射。因此,预留映射是在创建 inode 时分配的。

创建预留

当创建大页支持的共享内存段 (shmget(SHM_HUGETLB)) 或通过 mmap(MAP_HUGETLB) 创建映射时,将创建预留。这些操作会导致调用例程 hugetlb_reserve_pages()

int hugetlb_reserve_pages(struct inode *inode,
                          long from, long to,
                          struct vm_area_struct *vma,
                          vm_flags_t vm_flags)

hugetlb_reserve_pages() 执行的第一件事是检查是否在 shmget() 或 mmap() 调用中指定了 NORESERVE 标志。如果指定了 NORESERVE,则此例程将立即返回,因为不需要预留。

参数 'from' 和 'to' 是映射或基础文件中的大页索引。对于 shmget(),'from' 始终为 0,'to' 对应于段/映射的长度。对于 mmap(),可以使用 offset 参数指定基础文件中的偏移量。在这种情况下,'from' 和 'to' 参数已通过此偏移量进行调整。

PRIVATE 和 SHARED 映射之间的最大区别之一是预留在预留映射中的表示方式。

  • 对于共享映射,预留映射中的条目指示对应页面存在或曾经存在预留。当预留被使用时,预留映射不会被修改。

  • 对于私有映射,预留映射中缺少条目表示对应页面存在预留。当预留被使用时,会向预留映射添加条目。因此,预留映射也可以用于确定哪些预留已被使用。

对于私有映射,hugetlb_reserve_pages() 会创建预留映射,并将其悬挂在 VMA 结构上。此外,设置 HPAGE_RESV_OWNER 标志以指示此 VMA 拥有预留。

查询预留映射以确定当前映射/段需要多少大页预留。对于私有映射,此值始终为 (to - from)。但是,对于共享映射,可能在该范围内已存在一些预留 (to - from)。有关如何实现此目的的详细信息,请参见 预留映射修改 部分。

映射可能与子池相关联。如果是这样,则会查询子池以确保映射有足够的空间。子池可能会预留一些预留,这些预留可用于映射。有关更多详细信息,请参见 子池预留 部分。

在查询预留映射和子池后,已知所需的新预留数量。调用例程 hugetlb_acct_memory() 来检查并获取请求的预留数量。hugetlb_acct_memory() 调用例程,这些例程可能会分配和调整剩余页面计数。但是,在这些例程中,代码只是检查以确保有足够的空闲大页来容纳预留。如果有,则全局预留计数 resv_huge_pages 会像如下所示进行调整

if (resv_needed <= (resv_huge_pages - free_huge_pages))
        resv_huge_pages += resv_needed;

请注意,在检查和调整这些计数器时,会持有全局锁 hugetlb_lock。

如果有足够的空闲大页,并且已调整全局计数 resv_huge_pages,则会修改与映射关联的预留映射以反映预留。在共享映射的情况下,将存在一个 file_region,其中包括范围 'from' - 'to'。对于私有映射,不会对预留映射进行修改,因为缺少条目表示存在预留。

如果 hugetlb_reserve_pages() 成功,则将根据需要修改全局预留计数和与映射关联的预留映射,以确保范围 'from' - 'to' 存在预留。

使用预留/分配大页

当与预留关联的大页被分配并在相应的映射中实例化时,将使用预留。分配在例程 alloc_hugetlb_folio() 中执行

struct folio *alloc_hugetlb_folio(struct vm_area_struct *vma,
                             unsigned long addr, int avoid_reserve)

alloc_hugetlb_folio 被传递一个 VMA 指针和一个虚拟地址,因此它可以查询预留映射以确定是否存在预留。此外,alloc_hugetlb_folio 使用参数 avoid_reserve,该参数指示即使似乎已为指定的地址预留,也不应使用预留。avoid_reserve 参数最常用于写时复制和页面迁移的情况下,其中正在分配现有页面的其他副本。

调用辅助例程 vma_needs_reservation() 以确定映射 (vma) 内的地址是否存在预留。有关此例程执行的操作的详细信息,请参见 预留映射辅助例程 部分。从 vma_needs_reservation() 返回的值通常为 0 或 1。如果地址存在预留,则为 0,如果不存在预留,则为 1。如果不存在预留,并且映射有关联的子池,则会查询子池以确定它是否包含预留。如果子池包含预留,则可以将其用于此分配。但是,在每种情况下,avoid_reserve 参数都会覆盖为分配使用预留。在确定是否存在预留且可以用于分配之后,将调用例程 dequeue_huge_page_vma()。此例程采用两个与预留相关的参数

  • avoid_reserve,这与传递给 alloc_hugetlb_folio() 的值/参数相同。

  • chg,即使此参数的类型为 long,也仅将值 0 或 1 传递给 dequeue_huge_page_vma。如果值为 0,则表示存在预留(有关可能的问题,请参见“内存策略和预留”部分)。如果值为 1,则表示不存在预留,并且如果可能,该页面必须从全局空闲池中获取。

搜索与 VMA 的内存策略关联的空闲列表以查找空闲页面。如果找到页面,则在从空闲列表中删除页面时,该值 free_huge_pages 会递减。如果该页面有关联的预留,则进行以下调整

SetPagePrivate(page);   /* Indicates allocating this page consumed
                         * a reservation, and if an error is
                         * encountered such that the page must be
                         * freed, the reservation will be restored. */
resv_huge_pages--;      /* Decrement the global reservation count */

注意,如果没有找到满足 VMA 内存策略的巨页,则会尝试使用伙伴分配器分配一个。这引出了剩余巨页和过度提交的问题,这超出了预留的范围。即使分配了剩余页,也会进行与上述相同的基于预留的调整:设置 PagePrivate(page) 和 resv_huge_pages--。

在获得一个新的 hugetlb 页面集合 (folio) 后,(folio)->_hugetlb_subpool 会被设置为与该页关联的子池的值(如果存在)。当页面集合被释放时,这将用于子池记账。

然后调用例程 vma_commit_reservation(),根据预留的消耗调整预留映射。一般来说,这涉及到确保页面在区域映射的 file_region 结构中表示。对于存在预留的共享映射,预留映射中已经存在一个条目,因此不做任何更改。但是,如果共享映射中没有预留或这是一个私有映射,则必须创建一个新条目。

在 alloc_hugetlb_folio() 开始时调用 vma_needs_reservation() 和分配页面集合后调用 vma_commit_reservation() 之间,预留映射可能已被更改。如果为同一共享映射中的同一页面调用了 hugetlb_reserve_pages,则可能会发生这种情况。在这种情况下,预留计数和子池空闲页计数将相差一。通过比较 vma_needs_reservation 和 vma_commit_reservation 的返回值可以识别出这种罕见的情况。如果检测到此类竞争,则会调整子池和全局预留计数以进行补偿。有关这些例程的更多信息,请参见预留映射辅助例程部分。

实例化巨页

在分配巨页后,该页通常会添加到分配任务的页表中。在此之前,共享映射中的页面会添加到页面缓存,而私有映射中的页面会添加到匿名反向映射中。在这两种情况下,PagePrivate 标志都会被清除。因此,当释放已实例化的巨页时,不会对全局预留计数 (resv_huge_pages) 进行任何调整。

释放巨页

巨页通过 free_huge_folio() 释放。它仅传递一个指向页面集合的指针,因为它从通用 MM 代码中调用。当释放巨页时,可能需要执行预留记账。如果页面与包含预留的子池相关联,或者页面在错误路径上释放,必须恢复全局预留计数,则会发生这种情况。

page->private 字段指向与该页面关联的任何子池。如果设置了 PagePrivate 标志,则表示应调整全局预留计数(有关如何设置这些标志的信息,请参阅消耗预留/分配巨页部分)。

该例程首先为该页面调用 hugepage_subpool_put_pages()。如果此例程返回的值为 0(不等于传递的值 1),则表示预留与子池关联,并且此新释放的页面必须用于保持子池预留的数量高于最小值。因此,在这种情况下,全局 resv_huge_pages 计数器会递增。

如果在页面中设置了 PagePrivate 标志,则全局 resv_huge_pages 计数器将始终递增。

子池预留

每个巨页大小都有一个关联的 struct hstate。hstate 跟踪指定大小的所有巨页。子池表示 hstate 中与已挂载的 hugetlbfs 文件系统关联的页面的子集。

当挂载 hugetlbfs 文件系统时,可以指定 min_size 选项,该选项指示文件系统所需的最小巨页数。如果指定了此选项,则会为文件系统预留与 min_size 对应的巨页数。此数字在 struct hugepage_subpool 的 min_hpages 字段中跟踪。在挂载时,会调用 hugetlb_acct_memory(min_hpages) 来预留指定数量的巨页。如果无法预留,则挂载失败。

当从子池获取页面或将其释放回子池时,会调用例程 hugepage_subpool_get/put_pages()。它们执行所有子池记账,并跟踪与子池关联的任何预留。hugepage_subpool_get/put_pages 传递巨页的数量,用于调整子池“已用页面”计数(get 为减少,put 为增加)。通常,它们会返回传递的相同值,如果子池中没有足够的页面,则会返回错误。

但是,如果预留与子池相关联,则可能返回小于传递的值的值。此返回值表示必须进行的额外全局池调整的次数。例如,假设一个子池包含 3 个预留的巨页,并且有人请求 5 个。与子池关联的 3 个预留页面可以用于满足部分请求。但是,必须从全局池中获取 2 个页面。为了将此信息传递给调用者,将返回值为 2。然后,调用者负责尝试从全局池中获取额外的两个页面。

写时复制 (COW) 和预留

由于共享映射都指向并使用相同的底层页面,因此 COW 的最大预留问题是私有映射。在这种情况下,两个任务可以指向同一个先前分配的页面。一个任务尝试写入该页面,因此必须分配一个新页面,以便每个任务指向自己的页面。

当最初分配页面时,该页面的预留已被消耗。当由于 COW 而尝试分配新页面时,可能没有空闲的巨页,并且分配将失败。

当最初创建私有映射时,通过在所有者的预留映射指针中设置 HPAGE_RESV_OWNER 位来记录映射的所有者。由于所有者创建了映射,因此所有者拥有与映射关联的所有预留。因此,当发生写入错误并且没有可用页面时,将对预留的所有者和非所有者采取不同的操作。

如果出现错误的任务不是所有者,则错误将失败,并且该任务通常会收到 SIGBUS 信号。

如果所有者是出现错误的任务,我们希望它成功,因为它拥有原始预留。为了实现这一点,该页面将从非所有者任务中取消映射。这样,唯一的引用来自所有者任务。此外,在非所有者任务的预留映射指针中设置 HPAGE_RESV_UNMAPPED 位。如果非所有者任务稍后在不存在的页面上发生错误,它可能会收到 SIGBUS 信号。但是,映射/预留的原始所有者将按预期运行。

预留映射修改

以下底层例程用于修改预留映射。通常,这些例程不会直接调用。相反,会调用预留映射辅助例程,该例程会调用这些底层例程之一。这些底层例程在源代码 (mm/hugetlb.c) 中有很好的文档记录。这些例程是

long region_chg(struct resv_map *resv, long f, long t);
long region_add(struct resv_map *resv, long f, long t);
void region_abort(struct resv_map *resv, long f, long t);
long region_count(struct resv_map *resv, long f, long t);

对预留映射的操作通常涉及两个操作

  1. 调用 region_chg() 来检查预留映射并确定指定的范围 [f, t) 中当前未表示的页面数。

    调用代码执行全局检查和分配,以确定是否有足够的巨页来使操作成功。

    1. 如果操作可以成功,则调用 region_add() 来实际修改预留映射,范围与之前传递给 region_chg() 的范围相同 [f, t)。

    2. 如果操作无法成功,则为同一范围 [f, t) 调用 region_abort 以中止操作。

请注意,这是一个两步过程,其中保证在先前为同一范围调用 region_chg() 后,region_add() 和 region_abort() 都会成功。region_chg() 负责预先分配任何必要的数据结构,以确保后续操作(特别是 region_add())会成功。

如上所述,region_chg() 确定范围中当前未在映射中表示的页面数。此数字将返回给调用者。region_add() 返回添加到映射中的范围内的页面数。在大多数情况下,region_add() 的返回值与 region_chg() 的返回值相同。但是,在共享映射的情况下,预留映射的更改可能在调用 region_chg() 和 region_add() 之间进行。在这种情况下,region_add() 的返回值将与 region_chg() 的返回值不匹配。在这种情况下,全局计数和子池记账很可能是不正确的,并且需要调整。调用者有责任检查此情况并进行相应的调整。

调用例程 region_del() 以从预留映射中删除区域。它通常在以下情况下调用

  • 当删除 hugetlbfs 文件系统中的文件时,将释放 inode 并释放预留映射。在释放预留映射之前,必须释放所有单独的 file_region 结构。在这种情况下,region_del 传递的范围是 [0, LONG_MAX)。

  • 当截断 hugetlbfs 文件时。在这种情况下,必须释放新文件大小之后的所有已分配页面。此外,必须删除预留映射中新文件末尾之后的所有 file_region 条目。在这种情况下,region_del 传递的范围是 [new_end_of_file, LONG_MAX)。

  • 当在 hugetlbfs 文件中打孔时。在这种情况下,会一次一个地从文件中间删除巨页。删除页面后,将调用 region_del() 以从预留映射中删除相应的条目。在这种情况下,region_del 传递的范围是 [page_idx, page_idx + 1)。

在每种情况下,region_del() 都会返回从预留映射中删除的页面数。在非常罕见的情况下,region_del() 可能会失败。这只能在打孔的情况下发生,在这种情况下,它必须拆分现有的 file_region 条目并且无法分配新结构。在这种错误情况下,region_del() 将返回 -ENOMEM。这里的问题是预留映射将指示该页面存在预留。但是,子池和全局预留计数将不反映预留。为了处理这种情况,调用例程 hugetlb_fix_reserve_counts() 来调整计数器,以便它们与无法删除的预留映射条目相对应。

当取消映射私有巨页映射时,会调用 region_count()。在私有映射中,预留映射中缺少条目表示存在预留。因此,通过计算预留映射中的条目数,我们知道消耗了多少预留以及有多少未完成(未完成 = (end - start) - region_count(resv, start, end))。由于映射即将消失,子池和全局预留计数将按未完成的预留数递减。

预留映射辅助例程

存在几个辅助例程来查询和修改预留映射。这些例程只关注特定大页的预留,因此它们只传递一个地址而不是一个范围。此外,它们还传递相关的 VMA。从 VMA 中,可以确定映射的类型(私有或共享)以及预留映射的位置(inode 或 VMA)。这些例程只是调用“预留映射修改”部分中描述的底层例程。但是,它们会考虑私有和共享映射的预留映射条目的“相反”含义,并向调用者隐藏此细节。

long vma_needs_reservation(struct hstate *h,
                           struct vm_area_struct *vma,
                           unsigned long addr)

此例程为指定的页面调用 region_chg()。如果不存在预留,则返回 1。如果存在预留,则返回 0。

long vma_commit_reservation(struct hstate *h,
                            struct vm_area_struct *vma,
                            unsigned long addr)

此例程为指定的页面调用 region_add()。与 region_chg 和 region_add 的情况一样,此例程应在先前调用 vma_needs_reservation 之后调用。它将为页面添加预留条目。如果添加了预留,则返回 1,否则返回 0。返回值应与先前调用 vma_needs_reservation 的返回值进行比较。意外的差异表示预留映射在调用之间被修改。

void vma_end_reservation(struct hstate *h,
                         struct vm_area_struct *vma,
                         unsigned long addr)

此例程为指定的页面调用 region_abort()。与 region_chg 和 region_abort 的情况一样,此例程应在先前调用 vma_needs_reservation 之后调用。它将中止/结束正在进行的预留添加操作。

long vma_add_reservation(struct hstate *h,
                         struct vm_area_struct *vma,
                         unsigned long addr)

这是一个特殊的包装例程,旨在帮助在错误路径上进行预留清理。它仅从例程 restore_reserve_on_error() 中调用。此例程与 vma_needs_reservation 结合使用,试图向预留映射添加预留。它会考虑私有和共享映射的不同预留映射语义。因此,对于共享映射调用 region_add(因为映射中存在的条目表示预留),对于私有映射调用 region_del(因为映射中不存在条目表示预留)。有关需要在错误路径上执行的操作的更多信息,请参阅“错误路径中的预留清理”部分。

错误路径中的预留清理

预留映射辅助例程 部分所述,预留映射修改分两步执行。首先,在分配页面之前调用 vma_needs_reservation。如果分配成功,则调用 vma_commit_reservation。如果失败,则调用 vma_end_reservation。全局和子池预留计数根据操作的成功或失败进行调整,一切都正常。

此外,在大页实例化后,会清除 PagePrivate 标志,以便在最终释放页面时进行正确的记账。

但是,在分配大页后但在实例化之前,会遇到一些错误。在这种情况下,页面分配已消耗预留,并进行了适当的子池、预留映射和全局计数调整。如果此时释放页面(在实例化和清除 PagePrivate 之前),则 free_huge_folio 将增加全局预留计数。但是,预留映射表示预留已被消耗。这种导致的不一致状态将导致预留的大页“泄漏”。全局预留计数将高于应有的值,并阻止分配预先分配的页面。

例程 restore_reserve_on_error() 尝试处理这种情况。它有相当详细的文档。此例程的目的是将预留映射恢复到页面分配之前的状态。通过这种方式,预留映射的状态将与释放页面后的全局预留计数相对应。

例程 restore_reserve_on_error 本身在尝试恢复预留映射条目时可能会遇到错误。在这种情况下,它将简单地清除页面的 PagePrivate 标志。通过这种方式,当页面被释放时,全局预留计数不会增加。但是,预留映射将继续看起来好像预留已被消耗。仍然可以为该地址分配页面,但它不会像最初预期的那样使用预留的页面。

有一些代码(最值得注意的是 userfaultfd)无法调用 restore_reserve_on_error。在这种情况下,它只是修改 PagePrivate,以便在释放大页时不会泄漏预留。

预留和内存策略

当第一次使用 git 管理 Linux 代码时,每个节点的巨大页面列表都存在于 struct hstate 中。预留的概念是在一段时间后添加的。添加预留时,没有尝试考虑内存策略。虽然 cpuset 与内存策略并不完全相同,但 hugetlb_acct_memory 中的此注释总结了预留和 cpuset/内存策略之间的交互。

/*
 * When cpuset is configured, it breaks the strict hugetlb page
 * reservation as the accounting is done on a global variable. Such
 * reservation is completely rubbish in the presence of cpuset because
 * the reservation is not checked against page availability for the
 * current cpuset. Application can still potentially OOM'ed by kernel
 * with lack of free htlb page in cpuset that the task is in.
 * Attempt to enforce strict accounting with cpuset is almost
 * impossible (or too ugly) because cpuset is too fluid that
 * task or memory node can be dynamically moved between cpusets.
 *
 * The change of semantics for shared hugetlb mapping with cpuset is
 * undesirable. However, in order to preserve some of the semantics,
 * we fall back to check against current free page availability as
 * a best attempt and hopefully to minimize the impact of changing
 * semantics that cpuset has.
 */

添加大页预留是为了防止在页面错误时出现意外的页面分配失败(OOM)。但是,如果应用程序使用 cpuset 或内存策略,则不能保证在所需的节点上可以使用大页。即使存在足够数量的全局预留也是如此。

Hugetlbfs 回归测试

最完整的一组 hugetlb 测试位于 libhugetlbfs 存储库中。如果您修改任何 hugetlb 相关代码,请使用 libhugetlbfs 测试套件检查回归。此外,如果您添加任何新的 hugetlb 功能,请向 libhugetlbfs 添加适当的测试。

-- Mike Kravetz,2017 年 4 月 7 日