Оптимизация кода при помощи компилятора GNU C
 
Автор: (C) Rahul U Joshi
Перевод: (C) Александр Михайлов


Эта статья описывает некоторые приемы оптимизации программ при использовании компилятора GNU C для того, чтобы дать читателю почувствовать, что такое оптимизация на уровне исходного кода и как она может увеличить эффективность генерируемого компилятором объектного кода.

Содержание

  1. Введение
  2. Ассемблерный код программы на C
  3. Сворачивание констант
  4. Устранение повторяющихся подвыражений
  5. Уничтожение "мертвого" кода
  6. Снижение стоимости выполнения с использованием индуцированной переменной
  7. Заключение
  8. Благодарности
  9. Ссылки

1. Введение

Как мы знаем, компилятор -- это программа, которая читает исходный код на языке высокого уровня и переводит его (обычно) в машинный язык. Это сложный процесс, включающий в себя несколько стадий. Если компилятор является оптимизирующим компилятором, то одна из этих стадий "оптимизирует" код на машинном языке так, чтобы его исполнение занимало меньше времени, или он занимал меньше памяти, или и то и другое (иногда). Конечно, какую бы оптимизацию не делал компилятор, она не должны влиять на логику программы, т.е. оптимизация должна сохранять смысл (семантику) выполняемой программы. Вы можете спросить, какие же оптимизации делает компилятор, чтобы произвести эффективный машинный код? Так как не существует случаев, в которых можно менять логику компилируемой программы, компилятор должен очень тщательно изучить программу и найти возможности для оптимизации. Как можно догадаться, такой подробный анализ программы, нахождение и осуществление возможных оптимизаций -- это сложный и длительный процесс, детали которого выходят за рамки данной статьи.

В данной статье я собираюсь описать несколько типов оптимизации кода, которые использует компилятор GNU C, чтобы вы могли понять, как оптимизируется код и оценить сложность процесса оптимизации. Компилятор GNU C -- это изощренный оптимизирующий C-компилятор, использующий многие приемы оптимизации для того, чтобы создать более эффективный код. Описать их все невозможно, поэтому я выбрал лишь несколько таких подходов, которые показались мне наиболее интересными и легкими для понимания. Полный список способов оптимизации, применяемых в GNU C доступен по адресу http://www.redhat.com/products/support/gnupro/gnupro_gcc.html. Вместо того, чтобы просто объяснять то, что делает конкретный прием оптимизации, я буду описывать их, используя ассемблерный код, которые генерует компилятор GNU C. Кроме того, этот метод позволит читателям проводить дальнейшие исследование оптимизации, производимой компилятором, а также изучить 'продвинутую' технику оптимизации, в случае, если она их заинтересуют.

2. Ассемблерный код программы на C

Компилятор GNU C (далее просто GCC) читает исходный код C-программы и превращает его в объектный код, т.е. машинный код в двоичном виде. В тоже время, вместо объектного кода он может выдавать исходный код прогрммы на языке ассемблера, так что мы может посмотреть его и понять, как наша программа выглядит на языке ассемблера. Мы будем создавать файлы на языке ассемблера для того, чтобы посмотреть какую оптимизацию сделал компилятор, так что будет полезно посмотреть, как выглядит ассемблерный код простой C-программы. Хотя, нужно заметить, что нам не нужно понимать каждое ассемблерное выражение в сгенерированном коде, поэтому для простоты выражения, не критичные с точки зрения понимания оптимизации кода, объясняться не будут. Чтобы сгенерировать ассемблерный код, создайте файл test1.c как показано ниже и введите следующую команду:

$ gcc -c -S test1.c

Это создаст файл test1.s, который содержит листинг сгенерированного для C-программы кода на языке ассемблера. Файл test1.c вместе с ассемблерным кодом приведен ниже.


 1 : /* test1.c */
 2 : /* Этот первый файл просто демонстрирует то, как выглядит ассемблерная программа
 3 :    созданная компилятором и некоторые особенности ассемблера GNU,
 4 :    который следует несколько иным соглашениям, чем MASM/TASM.
 5 : */
 6 : #include <stdio.h>
 7 :
 8 : int main()
 9 : {
10 :     printf("Hello, World\n");
11 :     return 0;
12 : }
13 :
14 : /* end test1.c */
15 : /* ----------------------------------------------------------------- */
16 : /* сгенерированный ассемблерный файл */
17 :     .file   "test1.c"           /* Некоторые директивы ассемблера */
18 :     .version    "01.01"         /* которые можно проигнорировать */
19 : gcc2_compiled.:
20 : .section    .rodata             /* Этот сегмент содержит данные только для чтения */
21 : .LC0:
22 :     .string "Hello, World\n"
23 : .text
24 :     .align 4
25 : .globl main
26 :     .type    main,@function
27 : main:                           /* начало функции main */
28 :     pushl $.LC0                 /* заталкивает параметр для printf() в стек */
29 :     call printf                 /* вызываем функцию */
30 :     addl $4,%esp                /* очищаем стек */
31 :
32 :     xorl  %eax,%eax             /* установим EAX = 0, функции используют регистр */
33 :     jmp .L1                     /* EAX чтобы возвращать значения */
34 :     .p2align 4,,7               /* это директива выравнивания */
35 : .L1:
36 :     ret                         /*возврат из main, готово */
37 : .Lfe1:
38 :     /* Другие ассемблерные директивы, которые можно проигнорировать */
39 :     .size    main,.Lfe1-main
40 :     .ident  "GCC: (GNU) egcs-2.91.66 19990314/Linux (egcs-1.1.2 release)"
41 : /* конец сгенерированного ассемблерного файла */
42 : /* ---------------------------------------------------------------- */

