web-dev-qa-db-fra.com

Comprendre std :: hardware_destructive_interference_size et std :: hardware_constructive_interference_size

C++ 17 ajouté std::hardware_destructive_interference_size et std::hardware_constructive_interference_size . Tout d'abord, je pensais que c'était juste un moyen portable d'obtenir la taille d'une ligne de cache L1, mais c'est une simplification excessive.

Des questions:

  • Comment ces constantes sont-elles liées à la taille de la ligne de cache L1?
  • Existe-t-il un bon exemple qui illustre leurs cas d'utilisation?
  • Les deux sont définis static constexpr. N'est-ce pas un problème si vous créez un binaire et l'exécutez sur d'autres machines avec différentes tailles de ligne de cache? Comment peut-il se protéger contre le faux partage dans ce scénario lorsque vous n'êtes pas certain sur quelle machine votre code sera exécuté?
61
Philipp Claßen

L'intention de ces constantes est en effet d'obtenir la taille de la ligne de cache. Le meilleur endroit pour en lire la justification est dans la proposition elle-même:

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

Je vais citer un extrait de la justification de la facilité de lecture:

[...] la granularité de la mémoire qui n'interfère pas (au premier ordre) [est] communément appelée la taille de la ligne de cache.

Les utilisations de taille de la ligne de cache entrent dans deux grandes catégories:

  • Éviter les interférences destructrices (faux partage) entre des objets avec des modèles d'accès d'exécution temporellement disjoints provenant de différents threads.
  • Promouvoir l'interférence constructive (partage réel) entre des objets qui ont des modèles d'accès d'exécution temporellement locaux.

Le problème le plus significatif avec cette quantité utile de mise en œuvre est la portabilité discutable des méthodes utilisées dans la pratique actuelle pour déterminer sa valeur, malgré leur omniprésence et leur popularité en tant que groupe. [...]

Nous visons à apporter une modeste invention pour cette cause, des abstractions pour cette quantité qui peuvent être définies de façon conservatrice pour des objectifs donnés par des implémentations:

  • Taille d'interférence destructrice: un nombre qui convient comme décalage entre deux objets pour éviter probablement le faux partage en raison de modèles d'accès d'exécution différents de threads différents.
  • Taille d'interférence constructive: un nombre qui convient comme limite à la taille de l'empreinte mémoire combinée de deux objets et à l'alignement de base pour favoriser vraisemblablement le vrai partage entre eux.

Dans les deux cas, ces valeurs sont fournies en fonction de la qualité de la mise en œuvre, uniquement à titre d'indices susceptibles d'améliorer les performances. Ce sont des valeurs portables idéales à utiliser avec le mot clé alignas(), pour lesquelles il n'existe actuellement pratiquement aucune utilisation portable prise en charge standard.


"Comment ces constantes sont-elles liées à la taille de la ligne de cache L1?"

En théorie, assez directement.

Supposons que le compilateur sache exactement sur quelle architecture vous allez exécuter - alors cela vous donnerait presque certainement la taille de la ligne de cache L1 avec précision. (Comme indiqué plus loin, c'est une grande hypothèse.)

Pour ce que ça vaut, je m'attendrais presque toujours à ce que ces valeurs soient les mêmes. Je crois que la seule raison pour laquelle ils sont déclarés séparément est leur exhaustivité. (Cela dit, peut-être qu'un compilateur souhaite estimer la taille de la ligne de cache L2 au lieu de la taille de la ligne de cache L1 pour les interférences constructives; je ne sais pas si cela serait réellement utile, cependant.)


"Existe-t-il un bon exemple qui illustre leurs cas d'utilisation?"

Au bas de cette réponse, j'ai joint un long programme de référence qui démontre le faux partage et le vrai partage.

Il démontre le faux partage en allouant un tableau d'enveloppes int: dans un cas, plusieurs éléments tiennent dans la ligne de cache L1, et dans l'autre, un seul élément occupe la ligne de cache L1. Dans une boucle serrée un seul, un élément fixe est choisi dans le tableau et mis à jour à plusieurs reprises.

Il montre le vrai partage en allouant une seule paire d'entiers dans un wrapper: dans un cas, les deux entrées de la paire ne tiennent pas ensemble dans la taille de la ligne de cache L1, et dans l'autre elles le font. Dans une boucle serrée, chaque élément de la paire est mis à jour à plusieurs reprises.

Notez que le code d'accès à l'objet sous test pas change; la seule différence est la disposition et l'alignement des objets eux-mêmes.

Je n'ai pas de compilateur C++ 17 (et je suppose que la plupart des gens n'en ont pas non plus), j'ai donc remplacé les constantes en question par les miennes. Vous devez mettre à jour ces valeurs pour être précis sur votre machine. Cela dit, 64 octets est probablement la valeur correcte sur le matériel de bureau moderne typique (au moment de la rédaction).

Attention: le test utilisera tous les cœurs sur vos machines, et allouera ~ 256 Mo de mémoire. N'oubliez pas de compiler avec des optimisations !

Sur ma machine, la sortie est:

 Concurrence matérielle: 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 
 Exécution du test naive_int. 
 Durée moyenne: 0,0873625 secondes, résultat inutile: 3291773 
 Exécution du test cache_int. 
 Durée moyenne: 0,024724 secondes, résultat inutile: 3286020 
 Exécution du test bad_pair. 
 Durée moyenne: 0,308667 seconde, résultat inutile: 6396272 
 Exécution du test good_pair. 
 Durée moyenne: 0,174936 seconde, résultat inutile: 6668457 

J'obtiens une accélération de ~ 3,5x en évitant le faux partage et une accélération de ~ 1,7x en assurant le vrai partage.


"Les deux sont définis constexpr statiques. N'est-ce pas un problème si vous créez un binaire et l'exécutez sur d'autres machines avec différentes tailles de ligne de cache? Comment peut-il protéger contre le faux partage dans ce scénario lorsque vous ne savez pas sur quelle machine votre code sera exécuté? "

Ce sera effectivement un problème. Ces constantes ne sont pas garanties de correspondre à n'importe quelle taille de ligne de cache sur la machine cible en particulier, mais sont destinées à être la meilleure approximation que le compilateur puisse rassembler.

Ceci est noté dans la proposition, et en annexe, ils donnent un exemple de la façon dont certaines bibliothèques essaient de détecter la taille de la ligne de cache au moment de la compilation en fonction de divers indices environnementaux et macros. Vous êtes garanti que cette valeur est au moins alignof(max_align_t), ce qui est une borne inférieure évidente.

En d'autres termes, cette valeur doit être utilisée comme cas de secours; vous êtes libre de définir une valeur précise si vous la connaissez, par exemple:

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
}

Pendant la compilation, si vous voulez supposer une taille de ligne de cache, définissez simplement KNOWN_L1_CACHE_LINE_SIZE.

J'espère que cela t'aides!

Programme de référence:

#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);
    }
}
56
GManNickG

Je m'attendrais presque toujours à ce que ces valeurs soient les mêmes.

Concernant ce qui précède, je voudrais apporter une contribution mineure à la réponse acceptée. Il y a quelque temps, j'ai vu un très bon cas d'utilisation où ces deux devraient être définis séparément dans la bibliothèque folly. Veuillez consulter la mise en garde concernant le processeur 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?");
9
Validus Oculus