Skip to content

Latest commit

 

History

History
592 lines (427 loc) · 25.6 KB

File metadata and controls

592 lines (427 loc) · 25.6 KB

🔝 Retour au Sommaire

41.1.2 — Cache lines et false sharing

Chapitre 41 : Optimisation CPU et Mémoire · Section 41.1 · Sous-section 2
Niveau : Expert · Prérequis : Section 41.1.1 (niveaux de cache), Chapitre 21 (threads et concurrence)


Introduction

La section 41.1.1 a décrit la hiérarchie de caches et ses niveaux. Mais il manque une pièce essentielle du puzzle : le cache ne manipule jamais un octet isolé. L'unité atomique de transfert entre la mémoire et le cache est la cache line — un bloc contigu de 64 octets sur toutes les architectures x86-64 et ARM modernes.

Cette granularité de 64 octets a deux conséquences profondes pour le développeur C++ :

  1. Côté positif — Accéder à un seul octet charge 63 octets voisins gratuitement. C'est ce qui rend le parcours séquentiel de std::vector si performant.
  2. Côté négatif — En multi-thread, si deux cœurs modifient des variables distinctes mais situées sur la même cache line, le protocole de cohérence force des transferts coûteux entre cœurs. C'est le false sharing, un problème insidieux qui peut réduire le gain du parallélisme à néant.

Cette sous-section explore ces deux faces en détail.


Anatomie d'une cache line

Structure

Une cache line est un bloc de 64 octets aligné sur une frontière de 64 octets en mémoire. Autrement dit, les adresses 0x00000x003F forment une cache line, 0x00400x007F la suivante, et ainsi de suite.

Adresse mémoire (hex) :
0x0000 ┌──────────────────────────────────────────────────┐
       │              Cache Line 0 (64 octets)            │
0x003F └──────────────────────────────────────────────────┘
0x0040 ┌──────────────────────────────────────────────────┐
       │              Cache Line 1 (64 octets)            │
0x007F └──────────────────────────────────────────────────┘
0x0080 ┌──────────────────────────────────────────────────┐
       │              Cache Line 2 (64 octets)            │
0x00BF └──────────────────────────────────────────────────┘
       ...

Lorsque le CPU accède à l'adresse 0x0052, c'est la cache line entière 0x00400x007F qui est chargée dans le cache — pas seulement les 4 ou 8 octets demandés.

Ce que contient une cache line en termes de données C++

Pour donner une intuition concrète, voici ce que 64 octets représentent pour différents types :

Type C++ sizeof Éléments par cache line
char / std::byte 1 64
short / int16_t 2 32
int / int32_t / float 4 16
long long / int64_t / double 8 8
std::string (libstdc++) 32 2
void* (64-bit) 8 8
std::array<int, 16> 64 1 (exactement)

Un std::vector<int> de 16 éléments consécutifs tient dans une seule cache line. Accéder au premier élément charge les 15 suivants — c'est la localité spatiale en action.


Alignement et frontières de cache line

Le problème du chevauchement

Lorsqu'une structure de données chevauche deux cache lines, un seul accès logique peut déclencher le chargement de deux cache lines. Ce n'est pas catastrophique pour un accès isolé, mais dans une boucle serrée répétée des millions de fois, le surcoût s'accumule.

struct alignas(4) MalAligne {
    char padding[62];   // 62 octets
    int valeur;         // 4 octets, commence à l'offset 62
};
// sizeof(MalAligne) = 68 (avec padding éventuel)
// Si l'objet commence à l'adresse 0x0000 :
//   - padding occupe 0x0000–0x003D
//   - valeur occupe 0x003E–0x0041 ← CHEVAUCHE la frontière 0x0040 !
//   → Accéder à valeur charge 2 cache lines

Forcer l'alignement avec alignas

C++11 a introduit alignas pour contrôler l'alignement des objets. On peut l'utiliser pour garantir qu'une structure critique commence au début d'une cache line :

// Garantir l'alignement sur une frontière de cache line
struct alignas(64) AligneSurCacheLine {
    int compteurs[16];  // 64 octets = exactement 1 cache line
};

