Данная статья является вольным переводом статьи Optimizing C++/Code optimization/Faster operations. Оригинал найти можно по ссылке.


Предисловие


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


Часть 1


Расположение членов в структурах/классах


Распологать члены в структурах/классах, стоит таким образом, чтобы наиболее используемые переменные находились в первых 128 байтах, а затем отсортировать от самого большого члена до самого маленького.


Предположим, что есть некая структура:


struct {
    char msg[400];
    double d;
    int i;
};

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


struct {
    double d;
    int i;
    char msg[400];
};

На некоторых процессорах адресация элемента более эффективна, если его расстояние от начала структуры меньше 128 байт. В первом примере для адресации полей d и i, с использованием указателя на начало структуры, требуется смещение не менее 400 байт, тогда как во втором, смещения составляет всего несколько байтов, что позволяет использовать более компактные инструкции.


Другой пример:


struct {
    bool b;
    double d;
    short s;
    int i;
};

Если посчитать размер структуры, 1 (bool) + 7 (padding) + 8 (double) + 2 (short) + 2 (padding) + 4 (int), то мы получим 24 байта, 9 из котороых будут использованы для выравнивания, что относительно размера структуры довольно много. Давайте перепишем эту же структуру таким образом:


struct {
    double d;
    int i;
    short s;
    bool b;
};

Снова проведем подсчет — 8 (double) + 4 (int) + 2 (short) + 1 (bool) + 1 (padding). Теперь наша структура займет 16 байт. Сортировка устранила ненужные вызванные, и поэтому мы получили более компактную структуру.


Преобразование типа с плавающей точкой в целочисленный


Язык C++ не предоставляет примитивную операцию округления чисел с плавающей точкой. Самым простым методом преобразования числа с плавающей точкой x в ближайшее целое число n будет оператор:


n = int(floor(x + 0.5f));

Используя такой метод, если x будет точно посередине между двумя целыми числами, то n будет округлено в большую сторону. Например, 0,5 -> 1; 1,5 -> 2; -0,5 -> 0; -1,5 -> -1;...


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


#if defined(__unix__) || defined(__GNUC__)
    // For 32-bit Linux, with Gnu/AT&T syntax
    __asm ("fldl %1 \n fistpl %0 " : "=m"(n) : "m"(x) : "memory" );
#else
    // For 32-bit Windows, with Intel/MASM syntax
    __asm fld qword ptr x;
    __asm fistp dword ptr n;
#endif

Код, приведенный выше, округляет x до ближайшего целого числа, но если значение x находится точно между целыми числами, то n будет ближайшим четным целым числом. Например, 0,5 -> 0; 1,5 -> 2; -0,5 -> 0; -1,5 -> -2;...


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


Работа с битами


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


Размер ячеек массива


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


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


Однако, при доступе к ячейкам многомерного массива, в котором константа достигает достаточной большой степени двойки, то вы рискуете конфликт данных(data cache contention), что может замедлить вычисление в 10 и более раз. Это происходит только тогда, когда ячейки массива превышают определенный размер, который зависит от кэша данных, но составляет от 1 до 8 КБ. Следовательно, в случае, если алгоритм должен обрабатывать массив, чьи ячейки имеют или могут иметь размер равный 1024 байта, то стоит определить, имеет ли место конфликт данных, чтобы попробовать его избежать.


Например, матрица размером 100 x 512 float — это 100 массивов по 512 эелементов. Каждая ячейка массива первого уровня имеет размер 512 x 4 = 2048 байт, и поэтому она подвержена риску конфликта данных.


Чтобы обнаружить конкуренцию, достаточно добавить еще одну ячейку (float) в каждый массив из массива последнего уровня, но при этом обрабатывать те же ячейки, что и раньше, и измерять, существенно ли сокращается время обработки (по меньшей мере на 20% ). Если это так, то вы должны обеспечить более стабильную работу такого улучшения. Для этой цели вы можете использовать один из следующих методов:


  • Добавьте один или несколько неиспользуемых елементов в конец каждого массива последнего уровня. Например, массив double a [100] [1024] может стать double a [100] [1026], даже если полезные данные будут находиться также до 1024.
  • Храните размеры массива, разделяйте его на прямоугольные блоки и обрабатывайте все ячейки в одном блоке за раз.

Продолжение во второй части.

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


  1. Jamdaze
    05.10.2017 15:51
    +6

    Хотелось бы видеть проверку ваших утверждений в реальных тестах.


    1. mrobespierre
      05.10.2017 17:09
      +1

      вот да, я хоть и не большой спец в Си, но почти уверен, что gcc в режиме O2 (возможно и меньших) о таких вещах заботится, нужны тесты.


      1. genge Автор
        05.10.2017 17:17
        -1

        От себя могу сказать, что протестил пока только кейс с размером структуры (g++ -std=c++17), результат как и описано в статье (если правильно расположить члены структуры, то размер ее уменьшается)


        1. lorc
          05.10.2017 19:14

          Да, потому что это поведение прописано в стандарте, и соответственно, оптимизации не него не влияют.


  1. SyDr
    05.10.2017 16:16
    +7

    Язык C++ не предоставляет примитивную операцию округления чисел с плавающей точкой.

    round


  1. DareDen
    05.10.2017 16:16
    +9

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


    1. Jamdaze
      06.10.2017 09:28

      Опять фиолетовая ссылка =(


  1. lorc
    05.10.2017 16:54
    +5

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

    Не надо. Пожалуйста. Потому что
    переносимость на другие семейства процессоров оставляет желать лучшего.

    Уже давно прошли времена, когда можно было тестировать/запускать код только на х86.

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


  1. Gumanoid
    07.10.2017 01:41

    n = int(floor(x + 0.5f));
    Зачем здесь floor?

    fistp, который, как и в следующем коде, дает гораздо более быстрый
    x87 в 2017 году?


  1. DeadKnight
    08.10.2017 00:34
    -1

    По поводу округлений, автор предлагает сейчас писать процессорозависимый код. Отличное решение! Пусть программист пишет под все типы процессоров, которые на текущий момент имеются. А про преждевременную оптимизацию — это для лохов сказано. И не важно, скажем, что программа будет терять по 10-15 секунд на постоянных обращениях к БД, зато на процессорах семейства Pentium, она будет выполнять округление на 0.00001 с быстрее.

    И на счет банковского округления мне понравилось. Автор решил, что это самый «правильный» метод округления, значит тому и быть.