В этой статье мы рассмотрим как обычно происходит работа с динамическим полиморфизмом, где теряется производительность и как её можно улучшить, используя интересные структуры данных.

В С++ не так чтобы много способов получить из коробки динамический полиморфизм. Способов буквально два: виртуальные функции и std::variant.

про std::any

std::any неприменим нигде кроме каких-то костылей в dll и про него лучше забыть, благо есть аналоги с гораздо большим потенциалом, но об этом в другой статье

Рассмотрим эти способы подробнее:

  1. виртуальные функции:

    позволяют создать контейнер указателей на базовый тип, а потом складывать туда любых наследников.

struct base {
  virtual ~base() = default;
};

...
std::vector<base*> things;

Сразу проблемы:

  • если нужен указатель, то как управлять памятью?

  • если нужно копирование контейнера, то оно будет неправильным(скопируются указатели). И так как контейнер использует внутри конструкторы, нам ничего не остаётся кроме как написать свою обёртку или функцию копирования - что, вообще-то, крайне неудобно, затратно и опасно в плане багов

  • так как в контейнере лежат элементы с разными указателями на vtable, процессор и компилятор не состоянии предугадать vtable какого типа лежит следующим и это приводит к постоянным кеш-мисам, что значительно замедляет и итерацию и в целом использование подобного контейнера.

нестандартные альтернативы

проблемы с лишними аллокациями памяти и копированием/удобством в целом отлично решаются решениями основанными на стирании типов, например https://github.com/kelbon/AnyAny, но это не тема этой статьи

  1. std::variant

    позволяет складывать любые из *известных заранее* типов в контейнер

using myvar = std::variant<A, int, B>;
...
std::vector<myvar> things;

Это решает проблемы с конструкторами и выделением памяти, к тому же элементы теперь расположены более менее локально. Но это вносит кучу своих проблем:

  • типы должны быть известны заранее, невозможны "рекурсивные" структуры данных, например AST на варианте уже не напишешь

  • Любое увеличение количества типов должно изменить ВСЕ сигнатуры всех функций использующих этот вариант. Как минимум придётся перекомпилирвать весь проект

    void foo(const std::variant<int, float, double>&);

    просто сломается при введении ещё одного типа

  • Проблемы с исключениями и неэффективность при большой разнице sizeof

К тому же у обоих рассмотренных вариантов нет опции быстро посмотреть только интересующие типы, придётся идти и к тому же заниматься динамическим кастом/визитом

Заметим, что часто нам нужен именно контейнер полиморфных типов, а не просто одна штука. Также я уже упомянул про то, что неплохо бы уметь предсказывать какой тип будет следующим. Это приводит нас к идее сортировать значения внутри контейнера по типу! Хм, интересно. И это действительно значительно улучшает производительность при итерации, но согласитесь как-то неудобно и затратно, к тому же придётся каждый раз при вставке и удалении думать об этом.

А как это исправлять?

Я пошёл дальше, почему бы не сделать контейнер ведущий себя как контейнер из variant<Ts...>, но на самом деле хранящий каждый тип в отдельном контейнере? Тогда мы сразу совершенно избавились бы от кастов/прыжков по vtable, std::visit и прочего. В действительности мы избавились бы от полиморфизма вообще, хотя он и оставался бы со стороны публичного интерфейса.

Кстати, об интерфейсе, нам нужны операции:

  • вставка для каждого типа T из Ts...

  • удаление для каждого типа T из Ts...

  • просмотр контейнера для типа T

  • аналог посещения(visit) для всех значений контейнера

К тому же кажется, что контейнер может быть разным в зависимости от задачи, так что сделаем его кастомизируемым. Назвал я это чудо variant_swarm (буквально рой вариантов)

Итак, как реализовать это? Всё довольно просто:

