Мощь и беспомощность автоматической оптимизации

         

Авто-параллелизм


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

Для оптимизации под многопроцессорные машины, следует разбивать циклы с большим количеством итераций на N циклов меньшего размера, помещая каждый из них в свой поток, где N— количество процессоров, обычно равное двум. Такая техника оптимизации называется авто-параллелизмом (auto-parallelization) и наглядно демонстрируется следующим примером:

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

       a[i] = a[i] + b[i] * c[i];



Что надо оптимизировать


Теперь поговорим о том, с чем оптимизирующие компиляторы не справляются и начинают буксовать, резко снижая эффективность целевого кода. Помочь им выбраться из болота— наша задача! Чип и Дейл уже спешат! Ну а мыщщъх вращает хвостом. Руководит, значит.

Начнем с функций. Из всех рассматриваемых компиляторов только Intel C++ поддерживает глобальную оптимизацию, а остальные — транслируют функции по отдельности, задействую "сквозную" оптимизацию только на встраиваемых (inline) функциях. Отсюда: чем выше степень дробления программы на функции (и чем меньше средний размер одной функции), тем ниже качество оптимизации, не говоря уже о накладных расходах на передачу аргументов, открытие кадра стека и т. д.

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

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



Что не надо оптимизировать


Начнем с того, что _не_ _надо_ оптимизировать, позволяя транслятору сделать это за нас (нехай делает). В частности, практически все оптимизирующие компиляторы умеют вычислять константы на стадии трансляции. В различных русскоязычных источниках этот прием оптимизации называется как "сверткой", так и "размножением" констант, что соответствует английским терминам "constant folding/propagation". Еще один английский термин из той же кучи: "constant elimination" (буквально— "изгнание констант"). Все это синонимы и описывают один и тот же механизм вычисления константных выражения (как целочисленных, так и вещественных), в результате чего a = 2 * 2

превращается в a = 4, а x = 4*y/2 в x = 2*y.

Побочным эффектом оптимизации становится потеря переполнения (если таковое имело место быть). С точки зрения математика выражения foo = bar/4*4 и foo = bar

полностью эквиваленты, но если переменные foo и bar

целые, то не оптимизированный вариант обнуляет два младших бита bar! Некоторые программисты умышленно используют этот примем, вместо того, чтобы воспользоваться "foo = bar & (~3)", а ругаются на "глючный" оптимизатор!

За исключением Intel C++ все рассматриваемые компиляторы поддерживают "улучшенную свертку констант" ("advanced constant folding/propagation"), заменяя все константные переменные их непосредственным значением, в результате чего выражение: a = 2; b = 2 * a; c = b ? a;



превращается в c = 2, а переменные a

и b

(если они нигде более не используются) уничтожаются.



Константная подстановка в условиях


В операторах ветвления ("if", "?" и "switch") константные условия встречаются редко и обычно являются следствием чрезмерного увлечение #define, вот, например, как здесь:

#define MAX_SIZE     1024

#define REQ_SIZE     512

#define HDR_SIZE     16

int a = REQ_SIZE + HDR_SIZE;

if (a <= MAX_SIZE) foo(a); else return ERR_SIZE;



переменные a и b — лишнее


После оптимизации переменные a и b исчезают, а return

возвращает значение выражения (2*n+1):

main(int n, char** v)

{

       return

2*n+1;

}



хвостовая рекурсия до оптимизации


Вызов функции — достаточно "дорогостоящая" (в плане процессорных тактов) операция и за исключением Intel C++ все рассматриваемые компиляторы трансформирует рекурсивный вызов в цикл:

for(i=0; i<n; i++) result *= n;



не оптимизированный вариант с инвариантом в теле цикла


Если только компилятор не заинлайнит функцию strlen, она будет вычисляется на каждой итерации цикла, что приведет к значительному снижению производительности. Но если вынести инвариант за пределы цикла, все будет ОК:

t = strlen(s);

for(a=0;a<t;a++) b+=s[a];



ненормализованный цикл


Алгоритм нормализации выглядит так:

for (NCL = 0; i < (to - from + step)/step  - 1; 1)

