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

Под катом старший разработчик ПО компании Google, Minhaz A V*, рассказывает об оптимизации производительности кода. Менее чем за час работы автор ускорил код на 18%, добавив в него всего пару строк. Несмотря на то, что в большинстве примеров этого материала используется C++, статья может быть полезна широкому кругу читателей.

*Обращаем ваше внимание: позиция автора не всегда может совпадать с мнением МойОфис.


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

Современные процессоры

Фото: Франческо Вантини. Источник: https://unsplash.com/photos/ZavLsrP4CDI
Фото: Франческо Вантини. Источник: https://unsplash.com/photos/ZavLsrP4CDI

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

SIMD или SISD?

SISD расшифровывается как Single Instruction Stream, Single Data Stream (единый поток инструкций, единый поток данных). Обычно инструкции программного кода выполняются последовательно, т. е. одна за другой. Предположим, у нас есть два массива — a и b — и мы хотим написать программу, которая изменяет элементы массива a с помощью операции:

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

выполняемой для каждого возможного значения индекса i.

На примере простейшего алгоритма разворота массива видно, как мы можем визуализировать исполнение инструкций нашего кода на процессоре. Источник: https://github.com/Wunkolo/qreverse (лицензия MIT).
На примере простейшего алгоритма разворота массива видно, как мы можем визуализировать исполнение инструкций нашего кода на процессоре. Источник: https://github.com/Wunkolo/qreverse (лицензия MIT).

Программисты склонны писать код, оптимизируя Ω (то есть нижнюю границу сложности алгоритма, или  «лучший случай») — и это хорошая практика. Но современные процессоры способны на большее.

Современные процессоры поддерживают SIMD, что расшифровывается как Single Instruction, Multiple Data (одна инструкция, множество данных). Такие вычислительные устройства могут демонстрировать параллелизм на уровне данных (не путать с конкурентностью), то есть могут исполнять одновременно одну и ту же инструкцию сразу для множества данных.

Для приведённого выше примера, процессоры с технологией SIMD могли бы сгруппировать операции для параллельного исполнения в одном пакете следующим образом:

a[0] = a[0] + b[0];
a[1] = a[1] + b[1];
a[2] = a[2] + b[2];
a[3] = a[3] + b[3];

Инструкция SIMD для операции сложения называется addps в SSE или vaddps в AVX и поддерживает группировку четырех или восьми элементов соответственно (целочисленный тип).

Визуализация исполнения инструкций процессором SIMD на примере алгоритма разворота массива. Источник: https://github.com/Wunkolo/qreverse (лицензия MIT).
Визуализация исполнения инструкций процессором SIMD на примере алгоритма разворота массива. Источник: https://github.com/Wunkolo/qreverse (лицензия MIT).

То есть ваш код мог бы работать в k раз быстрее, если бы вы могли дать команду процессору запускать алгоритм именно таким образом.

И вы действительно можете это сделать.

Существуют интринсики (intrinsics) — внутренние функции, которые обрабатываются компилятором особым образом. С их помощью можно указать процессору на необходимость применения векторных операций (SIMD) вместо скалярных (SISD).

Вот пример кода для вычисления поэлементной суммы двух массивов с помещением в целевой массив с помощью Neon Intrinsics:

// Источник: https://stackoverflow.com/a/69799101/2614250
#include <arm_neon.h>

void add_arrays(int* a, int* b, int* target, int size) {
    for(int i=0; i<size; i+=4) {
        /* Загрузка данных в регистры */
        int32x4_t av = vld1q_s32(&(a[i]));
        int32x4_t bv = vld1q_s32(&(b[i]));

        /* Выполнение арифметической операции */
        int32x4_t targetv = vaddq_s32(av, bv);

        /* Сохранение результата */
        vst1q_s32(&(target[i]), targetv);
    }
}

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

Хорошая новость в том, что в большинстве случаев вам не придётся писать код с интринсиками, чтобы достичь высокой производительности кода. И здесь мы переходим к следующей теме — векторизации.

Векторизация

SIMD поддерживает инструкции, которые могут работать с векторными типами данных. В приведенном выше примере группы элементов массива, например, a[0...3] или b[4...7], могут называться векторами.

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

