- •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
Министерство цифрового развития, связи и массовых коммуникаций РФ ордена Трудового Красного Знамени федеральное государственное бюджетное образовательное учреждение высшего образования «МОСКОВСКИЙ ТЕХНИЧЕСКИЙ УНИВЕРСИТЕТ СВЯЗИ И ИНФОРМАТИКИ»
Кафедра «Системное программирование»
Лабораторная работа №9
«Исследование оптимизаций компилятора Clang»
по дисциплине «Системное программное обеспечение»
Выполнила:
студентка группы БФИ2203
Мячина Лада Андреевна
Проверила:
Старший преподаватель
Алексанян Диана Ашотовна
Москва, 2025
Цели работы:
Изучить основные принципы оптимизации исходного кода
Ознакомиться с влиянием оптимизаций компилятора Clang на промежуточное представление LLVM IR и результирующий файл на уровне инструкций ассемблера
Приобрести практические навыки анализа промежуточного представления программы
Порядок выполнения работы
1. Для выполнение лабораторной работы допустимо как использование компилятора Clang, установленного в ОС, так и онлайн-компилятора godbolt.
При выполнении задания в ОС семейства Linux проверьте наличие компилятора командой clang --version и установите, при необходимости.
2. Ознакомьтесь с официальной документацией Clang, в том числе с опциями -S, emit-llvm, -O0, -O1, -O2.
3. Последовательно выполните анализ приведенного в заданиях кода с использованием опций компилятора -S, -emit-llvm, -O0, -O1, -O2 для получения требуемых результатов. Назначение опций, результаты работы и выводы включите в отчет.
4. Выполняйте вывод результатов работы компилятора в отдельные файлы, фрагменты вывода включите в отчет в соответствии с заданиями.
Задания на лабораторную работу
Задание 1. Исследование базовых оптимизации
Проанализируйте приведенный код 1, сгенерируйте IR и ассемблер и включите в отчет
ответы на вопросы:
Какие выражения были вычислены на этапе компиляции?
Как изменилось количество инструкций?
Какие типы оптимизаций были применены?
Листинг 1
int constant_operations() {
int a = 10 + 20 * 5;
int b = (1 << 4) | 0x0F;
float c = 3.14 * 2.0 + 1.0;
return a + b + (int)c;
}
Сгенерируйте LLVM IR и ассемблерный код для (листинг 2 и 3) с разными уровнями оптимизации. Проанализируйте полученные файлы и включите в отчет вывод о применении оптимизаций разного уровня.
Листинг 2
int dead_code_elimination() {
int x = 10;
int y = 20;
int z = x + y;
int unused1 = 100;
int unused2 = 200;
if (0) {
unused1 = 300;
}
return z;
}
Листинг 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;
}
Задание 2. Оптимизация циклов
Для приведенного кода (листинг 4) определите, какие оптимизации были проведены при компиляции с включением различных уровней оптимизации.
Листинг 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;
}
Задание 3. Анализ оптимизаций программы
Для приведенной «сложной» программы (листинг 5) определите, какие оптимизации были проведены при компиляции на различных уровнях оптимизации.
Листинг 4
#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;
}
Ход работы
Основные опции Clang
-S — не создавать исполняемый файл, а остановиться на этапе генерации ассемблерного кода (.s).
-emit-llvm — выводить LLVM IR (файлы .ll или .bc).
-O0 — без оптимизаций (debug-режим, максимально “прямой” код).
-O1, -O2 — оптимизирующие уровни.
-O1 — базовые оптимизации, умеренное ускорение.
-O2 — более агрессивные оптимизации (inlining, упрощение циклов, propagation, удаление мёртвого кода, loop-инварианты и т.д.).
Задание 1. Исследование базовых оптимизаций
Листинг 1 – Исходный код
Int constant_operations() {
int a = 10 + 20 * 5; // 10 + 100 = 110
int b = (1 << 4) | 0x0F; // 16 | 15 = 31
float c = 3.14 * 2.0 + 1.0; // приблизительно 7.28
return a + b + (int)c;
}
а) Что происходит при -O0
При уровне оптимизации -O0 компилятор почти не трогает исходную структуру кода:
в LLVM IR создаются инструкции mul, add, shl, or, fmul, fadd;
промежуточные переменные a, b, c обычно хранятся в памяти (alloca / store / load);
результат вычисляется по шагам.
Пример фрагмента IR в -O0:
%a = alloca i32
%b = alloca i32
%c = alloca float
; a = 10 + 20 * 5
store i32 10, i32* %a
; 20 * 5
%tmp1 = mul nsw i32 20, 5
%tmp2 = add nsw i32 10, %tmp1
; и т.д.
b) Что происходит при -O1 / -O2
При оптимизации Clang выполняет вычисление констант на этапе компиляции (constant folding):
10 + 20 * 5 превращается в константу 110;
(1 << 4) | 0x0F превращается в константу 31;
3.14 * 2.0 + 1.0 вычисляется в compile-time (в пределах точности IEEE-754), например 7.28f.
Соответственно, в IR уже нет эксплицитных арифметических операций, остаются только константы:
define i32 @constant_operations() {
entry:
; a = 110
; b = 31
; c ≈ 7.28
; return 110 + 31 + 7
ret i32 148
}
То есть функция целиком сворачивается в возврат одной константы (если компилятор считает допустимым такую оптимизацию).
Ответы на вопросы:
Какие выражения были вычислены на этапе компиляции?
20 * 5, затем 10 + (20*5);
(1 << 4) и далее (1 << 4) | 0x0F;
3.14 * 2.0 + 1.0;
итоговое выражение a + b + (int)c также может быть вычислено на этапе компиляции, и функция превращается в return 148;.
Как изменилось количество инструкций?
При -O0 в IR присутствуют:
отдельные инструкции для умножения, сложения, побитового сдвига и ИЛИ, операции с плавающей точкой;
операции выделения памяти (alloca) и загрузки/сохранения (load, store) для переменных a, b, c.
При -O2 и -О1 количество инструкций резко сокращается:
нет лишних alloca, все переменные заменены на константы;
в идеале остаётся фактически одна инструкция ret i32 <константа>.
Какие типы оптимизаций были применены?
Constant folding (свёртка констант).
Constant propagation (распространение констант до места использования).
Elimination of dead code / unused temporaries — исчезают промежуточные переменные.
В IR также происходит scalar replacement of aggregates, удаление ненужных allocа.
Рисунок 1 – Сгенерированный ассемблер с оптимизатором -О0
Рисунок 2- Сгенерированный LLVM IR с оптимизатором -O0
Рисунок 3 - Сгенерированный ассемблер с оптимизатором -O1
Рисунок 4 - Сгенерированный LLVM IR с оптимизатором -O1
Рисунок 5 - Сгенерированный ассемблер с оптимизатором -O2
Рисунок 6 - Сгенерированный LLVM IR с оптимизатором -O2
Листинг 2 — dead_code_elimination
Int dead_code_elimination() {
int x = 10;
int y = 20;
int z = x + y;
int unused1 = 100;
int unused2 = 200;
if (0) {
unused1 = 300;
}
return z;
}
Поведение при -O0
Все переменные (x, y, z, unused1, unused2) хранятся в стеке.
Условный оператор if (0) всё равно порождает базовые блоки в IR, хотя условие константно.
В IR видим:
присваивание unused1 и unused2;
ветку if (условный переход) с недостижимым блоком.
Поведение при -O1 / -O2
Компилятор видит, что условие if (0) никогда не выполняется, ветка мёртвая.
Переменные unused1 и unused2 нигде не используются.
Результат:
Условие if (0) полностью удаляется (dead code elimination).
Переменные unused1 и unused2 исчезают.
Функция сводится к:
