CoW fault에서 복사하지 않아도 되는 순간

fork() 뒤 한쪽 process가 먼저 종료하면 남은 process의 write에서도 반드시 page를 복사해야 할까요. 서로 격리할 상대가 사라졌고 다른 reference 조건까지 맞는다면 기존 folio를 writable로 재사용할 수 있습니다. 중요한 것은 mapcount 숫자 하나가 아니라 현재 page의 공유와 수명 상태입니다.

exclusive와 refcount는 다른 축입니다

v6.0의 early cutoff page_count > 3은 세 개의 process까지 공유해도 된다는 뜻이 아닙니다. 현재 mapping reference 하나에 local LRU pagevec의 임시 reference와 swapcache reference가 더해질 가능성을 고려한 빠른 탈락 검사입니다. local drain은 remote CPU의 pagevec reference까지 모두 없애는 동작이 아니며, 뒤의 1 + PageSwapCache 검사와 lock 아래 최종 == 1 검사를 통과해야 합니다. KSM도 refcount만 보고 놓치지 않도록 별도로 배제합니다.

PageAnonExclusive()는 anonymous page-table 공유에 관한 불변식입니다. “folio reference가 문자 그대로 하나뿐”이라는 뜻이 아닙니다. GUP pin이나 임시 reference와 공존할 수 있습니다. pin된 exclusive folio를 불필요하게 copy하면 pin 보유자와 process가 다른 contents를 보게 되는 문제도 생길 수 있습니다.

exclusive가 이미 확인되지 않으면 추가 검사를 수행합니다. order-0의 전형적인 경로에서는 KSM과 지나치게 많은 reference를 먼저 배제하고, non-LRU 상태이면 local LRU batch를 drain해 임시 reference가 사라질 기회를 줍니다. swapcache reference를 제외해도 초과 reference가 있으면 copy를 선택합니다.

non-sleeping folio trylock이 실패해도 reuse를 포기할 수 있습니다. lock 아래에서 swapcache를 정리하고 최종 reference·KSM 조건을 다시 검사한 뒤 현재 VMA의 anon_vma로 관계를 옮기고 exclusive를 설정합니다. multi-page folio에는 별도 mapcount와 refcount 판단이 필요하므로 order-0의 숫자를 전체 folio에 적용하지 않습니다.

order-0 private anonymous write-protection fault의 대표 reuse 조건입니다. helper 명칭은 v6.18 기준이며 zero PFN, file/shared mapping, UNSHARE와 특수 기능은 생략했습니다.
order-0 private anonymous write-protection fault의 대표 reuse 조건입니다. helper 명칭은 v6.18 기준이며 zero PFN, file/shared mapping, UNSHARE와 특수 기능은 생략했습니다.

그림의 wp_can_reuse_anon_folio()는 v6.18의 별도 helper입니다. 아래 v6.0 전체 정의에서는 대응하는 page-count·KSM·swapcache·trylock 검사가 do_wp_page() 안에 있으므로, helper가 따로 정의돼 있다고 가정하지 않습니다. 이 그림은 두 version의 line이나 숫자를 합친 code가 아니라 order-0 판단의 개념적 비교입니다.

reuse가 하는 일

wp_page_reuse()는 기존 entry를 바탕으로 young·dirty와 허용된 writable 상태를 조합합니다. NUMA의 이전 접근 CPU 정보는 새 소유 관계에서 무효할 수 있어 정리합니다. VMA가 WRITE를 허용해야만 writable로 만들며, reuse라고 해서 backing identity를 새것으로 바꾸는 것은 아닙니다.

UNSHARE는 GUP의 read pin이 anonymous CoW 관계를 끊으려는 요청입니다. 이 경우 배타성을 확보하는 것과 write 권한을 주는 것은 다릅니다. WRITE와 동시에 지정할 수 없으며 원래 read-only mapping을 임의로 writable하게 만들어서는 안 됩니다.

copy 중에는 entry를 소유하지 않습니다

v6.0의 wp_page_copy()는 MMU notifier invalidate 구간을 PTE 교체 바깥에 설정합니다. CPU page table만 바꾸고 secondary MMU가 과거 backing을 계속 보게 두지 않기 위한 계약입니다. allocation·copy가 끝나도 snapshot 비교에서 지면 accounting과 old mapping 제거를 수행하지 않습니다. file source를 바꾼 성공 경로는 file counter를 내리고 anonymous counter를 올리며, zero PFN처럼 normal source page가 없는 성공도 anonymous counter를 추가합니다.

