黃爸爸狗園

本園只有sanitizer,沒有狗籠

0%

C++ Polymorphic allocator,花式記憶體管理 (二)

本系列文為C++ Weekly PMR Series的筆記文之二

讓我們接續先前的進度,繼續深入探討PMR

Custom memory resource

  • 我們在上篇文章的結尾,探討了當local buffer耗盡時的種種問題,問題是我們似乎沒有一個很好的手段可以去監視記憶體配置的行為
    • 也就是說,alloc與dealloc發生時並沒有一個很明確的feedback
    • 我們有辦法...印個log?
    • 來看一下影片中提到的memory_resource
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      // thanks to Rahil Baber
      // Prints if new/delete gets used.
      class print_alloc : public std::pmr::memory_resource {
      private:
      void* do_allocate(std::size_t bytes, std::size_t alignment) override {
      std::cout << "Allocating " << bytes << '\n';
      return std::pmr::new_delete_resource()->allocate(bytes, alignment);
      }

      void do_deallocate(void* p, std::size_t bytes,
      std::size_t alignment) override {
      std::cout << "Deallocating " << bytes << ": '";

      for (std::size_t i = 0; i < bytes; ++i) {
      std::cout << *(static_cast<char*>(p) + i);
      }
      std::cout << "'\n";
      return std::pmr::new_delete_resource()->deallocate(p, bytes, alignment);
      }

      bool do_is_equal(
      const std::pmr::memory_resource& other) const noexcept override {
      return std::pmr::new_delete_resource()->is_equal(other);
      }
      };
    • 雖然叫做print_alloc,但它並不是allocator,他是一個memory_resource
    • 萬事起頭難,先來看一看cppreference是如何描述它的
      • std::pmr::memory_resource is an abstract interface to an unbounded set of classes encapsulating memory resources
        • memory_resource作為一個介面(interface)存在,有下面三的virtual method需要我們去實作
          • do_allocate
          • do_deallocate
          • do_is_equal
      • 接著再來仔細看看這三個method的描述
        • void* do_allocate(std::size_t bytes, std::size_t alignment)
          • 負責從被管理的buffer(stack或是heap)中嘗試著分配一塊長度為bytes,並且memory address有對齊alignment的空間出來
            • 好比說對齊8就是return address永遠會是8的倍數
        • void do_deallocate(void* p, std::size_t bytes, std::size_t alignment);
          • 負責回收先前do_allocate()配置的記憶體,唯一一點要注意的是p必須是先前do_allocate()配置出來的
        • bool do_is_equal(const std::pmr::memory_resource& other) const noexcept
          • 負責判斷other跟當前的這個(*this)是否管理的是同一塊buffer

