std:: функция против шаблона


благодаря C++11, мы получили std::function семейство оболочек функторов. К сожалению, я продолжаю слышать только плохие вещи об этих новых дополнениях. Самым популярным является то, что они ужасно медленно. Я проверил и они действительно сосут по сравнению с шаблонами.

#include <iostream>
#include <functional>
#include <string>
#include <chrono>

template <typename F>
float calc1(F f) { return -1.0f * f(3.3f) + 666.0f; }

float calc2(std::function<float(float)> f) { return -1.0f * f(3.3f) + 666.0f; }

int main() {
    using namespace std::chrono;

    const auto tp1 = system_clock::now();
    for (int i = 0; i < 1e8; ++i) {
        calc1([](float arg){ return arg * 0.5f; });
    }
    const auto tp2 = high_resolution_clock::now();

    const auto d = duration_cast<milliseconds>(tp2 - tp1);  
    std::cout << d.count() << std::endl;
    return 0;
}

111 мс против 1241 МС. Я предполагаю, что это потому, что шаблоны можно красиво встроить, в то время как functions покрывают внутренние устройства с помощью виртуальных вызовов.

очевидно, шаблоны имеют свои проблемы, как я вижу они:

  • они должны быть предоставлены в качестве заголовков, что не является чем-то, что вы не можете сделать при выпуске библиотеки в виде закрытого кода,
  • они могут сделать время компиляции гораздо дольше, если extern template - вводится подобная политика,
  • нет (по крайней мере, мне известно) чистого способа представления требований (концепций, кто-нибудь?) шаблона, бар комментарий, описывающий, какой функтор ожидается.

могу ли я таким образом предположим, что function s можно использовать как де-факто стандарт прохождения функторов, а в местах, где ожидается высокая производительность, должны использоваться шаблоны?


Edit:

мой компилятор-это Visual Studio 2012 без CTP.

7 145

7 ответов:

В общем, если вы столкнулись с конструкция ситуация, которая дает вам выбор, использовать шаблоны. Я подчеркнул слово конструкция потому что я думаю, что вам нужно сосредоточиться на различии между случаями использования std::function и шаблоны, которые довольно разные.

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

Да, это правда, что поддержка шаблонов не идеальна, и C++11 по-прежнему не поддерживает концепции; однако я не вижу, как std::function спасет вас в этом отношении. std::function не является альтернативой шаблонам, а скорее инструментом для проектирования ситуаций, когда шаблоны не могут быть использованы.

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

std::function и std::bind также предложите естественную идиому для включения функциональное программирование в C++, где функции рассматриваются как объекты и получают естественное Карри и объединяются для создания других функций. Хотя такая комбинация может быть достигнута и с шаблонами, аналогичная ситуация проектирования обычно сочетается со случаями использования, которые требуют определения типа объединенных вызываемых объектов во время выполнения.

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

подводя итог, конструкция и попытайтесь понять, каковы концептуальные варианты использования этих двух конструкций. Если вы ставите их в сравнение так, как вы это сделали, вы заставляете их на арену, к которой они, вероятно, не принадлежат.

Энди проул прекрасно освещал вопросы дизайна. Это, конечно, очень важно, но я считаю, что исходный вопрос касается больше вопросов производительности, связанных с std::function.

прежде всего, быстрое замечание о методе измерения: 11 мс, полученные для calc1 не имеет смысла вообще. Действительно, глядя на сгенерированную сборку (или отладку кода сборки), можно увидеть, что оптимизатор VS2012 достаточно умен, чтобы понять, что результат вызова calc1 is независимо от итерации и перемещает вызов из цикла:

for (int i = 0; i < 1e8; ++i) {
}
calc1([](float arg){ return arg * 0.5f; });

кроме того, он понимает, что вызов calc1 не имеет видимого эффекта и отбрасывает вызов вообще. Поэтому 111 МС - это время, которое занимает пустой цикл для запуска. (Я удивлен, что оптимизатор сохранил цикл.) Итак, будьте осторожны с измерениями времени в петлях. Это не так просто, как может показаться.

как было указано, оптимизатор имеет больше проблем, чтобы понять std::function и не перемещает вызов из цикла. Таким образом, 1241ms является справедливым измерением для calc2.

заметил, что std::function способен хранить различные типы вызываемых объектов. Следовательно, он должен выполнять некоторую магию стирания типа для хранения. Как правило, это подразумевает динамическое выделение памяти (по умолчанию через вызов new). Хорошо известно, что это довольно дорогостоящая операция.

стандарт (20.8.11.2.1/5) инкорпорирует реализации во избежание динамическое выделение памяти для небольших объектов, что, к счастью, VS2012 делает (в частности, для исходного кода).

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

float a, b, c; // never mind the values
// ...
calc2([a,b,c](float arg){ return arg * 0.5f; });

