Per-CPU 변수

Linux 커널의 Per-CPU 변수는 각 CPU가 독립적인 데이터 복사본을 가지도록 하여 캐시라인 바운싱과 false sharing을 줄이고 lock contention을 제거하는 고성능 기법입니다. 이 문서는 DEFINE_PER_CPU, this_cpu_* fast path, alloc_percpu 기반 동적 할당, 전역 집계 시점의 동기화 전략, 네트워크/메모리 서브시스템 사례와 NUMA 환경 튜닝 포인트까지 상세히 설명합니다.

일상 비유: Per-CPU 변수는 개인 사물함과 비슷합니다. 공용 사물함(공유 변수)은 자물쇠(lock)로 보호해야 하지만, 각자의 사물함(per-CPU 변수)은 자물쇠 없이 빠르게 접근할 수 있습니다.

핵심 요약

  • 독립 복사본 — 각 CPU가 자신만의 변수 복사본을 가집니다.
  • Lock-free — 동일 CPU 내에서는 동기화가 필요 없습니다.
  • 캐시 효율 — False sharing이 없어 캐시 성능이 극대화됩니다.
  • 빠른 접근 — this_cpu_* 연산은 원자적이면서도 매우 빠릅니다.
  • 통계 집계 — 네트워크 패킷 수, 메모리 할당 통계 등에 최적입니다.

단계별 이해

  1. 핵심 요소 확인
    이 문서에서 다루는 자료구조/API를 먼저 정리합니다.
  2. 처리 흐름 추적
    요청 시작부터 완료까지 실행 경로를 순서대로 확인합니다.
  3. 문제 지점 점검
    실패 경로, 경합 구간, 성능 병목을 체크합니다.

개요 (Overview)

Per-CPU 변수는 SMP(Symmetric Multi-Processing) 시스템에서 성능을 극대화하는 핵심 기법입니다:

공유 변수 (False Sharing) 공유 캐시라인 counter (64 bytes) CPU0 CPU1 CPU2 CPU3 MESI: Invalidate → 캐시라인 무효화 모든 CPU가 재로드 필요 Per-CPU 변수 (독립 캐시라인) CPU0 캐시라인 counter[cpu0] CPU1 캐시라인 counter[cpu1] CPU2 캐시라인 counter[cpu2] CPU0 CPU1 CPU2 각 CPU 독립 접근 → 충돌 없음 캐시라인 무효화 불필요 캐시라인 충돌 독립 캐시라인
공유 변수는 여러 CPU가 같은 캐시라인을 두고 경쟁해 MESI 무효화가 발생하지만, per-CPU 변수는 CPU마다 독립 캐시라인을 사용합니다.
.data..percpu (링커 섹션 원본) CPU0 per-CPU 영역 offset[0] = 0x0000 CPU1 per-CPU 영역 offset[1] = 0x8000 CPU2 per-CPU 영역 offset[2] = 0x10000 __per_cpu_offset[] [0] = 0x0000 [1] = 0x8000 [2] = 0x10000 ... this_cpu_ptr(var) = &var + offset[cpu_id] CPU별 독립 영역 링커 원본 섹션 복사/오프셋 참조
부팅 시 setup_per_cpu_areas().data..percpu를 각 CPU별로 복사하며, __per_cpu_offset[]에 오프셋을 저장합니다.
ℹ️

실사용 빈도: 커널 통계 카운터의 90% 이상이 per-CPU 변수를 사용합니다. 네트워크 패킷 카운트, 메모리 할당 통계, 스케줄러 런큐, SLAB 할당자 등 모든 고성능 서브시스템에서 필수적입니다.

내부 구조 (Internals)

부팅 과정에서 커널은 각 CPU마다 .data..percpu 섹션의 복사본을 만들고, 각 복사본의 시작 주소를 __per_cpu_offset 배열에 저장합니다:

/* arch/x86/kernel/setup_percpu.c (개념 요약) */

/* 부팅 시 각 CPU에 .data..percpu 섹션 복사 */
void __init setup_per_cpu_areas(void)
{
    int cpu;
    for_each_possible_cpu(cpu) {
        /* NUMA 로컬 노드에 섹션 복사본 할당 */
        void *ptr = alloc_percpu_area(cpu);
        memcpy(ptr, __per_cpu_load, __per_cpu_size);
        __per_cpu_offset[cpu] = ptr - ((void *)&__per_cpu_start);
    }
}

/* this_cpu_ptr() 동작 원리 */
#define this_cpu_ptr(ptr) \
    ((typeof(ptr))(((void *)(ptr)) + __per_cpu_offset[smp_processor_id()]))

/* 예: CPU2의 my_counter 주소 */
/* = &my_counter + __per_cpu_offset[2]  */