В случае, если вы знакомы с архитектурой 80386 или более новых микропроцессоров Intel и имеете опыт программирования на ассемблере, вы обнаружите, что ассемблерный листинг понятен вам только частично. Этот ассемблер следует синтаксису ассемблера AT&T который отличается от синтаксиса ассемблера Intel/Microsoft Assembler/Turbo Assembler, с которым знакомы большинство из нас. Давайте пройдемся по ассемблерному коду. Мы будем игнорировать директивы ассемблера, так как они не критичны для понимания. В строке 20 был определен сегмент данных только для чтения, содержащий строку "Hello, World\n". Строке была присвоена метка .LCO. На строке 27, начинается код функции main(). Как мы знаем, в C параметры, передаваемые функциям, помещаются в стек, причем в обратном порядке, т.е. последний параметр "заталкивается" в стек первым. В нашем случае, функция printf() вызывается с единственным параметром, строкой "Hello, World\n". Выражение pushl $.LC0 помещает в стек адрес строки "Hello, World\n". l в pushl означает "длинный", так как мы имеем дело с 32 битными переменными. Во всех ассемблерным программах, которые вы увидите, команды будут дополнены "l", чтобы показать то, что они работают с 32 битными переменными. "$" перед .LCO означает адрес строки. Следующее выражение вызывает функцию printf. После printf() функция завершает выполнение, мы должны очистить стек, поэтому нужно добавить 4 к ESP. (Почему 4? Потому что перед вызовом printf мы поместили в стек 4 байта.) Чтобы сделать это, мы обычно пишем, ADD ESP, 4. Согласно синтаксису Intel: <instruction> dest, src. В синтаксисе AT&T это <instruction> src, dest, поэтому вы можете увидеть что инструкция на 30 строке выглядит как addl $4, %esp. Непосредственные операнды вроде 4, снабжаются префиксом $, а имена регистров имеют префикс %. Этим соглашениям нужно следовать для того, чтобы добиться совместимости с BSD-ассемблером. Следующее выражение производит логическую операцию XOR (исключающее "ИЛИ") над регистрами EAX и EAX, таким образом EAX становиться равен 0. Это необходимо поскольку значение, возвращаемое функцией, сохраняется в регистре EAX. После этого вы увидете инструкцию безусловного перехода и директиву выравнивания. Это не объясняется, достаточно знать, что функция main() возвращается в эту точку. Теперь, когда мы понимаем, как выглядит код на языке ассемблера, давайте перейдем к оптимизациям.

3. Сворачивание констант

