Skip to content

Latest commit

 

History

History
423 lines (295 loc) · 17.7 KB

File metadata and controls

423 lines (295 loc) · 17.7 KB

🔝 Retour au Sommaire

15.7.3 — Précautions et limitations pratiques

Chapitre 15 — Algorithmes de la STL


Introduction

Les sections précédentes ont montré la puissance des algorithmes parallèles : un seul mot ajouté pour exploiter tous les cœurs. Mais cette simplicité de surface cache des subtilités qui, ignorées, transforment une optimisation en source de bugs silencieux, de crashes ou de dégradations de performances. Cette section recense les pièges concrets que rencontrent les développeurs en production, et les stratégies pour les éviter.


Data races : le danger numéro un

Une data race se produit quand deux threads accèdent simultanément à la même donnée et qu'au moins un des accès est une écriture, sans synchronisation. En C++, une data race est un comportement indéfini — pas simplement un résultat incorrect, mais un programme dont le comportement est totalement imprévisible.

Le pattern le plus dangereux : accumulation dans for_each

Ce pattern apparaît constamment dans le code de développeurs découvrant les algorithmes parallèles :

std::vector<int> data = /* ...millions d'éléments... */;

// ⚠️ DATA RACE — comportement indéfini
double sum = 0.0;  
std::for_each(std::execution::par, data.begin(), data.end(),  
    [&sum](int x) {
        sum += x;  // Plusieurs threads écrivent simultanément dans sum
    }
);

Le programme peut produire un résultat incorrect, crasher, ou même sembler fonctionner correctement sur certaines machines et échouer sur d'autres. Le comportement indéfini rend le bug non-reproductible, ce qui le rend particulièrement vicieux.

