内存管理-73-hugetlb-1-理论-1-内核文档翻译
注:已翻译的文档有
kernel-6.1/Documentation$ find ./ -name "*tlb*" ./translations/zh_CN/core-api/cachetlb.rst ./translations/zh_CN/arm64/hugetlbpage.rst ./translations/zh_CN/mm/hugetlbfs_reserv.rst ./admin-guide/cgroup-v1/hugetlb.rst
一、cachetlb.rst
注: 本节翻译自 kernel-6.1/Documentation/translations/zh_CN/core-api/cachetlb.rst
======================
Linux下的缓存和TLB刷新
======================
:作者: David S. Miller <davem@redhat.com>
*译注:TLB,Translation Lookaside Buffer,页表缓存/变换旁查缓冲器*
本文描述了由Linux虚拟内存子系统调用的缓存/TLB刷新接口。它列举了每个接口,描述了它的预期目的,以及接口被调用后的预期副作用。
下面描述的副作用是针对单处理器的实现,以及在单个处理器上发生的情况。若为SMP,则只需将定义简单地扩展一下,使发生在某个特定接口的副作用扩展到系统的所有处理器上。不要被这句话吓到,以为SMP的缓存/tlb刷新一定是很低效的,事实上,这是一个可以进行很多优化的领域。例如,如果可以证明一个用户地址空间从未在某个cpu上执行过(见 mm_cpumask()),那么就不需要在该cpu上对这个地址空间进行刷新。
首先是TLB刷新接口,因为它们是最简单的。在Linux下,TLB被抽象为cpu用来缓存从软件页表获得的虚拟->物理地址转换的东西。这意味着,如果软件页表发生变化,这个“TLB”缓存中就有可能出现过时(脏)的翻译。因此,当软件页表发生变化时,内核会在页表发生 *变化后* 调用以下一种刷新方法:
1) ``void flush_tlb_all(void)``
最严格的刷新。在这个接口运行后,任何以前的页表修改都会对cpu可见。这通常是在内核页表被改变时调用的,因为这种转换在本质上是“全局”的。
2) ``void flush_tlb_mm(struct mm_struct *mm)``
这个接口从TLB中刷新整个用户地址空间。在运行后,这个接口必须确保以前对地址空间‘mm’的任何页表修改对cpu来说是可见的。也就是说,在运行后,TLB中不会有‘mm’的页表项。
这个接口被用来处理整个地址空间的页表操作,比如在 fork 和 exec 过程中发生的事情。
3) ``void flush_tlb_range(struct vm_area_struct *vma, unsigned long start, unsigned long end)``
这里我们要从 TLB 中刷新一个特定范围的(用户)虚拟地址转换。在运行后,这个接口必须确保以前对‘start’到‘end-1’范围内的地址空间‘vma->vm_mm’ 的任何页表修改对cpu来说是可见的。也就是说,在运行后,TLB 中不会有‘mm’的页表项用于‘start’到‘end-1’范围内的虚拟地址。
“vma”是用于该区域的备份存储。主要是用于 munmap() 类型的操作。
提供这个接口是希望端口能够找到一个合适的有效方法来从 TLB 中删除多个页面大小的转换,而不是让内核为每个可能被修改的页表项调用 flush_tlb_page(见下文)。
4) ``void flush_tlb_page(struct vm_area_struct *vma, unsigned long addr)``
这一次我们需要从 TLB 中删除 PAGE_SIZE 大小的转换。‘vma’是Linux用来跟踪进程的 mmap 区域的支持结构体,地址空间可以通过 vma->vm_mm 获得。另外,可以通过测试(vma->vm_flags & VM_EXEC)来查看这个区域是否是可执行的(因此在 split-tlb 类型的设置中可能在“指令TLB”中)。
在运行后,这个接口必须确保之前对用户虚拟地址“addr”的地址空间“vma->vm_mm”的页表修改对cpu来说是可见的。也就是说,在运行后,TLB 中不会有虚拟地址‘addr’的‘vma->vm_mm’的页表项。
这主要是在故障处理时使用。
5) ``void update_mmu_cache(struct vm_area_struct *vma, unsigned long address, pte_t *ptep)``
在每个缺页异常结束时,这个程序被调用,以告诉体系结构特定的代码,在软件页表中,在地址空间“vma->vm_mm”的虚拟地址“地址”处,现在存在一个翻译。
可以用它所选择的任何方式使用这个信息来进行移植。例如,它可以使用这个事件来为软件管理的TLB配置预装TLB转换。目前sparc64移植就是这么干的。
接下来,我们有缓存刷新接口。一般来说,当Linux将现有的虚拟->物理映射改变为新的值时,其顺序将是以下形式之一::
1) flush_cache_mm(mm); change_all_page_tables_of(mm); flush_tlb_mm(mm); 2) flush_cache_range(vma, start, end); change_range_of_page_tables(mm, start, end); flush_tlb_range(vma, start, end); 3) flush_cache_page(vma, addr, pfn); set_pte(pte_pointer, new_pte_val); flush_tlb_page(vma, addr);
缓存级别的刷新将永远是第一位的,因为这允许我们正确处理那些缓存严格,且在虚拟地址被从缓存中刷新时要求一个虚拟地址的虚拟->物理转换存在的系统。HyperSparc cpu就是这样一个具有这种属性的cpu。
下面的缓存刷新程序只需要在特定的cpu需要的范围内处理缓存刷新。大多数情况下,这些程序必须为cpu实现,这些cpu有虚拟索引的缓存,当虚拟->物理转换被改变或移除时,必须被刷新。因此,例如,IA32处理器的物理索引的物理标记的缓存没有必要实现这些接口,因为这些缓存是完全同步的,并且不依赖于翻译信息。
下面逐个列出这些程序:
1) ``void flush_cache_mm(struct mm_struct *mm)``
这个接口将整个用户地址空间从高速缓存中刷掉。也就是说,在运行后,将没有与‘mm’相关的缓存行。
这个接口被用来处理整个地址空间的页表操作,比如在退出和执行过程中发生的事情。
2) ``void flush_cache_dup_mm(struct mm_struct *mm)``
这个接口将整个用户地址空间从高速缓存中刷新掉。也就是说,在运行后,将没有与‘mm’相关的缓存行。
这个接口被用来处理整个地址空间的页表操作,比如在 fork 过程中发生 的事情。
这个选项与 flush_cache_mm 分开,以允许对 VIPT 缓存进行一些优化。
3) ``void flush_cache_range(struct vm_area_struct *vma, unsigned long start, unsigned long end)``
在这里,我们要从缓存中刷新一个特定范围的(用户)虚拟地址。运行后,在“start”到“end-1”范围内的虚拟地址的“vma->vm_mm”的缓存中将没有页表项。
“vma”是被用于该区域的备份存储。主要是用于 munmap() 类型的操作。
提供这个接口是希望端口能够找到一个合适的有效方法来从缓存中删除多个页面大小的区域,而不是让内核为每个可能被修改的页表项调用 flush_cache_page (见下文)。
4) ``void flush_cache_page(struct vm_area_struct *vma, unsigned long addr, unsigned long pfn)``
这一次我们需要从缓存中删除一个 PAGE_SIZE 大小的区域。“vma”是Linux用来跟踪进程的 mmap 区域的支持结构体,地址空间可以通过 vma->vm_mm 获得。另外,我们可以通过测试(vma->vm_flags & VM_EXEC)来查看这个区域是否是可执行的(因此在“Harvard”类型的缓存布局中可能是在“指令缓存”中)。
“pfn”表示“addr”所对应的物理页框(通过 PAGE_SHIFT 左移这个值来获得物理地址)。正是这个映射应该从缓存中删除。
在运行之后,对于虚拟地址‘addr’的‘vma->vm_mm’,在缓存中不会有任何页表项,它被翻译成‘pfn’。
这主要是在故障处理过程中使用。
5) ``void flush_cache_kmaps(void)``
只有在平台使用高位内存的情况下才需要实现这个程序。它将在所有的 kmaps 失效之前被调用。
运行后,内核虚拟地址范围 PKMAP_ADDR(0) 到 PKMAP_ADDR(LAST_PKMAP) 的缓存中将没有页表项。
这个程序应该在asm/highmem.h中实现。
6) ``void flush_cache_vmap(unsigned long start, unsigned long end)``
``void flush_cache_vunmap(unsigned long start, unsigned long end)``
在这里,在这两个接口中,我们从缓存中刷新一个特定范围的(内核)虚拟地址。运行后,在“start”到“end-1”范围内的虚拟地址的内核地址空间的缓存中不会有页表项。
这两个程序中的第一个是在 vmap_range() 安装了页表项之后调用的。第二个是在 vunmap_range() 删除页表项之前调用的。
还有一类cpu缓存问题,目前需要一套完全不同的接口来正确处理。最大的问题是处理器的数据缓存中的虚拟别名。
.. note::
这段内容有些晦涩,为了减轻中文阅读压力,特作此译注。
别名(alias)属于缓存一致性问题,当不同的虚拟地址映射相同的物理地址,而这些虚拟地址的 index 不同,此时就发生了别名现象(多个虚拟地址被称为别名)。通俗点来说就是指同一个物理地址的数据被加载到不同的 cacheline 中就会出现别名现象。
常见的解决方法有两种:第一种是硬件维护一致性,设计特定的cpu电路来解决问题(例如设计为 PIPT 的 cache);第二种是软件维护一致性,就是下面介绍的 sparc 的解决方案 —— 页面染色,涉及的技术细节太多,译者不便展开,请读者自行查阅相关资料。
您的移植是否容易在其 D-cache 中出现虚拟别名?嗯,如果您的 D-cache 是虚拟索引的,且 cache 大于 PAGE_SIZE(页大小),并且不能防止同一物理地址的多个 cache 行同时存在,您就会遇到这个问题。
如果你的 D-cache 有这个问题,首先正确定义 asm/shmparam.h SHMLBA,它基本上应该是你的虚拟寻址 D-cache 的大小(或者如果大小是可变的,则是最大的可能大小)。这个设置将迫使 SYSv IPC 层只允许用户进程在这个值的倍数的地址上对共享内存进行映射。
.. note::
这并不能解决共享 mmaps 的问题,请查看 sparc64 移植解决这个问题的一个方法(特别是 SPARC_FLAG_MMAPSHARED)。
接下来,你必须解决所有其他情况下的 D-cache 别名问题。请记住这个事实,对于一个给定的页面映射到某个用户地址空间,总是至少还有一个映射,那就是内核在其线性映射中从 PAGE_OFFSET 开始。因此,一旦第一个
用户将一个给定的物理页映射到它的地址空间,就意味着 D-cache 的别名问题有可能存在,因为内核已经将这个页映射到它的虚拟地址。
``void copy_user_page(void *to, void *from, unsigned long addr, struct page *page)``
``void clear_user_page(void *to, unsigned long addr, struct page *page)``
这两个程序在用户匿名或COW页中存储数据。它允许一个端口有效地避免用户空间和内核之间的 D-cache 别名问题。
例如,一个端口可以在复制过程中把“from”和“to”暂时映射到内核的虚拟地址上。这两个页面的虚拟地址的选择方式是,内核的加载/存储指令发生在虚拟地址上,而这些虚拟地址与用户的页面映射是相同的“颜色”。例如,Sparc64就使用这种技术。
“addr”参数告诉了用户最终要映射这个页面的虚拟地址,“page”参数给出了一个指向目标页结构体的指针。
如果 D-cache 别名不是问题,这两个程序可以简单地直接调用 memcpy/memset 而不做其他事情。
``void flush_dcache_page(struct page *page)``
任何时候,当内核写到一个页面缓存页,或者内核要从一个页面缓存页中读出,并且这个页面的用户空间共享/可写映射可能存在时,这个程序就会被调用。
.. note::
这个程序只需要为有可能被映射到用户进程的地址空间的页面缓存调用。因此,例如,处理页面缓存中vfs符号链接的VFS层代码根本不需要调用这个接口。
“内核写入页面缓存的页面”这句话的意思是,具体来说,内核执行存储指令,在该页面的页面->虚拟映射处弄脏该页面的数据。在这里,通过刷新的手段处理 D-cache 的别名是很重要的,以确保这些内核存储对该页的用户空间映射是可见的。
推论的情况也同样重要,如果有用户对这个文件有共享+可写的映射,我们必须确保内核对这些页面的读取会看到用户所做的最新的存储。
如果D-cache别名不是一个问题,这个程序可以简单地定义为该架构上的nop。
在 page->flags (PG_arch_1)中有一个位是“架构私有”。内核保证,对于分页缓存的页面,当这样的页面第一次进入分页缓存时,它将清除这个位。
这使得这些接口可以更有效地被实现。如果目前没有用户进程映射这个页面,它允许我们“推迟”(也许是无限期)实际的刷新过程。请看 sparc64 的 flush_dcache_page 和 update_mmu_cache 实现,以了解如何做到这一点。
这个想法是,首先在 flush_dcache_page() 时,如果 page->mapping->i_mmap 是一个空树,只需标记架构私有页标志位。之后,在 update_mmu_cache()
中,会对这个标志位进行检查,如果设置了,就进行刷新,并清除标志位。
.. important::
通常很重要的是,如果你推迟刷新,实际的刷新发生在同一个 CPU 上,因为它将 cpu 存储到页面上,使其变脏。同样,请看 sparc64 关于如何处理这个问题的例子。
``void flush_dcache_folio(struct folio *folio)``
该函数的调用情形与 flush_dcache_page() 相同。它允许架构针对刷新整个 folio 页面进行优化,而不是一次刷新一页。
``void copy_to_user_page(struct vm_area_struct *vma, struct page *page, unsigned long user_vaddr, void *dst, void *src, int len)``
``void copy_from_user_page(struct vm_area_struct *vma, struct page *page, unsigned long user_vaddr, void *dst, void *src, int len)``
当内核需要复制任意的数据进出任意的用户页时(比如 ptrace()),它将使用这两个程序。
任何必要的缓存刷新或其他需要发生的一致性操作都应该在这里发生。如果处理器的指令缓存没有对cpu存储进行窥探,那么你很可能需要为 copy_to_user_page() 刷新指令缓存。
``void flush_anon_page(struct vm_area_struct *vma, struct page *page, unsigned long vmaddr)``
当内核需要访问一个匿名页的内容时,它会调用这个函数(目前只有 get_user_pages())。注意:flush_dcache_page()故意对匿名页不起作用。默认的实现是nop(对于所有相干的架构应该保持这样)。对于不一致性的架构,它应该刷新 vmaddr 处的页面缓存。
``void flush_icache_range(unsigned long start, unsigned long end)``
当内核存储到它将执行的地址中时(例如在加载模块时),这个函数被调用。如果 icache 不对存储进行窥探,那么这个程序将需要对其进行刷新。
``void flush_icache_page(struct vm_area_struct *vma, struct page *page)``
flush_icache_page 的所有功能都可以在 flush_dcache_page 和 update_mmu_cache 中实现。在未来,我们希望能够完全删除这个接口。
最后一类API是用于I/O到内核内特意设置的别名地址范围。这种别名是通过使用 vmap/vmalloc API设置的。由于内核I/O是通过物理页进行的,I/O子系统假定用户映射和内核偏移映射是唯一的别名。这对 vmap 别名来说是不正确的,所以内核中任何试图对 vmap 区域进行I/O的东西都必须手动管理一致性。它必须在做I/O之前刷新 vmap 范围,并在I/O返回后使其失效。
``void flush_kernel_vmap_range(void *vaddr, int size)``
刷新 vmap 区域中指定的虚拟地址范围的内核缓存。这是为了确保内核在 vmap 范围内修改的任何数据对物理页是可见的。这个设计是为了使这个区域可以安全地执行I/O。注意,这个API并 *没有* 刷新该区域的偏移映射别名。
``void invalidate_kernel_vmap_range(void *vaddr, int size) invalidates``
在 vmap 区域的一个给定的虚拟地址范围的缓存,这可以防止处理器在物理页的I/O发生时通过投机性地读取数据而使缓存变脏。这只对读入 vmap 区域的数据是必要的。
二、hugetlbpage.rst
注: 本节翻译自 kernel-6.1/Documentation/translations/zh_CN/arm64/hugetlbpage.rst
=====================
ARM64中的 HugeTLBpage
=====================
大页依靠有效利用 TLBs 来提高地址翻译的性能。这取决于以下两点 -
- 大页的大小
- TLBs 支持的条目大小
ARM64 接口支持2种大页方式。
1) pud/pmd 级别的块映射
-----------------------
这是常规大页,他们的 pmd 或 pud 页面表条目指向一个内存块。不管 TLB 中支持的条目大小如何,块映射可以减少翻译大页地址所需遍历的页表深度。
2) 使用连续位
-------------
架构中转换页表条目(D4.5.3, ARM DDI 0487C.a)中提供一个连续位告诉 MMU 这个条目是一个连续条目集的一员,它可以被缓存在单个 TLB 条目中。
在 Linux 中连续位用来增加 pmd 和 pte(最后一级)级别映射的大小。受支持的连续页表条目数量因页面大小和页表级别而异。
支持以下大页尺寸配置 -
====== ======== ==== ======== === - CONT PTE PMD CONT PMD PUD ====== ======== ==== ======== === 4K: 64K 2M 32M 1G 16K: 2M 32M 1G 64K: 2M 512M 16G ====== ======== ==== ======== ===
三、hugetlbfs_reserv.rst
注: 本节翻译自 kernel-6.1/Documentation/translations/zh_CN/mm/hugetlbfs_reserv.rst
==============
Hugetlbfs 预留
==============
概述
====
:ref:`hugetlbpage` 中描述的巨页通常是预先分配给应用程序使用的。如果 VMA 指示要使用巨页,这些巨页会在缺页异常时被实例化到任务的地址空间。如果在缺页异常时没有巨页存在,任务就会被发送一个 SIGBUS,并经常不高兴地死去。在加入巨页支持后不久,人们决定,在 mmap() 时检测巨页的短缺情况会更好。这个想法是,如果没有足够的巨页来覆盖映射,mmap()将失败。这首先是在 mmap() 时在代码中做一个简单的检查,以确定是否有足够的空闲巨页来覆盖映射。就像内核中的大多数东西一样,代码随着时间的推移而不断发展。然而,基本的想法是在 mmap() 时 “预留”巨页,以确保巨页可以用于该映射中的缺页异常。下面的描述试图描述在 v4.10 内核中是如何进行巨页预留处理的。
读者
====
这个描述主要是针对正在修改 hugetlbfs 代码的内核开发者。
数据结构
========
resv_huge_pages
这是一个全局的(per-hstate)预留的巨页的计数。预留的巨页只对预留它们的任务可用。因此,一般可用的巨页的数量被计算为(``free_huge_pages - resv_huge_pages``)。
Reserve Map
预留映射由以下结构体描述::
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; };
file_region 结构体的 ‘from’ 和 ‘to’ 字段是进入映射的巨页索引。根据映射的类型,在 reserv_map 中的一个区域可能表示该范围存在预留,或预留不存在。
Flags for MAP_PRIVATE Reservations
这些被存储在预留的映射指针的底部。
``#define HPAGE_RESV_OWNER (1UL << 0)`` 表示该任务是与该映射相关的预留的所有者。
``#define HPAGE_RESV_UNMAPPED (1UL << 1)`` 表示最初映射此范围(并创建储备)的任务由于COW失败而从该任务(子任务)中取消映射了一个页面。
Page Flags
PagePrivate 页面标志是用来指示在释放巨页时必须恢复巨页的预留。更多细节将在 “释放巨页” 一节中讨论。
预留映射位置(私有或共享)
==========================
一个巨页映射或段要么是私有的,要么是共享的。如果是私有的,它通常只对一个地址空间(任务)可用。如果是共享的,它可以被映射到多个地址空间(任务)。对于这两种类型的映射,预留映射的位置和语义是明显不同的。位置的差异是:
- 对于私有映射,预留映射挂在 VMA 结构体上。具体来说,就是 vma->vm_private_data。这个保留映射是在创建映射(mmap(MAP_PRIVATE))时创建的。
- 对于共享映射,预留映射挂在 inode 上。具体来说,就是 inode->i_mapping->private_data。 由于共享映射总是由 hugetlbfs 文件系统中的文件支持,hugetlbfs 代码确保每个节点包含一个预留映射。因此,预留映射在创建节点时被分配。
创建预留
========
当创建一个巨大的有页面支持的共享内存段(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)的范围内。关于如何实现这一点的细节,请参见 :ref:`预留映射的修改 <resv_map_modifications>` 一节。
该映射可能与一个子池(subpool)相关联。如果是这样,将查询子池以确保有足够的空间用于映射。子池有可能已经预留了可用于映射的预留空间。更多细节请参见 :ref: `子池预留 <sub_pool_resv>`一节。
在咨询了预留映射和子池之后,就知道了需要的新预留数量。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_huge_page() 中进行的::
struct page *alloc_huge_page(struct vm_area_struct *vma, unsigned long addr, int avoid_reserve)
alloc_huge_page 被传递给一个 VMA 指针和一个虚拟地址,因此它可以查阅预留映射以确定是否存在预留。此外,alloc_huge_page 需要一个参数 avoid_reserve,该参数表示即使看起来已经为指定的地址预留了预留,也不应该使用预留。avoid_reserve 参数最常被用于写时拷贝和页面迁移的情况下,即现有页面的额
外拷贝被分配。
调用辅助函数 vma_needs_reservation() 来确定是否存在对映射(vma)中地址的预留。关于这个函数的详细内容,请参见 :ref:`预留映射帮助函数 <resv_map_helpers>` 一节。从 vma_needs_reservation() 返回的值通常为 0 或 1。如果该地址存在预留,则为 0,如果不存在预留,则为 1。如果不存在预留,并且有一个与映射相关联的子池,则查询子池以确定它是否包含预留。如果子池包含预留,则可将其中一个用于该分配。然而,在任何情况下,avoid_reserve 参数都会优先考虑为分配使用预留。在确定预留是否存在并可用于分配后,调用 dequeue_huge_page_vma() 函数。这个函数需要两个与预留有关的参数:
- avoid_reserve,这是传递给 alloc_huge_page() 的同一个值/参数。
- chg,尽管这个参数的类型是 long,但只有 0 或 1 的值被传递给 dequeue_huge_page_vma。如果该值为0,则表明存在预留(关于可能的问题,请参见 “预留和内存策略” 一节)。如果值为 1,则表示不存在预留,如果可能的话,必须从全局空闲池中取出该页。
与VMA的内存策略相关的空闲列表被搜索到一个空闲页。如果找到了一个页面,当该页面从空闲列表中移除时,free_huge_pages 的值被递减。如果有一个与该页相关的预留,将进行以下调整::
/* 表示分配这个页面消耗了一个预留,如果遇到错误,以至于必须释放这个页面,预留将被恢复。执行 set_bit(PG_private, &page->flags); */ SetPagePrivate(page); /* 减少全局预留计数 */ resv_huge_pages--;
注意,如果找不到满足 VMA 内存策略的巨页,将尝试使用伙伴分配器分配一个。这就带来了超出预留范围的剩余巨页和超额分配的问题。即使分配了一个多余的页面,也会进行与上面一样的基于预留的调整: SetPagePrivate(page) 和 resv_huge_pages--.
在获得一个新的巨页后,(page)->private 被设置为与该页面相关的子池的值,如果它存在的话。当页面被释放时,这将被用于子池的计数。
然后调用函数 vma_commit_reservation(),根据预留的消耗情况调整预留映射。一般来说,这涉及到确保页面在区域映射的 file_region 结构体中被表示。对于预留存在的共享映射,预留映射中的条目已经存在,所以不做任何改变。然而,如果共享映射中没有预留,或者这是一个私有映射,则必须创建一
个新的条目。
注意,如果找不到满足VMA内存策略的巨页,将尝试使用伙伴分配器分配一个。这就带来了超出预留范围的剩余巨页和过度分配的问题。即使分配了一个多余的页面,也会进行与上面一样的基于预留的调整。SetPagePrivate(page) 和 resv_huge_pages-。
在获得一个新的巨页后,(page)->private 被设置为与该页面相关的子池的值,如果它存在的话。当页面被释放时,这将被用于子池的计数。
然后调用函数 vma_commit_reservation(),根据预留的消耗情况调整预留映射。一般来说,这涉及到确保页面在区域映射的 file_region 结构体中被表示。对于预留存在的共享映射,预留映射中的条目已经存在,所以不做任何改变。然而,如果共享映射中没有预留,或者这是一个私有映射,则必须创建
一个新的条目。
在 alloc_huge_page() 开始调用 vma_needs_reservation() 和页面分配后调用 vma_commit_reservation() 之间,预留映射有可能被改变。如果 hugetlb_reserve_pages 在共享映射中为同一页面被调用,这将是可能的。在这种情况下,预留计数和子池空闲页计数会有一个偏差。这种罕见的情况可以通过比较 vma_needs_reservation 和 vma_commit_reservation 的返回值来识别。如果检测到这种竞争,子池和全局预留计数将被调整以进行补偿。关于这些函数的更多信息,请参见 :ref:`预留映射帮助函数 <resv_map_helpers>` 一节。
实例化巨页
==========
在巨页分配之后,页面通常被添加到分配任务的页表中。在此之前,共享映射中的页面被添加到页面缓存中,私有映射中的页面被添加到匿名反向映射中。在这两种情况下,PagePrivate 标志被清除。因此,当一个已经实例化的巨页被释放时,不会对全局预留计数(resv_huge_pages)进行调整。
释放巨页
========
巨页释放是由函数 free_huge_page() 执行的。这个函数是 hugetlbfs 复合页的析构器。因此,它只传递一个指向页面结构体的指针。当一个巨页被释放时,可能需要进行预留计算。如果该页与包含保留的子池相关联,或者该页在错误路径上被释放,必须恢复全局预留计数,就会出现这种情况。
page->private 字段指向与该页相关的任何子池。如果 PagePrivate 标志被设置,它表明全局预留计数应该被调整(关于如何设置这些标志的信息,请参见
:ref: `消耗预留/分配一个巨页 <consume_resv>` )。
该函数首先调用 hugepage_subpool_put_pages() 来处理该页。如果这个函数返回一个 0 的值(不等于传递的 1 的值),它表明预留与子池相关联,这个新释放的页面必须被用来保持子池预留的数量超过最小值。因此,在这种情况下,全局 resv_huge_pages 计数器被递增。
如果页面中设置了 PagePrivate 标志,那么全局 resv_huge_pages 计数器将永远被递增。
子池预留
========
有一个结构体 hstate 与每个巨页尺寸相关联。hstate 跟踪所有指定大小的巨页。一个子池代表一个 hstate 中的页面子集,它与一个已挂载的 hugetlbfs 文件系统相关。
当一个 hugetlbfs 文件系统被挂载时,可以指定 min_size 选项,它表示文件系统所需的最小的巨页数量。如果指定了这个选项,与 min_size 相对应的巨页的数量将被预留给文件系统使用。这个数字在结构体 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] 内有多少页目前没有被代表。
调用代码执行全局检查和分配,以确定是否有足够的巨页使操作成功。
2)
a) 如果操作能够成功,regi_add() 将被调用,以实际修改先前传递给 regi_chg() 的相同范围 [f, t] 的预留映射。
b) 如果操作不能成功,region_abort 被调用,在相同的范围 [f, t] 内中止操作。
注意,这是一个两步的过程,region_add() 和 region_abort() 在事先调用 region_chg() 后保证成功。 region_chg() 负责预先分配任何必要的数据结构以确保后续操作(特别是 region_add())的成功。
如上所述,region_chg() 确定该范围内当前没有在映射中表示的页面的数量。region_add() 返回添加到映射中的范围内的页数。在大多数情况下,region_add() 的返回值与 region_chg() 的返回值相同。然而,在共享映射的情况下,有可能在调用 region_chg() 和 region_add() 之间对预留映射进行更改。在这种情况下,regi_add() 的返回值将与 regi_chg() 的返回值不符。在这种情况下,全局计数和子池计数很可能是不正确的,需要调整。检查这种情况并进行适当的调整是调用者的责任。
函数 region_del() 被调用以从预留映射中移除区域。它通常在以下情况下被调用:
- 当 hugetlbfs 文件系统中的一个文件被删除时,该节点将被释放,预留映射也被释放。在释放预留映射 之前,所有单独的 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() 在解除私有巨页映射时被调用。在私有映射中,预留映射中没有条目表明存在一个预留。因此,通过计算预留映射中的条目数,我们知道有多少预留被消耗了,有多少预留是未完成的(Outstanding = (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)
这是一个特殊的包装函数,有助于在错误路径上清理预留。它只从 repare_reserve_on_error() 函数中调用。该函数与 vma_needs_reservation 一起使用,试图将一个预留添加到预留映射中。它考虑到了私有和共享映射的不同预留映射语义。因此,region_add 被调用用于共享映射(因为映射中的条目表
示预留),而 region_del 被调用用于私有映射(因为映射中没有条目表示预留)。关于在错误路径上需要做什么的更多信息,请参见 “错误路径中的预留清理” 。
错误路径中的预留清理
====================
正如在:ref:`预留映射帮助函数<resv_map_helpers>` 一节中提到的,预留的修改分两步进行。首先,在分配页面之前调用 vma_needs_reservation。如果分配成功,则调用 vma_commit_reservation。 如果不是,则调用 vma_end_reservation。全局和子池的预留计数根据操作的成功或失败进行调整,一切都很好。
此外,在一个巨页被实例化后,PagePrivate 标志被清空,这样,当页面最终被释放时,计数是正确的。
然而,有几种情况是,在一个巨页被分配后,但在它被实例化之前,就遇到了错误。在这种情况下,页面分配已经消耗了预留,并进行了适当的子池、预留映射和全局计数调整。如果页面在这个时候被释放(在实例化和清除 PagePrivate 之前),那么 free_huge_page 将增加全局预留计数。然而,预留映射显示报留被消耗了。这种不一致的状态将导致预留的巨页的 “泄漏” 。全局预留计数将比它原本的要高,并阻止分配一个预先分配的页面。
函数 restore_reserve_on_error() 试图处理这种情况。它有相当完善的文档。这个函数的目的是将预留映射恢复到页面分配前的状态。通过这种方式,预留映射的状态将与页面释放后的全局预留计数相对应。
函数 restore_reserve_on_error 本身在试图恢复预留映射条目时可能会遇到错误。在这种情况下,它将简单地清除该页的 PagePrivate 标志。这样一来,当页面被释放时,全局预留计数将不会被递增。然而,预留映射将继续看起来像预留被消耗了一样。一个页面仍然可以被分配到该地址,但它不会像最
初设想的那样使用一个预留页。
有一些代码(最明显的是 userfaultfd)不能调用 restore_reserve_on_error。在这种情况下,它简单地修改了 PagePrivate,以便在释放巨页时不会泄露预留。
预留和内存策略
==============
当 git 第一次被用来管理 Linux 代码时,每个节点的巨页列表就存在于 hstate 结构中。预留的概念是在一段时间后加入的。当预留被添加时,没有尝试将内存策略考虑在内。虽然 cpusets 与内存策略不完全相同,但 hugetlb_acct_memory 中的这个注释总结了预留和 cpusets/内存策略之间的相互作用::
/*
* 当 cpuset 被配置时,它打破了严格的 hugetlb 页面预留,因为计数是在一个全局变量上完
* 成的。在有 cpuset 的情况下,这样的预留完全是垃圾,因为预留没有根据当前 cpuset 的
* 页面可用性来检查。在任务所在的 cpuset 中缺乏空闲的 htlb 页面时,应用程序仍然有可能
* 被内核 OOM'ed。试图用 cpuset 来执行严格的计数几乎是不可能的(或者说太难看了),因
* 为 cpuset 太不稳定了,任务或内存节点可以在 cpuset 之间动态移动。与 cpuset 共享
* hugetlb 映射的语义变化是不可取的。然而,为了预留一些语义,我们退回到检查当前空闲
* 页的可用性,作为一种最好的尝试,希望能将 cpuset 改变语义的影响降到最低。
*/
添加巨页预留是为了防止在缺页异常时出现意外的页面分配失败(OOM)。然而,如果一个应用程序使用 cpusets 或内存策略,就不能保证在所需的节点上有巨页可用。即使有足够数量的全局预留,也是如此。
Hugetlbfs回归测试
=================
最完整的 hugetlb 测试集在 libhugetlbfs 仓库。如果你修改了任何 hugetlb 相关的代码,请使用 libhugetlbfs 测试套件来检查回归情况。此外,如果你添加了任何新的 hugetlb 功能,请在 libhugetlbfs 中添加适当的测试。
--
Mike Kravetz,2017年4月7日
posted on 2026-07-20 20:41 Hello-World3 阅读(3) 评论(0) 收藏 举报
浙公网安备 33010602011771号