ТЕХНИКА ОПТИМИЗАЦИИ ПРОГРАММ

       

Как компиляторы выравнивают данные


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

Стратегия выравнивания для каждого типа переменных своя: статические (static) и глобальные переменные большинство компиляторов насильно выравнивают в соответствии с их размером, игнорируя при этом кратность выравнивая, заданную прагмой pack или соответствующим ключом компилятора! Во всяком случае, именно так поступают Microsoft Visual C++ и Borland C++. Причем оба этих компилятора (как, впрочем, и подавляющее большинство других) не дают себе труда организовать переменные оптимальным образом, и размещают их в памяти в строгом соответствии с порядком объявления в программе. Что отсюда следует? Рассмотрим следующий пример:

// ….

static int a;

static char b;

static int c;

static char d;

// …

Компилятор, выравнивая переменную 'c' по адресу, кратному четырем, пропускает три байта, следующих за переменной 'b', образуя незанятую "дырку", по напрасно отъедающую память. А вот если объявить переменные так:

// ….

static int a;

static int c;

static char b;



static char d;

// …

…компилятор расположит их вплотную друг другу, не оставляя никаких дыр! Обязательно возьмите этот трюк на вооружение! (Однако, помните, что в этом случае цикл наподобие for(;;) b = d

будет выполняться достаточно неэффективно – т.к. переменные b и d попадут в один банк, это делает невозможной их синхронную обработку – подробнее см. "Стратегия распределения данных по кэш-банкам").

Автоматические переменные (т.е. обыкновенные локальные переменные) независимо от своего размера большинством компиляторов выравниваются по адресам кратным четырем. Это связано с тем, что машинные команды закидывания и стягивания данных со стека работают с один типом данных – 4-байтным двойными словами, поэтому переменные типа char


занимают в стеке ровно столько же места, как и int. И никакой перегруппировкой переменных при их объявлении от "пустот" избавиться не удастся. Локальные массивы так же всегда расширяются до размеров, кратным четырем, т.е. char a[11] и char b[12]

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

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

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

Динамические переменные размешаются в куче – области памяти выделенной специальной функцией наподобие malloc. Степень выравнивания (если таковая имеется) у каждой функции может быть своя. Например, malloc выравнивает выделяемые ей регионы памяти по адресам кратным 16.

Структуры. По умолчанию каждый элемент структуры выравнивается на величину, равную его размеру (т.е. char – вообще не выравниваются, short int – выравниваются по четным адресам, int и float – адресам, кратным четырем и double c __int64 – восьми).


Как уже отмечалось выше, это не самая лучшая стратегия, к тому же выравнивание структур, работающих с сетевыми протоколами, оборудованием, типизированными фалами категорически недопустимо! Как же запретить компилятору самовольничать? Универсальных решений нет – управление выравниванием не стандартизовано и специфично для каждого компилятора. Например, компиляторы Microsoft Visual C++ и Borland C++ поддерживают прагрму pack, задающую требую степень выравнивания. Например, "#pragma pack(1)" заставляет компилятор выравнивать элементы всех последующих структур по адресам, кратным единице – т.е. не выравнивать их вообще. Аналогичную роль играют ключи командной строки: /Zn? (Microsoft Visual C++) и –a? (Borland C++), где – "?" степень выравнивания.

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

может встречаться неоднократно и с различными степенями выравнивания. Более того, прагма pack

может сохранить предыдущее значение степени выравнивания, а затем восстановить его – pack(push)

запоминает, а pack(pop) вытаивает значение выравнивания из внутреннего стека компилятора. Разумеется, вызовы push/pop

могут быть вложенными. Пример их использования приведен ниже.

#pragma pack(push)

#pragma pack(1)

struct IP_HEADER{

// …

}

#pragma pack(pop)

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


Содержание раздела