__per_cpu_offset[NR_CPUS] 배열이 각 CPU의 per-CPU 영역 오프셋을 저장하며, this_cpu_ptr()은 단순 포인터 덧셈으로 현재 CPU의 변수 주소를 계산합니다.

기본 API (Basic API)

DEFINE_PER_CPU 매크로

/* include/linux/percpu-defs.h */
#define DEFINE_PER_CPU(type, name) \
    __PCPU_ATTRS(sec) __typeof__(type) name

/* 사용 예: 정적 per-CPU 변수 선언 */
DEFINE_PER_CPU(unsigned long, my_counter);

/* 배열도 가능 */
DEFINE_PER_CPU(int[10], my_array);

/* 구조체도 가능 */
struct stats {
    unsigned long count;
    unsigned long bytes;
};
DEFINE_PER_CPU(struct stats, net_stats);

DEFINE_PER_CPU 변체

매크로용도특성
DEFINE_PER_CPU(type, name)일반 per-CPU 변수기본 정렬, 읽기/쓰기 모두 빠름
DEFINE_PER_CPU_READ_MOSTLY(type, name)읽기 전용에 가까운 변수읽기 캐시라인 최적화, 쓰기는 드묾
DEFINE_PER_CPU_SHARED_ALIGNED(type, name)False sharing 명시적 방지캐시라인 경계 정렬 강제
DEFINE_PER_CPU_ALIGNED(type, name)정렬 보장 필요 시SMP_CACHE_BYTES 단위 정렬
DEFINE_PER_CPU_PAGE_ALIGNED(type, name)페이지 정렬 필요 시PAGE_SIZE 단위 정렬
/* READ_MOSTLY: 부팅 시 설정되고 이후 읽기만 하는 값 */
DEFINE_PER_CPU_READ_MOSTLY(struct cpuinfo_x86, cpu_info);

/* SHARED_ALIGNED: 같은 캐시라인에 들어갈 수 있는 변수들을 분리 */
/* 예: 두 hot counter가 같은 캐시라인이면 False Sharing 발생 */
DEFINE_PER_CPU_SHARED_ALIGNED(struct net_device_stats, dev_stats);
/* → 각 CPU의 dev_stats는 서로 다른 캐시라인에 배치됨 */

접근 API (Access API)

/* 현재 CPU의 변수 접근 (preemption 비활성화 필요) */
unsigned long *ptr = get_cpu_var(my_counter);
(*ptr)++;
put_cpu_var(my_counter);  /* preemption 재활성화 */

/* 특정 CPU의 변수 접근 */
unsigned long *cpu5_counter = per_cpu_ptr(&my_counter, 5);

/* 원자적 연산 (가장 빠름!) */
this_cpu_inc(my_counter);      /* counter++ */
this_cpu_add(my_counter, 10);  /* counter += 10 */
this_cpu_read(my_counter);     /* 값 읽기 */
this_cpu_write(my_counter, 0); /* 값 쓰기 */

this_cpu_* 연산 (this_cpu_* Operations)

함수 기능 특징
this_cpu_read(var) 값 읽기 원자적, preemption-safe
this_cpu_write(var, val) 값 쓰기 원자적
this_cpu_add(var, val) 덧셈 원자적, lock-free
this_cpu_inc(var) 1 증가 this_cpu_add(var, 1)
this_cpu_dec(var) 1 감소 this_cpu_add(var, -1)
this_cpu_and(var, val) 비트 AND 원자적
this_cpu_or(var, val) 비트 OR 원자적
this_cpu_xchg(var, val) 교환 이전 값 반환
this_cpu_cmpxchg(var, old, new) 조건부 교환 CAS 연산
💡

성능: this_cpu_* 연산은 x86에서 단일 명령어로 컴파일됩니다. 예를 들어 this_cpu_inc(counter)incl %gs:offset 하나로 끝나며, 이는 lock prefix가 붙은 lock incl보다 10배 이상 빠릅니다.

raw_cpu_* vs this_cpu_*

this_cpu_*raw_cpu_*는 모두 per-CPU 변수를 조작하지만, 사용 컨텍스트가 다릅니다:

함수 사용 컨텍스트 Preemption 상태 성능
this_cpu_inc(var) 일반 태스크, 소프트IRQ 자동 보장 (아키텍처 레벨) 빠름
raw_cpu_inc(var) IRQ 핸들러, NMI, preempt off 구간 이미 비활성화 전제 약간 더 빠름
/* 인터럽트 핸들러 — preemption 이미 비활성화 */
irqreturn_t my_irq_handler(int irq, void *dev)
{
    raw_cpu_inc(irq_counter);  /* IRQ 컨텍스트: raw_cpu_* 사용 */
    return IRQ_HANDLED;
}