UNSHARE는 새 PTE의 soft-dirty와 userfaultfd write-protect 상태를 보존하되 writable로 만들지 않습니다. soft-dirty는 x86에서도 configuration에 따라 의미가 있는 software bit이며, hardware accessed bit가 존재한다는 이유로 무시할 수 없습니다. 일반 WRITE 경로의 dirty 설정과 이 보존 경로를 구별해야 합니다.

ptep_clear_flush()는 먼저 기존 entry를 가져오며 clear하고, pte_accessible()이 true일 때 TLB invalidation을 수행합니다. v6.0 x86의 이 판정은 hardware PRESENT뿐 아니라 PROTNONE이면서 tlb_flush_pending이 있는 경우도 포함합니다. NUMA hinting처럼 PTE를 먼저 제한했지만 flush가 남은 구간에서 과거 translation을 고려해야 하기 때문입니다. 이 규칙을 모든 non-present entry가 TLB에 없다는 가정으로 대체할 수 없습니다.

copy를 택하면 source reference를 확보하고 PTE lock을 놓습니다. 새 anonymous rmap을 준비하며 target을 allocation하고 contents를 복사합니다. zero PFN에서는 source byte를 복사하지 않고 zeroed target을 사용할 수 있습니다. copy가 실패하거나 다른 fault가 먼저 해결했으면 target을 버리고 다시 fault할 수 있습니다.

PTE lock을 재획득한 뒤 snapshot이 같을 때만 교체합니다. old mapping을 clear하고 필요한 TLB invalidation을 한 뒤 new rmap·LRU·PTE를 연결합니다. old mapcount를 너무 일찍 내리면 다른 process가 단독 소유라고 판단해 old folio를 writable로 reuse할 수 있으므로 순서가 중요합니다.

두 개의 put이 같은 reference는 아닙니다

성공 시 new_page = old_page로 local variable을 바꾸는 것은 새 target을 반환하라는 뜻이 아니라 old mapping reference를 공통 cleanup으로 내려보내는 장치입니다. page_copied가 true일 때의 free_swap_cache()와 이후 old_page의 임시 reference 반환도 별개입니다. v6.0의 VM_FAULT_WRITE 반환은 이 역사적 interface의 일부이며 v6.18에는 그 bit가 없습니다.

v6.18은 folio 단위 상태와 folio_prealloc()·vmf_anon_prepare()를 사용하며, copy helper는 bool 대신 error code를 반환합니다. hardware poison이면 재시도 대신 HWPOISON 결과를 돌려주는 분기도 있습니다. 따라서 아래 v6.0의 page-count 숫자와 성공 반환값을 최신 multi-page folio 전체의 판단 규칙으로 확대하지 않습니다.

성공 시 old mapping이 소유했던 reference와 copy를 위해 임시로 확보한 source reference를 각각 정리합니다. 경쟁에서 졌다면 old mapping의 reference를 내릴 권리는 없으며 임시 source와 미사용 target만 반환해야 합니다. RSS도 file source에서 anonymous target으로 바뀌었는지에 따라 분류가 달라집니다.

결국 CoW의 비용을 이해하려면 “복사했는가”보다 “아직 누구와 공유하며 어떤 reference가 남았는가”를 먼저 물어야 합니다. minor fault 수나 RSS 변화 하나만으로 zero-page copy, fork copy와 reuse를 구분할 수는 없습니다.

Linux v6.0의 reuse와 copy 전체 정의

do_wp_page()에서 선택한 경로를 wp_page_copy()의 마지막 cleanup까지 연결합니다. copy 함수를 중간에서 나누거나 MMU notifier 분기를 생략하지 않았습니다. 이 tag의 VM_FAULT_WRITE 반환과 bool copy-helper 계약은 v6.18과 다릅니다.

아래는 Linux v6.0의 전체 함수·자료형 정의입니다. kernel의 header·configuration·호출 규약에 의존하는 코드이며 독립 실행 프로그램은 아닙니다. 본문 해설과는 별도로 각 source의 SPDX license를 적용합니다. 원래 copyright notice는 source notice에, GPL version 2 전문은 license 파일에 보존합니다. 표시상의 tab을 space로 바꾼 것 외에 함수 내부의 문장이나 configuration 분기는 제거하지 않았습니다.