Автовекторизация может быть выполнена AOT-компилятором во время компиляции или JIT-компилятором во время выполнения программы.

Раскрутка цикла

Раскрутка цикла — это метод преобразования цикла, при котором компилятор ускоряет работу программы ценой увеличения размера исполняемого файла.

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

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

// Обычный цикл for c 16 млн. итераций
for (int i = 0; i < 16000000; ++i) {
    a[i] = b[i] + c[i];
}

// Раскрученный цикл for
for (int i = 0; i < 4000000; i+=4) {
    a[i] = b[i] + c[i];
    a[i+1] = b[i+1] + c[i+1];
    a[i+2] = b[i+2] + c[i+2];
    a[i+3] = b[i+3] + c[i+3];
}

Далее в этой статье я поделюсь некоторыми показателями производительности для этих двух приёмов.

Современные компиляторы

gcc на Mac — изображение автора.
gcc на Mac — изображение автора.

Современные C++ компиляторы используют такие методы оптимизации кода, как векторизатор циклов, позволяющие генерировать векторные инструкции для кода, написанного в скалярном формате.

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

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

Приведенные ниже методы должны работать для определенной группы компиляторов, которые могут генерировать Neon-код (подробнее читайте ниже), например, GCCLLVM-clang (присутствует в Android NDK) и Arm C/C++ Compiler.

Передача компилятору инструкций для лучшей автовекторизации

В качестве демонстрационной задачи я возьму преобразование изображения из формата YUV в формат ARGB. Для оценки производительности я буду использовать Pixel 4a (устройство на базе Android).

Пример кода для преобразования изображения из формата YUV в формат ARGB. По умолчанию алгоритм построчно преобразовывает каждый пиксель:

// Код, выполняющий преобразование YUV изображения в формат ARGB.
for (int y = 0; y < image_height; ++y) {
    for (int x = 0; x < image_width; ++x) {
        int y_idx = (y * y_row_stride) + (x * y_pixel_stride);
        y_val = static_cast<int16_t>(y_buffer[y_idx]);

        int uvx = x / 2;
        int uvy = y / 2;
        int uv_idx = (uvy * uv_row_stride) +  (uvx * uv_pixel_stride);

        u_val = static_cast<int16_t>(u_buffer[uv_idx]) - 128;
        v_val = static_cast<int16_t>(v_buffer[uv_idx]) - 128;

        // Вычисление значений RGB
        r = y_val + 1.370705f * v_val;
        g = y_val - (0.698001f * v_val) - (0.337633f * u_val);
        b = y_val + 1.732446f * u_val;

        int argb_idx = y * image_width + x;
        argb_output[argb_idx] = a | r << 16 | g << 8 | b;
    }
}

На устройстве Pixel 4a для 8-мегапиксельного изображения (3264 x 2448 = ~8 миллионов пикселей) я запустил приведенный выше код и получил следующую среднюю задержку времени выполнения.

Средняя задержка времени выполнения приведенного выше кода на устройстве Pixel 4A для 8-мегапиксельного изображения.
Средняя задержка времени выполнения приведенного выше кода на устройстве Pixel 4A для 8-мегапиксельного изображения.

Стоит отметить, что компилятор уже пытается оптимизировать код. Он был запущен с флагом компиляции -O3 (то есть включена оптимизация скорости кода ценой увеличения размера бинарного файла и времени компиляции). Этот флаг подразумевает в том числе и включение автовекторизации.

Декларация pragma loop vectorize

Поместив следующую декларацию #pragma непосредственно перед циклом for, вы покажете компилятору, что цикл не содержит зависимостей данных, которые могут препятствовать автовекторизации:

#pragma clang loop vectorize(assume_safety)

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

Итак, если мы добавим ее в пример выше:

#pragma clang loop vectorize(assume_safety)
for (int y = 0; y < image_height; ++y) {
    for (int x = 0; x < image_width; ++x) {
        // остальная часть кода совпадает с предыдущим примером
    }
}

Мы получим следующие средние значения задержки:

Ускорение на 11,4 % достигнуто за счёт добавления директивы векторизации цикла.
Ускорение на 11,4 % достигнуто за счёт добавления директивы векторизации цикла.