static_assert(sizeof(AligneSurCacheLine) == 64);  
static_assert(alignof(AligneSurCacheLine) == 64);  

Avec alignas(64), le compilateur et l'allocateur garantissent que chaque instance de AligneSurCacheLine commence à une adresse multiple de 64. Aucun chevauchement n'est possible.

Alignement et allocation dynamique

L'opérateur new standard respecte alignas depuis C++17 grâce aux aligned allocation functions. Avant C++17, ou pour un contrôle plus fin, on peut utiliser std::aligned_alloc (C11/C++17) :

#include <cstdlib>
#include <new>

// C++17 : new respecte alignas automatiquement
auto* p1 = new AligneSurCacheLine;  // aligné sur 64 octets ✓

// Alternative : std::aligned_alloc (la taille doit être multiple de l'alignement)
void* raw = std::aligned_alloc(64, sizeof(AligneSurCacheLine));  
auto* p2 = static_cast<AligneSurCacheLine*>(raw);  
// ...
std::free(p2);  // libération avec std::free, pas delete

⚠️ Attention : std::aligned_alloc exige que la taille demandée soit un multiple de l'alignement. Allouer 60 octets avec un alignement de 64 est un comportement indéfini — il faut arrondir à 64.


Localité spatiale et parcours de données

Parcours séquentiel : le cas idéal

Lorsqu'on parcourt un tableau séquentiellement, le premier accès à une cache line charge 64 octets. Les accès suivants aux éléments voisins sont des cache hits gratuits. Pour un std::vector<int>, cela signifie que sur 16 accès consécutifs, seul le premier génère potentiellement un miss — les 15 suivants sont servis depuis le L1.

De plus, le processeur dispose d'un mécanisme de prefetch matériel (hardware prefetcher) qui détecte les patterns d'accès séquentiels et charge les cache lines suivantes avant que le programme ne les demande. Le résultat : un parcours linéaire d'un grand std::vector approche la bande passante théorique de la RAM, avec un taux de miss L1 quasi nul.

Parcours avec stride : dégradation progressive

Un stride est l'écart en octets entre deux accès consécutifs. Le parcours séquentiel a un stride de sizeof(element). Que se passe-t-il quand le stride augmente ?

#include <cstddef>
#include <cstdint>
#include <vector>

volatile std::uint64_t sink;

// Parcours avec un stride variable
void traverse_with_stride(const std::vector<int>& data, std::size_t stride) {
    std::uint64_t sum = 0;
    for (std::size_t i = 0; i < data.size(); i += stride)
        sum += data[i];
    sink = sum;
}

Comportement en fonction du stride (sur un tableau de 64 MiB) :

Stride (éléments int) Stride (octets) Cache lines utilisées par accès Perf. relative
1 4 1 pour 16 accès 1× (référence)
4 16 1 pour 4 accès ~1×
16 64 1 pour 1 accès ~3–4× plus lent
64 256 1 pour 1 accès (+ 3 lignes gaspillées) ~4–5× plus lent
256 1 024 1 pour 1 accès (+ 15 lignes gaspillées) ~5–8× plus lent

Au-delà d'un stride de 16 int (64 octets = 1 cache line), chaque accès touche une cache line différente et aucun prefetch matériel ne peut compenser — le prefetcher ne suit que les patterns séquentiels ou les strides réguliers courts. Le programme passe alors de bandwidth-bound (limité par la bande passante mémoire) à latency-bound (limité par la latence de chaque accès individuel).

Accès aléatoire : le pire cas

Le parcours aléatoire est le cas pathologique : chaque accès touche une cache line imprévisible, le prefetcher est inutile, et chaque accès coûte la latence complète du niveau de cache (ou de la RAM) où se trouve la donnée.

C'est exactement le comportement observé lors du parcours d'une std::list ou d'un std::map dont les nœuds sont dispersés dans le heap. La complexité algorithmique O(n) du parcours est respectée, mais le coût constant de chaque opération est multiplié par un facteur 10–50× par rapport à un std::vector.