Case study: vector of std::string

  • 講是這樣講,但實際上還是要看實作怎麼做,所以我們仔細看一看我們的print_alloc
    • print_alloc底下實作通通都是仰賴new_delete_resource,但至少在do_allocate()跟do_deallocate()裡頭多了std::cout來輸出一些訊息給我們
  • 我們做了一個會在alloc跟dealloc時印log的memory_resource,然後呢?
    • 我修改了一下影片中的範例,完整程式碼請看這裡,我們在這裡先將注意力放在main()上
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      int main() {
      // remember initializer lists are broken.
      print_alloc mem;
      std::pmr::set_default_resource(&mem);

      std::array<std::uint8_t, 1024> buffer{};
      std::pmr::monotonic_buffer_resource mem_resource(
      buffer.data(),
      buffer.size()
      );

      std::cout << "initializing vector\n";

      std::pmr::vector<std::string> container({
      "short string",
      "A really long string here",
      "Another really long string here"
      }, &mem_resource) ;

      int idx = 0 ;
      for (const auto& elem : container) {
      fmt::print("[{}] addr: 0x{:x},\n\t {}\n", idx++, (uint64_t)elem.data(), elem) ;
      } // for

      std::cout << "exiting main\n";
      }
    • 我們將剛剛提到的print_alloc設置為default_resource,如此一來凡是沒有預先給定memory_resource的容器都會使用到我們的print_alloc
      • 也就是說那些我們沒注意到的alloc跟dealloc行為現在都會印出Log來提醒我們了,好耶!
    • 但從輸出看起來,並沒有那麼順利......
      1
      2
      3
      4
      5
      6
      7
      8
      initializing vector
      [0] addr: 0x7ffe618514d0,
      short string
      [1] addr: 0x1874f20,
      A really long string here
      [2] addr: 0x1874f50,
      Another really long string here
      exiting main
    • 不僅所有long string的string data都跑去heap上,我們的print_alloc還完全沒有發揮出他該有的用途
      • 奈A安捏?
    • 冷靜的觀察一下輸出,很快的我們就會發現事有蹊蹺
      • 第一個std::string,比較短的那個好端端地被配置到我們的local buffer上了
      • 但是後面兩個比較長的全部失手
    • 讓我們再多印點東西,看看能不能調查出什麼,緊接著修改一下用來dump內容物的for
      1
      2
      3
      4
      for (const auto& elem : container) {
      fmt::print("[{}] elem addr: 0x{:x}, data addr: 0x{:x},\n\t {}\n",
      idx++, (uint64_t)&elem, (uint64_t)elem.data(), elem) ;
      } // for
    • 對應的輸出為
      1
      2
      3
      4
      5
      6
      7
      8
      initializing vector
      [0] elem addr: 0x7ffedbcc5650, data addr: 0x7ffedbcc5660,
      short string
      [1] elem addr: 0x7ffedbcc5670, data addr: 0x1276f20,
      A really long string here
      [2] elem addr: 0x7ffedbcc5690, data addr: 0x1276f50,
      Another really long string here
      exiting main
    • 喔齁,每個物件(str::string)本身確實都好端端地放在local buffer上,但是長字串的data()卻被丟去了heap,怎麼會因為字串長了點就有這樣的差別阿
      • 因為std::string具有所謂的Small Object Optimization機制
      • 正如我們現在在做的,為了避免動不動就使用dynamic allocation,std::string對於短字串(至於到底以個字元以下才算短,不一定,要看實作)並不會真的乖乖配置heap
      • 取而代之的是std::string內部會有一個std::array<char>,來存放短字串的內容
      • 由於這個array也是std::string物件的一部分,因此不屬於dynamic allocation
    • 仔細看,短字串的elem addr跟data addr不是正好都在local buffer上嗎?
    • 另外每一個std::string的大小皆為0x20 bytes(你可以從elem addr的差看出來)
      • 有興趣的話不妨留意一下libstdc++的basic_string裏頭的_M_local_buf
    • 所以現在我們知道問題了
      • std::pmr::vector<std::string>裡頭的std::string的allocator沒有跟著一起被換成mem_resource
      • OK,所以我們現在的目標就是讓std::string能夠吃到跟vector一樣的allocator
      • 我們來修改一下vector的宣告
        1
        2
        3
        4
        5
        std::pmr::vector<std::pmr::string> container({
        "short string",
        "A really long string here",
        "Another really long string here"
        }, &mem_resource) ;
    • 修改過後,其對應的輸出為
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      initializing vector
      Allocating 26
      Allocating 32
      Deallocating 32: 'Another really long string here'
      Deallocating 26: 'A really long string here'
      [0] elem addr: 0x7ffd2607d730, data addr: 0x7ffd2607d748,
      short string
      [1] elem addr: 0x7ffd2607d758, data addr: 0x7ffd2607d7a8,
      A really long string here
      [2] elem addr: 0x7ffd2607d780, data addr: 0x7ffd2607d7c2,
      Another really long string here
      exiting main
    • 好耶!現在三個std::"pmr"::string都在local buffer上了,但是看起來好像還是怪怪?
      • 我們的new_delete_resource居然被觸發了,為啥?

