web-dev-qa-db-fra.com

Différence entre les politiques d'exécution et quand les utiliser

J'ai remarqué qu'une majorité (sinon la totalité) fonctionne dans <algorithm> obtiennent une ou plusieurs surcharges supplémentaires. Toutes ces surcharges supplémentaires ajoutent un nouveau paramètre spécifique, par exemple, std::for_each va de:

template< class InputIt, class UnaryFunction >
UnaryFunction for_each( InputIt first, InputIt last, UnaryFunction f );

à:

template< class ExecutionPolicy, class InputIt, class UnaryFunction2 >
void for_each( ExecutionPolicy&& policy, InputIt first, InputIt last, UnaryFunction2 f );

Quel effet cet extra ExecutionPolicy a-t-il sur ces fonctions?

Quelles sont les différences entre:

  • std::execution::seq
  • std::execution::par
  • std::execution::par_unseq

Et quand utiliser l'un ou l'autre?

29
Sombrero Chicken

seq signifie "exécuter séquentiellement" et est exactement la même chose que la version sans politique d'exécution.

par signifie "exécuter en parallèle", ce qui permet à l'implémentation de s'exécuter sur plusieurs threads en parallèle. Vous êtes responsable de vous assurer qu'aucune course de données ne se produit dans f.

par_unseq signifie qu'en plus d'être autorisée à s'exécuter dans plusieurs threads, l'implémentation est également autorisée à entrelacer des itérations de boucle individuelles au sein d'un même thread, c'est-à-dire à charger plusieurs éléments et à exécuter f sur chacun d'eux uniquement par la suite. Ceci est nécessaire pour permettre une implémentation vectorisée.

17
Sebastian Redl

Quelle est la différence entre seq et par/par_unseq?

std::for_each(std::execution::seq, std::begin(v), std::end(v), function_call);

std::execution::seq Signifie exécution séquentielle. C'est la valeur par défaut si vous ne spécifiez pas du tout la politique d'exécution. Il forcera l'implémentation à exécuter tous les appels de fonction en séquence. Il est également garanti que tout est exécuté par le thread appelant.

En revanche, std::execution::par Et std::execution::par_unseq Impliquent une exécution parallèle. Cela signifie que vous promettez que toutes les invocations de la fonction donnée peuvent être exécutées en parallèle en toute sécurité sans violer les dépendances des données. L'implémentation est autorisée à utiliser une implémentation parallèle, mais elle n'est pas obligée de le faire.

Quelle est la différence entre par et par_unseq?

par_unseq Nécessite des garanties plus fortes que par, mais permet des optimisations supplémentaires. Plus précisément, par_unseq Nécessite l'option d'entrelacer l'exécution de plusieurs appels de fonction dans le même thread.

Illustrons la différence avec un exemple. Supposons que vous souhaitiez paralléliser cette boucle:

std::vector<int> v = { 1, 2, 3 };
int sum = 0;
std::for_each(std::execution::seq, std::begin(v), std::end(v), [&](int i) {
  sum += i*i;
});

Vous ne pouvez pas directement paralléliser le code ci-dessus, car cela introduirait une dépendance des données pour la variable sum. Pour éviter cela, vous pouvez introduire un verrou:

int sum = 0;
std::mutex m;
std::for_each(std::execution::par, std::begin(v), std::end(v), [&](int i) {
  std::lock_guard<std::mutex> lock{m};
  sum += i*i;
});

Désormais, tous les appels de fonction peuvent être exécutés en parallèle en toute sécurité et le code ne se cassera pas lorsque vous basculez vers par. Mais que se passerait-il si vous utilisez par_unseq À la place, où un thread pourrait potentiellement exécuter plusieurs appels de fonction non pas en séquence mais simultanément?

Cela peut entraîner un blocage, par exemple, si le code est réorganisé comme ceci:

 m.lock();    // iteration 1 (constructor of std::lock_guard)
 m.lock();    // iteration 2
 sum += ...;  // iteration 1
 sum += ...;  // iteration 2
 m.unlock();  // iteration 1 (destructor of std::lock_guard)
 m.unlock();  // iteration 2