Prefetching logiciel

Lorsque le prefetcher matériel ne peut pas anticiper le pattern d'accès (accès calculés, parcours de graphe, tables de hachage avec probing), on peut indiquer manuellement au processeur de précharger une cache line :

// GCC / Clang : __builtin_prefetch(adresse, rw, localité)
//   rw      : 0 = lecture, 1 = écriture
//   localité : 0 = non-temporel (NTA), 1 = L3, 2 = L2, 3 = L1
__builtin_prefetch(&data[i + distance], 0, 3);  // précharge dans L1

Le paramètre distance est la distance de prefetch : combien d'itérations à l'avance faut-il précharger. La valeur optimale dépend de la latence mémoire et du nombre de cycles par itération de boucle. En pratique, des valeurs entre 8 et 32 éléments fonctionnent bien pour des accès L3 ou RAM.

Exemple typique — parcours d'une liste chaînée avec prefetch :

struct Node {
    int data;
    Node* next;
};

long long sum_list_prefetch(const Node* head) {
    long long sum = 0;
    const Node* curr = head;
    while (curr) {
        // Précharge le nœud suivant pendant qu'on traite le courant
        if (curr->next)
            __builtin_prefetch(curr->next, 0, 3);
        sum += curr->data;
        curr = curr->next;
    }
    return sum;
}

Le gain typique est de 20–40 % sur un parcours de liste chaînée dispersée en mémoire. Ce n'est pas miraculeux — le pattern reste fondamentalement hostile au cache — mais c'est un gain gratuit en termes de complexité algorithmique.

⚠️ Précaution : le prefetching logiciel est une optimisation de dernier recours. Un prefetch inutile (donnée déjà en cache) gaspille de la bande passante et peut évincer des données utiles. Un prefetch trop tôt ou trop tard ne sert à rien. Profilez toujours avant et après.


False sharing : le tueur silencieux du multi-thread

Le problème

Le false sharing est un phénomène de contention qui se produit lorsque deux threads sur deux cœurs différents modifient des variables distinctes qui résident sur la même cache line.

Du point de vue logique, il n'y a aucun partage de données — chaque thread écrit dans sa propre variable. Mais du point de vue du matériel, les deux variables occupent la même cache line de 64 octets. Le protocole de cohérence de cache (MESI/MOESI) traite la cache line comme une unité indivisible : lorsque le cœur 0 modifie un octet de la ligne, la copie entière est invalidée dans le cache du cœur 1, qui doit la recharger avant de pouvoir modifier son octet. Et vice versa.

Le résultat : les deux cœurs passent leur temps à s'invalider mutuellement une cache line, générant un trafic de cohérence intense sur le bus inter-cœurs. Le programme s'exécute plus lentement que s'il tournait sur un seul cœur.

Illustration du mécanisme

    Cœur 0                     Cache Line                     Cœur 1
  ┌─────────┐          ┌───────────────────────┐          ┌─────────┐
  │ écrit   │───────►  │ [compteur_0] [compt_1]│  ◄───────│ écrit   │
  │ compt_0 │          │  ▲ thread 0   ▲ thr 1 │          │ compt_1 │
  └─────────┘          └───────────────────────┘          └─────────┘
                                │
              Chaque écriture invalide la ligne
              dans le cache de l'AUTRE cœur
              → ping-pong permanent sur le bus

Démonstration : impact mesuré

#include <atomic>
#include <chrono>
#include <cstdint>
#include <iostream>
#include <thread>
#include <vector>

constexpr int NUM_ITERATIONS = 100'000'000;

// ═══════════════════════════════════════════════════════
// CAS 1 : Compteurs contigus → FALSE SHARING
// ═══════════════════════════════════════════════════════
struct CountersPacked {
    std::int64_t counter_a;  // offset 0
    std::int64_t counter_b;  // offset 8
    // Les deux sont sur la MÊME cache line (64 octets)
};

