Сколько накладных расходов при вызове функции в C ++? - PullRequest
58 голосов
/ 28 сентября 2008

Много литературы говорит об использовании встроенных функций, чтобы «избежать накладных расходов при вызове функции». Однако я не видел количественных данных. Каковы фактические накладные расходы при вызове функции, т. Е. Какого увеличения производительности мы достигаем путем встраивания функций?

Ответы [ 16 ]

44 голосов
/ 28 сентября 2008

В большинстве архитектур стоимость состоит из сохранения всех (или некоторых, или ни одного) регистров в стеке, помещения аргументов функции в стек (или помещения их в регистры), увеличения указателя стека и перехода к начало нового кода. Затем, когда функция будет завершена, вы должны восстановить регистры из стека. Эта веб-страница содержит описание того, что входит в различные соглашения о вызовах.

Большинство компиляторов C ++ достаточно умны, чтобы встроить функции для вас. Ключевое слово inline - это просто подсказка компилятору. Некоторые даже сделают встраивание через единицы перевода, где они решат, что это полезно.

11 голосов
/ 28 сентября 2008

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

Технический ответ, на который ссылается ваша литература, обычно не актуален из-за оптимизации компилятора. Но если вам все еще интересно, это хорошо описано Джош .

Что касается «процента», то вам нужно знать, насколько дорогой была сама функция. Вне стоимости вызываемой функции нет процента, потому что вы сравниваете с операцией с нулевой стоимостью. Для встроенного кода плата не взимается, процессор просто переходит к следующей инструкции. Недостатком inling является больший размер кода, который показывает, что его затраты отличаются от затрат на строительство / разбор стека.

8 голосов
/ 04 марта 2012

Ваш вопрос - один из вопросов, на который нет ответа, который можно назвать «абсолютной истиной». Затраты на нормальный вызов функции зависят от трех факторов:

  1. Процессор. Затраты на процессоры x86, PPC и ARM сильно различаются, и даже если вы используете только одну архитектуру, накладные расходы также немного различаются между Intel Pentium 4, Intel Core 2 Duo и Intel Core i7. Издержки могут даже заметно различаться между процессорами Intel и AMD, даже если оба работают на одной тактовой частоте, поскольку такие факторы, как размеры кэша, алгоритмы кэширования, шаблоны доступа к памяти и фактическая аппаратная реализация самого кода операции вызова, могут иметь огромные влияние на накладные расходы.

  2. ABI (двоичный интерфейс приложения). Даже с одним и тем же процессором часто существуют различные ABI, которые определяют, как вызовы функций передают параметры (через регистры, через стек или через комбинацию обоих) и где и как происходит инициализация и очистка стекового фрейма. Все это влияет на накладные расходы. Различные операционные системы могут использовать разные ABI для одного и того же процессора; например Linux, Windows и Solaris могут все три использовать разные ABI для одного и того же процессора.

  3. Компилятор. Строгое следование ABI важно только в том случае, если функции вызываются между независимыми единицами кода, например если приложение вызывает функцию из системной библиотеки или пользовательская библиотека вызывает функцию из другой пользовательской библиотеки. Пока функции являются «частными», невидимыми вне определенной библиотеки или двоичного файла, компилятор может «обманывать». Он может не строго следовать ABI, но вместо этого использовать ярлыки, которые приводят к более быстрым вызовам функций. Например. он может передавать параметры в регистр вместо использования стека или может полностью пропустить установку и очистку кадра стека, если в этом нет особой необходимости.

Если вы хотите узнать накладные расходы для конкретной комбинации трех вышеупомянутых факторов, например, для Intel Core i5 в Linux с использованием GCC, единственный способ получить эту информацию - это сравнить разницу между двумя реализациями, одна из которых использует вызовы функций, а другая - где вы копируете код непосредственно в вызывающую программу; таким образом вы наверняка принудительно встраиваете, так как встроенное выражение является лишь подсказкой и не всегда приводит к встраиванию.

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

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

Пока мой ответ очень общий, он относится к C так же, как к C ++ и Objective-C. В качестве заключительного слова позвольте мне сказать кое-что о C ++, в частности: виртуальные методы - это двойные косвенные вызовы функций, это означает, что они имеют более высокие издержки вызова функций, чем обычные вызовы функций, и также они не могут быть встроенными. Невиртуальные методы могут быть встроены компилятором или нет, но даже если они не встроены, они все равно значительно быстрее виртуальных, поэтому не следует делать методы виртуальными, если вы действительно не планируете их переопределять или переопределять.

8 голосов
/ 28 сентября 2008

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

7 голосов
/ 07 августа 2016

Я сделал простой тест по сравнению с простой функцией приращения:

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 ™ (TM) i5 M 430 @ 2,27 ГГц дал мне:

  • 1,4 секунды для встраивания версия
  • 4,4 секунды для регулярно связанной версии

(кажется, колеблется до 0,2, но мне лень рассчитывать правильные стандартные отклонения, и я не забочусь о них)

Это говорит о том, что накладные расходы на вызовы функций на этом компьютере составляют 3 наносекунды

Самое быстрое, что я измерил при этом, было около 0,3 нс, так что можно было бы предположить, что вызов функции стоит около 9 примитивных операций , проще говоря.

Эти издержки увеличиваются примерно на 2 нс на вызов (общее время вызова около 6 нс ) для функций, вызываемых через PLT (функции в общей библиотеке).

5 голосов
/ 28 сентября 2008

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

3 голосов
/ 30 октября 2008

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


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

2 голосов
/ 15 января 2013

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

Издержки при вызове функции настолько малы, что только циклы, в которых вызывающие функции могут сделать накладные расходы по вызову релевантными.

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

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

Пример:

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 всегда возвращает один и тот же результат, но хорошие оптимизаторы могут это сделать.

1 голос
/ 09 февраля 2015

Слишком мало накладных расходов, особенно с небольшими (встроенными) функциями или даже классами.

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

#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

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

1 голос
/ 28 сентября 2008

Существует отличная концепция, называемая «теневое копирование регистров», которая позволяет передавать (до 6?) Значения через регистры (в ЦП) вместо стека (памяти). Кроме того, в зависимости от функции и переменных, используемых внутри, компилятор может просто решить, что код управления кадрами не требуется !!

Кроме того, даже компилятор C ++ может выполнить «оптимизацию хвостовой рекурсии», т. Е. Если A () вызывает B (), а после вызова B () A просто возвращается, компилятор повторно использует кадр стека !!

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

Добро пожаловать на сайт PullRequest, где вы можете задавать вопросы и получать ответы от других членов сообщества.
...