Методы оптимизации

Существуют различные методы машинно-зависимой и машинно-независимой оптимизации кода. Они могут применяться на всех синтаксических уровнях. Одним из простейших методов является "размножение констант". При его применении любая ссылка на константное значение замещается самим значением. В следующем примере повышается эффективность благодаря удалению трех адресных ссылок и замене их константами:

x = 2;

if( a < x && b < x)

c = x;s

переводится в

x = 2;

if(a < 2 && b < 2)

c = 2;

Целиком связано с размножением констант "размножение копий". В этом методе копируются переменные вместо константных значений. Например,

x = y;

if(a < x && b < x)

c = x;

переводится в

x = y;

if(a < y && b < y)

c = y;

Размножение констант и копий также может удалить излишние присваивания (x = 2 и x = y в примерах). Среди описываемых компиляторов только Microsoft C 5.0 и WATCOM C 6.0 применяют размножение констант.

Метод "свертки констант" (константная арифметика) сводит выражения, которые содержат константные данные, к возможно простейшей форме. Константные данные обычно используются в программе либо непосредственно (как в случае чисел или цифр), либо косвенно (как в случае объявленных манифестных констант). Свертка констант сводит следующий оператор:

#define TWO 2

a = 1 + TWO;

к его эквивалентной форме,

a = 3;

во время компиляции, благодаря чему удаляются ненужные арифметические операции из стадии выполнения программы. В Си сворачивание констант применяют как к целым константам, так и к константам с плавающей точкой.

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

x = y + 0;

x = y * 0;

x = y / 1.0;

x = y / 0;

должен быть простым константным присваиванием и не должен содержать команд для выполнения арифметических операций. Бдительный компилятор должен пометить последний оператор как ошибочный и не генерировать код для него.

"Извлечение общих подвыражений" - это процесс удаления лишних вычислений. Вместо того, чтобы генерировать код для вычисления значения каждый раз, когда оно используется, оптимизирующий компилятор пытается выделить выражение таким образом, чтобы его значение вычислялось только однажды. Там, где это возможно, последующие ссылки на такое же выражение используют ранее вычисленное значение. Выражения y * 3 и a[y*3] являются общими подвыражениями в следующем тексте:

if( a[y*3] < 0 || b[y*3] > 10)

a[y*3] = 0;

Выделение этих выражений приводит к логически эквивалентному тексту:

T1 = y*3;

A1 = &a[T1];

A2 = &b[T1];

if( *A1 < 0 || *A2 > 10)

*A1 = 0;

Выделение общих подвыражений обычно происходит внутри оператора или блока. "Глубокое выделение общих подвыражений" является более сложным и перекрывает базовые блоки. Выделение общего подвыражения, y*3, в операторе

if(a == 0)

a = y * 3;

else

b = y * 3;

приводит к логическому эквиваленту:

T1 = y * 3;

if(a == 0)

a = T1;

else

b = T1;

Рисунок 1 демонстрирует практический выигрыш от выделения общих подвыражений в реальном коде.

--------------------------------------------------------------¬

¦РИСУНОК 1: Выделение общих подвыражений ¦

+-------------------------------------------------------------+

¦Исходный текст на Си BORLAND LATTICE ¦

¦ Turbo C 1.5 MS-DOS C 3.2 ¦

+-------------------------------------------------------------+

¦if((h3 + k3) < 0 || mov AX,h3 mov AX,h3 ¦

¦ (h3 + k3) > 5) add AX,k3 add AX,k3 ¦