// ═══════════════════════════════════════════════════════
// CAS 2 : Compteurs séparés par padding → PAS de false sharing
// ═══════════════════════════════════════════════════════
struct CountersPadded {
    alignas(64) std::int64_t counter_a;  // cache line propre
    alignas(64) std::int64_t counter_b;  // cache line propre
};

template <typename Counters>  
void benchmark(const char* label) {  
    Counters counters{};

    auto start = std::chrono::high_resolution_clock::now();

    std::thread t1([&]() {
        for (int i = 0; i < NUM_ITERATIONS; ++i)
            ++counters.counter_a;
    });

    std::thread t2([&]() {
        for (int i = 0; i < NUM_ITERATIONS; ++i)
            ++counters.counter_b;
    });

    t1.join();
    t2.join();

    auto end = std::chrono::high_resolution_clock::now();
    auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(end - start);
    std::cout << label << " : " << ms.count() << " ms\n";
}

int main() {
    benchmark<CountersPacked>("Packed (false sharing)  ");
    benchmark<CountersPadded>("Padded (pas de sharing) ");
}

Résultats typiques (GCC 15, -O2, AMD Zen 4, 2 cœurs) :

Packed (false sharing)   : 580 ms  
Padded (pas de sharing)  :  85 ms  

Le code avec false sharing est ~7× plus lent — alors que les deux threads n'accèdent jamais à la même variable. L'intégralité de la dégradation vient du trafic de cohérence de cache.

Note : ce benchmark utilise des int64_t non-atomiques. Dans du vrai code multi-thread, on utiliserait std::atomic ou des mutex. Le false sharing affecte tout autant les std::atomic — le ping-pong de cache line est identique, voire pire car les opérations atomiques sont plus coûteuses par nature.


Détecter le false sharing

Le false sharing est particulièrement insidieux parce qu'il est invisible dans le code source. Deux variables peuvent sembler totalement indépendantes, mais le compilateur les place sur la même cache line sans que le développeur en soit conscient.

Avec perf c2c

L'outil perf c2c (cache-to-cache) est spécialement conçu pour détecter le false sharing sur Linux. Il identifie les cache lines qui génèrent un trafic de cohérence anormal :

# Enregistrement
perf c2c record -g ./mon_programme

# Rapport
perf c2c report --stdio

La sortie identifie les HITM (Hit In Modified) — des accès cache qui ont trouvé la donnée dans l'état « Modified » dans le cache d'un autre cœur. Un nombre élevé de HITM sur une cache line est la signature directe du false sharing.

Extrait typique du rapport :

  Shared Data Cache Line Distribution
  
  Total   Rmt   Lcl   
  HITM   HITM  HITM  
  8451   2103  6348   0x7fff5a3e0040  ◄── cache line problématique
  
  Offset  Pid    Tid     Symbol
  0x0     1234   1235    increment_counter_a
  0x8     1234   1236    increment_counter_b

Ce rapport montre clairement que deux threads (1235 et 1236) accèdent à des offsets différents (0x0 et 0x8) de la même cache line (0x7fff5a3e0040), générant 8 451 HITM.

Avec perf stat (indicateurs indirects)

Sans perf c2c, certains compteurs donnent des indices de false sharing :

perf stat -e \
  l2_rqsts.all_rfo,\
  offcore_response.demand_rfo.l3_miss.snoop_hitm \
  ./mon_programme

Un ratio élevé de RFO (Read For Ownership) et de snoop HITM par rapport au nombre total d'écritures suggère un problème de contention sur des cache lines partagées.

Vérification manuelle de la disposition mémoire

En complément des outils, on peut vérifier manuellement si deux variables risquent de partager une cache line :

#include <cstddef>
#include <cstdint>
#include <iostream>

struct SuspectStruct {
    int counter_a;
    int counter_b;
};