{

       i = step*NLC + from;

       // тело

цикла

}

i =  step * _max((to - from + step)/step, 0) + from;



цикл до разворота


for(i=1; i<n;i+=4)

{

       k += (n % i) +\

       (n % i+1) + \

       (n % i+2) + \

       (n % i+3);

}

// выполняем оставшиеся итерации

for(i=4; i<n;i++) k += (n % i);



не оптимизированный вариант


Поскольку зависимость по данным отсутствует, цикл можно разбить на два. Первый будет обрабатывать ячейки от 0 до XXL/2, а второй — от XXL/2 до XXL. Тогда на двухпроцессорной машине скорость выполнения цикла практически удвоится.

/* поток A */

for (i=0; i<XXL/2; i++)

       a[i] = a[i] + b[i] * c[i];

/* поток B */

for (i=XXL/2; i<XXL; i++)

       a[i] = a[i] + b[i] * c[i];



обработка массивов по столбцам (не оптимизированный вариант)


Здесь три массива обрабатываются по столбцам, что крайне непроизводительно и для достижения наивысшей эффективности циклы i и j следует поменять местами. Устоявшегося названия у данной методики оптимизации нет и в каждом источнике она называется по-разному: loop permutation/interchange/reversing,

rearranging array dimensions и т. д. Как бы там ни было, оптимизированный вариант выглядит так:

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

       for(j=0;j<m;j++)

              a[i][j] = b[i][j] + c[i][j];



пример с лишними обращениями к памяти, от которых нельзя избавиться


Компилятор не имеет права на размещение содержимого ячейки *a в регистровой переменной, поскольку если ячейки *a и *b

частично или полностью перекрываются, модификация ячейки *b

приводит к неожиданному изменению ячейки *a! Бред, конечно, но ведь Стандарт этого не запрещает, а компилятор _обязан_ следовать Стандарту, иначе, его место — на свалке.

Тоже самое относится и к следующему примеру:

f(char *x, int *dst, int n)

{

       int i;

       for (i = 0; i < n; i++) *dst += x[i];

}



не оптимизированный кандидат на регистровую ре-ассоциацию


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

int a[10][20][30];

void example (void)

{

       int i, j, k;

       register int (*p)[20][30];

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

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

                     for (p = (int (*)[20][30]) &a[0][j][k], i = 0; i < 10; i++)

                            *(p++[0][0]) = 1;

}



не оптимизированный вариант


За исключением Intel C++ все рассматриваемые компиляторы выполняют константную подстановку, оптимизируя код, избавляясь от ветвления и ликвидируя "мертвый код", который никогда не выполняется:

foo(528);



оптимизированный вариант


Выполнять эту оптимизацию вручную совершенно необязательно, поскольку оптимизированный листинг намного менее нагляден и совершенно негибок. По правилам этикета программирования, _все_ константы должны быть вынесены в #define, что значительно уменьшает число ошибок.



хвостовая рекурсия после оптимизации


Естественно, оптимизированный код менее нагляден, поэтому выполнять такое преобразование вручную — совершенно необязательно.



нормализованный цикл


Наибольшую отдачу нормализация дает на циклах с заранее известным количеством итераций, т. е. когда выражение (to ? from + step)/step

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

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



цикл, развернутый на 4 итерации (меньшей размер, большая скорость)


За исключением Microsoft Visual C++, все остальные рассматриваемые компиляторы умеют разворачивать циклы и самостоятельно, но… делают это настолько неумело, что вместо ожидаемого увеличения производительности сплошь и рядом наблюдается ее падение, поэтому автоматический разворот лучше сразу запретить и оптимизировать программу вручную, подбирая подходящую степень разворота опытным путем (вместе с профилировщиком).

