大页内存hugetlbfs学习记录

发布于:2025-03-07 ⋅ 阅读:(37) ⋅ 点赞:(0)

大页内存原理

在介绍 HugePages 之前,我们先来回顾一下 Linux 下 虚拟内存 与 物理内存 之间的关系。

物理内存:也就是安装在计算机中的内存条,比如安装了 2GB 大小的内存条,那么物理内存地址的范围就是 0 ~ 2GB。

虚拟内存:虚拟的内存地址。由于 CPU 只能使用物理内存地址,所以需要将虚拟内存地址转换为物理内存地址才能被 CPU 使用,这个转换过程由 MMU(Memory Management Unit,内存管理单元) 来完成。在 32 位的操作系统中,虚拟内存空间大小为 0 ~ 4GB。

我们通过 图1 来描述虚拟内存地址转换成物理内存地址的过程:
在这里插入图片描述

如 图1 所示,页表 保存的是虚拟内存地址与物理内存地址的映射关系,MMU页表 中找到虚拟内存地址所映射的物理内存地址,然后把物理内存地址提交给 CPU,这个过程与 Hash 算法相似。

内存映射是以内存页作为单位的,通常情况下,一个内存页的大小为 4KB(如图1所示),所以称为 分页机制

内存映射

我们来看看在 64 位的 Linux 系统中(英特尔 x64 CPU),虚拟内存地址转换成物理内存地址的过程,如图2:

在这里插入图片描述

从图2可以看出,Linux 只使用了 64 位虚拟内存地址的前 48 位(0 ~ 47位),并且 Linux 把这 48 位虚拟内存地址分为 5 个部分,如下:

PGD索引:39 ~ 47 位(共9个位),指定在 页全局目录(PGD,Page Global Directory)中的索引。

PUD索引:30 ~ 38 位(共9个位),指定在 页上级目录(PUD,Page Upper Directory)中的索引。

PMD索引:21 ~ 29 位(共9个位),指定在 页中间目录(PMD,Page Middle Directory)中的索引。

PTE索引:12 ~ 20 位(共9个位),指定在 页表(PT,Page Table)中的索引。

偏移量:0 ~ 11 位(共12个位),指定在物理内存页中的偏移量。

把 图1 中的== 页表== 分为 4 级:页全局目录页上级目录页中间目录页表 目的是为了减少内存消耗(思考下为什么可以减少内存消耗)。

注意:页全局目录、页上级目录、页中间目录 和 页表 都占用一个 4KB 大小的物理内存页,由于 64 位内存地址占用 8 个字节,所以一个 4KB 大小的物理内存页可以容纳 512 个 64 位内存地址。

另外,CPU 有个名为 CR3 的寄存器,用于保存 页全局目录 的起始物理内存地址(如图2所示)。所以,虚拟内存地址转换成物理内存地址的过程如下:

CR3 寄存器中获取 页全局目录 的物理内存地址,然后以虚拟内存地址的 39 ~ 47 位作为索引,从 页全局目录 中读取到 页上级目录 的物理内存地址。

以虚拟内存地址的 30 ~ 38 位作为索引,从 页上级目录 中读取到 页中间目录 的物理内存地址。

以虚拟内存地址的 21 ~ 29 位作为索引,从 页中间目录 中读取到 页表 的物理内存地址。

以虚拟内存地址的 12 ~ 20 位作为索引,从 页表 中读取到 物理内存页 的物理内存地址。

以虚拟内存地址的 0 ~ 11 位作为 物理内存页 的偏移量,得到最终的物理内存地址。

hugapage 原理

上面介绍了以 4KB 的内存页作为内存映射的单位,但有些场景我们希望使用更大的内存页作为映射单位(如 2MB)。使用更大的内存页作为映射单位有如下好处:

减少 TLB(Translation Lookaside Buffer) 的失效情况。

减少 页表 的内存消耗。

减少 PageFault(缺页中断)的次数。

Tips:TLB 是一块高速缓存,TLB 缓存虚拟内存地址与其映射的物理内存地址。MMU 首先从 TLB 查找内存映射的关系,如果找到就不用回溯查找页表。否则,只能根据虚拟内存地址,去页表中查找其映射的物理内存地址。

因为映射的内存页越大,所需要的 页表 就越小(很容易理解);页表 越小,TLB 失效的情况就越少。

使用大于 4KB 的内存页作为内存映射单位的机制叫 HugePages,目前 Linux 常用的 HugePages 大小为 2MB 和 1GB,我们以 2MB 大小的内存页作为例子。

要映射更大的内存页,只需要增加偏移量部分,如 图3 所示:
在这里插入图片描述
如 图3 所示,现在把偏移量部分扩展到 21 位(页表部分被覆盖了,21 位能够表示的大小范围为 0 ~ 2MB),所以 页中间目录 直接指向映射的 物理内存页地址

这样,就可以减少 页表 部分的内存消耗。由于内存映射关系变少,所以 TLB 失效的情况也会减少。

hugapages 使用

