주소 공간을 여러 CPU에서 사용하면 unmap한 CPU의 TLB만 비워서는 부족합니다. 그렇다고 언제나 system 전체 TLB를 버리면 비용이 큽니다. x86 TLB code는 어떤 mm을 어느 CPU가 사용하며 어느 generation까지 반영했는지 추적하여 필요한 invalidation을 정합니다.
좁은 range와 전체 mm flush의 선택
gather는 start·end와 page stride를 architecture flush에 전달합니다. range가 넓으면 개별 주소 invalidation보다 전체 address-space flush가 저렴할 수 있어 threshold를 사용합니다. fullmm·need_flush_all 상태도 영향을 줍니다. 이 전체 mm flush를 kernel global mapping까지 모두 무효화한다는 말로 바꾸면 안 됩니다.
CPU는 loaded mm과 local TLB generation을 보관하고 mm에는 변경 generation이 있습니다. caller는 context switch와 경합하지 않도록 CPU 선택과 preemption을 제어합니다. flush 정보에는 대상 mm, generation, range와 table 해제 여부 같은 조건이 포함됩니다.
remote가 필요한 CPU만 대상으로 합니다
mm의 CPU mask와 현재 CPU 상태를 보고 local 처리만 가능한지 remote 요청이 필요한지 정합니다. 아래에서 읽는 v6.0의 전통적인 remote shootdown은 IPI로 각 CPU가 flush handler를 실행하게 합니다. 별도 비교 버전인 v6.18에는 INVLPGB를 지원하는 CPU에서 global ASID를 부여받은 mm을 위한 hardware broadcast 경로도 있습니다. 이를 v6.0 코드에 소급하거나 “항상 CPU마다 IPI 하나”를 버전 독립적인 API 계약처럼 말할 수는 없습니다. v6.18 broadcast 선택
handler는 자신이 실제로 해당 mm을 사용하는지와 generation을 다시 검사합니다. 이미 최신 generation이면 같은 작업을 반복할 필요가 없고, 중간 generation을 놓쳤다면 좁은 range 하나만 반영해서는 부족할 수 있습니다. table 해제 여부는 lazy CPU까지 어떻게 처리할지에도 영향을 줍니다.
lazy TLB는 contents가 무작위로 깨졌다는 뜻이 아닙니다
kernel thread는 직전 user task의 address space를 active-mm으로 빌릴 수 있습니다. user mapping을 실제로 실행할 계획이 없는 동안 불필요한 switch를 줄이는 lazy TLB 상태입니다. 필요한 시점에 안전한 mm으로 전환하거나 generation을 맞추는 protocol이 따릅니다.
이 때문에 일부 stale permission fault를 architecture handler가 재검사할 수 있지만, arbitrary page-table corruption을 정상으로 인정한다는 뜻은 아닙니다. enter_lazy_tlb() state와 spurious_kernel_fault()의 제한된 복구를 구별해야 합니다.
flush 뒤에도 다른 reference가 남습니다
invalidation을 마치면 gather의 folio batch를 순회하며 reference를 내려놓습니다. LRU batch drain과 swapcache 정리가 필요한 경우도 있습니다. 최종 folio_put_testzero()가 0 전환을 확인해야 allocator로 반환할 수 있습니다. 다른 mapping·page cache·pin이 남았다면 이번 unmap이 끝나도 folio는 생존합니다.
mapcount는 page-table 관계를, refcount는 더 넓은 수명을 추적합니다. 정상적인 reference 계약에서는 mapping이 folio 수명을 보장하지만 두 숫자가 항상 같은 값은 아닙니다. 마지막 mapcount만 보고 free하거나, flush가 끝났다는 이유로 pin을 무시해서는 안 됩니다.
이 전체 흐름은 “entry를 지우면 memory가 해제된다”는 직관을 세 단계로 고칩니다. 주소 공간에서 연결을 끊고, 남은 translation 사용을 끝내며, 수명 reference가 실제로 마지막인지 확인해야 재사용할 수 있습니다.
Architecture 진입점과 세 generation을 함께 읽습니다
아래 source는 Linux v6.0 x86 구현입니다. tlb_flush_mmu_tlbonly()는 변경된 level이 있는지 확인하고 architecture flush와 notifier를 수행한 뒤 range를 reset합니다. cleared_ptes는 일반 base-page PTE 제거에도 사용됩니다. Huge mapping 전용이라고 제외하면 평범한 unmap의 flush 조건부터 놓칩니다.
/* Upstream Linux v6.0: mm/mmu_gather.c:259-263
* Exact source lines; kernel context, not a standalone program or book restoration.
*/
void tlb_flush_mmu(struct mmu_gather *tlb)
{
tlb_flush_mmu_tlbonly(tlb);
tlb_flush_mmu_free(tlb);
}
/* SPDX-License-Identifier: GPL-2.0-or-later */
/* include/asm-generic/tlb.h
*
* Generic TLB shootdown code
*
* Copyright 2001 Red Hat, Inc.
* Based on code from mm/memory.c Copyright Linus Torvalds and others.
*
* Copyright 2011 Red Hat, Inc., Peter Zijlstra
*/
/* Upstream Linux v6.0: include/asm-generic/tlb.h:416-429
* Exact source lines; kernel context, not a standalone program or book restoration.
*/
static inline void tlb_flush_mmu_tlbonly(struct mmu_gather *tlb)
{
/*
* Anything calling __tlb_adjust_range() also sets at least one of
* these bits.
*/
if (!(tlb->freed_tables || tlb->cleared_ptes || tlb->cleared_pmds ||
tlb->cleared_puds || tlb->cleared_p4ds))
return;
tlb_flush(tlb);
mmu_notifier_invalidate_range(tlb->mm, tlb->start, tlb->end);
__tlb_reset_range(tlb);
}
/* SPDX-License-Identifier: GPL-2.0 */
/* Upstream Linux v6.0: arch/x86/include/asm/tlb.h:10-21
* Exact source lines; kernel context, not a standalone program or book restoration.
*/
static inline void tlb_flush(struct mmu_gather *tlb)
{
unsigned long start = 0UL, end = TLB_FLUSH_ALL;
unsigned int stride_shift = tlb_get_unmap_shift(tlb);
if (!tlb->fullmm && !tlb->need_flush_all) {
start = tlb->start;
end = tlb->end;
}
flush_tlb_mm_range(tlb->mm, start, end, stride_shift, tlb->freed_tables);
}
// SPDX-License-Identifier: GPL-2.0-only
/* Upstream Linux v6.0: arch/x86/mm/tlb.c:981-1020
* Exact source lines; kernel context, not a standalone program or book restoration.
*/
void flush_tlb_mm_range(struct mm_struct *mm, unsigned long start,
unsigned long end, unsigned int stride_shift,
bool freed_tables)
{
struct flush_tlb_info *info;
u64 new_tlb_gen;
int cpu;
cpu = get_cpu();
/* Should we flush just the requested range? */
if ((end == TLB_FLUSH_ALL) ||
((end - start) >> stride_shift) > tlb_single_page_flush_ceiling) {
start = 0;
end = TLB_FLUSH_ALL;
}
/* This is also a barrier that synchronizes with switch_mm(). */
new_tlb_gen = inc_mm_tlb_gen(mm);
info = get_flush_tlb_info(mm, start, end, stride_shift, freed_tables,
new_tlb_gen);
/*
* flush_tlb_multi() is not optimized for the common case in which only
* a local TLB flush is needed. Optimize this use-case by calling
* flush_tlb_func_local() directly in this case.
*/
if (cpumask_any_but(mm_cpumask(mm), cpu) < nr_cpu_ids) {
flush_tlb_multi(mm_cpumask(mm), info);
} else if (mm == this_cpu_read(cpu_tlbstate.loaded_mm)) {
lockdep_assert_irqs_enabled();
local_irq_disable();
flush_tlb_func(info);
local_irq_enable();
}
put_flush_tlb_info();
put_cpu();
}
/* SPDX-License-Identifier: GPL-2.0 */
/* Upstream Linux v6.0: arch/x86/include/asm/tlbflush.h:187-211
* Exact source lines; kernel context, not a standalone program or book restoration.
*/
struct flush_tlb_info {
/*
* We support several kinds of flushes.
*
* - Fully flush a single mm. .mm will be set, .end will be
* TLB_FLUSH_ALL, and .new_tlb_gen will be the tlb_gen to
* which the IPI sender is trying to catch us up.
*
* - Partially flush a single mm. .mm will be set, .start and
* .end will indicate the range, and .new_tlb_gen will be set
* such that the changes between generation .new_tlb_gen-1 and
* .new_tlb_gen are entirely contained in the indicated range.
*
* - Fully flush all mms whose tlb_gens have been updated. .mm
* will be NULL, .end will be TLB_FLUSH_ALL, and .new_tlb_gen
* will be zero.
*/
struct mm_struct *mm;
unsigned long start;
unsigned long end;
u64 new_tlb_gen;
unsigned int initiating_cpu;
u8 stride_shift;
u8 freed_tables;
};
이 구현의 tlb_single_page_flush_ceiling은 초기값 33을 가진 tunable입니다. 영구적인 hardware 규칙이나 모든 architecture의 상수로 외우지 않겠습니다. Full flush 역시 대상 mm의 context를 비우는 의미이지 system 전체의 global kernel translation을 무조건 지우는 뜻이 아닙니다.
Remote 처리에서는 freed_tables가 중요합니다. Data translation만 제거할 때 건너뛸 수 있던 lazy CPU도 table memory가 재사용될 수 있다면 speculative page-table walk를 고려해야 합니다. 아래 함수는 tracing까지 포함한 전체 정의를 담았습니다.
// SPDX-License-Identifier: GPL-2.0-only
/* Upstream Linux v6.0: arch/x86/mm/tlb.c:889-919
* Exact source lines; kernel context, not a standalone program or book restoration.
*/
STATIC_NOPV void native_flush_tlb_multi(const struct cpumask *cpumask,
const struct flush_tlb_info *info)
{
/*
* Do accounting and tracing. Note that there are (and have always been)
* cases in which a remote TLB flush will be traced, but eventually
* would not happen.
*/
count_vm_tlb_event(NR_TLB_REMOTE_FLUSH);
if (info->end == TLB_FLUSH_ALL)
trace_tlb_flush(TLB_REMOTE_SEND_IPI, TLB_FLUSH_ALL);
else
trace_tlb_flush(TLB_REMOTE_SEND_IPI,
(info->end - info->start) >> PAGE_SHIFT);
/*
* If no page tables were freed, we can skip sending IPIs to
* CPUs in lazy TLB mode. They will flush the CPU themselves
* at the next context switch.
*
* However, if page tables are getting freed, we need to send the
* IPI everywhere, to prevent CPUs in lazy TLB mode from tripping
* up on the new contents of what used to be page tables, while
* doing a speculative memory access.
*/
if (info->freed_tables)
on_each_cpu_mask(cpumask, flush_tlb_func, (void *)info, true);
else
on_each_cpu_cond_mask(tlb_is_not_lazy, flush_tlb_func,
(void *)info, 1, cpumask);
}
flush_tlb_func()에는 mm의 최신 generation, 이 CPU의 반영 generation, 요청자가 보낸 generation이 있습니다. Valid한 요청 generation이 이미 반영된 경우에는 early return할 수 있습니다. 그러나 TLB_GENERATION_INVALID라는 값이 있다는 이유만으로 무조건 종료하는 조건은 아닙니다. 실제 조건의 !=와 &&를 함께 읽으셔야 합니다.
// SPDX-License-Identifier: GPL-2.0-only
/* Upstream Linux v6.0: arch/x86/mm/tlb.c:723-879
* Exact source lines; kernel context, not a standalone program or book restoration.
*/
static void flush_tlb_func(void *info)
{
/*
* We have three different tlb_gen values in here. They are:
*
* - mm_tlb_gen: the latest generation.
* - local_tlb_gen: the generation that this CPU has already caught
* up to.
* - f->new_tlb_gen: the generation that the requester of the flush
* wants us to catch up to.
*/
const struct flush_tlb_info *f = info;
struct mm_struct *loaded_mm = this_cpu_read(cpu_tlbstate.loaded_mm);
u32 loaded_mm_asid = this_cpu_read(cpu_tlbstate.loaded_mm_asid);
u64 local_tlb_gen = this_cpu_read(cpu_tlbstate.ctxs[loaded_mm_asid].tlb_gen);
bool local = smp_processor_id() == f->initiating_cpu;
unsigned long nr_invalidate = 0;
u64 mm_tlb_gen;
/* This code cannot presently handle being reentered. */
VM_WARN_ON(!irqs_disabled());
if (!local) {
inc_irq_stat(irq_tlb_count);
count_vm_tlb_event(NR_TLB_REMOTE_FLUSH_RECEIVED);
/* Can only happen on remote CPUs */
if (f->mm && f->mm != loaded_mm)
return;
}
if (unlikely(loaded_mm == &init_mm))
return;
VM_WARN_ON(this_cpu_read(cpu_tlbstate.ctxs[loaded_mm_asid].ctx_id) !=
loaded_mm->context.ctx_id);
if (this_cpu_read(cpu_tlbstate_shared.is_lazy)) {
/*
* We're in lazy mode. We need to at least flush our
* paging-structure cache to avoid speculatively reading
* garbage into our TLB. Since switching to init_mm is barely
* slower than a minimal flush, just switch to init_mm.
*
* This should be rare, with native_flush_tlb_multi() skipping
* IPIs to lazy TLB mode CPUs.
*/
switch_mm_irqs_off(NULL, &init_mm, NULL);
return;
}
if (unlikely(f->new_tlb_gen != TLB_GENERATION_INVALID &&
f->new_tlb_gen <= local_tlb_gen)) {
/*
* The TLB is already up to date in respect to f->new_tlb_gen.
* While the core might be still behind mm_tlb_gen, checking
* mm_tlb_gen unnecessarily would have negative caching effects
* so avoid it.
*/
return;
}
/*
* Defer mm_tlb_gen reading as long as possible to avoid cache
* contention.
*/
mm_tlb_gen = atomic64_read(&loaded_mm->context.tlb_gen);
if (unlikely(local_tlb_gen == mm_tlb_gen)) {
/*
* There's nothing to do: we're already up to date. This can
* happen if two concurrent flushes happen -- the first flush to
* be handled can catch us all the way up, leaving no work for
* the second flush.
*/
goto done;
}
WARN_ON_ONCE(local_tlb_gen > mm_tlb_gen);
WARN_ON_ONCE(f->new_tlb_gen > mm_tlb_gen);
/*
* If we get to this point, we know that our TLB is out of date.
* This does not strictly imply that we need to flush (it's
* possible that f->new_tlb_gen <= local_tlb_gen), but we're
* going to need to flush in the very near future, so we might
* as well get it over with.
*
* The only question is whether to do a full or partial flush.
*
* We do a partial flush if requested and two extra conditions
* are met:
*
* 1. f->new_tlb_gen == local_tlb_gen + 1. We have an invariant that
* we've always done all needed flushes to catch up to
* local_tlb_gen. If, for example, local_tlb_gen == 2 and
* f->new_tlb_gen == 3, then we know that the flush needed to bring
* us up to date for tlb_gen 3 is the partial flush we're
* processing.
*
* As an example of why this check is needed, suppose that there
* are two concurrent flushes. The first is a full flush that
* changes context.tlb_gen from 1 to 2. The second is a partial
* flush that changes context.tlb_gen from 2 to 3. If they get
* processed on this CPU in reverse order, we'll see
* local_tlb_gen == 1, mm_tlb_gen == 3, and end != TLB_FLUSH_ALL.
* If we were to use __flush_tlb_one_user() and set local_tlb_gen to
* 3, we'd be break the invariant: we'd update local_tlb_gen above
* 1 without the full flush that's needed for tlb_gen 2.
*
* 2. f->new_tlb_gen == mm_tlb_gen. This is purely an optimization.
* Partial TLB flushes are not all that much cheaper than full TLB
* flushes, so it seems unlikely that it would be a performance win
* to do a partial flush if that won't bring our TLB fully up to
* date. By doing a full flush instead, we can increase
* local_tlb_gen all the way to mm_tlb_gen and we can probably
* avoid another flush in the very near future.
*/
if (f->end != TLB_FLUSH_ALL &&
f->new_tlb_gen == local_tlb_gen + 1 &&
f->new_tlb_gen == mm_tlb_gen) {
/* Partial flush */
unsigned long addr = f->start;
/* Partial flush cannot have invalid generations */
VM_WARN_ON(f->new_tlb_gen == TLB_GENERATION_INVALID);
/* Partial flush must have valid mm */
VM_WARN_ON(f->mm == NULL);
nr_invalidate = (f->end - f->start) >> f->stride_shift;
while (addr < f->end) {
flush_tlb_one_user(addr);
addr += 1UL << f->stride_shift;
}
if (local)
count_vm_tlb_events(NR_TLB_LOCAL_FLUSH_ONE, nr_invalidate);
} else {
/* Full flush. */
nr_invalidate = TLB_FLUSH_ALL;
flush_tlb_local();
if (local)
count_vm_tlb_event(NR_TLB_LOCAL_FLUSH_ALL);
}
/* Both paths above update our state to mm_tlb_gen. */
this_cpu_write(cpu_tlbstate.ctxs[loaded_mm_asid].tlb_gen, mm_tlb_gen);
/* Tracing is done in a unified manner to reduce the code size */
done:
trace_tlb_flush(!local ? TLB_REMOTE_SHOOTDOWN :
(f->mm == NULL) ? TLB_LOCAL_SHOOTDOWN :
TLB_LOCAL_MM_SHOOTDOWN,
nr_invalidate);
}
Partial invalidation은 요청 generation이 local의 바로 다음이고 mm의 최신 generation과도 맞을 때 선택됩니다. 더 오래된 full flush를 놓친 CPU가 최신 partial range만 지운 뒤 generation을 올리면 중간 변경을 영원히 빠뜨릴 수 있기 때문입니다. Generation publication은 실제 invalidation 뒤에 옵니다. 이 ordering은 lock을 대신하는 추상 숫자 놀이가 아니라 stale translation을 남기지 않는 protocol입니다.
마지막 reference가 아닐 수도 있습니다
다음 code는 batch를 순회하고 swapcache를 정리한 뒤 folio reference를 내립니다. Batch의 page들을 보았다는 이유만으로 전부 allocator에 바로 반환하지 않습니다. release_pages()는 HugeTLB·device·cgroup 등의 분기도 포함한 전체 정의이며, folio_put_testzero()가 최종 lifetime 전환을 결정합니다.
/* Upstream Linux v6.0: mm/mmu_gather.c:45-66
* Exact source lines; kernel context, not a standalone program or book restoration.
*/
static void tlb_batch_pages_flush(struct mmu_gather *tlb)
{
struct mmu_gather_batch *batch;
for (batch = &tlb->local; batch && batch->nr; batch = batch->next) {
struct page **pages = batch->pages;
do {
/*
* limit free batch count when PAGE_SIZE > 4K
*/
unsigned int nr = min(512U, batch->nr);
free_pages_and_swap_cache(pages, nr);
pages += nr;
batch->nr -= nr;
cond_resched();
} while (batch->nr);
}
tlb->active = &tlb->local;
}
// SPDX-License-Identifier: GPL-2.0
/*
* linux/mm/swap_state.c
*
* Copyright (C) 1991, 1992, 1993, 1994 Linus Torvalds
* Swap reorganised 29.12.95, Stephen Tweedie
*
* Rewritten to use page cache, (C) 1998 Stephen Tweedie
*/
/* Upstream Linux v6.0: mm/swap_state.c:303-312
* Exact source lines; kernel context, not a standalone program or book restoration.
*/
void free_pages_and_swap_cache(struct page **pages, int nr)
{
struct page **pagep = pages;
int i;
lru_add_drain();
for (i = 0; i < nr; i++)
free_swap_cache(pagep[i]);
release_pages(pagep, nr);
}
// SPDX-License-Identifier: GPL-2.0-only
/*
* linux/mm/swap.c
*
* Copyright (C) 1991, 1992, 1993, 1994 Linus Torvalds
*/
/*
* This file contains the default values for the operation of the
* Linux VM subsystem. Fine-tuning documentation can be found in
* Documentation/admin-guide/sysctl/vm.rst.
* Started 18.12.91
* Swap aging added 23.2.95, Stephen Tweedie.
* Buffermem limits added 12.3.98, Rik van Riel.
*/
/* Upstream Linux v6.0: mm/swap.c:934-1013
* Exact source lines; kernel context, not a standalone program or book restoration.
*/
void release_pages(struct page **pages, int nr)
{
int i;
LIST_HEAD(pages_to_free);
struct lruvec *lruvec = NULL;
unsigned long flags = 0;
unsigned int lock_batch;
for (i = 0; i < nr; i++) {
struct folio *folio = page_folio(pages[i]);
/*
* Make sure the IRQ-safe lock-holding time does not get
* excessive with a continuous string of pages from the
* same lruvec. The lock is held only if lruvec != NULL.
*/
if (lruvec && ++lock_batch == SWAP_CLUSTER_MAX) {
unlock_page_lruvec_irqrestore(lruvec, flags);
lruvec = NULL;
}
if (is_huge_zero_page(&folio->page))
continue;
if (folio_is_zone_device(folio)) {
if (lruvec) {
unlock_page_lruvec_irqrestore(lruvec, flags);
lruvec = NULL;
}
if (put_devmap_managed_page(&folio->page))
continue;
if (folio_put_testzero(folio))
free_zone_device_page(&folio->page);
continue;
}
if (!folio_put_testzero(folio))
continue;
if (folio_test_large(folio)) {
if (lruvec) {
unlock_page_lruvec_irqrestore(lruvec, flags);
lruvec = NULL;
}
__folio_put_large(folio);
continue;
}
if (folio_test_lru(folio)) {
struct lruvec *prev_lruvec = lruvec;
lruvec = folio_lruvec_relock_irqsave(folio, lruvec,
&flags);
if (prev_lruvec != lruvec)
lock_batch = 0;
lruvec_del_folio(lruvec, folio);
__folio_clear_lru_flags(folio);
}
/*
* In rare cases, when truncation or holepunching raced with
* munlock after VM_LOCKED was cleared, Mlocked may still be
* found set here. This does not indicate a problem, unless
* "unevictable_pgs_cleared" appears worryingly large.
*/
if (unlikely(folio_test_mlocked(folio))) {
__folio_clear_mlocked(folio);
zone_stat_sub_folio(folio, NR_MLOCK);
count_vm_event(UNEVICTABLE_PGCLEARED);
}
list_add(&folio->lru, &pages_to_free);
}
if (lruvec)
unlock_page_lruvec_irqrestore(lruvec, flags);
mem_cgroup_uncharge_list(&pages_to_free);
free_unref_page_list(&pages_to_free);
}
// SPDX-License-Identifier: GPL-2.0-only
/* Upstream Linux v6.0: arch/x86/mm/tlb.c:663-669
* Exact source lines; kernel context, not a standalone program or book restoration.
*/
void enter_lazy_tlb(struct mm_struct *mm, struct task_struct *tsk)
{
if (this_cpu_read(cpu_tlbstate.loaded_mm) == &init_mm)
return;
this_cpu_write(cpu_tlbstate_shared.is_lazy, true);
}
free_unref_page_list()는 이름 그대로 반환 경로이지 physical allocation을 새로 수행하는 함수가 아닙니다. 또 raw mapcount와 folio refcount의 숫자 비교를 모든 folio 종류에 보편식으로 확대하지 않겠습니다. 이 경로에서 중요한 것은 필요한 mapping·pin·cache reference를 caller들이 올바르게 유지하고, 마지막 put 뒤에만 재사용한다는 계약입니다. Lazy TLB 역시 per-CPU 상태를 통해 안전한 context 전환을 관리하는 최적화이지 임의 corruption을 허용하는 상태가 아닙니다.
Source: Linux v6.0 arch/x86/mm/tlb.c, asm/tlb.h, asm/tlbflush.h, mm/swap.c, swap_state.c. 원래 notice와 COPYING, GPL-2.0를 보존했습니다. v6.0은 historical reference이고 book exact tag는 미확정이며, v6.18은 latest가 아닌 고정 비교 tag입니다. 이 글에서는 kernel build나 실행 측정을 하지 않았습니다.
참고 자료
Lorenzo Stoakes, The Linux Memory Manager, Chapter 7 §7.1.6–§7.1.8. Linux v6.18 arch/x86/mm/tlb.c, mm/mmu_gather.c, mm/swap.c를 참고하실 수 있습니다.