do_wp_page()

Linux v6.0 mm/memory.c, SPDX GPL-2.0-only입니다.

static vm_fault_t do_wp_page(struct vm_fault *vmf)
    __releases(vmf->ptl)
{
    const bool unshare = vmf->flags & FAULT_FLAG_UNSHARE;
    struct vm_area_struct *vma = vmf->vma;

    VM_BUG_ON(unshare && (vmf->flags & FAULT_FLAG_WRITE));
    VM_BUG_ON(!unshare && !(vmf->flags & FAULT_FLAG_WRITE));

    if (likely(!unshare)) {
        if (userfaultfd_pte_wp(vma, *vmf->pte)) {
            pte_unmap_unlock(vmf->pte, vmf->ptl);
            return handle_userfault(vmf, VM_UFFD_WP);
        }

        /*
         * Userfaultfd write-protect can defer flushes. Ensure the TLB
         * is flushed in this case before copying.
         */
        if (unlikely(userfaultfd_wp(vmf->vma) &&
                 mm_tlb_flush_pending(vmf->vma->vm_mm)))
            flush_tlb_page(vmf->vma, vmf->address);
    }

    vmf->page = vm_normal_page(vma, vmf->address, vmf->orig_pte);
    if (!vmf->page) {
        if (unlikely(unshare)) {
            /* No anonymous page -> nothing to do. */
            pte_unmap_unlock(vmf->pte, vmf->ptl);
            return 0;
        }

        /*
         * VM_MIXEDMAP !pfn_valid() case, or VM_SOFTDIRTY clear on a
         * VM_PFNMAP VMA.
         *
         * We should not cow pages in a shared writeable mapping.
         * Just mark the pages writable and/or call ops->pfn_mkwrite.
         */
        if ((vma->vm_flags & (VM_WRITE|VM_SHARED)) ==
                     (VM_WRITE|VM_SHARED))
            return wp_pfn_shared(vmf);

        pte_unmap_unlock(vmf->pte, vmf->ptl);
        return wp_page_copy(vmf);
    }

    /*
     * Take out anonymous pages first, anonymous shared vmas are
     * not dirty accountable.
     */
    if (PageAnon(vmf->page)) {
        struct page *page = vmf->page;

        /*
         * If the page is exclusive to this process we must reuse the
         * page without further checks.
         */
        if (PageAnonExclusive(page))
            goto reuse;

        /*
         * We have to verify under page lock: these early checks are
         * just an optimization to avoid locking the page and freeing
         * the swapcache if there is little hope that we can reuse.
         *
         * PageKsm() doesn't necessarily raise the page refcount.
         */
        if (PageKsm(page) || page_count(page) > 3)
            goto copy;
        if (!PageLRU(page))
            /*
             * Note: We cannot easily detect+handle references from
             * remote LRU pagevecs or references to PageLRU() pages.
             */
            lru_add_drain();
        if (page_count(page) > 1 + PageSwapCache(page))
            goto copy;
        if (!trylock_page(page))
            goto copy;
        if (PageSwapCache(page))
            try_to_free_swap(page);
        if (PageKsm(page) || page_count(page) != 1) {
            unlock_page(page);
            goto copy;
        }
        /*
         * Ok, we've got the only page reference from our mapping
         * and the page is locked, it's dark out, and we're wearing
         * sunglasses. Hit it.
         */
        page_move_anon_rmap(page, vma);
        unlock_page(page);
reuse:
        if (unlikely(unshare)) {
            pte_unmap_unlock(vmf->pte, vmf->ptl);
            return 0;
        }
        wp_page_reuse(vmf);
        return VM_FAULT_WRITE;
    } else if (unshare) {
        /* No anonymous page -> nothing to do. */
        pte_unmap_unlock(vmf->pte, vmf->ptl);
        return 0;
    } else if (unlikely((vma->vm_flags & (VM_WRITE|VM_SHARED)) ==
                    (VM_WRITE|VM_SHARED))) {
        return wp_page_shared(vmf);
    }
copy:
    /*
     * Ok, we need to copy. Oh, well..
     */
    get_page(vmf->page);

    pte_unmap_unlock(vmf->pte, vmf->ptl);
#ifdef CONFIG_KSM
    if (PageKsm(vmf->page))
        count_vm_event(COW_KSM);
#endif
    return wp_page_copy(vmf);
}

