Как использовать инструкции процессора в C++ для реализации быстрых арифметических операций

Я работал над реализацией c++ схемы разделения секрета Шамира. Я разделяю сообщение на 8-битные куски и на каждом выполняет соответствующую арифметику. Основной конечное поле, конечное поле Rijndael по F_256 / (х^8 + х^4 + х^3 + х + 1).

Я сделал быстрый поиск, если есть какая-то известная и распространенная библиотека для расчетов конечного поля Риндаля (e. г. OpenSSL или аналогичный), и не нашел ни одного. Поэтому я реализовал его с нуля, частично как программирование упражнение. Однако несколько дней назад профессор нашего университета упомянул следующее: "современные процессоры поддерживают целочисленные операции без переноса, поэтому умножение конечного поля характеристики-2 в настоящее время работает быстро.".

следовательно, поскольку я мало знаю об оборудовании, ассемблере и подобных вещах, мой вопрос: Как я на самом деле использую (в C++) все инструкции современных процессоров при создании криптографического программного обеспечения - будь то AES, SHA, арифметика сверху или что-то еще? Я не могу найти удовлетворительных ресурсов. Моя идея-создать библиотеку, содержащую как "быструю реализацию современного подхода", так и откат "чистый код без зависимостей C++" и пусть GNU Autoconf решает, какой из них использовать на каждом соответствующем хосте. Любая рекомендация книги/статьи/учебника по этой теме будет оценена по достоинству.

1 ответов


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

Признание Фразеологизм

выпишите операцию, не предлагаемую непосредственно на C++, в" длинной форме " и надейтесь, что ваш компилятор распознает ее как идиому для базовой инструкции, которую вы хотите. Например, можно написать переменная поворот слева от x by amount as (x << amount) | (x >> (32 - amount)) и все gcc, clang и icc признают это в качестве поворота и выдачи базового rol инструкция поддерживается x86.

иногда этот метод ставит вас в неудобное положение: вышеупомянутая реализация поворота C++ показывает неопределенное поведение на amount == 0 (а также amount >= 32) так как результат сдвига 32 на uint32_t не определено, но код на самом деле произведен эти компиляторы в этом случае просто отлично. Тем не менее, в этом скрывается неопределенное поведение в вашей программе-это опасно, и это, вероятно, не будет работать по отношению системные и друзей. Альтернативная безопасная версия amount ? (x << amount) | (x >> (32 - amount)) : x; распознается только icc, но не gcc или clang.

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

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

Встроенные Функции

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

GCC называет это встроенные функции и вы можете найти список общие здесь. Например, вы можете использовать __builtin_popcnt вызов испустить popcnt обучение, если текущая цель его поддерживает. Человек из GCC builtins также поддерживается icc и clang, и в этом случае все gcc, clang и icc поддержите этот звонок и испустите popcnt пока архитектура (-march=Haswell) имеет значение Haswell. В противном случае clang и icc встроят версию замены, используя некоторые умные трюки SWAR, в то время как gcc вызывает __popcountdi2 который предоставляется средой выполнения1.

список встроенных компонентов выше является общим и обычно предлагается на любой платформе, поддерживаемой компиляторами. Вы также можете найти платформу конкретные instrinics, например этот список от gcc.

для инструкций x86 SIMD, в частности, Intel предоставляет набор встроенные функции объявленные заголовки, охватывающие их расширения ISA, например, путем включения #include <x86intrin.h>. Они имеют более широкую поддержку, чем GCC instrinsics, например, они поддерживаются Microsoft Visual Studio compiler suite. Новые наборы инструкций обычно добавляются перед чипами, которые их поддерживают, поэтому вы можете используйте их для доступа к новым инструкциям сразу после выпуска.

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

Inline Собрание

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

вне линии сборки

вы также можете просто написать всю свою функцию ядра в сборке, собрать ее так, как вы хотите, а затем объявить ее extern "C" и вызовите его с C++. Это похоже на параметр встроенной сборки, но работает на компиляторах, которые не поддерживают встроенную сборку (например, 64-разрядная Visual Studio). Вы также можете использовать другой ассемблер, если хотите, что особенно важно удобно, если вы нацелены на несколько компиляторов C++, так как вы можете использовать один ассемблер для всех из них.

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

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

Авто-Векторизации4

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

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

рассмотрим эту простую функцию с takes payload и key массив байтов и xors key в полезную нагрузку:

void otp_scramble(uint8_t* payload, uint8_t* key, size_t n) {
    for (size_t i = 0; i < n; i++) {
        payload[i] ^= key[i];
    }
}

это пример софтбола, конечно, но в любом случае gcc, clang и icc все векторизуют это до что-то вроде этот внутренний петля4:

  movdqu xmm0, XMMWORD PTR [rdi+rax]
  movdqu xmm1, XMMWORD PTR [rsi+rax]
  pxor xmm0, xmm1
  movups XMMWORD PTR [rdi+rax], xmm0

