Урок 26. Оптимизация 64-битных программ

19.08.2013

Снижение объема расходуемой памяти

После компиляции программы в 64-битном режиме она начинает потреблять большее количество памяти, чем ее 32-битный вариант. Часто это увеличение почти незаметно, но иногда потребление памяти может возрастать в 2 раза. Увеличение расхода памяти связано со следующими причинами:

  • увеличение объема памяти для хранения некоторых объектов, например указателей;
  • изменение правил выравнивания данных в структурах;
  • увеличение расхода стековой памяти.

С увеличением потребляемой оперативной памяти часто можно мириться. Преимущество 64-битных систем как раз и заключается в том, что этой памяти много. Нет ничего плохого, что на 32-битной системе с 2 гигабайтами памяти программа занимала 300 Мбайт, а на 64-битной системе с 8 гигабайтами памяти программа займет 400 Мбайт. В относительных единицах мы получим, что программа на 64-битной системе занимает в 3 раза меньше доступной физической памяти. С описанным ростом потребляемой памяти нет смысла бороться. Проще добавить еще немного памяти.

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

Одним из направления уменьшений расходуемой памяти является оптимизация структур данных, о чем подробно было рассказано в уроке 23.

Еще одним направлением экономии памяти является использование более экономных типов данных. Например, если необходимо хранить большое количество целых чисел, и их значение никогда не превысит UINT_MAX, то стоит использовать тип unsigned, а не тип size_t.

Использование memsize-типов в адресной арифметике

Использование типов ptrdiff_t и size_t в адресной арифметике помимо повышения надежности кода может дать дополнительный выигрыш в производительности. Например, использование в качестве индекса типа int, размерность которого отличается от размерности указателя, приводит к тому, что в двоичном коде будут присутствовать дополнительные команды преобразования данных. Речь идет о 64-битном коде, в котором размер указателей стал равен 64-битам, а размер типа int остался 32-битным.

Показать короткий пример преимущества size_t над unsigned не простая задача. Чтобы быть объективным необходимо использовать оптимизирующие возможности компилятора. А два варианта оптимизированного кода часто становятся слишком непохожими, чтобы легко было продемонстрировать отличие. Попытка создать нечто близкое к простому примеру увенчалась успехом только с шестого раза. И все равно пример не идеален, так как показывает не лишние преобразования типов данных, о которых сказано выше, а то, что компилятор смог построить более эффективный код при использовании типа size_t. Рассмотрим код программы, переставляющий элементы массива в обратном порядке:

unsigned arraySize;
...
for (unsigned i = 0; i < arraySize / 2; i++)
{
  float value = array[i];
  array[i] = array[arraySize - i - 1];
  array[arraySize - i - 1] = value;
}

В примере переменные "arraySize" и "i" имеют тип unsigned. Этот тип легко можно заменить на size_t и сравнить небольшой участок ассемблерного кода, показанный на рисунке 1.

Рисунок 1 - Сравнение 64-битного ассемблерного кода при использовании типов unsigned и size_t

Рисунок 1 - Сравнение 64-битного ассемблерного кода при использовании типов unsigned и size_t

Компилятор смог построить более лаконичный код, когда использовал 64-битные регистры. Мы не беремся утверждать, что код, созданный при использовании типа unsigned (текст слева), будет работать медленнее, чем код с использованием size_t (текст справа). Сравнить скорость выполнения кода на современных процессорах крайне сложная задача. Но из примера видно, что когда компилятор работает с массивами, используя 64-битные типы, он может строить более короткий и быстрый код.

Рассмотрим пример, демонстрирующий преимущества использования типов ptrdiff_t и size_t с точки зрения производительности. Для демонстрации возьмем простой алгоритм вычисления минимальной длины пути. С полным кодом программы можно познакомиться здесь.

Функция FindMinPath32 написана в классическом 32-битном стиле с использованием типов unsigned. Функция FindMinPath64 отличается от нее только тем, что в ней все типы unsigned заменены на типы size_t. Других отличий нет! Согласитесь, что это не является сложной модификацией программы. А теперь сравним скорости выполнения этих двух функций (таблица 1).

Таблица 1 - Время выполнения функций FindMinPath32 и FindMinPath64

Таблица 1 - Время выполнения функций FindMinPath32 и FindMinPath64

В таблице 1 показано приведенное время относительно скорости выполнения функции FindMinPath32 на 32-битной системе. Это сделано для большей наглядности.

