В экосистеме современного C++ прочно укоренилось мнение: классический динамический полиморфизм через виртуальные функции (vtable) — это устаревший, медленный и недружелюбный к кэшу процессора механизм. В качестве «серебряной пули» модно предлагать связку std::variant и std::visit. По интернету кочуют статьи, утверждающие, что std::visit выполняет диспетчеризацию за фиксированное время O(1) и полностью уничтожает старый добрый ООП-подход.

Но в таких сравнениях авторы часто совершают методологическую ошибку: они противопоставляют вектор указателей std::vector<Base*> вектору сырых объектов std::vector<std::variant>. Разумеется, std::variant побеждает, но не из-за механики вызова, а благодаря стопроцентной локальности данных в кэше процессора.

Давайте снимем розовые очки, уравняем условия и изолируем саму механику вызовов. Представьте реальный сценарий: объекты тяжелые, создаются динамически в разное время и разбросаны по куче (Heap), а мы оперируем массивами их адресов.

Мы столкнем лоб в лоб std::vector<Base*> и std::vector<std::variant<TypeA, TypeB, TypeC>*> в условиях раздельной компиляции (когда оптимизатор -O2 не видит тела функций и не может применить тотальный инлайнинг).


Часть 1. Распространенные штампы и заблуждения

Заблуждение 1: «vtable — это всегда двойное разыменование указателя, а std::visit — быстрое извлечение по индексу»

Как это описывают: Чтобы вызвать виртуальный метод, процессор должен сначала прочитать адрес объекта, затем пойти в кучу, достать из объекта указатель на его vtable, а затем из vtable достать адрес функции. Это долго. В то же время std::visit якобы просто берет сохраненный тег типа и мгновенно прыгает куда нужно.

Заблуждение 2: «std::visit выполняет вызов за один прыжок через таблицу переходов»

Как это описывают: Компилятор строит внутреннюю таблицу адресов (Jump Table), аналогичную switch-case. Процессор берет индекс активного типа из std::variant, умножает его на 8 байт и делает один эффективный переход jmp прямо на нужный код.

Заблуждение 3: «std::visit не страдает от ошибок предсказателя переходов (Branch Misprediction)»

Как это описывают: Поскольку адреса в таблице переходов std::visit локальны и известны на этапе компиляции, процессор легко справляется с ветвлением. Виртуальные же вызовы call * полностью дезориентируют предсказатель переходов (Branch Predictor), если типы объектов в цикле перемешаны.


Часть 2. Реальная картина: Что происходит в кремнии

Давайте заглянем в честный ассемблер x86-64 под оптимизацией -O2 и посмотрим, как обе концепции выглядят на уровне инструкций процессора при работе через указатели.

Реальный ассемблер №1: Классический vtable

Контекст: Итерация по std::vector<Base*>. Адрес текущего указателя лежит в RAX.

.L_VTABLE_LOOP_BODY:
    movq    (%RAX), %RDI         # RDI = ptr (Указатель на объект в куче)
    movq    (%RDI), %RCX         # RCX = vptr (Читаем адрес vtable из первых 8 байт объекта)
    movq    0(%RCX), %RCX        # RCX = &VirtualMethod (Читаем адрес функции из vtable)
    call    *%RCX                # КОСВЕННЫЙ ВЫЗОВ: Прыгаем сразу в тело метода
.L_VTABLE_LOOP_NEXT:
    addq    $8, %RAX             # Переход к следующему указателю в векторе
    cmpq    %RBP, %RAX               jne     .L_VTABLE_LOOP_BODY
  • Механика: 3 последовательных чтения из памяти (зависимых друг от друга) и всего 1 косвенный переход (call *). Внутренний ret функции возвращает нас сразу в начало следующей итерации цикла.

Реальный ассемблер №2: std::visit через указатель на variant

Контекст: Итерация по std::vector<std::variant*>. Адрес текущего указателя лежит в регистре RBX.

.L_VISIT_LOOP_BODY:
    movq    (%RBX), %RDX         # RDX = ptr (Указатель на std::variant в куче)
    movzbl  1(%RDX), %EAX        # ЧИТАЕМ ТЕГ: Берем 1 байт дискриминатора типа из кучи
    jmp     *.L_JUMP_TABLE(,%RAX,8) # ПРЫЖОК 1: Косвенный переход по таблице адресов

.L_CASE_TYPE_A:
    movq    %RDX, %RDI           # Передаем адрес варианта как 'this'
    call    TypeA::PrintName     # ПРЫЖОК 2: Прямой вызов целевой функции
    jmp     .L_LOOP_NEXT         # ПРЫЖОК 3: Прямой переход к концу итерации

