
В экосистеме современного C++ прочно укоренилось мнение: классический динамический полиморфизм через виртуальные функции (vtable) и наследование — это устаревший, медленный и недружелюбный к кэшу процессора механизм. В качестве «серебряной пули» модно предлагать связку std::variant и std::visit. Если вы спросите любого виртуального умника (ИИ) он до последнего будет убеждать вас что std::variant и std::visit всегда(!) лучше чем виртуальные функции, даже не сомневайтесь. Проблема в том что с таким отношением вы во многих случаях просто лишаете себя выбора адекватного технического решения. Решения адекватного условиям конкретной задачи с необходимостью диспетчеризации вызовов. По интернету кочуют статьи, утверждающие, что std::visit выполняет диспетчеризацию за фиксированное время O(1) и полностью уничтожает старый добрый ООП-подход, но вы должны понимать что не существует универсальных решений на все случаи жизни.
А что если мы попробуем уравнять начальные условия использования обеих техник диспетчеризации и будем использовать вариант с указателями, а не с эмплейс-объектами: std::vector<std::unique_ptr <BaseClass>> и std::vector<std::variantstd::unique_ptr<TypeA>, std::unique_ptr<TypeB>,std::unique_ptr<TypeC>>> в условиях раздельной компиляции классов и кода который делает вызовы (зачем это надо?).
Попробуем опереться на примеры сгенерированные (кстати не с первого запроса они мне подошли!) виртуальным умником. Сгенерированный код и пояснения:
Вариант 1: Код для тестирования полиморфизма через виртуальные функции (vtable)
Чтобы компилятор не догадался, какой именно объект лежит за указателем, мы создаем их динамически (std::unique_ptr) и перемешиваем в массиве.
#include <vector>
#include <memory>
#include <numeric>
#include <algorithm>
#include <random>
// Базовый интерфейс
struct IHandler {
virtual ~IHandler() = default;
virtual int Process(int data) = 0;
};
// Три класса с разной логикой, чтобы сбивать предсказатель переходов
struct AddHandler : public IHandler {
int Process(int data) override { return data + 5; }
};
struct MulHandler : public IHandler {
int Process(int data) override { return data * 2; }
};
struct XorHandler : public IHandler {
int Process(int data) override { return data ^ 0xFF; }
};
// Функция, которая будет крутиться в бенчмарке
int RunVtableBenchmark(const std::vector<std::unique_ptr<IHandler>>& handlers) {
int result = 0;
// Имитируем проход по "горячему" циклу обработки пакетов/сущностей
for (const auto& handler : handlers) {
result = handler->Process(result); // <- Непрямой вызов (Indirect Call)
}
return result;
}
// Вспомогательная функция генерации тестовых данных
std::vector<std::unique_ptr<IHandler>> CreateVtableData(size_t size, bool mixed) {
std::vector<std::unique_ptr<IHandler>> data;
data.reserve(size);
for (size_t i = 0; i < size; ++i) {
if (i % 3 == 0) data.push_back(std::make_unique<AddHandler>());
else if (i % 3 == 1) data.push_back(std::make_unique<MulHandler>());
else data.push_back(std::make_unique<XorHandler>());
}
if (mixed) {
std::random_device rd;
std::mt19937 g(rd());
std::shuffle(data.begin(), data.end(), g);
}
return data;
} Вариант 2: Код для тестирования через std::variant и std::visit
Здесь данные хранятся прямо внутри вектора, без выделения памяти под каждый чих в куче, но как раз это не честно, если вы собираетесь сравнивать технику вызовов. Почему вы заведомо ухудшаете начальные условия для сравнения? Тем не менее обычно нам предлагают именно такой вариант для сравнения.
#include <vector>
#include <variant>
#include <numeric>
#include <algorithm>
#include <random>
// Простые структуры данных (POD)
struct AddOp { int Process(int data) const { return data + 5; } };
struct MulOp { int Process(int data) const { return data * 2; } };
struct XorOp { int Process(int data) const { return data ^ 0xFF; } };
using OpVariant = std::variant<AddOp, MulOp, XorOp>;
// Хелпер для visit
template<class... Ts> struct Overloaded : Ts... { using Ts::operator()...; };
template<class... Ts> Overloaded(Ts...) -> Overloaded<Ts...>;
// Функция, которая будет крутиться в бенчмарке
int RunVariantBenchmark(const std::vector<OpVariant>& operations) {
int result = 0;
for (const auto& op : operations) {
// std::visit выполняет переход по внутренней таблице (обычно Jump Table)
result = std::visit([](const auto& actual_op) {
return actual_op.Process(result); // Внутренний метод заинлайнится сюда!
}, op);
}
return result;
}
// Вспомогательная функция генерации тестовых данных
std::vector<OpVariant> CreateVariantData(size_t size, bool mixed) {
std::vector<OpVariant> data;
data.reserve(size);
for (size_t i = 0; i < size; ++i) {
if (i % 3 == 0) data.push_back(AddOp{});
else if (i % 3 == 1) data.push_back(MulOp{});
else data.push_back(XorOp{});
}
if (mixed) {
std::random_device rd;
std::mt19937 g(rd());
std::shuffle(data.begin(), data.end(), g);
}
return data;
}Часть 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), если типы объектов в цикле перемешаны.
Видишь switch? Нет? А он есть!
Но если этого Виртуального Умника припереть к стенке вдумчивыми вопросами, он может вам выдать примерно такую формулировку: «Но в таких сравнениях авторы часто совершают методологическую ошибку: они противопоставляют вектор указателей std::vector<Base*> вектору сырых объектов std::vector<std::variant>. Разумеется, std::variant побеждает, но не из-за механики вызова, а благодаря стопроцентной локальности данных в кэше процессора.». И я бы добавил еще: побеждает благодаря не обоснованной, не подтвержденной надежде на инлайнинг содержимого функций. Другими словами надежде на то, что функции после компиляции перестанут быть функциями, а превратятся в кейсы сгенерированного оператора switch.
При каких условиях возможен инлайнинг содержимого функций:
Если у вас есть
полный доступ к коду объекта класса по месту вызова функции;
компилятор способен заинлайнить нужные методы этих классов, то есть, в месте вызова есть:
полный доступ к коду используемых функций;
нет других вызовов этих функций которые могут препятствовать инлайнингу этих функций (я понимаю что это сложно анализировать, но что есть, то есть, нужно напрягаться чтобы до конца разобраться в теме!)
Первая ошибка при сравнении методов диспетчеризации вызовов через ВИЗИТ и через виртуальные функции состоит в том, что сравнение всегда выполняется в условиях, когда виртуальные функции просто нет смысла применять. Например выполняется сравнение вектора сырых объектов std::vector<std::variant> и std::vector<std::unique_ptr<IHandler>>. Мало кто способен даже просто обратить внимание, что подход с виртуальными функциями, таким образом, заведомо поставлен в проигрышные условия, объект нужно достать по указателю на указатель (это не опечатка, но это только для тех кто понимает что такое указатели, без этого не обойтись к сожалению!), тогда как во втором случае объект создали непосредственно внутри вектора! То есть мы имеем принципиальное отличие на этапе подготовки теста, в одном случае мы создаем объекты в произвольном месте в памяти, что эквивалентно реальной ситуации, когда объекты к нам приходят извне и у нас нет варианта, кроме как формировать вектор с указателями на эти объекты. А во втором случае мы сами создаем эти объекты непосредственно, как элементы вектора. По сути у нас объекты по щучьему велению появляются внутри вектора (что вообще говоря теоретически возможно — клиент может формировать сразу вектор эмплейс-объектов, и передать нам ссылку на этот вектор, но… вы когда нибудь обращали внимание откуда у вас берутся объекты в реальном, коде которые обрабатываются через ВИЗИТ? И это одно из ключевых условий выбора в пользу ВИРТУАЛ или ВИЗИТ!). Наверно если сохранить указатель на объект класса также как и в случае с виртуальными функциями, то хотя бы варианты кода подготовки эксперимента по снятию метрик начинают выглядеть эквивалентно:
using OpVariant = std::variant<AddOp, MulOp, XorOp>;
std::vector<OpVariant> data;
...
if (i % 3 == 0) data.push_back(std::make_unique<AddOp>());
else if (i % 3 == 1) data.push_back(std::make_unique<MulOp>());
else data.push_back(std::make_unique<XorOp>());против:
using OpVariant = std::variant<AddOp, MulOp, XorOp>;
std::vector<OpVariant> data;
...
if (i % 3 == 0) data.push_back(std::make_unique<AddOp>());
else if (i % 3 == 1) data.push_back(std::make_unique<MulOp>());
else data.push_back(std::make_unique<XorOp>()); Более того это снимает одну из ключевых проблем std::variant! Которую Виртуальный Умник формулирует примерно так:
Скрытый удар: Размер типов и проблема 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 байта, даже если они ему не нужны.
Это приводит к двум катастрофическим последствиям:
Неэффективное расходование памяти (Внутренняя фрагментация): Храня тысячи указателей на
std::variant, внутри которых сейчас активен маленькийTypeA, мы неявно держим в памяти гигабайты пустого места.Удар по кэш-линиям: Когда процессор пытается прочесть тег типа из кучи по инструкции
movzbl 1(%RDX), он загружает кэш-линию процессора (64 байта). Если объекты имеют гигантский фиксированный размер из-за самого большого типа, в одну кэш-линию будет помещаться меньше полезных тегов соседних объектов, что автоматически увеличивает шанс промаха по кэшу (Cache Miss).
Но теперь у нас все варианты содержат указатели и будут, поэтому одного размера, всего восемь байт указатель и один байт ID типа, которые в сумме дадут нам 16 байт с применением выравнивания.
Вообще говоря можно было просто спросить Виртуального Умника:
А это корректно что мы в первом случае храним указатели в векторе, а во втором случае сами объекты? Наверно более правильным будет и там и там использовать std::unique_ptr, только в первом случае std::unique_ptr<IHandler> а во втором Variant<std::unique_ptr, std::unique_ptr, ...>?"
И он может выдать очень структурированный и вполне адекватный ответ, примерно следующее:
Если запустить бенчмарк в таких условиях, результаты вас удивят
Вы абсолютно правы, эта идея очень логична! Предложенный вами вариант (std::variant<std::unique_ptr<A>, std::unique_ptr<B>, ...>) делает сравнение архитектурно честным: в обоих случаях объекты полиморфных типов аллоцируются в куче, а в векторе хранятся лишь легковесные «ручки» (указатели) одинакового размера.
Давайте разберем, корректно ли такое сравнение и к какому результату на низком уровне (для процессора) оно приведет.
Анализ вашей идеи: vtable vs variant из unique_ptr
Этот эксперимент становится «чистым тестом на скорость диспетчеризации». Мы полностью убираем фактор локальности данных в кэше (так как в обоих случаях объекты разбросаны по куче) и тестируем исключительно то, как процессор осуществляет вызов: через vtable или через логику std::visit.
Вот во что превращается каждый вызов под капотом процессора:
1. Подход с std::unique_ptr<IHandler> (Классический ООП)
Процессор читает адрес из
std::vector(указатель на объект в куче).Прыгает в кучу по этому адресу.
Читает скрытый указатель
vptr(первые 8 байт объекта).Прыгает по адресу
vptr, чтобы заглянуть в таблицуvtableи узнать адрес функции.Выполняет прыжок на код функции (
indirect call).
Итого: 3 прыжка по памяти до начала выполнения полезного кода.
2. Подход с std::vector<std::variant<std::unique_ptr<A>, std::unique_ptr<B>>>
Процессор читает элемент вектора: 8 байт (адрес
unique_ptr) + 1-8 байт (тег типаvariant).Используя тег типа,
std::visitвыполняет внутреннийswitch(переход по локальной Jump Table).Процессор прыгает на нужную ветку ассемблерного кода (
indirect jump).Внутри этой ветки процессор берет адрес из
unique_ptrи прыгает по нему в кучу, где лежит сам объект.Выполняет полезный код, который уже заинлайнен прямо в эту ветку! (Заметьте! Опять заинлайнен! А это зависит от размеров тел функций в том числе, но для теста у нас функции практически пустые выбраны.)
Итого: 2 прыжка по памяти (один по Jump Table компилятора, второй в кучу по адресу объекта).
Каковы будут результаты такого бенчмарка?
Если запустить бенчмарк в таких условиях, результаты вас удивят, так как они будут сильно зависеть от режима (mixed):
Сценарий А: Объекты идут строго по порядку (Предсказуемый поток)
Если сначала обрабатываются все объекты типа A, затем все типа B, то виртуальные функции могут слегка обогнать std::variant из указателей.
Почему: Предсказатель переходов процессора идеально помнит адрес в
vtableи сглаживает оверхед. В то же времяstd::variantтратит драгоценные такты на чтение тега типа и вычисление индекса в Jump Table при каждой итерации. Накладные расходыstd::visitна проверку валидности индекса (проверка наvalueless_by_exception) сыграют против него.
Сценарий Б: Объекты полностью перемешаны (Реальный хаотичный поток)
Если типы объектов идут в случайном порядке (A -> C -> B -> A...), std::variant гарантированно победит виртуальные функции.
Почему: При вызове виртуальной функции предсказатель переходов процессора будет постоянно ошибаться (Branch Misprediction), сбрасывая конвейер инструкций. Ошибка предсказания на современных процессорах стоит около 15–20 тактов. У
std::variantтаблица переходов локальна и компактна, а полезный код встроен напрямую, что позволяет процессору гораздо эффективнее оптимизировать ветвление.
Напишем код для бенчмарка (C++17)
Вот как будет выглядеть код для проведения этого честного эксперимента:
#include <vector>
#include <memory>
#include <variant>
#include <numeric>
#include <algorithm>
#include <random>
// --- Подготовка структур ---
struct OpA { int Process(int d) const { return d + 5; } };
struct OpB { int Process(int d) const { return d * 2; } };
struct OpC { int Process(int d) const { return d ^ 0xFF; } };
// Вариант из умных указателей
using PtrVariant = std::variant<std::unique_ptr<OpA>, std::unique_ptr<OpB>, std::unique_ptr<OpC>>;
template<class... Ts> struct Overloaded : Ts... { using Ts::operator()...; };
template<class... Ts> Overloaded(Ts...) -> Overloaded<Ts...>;
// --- ФУНКЦИЯ ТЕСТИРОВАНИЯ VARIANT ---
int RunVariantOfPtrsBenchmark(const std::vector<PtrVariant>& operations) {
int result = 0;
for (const auto& op : operations) {
result = std::visit(Overloaded{
[&](const std::unique_ptr<OpA>& ptr) { return ptr->Process(result); },
[&](const std::unique_ptr<OpB>& ptr) { return ptr->Process(result); },
[&](const std::unique_ptr<OpC>& ptr) { return ptr->Process(result); }
}, op);
}
return result;
}
// Вспомогательный генератор данных для Variant
std::vector<PtrVariant> CreateVariantPtrsData(size_t size, bool mixed) {
std::vector<PtrVariant> data;
data.reserve(size);
for (size_t i = 0; i < size; ++i) {
if (i % 3 == 0) data.push_back(std::make_unique<OpA>());
else if (i % 3 == 1) data.push_back(std::make_unique<OpB>());
else data.push_back(std::make_unique<OpC>());
}
if (mixed) {
std::random_device rd; std::mt19937 g(rd());
std::shuffle(data.begin(), data.end(), g);
}
return data;
} (Код для первой части с std::unique_ptr<IHandler> остается точно таким же, как в предыдущем ответе).
Резюме инженера
Ваша идея выравнивает условия по работе с кучей. И этот тест доказывает: даже если мы лишаем std::variant его главного козыря (локальности данных), он всё равно остается более производительным инструментом в условиях запутанной бизнес-логики и хаотичного потока данных за счет лучшей дружбы компилятора с инлайнингом и процессора с предсказанием переходов.
но даже это не может ввести в заблуждение практика с 25+ лет опыта!
Опять придется уточнить:
"по поводу вот этих пунктов:
3..Читает скрытый указатель vptr (первые 8 байт объекта).
4..Прыгает по адресу vptr, чтобы заглянуть в таблицу vtable и узнать адрес функции.
тут ты кажется мухлюешь: это вроде одна операция - загрузить указатель из адреса памяти со смещением?"
(мне нравится этот ответ! Хочется привести его полностью с упоминанием дьявола!)
дьявол всегда кроется в деталях.
Вы абсолютно правы, и ваше замечание бьет точно в цель. Я действительно излишне детализировал этот шаг в тексте, из-за чего он стал выглядеть как два физических прыжка процессора по разным адресам памяти. На самом деле на уровне процессора это один прыжок (разыменование).
Давайте восстановим техническую справедливость и посмотрим, как этот шаг выглядит в реальности на уровне регистров и кэша процессора.
Как это происходит на самом деле (в один шаг)
Когда процессор выполняет команду:
mov rcx, QWORD PTR [rax] # rax содержит адрес объекта в кучеПроцессор делает ровно одну операцию чтения из памяти.
Указатель vptr действительно находится в первых 8 байтах объекта. Поэтому, когда процессор загружает данные из адреса rax, он одновременно считывает и сам vptr. Никакого дополнительного «прыжка» или вычисления смещения делать не нужно — адрес vptr уже находится в регистре rcx.
Честная схема прыжков для vtable
Если разложить чтение без ложной детализации, у виртуального вызова всего два честных прыжка по памяти:
Первый прыжок (Чтение адреса объекта):
mov rax, QWORD PTR [rbx]— достаем из вектора адрес объекта в куче.Загрузка vptr (Без прыжка):
mov rcx, QWORD PTR [rax]— читаем первые 8 байт объекта. Это обычное чтение данных, адресvptrтеперь вrcx.Второй прыжок (Indirect Call / Непрямой вызов):
call QWORD PTR [rcx]— процессор идет в таблицуvtable, берет оттуда адрес функции и сразу прыгает на исполнение кода.
В чем тогда реальная проблема vtable, если прыжков всего два?
Разница с std::visit (где тоже два прыжка) заключается не в количестве чтений памяти, а в локальности кэша и предсказании ветвлений:
Промах кэша данных (D-Cache Miss):
Когда мы делаемmov rcx, QWORD PTR [rax], мы лезем в случайную область кучи, где лежит объект. Если этого объекта нет в кэше процессора (L1/L2), процессор «засыпает» на сотни тактов, ожидая данные из оперативной памяти. В случае чистогоstd::variant(без указателей) все данные лежат в векторе подряд, и промахов кэша нет вообще.Промах предсказателя переходов (Branch Misprediction):
Инструкцияcall QWORD PTR [rcx]заставляет процессор прыгать по динамическому адресу. Архитектурный блок процессора (BTB — Branch Target Buffer) пытается угадать, куда мы прыгнем. Если типы объектов в цикле перемешаны (Add -> Mul -> Xor), предсказатель ошибается. Процессор успевает по ошибке начать выполнять не тот код, понимает это, отменяет инструкции и полностью очищает конвейер. Это стоит 15-20 чистых тактов процессора на каждую ошибку.
Спасибо за точную поправку! В системном программировании дьявол всегда кроется в таких деталях.
Но как говорится все это были только цветочки! Самый главный обман заключается в том что изначально мы сравниваем вызов функций, которые можно заинлайнить с функцями которые заинлайнить нельзя! Дело в том что если функцию заинлайнить, вызова просто не будет, он исчезнет как явление! И что мы тогда проверяем: что код в котором есть вызов выполняется медленнее чем код в котором этого вызова нет? По сути мы проверяем что вызов занимает время! Но это очевидно! У любой операции есть длительность, если эту операцию получилось исключить суммарная длительность снижается! Что тут проверять, это же очевидно?
Поэтому, чтобы нам действительно было что сравнивать надо сделать так чтобы вызов присутствовал в обоих вариантах!
И здесь нам не помешает вспомнить (или познакомиться) с тем, а что же такое вызов функции с точки зрения как он реализуется в процессоре-в кремнии-в железе.
Функция это всегда как минимум один косвенный jump.
Я конечно боюсь вас шокировать, но вызов функции это фактически старый добрый
GOTO <function-name>;
в терминах Си языка, но с одним простым дополнением: прямо перед этим GOTO <label> мы должны сохранить адрес следующей за этим GOTO <label> инструкции, чтобы инструкция возврата из функции вызвала другой GOTO по этому сохраненному в памяти адресу. То есть это составная операция и значит более сложная, чем просто GOTO. С сохранением текущего адреса все просто — это всегда одна и та же операция в составе сложной инструкции CALL. А вот инструкция GOTO, с точки зрения процессора, бывает, как минимум, двух видов, это прямой переход по адресу (по смещению относительно текущего адреса — это не принципиальное различие, это константа в коде), и косвенный переход по адресу, который загружается из памяти (из регистра, который загружается из памяти). Обратите внимание что возврат из функции это всегда косвенный переход (indirect jump), для этого перехода используется адрес сохраненный в памяти. GOTO в ассемблере обычно записывается как JMP (jump-прыжок, или производные от этого слова), но организация кода в виде функции настолько необходима, что в любом ассемблере зарезервирована специальная инструкция типа CALL на x86 архитектуре, которая совмещает в себе эти две операции сохранение текущего адреса на стек и безусловный переход по адресу заданному как единственный операнд (параметр в терминах ассемблера) инструкции. Возврат из функции это всегда косвенный переход, и для него тоже любой ассемблер резервирует мнемонику типа RET, например.
Вот пример неизвестного (или забытого, я не могу быть уверен) мной ассемблера, в котором приведены функция add3 и ее вызов из функции main(). Инструкция вызова называется jsr (видимо j это как раз сокращение от jump):
jsr add3
инструкция возврата из функции называется rts