¦ printf("Common\ jl @18 js L0187 ¦

¦ subexpression\ mov AX,h3 cmp AX,5 ¦

¦ elimination"); add AX,k3 jle L0193 ¦

¦ cmp AX,5 L0187: ¦

¦ jle @17 mov AX,01.0000 ¦

¦ @18: push AX ¦

¦ mov AX,offset s@ call printf ¦

¦ push AX add SP,2 ¦

¦ call printf L0193: ¦

¦ mov SP,BP ¦

¦ @17: ¦

+-------------------------------------------------------------+

¦Многократные вхождения вычислений заменяются значением, ¦

¦которое является результатом единственного вхождения ¦

¦вычисления. Borland Turbo C вычисляет значение выделенного ¦

¦выражения h3+k3 дважды, тогда как LATTICE MS-DOS C и другие ¦

¦применяют выделение общих подвыражений и вычисляют ¦

¦выражение только один раз. ¦

L--------------------------------------------------------------

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

"Удаление недостижимого кода" - еще один метод оптимизации. Недостижимый код – это некоторая последовательность инструкций программы, которая недостижима ни по одному пути в программе. Он может образоваться как следствие предыдущих операций оптимизации, кода условной отладки, или частых изменений программы многими программистами. Следующие операторы - это вариант кода для проверки компилятора на выполнение этого метода оптимизации.

#define DEBUG 0

if(DEBUG)

printf("Debug Function
\n
");

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

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

a = 5;

b = 0;

a = b;

первый оператор есть лишнее присваивание, и может быть удален безопасно. Лишние присваивания могут возникать непреднамеренно, когда промежуток жизни переменной велик и между вхождениями переменной имеется более-менее длинный код. Лишние присваивания могут быть также результатом предыдущих проходов оптимизации.

Цель "распределения переменных по регистрам" состоит в попытке обеспечить оптимальное назначение регистров путем сохранения часто используемых переменных в регистрах так долго, как это возможно, для того, чтобы исключить более медленный доступ к памяти. Количество регистров, доступных для использования, зависит от архитектуры процессора. Семейство микропроцессоров Intel 80x86 резервирует много регистров для специального использования и имеет несколько универсальных регистров. В помощь распределению переменных по регистрам язык Си предоставляет спецификатор класса регистровой памяти, который дает возможность программисту указывать, какие переменные должны располагаться в регистрах.

При назначении переменных регистрам компилятор принимает во внимание не только какие переменные нужно выделить, но также и регистры, которым они назначаются. Выбор переменных зависит от частоты их использования, промежутков жизни текущих регистровых переменных (которые определяются при анализе потоков данных) и количества доступных регистров. В зависимости от степени выполняемой компилятором оптимизации промежуток жизни переменной может определяться внутри единственного оператора, внутри базового блока или перекрывать несколько базовых блоков. Переменная сохраняется в регистре только если она будет снова использоваться. Если на переменную в дальнейшем не будет ссылок, то она сохраняется в оперативной памяти, освобождая регистр для другой переменной.

Поскольку оптимизирующему компилятору известен промежуток жизни переменной, он не будет намеренно генерировать "лишние операции сохранения и загрузки" (регистров). Лишние операции сохранения удаляются посредством удаления излишних присваиваний; лишние операции загрузки опускаются с помощью усовершенствованного распределения переменных по регистрам. Имея текст:

a = i + 2;

b = a + 3;

компилятор без возможностей оптимизации может сгенерировать следующий код:

mov AX,i

add AX,2

mov a,AX

mov AX,a

add AX,3

mov b,AX

тогда как оптимизирующий компилятор может использовать механизм размещения переменных в регистрах для удаления лишней четвертой инструкции (mov AX,a).

Время, проводимое в циклах, может считаться основной частью всего времени выполнения программы. Наиболее важным в оптимизации циклов является минимизация временных циклов микропроцессора, требуемых для одной итерации цикла. Количество инструкций, генерируемых для цикла, не так важно, как количество временных циклов, которое требуется для выполнения каждой инструкции. Простой цикл и код, сгенерированный для него четырьмя компиляторами, демонстрирует большое разнообразие в размере и качестве кода (см. рис. 2).

--------------------------------------------------------------¬

¦РИСУНОК 2: Простой цикл ¦

+-------------------------------------------------------------+

¦Исходный текст на Си BORLAND METAWARE ¦

¦ Turbo C 1.5 High C 1.4 ¦

¦(x) - врем. циклы (125) (87) ¦

+-------------------------------------------------------------+

¦k5 = 10000; mov j5,0 mov j5,0 ¦

¦j5 = 0; mov k5,10000 mov k5,10000 ¦

¦do { @10: L00e3: ¦

¦ k5 = k5 - 1; mov AX,k5 dec k5 ¦

¦ j5 = j5 + 1; dec AX inc j5 ¦

¦ i5 = (k5 * 3) / mov k5,AX mov AX,j5 ¦

¦ (j5 * constant5); mov AX,j5 mov SI,AX ¦

¦} while (k5 > 0); inc AX sal SI,2 ¦

¦ mov j5,AX add SI,AX ¦

¦ mov AX,k5 mov AX,k5 ¦

¦ imul AX,AX,3 mov DX,AX ¦

¦ push AX add DX,DX ¦

¦ mov AX,j5 add DX,AX ¦

¦ imul AX,AX,5 xchg AX,DX ¦

¦ mov BX,AX cwd ¦

¦ pop AX idiv SI ¦

¦ cwd mov I5,AX ¦

¦ idiv BX cmp k5,0 ¦

¦ mov i5,AX jnle L00e3 ¦

¦ cmp k5,0 ¦

¦ jg @10 ¦

+-------------------------------------------------------------+

¦ MICROSOFT WATCOM ¦

¦ C 5.0 C 6.0 ¦

¦ (46) (91) ¦

+-------------------------------------------------------------+

¦ mov j5,10000 mov j5,0 ¦

¦ mov k5,0 mov DI,10000 ¦

¦ mov CX,30000 L4 dec DI ¦

¦ sub SI,SI imul AX,DI,3 ¦

¦ $0265: inc j5 ¦

¦ sub CX,3 imul BX,j5,5 ¦

¦ add SI,5 cwd ¦

¦ mov AX,CX idiv BX ¦

¦ cwd mov i5,AX ¦

¦ idiv SI test DI,DI ¦

¦ mov DI,AX jg L4 ¦

¦ or CX,CX ¦

¦ jg $0265 ¦

¦ mov i5,DI ¦

+-------------------------------------------------------------+

¦ Компилятор Microsoft C 5.0 выполнил снижение мощности на ¦

¦ константном выражении и разместил в регистрах все ¦

¦ переменные внутри простого цикла, включая вычисляемое ¦

¦ значение i5. Высокая степень проведенного анализа цикла ¦

¦ демонстрируется тем, что заключительные состояния k5 и j5 ¦

¦ были определены заранее компилятором, а не позже, во ¦

¦ время выполнения. ¦

L--------------------------------------------------------------

"Вынесение инвариантного (неизменяющегося) кода" - один из путей ускорения циклов, заключающийся в вынесении выражений за пределы цикла, если значения, ими вычисляемые, являются неизменными во время выполнения цикла. Если инвариантный код выносится из следующего цикла:

unsigned char i,j,k,v,x;

for( i = 0; i < v; i++)

x = i * (j+k);

его логический эквивалент будет:

T1 = j + k;

for(i = 0; i < v; i++)

x = i * T1;

 

--------------------------------------------------------------¬

¦РИСУНОК 3: Вынесение инвариантного кода - Microsofr C 5.0 ¦

+-------------------------------------------------------------+

¦Исходный текст на Си MICROSOFT COMPUTER INNOVATIONS ¦

¦ C 5.0 C86Plus 1.10 ¦

+-------------------------------------------------------------+

¦for(i4=0;i4<=2;i4++) sub SI,SI mov i4,0 ¦

¦ ivector2[i4] =j*k; mov AX,j jmp L44@2 ¦

¦ imul k L9@2: ¦

¦ mov [BP-4],AL mov AX,j ¦

¦ $L20007: imul k ¦

¦ mov AL,[BP-4] mov SI,i4 ¦

¦ mov ivector2[SI],AL ¦

¦ inc SI mov ivector2[SI],AL¦

¦ cmp SI,2 inc i4 ¦

¦ jle $L20007 L44@2: ¦

¦ mov i4,SI cmp i4,2 ¦

¦ jle L9@2 ¦

+-------------------------------------------------------------+

¦ Вынесение инвариантного кода уменьшает время выполнения ¦

¦ цикла путем вынесения неизменяющихся выражений из тела ¦

¦ цикла. В отличие от Computer Innovations C86Plus 1.10, ¦

¦ компилятор Microsoft C 5.0 успешно выносит выражение j * h ¦

¦ за пределы цикла, так что оно выполняется только один раз, ¦

¦ вместо того, чтобы выполняться на каждой итерации цикла. ¦

L--------------------------------------------------------------

Рис. 3 демонстрирует вынесение инвариантного кода компилятором Microsoft C 5.0.

Дальнейший анализ примера показывает, что значение переменной i, индекса цикла, изменяется непосредственно с каждой итерацией. Отдельное присваивание i, известнойкак "переменная индукции цикла", может быть удалено:

T1 = j + k;

for(x = 0; x< T1 * v; x += T1);

i = v;

Поскольку использование переменных - индексов цикла во внутренних выражениях цикла общеупотребительно, удаление переменных индукции цикла вместе со связанными с ними "снижениями мощности", может значительно улучшить исполнение программы. Рис. 4 показывает пример удаления переменной индукции цикла.

--------------------------------------------------------------¬

¦РИСУНОК 4: Удаление переменных индукции цикла ¦

+-------------------------------------------------------------+

¦Исходный текст на Си MICROSOFT DATALIGHT ¦

¦ C 5.0 Optimum-C 3.14 ¦

+-------------------------------------------------------------+

¦for(i=0;i<100;i++) mov AX,0 ¦

¦ ivector5[i*2+3]=5; mov i,100 mov i,AX ¦

¦ mov SI,OFFSET ivector5+6 cmp AX,100 ¦

¦ $L20006: jge L134 ¦

¦ mov [SI],5 L11B: ¦

¦ add SI,4 mov BX,i ¦

¦ cmp SI,OFFSET ivector5+406 shl BX,1 ¦

¦ jb $L20006 shl BX,1 ¦

¦ mov ivector+6[BX],5 ¦

¦ inc i ¦

¦ cmp i,100 ¦

¦ jl L11B ¦

¦ L134: ¦

+-------------------------------------------------------------+

¦ Удаление переменных индукции цикла помогает минимизировать ¦

¦ время, проводимое в каждой итерации цикла, путем вынесения ¦

¦ индексирующих цикл переменных (переменных индукции) из ¦

¦ тела цикла. В то время, как компилятор Datalight Optimum-C ¦

¦ использует переменную индукции i для индексации массива ¦

¦ ivector5, компилятор Microsoft C 5.0 удаляет ее благодаря ¦

¦ накоплению смещения для каждого элемента массива и ¦

¦ добавлению результата к базовому адресу массива. ¦

L--------------------------------------------------------------

"Слияние циклов" минимизирует управляющие заголовки циклов путем сращивания кода из циклов, имеющих одинаковые управляющие заголовки, в один цикл. Для того, чтобы удалить управляющий заголовок второго цикла, два простых цикла

for(i = 0; i < 10; i++)

a = b + c;

for(i = 0; i < 10; i++)

d = e + f;

могут быть объединены в один цикл

for(i = 0; i < 10; i++) {

a = b + c;

d = e + f;

}

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

Непосредственно связано со слиянием циклов "разворачивание циклов", которое минимизирует количество проходов через цикл путем увеличения числа операций, выполняемых внутри каждой итерации. Цикл инициализации массива

int a[3];

int i;

for(i = 0; i < 3; i++)

a[i] = 0;

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

mov i,0

LOOP:

mov BX,i

shl BX,1

mov a[BX],0

inc i

cmp i,3

jl LOOP

В том же коде, оптимизированном по методу разворачивания цикла, удаляется цикл путем замещения его тремя инструкциями присваивания:

mov a,0

mov a+2,0

mov a+4,0

Хотя ни один из компиляторов, включенных в обзор, не выполняет буквальное разворачивание циклов, некоторые из них оптимизируют цикл путем использования "специализированных инструкций прцессора". Многие процессоры предоставляют специализированные инструкции для управления перемещением блоков данных, инициализации памяти и других часто встречающихся ситуаций управления данными. К примеру, строковые инструкции с префиксом повторения (в семействе процессоров 80x86), выполняющиеся быстрее, чем посимвольные команды в цикле. Оптимизирующий компилятор использует, когда возможно, инструкции процессора для управления ситуациями в специальных случаях. Применение специализированных инструкций процессора к расширенной версии предыдущего примера разворачивания циклов

int a[10000];

int i;

for(i = 0; i < 10000; i++)

a[i] = 0;

дает приведенный ниже ассемблерный код процессора 80x86. Он гораздо быстрее, чем его аналог, записанный в виде цикла или набора инструкций непосредственной засылки в память, имеющего соответствующую длину:

mov CX,10000

mov i,CX

sub AX,AX

mov DI,offset a

push DS

pop ES

cld

rep stosw

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

Начиная с процессора Intel 80186, семейство микропроцессоров 80x86 предоставляет инструкции ENTER и LEAVE для обработки вызовов функций. Полезность инструкции ENTER снижается, так как ее выполнение занимает гораздо больше временных циклов процессора, чем выполнение последовательности команд, осуществляющих засылку в стек базового указателя и вычитание необходимого количества байт для фрейма из указателя стека.

Альтернативой использованию стека для передачи параметров функции является задание корректно определенного протокола для передачи стольких параметров, сколько возможно, в регистрах. Если доступно достаточное количество регистров чтобы передать все параметры функции, и вызываемая функция не использует локальные переменные, то отпадает необходимость генерации кода для пролога и эпилога функции (они обычно нужны для установки адресации фрейма стека). Компилятор WATCOM C 6.0 использует этот подход (см. рис. 5). Существенное приращение скорости получается потому, что не только удаляются инструкции, но и потому, что параметры уже регистровые и могут обрабатываться более эффективно.

--------------------------------------------------------------¬

¦РИСУНОК 5: Строение заголовка вызова функции ¦

+-------------------------------------------------------------+

¦Исходный текст на Си MICROSOFT WATCOM ¦

¦(x)-врем. циклы C 5.0 C 6.0 ¦

+-------------------------------------------------------------+

¦/*Тест вызова funcall funcall ¦

¦ функции */ push bp push DX ¦

¦int funcall() mov BP,SP xor DX,DX ¦

¦{ sub SP,2 L4 mov AX,DX <-¬ ¦

¦ int i; push SI call dummy ¦ ¦

¦ sub SI,SI inc DX (23)¦

¦ for(i=0;i<20000;i++) $L20008: cmp DX,2000 ¦ ¦

¦ { dummy(i); } ; push SI <-¬ jl L4 <-- ¦

¦} call dummy ¦ pop DX ¦

¦ add SP,2 (31) ret ¦

¦int dummy(i) inc SI ¦ ¦

¦int i; cmp SI,20000 ¦ ¦

¦{ jl $L20008 <-- ¦

¦ return (i+1); mov [BP-2],SI ¦

¦} pop SI ¦

¦ leave ¦

¦ ret ¦

¦ ¦

¦ --> dummy push BP dummy inc AX <-¬(13)¦

¦ ¦ mov BP,SP ret <-- ¦

¦ (28)¦ mov AX,[BP+4] ¦

¦ ¦ inc AX ¦

¦ ¦ leave ¦

¦ L-> ret ¦

+-------------------------------------------------------------+

¦ Подобно большинству компиляторов Си Microsoft C 5.0 ¦

¦ передает параметры функциям путем засылки их в стек. ¦

¦ Всякий раз при вызове выполняется заголовок, так как ¦

¦ функция должна установить адресацию базирующихся на стеке ¦

¦ параметров. Однако компилятор WATCOM C 6.0 удаляет ¦

¦ стековый заголовок благодаря передаче в регистрах стольких ¦

¦ параметров, сколько возможно. ¦

L--------------------------------------------------------------

Большинство компиляторов Си позволяют пользователю указывать, какой набор команд процессора должен использоваться при генерации кода. Хотя специализированные инструкции конкретного процессора и могут ускорить выполнение программы, но их применение может ограничить количество машин, на которых программа может работать.

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

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

При трансляции условных операторов генератор кода компилятора иногда генерирует инструкции перехода, которые передают управление на другие инструкции перехода. "Сжатие цепочки переходов" просто превращает связанное множество переходов в единственный переход от начала цепочки переходов к конечной цели.