он использует инструкции SSE для загрузки и xor 16 байтов за раз. Разработчик должен только рассуждать о простом скалярном коде, однако!

одним из преимуществ этого подхода по сравнению с внутренними компонентами или сборкой является то, что вы не выпекаете длину SIMD набора инструкций на исходном уровне. Тот же код C++, что и выше, скомпилирован с -march=haswell результаты в цикле, такие как:

  vmovdqu ymm1, YMMWORD PTR [rdi+rax]
  vpxor ymm0, ymm1, YMMWORD PTR [rsi+rax]
  vmovdqu YMMWORD PTR [rdi+rax], ymm0

он использует инструкции AVX2 доступно на Haswell делать 32 байта за раз. Если вы компилируете с -march=skylake-avx512 clang использует 64-байтовый vxorps инструкции zmm регистры (но GCC и icc придерживаются 32-байтовых внутренних циклов). Так что в принципе вы можете воспользоваться новой ISA просто с перекомпиляцией.

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

иногда компилятор принимает "интересные" решения, когда он векторизуется, например, небольшое не развернутое тело для внутреннего цикла, но затем гигантское "вступление" или " завершение" обработка нечетных итераций, например what gcc производит после первого цикла показано ниже:

  movzx ecx, BYTE PTR [rsi+rax]
  xor BYTE PTR [rdi+rax], cl
  lea rcx, [rax+1]
  cmp rdx, rcx
  jbe .L1
  movzx r8d, BYTE PTR [rsi+1+rax]
  xor BYTE PTR [rdi+rcx], r8b
  lea rcx, [rax+2]
  cmp rdx, rcx
  jbe .L1
  movzx r8d, BYTE PTR [rsi+2+rax]
  xor BYTE PTR [rdi+rcx], r8b
  lea rcx, [rax+3]
  cmp rdx, rcx
  jbe .L1
  movzx r8d, BYTE PTR [rsi+3+rax]
  xor BYTE PTR [rdi+rcx], r8b
  lea rcx, [rax+4]
  cmp rdx, rcx
  jbe .L1
  movzx r8d, BYTE PTR [rsi+4+rax]
  xor BYTE PTR [rdi+rcx], r8b
  lea rcx, [rax+5]
  cmp rdx, rcx
  jbe .L1
  movzx r8d, BYTE PTR [rsi+5+rax]
  xor BYTE PTR [rdi+rcx], r8b
  lea rcx, [rax+6]
  cmp rdx, rcx
  jbe .L1
  movzx r8d, BYTE PTR [rsi+6+rax]
  xor BYTE PTR [rdi+rcx], r8b
  lea rcx, [rax+7]
  cmp rdx, rcx
  jbe .L1
  movzx r8d, BYTE PTR [rsi+7+rax]
  xor BYTE PTR [rdi+rcx], r8b
  lea rcx, [rax+8]
  cmp rdx, rcx
  jbe .L1
  movzx r8d, BYTE PTR [rsi+8+rax]
  xor BYTE PTR [rdi+rcx], r8b
  lea rcx, [rax+9]
  cmp rdx, rcx
  jbe .L1
  movzx r8d, BYTE PTR [rsi+9+rax]
  xor BYTE PTR [rdi+rcx], r8b
  lea rcx, [rax+10]
  cmp rdx, rcx
  jbe .L1
  movzx r8d, BYTE PTR [rsi+10+rax]
  xor BYTE PTR [rdi+rcx], r8b
  lea rcx, [rax+11]
  cmp rdx, rcx
  jbe .L1
  movzx r8d, BYTE PTR [rsi+11+rax]
  xor BYTE PTR [rdi+rcx], r8b
  lea rcx, [rax+12]
  cmp rdx, rcx
  jbe .L1
  movzx r8d, BYTE PTR [rsi+12+rax]
  xor BYTE PTR [rdi+rcx], r8b
  lea rcx, [rax+13]
  cmp rdx, rcx
  jbe .L1
  movzx r8d, BYTE PTR [rsi+13+rax]
  xor BYTE PTR [rdi+rcx], r8b
  lea rcx, [rax+14]
  cmp rdx, rcx
  jbe .L1
  movzx eax, BYTE PTR [rsi+14+rax]
  xor BYTE PTR [rdi+rcx], al

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

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


1 преимущество подхода gcc здесь в том, что во время если платформа поддерживает popcnt этот вызов может разрешить реализацию, которая просто использует popcnt инструкция, с помощью GNU IFUNC механизм.

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

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

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

5 с незначительными отличиями: gcc, как показано, немного развернулся, и icc использовал load-op pxor вместо отдельной нагрузки.