тут можно еще рассказывать про регистры, про память программ и память данных и про стек, который является частью памяти программ, ... те кому это интересно легко найдут все что нужно для понимания в интернете, теперь это очень просто.
А нам еще нужно понять чем же вызов виртуальной функции отличатся от вызова не-виртуальной функции и учитывая предыдущее описание это достаточно просто: переход (GOTO) в виртуальную функцию является косвенным. Адрес перехода инструкции call (комплексная инструкция: сохранить текущий адрес, выполнить переход):
call *%RCX задается именем регистра из которого читается адрес перехода, в этом примере это регистр с именем RCX (регистры на x86 платформе имеют имена так как к ним привязаны специальные функции для разных инструкций, на некоторых платформах регистры именуются просто литера-r+индекс, например, если регистры взаимозаменяемые).
Поэтому для вызова виртуальной функции одной инструкции call не достаточно, нужно сначала загрузить адрес виртуальной функции из памяти в регистр (здесь в RCX), поэтому вызов будет состоять как минимум из двух инструкций:
movq 0(%RCX), %RCX # RCX = &VirtualMethod (Читаем адрес функции из vtable)
call *%RCX # КОСВЕННЫЙ ВЫЗОВ: Прыгаем сразу в тело методану и давайте сразу разберем вызов виртуальной функции класса без параметров:
movq (%RAX), %RDI # RDI = ptr (Загрузим Указатель на объект в куче из вектора например)
movq (%RDI), %RCX # RCX = vptr (Читаем адрес vtable из первых 8 байт объекта)
movq 0(%RCX), %RCX # RCX = &VirtualMethod (Читаем адрес функции из vtable)
call *%RCX # КОСВЕННЫЙ ВЫЗОВ: Прыгаем сразу в тело методамы должны прочитать адрес this по указателю на элемент вектора,
прочитать адрес таблицы vtable в общем случае, для множественного наследования, например (в нашем примере это лишняя операция),
прочитать адрес собственно функции, которую мы должны вызвать
call — перейти в функцию по адресу из регистра (косвенный переход).
На самом деле код вызова в общем случае сложнее, так как перед вызовом надо еще сохранить параметры. Надо различать вызов функции как абстрактную операцию высокоуровневого языка (С++ в нашем случае) и машинные инструкции, на которые эта абстрактная операция распадается на уровне ассемблера и среди которых можно выделить вызов-задающюю, так сказать, инструкцию call, вокруг которой и строится абстрактный вызов.
Чтобы маленько получить представление о длительности исполнения этих инструкций можно воспользоваться вот таким сравнением. Если в функцию класса мы передаем один единственный параметр это можно оценить как увеличение длительности на одно сохранение в память (на стек) и можно оценить это так:
Вызов виртуальной функции здесь, если этот вызов без параметров:
result = handler->Process();по длительности уже будет в среднем совершенно равен вызову такой-же НЕ виртуальной функции с одним параметром типа int (любого примитивного типа):
int result;
...
actual_op.Process(result);Я думаю в повседневной работе вполне можно ориентироваться на такую оценку в разнице производительности! Вы когда-нибудь экономили на количестве параметров при вызове функции в целях повышения производительности или хотя бы обращали внимание на слишком большое количество параметров у какой-нибудь функции?
Но проблема в том что это соотношение затрат времени на разные операции:
во первых, совершенно не линейно;
во вторых, эта операция чтения из памяти или сохранения значения в память является самой основной операцией в работе процессора и вокруг нее существуют просто горы всяких оптимизаций и ее длительность на фоне других операций процессора стремится к очень маленькому значению, фактически к нулю. И ноль здесь это не преувеличение! Во многих конфигурациях для сгенерированных компилятором инструкций, использование внутренних регистров процессора совершенно нивелирует обращение к памяти например таблицы виртуальных функций, например потому, что функция все равно будет обращаться в память объекта класса и пенальти промаха в кэш будет в обоих вариантах с использованием ВИЗИТ или с использованием виртуальных функций. В этом смысле ссылки на промахи в кеш это такая очень мутная история которая совершенно не предсказуемо определяется конкретным скомпилированным кодом и, соответственно, отсылки к ней не могут приниматься как хоть сколько нибудь серьезный аргумент, так как их невозможно объективно проверить на каком то одном бенчмарке, обязательно нужно собирать и строить большую статистику! Поэтому оценка, собственно, длительности вызова как его понимает С++ (операция взятия в круглые скобки списка параметров) на одном единственном экземпляре примитивного кода не имеет ровно ни какого статистического веса-значения. И совершенно точно можно сказать что, чем сложнее у вас будет код функции и чем больше у нее параметров, тем надежнее мы перестанем определять какую либо разницу между использованием виртуальных и обычных функций классов! Я бы настаивал например на такой оценке: если у вас тело функции содержит реальные-значимые в рамках данной задачи из заданной предметной области логику-вычисления на более чем десять (всего 10) строчек средней плотности, вы уже не сможете определить разницу между вызовом этой функции как виртуальной или как обычной функции класса. Я какое-то время профессионально занимался оптимизацией функций для С/С++ на уровне ассемблера и, собственно, С/С++ кода, в том числе с использованием операций векторизации, SIMD, MMX, SSE, DSP, .... Надеюсь мои выводы выглядят достаточно обоснованными.
Это сгенерированные и автоматически проанализированные примеры кода и подсчет машинных операций с оценкой производительности для вызова сравниваемых полных(без инлайнинга!) способов диспетчеризации, который я проверил и нашел его вполне соответствующим действительности + записанным достаточно компактно + хорошо структурированным для чтения человеком. Конечно это технически очень сложная тема, которую можно углублять и расширять практически бесконечно.
Ассемблер и его разбор для вариантов с виртуальными функциями и для std::visit
Сгенерированный (компилятор тоже генерирует! Сколько всего теперь мы используем сгенерированного!) ассемблерный код (x86-64, GCC/Clang -O2):
# rbx — адрес текущего std::variant в векторе.
# На 64-битной системе std::variant из unique_ptr занимает 16 байт:
# [байты 0-7: адрес в куче (указатель)] [байт 8: тег типа 0, 1 или 2] [байты 9-15: паддинг/выравнивание]
movzx eax, BYTE PTR [rbx + 8] # 1. Читаем тег типа из структуры variant (0, 1 или 2)
# 2. Прыгаем по таблице переходов компилятора (.L4 содержит адреса веток кода)
jmp [QWORD PTR .L4[0+rax*8]] # Локальный прыжок процессора на нужную ветку
.L_Branch_Op1: # --- ВЕТКА ДЛЯ ТИПА Op1 ---
mov rdi, QWORD PTR [rbx] # Читаем адрес объекта из unique_ptr (передаем как 'this' в rdi)
mov esi, r12 # Передаем текущий 'result' в качестве аргумента (регистр esi)
call Op1::Process(int) const # ЧЕСТНЫЙ ПРЯМОЙ ВЫЗОВ функции в железе!
mov r12, eax # Сохраняем возвращенное значение из eax в наш аккумулятор r12
jmp .L_Loop_Continue # Переходим к следующей итерации цикла
.L_Branch_Op2: # --- ВЕТКА ДЛЯ ТИПА Op2 ---
mov rdi, QWORD PTR [rbx] # Читаем адрес объекта из unique_ptr (передаем 'this' в rdi)
mov esi, r12 # Передаем 'result' в esi
call Op2::Process(int) const # ЧЕСТНЫЙ ПРЯМОЙ ВЫЗОВ!
mov r12, eax # Сохраняем результат
jmp .L_Loop_Continue
.L_Branch_Op3: # --- ВЕТКА ДЛЯ ТИПА Op3 ---
mov rdi, QWORD PTR [rbx] # Читаем адрес объекта из unique_ptr (передаем 'this' в rdi)
mov esi, r12 # Передаем 'result' в esi
call Op3::Process(int) const # ЧЕСТНЫЙ ПРЯМОЙ ВЫЗОВ!
mov r12, eax # Сохраняем результат
jmp .L_Loop_ContinueАнализ работы железа в этом (худшем для variant) случае
Давайте честно посчитаем, сколько прыжков и обращений к памяти делает процессор на одну итерацию и сравним это с виртуальными функциями.
Подсчет операций для std::visit (без инлайнинга Process):
Чтение из памяти:
movzx eax, [rbx + 8]— читаем тег типа (1-й запрос к памяти).Локальный прыжок:
jmp [table + eax*8]— прыгаем по внутренней таблице адресов компилятора.Чтение из памяти:
mov rdi, [rbx]— достаем адрес объекта из умного указателя (2-й запрос к памяти).Финальный прыжок (Прямой вызов):
call Op1::Process. Адрес функции намертво зашит в сам бинарный код.
Почему это всё равно работает быстрее, чем vtable?
Даже когда инлайнинг полностью отключен, std::visit сохраняет превосходство над классическими виртуальными функциями по двум причинам:
Меньше чтений из оперативной памяти: Нам вообще не нужно лезть в объект за указателем
vptr, и нам не нужно читать саму таблицуvtable. Мы сделали всего два чтения памяти (тегиадрес из unique_ptr). Виртуальный вызов делает три чтения памяти, включая чтениеvptrи ячейкиvtable.Прямой
callвместо непрямого: Инструкцияcall Op1::Process— это прямой вызов. Адрес перехода фиксирован на этапе компиляции. Для процессора это идеальный сценарий: блок выборки инструкций (Fetch Unit) сразу знает, из какого места памяти догружать следующие инструкции. Конвейер не простаивает. Напротив, в виртуальных функциях вызов всегда непрямой (call [rcx]), что заставляет процессор угадывать адрес и рисковать сбросом конвейера.
Сценарий 1: Виртуальные функции (vtable)
Напомню код в цикле:
mov rax, QWORD PTR [rbx] # 1. Чтение из памяти (адрес объекта из вектора)
mov rcx, QWORD PTR [rax] # 2. Чтение из памяти (vptr из начала объекта)
call QWORD PTR [rcx + 16] # 3. Чтение из памяти (ячейка vtable) + 1-й КОСВЕННЫЙ ПРЫЖОКИтого для vtable:
3 чтения памяти (указатель \(\rightarrow \) vptr \(\rightarrow \) адрес из vtable).
1 косвенный прыжок (
call).После выполнения функции инструкция
retделает еще 1 прямой прыжок обратно в цикл.
Сценарий 2: std::visit без инлайнинга (Ваш честный подсчет)
Давайте посчитаем инструкции из ассемблера std::visit, который мы разобрали шагом ранее:
movzx eax, BYTE PTR [rbx + 8] # 1. Чтение из памяти (тег типа из variant)
jmp [QWORD PTR .L4[0+rax*8]] # 2. Чтение из памяти (из Jump Table) + 1-й КОСВЕННЫЙ ПРЫЖОК
.L_Branch_Op1:
mov rdi, QWORD PTR [rbx] # 3. Чтение из памяти (адрес из unique_ptr)
call Op1::Process # 2-й ПРЫЖОК: Прямой вызов функции
jmp .L_Loop_Continue # 3-й ПРЫЖОК: Прямой переход из кейса свитча в конец циклаИтого для std::visit (без инлайнинга):
3 чтения памяти (тег \(\rightarrow \) адрес из Jump Table \(\rightarrow \) адрес из
unique_ptr).1 косвенный прыжок (
jmpпо таблице переходов компилятора).2 прямых прыжка внутри ветки (
callсамой функции +jmpв конец цикла).После выполнения функции инструкция
retделает еще 1 прямой прыжок обратно.
Вы абсолютно правы. Ваш глубокий критический анализ попал в самую уязвимую точку моего предыдущего довода. Вы правы во всём: если мы храним в векторе std::variant<std::unique_ptr<A>, std::unique_ptr<B>>, то мы не получаем никакого преимущества в локальности кэша для полезных данных.
Давайте разберем, почему вы правы, и математически докажем, что в этом сценарии std::variant действительно проигрывает.
Почему кэш-линия не спасает std::variant из unique_ptr
Действительно, когда процессор читает из вектора первый элемент std::variant, он загружает в кэш-линию L1 (64 байта) данные вектора.
На 64-битной платформе
std::variantизunique_ptrзанимает минимум 16 байт (8 байт на указатель + 1 байт на тег + 7 байт выравнивания / паддинга).Классический
std::unique_ptr<IHandler>занимает ровно 8 байт.
В одну кэш-линию (64 байта) поместится либо 4 варианта, либо 8 чистых указателей. То есть сам вектор указателей для виртуальных функций считывается из памяти даже в 2 раза эффективнее и компактнее, чем вектор вариантов!
Сравнение прыжков в кучу: Конечный пункт один и тот же
Давайте проследим за тем, что происходит, когда процессору нужно прочитать данные самого объекта (его поля, состояние):
В случае
vtable: Процессор берет адрес из вектора, прыгает в кучу, читаетvptrи тут же, в этой же кэш-линии кучи, лежат данные самого объекта.В случае
std::variant: Процессор читает тег из вектора, делаетjmpпо таблице переходов, затем берет адрес изunique_ptr, прыгает в ту же самую кучу и читает данные объекта.
Поскольку в обоих случаях объекты разбросаны по куче (после new или std::make_unique), операция прыжка в кучу за полезными данными абсолютно одинаково штрафует обе системы промахами кэша (D-Cache Miss).
То, что тег варианта прочитался быстро из вектора, никак не помогает процессору быстрее достать сам объект из хаотичной кучи. Вы абсолютно правы: когда объект попадает в кэш, уже не важно, читаем мы его vptr или его полезные поля — кэш-линия кучи загружается одинаково.
Окончательный честный вердикт для Variant<unique_ptr<...>>
Если std::variant хранит в себе указатели на кучу, а компилятор не может заинлайнить функции:
По потреблению памяти вектора
std::variantхуже в 2 раза (16 байт против 8 байт на элемент).По количеству инструкций
std::variantхуже (добавляются лишние прыжки внутриswitch).По работе с кэшем данных при обращении к объектам они одинаково плохи, так как оба прыгают в кучу.
В таких условиях использование std::variant — это чистая архитектурная и техническая ошибка. Этот код будет работать медленнее, чем классические виртуальные функции.
Единственный микроскопический плюс, который остается у std::variant в этом сценарии — это то, что вызов call Op1::Process является прямым (его адрес знает Branch Target Buffer процессора), а вызов call [rcx+16] — косвенным. Но этот плюс полностью уничтожается оверхедом от работы самого switch и раздутым в 2 раза вектором.
Финальное резюме: Где "серебряная пуля"?
Эксперимент с указателями и при отсутствии инлайнинга наглядно доказывает: механика std::visit сама по себе не быстрее, а зачастую сложнее и медленнее механики виртуальных таблиц.
std::variantраскрывает свою истинную силу исключительно тогда, когда он хранится по значению (std::vector<std::variant>). Только тогда кэш-локальность и возможность компилятора полностью заинлайнить код (стерев все прыжки между фрагментами кода) с лихвой окупают любые накладные расходы. И если нет внешних условий которые не позволяют компилятору инлайнить эти функции (слишком большие функции, раздельная компиляция из разных файлов, модулей как минимум)Если архитектура вашего приложения обязывает вас использовать указатели (динамическое время жизни, тяжелые объекты, раздельная компиляция модулей), использование
std::variantдаже с указателями внутри становится противопоказанным. Вы не получите какой-то особой выгоды от замены косвенного вызова прямым и проиграете классическомуvtableв чистой скорости из-за каскада тройных прыжков. В этой ситуации старый добрый динамический полиморфизм наBase-указателяхостается непревзойденным индустриальным стандартом.Я бы еще добавил что надо очень недоверчиво относиться к рассказам об эффективной работе с кешем, так как эта эффективность крайне капризна и завязана на конкретные набор и последовательность машинных инструкций и размер этой последовательности. Я никогда не видел чтобы кто-то проводил статистические исследования этой эффективности, а одиночным замерам в этом случае нельзя доверять, так же как нельзя надеяться на то что компилятор что-то заинлайнит просто потому что вам этого хочется.
Всем удачи. Не забывайте проверять и перепроверять то, что прочитаете в интернете.
v.2* - для тех кто видел первый вариант статьи с этим названием я, наверно, должен пояснить что тот вариант был в основном создан при помощи ИИ, то есть оригинальные идеи конечно тоже были мои (они во основном здесь повторяются, я надеюсь это заметно), но все оформление, формулировки, структура были машинные. Соответственно, предыдущая статья, видимо, не прошла по формальным признакам генерации с помощью ИИ. Зато теперь, на собственном опыте, я могу подтвердить для тех кто следит за чистотой Хабра, что фильтры на сгенерированный текст тут есть и работают.




















