Compound page의 head와 tail은 같은 일을 하지 않습니다

연속한 8 page를 할당받았다고 해서 descriptor 여덟 개가 자동으로 하나의 compound page가 되는 것은 아닙니다. 연속 allocation은 물리 범위를 확보하는 일이고, compound 준비는 그 범위를 하나의 수명 단위로 취급하도록 metadata를 구성하는 일입니다. 둘을 섞으면 tail에서 refcount를 읽거나 잘못된 order로 반환하는 실수를 하기 쉽습니다.

여기서는 Linux v6.1의 compound 배치를 먼저 살펴본 뒤, 같은 struct page 공간을 사용하는 page table·network·device 용도를 구별하겠습니다.

연속 범위에 compound 계약을 더합니다

일반 alloc_pages()의 order는 연속한 base page의 수를 정합니다. prep_new_page()는 order가 양수이고 __GFP_COMP가 설정된 경우 prep_compound_page()를 통해 compound metadata를 준비합니다. folio allocation wrapper처럼 이 요청을 더하는 API가 있는 반면, 모든 higher-order allocation이 같은 계약을 갖지는 않습니다.

head에는 PG_head를 설정하고 tail에는 head를 찾을 수 있는 값을 넣습니다. set_compound_head()가 저장하는 값은 head pointer에 낮은 bit 0 표시를 더한 형태입니다. 높은 bit가 tail 표시는 아닙니다. PageTail()의 tag 판별과 PageHead()의 head flag 판별을 구별하며 PageCompound()는 두 역할을 포괄합니다. compound_head()는 이 표현을 판별해 head를 얻습니다. descriptor의 역할을 확인하지 않고 첫 word를 일반 pointer로 읽으면 이 encoding을 잘못 해석하게 됩니다.

tail 초기화는 head 주소만 기록하는 것으로 끝나지 않습니다. tail에 허용되지 않는 mapping 사용을 잡아내기 위한 값 등 debug 관련 준비도 있습니다. 반대로 first tail에는 order와 destructor 선택, compound mapping 관련 count처럼 전체 범위를 대표하는 정보가 들어갑니다. head에 모든 정보가 모여 있을 것이라는 기대와 실제 배치가 다른 이유입니다.

그 debug 값이 TAIL_MAPPING입니다. v6.1의 prep_compound_tail()은 해당 tail의 mapping에 이를 넣고, set_compound_head()로 head tag를 기록한 뒤, set_page_private()로 private 값을 0으로 준비합니다. head pointer와 낮은 tag를 기록할 때는 WRITE_ONCE()를 사용합니다. 이는 해당 저장을 compiler가 임의로 분할·중복하는 문제를 제어하기 위한 것이지, tail 초기화 전체를 다른 CPU에 publish하는 protocol이나 참조 획득을 대신하는 lock은 아닙니다.

첫 tail과 둘째 tail의 존재 조건이 다릅니다

v6.1의 first tail에는 compound_order, compound_dtor, compound_mapcount, pin tracking에 쓰이는 정보와 configuration에 따른 page 개수 표현이 배치됩니다. descriptor 하나의 제한된 공간을 활용하기 위해 전체 단위의 metadata 일부를 tail에 둡니다. helper가 필요한 위치를 선택하므로 호출자가 offset을 추측할 필요는 없습니다.

일부 큰 compound page의 second tail에는 deferred split list 연결이 들어갑니다. 다만 order-1은 head 한 개와 tail 한 개뿐입니다. compound라면 항상 second tail이 있다고 일반화할 수 없습니다. 특정 huge-page 처리에서 접근하는 필드와 모든 compound allocation에 적용되는 규칙을 구별해야 합니다.

compound head와 tail descriptor의 역할을 분리한 그림입니다. Linux v6.1의 대표적인 배치를 설명하며 configuration별 추가 필드와 모든 order의 예외를 한 그림에 넣지는 않았습니다.
compound head와 tail descriptor의 역할을 분리한 그림입니다. Linux v6.1의 대표적인 배치를 설명하며 configuration별 추가 필드와 모든 order의 예외를 한 그림에 넣지는 않았습니다.