/* 일반 태스크 컨텍스트 */
void handle_packet(void)
{
    this_cpu_inc(pkt_counter);  /* 태스크 컨텍스트: this_cpu_* 사용 */
}

동적 할당 (Dynamic Allocation)

Per-CPU 정적 할당 vs 동적 할당 정적 할당 (DEFINE_PER_CPU) 링크 시 .data..percpu 섹션에 배치 .data..percpu 원본 (커널 이미지) setup_per_cpu_areas() 에서 CPU별 복사: CPU 0 복사 CPU 1 복사 CPU N 복사 장점: • 부팅 시 자동 초기화 (0으로) • 동적 할당 오버헤드 없음 • 컴파일 시 크기 확정 DEFINE_PER_CPU(int, counter); 동적 할당 (alloc_percpu) 런타임에 vmalloc 영역에서 per-CPU 청크 할당 pcpu_chunk (vmalloc 영역) 각 CPU에 독립 청크 배분: CPU 0 청크 CPU 1 청크 CPU N 청크 장점: • 런타임 크기 결정 (모듈에서 사용) • 모듈 언로드 시 해제 가능 • 정렬 파라미터 지정 가능 ptr = alloc_percpu(int);
정적 할당은 커널 이미지에 포함되어 부팅 시 자동 복사되고, 동적 할당은 런타임에 vmalloc 기반 percpu 청크에서 할당됩니다. 모듈에서는 동적 할당을 사용해야 합니다.
/* include/linux/percpu.h */

/* per-CPU 메모리 할당 */
void __percpu *alloc_percpu(type);

/* 정렬 지정 할당 */
void __percpu *__alloc_percpu(size_t size, size_t align);

/* 해제 */
void free_percpu(void __percpu *ptr);

/* 사용 예 */
struct my_data {
    unsigned long count;
    unsigned long bytes;
};

struct my_data __percpu *stats;

stats = alloc_percpu(struct my_data);
if (!stats)
    return -ENOMEM;

/* 현재 CPU의 데이터 접근 */
struct my_data *ptr = this_cpu_ptr(stats);
ptr->count++;

/* 정리 */
free_percpu(stats);

NUMA 인식 할당

NUMA 시스템에서 per-CPU 변수는 각 CPU가 속한 로컬 NUMA 노드에 메모리를 배치하여 cross-node 접근 비용을 최소화합니다. 일반 alloc_percpu()는 기본 GFP 플래그를 사용하지만, GFP 플래그를 명시하면 메모리 부족 동작을 제어할 수 있습니다:

/* include/linux/percpu.h */

/* GFP 플래그 지정 저수준 할당 */
void __percpu *__alloc_percpu_gfp(size_t size, size_t align, gfp_t gfp);

/* 타입 기반 GFP 할당 (권장) */
#define alloc_percpu_gfp(type, gfp) \
    (typeof(type) __percpu *)__alloc_percpu_gfp(sizeof(type), \
                                     __alignof__(type), gfp)

/* NUMA 친화적 할당: GFP_KERNEL로 로컬 노드 우선 */
struct my_data __percpu *stats;
stats = alloc_percpu_gfp(struct my_data, GFP_KERNEL);
if (!stats)
    return -ENOMEM;

/* 각 CPU의 per-CPU 데이터는 해당 CPU의 NUMA 노드에 배치됨 */
💡

NUMA 팁: NUMA 시스템에서 cross-node 메모리 접근은 로컬 접근보다 2~4배 느립니다. alloc_percpu_gfp(type, GFP_KERNEL)을 사용하면 커널이 각 CPU의 로컬 NUMA 노드에 메모리를 배치하여 이 비용을 회피합니다.

실사용 사례 (Real-World Usage)

네트워크 통계

/* net/core/dev.c */
struct pcpu_lstats {
    u64 packets;
    u64 bytes;
    struct u64_stats_sync syncp;
};

struct net_device {
    struct pcpu_lstats __percpu *lstats;
    /* ... */
};

/* 패킷 수신 시 */
void update_rx_stats(struct net_device *dev, int len)
{
    struct pcpu_lstats *stats = this_cpu_ptr(dev->lstats);

    u64_stats_update_begin(&stats->syncp);
    stats->packets++;
    stats->bytes += len;
    u64_stats_update_end(&stats->syncp);
}

메모리 할당자

/* mm/slab.h - SLAB per-CPU 캐시 */
struct kmem_cache_cpu {
    void **freelist;
    unsigned long tid;
    struct page *page;
};

struct kmem_cache {
    struct kmem_cache_cpu __percpu *cpu_slab;
    /* ... */
};