для этой версии время составляет приблизительно 16000 МС (по сравнению с 1241мс для оригинала код.)

наконец, обратите внимание, что время жизни лямбды заключает в себе время жизни std::function. В этом случае, вместо того, чтобы хранить копию лямда, std::function может хранить "ссылку" на него. Под "ссылкой" я подразумеваю std::reference_wrapper который легко построить с помощью функций std::ref и std::cref. Точнее, с помощью:

auto func = [a,b,c](float arg){ return arg * 0.5f; };
calc2(std::cref(func));

время уменьшается примерно до 1860 МС.

я писал об этом некоторое время назад:

http://www.drdobbs.com/cpp/efficient-use-of-lambda-expressions-and/232500059

как я уже сказал в статье, аргументы не совсем применимы для VS2010 из-за его плохой поддержки C++11. На момент написания статьи была доступна только бета-версия VS2012, но ее поддержка C++11 уже была достаточно хороша для этого вопроса.

С Clang нет разницы в производительности между двумя

С помощью clang (3.2, trunk 166872) (- O2 на Linux),двоичные файлы из двух случаев на самом деле идентичны.

- Я вернусь, чтобы лязгать в конце поста. Но сначала, gcc 4.7.2:

там уже много информации, но я хочу отметить, что в результате расчетов calc1 и calc2 не то же самое, из-за подкладки и т. д. Сравните, например сумма всех результатов:

float result=0;
for (int i = 0; i < 1e8; ++i) {
  result+=calc2([](float arg){ return arg * 0.5f; });
}

С calc2 это становится

1.71799e+10, time spent 0.14 sec

в то время как с calc1 это становится

6.6435e+10, time spent 5.772 sec

это коэффициент ~40 в разнице скоростей и коэффициент ~4 в значениях. Во-первых, это гораздо большая разница, чем то, что OP опубликовал (используя visual studio). На самом деле печать значения a конец также является хорошей идеей, чтобы предотвратить компилятор для удаления кода без видимого результата (как-если правило). Кассио Нери уже сказал это в своем ответ. Обратите внимание, насколько разные результаты-следует быть осторожным при сравнении коэффициентов скорости кодов, которые выполняют разные вычисления.

кроме того, чтобы быть справедливым, сравнение различных способов многократного вычисления f(3.3), возможно, не так интересно. Если вход является постоянным, он не должен быть в цикле. (Это легко для оптимизатора, чтобы заметить)

если я добавлю пользовательский аргумент значения в calc1 и 2, коэффициент скорости между calc1 и calc2 приходит в 5 раз меньше, чем в 40! С visual studio разница близка к коэффициенту 2, а с clang разницы нет (см. ниже).

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

лязг:

Clang (я использовал 3.2) на самом деле производится одинаковых бинарники когда я переключаюсь между calc1 и calc2 для примера кода (опубликовано ниже). С оригинальным примером, опубликованным в вопросе, оба также идентичны, но не занимают времени вообще (петли просто полностью удалены, как описано выше). С моим модифицированным примером, с -O2:

количество секунд для выполнения (лучше всего 3):

clang:        calc1:           1.4 seconds
clang:        calc2:           1.4 seconds (identical binary)

gcc 4.7.2:    calc1:           1.1 seconds
gcc 4.7.2:    calc2:           6.0 seconds

VS2012 CTPNov calc1:           0.8 seconds 
VS2012 CTPNov calc2:           2.0 seconds 

VS2015 (14.0.23.107) calc1:    1.1 seconds 
VS2015 (14.0.23.107) calc2:    1.5 seconds 

MinGW (4.7.2) calc1:           0.9 seconds
MinGW (4.7.2) calc2:          20.5 seconds 

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

мой измененный тестовый код:

#include <functional>
#include <chrono>
#include <iostream>

template <typename F>
float calc1(F f, float x) { 
  return 1.0f + 0.002*x+f(x*1.223) ; 
}

float calc2(std::function<float(float)> f,float x) { 
  return 1.0f + 0.002*x+f(x*1.223) ; 
}

int main() {
    using namespace std::chrono;

    const auto tp1 = high_resolution_clock::now();

    float result=0;
    for (int i = 0; i < 1e8; ++i) {
      result=calc1([](float arg){ 
          return arg * 0.5f; 
        },result);
    }
    const auto tp2 = high_resolution_clock::now();

    const auto d = duration_cast<milliseconds>(tp2 - tp1);  
    std::cout << d.count() << std::endl;
    std::cout << result<< std::endl;
    return 0;
}

обновление:

добавил vs2015. Я также заметил,что в calc1, calc2 есть преобразования double->float. Удаление их не изменяет вывод для visual studio (оба намного быстрее, но соотношение примерно одинаковое).

разные-это не одно и то же.

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