compound mapcount도 단순히 모든 subpage mapcount를 대체하는 하나의 숫자가 아닙니다. 큰 mapping에 대한 장부와 개별 base-page mapping 장부를 함께 고려하는 경로가 있습니다. raw 값의 초기 표현과 helper가 반환하는 의미도 다르므로, first tail의 counter만 보고 현재 mapping 총수를 계산하면 잘못된 결론에 도달할 수 있습니다.

head 준비에서는 일반 compound용 COMPOUND_PAGE_DTOR를 선택하고 compound mapcount를 -1, pincount를 0으로 초기화합니다. set_compound_order()가 기록하는 64-bit 배치의 compound_nr는 order 자체가 아니라 2^order에 해당하는 base page 개수를 나타냅니다. pin count는 해당 large-page pin tracking을 위한 장부이며 ordinary reference나 모든 subpage mapping 개수와 같은 숫자가 아닙니다.

prep_compound_page()의 준비 순서도 이 배치를 따릅니다. 먼저 __SetPageHead()로 head 표시를 세우고, index 1부터 2^order - 1까지 prep_compound_tail()로 tail을 초기화한 뒤, 마지막에 prep_compound_head()로 전체 단위의 metadata를 준비합니다. compound_mapcount_ptr()와 compound_pincount_ptr()가 반환하는 주소는 head 자체의 counter가 아니라 page[1] 안의 해당 member입니다. page_deferred_list()는 다른 위치인 page[2]를 선택하므로 second tail이 존재하고 그 용도로 준비된 단위에서만 사용해야 합니다.

최대 order 역시 build에 고정됩니다. v6.1에서 MAX_ORDER가 11인 configuration은 order 0부터 10까지의 buddy block을 다루지만, 이 숫자가 architecture를 초월한 규격은 아닙니다. HugeTLB가 다루는 단위와 일반 buddy의 최대 order를 무조건 같다고 보아서도 안 됩니다.

마지막 참조 뒤에는 용도별 destructor가 있습니다

compound 수명은 tail을 개별 ordinary page처럼 해제하는 방식으로 끝내지 않습니다. head를 기준으로 참조를 다루고, 마지막 참조가 사라질 때 어떤 종류의 large allocation인지에 맞는 정리를 수행합니다. v6.1의 compound_page_dtors와 destroy_large_folio()를 보면 일반 compound, HugeTLB 등 서로 다른 반환 경로를 선택하는 구조를 확인할 수 있습니다.

enum compound_dtor_id와 compound_page_dtors[]는 같은 index 순서를 유지해야 합니다. NULL_COMPOUND_DTOR의 entry는 NULL이고, 일반 compound는 free_compound_page에 대응합니다. CONFIG_HUGETLB_PAGE와 CONFIG_TRANSPARENT_HUGEPAGE가 켜진 build에서는 각각 free_huge_page와 free_transhuge_page용 entry가 추가됩니다. set_compound_page_dtor()는 index가 NR_COMPOUND_DTORS 범위를 벗어나지 않는지 검사합니다. 존재하는 entry 중 아무 것이나 고르는 것이 아니라 allocation의 준비·반환 protocol에 맞는 종류를 기록해야 합니다.

destructor index가 있다는 사실은 임의 시점에 destructor를 호출해도 된다는 뜻이 아닙니다. mapping, LRU, memcg와 subsystem의 소유권을 정리하는 protocol이 앞에 있습니다. tail에서 얻은 pointer를 head로 바꾼다는 것은 표현의 정규화일 뿐, 새 참조 획득이나 동시 free 방지를 자동으로 수행하는 연산이 아닙니다.

Page table descriptor는 lock과 소유자를 담습니다

같은 union을 page-table page로 쓰면 의미가 바뀝니다. v6.1의 배치에는 huge PMD를 처리하면서 보관하는 fallback PTE table과 연결되는 pmd_huge_pte, x86 PGD의 소유 mm를 추적하는 pt_mm, PowerPC fragment reference를 위한 pt_frag_refcount, page-table lock용 ptl 등이 나타납니다. 서로 다른 architecture의 분기를 모두 한 page에서 동시에 사용하는 구조가 아닙니다.

