Сколько накладных расходов при вызове функции в C++?
Много литературы говорит об использовании встроенных функций, чтобы "избежать накладных расходов при вызове функции". Однако я не видел количественных данных. Каковы фактические издержки при вызове функции, то есть какого увеличения производительности мы достигаем путем встраивания функций?
16 ответов
На большинстве архитектур стоимость состоит в сохранении всех (или некоторых, или ни одного) регистров в стеке, переносе аргументов функции в стек (или помещении их в регистры), увеличении указателя стека и переходе к началу новый код Затем, когда функция будет завершена, вы должны восстановить регистры из стека. Эта веб-страница содержит описание того, что входит в различные соглашения о вызовах.
Большинство компиляторов C++ достаточно умны, чтобы встроить функции для вас. Ключевое слово inline - это просто подсказка компилятору. Некоторые даже сделают встраивание через единицы перевода, где они решат, что это полезно.
Я сделал простой тест по сравнению с простой функцией приращения:
inc.c:
typedef unsigned long ulong;
ulong inc(ulong x){
return x+1;
}
main.c
#include <stdio.h>
#include <stdlib.h>
typedef unsigned long ulong;
#ifdef EXTERN
ulong inc(ulong);
#else
static inline ulong inc(ulong x){
return x+1;
}
#endif
int main(int argc, char** argv){
if (argc < 1+1)
return 1;
ulong i, sum = 0, cnt;
cnt = atoi(argv[1]);
for(i=0;i<cnt;i++){
sum+=inc(i);
}
printf("%lu\n", sum);
return 0;
}
Запуск его с миллиардом итераций на моем процессоре Intel® Core iM5 M530 с тактовой частотой 2,27 ГГц дал мне:
- 1,4 секунды для встроенной версии
- 4,4 секунды для регулярно связанной версии
(Кажется, он колеблется до 0,2, но мне лень рассчитывать правильные стандартные отклонения, и я не забочусь о них)
Это говорит о том, что накладные расходы на вызовы функций на этом компьютере составляют около 3 наносекунд
Самое быстрое, что я измерил при этом, было около 0,3 нс, так что можно было бы предположить, что вызов функции стоит около 9 примитивных операций, проще говоря.
Эти накладные расходы увеличиваются примерно на 2 нс за вызов (общее время вызова около 6 нс) для функций, вызываемых через PLT (функции в общей библиотеке).
Там технический и практический ответ. Практический ответ - это никогда не будет иметь значения, и в очень редком случае это будет единственный способ, который вы узнаете, - через реальные профилированные тесты.
Технический ответ, на который ссылается ваша литература, как правило, не актуален из-за оптимизации компилятора. Но если вам все еще интересно, это хорошо описано Джошем.
Что касается "процента", вы должны знать, насколько дорогой была сама функция. Вне стоимости вызываемой функции нет процента, потому что вы сравниваете с операцией с нулевой стоимостью. Для встроенного кода плата не взимается, процессор просто переходит к следующей инструкции. Недостатком inling является больший размер кода, который показывает, что его затраты отличаются от затрат на строительство / разборку стека.
Ваш вопрос - это один из вопросов, на который нет ответа, который можно назвать "абсолютной истиной". Затраты на обычный вызов функции зависят от трех факторов:
ЦП. Затраты на процессоры x86, PPC и ARM сильно различаются, и даже если вы используете только одну архитектуру, накладные расходы также немного различаются между Intel Pentium 4, Intel Core 2 Duo и Intel Core i7. Издержки могут даже заметно различаться между процессорами Intel и AMD, даже если они оба работают с одинаковой тактовой частотой, поскольку такие факторы, как размеры кэша, алгоритмы кэширования, шаблоны доступа к памяти и фактическая аппаратная реализация самого кода операции вызова, могут иметь огромные влияние на накладные расходы.
ABI (прикладной двоичный интерфейс). Даже с одним и тем же процессором часто существуют различные ABI, которые определяют, как вызовы функций передают параметры (через регистры, через стек или через комбинацию обоих) и где и как происходит инициализация и очистка стекового фрейма. Все это влияет на накладные расходы. Различные операционные системы могут использовать разные ABI для одного и того же процессора; например, Linux, Windows и Solaris могут использовать три ABI для одного и того же процессора.
Компилятор. Строгое следование ABI важно только в том случае, если функции вызываются между независимыми единицами кода, например, если приложение вызывает функцию из системной библиотеки или пользовательская библиотека вызывает функцию из другой пользовательской библиотеки. Пока функции являются "частными", невидимыми вне определенной библиотеки или двоичного файла, компилятор может "обманывать". Он не может строго следовать ABI, но вместо этого использовать ярлыки, которые приводят к более быстрым вызовам функций. Например, он может передавать параметры в регистр вместо использования стека или может полностью пропустить установку и очистку кадров стека, если в этом нет особой необходимости.
Если вы хотите узнать накладные расходы для конкретной комбинации трех вышеуказанных факторов, например, для Intel Core i5 в Linux с использованием GCC, единственный способ получить эту информацию - это сравнить разницу между двумя реализациями, одна из которых использует вызовы функций, а другая - где вы скопируйте код непосредственно в вызывающую программу; таким образом вы наверняка принудительно встраиваете, так как встроенное выражение является лишь подсказкой и не всегда приводит к встраиванию.
Однако настоящий вопрос здесь таков: действительно ли точные накладные расходы имеют значение? Одно можно сказать наверняка: вызов функции всегда имеет накладные расходы. Он может быть маленьким, он может быть большим, но он наверняка существует. И независимо от того, насколько она мала, если функция вызывается достаточно часто в критическом разделе производительности, накладные расходы будут иметь значение в некоторой степени. Встраивание редко делает ваш код медленным, если вы не переборщите с этим; хотя это сделает код больше. Сегодняшние компиляторы довольно хорошо решают сами, когда подключать, а когда нет, так что вам вряд ли когда-нибудь придется ломать голову над этим.
Лично я полностью игнорирую встраивание во время разработки, пока у меня не появится более или менее полезный продукт, который я могу профилировать, и только если профилирование скажет мне, что определенная функция вызывается действительно часто, а также в критически важной для приложения части приложения, тогда я буду рассмотрим "принудительное включение" этой функции.
Пока что мой ответ очень общий, он относится к C так же, как к C++ и Objective-C. В качестве заключительного слова позвольте мне сказать кое-что о C++, в частности: виртуальные методы - это двойные косвенные вызовы функций, это означает, что они имеют более высокие издержки вызова функций, чем обычные вызовы функций, и также они не могут быть встроенными. Невиртуальные методы могут быть встроены компилятором или нет, но даже если они не встроены, они все равно значительно быстрее виртуальных, поэтому не следует делать методы виртуальными, если вы действительно не планируете их переопределять или переопределять.
Объем служебных данных будет зависеть от компилятора, процессора и т. Д. Процент служебных данных будет зависеть от кода, который вы встраиваете. Единственный способ узнать это - взять свой код и профилировать его в обоих направлениях - вот почему нет однозначного ответа.
Стоит отметить, что встроенная функция увеличивает размер вызывающей функции, и все, что увеличивает размер функции, может негативно повлиять на кэширование. Если вы находитесь прямо на границе, "только еще одна тонкая пластинка" встроенного кода может оказать существенное негативное влияние на производительность.
Если вы читаете литературу, предупреждающую о "стоимости вызова функции", я бы предположил, что это может быть более старый материал, который не отражает современные процессоры. Если вы не во встроенном мире, эра, в которой C является "переносимым языком ассемблера", по существу, прошла. Большая часть изобретательности разработчиков микросхем в последнее десятилетие (скажем) пошла на всевозможные сложности низкого уровня, которые могут радикально отличаться от того, как все работало "в свое время".
Для очень маленьких функций встраивание имеет смысл, потому что (маленькая) стоимость вызова функции значительна относительно (очень маленькой) стоимости тела функции. Для большинства функций в несколько строк это не большая победа.
Современные процессоры очень быстрые (очевидно!). Почти каждая операция, связанная с вызовами и передачей аргументов, является инструкциями с полной скоростью (косвенные вызовы могут быть немного дороже, в основном, в первый раз через цикл).
Затраты на вызовы функций настолько малы, что только циклы, вызывающие функции, могут сделать релевантными затраты на вызов.
Поэтому, когда мы говорим о (и измеряем) издержках вызова функций сегодня, мы обычно действительно говорим о накладных расходах, заключающихся в невозможности поднять общие подвыражения из циклов. Если функция должна выполнять кучу (идентичной) работы каждый раз, когда она вызывается, компилятор сможет "поднять" ее из цикла и сделать это один раз, если она была встроена. Если код не указан, то, вероятно, просто продолжит работу и повторит работу, как вы и сказали!
Встроенные функции кажутся невероятно быстрыми не из-за накладных расходов и аргументов, а из-за общих подвыражений, которые могут быть выведены из функции.
Пример:
Foo::result_type MakeMeFaster()
{
Foo t = 0;
for (auto i = 0; i < 1000; ++i)
t += CheckOverhead(SomethingUnpredictible());
return t.result();
}
Foo CheckOverhead(int i)
{
auto n = CalculatePi_1000_digits();
return i * n;
}
Оптимизатор может видеть сквозь эту глупость и делать:
Foo::result_type MakeMeFaster()
{
Foo t;
auto _hidden_optimizer_tmp = CalculatePi_1000_digits();
for (auto i = 0; i < 1000; ++i)
t += SomethingUnpredictible() * _hidden_optimizer_tmp;
return t.result();
}
Похоже, что накладные расходы невозможно уменьшить, потому что они действительно вырвали большую часть функции из цикла (вызов CalculatePi_1000_digits). Компилятор должен быть в состоянии доказать, что CalculatePi_1000_digits всегда возвращает один и тот же результат, но хорошие оптимизаторы могут это сделать.
Существует отличная концепция, называемая "теневое копирование регистров", которая позволяет передавать (до 6?) Значения через регистры (на ЦП) вместо стека (памяти). Кроме того, в зависимости от функции и переменных, используемых внутри, компилятор может просто решить, что код управления кадрами не требуется!!
Кроме того, даже компилятор C++ может выполнить "оптимизацию хвостовой рекурсии", т. Е. Если A() вызывает B(), а после вызова B () A просто возвращается, компилятор повторно использует кадр стека!!
Конечно, это все можно сделать, только если программа придерживается семантики стандарта (см. Псевдонимы указателей и их влияние на оптимизацию)
Затраты не очень большие, особенно с небольшими (встроенными) функциями или даже классами.
В следующем примере есть три разных теста, каждый из которых выполняется много, много раз и по времени. Результаты всегда равны порядку пары тысячных единицы времени.
#include <boost/timer/timer.hpp>
#include <iostream>
#include <cmath>
double sum;
double a = 42, b = 53;
//#define ITERATIONS 1000000 // 1 million - for testing
//#define ITERATIONS 10000000000 // 10 billion ~ 10s per run
//#define WORK_UNIT sum += a + b
/* output
8.609619s wall, 8.611255s user + 0.000000s system = 8.611255s CPU(100.0%)
8.604478s wall, 8.611255s user + 0.000000s system = 8.611255s CPU(100.1%)
8.610679s wall, 8.595655s user + 0.000000s system = 8.595655s CPU(99.8%)
9.5e+011 9.5e+011 9.5e+011
*/
#define ITERATIONS 100000000 // 100 million ~ 10s per run
#define WORK_UNIT sum += std::sqrt(a*a + b*b + sum) + std::sin(sum) + std::cos(sum)
/* output
8.485689s wall, 8.486454s user + 0.000000s system = 8.486454s CPU (100.0%)
8.494153s wall, 8.486454s user + 0.000000s system = 8.486454s CPU (99.9%)
8.467291s wall, 8.470854s user + 0.000000s system = 8.470854s CPU (100.0%)
2.50001e+015 2.50001e+015 2.50001e+015
*/
// ------------------------------
double simple()
{
sum = 0;
boost::timer::auto_cpu_timer t;
for (unsigned long long i = 0; i < ITERATIONS; i++)
{
WORK_UNIT;
}
return sum;
}
// ------------------------------
void call6()
{
WORK_UNIT;
}
void call5(){ call6(); }
void call4(){ call5(); }
void call3(){ call4(); }
void call2(){ call3(); }
void call1(){ call2(); }
double calls()
{
sum = 0;
boost::timer::auto_cpu_timer t;
for (unsigned long long i = 0; i < ITERATIONS; i++)
{
call1();
}
return sum;
}
// ------------------------------
class Obj3{
public:
void runIt(){
WORK_UNIT;
}
};
class Obj2{
public:
Obj2(){it = new Obj3();}
~Obj2(){delete it;}
void runIt(){it->runIt();}
Obj3* it;
};
class Obj1{
public:
void runIt(){it.runIt();}
Obj2 it;
};
double objects()
{
sum = 0;
Obj1 obj;
boost::timer::auto_cpu_timer t;
for (unsigned long long i = 0; i < ITERATIONS; i++)
{
obj.runIt();
}
return sum;
}
// ------------------------------
int main(int argc, char** argv)
{
double ssum = 0;
double csum = 0;
double osum = 0;
ssum = simple();
csum = calls();
osum = objects();
std::cout << ssum << " " << csum << " " << osum << std::endl;
}
Выход для выполнения 10 000 000 итераций (каждого типа: простой, шесть вызовов функций, три вызова объектов) был с этой полуконволюционной полезной нагрузкой:
sum += std::sqrt(a*a + b*b + sum) + std::sin(sum) + std::cos(sum)
следующее:
8.485689s wall, 8.486454s user + 0.000000s system = 8.486454s CPU (100.0%)
8.494153s wall, 8.486454s user + 0.000000s system = 8.486454s CPU (99.9%)
8.467291s wall, 8.470854s user + 0.000000s system = 8.470854s CPU (100.0%)
2.50001e+015 2.50001e+015 2.50001e+015
Используя простую рабочую нагрузку
sum += a + b
Дает те же результаты, за исключением на пару порядков быстрее для каждого случая.
Здесь есть несколько вопросов.
Если у вас достаточно умный компилятор, он выполнит автоматическую вставку для вас, даже если вы не указали inline. С другой стороны, есть много вещей, которые нельзя выделить.
Если функция виртуальная, то, конечно, вы заплатите цену, которую нельзя встроить, поскольку цель определяется во время выполнения. И наоборот, в Java вы можете платить эту цену, если не указали, что метод является окончательным.
В зависимости от того, как ваш код организован в памяти, вы можете платить за пропуски кэша и даже пропуски страниц, так как код находится в другом месте. Это может оказать огромное влияние на некоторые приложения.
Как уже говорили другие, вам действительно не нужно слишком беспокоиться о накладных расходах, если только вы не стремитесь к максимальной производительности или чему-то подобному. Когда вы создаете функцию, компилятор должен написать код:
- Сохранить параметры функции в стек
- Сохранить адрес возврата в стек
- Перейти к начальному адресу функции
- Выделите место для локальных переменных функции (стек)
- Запустите тело функции
- Сохранить возвращаемое значение (стек)
- Свободное место для локальных переменных ака сборка мусора
- Перейти к сохраненному обратному адресу
- Освободите, сохраните для параметров и т.д...
Однако вы должны учитывать снижение читабельности вашего кода, а также то, как это повлияет на ваши стратегии тестирования, планы обслуживания и общее влияние размера файла src.
В зависимости от того, как вы структурируете свой код, деление на модули, такие как модули и библиотеки, в некоторых случаях может иметь большое значение.
- Использование функции динамической библиотеки с внешним связыванием в большинстве случаев приведет к полной обработке кадров стека.
Вот почему использование qsort из библиотеки stdc на один порядок (в 10 раз) медленнее, чем использование кода stl, когда операция сравнения так же проста, как и целочисленное сравнение. - Передача указателей на функции между модулями также будет затронута.
То же самое наказание, скорее всего, повлияет на использование виртуальных функций C++, а также других функций, код которых определен в отдельных модулях.
Хорошей новостью является то, что оптимизация всей программы может решить проблему зависимостей между статическими библиотеками и модулями.
У меня тоже нет номеров, но я рад, что вы спрашиваете. Слишком часто я вижу людей, пытающихся оптимизировать свой код, начиная с туманных представлений о накладных расходах, но не осознавая этого.
Каждая новая функция требует создания нового локального стека. Но издержки этого будут заметны, только если вы вызываете функцию на каждой итерации цикла в течение очень большого количества итераций.
Для большинства функций они не являются дополнительными накладными расходами для вызова их в C++ против C (если только вы не считаете, что указатель "this" является ненужным аргументом для каждой функции. Вы должны каким-то образом передать функцию в функцию)...
Для виртуальных функций это дополнительный уровень косвенности (эквивалентный вызову функции через указатель в C)... Но на самом деле, на современном оборудовании это тривиально.