perf c2c로 false sharing 찾기: 캐시 라인 하나를 두고 다투는 코드 읽는 법

스레드를 늘렸는데 빨라지지 않을 때 의심할 false sharing을 Linux perf c2c로 찾는 방법. 기록과 리포트 옵션, 오프셋 읽기, CPU별 지원 차이와 완화책까지 공식 문서 기준으로 정리했습니다.

linuxperfperformancecpu-cache

서로 다른 변수를 서로 다른 스레드가 건드리는데도 성능이 묶여 버리는 경우가 있습니다. 코드만 보면 공유하는 데이터가 없으니 락도 필요 없어 보입니다. 그런데 CPU 캐시는 변수 단위가 아니라 캐시 라인 단위로 데이터를 주고받습니다. 두 변수가 같은 라인에 들어 있고 그중 하나가 자주 쓰이면, 다른 쪽을 읽거나 쓰는 CPU도 그 라인을 계속 다시 가져와야 합니다. 이것이 false sharing입니다.

이 글은 Linux에서 perf c2c로 그런 캐시 라인을 찾아내고, 리포트에서 어떤 열을 봐야 하는지, 찾은 뒤 무엇을 할 수 있는지를 다룹니다. 근거는 Linux 커널 문서의 False Sharing 페이지와 perf-c2c 매뉴얼입니다. 측정 결과나 성능 향상 수치는 넣지 않았습니다. 하드웨어마다 결과가 너무 달라서, 숫자를 붙이면 오히려 오해를 부릅니다.

false sharing이 생기는 조건

커널 문서는 해로운 false sharing의 조건을 두 가지로 정리합니다.

  • 여러 CPU가 접근하는 전역 데이터가 있다.
  • 그 동시 접근 중에 쓰기가 최소 하나 있다. 즉 쓰기/쓰기 또는 쓰기/읽기 조합이다.

읽기만 하는 필드 여러 개는, 그 라인 안에서 쓰기가 전혀 일어나지 않는 한 같은 라인에 있어도 안전합니다. 문서의 예시는 이렇습니다. 구조체 안에 refcount와 name이 같은 캐시 라인에 있고, refcount는 한 CPU가 자주 올리며 name은 생성 시 한 번 쓴 뒤 바뀌지 않습니다. 그래도 name을 읽는 다른 CPU들은 refcount가 바뀔 때마다 라인 전체를 다시 불러와야 합니다. name 자체는 한 번도 바뀌지 않았는데도요.

캐시 라인 크기는 커널 문서 표현으로 “보통 64바이트 이상”입니다. 정확한 값은 플랫폼마다 확인해야 합니다.

문서가 꼽는 자주 나오는 패턴도 알아 둘 만합니다.

  • 락(spinlock, mutex, semaphore)과 그 락이 보호하는 데이터를 일부러 한 라인에 넣은 경우. CPU가 적던 시절엔 캐시 효율을 위한 배치였지만, 락 경합이 심한 큰 시스템에서는 락 소유자가 데이터를 쓰는 동안 다른 CPU들이 같은 라인에서 락을 돌며 기다리게 됩니다.
  • 작은 전역 변수 여러 개(예: 4바이트짜리 파라미터)가 자연스럽게 한 라인에 모인 경우.
  • 큰 구조체의 멤버들이 아무도 의식하지 못한 채 같은 라인에 붙어 있는 경우. 문서는 mem_cgroup을 예로 듭니다.

공유가 전혀 관계없는 두 컴포넌트에서 올 수도 있고, 같은 컴포넌트의 다른 코드 경로에서 올 수도 있다는 점이 진단을 어렵게 만듭니다. 코드 리뷰로는 잘 안 보이고, 측정해야 보입니다.

perf c2c가 기록하는 것

C2C는 Cache To Cache의 줄임말입니다. 매뉴얼은 이 도구를 “Shared Data C2C/HITM Analyzer”라고 소개하고, 캐시 라인 경합을 추적하는 도구라고 설명합니다. 사용하는 이벤트는 다음 정보를 제공합니다.

  • 접근한 메모리 주소
  • 접근 유형(load/store 구분과 세부 정보)
  • load 접근의 지연 시간(사이클)

