거짓 공유로 인해 코드가 100배 느려지는 이유
100x Slower Code due to False Sharing by Keifer
거짓 공유(False Sharing)로 인해 코드가 100배 느려지는 이유와 해결책
본 영상은 병렬 프로그래밍에서 흔히 발생하는 성능 저하 원인인 거짓 공유(False Sharing) 의 메커니즘을 상세히 설명합니다. 동일한 캐시 라인 내의 데이터를 여러 코어가 동시에 수정할 때 발생하는 캐시 무효화 현상을 하드웨어 모델과 실제 벤치마크 데이터를 통해 분석하며, 이를 방지하기 위한 메모리 정렬 및 데이터 구조 설계 전략을 제시합니다.
-
거짓 공유의 기본 개념과 하드웨어 작동 원리
- 단순화된 모델 기반 설명
- 메인 메모리가 8바이트이고, 두 개의 코어가 각각 4바이트 크기의 캐시 라인을 하나씩 가진 상황을 가정합니다.
- 데이터 로드 시 특정 주소의 단일 값만 가져오는 것이 아니라, 해당 주소를 포함한 연속된 블록(캐시 라인) 전체를 캐시에 저장합니다.
- 예를 들어 CPU A가 주소 2의 값을 읽으려 하면, 주소 0부터 3까지의 데이터가 CPU A의 캐시에 복사됩니다.
- 이어 CPU B가 주소 0의 값을 읽으면, 동일한 0~3 범위의 데이터가 CPU B의 캐시에도 복사됩니다.
- 쓰기 작업과 캐시 일관성(Coherency) 문제
- CPU A가 주소 2의 값을 계산 후 업데이트하면, CPU B가 가진 캐시 값은 오래된 데이터(Outdated) 가 됩니다.
- 이때 시스템은 일관성을 유지하기 위해 CPU B의 캐시를 무효화(Invalidate) 처리합니다.
- 결과적으로 CPU B는 자신이 사용하는 주소 1의 값이 변경되지 않았음에도 불구하고, 동일한 캐시 라인 내의 다른 값이 수정되었다는 이유만으로 메인 메모리에서 다시 데이터를 읽어와야 하는 캐시 미스를 겪게 됩니다.
- 현대 시스템으로의 확장
- 현대적인 64비트 x86 플랫폼은 더 많은 코어, 다층 구조의 캐시, 그리고 64바이트 크기의 캐시 라인을 사용하지만, 위와 동일한 거짓 공유 문제가 발생합니다.
- 단순화된 모델 기반 설명
-
실전 예제: 운동학(Kinematics) 시뮬레이션 벤치마크
- 기본 설정
- 속도(Velocity)에 따라 점의 위치(Position)를 업데이트하는 '당황스러울 정도로 병렬적인(Embarrassingly Parallel)' 문제를 활용합니다.
- 각 하위 작업 간에 상태를 공유하지 않으므로 이론적으로는 완벽한 성능 향상이 기대되는 구조입니다.
- 기준 성능(Baseline) 측정
- 로컬 배열 기반: 단일 스레드에서 로컬 배열의 점들을 업데이트할 때 약 2나노초(ns) 가 소요됩니다.
- 원자적 변수(Atomics) 도입: 동기화를 위해
std::atomic을 적용하고fetch_add를 명시적으로 사용하면 오버헤드로 인해 약 12나노초로 실행 시간이 늘어납니다.
- 다중 스레드 환경에서의 성능 퇴보
- 스레드 간 작업 미분배: 각 스레드에 독립적인 작업을 주는 것이 아니라, 동일한 양의 작업을 각 스레드가 수행하도록 설정하여 순수하게 스레드 추가에 따른 성능 변화를 관찰합니다.
- 이론적으로는 스레드 수가 늘어나도 개별 스레드의 처리 시간은 동일해야 합니다.
- 기본 설정
-
거짓 공유가 발생하는 구체적 시나리오
- 인터리브(Interleaved) 접근 방식
- 공유 범위에 점들의 배열을 생성하고, 각 스레드가 자신의 인덱스에서 시작하여 스레드 수만큼의 간격(Stride)을 두고 데이터에 접근하도록 합니다.
- 스레드들이 직접적으로 동일한 메모리 주소를 공유하지 않음에도 불구하고, 스레드가 8개로 늘어날 경우 성능이 약 100배 저하되는 현상이 관찰됩니다.
- 성능 분석 도구(Perf)를 이용한 진단
- 데이터 캐시 미스: CPU와 가장 가까운 캐시에서 약 20%의 미스율이 확인됩니다.
- Perf C2C 분석: 'HitM(Modified 캐시 라인에 대한 Hit)' 이벤트가 다수 발견됩니다. 이는 다른 코어가 수정된 캐시 라인을 소유하고 있어 현재 코어의 접근이 차단되거나 지연됨을 의미합니다.
- 청크(Chunk) 기반 접근 방식
- 인터리브 방식 대신 각 스레드에 연속된 데이터 덩어리(Chunk) 를 할당합니다.
- 성능 저하 폭이 100배에서 10배 수준으로 완화되지만, 여전히 유의미한 성능 퇴보가 발생합니다.
- 이는 각 청크의 경계 부분(시작과 끝)에 위치한 데이터들이 여전히 동일한 캐시 라인에 걸쳐 있기 때문입니다. 특히 작업 부하가 작을수록 경계 데이터의 비중이 커져 문제가 두드러집니다.
- 인터리브(Interleaved) 접근 방식
-
문제 해결을 위한 기술적 방법
- 메모리 정렬(Alignment) 강제
- x86 시스템에서 부동 소수점(Float)은 보통 4바이트 경계에 정렬되지만, 캐시 라인은 64바이트 경계입니다.
- 배열이 캐시 라인 경계에서 시작하도록 엄격한 정렬을 적용합니다. C++에서는
alignas(64)또는 이식성을 고려한std::hardware_destructive_interference_size를 사용할 수 있습니다.
- 데이터 구조 설계 변경
- 개별 항목 정렬: 각 점(Point) 구조체 자체에 캐시 라인 크기만큼의 정렬을 적용하여 서로 다른 캐시 라인에 위치하게 만듭니다. 하지만 이 방법은 ** 메모리 낭비**가 심하고 연속 데이터의 이점을 상실합니다.
- 스레드 로컬 데이터 구조: 각 스레드가 자신만의 배열을 가지도록 설계합니다. 포인터 등을 통해 데이터를 나중에 공유할 수 있으면서도 실행 시점의 거짓 공유를 완벽히 차단합니다.
- 하위 배열 분리 및 간접 참조: 각 스레드용 하위 배열을 별도로 생성하고 이를 캐시 라인 단위로 정렬된 인덱스를 통해 관리합니다. 이 방식은 스레드가 담당하는 데이터 양과 무관하게 거짓 공유를 방지할 수 있습니다.
- 메모리 정렬(Alignment) 강제
-
결론 및 시사점
- 병렬 프로그래밍에서 데이터가 논리적으로 분리되어 있더라도, 물리적인 메모리 배치(Data Layout) 가 성능에 치명적인 영향을 미칠 수 있습니다.
- 벤치마크 수행 시 작업 부하의 크기나 정렬 상태에 따라 결과가 왜곡될 수 있으므로, 자신이 무엇을 테스트하고 있는지 정확히 이해하는 것이 중요합니다.
- 고성능 코드를 작성하기 위해서는 알고리즘의 병렬성뿐만 아니라 하드웨어 캐시 계층의 작동 방식을 반드시 고려해야 합니다.
토픽:
프로그래밍