Подтвердить что ты не робот

Сколько накладных расходов при вызове функции на С++?

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

4b9b3361

Ответ 1

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

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

Ответ 2

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

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

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

Ответ 3

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

Ответ 4

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

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

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

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

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

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

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

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

Ответ 5

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

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 (R) Core i5, процессор M 430 @2.27GHz дал мне:

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

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

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

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

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

Ответ 6

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

Ответ 7

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


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

Ответ 8

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

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

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

Ответ 9

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

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

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

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

Пример:

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

Ответ 10

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

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

#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

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

Ответ 11

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

Ответ 12

Для большинства функций они не являются дополнительными накладными расходами для их вызова в С++ vs C (если вы не считаете, что указатель "this" как ненужный аргумент для каждой функции). Вы должны передать состояние функции каким-либо образом)...

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

Ответ 13

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

Ответ 14

Здесь есть несколько проблем.

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

  • Если функция виртуальна, то, конечно, вы собираетесь заплатить цену, которую она не может быть встроена, потому что цель определена во время выполнения. И наоборот, на Java вы можете заплатить эту цену, если не укажете, что этот метод является окончательным.

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

Ответ 15

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

  • Использование динамической библиотечной функции с внешней связью в большинстве случаев накладывает полную обработку кадров стека.
    Вот почему использование qsort из библиотеки stdc на порядок меньше (в 10 раз) медленнее, чем при использовании stl-кода, когда операция сравнения так же просто, как целочисленное сравнение.
  • Также будут затронуты указатели на функции между модулями.
  • Такое же наказание, скорее всего, повлияет на использование виртуальных функций С++, а также на другие функции, код которых определен в отдельных модулях.

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

Ответ 16

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

  • Сохранить функциональные параметры в стеке
  • Сохранить адрес возврата в стек
  • Переход к начальному адресу функции
  • Выделить пробел для функции local variables (stack)
  • Запустить тело функции
  • Сохранить возвращаемое значение (стек)
  • Свободное место для локальных переменных aka сбор мусора
  • Возврат к сохраненному обратному адресу
  • Свободное сохранение параметров и т.д.

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