Сворачивание констант [constant folding] -- это самая простая для понимания оптимизация. Предположим, что вы пишете в программе на C выражение вида x = 45*88;. Не-оптимизирующий компилятор сгенерирует код для умножения 45*88 и сохранит значение в x. Оптимизирующий компилятор обнаружит, что 45 и 88 являются константами, так что их произведение также будет константой. Следовательно он вычислит 45*88=3960 и сгенерирует код, который просто копирует в x значение 3960. Это называется Сворачиванием констант, и означает, что вычисления производятся лишь один раз, на этапе компиляции, а не при каждом запуске программы. Чтобы проиллюстрировать, создайте файл test2.c как показано ниже. Сгенерируйте для этой программы ассемблерный код, как было указано ранее. Т.к. по умолчанию GCC не оптимизирует программу, вы увидете, что ассемблерный код представляет собой прямолинейное преобразование C программы в машинный код (Строки с 18 по 62).


 1 : /* test2.c */
 2 : /* Демонстрация сворачивания констант */
 3 : #include <stdio.h>
 4 :
 5 : int main()
 6 : {
 7 :     int x, y, z;
 8 :     x = 10;
 9 :     y = x + 45;
10 :     z = y + 4;
11 :     printf("The value of z = %d", z);
12 :     return 0;
13 : }
14 : /* end of test2.c */
15 :
16 : /* ---------------------------------------------------------------- */
17 : /* ассемблерный файл без оптимизации */
18 :     .file   "test2.c"
19 :     .version    "01.01"
20 : gcc2_compiled.:
21 : .section    .rodata
22 : .LC0:
23 :     .string "The value of z = %d"
24 : .text
25 :     .align 4
26 : .globl main
27 :     .type    main,@function
28 : main:
29 :     pushl %ebp             /* сохраним в стеке регистр EBP  */
30 :     movl %esp,%ebp         /* EBP = ESP */
31 :     subl $12,%esp          /* Создадим стековый фрейм для 3 переменных по 4 байта */
32 :
33 :     /* x = 10; */
34 :     movl $10,-4(%ebp)      /* x = 10. x на вершине стека */
35 :
36 :     /* y = x + 45; */
37 :     movl -4(%ebp),%edx     /* EDX = x */
38 :     addl $45,%edx          /* EDX = EDX + 45 */
39 :     movl %edx,-8(%ebp)     /* y = EDX. y вторая от вершины стека */
40 :
41 :     /* z = y + 4 */
42 :     movl -8(%ebp),%edx     /* EDX = y */
43 :     addl $4,%edx           /* EDX = EDX + 4 */
44 :     movl %edx,-12(%ebp)    /* z = EDX. z третья от вершины стека */
45 :
46 :     /* printf("The value of z = ", z); */
47 :     movl -12(%ebp),%eax    /* EAX = z */
48 :     pushl %eax             /* push EAX(=z) как первый параметр printf */
49 :     pushl $.LC0            /* второй параметр для printf */
50 :     call printf
51 :     addl $8,%esp           /* очистить стек */
52 :
53 :     /* return 0; */
54 :     xorl %eax,%eax         /* чтобы вернуть 0 */
55 :     jmp .L1
56 :     .p2align 4,,7
57 : .L1:
58 :     leave
59 :     ret
60 : .Lfe1:
61 :     .size    main,.Lfe1-main
62 :     .ident  "GCC: (GNU) egcs-2.91.66 19990314/Linux (egcs-1.1.2 release)"
63 : /* конец ассемблерного кода */
64 : /* ---------------------------------------------------------------- */
65 :
66 : /* ---------------------------------------------------------------- */
67 : /* сгенерированный код на языке ассемблера, после оптимизации */
68 :
69 :     .file   "test2.c"
70 :     .version    "01.01"
71 : gcc2_compiled.:
72 : .section    .rodata
73 : .LC0:
74 :     .string "The value of z = %d"
75 : .text
76 :     .align 4
77 : .globl main
78 :     .type    main,@function
79 : main:
80 :     pushl %ebp             /* Сохраним в стеке регистр EBP  */
81 :     movl %esp,%ebp         /* EBP = ESP */
82 :
83 :     /* благодаря "распространению" констант [constant propagation], z всегда будет равен 59 */
84 :     /* printf("The value of z = %d", z); */
85 :     pushl $59              /* первый параметр printf */
86 :     pushl $.LC0            /* второй параметр printf */
87 :     call printf
88 :                            /* нет необходимости в очистке стека, мы все равно выходим */
89 :     /* return 0; */
90 :     xorl %eax,%eax
91 :     leave
92 :     ret
93 : .Lfe1:
94 :     .size    main,.Lfe1-main
95 :     .ident  "GCC: (GNU) egcs-2.91.66 19990314/Linux (egcs-1.1.2 release)"
96 : /* конец кода на языке ассемблера */
97 : /* ----------------------------------------------------------------- */

В ассемблерном коде есть несколько моментов, которые нам нужно понять. Во первых, main() определяет 3 локальных переменных, память под которые выделяется в стеке. Также, чтобы получить доступ к этим переменным, мы будем использовать индексный режим адресации [indexed addressing mode] с помощью регистра базы EBP. Поэтому первое выражение сохраняет текущее значение EBP в стеке. После этого указатель стека ESP копируется в EBP (заметьте, что "источник" ESP стоит первым), так что EBP может использоваться для доступа к локальным переменным. В завершение нам необходимо создать пространство для трех четырехбайтовых переменных в стеке, поэтому мы вычитаем из ESP 12, т.е., мы наращиваем стек на 12 байт. x будет самым нижним элементом стека по смещению -4 байта от EBP. Посмотрите на диаграмму приведенную ниже.


             EBP-----> ---------------
                       |      x      |       4 Байта
                       ---------------
                       |      y      |       4 Байта
                       ---------------
                       |      z      |       4 Байта
             ESP-----> ---------------
                       |             |
                       |             |
                   ^   |             |
                   |         ...