La correction n'est pas d'ajouter un mutex (qui détruirait les performances) ni un std::atomic<double> (qui n'existe pas nativement en opération += sur tous les hardwares). La correction est d'utiliser le bon algorithme :

// ✅ Correct et performant
double sum = std::reduce(std::execution::par, data.begin(), data.end(), 0.0);

Écriture dans un conteneur partagé

Autre variante fréquente — collecter des résultats dans un vector partagé :

std::vector<int> data = /* ... */;  
std::vector<int> results;  

// ⚠️ DATA RACE — push_back n'est pas thread-safe
std::for_each(std::execution::par, data.begin(), data.end(),
    [&results](int x) {
        if (x > threshold) {
            results.push_back(x);  // Réallocation possible + écriture concurrente
        }
    }
);

std::vector::push_back n'est pas thread-safe. Plusieurs threads appelant push_back simultanément corrompent l'état interne du vector. La correction :

// ✅ Utiliser l'algorithme adapté
std::vector<int> buffer(data.size());  
auto it = std::copy_if(std::execution::par,  
    data.begin(), data.end(), buffer.begin(),
    [](int x) { return x > threshold; }
);
buffer.erase(it, buffer.end());

Modification d'un objet partagé

Toute variable capturée par référence et modifiée dans le callable est une data race potentielle :

int min_val = INT_MAX;  
int max_val = INT_MIN;  

// ⚠️ DATA RACE sur min_val et max_val
std::for_each(std::execution::par, data.begin(), data.end(),
    [&min_val, &max_val](int x) {
        min_val = std::min(min_val, x);
        max_val = std::max(max_val, x);
    }
);

// ✅ Correct
auto [min_it, max_it] = std::minmax_element(std::execution::par,
    data.begin(), data.end());

La règle fondamentale : si le callable capture une variable par référence et la modifie, il y a probablement une data race. La solution est presque toujours de remplacer for_each + accumulation manuelle par l'algorithme spécialisé (reduce, transform_reduce, count_if, minmax_element…).


Gestion des exceptions

Le comportement des exceptions diffère radicalement entre les algorithmes séquentiels et parallèles.

Algorithmes séquentiels (sans politique ou avec seq)

L'exception est propagée normalement au code appelant :

try {
    std::for_each(data.begin(), data.end(), [](int x) {
        if (x < 0) throw std::runtime_error("valeur négative");
    });
} catch (const std::exception& e) {
    std::print("Erreur attrapée : {}\n", e.what());
    // ✅ Fonctionne normalement
}

Algorithmes parallèles (par, par_unseq, unseq)

Si le callable lève une exception pendant une exécution parallèle, le standard spécifie que std::terminate est appelé — le programme est immédiatement tué, sans possibilité de récupération :

try {
    std::for_each(std::execution::par, data.begin(), data.end(), [](int x) {
        if (x < 0) throw std::runtime_error("valeur négative");
        // ⚠️ std::terminate() — le catch ci-dessous n'est JAMAIS atteint
    });
} catch (const std::exception& e) {
    // ❌ Ce code n'est jamais exécuté
    std::print("Erreur : {}\n", e.what());
}

La raison est pragmatique : gérer proprement les exceptions dans un contexte multi-thread est extrêmement complexe. Quand un thread lève une exception, les autres threads continuent de s'exécuter — comment annuler leur travail, propager l'exception au bon endroit, et nettoyer proprement ? Le comité a choisi la voie simple : std::terminate.

Stratégies de protection

Validation préalable — Vérifier les données avant de les passer à l'algorithme parallèle :

// Valider d'abord (séquentiel ou parallèle)
bool all_valid = std::all_of(std::execution::par,
    data.begin(), data.end(),
    [](int x) { return x >= 0; }
);

if (!all_valid) {
    std::print("Données invalides — abandon\n");
    return;
}

// Traitement parallèle en confiance — pas d'exception possible
std::transform(std::execution::par,
    data.begin(), data.end(), result.begin(),
    [](int x) { return std::sqrt(static_cast<double>(x)); }
);

Gestion d'erreur dans le callable — Capturer les erreurs localement au lieu de lever des exceptions :

struct Result {
    double value;
    bool error;
};

std::vector<Result> results(data.size());

std::transform(std::execution::par,
    data.begin(), data.end(), results.begin(),
    [](int x) -> Result {
        if (x < 0) return {0.0, true};      // erreur locale, pas d'exception
        return {std::sqrt(static_cast<double>(x)), false};
    }
);

// Vérifier les erreurs après coup
auto has_error = std::any_of(std::execution::par,
    results.begin(), results.end(),
    [](const Result& r) { return r.error; }
);

Utiliser noexcept pour documenter — Marquer les callables noexcept rend explicite la garantie d'absence d'exception :

std::transform(std::execution::par,
    data.begin(), data.end(), result.begin(),
    [](double x) noexcept { return x * x; }  // garanti sans exception
);

Fallback silencieux : la parallélisation fantôme

Les implémentations sont autorisées à ignorer silencieusement la politique d'exécution et à exécuter l'algorithme séquentiellement. Le standard dit explicitement que les politiques d'exécution sont des permissions, pas des obligations.

GCC sans TBB : le piège classique

Sur Ubuntu avec GCC/libstdc++, les algorithmes parallèles nécessitent Intel TBB. Sans la bibliothèque liée, le code compile et s'exécute — mais séquentiellement :

# ⚠️ Compile sans erreur, mais s'exécute séquentiellement
g++ -std=c++17 -O2 main.cpp

# ✅ Réellement parallèle
g++ -std=c++17 -O2 main.cpp -ltbb

Il n'y a aucun warning, aucune erreur, aucune indication que la parallélisation n'a pas lieu. Le seul symptôme est une absence de speedup dans les benchmarks.

Comment détecter le fallback

Il n'existe pas de mécanisme standard pour savoir si un algorithme s'est réellement exécuté en parallèle. Stratégies de détection :

Benchmarking comparatif — Comparer les temps d'exécution avec et sans politique. Si les temps sont identiques, la parallélisation ne fonctionne probablement pas :

auto data = generate_large_dataset();

auto v1 = data;  
Timer t1;  
std::sort(v1.begin(), v1.end());  
auto seq_time = t1.elapsed_ms();  

auto v2 = data;  
Timer t2;  
std::sort(std::execution::par, v2.begin(), v2.end());  
auto par_time = t2.elapsed_ms();  

std::print("Séquentiel : {:.1f} ms\n", seq_time);  
std::print("Parallèle  : {:.1f} ms\n", par_time);  

if (par_time > seq_time * 0.9) {
    std::print("⚠️ Pas de speedup détecté — vérifier la configuration TBB\n");
}

Vérification à la compilation — Tester si TBB est disponible dans le système de build :

# CMakeLists.txt
find_package(TBB QUIET)  
if(TBB_FOUND)  
    target_link_libraries(${PROJECT_NAME} PRIVATE TBB::tbb)
    target_compile_definitions(${PROJECT_NAME} PRIVATE HAS_PARALLEL_STL=1)
    message(STATUS "TBB trouvé — algorithmes parallèles activés")
else()
    message(WARNING "TBB non trouvé — fallback séquentiel")
endif()

Surcoût de synchronisation et seuils de rentabilité

La parallélisation a un coût fixe incompressible : réveil des threads du pool, partitionnement des données, synchronisation des résultats. Ce coût est typiquement de l'ordre de 10 à 100 microsecondes selon l'implémentation et le matériel.

Quand le surcoût domine

Pour de petits volumes, ce coût fixe dépasse le gain de la parallélisation :

std::vector<int> small(100);

// ❌ Plus lent que la version séquentielle — surcoût du thread pool
std::sort(std::execution::par, small.begin(), small.end());

// ✅ Séquentiel est optimal ici
std::sort(small.begin(), small.end());

Seuils adaptatifs en production

Un pattern robuste consiste à choisir la politique selon le volume :

template<typename Iter, typename Compare = std::less<>>  
void adaptive_sort(Iter first, Iter last, Compare comp = {}) {  
    auto n = std::distance(first, last);

    if (n < 50'000) {
        std::sort(first, last, comp);
    } else {
        std::sort(std::execution::par, first, last, comp);
    }
}

Le seuil optimal dépend du matériel et de l'opération. En l'absence de benchmark spécifique, 50 000 à 100 000 éléments est un point de départ raisonnable pour les opérations simples. Pour les opérations CPU-intensives (comparateurs coûteux, transformations complexes), le seuil peut descendre à 10 000 voire moins.


Ordre d'exécution et déterminisme

Les algorithmes parallèles ne garantissent pas l'ordre

Avec std::execution::par, les éléments peuvent être traités dans n'importe quel ordre. Cela a des conséquences concrètes :

std::find_if parallèle renvoie toujours le premier élément satisfaisant le prédicat — c'est garanti par le standard. L'implémentation parallélise la recherche (plusieurs threads explorent des blocs), mais le résultat est déterministe. En revanche, l'ordre d'évaluation du prédicat n'est pas garanti — un effet de bord dans le prédicat pourrait s'exécuter dans un ordre imprévisible.

std::for_each parallèle ne traite pas les éléments dans l'ordre :

// ⚠️ L'ordre d'affichage est non déterministe
std::for_each(std::execution::par, v.begin(), v.end(), [](int x) {
    std::print("{} ", x);  // Sortie mélangée et potentiellement entrelacée
});

Non-déterminisme numérique avec reduce

Pour les flottants, l'addition n'est pas exactement associative (erreurs d'arrondi). std::reduce parallèle peut regrouper les additions différemment à chaque exécution, produisant des résultats légèrement différents :

std::vector<float> data(1'000'000);
// ...

float sum1 = std::reduce(std::execution::par, data.begin(), data.end(), 0.0f);  
float sum2 = std::reduce(std::execution::par, data.begin(), data.end(), 0.0f);  
// sum1 et sum2 peuvent différer aux derniers bits de la mantisse

Pour double, la différence est généralement négligeable (de l'ordre de 10⁻¹² en relatif). Pour float, elle peut être plus visible. Si la reproductibilité bit-à-bit est requise (tests de régression, certification), il faut utiliser std::accumulate séquentiel, ou std::reduce avec std::execution::seq.


Interactions avec les allocateurs

Les algorithmes parallèles peuvent effectuer des allocations internes (buffers temporaires pour le tri parallèle, par exemple). Ces allocations utilisent l'allocateur par défaut (new/delete), qui est thread-safe dans les implémentations conformes. Cependant, la contention sur l'allocateur global peut devenir un goulot d'étranglement.

Si le callable lui-même alloue de la mémoire (via new, std::string, std::vector…), ces allocations se disputent le même allocateur global :

// ⚠️ Chaque thread alloue/désalloue des strings → contention sur l'allocateur
std::transform(std::execution::par,
    data.begin(), data.end(), strings.begin(),
    [](int x) {
        return std::to_string(x);  // Allocation de string à chaque appel
    }
);

Ce code est correct (pas de data race), mais la contention sur l'allocateur peut limiter le speedup. Solutions possibles :

  • Pré-allouer les buffers quand c'est faisable.
  • Utiliser un allocateur thread-local (jemalloc, tcmalloc, mimalloc) qui réduit la contention.
  • Accepter le speedup réduit si le coût de l'opération domine le coût de l'allocation.

Signaux et algorithmes parallèles

Les handlers de signaux POSIX (chapitre 20) sont exécutés dans le contexte d'un thread indéterminé. Si un signal arrive pendant l'exécution d'un algorithme parallèle, le handler peut s'exécuter dans un des threads du pool interne, avec un contexte potentiellement inattendu. Les handlers de signaux doivent être écrits avec une prudence extrême dans un programme utilisant des algorithmes parallèles.


Limitations des implémentations actuelles

Pas de contrôle du nombre de threads

Le standard ne fournit aucun moyen de contrôler le nombre de threads utilisés par les algorithmes parallèles. L'implémentation décide seule. On ne peut pas dire « utilise 4 threads » ou « laisse 2 cœurs libres pour d'autres tâches ».

Avec libstdc++ + TBB, la variable d'environnement TBB_NUM_THREADS permet un contrôle indirect, mais ce n'est pas standard et pas portable :

# Limiter à 4 threads (spécifique à TBB)
TBB_NUM_THREADS=4 ./my_program

Pas de retour d'information sur la parallélisation

Comme vu avec le fallback silencieux, il n'y a aucun moyen standard de savoir si l'algorithme a été effectivement parallélisé, combien de threads ont été utilisés, ou quel partitionnement a été choisi.

Pas de composition inter-algorithmes

Chaque appel d'algorithme parallèle est indépendant. Le pool de threads est potentiellement recréé ou resynchronisé entre deux appels consécutifs :

// Deux algorithmes parallèles consécutifs
std::sort(std::execution::par, v.begin(), v.end());
// ← Barrière implicite : tous les threads ont fini
std::transform(std::execution::par, v.begin(), v.end(), result.begin(), func);
// ← Barrière implicite

Il n'est pas possible de « pipeliner » deux algorithmes parallèles pour que la sortie du premier alimente directement l'entrée du second sans synchronisation globale. C'est un domaine que std::execution (Senders/Receivers, C++26) vise à résoudre.


Checklist avant de paralléliser

Avant de remplacer un algorithme séquentiel par sa version parallèle, vérifier systématiquement :

1. Le callable est-il thread-safe ? Aucune écriture dans une variable partagée sans synchronisation. Aucune utilisation de mutex avec par_unseq ou unseq. Les captures par référence mutable sont suspectes.

2. Le callable peut-il lever une exception ? Si oui, la traiter localement dans le callable ou valider les données au préalable. Une exception non attrapée provoque std::terminate.

3. Le volume de données justifie-t-il la parallélisation ? En dessous de ~10 000 éléments pour les opérations simples, le surcoût domine. Mesurer au-delà de 50 000.

4. TBB (ou équivalent) est-il correctement lié ? Sur GCC/libstdc++, vérifier la présence de -ltbb. Tester le speedup effectif.

5. L'ordre de traitement importe-t-il ? for_each parallèle ne traite pas les éléments dans l'ordre. Les réductions parallèles sur flottants peuvent regrouper les opérations différemment. Vérifier que le code ne dépend pas de l'ordre d'évaluation.

6. La reproductibilité numérique est-elle requise ? std::reduce sur des flottants peut produire des résultats légèrement différents entre exécutions. Si la reproductibilité bit-à-bit est critique, rester séquentiel.

7. Le benchmark confirme-t-il un gain ? La théorie ne suffit pas. Mesurer sur le matériel cible avec des données réalistes. Un benchmark qui montre un ralentissement est un résultat précieux — il évite une « optimisation » qui dégrade les performances.


Synthèse

Les algorithmes parallèles de la STL offrent un rapport effort/gain remarquable — un seul mot ajouté peut multiplier les performances par quatre ou cinq. Mais cette simplicité d'interface ne doit pas masquer les contraintes sous-jacentes : thread-safety du callable, terminaison sur exception, fallback silencieux sans TBB, non-déterminisme de l'ordre et des résultats flottants, surcoût de synchronisation sur les petits volumes. La clé d'une utilisation réussie est de toujours utiliser le bon algorithme (reduce au lieu de for_each + accumulation, copy_if au lieu de for_each + push_back), de valider les données avant le traitement parallèle plutôt que de gérer les exceptions dedans, et de mesurer systématiquement le gain réel sur le matériel cible. Avec ces précautions, les algorithmes parallèles sont un outil puissant et fiable pour exploiter le matériel moderne.

⏭️ Templates et Métaprogrammation