int main() {
    SuspectStruct s;
    
    auto addr_a = reinterpret_cast<std::uintptr_t>(&s.counter_a);
    auto addr_b = reinterpret_cast<std::uintptr_t>(&s.counter_b);
    
    // Deux adresses sont sur la même cache line si :
    // (addr / 64) est identique
    bool meme_ligne = (addr_a / 64) == (addr_b / 64);
    
    std::cout << "counter_a @ 0x" << std::hex << addr_a 
              << " → cache line " << std::dec << (addr_a / 64) << "\n";
    std::cout << "counter_b @ 0x" << std::hex << addr_b 
              << " → cache line " << std::dec << (addr_b / 64) << "\n";
    std::cout << "Même cache line : " << (meme_ligne ? "OUI" : "NON") << "\n";
}

Corriger le false sharing

Technique 1 : alignas(64) — la solution recommandée

La correction la plus directe consiste à forcer chaque variable contended sur sa propre cache line avec alignas :

struct Counters {
    alignas(64) std::int64_t counter_a;  // 64 octets réservés
    alignas(64) std::int64_t counter_b;  // 64 octets réservés
};
// sizeof(Counters) == 128  (2 cache lines)

C'est la solution idiomatique en C++ moderne. Le coût est un gaspillage de mémoire (56 octets de padding par variable), mais pour des compteurs partagés entre threads, ce surcoût est négligeable comparé au gain de performance.

Technique 2 : std::hardware_destructive_interference_size

C++17 fournit une constante portable pour exprimer l'intention « ces données ne doivent pas partager une cache line » :

#include <new>  // std::hardware_destructive_interference_size

struct Counters {
    alignas(std::hardware_destructive_interference_size)
        std::int64_t counter_a;
    alignas(std::hardware_destructive_interference_size)
        std::int64_t counter_b;
};

La constante compagnon std::hardware_constructive_interference_size exprime l'intention inverse : « ces données devraient être sur la même cache line » — utile pour les structures accédées ensemble par le même thread.

#include <new>

// Ces deux champs sont toujours accédés ensemble par le même thread
// → on VEUT qu'ils soient sur la même cache line
struct HotPair {
    int key;
    int value;
};
static_assert(sizeof(HotPair) <= std::hardware_constructive_interference_size);

⚠️ Rappel : ces constantes sont constexpr et déterminées à la compilation. Sur certaines plateformes (notamment certaines versions de Clang), elles ne sont pas disponibles. Le fallback alignas(64) reste fiable pour x86-64.

Technique 3 : padding manuel

Avant C++17, ou pour un contrôle total, on utilise un padding explicite :

struct CountersManualPad {
    std::int64_t counter_a;
    char pad_a[64 - sizeof(std::int64_t)];   // remplit jusqu'à 64 octets
    
    std::int64_t counter_b;
    char pad_b[64 - sizeof(std::int64_t)];   // remplit jusqu'à 64 octets
};
static_assert(sizeof(CountersManualPad) == 128);

Cette technique fonctionne mais est fragile — si la structure évolue, le calcul du padding doit être mis à jour manuellement. alignas(64) est préférable dans tous les cas où le compilateur le supporte.

Technique 4 : réorganisation structurelle

Parfois, la meilleure correction n'est pas d'ajouter du padding mais de repenser la structure des données. Si chaque thread possède son propre compteur, pourquoi ne pas utiliser un tableau indexé par thread ID, avec chaque élément sur sa propre cache line ?

#include <new>
#include <thread>

struct alignas(64) PaddedCounter {
    std::int64_t value{0};
};

// Un compteur par thread, chacun sur sa propre cache line
constexpr int MAX_THREADS = 16;  
PaddedCounter per_thread_counters[MAX_THREADS];  

// Chaque thread incrémente son propre compteur
void worker(int thread_id) {
    for (int i = 0; i < NUM_ITERATIONS; ++i)
        ++per_thread_counters[thread_id].value;
}

// Réduction finale (single-thread, pas de contention)
std::int64_t total() {
    std::int64_t sum = 0;
    for (int i = 0; i < MAX_THREADS; ++i)
        sum += per_thread_counters[i].value;
    return sum;
}

Ce pattern — compteurs locaux par thread suivis d'une réduction finale — est fondamental en programmation concurrente haute performance. On le retrouve dans les implémentations de compteurs atomiques distribués, de statistiques de serveurs, et d'accumulateurs parallèles.