В первой строке время работы функции FindMinPath32 на 32-битной системе равно 1. Это связано с тем, что мы взяли время ее работы за единицу измерения.

Во второй строке мы видим, что время работы функции FindMinPath64 на 32-битной системе также равно 1. Это не удивительно, так как на 32-битной системе тип unsigned совпадает с типом size_t и никакой разницы между функцией FindMinPath32 и FindMinPath64 нет. Небольшое отклонение (1.002) говорит только о небольшой погрешности в измерениях.

В третьей строке мы видим прирост производительности, равный 7%. Это вполне ожидаемый результат от перекомпиляции кода для 64-битной системы.

Наибольший интерес представляет 4 строка. Прирост производительности составляет 15%. Это значит, что простое использование типа size_t вместо unsigned позволяет компилятору построить более эффективный код, работающий еще на 8% быстрее!

Это простой и наглядный пример, когда использование данных, не равных размеру машинного слова, снижает производительность алгоритма. Простая замена типов int и unsigned на типы ptrdiff_t и size_t может дать существенный прирост производительности. В первую очередь это относится к использованию этих типов данных для индексации массивов, адресной арифметики и организации циклов.

Примечание. Хотя статический анализатор PVS-Studio специально не ориентирован на оптимизацию программ, его использование способствует рефакторингу кода, делающего код более эффективным. Например, исправляя потенциальные ошибки, связанные с адресной арифметикой, вы будете использовать memsize-типы, что позволит компилятору строить более оптимизированный код.

Intrinsic-функции

Intrinsic-функции - это специальные системно-зависимые функции, выполняющие действия, которые невозможно выполнить на уровне Си/Си++ кода или выполняющие эти действия намного эффективнее. По сути, они позволяют избавиться от использования inline-ассемблера, т.к. его использование часто нежелательно или невозможно.

Программы могут использовать intrinsic-функции для создания более быстрого кода за счет отсутствия накладных расходов на вызов обычного вида функций. При этом, естественно, размер кода будет чуть-чуть больше. В MSDN приводится список функций, которые могут быть заменены их intrinsic-версией. Это, например, memcpy, strcmp и другие.

В компиляторе Microsoft Visual C++ есть специальная опция "/Oi", которая позволяет автоматически заменять вызовы некоторых функций на intrinsic-аналоги.

Помимо автоматической замены обычных функций на intrinsic-варианты, можно явно использовать в коде intrinsic-функции. Вот для чего это может быть нужно:

  • Встроенный (inline) ассемблерный код не поддерживается компилятором Visual C++ в 64-битном режиме. Intrinsic-код поддерживается.
  • Intrinsic-функции проще использовать, так как они не требуют знания регистров и других подобных низкоуровневых конструкций.
  • Intrinsic-функции обновляются в компиляторах. Ассемблерный же код придется обновлять вручную.
  • Встроенный оптимизатор не работает с ассемблерным кодом.
  • Intrinsic-код легче переносить, чем ассемблерный.

Использование intrinsic-функций в автоматическом режиме (с помощью ключа компилятора) позволяет получить бесплатно несколько процентов прироста производительности, а "ручное" - даже больше. Поэтому использование intrinsic-функций вполне оправдано.

Более подробно с применением intrinsic-функций можно ознакомиться в блоге команды Visual C++.

Выравнивание

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

// 16-byte aligned data
__declspec(align(16)) double init_val[2] = {3.14, 3.14};
// SSE2 movapd instruction
_m128d vector_var = __mm_load_pd(init_val);

Источники "Porting and Optimizing Multimedia Codecs for AMD64 architecture on Microsoft Windows", "Porting and Optimizing Applications on 64-bit Windows for AMD64 Architecture" дают подробный обзор данных вопросов.

Другие способы повышения производительности

Подробно с вопросами оптимизации 64-битных приложений вы сможете познакомиться в документе "Software Optimization Guide for AMD64 Processors".

Авторы курса: Андрей Карпов (karpov@viva64.com), Евгений Рыжков (evg@viva64.com).

Правообладателем курса "Уроки разработки 64-битных приложений на языке Си/Си++" является ООО "Системы программной верификации". Компания занимается разработкой программного обеспечения в области анализа исходного кода программ. Сайт компании: http://www.viva64.com.

Контактная информация: e-mail: support@viva64.com, 300027, г. Тула, а/я 1800.