Адрес увеличивается|   |             |
когда мы идем      | 0 ---------------
вверх

Аналогично, смещение y относительно EBP = -8, а смещение z = -12. Теперь обдумайте операцию присвоения x = 10;. Реализующее его выражение находится в 34 строке: movl $10, -4(%ebp). Индексный режим адресации записывается как <смещение>(<базовый регистр>). Поэтому приведенное выше выражение скопирует константу 10, по адресу (EBP - 4), т.е. по адресу x в стеке. В аналогичной манере, -8(%ebp) используется для доступа к y и -12(%ebp) используется для доступа к z. Строки 37,38 и 39 выполняют действие x+45 и присваивают результат y. Чтобы сделать это, значение x сначала копируется в регистр EDX, 45 добавляется к нему и результат, находящийся в EDX, копируется в y. Код для z=y+4 аналогичен приведенному. В строке 47, параметры для printf() помещаются в стек. Последний параметр (z) помещается в стек первым и после этого в стек помещается адрес строки. В строке 51, мы очищаем стек, путем добавления значения 8 к ESP. Я думаю, что так как мы "затолкали" в стек два 4-х байтовых параметра, мы должны прибавить к ESP 8. В первом примере, мы поместили только один аргумент, и, поэтому, должны были прибавить к ESP только 4. Остальной код понять легко.

Теперь, если мы взглянем на приведенную выше программу, когда мы выполняем x+45, значение x всегда будет равно 10 из за присваивания x=10. Поэтому x+45 всегда будет равно 55. Итак, выражение всегда присваивает y значение 55. Аналогично, когда выполняется операция y + 4, результат всегда будет равен 59. Если включить оптимизацию, то компилятор будет способен это обнаружить. Чтобы активировать оптимизацию, используйте ключ -O2. Введите следующую команду:

gcc -c -S -O2 test2.c

Ассемблерный код, сгенерированный с оптимизацией, показан в строках с 69 по 95. Заметьте, что на строке 85 компилятор просто помещает в стек константу 59, а затем (после неё) адрес переменной, и вызывает printf(). Поэтому, при помощи сворачивания констант, различные выражения в программе вычисляются компилятором только 1 раз и итоговое значение вставляется в создаваемый код. Еще одна интересная вещь: после сворачивания констант нет необходимости в переменных x, y и z. Поэтому для них не выделяется место в стеке, что уменьшает требования программы к памяти. Это показывает, как выполнение одной оптимизации влечет за собой другую. В приведенном выше случае сворачивание констант приводит к уменьшению времени исполнения (так как все вычисления производятся на этапе компиляции), одновременно уменьшая требования программы к памяти.

4. Устранение повторяющихся подвыражений

