Блог → Компилятор GCC: искусство оптимизации кода. Часть 4

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

GNU C расширяет возможности ANSI C, добавляя ключевое слово inline, имеющее то же значение, что и аналогичное ключевое слово в C++. Для C-программ указания inline выполняются компилятором только при включенном режиме оптимизации (опции -01, -02, -03). Не все функции, объявленные как inline, будут встроены компилятором. Решение о том, встраивать или не встраивать inline-функцию, принимается компилятором на основе её размеров. Опция -finline-limit=xxx позволяет установить значение порогового размера встраиваемых функций. По умолчанию значение опции -finline-limit равно 600. При увеличении этого значения число потенциально встраиваемых функций увеличится, при уменьшении - уменьшится4. Опция -fno-inline заставляет компилятор игнорировать ключевое слово inline в процессе оптимизации.

Опция -finline-functions, по умолчанию включаемая ключом -03, позволяет выполнять встраивание функций, не объявленных, как inline. При этом компилятор сам решает, какие функции должны быть встроены, а какие - нет, на основании критерия, заданного -finline-limit. Если все вызовы встраиваемой функции заменены ее кодом и функция объявлена с атрибутом static, компилятор удаляет саму функцию из объектного файла. Если вы хотите отменить удаление встроенных static-функций из объектного файла, используйте опцию -fkeep-inline-functions. Встраивание рекурсивных функций возможно только в том случае, если в процессе оптимизации эти функции были преобразованы в нерекурсивную форму.

Локальные методы оптимизации

Давайте рассмотрим методы оптимизации машинного кода, основанные на особенностях набора команд процессора. Общим для этих методов является то, что все они носят локальный характер. В потоке кода выделяются небольшие фрагменты, окна (англ. peepholes), включающие несколько (обычно 3-4) инструкций. Над каждым таким фрагментом выполняются различные оптимизирующие операции - замена нескольких инструкций одной, более эффективной, перестановка инструкций и т.п.

Примером локальной оптимизации может служить преобразование последовательности инструкций movl %еах, (%есх) movl (%есх), %six в последовательность movl %еах, (%есх) movl %еах, %edx, т.е. замена команды загрузки значения из памяти командой копирования значения регистра. Перестановка инструкций предназначена для сокращения времени ожидания выполнения и может быть особенно эффективна для инструкций математического сопроцессора и инструкций обращения к памяти. Основная группа этих локальных оптимизаций включается и выключается опциями -fpeephole/-fno-peephole (опция -fpeephole по умолчанию включается всеми командами -0х). Кроме опции -fpeephole компилятор GCC также предоставляет опцию -fpeephole2, которая выполняет ту же операцию, используя несколько иной алгоритм. Опция -fpeephole2 используется не на всех платформах, что, видимо, связано с особенностями компиляции компиляторов. Компилятор для платформы IA32 включает эту опцию при использовании ключа -02.

Ещё одна возможность локальной оптимизации связана с использованием инструкций, специфических для конкретного типа процессора. На платформе IA32 GCC по умолчанию генерирует код, полностью совместимый с процессором i386. Ниже перечислены опции компилятора, позволяющие указывать другой тип процессора IA32:
• -mcpu=cpu-type, где cpu-type -одно из значений: i386, i486, i586, i686, pentium, pentium-mmx, pentiumpro, pentium2, pentium3, pentium4, k6, k6-2, k6-3, athlon, athlon-tbird, athlon-4, athlon-xp и athlon-mp. Данная опция заставляет компилятор размещать инструкции в последовательности, оптимальной для указанного процессора, но при этом не генерируется никаких инструкций, не входящих в набор команд i386.
• -march=cpu-type, где cpu-type принимает те же значения, что и в предыдущем случае. Данная опция генерирует код, специфичный для указанного процессора. При включении этой опции по умолчанию включается и опция -mcpu.
• -mfpmath=unit, где unit принимает одно из значений: 387; sse; 387,sse. Позволяет указать, какие команды (команды сопроцессора, или команды SSE) следует использовать при выполнении операций с плавающей точкой. Значение 387,sse заставляет компилятор использовать сразу оба варианта, выбирая оптимальный для каждого конкретного случая.
• -malign-double заставляет компилятор выравнивать адреса переменных типа double по значениям, кратным четырем. Получающийся в результате код занимает несколько больший объем памяти, но выполняется быстрее на процессорах Intel Pentium и ему подобных.

