Lock-free pool allocators

Lock-free pool allocators - C++ - Programmation

Marsh Posté le 29-12-2014 à 22:42:11    

Hello :hello:  
 
Je commence à regarder la programmation concurrentiel en lock-free, et j'avoue que ça me séduit très fortement :D
Histoire de m'échauffer un peu, j'ai voulu commencer par implémenter un pool allocator en lock-free.
(Pour des raisons d'apprentissage / cas ultra foireux de la mort caca, dans un vrai code j'essaierai plutôt d'avoir une architecture qui minimise au maximum le partage d'objets..)
 
J'ai une version non thread-safe déjà implémentée qui marche comme ça en gros :  
 
Chaque chunk est une zone mémoire de N bytes (où N >= sizeof(void*)).
Dans la mesure où un chunk est "libre", l'adresse vers le prochain chunk free est stockée en début, ça correspond globalement à ça :
 

Code :
  1. struct pool_chunk
  2. {
  3.    pool_chunk* next_chunk;
  4.    // padding mémoire ici -pas en dur, biensûr-
  5. };


 
Et globalement, l'alloc/dealloc donne ça (sans les check de "apu chunks"/alignment demandé/taille demandée/constructeur/.... ) :
 

Code :
  1. class pool_allocator
  2. {
  3.     private:
  4.         pool_chunk* m_next_free_chunk;
  5.    public:
  6.        void* allocate( ... )
  7.        {
  8.             pool_chunk* free_chunk = m_next_free_chunk;
  9.             m_next_free_chunk = free_chunk->next_chunk;
  10.             return reinterpret_cast<void*>(free_chunk);
  11.        }
  12.        void deallocate( void* address )
  13.        {
  14.           pool_chunk* new_free_chunk = reinterpret_cast<pool_chunk*>(address);
  15.           new_free_chunk->next_chunk = m_next_free_chunk;
  16.           m_next_free_chunk = new_free_chunk;
  17.        }
  18. };


 
Jusqu'ici tout va bien (O(1) en alloc/dealloc, c'est du propre :))
 
Comme le processus est assez simple, j'ai pensé qu'une version lock-free devrait être pas trop chiante à implémenter.
 
Ma première idée (que je vais pas tellement détailler) consistait à avoir un tableau de pool_chunk* comme racine.
Chaque entrée de tableau était associé à un compteur atomic.
L'allocation se faisait avec le même process en gros, sauf qu'au lieu de bosser direct sur un pointeur, il y avait une boucle active qui incrémentait chaque entrée une-à-une en récupérant la valeur précédente.
Si elle était à 0, ça veut dire que la racine correspondante n'était pas utilisée, et donc le thread pouvait tranquillement faire son affaire, et finir par relâcher l'entrée en décrémentant le compteur.
Si c'était pas à 0, il décrémentait direct le compteur et passait au suivant (en gros).
 
Bon sauf que c'est carrément un spinlock moins "hard", et que toute l'optim se base sur un principe que globalement le bordel est équilibré (ce qui est pas forcément juste :D, peut-être en parcourant linéairement avec un offset calculé selon l'ID du thread...)
 
Bref, du coups je viens de partir sur une autre idée un peu plus "fancy" qui a l'air de faire le café (testé en release avec 40 threads + 4096 allocs, aucune collisions, ~10x plus rapide qu'un mutex sachant que la version d'avant était de 2 à 6x plus rapide qu'un mutex).
 
Le principe, c'est de coller un flag pour chaque chunk qui dit si oui ou non il est en train d'être manipulé par un autre thread (copie locale).
Pour ce flag, je part du principe que l'alignment minimal est de 2 (assert), du coups le dernier bit de l'adresse sera toujours 0.
Je me sert de ce flag pour ne pas manipuler plusieurs fois le même chunk et avoir aucune collisions, voilà l'implém (en gros) :  
 

Code :
  1. class atomic_pool_chunk
  2. {
  3.     std::atomic<intptr_t> next_chunk;
  4. };


 

Code :
  1. class atomic_pool_allocator
  2. {
  3.     protected:
  4.         std::atomic<intptr_t> m_root;
  5.     public:
  6.         void* allocate(...)
  7.         {
  8.             const intptr_t kmask0 = ~intptr_t(1); // Masque logique à 111...0
  9.             const intptr_t kmask1 =   intptr_t(1); // Masque logique à 000...1
  10.             intptr_t i_next_chunk = 0;
  11.             intptr_t i_chunk = m_root.load();
  12.             atomic_pool_chunk p_chunk = nullptr;
  13.             for(;;)
  14.             {
  15.                 p_chunk = reinterpret_cast<atomic_pool_chunk*>( i_chunk & kmask0 ); // On récupère le chunk courant, en forcant le dernier bit à 0 si le précédent est flagé
  16.                 i_next_chunk = p_chunk->next_chunk.fetch_or( kmask1 ); // On essaie de flagger ce chunk comme "utilisé" et on récupère la valeur précédente.
  17.                 if ( ( i_chunk & kmask1 ) == 0 ) // youpi, le chunk était pas flagé donc on a le pouvoir suprême sur celui-ci
  18.                     break;
  19.                
  20.                 i_chunk = i_next_chunk;
  21.             }
  22.             // On a récupéré un chunk valide (le cas de "apu chunk" est pas encore traité ceci-dit..)
  23.             // Vu que le i_chunk était bien flaggé, forcément p_chunk pointe sur la bonne adresse)
  24.             root.store( p_chunk->next_chunk.load() ); // Cette ligne me semble un peu douteuse, je part du principe que le load correspond forcément au dernier store sur le next_chunk, et je restock ça sur la racine.
  25.             return reinterpret_cast<void*>(p_chunk);
  26.         }
  27. };


 
Voilà le code pour l'allocation( sans les check ni le cas où il n'y a plus de chunks dispo, mais c'est trivial), j'imaginais un système équivalent pour la deallocation qui permettrait d'assigner au suivant un pointeur pas manipulé par un thread (et de relâcher le bit lors du store), histoire de jamais brancher un nouveau noeud sur un noeud qui va peut-être partir..
 
Avant de m'aventurer plus loin, j'aimerai savoir si ce code est bien data-race-free :??:
 
A priori ça me paraît OK, sachant que toutes les transactions mémoires par défaut sont en sequentially-consistent donc il ne devrait pas y avoir de soucis de réorganisation mémoire foireuse dans ce cas.. ?
Je peux pas tellement partir du postulat que "après x essais sur mon CPU  avec 40threads et 4096 allocs c'est OK !".
J'imagine aussi que le code peut être amélioré en utilisant des barrières mémoires adéquat ?
J'avoue qu'après avoir vu les deux speechs de Herb Sutter, ça fait un peu peur tout ça :D
 
 
Désolé pour le pavé et merci pour votre aide ! [:mad_overclocker]  


---------------
Perhaps you don't deserve to breathe
Reply

Marsh Posté le 29-12-2014 à 22:42:11   

Reply

Sujets relatifs:

Leave a Replay

Make sure you enter the(*)required information where indicate.HTML code is not allowed