// БАЗА с настомизируемым контейнером
template <template <typename> typename Container, typename... Ts>
struct basic_variant_swarm {
private:
  std::tuple<Container<Ts>...> containers;
public:
// операция "посмотреть контейнеры для типов Types..."
  auto& view<Types...>();
// операция "посетить все типы из контейнеров для Types..."
  void visit<Types...>(visitor);
// операция вставки, перегруженная для каждого из типов Ts...
  auto insert(*сложный момент* auto value)
// операция вставки, перегруженная для каждого из типов Ts...
  auto erase(*сложный момент* auto iterator)
};

// алиас для самого частого случая
template<typename... Ts>
using variant_swarm = basic_variant_swarm<std::vector, Ts...>;

Конечно всё немного сложнее и это очень условный минимальный набор. Но в целом всё понятно - у нас есть tuple из контейнеров для каждого типа и перегруженные под каждый тип операции. Это интуитивно и просто.

Использовать это можно примерно так:

variant_swarm<int, double, std::string> f;
// в операции вставки нет никакого динамического полиморфизма,
// всё решено на компиляции
f.insert("hello");
f.insert(155);
f.insert(3.14);
// должно вывести "hello" 155 3.14 в КАКОМ-ТО порядке
f.visit_all([](auto& v) { std::cout<< v << std::endl;});

Полный код можно посмотреть здесь

Обратите внимание, значения будут появляется в visit_all упорядоченно по типам. А что если хочется упорядочить по индексу?

На самом деле ничего сложного, в самом деле достаточно заменить контейнер на unordered_map и вставлять вместе со значением текущее количество элементов в контейнере как ключ. Тогда операция find(index) определяется за ожидаемое время O(1).

Но двинемся дальше.

Получается, что мы определили контейнер сумтипов, если говорить терминами высших эльфов. Сразу хочется подумать, а какой аналог такой вещи был бы для типа-произведения, также известного в C++ как std::tuple? Не буду долго томить, просто ПОЧЕМУ БЫ НЕ хранить каждое поле tuple или агрегата как отдельный контейнер и так организовать data parallel контейнер?

Опять сразу определимся с интерфейсом, кажется эта штука должна вести себя практически также как std::vector снаружи, уметь хранить агрегаты и туплы, но просто делать это более эффективно и также поддерживать операцию "посмотреть филд №3 для всех сразу". И сразу заметим, что наш контейнер не сможет быть contiguous ренжом, только random_access всилу того как элементы располагаются в памяти.

Ну и с точки зрения эстетической красоты хотелось бы, чтобы это просто работало:

struct mytype {
  int i;
  float d;
  char x;
};
...
data_parallel_vector<mytype> things;
// использование structured binding из С++17
auto [a, b, c] = things;
// реализуемо?)

Итак, посмотрим как выглядит каркас реализации:

// конечно мы поддерживаем аллокаоры, мы же серьёзные люди!
template <typename T, typename Alloc = std::allocator<T>>
struct data_parallel_vector {
private:
// как доставать поля?
   std::tuple<std::vector<???, Alloc>...> field_containers;
public:
using iterator; // как делать итератор?
// операция "посмотреть поля по номерам Nbs..."
// Отмечу, что она не может возвращать ссылку на контейнер,
// потому что тогда юзер может сломать инварианты нашего контейнера
// например удалить элементы
  template <std::size_t... Nbs>
  constexpr std::span<...> view();
// тут все операции которые поддерживаются вектором
// и могут поддерживаться нашим контейнером, а их крайне много...
};

Тут С++ явно бросает нам вызов, чего только стоит специализация std::vector<bool>, которая может сломать нам буквально всё.

Для реализации итератора достаточно заметить, что для I-того элемента в каждом контейнере лежит I-тое его поле. Поэтому мы можем создать random access итератор состоящий из тупла итераторов на контейнеры.

Остальное - лучше смотреть в реализации, иначе статья будет больше хабра. Кстати, реализация полностью тут.

Пример использования (да, это работает):

struct mytype {
  int i;
  float d;
  bool x;
};
...
data_parallel_vector<mytype> things;
// a, b, c это здесь std::span<int> span<double> и span<bool> 
auto [a, b, c] = things;
// А вы что, думали это нереализуемо?

Итоги