perf c2c는 이 데이터를 모아 HITM 접근이 가장 많은 캐시 라인, 즉 경합이 가장 심한 라인부터 보여 줍니다. 흐름은 다른 perf 도구와 같이 record로 기록하고 report로 봅니다.

중요한 점은 이 이벤트가 하드웨어 기능에 기대고 있다는 것입니다. 매뉴얼에 적힌 아키텍처별 방식은 다음과 같습니다.

아키텍처 사용하는 기능 매뉴얼에 적힌 주의점
Intel load latency 이벤트와 precise store 이벤트 기본 이벤트는 cpu/mem-loads,ldlat=30/P, cpu/mem-stores/P. AUX 이벤트가 있는 CPU는 묶음 형태가 다름
AMD IBS op PMU 하드웨어 제약으로 Zen3에서는 지원하지 않음
PowerPC 임계값이 있는 무작위 명령어 샘플링
Arm64 SPE로 load/store 샘플링 하드웨어와 커널 지원이 필요하고, 통계적 샘플링이라 모든 메모리 연산이 잡히지는 않음

그러니 “perf c2c를 실행하면 된다”는 말은 반만 맞습니다. CPU 모델, PMU, 커널, 그리고 perf 빌드에 따라 쓸 수 있는 이벤트가 달라집니다. 그래서 먼저 이 시스템에서 무엇을 쓸 수 있는지부터 확인하는 편이 좋습니다.

perf c2c record -e list

매뉴얼이 안내하는 이벤트 목록 확인 방법입니다. 여기서 아무것도 나오지 않거나 이벤트를 열 수 없다는 오류가 나면, 리포트를 읽기 전에 환경 문제부터 풀어야 합니다. -v(--verbose)를 붙이면 카운터를 열 때의 오류를 더 자세히 보여 줍니다.

AMD에서 load latency 필터(-l, --ldlat)를 쓰려면 매뉴얼 기준으로 /sys/bus/event_source/devices/ibs_op/caps/ldlat 파일에 1이 들어 있어야 하고, 값은 128부터 2048 사이여야 합니다. 128의 배수가 오버헤드가 조금 더 적고, 이 필터는 기본으로 꺼져 있습니다.

작은 재현 코드

설명을 구체적으로 하기 위해 일부러 false sharing을 만드는 C 코드를 하나 두겠습니다. 두 스레드가 각자 자기 카운터만 올리지만, 두 카운터는 한 구조체 안에 붙어 있습니다.

#include <inttypes.h>
#include <pthread.h>
#include <stdint.h>
#include <stdio.h>

#define ITERS UINT64_C(100000000)

struct counters {
    volatile uint64_t a;   /* bump_a 스레드만 쓴다 */
    volatile uint64_t b;   /* bump_b 스레드만 쓴다 */
};

static _Alignas(64) struct counters c;

static void *bump_a(void *arg) {
    (void)arg;
    for (uint64_t i = 0; i < ITERS; i++) c.a++;
    return NULL;
}

static void *bump_b(void *arg) {
    (void)arg;
    for (uint64_t i = 0; i < ITERS; i++) c.b++;
    return NULL;
}

int main(void) {
    pthread_t t1, t2;
    pthread_create(&t1, NULL, bump_a, NULL);
    pthread_create(&t2, NULL, bump_b, NULL);
    pthread_join(t1, NULL);
    pthread_join(t2, NULL);
    printf("%" PRIu64 " %" PRIu64 "\n", c.a, c.b);
    return 0;
}

이 예시는 캐시 라인이 64바이트라고 가정합니다. 카운터는 고정 폭 uint64_t라서 어느 플랫폼에서나 각각 8바이트이고, a는 오프셋 0x0, b는 오프셋 0x8에 놓입니다. _Alignas(64)로 구조체 시작을 64바이트 경계에 맞췄으니, 라인이 64바이트라면 두 카운터는 같은 라인에 들어갑니다. volatile은 컴파일러가 루프를 레지스터 안에서 끝내 버리지 않게 하려는 장치입니다. C11로 빌드하되, 소스 줄 정보가 리포트에 나오도록 디버그 정보를 넣습니다.

gcc -std=c11 -O2 -g -pthread fs.c -o fs

기록하기

매뉴얼의 기본 형식은 perf c2c record [<options>] <command>입니다. 이 프로그램 하나만 볼 때는 이렇게 씁니다.

perf c2c record ./fs