Dans la norme, le terme est vectorization-unsafe. Pour citer P0024R2 :

Une fonction de bibliothèque standard est dangereuse pour la vectorisation si elle est spécifiée pour se synchroniser avec une autre invocation de fonction, ou si une autre invocation de fonction est spécifiée pour se synchroniser avec elle, et si ce n'est pas une fonction d'allocation de mémoire ou de désallocation. Les fonctions de bibliothèque standard non sécurisées pour la vectorisation ne peuvent pas être invoquées par le code utilisateur appelé depuis les algorithmes parallel_vector_execution_policy.

Une façon de sécuriser le code ci-dessus est de remplacer le mutex par un atomique:

std::atomic<int> sum{0};
std::for_each(std::execution::par_unseq, std::begin(v), std::end(v), [&](int i) {
  sum.fetch_add(i*i, std::memory_order_relaxed);
});

Quels sont les avantages d'utiliser par_unseq Par rapport à par?

Les optimisations supplémentaires qu'une implémentation peut utiliser en mode par_unseq Incluent l'exécution vectorisée et les migrations de travail sur les threads (ce dernier est pertinent si le parallélisme des tâches est utilisé avec un planificateur de vol parent).

Si la vectorisation est autorisée, les implémentations peuvent utiliser en interne le parallélisme SIMD (Single-Instruction, Multiple-Data). Par exemple, OpenMP le prend en charge via #pragma omp simd Annotations , ce qui peut aider les compilateurs à générer un meilleur code.

Quand devrais-je préférer std::execution::seq?

  1. exactitude (éviter les courses de données)
  2. éviter les frais généraux parallèles (coûts de démarrage et synchronisation)
  3. simplicité (débogage)

Il n'est pas rare que les dépendances de données imposent l'exécution séquentielle. En d'autres termes, utilisez l'exécution séquentielle si l'exécution parallèle ajouterait des courses de données.

La réécriture et le réglage du code pour une exécution parallèle ne sont pas toujours triviaux. Sauf s'il s'agit d'une partie critique de votre application, vous pouvez commencer avec une version séquentielle et l'optimiser ultérieurement. Vous pouvez également éviter l'exécution parallèle si vous exécutez le code dans un environnement partagé où vous devez être prudent dans l'utilisation des ressources.

Le parallélisme n'est pas non plus gratuit. Si le temps d'exécution total prévu de la boucle est très faible, l'exécution séquentielle sera très probablement la meilleure, même du point de vue des performances pures. Plus les données sont volumineuses et plus chaque étape de calcul est coûteuse, moins la surcharge de synchronisation sera importante.

Par exemple, l'utilisation du parallélisme dans l'exemple ci-dessus n'aurait aucun sens, car le vecteur ne contient que trois éléments et les opérations sont très bon marché. Notez également que la version originale - avant l'introduction des mutex ou des atomiques - ne contenait aucune surcharge de synchronisation. Une erreur courante dans la mesure de l'accélération d'un algorithme parallèle est d'utiliser une version parallèle fonctionnant sur un processeur comme ligne de base. Au lieu de cela, vous devez toujours comparer avec une implémentation séquentielle optimisée sans la surcharge de synchronisation.

Quand devrais-je préférer std::execution::par_unseq?

Tout d'abord, assurez-vous qu'il ne sacrifie pas l'exactitude:

  • S'il y a des courses de données lors de l'exécution d'étapes en parallèle par différents threads, par_unseq N'est pas une option.
  • Si le code est vectorization-unsafe, par exemple, parce qu'il acquiert un verrou, par_unseq N'est pas une option (mais par peut l'être).

Sinon, utilisez par_unseq S'il s'agit d'une pièce critique pour les performances et par_unseq Améliore les performances par rapport à seq.

Quand devrais-je préférer std::execution::par?

Si les étapes peuvent être exécutées en toute sécurité en parallèle, mais que vous ne pouvez pas utiliser par_unseq Car c'est vectorization-unsafe, c'est un candidat pour par.

Comme seq_unseq, Vérifiez qu'il s'agit d'une partie critique des performances et que par est une amélioration des performances par rapport à seq.

Sources:

13
Philipp Claßen