9.5.2.2 CPU 캐시 라인(Cache Line) 정렬을 통한 페이크 셰어링(False Sharing) 차단

9.5.2.2 CPU 캐시 라인(Cache Line) 정렬을 통한 페이크 셰어링(False Sharing) 차단

멀티 코어(Multi-core) 시스템에서 분산 트래픽을 처리하는 각 워커 스레드들은 병렬성의 축복을 받으며, 각자 할당받은 코어에서 연산 속도를 극대화한다. 그러나 이 모든 코어들이 하나의 공유 배열 구조체를 바라보고 있을 때, 엔지니어가 예상치 못한 최악의 성능 역행 장벽인 페이크 셰어링(False Sharing, 거짓 공유) 이 폭발력을 발휘한다.

스레드가 각각 독립된 변수를 다루고 있는데도 불구하고, CPU 간 내부 캐시 동기화 프로토콜(MESI)이 지옥의 공회전을 일으켜 처리 속도가 싱글 코어보다도 참담하게 떨어져 버리는 현상. 본 절에서는 하드웨어 계층의 캐시 통신 기저를 파헤치고, zenoh-c 통신망에서 페이크 셰어링을 완벽하게 차단하기 위한 캐시 라인 정렬(Cache Line Alignment) 기법을 강구한다.

1. 하드웨어 캐시 라인(Cache Line)의 배신적 특성 관찰

CPU는 메인 메모리(RAM)에서 데이터를 읽어올 때 1바이트, 혹은 4바이트 씩 옹졸하게 퍼오지 않는다. 현대 x86_64, ARM 아웃 오브 오더(Out-of-Order) 프로세서들은 메모리 병목을 줄이기 위해 한 번 읽을 때 인접한 메모리 이웃들까지 통째로 64바이트 길이의 거대한 선분(Cache Line) 단위로 뜯어와서 자신의 L1 캐시에 저장한다.

이 지점에서 비극이 탄생한다. 만약 배열 안에 다음과 같은 통계 카운터 구조체가 있다고 가정해 보자.

// [시스템 파멸의 안티 패턴 구조체]
struct NetworkStats {
    uint32_t rx_count_core0; // 코어 0번의 Zenoh 워커가 1밀리초마다 +1 증가
    uint32_t rx_count_core1; // 코어 1번의 Zenoh 워커가 1밀리초마다 +1 증가
};

struct NetworkStats global_stats;

코어 0번 스레드는 rx_count_core0 변수만, 코어 1번 스레드는 rx_count_core1 변수만 배타적으로 접근한다. 논리적으로 이 둘은 전혀 락(Lock) 경합이 없어야 한다.
그러나 이 두 변수(4바이트씩 도합 8바이트)는 메모리 상에 딱 붙어있기 때문에, 운영체제 하드웨어 관점에서는 단 하나의 64바이트 캐시 라인(Cache Line) 블록 안에 우겨 들어가 버린다.

2. 무한 캐시 무효화(Cache Invalidation) 폭풍: False Sharing

문제의 발동은 이러하다.

  1. 코어 0이 rx_count_core0에 데이터를 쓴다(+1). 이때 코어 0은 이 64바이트 캐시 라인 전체에 대한 ’배타적 소유권(Modified)’을 선언해버린다.
  2. 순간 코어 1이 자신의 변수인 rx_count_core1을 쓰려고(+1) 시도한다. 그러나 이 변수 역시 동일한 캐시 라인에 묶여 있으므로, MESI 프로토콜에 의해 코어 1은 코어 0에게 “네 캐시를 무효화(Invalidate)하고 메인 램에 당장 플러시(Flush)하라!“고 인터럽트를 때린다.
  3. 코어 0이 데이터를 밀어내고 통제를 상실한다. 코어 1이 그 블록을 점유한다.
  4. 그러나 0.1밀리초 후, 코어 0이 다시 자신의 독립 변수를 수정하려 할 때 방금 당했던 동일한 공격을 코어 1에게 가하며 캐시를 강제 박탈한다.

두 스레드는 공유 변수를 건드린 적이 단 한 번도 없음에도, 하드웨어 계층의 “거짓 공유된 아파트(동일 캐시 라인)“에 산다는 죄목만으로 소유권 핑퐁(Ping-pong)을 무한 반복한다. 시스템의 최고급 L1 캐시는 즉각 무력화되며 RAM과 코어 사이의 버스는 이 무의미한 소통만으로 대역폭을 100% 매몰시킨다. 이 페이크 셰어링 상태에 빠지면 멀티 스레드의 스루풋은 최대 1/100 토막으로 내려앉는다.

3. alignas(64) 지시어를 이용한 강제 방화벽(Padding) 구축

이 하드웨어적 대재앙을 종식하기 위해 C/C++ 시스템 프로그래머들이 꺼내 드는 방어기재는 낭비를 수반한 캐시 라인 강제 정렬(Cache Line Alignment) 이다.

변수와 변수 사이에 빈 공간(보이드)을 강제로 밀어 넣어 물리적으로 거리를 벌출하고, 이들이 결코 같은 64바이트 라운드 블록 내에 안착하지 못하도록 구조체 사이에 방화벽(Padding)을 치는 것이다.

// [견고하게 정비된 하드웨어 친화적 통계 구조체]

#define CACHE_LINE_SIZE 64  // 대부분의 모던 CPU 캐시 라인 바이트 수

// 코어 0 전용 공간 (자신의 변수 뒤에 60바이트의 강제 빈 공간을 깔아버린다)
struct alignas(CACHE_LINE_SIZE) Core0Stats {
    uint32_t rx_count; 
    // 보이지 않는 60바이트 패딩이 이곳에 자동 적재됨
};

// 코어 1 전용 공간
struct alignas(CACHE_LINE_SIZE) Core1Stats {
    uint32_t rx_count; 
    // 보이지 않는 60바이트 패딩이 이곳에 자동 적재됨
};

// 메모리를 낭비하여 코어 간 독립 병렬성을 창출!
Core0Stats core0_data;
Core1Stats core1_data;

alignas(64) 와 같은 메모리 선언 체계를 기입하는 순간, 컴파일러는 core0_datacore1_data의 메모리 진입점을 서로 다른 64바이트 덩어리의 프론트로 철저하게 분리해 할당한다. 이제 코어 0번과 1번은 서로 독립적인 독립 채널 위에서 MESI 프로토콜의 무효화 통지를 단 한 번도 촉발하지 않은 채 각각 100% 클럭 스피드로 자신의 카운트를 무한대로 밀어 올리게 될 것이다.
스레드 공간 분리를 넘어 캐시 공간 분리까지 도달한 자만이 초저지연 병렬 시스템의 궁극에 다가설 수 있다.