飛哥帶你揭秘:為什么HugePage能讓Oracle數(shù)據(jù)庫如虎添翼?
大家如果有人部署過 Oracle 數(shù)據(jù)庫的話,一定也看到過 Oracle 為了性能考慮,是推薦開啟大頁(HugePage)的。
那么為什么開啟大頁 能有性能提升,它的優(yōu)化原理是啥,又是如何實現(xiàn)的呢?今天飛哥就來和你一起深入地聊聊這個 Topic。
一、 內(nèi)核四級頁表之殤
為了更好了解 HugePage,我們需要溫習(xí)一下內(nèi)核的頁表機制。
在這個機制中有兩個前提知識點,那就是
- 第一、應(yīng)用程序申請內(nèi)存時不會分配物理內(nèi)存,訪問觸發(fā)缺頁中斷時才分配!
- 第二、頁是內(nèi)核分配物理內(nèi)存的最小單位!
我們應(yīng)用程序使用的都是虛擬內(nèi)存地址。在程序?qū)嶋H運行的時候,需要轉(zhuǎn)換成實際的物理地址。如果轉(zhuǎn)換后的物理地址所在的頁面正好存在,那么直接訪問就可以了。如果頁面不存在,那么需要觸發(fā)缺頁中斷并申請一個完整的頁面后再供應(yīng)用程序繼續(xù)訪問。頁的最小單位是 4 KB。
在《深入理解Linux進程與內(nèi)存》里的第六章「進程如何使用內(nèi)存」中,我們提到過 Linux 將虛擬地址到物理地址中用到的四級頁表機制。
圖片
內(nèi)核四級頁表機制把 64 位的內(nèi)存地址范圍分成了幾段。
- 第 63-48 位,額。。64位內(nèi)存地址太大了,這段屬于廢棄不用的。
- 第 39-47(9)位指定在一級頁表 PGD 中索引位置
- 第 30-38(9)位指定在二級頁表 PUD 中索引位置
- 第 21-29(9)位指定在對應(yīng)三級頁表 PMD 中索引位置
- 第 12-20(9)位指定在四級頁表 PTE 中索引位置
大家注意下,每一級頁表管理的地址范圍都是 9 個位。為啥是 9 ,不是 8 ,也不是 10。原因是為了將數(shù)據(jù)結(jié)構(gòu)對齊到 4 KB。這樣具體的一個 PGD/PUD/PMD/PTE,保存著 2 的 9 次方, 512 個 64 位物理地址(8個字節(jié))。512 * 8 = 正好是 4 KB。
在將某進程的一個具體的 64 位的虛擬內(nèi)存地址轉(zhuǎn)換為物理地址時,首先按照上述地址范圍把虛擬地址切分成幾段。然后經(jīng)過下面幾步轉(zhuǎn)換成物理地址。
- 第一步:從 CPU 中名為 CR3 的寄存器中找到當(dāng)前進程的一級頁表 PGD 的地址
- 第二步:以虛擬地址中的 39 ~ 47 位作為索引,找到 PUD 所在的內(nèi)存地址
- 第三步:再以虛擬地址中的 30 ~ 38 位作為索引,找到 PMD 所在的內(nèi)存地址
- 第四步:再以虛擬地址中的 21 ~ 29 位作為索引,找到 PTE 所在的內(nèi)存地址
- 第五步:再以虛擬內(nèi)存地址的 0 ~ 11 位作為物理內(nèi)存頁的偏移量,得到最終的物理地址
Linux分頁機制就帶領(lǐng)大家簡單回憶這么一下。今天我們的重點是想說頁表機制帶來的額外的問題。
頁表是存在內(nèi)存里的。完成一個虛擬地址轉(zhuǎn)換的過程中需要把當(dāng)前虛擬地址對應(yīng)的四個頁表全部找出來才能完成虛擬地址到物理地址的轉(zhuǎn)換。那就是一次內(nèi)存 IO 光是虛擬地址到物理地址的轉(zhuǎn)換就要去內(nèi)存查 4 次頁表。再算上真正的內(nèi)存訪問,最壞情況下需要 5 次內(nèi)存 IO 才能獲取一個內(nèi)存數(shù)據(jù)!
為了提升地址轉(zhuǎn)換效率。既然進行地址轉(zhuǎn)換需要的內(nèi)存 IO 次數(shù)多,且耗時。那么干脆就和 CPU 的 L1、L2、L3 的緩存思想一樣,在 CPU 里把頁表中的數(shù)據(jù)盡可能地緩存起來不就行了么,
所以 CPU 硬件中有個 TLB(Translation Lookaside Buffer) 模塊,專門用于加速虛擬地址到物理地址轉(zhuǎn)換速度的緩存。其訪問速度非??欤图拇嫫飨喈?dāng),比 L1 訪問還快。
雖然有了 TLB 加速的方案,但這個方案并不是萬能的。最大的缺點是 TLB 太小了。一般的 CPU 中 L1 TLB 一般也就幾十個條目容量,L2 TLB 一般也就小幾千。
再看需求端,我們假設(shè)每個進程需要 40 GB 物理內(nèi)存,那換算成 4 KB 頁面的話就是大約 1000 萬個頁面,也就對應(yīng) 1000 萬個頁表條目。TLB 里這點點容量還是捉襟見肘。
正因為在四級頁表下有這樣潛在的性能隱患。所以 Oracle 這種內(nèi)存密集型的應(yīng)用就推薦配置 HugePage 來提高它的運行性能了。
二、HugePage 如何使用
可見,四級頁表最大的問題是在于頁面太多時性能較差。頁面一多,管理這些頁面的頁表項就多,TLB緩存命中率就會很差。那如果能把頁面數(shù)量給降下來,TLB 緩存命中率一定會有大幅度的提升。
假如說我們把 4 KB 的頁面換成 2 MB 的頁面,那么同樣對于 40 GB 物理內(nèi)存消耗,那僅僅只需要 2 萬個頁面就夠了。相比于原來的 1000 萬 降低到了 500 之一。
另外這樣不光是 TLB 緩存命中率會有大幅度的提升。內(nèi)核的虛擬地址轉(zhuǎn)換時的頁表機制也可以簡化成下面這樣的三級頁表,少了一次轉(zhuǎn)換開銷。
所以,一個結(jié)論是把 4 KB 的頁面換成 2 MB 的頁面,可以大幅度提升虛擬地址轉(zhuǎn)換物理地址時的性能!!
那么,如果你想獲取這個性能提升的話,該如何操作呢?
第一步首先是大頁的預(yù)留。
預(yù)留的方式分為啟動階段預(yù)留和運行時預(yù)留。
對于啟動階段預(yù)留,需要修改 Linux 內(nèi)核的啟動參數(shù)。編輯/boot/grub/grub.cfg 文件找到啟動參數(shù)行(不同的發(fā)行版可能修改方式會有一些出入)。添加以下內(nèi)容,指定 HugePage 的頁面大小,指定預(yù)留的大頁數(shù)量。:
hugepagesz=2M hugepages=512
對于運行時預(yù)留,直接修改內(nèi)核 hugetlbfs 暴露出來的偽文件即可。
// 預(yù)留特定size的大頁
echo 5 > /sys/kernel/mm/hugepages/hugepages-2048kB/nr_hugepages
第二步是大頁的申請
申請的時候,先打開通過 open 打開 hugepage 偽文件句柄,再通過 mmap 來申請即可。
int main(){
// 打開 hugepage 句柄
fd = open("/mnt/huge/hugepage...", O_CREAT|O_RDWR);
// 申請大頁
addr = mmap(0, MAP_LENGTH, PROT_READ|PROT_WRITE, MAP_SHARED, fd, 0);
}
這樣,你的應(yīng)用程序就能享受 TLB 緩存命中率提升帶來的飛翔感覺了。
三、內(nèi)核啟動時 HugePage 處理
咱們「開發(fā)內(nèi)功修煉」公眾號的風(fēng)格是不僅要會用,還要懂內(nèi)部原理。接下來飛哥再來帶你看下內(nèi)核是如何管理 HugePage 的!
3.1 回顧普通頁的伙伴系統(tǒng)
在《深入理解Linux進程與內(nèi)存》里的第五章「系統(tǒng)物理內(nèi)存初始化」中介紹過,
- 內(nèi)核先是通過固件 ACPI E820 規(guī)范探測安裝的內(nèi)存的物理地址范圍
- 將探測到的內(nèi)存交給 memblock 初期內(nèi)存分配器來管理,同時會再讀取 ACPI 中的 SRAT 表獲取 NUMA 信息
- 接著在初期內(nèi)存分配器中申請管理所有頁面的 struct page 對象(一個 struct page 一般是 64 字節(jié))
- 最后釋放其余的可用內(nèi)存交給伙伴系統(tǒng)來管理
start_kernel
-> setup_arch
---> e820__memory_setup // 內(nèi)核把物理內(nèi)存檢測保存從boot_params.e820_table保存到e820_table中,并打印出來
---> e820__memblock_setup // 根據(jù)e820信息構(gòu)建memblock內(nèi)存分配器,開啟調(diào)試能打印
---> initmem_init // 內(nèi)存中 NUMA 機制初始化)
---> x86_init.paging.pagetable_init(native_pagetable_init)
-----> paging_init // 頁管理機制的初始化
-> mm_init
---> mem_init
-----> memblock_free_all // 向伙伴系統(tǒng)移交控制權(quán)
// file:include/linux/mmzone.h
struct zone {
......
// zone的名稱
const char *name;
// 管理zone下面所有頁面的伙伴系統(tǒng)
struct free_area free_area[MAX_ORDER];
......
}
圖片
3.2 空閑 HugePage 的管理
相比伙伴系統(tǒng)中 4KB 頁面的管理,內(nèi)核對 HugePage 頁面的管理要簡單許多。內(nèi)核中維持一個各種 HugePage 頁面(內(nèi)核支持多種大小的 HugePage,不僅僅只有 2 MB)的 struct hstate 數(shù)組。
// file:mm/hugetlb.c
struct hstate hstates[HUGE_MAX_HSTATE];
在每一個 hstate 成員內(nèi),有一個空閑鏈表 hugepage_freelists,會把所有的空閑頁面給串起來。
我們來看大致看下空閑頁面的初始化過程。內(nèi)核啟動過程中,還會按照一定的順序執(zhí)行初始化函數(shù)。HugePage 的初始化函數(shù) hugetlb_init 通過 subsys_initcall 注冊。
// file:mm/hugetlb.c
subsys_initcall(hugetlb_init);
這樣內(nèi)核啟動的時候,就會執(zhí)行到 hugetlb_init 進行 HugePage 的初始化。
// file:mm/hugetlb.c
static int __init hugetlb_init(void)
{
...
// 初始化默認大頁 state,空閑大內(nèi)存頁鏈表 hugepage_freelists
hugetlb_add_hstate(HUGETLB_PAGE_ORDER);
// 申請大內(nèi)存頁, 并且保存到 hugepage_freelists 鏈表中
hugetlb_init_hstates();
...
// 創(chuàng)建/sys/kernel/mm/hugepages相關(guān)目錄文件
hugetlb_sysfs_init();
// 創(chuàng)建/sys/device/system/node/node*/hugepages相關(guān)目錄文件
hugetlb_register_all_nodes();
...
}
hugetlb_init 函數(shù)主要完成兩個工作:
第一:初始化默認大頁 state。在 Linux 中是支持多種規(guī)格的大頁的,存在一個全局變量 states 數(shù)組,其中每一個元素都對應(yīng)一個規(guī)格的大頁的管理數(shù)據(jù)結(jié)構(gòu),包括所有空閑頁面管理用的鏈表 hugepage_freelists。
第二:為系統(tǒng)申請空閑的大內(nèi)存頁,并且保存到空閑鏈表 hugepage_freelists 中。
第三:創(chuàng)建 hugetlbfs 相關(guān)偽文件,如 /sys/kernel/mm/hugepages、/sys/device/system/node/node*/hugepages。用戶后續(xù)可以通過這些偽文件來和內(nèi)核交互。
我們來重點看下申請空閑大內(nèi)存頁的邏輯,這是依次調(diào)用 hugetlb_init_hstates -> hugetlb_hstate_alloc_pages,在執(zhí)行到 hugetlb_hstate_alloc_pages_onenode 中完成的。
// file:mm/hugetlb.c
static void __init hugetlb_hstate_alloc_pages_onenode(struct hstate *h, int nid)
{
...
for (i = 0; i < h->max_huge_pages_node[nid]; ++i) {
page = alloc_fresh_huge_page(h, gfp_mask, nid,
&node_states[N_MEMORY], NULL);
if (page)
break;
}
free_huge_page(page);
return 1;
}
其中 alloc_fresh_huge_page 是在申請頁面,free_huge_page 會將其放到空閑鏈表 hugepage_freelists 中。
四、mmap 申請內(nèi)存
大頁的內(nèi)存申請內(nèi)核工作原理大概分三步:
- 第一先是要打開 HugePage 偽文件句柄,
- 第二是通過 mmap 申請大頁
- 第三是在訪問缺頁中斷時實際申請真正的物理大頁
int main(){
// 打開 hugepage 句柄
fd = open("/mnt/huge/hugepage...", O_CREAT|O_RDWR);
// 申請大頁
addr = mmap(0, MAP_LENGTH, PROT_READ|PROT_WRITE, MAP_SHARED, fd, 0);
}
4.1 打開 HugePage 偽文件句柄
調(diào)用 open 打開 hugetlbfs 下的文件時,會執(zhí)行到 hugetlb_file_setup 函數(shù),在這里會給申請文件內(nèi)核對象,為它指定它所綁定的各種 operations 方法。
// file:fs/hugetlbfs/inode.c
struct file *hugetlb_file_setup(const char *name, ...)
{
...
file = alloc_file_pseudo(inode, mnt, name, O_RDWR,
&hugetlbfs_file_operations);
...
}
其中 hugetlbfs_file_operations 指定了這類文件的各種具體的方法。
const struct file_operations hugetlbfs_file_operations = {
.read_iter = hugetlbfs_read_iter,
.mmap = hugetlbfs_file_mmap,
.fsync = noop_fsync,
.get_unmapped_area = hugetlb_get_unmapped_area,
......
};
這樣當(dāng)對該文件執(zhí)行 mmap 操作時,就會調(diào)用到內(nèi)核中的 hugetlbfs_file_mmap 函數(shù)。
4.2 mmap 分配虛擬內(nèi)存
mmap 系統(tǒng)調(diào)用執(zhí)行經(jīng)過如下的復(fù)雜調(diào)用鏈后,最終會調(diào)用到 file 內(nèi)核對象的 map 方法。
mmap // offset轉(zhuǎn)成頁為單位
+-- sys_mmap_pgoff // 通過fd獲取file
+-- vm_mmap_pgoff // 信號量保護,映射完成后populate
+-- do_mmap_pgoff // 簡單封裝
+-- do_mmap // 映射長度頁對齊,prot和flags檢查,設(shè)置vm_flags,獲取映射虛擬地址
+-- mmap_region // 地址空間檢查,vma_merge,vma分配及初始化
|-- call_mmap // 文件映射,簡單封裝
| +-- file->f_op->mmap // 調(diào)用實際文件的mmap方法
....
執(zhí)行到的 file->f_op->mmap 是一個函數(shù)指針。在上一小節(jié)我們看到對于 hugetlbfs 下的文件,其 mmap 函數(shù)指針對應(yīng)的是 hugetlbfs_file_mmap 函數(shù)。
// file:fs/hugetlbfs/inode.c
static int hugetlbfs_file_mmap(struct file *file, struct vm_area_struct *vma)
{
...
// 為映射分配所需的大頁框
hugetlb_reserve_pages(inode,
vma->vm_pgoff >> huge_page_order(h),
len >> huge_page_shift(h), vma,
vma->vm_flags)
...
}
在該函數(shù)中主要做的就是調(diào)用 hugetlb_reserve_pages 預(yù)留大頁。
4.3 缺頁中斷處理
當(dāng)缺頁中斷發(fā)生時,內(nèi)核會調(diào)用到 handle_mm_fault 函數(shù)。在這里對于 HugePage、普通缺頁、透明大頁的處理都是不一樣的。
// file:mm/memory.c
vm_fault_t handle_mm_fault(struct vm_area_struct *vma, ...)
{
...
// 是否是大頁缺頁
if (is_vm_hugetlb_page(vma))
ret = hugetlb_fault(vma->vm_mm, vma, address, flags);
else
// 普通的缺頁中斷,包括透明大頁也都在這里
ret = __handle_mm_fault(vma, address, flags);
...
}
HugePage 缺頁會執(zhí)行到 hugetlb_fault 函數(shù),然后再調(diào)用 hugetlb_no_page。
static vm_fault_t hugetlb_no_page(struct mm_struct *mm, ...)
{
page = find_lock_page(mapping, idx);
if (!page) {
...
// 1. 從空閑大內(nèi)存頁鏈表 hugepage_freelists 中申請一個大內(nèi)存頁
page = alloc_huge_page(vma, haddr, 0);
}
// 2. 通過大內(nèi)存頁的物理地址生成頁表表項
new_pte = make_huge_pte(vma, page, ((vma->vm_flags & VM_WRITE)
&& (vma->vm_flags & VM_SHARED)));
// 3. 將頁表表項掛到頁表中
set_huge_pte_at(mm, haddr, ptep, new_pte);
...
return ret;
}
在 hugetlb_no_page 中主要做了兩件事:
- 第一件:調(diào)用 alloc_huge_page 從空閑鏈表中 hugepage_freelists 摘一個頁面下來
- 第二件:設(shè)置頁表。先是通過大內(nèi)存頁的物理地址生成頁表表項,再將頁表表項掛到頁表中
這樣,應(yīng)用程序就申請到了大頁物理內(nèi)存了。
五、總結(jié)
我們應(yīng)用程序使用的都是虛擬內(nèi)存地址。在程序?qū)嶋H運行的時候,需要轉(zhuǎn)換成實際的物理地址。
為了提升地址轉(zhuǎn)換效率。CPU 硬件中設(shè)計有 TLB 模塊,用于緩存內(nèi)存中的頁表項,加速訪問。這樣 CPU 在執(zhí)行虛擬地址轉(zhuǎn)換時,就可以避免很多的內(nèi)存訪問,極大地提升效率。
但可惜的是 TLB 緩存容量都不大,一般 CPU 中 L1 TLB 一般也就幾十個條目容量,L2 TLB 一般也就小幾千,我手頭的一臺服務(wù)器 L2 TLB 才是 1500 個條目。
如果使用 4 KB 的小頁面。假設(shè)每個進程需要 40 GB 物理內(nèi)存,每個頁面 4 KB,那就是大約 1000 萬個頁面,也就要管理 1000 萬個頁表條目。區(qū)區(qū) 1500 個 TLB 緩存條目空間,顯然是捉襟見肘。
如果使用 2 MB 的 HugePage, 40 GB / 2 MB,只需要 2 萬個頁面。管理的頁表條目一下子從 1000 萬下降到了 2萬,這樣 1500 個條目就挺充裕的了。
使用 HugePage 能幫助 TLB 緩存命中率得到了大大的提升。應(yīng)用程序在執(zhí)行虛擬地址到物理地址的轉(zhuǎn)換過程中就會節(jié)約許多開銷。
Oracle 數(shù)據(jù)庫是一個存儲密集型的應(yīng)用,會申請大量的內(nèi)存,也會涉及到大量的內(nèi)存訪問。那么用 HugePage 優(yōu)化一下性能的話,對于它來講再合適不過了。
要補充提的一點是,如果你的應(yīng)用程序使用的內(nèi)存很小,例如只有幾百 M,那建議你還是不要費這個勁兒了,提升不了多少。