...но что-то пошло не совсем так.
Был у меня объект со словарем внутри. Что-то вроде:
struct info_t {...}; using info_map_t = std::map<key_t, info_t>; class info_holder_t { ... private: info_map_t m_infos; ... }; |
Захотелось немного оптимизировать этот info_holder_t::m_infos. Дело в том, что сбор статистики по реальному использованию info_holder_t показал, что в подавляющем большинстве случаев (сильно больше 95%) в m_infos хранится либо один, либо два экземпляра info_t.
Более того, была еще одна очень важная особенность: в абсолютном большинстве случаев m_infos только наполняется, ничего оттуда не удаляется до самого уничтожения info_holder_t.
Вот я и подумал, что std::map -- это же дерево, где каждый узел -- это динамически созданный объект. А new/delete не самые дешевые операции. Что, если завести в info_holder_t буфер под два info_map_t::value_type и "выделять" память оттуда? А уже когда этот буфер исчерпается, переходить на использование обычных new/delete.
При этом очень и очень хотелось сохранить реализацию основных методов info_holder_t прежней. Т.е. если бы остался именно m_infos типа info_map_t, то это было бы идеально. А вот если бы вместо m_infos оказался какой-то std::variant, в котором был бы отдельный кейс для крошечного словаря, и отдельный кейс для полноценного std::map, то пришлось бы перелопатить более тысячи строк уже отлаженного и проверенного на разных сценариях кода, что не вселяло оптимизма.
Оказалось, что благодаря std::pmr такой оптимизированный std::map можно сделать с минимальными усилиями. Всего-то нужно:
struct info_t {...}; // Вместо просто std::map теперь std::pmr::map. using info_map_t = std::pmr::map<key_t, info_t>; class info_holder_t { ... private: // Локальный буфер. std::array<std::byte, какой-то-размер> m_small_map_fixed_buffer; // То, что будет превращать m_small_map_fixed_buffer в арену // памяти для m_infos. std::pmr::monotonic_buffer_resource m_infos_memory_resource{ m_small_map_fixed_buffer.data(), m_small_map_fixed_buffer.size() }; // Модицифированный словарь. info_map_t m_infos{ std::addressof(m_infos_memory_resource) }; ... }; |
Получается, что для первых нескольких элементов в m_infos память "выделяется" из m_small_map_fixed_buffer через m_infos_memory_resource. А если элементов становится больше, то m_infos_memory_resource сам обращается к динамической памяти посредством std::pmr::get_default_resource. Как раз то, что мне было нужно.
Казалось бы, золотой ключик уже в кармане.
Но есть несколько "Но".
Во-первых, какое выравнивание должно быть у m_small_map_fixed_buffer?
Во-вторых, какой же размер должен быть у m_small_map_fixed_buffer?
Оба эти вопроса упираются в то, что std::map::value_type -- это же не реальный тип узла дерева. Это только тип того, что внутри дерева поиска содержится. Но кроме этого в узле должны быть еще и какие-то указатели (по крайней мере на дочерние узлы и, вероятно, на родительский узел).
Поскольку реальный тип узла дерева std::map наружу никак не выставляет, а код должен работать на разных ОС и собираться разными компиляторами, то хороших ответов на эти вопросы я лично не вижу.
И если с выравниванием есть надежное (но может и не оптимальное) решение:
// Локальный буфер. alignas(std::max_align_t) std::array<std::byte, какой-то-размер> m_small_map_fixed_buffer; |
То вот с размером этого буфера начинаются какие-то пляски с бубном. Для эксперимента просто взял (sizeof(info_map_t::value_type) * 10)), этого хватило, чтобы сделать замеры производительности.
И вот замеры удивили еще больше, чем необходимость искать ответы на два озвученных выше вопроса: на простеньком бенчмарке, который был сделан для проверки гипотезы о выгоде подобной оптимизации, оказалось, что связка из буфера и monotonic_buffer_resource не дает никакого выигрыша по сравнению с первоначальной простой реализацией. Т.е. и там, и там при добавлении в m_infos одного-двух элементов время работы бенчмарка оказывается практически одинаковым.
Полагаю, здесь сказывается несколько факторов:
- в проекте уже используется mimalloc, а он делает операции new/delete очень быстрыми. Особенно когда и new, и delete происходят на контексте одной и той же нити (что и имело место для моего info_holder-а);
- появление в info_holder-е дополнительной "начинки", которая требует инициализации (речь про конструктор std::pmr::monotonic_buffer_resource и передачу указателя на него в m_infos) утяжеляет процедуру создания info_holder_t. Плюс к тому, работа monotonic_buffer_resource все же не бесплатна.
Добавлю сюда два обозначенных выше вопроса, хороших ответов на которые у меня нет, и получаю, что простая попытка сделать small object optimization на базе std::pmr::monotonic_buffer_resource завершилась с отрицательным результатом.
PS. Вообще, идея std::pmr, на первый взгляд, очень простая и красивая. Кажется, что разобраться с этим не так уж и сложно. Но, как обычно в C++, кроличья нора гораздо глубже, чем кажется.