Softwareentwicklung: Optimierung mit Allokatoren in C++17​

Das Ende der Miniserie zu C++17 stellt die polymorphen Allokatoren zum Optimieren der Speicherzuweisung vor.

In Pocket speichern vorlesen Druckansicht 4 Kommentare lesen
Lesezeit: 3 Min.
Von
  • Rainer Grimm
Inhaltsverzeichnis

Polymorphe Allokatoren in C++17 helfen dabei, die Speicherzuweisung sowohl auf die Performanz als auch auf die Wiederverwendung von Speicher zu optimieren.

Modernes C++ – Rainer Grimm

Rainer Grimm ist seit vielen Jahren als Softwarearchitekt, Team- und Schulungsleiter tätig. Er schreibt gerne Artikel zu den Programmiersprachen C++, Python und Haskell, spricht aber auch gerne und häufig auf Fachkonferenzen. Auf seinem Blog Modernes C++ beschäftigt er sich intensiv mit seiner Leidenschaft C++.

Das folgende Programm stammt von cppreference.com/monotonic_buffer_resource. Ich werde seinen Performanz-Test für Clang und den MSVC-Compiler erweitern und erklären.

// pmrPerformance.cpp
// https://en.cppreference.com/w/cpp/memory/monotonic_buffer_resource

#include <array>
#include <chrono>
#include <cstddef>
#include <iomanip>
#include <iostream>
#include <list>
#include <memory_resource>
 
template<typename Func>
auto benchmark(Func test_func, int iterations)        // (1)
{
    const auto start = std::chrono::system_clock::now();
    while (iterations-- > 0)
        test_func();
    const auto stop = std::chrono::system_clock::now();
    const auto secs = std::chrono::duration<double>(stop - start);
    return secs.count();
}
 
int main()
{
    constexpr int iterations{100};
    constexpr int total_nodes{2'00'000};
 
    auto default_std_alloc = [total_nodes]            // (2)
    {
        std::list<int> list;
        for (int i{}; i != total_nodes; ++i)
            list.push_back(i);
    };
 
    auto default_pmr_alloc = [total_nodes]            // (3)
    {
        std::pmr::list<int> list;
        for (int i{}; i != total_nodes; ++i)
            list.push_back(i);
    };
 
    auto pmr_alloc_no_buf = [total_nodes]             // (4)
    {
        std::pmr::monotonic_buffer_resource mbr;
        std::pmr::polymorphic_allocator<int> pa{&mbr};
        std::pmr::list<int> list{pa};
        for (int i{}; i != total_nodes; ++i)
            list.push_back(i);
    };
 
    auto pmr_alloc_and_buf = [total_nodes]            // (5)
    {
        // enough to fit in all nodes:
        std::array<std::byte, total_nodes * 32> buffer; 
        std::pmr::monotonic_buffer_resource mbr{buffer.data(), 
          buffer.size()};
        std::pmr::polymorphic_allocator<int> pa{&mbr};
        std::pmr::list<int> list{pa};
        for (int i{}; i != total_nodes; ++i)
          list.push_back(i);
    };
 
    const double t1 = benchmark(default_std_alloc, iterations);
    const double t2 = benchmark(default_pmr_alloc, iterations);
    const double t3 = benchmark(pmr_alloc_no_buf , iterations);
    const double t4 = benchmark(pmr_alloc_and_buf, iterations);
 
    std::cout << std::fixed << std::setprecision(3)
              << "t1 (default std alloc): " << t1 
              << " sec; t1/t1: " << t1/t1 << '\n'
              << "t2 (default pmr alloc): " << t2 
              << " sec; t1/t2: " << t1/t2 << '\n'
              << "t3 (pmr alloc  no buf): " << t3 
              << " sec; t1/t3: " << t1/t3 << '\n'
              << "t4 (pmr alloc and buf): " << t4 
              << " sec; t1/t4: " << t1/t4 << '\n';
}

