vmemmap에 대하여

커널은 물리 페이지마다 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 배치비용
FLATMEMmem_map + (pfn - ARCH_PFN_OFFSET)연속 배열 1개구멍만큼 memmap을 버린다
SPARSEMEM__section_mem_map_addr(__pfn_to_section(pfn)) + pfn섹션(section)별 배열변환마다 섹션 표를 읽는다
SPARSEMEM_VMEMMAPvmemmap + 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_VMEMMAPSPARSEMEM
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 no5lvl4단계ffffea0000000000
nokaslr5단계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, 0x200000valid=0, read=-14주소는 계산되지만 매핑이 없어 읽기가 실패한다
0xa0, 0x7ffdfvalid=1, reserved=1RAM이 아니어도 섹션 안이면 struct page가 있다
0x100000phys=0x1f9a00000가상 주소는 64MiB 떨어졌지만 물리 주소는 32MiB만 떨어졌다

마지막 행이 핵심이다. 가상 주소에는 구멍이 그대로 남고, 물리 메모리에서는 구멍이 사라진다.

vmemmap 가상 주소 물리 메모리 (memblock 버퍼 96MiB) pfn 0 ~ 0x7ffff +0x0000000, 32MiB, PMD 매핑 pfn 0x80000 ~ 0xfffff +0x2000000, 32MiB, 매핑 없음 pfn 0x100000 ~ 0x1fffff +0x4000000, 64MiB, PMD 매핑 0x1f7a00000 32MiB 0x1f9a00000 64MiB, 앞 구간에 바로 이어진다 6GiB 게스트 실측값, 오프셋은 0xffffea0000000000 기준

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=0MEMBLOCK_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에 연결한다. 전체 흐름은 아래 순서다.

  1. sparse_init_nid()가 노드의 섹션 수를 세고 sparse_buffer_init()을 호출한다.
  2. memblock_alloc_exact_nid_raw()가 2MiB로 정렬한 연속 버퍼를 예약한다.
  3. __populate_section_memmap()이 섹션의 vmemmap 주소 범위를 계산한다.
  4. vmemmap_populate_hugepages()가 빠진 중간 페이지 테이블을 채운다.
  5. vmemmap_alloc_block_buf()가 sparse_buffer_alloc()으로 버퍼에서 2MiB를 꺼낸다.
  6. 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_pagesRAM 대비
1GiB16MiB4,0961.56%
2GiB32MiB8,1921.56%
4GiB64MiB16,3841.56%
6GiB96MiB24,5761.56%
8GiB128MiB32,7681.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=0memmap_on_memory=1
추가 직후 nr_memmap_pages4,0964,096
추가 직후 MemFree 변화-16,888 kB-576 kB
온라인 후 MemTotal 증가1,048,576 kB1,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_hugepages

4GiB 게스트에서 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_pages16,384 → 16,38416,384 → 12,288
nr_memmap_pages0 → 00 → 512
ptdump 줄 수7 → 77 → 1,032
MemFree 감소1,049,020 kB1,048,576 kB
MemFree + pcp 감소1,048,652 kB1,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를 먼저 확인하면 된다.

참고

답글 남기기