Автор: Марцин Лавицкий, менеджер по развитию, Huuuge

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

В этой статье я дам 7 советов о том, как решить эту проблему и сделать так, чтобы ваши приложения не только выглядели, но и отлично работали.

Примеры кода приведены на C++, но многие из предложенных методов можно применять и в других технологиях.

Что такое оптимизация кода?

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

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

Зачем нам оптимизировать код?

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

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

Давайте перечислим несколько примеров, когда можно удвоить усилия по оптимизации:

1. Чтобы программа работала быстрее. В случае игр, чем короче выполнение игрового цикла, тем больше кадров производится в секунду, что, по сути, приводит к более плавному игровому процессу и лучшему игровому процессу.

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

В случае бэкендов или серверов это обычно означает возможность обслуживать больше клиентов или быстрее возвращать требуемые результаты.

2. Чтобы программное обеспечение работало на ограниченном оборудовании. В случае встроенных устройств, таких как ЭБУ (электронный блок управления) в вашем автомобиле или устройства, подпадающие под широкую категорию IoT (Интернет вещей), программное обеспечение должно работать на гораздо более ограниченном аппаратном обеспечении, чем наши ПК, консоли или даже смартфоны. при этом надежно выполнять свою работу. В таких случаях код должен быть тщательно разработан с учетом ограничений вычислительной мощности и доступной памяти, а в некоторых случаях даже ограничений энергопотребления.

3. Сделать программные решения возможными. Некоторым приложениям необходимо обрабатывать большие объемы данных, которые не помещаются в доступную память, или требуют сильно распараллеленных вычислений для получения результатов в разумные сроки. Это могут быть решения, обрабатывающие огромные наборы данных, или приложения, используемые для моделирования сложных систем.

Как узнать, нужна ли нам оптимизация

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

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

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

7 советов по оптимизации кода

Сцена готова. Мы знаем, что, почему и когда. Теперь поговорим о том, как оптимизировать.

1. Не оптимизируйте преждевременно.

Есть высказывание, приписываемое Дональду Кнуту, одному из отцов программирования: «преждевременная оптимизация — корень всех зол». Иногда им злоупотребляют и даже искажают таким образом, что ленивые программисты вообще отказываются от оптимизации. Настоящее значение этих слов заключается в том, что программисты должны в первую очередь сосредоточиться на двух вещах — заставить код работать правильно и сделать его легким для чтения и понимания. Только при соблюдении этих требований следует рассматривать оптимизацию кода. Мы не должны уходить в автоматический режим и всегда пытаться оптимизировать, а делать это только тогда, когда нам нужно. И будьте осторожны — оптимизация, которая делает ваш код беспорядочным и трудным для понимания, может дорого стоить вам в долгосрочной перспективе.

2. Запускайте тесты производительности и используйте профилировщики

Нет смысла оптимизировать что-то только потому, что это можно оптимизировать, или сделать это за пределами некоторого порога. Определите цель, которую вы хотите достичь, и следите за эффективностью, чтобы не сбиться с нее. Если ожидается, что ваша игра будет работать со скоростью 60 кадров в секунду, не обязательно заставлять ее работать со скоростью 80 — особенно если это будет дорого или сильно повлияет на читаемость кода и простоту обслуживания.

3. Начните с алгоритмов

Не все методы оптимизации кода одинаковы. Вы должны хорошо понимать свой домен, прежде чем начать бороться за лучшую производительность. Если у вас достаточно знаний, начните с изучения ваших алгоритмов. Это, вероятно, самая важная область, в которой вы можете совершенствоваться. Если вы реализовали собственное решение, попытайтесь понять его сложность в отношении большого O. Каков класс вашего алгоритма? Если это O (n3) или O (n²), есть большие шансы на значительные улучшения. Попробуйте найти классические проблемы, которые больше всего напоминают ваши собственные, и адаптируйте известные и оптимизированные решения под свои нужды. Только когда вы исчерпаете все возможности в этой области, вы можете переходить к другим методам.

Взгляните на этот (немного упрощенный) пример — мы хотим отсортировать набор из 1 миллиона элементов на машине, которая выполняет 100 миллионов операций в секунду.
Первый алгоритм, выбранный для задачи, — это сортировка выбором, которая имеет сложность O (n²). Подсчитав фактическое время, необходимое для сортировки данных, мы получаем довольно большое число — 104 секунды.

Второй алгоритм — quicksort, решает ту же задачу менее чем за секунду.

Хотя этот пример довольно упрощен, он показывает, как много можно изменить, просто применив другой алгоритм.

4. Используйте свой язык лучше

Проанализируйте свои узкие места в отношении особенностей языка программирования, который вы используете. Если это C++, возможно, вы можете изменить свой код, чтобы вам не нужно было использовать виртуальные функции, или, может быть, те интеллектуальные указатели, которые вы туда поместили, не так уж необходимы. Проверьте свои типы данных — возможно, есть какие-то байты, которые нужно сохранить. Убедитесь, что структуры данных, которые вы выбрали из STL, являются лучшим выбором. Возможно, есть какая-то сторонняя библиотека, которая лучше реализует требуемую структуру данных и соответствует вашим требованиям. Дважды подумайте, прежде чем генерировать исключение или даже подумать об их отключении.

