Implémentation d’un répartiteur de mémoire personnalisé

Objectif : Ce didacticiel montre comment utiliser un répartiteur de mémoire personnalisé lors de l’écriture de code ROS 2 C++.

Niveau du didacticiel : Avancé

Durée : 20 minutes

Ce didacticiel vous apprendra comment intégrer un alternateur personnalisé pour les éditeurs et les abonnés afin que l’allocateur de tas par défaut ne soit jamais appelé pendant l’exécution de vos nœuds ROS. Le code de ce tutoriel est disponible ici.

Arrière-plan

Supposons que vous souhaitiez écrire du code sécurisé en temps réel et que vous ayez entendu parler des nombreux dangers liés à l’appel de « new » pendant la section critique en temps réel, car l’allocateur de tas par défaut sur la plupart des plates-formes n’est pas déterministe.

Par défaut, de nombreuses structures de bibliothèques standard C++ alloueront implicitement de la mémoire au fur et à mesure de leur croissance, comme std::vector. Cependant, ces structures de données acceptent également un argument de modèle « Allocator ». Si vous spécifiez un alternateur personnalisé à l’une de ces structures de données, il utilisera cet alternateur pour vous au lieu de l’allocateur système pour agrandir ou réduire la structure de données. Votre alternateur personnalisé peut avoir un pool de mémoire préalloué sur la pile, ce qui peut être mieux adapté aux applications en temps réel.

Dans la bibliothèque cliente ROS 2 C++ (rclcpp), nous suivons une philosophie similaire à la bibliothèque standard C++. Les éditeurs, les abonnés et l’exécuteur acceptent un paramètre de modèle Allocator qui contrôle les allocations effectuées par cette entité lors de l’exécution.

Écrire un répartiteur

Pour écrire un alternateur compatible avec l’interface d’allocation de ROS 2, votre alternateur doit être compatible avec l’interface d’allocation de la bibliothèque standard C++.

La bibliothèque C++11 fournit quelque chose appelé allocator_traits. La norme C++11 spécifie qu’un alternateur personnalisé n’a besoin de remplir qu’un ensemble minimal d’exigences pour être utilisé pour allouer et libérer de la mémoire de manière standard. allocator_traits est une structure générique qui remplit d’autres qualités d’un alternateur basé sur un alternateur écrit avec les exigences minimales.

Par exemple, la déclaration suivante pour un alternateur personnalisé satisferait allocator_traits (bien sûr, vous devrez toujours implémenter les fonctions déclarées dans cette structure) :

template <class T>
struct custom_allocator {
  using value_type = T;
  custom_allocator() noexcept;
  template <class U> custom_allocator (const custom_allocator<U>&) noexcept;
  T* allocate (std::size_t n);
  void deallocate (T* p, std::size_t n);
};

template <class T, class U>
constexpr bool operator== (const custom_allocator<T>&, const custom_allocator<U>&) noexcept;

template <class T, class U>
constexpr bool operator!= (const custom_allocator<T>&, const custom_allocator<U>&) noexcept;

Vous pouvez ensuite accéder à d’autres fonctions et membres de l’allocateur remplis par allocator_traits comme ceci : std::allocator_traits<custom_allocator<T>>::construct(...)

Pour en savoir plus sur toutes les fonctionnalités de allocator_traits, consultez https://en.cppreference.com/w/cpp/memory/allocator_traits .

Cependant, certains compilateurs qui n’ont qu’une prise en charge partielle de C++11, tels que GCC 4.8, nécessitent toujours que les allocateurs implémentent beaucoup de code passe-partout pour fonctionner avec des structures de bibliothèque standard telles que des vecteurs et des chaînes, car ces structures n’utilisent pas `` allocator_traits `` en interne. Par conséquent, si vous utilisez un compilateur avec une prise en charge partielle de C++11, votre alternateur devra ressembler davantage à ceci :

template<typename T>
struct pointer_traits {
  using reference = T &;
  using const_reference = const T &;
};

// Avoid declaring a reference to void with an empty specialization
template<>
struct pointer_traits<void> {
};

template<typename T = void>
struct MyAllocator : public pointer_traits<T> {
public:
  using value_type = T;
  using size_type = std::size_t;
  using pointer = T *;
  using const_pointer = const T *;
  using difference_type = typename std::pointer_traits<pointer>::difference_type;

  MyAllocator() noexcept;

  ~MyAllocator() noexcept;

  template<typename U>
  MyAllocator(const MyAllocator<U> &) noexcept;

  T * allocate(size_t size, const void * = 0);

  void deallocate(T * ptr, size_t size);

  template<typename U>
  struct rebind {
    typedef MyAllocator<U> other;
  };
};

template<typename T, typename U>
constexpr bool operator==(const MyAllocator<T> &,
  const MyAllocator<U> &) noexcept;

template<typename T, typename U>
constexpr bool operator!=(const MyAllocator<T> &,
  const MyAllocator<U> &) noexcept;

Rédaction d’un exemple principal

Une fois que vous avez écrit un répartiteur C++ valide, vous devez le transmettre en tant que pointeur partagé à votre éditeur, abonné et exécuteur.

auto alloc = std::make_shared<MyAllocator<void>>();
auto publisher = node->create_publisher<std_msgs::msg::UInt32>("allocator_example", 10, alloc);
auto msg_mem_strat =
  std::make_shared<rclcpp::message_memory_strategy::MessageMemoryStrategy<std_msgs::msg::UInt32,
  MyAllocator<>>>(alloc);