Декларация pragma loop unroll

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

#pragma clang loop unroll_count(2)

Итак, если мы добавим в пример выше:

#pragma clang loop vectorize(assume_safety)
for (int y = 0; y < image_height; ++y) {
    #pragma clang loop unroll_count(4)
    for (int x = 0; x < image_width; ++x) {
        // остальная часть кода совпадает с предыдущим примером
    }
}

Мы получим следующие средние числа задержки:

Ускорение на 18,5 % за счет добавления директив векторизации и развертывания цикла.
Ускорение на 18,5 % за счет добавления директив векторизации и развертывания цикла.

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

Мы ускорили наш код на 18%, добавив всего лишь две строчки и потратив менее часа усилий! При этом получившийся код легко читается и не вызывает трудностей при поддержке.

Некоторые другие советы

  1. Для создания циклов используйте < вместо <= или !=.

  2. Опция -ffast-math может значительно повысить производительность операций над числами с плавающей точкой, но при этом нарушает обратную совместимость со стандартами IEEE и ISO для математических операций и может приводить к небольшим неточностям результатов вычислений из-за агрессивных оптимизаций.

TL;DR;

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

В рассматриваемом примере я смог добиться ускорения на 18% с двумя дополнительными строками кода, а сам код намного проще читать и поддерживать, чем код с векторными функциями-интринсиками.

Заключение

Итоговое ускорение, полученное с помощью этого подхода, зависит от степени независимости данных в рассматриваемом конкретном алгоритме.

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

[Бонус] Есть что еще почитать!

В этом разделе я немного расскажу о некоторых недостаточно раскрытых выше концепциях.

Neon

Нет, это не благородный газ. Neon является реализацией архитектуры Arm Advanced SIMD. Назначение Neon заключается в ускорении операций над данными благодаря следующим элементам:

  • Тридцать два 128-битных векторных регистра, каждый из которых способен содержать несколько потоков (lanes) данных.

  • Инструкции SIMD для одновременной работы на этих нескольких потоках данных.

Источник: developer.arm.com

В документации описано несколько способов использования этой технологии:

  1. Использование библиотек с открытым исходным кодом и поддержкой Neon.

  2. Функция автовекторизации в компиляторах, которые могут воспользоваться преимуществами Neon.

  3. Использование функций-интринсиков Neon — компилятор заменит их соответствующими инструкциями Neon. Это предоставит низкоуровневый доступ к конкретным инструкциям Neon из кода C/C++.

  4. Написание ассемблерного кода с инструкциями Neon — прерогатива только опытных программистов.

Ссылки

  1. Что такое Neon?

  2. Лучшие практики программирования для достижения целей автовекторизации

***

В блоге МойОфис на Хабре вы можете ознакомиться с переводами других интересных зарубежных текстов:

Впереди — больше переводов и подробных материалов с ИТ-экспертизой от специалистов МойОфис. Следите за нашими новостями и блогом на Хабре!