Выполняя эти микрооптимизации кода, вы должны помнить, что компиляторы — довольно умные звери. Избегайте «умных» улучшений, таких как использование побитового сдвига вместо фактического деления. Компилятор достаточно умен, чтобы сделать это за вас, и все, чего вы добьетесь, — сделать код менее читаемым.

В приведенном ниже примере мы видим, как разные реализации одной и той же концепции могут различаться по производительности. Диаграмма представляет собой сравнение времени поиска std::string на карте, содержащей 1 миллион элементов.

5. Лучше используйте свою платформу

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

Ниже приведен фрагмент кода на C, который создает разные сборки на разных компиляторах.

#include ‹immintrin.h›

__m256i f(__m128i in)

{

__m128i p1 = _mm_and_si128(in,_mm_set1_epi32(0xFF));

__m128i p2 = _mm_packus_epi32 (p1, p1);

__m128i p = _mm_packus_epi16 (p2, p2);

вернуть _mm256_set_m128i (р, р);

}

Сборка от clang:

vpshufb xmm0,xmm2,xmmword ptr[rip+.LCPI0_0]
винсерти128 ymm0,ymm0,xmm0,1

из gcc:

vpand xmm0, xmm2, XMMWORD PTR .LC0[rip]

vpackusdw хмм0, хмм0, хмм0

vpackuswb хмм0, хмм0, хмм0

vinserti128 ymm0, ymm0, xmm0, 1

и из MSVC:

vmovdqu xmm0, XMMWORD PTR __xmm@000000ff000000ff000000ff000000ff

vpand xmm1, xmm0, XMMWORD PTR [r8]

vpackusdw хмм2, хмм1, хмм1

vpackuswb хмм3, хмм2, хмм2

вмовупс хмм0, хмм3

vinsertf128 ymm0, ymm0, xmm3, 1

Ясно, что версия clang выделяется и указывает на возможные улучшения. Анализируя эту версию, мы приходим к разным кодам C:

#include ‹immintrin.h›

__m256i f(__m128i in)

{

__m128i p = _mm_shuffle_epi8 (в, _mm_set1_epi32 (0x0C080400));

вернуть _mm256_set_m128i (р, р);

}

Это приводит к изменениям в ассемблерном коде, из-за чего версия gcc выглядит почти так же, как clang и версия MSVC, лишь немного отличаясь.

Gcc:

vpshufb xmm0, xmm0, XMMWORD PTR .LC0[rip]

vinserti128 ymm0, ymm0, xmm0, 1

МСВК:

vmovdqu xmm0, XMMWORD PTR [rcx]

vpshufb xmm2, xmm0, XMMWORD PTR __xmm@0c0804000c0804000c0804000c080400

вмовупс хмм1, хмм2

vinsertf128 ymm0, ymm1, xmm2, 1

6. Ударь по металлу

Если производительность вашего кода по-прежнему неудовлетворительна, вы можете применить методы, зависящие от аппаратного обеспечения. По сути, это сводится к использованию функций, характерных для данного семейства процессоров и доступных через высокоуровневые функции, называемые встроенными функциями. С помощью таких функций мы можем получить доступ, например, к инструкции SIMD — SSE и AVX на кремнии Intel или NEON на чипах ARM. Внутренности не подходят для каждой задачи, но вы все равно можете попробовать использовать конкретные инструкции, используя фрагменты ассемблерного кода, созданного вручную. Однако это может сработать только в том случае, если вы точно знаете, что делаете.

Пример, представленный ниже, является выдержкой из утилиты сжатия etcpak. Цель этого кода — проверить, является ли заданный блок пикселей сплошным цветом.

const uint8_t* ptr = src + 4;
for(int i=1; i‹16; i++)
{
if(memcmp(src, ptr, 4) != 0)< br /> {
return 0;
}
ptr += 4;
}

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

__m128i d0 = _mm_loadu_si128(((__m128i*)src) + 0);
__m128i d1 = _mm_loadu_si128(((__m128i*)src) + 1);
__m128i d2 = _mm_loadu_si128(((__m1 28и*) src) + 2);
__m128i d3 = _mm_loadu_si128(((__m128i*)src) + 3);
__m128i c = _mm_shuffle_epi32(d0, _MM_SHUFFLE(0, 0, 0, 0)); __m128i c0 = _mm_cmpeq_epi8(d0, c);
__m128i c1 = _mm_cmpeq_epi8(d1, c);
__m128i c2 = _mm_cmpeq_epi8(d2, c);
__m128i c3 = _mm_cmpeq_epi8(d3, с );
__m128i m0 = _mm_and_si128(c0, c1);
__m128i m1 = _mm_and_si128(c2, c3);
__m128i m = _mm_and_si128(m0, m1);

if (!_mm_testc_si128(m, _mm_set1_epi32(-1)))
{
return 0;
}

7. Следуйте правилу убывающей отдачи

Выбирая оптимизацию для применения, всегда начинайте с наименее висящих плодов. Лучшие изменения — это те, которые не требуют больших усилий, но приносят заметные изменения. Только когда нет более простых решений, переходите к тем, которые требуют больше работы. Мы называем этот подход «правилом убывающей отдачи».

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

Если в процессе вы попали в точку — остановитесь, иначе — продолжайте. Если ваши цели реалистичны, а навыки достаточны, вы добьетесь успеха.

Марцин Лавицкий, менеджер по развитию, Huuuge Games