Что делает параметр optimize?


Меня недвано спросили, какие именно оптимизации выполняет компилятор C#, когда ему указываешь параметр optimize. Перед тем, как ответить, я хочу убедиться в том, что кое-что абсолютно ясно. Строки компилятора про «способ применения» не врут, когда говорят

/debug[+|-] Emit debugging information
/optimize[+|-] Enable optimizations

Включение отладочной информации и оптимизация генерируемого IL ортогональны; они не оказывают совсем никакого эффекта друг на друга (*). Обычно включают отладку и выключают оптимизацию, или наоборот, но и остальные две комбинации вполне законны. (И, чтоб два раза не вставать: включение генерации отладочной информации не подразумевает /d:DEBUG, они тоже ортогональны. Опять же, разумно держать /debug и /d:DEBUG согласованными, но вы не обязаны это делать; вам может захотеться поотлаживать оптимизированную версию без ассертов, и мы не собираемся вас останавливать)

С этого момента, когда я говорю «порождать», я имею в виду «производить метаданные», а когда говорю «генерировать» я имею в виду «производить IL».

Итак, прежде всего, есть некоторые оптимизации, которые компилятор выполняет всегда, вне зависимости от флага optimize.

Семантика языка требует от нас выполнять свёртывание констант, потому что оно нужно для обнаружения ошибок вроде этой:

switch(x) {
    case 2:
    case 1+1:

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

Кстати о нём: есть два вида анализа достижимости, которые мы применяем для устранения мёртвого кода. Во-первых, есть достижимость «по спецификации». Это означает, что анализ достижимости применяет константы времени компиляции. Это тот вид анализа, который мы используем для вывода предупреждений о «недостижимом коде», для проверки того, что все локальные переменные проходят через определяющее присваивание на всех путях исполнения, а также для определения достижимости точки выхода из не-void метода. Если этот первый проход определяет, что некоторый код недостижим, то мы не генерируем для него IL. Например, такое устраняется оптимизацией:

if (false) M();

Но если выражение включает константы, недоступные во время компиляции, то эта первая форма анализа достижимости скажет вам, что вызов N здесь достижим, и что имеет место ошибка обращения к неинциализированной переменной:

int x = M();
int y;
if (x * 0 != 0) N(y);

Если это исправить так, чтобы y получал определённое значение, тогда первый проход анализатора достижимости НЕ СТАНЕТ вырезать этот код, потому что он всё еще думает, что вызов достижим.

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

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

if (M() * 0 == 0) N();

можно генерировать так, как будто вы написали просто:

M();
N();

У нас есть куча простых оптимизаций алгебры чисел и типов, которые проверяют вещи вроде прибавления нуля к целому, умножения целого на единицу, использования null как аргумента для is или as, конкатенации строковых литералов, и так далее. Оптимизации выражений происходят всегда – установите ли вы флаг optimize или нет; устранение базовых блоков, которые определяются как недостижимые после этих оптимизаций, зависит от этого флага.

Мы также проводим несколько мелких оптимизаций в некоторых вызовах и проверках на null. (Хотя не так много, как могли бы). Например, предположим, что у вас есть невиртуальный метод экземпляра M у ссылочного типа C, и метод GetC, который возвращает C. Если вы пишете GetC().M(), то мы генерируем для вызова M инструкцию callvirt. Почему? Потому, что генерировать callvirt для метода экземпляра у ссылочного типа разрешено, а callvirt автоматически вставляет проверку на null. Инструкция невиртуального вызова не выполняет проверку на null; нам бы пришлось генерировать дополнительный код для проверки, не вернул ли GetC null. Так что это мелкая оптимизация размера кода. Но мы можем сделать даже лучше; если у вас есть (new C()).M(), то мы генерируем инструкцию call, потому что знаем, что результат оператора new не может быть null. Это даёт нам небольшую оптимизацию по времени, потому что мы можем не тратить наносекунду, нужную для проверки на null. Как я уже сказал, это небольшая оптимизация.

Флаг /optimize не меняет огромное количество нашей логики по включению и генерации. Мы стараемся всегда генерировать прямолинейный, верифицируемый код и затем полагаться на JIT в основной работе по оптимизации, когда он генерирует код для реальной машины. Но мы делаем некоторые простые оптимизации, когда этот флаг установлен:

· выражения, которые определяются как нужные только для побочных эффектов, заменяются на код, который просто производит побочные эффекты

· Мы пропускаем генерацию кода для фрагментов типа int foo = 0; потому что мы знаем, что аллокатор проинициализирует поля значениями по умолчанию.

· Мы пропускаем порождение и генерацию пустых статических конструкторов класса. (Что обычно получается, если статический конструктор устанавливает все поля в их значения по умолчанию и предыдущая оптимизация всё это устраняет)

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

· Мы стараемся минимизировать количество слотов для локальных переменных и временных значений. Например, если у вас есть:

for (int i = …)  {…}
for (int j = …) {…}

то, когда дело дойдет до j, компилятор сгенерирует код для повторного использования места, зарезервированного под i.

· Кроме того, если у вас есть переменная, которая вообще нигде не используется, то под неё не станет выделяться место при установленном флаге.

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

· Также, при установленном флаге компилятор более агрессивен насчёт генерации кода, который быстро выбрасывает «временные» значения, использованные в местах вроде управляющей переменной оператора switch, условий в операторе if, возвращаемых значений, и т.д. При неоптимизированной компиляции эти значения трактуются как неименованные локальные переменные, загружаемые и сохраняемые в определённых местах. При оптимизированной компиляции их зачастую держат прямо на стеке.

· Мы устраняем почти все nop-ы, вставленные «для удобства точек остановки»

· Мы стараемся устранить генерацию «защищённых» регионов. Например, если блок try пуст, то ясно, что catch блоки недостижимы, а finally может быть просто обычным кодом.

· Если у нас есть инструкция перехода на LABEL1, а по адресу LABEL1 инструкция перехода на LABEL2, то мы заменяем первую инструкцию переходом сразу на LABEL2. То же самое с переходами, которые ведут к return.

· Мы ищем ситуации «перепрыгивания через переход». Например, вот тут мы идем на LABEL1 если условие ложно, иначе мы идем на LABEL2.

brfalse condition, LABEL1
br LABEL2
LABEL1: somecode

Поскольку мы всего лишь прыгаем через другой переход, мы можем переписать это как просто «если условие истинно, то иди на LABEL2»:

brtrue condition, LABEL2
somecode

· Мы ищем ситуации «переход на nop». Если переход ведет к nop, то можно заменить его переходом на инструкцию сразу за nop.

· Мы ищем ситуации «переход к следующему»; если переход ведет к следующей инструкции, то его можно устранить.

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

Вот, в общем-то, всё. Это очень прямолинейные оптимизации; нет инлайнинга IL, нет развёртки циклов, нет межпроцедурного анализа и много чего еще. Мы предоставляем команде JIT беспокоиться насчёт оптимизации кода, когда он превращается в машинные инструкции; именно там можно добиться реального выигрыша.

*******

(*) Небольшая ложь. Есть некоторая взаимосвязь между ними, когда мы генерируем атрибуты, определяющие, можно ли Edit’n’Continue сборку при отладке.

Comments (0)

Skip to main content