Розуміння std :: hardware_destructive_interference_size та std :: hardware_constructive_interference_size


79

C ++ 17 додано std::hardware_destructive_interference_sizeтаstd::hardware_constructive_interference_size . По-перше, я думав, що це просто портативний спосіб отримати розмір лінії кешу L1, але це занадто спрощення.

Запитання:

  • Як ці константи пов'язані з розміром рядка кешу L1?
  • Чи є хороший приклад, який демонструє випадки їх використання?
  • Обидва визначені static constexpr. Це не проблема, якщо ви створюєте двійковий файл і виконуєте його на інших машинах з різними розмірами рядків кешу? Як він може захистити від обміну помилками в цьому випадку, коли ви не впевнені, на якій машині буде працювати ваш код?

Відповіді:


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 байти - це, мабуть, правильне значення для типового сучасного настільного обладнання (на момент написання статті).

Попередження: під час тесту будуть використані всі ядра на ваших машинах та виділено ~ 256 МБ пам'яті. Не забудьте скомпілювати з оптимізаціями!

На моїй машині результат:

Паралельність обладнання: 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);
    }
}

36
Я написав цю пропозицію, чудова відповідь! Щоб пояснити одне, що ви сказали: "Я майже завжди мав би очікувати, що ці значення будуть однаковими. Я вважаю, що єдина причина, за якою вони оголошуються окремо, - це повнота". Так, вони завжди повинні бути однаковими, якщо: 1) ISA не постачається з різними розмірами кеш-лінії і не вказана цільова арка; 2) ви націлюєтеся на віртуальну ISA, таку як WebAssembly, для якої фактична ISA невідома (тоді ви отримуєте максимум зусиль). На constexpr: потрібно constexpr, щоб значення було придатним для визначення макета структури. Значення часу роботи корисні за інших обставин.
Дж. Ф. Бастієн

16

Я б майже завжди очікував, що ці значення будуть однаковими.

Що стосується вище, я хотів би зробити незначний внесок у прийняту відповідь. Деякий час тому я побачив дуже хороший варіант використання, коли ці два слід визначити окремо в follyбібліотеці. Будь ласка, перегляньте застереження щодо процесора 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?");

1
Так, просторовий попередній вибір L2 від Intel (Nehalem і пізніше, включаючи всі сімейства Sandybridge) намагається заповнити вирівняні пари ліній кешу (якщо є запасна пропускна здатність). попереднє отримання даних на L1 і L2 містить уривок керівництва з оптимізації від Intel. В якому стані попереднє завантаження DCU починає попереднє завантаження? містить деякі подробиці про те, коли саме вони спрацьовують.
Пітер Кордес,

1
Зверніть увагу , що наявність зміна значення в залежності від того -march=sandybridgeпроти -march=znver1(ryzen) в межах x86 може привести до несумісності структури компоновки , якщо зв'язок по- різному скомпільовані об'єкти або бібліотеки. ( clang-developers.42468.n3.nabble.com/… ). Ось чому clang все ще не реалізує жодної константи. Використання destructive = 128 для x86 загалом - гарна ідея; консервативні умови безпечні скрізь.
Пітер Кордес,

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 дуже погано використовує наявний кеш.

Я раптово зрозумів, що ваше запитання допомагає мені зрозуміти, про що говорив Джефф Прешинг, все про корисне навантаження!?

Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.