wp_page_reuse()

Linux v6.0 mm/memory.c, SPDX GPL-2.0-only입니다.

static inline void wp_page_reuse(struct vm_fault *vmf)
    __releases(vmf->ptl)
{
    struct vm_area_struct *vma = vmf->vma;
    struct page *page = vmf->page;
    pte_t entry;

    VM_BUG_ON(!(vmf->flags & FAULT_FLAG_WRITE));
    VM_BUG_ON(page && PageAnon(page) && !PageAnonExclusive(page));

    /*
     * Clear the pages cpupid information as the existing
     * information potentially belongs to a now completely
     * unrelated process.
     */
    if (page)
        page_cpupid_xchg_last(page, (1 << LAST_CPUPID_SHIFT) - 1);

    flush_cache_page(vma, vmf->address, pte_pfn(vmf->orig_pte));
    entry = pte_mkyoung(vmf->orig_pte);
    entry = maybe_mkwrite(pte_mkdirty(entry), vma);
    if (ptep_set_access_flags(vma, vmf->address, vmf->pte, entry, 1))
        update_mmu_cache(vma, vmf->address, vmf->pte);
    pte_unmap_unlock(vmf->pte, vmf->ptl);
    count_vm_event(PGREUSE);
}

wp_page_copy()

Linux v6.0 mm/memory.c, SPDX GPL-2.0-only입니다.