Тут ведь как — чем сильнее разворот, тем больше места занимает код и появляется риск, что в кэш первого уровня он может вообще не влезть, вызывая обвальное падение производительности! (Подробнее о влиянии степени разворота на быстродействие можно прочитать в моей "технике оптимизации", электронная копию которой как обычно лежит на моем мыщъхином ftp://nezumi.org.ru).



оптимизированный вариант


Intel C++ – единственный из всех рассматриваемых компиляторов, поддерживающий технику авто-парализации, активируемую ключом –parallel, однако, качество оптимизации оставляет желать лучшего и эту работу лучше осуществлять вручную.



обработка массивов по столбцам (оптимизированный вариант)


Все рассматриваемые компиляторы поддерживают данную стратегию оптимизации, однако их интеллектуальные способности очень ограничены и со следующим примером справляется только Hewlett-Packard C++:

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

       for (j=0; j<n; j++)

              for (k=0; k<n; k++)

                     a[j][i] = a[j][i] + b[k][i] * c[j][k];



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


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

f(char *x, int *dst, int n)

{

       int i,t =0;

       for

(i=0;i<n;i++) t+=x[i]; // сохранение суммы во временной переменной

       *dst+=t;                   // запись конечного результата в память

}



Мощь и беспомощность автоматической оптимизации


крис касперски ака мыщъх, no-email

к концу 90х годов компиляторы по своей эффективности вплотную приблизились к ассемблеру, однако, все еще существует множество конструкций, неподдающихся автоматической оптимизации, но легко трансформируемых вручную. покажем как надо и как не надо оптимизировать программы на примере: Microsoft Visual C++, Intel C++, Borland Builder, GCC и Hewlett-Packard C++



Нормализация циклов


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

Рассмотрим типичный цикл:

for (a = from; a < to; i+=(-step))

{

       // тело цикла

}



Программная конвейеризация


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

Чтобы избавиться от зависимости по данным, необходимо развернуть не только цикл, но и "расщепить" переменную, используемую для суммирования. Такая техника оптимизации называется программной конвейеризацией (software pipelining) и из всех рассматриваемых компиляторов ее поддерживает только GCC, да и то лишь частично. В тоже самое время, она элементарно реализуется "руками":

// обрабатываем первые XXL

– (XXL

% 4) итераций

for(i=0; i<XXL;i+=4)

{

       sum_1 += a[i+0];

       sum_2 += a[i+2];

       sum_3 += a[i+3];

       sum_4 += a[i+4];

}

// обрабатываем оставшийся "хвост"

for(i-=XXL; i<XXL;i++)

       sum += a[i];

// складываем все воедино

sum

+= sum_1 + sum_2 + sum_3 + sum_4;



Разворот циклов


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

Компактные циклы вида for(a=0;a<n;a++)*dst++= *src++;

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

for(i=1; i<n;i+)

      k += (n % i);



Регистровые ре-ассоциации


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

Код вида "for(i = 0; I < n; i++) n+=a[i];" легко оптимизировать, если переписать его так: "for (p= a; p < &a[n]; p++) n+=*p;" Насколько известно мыщъх'у впервые эта техника использовалась в компиляторах фирмы Hewlett-Packard, где она фигурировала под термином register reassociation, а вот остальные рассматриваемые нам компиляторы этого делать, увы, не умеют.

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

int a[10][20][30];

void example (void)

{

       int i, j, k;

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

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

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

                           a[i][j][k] = 1;

}



Удаление копий переменных


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

В англоязычной литературе данный примем называется "размножением копий" ("copy propagation"), что на первый взгляд не совсем логично, но если задуматься, то все проясняется: да, мы сокращаем переменные, размножая копии, хранящихся в них значений, что наглядно продемонстрировано в следующем примере:

main(int n, char** v)

{

       int a, b;

       …

       a

= n+1;

       b

= 1-a;      // избавляется от переменной a: (1 – (n + 1));

       return a-b;   // избавляется от переменной b: ((n + 1) – (1 – (n + 1)));

}



Удаление лишних обращений к памяти


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

Вот, например:

f(int *a, int *b)

{

       int x;

       x = *a + *b;  // сложение содержимого двух ячеек

       *b = 0x69;    // изменение ячейки *b, адрес которой не известен компилятору

       x += *a;      // нет гарантии что запись в ячейку *b

не изменила ячейку *a

}



Удаление неиспользуемых функций


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

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

Теоретически, неиспользуемые функции должен удалять линкер, но популярные форматы объектных файлов к этому не располагают и в грубом приближении представляют собой набор секций (.text, .data и т. д.), каждая из которых с точки зрения линкера представляет монолитный блок, внутрь которого линкер не лезет, а просто объединят блоки тем или иным образом.

Вот потому-то и не рекомендуется держать весь проект в одном файле (особенно если это библиотека). Помещайте в файл только "родственные" функции, _всегда_ используемые в паре и по отдельности не имеющие никакого смысла. Посмотрите как устроена стандартная библиотека языка Си — большинство функций реализованы в "своем" собственном файле, компилируемом в obj, содержащим _только_ эту функцию и ничего сверх нее! Множество таких obj объединяются библиотекарем в один lib-файл, откуда линкер свободно достает любую необходимую функцию, не таща ничего остального! Собственно говоря, именно для этого библиотеками и придумали. Программисты, помещающие реализации всех функций своей библиотеки в один-единственный файл, совершают большую ошибку!

Из всех рассматриваемых компиляторов, только Intel C++ умеет отслеживать неиспользуемые функции, предотвращая их включение в obj (для этого ему необходимо указать ключ–ipo, активирующий режим глобальной оптимизации).



Упорядочивание обращений к памяти


При обращении к одной-единственной ячейки памяти, в кэш первого уровня загружается целая строка, длина которой в зависимости от типа процессора варьируется от 32- до 128- или даже 256байт, поэтому большие массивы выгоднее всего обрабатывать по строкам, а не по столбцам.

for(j=0;j<m;j++)

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

              a[i][j] = b[i][j] + c[i][j];



Устранение хвостовой рекурсии


Хвостовой рекурсией (tail recursion) называется такой тип рекурсии, при котором вызов рекурсивной функции следует непосредственно за оператором return. Классическим примером тому является алгоритм вычисления факториала:

int fact(int n, int result)

{

       if(n == 0)

       {

              return result;

       }

              else

       {

              return fact(n - 1, result * n);

       }

}



Внос инвариантных функций из циклов


Инвариантными

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

Рассмотрим типичный пример:

for(a=0;a<strlen(s);a++) b+=s[a];



С точки зрения прикладного программиста,


С точки зрения прикладного программиста, компилятор — это черный ящик, заглатывающий исходный текст и выплевывающий двоичный файл. Какие процессы протекают в его "пищеварительном тракте" — неизвестно. Разработчики компиляторов крайне поверхностно описывают механизмы оптимизации в прилагаемой документации, так и не давая ответа на вопрос: _что_ именно оптимизирует компилятор, а что нет.
Как следствие — одни разработчики пишут ужасно кривой код, надеясь, что все огрехи исправит компилятор (он же ведь "оптимизирующий!"). Другие же, наоборот, пытаются помощь компилятору, оптимизируя программу вручную и выполняя кучу глупых и ненужных действий, например, заменяя a = b/4 на a = b >> 2, хотя _любой_ компилятор сделает это и сам, а вот поместить в регистр переменную, переданную по ссылке, он уже не решается (почему — см. "удаление лишних обращений к памяти"), то же самое относится и к выносу инвариантных функций из тела цикла.
Для достижения наивысшей эффективности, необходимо помочь компилятору, придерживаясь определенных привил программирования (кстати говоря, не описанных в штатной документации). Если вы недовольны быстродействием откомпилированной программы, не спешите переписать ее на ассемблер. Попробуйте сначала оптимизировать код путем реконструкции исходного текста. В большинстве случаев, вы получите тот же самый результат, но потратите меньше времени и сохраните переносимость.

Собирать свою коллекцию как надо


Собирать свою коллекцию как надо и как не надо оптимизировать программы мыщъх начал еще давно (здесь приведена лишь крошечная ее часть). Время шло, компиляторы совершенствовались и все больше примеров перемещалось из первой категории во вторую. А затем… разработчики компиляторов поутихли и со временен Microsoft Visual C++ 6.0 новых рывков что-то не наблюдается, поэтому у статьи есть все шансы сохранить свою актуальность в течении нескольких лет. А, возможно, и нет.