std :: function против шаблона

161

Благодаря 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не введена политика, подобная
  • не существует (по крайней мере, мне известно) чистого способа представления требований (концепций, кого-либо?) шаблона, за исключением комментария, описывающего, какой тип функтора ожидается.

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


Редактировать:

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

Красный XIII
источник
16
Используйте std::functionтогда и только тогда, когда вам действительно нужна гетерогенная коллекция вызываемых объектов (т. Е. Во время выполнения дополнительная информация не доступна).
Kerrek SB
30
Вы сравниваете неправильные вещи. Шаблоны используются в обоих случаях - это не « std::functionили шаблоны». Я думаю, что здесь проблема заключается в том, чтобы просто обернуть лямбду в, а std::functionне в лямбду std::function. На данный момент ваш вопрос похож на вопрос: "Мне лучше выбрать яблоко или миску?"
Гонки легкости на орбите
7
Будь то 1 нс или 10 нс, оба ничего.
МПК
23
@ipc: 1000% не является ничем, хотя. Как определяет OP, вы начинаете заботиться о масштабируемости для любых практических целей.
Гонки легкости на орбите
18
@ipc Это в 10 раз медленнее, что огромно. Скорость должна быть сравнена с базовой линией; обманчиво думать, что это не имеет значения только потому, что это наносекунды.
Пол Манта

Ответы:

170

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

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

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

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

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

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

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

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

Энди Проул
источник
23
Я думаю: «Обычно это тот случай, когда у вас есть коллекция обратных вызовов потенциально разных типов, но которые вы должны вызывать единообразно»; это важный бит. Мое эмпирическое правило таково: «Предпочитаю std::functionна стороне хранилища и шаблон Funна интерфейсе».
Р. Мартиньо Фернандес
2
Примечание: техника сокрытия конкретных типов называется стиранием типов (не путать со стиранием типов в управляемых языках). Он часто реализуется с точки зрения динамического полиморфизма, но является более мощным (например, unique_ptr<void>вызывает соответствующие деструкторы даже для типов без виртуальных деструкторов).
Ecatmur
2
@ecatmur: я согласен по существу, хотя мы немного не согласны с терминологией. Динамический полиморфизм означает для меня «принятие различных форм во время выполнения», в отличие от статического полиморфизма, который я интерпретирую как «принятие различных форм во время компиляции»; последнее не может быть достигнуто с помощью шаблонов. Для меня стирание типов является с точки зрения дизайна, своего рода предварительным условием для достижения вообще динамического полиморфизма: вам нужен какой-то унифицированный интерфейс для взаимодействия с объектами разных типов, а стирание типов - это способ абстрагировать конкретная информация.
Энди Prowl
2
@ecatmur: Таким образом, динамический полиморфизм является концептуальным паттерном, в то время как стирание типов является техникой, которая позволяет его реализовать.
Энди Проул
2
@ Downvoter: Мне было бы интересно услышать, что вы нашли неправильно в этом ответе.
Энди Prowl
89

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

Прежде всего, краткое замечание о методике измерения: полученные calc1значения 11 мс не имеют никакого значения. Действительно, глядя на сгенерированную сборку (или отлаживая код сборки), можно увидеть, что оптимизатор 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секунды. Это делает вызываемый объект слишком большим, чтобы применить оптимизацию маленького объекта:

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 уже была достаточно хороша для этого вопроса.

Кассио Нери
источник
Я нахожу это действительно интересным, желая доказать скорость кода, используя игрушечные примеры, которые оптимизируются компилятором, потому что они не имеют побочных эффектов. Я бы сказал, что редко можно сделать ставку на такие измерения без какого-либо реального / производственного кода.
Гита,
@ Ghita: В этом примере, чтобы предотвратить оптимизацию кода, calc1можно принять floatаргумент, который будет результатом предыдущей итерации. Нечто подобное x = calc1(x, [](float arg){ return arg * 0.5f; });. Кроме того, мы должны убедиться, что calc1использует x. Но этого еще недостаточно. Нам нужно создать побочный эффект. Например, после измерения печать xна экране. Несмотря на то, что я согласен, что использование игрушечных кодов для измерений Timimg не всегда может дать точное представление о том, что произойдет с реальным / производственным кодом.
Кассио Нери
Мне также кажется, что эталонный тест создает объект std :: function внутри цикла и вызывает calc2 в цикле. Независимо от того, что компилятор может или не может оптимизировать это (и что конструктор может быть таким же простым, как хранение vptr), я бы больше интересовался случаем, когда функция создается один раз и передается другой функции, которая вызывает это в цикле. Т.е. накладные расходы на вызов, а не на время создания (и вызов 'f', а не calc2). Также было бы интересно, если бы вызов f в цикле (в calc2), а не один раз, выиграл бы от любого подъема.
Грегго
Отличный ответ. 2 вещи: хороший пример правильного использования std::reference_wrapper(для принуждения шаблонов; это не только для общего хранения), и забавно видеть, что оптимизатору VS не удается отбросить пустой цикл ... как я заметил с этой ошибкой GCCvolatile .
underscore_d
37

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

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