了解了 HugePages 的原理后,我们来介绍一下怎么使用 HugePages。

HugePages 的使用不像普通内存申请那么简单,而是需要借助 Hugetlb文件系统 来创建,下面将会介绍 HugePages 的使用步骤:

  1. 挂载 Hugetlb 文件系统
    Hugetlb 文件系统是专门为 HugePages 而创造的,我们可以通过以下命令来挂载一个 Hugetlb 文件系统:
1$ mkdir /mnt/huge
2$ mount none /mnt/huge -t hugetlbfs

执行完上面的命令后,我们就在 /mnt/huge 目录下挂载了 Hugetlb 文件系统。

  1. 声明可用 HugePages 数量
    要使用 HugePages,首先要向内核声明可以使用的 HugePages 数量。/proc/sys/vm/nr_hugepages 文件保存了内核可以使用的 HugePages 数量,我们可以使用以下命令设置新的可用 HugePages 数量:
1$ echo 20 > /proc/sys/vm/nr_hugepages

  1. 编写申请 HugePages 的代码
    要使用 HugePages,必须使用 mmap 系统调用把虚拟内存映射到 Hugetlb 文件系统中的文件,如下代码:
1#include <fcntl.h>
 2#include <sys/mman.h>
 3#include <errno.h>
 4#include <stdio.h>
 5
 6#define MAP_LENGTH (10*1024*1024) // 10MB
 7
 8int main()
 9{
10    int fd;
11    void * addr;
12
13    // 1. 创建一个 Hugetlb 文件系统的文件
14    fd = open("/mnt/huge/hugepage1", O_CREAT|O_RDWR);
15    if (fd < 0) {
16        perror("open()");
17        return -1;
18    }
19
20    // 2. 把虚拟内存映射到 Hugetlb 文件系统的文件中
21    addr = mmap(0, MAP_LENGTH, PROT_READ|PROT_WRITE, MAP_SHARED, fd, 0);
22    if (addr == MAP_FAILED) {
23        perror("mmap()");
24        close(fd);
25        unlink("/mnt/huge/hugepage1");
26        return -1;
27    }
28
29    strcpy(addr, "This is HugePages example...");
30    printf("%s\n", addr);
31
32    // 3. 使用完成后,解除映射关系
33    munmap(addr, MAP_LENGTH);
34    close(fd);
35    unlink("/mnt/huge/hugepage1");
36
37    return 0;
38 }

编译上面的代码并且执行,如果没有问题,将会输出以下信息:

This is HugePages example...

大页内存的实现

HugePages分配器初始化

在内核初始化时,会调用 hugetlb_init 函数对 HugePages 分配器进行初始化,其实现如下:

1static int __init hugetlb_init(void)
 2{
 3    unsigned long i;
 4
 5    // 1. 初始化空闲大内存页链表 hugepage_freelists, 
 6    //    内核使用 hugepage_freelists 链表把空闲的大内存页连接起来,
 7    //    为了分析简单,我们可以把 MAX_NUMNODES 当成 1
 8    for (i = 0; i < MAX_NUMNODES; ++i)          
 9        INIT_LIST_HEAD(&hugepage_freelists[i]); 
10
11    // 2. max_huge_pages 为系统能够使用的大页内存的数量,
12    //    由系统启动项 hugepages 指定,
13    //    这里主要申请大内存页, 并且保存到 hugepage_freelists 链表中.
14    for (i = 0; i < max_huge_pages; ++i) {
15        if (!alloc_fresh_huge_page())
16            break;
17    }
18
19    max_huge_pages = free_huge_pages = nr_huge_pages = i;
20
21    return 0;
22}

hugetlb_init 函数主要完成两个工作:

初始化空闲大内存页链表 hugepage_freelists,这个链表保存了系统中能够使用的大内存。

为系统申请空闲的大内存页,并且保存到 hugepage_freelists 链表中。

我们再来分析下 alloc_fresh_huge_page 函数是怎么申请大内存页的,其实现如下:

1static int alloc_fresh_huge_page(void)
 2{
 3    static int prev_nid;
 4    struct page *page;
 5    int nid;
 6    ...
 7    // 1. 申请一个大的物理内存页...
 8    page = alloc_pages_node(nid, htlb_alloc_mask|__GFP_COMP|__GFP_NOWARN,
 9                            HUGETLB_PAGE_ORDER);
10
11    if (page) {
12        // 2. 设置释放大内存页的回调函数为 free_huge_page
13        set_compound_page_dtor(page, free_huge_page); 
14        ...
15        // 3. put_page 函数将会调用上面设置的 free_huge_page 函数把内存页放入到缓存队列中
16        put_page(page);
17
18        return 1;
19    }
20
21    return 0;
22}

所以,alloc_fresh_huge_page 函数主要完成三个工作:

调用 alloc_pages_node 函数申请一个大内存页(2MB)。

设置大内存页的释放回调函数为 free_huge_page,当释放大内存页时,将会调用这个函数进行释放操作。