auto subscriber = node->create_subscription<std_msgs::msg::UInt32>(
  "allocator_example", 10, callback, nullptr, false, msg_mem_strat, alloc);

std::shared_ptr<rclcpp::memory_strategy::MemoryStrategy> memory_strategy =
  std::make_shared<AllocatorMemoryStrategy<MyAllocator<>>>(alloc);
rclcpp::executors::SingleThreadedExecutor executor(memory_strategy);

Vous devrez également utiliser votre alternateur pour allouer tous les messages que vous transmettez le long du chemin de code d’exécution.

auto alloc = std::make_shared<MyAllocator<void>>();

Une fois que vous avez instancié le nœud et ajouté l’exécuteur au nœud, il est temps de lancer :

uint32_t i = 0;
while (rclcpp::ok()) {
  msg->data = i;
  i++;
  publisher->publish(msg);
  rclcpp::utilities::sleep_for(std::chrono::milliseconds(1));
  executor.spin_some();
}

Passer un alternateur au pipeline intra-processus

Même si nous avons instancié un éditeur et un abonné dans le même processus, nous n’utilisons pas encore le pipeline intra-processus.

L’IntraProcessManager est une classe qui est généralement cachée à l’utilisateur, mais pour lui transmettre un alternateur personnalisé, nous devons l’exposer en l’obtenant à partir du contexte rclcpp. L’IntraProcessManager utilise plusieurs structures de bibliothèque standard, donc sans un alternateur personnalisé, il appellera le new par défaut.

auto context = rclcpp::contexts::default_context::get_global_default_context();
auto ipm_state =
  std::make_shared<rclcpp::intra_process_manager::IntraProcessManagerState<MyAllocator<>>>();
// Constructs the intra-process manager with a custom allocator.
context->get_sub_context<rclcpp::intra_process_manager::IntraProcessManager>(ipm_state);
auto node = rclcpp::Node::make_shared("allocator_example", true);

Assurez-vous d’instancier les éditeurs et les abonnés APRÈS avoir construit le nœud de cette manière.

Tester et vérifier le code

Comment savez-vous que votre répartiteur personnalisé est effectivement appelé ?

La chose évidente à faire serait de compter les appels effectués aux fonctions allocate et deallocate de votre allocation personnalisée et de comparer cela aux appels à new et delete.

L’ajout du comptage à l’allocateur personnalisé est simple :

T * allocate(size_t size, const void * = 0) {
  // ...
  num_allocs++;
  // ...
}

void deallocate(T * ptr, size_t size) {
  // ...
  num_deallocs++;
  // ...
}

Vous pouvez également remplacer les opérateurs globaux new et delete :

void operator delete(void * ptr) noexcept {
  if (ptr != nullptr) {
    if (is_running) {
      global_runtime_deallocs++;
    }
    std::free(ptr);
    ptr = nullptr;
  }
}

void operator delete(void * ptr, size_t) noexcept {
  if (ptr != nullptr) {
    if (is_running) {
      global_runtime_deallocs++;
    }
    std::free(ptr);
    ptr = nullptr;
  }
}

où les variables que nous incrémentons ne sont que des entiers statiques globaux, et is_running est un booléen statique global qui est basculé juste avant l’appel à spin.

L’exemple d’exécutable <https://github.com/ros2/demos/blob/rolling/demo_nodes_cpp/src/topics/allocator_tutorial.cpp>`__ affiche la valeur des variables. Pour exécuter l’exemple d’exécutable, utilisez :

allocator_example

ou, pour exécuter l’exemple avec le pipeline intra-processus sur :

allocator_example intra-process

Vous devriez obtenir des nombres comme :

Global new was called 15590 times during spin
Global delete was called 15590 times during spin
Allocator new was called 27284 times during spin
Allocator delete was called 27281 times during spin

Nous avons détecté environ 2/3 des allocations/désallocations qui se produisent sur le chemin d’exécution, mais d’où vient le 1/3 restant ?

En fait, ces allocations/désallocations proviennent de l’implémentation DDS sous-jacente utilisée dans cet exemple.

Prouver cela n’entre pas dans le cadre de ce didacticiel, mais vous pouvez consulter le test du chemin d’allocation qui est exécuté dans le cadre des tests d’intégration continue ROS 2, qui revient sur le code et détermine si certains appels de fonction proviennent du implémentation rmw ou dans une implémentation DDS :

https://github.com/ros2/realtime_support/blob/rolling/tlsf_cpp/test/test_tlsf.cpp#L41

Notez que ce test n’utilise pas l’allocateur personnalisé que nous venons de créer, mais l’allocateur TLSF (voir ci-dessous).

L’allocateur TLSF

ROS 2 prend en charge l’allocateur TLSF (Two Level Segregate Fit), qui a été conçu pour répondre aux exigences en temps réel :

https://github.com/ros2/realtime_support/tree/rolling/tlsf_cpp

Pour plus d’informations sur TLSF, voir http://www.gii.upv.es/tlsf/

Notez que l’allocateur TLSF est sous licence double GPL/LGPL.

Un exemple de travail complet utilisant l’allocateur TLSF est ici : https://github.com/ros2/realtime_support/blob/rolling/tlsf_cpp/example/allocator_example.cpp