.L_CASE_TYPE_B:
 ... ... ...

.L_CASE_TYPE_C:
 ... ... ...

.L_LOOP_NEXT:
    addq    $8, %RBX             # Переход к следующему указателю в векторе (размер указателя 8 байт)
    cmpq    %RBP, %RBX           
    jne     .L_VISIT_LOOP_BODY
  • Механика: 2 чтения из памяти (загрузка адреса варианта, затем загрузка его тега) и целый каскад из 3 прыжков (косвенный jmp -> прямой call -> прямой jmp).


Часть 3. Обоснованные выводы: Разрушаем штампы

Сравнивая эти два листинга, мы можем сформулировать объективные факты, которые полностью опровергают устоявшиеся мифы.

Разоблачение мифа о «двойном прыжке» std::visit

Посмотрите на ассемблер std::visit. Если компилятор не может заинлайнить тела функций, std::visit вместо "одного эффективного прыжка" совершает каскад из трех переходов!

  1. Косвенный jmp по таблице адресов на нужную метку (.L_CASE_TYPE_A).

  2. Прямой call из этой метки в саму функцию.

  3. Прямой jmp после функции, чтобы вернуться в основной поток цикла.

Классический vtable делает ровно один косвенный вызов call *%RCX и попадает сразу в целевую функцию. С точки зрения плотности управляющих инструкций (Control Flow Overhead) накладные расходы у std::visit выше.

Разоблачение мифа о предсказателе переходов

Инструкция jmp .L_JUMP_TABLE(,%RAX,8) (выбор ветки в std::visit) и инструкция call %RCX (выбор метода в vtable) — это близнецы-братья. С точки зрения архитектуры процессора обе они являются косвенными переходами (Indirect Branches).

За их обработку отвечает один и тот же аппаратный узел процессора — Target Cache (или Indirect Branch Predictor).

  • Если в вашем векторе типы данных перемешаны хаотично (TypeA -> TypeC -> TypeB -> TypeA), этот блок процессора будет ошибаться с абсолютно одинаковой частотой как на виртуальных функциях, так и на std::visit.

  • Штраф за ошибку (Branch Misprediction Penalty) в обоих случаях идентичен: полная очистка конвейера и потеря 15–20 тактов процессора.


Часть 4. Сводный расчет стоимости вызова (В тактах)

Внесем инженерную точность и оценим стоимость диспетчеризации в тактах процессора (в среднем для современных архитектур Intel Core / AMD Zen). Мы рассматриваем только накладные расходы на сам вызов, без учета полезной работы внутри метода.

Сценарий А: Худший случай (Полный хаос в данных)

Условия: Объекты в куче фрагментированы (кэш пуст). Типы объектов в векторе чередуются хаотично, из-за чего предсказатель переходов ошибается в 100% случаев.

  • Классический vtable:

    • Промах мимо кэша L1d при чтении объекта и его vptr: от примерно 4-12 тактов (если повезло с L2/L3) до 200+ тактов (если пришлось идти в RAM).

    • Ошибка предсказания (Branch Misprediction) на call *: 15-20 тактов на очистку конвейера.

    • Итого: от 25 до 220+ тактов.

  • Указатель на std::variant + std::visit:

    • Промах мимо кэша при чтении адреса варианта и его тега (movzbl): те же 4-200+ тактов.

    • Ошибка предсказания на косвенной инструкции jmp *.L_JUMP_TABLE: 15-20 тактов.

    • Дополнительные расходы на каскад переходов (call + jmp): еще примерно 2-4 такта.

    • Итого: от 27 до 225+ тактов.

Вывод для хаотичных данных: Если данные не лежат в кэше, а типы перемешаны, оба подхода работают одинаково медленно (или одинаково быстро, смотря с чем сравнивать, но ключевое слово здесь одинаково). При этом std::visit на указателях даже на 2-3 такта хуже из-за избыточных прыжков.

Сценарий Б: Лучший случай (Идеальный кэш и структура)

Условия: Все объекты прогреты в L1-кэше. В векторе сначала идут все элементы TypeA, затем все TypeB, затем все TypeC (идеально для предсказателя переходов).

  • Классический vtable:

    • Чтение данных из L1: 1-2 такта.

    • Успешное предсказание call *: 1-2 такта.

    • Итого: примерно 3-4 такта на вызов.

  • Указатель на std::variant + std::visit:

    • Чтение данных и тега из L1: 1-2 такта.

    • Успешное предсказание косвенного jmp: 1-2 такта.

    • Прямой call внутри ветки + финальный jmp: примерно 2-3 такта.

    • Итого: примерно 5-7 тактов.

