В начало → Изучаем параметры gcc |
Ссылка на оригинал: Getting Familiar with GCC Parameters
Ссылка на перевод: http://netsago.org/ru/docs/1/9/
С версии: 1.5
Перевод: n0xi0uzz
Перевод статьи «Getting Familiar with GCC Parameters», автор — Mulyadi Santosa
gcc (GNU C Compiler) — набор утилит для компиляции, ассемблирования и компоновки. Их целью является создание готового к запуску исполняемого файла в формате, понимаемом вашей ОС. Для Linux, этим форматом является ELF (Executable and Linking Format) на x86 (32- и 64-битных). Но знаете ли вы, что могут сделать для вас некоторые параметры gcc? Если вы ищете способы оптимизации получаемого бинарного файла, подготовки сессии отладки или просто наблюдать за действиями, предпринимаемыми gcc для превращения вашего исходного кода в исполняемый файл, то знакомство с этими параметрами обязательно. Так что, читайте.
Напомню, что gcc делает несколько шагов, а не только один. Вот небольшое объяснение их смысла:
Препроцессирование: Создание кода, более не содержащего директив. Вещи вроде «#if» не могут быть поняты компилятором, поэтому должны быть переведены в реальный код. Также на этой стадии разворачиваются макросы, делая итоговый код больше, чем оригинальный. [1]
Компиляция: Берется обработанный код, проводятся лексический и синтаксический анализы, и генерируется ассемблерный код. В течение этой фазы, gcc выдает сообщения об ошибках или предупреждениях в случае, если анализатор при парсинге вашего кода находит там какие-либо ошибки. Если запрашивается оптимизация, gcc продолжит анализировать ваш код в поисках улучшений и манипулировать с ними дальнейшем. Эта работа происходит в многопроходном стиле, что показывает то, что иногда требуется больше одного прохода по коду для оптимизации. [2]
Ассемблирование: Принимаются ассемблерные мнемоники и производятся объектные коды, содержащие коды команд. Часто недопонимают то, что на стадии компиляции не производятся коды команд, это делается на стадии ассемблирования. В результате получаются один или более объектных файла, содержащие коды команд, которые являются действительно машинозависимыми. [3]
Компоновка: Трансформирует объектные файлы в итоговые исполняемые. Одних только кодов операции недостаточно для того, чтобы операционная система распознала и выполнила их. Они должны быть встроены в более полную форму. Эта форма, известная как бинарный формат, указывает, как ОС загружает бинарный файл, компонует перемещение и делает другую необходимую работу. ELF является форматом по умолчанию для Linux на x86. [4]
Параметры gcc описаны здесь, прямо и косвенно затрагивая все четыре стадии, поэтому для ясности, эта статья построена следующим образом:
— Параметры, относящиеся к оптимизации
— Параметры, относящиеся к вызову функций
— Параметры, относящиеся к отладке
— Параметры, относящиеся к препроцессированию
Прежде всего, давайте ознакомимся с вспомогательными инструментами, которые помогут нам проникать в итоговый код:
— Коллекция утилит ELF, которая включает в себя такие программы, как objdump и readelf. Они парсят для нас информацию о ELF.
— Oprofile, один из стандартных путей подсчета производительности аппаратного обеспечения. Нам нужна эта утилита для просмотра нескольких аспектов производительности кода.
— time, простой способ узнать общее время работы программы.
Следующие инструкции могут быть применены в gcc версий 3.x и 4.x, так что они достаточно общие. Начнем копать?
gcc предоставляет очень простой способ производить оптимизацию:
опция -O. Она и ускоряет выполнение вашего кода, и сжимает размер итогового кода. У неё существует пять вариантов:
от -O0 (O ноль) до -O3. "0" означает отсутствие оптимизации, а "3" — наивысший уровень оптимизации. "1" и "2" находятся между этими краями. Если просто используете -O без указания номера, это будет означать -O1.
— -Os говорит gcc оптимизировать размер. В общем-то, это похоже на -O2, но пропускает несколько шагов, которые могут увеличить размер.
Какое ускорение в действительности можно от них получить? Что ж, предположим, у нас есть такой код:
#include int main(int argc, char *argv[]) { int i,j,k; unsigned long acc=0; for(i=0;i<10000;i++) for(j=0;j<5000;j++) for(k=0;k<4;k++) acc+=k; printf("acc = %lu\n",acc); return 0; }
С помощью gcc, создадутся четыре разных бинарных файла, используя каждый из -O вариантов (кроме -Os). Утилита time запишет их время исполнения, таким образом:
$
time ./non-optimized
Без оптимизации | -O1 | -O2 | -O3 | |
real |
0.728 |
0.1 |
0.1 |
0.1 |
user |
0.728 |
0.097 |
0.1 |
0.1 |
sys |
0.000 |
0.002 |
0.000 |
0.000 |
Для упрощения, будем использовать следующие обозначения:
— Non-optimized обозначает исполняемый файл, скомпилированный с опцией -O0.— OptimizedO1 обозначает исполняемый файл, скомпилированный с опцией -O1.— OptimizedO2 обозначает исполняемый файл, скомпилированный с опцией -O2.— OptimizedO3 обозначает исполняемый файл, скомпилированный с опцией -O3.
Как вы могли заметить, время выполнения программы, скомпилированной с -O1 в семь раз меньше, чем время выполнения программы, при компиляции которой не использовалась оптимизация. Обратите внимание, что нет большой разницы между -O1, -O2 и -O3, — на самом деле, они почти одинаковы. Так в чем же магия -O1?
После беглого изучения исходного кода, вы должны отметить, что такой код конечен для оптимизации. Прежде всего, давайте посмотрим на короткое сравнение дизассемблированных версий non-optimized и optimizedO1:
$ objdump -D non-optimized $ objdump -D optimizedO1
(Примечание: вы можете получить другие результаты, поэтому используйте эти как основные)
Non-optimized |
OptimizedO1 |
mov 0xfffffff4(%ebp)add %eax,0xfffffff8(%ebp)addl $0x1,0xfffffff4(%ebp)cmpl $0x3,0xfffffff4(%ebp) |
add $0x6,%edxadd $0x1,%eaxcmp $0x1388,%eax |
Приведенные примеры реализуют самый вложенный цикл (for (k=0;k<4;k++)). Обратите внимание на различие: неоптимизированный код напрямую загружает и хранит из адреса памяти, в то время как optimized01 использует регистры ЦПУ в качестве сумматора и счетчик цикла. Как вам, возможно, известно, доступ к регистрам может быть получен в сотни или тысячи раз быстрее, чем к ячейкам ОЗУ.Не удовлетворяясь простым использованием регистров ЦПУ, gcc использует другой трюк оптимизации. Давайте снова посмотрим дизассемблированный код optimizedO1 и обратим внимание на функцию main():
...... 08048390 : ... 80483a1: b9 00 00 00 00 mov $0x0,%ecx 80483a6: eb 1f jmp 80483c7 80483a8: 81 c1 30 75 00 00 add $0x7530,%ecx
0x7530 это 30000 в десятичной форме, поэтому мы можем быстро угадать цикл. Этот код представляет собой самый вложенный и самый внешний циклы (for(j=0;j<5000;j++) ... for(k=0;k<4;k++)), так как они являются буквальным запросом на 30000 проходов. Примите во внимание, что вам нужно всего лишь три прохода внутри. Когда k=0, acc остается прежним, поэтому первый проход можно проигнорировать.
80483ae: 81 f9 00 a3 e1 11 cmp $0x11e1a300,%ecx 80483b4: 74 1a je 80483d0 80483b6: eb 0f jmp 80483c7
Хмм, теперь это соответствует 300 000 000 (10 000*5 000*6). Представлены все три цикла. После достижения этого числа проходов, мы переходим прямо к printf() для вывода суммы (адреса 0x80483d0 - 0x80483db).
80483b8: 83 c2 06 add $0x6,%edx 80483bb: 83 c0 01 add $0x1,%eax 80483be: 3d 88 13 00 00 cmp $0x1388,%eax 80483c3: 74 e3 je 80483a8 80483c5: eb f1 jmp 80483b8
Шесть добавляется в сумматор при каждой итерации. В итоге, %edx будет содержать всю сумму после выполнения всех трех циклов. Третья и четвертая строки показывают нам, что после выполнения 5000 раз, должен быть переход к адресу 0x80483a8 (как указано ранее).
Мы можем заключить, что gcc создает здесь упрощение. Вместо прохода три раза в самый вложенный цикл, он просто добавляет шесть для каждого среднего цикла. Это звучит просто, но это заставляет вашу программу сделать только 100 000 000 проходов вместо 300 000 000. Это упрощение, называемое разворачиванием цикла, одно из тех задач, которые делают -O1/2/3. Конечно же, вы и сами можете это сделать, но иногда неплохо знать, что gcc может определить такие вещи и оптимизировать их.
С опциями -O2 и -O3 gcc тоже пытается произвести оптимизацию. Обычно она достигается посредством переупорядочивания [5] и трансформацией кода. Целью этой процедуры является устранить столько ошибочных ветвей, сколько возможно, что повышает качество использования конвейера. Например, мы можем сравнить, как non-optimized и optimizedO2 выполняет самый внешний цикл.
80483d4: 83 45 ec 01 addl $0x1,0xffffffec(%ebp) 80483d8: 81 7d ec 0f 27 00 00 cmpl $0x270f,0xffffffec(%ebp) 80483df: 7e c4 jle 80483a5
Бинарный файл non-optimized использует jle для выполнения перехода. Математически это означает, что вероятность выбора ветви 50%. С другой стороны, версия optimizedO2 использует следующее:
80483b4: 81 c1 30 75 00 00 add $0x7530,%ecx 80483ba: 81 f9 00 a3 e1 11 cmp $0x11e1a300,%ecx 80483c0: 75 e1 jne 80483a3
Теперь, вместо jle используется jne. При условии, что любое целое может быть сопоставлено в предыдущем cmp, нетрудно сделать вывод, что это увеличит шанс выбора ветви почти до 100%. Это небольшое, но полезное указание процессору для определения того, какой код должен быть выполнен. Хотя, для большинства современных процессоров, этот вид трансформации не является ужасно необходимым, так как предсказатель переходов достаточно умен для того, чтобы сделать это сам.
Для доказательства того, как сильно это изменение может помощь, к нам на помощь придет OProfile. Oprofile выполнен для записи числа изолированных ветвей и изолированных ошибочных ветвей. Изолированные здесь обозначает «выполненные внутри конвейера данных ЦПУ»
$
opcontrol --event=RETIRED_BRANCHES_MISPREDICTED:1000 --event=RETIRED_BRANCHES:1000;
Мы запустим non-optimized и optimizedO2 пять раз каждый. Затем мы возьмем максимум и минимум примеров. Мы посчитаем степень ошибки, используя эту формулу (выведена отсюда).
Степень ошибки = изолированные ошибочные ветви / изолированные ветви
Теперь вычислим степень ошибки для каждого бинарного файла. Для non-optimized получилось 0,5117%, в то время как optimizedO2 получил 0,4323% — в нашем случае, выгода очень мала. Фактическая выгода может различаться для реальных случаев, так как gcc сам по себе не может много сделать без внешних указаний. Пожалуйста, прочтите о __builtin_expect() в документации по gcc для подробной информации.
В начало → Изучаем параметры gcc |