了解std :: hardware_destructive_interference_size和std :: hardware_constructive_interference_size


79

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

问题:

  • 这些常量与L1缓存行大小有何关系?
  • 有没有一个很好的例子来说明他们的用例?
  • 两者都被定义static constexpr。如果生成二进制文件并在具有不同缓存行大小的其他计算机上执行二进制文件,这不是问题吗?当您不确定代码将在哪台计算机上运行时,如何在这种情况下防止错误共享?

Answers:


70

这些常量的目的确实是为了获取高速缓存行的大小。提案本身的最佳阅读之处是:

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

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

不会干扰(一阶)的内存粒度通常称为高速缓存行大小

的使用高速缓存行的大小可分为两大类:

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

尽管这些方法作为一个整体无处不在并且很受欢迎,但是,这种有用的实现量最显着的问题是,当前实践中用来确定其价值的方法的可移植性令人怀疑。[...]

我们旨在为此目的贡献一个适度的发明,为此数量的抽象可以通过实现为给定目的保守地定义:

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

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


“这些常量与L1缓存行大小有何关系?”

从理论上讲,很直接。

假设编译器确切地知道您将在哪种架构上运行-那么这些几乎可以肯定地为您提供L1缓存行大小。(如稍后所述,这是一个很大的假设。)

对于它的价值,我几乎总是希望这些值是相同的。我相信将它们分开声明的唯一原因是为了完整性。(也就是说,可能是编译器想要估计L2缓存行的大小,而不是L1缓存行的大小,以进行相长干涉;不过,我不知道这是否真的有用。)


“有没有一个很好的例子来说明他们的用例?”

在此答案的底部,我附加了一个较长的基准程序,该程序演示了错误共享和真实共享。

它通过分配一个int包装器数组来演示错误共享:在一种情况下,多个元素适合L1高速缓存行,而在另一种情况下,单个元素占据了L1高速缓存行。在紧密循环中,从数组中选择一个固定元素,然后重复更新。

它通过在包装器中分配一对整数来演示真正的共享:在一种情况下,该对中的两个整数不能一起适合L1缓存行的大小,而在另一种情况下可以。在紧密的循环中,该对中的每个元素都会重复更新。

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

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

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

在我的机器上,输出为:

硬件并发:16
sizeof(naive_int):4
alignof(naive_int):4
sizeof(cache_int):64
alignof(cache_int):64
sizeof(坏对):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秒,无用的结果:6392272
正在运行good_pair测试。
平均时间:0.174936秒,无用结果:6666685

通过避免错误共享,我获得了〜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);
    }
}

36
我写了建议,很好的答案!为了阐明您的观点,“我几乎总是希望这些值是相同的。我认为,分别声明它们的唯一原因是为了完整性。” 是的,它们应该始终相同,除非:1)ISA附带了不同的缓存行大小,并且没有给出目标拱门;2)您以虚拟ISA(例如WebAssembly)为目标,而实际的ISA未知(然后您将尽力而为)。在constexpr上:必须使用constexpr才能在确定结构布局时使用该值。运行时值在其他情况下很有用。
JF Bastien

16

我几乎总是希望这些值是相同的。

关于上述内容,我想对已接受的答案做出一点点贡献。前一阵子,我看到了一个很好的用例,应该在folly库中分别定义这两个用例。请参阅有关英特尔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?");

1
是的,Intel的L2空间预取器(Nehalem和更高版本,包括所有Sandybridge系列产品)试图完成对齐的高速缓存线对(如果有可用带宽)。 在L1和L2处预取数据摘录自Intel的优化手册。 DCU预取器在哪种情况下开始预取?详细介绍了它们何时触发。
彼得·科德斯

1
请注意,如果链接不同编译的对象或库,则根据x86中的-march=sandybridgevs. -march=znver1(ryzen)更改值可能会导致结构布局不兼容。(clang-developers.42468.n3.nabble.com/…)。这就是为什么clang仍然不实现任何常量的原因。通常在x86上使用destructive = 128是个好主意。保守的环境在任何地方都是安全的。
彼得·科德斯

0

我已经测试了上面的代码,但是我认为有一个小错误使我们无法理解底层的功能,不应在两个不同的原子之间共享一条缓存行,以防止错误共享。我已经更改了这些结构的定义。

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,并且在使用64或128进行的各种运行中,hardware_constructive_interference_size = 128我发现64绰绰有余,而128则对可用缓存的使用非常差。

我突然意识到,您的问题可以帮助我理解Jeff Preshing在说什么,所有关于有效载荷的内容!?

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.