Initializer list的玄機

  • 要解釋這個問題,我們得先來仔細端詳端詳我們對vector的初始化步驟,那個{}包起來的東東型別如下
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    // What is this?
    // {
    // "short string",
    // "A really long string here",
    // "Another really long string here"
    // }

    std::initializer_list<
    std::basic_string<
    char,
    std::char_traits<char>,
    std::pmr::polymorphic_allocator<char>
    >
    >
  • 型別有點長,但是我有先幫你format好喔
    • 他是一個initializer_list,根據cppreference所述,其背後運作機制是這樣:
      • 建構一個const T[N](在我們的例子中T=std::basic_string<char, std::char_traits<char>, std::pmr::polymorphic_allocator<char>>)
      • 接著vector的初始化會拿T的begin跟end(也可能會是begin跟length)去做真正的初始化(local buffer上的string是這一步做出來的)
    • 由於default_resource被我們換成print_alloc了,所以才抓的到const T[N]的allocation
  • 如果我們今天沒有看log,還真的不會察覺到有這件事情發生,不過問題又來了,那怎樣初始化才是對的?

push_back() V.S. emplace_back()

  • 既然用initializer_list會有非必要的allocation發生,那換種方式總可以了吧
    • 試試push_back()?
      1
      2
      3
      4
      std::pmr::vector<std::pmr::string> container(&mem_resource) ;
      container.push_back("short string") ;
      container.push_back("A really long string here");
      container.push_back("Another really long string here");
    • 輸出為
      1
      2
      3
      4
      Allocating 26
      Deallocating 26: 'A really long string here'
      Allocating 32
      Deallocating 32: 'Another really long string here'
      雖然建構跟解構的順序不同了,但基本上還是沒有解決問題啊...
    • 隨便亂試解決不了問題,看來得仔細想想背後到底出了什麼問題
      • 就如同剛剛initializer list發生的事情一樣,我們都先建構了一個temp value,然後才用copy initialized的方式在local buffer上建構出正確的物件
      • push_back()也會做一樣的事情
      • 有沒有辦法不要? 建構temp value並不是我們需要的步驟
        • How about emplace_back()
    • emplace_back()做為跟push_back()相似的method,雖然目的差不多,但運作機制不太一樣
      • push_back()是接受一個已經建構好的物件,複製進容器
      • emplace_back()接受的是該物件的constructor所需的參數,內部會直接利用allocator建構出物件,最後放進容器
      • 不正是我們要的?
    • 手起刀落,馬上給他改下去
      1
      2
      3
      4
      std::pmr::vector<std::pmr::string> container(&mem_resource) ;
      container.emplace_back("short string") ;
      container.emplace_back("A really long string here");
      container.emplace_back("Another really long string here");
    • 這樣做的確能達到我們要的效果,但總覺得有點麻煩阿,emplace_back連續寫好幾次
    • 影片中給出的解法是用一個帶有parameter pack的function,裡面再去用fold expression去自動的生出一堆emplace_back()出來
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      template <typename Container, typename... Values>
      auto create_container(auto *resource, Values&&... values) {
      Container result{resource};
      result.reserve(sizeof...(values));
      (result.emplace_back(std::forward<Values>(values)), ...);
      return result;
      }

      int main() {
      // ...
      auto container = create_container<std::pmr::vector<std::pmr::string>> (
      &mem_resource,
      "short string",
      "A really long string here",
      "Another really long string here"
      ) ;
      // ...
      }
    • 雖然現在講這個有點後知後覺,不過關於為何pmr container是傳入memory_resource的pointer而非reference,以及內部的一些細節可以參考std::pmr::vector的參數危機

小結

  • 我們在本篇文章中介紹了如何自定義一個memory_resource,用於監視alloc以及dealloc的發生
  • 並且提到了std::string以及std::pmr::string的差異
  • 還介紹了正確建構pmr容器的方式

關於PMR的相關細節還有不少沒有提到,所以我們會在下篇文章繼續講下去