split page-table lock은 작은 page-table 단위로 동기화를 분산하려는 장치입니다. 어떤 level에서 별도 lock을 쓰는지는 architecture와 configuration에 달려 있습니다. ALLOC_SPLIT_PTLOCKS가 필요한 경우 ptl이 가리키는 lock을 따로 마련하며, spinlock 크기와 debug configuration도 저장 방식에 영향을 줍니다. 따라서 구조체 안의 lock 자리만 보아 lock 객체의 allocation과 free가 필요 없다고 결론 내리면 안 됩니다.

v6.1의 USE_SPLIT_PTE_PTLOCKS는 NR_CPUS >= CONFIG_SPLIT_PTLOCK_CPUS라는 build 조건입니다. 지금 online인 CPU 수가 아니라 build가 지원하는 CPU 수와 threshold를 비교합니다. PMD 쪽의 USE_SPLIT_PMD_PTLOCKS는 이 조건에 더해 CONFIG_ARCH_ENABLE_SPLIT_PMD_PTLOCK도 요구합니다. 해당 level에서 split lock을 쓰지 않으면 mm->page_table_lock을 공유하는 경로를 사용합니다. 한편 ALLOC_SPLIT_PTLOCKS는 split 여부 자체가 아니라 spinlock이 한 word보다 커서 별도 storage가 필요한지를 나타냅니다. 두 종류의 configuration 질문을 구별해야 합니다.

page-table 배치의 padding과 비워 두는 위치도 의미가 있습니다. tail tag가 거짓으로 보이지 않도록 하고, 일반 cache·anonymous page의 mapping과 겹치는 자리를 잘못된 pointer로 남기지 않기 위한 제약입니다. architecture별 pt_mm와 fragment reference를 모두 한꺼번에 사용하는 것으로 읽지 않아야 합니다.

이 배치는 버전 의존적입니다. v6.18에서는 page-table 역할을 나타내는 ptdesc 표현을 함께 읽어야 합니다. v6.1의 union field 목록을 최신 crash dump의 고정 offset 설명으로 사용하지 않는 편이 정확합니다.

Network page와 device page는 재사용 조건도 다릅니다

network의 page_pool 용도에서는 pp_magic이 pool에서 온 page인지 판별하는 표지이고 pp는 소속 pool을 가리킵니다. pp_magic의 낮은 bit도 tail 판별과 충돌하지 않아야 합니다. DMA address 저장 공간과, 환경에 따라 dma_addr_upper 또는 pp_frag_count에 사용하는 union도 있습니다. v6.1에서는 32-bit architecture에서 64-bit DMA address가 필요하면 그 상위 부분을 저장해야 하므로 같은 공간을 fragment count로 함께 사용할 수 없습니다. 단순히 CPU가 32-bit인지 64-bit인지만으로 모든 사용 조건을 나누면 안 됩니다. mapping과 겹치는 padding은 0을 유지해 page-cache entry로 오인되지 않도록 합니다. pool pointer가 있다고 해서 일반 page 반환과 pool recycle을 바꿔 써도 되는 것은 아닙니다. DMA와 fragment의 소유권까지 pool의 반환 protocol이 다룹니다.

ZONE_DEVICE에서는 pgmap과 device-specific 자료를 담는 zone_device_data가 중요합니다. device-private memory로 migration하는 동안에는 원래 anonymous 또는 page-cache page의 mapping, index, private 의미를 유지해야 합니다. device page라는 이유만으로 union 전체를 아무 용도로 써도 된다는 뜻이 아닙니다. 일반 RAM으로 CPU가 직접 접근할 수 있는지와 descriptor가 존재하는지도 다른 조건입니다.

이 세 field를 유지하는 경우가 migration에만 있는 것은 아닙니다. v6.1의 MEMORY_DEVICE_FS_DAX page도 pmem-backed DAX file이 mapping될 때 같은 mapping, index, private 위치를 사용합니다. 위쪽의 pgmap·zone_device_data와 아래쪽의 file·anonymous metadata가 어떻게 공존하는지를 보아야 하며, pgmap pointer의 낮은 bit 역시 tail tag로 오인되지 않도록 정렬되어야 합니다.