Если я добавлю аргумент пользовательского значения в calc1 и 2, коэффициент скорости между calc1 и calc2 снизится до 5 с 40! С визуальной студией разница близка к коэффициенту 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 

Результаты вычислений всех двоичных файлов одинаковы, и все тесты были выполнены на одной машине. Было бы интересно, если бы кто-то с более глубоким знанием лязга или 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;
}

Обновить:

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

Йохан Лундберг
источник
8
Что, возможно, просто показывает, что эталонный тест неверен. ИМХО интересный вариант использования - это когда вызывающий код получает объект функции откуда-то еще, поэтому компилятор не знает источник std :: function при компиляции вызова. Здесь компилятор точно знает состав std :: function при вызове, расширяя встроенный calc2 в main. Легко исправить, сделав calc2 'extern' в сентябре. исходный файл. Вы тогда сравниваете яблоки с апельсинами; calc2 делает что-то, что calc1 не может. И цикл может быть внутри calc (много вызовов f); не вокруг ctor объекта функции.
Грего
1
Когда я смогу найти подходящий компилятор. Можно пока сказать, что (a) ctor для фактического вызова std :: function 'new'; (б) сам вызов довольно скудный, когда целью является соответствующая фактическая функция; (c) в случаях с привязкой есть фрагмент кода, который выполняет адаптацию, выбранный кодом ptr в функции obj, и который получает данные (связанные параметры) из функции obj (d), функция 'bound' может быть встроенным в этот адаптер, если его видит компилятор.
Грего
Новый ответ добавлен с описанной настройкой.
Грегго
3
Кстати, тест не ошибается, вопрос («std :: function vs template») действителен только в области действия одной и той же единицы компиляции. Если вы перенесете функцию на другой блок, шаблон больше не возможен, поэтому сравнивать не с чем.
rustyx
13

Разное не то же самое.

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

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.

Пит Беккер
источник
2
Просто хочу отметить, что когда «fun = f2» завершено, объект «fun» заканчивается указанием на скрытую функцию, которая преобразует int в double, вызывает f2 и преобразует двойной результат обратно в int. (В реальном примере 'f2' может быть встроен в эту функцию). Если вы назначите std :: bind для fun, объект fun может в конечном итоге содержать значения, которые будут использоваться для связанных параметров. Чтобы поддерживать эту гибкость, назначение 'fun' (или init of) может включать выделение / освобождение памяти, и это может занять больше времени, чем фактические накладные расходы на вызов.
Грегго
8

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

TheAgitator
источник
6

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

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

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

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

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

  1. Каждый случай повторяется 1 миллиард раз, но объекты std :: function создаются только один раз. Посмотрев в выходной код, я обнаружил, что «оператор new» вызывается при построении реальных вызовов std :: function (возможно, не тогда, когда они оптимизированы).
  2. Тест разбит на два файла, чтобы предотвратить нежелательную оптимизацию
  3. Мои случаи: (а) функция встроена (б) функция передается обычным указателем на функцию (в) функция является совместимой функцией, заключенной в std :: function (d) функция является несовместимой функцией, сделанной совместимой с std :: связать, обернутый как std :: function

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

  • case (a) (inline) 1,3 нс

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

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

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

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

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

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

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

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)
greggo
источник
1
С clang 3.4.1 x64 результаты: (a) 1.0, (b) 0.95, (c) 2.0, (d) 5.0.
rustyx
4

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

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 против шаблона функтора практически одинакова. Однако это значительно усложняет работу оптимизатора.

Джошуа Риттерман
источник
1
Все дело в том, чтобы передать функтор в качестве параметра. Ваше calc3дело не имеет смысла; calc3 теперь жестко запрограммирован для вызова f2. Конечно, это можно оптимизировать.
rustyx
действительно, это то, что я пытался показать. Этот calc3 эквивалентен шаблону, и в этой ситуации фактически является конструкцией времени компиляции, как шаблон.
Джошуа Риттерман