透明大页支持

本文档描述了透明大页 (THP) 支持的设计原则及其与内存管理系统其他部分的交互。

设计原则

  • “优雅的回退”: 不了解透明大页的 mm 组件会回退到将大的 pmd 映射分解为 pte 表,并在必要时拆分透明大页。 因此,这些组件可以继续处理常规页面或常规 pte 映射。

  • 如果由于内存碎片导致大页分配失败,则应优雅地分配常规页面并在同一 vma 中混合,而不会出现任何故障或显着延迟,并且用户空间不会注意到

  • 如果某些任务退出并且更多的大页变得可用(无论是立即在伙伴系统中还是通过 VM),则由常规页面支持的访客物理内存应自动(使用 khugepaged)重新定位到大页上

  • 它不需要内存预留,而是尽可能使用大页(这里唯一可能的预留是 kernelcore=,以避免不可移动的页面使所有内存碎片化,但这种调整并非透明大页支持所特有,它是一个通用特性,适用于内核中的所有动态高阶分配)

get_user_pages 和 pin_user_pages

如果在巨页上运行 get_user_pages 和 pin_user_pages,它们将像往常一样返回头页或尾页(与在 hugetlbfs 上完全一样)。 大多数 GUP 用户只会关心页面的实际物理地址及其临时锁定以便在 I/O 完成后释放,因此他们永远不会注意到页面是巨大的。 但是,如果任何驱动程序要处理尾页的页面结构(例如检查 page->mapping 或其他与头页相关而不是尾页相关的位),则应更新为跳转以检查头页。 引用任何头/尾页都会阻止该页被任何人拆分。

注意

这些不是 GUP API 的新约束,它们与适用于 hugetlbfs 的约束相同,因此任何能够处理 hugetlbfs 上的 GUP 的驱动程序也可以在透明大页支持的映射上正常工作。

优雅的回退

遍历页表但不了解大的 pmd 的代码可以简单地调用 split_huge_pmd(vma, pmd, addr),其中 pmd 是 pmd_offset 返回的那个。 通过查找 “pmd_offset” 并在 pmd_offset 返回 pmd 后在缺少的地方添加 split_huge_pmd,可以很容易地使代码了解透明大页。 感谢优雅的回退设计,只需一行代码的更改,您就可以避免编写数百甚至数千行复杂的代码来使您的代码了解大页。

如果您没有遍历页表,但遇到了无法在代码中本地处理的物理大页,则可以通过调用 split_huge_page(page) 来拆分它。 这是 Linux VM 在尝试交换大页之前所做的事情。 如果该页面被锁定,split_huge_page() 可能会失败,您必须正确处理这种情况。

通过一行代码更改使 mremap.c 了解透明大页的示例