Часто бывает так, что одно и то же выражение вычисляется в разных местах программы, а значение операндов выражения не изменяются в промежутке между выполнением двух участков кода. Например, программа может вычислять, скажем a*b в начале и в конце. Если значения a и b не изменяются между этими двумя вычислениями a*b , то, вместо вычисления a * b в конце, мы можем сохранить результат полученный при первом вычислении a * b во временной переменной и использовать в последствии. Это позволит исключить лишние вычисления в программе. Такая оптимизация называется устранение повторяющихся подвыражений. Рассмотрим следующую программу, демонстрирующую этот прием.


 1 : /* test3.c */
 2 : /* устранение повторяющихся подвыражений и сворачивание констант */
 3 : #include <stdio.h>
 4 :
 5 : int main()
 6 : {
 7 :     int a, b;
 8 :     int x, y, z;
 9 :     scanf("%d %d", &a, &b);
10 :     x = a * b;
11 :
12 :     if(b >= 4)
13 :     {
14 :         y = a * b;
15 :         z = 0;
16 :     }
17 :     else
18 :     {
19 :         z = a * b * 4;
20 :         y = 0;
21 :     }
22 :
23 :     printf("x = %d, y = %d, z = %d\n", x, y, z);
24 :     return 0;
25 : }
26 : /* конец test3.c */
27 :
28 : /* ----------------------------------------------------------------- */
29 : /* неоптимизированный ассемблерный код */
30 :     .file   "test3.c"
31 :     .version    "01.01"
32 : gcc2_compiled.:
33 : .section    .rodata
34 : .LC0:
35 :     .string "%d %d"
36 : .LC1:
37 :     .string "x = %d, y = %d, z = %d\n"
38 : .text
39 :     .align 4
40 : .globl main
41 :     .type    main,@function
42 : main:
43 :     pushl %ebp                  /* сохраним EBP */
44 :     movl %esp,%ebp              /* EBP = ESP */
45 :     subl $20,%esp               /* Создадим место для 5 переменных */
46 :
47 :     /* scanf("%d %d". &a, &b); */
48 :     leal -8(%ebp),%eax
49 :     pushl %eax                  /* помещаем в стек адрес b */
50 :     leal -4(%ebp),%eax
51 :     pushl %eax                  /* помещаем в стек адрес a */
52 :     pushl $.LC0                 /* помещаем в стек адрес строки */
53 :     call scanf
54 :     addl $12,%esp               /* очистка стека после scanf */
55 :
56 :     /* x = a * b; */
57 :     movl -4(%ebp),%eax          /* EAX = a */
58 :     imull -8(%ebp),%eax         /* EAX = EAX * b = a * b */
59 :     movl %eax,-12(%ebp)         /* x = EAX = a * b */
60 :
61 :     /* if( b >= 4)... */
62 :     cmpl $3,-8(%ebp)            /* сравним b и 3 */
63 :     jle .L2                     /* блок else по метке .L2, сначала блок if */
64 :
65 :     /* y = a * b; */
66 :     movl -4(%ebp),%eax          /* EAX = a */
67 :     imull -8(%ebp),%eax         /* EAX = EAX * b = a * b */
68 :     movl %eax,-16(%ebp)         /* y = EAX = a * b */
69 :     /* z = 0; */
70 :     movl $0,-20(%ebp)
71 :     jmp .L3                     /* перескочим через блок else */
72 :
73 :     .p2align 4,,7
74 : .L2:
75 :     /* блок else начинается здесь */
76 :
77 :     /* z = a * b * 4; */
78 :     movl -4(%ebp),%eax          /* EAX = a */
79 :     imull -8(%ebp),%eax         /* EAX = EAX * b = a * b */
80 :     leal 0(,%eax,4),%edx        /* EDX = EAX*4 + 0 */
81 :     movl %edx,-20(%ebp)         /* z = EDX */
82 :     /* y = 0; */
83 :     movl $0,-16(%ebp)
84 : .L3:
85 :     /* блок if..else заканчивается здесь */
86 :
87 :     /* printf("x = %d, y = %d, z = %d\n", x, y, x); */
88 :     movl -20(%ebp),%eax
89 :     pushl %eax                  /* адрес z в стеке */
90 :     movl -16(%ebp),%eax
91 :     pushl %eax                  /* адрес y в стеке */
92 :     movl -12(%ebp),%eax
93 :     pushl %eax                  /* адрес x в стеке */
94 :     pushl $.LC1                 /* address of string */
95 :     call printf
96 :     addl $16,%esp               /* очистка стека после printf */
97 :
98 :     /* возвращаем 0 */
99 :     xorl %eax,%eax
100 :     jmp .L1
101 :     .p2align 4,,7
102 : .L1:
103 :     leave
104 :     ret
105 : .Lfe1:
106 :     .size    main,.Lfe1-main
107 :     .ident  "GCC: (GNU) egcs-2.91.66 19990314/Linux (egcs-1.1.2 release)"
108 : /* конец неоптимизированного ассемблерного кода */
109 : /* --------------------------------------------------------------- */
110 :
111 : /* --------------------------------------------------------------- */
112 : /* оптимизированный ассемблерный код */
113 :     .file   "test3.c"
114 :     .version    "01.01"
115 : gcc2_compiled.:
116 : .section    .rodata
117 : .LC0:
118 :     .string "%d %d"
119 : .LC1:
120 :     .string "x = %d, y = %d, z = %d\n"
121 : .text
122 :     .align 4
123 : .globl main
124 :     .type    main,@function
125 : main:
126 :     pushl %ebp               /* сохраним EBP */
127 :     movl %esp,%ebp           /* EBP = ESP */
128 :     subl $8,%esp             /* место в стеке только для 2-х переменных */
129 :
130 :     /* scanf("%d %d", &a, &b); */
131 :     leal -4(%ebp),%eax
132 :     pushl %eax               /* помещаем в стек адрес b */
133 :     leal -8(%ebp),%eax
134 :     pushl %eax               /* помещаем в стек адрес a */
135 :     pushl $.LC0              /* адрес строки */
136 :     call scanf
137 :
138 :     /* x = a * b; */
139 :     movl -4(%ebp),%eax       /* EAX = b */
140 :     movl %eax,%edx           /* EDX = EAX = b */
141 :     imull -8(%ebp),%edx      /* EDX = EDX * a = b * a = a * b */
142 :
143 :     addl $12,%esp            /* отложенная очистка стека */
144 :     /* if( b >= 4).... */
145 :     cmpl $3,%eax             /* сравним EAX = b с 3 */
146 :     jle .L17                 /* блок else начинается с метки .L17 */
147 :
148 :                              /* y сохранено в ECX, z в EAX, x в EDX */
149 :     /* y = a * b; */
150 :     movl %edx,%ecx
151 :     /* z = 0; */
152 :     xorl %eax,%eax
153 :     jmp .L18                 /* перепрыгнем через блок else */
154 :     .p2align 4,,7
155 : .L17:
156 :     /* z = a * b * 4; */
157 :     leal 0(,%edx,4),%eax     /* LEA EAX, [EDX*4]+0 */
158 :     /* y = 0; */
159 :     xorl %ecx,%ecx
160 : .L18:
161 :     pushl %eax               /* значение z в стеке*/
162 :     pushl %ecx               /* значение y в стеке */
163 :     pushl %edx               /* значение x в стеке*/
164 :     pushl $.LC1              /* помещаем в стек адрес строки */
165 :     call printf
166 :     /* очистка стека после printf необязательна */
167 :
168 :     /* возвращаем 0; */
169 :     xorl %eax,%eax
170 :     leave
171 :     ret
172 : .Lfe1:
173 :     .size    main,.Lfe1-main
174 :     .ident  "GCC: (GNU) egcs-2.91.66 19990314/Linux (egcs-1.1.2 release)"
175 : /* конец оптимизированного ассемблерного кода */
176 : /* -------------------------------------------------------------- */

