Оптимизация компилятора C/C++: лучше ли создавать новые переменные, повторно использовать существующие или вообще избегать переменных?

Я всегда задавался вопросом: легче ли компилятору оптимизировать функции, в которых повторно используются существующие переменные, где создаются новые (в идеале const) промежуточные переменные или где вместо создания переменных вместо прямого использования выражений?

Например, рассмотрим следующие функции:

// 1. Use expression as and when needed, no new variables
void MyFunction1(int a, int b)
{
    SubFunction1(a + b);
    SubFunction2(a + b);
    SubFunction3(a + b);
}

// 2. Re-use existing function parameter variable to compute
// result once, and use result multiple times.
// (I've seen this approach most in old-school C code)
void MyFunction2(int a, int b)
{
    a += b;
    
    SubFunction1(a);
    SubFunction2(a);
    SubFunction3(a);
}

// 3. Use a new variable to compute result once,
// and use result multiple times.
void MyFunction3(int a, int b)
{
    int sum = a + b;
    
    SubFunction1(sum);
    SubFunction2(sum);
    SubFunction3(sum);
}

// 4. Use a new const variable to compute result once,
// and use result multiple times.
void MyFunction4(int a, int b)
{
    const int sum = a + b;
    
    SubFunction1(sum);
    SubFunction2(sum);
    SubFunction3(sum);
}

Моя интуиция такова:

  • В этой конкретной ситуации функцию 4 оптимизировать проще всего, поскольку в ней явно указывается намерение использовать данные. Он сообщает компилятору: «Мы суммируем два входных аргумента, результат которых не будет изменен, и мы передаем результат идентичным образом при каждом последующем вызове функции». Я ожидаю, что значение переменной sum будет просто помещено в регистр, и фактического доступа к базовой памяти не произойдет.
  • Функция 1 является следующей по простоте оптимизации, хотя она требует большего количества выводов со стороны компилятора. Компилятор должен определить, что a + b используется одинаковым образом для каждого вызова функции, и он должен знать, что результат a + b идентичен при каждом использовании этого выражения. Я бы по-прежнему ожидал, что результат a + b будет помещен в регистр, а не зафиксирован в памяти. Однако, если бы входные аргументы были более сложными, чем простые ints, я вижу, что их было бы сложнее оптимизировать (правила для временных аргументов применялись бы для C++).
  • Функция 3 является следующей по простоте после нее: результат не помещается в переменную const, но компилятор видит, что sum нигде в функции не изменяется (при условии, что последующие функции не берут на нее изменяемую ссылку), поэтому он может просто сохранить значение в регистре, как и раньше. Однако это менее вероятно, чем в случае с функцией 4.
  • Функция 4 оказывает наименьшую помощь в оптимизации, так как напрямую изменяет входящий аргумент функции. Я не уверен на 100%, что здесь сделает компилятор: я не думаю, что неразумно ожидать, что он будет достаточно умным, чтобы заметить, что a больше нигде в функции не используется (аналогично sum в функции 3), но Я бы не стал этого гарантировать. Это может потребовать изменения памяти стека в зависимости от того, как передаются аргументы функции (я не слишком хорошо знаком с тонкостями работы вызовов функций на этом уровне детализации).

Верны ли мои предположения? Есть ли еще факторы, которые следует учитывать?

Обновлено: Пара пояснений в ответ на комментарии:

  • Если бы компиляторы C и C++ подходили к приведенным выше примерам по-разному, мне было бы интересно узнать, почему. Я могу понять, что C++ будет оптимизировать вещи по-разному в зависимости от того, какие ограничения существуют для любых объектов, которые могут быть входными данными для этих функций, но для примитивных типов, таких как int, я ожидаю, что они будут использовать идентичные эвристики.
  • Да, я мог бы скомпилировать с оптимизацией и посмотреть на вывод сборки, но я не знаю сборки, поэтому вместо этого спрашиваю здесь.

«Правильны ли мои предположения?» Простой способ это выяснить: скомпилируйте с помощью -O3 и посмотрите на вывод сборки каждой функции.

tadman 19.12.2020 12:40

Обычно недооценивают, насколько умными могут быть компиляторы => godbolt.org/z/n4b56v Это всегда один и тот же код, поэтому используйте то, что более читабельно.

churill 19.12.2020 12:42

Это не имеет большого значения; см. en.wikipedia.org/wiki/Static_single_assignment_form, что делают gcc и clang.

Shawn 19.12.2020 14:07
Стоит ли изучать PHP в 2026-2027 годах?
Стоит ли изучать PHP в 2026-2027 годах?
Привет всем, сегодня я хочу высказать свои соображения по поводу вопроса, который я уже много раз получал в своем сообществе: "Стоит ли изучать PHP в...
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
В JavaScript одним из самых запутанных понятий является поведение ключевого слова "this" в стрелочной и обычной функциях.
Приемы CSS-макетирования - floats и Flexbox
Приемы CSS-макетирования - floats и Flexbox
Здравствуйте, друзья-студенты! Готовы совершенствовать свои навыки веб-дизайна? Сегодня в нашем путешествии мы рассмотрим приемы CSS-верстки - в...
Тестирование функциональных ngrx-эффектов в Angular 16 с помощью Jest
В системе управления состояниями ngrx, совместимой с Angular 16, появились функциональные эффекты. Это здорово и делает код определенно легче для...
Концепция локализации и ее применение в приложениях React ⚡️
Концепция локализации и ее применение в приложениях React ⚡️
Локализация - это процесс адаптации приложения к различным языкам и культурным требованиям. Это позволяет пользователям получить опыт, соответствующий...
Пользовательский скаляр GraphQL
Пользовательский скаляр GraphQL
Листовые узлы системы типов GraphQL называются скалярами. Достигнув скалярного типа, невозможно спуститься дальше по иерархии типов. Скалярный тип...
0
3
109
1
Перейти к ответу Данный вопрос помечен как решенный

Ответы 1

Ответ принят как подходящий

Хорошие современные компиляторы обычно не «заботятся» об именах, которые вы используете для хранения значений. Они выполняют пожизненный анализ значений и генерируют код на его основе. Например, учитывая:

int x = complicated expression 0;
... code using x
x = complicated expression 1;
... code using x

компилятор увидит, что complicated expression 0 используется в первом разделе кода, а complicated expression 1 используется во втором разделе кода, а имя x не имеет значения. Результат будет таким же, как если бы код использовал разные имена:

int x0 = complicated expression 0;
... code using x0
int x1 = complicated expression 1;
... code using x1

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

Даже если код был в цикле, например:

int x;
while (some condition)
{
    x = complicated expression;
    ... code using x
}

компилятор увидит, что complicated expression рождается в начале тела цикла и заканчивается в конце тела цикла.

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

  • Избегайте повторного использования переменной более чем для одной цели. Например, если кто-то позже обновит вашу функцию, чтобы добавить новую функцию, он может пропустить тот факт, что вы изменили параметр функции с помощью a += b;, и использовать a позже в коде, как если бы он все еще содержал исходный параметр.
  • Свободно создавайте новые переменные для хранения повторяющихся выражений. int sum = a + b; в порядке; оно выражает намерение и делает его более понятным для читателей, когда одно и то же выражение используется в нескольких местах.
  • Ограничьте область действия переменных (и идентификаторов в целом). Объявляйте их только в самой внутренней области, где они необходимы, например, внутри цикла, а не снаружи. Предотвращает случайное использование переменной там, где она больше не подходит.

Другие вопросы по теме