이벤트를 사용자 공간으로만 제한하려면 -u(--all-user), 커널 공간으로만 제한하려면 -k(--all-kernel)를 씁니다.

perf c2c record는 내부적으로 일반 perf record를 호출하고, 기본으로 -W,-d,--phys-data,--sample-cpu 옵션을 켭니다. 다른 perf record 옵션을 넘기고 싶다면 -- 뒤에 둡니다. 매뉴얼은 콜체인과 시스템 전체 수집을 켜는 예로 다음을 보여 줍니다.

perf c2c record -- -g -a

커널 문서의 False Sharing 페이지에는 시스템 전체를 3초 동안 콜체인과 함께 기록하는 짧은 형태가 나옵니다.

perf c2c record -ag sleep 3

이미 돌아가는 서비스에서 원인을 찾을 때는 이처럼 시스템 전체를 짧게 기록하는 쪽이, 재현 프로그램을 따로 만들 수 있을 때는 대상 명령만 기록하는 쪽이 다루기 쉽습니다.

리포트 읽기

perf c2c report --stdio

리포트는 기본이 TUI이고, --stdio를 주면 표준 출력으로 나옵니다. -i(--input)로 다른 perf.data 파일을 지정할 수 있고, --stats는 통계 표만 보여 줍니다. stdio 출력에는 네 개의 표가 나옵니다.

  1. Trace Event Information: 메모리 접근 전체 통계
  2. Global Shared Cache Line Event Information: 공유 캐시 라인 전체 통계
  3. Shared Data Cache Line Table: 비용이 큰 캐시 라인 목록
  4. Shared Cache Line Distribution Pareto: 라인마다 접근된 오프셋 목록

실제로 시간을 쓰게 되는 곳은 3번과 4번입니다.

캐시 라인 목록

3번 표의 각 행은 캐시 라인 하나입니다. 0부터 시작하는 Index, 라인 주소인 Cacheline, 이 라인이 전체 HITM 중 차지하는 비율(Rmt/Lcl Hitm), 그리고 HITM 개수(LLC Load Hitm의 Total, LclHitm, RmtHitm)가 나옵니다. 그 밖에 전체 load/store 수, store가 L1에 맞았는지(Store Reference의 L1Hit, L1Miss, N/A), load가 어디서 맞았는지(Fill Buffer, L1, L2, LLC, 원격, DRAM)도 함께 보여 줍니다.

위에서부터 HITM 비율이 높은 라인을 고르는 것이 출발점입니다. 기본 정렬 기준은 전체 HITM(tot)이고, -d(--display)로 rmt, lcl, 또는 peer로 바꿀 수 있습니다. Arm64는 기본값이 peer이고, 이 모드에서는 HITM 열 대신 peer 캐시나 DRAM에서 가져온 load를 세는 Load Peer, Peer Snoop 열이 나옵니다. 아키텍처에 따라 같은 문제도 다른 이름의 열로 보인다는 뜻입니다.

참고로 리포트에는 기본으로 HITM 비율 0.0005라는 하한이 걸려 있어서, 그보다 작은 라인은 목록에 나오지 않습니다. 이 하한 없이 잡힌 HITM 라인을 전부 보려면 --show-all을 씁니다.

오프셋 분포

4번 표가 false sharing을 판별하는 핵심입니다. 라인 하나를 오프셋 단위로 쪼개서 보여 주기 때문입니다. 각 행에는 다음이 있습니다.

  • 해당 오프셋의 HITM 비율과 store 비율
  • Data address의 Offset
  • 접근한 Pid, Tid
  • 접근한 Code address와 그 Symbol, Shared Object, Source:Line
  • 원격/로컬 HITM과 일반 load의 사이클 합계
  • 접근에 참여한 CPU 수(cpu cnt)와 Node

이 표에서 볼 것은 단순합니다. 한 라인 안에서 서로 다른 오프셋을 서로 다른 코드가 쓰고 있는가. 그렇다면 false sharing 후보입니다. 반대로 같은 오프셋 하나에 여러 쓰기 주체가 몰려 있다면, 그것은 변수 자체를 진짜로 공유하는 경합입니다. 이 경우 필드를 떼어 놓아도 해결되지 않고, 쓰기 횟수 자체를 줄여야 합니다.

