Understanding std::hardware_destructive_interference_size an

Understanding std::hardware_destructive_interference_size and std::hardware_constructive_interference_size

本文关键字:size an interference Understanding hardware std destructive      更新时间:2023-10-16

C++17添加了std::hardware_destructive_interference_sizestd::hardware_constructive_interference_size。首先,我认为这只是一种获取一级缓存线大小的可移植方法,但这过于简单化了。

问题:

  • 这些常量与一级缓存行大小有何关系
  • 有没有一个好的例子来演示它们的用例
  • 两者都被定义为CCD_ 3。如果您构建一个二进制文件并在具有不同缓存行大小的其他机器上执行它,这不是问题吗?在这种情况下,当你不确定你的代码将在哪台机器上运行时,它如何防止虚假共享

这些常量的目的实际上是获取缓存行大小。阅读它们的理由最好的地方是提案本身:

http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2016/p0154r1.html

为了便于阅读,我将在这里引用一段基本原理:

[…]不干扰(一阶)的内存粒度[通常称为缓存行大小

缓存行大小的用途分为两大类:

  • 避免来自不同线程的具有时间上不相交的运行时访问模式的对象之间的破坏性干扰(错误共享)
  • 促进具有临时本地运行时访问模式的对象之间的建设性干扰(真正的共享)

这个有用的实现数量最显著的问题是当前实践中用于确定其价值的方法的可移植性值得怀疑,尽管它们作为一个群体普遍存在和流行。[…]

我们的目标是为这一事业贡献一项适度的发明,对这个数量的抽象可以通过实现保守地定义:

  • 破坏性干扰大小:一个适合作为两个对象之间偏移的数字,以避免由于来自不同线程的不同运行时访问模式而导致的错误共享
  • 构造性干扰大小:一个适合作为两个对象的组合内存占用空间大小和基本对齐的限制的数字,以可能促进它们之间的真正共享

在这两种情况下,这些值都是在实现质量的基础上提供的,纯粹是作为可能提高性能的提示。这些是与alignas()关键字一起使用的理想可移植值,目前几乎没有标准支持的可移植使用。


"这些常数与一级缓存行大小有何关系?">

理论上,非常直接。

假设编译器确切地知道你将在什么体系结构上运行——那么这些几乎肯定会精确地给出一级缓存行的大小。(如后所述,这是一个很大的假设。)

就其价值而言,我几乎总是期望这些价值是相同的。我相信它们单独声明的唯一原因是为了完整性。(也就是说,编译器可能想估计L2缓存行大小,而不是L1缓存行大小以进行建设性干扰;不过,我不知道这是否真的有用。)


"是否有一个很好的例子来演示它们的用例?">

在这个答案的底部,我附上了一个很长的基准程序,展示了虚假分享和真实分享。

它通过分配一个int包装器数组来证明错误的共享:在一种情况下,多个元素可以放在一级缓存行中,而在另一种情况中,单个元素占用一级缓存线。在紧密循环中,从数组中选择单个固定元素并重复更新。

它通过在包装器中分配一对int来展示真正的共享:在一种情况下,这对int中的两个int不适合一级缓存行大小,而在另一种情况中,它们适合。在一个紧密的循环中,这对中的每个元素都会重复更新。

请注意,访问测试对象的代码不会发生更改;唯一的区别是对象本身的布局和对齐方式。

我没有C++17编译器(假设目前大多数人也没有),所以我用自己的编译器替换了有问题的常量。您需要在机器上更新这些值以使其准确无误。也就是说,64字节可能是典型的现代桌面硬件(在撰写本文时)的正确值。

警告:测试将使用您机器上的所有内核,并分配约256MB的内存。不要忘记使用优化进行编译

在我的机器上,输出是:

硬件并发:16大小(naive_int):4alignof(naive_int):4大小(cache_int):64alignof(cache_int):64大小(bad_pair):72alignof(bad_pair):4尺寸(good_pair):8alignof(good_pair):4正在运行naive_int测试。平均时间:0.0873625秒,无效结果:3291773正在运行cache_int测试。平均时间:0.024724秒,无效结果:3286020正在运行bad_pair测试。平均时间:0.308667秒,无效结果:6396272正在运行good_air测试。平均时间: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库中分别定义。请参阅有关英特尔SandyBridge处理器的警告。

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?");

我已经测试了上面的代码,但我认为有一个小错误阻碍了我们理解底层功能。为了防止错误共享,两个不同的原子之间不应该共享一个缓存行。我已经改变了那些结构的定义。

struct naive_int
{
alignas ( sizeof ( int ) ) atomic < int >               value;
};
struct cache_int
{
alignas ( hardware_constructive_interference_size ) atomic < int >  value;
};
struct bad_pair
{
// two atomics sharing a single 64 bytes cache line 
alignas ( hardware_constructive_interference_size ) atomic < int >  first;
atomic < int >                              second;
};
struct good_pair
{
// first cache line begins here
alignas ( hardware_constructive_interference_size ) atomic < int >  
first;
// That one is still in the first cache line
atomic < int >                              first_s; 
// second cache line starts here
alignas ( hardware_constructive_interference_size ) atomic < int >
second;
// That one is still in the second cache line
atomic < int >                              second_s;
};

以及由此产生的运行:

Hardware concurrency := 40
sizeof(naive_int)    := 4
alignof(naive_int)   := 4
sizeof(cache_int)    := 64
alignof(cache_int)   := 64
sizeof(bad_pair)     := 64
alignof(bad_pair)    := 64
sizeof(good_pair)    := 128
alignof(good_pair)   := 64
Running naive_int test.
Average time: 0.060303 seconds, useless result: 8212147
Running cache_int test.
Average time: 0.0109432 seconds, useless result: 8113799
Running bad_pair test.
Average time: 0.162636 seconds, useless result: 16289887
Running good_pair test.
Average time: 0.129472 seconds, useless result: 16420417

我在上一个结果中经历了很多变化,但从未将任何核心精确地用于那个特定的问题。无论如何,这已经用完了2个Xeon 2690V2,从hardware_constructive_interference_size = 128使用64或128的各种运行中,我发现64已经用完了,128对可用缓存的使用非常差。

我突然意识到你的问题有助于我理解什么是Jeff Preshing说的都是有效载荷!?