Эта программа содержит пример повторяющегося подвыражения. На строке 10, выражение a * b выполняется в первый раз,а затем еще два раза в строках 14 и 19. Последние два вычисления a * b излишни, так как значения a или b не изменялись после первого исполнения. Эти два вычисления -- 'повторяющиеся подвыражения', которые могут быть удалены. Строки листинга с 30 по 108 показывают не-оптимизированный ассемблерный код, который является прямым переводом кода на C и легко читается. Теперь взгляните на оптимизированный ассемблерный код в строках с 113 по 176. Первое, что нужно заметить -- теперь в стеке сохраняются только переменные a и b, следовательно, вместо 20 байтов стека в неотимизированной версии требуется только 8. Вы можете удивиться, что такого особенного в переменных a и b. Их уникальность состоит в том, что адреса переменных a и b используются в программе (для scanf()), а переменные, которые находятся в регистрах, не могут иметь адрес в памяти. Следовательно компилятор не может поместить их в регистры. Регистры которые компилятор выбрал для других переменных: x в EDX, y в ECX и z в EAX. Выражения на строках с 139 по 141 исполняют операцию a * b и сохраняют значение в EDX, который содержит x. Также, значение b доступно в регистре EAX. Нужно также отметить отложенную очистку стека после вызова scanf на строке 143. Возможно это дает какие-то преимущества, я не знаю точно.

Дальше начинается блок if. В блоке if выражение a * b выполняется еще раз и мы ожидаем, что компилятор будет использовать значение сохраненное в EDX. Это именно то, что он делает. Для выражения y = a*b, сгенерированный код на строке 150 просто копирует значение a*b из регистра EDX, в регистр ECX (который содержит y). В блоке else, опять встречается под-выражение в выражении z = a * b * 4. Поэтому мы ожидаем, что компилятор возмет значение a * b из EDX, умножит его на 4 и затем сохранит результат в EAX (который содержит z). Есть несколько возможностей сделать это. Одна из них -- использовать последовательность movl, imull, следующим образом

 movl %edx, %eax         /* EAX = EDX  = a * b */
imull $4, %eax          /* EAX = EAX * 4 */

Другой вариант -- использовать в вышеприведенной последовательности инструкций сдвига (sall) вместо imull (сдвиг на один двоичный разряд вслево - shift left или shl - эквивалентно умножению на два, но выполняется быстрее. -- Прим. редактора). Но, оказывается, GCC использует для оптимизации кода не совсем понятный прием. Он использует инструкцию


    leal 0(,%edx,4), %eax

Эта инструкция использует возможности масштабирования и индексации адресов, имеющиеся у процессоров семейства 80386. Вышеприведенная инструкция использует регистр EDX в качестве индексного, 4 в качестве масштаба и 0 как смещение, расчитывает 'эффективный адрес' и сохраняет результат вычислений в регистре EAX. В результате регистр EAX получает значение EDX(=a*b)*4+0 т.е. a * b * 4. Инструкция leal (load effective address -- Прим. редактора) выполняется на 80386 всего за два такта, в то время, как простейший сдвиг займет около 7 тактов. Мы видим, что компилятор знает о особенностях процессора и использует их при генерации кода.

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

5. Устранение "мертвого" кода.