Некоторые другие методы оптимизации

Мы с вами не успели рассмотреть ещё один, довольно важный вид оптимизации - удаление недоступного кода (англ. dead code elimination). Термином "недоступный код" обозначается последовательность инструкций, которая присутствует в программе, но никогда не выполняется. Таковыми, например, являются инструкции, которые должны выполняться при заведомо невыполнимом условии ветвления. Недоступный код может возникать в больших проектах по недосмотру программиста, однако иногда его можно использовать совершенно сознательно. Речь идет о вставке в программу специального отладочного кода. Эту задачу можно решить при помощи макросов:

#define _DEBUG
...
#ifdef _DEBUG
...
#endif


Однако у такого подхода имеются и свои недостатки. Так, если закомментировать объявление _DEBUG, получится, что код заключённый между #ifdef и #endif, вообще не будет анализироваться компилятором, в частности - не будет выполняться проверка его синтаксиса. В связи с этим K&R советуют использовать для включения отладочного кода не макросы, а
блоки if (...) {...} (разумеется, при условии, что компилятор способен удалять недоступный код). Посмотрим, насколько данный метод эффективен при использовании GCC. Давайте вернёмся к функции sqroot и перепишем её следующим образом:

#define EPS le-6
enum {_DEBUG=0};

double sqroot(double x, double a) {
double b = (a + x/a)/2.;
if (_DEBUG)
printf("in sqroot: %d
", b);
if ({(b*b - x) < EPS) && (b*b - x) > -EPS)
return b;
return sqroot(x, b);
}


Что мы сделали в данном случае? Ввели в функцию отладочный фрагмент, выводящий на консоль значение переменной b. При включённом режиме удаления недоступных фрагментов, если значение DEBUG равно 0, отладочный код в скомпилированную программу включен не будет, хотя компилятор и проверит его синтаксис. Для того чтобы включить отладочный код в двоичный файл, необходимо присвоить _DEBUG какое-либо другое значение. Удаление недоступного кода, как и некоторые другие базовые методы оптимизации, включается опцией -fcommon (входит в состав всех опций -0х).

Ну и под конец, давайте рассмотрим ещё несколько методов оптимизации. Опция -ffast-math позволяет ускорить выполнение математического кода за счет нарушения правил ANSI/IEEE. Такой код не сможет корректно обрабатывать ситуации с некорректными аргументами математических функций и значениями пап. Используйте эту опцию, только если совместимость со стандартами ANSI/IEEE не важна для вас.

Опция -freg-struct-return заставляет компилятор возвращать значения типа "запись", используя регистры, во всех случаях, когда это возможно. Для уменьшения размеров программы можно воспользоваться опциями компоновщика, такими как опция -s команды Id, удаляющими различную служебную информацию (таблицы символов и т.п.)

Ключ -param позволяет настроить некоторые параметры самой подсистемы оптимизации. Эти параметры дают возможность установить подходящее соотношение между качеством оптимизации и временем, затрачиваемым на компиляцию программы. Например, выражение -param max-gcse-memo-rу=ххх позволяет установить максимальный объем памяти, выделяемый алгоритму глобального удаления повторяющихся подвыражений, а ключ -param max-gcse-pass-es=xxx - максимальное число проходов этого алгоритма (где ххх - целое число).

В заключение обзора мне остается лишь отметить, что не следует чрезмерно полагаться на все рассмотренные методы оптимизации. В конце концов, лучший оптимизатор программ - голова разработчика, и даже в тривиальных случаях можно выжать "last drop of performance". Ну и конечно, помните, что только вы можете выбрать наиболее оптимальный алгоритм, ведь, в конце концов, только вы знаете, что именно и как, должна делать ваша программа. Удачи в программировании!