2016-09-24 334 views
34

C++ 17添加了std::hardware_destructive_interference_size and std::hardware_constructive_interference_size。首先,我認爲這只是獲得L1緩存線大小的便攜式方法,但這太簡單了。瞭解std :: hardware_destructive_interference_size和std :: hardware_constructive_interference_size

問題:

  • 如何對與L1高速緩存行的大小這些常量?
  • 有沒有一個很好的例子來演示他們的用例?
  • 二者都定義爲static constexpr。如果您構建二進制文件並在具有不同緩存行大小的其他機器上執行它,那麼這不是問題嗎?如果您不確定代碼將運行在哪臺機器上,它如何防止在這種情況下發生虛假共享?

回答

25

這些常量的意圖確實是獲得緩存行大小。閱讀關於它們的基本原理的最佳位置是在建議本身:

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

我會在這裏引述的理由片段爲便於閱讀:

[。 ..]不干擾(到一階)的內存粒度[通常被稱爲緩存行大小

用途的高速緩存行大小分爲兩大類:

  • 避免與來自不同線程的時間上不相交的運行時訪問模式對象之間的相消干涉(共享假)。
  • 在具有暫時本地運行時訪問模式的對象之間促進建設性干擾(真實共享)。

這個有用的執行量最sigificant問題是在目前的實踐中,以確定其價值的方法值得商榷的便攜性,儘管其普及程度和受歡迎程度爲一組。 [...]

我們旨在有助於適度發明爲這個原因,對於此數量的抽象可保守地對於給定的目的通過實現定義:

  • 破壞性干擾大小:一個數字,是適合作爲兩個對象之間的偏移以避免由於來自不同線程的不同運行時訪問模式而導致錯誤共享。
  • 建設性干擾大小:一個適合作爲兩個對象的組合內存佔用空間大小和基本對齊限制的數字,可能促進它們之間的真實共享。

在這兩種情況下,這些值均以實施質量爲基礎提供,純粹作爲可能提高性能的提示。這些是與alignas()關鍵字配合使用的理想便攜式值,目前幾乎沒有標準支持的便攜式用途。


「如何對與L1高速緩存行的大小這些常量?」

理論上,很直接。

假設編譯器確切地知道你將在哪個體系結構上運行 - 那麼這些幾乎肯定會給你準確的L1緩存行大小。 (如下文所指出的,這是一個很大的假設。)

對於它的價值,我幾乎總是期待着這些價值是相同的。我相信他們分開申報的唯一原因是爲了完整。 (這就是說,也許編譯器要估算的,而不是L1緩存行大小相長干涉的L2高速緩存行的大小,我不知道這實際上是有用的,雖然)。


「有沒有一個很好的例子來證明他們的用例?「

在這個答案我已經附上長的基準測試程序,演示了假共享和共享真實的底部。

它通過分配INT包裝的陣列演示共享假:在一種情況下的多個元件裝配在L1高速緩存行,而在其它的單個元素佔據了L1超高速緩存行。在緊密的循環中,從陣列中選擇一個固定元素並重復更新。

它通過在包裝器中分配單個int對來演示真正的共享:在一種情況下,對中的兩個int不一定適合L1緩存行大小,而在另一個情況下,它們可以。在緊密的循環中,該對中的每個元素都會重複更新。

注意,對於被測訪問對象的代碼做變化;唯一的區別是對象本身的佈局和對齊。

我沒有一個C++編譯器17(並假設目前大多數人都不要麼),所以我用我自己的問題取代了常量。您需要更新這些值才能在您的機器上保持準確。也就是說,64字節可能是典型的現代桌面硬件上的正確值(在撰寫本文時)。

警告:測試將使用您計算機上的所有內核,並分配〜256MB的內存。不要忘記編譯優化!

在我的機器,輸出結果是:

 
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

希望這會有所幫助!

Benchmark程序:

#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); 
    } 
} 
+16

我撰寫提案,偉大的答案!澄清你提出的一點:「我幾乎總是會期望這些值是一樣的,我相信他們分開申報的唯一原因是爲了完整性。」是的,除非:1)ISA已經裝載了不同的緩存線大小,並且沒有給出目標拱; 2)你的目標是一個虛擬的ISA,例如WebAssembly,其實際的ISA是未知的(然後你盡力而爲)。在constexpr上:需要將constexpr中的值用於確定結構佈局。運行時值在其他情況下很有用。 –