static vm_fault_t wp_page_copy(struct vm_fault *vmf)
{
    const bool unshare = vmf->flags & FAULT_FLAG_UNSHARE;
    struct vm_area_struct *vma = vmf->vma;
    struct mm_struct *mm = vma->vm_mm;
    struct page *old_page = vmf->page;
    struct page *new_page = NULL;
    pte_t entry;
    int page_copied = 0;
    struct mmu_notifier_range range;

    delayacct_wpcopy_start();

    if (unlikely(anon_vma_prepare(vma)))
        goto oom;

    if (is_zero_pfn(pte_pfn(vmf->orig_pte))) {
        new_page = alloc_zeroed_user_highpage_movable(vma,
                                  vmf->address);
        if (!new_page)
            goto oom;
    } else {
        new_page = alloc_page_vma(GFP_HIGHUSER_MOVABLE, vma,
                vmf->address);
        if (!new_page)
            goto oom;

        if (!__wp_page_copy_user(new_page, old_page, vmf)) {
            /*
             * COW failed, if the fault was solved by other,
             * it's fine. If not, userspace would re-fault on
             * the same address and we will handle the fault
             * from the second attempt.
             */
            put_page(new_page);
            if (old_page)
                put_page(old_page);

            delayacct_wpcopy_end();
            return 0;
        }
    }

    if (mem_cgroup_charge(page_folio(new_page), mm, GFP_KERNEL))
        goto oom_free_new;
    cgroup_throttle_swaprate(new_page, GFP_KERNEL);

    __SetPageUptodate(new_page);

    mmu_notifier_range_init(&range, MMU_NOTIFY_CLEAR, 0, vma, mm,
                vmf->address & PAGE_MASK,
                (vmf->address & PAGE_MASK) + PAGE_SIZE);
    mmu_notifier_invalidate_range_start(&range);

    /*
     * Re-check the pte - we dropped the lock
     */
    vmf->pte = pte_offset_map_lock(mm, vmf->pmd, vmf->address, &vmf->ptl);
    if (likely(pte_same(*vmf->pte, vmf->orig_pte))) {
        if (old_page) {
            if (!PageAnon(old_page)) {
                dec_mm_counter_fast(mm,
                        mm_counter_file(old_page));
                inc_mm_counter_fast(mm, MM_ANONPAGES);
            }
        } else {
            inc_mm_counter_fast(mm, MM_ANONPAGES);
        }
        flush_cache_page(vma, vmf->address, pte_pfn(vmf->orig_pte));
        entry = mk_pte(new_page, vma->vm_page_prot);
        entry = pte_sw_mkyoung(entry);
        if (unlikely(unshare)) {
            if (pte_soft_dirty(vmf->orig_pte))
                entry = pte_mksoft_dirty(entry);
            if (pte_uffd_wp(vmf->orig_pte))
                entry = pte_mkuffd_wp(entry);
        } else {
            entry = maybe_mkwrite(pte_mkdirty(entry), vma);
        }

        /*
         * Clear the pte entry and flush it first, before updating the
         * pte with the new entry, to keep TLBs on different CPUs in
         * sync. This code used to set the new PTE then flush TLBs, but
         * that left a window where the new PTE could be loaded into
         * some TLBs while the old PTE remains in others.
         */
        ptep_clear_flush_notify(vma, vmf->address, vmf->pte);
        page_add_new_anon_rmap(new_page, vma, vmf->address);
        lru_cache_add_inactive_or_unevictable(new_page, vma);
        /*
         * We call the notify macro here because, when using secondary
         * mmu page tables (such as kvm shadow page tables), we want the
         * new page to be mapped directly into the secondary page table.
         */
        BUG_ON(unshare && pte_write(entry));
        set_pte_at_notify(mm, vmf->address, vmf->pte, entry);
        update_mmu_cache(vma, vmf->address, vmf->pte);
        if (old_page) {
            /*
             * Only after switching the pte to the new page may
             * we remove the mapcount here. Otherwise another
             * process may come and find the rmap count decremented
             * before the pte is switched to the new page, and
             * "reuse" the old page writing into it while our pte
             * here still points into it and can be read by other
             * threads.
             *
             * The critical issue is to order this
             * page_remove_rmap with the ptp_clear_flush above.
             * Those stores are ordered by (if nothing else,)
             * the barrier present in the atomic_add_negative
             * in page_remove_rmap.
             *
             * Then the TLB flush in ptep_clear_flush ensures that
             * no process can access the old page before the
             * decremented mapcount is visible. And the old page
             * cannot be reused until after the decremented
             * mapcount is visible. So transitively, TLBs to
             * old page will be flushed before it can be reused.
             */
            page_remove_rmap(old_page, vma, false);
        }

        /* Free the old page.. */
        new_page = old_page;
        page_copied = 1;
    } else {
        update_mmu_tlb(vma, vmf->address, vmf->pte);
    }

    if (new_page)
        put_page(new_page);

    pte_unmap_unlock(vmf->pte, vmf->ptl);
    /*
     * No need to double call mmu_notifier->invalidate_range() callback as
     * the above ptep_clear_flush_notify() did already call it.
     */
    mmu_notifier_invalidate_range_only_end(&range);
    if (old_page) {
        if (page_copied)
            free_swap_cache(old_page);
        put_page(old_page);
    }

    delayacct_wpcopy_end();
    return (page_copied && !unshare) ? VM_FAULT_WRITE : 0;
oom_free_new:
    put_page(new_page);
oom:
    if (old_page)
        put_page(old_page);

    delayacct_wpcopy_end();
    return VM_FAULT_OOM;
}

ptep_clear_flush()

Linux v6.0 mm/pgtable-generic.c, SPDX GPL-2.0입니다.

pte_t ptep_clear_flush(struct vm_area_struct *vma, unsigned long address,
               pte_t *ptep)
{
    struct mm_struct *mm = (vma)->vm_mm;
    pte_t pte;
    pte = ptep_get_and_clear(mm, address, ptep);
    if (pte_accessible(mm, pte))
        flush_tlb_page(vma, address);
    return pte;
}

pte_accessible()

Linux v6.0 arch/x86/include/asm/pgtable.h, SPDX GPL-2.0입니다.

static inline bool pte_accessible(struct mm_struct *mm, pte_t a)
{
    if (pte_flags(a) & _PAGE_PRESENT)
        return true;

    if ((pte_flags(a) & _PAGE_PROTNONE) &&
            atomic_read(&mm->tlb_flush_pending))
        return true;

    return false;
}

참고 자료

Lorenzo Stoakes, The Linux Memory Manager, Chapter 6 §6.9.2–§6.9.5. 현재 reuse helper와 copy 순서는 Linux v6.18 mm/memory.c, pin과 exclusive의 관계는 pin_user_pages 문서를 참고하실 수 있습니다.