마지막으로 union의 rcu_head는 RCU callback을 연결할 수 있는 저장 공간입니다. 이 필드가 있다는 사실만으로 모든 page 반환이 RCU grace period를 기다리거나, 모든 reader가 RCU-safe인 것은 아닙니다. 해당 subsystem이 publication, reader 접근, callback과 최종 반환을 하나의 protocol로 구성해야 합니다. compound·page table·network·device 모두에서 먼저 확인할 것은 필드 이름보다 현재 역할과 소유권입니다.

Compound metadata의 실제 준비와 접근 코드입니다

먼저 Linux v6.1의 page-flags.h의 head 변환과 tail 판별을 보겠습니다. 아래는 해당 configuration 분기와 함수·macro의 전문이며 SPDX는 GPL-2.0입니다. 들여쓰기와 긴 선언의 줄바꿈만 정리했습니다.

#ifdef CONFIG_HUGETLB_PAGE_OPTIMIZE_VMEMMAP
DECLARE_STATIC_KEY_FALSE(hugetlb_optimize_vmemmap_key);

/*
 * Return the real head page struct iff the @page is a fake head page, otherwise
 * return the @page itself. See Documentation/mm/vmemmap_dedup.rst.
 */
static __always_inline const struct page *
page_fixed_fake_head(const struct page *page)
{
    if (!static_branch_unlikely(&hugetlb_optimize_vmemmap_key))
        return page;

    /*
     * Only addresses aligned with PAGE_SIZE of struct page may be fake head
     * struct page. The alignment check aims to avoid access the fields (
     * e.g. compound_head) of the @page[1]. It can avoid touch a (possibly)
     * cold cacheline in some cases.
     */
    if (IS_ALIGNED((unsigned long)page, PAGE_SIZE) &&
        test_bit(PG_head, &page->flags)) {
        /*
         * We can safely access the field of the @page[1] with PG_head
         * because the @page is a compound page composed with at least
         * two contiguous pages.
         */
        unsigned long head = READ_ONCE(page[1].compound_head);

        if (likely(head & 1))
            return (const struct page *)(head - 1);
    }
    return page;
}
#else
static inline const struct page *page_fixed_fake_head(const struct page *page)
{
    return page;
}
#endif

static __always_inline int page_is_fake_head(struct page *page)
{
    return page_fixed_fake_head(page) != page;
}

static inline unsigned long _compound_head(const struct page *page)
{
    unsigned long head = READ_ONCE(page->compound_head);

    if (unlikely(head & 1))
        return head - 1;
    return (unsigned long)page_fixed_fake_head(page);
}

#define compound_head(page)    ((typeof(page))_compound_head(page))
static __always_inline int PageTail(struct page *page)
{
    return READ_ONCE(page->compound_head) & 1 || page_is_fake_head(page);
}

static __always_inline int PageCompound(struct page *page)
{
    return test_bit(PG_head, &page->flags) ||
           READ_ONCE(page->compound_head) & 1;
}

ordinary tail의 bit 0 표현뿐 아니라 HugeTLB vmemmap 최적화의 fake head도 고려합니다. 최적화 configuration과 runtime static key가 모두 조건이며, 맞지 않는 descriptor는 그대로 반환합니다. 이 특수 표현을 일반 RAM data가 중복 공유된다는 뜻으로 해석하면 안 됩니다.

head 표시를 기록하는 set_compound_head() 전문입니다.

static __always_inline void set_compound_head(struct page *page, struct page *head)
{
    WRITE_ONCE(page->compound_head, (unsigned long)head + 1);
}

다음 세 함수는 mm/page_alloc.c의 compound 준비 전문입니다. 이 파일의 SPDX는 GPL-2.0-only입니다.

static void prep_compound_head(struct page *page, unsigned int order)
{
    set_compound_page_dtor(page, COMPOUND_PAGE_DTOR);
    set_compound_order(page, order);
    atomic_set(compound_mapcount_ptr(page), -1);
    atomic_set(compound_pincount_ptr(page), 0);
}

static void prep_compound_tail(struct page *head, int tail_idx)
{
    struct page *p = head + tail_idx;

    p->mapping = TAIL_MAPPING;
    set_compound_head(p, head);
    set_page_private(p, 0);
}

