Comment partager efficacement des données entre threads en C++

Le problème exposé dans ce sujet a été résolu.

Bonjour,

Je suis en train d’écrire un programme C++ dans lequel je souhaite avoir une plage mémoire partagé entre 2 threads: un thread (W) écrit des données petit à petit et l’autre thread ® va lire ces données de temps en temps. La mémoire représente une image où chaque pixel est stocké dans un entier 32 bits (RGBA).

La contrainte de cohérence que j’ai est à peine plus forte que celle d'eventual consistency:

  • si W s’arrête d’écrire dans la mémoire, il doit exister un instant tt à partir duquel pour tout t>tt' > t, R doit lire la dernière valeur écrite par W dans chaque pixel.
  • Toute valeur lu par R doit avoir été écrite par W.

Évidemment, je souhaiterais que W ait des performances les plus proches possible de celles qu’il aurait si R n’existait pas et qu’il y ait le moins de délai possible entre le moment où W écrit dans un pixel et celui où R peut lire cette valeur.

Pour l’implémentation, le plus simple serait évidemment de ne pas avoir de synchronisation du tout. Seulement, ce n’est pas légal en C++ puisqu’avoir une même zone mémoire écrite et lu par 2 threads différents est une data race à moins que les opérations soient atomiques ou que l’une arrive avant l’autre (en suivant la définition C++ du terme)1. Et une data race, c’est un comportement indéterminé et je voudrais éviter ça. Utiliser le arrive avant nécessite une synchronisation entre les deux threads (via mutex, sémaphore, barrière ou autre), ce qui a typiquement un impact négatif assez fort sur les performances. Il ne me reste donc que l’option d’utiliser des opérations atomiques.

Pour l’instant, mon implémentation se résume à ceci:

// Initialize data
auto pixels = std::make_unique<std::vector<std::atomic<uint32_t>>>(num_pixels);
for (auto& pixel : *pixels) {
  pixel = 0;
}

// Thread R
while (true) {
  // Read data
  for (int i = 0; i < pixels->size(); ++i) {
    const uint32_t pixel = (*pixels)[i].load(std::memory_order_relaxed);
    // Use pixel
  }
  // Do something else
}

// Thread W
while (true) {
  const size_t i = ...;  // Find the next pixel to write
  const uint32_t color = ...;   // Find the color of that pixel
  (*pixels)[i].store(color, std::memory_order_relaxed);
}

Le programme en question fonctionne sans problème. En revanche, je suis un peu déçu du fait que le compilo (clang++ 11.1.0) refuse de vectoriser la boucle de lecture à cause de l’opération atomique: remark: loop not vectorized: read with atomic ordering or volatile read [-Rpass-analysis=loop-vectorize].

De ce que je comprends, std::memory_order_relaxed spécifie uniquement que l’opération doit être atomique, mais ne spécifie rien pour la synchronisation, ce qui est exactement ce que je veux. Est-ce que quelqu’un sait d’où viens ce manque de vectorisation? Du compilo trop prudent? De mon manque de compréhension de l’opération atomique que j’utilise? Autre?

Au passage, si quelqu’un à une meilleur idée de comment implémenter mon programme, je suis preneur.

+0 -0

Salut !

Pour faire court, clang ne vectorise pas la boucle pour une question d’efficacité. Sur processeur x64, les lectures et écritures atomiques sur des entiers jusqu’à 8 octets n’ont rien de particulier : si le compilateur était naïf, et ne faisait pas des optimisations dans touts les sens, il n’y aurait pas de différence entre un read/store et une assignation normale. En revanche, dès que tu dépasses les 8 octets (ce qu’il se passe en vectorisant une boucle, donc), il faut utiliser des mécanismes de synchronisation explicitement. Ce qui, comme tu l’as toi-même dit, est lourd en ressources.

Maintenant, pour le cas de ton programme : si je comprends bien, tu ne te soucies pas de savoir si l’image est cohérente ? Puisque dans ton code, tu as une seule instance d’image, et tu ne te soucies jamais de savoir si le pixel que tu analyses fait partie de la même image que le pixel précédent. Si c’est bien ça, en quoi d’éventuelles data races iraient contre ton but ? Et si ça ne contrevient à rien, peut-être que tu pourrais revenir à des structures de données plus simples et abandonner atomic, ça permettrait à clang de vectoriser la boucle et serait généralement plus léger.

+1 -1

Ok, je me doutais bien que les lectures/écritures d’entiers 32/64 bits étaient atomiques sur un processeur x64, par contre je n’avais pas réalisé que les lectures/écritures vectorisés ne l’était pas forcément.

Oui, je ne me soucis pas trop de savoir si l’image est cohérente. Mon but est d’avoir une sorte de pré-visualisation en temps réel de la génération de l’image qui soit le plus transparente possible pour le processus de génération. Je voulais éviter les data races parce que c’est des comportements indéterminés et que j’ai pas trop envie d’avoir des démons qui commencent à sortir de mon nez.

Edit: en regardant un peu les questions d’atomicité pour les opérations vectorisés, il semblerait effectivement qu’il n’y ai pas de garantie d’atomicité, pas même par élément (StackOverflow). Donc écrire 2 entiers 64 bits l’un après l’autre garanti que les deux écritures soient atomiques, par contre utiliser une instruction SSE/AVX pour faire la même chose ne garanti absolument rien.

+1 -0

Si je comprends bien, tu n’as qu’un seul thread qui écrit dans cette structure de données?

Parce que si c’est le cas, il n’y a en soi pas de risque de data-race, puisqu’une data-race n’apparaît que quand deux (ou plus) threads essayent d’écrire une valeur au même emplacement. Le seul risque, c’est que le thread lecteur utilise une donnée obsolète, mais ce n’est pas forcément un souci, en fonction du problème à résoudre.

+0 -0

Non, une data-race peut aussi arriver avec un seul thread qui écrit. En manipulant des données plus complexe que des entiers ou des flottants (genre une hash map), c’est très vite arrivé que le thread lecteur lise une info, qu’ensuite le thread modificateur change quelque chose qui rend l’info précédemment lu invalide et qu’ensuite le thread lecteur utilise l’info désormais invalide, avec tous les soucis que ça peut générer.

Pour clore le topic, j’ai essayé de remplacer la boucle de load sur le vecteur d'atomics par un simple memcpy et j’ai pas réussi à mesurer une différence de performance entre les deux. J’en conclu donc que si je veux améliorer les performances, il faudra que je trouve un moyen de ne pas recopier la totalité de l’image à chaque fois. J’ai pas mal d’idées de comment faire ça, mais ça va beaucoup augmenter le niveau de complexité et donc je ne le ferais que si ça deviens nécessaire.

Connectez-vous pour pouvoir poster un message.
Connexion

Pas encore membre ?

Créez un compte en une minute pour profiter pleinement de toutes les fonctionnalités de Zeste de Savoir. Ici, tout est gratuit et sans publicité.
Créer un compte