- •Int constant_operations() {
- •Какие выражения были вычислены на этапе компиляции?
- •Как изменилось количество инструкций?
- •Какие типы оптимизаций были применены?
- •Int dead_code_elimination() {
- •Int main_inline() {
- •Int dead_code_elimination() {
- •Int helper1(int a, int b) {
- •Int helper2(int X) {
- •Int main_inline() {
- •6) Вызов calculate из main
Int dead_code_elimination() {
return 30; // 10 + 20
}
Соответствующий IR:
define i32 @dead_code_elimination() {
entry:
ret i32 30
}
Типы оптимизаций:
Dead code elimination — удаление неиспользуемых переменных и недостижимых веток.
Constant folding / propagation — свёртка x + y в константу 30.
Упрощение CFG (Control Flow Graph): удаление лишних базовых блоков.
Рисунок 7 - Сгенерированный ассемблер с оптимизатором -O0
Рисунок 8 - Сгенерированный LLVM IR с оптимизатором -O0
Рисунок 9 - Сгенерированный ассемблер с оптимизатором -O2
Рисунок 10 - Сгенерированный LLVM IR с оптимизатором -O2
Листинг 3 — Инлайнинг функций
Исходный код:
Int helper1(int a, int b) {
return a * b + 10;
}
Int helper2(int X) {
return helper1(x, x + 1);
}
Int main_inline() {
int result = 0;
for (int i = 0; i < 10; i++) {
result += helper2(i);
}
return result;
}
Поведение при -O0
Все функции отдельные, без инлайнинга.
В main_inline на каждой итерации цикла:
вызывается helper2(i);
внутри неё вызывается helper1(x, x + 1).
В IR видно несколько функций, вызовы call.
Поведение при -O1 / -O2
На оптимизирующих уровнях компилятор обычно выполняет:
Function inlining:
Тело helper1 подставляется внутрь helper2.
Затем helper2 может быть также заинлайнена в main_inline, особенно при -O2.
После полного инлайнинга в main_inline:
Int main_inline() {
int result = 0;
for (int i = 0; i < 10; i++) {
// result += helper2(i);
// helper2(i) = helper1(i, i+1) = i*(i+1) + 10
result += i * (i + 1) + 10;
}
return result;
}
Далее возможно:
Strength reduction / constant folding внутри цикла (частично).
Типы оптимизаций:
Inlining (встраивание функций) — уменьшение overhead вызовов.
Loop-invariant optimizations (если появляются инварианты).
Constant folding внутри цикла и, потенциально, полная свёртка цикла (loop unrolling + constant propagation).
Уменьшение количества функций и вызовов в ассемблере: меньше инструкций call.
Рисунок 11 - Сгенерированный ассемблер с оптимизатором -O0
Рисунок 12 - Сгенерированный LLVM IR с оптимизатором -O0
Рисунок 13 - Сгенерированный ассемблер с оптимизатором -O2
Рисунок 14 - Сгенерированный LLVM IR с оптимизатором -O2
Задание 2. Оптимизация циклов
Листинг 4:
// Простой цикл с инвариантом
int simple_loop() {
int sum = 0;
for (int i = 0; i < 100; i++) {
sum += i;
}
return sum;
}
// Цикл с инвариантными вычислениями
int loop_with_invariant(int n) {
int result = 0;
int constant = 100 * 50; // инвариант
for (int i = 0; i < n; i++) {
result += i + constant;
}
return result;
}
// Вложенные циклы
int nested_loops() {
int sum = 0;
for (int i = 0; i < 10; i++) {
for (int j = 0; j < 10; j++) {
sum += i * j;
}
}
return sum;
}
4.2.1. simple_loop
При -O0: обычный цикл, много инструкций:
инициализация i = 0;
сравнение i < 100;
переход назад;
sum += i.
При -O1/-O2:
Компилятор видит, что цикл полностью детерминирован: сумма от 0 до 99.
Выполняется loop unrolling (частичный) или вообще полная свёртка: сумма вычисляется при компиляции как 4950.
Итоговая функция:
int simple_loop() {
return 4950;
}
В IR/asm остаётся одна константа.
Оптимизации:
Constant folding / propagation.
Loop unrolling / loop elimination (цикл полностью удалён).
Dead code elimination (удалены все счётчики).
4.2.2. loop_with_invariant
Здесь constant = 100 * 50:
100 * 50 — константное выражение. При оптимизации:
до входа в цикл вычисляется константа 5000;
значение constant рассматривается как loop-invariant.
Внутри цикла остаётся:
result += i + 5000;
При -O2:
часто выполняются loop-invariant code motion (LICM) — хотя в исходнике constant уже вне цикла, компилятор может дополнительно упростить вычисления.
возможны оптимизации для случая, когда n известно (если вызывать с фиксированным n и включить LTO, можно свернуть цикл полностью).
Оптимизации:
Constant folding: 100 * 50 → 5000.
Loop-invariant code usage: переменная constant не пересчитывается в цикле, используется как константный регистр.
Возможное частичное развёртывание цикла (unrolling) для производительности.
4.2.3. nested_loops
При -O0: два вложенных цикла, явные i, j, умножение i * j в теле.
При -O1 / -O2:
i и j становятся индукционными переменными, хранятся в регистрах.
Может выполняться strength reduction — вместо того, чтобы каждый раз вычислять i * j через инструкцию mul, компилятор может преобразовать часть выражений к цепочке сложений, но современный компилятор часто оставляет mul, т.к. оно дешёвое.
Возможен loop unrolling внешнего и/или внутреннего циклов (особенно при малых границах 0..9).
Возможна полная свёртка (так как пределы известны), и вычисление суммы ∑i∑j i*j во время компиляции, в результате функция вернёт константу.
Оптимизации:
Strength reduction (частично).
Loop unrolling.
Возможная полная свёртка вложенных циклов.
Оптимизация использования регистров и индексации.
Рисунок 15 - Сгенерированный ассемблер с оптимизатором -O0
Рисунок 16 - Продолжение сгенерированного ассемблера с оптимизатором -O0
Рисунок 17 - Сгенерированный LLVM IR с оптимизатором -O0
Рисунок 18 - Продолжение сгенерированного LLVM IR с оптимизатором -O0
Рисунок 19 - Сгенерированный LLVM IR с оптимизатором -O1
Рисунок 20 - Сгенерированный ассемблер с оптимизатором -O2
Рисунок 21 - Сгенерированный LLVM IR с оптимизатором -O2
Задание 3. Анализ оптимизаций “сложной” программы
Исходный код:
#include <stdio.h>
int calculate(int base, int iterations) {
int result = base;
if (iterations < 0) {
printf("This should never happen\n");
return -1;
}
for (int i = 0; i < iterations; i++) {
if (i % 2 == 0) {
result += i * 2;
} else {
result += i;
}
int invariant = 100 * 50;
result += invariant;
}
int final_adjustment = (10 + 20) * 3;
result += final_adjustment;
return result;
}
int main() {
int value = calculate(10, 5);
printf("Result: %d\n", value);
return 0;
}
4.3.1. Что делает компилятор при разных уровнях оптимизации
При -O0
Сохраняется структура программы:
условие if (iterations < 0) реализовано ветвлением;
цикл for с явным счётчиком;
внутри цикла вычисляется i % 2 — реальная операция деления (или специальные инструкции);
int invariant = 100 * 50; вычисляется на каждой итерации;
после цикла — вычисление final_adjustment = (10 + 20) * 3; как обычные операции.
В main:
вызывается calculate(10, 5);
затем printf.
При -O1 / -O2
1) Свёртка констант и инвариантов
int invariant = 100 * 50;→ constant folding → int invariant = 5000;.
int final_adjustment = (10 + 20) * 3;→ constant folding → int final_adjustment = 90;.
2) Loop-invariant code motion
Переменная invariant объявлена внутри цикла, но её значение не зависит от i:
for (...) {
int invariant = 5000;
result += invariant;
}
Оптимизированный вариант:
int invariant = 5000;
for (...) {
result += invariant;
}
То есть инвариант выносится из тела цикла, чтобы не пересчитывать на каждой итерации.
3) Оптимизация проверки чётности
Выражение:
if (i % 2 == 0)
При оптимизации % 2 превращается в более дешёвую операцию:
if ((i & 1) == 0)
То есть деление заменено на побитовое И (strength reduction).
4) Упрощение ветвления внутри цикла
Внутри цикла:
if (i % 2 == 0) {
result += i * 2;
} else {
result += i;
}
Может быть переписано компилятором в форму без явного ветвления (branchless), например с использованием условных инструкций/селекторов:
int add_even = i * 2;
int add_odd = i;
int to_add = (i & 1) ? add_odd : add_even;
result += to_add;
В LLVM IR это может быть через инструкцию select.
5) Вызовы printf и условная ветка iterations < 0
Параметр iterations — аргумент функции, его значение не известно на этапе компиляции (если не включать whole-program оптимизации и не анализировать конкретный вызов).
Поэтому ветка if (iterations < 0) не удаляется, но:
Проверка упрощается (нет лишних копий, используется регистр).
Код в недостижимой ветке (после return -1) не оптимизируется сильно, но может быть слегка упорядочен.
