Std:: function vs template

Благодаря С++ 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 мс. Я предполагаю, что это потому, что шаблоны могут быть красиво встроены, а function охватывают внутренности посредством виртуальных вызовов.

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

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

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


Edit:

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

Ответ 1

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

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

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

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

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

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

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

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

Ответ 2

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

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

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 (в частности, для исходного кода).

Чтобы получить представление о том, насколько медленнее он может получить при распределении памяти, я изменил выражение лямбда, чтобы захватить три float s. Это делает объект с вызовом слишком большим, чтобы применить оптимизацию небольших объектов:

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

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

Наконец, обратите внимание, что время жизни лямбда заключает в себе время жизни std::function. В этом случае вместо сохранения копии лямбда std::function может сохранить ссылку на нее. Под "ссылкой" я подразумеваю a 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 из-за плохой поддержки С++ 11. На момент написания этой статьи была доступна только бета-версия VS2012, но ее поддержка для С++ 11 была уже достаточно хороша для этого.

Ответ 3

В 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 (с использованием визуальной студии). Фактически, распечатывая значение, конец также является хорошей идеей, чтобы компилятор не удалял код без видимого результата (правило as-if). Кассио Нери уже сказал это в своем ответе. Обратите внимание, насколько различны результаты. Следует быть осторожным при сравнении коэффициентов скорости кодов, которые выполняют разные вычисления.

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

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

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

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 

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

Модифицированный тестовый код:

#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;
}

Update:

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

Ответ 4

Разные разные.

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

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.

Ответ 5

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

Ответ 6

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

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

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

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

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

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

Получаемые результаты:

  • case (a) (inline) 1.3 nsec

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

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

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

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

-O2 gcc 4.8.1, до цели x86_64 (ядро 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;
}

Для тех, кого интересует, адаптер, созданный для make '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.5f в bind...) Обратите внимание, что параметр "x" поступает в% xmm0 и остается там.

Здесь код в области, где построена функция, до вызова test_stdfunc - выполняется через С++ filter:

movl    $16, %edi
movq    $0, 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    $1000000000, %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)

Ответ 7

Я нашел ваши результаты очень интересными, поэтому я немного поработал, чтобы понять, что происходит. Во-первых, как и многие другие говорили, что результаты расчета влияют на состояние программы, компилятор просто оптимизирует это. Во-вторых, имея постоянную 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, и получил время 330ms для calc1 и 2702 для calc2. Таким образом, использование шаблона было в 8 раз быстрее, это число выглядело подозрительным для меня, скорость 8 раз часто указывает на то, что компилятор что-то векторизовал. когда я смотрел на сгенерированный код для версии шаблонов, он был явно vectoreized

.L34:
cvtsi2ss        %edx, %xmm0
addl    $1, %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:: function, поэтому она не может быть векторизованной.

Это заставило меня попробовать что-то еще, чтобы посмотреть, могу ли я заставить компилятор выполнить ту же оптимизацию в версии 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:: function: 2702ms
  • global std:: function: 330ms

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