위의 재현 코드라면, 라인이 64바이트라고 할 때 기대하는 모양은 이렇습니다. 상위 라인 하나에 오프셋 0x0과 0x8이 보이고, 각각의 코드 주소가 bump_a와 bump_b로 나뉩니다. 다만 샘플링 기반이라 어떤 행이 잡히고 몇 개가 잡히는지는 실행할 때마다, 그리고 하드웨어마다 다릅니다. 이 글에는 측정 결과를 싣지 않았습니다.

정렬 기준이 되는 묶음 단위는 -c(--coalesce)로 바꿉니다. 고를 수 있는 필드는 tid, pid, iaddr, dso이고 기본값은 pid,iaddr입니다. 스레드별로 나눠 보고 싶다면 이렇게 합니다.

perf c2c report --stdio -c tid,iaddr

-N(--node-info)을 주면 Node 열이 노드 ID만 보이는 형태에서 노드별 CPU 수와 HITM/store 비율, 또는 CPU 목록을 보이는 형태로 바뀝니다. TUI에서는 n 키로 전환합니다.

커널 문서의 실제 예

커널 문서는 will-it-scale의 tlb_flush1 테스트를 돌리며 다음 명령으로 얻은 리포트 일부를 보여 줍니다.

perf c2c record -ag sleep 3
perf c2c report --call-graph none -k vmlinux

요약에는 Load Local HITM이 92117, Load Remote HITM이 139로 나옵니다. 그 아래 한 캐시 라인의 오프셋 표를 보면 오프셋 0x8에는 __mod_lruvec_page_state, folio_lruvec_lock_irqsave, lru_add_fn, release_pages가, 오프셋 0x10에는 __mod_memcg_lruvec_state가 찍혀 있고, 소스 위치는 memcontrol.h:752, mm_inline.h:41, memcontrol.c:736입니다. 한 라인 안의 서로 붙어 있는 두 오프셋을 서로 다른 함수들이 두드리고 있다는 것이 한눈에 보입니다. 요약 숫자에서는 원격 HITM보다 로컬 HITM이 압도적으로 많다는 것도 읽힙니다. 커널 데이터를 볼 때는 이처럼 -k로 vmlinux 경로를 지정합니다.

오프셋에서 필드 이름까지

리포트가 주는 것은 라인 주소와 오프셋입니다. 그것이 어느 구조체의 어느 멤버인지는 따로 맞춰 봐야 합니다. 커널 문서가 권하는 도구는 다음과 같습니다.

  • pahole: 구조체 레이아웃을 캐시 라인 경계 단위로 풀어 보여 줍니다. perf c2c의 오프셋과 pahole 출력을 대조하면 정확한 멤버를 찾을 수 있습니다.
  • System.map: 커널 전역 데이터라면 데이터 주소를 여기서 검색합니다.
  • addr2line: 인라인 함수가 여러 겹이라 명령어 주소만으로 위치를 알기 어려울 때 쓸모가 있습니다.

라인이 64바이트가 아니라 128바이트 단위로 움직이는 것처럼 보일 때도 있습니다. 매뉴얼은 일부 아키텍처의 Adjacent Cacheline Prefetch 기능 때문에 캐시 라인 공유가 라인 크기를 두 배로 한 것처럼 동작할 수 있다고 설명하고, 이런 경우 --double-cl로 두 라인 단위로 묶어 보게 합니다. 오프셋 표에 이웃 라인의 필드가 함께 얽혀 있는 것 같다면 이 옵션으로 다시 봅니다.

찾은 뒤에 할 수 있는 일

커널 문서가 정리한 완화 방법은 네 가지이고, 각각 실제 커널 커밋 예가 붙어 있습니다.

  1. 자주 쓰이는 전역 데이터를 전용 캐시 라인에 둔다. short 하나라도 그렇게 합니다. 대가는 메모리, 캐시 라인, TLB 엔트리를 더 쓴다는 점입니다. (예: 커밋 91b6d3256356, “net: cache align tcp_memory_allocated, tcp_sockets_allocated”)
  2. 구조체를 재배치해 서로 간섭하는 멤버를 다른 라인으로 보낸다. 대신 다른 멤버 사이에 새로운 false sharing이 생길 수 있습니다. (예: 커밋 802f1d522d5f, “mm: page_counter: re-layout structure to reduce false sharing”)
  3. 가능하면 쓰기를 읽기로 바꾼다. 특히 루프 안에서 무조건 쓰는 대신 먼저 읽고 필요할 때만 씁니다. 문서는 set_bit(XXX) 대신 if (!test_bit(XXX)) set_bit(XXX);, atomic 값도 if (atomic_read(XXX) == AAA) atomic_set(XXX, BBB); 형태를 예로 듭니다. (예: 커밋 7b1002f7cfe5, 292648ac5cf1)
  4. 뜨거운 전역 데이터를 “CPU별 데이터 + 전역 데이터”로 나눈다. 또는 CPU별 값을 전역 값에 합치는 임계값을 적절히 올려 전역 쓰기를 줄이거나 미룹니다. (예: 커밋 520f897a3554, 56f3547bfa4d)

