Производительность программного обеспечения (ПО) является важным аспектом в разработке любого программного продукта. Актуальность этого вопроса объясняется постоянно возрастающей сложностью и значимостью программных средств.
Низкая производительность может обуславливать неэффективность работы пользователей и, как следствие, негативную реакцию на программный’ продукт, потерю прибыли и клиентуры, доли рынка; перерасход средств на модификации и перепроектирование. Более того, переработка кода с целью увеличения производительности с большой вероятностью может разрушить первоначальную структуру системы, сводя на нет преимущества от использования объектно-ориентированного подхода. Наконец, маловероятно, что переработанный код сможет довести производительность системы до уровня, на котором она могла бы быть в случае изначального проектирования с учетом вопроса производительности. В худшем случае, будет невозможно достичь поставленных целей простой модификацией исходного кода системы, что обусловит необходимость полного перепроектирования или остановки проекта.
Каждый программист, начиная работу над программным кодом той или иной программы понимает, что все программы должны быть правильными, но некоторые программы должны быть быстрыми. Если производительность является ключевым фактором, то недостаточно использовать эффективные алгоритмы и структуры данных. Нужно писать такой код, который компилятор легко оптимизирует и транслирует в быстрый исполняемый код.
Зачастую перед теми, кто занимается программированием в сфере игровых модификаций встает вопрос: Как стоит строить код: максимально производительно, или максимально экономично в отношении памяти [1, 5, 6, 7, 9, 11].
Большинство программистов решит, что нужно искать компромисс [1, 2, 3]. Но что делать в тех случаях, когда компромисс найти тяжело, либо вообще невозможно? Искать!
Одним из направления уменьшений расходуемой памяти является оптимизация структур данных.
Допустим, нам нужно иметь в памяти 1000 ячеек содержащих двоичный ноль или единицу. 1000 целочисленных переменных (int) займет в памяти 1.95 Мб, в то время как 1000 переменных логического типа (bool) займет в памяти примерно 490 Кб. Этот метод перехода от использования одного типа данных к использованию другого приводит к экономии трёх четвертей используемой памяти [4, 8, 10]. Неплохо! Но, тут же возникает вопрос, а можно ли сэкономить ещё больше?
Еще одним направлением экономии памяти является использование более экономных типов данных.
Проведённые нами эксперименты по преобразованию компьютерного программного кода привели нас к ещё более мощному способу оптимизации памяти: хранение данных в ячейках с целью дальнейшего использования их через побитовый сдвиг.
В этом случае, на 1000 переменных мы потратим порядка 80 Кб оперативной памяти, что почти в 25 раз меньше, по сравнению с той же тысячью целочисленных переменных типа int.
Результаты проведённого сравнительного анализа представлены в табл. 1.
Таблица 1
Сравнительные анализ количества переменных различного типа на фиксированный объём оперативной памяти
Фиксированный объём памяти |
1950 кб = 1.95 мб |
||
Тип переменной |
int |
bool |
bit’s |
Количество переменных |
1000 |
4000 |
>24 000 |
В качестве эксперимента, каждый вид переменных был записан, использован и перезаписан 10 000 раз с целью определения скорости работы программы, реализующей одну и ту же задачу посредством использования различных типов переменных. Результаты эксперимента представлены в табл. 2:
Таблица 2
Результаты эксперимента
Тип данных |
Время (усл. ед.) |
int |
~190 |
bool |
~170 |
bit’s |
~240 |
Как показал эксперимент, bit-овые переменные оказались «медленнее» остальных.Почему? Потому, что для выполнения побитовых операций генерируются дополнительные инструкции в секции кода, которые идут параллельно с нашими bit-ами. А, как известно, любая операция в той или иной степени «загружает» машину, что, естественно, сказывается на её быстродействии. Проведённый нами эксперимент показал также, что не все программные двигатели имеют многопоточность и выполняют все операции последовательно.
Приведём фрагменты программного кода, иллюстрирующие проведённое нами исследование.
Итак, создаем:
#define MAX_USERS (1000)
enumBits:(<<= 1) //не забываем, не более 32
{
EnumOne = 1,
EnumTwo, ...
};
newBits:UserBits[MAX_USERS];
Узнаем значение и обнуляем весь массив пользователя:
UserBits[userid] &EnumOne;
UserBits[userid] = Bits:0;
Устанавливаемячейкезначение 1 (true)
UserBits[userid] |= EnumOne;
Устанавливаемячейкезначение 0 (false)
UserBits[userid] &= ~EnumOne;
Меняем значение в ячейке (при 0 ставим 1, и наоборот)
UserBits[userid] ^= EnumOne;
Таким образом, для грамотной работы по оптимизации памяти, прежде всего, необходимо понимать, как эта работа «устроена изнутри», какие действия происходят, когда компилятор преобразует наши скупые синтаксические конструкции в машинный код.
Современный процессор скрывает огромную вычислительную мощь. Но, чтобы получить к ней доступ, нужно писать программы в определённом стиле. Решить какие трансформации и к какой части кода применить- это и есть рецепт написания быстрого кода.
В оптимизации есть несколько важных моментов. Оптимизация должна быть естественной. Оптимизированный фрагмент кода должен легко вливаться в программу, не нарушая логики ее работы. Он должен легко вводиться в программу, изменяться или удаляться из нее.
Оптимизация должна приносить существенный прирост производительности. Оптимизированная программа должна работать минимум на 20 %-30 % эффективней, чем ее неоптимизированный аналог, иначе оптимизация теряет смысл. Зачем мучиться и вносить изменения в уже готовый код, если этоне дастпрактически никакого результата?
При этом разработка (и отладка) критических областей не должна увеличивать время разработки программы более чем на 10 %-15 %.
Помимо рассмотренных в нашей статье способов оптимизации, мы также предлагаем известную базовую стратегию оптимизации программного кода, а именно:
- Выбирайте эффективные алгоритмы и структуры данных. Никакой компилятор не заменит плохие алгоритмы или структуры данных на хорошие.
- Избегайте блокировщиков оптимизации, чтобы помочь компилятору генерировать эффективный код. Избавьтесь от ненужных вызовов функций. Если возможно, вынесите вычисления за пределы цикла. Избавьтесь от ненужных запросов к памяти. Введите временные переменные для хранения промежуточных результатов.
- Используйте низкоуровневую оптимизацию. Применяйте раскрутку циклов, чтобы уменьшить накладные расходы на цикл.