C++/개념(Concept)

True sharing / False sharing : std::hardware_..._interference_size

kim선달 2021. 8. 30. 06:51
Select Language

True sharing / False sharing : std::hardware_..._interference_size


1. 설명

False sharing은 2개 이상의 코어가 서로 다른 메모리 주소에 접근하지만, 두 주소가 동일한 캐시 최소 단위에 적제되어 있어(논리적으로는 전혀 영향이 없지만 캐시는 이를 모르기에) 동기화가 이루어져 성능이 하락하는것을 말합니다.

 

True sharing은 false sharing과는 반대로 동일한 메모리 주소에 값을 쓸때를 의미합니다. 이때는 당연히 동기화가 이루어지기에(논리적으로도 그것이 맞고) 이번글에서는 딱히 다루지 않겠습니다.

 

C++17 에는 이렇게 동일한 캐시라인에 적제되는지 판별할수 있는(L1 캐시 라인 크기를 얻어오는) 표준이 추가되었습니다.

다만, 아직까지는 Windows를 제외하고는 해당 표준을 다들 구현해놓지 않았습니다.

그래도 현재 일반적으로 대부분의 아키텍쳐에서 해당 값이 64를 넘지는 않습니다.


2. 예시

struct One {
  std::atomic<int64_t> x; // 8 바이트
  std::atomic<int64_t> y; // 8 바이트
};

// 캐시 라인 크기보다 작거나 같은지 체크
static_assert(sizeof(One) <= std::hardware_constructive_interference_size);

One 구조체의 크기는 16비트입니다.

일반적인 캐시 라인 크기인 32비트나 64비트보다 작으니 두 스레드가 각각 x, y만 접근해도 false sharing이 일어나게 됩니다.

 

struct Two {
  alignas(hardware_destructive_interference_size) std::atomic<std::int64_t> x;
  alignas(hardware_destructive_interference_size) std::atomic<std::int64_t> y;
};

Two 구조체의 크기는 최소 캐시 라인 크기의 2배가 됩니다.

이런 경우에는 두 변수가 false sharing이 일어나지 않습니다.

운영체제에 따라 Two의 크기는 32x2=64비트일 수도, 64x2=128비트일 수 있습니다.

 

만약 운영체제가 Windows가 아니라면 그냥 고정된 상수값(64~)을 넣으셔도 무방합니다.

 

그러면 실제로 퍼포먼스에 문제가 있을지 아래 코드를 통해 판별해 보겠습니다.

#include <atomic>
#include <chrono>
#include <iostream>
#include <thread>
#include <sstream>

constexpr auto iter = 10'000'000;

struct One {
  std::atomic<int64_t> x; // 8 바이트
  std::atomic<int64_t> y; // 8 바이트
} one;

struct Two {
  alignas(64) std::atomic<std::int64_t> x;
  alignas(64) std::atomic<std::int64_t> y;
} two;

template<typename Func>
auto measure_time(Func f) {
  auto t1 = std::chrono::high_resolution_clock::now();
  f();
  auto t2 = std::chrono::high_resolution_clock::now();
  const auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(t2 - t1).count();
  std::cout << (std::stringstream() << ms << "ms\n").str();
  return ms;
}

int main() {
  constexpr auto test_count = 5;

  {
    std::cout << "False sharing : \n";
    auto add_x = [&]{ for(int j = 0; j < iter; ++j) {one.x.fetch_add(1, std::memory_order_relaxed);}};
    auto add_y = [&]{ for(int j = 0; j < iter; ++j) {one.y.fetch_add(1, std::memory_order_relaxed);}};
    for (int i = 0; i < test_count; ++i) {
      std::thread t1([&]{measure_time(add_x);});
      std::thread t2([&]{measure_time(add_y);});
      t1.join();
      t2.join();
    }
  }
  
  {
    std::cout << "No sharing : \n";
    auto add_x = [&]{ for(int j = 0; j < iter; ++j) {two.x.fetch_add(1, std::memory_order_relaxed);}};
    auto add_y = [&]{ for(int j = 0; j < iter; ++j) {two.y.fetch_add(1, std::memory_order_relaxed);}};
    for (int i = 0; i < test_count; ++i) {
      std::thread t1([&]{measure_time(add_x);});
      std::thread t2([&]{measure_time(add_y);});
      t1.join();
      t2.join();
    }
  }
}

최적화와 race condition을 방지하기위해 std::atomic을 사용하였습니다.

atomic을 사용하지 않을거라면 1 말고 그냥 랜덤값을 더해주는 식으로 해도 상관 없습니다.

캐시 라인 크기는 제가 테스트한 PC가 macOS라서 그냥 고정된 상수값을 사용하였습니다.

 

반복 횟수 및 순서에 따라 속도가 차이날 수 있어 각각 5번씩 진행했을때의 결과입니다.

 

실행속도가 5~6배 정도 차이나는 모습입니다.

Sharing이 일어날 경우 멀티 스레드를 사용하는게 오히려 싱글스레드보다 훨씬 느릴 수 있다는것을 보여주는 장면입니다.

 


여담

참고로 C++17에 이번 표준에 대한 제안서를 제출한 사람은 Apple Clang 컴파일러팀에서 일하시는 분입니다.

하지만 아이러니하게도 Apple Clang 컴파일러는 해당 표준이 정의되어 있지 않고, 또 정의되었는지 확인하는 매크로조차 올바르게 정의가 되어 있지 않습니다. 슬픈 일이네요.