void prep_compound_page(struct page *page, unsigned int order)
{
    int i;
    int nr_pages = 1 << order;

    __SetPageHead(page);
    for (i = 1; i < nr_pages; i++)
        prep_compound_tail(page, i);

    prep_compound_head(page, order);
}

tail의 mapping과 private도 준비한 뒤 head의 전체 범위 장부를 초기화합니다. 이 코드를 이미 사용 중인 임의 page에 적용해 compound로 바꿔도 된다는 뜻은 아닙니다. allocator가 해당 범위를 배타적으로 준비하는 단계의 계약입니다.

위 v6.1의 prep_compound_tail()에는 set_page_private(p, 0)도 들어 있습니다. 책의 예제 형태와 같은 body라고 단정하거나 생략된 줄을 복원한 code로 제시하지 않습니다. 책에서 사용한 정확한 upstream tag는 미확인이고, 여기서는 명시한 공식 tag의 전체 구현을 기준으로 설명합니다.

counter 주소를 선택하는 두 함수는 mm_types.h, deferred list를 선택하는 함수는 THP 분기의 huge_mm.h에 있는 전문입니다. 두 header의 SPDX는 GPL-2.0입니다.

static inline atomic_t *compound_mapcount_ptr(struct page *page)
{
    return &page[1].compound_mapcount;
}

static inline atomic_t *compound_pincount_ptr(struct page *page)
{
    return &page[1].compound_pincount;
}
static inline struct list_head *page_deferred_list(struct page *page)
{
    /*
     * See organization of tail pages of compound page in
     * "struct page" definition.
     */
    return &page[2].deferred_list;
}

이 포인터 계산에는 범위 검사나 reference 획득이 없습니다. first tail과 second tail이 실제로 존재하고 해당 용도로 준비되었어야 하며, counter가 atomic type이라는 사실도 객체 수명 보호를 대신하지 않습니다.

마지막으로 mm.h의 destructor ID·setter와 order setter 전문입니다. SPDX는 GPL-2.0입니다.

typedef void compound_page_dtor(struct page *);

/* Keep the enum in sync with compound_page_dtors array in mm/page_alloc.c */
enum compound_dtor_id {
    NULL_COMPOUND_DTOR,
    COMPOUND_PAGE_DTOR,
#ifdef CONFIG_HUGETLB_PAGE
    HUGETLB_PAGE_DTOR,
#endif
#ifdef CONFIG_TRANSPARENT_HUGEPAGE
    TRANSHUGE_PAGE_DTOR,
#endif
    NR_COMPOUND_DTORS,
};
extern compound_page_dtor * const compound_page_dtors[NR_COMPOUND_DTORS];

static inline void set_compound_page_dtor(struct page *page,
        enum compound_dtor_id compound_dtor)
{
    VM_BUG_ON_PAGE(compound_dtor >= NR_COMPOUND_DTORS, page);
    page[1].compound_dtor = compound_dtor;
}
static inline void set_compound_order(struct page *page, unsigned int order)
{
    page[1].compound_order = order;
#ifdef CONFIG_64BIT
    page[1].compound_nr = 1U << order;
#endif
}

아래 대응 배열은 mm/page_alloc.c의 전문이며 SPDX는 GPL-2.0-only입니다.

compound_page_dtor * const compound_page_dtors[NR_COMPOUND_DTORS] = {
    [NULL_COMPOUND_DTOR] = NULL,
    [COMPOUND_PAGE_DTOR] = free_compound_page,
#ifdef CONFIG_HUGETLB_PAGE
    [HUGETLB_PAGE_DTOR] = free_huge_page,
#endif
#ifdef CONFIG_TRANSPARENT_HUGEPAGE
    [TRANSHUGE_PAGE_DTOR] = free_transhuge_page,
#endif
};

enum과 배열은 configuration 분기까지 같은 순서를 유지합니다. NULL_COMPOUND_DTOR는 호출할 수 있는 일반 destructor가 아닙니다. 실제 최종 반환은 reference·LRU·memcg 등 역할별 정리와 이어지며, free 경로의 destroy_large_folio()에서 이 배열을 사용하는 전문을 확인할 수 있습니다. page table·network·device overlay를 포함한 전체 descriptor 선언은 struct page 글에 있습니다.

참고 자료