Вопрос или проблема
Кратко
Является ли бенчмарк, который я представлю ниже, справедливым способом сравнения подхода на основе наследования и подхода на основе std::function
к полиморфизму?
Полный вопрос
Если необходимо иметь разные объекты, которые реализуют один и тот же интерфейс разными способами, и также нужно иметь возможность помещать их в контейнер и обменивать один на другой во время выполнения, то самым популярным решением является использование наследования:
struct Base {
virtual void f() = 0;
virtual ~Base() = default;
};
struct Derived1 : Base {
virtual void f();
};
struct Derived2 : Base {
virtual void f();
};
Другим решением является наличие единого класса, но замена виртуального
метода на std::function
:
struct Foo {
std::function<void()> f{};
};
auto foo1 = Foo{[]{ return /* реализация как у Derived1 */; }};
auto foo2 = Foo{[]{ return /* реализация как у Derived2 */; }};
(Некоторые вопросы о различиях между двумя подходами можно найти здесь, здесь и здесь.)
Тем не менее, независимо от других плюсов и минусов любого из решений, мне интересно измерить разницу в производительности с помощью бенчмарка.
Я понимаю, что производительность будет явно варьироваться в зависимости от того, как реализован std::function
, а также от компилятора и переданных ему параметров, операционной системы и, кто знает, чего еще.
Но при фиксировании всех этих факторов, я думаю, можно измерить разницу в производительности, если она вообще существует.
Я хочу уточнить, что мое намерение – увидеть своими глазами, что действительно разница между двумя подходами должна считаться незначительной, если не в очень специфических случаях использования, как я понял из связанных вопросов и других источников. Или доказать, что мое понимание ошибочно и действительно существует важная разница в производительности.
Моя попытка написать бенчмарк представлена ниже:
Несколько объяснений относительно различных его частей:
- Все
f
выше по-разному изменяют глобальнуюunsigned int
,unsigned int RETURN{};
которую я
возвращаю
изmain
, чтобы убедиться, что тело этих функций нельзя оптимизировать; - Я изменил
Derived1::f
/Derived2::f
и тела лямбдfoo1
/foo2
(относительно приведенных фрагментов) так, чтобы они изменяли вышеупомянутую глобальнуюunsigned int
:struct Base { virtual void f() = 0; virtual ~Base() = default; }; struct Derived1 : Base { virtual void f() { RETURN += 1; } }; struct Derived2 : Base { virtual void f() { RETURN += 2; } }; struct Foo { std::function<void()> f{}; }; auto const foo1 = Foo{[]{ RETURN += 1; }}; auto const foo2 = Foo{[]{ RETURN += 2; }};
- Перед кодом измерения я генерирую случайные
bool
, которые использую для случайного выбора междуDerived1
/foo1
иDerived2
/foo2
std::random_device rd; std::mt19937 gen{rd()}; std::bernoulli_distribution randBool{0.5}; constexpr int N = 1000000; std::array<bool, N> bools; for (bool& b : bools) { b = randBool(gen); }
- Я использую Boost.Hana для удобного обхода кортежа из 2 компилируемых
true
/false
, что позволяет параметризовать два случая, и Range-v3 для удобного накопления времени измерений, выполняемых для каждого вызовавиртуальной
функции/std::function
:using Time = duration<double, std::milli>; std::array<Time, 2> times; // 0: на основе std::function, 1: на основе наследования hana::for_each(hana::make_basic_tuple(hana::false_c, hana::true_c), [&](auto hb) { constexpr bool B = hb; auto const elapsed = ranges::accumulate(bools, Time{}, [](auto acc, auto b){ /* измерение времени */; }); times[!B] = elapsed; });
- Функция для выбора в зависимости от этого времени
bool
eanb
следующая, которая также является шаблонной по компилируемомуbool
eanB
, используемому для выбора между двумя сравниваемыми случаями:template<bool B> constexpr auto bool2Obj = []{ if constexpr (B) { return [](bool b){ return b ? foo1 : foo2; }; } else { using BasePtr = std::unique_ptr<Base>; return [](bool b){ return b ? BasePtr{std::make_unique<Derived1>()} : BasePtr{std::make_unique<Derived2>()}; }; } }();
- Как только объект выбран, его метод вызывается следующим образом, что также является шаблонным по
bool B
по той же причине, что и выше, т.е. позволяет выбрать каждый из двух сравниваемых случаев:template<bool B> constexpr auto call = []{ if constexpr (B) { return [](Foo const& p){ p.f(); }; } else { return [](std::unique_ptr<Base> const& p){ p->f(); }; } }();
- Измерение времени выглядит следующим образом:
auto obj = bool2Obj<B>(b); auto const start = high_resolution_clock::now(); call<B>(obj); auto const end = high_resolution_clock::now() - start; return acc + Time{end};
где я исключил случайный выбор объекта из измерений, оставив в измерении только
call
.
Результат,
- с несколькими повторениями, которые я могу выполнить перед завершением процессов из-за времени ожидания, CompilerExplorer кажется, сообщает мне, что оба подхода имеют примерно одинаковую производительность, так как процент, который я вывожу,
(i - f) / i
(гдеi
иf
– это времена выполнения подходов на основеi
нкеродиованя иf
ункций), часто меняет знак; это наблюдается как в Clang, так и в GCC - тем не менее, при выполнении программы на моем компьютере, в то время как GCC, похоже, ведет себя аналогично, Clang (18.1.8) постоянно возвращает положительные результаты, такие как следующие, что предполагает, что подход на основе
std::function
быстрее:0.0648057 0.0716398 0.0636759 0.0649676 0.0673908 0.0756509 0.0780861 0.0890416 0.090532 0.094767
- с третьей стороны, QuickBench (при этом мне пришлось отказаться от Boost и Range-v3), кажется, последовательно поддерживает, что подход на основе
std::function
быстрее, для GCC, Clang + LLVM, Clang + GNU.
Ответ или решение
Сравнение производительности между виртуальными функциями с наследованием и использованием std::function
без наследования является важным аспектом при проектировании архитектуры программного обеспечения на C++. Правильная оценка различий в производительности может помочь разработчикам выбрать наилучший подход, учитывая специфику задачи.
1. Понимание подходов: наследование и std::function
Сначала кратко изложим основные характеристики каждого подхода:
- Наследование с виртуальными функциями: Этот метод позволяет создавать иерархии классов, где <<базовый класс>> определяет интерфейс, а производные классы реализуют специфическую функциональность. Вызов виртуальных функций требует разыменования указателя на виртуальную таблицу (vtable), что может вносить дополнительные накладные расходы.
struct Base {
virtual void f() = 0;
virtual ~Base() = default;
};
struct Derived1 : Base {
void f() override { RETURN += 1; }
};
struct Derived2 : Base {
void f() override { RETURN += 2; }
};
- Использование
std::function
: Этот подход состоит в сохранении функции или лямбда-выражения в переменной типаstd::function
. Этот метод более гибок и может использоваться без создания множества классов, однако он может вести к дополнительным затратам на управление памятью и копирование.
struct Foo {
std::function<void()> f;
};
auto foo1 = Foo{[]{ RETURN += 1; }};
auto foo2 = Foo{[]{ RETURN += 2; }};
2. Методика измерения производительности
Для исследования разницы в производительности предлагается следующая методология:
-
Создание глобального指标: Глобальная переменная
unsigned int RETURN
используется для проверки того, что функции действительно выполняются, и их результат может быть измерен. -
Количество вызовов: Для тестирования выбираем достаточное количество вызовов функций (например, 1 миллион), чтобы получить статистически значимые результаты.
-
Случайный выбор: Генерируем случайные булевы значения для выбора между
Derived1
иDerived2
или междуfoo1
иfoo2
. -
Замеры времени: Используем стандартные методы C++ для измерения времени выполнения (например,
std::chrono
). -
Сравнительные запуски: Запускаем тесты несколько раз для получения усредненных значений и минимизации влияния случайных факторов.
3. Интерпретация результатов
По вашим наблюдениям, результаты могут варьироваться в зависимости от компилятора, среды выполнения, а также от параметров, заданных при компиляции. Конкретные результаты могут указывать на:
-
Различия в реализации компилятора: Некоторые компиляторы могут оптимизировать вызовы виртуальных функций более эффективно, чем это делает
std::function
, и наоборот. Рассмотрите возможность использования флагов оптимизации для вашего компилятора. -
Индивидуальные особенности: Может оказаться так, что в специфических целях использования один метод будет предпочтительнее другого. Например, в реальных условиях использования системы, где производительность важнее гибкости интерфейса, возможно, имеет смысл выбирать виртуальные функции.
4. Рекомендации
-
Повторяйте сравнительные тесты на разных компиляторах и версиях, чтобы понять, как применяемый метод производительности адаптируется к вашим требованиям.
-
Профилируйте ваш код в условиях реального использования, чтобы получить более точные показатели производительности, которые соответствуют конкретным сценариям вашей программы.
-
Учтите, что производительность – не единственный фактор; простота поддержки и понимания кода также важна.
В итоге, ваш подход к сравнению std::function
и виртуальных функций правильный, и вы получаете показательные результаты, которые подтверждают или опровергают различные гипотезы о производительности. Далее важно сделать выводы на основе собранной информации и учитывать как технические, так и практические аспекты при выборе подхода.