Оптимизации компилятора
Компилятор может улучшить код, не меняя его результата, — этим занимаются оптимизации.
Оптимизация — преобразование программы, ускоряющее её или уменьшающее размер кода, при строгом сохранении наблюдаемого результата.
Оптимизатор работает между генерацией IR и финальным кодом. Его золотое правило: результат программы обязан остаться прежним. Менять можно форму, но не смысл. Рассмотрим две классические, наглядные оптимизации.
Свёртка констант
Свёртка констант (constant folding) — вычисление выражений из одних констант на этапе компиляции, чтобы не считать их при каждом запуске.
Зачем процессору в рантайме считать 2 + 3 * 4, если ответ 14 известен уже при компиляции? Оптимизатор находит поддеревья из одних чисел и заменяет их результатом.
class Num:
def __init__(self, v): self.value = v
def __repr__(self): return str(self.value)
class BinOp:
def __init__(self, op, l, r): self.op, self.left, self.right = op, l, r
def __repr__(self): return "(" + repr(self.left) + self.op + repr(self.right) + ")"
def fold(node):
if isinstance(node, Num):
return node
left = fold(node.left)
right = fold(node.right)
if isinstance(left, Num) and isinstance(right, Num):
ops = {"+": left.value + right.value, "*": left.value * right.value}
return Num(ops[node.op])
return BinOp(node.op, left, right)
tree = BinOp("+", Num(2), BinOp("*", Num(3), Num(4)))
print("до: ", tree)
print("после:", fold(tree))Вывод:
до: (2+(3*4)) после: 14
Всё дерево свернулось в одно число. В рантайме не останется ни одной арифметической операции.
Удаление мёртвого кода
Удаление мёртвого кода (dead code elimination) — выбрасывание инструкций, результат которых нигде не используется или которые недостижимы.
Если переменную вычислили, но ни разу не прочитали, её вычисление бессмысленно. То же с кодом после return — он недостижим. Покажем простую идею на «инструкциях присваивания».
program = [
("assign", "a", "1"),
("assign", "b", "2"), # b нигде не используется (мёртвый код)
("use", "a"),
]
used = {name for kind, name, *_ in program if kind == "use"}
clean = [ins for ins in program
if ins[0] != "assign" or ins[1] in used]
for ins in clean:
print(ins)Вывод:
('assign', 'a', '1')
('use', 'a')Как работает под капотом
Обе оптимизации — снова обходы. Свёртка обходит AST снизу вверх и заменяет числовые поддеревья. Удаление мёртвого кода анализирует, какие значения реально используются, и выбрасывает остальное. Реальные оптимизаторы делают десятки таких проходов: инлайнинг функций, вынос инвариантов из циклов, распределение регистров. Но принцип один — переписать программу, сохранив её наблюдаемое поведение.
Частые ошибки
Опаснейшая ошибка оптимизатора — изменить результат. Например, «свернуть» 10 / 0 в момент компиляции и убрать ошибку, которая должна была случиться в рантайме. Или удалить присваивание, у которого есть побочный эффект (например, вызов функции). Оптимизатор обязан учитывать побочные эффекты, иначе он сломает программу.
Вторая — оптимизировать слишком агрессивно ценой времени компиляции. Иногда простой код компилируется быстрее, чем оптимизатор успевает его «улучшить». Поэтому уровни оптимизации (-O0, -O2) дают выбор баланса.
Итог
- Оптимизация меняет форму кода, строго сохраняя его результат.
- Свёртка констант вычисляет константные выражения при компиляции.
- Удаление мёртвого кода выбрасывает неиспользуемые и недостижимые инструкции.
- Оптимизатор обязан учитывать побочные эффекты и не менять наблюдаемое поведение.