메모리가 부족해지면 커널은 페이지 캐시나 익명 페이지 중 “안 쓸 것 같은” 것을 골라 내보낸다. 이 판단이 틀리면 방금 내보낸 페이지를 곧바로 다시 읽어들이는 스래싱(thrashing)이 생기고, 저사양 기기나 메모리 상한이 걸린 컨테이너에서 특히 체감된다. 기존 LRU는 active/inactive 두 개의 리스트로 이 판단을 하는데, 커널 6.1에 병합된 Multi-Gen LRU(MGLRU)는 페이지를 여러 “세대(generation)”로 나눠 접근 시점을 더 촘촘히 추적한다. 이 글에서는 MGLRU가 켜져 있는지 확인하는 법, 세대 히스토그램으로 워킹셋을 들여다보는 법, 그리고 메모리 압박이 없는 상태에서 원하는 만큼만 회수하는 선제적 회수(proactive reclaim)를 Ubuntu 24.04에서 실제로 돌려가며 정리한다.
세대라는 개념
MGLRU는 페이지를 생성 시점 기준으로 세대에 넣고, 접근된 페이지를 더 높은 세대로 승격시킨다. 세대 번호가 클수록 최근에 접근된 페이지이고, 가장 낮은 세대(min_gen_nr)가 가장 오래된 페이지다. 회수는 낮은 세대부터 이뤄진다.
| 기존 LRU | MGLRU | |
|---|---|---|
| 분류 단위 | active / inactive 두 리스트 | 여러 세대(기본 최대 4개) |
| 접근 추적 | 리스트 간 이동 | 세대 승격 + 페이지 테이블 스캔 |
| 워킹셋 관찰 | 직접 볼 수단 없음 | /sys/kernel/debug/lru_gen 히스토그램 |
| 선제적 회수 | 불가 | 세대 지정으로 가능 |
켜져 있는지 확인하기
Ubuntu 24.04의 배포 커널은 MGLRU가 이미 빌드·활성화되어 있어 따로 커널을 빌드할 필요가 없다.
$ uname -r
6.8.0-137-generic
$ grep '^CONFIG_LRU_GEN' /boot/config-$(uname -r)
CONFIG_LRU_GEN=y
CONFIG_LRU_GEN_ENABLED=y
CONFIG_LRU_GEN_WALKS_MMU=y
$ ls /sys/kernel/mm/lru_gen/
enabled min_ttl_ms
$ cat /sys/kernel/mm/lru_gen/enabled
0x0007
enabled는 on/off가 아니라 구성 요소별 비트마스크다. 0x0007은 셋 다 켜진 상태이고, echo y/echo n으로 전체를 한 번에 켜고 끌 수 있다.
| 비트 | 의미 |
|---|---|
0x0001 | MGLRU 메인 스위치 |
0x0002 | 리프 PTE의 accessed 비트를 큰 배치로 정리 (x86처럼 MMU가 설정하는 경우) |
0x0004 | 비리프 PTE의 accessed 비트까지 정리 |
min_ttl_ms는 스래싱 방지용이다. N을 쓰면 최근 N밀리초의 워킹셋을 회수 대상에서 빼고, 그래도 메모리가 부족하면 OOM 킬러를 부른다. 기본값 0은 비활성이다.
워킹셋 들여다보기
/sys/kernel/debug/lru_gen을 읽으면 memcg·노드별로 세대 히스토그램이 나온다. 확인을 위해 cgroup을 하나 만들고 그 안에서 400MiB 파일을 읽어 페이지 캐시를 그 cgroup에 청구시켰다.
CG=/sys/fs/cgroup/mglru-pr
mkdir -p $CG
echo max > $CG/memory.max
echo 0 > $CG/memory.swap.max # 스왑 없이 파일 페이지만 본다
dd if=/dev/urandom of=/var/tmp/mglru/pr bs=1M count=400 status=none
sync; echo 3 > /proc/sys/vm/drop_caches
# cgroup 안에서 읽어야 페이지 캐시가 이 cgroup에 청구된다
( echo $BASHPID > $CG/cgroup.procs; cat /var/tmp/mglru/pr > /dev/null )memcg_id = 63
회수 전 memory.current = 401 MiB
[세대 히스토그램] gen age_in_ms nr_anon nr_file
memcg 63 /mglru-pr
node 0
0 11588 0 0
1 11588 0 102550
2 11588 0 0
3 11588 0 52
세대 1에 파일 페이지 102,550개가 몰려 있다. 4KiB를 곱하면 약 401MiB로 memory.current와 정확히 맞는다 — 방금 읽은 그 파일이다.
세대를 직접 만들기
같은 파일에 + memcg_id node_id max_gen_nr 형식을 쓰면 새 세대가 생기면서 기존 페이지들이 상대적으로 “늙는다”. 작업 스케줄러가 일정 주기로 이 명령을 돌려 서버별 콜드 페이지 양을 재는 것이 문서가 소개하는 용법이다.
[새 세대 생성] echo "+ 63 0 3" > /sys/kernel/debug/lru_gen
memcg 63 /mglru-pr
node 0
1 11733 0 102550
2 11733 0 0
3 11733 0 52
4 2 0 0
세대 4가 age_in_ms=2로 새로 생겼고, 세대 0은 밀려나 사라졌다. 파일 페이지는 여전히 세대 1에 그대로 있으므로 이제 이 세대가 가장 오래된 회수 후보다.
압박 없이 회수하기
- memcg_id node_id min_gen_nr [swappiness [nr_to_reclaim]]를 쓰면 지정한 세대 이하를 즉시 회수한다. 메모리가 부족하지 않아도 동작한다는 점이 핵심이다.
[cold 세대 회수] echo "- 63 0 1 0" > /sys/kernel/debug/lru_gen
회수 후 memory.current = 1 MiB
memcg 63 /mglru-pr
node 0
1 12844 0 0
2 12844 0 18
3 12844 0 52
4 1113 0 0
memory.current가 401MiB에서 1MiB로 떨어졌고 세대 1의 파일 페이지도 0이 됐다. 마지막 인자 0은 swappiness로, 익명 페이지는 건드리지 말라는 뜻이다. 회수량을 제한하려면 뒤에 nr_to_reclaim을 더 붙인다.
주의할 제약이 하나 있다. min_gen_nr은 max_gen_nr-1보다 작아야 한다. 최상위 두 세대는 아직 충분히 늙지 않은 것으로 취급되어(기존 LRU의 active 리스트에 해당) 회수 대상이 될 수 없다.
켜고 끄고 재봤을 때
MGLRU를 껐다 켜면서 같은 워크로드를 돌려봤다. 512MiB 상한을 건 cgroup에서 192MiB짜리 hot 파일을 읽고 1.5GiB짜리 cold 파일을 흘려보낸 뒤, hot 파일을 다시 읽는 시간을 쟀다. 워킹셋이 캐시에 살아남았다면 이 재접근이 빨라야 한다.
== 6.8.0-137-generic / limit=512M / hot=192M cold=1536M / 반복=3
MGLRU 켬 enabled=0x0007 hot재접근평균= 0.32s refault_file=1138667 restore_file=65543 pgsteal=1639948
MGLRU 끔 enabled=0x0000 hot재접근평균= 0.22s refault_file=1115752 restore_file=0 pgsteal=1576080
MGLRU 켬(재확인) enabled=0x0007 hot재접근평균= 0.37s refault_file=1155136 restore_file=65536 pgsteal=1640020
MGLRU 끔(재확인) enabled=0x0000 hot재접근평균= 0.26s refault_file=1115819 restore_file=0 pgsteal=1576111
이 워크로드에서는 MGLRU가 오히려 근소하게 느렸다. 눈에 띄는 차이는 workingset_restore_file이 MGLRU를 켰을 때만 잡힌다는 점 정도인데, 값이 hot 파일 크기와 무관하게 65,536 근처로 고정돼 나오므로 “워킹셋을 그만큼 지켜냈다”고 읽을 근거는 되지 못한다. cold 파일을 순차로 흘려보내는 패턴은 어느 알고리즘이든 결국 전부 회수하게 되는 경우라, 세대 구분의 이점이 드러나기 어려운 조건이다.
주의사항
- 위 성능 비교를 일반적인 결론으로 받아들이지 말 것. 측정 환경이 중첩 가상화된 VM이라 I/O와 타이머가 모두 왜곡되고, 워크로드도 순차 스트리밍 한 종류뿐이다. MGLRU의 이점이 보고되는 영역은 주로 익명 페이지와 스왑이 개입하는 상황인데, 그 조합은 이 VM에서 측정을 끝낼 수 없을 만큼 느려 이번에는 재지 못했다.
/sys/kernel/debug/lru_gen은 문서가 명시적으로 “experimental features”로 분류한 인터페이스다. 반면/sys/kernel/mm/lru_gen/의enabled와min_ttl_ms는 안정 ABI다. 운영 자동화에 debugfs 명령을 쓴다면 커널 버전 간 변경 가능성을 감안해야 한다.- 페이지 캐시는 처음 그 페이지를 채운 cgroup에 청구된다. 위 실험에서
cat을 굳이 cgroup 안에서 실행한 이유가 이것이다. 바깥에서 미리 읽어두면 캐시가 다른 cgroup에 달려memory.current가 올라가지 않아 실험이 성립하지 않는다. enabled를 끄면 시스템 전역 회수 정책이 바뀐다. 테스트가 끝나면echo y > /sys/kernel/mm/lru_gen/enabled로 되돌려 놓을 것. 중간에 스크립트가 죽으면 꺼진 채로 남는다.- 선제적 회수는
min_gen_nr < max_gen_nr-1조건을 만족해야 한다. 세대가 충분히 쌓이지 않은 상태에서 호출하면 아무것도 회수되지 않으므로, 필요하면+명령으로 세대를 먼저 만든 뒤 회수한다.
마무리
MGLRU에서 가장 실용적인 부분은 성능 그 자체보다 회수 정책을 들여다보고 조작할 수 있게 됐다는 점이다. 기존 LRU에서는 “지금 이 컨테이너가 들고 있는 페이지 중 얼마나가 콜드인가”를 물어볼 방법이 없었는데, 세대 히스토그램은 그 질문에 숫자로 답하고 선제적 회수는 그중 원하는 만큼만 즉시 비워준다. 컨테이너 밀도를 높이거나 메모리 상한을 조정할 근거를 만들 때 먼저 꺼내볼 도구다.