Как измерить разницу в производительности между виртуальной функцией с наследованием и членом std::function без наследования?

Вопрос или проблема

Кратко

Является ли бенчмарк, который я представлю ниже, справедливым способом сравнения подхода на основе наследования и подхода на основе 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;
    });
    
  • Функция для выбора в зависимости от этого времени boolean b следующая, которая также является шаблонной по компилируемому boolean B, используемому для выбора между двумя сравниваемыми случаями:
    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. Методика измерения производительности

Для исследования разницы в производительности предлагается следующая методология:

  1. Создание глобального指标: Глобальная переменная unsigned int RETURN используется для проверки того, что функции действительно выполняются, и их результат может быть измерен.

  2. Количество вызовов: Для тестирования выбираем достаточное количество вызовов функций (например, 1 миллион), чтобы получить статистически значимые результаты.

  3. Случайный выбор: Генерируем случайные булевы значения для выбора между Derived1 и Derived2 или между foo1 и foo2.

  4. Замеры времени: Используем стандартные методы C++ для измерения времени выполнения (например, std::chrono).

  5. Сравнительные запуски: Запускаем тесты несколько раз для получения усредненных значений и минимизации влияния случайных факторов.

3. Интерпретация результатов

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

  • Различия в реализации компилятора: Некоторые компиляторы могут оптимизировать вызовы виртуальных функций более эффективно, чем это делает std::function, и наоборот. Рассмотрите возможность использования флагов оптимизации для вашего компилятора.

  • Индивидуальные особенности: Может оказаться так, что в специфических целях использования один метод будет предпочтительнее другого. Например, в реальных условиях использования системы, где производительность важнее гибкости интерфейса, возможно, имеет смысл выбирать виртуальные функции.

4. Рекомендации

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

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

  • Учтите, что производительность – не единственный фактор; простота поддержки и понимания кода также важна.

В итоге, ваш подход к сравнению std::function и виртуальных функций правильный, и вы получаете показательные результаты, которые подтверждают или опровергают различные гипотезы о производительности. Далее важно сделать выводы на основе собранной информации и учитывать как технические, так и практические аспекты при выборе подхода.

Оцените материал
Добавить комментарий

Капча загружается...