英语

Hugetlbfs 预留

概述

HugeTLB Pages 中描述的巨型页面通常被预先分配供应用程序使用。如果 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,则会修改与映射关联的预留映射以反映预留。在共享映射的情况下,将存在一个包含范围“from” - “to”的 file_region。对于私有映射,不会对预留映射进行任何修改,因为缺少条目指示存在预留。

如果 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 内存策略的巨型页面,则将尝试使用伙伴分配器分配一个页面。这提出了剩余巨型页面和过度提交的问题,这些问题超出了预留的范围。即使分配了剩余页面,也会进行与上面相同的基于预留的调整:SetPagePrivate(page) 和 resv_huge_pages--。

在获得新的 hugetlb 对开本后,(对开本)->_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 代码中调用。释放巨型页面时,可能需要执行预留记帐。如果页面与包含预留的子池关联,或者页面在错误路径上释放,则必须恢复全局预留计数,在这种情况下,将需要执行预留记帐。

页面->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. 如果操作无法成功,则调用 region_abort 以中止相同范围 [f, t) 的操作。

请注意,这是一个两步过程,在先前对相同范围调用 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 中存在每个节点的巨型页面列表。预留的概念是在一段时间后添加的。添加预留时,未尝试考虑内存策略。虽然 cpusets 与内存策略不完全相同,但 hugetlb_acct_memory 中的此注释总结了预留与 cpusets/内存策略之间的交互

/*
 * 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)。但是,如果应用程序使用 cpusets 或内存策略,则无法保证所需节点上提供巨型页面。即使有足够的全局预留,情况也是如此。

Hugetlbfs 回归测试

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

-- Mike Kravetz,2017 年 4 月 7 日