调用 put_page 函数释放大内存页,其将会调用 free_huge_page 函数进行相关操作。

那么,我们来看看 free_huge_page 函数是怎么释放大内存页的,其实现如下:

1static void free_huge_page(struct page *page)
2{
3    ...
4    enqueue_huge_page(page);     // 把大内存页放置到空闲大内存页链表中
5    ...
6}

free_huge_page 函数主要调用 enqueue_huge_page 函数把大内存页添加到空闲大内存页链表中,其实现如下:

 1static void enqueue_huge_page(struct page *page)
 2{
 3    int nid = page_to_nid(page); // 我们假设这里一定返回 0
 4
 5    // 把大内存页添加到空闲链表 hugepage_freelists 中
 6    list_add(&page->lru, &hugepage_freelists[nid]);
 7
 8    // 增加计数器
 9    free_huge_pages++;
10    free_huge_pages_node[nid]++;
11}

从上面的实现可知,enqueue_huge_page 函数只是简单的把大内存页添加到空闲链表 hugepage_freelists 中,并且增加计数器。

假如我们设置了系统能够使用的大内存页为 100 个,那么空闲大内存页链表 hugepage_freelists 的结构如下图所示:

在这里插入图片描述
所以,HugePages 分配器初始化的调用链为:

 1hugetlb_init()
 2      |
 3      +——> alloc_fresh_huge_page()
 4                      |
 5                      |——> alloc_pages_node()
 6                      |——> set_compound_page_dtor()
 7                      +——> put_page()
 8                               |
 9                               +——> free_huge_page()
10                                            |
11                                            +——> enqueue_huge_page()

hugetlbfs 文件系统

为系统准备好空闲的大内存页后,现在来了解下怎样分配大内存页。在《一文读懂 HugePages的原理》一文中介绍过,要申请大内存页,必须使用 mmap 系统调用把虚拟内存映射到 hugetlbfs 文件系统中的文件中。

免去繁琐的文件系统挂载过程,我们主要来看看当使用 mmap 系统调用把虚拟内存映射到 hugetlbfs 文件系统的文件时会发生什么事情。

每个文件描述符对象都有个 mmap 的方法,此方法会在调用 mmap 函数映射到文件时被触发,我们来看看 hugetlbfs 文件的 mmap 方法所对应的真实函数,如下:

1const struct file_operations hugetlbfs_file_operations = {
2    .mmap               = hugetlbfs_file_mmap,
3    .fsync              = simple_sync_file,
4    .get_unmapped_area  = hugetlb_get_unmapped_area,
5};

从上面的代码可以发现,hugetlbfs 文件的 mmap 方法被设置为 hugetlbfs_file_mmap 函数。所以当调用 mmap 函数映射 hugetlbfs 文件时,将会调用 hugetlbfs_file_mmap 函数来处理。

而 hugetlbfs_file_mmap 函数最主要的工作就是把虚拟内存分区对象的 vm_flags 字段添加 VM_HUGETLB 标志位,如下代码:

在这里插入图片描述
从上图可以看出,使用 HugePages 后,页中间目录 直接指向物理内存页。所以,hugetlb_fault 函数主要就是对 页中间目录项 进行填充。实现如下:

hugetlb_fault 函数进行精简后,主要完成两个工作:

通过触发 缺页异常 的虚拟内存地址找到其对应的 页中间目录项。

调用 hugetlb_no_page 函数对 页中间目录项 进行映射操作。

我们再来看看 hugetlb_no_page 函数怎么对 页中间目录项 进行填充:

1static int
 2hugetlb_no_page(struct mm_struct *mm, struct vm_area_struct *vma,
 3                unsigned long address, pte_t *ptep, int write_access)
 4{
 5    ...
 6    page = find_lock_page(mapping, idx);
 7    if (!page) {
 8        ...
 9        // 1. 从空闲大内存页链表 hugepage_freelists 中申请一个大内存页
10        page = alloc_huge_page(vma, address);
11        ...
12    }
13    ...
14    // 2. 通过大内存页的物理地址生成页中间目录项的值
15    new_pte = make_huge_pte(vma, page, ((vma->vm_flags & VM_WRITE)
16                                            && (vma->vm_flags & VM_SHARED)));
17
18    // 3. 设置页中间目录项的值为上面生成的值
19    set_huge_pte_at(mm, address, ptep, new_pte);
20    ...
21    return ret;
22}

通过对 hugetlb_no_page 函数进行精简后,主要完成3个工作:

调用 alloc_huge_page 函数从空闲大内存页链表 hugepage_freelists 中申请一个大内存页。

通过大内存页的物理地址生成页中间目录项的值。

设置页中间目录项的值为上面生成的值。

至此,HugePages 的映射过程已经完成。

还有个问题,就是 CPU 怎么知道 页中间表项 指向的是 页表 还是 大内存页 呢?

这是因为 页中间表项 有个 PSE 的标志位,如果将其设置为1,那么就表明其指向 大内存页 ,否则就指向 页表