커널은 물리 페이지마다 struct page를 하나씩 둔다. 버디 할당기(buddy allocator)와 페이지 폴트 경로는 pfn과 struct page를 계속 맞바꾼다. 그런데 물리 주소 공간에는 구멍이 있다. QEMU q35 머신에 6GiB를 주면 RAM이 0~2GiB와 4~8GiB로 갈라진다.
구멍까지 배열을 만들면 메모리를 버린다. 구간별로 배열을 나누면 변환할 때마다 표를 조회해야 한다. vmemmap은 가상 주소만 연속으로 두고, RAM이 있는 구간에만 실제 메모리를 매핑해 이 문제를 푼다.
이 글에서는 vmemmap의 주소 계산과 구멍 처리를 확인한다. 실제 메모리를 잡는 memblock_alloc_exact_nid_raw()도 따라간다. 이어서 NUMA 폴백(fallback), 메모리 핫플러그(memory hotplug), HugeTLB 최적화에서 이 메모리가 어떻게 바뀌는지 측정한다. 이 글은 struct page 배열을 memmap, 그 배열을 매핑한 가상 주소 구간을 vmemmap이라고 부른다.
메모리 모델 세 가지
세 모델의 차이는 pfn_to_page() 구현에서 바로 드러난다(include/asm-generic/memory_model.h).
| 모델 | pfn_to_page(pfn) | memmap 배치 | 비용 |
|---|---|---|---|
| FLATMEM | mem_map + (pfn - ARCH_PFN_OFFSET) | 연속 배열 1개 | 구멍만큼 memmap을 버린다 |
| SPARSEMEM | __section_mem_map_addr(__pfn_to_section(pfn)) + pfn | 섹션(section)별 배열 | 변환마다 섹션 표를 읽는다 |
| SPARSEMEM_VMEMMAP | vmemmap + pfn | 섹션별 메모리, 연속 가상 주소 | 페이지 테이블과 TLB 엔트리 |
/* include/asm-generic/memory_model.h (6.12) */
/* memmap is virtually contiguous. */
#define __pfn_to_page(pfn) (vmemmap + (pfn))
#define __page_to_pfn(page) (unsigned long)((page) - vmemmap)실습 환경
커널은 6.12 x86_64이고, 게스트는 QEMU TCG로 부팅했다. 비교용으로 CONFIG_SPARSEMEM_VMEMMAP만 끈 커널도 함께 빌드했다.
CONFIG_SPARSEMEM=y
CONFIG_SPARSEMEM_EXTREME=y
CONFIG_SPARSEMEM_VMEMMAP=y
CONFIG_DEFERRED_STRUCT_PAGE_INIT=y
CONFIG_HUGETLB_PAGE_OPTIMIZE_VMEMMAP=y
CONFIG_MEMORY_HOTPLUG=y
CONFIG_MHP_MEMMAP_ON_MEMORY=y
CONFIG_PTDUMP_DEBUGFS=y
시나리오마다 아래 스크립트로 부팅한다. memblock=debug를 주면 memblock 할당과 호출자가 로그에 찍힌다. 이후 출력에서 dmesg 타임스탬프는 뺐다.
#!/bin/bash
# run.sh <name> <qemu-args> <append>
n=$1; q=$2; a=$3
cd ~/vmemmap; mkdir -p log
eval timeout 300 qemu-system-x86_64 -machine q35 -cpu max -smp 2 -nographic -no-reboot \
-kernel ${KERN:-b-vmemmap}/arch/x86/boot/bzImage -initrd ir.cpio.gz \
$q -append "'console=ttyS0 no_timer_check loglevel=7 log_buf_len=8M tsc=reliable $a'" \< /dev/null \> log/$n.log 2\>\&1
echo "$n rc=$? end=$(grep -c '===MARK end' log/$n.log)"아래 모듈은 pfn을 struct page로 바꾸고, 그 주소가 어느 물리 메모리에 매핑됐는지 출력한다.
// SPDX-License-Identifier: GPL-2.0
/* pfn을 struct page로 바꾸고, 그 struct page가 어디에 매핑됐는지 확인한다. */
#include <linux/module.h>
#include <linux/mm.h>
#include <linux/mmzone.h>
#include <linux/uaccess.h>
#include <asm/pgtable.h>
static unsigned long pfns[16];
static int npfns;
module_param_array(pfns, ulong, &npfns, 0);
static const char *lvl_name(unsigned int level)
{
return level == PG_LEVEL_2M ? "2M" : level == PG_LEVEL_4K ? "4K" : "?";
}
static void show(unsigned long pfn)
{
struct page *page = pfn_to_page(pfn); /* 메모리를 읽지 않는 산술 연산 */
unsigned long va = (unsigned long)page;
unsigned int level;
pte_t *pte = lookup_address(va, &level);
struct page tmp;
long ret = copy_from_kernel_nofault(&tmp, page, sizeof(tmp));
if (!pte || !pte_present(*pte)) {
pr_info("pfn %#8lx page %px valid=%d mapped=no read=%ld\n",
pfn, page, pfn_valid(pfn), ret);
return;
}
{
phys_addr_t phys = ((phys_addr_t)pte_pfn(*pte) << PAGE_SHIFT) |
(va & ~page_level_mask(level));
unsigned long bpfn = PHYS_PFN(phys);
pr_info("pfn %#8lx page %px valid=%d mapped=%s phys=%pa nid(page)=%d nid(memmap)=%d reserved=%d\n",
pfn, page, pfn_valid(pfn), lvl_name(level), &phys,
pfn_valid(pfn) ? pfn_to_nid(pfn) : -1,
pfn_valid(bpfn) ? pfn_to_nid(bpfn) : -1,
pfn_valid(pfn) ? PageReserved(page) : -1);
if (pfn_valid(pfn)) /* vmemmap 주소와 직접 매핑 주소로 같은 메모리를 읽는다 */
pr_info("alias flags: vmemmap=%#lx linear(%px)=%#lx\n",
page->flags, __va(phys),
((struct page *)__va(phys))->flags);
}
}
static int __init vmm_init(void)
{
int i;
pr_info("vmemmap=%px sizeof(struct page)=%zu PAGES_PER_SECTION=%lu memmap_per_section=%lu KiB\n",
vmemmap, sizeof(struct page), PAGES_PER_SECTION,
PAGES_PER_SECTION * sizeof(struct page) / 1024);
pr_info("page_to_pfn(pfn_to_page(0x12345)) = %#lx\n",
page_to_pfn(pfn_to_page(0x12345)));
for (i = 0; i < npfns; i++)
show(pfns[i]);
return 0;
}
static void __exit vmm_exit(void) { }
module_init(vmm_init);
module_exit(vmm_exit);
MODULE_LICENSE("GPL");pfn_to_page()는 곱셈과 덧셈이다
두 변환을 함수로 감싸 컴파일하고, 두 커널에서 나온 기계어를 비교한다.
noinline struct page *my_pfn_to_page(unsigned long pfn)
{
return pfn_to_page(pfn);
}
EXPORT_SYMBOL_GPL(my_pfn_to_page);
noinline unsigned long my_page_to_pfn(struct page *page)
{
return page_to_pfn(page);
}
EXPORT_SYMBOL_GPL(my_page_to_pfn);CONFIG_SPARSEMEM_VMEMMAP=y인 커널의 결과다. shl $0x6이 sizeof(struct page)인 64를 곱한다.
0000000000000010 <my_pfn_to_page>:
10: endbr64
14: mov %rdi,%rax
17: shl $0x6,%rax
1b: add 0x0(%rip),%rax # 22 <my_pfn_to_page+0x12>
1e: R_X86_64_PC32 vmemmap_base-0x4
22: jmp 27 <my_pfn_to_page+0x17>
23: R_X86_64_PLT32 __x86_return_thunk-0x4
0000000000000040 <my_page_to_pfn>:
40: endbr64
44: mov %rdi,%rax
47: sub 0x0(%rip),%rax # 4e <my_page_to_pfn+0xe>
4a: R_X86_64_PC32 vmemmap_base-0x4
4e: sar $0x6,%rax
52: jmp 57 <my_page_to_pfn+0x17>
53: R_X86_64_PLT32 __x86_return_thunk-0x4
같은 함수를 vmemmap 없는 SPARSEMEM으로 컴파일하면 mem_section 표를 단계별로 읽는다.
0000000000000070 <my_pfn_to_page>:
70: endbr64
74: mov %rdi,%rdx
77: mov %rdi,%rcx
7a: shr $0xf,%rdx
7e: shr $0x17,%rcx
82: cmp $0x7ffff,%rdx
89: ja bd <my_pfn_to_page+0x4d>
8b: mov 0x0(%rip),%rax # 92 <my_pfn_to_page+0x22>
8e: R_X86_64_PC32 mem_section-0x4
92: test %rax,%rax
95: je aa <my_pfn_to_page+0x3a>
97: mov (%rax,%rcx,8),%rax
9b: test %rax,%rax
9e: je aa <my_pfn_to_page+0x3a>
a0: movzbl %dl,%edx
a3: shl $0x4,%rdx
a7: add %rdx,%rax
aa: mov (%rax),%rax
ad: shl $0x6,%rdi
b1: and $0xfffffffffffffff0,%rax
b5: add %rdi,%rax
b8: jmp bd <my_pfn_to_page+0x4d>
두 결과에서 메모리를 읽는 명령 수를 세면 아래와 같다. page_to_pfn()은 섹션 번호를 얻으려고 page->flags까지 읽는다.
| 변환 | SPARSEMEM_VMEMMAP | SPARSEMEM |
|---|---|---|
pfn_to_page() | 1회 (vmemmap_base) | 3회 (mem_section, 루트 엔트리, section_mem_map) |
page_to_pfn() | 1회 (vmemmap_base) | 4회 (page->flags와 위 3회) |
vmemmap은 어느 주소에 있는가
기준 주소는 페이지 테이블 단계와 KASLR에 따라 달라진다. x86_64에서 vmemmap은 런타임 변수 vmemmap_base를 가리킨다.
| 부팅 옵션 | 페이지 테이블 | 측정한 vmemmap |
|---|---|---|
nokaslr no5lvl | 4단계 | ffffea0000000000 |
nokaslr | 5단계 | ffd4000000000000 |
| KASLR 기본, 1회차 | 5단계 | ff9630f780000000 |
| KASLR 기본, 2회차 | 5단계 | fff885e7c0000000 |
이후 측정은 값을 읽기 쉽도록 전부 nokaslr no5lvl로 부팅한다.
구멍은 매핑하지 않는다
6GiB 게스트의 RAM 배치와 vmemmap 페이지 테이블을 함께 본다. ptdump 출력은 /sys/kernel/debug/page_tables/kernel에서 Vmemmap 구간만 잘랐다.
===MARK iomem
00001000-0009fbff : System RAM
00100000-7ffdefff : System RAM
100000000-1ffffffff : System RAM
===MARK vmap
---[ Vmemmap ]---
0xffffea0000000000-0xffffea0002000000 32M RW PSE GLB NX pmd
0xffffea0002000000-0xffffea0004000000 32M pmd
0xffffea0004000000-0xffffea0008000000 64M RW PSE GLB NX pmd
0xffffea0008000000-0xffffea0040000000 896M pmd
0xffffea0040000000-0xffffea8000000000 511G pud
0xffffea8000000000-0xfffffe0000000000 19968G pgd
2~4GiB 구멍에 해당하는 32MiB는 PMD 엔트리가 비어 있다. 이제 모듈로 경계의 pfn을 읽어 본다.
vmemmap=ffffea0000000000 sizeof(struct page)=64 PAGES_PER_SECTION=32768 memmap_per_section=2048 KiB
page_to_pfn(pfn_to_page(0x12345)) = 0x12345
pfn 0x0 page ffffea0000000000 valid=1 mapped=2M phys=0x00000001f7a00000 nid(page)=0 nid(memmap)=0 reserved=1
alias flags: vmemmap=0xfffc0000002000 linear(ffff8881f7a00000)=0xfffc0000002000
pfn 0xa0 page ffffea0000002800 valid=1 mapped=2M phys=0x00000001f7a02800 nid(page)=0 nid(memmap)=0 reserved=1
alias flags: vmemmap=0xfffc0000002000 linear(ffff8881f7a02800)=0xfffc0000002000
pfn 0x7ffdf page ffffea0001fff7c0 valid=1 mapped=2M phys=0x00000001f99ff7c0 nid(page)=0 nid(memmap)=0 reserved=1
alias flags: vmemmap=0x2fffc0000002000 linear(ffff8881f99ff7c0)=0x2fffc0000002000
pfn 0x80000 page ffffea0002000000 valid=0 mapped=no read=-14
pfn 0x100000 page ffffea0004000000 valid=1 mapped=2M phys=0x00000001f9a00000 nid(page)=0 nid(memmap)=0 reserved=0
alias flags: vmemmap=0x2fffc0000000000 linear(ffff8881f9a00000)=0x2fffc0000000000
pfn 0x200000 page ffffea0008000000 valid=0 mapped=no read=-14
| pfn | 결과 | 의미 |
|---|---|---|
0x80000, 0x200000 | valid=0, read=-14 | 주소는 계산되지만 매핑이 없어 읽기가 실패한다 |
0xa0, 0x7ffdf | valid=1, reserved=1 | RAM이 아니어도 섹션 안이면 struct page가 있다 |
0x100000 | phys=0x1f9a00000 | 가상 주소는 64MiB 떨어졌지만 물리 주소는 32MiB만 떨어졌다 |
마지막 행이 핵심이다. 가상 주소에는 구멍이 그대로 남고, 물리 메모리에서는 구멍이 사라진다.
alias flags 줄은 이 대응을 확인한다. 측정한 모든 pfn에서 vmemmap 주소와 직접 매핑 주소로 읽은 page->flags가 같다.
memblock_alloc_exact_nid_raw(): memmap 메모리의 출처
부팅 초기에는 버디 할당기가 없어서 memblock이 memmap 메모리를 잡는다. gdb 스텁에 하드웨어 브레이크포인트를 걸어 실제 호출 경로를 잡는다.
qemu-system-x86_64 -machine q35 -cpu max -smp 2 -nographic -no-reboot -S -s \
-kernel b-vmemmap/arch/x86/boot/bzImage -initrd ir.cpio.gz -m 6G \
-append "console=ttyS0 nokaslr no5lvl tsc=reliable scn=size" &
gdb -q -batch \
-ex "file b-vmemmap/vmlinux" -ex "target remote :1234" \
-ex "hbreak memblock_alloc_exact_nid_raw" -ex continue \
-ex bt -ex finish#0 memblock_alloc_exact_nid_raw (size=size@entry=100663296, align=align@entry=2097152, min_addr=16777216, max_addr=max_addr@entry=0, nid=nid@entry=0)
#1 memmap_alloc (size=size@entry=100663296, align=align@entry=2097152, min_addr=<optimized out>, nid=nid@entry=0, exact_nid=exact_nid@entry=true)
#2 sparse_buffer_init (nid=0, size=100663296) at mm/sparse.c:463
#3 sparse_init_nid (nid=nid@entry=0, pnum_begin=pnum_begin@entry=0, pnum_end=pnum_end@entry=18446744073709551615, map_count=48)
#4 sparse_init () at mm/sparse.c:587
#5 paging_init () at arch/x86/mm/init_64.c:824
#6 setup_arch (cmdline_p=cmdline_p@entry=0xffffffff82a03f00) at arch/x86/kernel/setup.c:1120
#7 start_kernel () at init/main.c:925
Value returned is $1 = (void *) 0xffff8881f9e00000
map_count=48은 RAM이 있는 섹션 수다. 섹션 하나(128MiB)의 memmap은 2MiB이므로 요청 크기는 48 × 2MiB = 96MiB가 된다. 위 출력에서 프레임 주소와 긴 소스 경로는 뺐다.
/* mm/sparse.c:454 (6.12) */
static void __init sparse_buffer_init(unsigned long size, int nid)
{
phys_addr_t addr = __pa(MAX_DMA_ADDRESS);
WARN_ON(sparsemap_buf); /* forgot to call sparse_buffer_fini()? */
/*
* Pre-allocated buffer is mainly used by __populate_section_memmap
* and we want it to be properly aligned to the section size - this is
* especially the case for VMEMMAP which maps memmap to PMDs
*/
sparsemap_buf = memmap_alloc(size, section_map_size(), addr, nid, true);
sparsemap_buf_end = sparsemap_buf + size;
#ifndef CONFIG_SPARSEMEM_VMEMMAP
memmap_boot_pages_add(DIV_ROUND_UP(size, PAGE_SIZE));
#endif
}마지막 인자 true가 exact_nid다. memmap_alloc()은 이 값으로 memblock 함수를 고른다.
/* mm/mm_init.c:1581 (6.12) */
void __init *memmap_alloc(phys_addr_t size, phys_addr_t align,
phys_addr_t min_addr, int nid, bool exact_nid)
{
void *ptr;
if (exact_nid)
ptr = memblock_alloc_exact_nid_raw(size, align, min_addr,
MEMBLOCK_ALLOC_ACCESSIBLE,
nid);
else
ptr = memblock_alloc_try_nid_raw(size, align, min_addr,
MEMBLOCK_ALLOC_ACCESSIBLE,
nid);
if (ptr && size > 0)
page_init_poison(ptr, size);
return ptr;
}함수 이름의 두 부분이 동작을 그대로 설명한다.
| 이름 | 동작 | 근거 |
|---|---|---|
exact_nid | 지정한 노드에서만 찾고, 없으면 NULL을 돌려준다 | memblock_alloc_range_nid()의 !exact_nid 분기 |
raw | 메모리를 0으로 채우지 않는다 | __init_single_page()가 나중에 mm_zero_struct_page()로 지운다 |
max_addr=0 | MEMBLOCK_ALLOC_ACCESSIBLE로, 상한을 memblock.current_limit로 둔다 | gdb 인자와 memblock=debug 로그의 값 |
/* mm/memblock.c, memblock_alloc_range_nid() (6.12) */
again:
found = memblock_find_in_range_node(size, align, start, end, nid,
flags);
if (found && !memblock_reserve(found, size))
goto done;
if (numa_valid_node(nid) && !exact_nid) {
found = memblock_find_in_range_node(size, align, start,
end, NUMA_NO_NODE,
flags);
if (found && !memblock_reserve(found, size))
goto done;
}버퍼에서 PMD 매핑까지
버퍼를 잡은 뒤 sparse_init_nid()가 섹션마다 2MiB씩 잘라 PMD에 연결한다. 전체 흐름은 아래 순서다.
sparse_init_nid()가 노드의 섹션 수를 세고sparse_buffer_init()을 호출한다.memblock_alloc_exact_nid_raw()가 2MiB로 정렬한 연속 버퍼를 예약한다.__populate_section_memmap()이 섹션의 vmemmap 주소 범위를 계산한다.vmemmap_populate_hugepages()가 빠진 중간 페이지 테이블을 채운다.vmemmap_alloc_block_buf()가sparse_buffer_alloc()으로 버퍼에서 2MiB를 꺼낸다.vmemmap_set_pmd()가 그 2MiB를 PSE 엔트리로 연결한다.
/* mm/sparse-vmemmap.c:77 (6.12) */
void * __meminit vmemmap_alloc_block_buf(unsigned long size, int node,
struct vmem_altmap *altmap)
{
void *ptr;
if (altmap)
return altmap_alloc_block_buf(size, altmap);
ptr = sparse_buffer_alloc(size);
if (!ptr)
ptr = vmemmap_alloc_block(size, node);
return ptr;
}memblock=debug 로그에서 할당을 모으면 비용이 보인다. 96MiB를 매핑하는 데 페이지 테이블은 PUD 테이블과 PMD 테이블 한 장씩, 합쳐 8KiB만 쓴다.
memblock_alloc_exact_nid_raw: 100663296 bytes align=0x200000 nid=0 from=0x0000000001000000 max_addr=0x0000000000000000 sparse_init_nid+0x107/0x5b0
memblock_alloc_try_nid_raw: 4096 bytes align=0x1000 nid=0 from=0x0000000001000000 max_addr=0x0000000000000000 vmemmap_p4d_populate+0x5d/0xf0
memblock_alloc_try_nid_raw: 4096 bytes align=0x1000 nid=0 from=0x0000000001000000 max_addr=0x0000000000000000 vmemmap_pud_populate+0x4a/0xd0
memmap 크기
메모리 크기만 바꿔 부팅하면 요청 크기가 RAM에 비례한다. nr_memmap_boot_pages는 /proc/vmstat에서 읽었다.
| 게스트 RAM | 버퍼 요청 | nr_memmap_boot_pages | RAM 대비 |
|---|---|---|---|
| 1GiB | 16MiB | 4,096 | 1.56% |
| 2GiB | 32MiB | 8,192 | 1.56% |
| 4GiB | 64MiB | 16,384 | 1.56% |
| 6GiB | 96MiB | 24,576 | 1.56% |
| 8GiB | 128MiB | 32,768 | 1.56% |
1.56%는 sizeof(struct page) 64바이트를 4KiB 페이지로 나눈 값이다. vmemmap을 끈 커널도 6GiB에서 같은 96MiB를 요청한다. vmemmap의 이득은 메모리 절약이 아니라 변환 비용 감소다.
# CONFIG_SPARSEMEM_VMEMMAP is not set, -m 6G
memblock_alloc_exact_nid_raw: 100663296 bytes align=0x200000 nid=0 from=0x0000000001000000 max_addr=0x0000000000000000 sparse_init_nid+0xf8/0x570
nr_memmap_boot_pages 24576
exact_nid가 실패하면
버퍼를 노드 안에서 못 잡아도 부팅은 계속된다. 노드 1의 물리 메모리를 memmap= 예약으로 잘게 나눠 이 경로를 일부러 탄다.
# 노드 1(128MiB)의 2MiB 블록마다 4KiB를 예약해 정렬된 2MiB 블록을 없앤다
m=""; for k in $(seq 0 63); do a=$(printf "0x%x" $((0x80000000 + 0x100000 + k*0x200000))); m="$m memmap=4K\$$a"; done
OF="-m 2176M -object memory-backend-ram,id=m0,size=2G -object memory-backend-ram,id=m1,size=128M -numa node,memdev=m0,cpus=0 -numa node,memdev=m1,cpus=1"
./run.sh off "$OF" "scn=numa nokaslr no5lvl memblock=debug vp=0x1000,0x80400 $m"gdb로 노드 1의 호출만 잡으면 memblock_alloc_exact_nid_raw()가 NULL을 돌려준다. 이어지는 memblock_alloc_try_nid_raw()는 노드 0 주소를 돌려준다.
Thread 1 hit Breakpoint 1, memblock_alloc_exact_nid_raw (size=size@entry=2097152, align=align@entry=2097152, min_addr=16777216, max_addr=max_addr@entry=0, nid=nid@entry=1)
Value returned is $1 = (void *) 0x0 <fixed_percpu_data>
Thread 1 hit Breakpoint 2, memblock_alloc_try_nid_raw (size=2097152, align=2097152, min_addr=16777216, max_addr=0, nid=1)
#1 vmemmap_populate_hugepages (start=<optimized out>, end=18446719884489392128, node=1, altmap=0x0 <fixed_percpu_data>)
Value returned is $2 = (void *) 0xffff88807dc00000
모듈로 확인하면 노드 1 페이지의 struct page가 노드 0에 있다.
pfn 0x1000 page ffffea0000040000 valid=1 mapped=2M phys=0x000000007bc40000 nid(page)=0 nid(memmap)=0 reserved=1
pfn 0x80400 page ffffea0002010000 valid=1 mapped=2M phys=0x000000007ba10000 nid(page)=1 nid(memmap)=0 reserved=0
예약 간격을 바꿔 세 경우를 비교한 결과다.
| 노드 1 메모리 | 버퍼 요청 | exact_nid 결과 | 대체 할당 | 노드 1 페이지의 memmap |
|---|---|---|---|---|
| 2GiB, 예약 없음 | 32MiB | 성공 | 없음 | 노드 1 |
| 512MiB, 7MiB마다 4KiB 예약 | 8MiB | 실패 | 2MiB 4회, 노드 1에서 성공 | 노드 1 |
| 128MiB, 2MiB마다 4KiB 예약 | 2MiB | 실패 | 2MiB 1회, 노드 0으로 폴백 | 노드 0 |
두 번째 경우는 큰 연속 버퍼만 실패하고, 2MiB 블록은 노드 1에 남아 있어 memmap이 노드를 벗어나지 않는다. 세 번째 경우는 부팅 로그에 offnode 경고가 한 줄도 없었다.
메모리 핫플러그: 부팅 후의 memmap
부팅이 끝나면 memblock을 쓸 수 없다. vmemmap_alloc_block()은 slab_is_available()이 참이면 alloc_pages_node()를 호출한다. QEMU 모니터로 1GiB DIMM을 넣어 memory_hotplug.memmap_on_memory 값별로 비교한다.
# QEMU 모니터 명령
object_add memory-backend-ram,id=m1,size=1G
device_add pc-dimm,id=d1,memdev=m1# memory_hotplug.memmap_on_memory=0
pfn 0x100000 page ffffea0004000000 valid=1 mapped=2M phys=0x0000000005000000 nid(page)=0 nid(memmap)=0 reserved=0
# memory_hotplug.memmap_on_memory=1
pfn 0x100000 page ffffea0004000000 valid=1 mapped=2M phys=0x0000000100000000 nid(page)=0 nid(memmap)=0 reserved=0
| 항목 | memmap_on_memory=0 | memmap_on_memory=1 |
|---|---|---|
추가 직후 nr_memmap_pages | 4,096 | 4,096 |
추가 직후 MemFree 변화 | -16,888 kB | -576 kB |
온라인 후 MemTotal 증가 | 1,048,576 kB | 1,032,192 kB |
| memmap 물리 위치 | 기존 RAM (0x5000000) | 추가한 DIMM의 맨 앞 (0x100000000) |
옵션을 켜면 DIMM이 자기 memmap 16MiB를 직접 담는다. 그래서 메모리가 부족해도 추가에 실패할 가능성이 낮고, 사용 가능한 용량은 그만큼 줄어든다.
HugeTLB Vmemmap Optimization: memmap 줄이기
2MiB HugeTLB 페이지 하나의 memmap은 512 × 64바이트 = 32KiB, 즉 8페이지다. tail struct page는 내용이 거의 같다. 그래서 커널은 7페이지를 줄이고, 그 가상 주소를 남은 1페이지에 읽기 전용으로 다시 매핑한다. 이 기능이 HVO(HugeTLB Vmemmap Optimization)다.
# 부팅 옵션: hugetlb_free_vmemmap=on (이 커널의 기본값은 off)
cat /proc/sys/vm/hugetlb_optimize_vmemmap
echo 512 > /proc/sys/vm/nr_hugepages4GiB 게스트에서 2MiB 페이지 512개(1GiB)를 잡고 켠 경우와 끈 경우를 비교한다. 켠 경우 ptdump는 PMD 매핑이 PTE 단위로 쪼개진 모습을 보여준다.
===MARK after512
nr_memmap_pages 512
nr_memmap_boot_pages 12288
MemTotal: 3998764 kB
MemFree: 2899804 kB
HugePages_Total: 512
Hugetlb: 1048576 kB
pcp_kB 31388
1032
---[ Vmemmap ]---
0xffffea0000000000-0xffffea0002000000 32M RW PSE GLB NX pmd
0xffffea0002000000-0xffffea0004000000 32M pmd
0xffffea0004000000-0xffffea0004059000 356K RW GLB NX pte
0xffffea0004059000-0xffffea0004060000 28K ro GLB NX pte
0xffffea0004060000-0xffffea0004081000 132K RW GLB NX pte
| 512개 할당 전후 | HVO 끔 | HVO 켬 |
|---|---|---|
nr_memmap_boot_pages | 16,384 → 16,384 | 16,384 → 12,288 |
nr_memmap_pages | 0 → 0 | 0 → 512 |
| ptdump 줄 수 | 7 → 7 | 7 → 1,032 |
MemFree 감소 | 1,049,020 kB | 1,048,576 kB |
MemFree + pcp 감소 | 1,048,652 kB | 1,034,320 kB |
카운터가 절감량을 정확히 설명한다. 커널은 부팅 때 잡은 8페이지를 모두 해제하고(4,096페이지), head용 새 페이지를 하나 할당한다(512페이지). 순감소는 3,584페이지, 즉 14,336KiB로 512 × 28KiB와 같다.
/* mm/hugetlb_vmemmap.c, vmemmap_remap_free() (6.12) */
walk.reuse_page = alloc_pages_node(nid, gfp_mask, 0);
if (walk.reuse_page) {
copy_page(page_to_virt(walk.reuse_page),
(void *)walk.reuse_addr);
list_add(&walk.reuse_page->lru, vmemmap_pages);
memmap_pages_add(1);
}두 부팅의 MemFree + pcp 감소 차이는 14,332kB로 카운터 값과 거의 같다. MemFree만 비교하면 차이가 444kB로 보이는데, 해제한 페이지가 per-CPU 목록(pcp)에 머물기 때문이다. pcp 값은 /proc/zoneinfo의 count를 더해 구했다.
1GiB 페이지는 절감 폭이 더 크다. 페이지 1개로 부팅하면 부팅 memmap 4,096페이지가 해제되고 새 페이지 1개만 할당된다. 순감소 4,095페이지(16,380KiB)는 커널이 출력한 값과 일치한다.
HugeTLB: registered 1.00 GiB page size, pre-allocated 1 pages
HugeTLB: 16380 KiB vmemmap can be freed for a 1.00 GiB page
HugeTLB: registered 2.00 MiB page size, pre-allocated 0 pages
HugeTLB: 28 KiB vmemmap can be freed for a 2.00 MiB page
nr_memmap_pages 1
nr_memmap_boot_pages 12288
해제해도 원래 상태로 돌아가지 않는다
nr_hugepages를 다시 0으로 쓰면 커널은 memmap을 복원한다. 이때 버디 할당기에서 4,096페이지를 새로 받고, 쪼갠 PTE 매핑은 PMD로 합치지 않는다.
===MARK release
nr_memmap_pages 4096
nr_memmap_boot_pages 12288
HugePages_Total: 0
8
===MARK vmapafter
---[ Vmemmap ]---
0xffffea0000000000-0xffffea0002000000 32M RW PSE GLB NX pmd
0xffffea0002000000-0xffffea0004000000 32M pmd
0xffffea0004000000-0xffffea0005200000 18M RW GLB NX pte
0xffffea0005200000-0xffffea0006000000 14M RW PSE GLB NX pmd
주의사항
pfn_to_page()결과를 읽기 전에pfn_valid()를 확인할 것. 구멍의 주소도 계산되며, 읽으면 fault가 난다(측정값read=-14).pfn_valid()가 참이라고 RAM으로 단정하지 말 것. 섹션 안의 비RAM pfn도 참이다(0xa0은reserved=1).- vmemmap 주소를 코드에 고정하지 말 것. 페이지 테이블 단계와 KASLR에 따라 값이 바뀐다.
- NUMA 머신에서
offnode경고만 믿지 말 것. 새 PMD를 연결하는 경로는vmemmap_verify()를 거치지 않아, 측정에서 경고 없이 노드 0으로 폴백했다. - HVO 효과를
MemFree만으로 판단하지 말 것.nr_memmap_boot_pages와nr_memmap_pages를 함께 본다. - HVO를 켠 시스템은 HugeTLB 페이지를 해제할 때 여유 메모리를 남길 것. memmap 복원에 실패하면
__update_and_free_hugetlb_folio()가 해제를 거부한다. 그 페이지는 surplus로 돌아간다. memblock_alloc_exact_nid_raw()를 직접 쓸 때는 NULL을 확인하고 메모리를 초기화할 것. 이 함수는 폴백도 0 채우기도 하지 않는다.- 커널 버전을 확인할 것. 6.18.38에서는
mm/hugetlb.c의alloc_bootmem()도 이 함수를 호출한다.memmap_alloc()은MEMBLOCK_ALLOC_NOLEAKTRACE를 넘긴다. - x86_64의 주소식을 다른 아키텍처에 그대로 쓰지 말 것. arm64의
vmemmap은VMEMMAP_START - (memstart_addr >> PAGE_SHIFT)이다. - 이 글의 숫자를 속도 비교에 쓰지 말 것. QEMU TCG에서 측정했으므로 페이지 수와 기계어만 비교했다.
마무리
vmemmap은 가상 주소를 연속으로 두어 pfn_to_page()를 곱셈과 덧셈으로 줄인다. 실제 메모리는 부팅 때 memblock_alloc_exact_nid_raw()가 노드별 버퍼로 잡는다. 이 할당이 실패하면 커널은 섹션별로 다시 할당하고, 노드 밖으로 폴백할 수도 있다.
부팅 후에는 이 메모리의 출처와 매핑이 바뀐다. 핫플러그는 버디 할당기나 DIMM 자신에서 memmap을 얻고, HVO는 부팅 memmap을 해제해 PTE 매핑으로 바꾼다. 메모리 사용량을 볼 때는 nr_memmap_boot_pages와 nr_memmap_pages를 먼저 확인하면 된다.