Les allocateurs pmr (Polymorphic Memory Resource) depuis C++ 17

Pour en savoir plus sur les allocateurs standards depuis C++ 11, voir allocateurs_cpp11.html.

Les allocateurs ont été introduits dans C++ pour permettre au code client d'utiliser un conteneur tel que std::list<T> ou std::vector<T> tout en contrôlant les mécanismes d'allocation (et de libération) dynamique de mémoire.

Écrire un allocateur pour un conteneur avec C++ 03 est une tâche relativement complexe. Depuis C++ 11, cette tâche a été significativement allégée, une part importante du travail étant synthétisé à partir de traits.

Toutefois, le design des allocateurs traditionnels fait de ceux-ci une partie du type des conteneurs qui les utilisent. Ainsi, examinez l'extrait de code suivant (https://wandbox.org/permlink/0XDOClPi3zbRjf3r)  :

#include <memory>
//
// allocateur trivial de C++11
//
template <class T>
   struct allocateur_minimaliste {
      using value_type = T;
      allocateur_minimaliste(/*ctor args*/) = default;
      template <class U>
         allocateur_minimaliste(const allocateur_minimaliste<U> &) {
         }
      value_type* allocate(std::size_t n) {
         auto p = static_cast<value_type*>(std::malloc(n * sizeof(T)));
         if (!p) throw std::bad_alloc{};
         return p;
      }
      void deallocate(value_type *p, std::size_t) {
         free(p);
      }
   };

template <class T, class U>
   constexpr bool operator==(const allocateur_minimaliste<T>&, const allocateur_minimaliste<U>&) {
      return true;
   }
template <class T, class U>
   constexpr bool operator!=(const allocateur_minimaliste<T>&, const allocateur_minimaliste<U>&) {
      return false;
   }
   
#include <vector>
int main() {
   using namespace std;
   vector<int> v{ 2,3,5,7,11 }; // vector<int, std::allocator<int>>
   vector<int> v0;              // vector<int, std::allocator<int>>
   vector<int, allocateur_minimaliste<int>> w;
   v0 = v; // Ok
   // w = v; // ne compile pas : v et w ne sont pas du même type!
}