"Мертвый" код -- это такой код программы, который не будет выполнен никогда, ни при каких входных данных или условиях. Наиболее типичный пример - выражение if. Если компилятор обнаруживает, что условия, указанные в операторе if никогда не будут истинными, то тело оператора if никогда не будет выполняться. В этом случае компилятор может полностью удалить такой 'мертвый' код, тем самым уменьшая место, занимаемое программой в памяти. Следующий пример демонстрирует устранение 'мертвого' кода.


 1 : /* test4.c */
 2 : /* Демонстрация устранение мертвого кода */
 3 : #include <stdio.h>
 4 :
 5 : int main()
 6 : {
 7 :     int x;
 8 :
 9 :     scanf("%d", &x);
10 :
11 :     if(x < 0 && x > 0)
12 :     {
13 :         x = 99;
14 :         printf("Hello. Inside the if!!!");
15 :     }
16 :
17 :     return 0;
18 : }
19 : /* конец test4.c */
20 :
21 : /* --------------------------------------------------------------- */
22 : /* Оптимизированный ассемблерный код */
23 :     .file   "test4.c"
24 :     .version    "01.01"
25 : gcc2_compiled.:
26 : .section    .rodata
27 : .LC0:
28 :     .string "%d"
29 : .LC1:
30 :     .string "Hello. Inside the if!!!"
31 : .text
32 :     .align 4
33 : .globl main
34 :     .type    main,@function
35 : main:
36 :     pushl %ebp             /* сохраним EBP в стеке */
37 :     movl %esp,%ebp         /* EBP = ESP */
38 :     subl $4,%esp           /* создадим пространство для x в стеке */
39 :
40 :     /* scanf("%d", &x); */
41 :     leal -4(%ebp),%eax
42 :     pushl %eax             /* поместим  адрес a в стек */
43 :     pushl $.LC0            /* поместим строку в стек */
44 :     call scanf
45 :
46 :     /* всё тело if и условие, это мертвый код */
47 :     /* return 0; */
48 :     xorl %eax,%eax         /* нет очистки стека, мы все равно выходим*/
49 :     leave
50 :     ret
51 : .Lfe1:
52 :     .size    main,.Lfe1-main
53 :     .ident  "GCC: (GNU) egcs-2.91.66 19990314/Linux (egcs-1.1.2 release)"
54 : /* конец оптимизированного ассемблерного кода */
55 : /* ---------------------------------------------------------------- */

Так как неоптимизированный ассемблерный код ничего не делает и легок в понимании, то я опустил его. Условие в строке 11 (x < 0 && x > 0) никогда не может быть правдой. Компилятор обнаруживает это и делает вывод, что тело оператора if формирует 'мертвый' код, поэтому не создает для него ничего. Обратите внимание, что после того, как тело if было "уничтожено", строка "Hello. Inside the if!!!" больше не нужна и поэтому может быть убрана из секции данных только для чтения. Выявление подобных возможностей, однако, может усложнить компилятор и поэтому не используется.

6. Снижение стоимости выполнения с использованием индуцированной переменной

Один из типов оптимизации кода -- это снижение стоимости выполнения [strength reduction], в котором "дорогая" операция заменяется на более дешевую (Хороший пример был приведен выше, когда умножение на степень 2 заменялось сдвигом или даже адресным вычислением! -- Прим. редактора). Например, выполнение возведения в квадрат x^2 более эффективно производить путем умножения x на x, вместо того, чтобы вызывать функцию вычисления экспоненты. Одно из возможных применений такой оптимизации -- циклы. Часто в цикле какая-либо переменная изменяется синхронно с управляющей переменной цикла. Такая переменная называется индуцированной переменной [Induced Variable]. Индукция переменной дает компилятору шанс применить снижение стоимости выполнения, что можно видеть в следующей программе.


 1 : /* test5.c */
 2 : /* демонстрация уничтожения индукции переменной */
 3 : int main()
 4 : {
 5 :     int i, j;
 6 :
 7 :     for(i = 0; i < 10; i++)
 8 :     {
 9 :         j = i * 7;
10 :         printf("i = %d, j = %d", i, j);
11 :     }
12 :     return 0;
13 : }
14 : /* end test5.c */
15 :
16 : /* --------------------------------------------------------------- */
17 : /* оптимизированный ассемблерный код */
18 :     .file   "test5.c"
19 :     .version    "01.01"
20 : gcc2_compiled.:
21 : .section    .rodata
22 : .LC0:
23 :     .string "i = %d, j = %d"
24 : .text
25 :     .align 4
26 : .globl main
27 :     .type    main,@function
28 : main:
29 :     pushl %ebp                  /* сохраним EBP в стеке */
30 :     movl %esp,%ebp              /* ESP = EBP */
31 :
32 :     pushl %esi                  /* ESI будет содержать 'j' */
33 :     pushl %ebx                  /* EBX будет содержать 'i' */
34 :     xorl %ebx,%ebx              /* обе сбрасываются в 0 */
35 :     xorl %esi,%esi
36 :     .p2align 4,,7
37 : .L5:
38 :     /* printf("i = %d, j = %d", i, j); */
39 :     pushl %esi                  /* поместим в стек значение j */
40 :     pushl %ebx                  /* поместим в стек значение i */
41 :     pushl $.LC0                 /* поместим в стек адрес строки */
42 :     call printf
43 :     addl $12,%esp               /* очистка стека */
44 :
45 :     /* вместо использования j = i * 7, эффективней будет использовать j = j + 7 */
46 :     addl $7,%esi
47 :     incl %ebx                   /* i++ */
48 :     cmpl $9,%ebx                /* проверяем i <= 9, если нет повторяем цикл */
49 :     jle .L5
50 :
51 :     /* return 0; */
52 :     xorl %eax,%eax
53 :     leal -8(%ebp),%esp
54 :     popl %ebx
55 :     popl %esi
56 :     leave
57 :     ret
58 : .Lfe1:
59 :     .size    main,.Lfe1-main
60 :     .ident  "GCC: (GNU) egcs-2.91.66 19990314/Linux (egcs-1.1.2 release)"
61 :
62 : /* конец оптимизированного ассемблерного кода */
63 : /* ----------------------------------------------------------------- */