void eval(const std::function<int(int)>& f) {
    std::cout << f(3);
}

int f1(int i) {
    return i;
}

float f2(double d) {
    return d;
}

int main() {
    std::function<int(int)> fun(f1);
    eval(fun);
    fun = f2;
    eval(fun);
    return 0;
}

отметим, что то же самое объект функции, fun, передается на оба вызова eval. Он держит два разные функции.

Если вам не надо этого делать, то вы должны не использовать std::function.

У вас уже есть некоторые хорошие ответы здесь, поэтому я не буду им противоречить, короче говоря, сравнение std::function с шаблонами похоже на сравнение виртуальных функций с функциями. Вы никогда не должны "предпочитать" виртуальные функции функциям, а скорее использовать виртуальные функции, когда это соответствует проблеме, перемещая решения из времени компиляции во время выполнения. Идея заключается в том, что вместо того, чтобы решать проблему с помощью специального решения (например, таблицы переходов), вы используете что-то, что дает компилятору больше шансов оптимизации для вас. Это также помогает другим программистам, если вы используете стандартное решение.

этот ответ призван внести вклад в набор существующих ответов, что, по моему мнению, является более значимым ориентиром для стоимости выполнения вызовов std::function.

механизм std::function должен быть распознан для того, что он обеспечивает: любая вызываемая сущность может быть преобразована в функцию std::соответствующей подписи. Предположим, у вас есть библиотека, которая соответствует поверхности функции, определенной z = f (x,y), вы можете написать ее, чтобы принять a std::function<double(double,double)>, и пользователь библиотеки может легко преобразовать любую вызываемую сущность в это; будь то обычная функция, метод экземпляра класса или лямбда-выражение, или все, что поддерживается std::bind.

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

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

Я сделал тест ниже, похожий на OP; но основные изменения:

  1. каждый случай циклически повторяется 1 миллиард раз, но объекты std::function строятся только один раз. Я нашел, посмотрев на выходной код, что "оператор new" вызывается при построении фактические вызовы std::function (возможно, нет, когда они оптимизированы).
  2. тест разделен на два файла, чтобы предотвратить нежелательную оптимизацию
  3. мои случаи: (a) функция встроена (b) функция передается обычным указателем функции (c) функция является совместимой функцией, обернутой как std::function (d) функция является несовместимой функцией, совместимой с std::bind, обернутой как std::function

результаты, которые я получаю являются:

  • case (a) (inline) 1.3 nsec

  • все остальные случаи: 3,3 нсек.

случай (d) имеет тенденцию быть немного медленнее, но разница (около 0,05 НС) поглощается шумом.

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

когда я запускаю код Йохана-Лундберга на той же машине, я вижу около 39 НС на цикл, но там гораздо больше в цикле, включая фактический конструктор и деструктор функции std::, который, вероятно, довольно высок, поскольку он включает в себя новое и удаление.

- O2 gcc 4.8.1, к цели x86_64 (core i5).

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

----- первый исходный файл --------------

#include <functional>


// simple funct
float func_half( float x ) { return x * 0.5; }

// func we can bind
float mul_by( float x, float scale ) { return x * scale; }

//
// func to call another func a zillion times.
//
float test_stdfunc( std::function<float(float)> const & func, int nloops ) {
    float x = 1.0;
    float y = 0.0;
    for(int i =0; i < nloops; i++ ){
        y += x;
        x = func(x);
    }
    return y;
}

// same thing with a function pointer
float test_funcptr( float (*func)(float), int nloops ) {
    float x = 1.0;
    float y = 0.0;
    for(int i =0; i < nloops; i++ ){
        y += x;
        x = func(x);
    }
    return y;
}

// same thing with inline function
float test_inline(  int nloops ) {
    float x = 1.0;
    float y = 0.0;
    for(int i =0; i < nloops; i++ ){
        y += x;
        x = func_half(x);
    }
    return y;
}

----- второй исходный файл -------------

#include <iostream>
#include <functional>
#include <chrono>

extern float func_half( float x );
extern float mul_by( float x, float scale );
extern float test_inline(  int nloops );
extern float test_stdfunc( std::function<float(float)> const & func, int nloops );
extern float test_funcptr( float (*func)(float), int nloops );

int main() {
    using namespace std::chrono;


    for(int icase = 0; icase < 4; icase ++ ){
        const auto tp1 = system_clock::now();

        float result;
        switch( icase ){
         case 0:
            result = test_inline( 1e9);
            break;
         case 1:
            result = test_funcptr( func_half, 1e9);
            break;
         case 2:
            result = test_stdfunc( func_half, 1e9);
            break;
         case 3:
            result = test_stdfunc( std::bind( mul_by, std::placeholders::_1, 0.5), 1e9);
            break;
        }
        const auto tp2 = high_resolution_clock::now();

        const auto d = duration_cast<milliseconds>(tp2 - tp1);  
        std::cout << d.count() << std::endl;
        std::cout << result<< std::endl;
    }
    return 0;
}