La tentative d'affectation de v à v0 compile sans peine (l'opérateur = est défini entre deux std::vector de même type, donc entre deux std::vector<T,A> pour un même type T... et un même type A). En retour, l'affectation de v à w ne compile pas car v et w sont de types distincts : omettant std:: pour allégr l'écriture, v est un vector<int,allocator<int>> alors que w est un vector<int,allocateur_minimaliste<int>> (même T, mais pas le même A).

 Pourtant, on pourrait défendre un argumentaire à l'effet que l'affectation pourrait raisonnablement ne s'exprimer qu'en termes des valeurs. Après tout, à quoi bon exiger la même stratégie d'allocation à gauche et à droite de l'affectation? Un argumentaire pourrait être fait pour d'autres situations, comme écrire une fonction comparant deux vector<T> sans tenir compte du deuxième paramètre du template (le A, l'allocateur). Par exemple (https://wandbox.org/permlink/lqAHnAR1IdmiypoV) :

#include <memory>
//
// allocateur trivial de C++11
//
template <class T>
   struct allocateur_minimaliste {
      using value_type = T;
      allocateur_minimaliste(/*ctor args*/) = default;
      template <class U>
         allocateur_minimaliste(const allocateur_minimaliste<U> &) {
         }
      value_type* allocate(std::size_t n) {
         auto p = static_cast<value_type*>(std::malloc(n * sizeof(T)));
         if (!p) throw std::bad_alloc{};
         return p;
      }
      void deallocate(value_type *p, std::size_t) {
         free(p);
      }
   };

template <class T, class U>
   constexpr bool operator==(const allocateur_minimaliste<T>&, const allocateur_minimaliste<U>&) {
      return true;
   }
template <class T, class U>
   constexpr bool operator!=(const allocateur_minimaliste<T>&, const allocateur_minimaliste<U>&) {
      return false;
   }
   
#include <vector>
#include <algorithm>
#include <iostream>
template <class T>
   bool comparer(const std::vector<T> &v0, const std::vector<T> &v1) {
      return std::size(v0) == std::size(v1) &&
             std::equal(std::begin(v0), std::end(v0), std::begin(v1));
   }
int main() {
   using namespace std;
   vector<int> v{ 2,3,5,7,11 }; // vector<int, std::allocator<int>>
   vector<int> v0;              // vector<int, std::allocator<int>>
   vector<int, allocateur_minimaliste<int>> w;
   v0 = v; // Ok
    if(comparer(v, v0))
        std::cout << "Ok"; // Ok
    // if(comparer(v, w))
    //   std::cout << "Ok"; // ne compile pas (pas le même A)
}

Autre enjeu : utiliser plusieurs allocateurs avec plusieurs conteneurs accroît le nombre de types, ce qui accroît le nombre de fonctions dans le binaire généré, et par conséquent la taille des binaires.

En contrepartie, l'accès aux fonctions d'allocation et de libération de mémoire est typiquement « inliné » et est donc aussi rapide que possible, et ce design fonctionne... depuis des décennies!

L'approche pmr

Depuis C++ 17, une nouvelle approche, l'approche pmr (Polymorphic Memory Resources) est supportée de manière standard. Cette approche est complémentaire à l'approche traditionnelle, et elle ne la remplace pas. L'idée derrière cette approche est la suivante :

Ainsi, les allocateurs pmr accompagnent des conteneurs pmr. Ceci permet un ensemble de nouvelles pratiques dans le contexte de la gestion des mécanismes d'allocation dynamique de mémoire. En effet, il est plus facile désormais d'offrir des stratégies composables standards pour faciliter la mise en application d'approches typiques de gestion de mémoire. Par exemple, avec un std::pmr::vector<T> il faut utiliser un std::pmr::polymorphic_allocator*. Heureusement, les interfaces de std::vector<T> et de std::pmr::vector<T> sont identiques.

Approche pmr : éléments clés

Pour utiliser un allocateur pmr, il faut au minimum comprendre deux idées :

Il est relativement rare que l'on spécialise std::pmr::polymorphic_allocator, mais il serait possible de le faire (p. ex. : pour tracer les allocations et les libérations de mémoire). En retour, il est probable que l'on puisse envisager spécialiser std::pmr::memory_resource.

Exemple : gestion de mémoire sur la pile

Supposons un cas typique de recours aux allocateurs, soit la gestion d'un tampon de mémoire préalablement alloué sur la pile. Ce cas d'utilisation survient dans de nombreux cas de systèmes à basse latence (jeux, finance, systèmes embarqués) où la fonctionnalité d'un std::vector est souhaitée, où le pire cas d'allocation est connu a priori, et où l'indéterminisme associé à une allocation de mémoire est inacceptable. En utilisant de la mémoire existante ou préalablement rendue disponible (ici : un tampon sur la pile), tout le volet ayant trait au coût des allocations disparaît.

La technique est donc :

Par exemple (https://wandbox.org/permlink/rEPTYwjLYSCTw3VS) :

#include <iostream>
#include <vector>
#include <string>
#include <memory_resource>
int main() {
   enum { N = 10'000 };
   alignas(int) char buf[N * sizeof(int)]{};
   std::pmr::monotonic_buffer_resource
      res{ std::begin(buf), std::size(buf) };
   std::pmr::vector<int> v{ &res };
   v.reserve(N);
   for (int i = 0; i != N; ++i)
      v.emplace_back(i + 1);
   for (auto n : v)
      std::cout << n << ' ';
   std::cout << '\n' << std::string(70, '-') << '\n';
   for (char * p = buf; p != buf + std::size(buf); p += sizeof(int))
      std::cout << *reinterpret_cast<int*>(p) << ' ';
}

Notez que les deux affichages produisent le même résultat, car v insèrera les données dans buf. Dans cet exemple, il n'y a aucune allocation dynamique de mémoire, ce qui confère un caractère déterministe au temps d'exécution du programme (au moins en regard du vector).

Exemple : propagation de stratégie

Il est possible, avec le modèle pmr, de propager une stratégie d'allocation d'un conteneur englobant vers un conteneur englobé. Le cas typique donné en exemple est un vector<string> : associer une std::pmr::memory_resource à un std::pmr:vector<std::pmr::string> fait en sorte que la mémoire du vecteur soit gérée par la ressource, mais aussi que la mémoire des string soit gérée par la même ressource.

Par exemple (https://wandbox.org/permlink/90F02Go2V4FEjL1F) :

#include <iostream>
#include <vector>
#include <string>
#include <memory_resource>
int main() {
   auto make_str = [](const char *p, int n) -> std::pmr::string {
      auto s = std::string{ p } + std::to_string(n);
      return { std::begin(s), std::end(s) };
   };
   enum { N = 2'000 };
   alignas(std::pmr::string) char buf[N]{};
   std::pmr::monotonic_buffer_resource
      res{ std::begin(buf), std::size(buf) };
   std::pmr::vector<std::pmr::string> v{ &res };
   for (int i = 0; i != 10; ++i)
      v.emplace_back(make_str("J'aime mon prof ", i));
   for (auto s : v)
      std::cout << s << ' ';
   std::cout << '\n' << std::string(70, '-') << '\n';
   for (char c : buf)
       std::cout << c;
}

La première boucle, parcourant les string une à une, affichera les messages qui auront été insérés dans le tampon. La seconde, qui parcourt les bytes du substrat un à un, affichera entre autres le texte des string (celles-ci étant courtes, elles sont sujettes à l'optimisation SSO), en plus de valeurs qui ne semblent pas faire de sens mais sont en fait les métadonnées (nombre d'éléments, capacité, etc.) du vector et des diverses string.

À noter : le polymorphic_allocator utilisera la ressource jusqu'à épuisement de la mémoire qui y est disponible. Par la suite, il basculera upstream, soit vers un mécanisme « englobant » quand il y en a un – ici, faute d'un tel mécanisme, le upstream sera un appel à ::operator new tout simplement. Ainsi, dans notre exemple, si le tampon buf est de taille insuffisante pour nos besoins, le programme ne plantera pas (mais il sera quelque peu ralenti, et son comportement à l'exécution sera moins déterministe).

Exemple de ressource « maison » : un traceur d'allocations

Il est bien sûr possible d'implémenter notre propre dérivé de std::memory_resource; il suffit pour ce faire de spécialiser trois services, soit do_allocate(), do_deallocate() et do_equals(). À titre d'exemple, voici un petit système permettant de tracer les allocations et les déallocations, tout en assurant l'allocation et la libération de la mémoire à travers une memory_resource standard (le new_delete_resource, qui appelle ::operator new et ::operator delete tout simplement).

Le code suit (https://wandbox.org/permlink/Dq8TXlx7ofWU2qrk) :

#include <iostream>
#include <vector>
#include <string>
#include <memory_resource>
class tracing_resource : public std::pmr::memory_resource {
   void* do_allocate( std::size_t bytes, std::size_t alignment ) override {
       std::cout << "do_allocate of " << bytes << " bytes\n";
       return upstream->allocate(bytes, alignment);
   }
   void do_deallocate( void* p, std::size_t bytes, std::size_t alignment ) override {
       std::cout << "do_deallocate of " << bytes << " bytes\n";
       return upstream->deallocate(p, bytes, alignment);
   }
   bool do_is_equal( const std::pmr::memory_resource& other ) const noexcept override {
       return upstream->is_equal(other);
   }
   std::pmr::memory_resource *upstream;
public:
   tracing_resource(std::pmr::memory_resource *upstream) noexcept : upstream{ upstream } {
   }
};
int main() {
   enum { N = 100 };
   tracing_resource traceur{ std::pmr::new_delete_resource() };
   std::pmr::vector<int> v{ &traceur };
   for (int i = 0; i != N; ++i)
      v.emplace_back(i + 1);
   for (auto s : v)
      std::cout << s << ' ';
}

C'est quand même simple, n'est-ce pas?

Quelques ressources de mémoire standards

Avec C++ 17, le standard offre un humble éventail de dérivés de std::pmr::memory_resource :

Coûts du modèle pmr

Le modèle pmr a plusieurs bons côtés, mais il comporte aussi quelques coûts. Entre autres :

Comme c'est souvent le cas, mesurez avant de faire un choix d'approche.

Lectures complémentaires

Quelques liens pour enrichir votre compréhension.


Valid XHTML 1.0 Transitional

CSS Valide !