Dieser Performanz-Test in (1) führt die Funktionen in (2) - (5) hundertmal aus (constexpr int iterations{100}). Jeder Aufruf der Funktionen erzeugt eine std::pmr::list<int> mit zweihunderttausend Knoten (constexpr int total_nodes{2'00'000}). Die Knoten der einzelnen Listen werden auf unterschiedliche Weise allokiert:

  • (2): std::list<int> verwendet den globalen operator new
  • (3): std::pmr::list<int> verwendet die spezielle Speicherressource std::pmr::new_delete_resource
  • (4): std::pmr::list<int> verwendet std::pmr::monotonic_buffer ohne einen vorab zugewiesenen Puffer auf dem Stack
  • (5): std::pmr::list verwendet std::pmr::monotonic_buffer mit einem vorab zugewiesenen Puffer auf dem Stack

Der Kommentar zur letzten Funktion (5) behauptet, dass auf dem Stack genug Platz ist, um alle Knoten unterzubringen: "enough to fit in all nodes". Das war auf meinem Linux-PC richtig, aber nicht auf meinem Windows-PC. Unter Linux ist die Standardgröße für den Stack 8 MByte, unter Windows aber nur 1 MByte. Das hatte zur Folge, dass meine Programmausführung unter Windows mit dem MSVC-Compiler und dem Clang-Compiler lautlos fehlschlug. Ich habe das Problem behoben, indem ich mithilfe von editbin.exe die Stack-Größe meiner MSVC- und Clang-Executables geändert habe:

Hier sind endlich die Zahlen. Der Referenzwert ist die Zuweisung mit std::list<int> (Zeile 2). Vergleiche nicht die absoluten, sondern die relativen Zahlen, denn ich habe einen virtualisierten Linux-PC und einen nicht-virtuellen Windows-PC verwendet. Natürlich habe ich die maximale Optimierung aktiviert. Das bedeutet (/Ox) für den MSVC Compiler und (-Ox) für die GCC und Clang Compiler.

  • Clang Compiler
  • GCC Compiler
  • MSVC-Compiler

Interessanterweise war die Speicherzuweisung mit der Speicherressource std::pmr::new_delete_resource immer die langsamste. Im Gegenteil dazu stellt std::pmr::monotonic_buffer die schnellste Speicherzuweisung dar. Das gilt vor allem, wenn man einen vorab zugewiesenen Puffer auf dem Stack verwendet. Auf Windows ist dadurch die Speicherallokation etwa zehnmal schneller.

iX-Workshop mit Rainer Grimm: C++20 Deep Dive

Die mit C++20 eingeführten Concepts haben zusammen mit der Ranges-Bibliothek, Modulen und Coroutinen das Erstellen von modernen C++- Anwendungen neu definiert. Vom 7. bis zum 9. November 2023 bringt Sie Rainer Grimm in seinem Intensiv-Workshop C++20: die neuen Konzepte umfassend erklärt auf Stand und geht auf die vielen nützlichen Funktionen ein, die C++20 mitbringt.

Die Speicherressource std::pmr::new_delete_resource bietet noch mehr Optimierung an.

std::pmr::monotonic_buffer ermöglicht die Wiederverwendung von Speicher, sodass man sich das Freigeben des Speichers sparen kann.

// reuseMemory.cpp

#include <array>
#include <cstddef>
#include <iostream>
#include <memory_resource>
#include <string>
#include <vector>

int main() {
 
  std::array<std::byte, 2000> buf;

  for (int i = 0; i < 100; ++i) {                          // (1)
    std::pmr::monotonic_buffer_resource pool{buf.data(), 
                        buf.size(),                        // (2)
                        std::pmr::null_memory_resource()};
    std::pmr::vector<std::pmr::string> myVec{&pool};
    for (int j = 0; j < 16; ++j) {                         // (3)
      myVec.emplace_back("A short string");
    }
  }
}

Dieses Programm allokiert ein std::array mit 2000 Bytes: std::array<std::byte, 2000>. Dieser vom Stack zugewiesene Speicher wird hundertmal wiederverwendet (1). Der std::pmr::vector<std::prm::string> verwendet die std::pmr::monotonic_buffer_resource mit der upstream-Speicherressource std::pmr::null_memory_resource (2). Schließlich werden auf den Vektor 16 Strings geschoben.

Dieser Artikel beendet meine Miniserie über die polymorphen Speicherressourcen in C++17. In meinem nächsten Artikel werde ich drei Jahre weiter springen und meine Reise durch C++20 fortsetzen. (rme)