# 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
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.c — workingset_refault() 재진입점 (filemap_add_folio())mm/vmscan.c — workingset_eviction() 호출 지점 (shrink_folio_list())mm/swap.c — workingset_activation() 호출 지점 (folio_mark_accessed())mm/memcontrol.c — WORKINGSET_* vmstat/cgroup 통계 이름include/linux/swap.h — workingset_eviction(), workingset_refault() 등 선언 (line 314-320)include/linux/mmzone.h — lruvec.nonresident_age, WORKINGSET_* 통계 enum (line 192-202, 681)include/linux/mm_inline.h — lru_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는 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로 처리// 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 제거/활성화 시점에 이 값을 타임스탬프로 저장// 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 노드 수
// 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);
}
// 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으로 활성화되었는지 추적
// 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이면 아무 동작 없이 반환
// 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이면 활성화 대상
// 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이 될 때까지 반복nonresident_age에 nr_pages를 더함// 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 증가를 맞춰 준다.
// 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에서 제거
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 저장
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)
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 (`!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 거리와 메모리 크기 비교 | 시퀀스 기반 최근성 비교 |
| 조건 | 동작 | 설명 |
|---|---|---|
| shadow 없음 | pass | 이전 제거 기록 없음 |
| `workingset_test_recent()` = false | pass | 너무 오래전 제거, 관련 없음 |
| recent = true, workingset = false | `folio_set_active()` | 비활성 목록에서 활성으로 승격 |
| recent = true, workingset = true | `folio_set_active()` + `folio_set_workingset()` + `lru_note_cost_refault()` | 이전에 active였던 페이지 복원, 비용 증가 기록 |
| 조건 | `bucket_order` | 설명 |
|---|---|---|
| 메모리 ≤ 타임스탬프 비트 범위 | 0 | 전체 정밀도 유지 |
| 메모리 > 타임스탬프 비트 범위 | `max_order - timestamp_bits` | 하위 비트 잘라 coarsening |
| 페이지 유형 | workingset_size에 포함 |
|---|---|
| file 페이지 | `NR_ACTIVE_FILE` + (swap 사용 시 `NR_ACTIVE_ANON` + `NR_INACTIVE_ANON`) |
| anon 페이지 | `NR_ACTIVE_FILE` + `NR_INACTIVE_FILE` + (swap 사용 시 `NR_ACTIVE_ANON`) |