了解std :: hardware_destructive_interference_size和std :: hardware_constructive_interference_size

时间:2016-09-24 19:34:22

标签: c++ concurrency c++17

C ++ 17添加了std::hardware_destructive_interference_size and std::hardware_constructive_interference_size。首先,我认为这只是获取L1缓存行大小的可移植方式,但这是过于简单化。

问题:

  • 这些常量如何与L1缓存行大小相关?
  • 是否有一个很好的例子来演示他们的用例?
  • 两者都定义为static constexpr。如果您构建二进制文件并在具有不同缓存行大小的其他计算机上执行它,这不是问题吗?当你不确定你的代码将运行在哪台机器上时,它如何防止错误共享呢?

3 个答案:

答案 0 :(得分:49)

这些常量的意图确实是获取缓存行大小。阅读其理由的最佳位置在提案本身中:

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

为了便于阅读,我在这里引用一小部分理由:

  

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

     

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

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

这个有用的实施数量最显着的问题是当前实践中用于确定其价值的方法的可疑的可移植性,尽管它们作为一个群体的普遍性和普及性。 [...]

     

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

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

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

"这些常量如何与L1缓存行大小相关?"

理论上,非常直接。

假设编译器确切地知道您正在运行什么体系结构 - 那么这些几乎肯定会精确地为您提供L1缓存行大小。 (如后所述,这是一个很大的假设。)

对于它的价值,我几乎总是期望这些价值是相同的。我认为他们被单独宣布的唯一原因是完整性。 (也就是说,也许编译器想要估计L2缓存行大小而不是L1缓存行大小以进行建设性干扰;但我不知道这是否真的有用。)

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

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

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

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

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

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

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

在我的机器上,输出为:

Hardware concurrency: 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
Running naive_int test.
Average time: 0.0873625 seconds, useless result: 3291773
Running cache_int test.
Average time: 0.024724 seconds, useless result: 3286020
Running bad_pair test.
Average time: 0.308667 seconds, useless result: 6396272
Running good_pair test.
Average time: 0.174936 seconds, useless result: 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);
    }
}

答案 1 :(得分:8)

  

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

关于上述内容,我想对已接受的答案做出一点点贡献。不久前,我看到了一个很好的用例,应该在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?");

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

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