Комментарии (8)


  1. eurol
    17.05.2022 16:11
    +3

    А можно раскрыть немного некоторый другой совет номер 1?


    1. myoffice_ru
      18.05.2022 12:26
      +3

      Предполагаем, что в данной рекомендации автор предлагает использовать < вместо <= в качестве низкоуровневой оптимизации, которая экономит одну процессорную инструкцию в некоторых менее распространённых или устаревших архитектурах. Существуют и противоположные точки зрения: например, для определённых версий компилятора Microsoft Visual C++ >= лучше чем > и <=, то есть итерироваться надо в обратном порядке и декрементить переменную цикла.

      Если же мы посмотрим на ассемблерный код, выдаваемый современным компилятором С++ под платформу x86_64 (GCC 11.2), то окажется, что для обоих вариантов функции, выполняющих итерацию по массиву, выдаётся практически идентичный набор инструкций. Отличие лишь в инструкциях jl и jle:

      1) godbolt.org/z/MKMM57EK6

      2) godbolt.org/z/Kacd7xeEW

      Вопрос в том, выполняются ли jl и jle за одно и то же время. Быстрый ответ — да. Но точный ответ весьма затруднён из-за огромного разнообразия процессорных архитектур и оптимизаций, применяемых в современных процессорах: конвейеризация и суперскалярность позволяет нескольким инструкциям процессора выполняться параллельно, а внеочередное исполнение может менять порядок исполнения инструкций. Поэтому для анализа производительности инструкций в основном применяются статистические подходы.

      Но всё же есть специальные таблицы, позволяющие определить количество микроопераций, выполняемых процессором при проведении макроопераций, таких как jl и jle. Согласно этому документу на большинстве архитектур conditional jump в любом варианте «стоит» ровно одну микрооперацию — то есть между jl и jle разницы нет.

      Что же касается сравнения операторов < и !=, то первый считается чуть более безопасным, точнее, располагающим к написанию более безопасного кода.


  1. dendron
    17.05.2022 16:34
    +7

    Картинки красивые, но статья, на мой взгляд, так себе.

    • Очень поверхностно пробежались по теме, вида "скопипасть вот это в код чтобы программа работала быстрее". Причём в 99% случаев это не поможет.

    • Не раскрыта тема авто-векторизации, почему компилятор может или не может её применять (в том числе не рассказано про диагностику таких случаев).

    • Не раскрыта тема SIMD инструкций, их семейства, доступность, что именно они могут делать, и.т.п. Словно бы это просто некий "волшебный ускоритель"

    • Нет вывода дизассемблера. Скопипастили в код, тык-мык, минус сколько-то миллисекунд, продолжаем пить смузи. Прямо stackoverflow programming.

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

    Это уровень доклада на школьной (даже не студенческой) конференции, несерьёзно.


    1. myoffice_ru
      18.05.2022 13:02
      +2

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


  1. Yermack
    17.05.2022 17:43

    Чуть более подробная статья на эту тему: https://habr.com/ru/post/529204/


  1. LittleAlien
    17.05.2022 19:14
    +4

    Действительно как-то очень простенько.
    Фактически всё ограничивается знакомством с некими магическими заклинаниями для ускорения кода, "но всемогущий маг лишь на бумаге я" - 18% это очень мало для успешной векторизации.
    Я попытался восстановить полный код примера, правда на x86, c ARM/Neon я не знаком:
    https://gcc.godbolt.org/z/99Yfd7Y9E
    Видно, что векторизует, но масса вот этих vpinsrb, выдёргиваний по 1 байтику - это явно неэффективно.
    Буферы U,V имеют в 2 раза меньшее разрешение - логично считать за одну итерацию квадрат 2*2 пикселя, чтобы не читать одни те же значения U,V по 4 раза:
    https://gcc.godbolt.org/z/T68hdvTcs
    Вроде бы стало лучше, во всяком случае махинаций с байтиками сходу не вижу. Но надо тестировать.
    Также я заменил прагму assume_safety на модификатор __restrict для параметров, действует аналогично, но совместимо с gcc.

    Ещё следует подумать, можно ли избавиться от плавающей точки с сохранением приемлемой точности, это уберёт лишние команды конверсии и может улучшить векторизацию (если обойтись int16, то их больше влезет в вектор, чем float32).

    В общем, надо дописывать свою статью про высокоуровневую векторизацию (у меня на другом примере, но не суть).


    1. LittleAlien
      17.05.2022 21:15
      +2

      С тестом:
      https://gcc.godbolt.org/z/9n53e9588
      Квадрат 2*2 оказался медленнее, видимо непоследовательная запись в память всё портила, поэтому переделал на 2 соседних пикселя по горизонтали, теперь в 2 раза быстрее. А простое дописывание vectorize(assume_safety) к циклу почти ничего не даёт (от 0 до 15%, но чаще 0).
      Также в варианте с 2*2 я ошибся и не использовал y_pixel_stride, uv_row_stride, uv_pixel_stride - из-за этого и пропали vpinsrb. Все эти параметры дают гибкость, но с ними приходится брать по байтику, компилятор не может быть уверен, что данные лежат последовательно. Впрочем, по факту простыня vpinsrb не делает хуже, ускоряют именно 2 пикселя за итерацию.
      Хотя 2 раза - тоже далеко не предел мечтаний, но больше не хочется тратить на это времени.


  1. Kyushu
    19.05.2022 07:43
    +2

    Полезная информация. Утащил в закладки.