/* 빠른 할당 경로 */
void *kmem_cache_alloc(struct kmem_cache *s, gfp_t flags)
{
    struct kmem_cache_cpu *c = this_cpu_ptr(s->cpu_slab);
    void *object;

    object = c->freelist;
    if (object) {
        c->freelist = get_freepointer(s, object);
        return object;
    }

    return __slab_alloc(s, flags, c);
}

전역 집계 동기화 (Global Aggregation)

per-CPU 변수를 전체 CPU에 걸쳐 집계할 때는 일관성과 CPU 핫플러그를 고려해야 합니다.

기본 집계 패턴

단순 정수 카운터는 for_each_possible_cpu()per_cpu()로 합산합니다:

unsigned long total = 0;
int cpu;

for_each_possible_cpu(cpu)
    total += per_cpu(my_counter, cpu);

pr_info("total = %lu\n", total);

64비트 통계 안전 집계

32비트 아키텍처에서 u64를 읽으면 중간에 갱신이 끼어들 수 있습니다. u64_stats_sync와 시퀀스 카운터를 이용해 tearing을 방지합니다:

/* 쓰기 측 (패킷 수신 등) */
struct pcpu_lstats *s = this_cpu_ptr(lstats);
u64_stats_update_begin(&s->syncp);
s->packets++;
s->bytes += len;
u64_stats_update_end(&s->syncp);

/* 읽기 측 (전역 집계) */
u64 sum_packets = 0, sum_bytes = 0;
int cpu;

for_each_possible_cpu(cpu) {
    struct pcpu_lstats *s = per_cpu_ptr(lstats, cpu);
    unsigned int start;
    u64 p, b;

    do {
        start = u64_stats_fetch_begin(&s->syncp);
        p = s->packets;
        b = s->bytes;
    } while (u64_stats_fetch_retry(&s->syncp, start));

    sum_packets += p;
    sum_bytes   += b;
}

CPU 핫플러그 고려

집계 도중 CPU가 오프라인이 되면 for_each_possible_cpu()는 오프라인 CPU도 순회하므로 주의가 필요합니다. 온라인 CPU만 대상으로 하거나 핫플러그를 잠금으로 보호합니다:

/* 온라인 CPU만 순회 + 핫플러그 방지 */
cpus_read_lock();          /* CPU 핫플러그 비활성화 */
for_each_online_cpu(cpu) {
    total += per_cpu(my_counter, cpu);
}
cpus_read_unlock();
⚠️

일관성 주의: 집계 도중에도 다른 CPU가 값을 변경할 수 있으므로, 읽은 합계는 근사값입니다. 엄격한 실시간 일관성이 요구되는 경우(예: 결제 금액, 보안 카운터)에는 per-CPU 변수가 부적합하며, 대신 atomic 변수나 seqlock을 고려하십시오.

성능 최적화 (Performance)

방법 처리량 (ops/sec) 지연시간 (ns)
공유 변수 + spinlock 5M 200
공유 변수 + atomic_t 15M 67
per-CPU + get/put_cpu_var 80M 12
per-CPU + this_cpu_inc 250M 4
💡

벤치마크 결론: per-CPU 변수는 공유 변수 대비 50배 빠르며, 특히 this_cpu_* 연산은 원자적이면서도 spinlock보다 훨씬 빠릅니다.

모범 사례 (Best Practices)

언제 사용하는가

적합한 경우:

부적합한 경우:

주의사항

/* 잘못된 코드: preemption 보호 없이 접근 */
my_counter++;  /* BUG: 프로세스가 다른 CPU로 이동 가능! */

/* 올바른 코드 1: this_cpu_* 사용 */
this_cpu_inc(my_counter);

/* 올바른 코드 2: get/put_cpu_var 사용 */
unsigned long *ptr = get_cpu_var(my_counter);
(*ptr)++;
put_cpu_var(my_counter);

실습 예제

/* percpu_example.c */
#include <linux/module.h>
#include <linux/percpu.h>

DEFINE_PER_CPU(unsigned long, test_counter);

static int __init percpu_init(void)
{
    int cpu;
    int i;

    /* 패턴 1: 현재 CPU의 카운터를 증가 (실제 사용 시엔 인터럽트/태스크 컨텍스트에서) */
    for (i = 0; i < 10000; i++)
        this_cpu_inc(test_counter);  /* 현재 CPU의 복사본만 증가 */

    /* 패턴 2: 전체 CPU 합계 집계 (읽기 전용, CPU 핫플러그 고려 필요) */
    unsigned long total = 0;
    for_each_possible_cpu(cpu) {
        total += per_cpu(test_counter, cpu);  /* 각 CPU의 복사본 합산 */
    }

    pr_info("Total count: %lu\n", total);
    return 0;
}

module_init(percpu_init);
MODULE_LICENSE("GPL");