Мы реализовали контейнер сум-типов, который позволяет совершенно без оверхеда и удобно использовать рантайм полиморфизм подобный контейнеру std::variant<Ts...>

И контейнер-тип-произведение, который ведёт себя как std::vector<T>, но позволяет делать параллельные вычисления или, например, ECS систему в играх гораздо удобнее и эффективнее

Надеюсь статья была для вас интересна, предлагайте свои улучшения/идеи в комментариях!

Комментарии (45)


  1. bfDeveloper
    05.12.2022 19:58

    Я так понял, что в векторе вы взяли magic_get (который нынче Boost.PFR) и развернули массив структур в структуру массивов. Интересное упражнение.

    А что насчёт индексов и выборок? Для ECS же важно уметь выбирать наборы компонент. Типа для всех, содержащих компоненту Health и Collision, запустить систему нанесения урона при столкновении.


    1. Kelbon Автор
      05.12.2022 20:04

      Можете посмотреть на интерфейс контейнеров в хедерах, там есть выбор .view<Types...> и .view<Nbs...>, для variant_swarm тоже есть возможность посетить только нужные типы и посмотреть на только нужные

      https://github.com/kelbon/AnyAny/blob/fbb1b0ea2c9a83f00ba18b657cbb68d7ab3b82b4/include/variant_swarm.hpp#L104


    1. Kelbon Автор
      05.12.2022 20:30
      +1

      Техника эта была известна ещё до Boost PFR и никаких зависимостей в библиотеке нет, можете посмотреть в noexport/data_parallel_vector_details.hpp как это реализовано


  1. pharo
    05.12.2022 20:06
    +4

    Полиморфные структуры данных и производительность

    Извините, но где в статье про производительность?


    1. Kelbon Автор
      05.12.2022 20:08

      Очевидно где, такая структура данных повышает локальность данных до предела, позволяет мгновенно получить доступ значениям заданных типов, убрать динамик касты и visit


      1. zzzzzzerg
        05.12.2022 21:14
        +1

        Это в общем-то общее знание. А есть подтверждение в цифрах - было так, мы сделали так и стало так?


        1. Kelbon Автор
          05.12.2022 21:24

          https://www.youtube.com/watch?v=n6PvvE_tEPk&t=1225s&ab_channel=CppCon
          Вот в этом докладе есть какие-то измерения про потерю производительности из-за кеш мисов по vtable. Но это лишь малая часть

          Ну и да, конечно же стоит упомянуть, что при той версии что с разделёнными контейнерами можно функцию и заинлайнить и даже векторизовать. С виртуальными функциями это даже теоретически невозможно. Это громадный плюс


          1. zzzzzzerg
            05.12.2022 21:26
            +2

            Спасибо за видео. Но хотелось бы увидеть ваши цифры, из первых рук, так сказать.


      1. pharo
        05.12.2022 21:49

        Интересно увидеть в цифрах какие то выводы (на вычислительных задачах)
        К, примеру может подойдёт учебная задача реализации RPN калькулятора?
        Топик на Github reverse-polish-notation

        P.S. Ещё интереснее с использованием профайлера или «даже» ассемблерного кода.


  1. Xop
    05.12.2022 22:27
    +2

    Рискую нарваться на "опять любители раста понабежали", но в Rust уже есть встроенный тип enum, который как раз variant, плюс в языке куча синтаксического сахара для работы с ним. Не ради критики или попытки обесценить (на самом деле статья огонь, жаль в последнее время мало такого пишут), но для рекламы любимого языка.


    1. Kelbon Автор
      05.12.2022 22:30

      в С++ есть std::variant, только вот это не помогает сделать эффективный контейнер таких типов. Не знаю получится ли в расте реализовать нечто подобное, так как там нет перегрузок и шаблонных шаблонных параметров


      1. Xop
        05.12.2022 23:04
        +1

        Пардон, сначала невнимательно прочитал - я сначала подумал, что у вас аналог std::variant, но на самом деле это аналог std::vector<std::variant>>, но с разложением значений различных типов по отдельным векторам и сортировкой по типам (прям по канонам data oriented design). Да, в стандартной библиотеке раста такого нет, но реализовать не вижу большой проблемы - по крайней мере если не параметризовать типом самого контейнера.


      1. 0xd34df00d
        06.12.2022 02:31
        +1

        Про раст я тоже не знаю, но в х-ле AoS преобразуется в SoA легко и просто тайпклассами (и еще там их дженерик-вывод прикрутить можно).


        1. Kelbon Автор
          06.12.2022 07:56
          -1

          вопрос есть ли смысл делать подобное на хаскеле, если там невозможно добиться локальности данных / убирания оверхеда, т.к. оверхед заложен в язык


          1. 0xd34df00d
            06.12.2022 08:14
            +5

            Почему невозможно? Unboxed (и Storable) располагают данные в памяти последовательно и локально, не имеют лишнего индерекшна из-за ленивости, и так далее.


  1. iCpu
    06.12.2022 07:29

    "Полиморфные структуры данных и производительность"?
    Насколько я понял, поставленную в первом абзаце задачу вы не решили. Где здесь, собственно, "полиморфизм", если вы решили задачу разворачивания вектора структур в структуру из векторов. Где здесь, собственно, "динамический", если у вас всё на статике и этапе компиляции?

    Не, сама по себе структура прикольная - и очень полезна в числодробилках. Возможно, я её у вас даже и притырю. Просто назовите статью честно, "Преобразование AoS в SoA на двадцатых шаблонах", и будет вам почёт и уважение.
    А так, я пришёл за пельменями, а меня шарлоткой накормили. Вкусно, конечно, но не то.


    1. Kelbon Автор
      06.12.2022 07:57

      кажется вы пропустили середину статьи про variant_swarm, там есть динамический полиморфизм


      1. iCpu
        06.12.2022 08:41

        Кажется, не пропустил. Перечень типов вами заранее определён в шаблоне. Где здесь динамика? Тем более, где здесь полиморфизм? (Ладно, это я погорячился.)


        1. Kelbon Автор
          06.12.2022 08:51
          +1

          Перечень типов известен на компиляции. Где здесь полиморфизм? /s


          1. iCpu
            06.12.2022 10:01

            Перечень типов известен на компиляции.

            Это и называется "статический". Динамический - это когда перечень типов на этапе компиляции не известен и уточняется на этапе выполнения. И может прилететь из-вне, например, из инъекции кода.

            На самом деле, менять придётся не то, чтобы очень много - std::tuple заменить на std::map<std::type_index, ...> и добавить код расширения-сужения списка типов в рантайме. Ну и, возможно, вынести шаблонные методы доступа из класса, чтобы их можно было перегружать. Хотя, может я и излишне оптимистичен.


            1. Kelbon Автор
              06.12.2022 10:15

              Вы не поняли, типы всегда известны на компиляции. Просто иногда они устроены так, что создают полиморфизм. И в variant swarm есть динамический полиморфизм, т.к. он ведёт себя как набор std::variant<Ts...>.
              Для тех случаев что вы описываете в библиотеке есть другие инструменты, такие как полиморфные ссылки и референсы, any_with<...> и подобное.

              Смысла в map<index> что то там я особого не вижу, потому что это не поможет компилятору инлайнить и оптимизировать код.

              Это будет непоследовательная память и оптимизатору неизвестно когда один тип сменится другим(он даже не сможет понять из кода, что там есть какие то типы). Кроме того вам придётся оставить на элементах контейнера какие то части полиморфизма, либо продолжать их визитить, либо добавлять vtable, тогда как в текущем решении это могут быть абсолютно произвольные типы без каких-либо изощрений для того чтобы они вели себя полиморфно.


              1. iCpu
                06.12.2022 10:28
                +3

                Так и std::variant - это статически полиморфный тип. Статический и динамический полиморфизм на примерах.

                К вашей реализации вопросов нет. Вы введение к статье перепишите, вы там за динамику много пишите, а у вас её нет.


                1. Kelbon Автор
                  06.12.2022 10:32

                  variant это инструмент динамического полиморфизма, вы только на рантайме знаете что лежит в variant<Ts...> его поведение на динамике меняется в зависимости от того что там лежит


                  1. AnthonyMikh
                    07.12.2022 21:38
                    -1

                    int — это инструмент динамического полиморфизма, вы только в рантайме знаете, лежит ли там 0, 1 или 42. /s


            1. Kelbon Автор
              06.12.2022 10:21

              Например, пусть у нас есть типы А B и C, типичная ситуация, когда виртуальный метод не делает ничего для А, но делает полезную работу для B и какую-то мелочь для C.
              Если вы имеете

              std::vector<Base*> x;

              При итерации и вызове .foo() оптимизатору ничего не останется, кроме как честно вызывать каждую из функций, даже если она не делает совершенно ничего.
              Если же вы сделали

              aa::variant_swarm<A, B, C> y

              то оптимизатор при вызове .visit_all видит, что сначала вызыватся в цикле .foo для A, которое ничего не делает - значит этот цикл можно вовсе выкинуть.
              Затем вызывается в цикле .foo для B, это можно заинлайнить, оптимизировать и векторизовать.
              Затем вызывается в цикле .foo для С, это можно также заинлайнить или убрать цикл, если возможно.
              Таким образом сам по себе бенчмарк типичных ситуаций будет выглядеть абсурдно - ускорение в бесконечность раз, ведь был цикл, а теперь пусто


              Ещё интересный вариант - увидеть что циклы можно объединить и схлопнуть, что иногда сильно ускоряет код.


              1. iCpu
                06.12.2022 10:40
                +3

                Всё это хорошо. Но динамический полиморфизм называется динамическим потому, что я на этапе выполнения могу добавить тип D - и у меня всё отработает корректно. Откуда я его добавлю? Не_твоего_ума_дело.dll подгружу, например.


                1. Kelbon Автор
                  06.12.2022 10:45
                  -1

                  (нейросеть отвечает)
                  То что описываете вы это другое явление, одно из свойств какого-то вида полиморфизма, а не весь полиморфизм


                  1. iCpu
                    06.12.2022 10:52
                    +1

                    В Java нет статического полиморфизма, в ней только динамический. Все методы там виртуальные - и инстанцируются jit'ом. Все типы наследуются от Object, а контейнеры хранят наследников интерфейса. По крайней мере, году в 2015 всё ещё было так.

                    Примите уже как данность, что всё, что имеет угловые скобки <> в C++ - это статика.


                    1. Kelbon Автор
                      06.12.2022 10:54
                      -1


                      1. iCpu
                        06.12.2022 11:39
                        +1

                        Задаёшь неправильные вопросы.
                        @
                        Получаешь бесполезные ответы.

                        Так спроси, что такое статический полиморфизм в C++.

                        И что, собственно, меня должно удивить в any_with? То, что там кто-то на std::any насыпал сверху vtable_ptr? Не, похвально, конечно, но по удобству на 5 шагов отстаёт от CRTP. Чтобы описать интерфейс, нужно туеву хучу лишних движений совершить.


                      1. Kelbon Автор
                        06.12.2022 12:02

                        CRTP это статический полиморфизм.
                        any_with<...> это динамический полиморфизм. В том числе это работает при подключении длл.

                        Советую разобраться в том о чём вы спорите

                        P.S. std::any уже содержит vtable с копированием и деструктором.


                      1. iCpu
                        06.12.2022 12:39

                        У вас в статье сколько упоминаний any_with? Подсказываю,

                        Вы в статье писали про basic_variant_swarm и data_parallel_vector, а они не про динамический полиморфизм.

                        Но да ладно, хватит спорить из-за фигни.


                      1. Kelbon Автор
                        06.12.2022 12:59

                        Это был ответ на ваше утверждение, что всё в С++ с <> является "статикой"


                    1. Kelbon Автор
                      06.12.2022 10:55

                      Тогда вам стоит увидеть это:
                      https://github.com/kelbon/AnyAny
                      (см. any_with<...>)


  1. Gorthauer87
    06.12.2022 09:35

    Блин, прочитал как полиаморные типы и сильно задумался.


  1. eao197
    06.12.2022 14:30
    +1

    Прочитал статью, прочитал имеющиеся на данный момент комментарии. Увидел, что недостаточно комментариев о том, что статья не имеет отношения к динамическому полиморфизму, хотя вначале его и упоминает.

    Так что этот комментарий призван исправить недостаток таких комментариев. Посему: статья не имеет отношения к динамическому полиморфизму и этот самый динамический полиморфизм вначале статьи был упомянут напрасно (либо с кликбейтной целью).


    1. Kelbon Автор
      06.12.2022 15:32

      На основании чего вы считаете, что variant_swarm<Ts...> не ведёт себя как контейнер полиморфных типов?


      1. eao197
        06.12.2022 15:38
        +2

        На том же основании, что и ув.тов. @iCpu. Динамический полиморфизм появляется тогда, когда мы в compile-time видим лишь абстрактный базовый тип A, а в run-time у нас может появится экземпляр любого другого типа X, который удовлетворяет интерфейсу A (в терминах C++: в compile-time мы имеем базовый тип A, в run-time любой наследник от A). Причем если в контейнере лежит сразу несколько значений (v1, v2, v3, ...), то каждый из них может принадлежать любому типу наследнику X1, X2, X3, ... Причем в том числе и тем типам-наследников, которых еще не существовало в момент компиляции нашего кода с контейнером.


        1. Kelbon Автор
          06.12.2022 16:10

          Нет, динамический полиморфизм это когда объект ведёт себя по разному в зависимости от своего динамического типа. Или функция ведёт себя по разному в зависимости от динамических типов своих аргументов

          А вы описываете один единственный возможный для вас полиморфизм на виртуальных функциях.


          1. eao197
            06.12.2022 16:16

            Нет, динамический полиморфизм это когда объект ведёт себя по разному в зависимости от своего динамического типа.

            Мне непонятно почему здесь "Нет", т.к. то, что вы написали является прямым следствием того, что описал я. Ведь если за интерфейсом A в динамике обнаруживается X, то мы как раз и получаем то поведение, которое реализует именно X. Если за A стоит не X, а Y, то получим поведение от Y. Вот вам и "ведет себя по разному".

            А вы описываете один единственный возможный для вас полиморфизм на виртуальных функциях.

            Просто в C++ вы по другому динамический полиморфизм и не получите.

            И нет, ни std::variant, ни std::any не являются примерами динамического полиморфизма в C++, т.к. вам всегда нужно знать точный тип, который вы собираетесь извлечь из std::variant/any.


            1. Kelbon Автор
              06.12.2022 16:17
              -1

              Мне непонятно почему здесь "Нет", т.к. то, что вы написали является прямым следствием того, что описал я

              потому что все караси рыбы, но не все рыбы караси. У вас логическая ошибка в определении динамического полиморфизма


              1. eao197
                06.12.2022 16:20

                Вы попробуйте подставить в мое описание вместо "A" своих "карасей", а вместо X -- свою "рыбу". А потом наоборот. И увидите, что "все караси рыбы, но не все рыбы караси". А так же и отсутствие логической ошибки, которая вам померещилась.


            1. Kelbon Автор
              06.12.2022 16:20

              И нет, ни std::variant, ни std::any не являются примерами динамического полиморфизма в C++, т.к. вам всегда нужно знать точный тип, который вы собираетесь извлечь из std::variant/any.

              То есть std::visit и тот факт, что any можно не кастовать ни к чему и оно выполнит полиморфно копирование/деструктор вас не смущает


              1. eao197
                06.12.2022 17:27
                +2

                any можно не кастовать ни к чему и оно выполнит полиморфно копирование/деструктор вас не смущает

                Не смущает, поскольку без знания того, что лежит внутри any практически никакой пользы от содержимого any не получить. За исключением некоторых вырожденных случаев.


  1. DeepFakescovery
    06.12.2022 14:39

    время идёт, а C++ программист всё еще изобретает контейнеры