Здесь i -- управляющая переменная цикла, тогда как j -- индуцированная переменная, так как j изменяется в жесткой взаимосвязи с i. В сгенерированном ассемблерном коде компилятор использует регистры для хранения как i, так и j: i в EBX, а j в ESI. Строка 34 сбрасывает EBX(=i) в ноль, как указано в начальных условиях оператора цикла. Компилятор видит, что во время первого прохода, j будет присвоено значение 0, поэтому в строке 35 он также сбрасывает в 0 регистр ESI. Цикл начинается в строке 37 и продолжается до строки 49. Т.к. значение j уже присвоено, то первое, что нужно сделать компилятору внутри цикла -- вызвать функциюprintf(). Здесь компилятор изменил порядок следования выражений так, чтобы стало возможным использовать снижение стоимости выполнения инструкций. После анализа программы компилятор видит, что внутри цикла, значение i всегда будет увеличиваться на 1 и т.к. j -- переменная, 'индуцированная' переменной i, ее значение всегда будет увеличиваться на 7. В связи с этим вместо умножения i на 7 (что 'дорого' в циклах процессора), перед тем, как приступить к следующей итерации GCC добавляет 7 к старому значению j. Таким образом дорогая операция умножения была заменена сложением, что дешевле (в терминах времени выполнения).

7. Заключение

В этой статье, мы познакомились с некоторыми простейшими приемами, используемыми компилятором GNU C для оптимизации создаваемого им кода. На основании знания этих приемов и информации, необходимой для их применения, читатель может оценить то, сколь сложный и изощренный анализ программы должен быть проведен компилятором. Компилятор должен следить за различными аспектами выполнения программы: переменными, местом их хранения (в памяти или в регистрах), вычислением выражений, константными выражениями, 'мертвым' кодом и так далее. Из за сложности этого процесса, компиляция программы с включенной оптимизацией занимает значительно больше времени. Есть еще множество деталей, касающихся оптимизации и генерации кода, которые я не стал объяснять и которые я не знаю. Оптимизация кода предоставляет широкое поле исследований и заинтересованные читатели могут обратиться к [1] за дополнительной информацией.

8. Благодарности

Я хочу поблагодарить руководителя моего проекта в Bachelor, доктора Uday Khedker за то, что он пробудил во мне интерес к компиляторам и оптимизации кода. Также я хочу сказать спасибо Linux Gazette, Linux Documentation Project, PC Quest и Pune Linux User's Group (http://www.pluggies.org/) которые ранее приняли и опубликовали мои статьи, что воодушевило меня на то, чтобы писать еще.

9. Ссылки

  1. Compilers: Principles, Techniques, and Tools, A.V.Aho, Ravi Sethi and J.D.Ullman, Addison Wesley.
  2. Systems Programming and Operating Systems, D.M.Dhamdhere, Tata McGraw-Hill.
  3. Assembly HOWTO, Franois-Ren Rideau, Linux Documentation Project.
  4. Advanced 80386 Programming Techniques, James L. Turley, Osborne McGraw-Hill.

С недавнего времени я работаю в Veritas Software India Pvt. Ltd. в качестве программиста [Associate Software Engineer]. Во время получения степени бакалавра участвовал в исследовательском проекте "Node Listing Based Global Data Flow Analysis". Интересуюсь компиляторами, языками программирования, операционными системами и парралельной/распределенной обработкой. Я публиковал статьи в Linux Gazette о паралельной обработке и об оптимизации в C.
Copyright (C) 2001, Rahul U Joshi.
Copying license http://www.linuxgazette.com/copying.html
Published in Issue 71 of Linux Gazette, October 2001