С++ 11 std:: функция медленнее, чем виртуальные вызовы?
Я создаю механизм, который позволяет пользователям создавать произвольные сложные функции из основных строительных блоков, используя шаблон декоратора. Это отлично работает, но мне не нравится тот факт, что он включает в себя множество виртуальных вызовов, особенно когда глубина вложенности становится большой. Меня это беспокоит, потому что сложная функция может часто вызываться ( > 100 000 раз).
Чтобы избежать этой проблемы, я попытался превратить схему декоратора в std::function
после ее завершения (cfr. to_function()
в SSCCE). Все внутренние вызовы функций подключаются при построении std::function
. Я полагал, что это будет быстрее оценивать, чем исходная схема декоратора, потому что в версии std::function
не нужно выполнять виртуальный поиск.
Увы, тесты показывают, что я ошибаюсь: схема декоратора на самом деле быстрее, чем std::function
, которую я построил из нее. Так что теперь мне остается удивляться, почему. Может быть, моя тестовая установка неисправна, так как я использую только две тривиальные базовые функции, что означает, что поиск в vtable может быть кэширован?
Код, который я использовал, приведен ниже, к сожалению, он довольно длинный.
SSCCE
// sscce.cpp
#include <iostream>
#include <vector>
#include <memory>
#include <functional>
#include <random>
/**
* Base class for Pipeline scheme (implemented via decorators)
*/
class Pipeline {
protected:
std::unique_ptr<Pipeline> wrappee;
Pipeline(std::unique_ptr<Pipeline> wrap)
:wrappee(std::move(wrap)){}
Pipeline():wrappee(nullptr){}
public:
typedef std::function<double(double)> FnSig;
double operator()(double input) const{
if(wrappee.get()) input=wrappee->operator()(input);
return process(input);
}
virtual double process(double input) const=0;
virtual ~Pipeline(){}
// Returns a std::function which contains the entire Pipeline stack.
virtual FnSig to_function() const=0;
};
/**
* CRTP for to_function().
*/
template <class Derived>
class Pipeline_CRTP : public Pipeline{
protected:
Pipeline_CRTP(const Pipeline_CRTP<Derived> &o):Pipeline(o){}
Pipeline_CRTP(std::unique_ptr<Pipeline> wrappee)
:Pipeline(std::move(wrappee)){}
Pipeline_CRTP():Pipeline(){};
public:
typedef typename Pipeline::FnSig FnSig;
FnSig to_function() const override{
if(Pipeline::wrappee.get()!=nullptr){
FnSig wrapfun = Pipeline::wrappee->to_function();
FnSig processfun = std::bind(&Derived::process,
static_cast<const Derived*>(this),
std::placeholders::_1);
FnSig fun = [=](double input){
return processfun(wrapfun(input));
};
return std::move(fun);
}else{
FnSig processfun = std::bind(&Derived::process,
static_cast<const Derived*>(this),
std::placeholders::_1);
FnSig fun = [=](double input){
return processfun(input);
};
return std::move(fun);
}
}
virtual ~Pipeline_CRTP(){}
};
/**
* First concrete derived class: simple scaling.
*/
class Scale: public Pipeline_CRTP<Scale>{
private:
double scale_;
public:
Scale(std::unique_ptr<Pipeline> wrap, double scale) // todo move
:Pipeline_CRTP<Scale>(std::move(wrap)),scale_(scale){}
Scale(double scale):Pipeline_CRTP<Scale>(),scale_(scale){}
double process(double input) const override{
return input*scale_;
}
};
/**
* Second concrete derived class: offset.
*/
class Offset: public Pipeline_CRTP<Offset>{
private:
double offset_;
public:
Offset(std::unique_ptr<Pipeline> wrap, double offset) // todo move
:Pipeline_CRTP<Offset>(std::move(wrap)),offset_(offset){}
Offset(double offset):Pipeline_CRTP<Offset>(),offset_(offset){}
double process(double input) const override{
return input+offset_;
}
};
int main(){
// used to make a random function / arguments
// to prevent gcc from being overly clever
std::default_random_engine generator;
auto randint = std::bind(std::uniform_int_distribution<int>(0,1),std::ref(generator));
auto randdouble = std::bind(std::normal_distribution<double>(0.0,1.0),std::ref(generator));
// make a complex Pipeline
std::unique_ptr<Pipeline> pipe(new Scale(randdouble()));
for(unsigned i=0;i<100;++i){
if(randint()) pipe=std::move(std::unique_ptr<Pipeline>(new Scale(std::move(pipe),randdouble())));
else pipe=std::move(std::unique_ptr<Pipeline>(new Offset(std::move(pipe),randdouble())));
}
// make a std::function from pipe
Pipeline::FnSig fun(pipe->to_function());
double bla=0.0;
for(unsigned i=0; i<100000; ++i){
#ifdef USE_FUNCTION
// takes 110 ms on average
bla+=fun(bla);
#else
// takes 60 ms on average
bla+=pipe->operator()(bla);
#endif
}
std::cout << bla << std::endl;
}
Benchmark
Использование pipe
:
g++ -std=gnu++11 sscce.cpp -march=native -O3
sudo nice -3 /usr/bin/time ./a.out
-> 60 ms
Использование fun
:
g++ -DUSE_FUNCTION -std=gnu++11 sscce.cpp -march=native -O3
sudo nice -3 /usr/bin/time ./a.out
-> 110 ms
Ответы
Ответ 1
Как говорит Sebastian Redl, ваша "альтернатива" виртуальным функциям добавляет несколько уровней косвенности через динамически связанные функции (виртуальные или через указатели функций, в зависимости от реализации std::function
), а затем она по-прежнему вызывает виртуальную Pipeline::process(double)
в любом случае!
Эта модификация делает ее значительно быстрее, удаляя один слой с std::function
и не допуская, чтобы вызов Derived::process
был виртуальным:
FnSig to_function() const override {
FnSig fun;
auto derived_this = static_cast<const Derived*>(this);
if (Pipeline::wrappee) {
FnSig wrapfun = Pipeline::wrappee->to_function();
fun = [=](double input){
return derived_this->Derived::process(wrapfun(input));
};
} else {
fun = [=](double input){
return derived_this->Derived::process(input);
};
}
return fun;
}
Здесь еще больше работы, чем в версии виртуальной функции.
Ответ 2
У вас есть std::function
привязка lambdas, которые вызывают std::function
, которые связывают lamdbas, вызывающие std::function
, которые...
Посмотрите на свой to_function
. Он создает лямбда, которая вызывает два std::function
s, и возвращает эту лямбду в другую std::function
. Компилятор не решит их статически.
Итак, в конце концов, вы заканчиваете с таким же количеством косвенных вызовов, как и решение виртуальной функции, и что если вы избавитесь от связанной processfun
и прямо вызовите ее в лямбда. В противном случае у вас будет вдвое больше.
Если вы хотите ускорить работу, вам нужно будет создать весь конвейер таким образом, чтобы его можно было статически разрешать, а это значит, что вам нужно больше шаблонов, прежде чем вы сможете окончательно стереть тип в один std::function
.
Ответ 3
std::function
является заведомо медленным; стирание стилей и результирующее распределение играют роль в этом, а также с помощью gcc
, вызовы встраиваются/оптимизируются плохо. По этой причине существует множество делегатов С++, с помощью которых люди пытаются решить эту проблему. Я поместил его в Code Review:
https://codereview.stackexchange.com/questions/14730/impossibly-fast-delegate-in-c11
Но вы можете найти много других с Google или написать свой собственный.
EDIT:
В эти дни посмотрите здесь для быстрого делегирования.
Ответ 4
libstdС++ реализация std:: function работает примерно следующим образом:
template<typename Signature>
struct Function
{
Ptr functor;
Ptr functor_manager;
template<class Functor>
Function(const Functor& f)
{
functor_manager = &FunctorManager<Functor>::manage;
functor = new Functor(f);
}
Function(const Function& that)
{
functor = functor_manager(CLONE, that->functor);
}
R operator()(args) // Signature
{
return functor_manager(INVOKE, functor, args);
}
~Function()
{
functor_manager(DESTROY, functor);
}
}
template<class Functor>
struct FunctorManager
{
static manage(int operation, Functor& f)
{
switch (operation)
{
case CLONE: call Functor copy constructor;
case INVOKE: call Functor::operator();
case DESTROY: call Functor destructor;
}
}
}
Итак, хотя std::function
не знает точного типа объекта Functor, он отправляет важные операции с помощью указателя функции functor_manager, который является статической функцией экземпляра шаблона, который знает о типе Functor
.
Каждый экземпляр std::function
выделяет в куче свою собственную копию объекта-функтора (если только он не больше указателя, такого как указатель на функцию, в этом случае он просто удерживает указатель как подобъект).
Важным ответом является то, что копирование std::function
является дорогостоящим, если базовый объект-функтор имеет дорогостоящий конструктор копирования и/или занимает много места (например, для хранения связанных параметров).