Ryotta's Linux 7.0 MM

메모리 관리 서브시스템 완전 분석

# Workingset (작업 집합 감지)

관련 소스: mm/workingset.c, mm/filemap.c, mm/vmscan.c, mm/swap.c, mm/memcontrol.c, include/linux/swap.h, include/linux/mmzone.h, include/linux/mm_inline.h

개요 (Overview)

Workingset은 페이지가 메모리에서 제거된 후 짧은 시간 내에 재사용되는지(refault)를 추적하여, 메모리 회수 결정에 활용하는 메커니즘이다. 핵심 아이디어는 "shadow entry"로, folio가 제거될 때 xarray의 해당 슬롯에 제거 시점의 타임스탬프(비거주 연령)를 저장하고, 나중에 같은 folio가 다시 fault되면 그 시간 간격(refault distance)을 계산하여 working set 경쟁 상황을 판단한다. refault distance가 현재 활성+비활성 캐시 크기 이내이면 해당 folio는 활성 목록으로 활성화되어 보호된다.

일상 비유: 도서관 비유로 설명하면, workingset은 "최근에 빌려간 책"을 추적하는 시스템이다. 비활성 목록은 "임시 진열대", 활성 목록은 "자주 빌리는 책 진열대"이다. 새로운 책이 들어오면 임시 진열대 끝에 놓이고, 오래된 책은 밀려나 퇴출된다. 어떤 책이 퇴출된 후 다시 빌려가면(refault), 이전 대여 기록(shadow entry)을 확인하여 "이 책이 얼마나 자주 빌려졌는지" 판단한다. 충분히 자주 빌려진 책은 자주 빌리는 책 진열대로 승격된다.

Linux 7.0에서는 두 가지 구현 경로가 공존한다: (1) 기존 Double CLOCK 알고리즘(MGLRU 미사용 시)과 (2) MGLRU(CONFIG_LRU_GEN) 기반 세대별 추적. Double CLOCK은 비활성/활성 목록에서 각각의 eviction/activation 카운터를 lruvec->nonresident_age로 관리하고, MGLRU는 lrugen->evicted[hist][type][tier]lrugen->refaulted[hist][type][tier] 배열로 세대별로 추적한다. 또한 shadow entry가 과도하게 누적되지 않도록 shadow_nodes list_lru와 shrinker로 shadow 노드를 관리한다.

