역사
현대 CPU는 MESI와 같은 캐시 일관성 프로토콜을 사용하여 서로 다른 코어의 개인 L1 캐시 간 데이터를 동기화합니다. 독립적인 스레드가 우연히 동일한 캐시 라인에 있는 별도의 메모리 위치에 쓰기를 할 때 (일반적으로 64 또는 128 바이트), 하드웨어는 이러한 작업을 계속 무효화하고 해당 라인의 소유권을 전송하면서 직렬화합니다. 이 현상을 잘못된 공유라고 합니다. C++17은 std::hardware_destructive_interference_size를 도입하여 아키텍처의 캐시 라인 너비를 노출시켜 개발자가 변경 가능한 데이터를 분리하여 각 스레드의 핫 변수가 서로 다른 라인에 배치되어 이 동기화 오버헤드를 피할 수 있도록 합니다.
문제
자동 저장 기간을 가진 변수에 **alignas(std::hardware_destructive_interference_size)**를 적용하면 객체의 시작 주소가 특정 스레드의 스택 프레임 내에서 캐시 라인 크기의 배수임을 보장합니다. 그러나 이 정렬은 스레드의 메모리 뷰에 국한되어 있으며 물리적 캐시 라인의 독점 점유를 보장하지 않습니다. 객체가 캐시 라인보다 작으면 동일한 스택의 인접 변수가나 물리적 주소가 라인 크기의 배수만큼 다른 서로 다른 스레드의 스택에서 할당된 변수가 동일한 물리적 캐시 라인에 매핑될 수 있습니다. 결과적으로 하드웨어는 다른 스레드가 동일한 라인에 있는 다른 변수에 작성할 때 여전히 일관성 트래픽을 경험하게 되며, 따라서 alignas 사양은 격리를 위해 불충분합니다.
해결책
잘못된 공유를 피하려면 데이터가 캐시 라인을 완전히 소비하도록 패딩되어야 하며, 런타임 주소 배열과 관계없이 다른 데이터가 물리적 저장소를 공유하지 않도록 해야 합니다. 이는 std::hardware_destructive_interference_size에 따라 정렬되고 크기가 정의된 구조체를 정의함으로써 수행됩니다.
#include <new> #include <cstddef> #include <atomic> struct alignas(std::hardware_destructive_interference_size) PaddedCounter { std::atomic<int> value; // 패딩은 캐시 라인의 나머지를 채우기 위해 공유 방지를 위해 사용됩니다. char padding[std::hardware_destructive_interference_size - sizeof(std::atomic<int>)]; }; // 배열은 각 요소가 서로 다른 캐시 라인에 위치하도록 보장합니다. PaddedCounter thread_counters[8];
문제 설명
저지연 시장 데이터 프로세서는 여덟 개의 작업 스레드를 사용하며, 각 스레드는 **std::atomic<int> stats[8]**의 글로벌 배열에서 스레드마다 고유한 틱 카운터를 유지했습니다. 각 스레드는 잠금을 사용하지 않고 자신의 인덱스를 독점적으로 증가시켰지만, 프로파일링 결과 처리량은 이론적 최대의 일부에 그쳤으며, CPU 카운터는 사용자 모드 계산보다 과도한 캐시 일관성 사이클을 보여주었습니다. 조사를 통해 원자 정수들이 논리적으로 독립적임에도 불구하고 단일 64 바이트 캐시 라인에 연속적으로 배치되어 코어 간 간섭이 발생함을 확인했습니다.
해결책 1: 로컬 정렬 변수
팀은 처음에 각 스레드의 실행 함수 내에 alignas(64) std::atomic<int> local_stat를 선언하고 모니터링 스레드에 포인터를 전달하는 방법을 시도했습니다. 이 접근 방식은 최소한의 리팩토링이 필요하고 글로벌 상태를 피할 수 있었습니다. 그러나 컴파일러가 local_stat와 동일한 캐시 라인 내에 다른 자동 변수를 배치할 수 있기 때문에 신뢰할 수 없게 되었습니다. 또한 서로 다른 스레드의 스택 할당이 정확한 64 바이트의 배수로 분리될 수 있어 정렬된 변수가 동일한 물리적 라인에 별칭을 가질 수 있고 잘못된 공유가 지속되었습니다.
해결책 2: 원시 포인터를 사용한 힙 할당
또 다른 고려된 접근 방식은 각 카운터를 **new std::atomic<int>**를 통해 할당하여 힙 할당자가 먼 메모리 주소에 할당을 분산시킬 수 있다고 기대하는 것이었습니다. 이 방법은 때때로 경쟁을 줄이긴 했지만, 소규모 할당이 종종 연속 슬랩에서 제공되므로 비결정론적인 성능을 초래했습니다. 또한 할당자 메타데이터가 서로 다른 객체를 동일한 캐시 라인에 배치할 수 있었고, 수동 메모리 관리가 필요했으며 정렬 또는 패딩에 대한 컴파일 타임 보장을 제공하지 않았습니다.
선택된 해결책 및 결과
최종 구현은 위에서 정의된 PaddedCounter 구조체를 채택하여 인스턴스를 정적 배열에 저장했습니다. 이 솔루션은 컴파일 타임 패딩 및 정렬을 통해 캐시 라인 분리를 결정적으로 enforced하여 런타임 메모리 배열과 관계없이 하드웨어 수준의 경쟁을 제거하기 때문에 선택되었습니다. 메모리 소비는 32 바이트에서 512 바이트로 증가했지만 성능 향상을 위해 허용되었습니다. 결과적으로 처리량이 열 배 증가하고 지연 변동성이 감소하여 마이크로초 미만의 처리 요구를 충족했습니다.
왜 std::hardware_destructive_interference_size를 작은 객체에 적용하는 것이 동일 스레드의 다른 데이터와 잘못된 공유를 방지하지 못합니까?
alignas는 객체의 시작 주소의 정렬만 제어할 뿐, 객체의 범위는 제어하지 않습니다. 객체가 캐시 라인보다 작으면 (예: 64바이트 라인에서 4바이트 정수) 캐시 라인의 나머지 바이트에 다른 변수가 있을 수 있습니다. 컴파일러가 동일한 라인에 다른 변수를 배치하거나 다른 스레드의 변수가 해당 물리적 라인에 매핑되면 잘못된 공유가 발생합니다. 진정한 격리는 패딩을 통해 객체가 전체 라인을 차지하도록 요구하며, 시작 위치에만 정렬되어서는 불충분합니다.
std::hardware_destructive_interference_size와 std::hardware_constructive_interference_size의 차이점은 무엇이며, 후자의 데이터 크기에 그룹화하면 성능이 개선되는 경우는 언제입니까?**
std::hardware_destructive_interference_size는 잘못된 공유를 피하기 위해 필요한 최소한의 분리를 제공하는 반면, std::hardware_constructive_interference_size는 단일 캐시 라인에서 공간 지역성의 이점을 받는 데이터의 최대 크기입니다. 관련된 자주 접근되는 필드(예: 포인트의 x, y, z 좌표)를 구조체에 그룹화하여 건설적인 크기에 맞추면 동일한 라인에 존재하여 캐시 적중률 및 프리페칭 효율을 극대화할 수 있으며, 반면 파괴적인 크기는 관련이 없는 변경 가능한 데이터를 분리하는 데 사용됩니다.
false sharing은 std::atomic 연산에 memory_order_relaxed를 사용할 때 어떻게 영향을 줍니, 그리고 완화된 메모리 순서가 성능 저하를 해결하지 않는 이유는 무엇인가요?
memory_order_relaxed를 사용하더라도 메모리 작업에 대한 순서 제약이 없지만 원자 쓰기는 여전히 CPU 코어가 캐시 라인의 독점 소유권을 획득해야 합니다 (소유권을 위한 읽기 주기). 다른 스레드가 최근에 동일한 라인에 있는 다른 변수를 수정한 경우, 캐시 일관성 프로토콜은 해당 라인이 코어 간에 바운스되도록 강제합니다. 이러한 하드웨어 수준의 동기화는 C++ 메모리 모델의 논리적 보증과는 독립적으로 발생하므로, 잘못된 공유는 지정된 메모리 순서와 관계없이 전체 캐시 미스 대기 시간을 초래합니다.