Cas concrets de false sharing dans du code réel

Cas 1 : std::vector<std::atomic<int>> partagé

// DANGER : les atomics consécutifs partagent des cache lines
std::vector<std::atomic<int>> counters(num_threads);

// Thread i incrémente counters[i]
// → false sharing entre counters[0] et counters[1] (même cache line)

Correction :

struct alignas(64) AtomicCounter {
    std::atomic<int> value{0};
};
std::vector<AtomicCounter> counters(num_threads);
// Chaque atomic est maintenant sur sa propre cache line

Cas 2 : champs « chauds » et « froids » mélangés

struct Connection {
    // Champs modifiés constamment par le thread réseau
    std::int64_t bytes_sent;       // offset 0
    std::int64_t bytes_received;   // offset 8
    std::int64_t last_activity_ms; // offset 16
    
    // Champs lus par le thread de monitoring (autre cœur)
    std::int64_t created_at_ms;    // offset 24
    int socket_fd;                 // offset 32
    bool is_tls;                   // offset 33
    // Tout est sur la même cache line → false sharing entre les deux threads
};

Correction par séparation hot/cold :

struct Connection {
    // === Données chaudes (modifiées fréquemment par le thread réseau) ===
    alignas(64) struct {
        std::int64_t bytes_sent;
        std::int64_t bytes_received;
        std::int64_t last_activity_ms;
    } hot;

    // === Données froides (rarement modifiées, lues par le monitoring) ===
    alignas(64) struct {
        std::int64_t created_at_ms;
        int socket_fd;
        bool is_tls;
    } cold;
};

Les données fréquemment modifiées par un thread sont isolées sur leur propre cache line. Les données rarement modifiées mais lues par d'autres threads sont sur une cache line séparée. Le trafic de cohérence est éliminé.

Cas 3 : tableaux de flags en multi-thread

// Chaque thread écrit dans son flag pour signaler qu'il a terminé
bool done[NUM_THREADS];  // DANGER : 64 flags sur une seule cache line !

// Correction :
struct alignas(64) PaddedBool {
    bool value{false};
};
PaddedBool done[NUM_THREADS];

False sharing vs true sharing

Il est important de distinguer le false sharing du true sharing — la contention sur une variable réellement partagée entre threads. Le true sharing est un problème algorithmique : deux threads accèdent à la même donnée, et la synchronisation (mutex, atomic) est inévitable.

Aspect False sharing True sharing
Cause Variables distinctes, même cache line Même variable partagée
Visible dans le code Non (insidieux) Oui (accès concurrent explicite)
Solution Padding / alignement Réduction du partage, lock-free, partitionnement
Détection perf c2c, analyse du layout Code review, ThreadSanitizer
Coût typique 5–10× sur la section affectée Dépend de la contention

Le false sharing est plus traître précisément parce qu'il est invisible : le code semble correct, les variables sont indépendantes, et pourtant les performances s'effondrent. C'est pourquoi les outils comme perf c2c sont indispensables dans toute démarche d'optimisation multi-thread.


Résumé des règles pratiques

Pour la localité spatiale :

  • Privilégier les conteneurs à mémoire contiguë (std::vector, std::array, std::span) sur les conteneurs à nœuds (std::list, std::map).
  • Minimiser les strides d'accès. Parcourir les données dans l'ordre de stockage mémoire.
  • Envisager le prefetching logiciel (__builtin_prefetch) uniquement quand le pattern d'accès est imprévisible pour le matériel et que le profiling confirme un taux de miss élevé.

Pour éviter le false sharing :

  • Toute variable modifiée fréquemment par un thread et potentiellement accédée par un autre doit être isolée sur sa propre cache line (alignas(64) ou alignas(std::hardware_destructive_interference_size)).
  • Séparer les données « chaudes » et « froides » au sein des structures accédées par plusieurs threads.
  • Utiliser des compteurs par thread + réduction finale plutôt que des compteurs atomiques partagés.
  • Valider avec perf c2c après correction pour confirmer la disparition des HITM.

⏭️ Data-oriented design