diff --git a/mm/mremap.c b/mm/mremap.c
--- a/mm/mremap.c
+++ b/mm/mremap.c
@@ -41,6 +41,7 @@ static pmd_t *get_old_pmd(struct mm_stru
                return NULL;

        pmd = pmd_offset(pud, addr);
+       split_huge_pmd(vma, pmd, addr);
        if (pmd_none_or_clear_bad(pmd))
                return NULL;

巨页感知代码中的锁定

我们希望尽可能多的代码是巨页感知的,因为调用 split_huge_page() 或 split_huge_pmd() 会有成本。

要使页表遍历了解大的 pmd,您只需在 pmd_offset 返回的 pmd 上调用 pmd_trans_huge()。 您必须以读取(或写入)模式持有 mmap_lock,以确保 khugepaged 不会从您身下创建大的 pmd(khugepaged collapse_huge_page 除了 anon_vma 锁之外,还以写入模式获取 mmap_lock)。 如果 pmd_trans_huge 返回 false,您只需回退到旧的代码路径。 如果 pmd_trans_huge 返回 true,则必须获取页表锁 (pmd_lock()) 并重新运行 pmd_trans_huge。 获取页表锁将防止大的 pmd 从您身下转换为常规 pmd(split_huge_pmd 可以与页表遍历并行运行)。 如果第二个 pmd_trans_huge 返回 false,您应该只需释放页表锁并像以前一样回退到旧的代码。 否则,您可以继续本地处理大的 pmd 和大的页面。 完成后,您可以释放页表锁。

引用计数和透明大页

THP 上的引用计数与在其他复合页面上的引用计数基本一致

  • get_page()/put_page() 和 GUP 对 folio->_refcount 进行操作。

  • 尾页中的 ->_refcount 始终为零:get_page_unless_zero() 永远不会在尾页上成功。

  • 整个 THP 的 PMD 条目的 map/unmap 增加/减少 folio->_entire_mapcount 和 folio->_large_mapcount。

    我们还维护两个用于跟踪 MM 所有者的槽(MM ID 和相应的 mapcount),以及当前状态(“可能已映射共享”与“独占映射”)。

    使用 CONFIG_PAGE_MAPCOUNT,当 _entire_mapcount 从 -1 变为 0 或 0 变为 -1 时,我们还会将 folio->_nr_pages_mapped 增加/减少 ENTIRELY_MAPPED。

  • 使用 PTE 条目的单个页面的 map/unmap 增加/减少 folio->_large_mapcount。

    我们还维护两个用于跟踪 MM 所有者的槽(MM ID 和相应的 mapcount),以及当前状态(“可能已映射共享”与“独占映射”)。

    使用 CONFIG_PAGE_MAPCOUNT,当 page->_mapcount 从 -1 变为 0 或 0 变为 -1 时,我们还会增加/减少 page->_mapcount 并增加/减少 folio->_nr_pages_mapped,因为这会计算 PTE 映射的页面数。

split_huge_page 内部必须在从页面结构中清除所有 PG_head/tail 位之前,将头页中的引用计数分配给尾页。 对于页面表条目采用的引用计数,可以很容易地完成,但我们没有足够的信息来分配任何额外的锁定(即来自 get_user_pages)。 split_huge_page() 会拒绝任何拆分锁定大页的请求:它期望页面计数等于所有子页面的 mapcount 之和加一(split_huge_page 调用者必须引用头页)。

split_huge_page 使用迁移条目来稳定匿名页面的 page->_refcount 和 page->_mapcount。 文件页面只需取消映射。

我们对物理内存扫描器也很安全:扫描器获取页面引用的唯一合法方式是 get_page_unless_zero()。

所有尾页在 atomic_add() 之前都具有零 ->_refcount。 这可以防止扫描器获取到该点的尾页的引用。 在 atomic_add() 之后,我们不关心 ->_refcount 值。 我们已经知道在拆分后应该从头页中取消多少引用。

对于头页,get_page_unless_zero() 将成功,我们不介意。 拆分后引用的去向很明确:它将保留在头页上。

请注意,split_huge_pmd() 对引用计数没有任何限制:pmd 可以在任何时候拆分,永远不会失败。

部分取消映射和 deferred_split_folio()(仅限匿名 THP)

取消映射 THP 的一部分(使用 munmap() 或其他方式)不会立即释放内存。 相反,我们在 folio_remove_rmap_*() 中检测到 THP 的子页面未使用,并在内存压力到来时将 THP 排队以进行拆分。 拆分将释放未使用的子页面。

由于我们可以在检测到部分取消映射的地方进行锁定,因此立即拆分页面不是一个选项。 如果 THP 跨越 VMA 边界,它也可能适得其反,因为在 exit(2) 期间会发生许多情况下的部分取消映射。

函数 deferred_split_folio() 用于将 folio 排队以进行拆分。 拆分本身将在我们通过 shrinker 接口获得内存压力时发生。

使用 CONFIG_PAGE_MAPCOUNT,我们可以根据 folio->_nr_pages_mapped 可靠地检测到部分映射。

使用 CONFIG_NO_PAGE_MAPCOUNT,我们根据 THP 中每个页面的平均 mapcount 来检测部分映射:如果平均值 < 1,则匿名 THP 肯定是部分映射的。 只要只有一个进程映射 THP,这种检测就是可靠的。 对于长时间运行的子进程,可能存在当前无法检测到部分映射的场景,并且将来可能需要在内存回收期间进行异步检测。