C++ 17が追加されました std::hardware_destructive_interference_size
およびstd::hardware_constructive_interference_size
。まず、L1キャッシュラインのサイズを取得するための単なる移植可能な方法だと思いましたが、これは単純化しすぎです。
質問:
static constexpr
。バイナリをビルドして、キャッシュラインサイズの異なる他のマシンで実行する場合、これは問題になりませんか?どのマシンでコードが実行されるかわからない場合、そのシナリオでの偽共有からどのように保護できますか?これらの定数の目的は、実際にキャッシュラインのサイズを取得することです。それらの理論的根拠について読むのに最適な場所は、提案そのものです。
http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2016/p0154r1.html
読みやすくするために、ここに理論的根拠のスニペットを引用します。
[...]干渉しないメモリの粒度(一次)[一般的にcache-line sizeと呼ばれます)。
cache-line sizeの使用は、大きく2つのカテゴリに分類されます。
- 異なるスレッドからの一時的にばらばらな実行時アクセスパターンを持つオブジェクト間の破壊的な干渉(偽共有)を回避します。
- 時間的にローカルなランタイムアクセスパターンを持つオブジェクト間の建設的な干渉(真の共有)を促進します。
この有用な実装量の最も重要な問題は、グループとしての普及と人気にもかかわらず、その価値を決定するために現在のプラクティスで使用されているメソッドの移植性が疑わしいことです。 [...]
私たちは、この原因のためのささやかな発明、実装によって与えられた目的のために保守的に定義できるこの量の抽象化に貢献することを目指しています:
- 破壊的干渉サイズ:異なるスレッドからの異なるランタイムアクセスパターンによる誤った共有を回避するために、2つのオブジェクト間のオフセットとして適切な数値。
- 建設的な干渉サイズ:2つのオブジェクトのメモリフットプリントサイズとベースアラインメントの組み合わせの制限として適切な数で、それらの間の真の共有を促進する可能性があります。
どちらの場合も、これらの値は実装の品質に基づいて、純粋にパフォーマンスを改善する可能性のあるヒントとして提供されます。これらは
alignas()
キーワードと共に使用する理想的な移植可能な値であり、現在のところ、標準でサポートされている移植的な使用はほとんどありません。
"これらの定数はL1キャッシュラインサイズにどのように関連していますか?"
理論的には、かなり直接的です。
コンパイラーは、実行するアーキテクチャーを正確に認識していると仮定します。これにより、ほぼ確実にL1キャッシュラインのサイズが正確に得られます。 (後述するように、これは大きな仮定です。)
それが価値があるものとして、私はこれらの値がほぼ同じであることをほとんど常に期待しています。それらが別々に宣言されている唯一の理由は完全性のためだと思います。 (つまり、コンパイラは建設的な干渉のためにL1キャッシュラインサイズではなくL2キャッシュラインサイズを推定したいかもしれませんが、これが実際に役立つかどうかはわかりません)
"ユースケースを示す良い例はありますか?"
この回答の最後に、偽共有と真共有を示す長いベンチマークプログラムを添付しました。
これは、intラッパーの配列を割り当てることにより、偽共有を示しています。1つのケースでは複数の要素がL1キャッシュラインに収まり、もう1つでは単一のエレメントがL1キャッシュラインを占有します。タイトループでは、単一の固定要素が配列から選択され、繰り返し更新されます。
ラッパーにintの単一のペアを割り当てることにより、真の共有を示します。1つのケースでは、ペア内の2つのintがL1キャッシュラインサイズに収まらず、他の場合は収まります。タイトループでは、ペアの各要素が繰り返し更新されます。
テスト対象のオブジェクトにアクセスするためのコードはnotを変更することに注意してください。唯一の違いは、オブジェクト自体のレイアウトと配置です。
私はC++ 17コンパイラを持っていません(そして、ほとんどの人が現在も持っていないことを仮定しています)ので、問題の定数を自分のものに置き換えました。マシンで正確になるようにこれらの値を更新する必要があります。とはいえ、64バイトはおそらく(執筆時点の)現代の典型的なデスクトップハードウェアでは正しい値です。
警告:テストでは、マシン上のすべてのコアが使用され、最大256MBのメモリが割り当てられます。最適化してコンパイルすることを忘れないでください!
私のマシンでは、出力は次のとおりです。
ハードウェアの同時実行性:16 sizeof(naive_int):4 alignof(naive_int):4 sizeof(cache_int):64 alignof(cache_int ):64 sizeof(bad_pair):72 alignof(bad_pair):4 sizeof(good_pair):8 alignof(good_pair):4 naive_intテストの実行。 平均時間:0.0873625秒、無駄な結果:3291773 cache_intテストの実行。 平均時間:0.024724秒、無駄な結果:3286020 bad_pairテストの実行。 平均時間:0.308667秒、役に立たない結果:6396272 good_pairテストの実行。 平均時間:0.174936秒、役に立たない結果:6668457
偽共有を回避することで最大3.5倍、真の共有を保証することで最大1.7倍の高速化が得られます。
"どちらも静的なconstexprとして定義されています。バイナリを構築し、キャッシュラインサイズの異なる他のマシンで実行する場合、問題はありませんか?どのマシンでコードが実行されるかわからない?」
これは確かに問題になります。これらの定数は、特にターゲットマシンのキャッシュラインサイズにマップされることを保証するものではありませんが、コンパイラが必要とする最良の近似値になることを目的としています。
これは提案に記載されており、付録では、さまざまな環境のヒントとマクロに基づいて、コンパイル時にキャッシュラインサイズを検出するライブラリの例を示しています。あなたはこの値が少なくともalignof(max_align_t)
であることを保証されます。これは明らかな下限です。
つまり、この値をフォールバックケースとして使用する必要があります。正確な値を知っていれば自由に定義できます。例:
constexpr std::size_t cache_line_size() {
#ifdef KNOWN_L1_CACHE_LINE_SIZE
return KNOWN_L1_CACHE_LINE_SIZE;
#else
return std::hardware_destructive_interference_size;
#endif
}
コンパイル中に、キャッシュラインのサイズを仮定する場合は、KNOWN_L1_CACHE_LINE_SIZE
。
お役に立てれば!
ベンチマークプログラム:
#include <chrono>
#include <condition_variable>
#include <cstddef>
#include <functional>
#include <future>
#include <iostream>
#include <random>
#include <thread>
#include <vector>
// !!! YOU MUST UPDATE THIS TO BE ACCURATE !!!
constexpr std::size_t hardware_destructive_interference_size = 64;
// !!! YOU MUST UPDATE THIS TO BE ACCURATE !!!
constexpr std::size_t hardware_constructive_interference_size = 64;
constexpr unsigned kTimingTrialsToComputeAverage = 100;
constexpr unsigned kInnerLoopTrials = 1000000;
typedef unsigned useless_result_t;
typedef double elapsed_secs_t;
//////// CODE TO BE SAMPLED:
// wraps an int, default alignment allows false-sharing
struct naive_int {
int value;
};
static_assert(alignof(naive_int) < hardware_destructive_interference_size, "");
// wraps an int, cache alignment prevents false-sharing
struct cache_int {
alignas(hardware_destructive_interference_size) int value;
};
static_assert(alignof(cache_int) == hardware_destructive_interference_size, "");
// wraps a pair of int, purposefully pushes them too far apart for true-sharing
struct bad_pair {
int first;
char padding[hardware_constructive_interference_size];
int second;
};
static_assert(sizeof(bad_pair) > hardware_constructive_interference_size, "");
// wraps a pair of int, ensures they fit nicely together for true-sharing
struct good_pair {
int first;
int second;
};
static_assert(sizeof(good_pair) <= hardware_constructive_interference_size, "");
// accesses a specific array element many times
template <typename T, typename Latch>
useless_result_t sample_array_threadfunc(
Latch& latch,
unsigned thread_index,
T& vec) {
// prepare for computation
std::random_device rd;
std::mt19937 mt{ rd() };
std::uniform_int_distribution<int> dist{ 0, 4096 };
auto& element = vec[vec.size() / 2 + thread_index];
latch.count_down_and_wait();
// compute
for (unsigned trial = 0; trial != kInnerLoopTrials; ++trial) {
element.value = dist(mt);
}
return static_cast<useless_result_t>(element.value);
}
// accesses a pair's elements many times
template <typename T, typename Latch>
useless_result_t sample_pair_threadfunc(
Latch& latch,
unsigned thread_index,
T& pair) {
// prepare for computation
std::random_device rd;
std::mt19937 mt{ rd() };
std::uniform_int_distribution<int> dist{ 0, 4096 };
latch.count_down_and_wait();
// compute
for (unsigned trial = 0; trial != kInnerLoopTrials; ++trial) {
pair.first = dist(mt);
pair.second = dist(mt);
}
return static_cast<useless_result_t>(pair.first) +
static_cast<useless_result_t>(pair.second);
}
//////// UTILITIES:
// utility: allow threads to wait until everyone is ready
class threadlatch {
public:
explicit threadlatch(const std::size_t count) :
count_{ count }
{}
void count_down_and_wait() {
std::unique_lock<std::mutex> lock{ mutex_ };
if (--count_ == 0) {
cv_.notify_all();
}
else {
cv_.wait(lock, [&] { return count_ == 0; });
}
}
private:
std::mutex mutex_;
std::condition_variable cv_;
std::size_t count_;
};
// utility: runs a given function in N threads
std::Tuple<useless_result_t, elapsed_secs_t> run_threads(
const std::function<useless_result_t(threadlatch&, unsigned)>& func,
const unsigned num_threads) {
threadlatch latch{ num_threads + 1 };
std::vector<std::future<useless_result_t>> futures;
std::vector<std::thread> threads;
for (unsigned thread_index = 0; thread_index != num_threads; ++thread_index) {
std::packaged_task<useless_result_t()> task{
std::bind(func, std::ref(latch), thread_index)
};
futures.Push_back(task.get_future());
threads.Push_back(std::thread(std::move(task)));
}
const auto starttime = std::chrono::high_resolution_clock::now();
latch.count_down_and_wait();
for (auto& thread : threads) {
thread.join();
}
const auto endtime = std::chrono::high_resolution_clock::now();
const auto elapsed = std::chrono::duration_cast<
std::chrono::duration<double>>(
endtime - starttime
).count();
useless_result_t result = 0;
for (auto& future : futures) {
result += future.get();
}
return std::make_Tuple(result, elapsed);
}
// utility: sample the time it takes to run func on N threads
void run_tests(
const std::function<useless_result_t(threadlatch&, unsigned)>& func,
const unsigned num_threads) {
useless_result_t final_result = 0;
double avgtime = 0.0;
for (unsigned trial = 0; trial != kTimingTrialsToComputeAverage; ++trial) {
const auto result_and_elapsed = run_threads(func, num_threads);
const auto result = std::get<useless_result_t>(result_and_elapsed);
const auto elapsed = std::get<elapsed_secs_t>(result_and_elapsed);
final_result += result;
avgtime = (avgtime * trial + elapsed) / (trial + 1);
}
std::cout
<< "Average time: " << avgtime
<< " seconds, useless result: " << final_result
<< std::endl;
}
int main() {
const auto cores = std::thread::hardware_concurrency();
std::cout << "Hardware concurrency: " << cores << std::endl;
std::cout << "sizeof(naive_int): " << sizeof(naive_int) << std::endl;
std::cout << "alignof(naive_int): " << alignof(naive_int) << std::endl;
std::cout << "sizeof(cache_int): " << sizeof(cache_int) << std::endl;
std::cout << "alignof(cache_int): " << alignof(cache_int) << std::endl;
std::cout << "sizeof(bad_pair): " << sizeof(bad_pair) << std::endl;
std::cout << "alignof(bad_pair): " << alignof(bad_pair) << std::endl;
std::cout << "sizeof(good_pair): " << sizeof(good_pair) << std::endl;
std::cout << "alignof(good_pair): " << alignof(good_pair) << std::endl;
{
std::cout << "Running naive_int test." << std::endl;
std::vector<naive_int> vec;
vec.resize((1u << 28) / sizeof(naive_int)); // allocate 256 mibibytes
run_tests([&](threadlatch& latch, unsigned thread_index) {
return sample_array_threadfunc(latch, thread_index, vec);
}, cores);
}
{
std::cout << "Running cache_int test." << std::endl;
std::vector<cache_int> vec;
vec.resize((1u << 28) / sizeof(cache_int)); // allocate 256 mibibytes
run_tests([&](threadlatch& latch, unsigned thread_index) {
return sample_array_threadfunc(latch, thread_index, vec);
}, cores);
}
{
std::cout << "Running bad_pair test." << std::endl;
bad_pair p;
run_tests([&](threadlatch& latch, unsigned thread_index) {
return sample_pair_threadfunc(latch, thread_index, p);
}, cores);
}
{
std::cout << "Running good_pair test." << std::endl;
good_pair p;
run_tests([&](threadlatch& latch, unsigned thread_index) {
return sample_pair_threadfunc(latch, thread_index, p);
}, cores);
}
}
私はほとんど常にこれらの値が同じであることを期待しています。
上記に関して、私は受け入れられた答えに少し貢献したいと思います。しばらく前、folly
ライブラリーでこれら2つを別々に定義する非常に良いユースケースを見ました。 Intel Sandy Bridgeプロセッサに関する警告を参照してください。
https://github.com/facebook/folly/blob/3af92dbe6849c4892a1fe1f9366306a2f5cbe6a0/folly/lang/Align.h
// Memory locations within the same cache line are subject to destructive
// interference, also known as false sharing, which is when concurrent
// accesses to these different memory locations from different cores, where at
// least one of the concurrent accesses is or involves a store operation,
// induce contention and harm performance.
//
// Microbenchmarks indicate that pairs of cache lines also see destructive
// interference under heavy use of atomic operations, as observed for atomic
// increment on Sandy Bridge.
//
// We assume a cache line size of 64, so we use a cache line pair size of 128
// to avoid destructive interference.
//
// mimic: std::hardware_destructive_interference_size, C++17
constexpr std::size_t hardware_destructive_interference_size =
kIsArchArm ? 64 : 128;
static_assert(hardware_destructive_interference_size >= max_align_v, "math?");
// Memory locations within the same cache line are subject to constructive
// interference, also known as true sharing, which is when accesses to some
// memory locations induce all memory locations within the same cache line to
// be cached, benefiting subsequent accesses to different memory locations
// within the same cache line and heping performance.
//
// mimic: std::hardware_constructive_interference_size, C++17
constexpr std::size_t hardware_constructive_interference_size = 64;
static_assert(hardware_constructive_interference_size >= max_align_v, "math?");