для тех, кто заинтересован, вот адаптер, который компилятор построил, чтобы сделать "mul_by" похожим на float (float) - это "называется", когда функция,созданная как bind(mul_by, _1, 0.5), называется:

movq    (%rdi), %rax                ; get the std::func data
movsd   8(%rax), %xmm1              ; get the bound value (0.5)
movq    (%rax), %rdx                ; get the function to call (mul_by)
cvtpd2ps    %xmm1, %xmm1        ; convert 0.5 to 0.5f
jmp *%rdx                       ; jump to the func

(так это возможно, было бы немного быстрее, если бы я написал 0.5 f в привязке...) Обратите внимание, что параметр' x ' поступает в %xmm0 и просто остается там.

вот код в области, где построена функция, перед вызовом test_stdfunc-run through c++filt:

movl    , %edi
movq    , 32(%rsp)
call    operator new(unsigned long)      ; get 16 bytes for std::function
movsd   .LC0(%rip), %xmm1                ; get 0.5
leaq    16(%rsp), %rdi                   ; (1st parm to test_stdfunc) 
movq    mul_by(float, float), (%rax)     ; store &mul_by  in std::function
movl    00000000, %esi                ; (2nd parm to test_stdfunc)
movsd   %xmm1, 8(%rax)                   ; store 0.5 in std::function
movq    %rax, 16(%rsp)                   ; save ptr to allocated mem

   ;; the next two ops store pointers to generated code related to the std::function.
   ;; the first one points to the adaptor I showed above.

movq    std::_Function_handler<float (float), std::_Bind<float (*(std::_Placeholder<1>, double))(float, float)> >::_M_invoke(std::_Any_data const&, float), 40(%rsp)
movq    std::_Function_base::_Base_manager<std::_Bind<float (*(std::_Placeholder<1>, double))(float, float)> >::_M_manager(std::_Any_data&, std::_Any_data const&, std::_Manager_operation), 32(%rsp)


call    test_stdfunc(std::function<float (float)> const&, int)

Я нашел ваши результаты очень интересными, поэтому я немного покопался, чтобы понять, что происходит. Во-первых, как многие другие сказали, не имея результатов вычислительного эффекта, состояние программы компилятор просто оптимизирует это. Во-вторых, имея постоянную 3.3, данную в качестве вооружения для обратного вызова, я подозреваю, что будут происходить другие оптимизации. Имея это в виду, я немного изменил ваш тестовый код.

template <typename F>
float calc1(F f, float i) { return -1.0f * f(i) + 666.0f; }
float calc2(std::function<float(float)> f, float i) { return -1.0f * f(i) + 666.0f; }
int main() {
    const auto tp1 = system_clock::now();
    for (int i = 0; i < 1e8; ++i) {
        t += calc2([&](float arg){ return arg * 0.5f + t; }, i);
    }
    const auto tp2 = high_resolution_clock::now();
}

учитывая это изменение к коду, который я скомпилировал с gcc 4.8-O3 и получил время 330 мс для calc1 и 2702 для calc2. Таким образом, использование шаблона было в 8 раз быстрее, это число выглядело подозрительно для меня, скорость мощности 8 часто указывает на то, что компилятор векторизовал что-то. когда я посмотрел на сгенерированный код для версии шаблонов, он был четко векторизован

.L34:
cvtsi2ss        %edx, %xmm0
addl    , %edx
movaps  %xmm3, %xmm5
mulss   %xmm4, %xmm0
addss   %xmm1, %xmm0
subss   %xmm0, %xmm5
movaps  %xmm5, %xmm0
addss   %xmm1, %xmm0
cvtsi2sd        %edx, %xmm1
ucomisd %xmm1, %xmm2
ja      .L37
movss   %xmm0, 16(%rsp)

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

Это заставило меня попробовать что-то еще, чтобы увидеть, могу ли я заставить компилятор выполнить ту же оптимизацию в версии std::function. Вместо передачи функции я делаю std:: function как глобальный var, и вызываю это.

float calc3(float i) {  return -1.0f * f2(i) + 666.0f; }
std::function<float(float)> f2 = [](float arg){ return arg * 0.5f; };

int main() {
    const auto tp1 = system_clock::now();
    for (int i = 0; i < 1e8; ++i) {
        t += calc3([&](float arg){ return arg * 0.5f + t; }, i);
    }
    const auto tp2 = high_resolution_clock::now();
}

С этой версией мы видим, что компилятор теперь векторизовать код таким же образом и я получаю те же результаты тестов.

  • шаблон: 330мс
  • std:: функция: 2702ms
  • глобальный std:: функция: 330ms

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