핵심 소스 파일:

  • mm/workingset.c — workingset 감지 전체 구현 (826줄)
  • mm/filemap.cworkingset_refault() 재진입점 (filemap_add_folio())
  • mm/vmscan.cworkingset_eviction() 호출 지점 (shrink_folio_list())
  • mm/swap.cworkingset_activation() 호출 지점 (folio_mark_accessed())
  • mm/memcontrol.cWORKINGSET_* vmstat/cgroup 통계 이름
  • include/linux/swap.hworkingset_eviction(), workingset_refault() 등 선언 (line 314-320)
  • include/linux/mmzone.hlruvec.nonresident_age, WORKINGSET_* 통계 enum (line 192-202, 681)
  • include/linux/mm_inline.hlru_gen_enabled(), lru_gen_in_fault(), folio_lru_refs() 등 MGLRU 헬퍼

  • 빠른 점검 명령

    # workingset 관련 커널 심볼 확인
    cat /proc/kallsyms | grep -E 'workingset_eviction|workingset_refault|workingset_test_recent|workingset_age_nonresident|workingset_activation'
    
    # workingset 통계 확인 (노드별)
    cat /proc/vmstat | grep -E 'workingset_|nr_workingset'
    
    # shadow entry 통계
    cat /proc/vmstat | grep -E 'workingset_nodes|workingset_refault|workingset_activate|workingset_restore|workingset_nodereclaim'
    
    # MGLRU 활성화 여부 확인
    cat /sys/kernel/mm/lru_gen/enabled
    
    # memcg별 workingset 통계 확인
    cat /sys/fs/cgroup/<cgroup>/memory.stat | grep -E 'workingset_refault|workingset_activate|workingset_restore|workingset_nodereclaim'
    
    # memcg 이벤트 확인
    cat /sys/fs/cgroup/<cgroup>/memory.events
    
    # 페이지 폴트/메모리 압박 지표 확인
    cat /proc/vmstat | grep -E 'pgfault|pgmajfault|allocstall|workingset_'
    
    # PSI 메모리 압력 확인
    cat /proc/pressure/memory
    
    # LRU_GEN 관련 통계 확인
    cat /proc/vmstat | grep -E 'nr_lru_gen|nr_inactive|nr_active'
    
    # workingset refault 빈도 모니터링 (실시간)
    watch -n 1 'cat /proc/vmstat | grep workingset'
    
    # 특정 프로세스의 workingset 크기 확인
    cat /proc/<pid>/smaps | grep -E 'Referenced|Anonymous|File'
    
    # shadow entry 메모리 사용량 확인
    cat /proc/meminfo | grep -E 'Slab|SReclaimable|SUnreclaim'
    
    # MGLRU 세대별 통계 확인
    cat /sys/kernel/mm/lru_gen/stats
    
    # memcg별 nonresident_age 확인
    cat /sys/fs/cgroup/<cgroup>/memory.stat | grep -E 'workingset_refault|workingset_activate|workingset_restore|workingset_nodereclaim'

    핵심 자료구조

    shadow entry — 제거 시점 정보 저장

    shadow entry는 xarray의 value 엔트리로, folio 제거 시 메모리 캐시 슬롯에 저장된다. pack_shadow()로 인코딩하고 unpack_shadow()로 디코딩한다.

    // mm/workingset.c:199-228
    static void *pack_shadow(int memcgid, pg_data_t *pgdat, unsigned long eviction,
    				 bool workingset)
    {
    	eviction &= EVICTION_MASK;
    	eviction = (eviction << MEM_CGROUP_ID_SHIFT) | memcgid;
    	eviction = (eviction << NODES_SHIFT) | pgdat->node_id;
    	eviction = (eviction << WORKINGSET_SHIFT) | workingset;
    
    	return xa_mk_value(eviction);
    }
    
    static void unpack_shadow(void *shadow, int *memcgidp, pg_data_t **pgdat,
    			  unsigned long *evictionp, bool *workingsetp)
    {
    	int memcgid, nid;
    	bool workingset;
    	unsigned long entry = xa_to_value(shadow);
    
    	workingset = entry & ((1UL << WORKINGSET_SHIFT) - 1);
    	entry >>= WORKINGSET_SHIFT;
    	nid = entry & ((1UL << NODES_SHIFT) - 1);
    	entry >>= NODES_SHIFT;
    	memcgid = entry & ((1UL << MEM_CGROUP_ID_SHIFT) - 1);
    	entry >>= MEM_CGROUP_ID_SHIFT;
    
    	*memcgidp = memcgid;
    	*pgdat = NODE_DATA(nid);
    	*evictionp = entry;
    	*workingsetp = workingset;
    }
  • EVICTION_SHIFT: BITS_PER_LONG - BITS_PER_XA_VALUE + WORKINGSET_SHIFT + NODES_SHIFT + MEM_CGROUP_ID_SHIFT — 타임스탬프에 사용 가능한 비트 수 결정
  • bucket_order: 메모리 크기가 타임스탬프 비트 범위를 초과하면 하위 비트를 잘라 coarser granularity로 처리
  • lruvec — LRU 벡터와 비거주 연령

    // include/linux/mmzone.h:670-698
    struct lruvec {
    	struct list_head		lists[NR_LRU_LISTS]; // 비활성/활성 LRU 목록 (anon/file/unevictable)
    	spinlock_t			lru_lock;           // memcg별 LRU 잠금
    	unsigned long			anon_cost;          // anon LRU 회수 비용 추적
    	unsigned long			file_cost;          // file LRU 회수 비용 추적
    	atomic_long_t			nonresident_age;    // 비거주 엔트리 연령 (핵심!)
    	unsigned long			refaults[ANON_AND_FILE]; // 이전 회수 사이클 시 refault 수
    	unsigned long			flags;
    #ifdef CONFIG_LRU_GEN
    	struct lru_gen_folio		lrugen;             // MGLRU 세대별 folio 관리
    #endif
    #ifdef CONFIG_MEMCG
    	struct pglist_data *pgdat;  // 이 lruvec이 속한 NUMA 노드
    #endif
    };
  • nonresident_age: workingset_age_nonresident()에서 atomic_long_add(nr_pages)로 증가. folio 제거/활성화 시점에 이 값을 타임스탬프로 저장
  • WORKINGSET_* 통계 enum

    // include/linux/mmzone.h:192-202
    WORKINGSET_NODES,              // shadow entry를 포함하는 xarray 노드 수
    WORKINGSET_REFAULT_BASE,
    WORKINGSET_REFAULT_ANON = WORKINGSET_REFAULT_BASE,  // 익명 refault 수
    WORKINGSET_REFAULT_FILE,       // 파일 refault 수
    WORKINGSET_ACTIVATE_BASE,
    WORKINGSET_ACTIVATE_ANON = WORKINGSET_ACTIVATE_BASE, // refault로 활성화된 익명 페이지 수
    WORKINGSET_ACTIVATE_FILE,      // refault로 활성화된 파일 페이지 수
    WORKINGSET_RESTORE_BASE,
    WORKINGSET_RESTORE_ANON = WORKINGSET_RESTORE_BASE,  // workingset으로 복원된 익명 페이지 수
    WORKINGSET_RESTORE_FILE,       // workingset으로 복원된 파일 페이지 수
    WORKINGSET_NODERECLAIM,        // shrinker에 의해 회수된 shadow 노드 수

    MGLRU 세대별 추적 구조 (CONFIG_LRU_GEN)

    // mm/workingset.c:232-258 (lru_gen_eviction 내부)
    // MGLRU에서 folio 제거 시 shadow entry 생성
    static void *lru_gen_eviction(struct folio *folio)
    {
    	int hist;
    	unsigned long token;
    	unsigned long min_seq;
    	struct lruvec *lruvec;
    	struct lru_gen_folio *lrugen;
    	int type = folio_is_file_lru(folio);     // 익명(0) 또는 파일(1)
    	int delta = folio_nr_pages(folio);
    	int refs = folio_lru_refs(folio);        // 접근 횟수 참조
    	bool workingset = folio_test_workingset(folio);
    	int tier = lru_tier_from_refs(refs, workingset);  // 참조 횟수에서 tier로 변환
    	struct mem_cgroup *memcg = folio_memcg(folio);
    	struct pglist_data *pgdat = folio_pgdat(folio);
    
    	BUILD_BUG_ON(LRU_GEN_WIDTH + LRU_REFS_WIDTH > BITS_PER_LONG - EVICTION_SHIFT);
    
    	lruvec = mem_cgroup_lruvec(memcg, pgdat);
    	lrugen = &lruvec->lrugen;
    	min_seq = READ_ONCE(lrugen->min_seq[type]);
    	token = (min_seq << LRU_REFS_WIDTH) | max(refs - 1, 0);  // 시퀀스 + 참조 횟수 결합
    
    	hist = lru_hist_from_seq(min_seq);
    	atomic_long_add(delta, &lrugen->evicted[hist][type][tier]);  // 세대별 축출 카운트 증가
    
    	return pack_shadow(mem_cgroup_private_id(memcg), pgdat, token, workingset);
    }

    핵심 함수

    workingset_eviction() — folio 제거 시 shadow entry 생성

    // mm/workingset.c:381-404
    void *workingset_eviction(struct folio *folio, struct mem_cgroup *target_memcg)

    역할: folio가 메모리에서 제거될 때 shadow entry를 생성하여 xarray에 저장한다.

    분기 로직:

    1. lru_gen_enabled()lru_gen_eviction() 호출 (MGLRU 경로)

    2. MGLRU 미사용 시: lruvec->nonresident_age를 읽어 bucket_order로 정렬 후 pack_shadow()로 인코딩

    3. folio_test_workingset(folio) 플래그를 shadow entry에 포함 — 이전에 workingset으로 활성화되었는지 추적

    workingset_refault() — refault 시 workingset 판단 및 활성화

    // mm/workingset.c:534-583
    void workingset_refault(struct folio *folio, void *shadow)

    역할: 이전에 제거된 folio가 다시 fault되면 refault distance를 계산하고, 적절하면 active list로 활성화한다.

    분기 로직:

    1. lru_gen_enabled()lru_gen_refault() 호출 (MGLRU 경로)

    2. workingset_test_recent() 호출 → shadow entry가 최근 것인지 판단

    3. workingset_test_recent()가 true이면:

    - folio_set_active(folio) — active list로 활성화

    - workingset_age_nonresident() — 비거주 연령 증가

    - workingset 플래그가 있으면: folio_set_workingset(), lru_note_cost_refault() 호출

    4. false이면 아무 동작 없이 반환

    workingset_test_recent() — refault distance 계산 및 판단

    // mm/workingset.c:418-523
    bool workingset_test_recent(void *shadow, bool file, bool *workingset, bool flush)

    역할: shadow entry를 디코딩하고, 현재 nonresident_age와 비교하여 refault distance가 working set 크기 이내인지 판단한다.

    분기 로직:

    1. lru_gen_enabled()lru_gen_test_recent() 호출

    2. unpack_shadow()로 memcgid, node, eviction 타임스탬프 추출

    3. mem_cgroup_from_private_id()로 eviction 시점의 memcg 조회 (삭제되었을 수 있음)

    4. mem_cgroup_flush_stats_ratelimited()로 통계 갱신

    5. refault_distance = (refault - eviction) & EVICTION_MASK 계산

    6. workingset_size 계산:

    - file 페이지인 경우: NR_ACTIVE_FILE만 기본 포함

    - anon 페이지인 경우: NR_INACTIVE_FILE도 포함

    - swap 사용 가능 시: NR_ACTIVE_ANON을 추가하고, file 페이지인 경우 NR_INACTIVE_ANON도 포함

    7. refault_distance <= workingset_size 반환 — 이 조건이 true이면 활성화 대상

    workingset_age_nonresident() — 비거주 연령 증가

    // mm/workingset.c:355-371
    void workingset_age_nonresident(struct lruvec *lruvec, unsigned long nr_pages)

    역할: 메모리 내 LRU가 aging될 때 비거주 엔트리도 함께 aging시킨다. memcg 계층 구조를 따라 상위 memcg까지 모두 증가시킨다.

    분기 로직:

  • do-while 루프: parent_lruvec(lruvec)가 NULL이 될 때까지 반복
  • 모든 상위 memcg의 nonresident_agenr_pages를 더함
  • workingset_activation() — accessed folio의 비거주 연령 반영

    // mm/workingset.c:589-596
    void workingset_activation(struct folio *folio)
    {
    	if (mem_cgroup_disabled() || folio_memcg_charged(folio))
    		workingset_age_nonresident(folio_lruvec(folio), folio_nr_pages(folio));
    }

    folio_mark_accessed()에서 active 전환이 일어날 때 함께 호출되어, LRU 이동과 nonresident age 증가를 맞춰 준다.

    workingset_update_node() — shadow 노드 LRU 관리

    // mm/workingset.c:613-638
    void workingset_update_node(struct xa_node *node)

    역할: xarray 노드가 shadow entry만 포함하는지 추적하여, 과도하게 누적되면 shrinker로 회수할 수 있도록 한다.

    분기 로직:

    1. node->count && node->count == node->nr_values → 모든 값이 shadow entry

    - list_empty(&node->private_list)이면: list_lru_add_obj()로 shadow 노드 LRU에 추가

    2. 그 외 (일반 페이지 포함 또는 해제 중):

    - list_empty()가 아니면: list_lru_del_obj()로 LRU에서 제거


    호출 흐름

    1. folio 제거 경로

    vmscan.c: shrink_folio_list()
      └─ workingset_eviction(folio, memcg)
           ├─ [MGLRU] lru_gen_eviction(folio)
           │    └─ pack_shadow() → xa_mk_value()
           └─ [CLOCK] atomic_long_read(nonresident_age) → pack_shadow()
                └─ folio->mapping->i_pages[xarray]에 shadow entry 저장

    2. folio refault 경로

    mm/filemap.c: filemap_add_folio()
      ├─ __filemap_add_folio(..., &shadow)
      ├─ shadow && !(gfp & __GFP_WRITE)
      │    └─ workingset_refault(folio, shadow)
      │         ├─ [MGLRU] lru_gen_refault(folio, shadow)
      │         │    ├─ lru_gen_test_recent() → abs_diff(max_seq, token) < MAX_NR_GENS
      │         │    ├─ lrugen->refaulted[hist][type][tier] 증가
      │         │    └─ folio_set_workingset() + mod_lruvec_state()
      │         └─ [CLOCK] workingset_test_recent()
      │              ├─ unpack_shadow() → memcgid, node, eviction
      │              ├─ mem_cgroup_from_private_id() → eviction 시점 memcg 조회
      │              ├─ refault_distance = (refault - eviction) & EVICTION_MASK
      │              ├─ workingset_size = NR_ACTIVE + NR_INACTIVE (+ swap)
      │              └─ refault_distance <= workingset_size ?
      │                   ├─ true: folio_set_active() + workingset_age_nonresident()
      │                   └─ false: 아무 동작 없음
      └─ folio_add_lru(folio)
    mm/swap.c: folio_mark_accessed()
      └─ workingset_activation(folio)

    3. shadow 노드 회수 경로

    kswapd / direct reclaim
      └─ scan_shadow_nodes(shrinker, sc)
           └─ list_lru_shrink_walk_irq(&shadow_nodes, shadow_lru_isolate)
                ├─ xa_trylock(&mapping->i_pages)
                ├─ spin_trylock(&mapping->host->i_lock)
                ├─ xa_delete_node(node, workingset_update_node)
                └─ WORKINGSET_NODERECLAIM 통계 증가

    조건별 비교

    Double CLOCK vs MGLRU workingset 동작

    항목Double CLOCK (`!CONFIG_LRU_GEN`)MGLRU (`CONFIG_LRU_GEN`)
    **제거 시**`lruvec->nonresident_age`에서 읽음`lrugen->min_seq[type]` + `refs`를 token으로 사용
    **refault 판정**`refault_distance <= workingset_size` 수식`abs_diff(max_seq, token >> LRU_REFS_WIDTH) < MAX_NR_GENS`
    **세부 추적**전체 nonresident_age 카운터 하나`evicted[hist][type][tier]` 3차원 배열
    **tier 분류**없음 (workingset 플래그만)`lru_tier_from_refs(refs, workingset)`으로 4단계
    **최근 판정**refault 거리와 메모리 크기 비교시퀀스 기반 최근성 비교

    refault 결과별 동작

    조건동작설명
    shadow 없음pass이전 제거 기록 없음
    `workingset_test_recent()` = falsepass너무 오래전 제거, 관련 없음
    recent = true, workingset = false`folio_set_active()`비활성 목록에서 활성으로 승격
    recent = true, workingset = true`folio_set_active()` + `folio_set_workingset()` + `lru_note_cost_refault()`이전에 active였던 페이지 복원, 비용 증가 기록

    shadow entry 타임스탬프 정밀도

    조건`bucket_order`설명
    메모리 ≤ 타임스탬프 비트 범위0전체 정밀도 유지
    메모리 > 타임스탬프 비트 범위`max_order - timestamp_bits`하위 비트 잘라 coarsening

    anon vs file 페이지의 refault distance 계산

    페이지 유형workingset_size에 포함
    file 페이지`NR_ACTIVE_FILE` + (swap 사용 시 `NR_ACTIVE_ANON` + `NR_INACTIVE_ANON`)
    anon 페이지`NR_ACTIVE_FILE` + `NR_INACTIVE_FILE` + (swap 사용 시 `NR_ACTIVE_ANON`)

    관련 문서

  • 05-page_reclaim.html — 페이지 회수에서 workingset_eviction/refault 호출
  • 00-overview.html — 메모리 관리 전체 개요
  • 28-rmap.html — Reverse Mapping과 연결된 folio 추적
  • 02-slab.html — shadow 노드의 list_lru/shrinker와 연동
  • 14-memcontrol.html — memcg별 nonresident_age 관리

  • SVG 다이어그램

    Double CLOCK workingset 흐름

    Double CLOCK workingset 흐름

    MGLRU 세대별 workingset 흐름

    MGLRU 세대별 workingset 흐름