위 재현 코드에 1번이나 2번을 적용하면 두 필드 사이에 패딩을 넣는 모양이 됩니다.

struct counters {
    volatile uint64_t a;
    char pad[64 - sizeof(uint64_t)];
    volatile uint64_t b;
};

64는 이 예시에서 가정한 라인 크기일 뿐입니다. 라인이 더 크거나 인접 라인 프리페치가 있는 시스템이라면 이 값으로는 부족할 수 있습니다. 고친 뒤에는 같은 방식으로 다시 기록해서 해당 라인이 상위 목록에서 내려갔는지 확인하는 것이 순서입니다.

새 코드를 쓸 때 커널 문서가 권하는 습관도 있습니다. 캐시 라인 경계를 의식할 것, 주로 읽기만 하는 필드끼리 모을 것, 같은 시점에 쓰이는 필드끼리 모을 것, 자주 읽히는 필드와 자주 쓰이는 필드를 다른 라인에 둘 것, 그리고 그렇게 배치한 이유를 주석으로 남길 것.

고치기 전에 생각할 것

커널 문서는 완화가 항상 필요한 것은 아니라고 분명히 말합니다. 성능 이득을 복잡도와 공간 사용량에 견줘 봐야 하고, 잘 쓰이지 않는 구조체나 차가운 경로까지 최적화할 필요는 없습니다. 모든 완화책은 부작용이 없는지 따로 검증해야 합니다.

또 하나, 심한 false sharing을 찾아 고쳤는데도 성능이 눈에 띄게 나아지지 않을 수 있습니다. 병목이 다른 곳으로 옮겨 가기 때문입니다. 수정 전후를 같은 조건으로 측정하고, perf c2c 결과에서 상위 라인이 어떻게 바뀌었는지까지 같이 봐야 합니다.

커널에는 구조체 멤버 배치를 무작위로 섞는 선택적 기능이 있고, 이것이 어떤 멤버들이 같은 라인을 공유하게 될지도 무작위로 만든다는 점도 문서에 미해결 문제로 적혀 있습니다. 같은 커널 소스라도 설정에 따라 다른 결과가 나올 수 있다는 뜻입니다.

버전과 범위

  • 이 글의 옵션은 2026-10-15에 확인한 perf-c2c 매뉴얼(man7.org 판, 2026-08-04 업스트림 기준)과 같은 날의 커널 소스 트리 문서를 기준으로 했습니다. 설치된 perf가 이보다 오래됐다면 옵션 구성이 다를 수 있으니, 시스템에 설치된 perf-c2c 매뉴얼과 대조해 보세요.
  • 최신 업스트림 문서에는 TUI에서 TAB 키로 캐시 라인 대신 함수 단위로 보는 화면이 추가되어 있지만, man7.org 판에는 아직 없습니다. 이 화면이 보이지 않는다면 버전 차이입니다.
  • 어떤 이벤트가 기본으로 쓰이는지, HITM 열이 나오는지 peer 열이 나오는지, 아예 지원되는지는 CPU, PMU, 커널, perf 빌드에 따라 다릅니다. AMD Zen3는 매뉴얼에 지원하지 않는다고 적혀 있습니다.
  • 리포트의 숫자는 샘플링 결과입니다. 특히 Arm SPE는 매뉴얼이 직접 모든 메모리 연산이 샘플링되지는 않는다고 밝힙니다. 개수 자체보다 라인과 오프셋의 상대적 순위를 보는 용도로 쓰는 것이 안전합니다.

참고 자료

2026-10-15에 확인했습니다.

블로그로