Вывод для идеальных данных: В стерильных условиях, когда мы убрали преимущество плотной упаковки и оставили только указатели, классический vtable обгоняет std::visit почти в два раза, потому что его бинарный код короче, лаконичнее и не совершает лишних перестроений потока команд.


Часть 5. Скрытый удар: Размер типов и проблема Padding (Выравнивание)

Раз уж мы заговорили про указатели на std::variant, всплывает еще один критический нюанс: размер самого типа std::variant в куче.

Как известно, размер std::variant всегда равен размеру самого большого типа из его списка плюс размер тега (1 байт) плюс выравнивание (padding).

  • Представьте, что у нас в списке три типа. TypeA занимает 4 байта, TypeB — 4 байта, а TypeC — 64 байта (например, содержит внутри тяжелый массив).

  • Из-за правил выравнивания каждый объект std::variant в куче будет насильно раздут до размеров своего самого "тяжелого" собрата — он будет занимать 72 байта (64 байта под данные + 1 байт тег + 7 байт на выравнивание, чтобы итоговый размер делился на 8).

Если бы мы использовали обычное наследование, объект маленького класса TypeA в куче занимал бы всего 16 байт (8 байт vptr + 4 байта данные + 4 байта padding). Но будучи упакованным в std::variant*, он резервирует под себя все 72 байта, даже если они ему не нужны.

Это приводит к двум катастрофическим последствиям:

  1. Неэффективное расходование кучи (Внутренняя фрагментация): Храня тысячи указателей на std::variant, внутри которых сейчас активен маленький TypeA, мы неявно держим в памяти гигабайты пустого места.

  2. Удар по кэш-линиям: Когда процессор пытается прочесть тег типа из кучи по инструкции movzbl 1(%RDX), он загружает кэш-линию процессора (64 байта). Если объекты имеют гигантский фиксированный размер из-за самого большого типа, в одну кэш-линию будет помещаться меньше полезных тегов соседних объектов, что автоматически увеличивает шанс промаха по кэшу (Cache Miss).


Финальное резюме: Где "серебряная пуля"?

Эксперимент с указателями наглядно доказывает: механика std::visit сама по себе не быстрее, а зачастую сложнее и медленнее механики виртуальных таблиц.

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

  • Если архитектура вашего приложения обязывает вас использовать указатели (динамическое время жизни, тяжелые объекты, раздельная компиляция модулей), использование std::variant* становится противопоказанным. Вы получите жесткое раздувание памяти из-за выравнивания типов и проиграете классическому vtable в чистой скорости из-за каскада тройных прыжков. В этой ситуации старый добрый динамический полиморфизм на Base* остается непревзойденным индустриальным стандартом.

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


  1. tbl
    10.06.2026 03:36

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


    1. rukhi7 Автор
      10.06.2026 03:36

      Теория без практики мертва, это точно! К сожалению у меня нет таких инструментов под рукой. Надеюсь кто-нибудь не поленится проверить.

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


  1. sergio_nsk
    10.06.2026 03:36

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

    Реальный ассемблер №1: Классический vtable

    Это что за Base? Если вся программа - единственный файл, то, конечно, компилятор оптимизирует чтение vtable, он знает точный указываемый тип, и пример - далёк от реальной жизни.

    std::vector<std::variant*> - это как компилируется? Давай работающий код, с которым можно воспроизвести твои выводы.

    • Из-за правил выравнивания каждый объект std::variant в куче будет насильно раздут до размеров своего самого "тяжелого" собрата — он будет занимать 72 байта (64 байта под данные + 1 байт тег + 7 байт на выравнивание, чтобы итоговый размер делился на 8).

    А эта выдумка откуда? sizeof(std::variant<char>) есть 2. Какое ещё выравнивание до 8?


    1. Apoheliy
      10.06.2026 03:36

      Какое ещё выравнивание до 8?

      Да вроде бы обычное ... Годболт в помощь:

      sizeof(std::variant<uint8_t>) = 2

      sizeof(std::variant<uint32_t>) = 8

      sizeof(std::variant<uint64_t>) = 16

      Скрытый текст
      #include <cstdint>
      #include <iostream>
      #include <variant>
      
      int main() {
          auto k = sizeof(std::variant<uint64_t>);
          return k;
      }

      Т.е. на тэг выделяется блок размера выравнивания (1/4/8 байт в зависимости от остальных "объектов"). И для тяжёлых - это будет 8 байт. Те самые 1 байт + 7 на выравнивание из статьи. А что не так-то?


      1. sergio_nsk
        10.06.2026 03:36

        чтобы итоговый размер делился на 8

        Ни 2, ни 4 не делятся на 8.