Введение
Деление — достаточно затратная операция. Например, на CPU Cannon Lake задержки 32-битного деления находятся в интервале 10-15 тактов, а на Zen4 — 9-14 тактов. Задержки 32-битного умножения на обоих CPU составляют 3-4 такта.
Ни в одном из популярных ISA SIMD (SSE, AVX, AVX-512, ARM Neon, ARM SVE) нет целочисленного деления, оно есть только в RISC-V Vector Extension. Однако во всех этих ISA есть деление с плавающей запятой.
В этой статье мы представим два подхода к реализации SIMD-деления 8-битных беззнаковых чисел:
-
с помощью деления с плавающей запятой,
-
с помощью алгоритма деления столбиком.
Мы попробуем векторизировать показанную ниже процедуру C++. Процедура не может делать никаких допущений о делимых, особенно если все они равны. Таким образом, мы не можем использовать деление на константу.
void scalar_div_u8(const uint8_t* a, const uint8_t* b, uint8_t* out, size_t n) { for (size_t i=0; i < n; i++) { out[i] = a[i] / b[i]; } }
Компиляторы не могут векторизировать эту процедуру. Например, GCC 14.1.0 генерирует такой ассемблерный код (вырезан весь мусор, связанный с выравниванием кода):
2b40: 48 85 c9 test %rcx,%rcx 2b43: 74 30 je 2b75 <_Z13scalar_div_u8PKhS0_Phm+0x35> 2b45: 45 31 c0 xor %r8d,%r8d 2b60: 42 0f b6 04 07 movzbl (%rdi,%r8,1),%eax 2b65: 42 f6 34 06 divb (%rsi,%r8,1) 2b69: 42 88 04 02 mov %al,(%rdx,%r8,1) 2b6d: 49 ff c0 inc %r8 2b70: 4c 39 c1 cmp %r8,%rcx 2b73: 75 eb jne 2b60 <_Z13scalar_div_u8PKhS0_Phm+0x20> 2b75: c3 ret
Операции с плавающей запятой
8-битное число можно без потери точности преобразовать в число с плавающей запятой одинарной точности.
Общий процесс деления состоит из следующих этапов:
-
преобразуем тип 8-битных делимого и делителя в 32-битные integer,
-
преобразуем беззнаковые integer в числа с плавающей запятой,
-
выполняем деление с плавающей запятой,
-
преобразуем результат с плавающей запятой в 32-битные integer,
-
выполняем обратное преобразование типа 32-битного integer в 8-битный окончательный результат.
Деление с округлением
Вот настоящая реализация процедуры SSE. Стоит отметить, что перед обратным преобразованием в integer нужно явным образом усечь число с плавающей запятой. По умолчанию это преобразование округляет аргумент, так что мы можем получить ошибочные результаты (смещённые на 1).
-
Загружаем четыре 8-битных делимых.
uint32_t buf_a; memcpy(&buf_a, &a[i], 4);
-
И четыре 8-битных делителя.
uint32_t buf_b; memcpy(&buf_b, &b[i], 4);
-
Переносим их в регистр SSE и преобразуем тип в 32-битный.
const __m128i a_u8 = _mm_cvtsi32_si128(buf_a); const __m128i a_u32 = _mm_cvtepu8_epi32(a_u8); const __m128i b_u8 = _mm_cvtsi32_si128(buf_b); const __m128i b_u32 = _mm_cvtepu8_epi32(b_u8);
-
Преобразуем тип 32-битных integer во float.
const __m128 a_f32 = _mm_cvtepi32_ps(a_u32); const __m128 b_f32 = _mm_cvtepi32_ps(b_u32);
-
Выполняем деление, а затем усечение.
const __m128 c_f32 = _mm_div_ps(a_f32, b_f32); const __m128 c_f32_2 = _mm_round_ps(c_f32, _MM_FROUND_TO_ZERO | _MM_FROUND_NO_EXC);
-
Преобразуем float обратно в integer.
const __m128i c_i32 = _mm_cvtps_epi32(c_f32_2);
-
Преобразуем 32-битный тип в 8-битный: собираем младшие 8-битные числа в одно 32-битное слово и сохраняем его в выходной массив.
const __m128i c_u8 = _mm_shuffle_epi8(c_i32, _mm_setr_epi8( 0, 4, 8, 12, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1 )); const uint32_t buf = _mm_cvtsi128_si32(c_u8); memcpy(&out[i], &buf, 4);
Деление без округления
Команда округления ROUNDPS имеет довольно большую задержку, по крайней мере, на CPU Intel. На IceLake — 8 тактов, а на Zen4 — всего 3 такта.
Мы можем избежать округления чисел плавающей запятой, умножив делимое на 256 (выполнив сдвиг влево на 8 битов), а затем сдвинув вправо на 8 окончательный результат. Сдвиг вправо можно выполнить без лишних затрат, потому что мы и так используем перетасовку для получения отдельных битов, так что вопрос только в константе. Сдвиг влево на 8 подходит только для SSE-кода — мы можем использовать перетасовку байтов (byte shuffle), чтобы выполнить сдвиг и расширение целых чисел. В случае AVX2-кода перетасовка байтов выполняется на 128-битных трактах, поэтому нам потребуется больше работы, чтобы подготовить ввод для этой операции.
Эта SSE-процедура практически идентична процедуре из предыдущего раздела:
-
Загружаем четыре делимых.
uint32_t buf_a; memcpy(&buf_a, &a[i], 4); const __m128i a_u8 = _mm_cvtsi32_si128(buf_a);
-
Преобразуем делимое << 8 в 32-битные числа.
const __m128i a_u32 = _mm_shuffle_epi8(a_u8, _mm_setr_epi8( -1, 0, -1, -1, -1, 1, -1, -1, -1, 2, -1, -1, -1, 3, -1, -1 ));
-
Загружаем четыре делителя и преобразуем их в 32-битные числа.
uint32_t buf_b; memcpy(&buf_b, &b[i], 4); const __m128i b_u8 = _mm_cvtsi32_si128(buf_b); const __m128i b_u32 = _mm_cvtepu8_epi32(b_u8);
-
Преобразуем тип всех 32-битных integer во float.
const __m128 a_f32 = _mm_cvtepi32_ps(a_u32); const __m128 b_f32 = _mm_cvtepi32_ps(b_u32);
-
Выполняем деление.
const __m128 c_f32 = _mm_div_ps(a_f32, b_f32);
-
Преобразуем результат деления в 32-битные integer.
const __m128i c_i32 = _mm_cvtps_epi32(c_f32);
-
Преобразуем результат деления >> 8 в 8-битные числа: получаем бит 1 каждого 32-битного слова.
const __m128i c_u8 = _mm_shuffle_epi8(c_i32, _mm_setr_epi8( 0 + 1, 4 + 1, 8 + 1, 12 + 1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1 )); const uint32_t buf = _mm_cvtsi128_si32(c_u8); memcpy(&out[i], &buf, 4);
Использование приближенной обратной величины
В SSE есть команда RCPPS, вычисляющая приближенную инверсию её аргумента: 1/x. Она позволит нам использовать выражение делимое ⋅ approx(1/делитель).
В спецификации говорится, что относительная погрешность не превышает 1.5 ⋅ 2 − 12. Но в нашей задаче абсолютная погрешность оказывается слишком большой, чтобы использовать результат команды напрямую. В таблице ниже показаны результаты для первых x значений, для которых погрешность значима.
Однако путём проб и ошибок мы выяснили, что после умножения делимого на значение 1.00025 результат RCPPS можно использовать. Если точнее, подойдёт любой коэффициент от 1.00024 до 1.00199.
x |
1 / x |
approx 1 / x |
error |
||
---|---|---|---|---|---|
float |
hex |
float |
hex |
||
1 |
1.000000 |
3f800000 |
0.999756 |
3f7ff000 |
0.000244 |
2 |
0.500000 |
3f000000 |
0.499878 |
3efff000 |
0.000122 |
3 |
0.333333 |
3eaaaaab |
0.333313 |
3eaaa800 |
0.000020 |
4 |
0.250000 |
3e800000 |
0.249939 |
3e7ff000 |
0.000061 |
5 |
0.200000 |
3e4ccccd |
0.199951 |
3e4cc000 |
0.000049 |
6 |
0.166667 |
3e2aaaab |
0.166656 |
3e2aa800 |
0.000010 |
7 |
0.142857 |
3e124925 |
0.142822 |
3e124000 |
0.000035 |
8 |
0.125000 |
3e000000 |
0.124969 |
3dfff000 |
0.000031 |
9 |
0.111111 |
3de38e39 |
0.111084 |
3de38000 |
0.000027 |
10 |
0.100000 |
3dcccccd |
0.099976 |
3dccc000 |
0.000024 |
11 |
0.090909 |
3dba2e8c |
0.090897 |
3dba2800 |
0.000012 |
12 |
0.083333 |
3daaaaab |
0.083328 |
3daaa800 |
0.000005 |
13 |
0.076923 |
3d9d89d9 |
0.076920 |
3d9d8800 |
0.000004 |
14 |
0.071429 |
3d924925 |
0.071411 |
3d924000 |
0.000017 |
15 |
0.066667 |
3d888889 |
0.066650 |
3d888000 |
0.000016 |
16 |
0.062500 |
3d800000 |
0.062485 |
3d7ff000 |
0.000015 |
17 |
0.058824 |
3d70f0f1 |
0.058807 |
3d70e000 |
0.000016 |
18 |
0.055556 |
3d638e39 |
0.055542 |
3d638000 |
0.000014 |
19 |
0.052632 |
3d579436 |
0.052620 |
3d578800 |
0.000012 |
20 |
0.050000 |
3d4ccccd |
0.049988 |
3d4cc000 |
0.000012 |
… |
|||||
255 |
0.003922 |
3b808081 |
0.003922 |
3b808000 |
0.000000 |
Деление столбиком
Алгоритм деления столбиком известен нам со школы.
-
Мы начинаем с остатка и результата деления, равного нулю.
-
Затем на каждом этапе мы расширяем остаток, добавляя в конец следующий разряд делимого, начиная с самых старших разрядов.
-
Если остаток оказывается больше делителя, то мы вычисляем q := остаток/делитель. Это число, разряд, находится в интервале [0, 9] для десятичного деления и равно 0 или 1 для двоичного деления.
-
Выполняем декремент остатка на q ⋅ divisor.
-
Разряд q мы добавляем в начало результата деления.
-
Если в делимом ещё остаются разряды, переходим к пункту 2.
Удобное свойство этого алгоритма заключается в вычислении и результата деления, и остатка. Его недостатки:
-
Каждый этап зависит от предыдущего, из-за чего сложно использовать внеочередное выполнение в CPU.
-
Количество итераций равно количество значимых разрядов в делимом минус количество разрядов в делителе плюс 1. В наихудшем случае оно равно количеству разрядов в делимом.
Так как мы будем делить 8-битные числа, основной этап алгоритма должен повторяться восемь раз. Вот референсная реализация:
uint8_t long_div_u8(uint8_t dividend, uint8_t divisor) { uint8_t reminder = 0; uint8_t quotient = 0; for (int i=7; i >= 0; i--) { // Освобождаем место для i-того бита делимого в 0-й позиции reminder <<= 1; // вставляем этот бит reminder |= (dividend >> i) & 1; if (reminder >= divisor) { // задаём i-тый бит в результате деления quotient |= 1 << i; // изменяем остаток reminder -= divisor; } } return quotient; }
Деление столбиком можно применять и к целым числам со знаком. Нужно вычислить абсолютные значения делимого и делителя, а затем выполнить алгоритм. Если операнды имели разные знаки, то результат нужно сделать отрицательным.
Таккак 8-битные целые числа со знаком находятся в интервале от − 128 до 127, интервал их абсолютных значений равен 0…128. То есть операнды деления в столбик всё равно будут иметь восемь битов.
Векторизация
Алгоритм состоит из следующих операций:
-
Извлекаем i-тый бит из делителя и сдвигаем его в остаток:
reminder = (reminder << 1) | ((dividend >> i) & 1);
-
Сравниваем остаток и делитель;
-
Условным образом задаём i-тый бит в результате деления и изменяем остаток:
quotient |= 1 << i; reminder -= divisor;
SSE и AVX2
Этап 1: обновление остатка
В SSE и AVX2 можно очень легко скопировать самый старший бит в самый младший бит 0. Мы сравниваем с нулём число, интерпретируемое как единица со знаком. В результате получаем 0x00 или 0xff ( − 1).
Можно переписать основной цикл так, чтобы делимое сдвигалось влево на 1, благодаря чему мы сможем скопировать этим способом все его биты.
Сдвиг влево на 1 — это простое сложение, то есть очень быстрая операция.
Этап 2: сравнение
В SSE и AVX2 нет беззнакового сравнения, только со знаком. Можно сравнить два беззнаковых числа сравнением со знаком: нужно обратить их самые старшие биты. Это выполняется операции XOR с 0x80.
Стоит отметить, что нам придётся выполнять xor один раз для делителя и восемь раз для остатка.
Этап 3: условные операции
Получив после сравнения маску, мы можем легко вычислить операции с масками.
На самом деле, нам нужен безусловный сдвиг результата деления на 1 с последующим условным заданием i-того бита.
Реализация (SSE)
В представленном ниже коде на C показаны все подробности реализации.
uint8_t long_div_u8(uint8_t dividend, uint8_t divisor) { uint8_t reminder = 0; uint8_t quotient = 0; uint8_t bit = 0x80; uint8_t divisor_xored = divisor ^ 0x80; for (int i=7; i >= 0; i--) { // msb => 0 или -1 const uint8_t msb = (int8_t)dividend < 0 ? 0xff : 0x00; // вставляем бит reminder -= msb; if ((int8_t)(reminder ^ 0x80) >= (int8_t)(divisor_xored) { // задаём i-тый бит в результате деления quotient |= bit; // изменяем остаток reminder -= divisor; } bit >>= 1; dividend <<= 1; // освобождаем место для i-того бита делимого в 0-й позиции reminder <<= 1; } return quotient; }
Настоящая реализация на SSE.
void sse_long_div_u8(const uint8_t* A, const uint8_t* B, uint8_t* C, size_t n) { const __m128i msb = _mm_set1_epi8(int8_t(0x80)); const __m128i zero = _mm_set1_epi8(0x00); for (size_t i=0; i < n; i += 16) { __m128i dividend = _mm_loadu_si128((__m128i*)(&A[i])); const __m128i divisor = _mm_loadu_si128((__m128i*)(&B[i])); __m128i divisor_xored = _mm_xor_si128(divisor, msb); __m128i bit = msb; __m128i reminder = _mm_set1_epi16(0); __m128i quotient = _mm_set1_epi16(0); for (int j=0; j < 8; j++) { // копируем msb делимого в остаток const __m128i t0 = _mm_cmplt_epi8(dividend, zero); reminder = _mm_sub_epi8(reminder, t0); // беззнаковое сравнение делителя и остатка const __m128i reminder_xored = _mm_xor_si128(reminder, msb); const __m128i gt = _mm_cmpgt_epi8(divisor_xored, reminder_xored); // получаем условное вычитание и бит результата деления const __m128i cond_divisor = _mm_andnot_si128(gt, divisor); const __m128i cond_quotient_bit = _mm_andnot_si128(gt, bit); // условным образом обновляем остаток и результат деления reminder = _mm_sub_epi16(reminder, cond_divisor); quotient = _mm_or_si128(quotient, cond_quotient_bit); // следующий бит для результата деления bit = _mm_srli_epi32(bit, 1); // помещаем следующий бит из делимого в MSB dividend = _mm_add_epi8(dividend, dividend); // освобождаем место для бита из делимого reminder = _mm_add_epi32(reminder, reminder); } _mm_storeu_si128((__m128i*)(&C[i]), quotient); } }
AVX-512
Этап 1: обновляем остаток
В отличие от кода для SSE/AVX2, здесь проще выполнять сдвиг вправо, чтобы поместить i-тый бит в нулевую позицию. Значит, изолирование самого младшего бита и объединение его с результатом деления можно выразить одной тернарной операцией.
reminder <<= 1; t0 = divisor >> i; reminder = reminder | (t0 & 1); // тернарная операция
Этап 2: сравнение
AVX512 поддерживает беззнаковое сравнение байтов, в результате которого мы получаем маску.
Этап 3: условные операции
Простое использование операций с масками.
Реализация
Показана настоящая реализация с AVX512. В отличие от SSE-кода, внутренний цикл развёрнут вручную. Кроме того, здесь нет явного использования встроенной функции с тернарной логикой, однако взглянув на ассемблерный код, можно увидеть, что компилятор выполнил слияние двоичной операции.
void avx512_long_div_u8(const uint8_t* A, const uint8_t* B, uint8_t* C, size_t n) { const __m512i one = _mm512_set1_epi8(1); for (size_t i=0; i < n; i += 64) { const __m512i dividend = _mm512_loadu_si512((const __m512*)(&A[i])); const __m512i divisor = _mm512_loadu_si512((const __m512*)(&B[i])); const __m512i dividend_bit7 = _mm512_and_epi32(_mm512_srli_epi32(dividend, 7), one); const __m512i dividend_bit6 = _mm512_and_epi32(_mm512_srli_epi32(dividend, 6), one); const __m512i dividend_bit5 = _mm512_and_epi32(_mm512_srli_epi32(dividend, 5), one); const __m512i dividend_bit4 = _mm512_and_epi32(_mm512_srli_epi32(dividend, 4), one); const __m512i dividend_bit3 = _mm512_and_epi32(_mm512_srli_epi32(dividend, 3), one); const __m512i dividend_bit2 = _mm512_and_epi32(_mm512_srli_epi32(dividend, 2), one); const __m512i dividend_bit1 = _mm512_and_epi32(_mm512_srli_epi32(dividend, 1), one); const __m512i dividend_bit0 = _mm512_and_epi32(_mm512_srli_epi32(dividend, 0), one); __m512i quotient = _mm512_set1_epi32(0); __m512i reminder = dividend_bit7; { const __mmask64 ge = _mm512_cmpge_epu8_mask(reminder, divisor); reminder = _mm512_mask_sub_epi8(reminder, ge, reminder, divisor); quotient = _mm512_mask_add_epi8(quotient, ge, quotient, one); } reminder = _mm512_add_epi32(reminder, reminder); reminder = _mm512_or_epi32(reminder, dividend_bit6); { const __mmask64 ge = _mm512_cmpge_epu8_mask(reminder, divisor); reminder = _mm512_mask_sub_epi8(reminder, ge, reminder, divisor); quotient = _mm512_add_epi32(quotient, quotient); quotient = _mm512_mask_add_epi8(quotient, ge, quotient, one); } reminder = _mm512_add_epi32(reminder, reminder); reminder = _mm512_or_epi32(reminder, dividend_bit5); { const __mmask64 ge = _mm512_cmpge_epu8_mask(reminder, divisor); reminder = _mm512_mask_sub_epi8(reminder, ge, reminder, divisor); quotient = _mm512_add_epi32(quotient, quotient); quotient = _mm512_mask_add_epi8(quotient, ge, quotient, one); } reminder = _mm512_add_epi32(reminder, reminder); reminder = _mm512_or_epi32(reminder, dividend_bit4); { const __mmask64 ge = _mm512_cmpge_epu8_mask(reminder, divisor); reminder = _mm512_mask_sub_epi8(reminder, ge, reminder, divisor); quotient = _mm512_add_epi32(quotient, quotient); quotient = _mm512_mask_add_epi8(quotient, ge, quotient, one); } reminder = _mm512_add_epi32(reminder, reminder); reminder = _mm512_or_epi32(reminder, dividend_bit3); { const __mmask64 ge = _mm512_cmpge_epu8_mask(reminder, divisor); reminder = _mm512_mask_sub_epi8(reminder, ge, reminder, divisor); quotient = _mm512_add_epi32(quotient, quotient); quotient = _mm512_mask_add_epi8(quotient, ge, quotient, one); } reminder = _mm512_add_epi32(reminder, reminder); reminder = _mm512_or_epi32(reminder, dividend_bit2); { const __mmask64 ge = _mm512_cmpge_epu8_mask(reminder, divisor); reminder = _mm512_mask_sub_epi8(reminder, ge, reminder, divisor); quotient = _mm512_add_epi32(quotient, quotient); quotient = _mm512_mask_add_epi8(quotient, ge, quotient, one); } reminder = _mm512_add_epi32(reminder, reminder); reminder = _mm512_or_epi32(reminder, dividend_bit1); { const __mmask64 ge = _mm512_cmpge_epu8_mask(reminder, divisor); reminder = _mm512_mask_sub_epi8(reminder, ge, reminder, divisor); quotient = _mm512_add_epi32(quotient, quotient); quotient = _mm512_mask_add_epi8(quotient, ge, quotient, one); } reminder = _mm512_add_epi32(reminder, reminder); reminder = _mm512_or_epi32(reminder, dividend_bit0); { const __mmask64 ge = _mm512_cmpge_epu8_mask(reminder, divisor); reminder = _mm512_mask_sub_epi8(reminder, ge, reminder, divisor); quotient = _mm512_add_epi8(quotient, quotient); quotient = _mm512_mask_add_epi8(quotient, ge, quotient, one); } _mm512_storeu_si512((__m512*)&C[i], quotient); } }
Результаты эксперимента
Краткие итоги:
-
На CPU Intel быстрее всех оказалась реализация деления в столбик с AVX-512.
-
На Ryzen быстрее всех был способ с приближенным обратным значением AVX2.
-
Стоит отметить, что автоматическая векторизация GCC создавала более качественный по сравнению с написанными вручную вариантами AVX2, или сравнимый с ними.
Все программы бенчмарков компилировались с опциями -O3 -march=native
отдельно на каждой машине.
Процедура |
Комментарии |
---|---|
scalar |
простое 8-битное деление |
scalar (unrolled x 4) |
деление, развёрнутое вручную 4 раза |
scalar (long div) |
скалярная реализация деления столбиком с отключенной автовекторизацией |
scalar (long div, autovect) |
скалярная реализация деления столбиком с автовекторизацией |
SSE |
|
SSE (no rounding) |
деление без округления (делимое умножается на 256) |
SSE (cvtt) |
деление с последующим преобразованием типов и усечением (CVTTPS2DQ) |
SSE (rcp) |
|
SSE long div |
|
AVX2 |
|
AVX2 (cvtt) |
деление с последующим преобразованием типов и усечением (CVTTPS2DQ) |
AVX2 (rcp) |
|
AVX2 long div |
|
AVX512 (cvtt) |
деление с последующим преобразованием типов и усечением (CVTTPS2DQ) |
AVX512 (rcp) |
|
AVX512 long div |
Ryzen 7
-
Компилятор: gcc (Debian 14.1.0-5) 14.1.0
-
CPU: AMD Ryzen 7 7730U с Radeon Graphics
процедура |
время в тактах на байт |
ускорение |
||
---|---|---|---|---|
|
среднее |
лучшее |
|
|
scalar |
1.777 |
1.759 |
1.0 |
████▊ |
scalar (unrolled x 4) |
1.881 |
1.869 |
0.9 |
████▌ |
scalar (long div) |
5.548 |
5.523 |
0.3 |
█▌ |
scalar (long div, autovect) |
0.418 |
0.414 |
4.2 |
████████████████████▍ |
SSE |
0.372 |
0.367 |
4.8 |
███████████████████████ |
SSE (no rounding) |
0.334 |
0.331 |
5.3 |
█████████████████████████▌ |
SSE (rcp) |
0.330 |
0.327 |
5.4 |
█████████████████████████▉ |
SSE long div |
0.751 |
0.741 |
2.4 |
███████████▍ |
AVX2 |
0.220 |
0.218 |
8.1 |
██████████████████████████████████████▉ |
AVX2 (rcp) |
0.215 |
0.212 |
8.3 |
████████████████████████████████████████ |
AVX2 long div |
0.376 |
0.372 |
4.7 |
██████████████████████▊ |
Skylake-X
-
CPU: Intel(R) Xeon(R) W-2104, 3.20GHz
-
Компилятор: gcc (GCC) 11.2.0
процедура |
время в тактах на байт |
ускорение |
||
---|---|---|---|---|
|
среднее |
лучшее |
|
|
scalar |
8.044 |
8.018 |
1.0 |
███▌ |
scalar (unrolled x 4) |
7.020 |
7.016 |
1.1 |
████ |
scalar (long div) |
18.988 |
18.176 |
0.4 |
█▌ |
scalar (long div, autovect) |
1.003 |
0.991 |
8.1 |
████████████████████████████▎ |
SSE |
1.168 |
1.163 |
6.9 |
████████████████████████▏ |
SSE (no rounding) |
0.820 |
0.814 |
9.9 |
██████████████████████████████████▍ |
SSE (cvtt) |
0.831 |
0.826 |
9.7 |
█████████████████████████████████▉ |
SSE (rcp) |
0.907 |
0.903 |
8.9 |
███████████████████████████████ |
SSE long div |
2.096 |
2.090 |
3.8 |
█████████████▍ |
AVX2 |
1.062 |
1.057 |
7.6 |
██████████████████████████▌ |
AVX2 (cvtt) |
0.839 |
0.835 |
9.6 |
█████████████████████████████████▋ |
AVX2 (rcp) |
0.971 |
0.967 |
8.3 |
█████████████████████████████ |
AVX2 long div |
1.076 |
1.069 |
7.5 |
██████████████████████████▎ |
AVX512 (cvtt) |
1.483 |
1.474 |
5.4 |
███████████████████ |
AVX512 (rcp) |
1.191 |
1.186 |
6.8 |
███████████████████████▋ |
AVX512 long div |
0.709 |
0.702 |
11.4 |
████████████████████████████████████████ |
IceLake
-
Компилятор: gcc (GCC) 13.3.1 20240611 (Red Hat 13.3.1-2)
-
CPU: Intel(R) Xeon(R) Gold 6338, 2.00GHz
процедура |
время в тактах на байт |
ускорение |
||
---|---|---|---|---|
|
среднее |
лучшее |
|
|
scalar |
6.036 |
6.011 |
1.0 |
██▋ |
scalar (unrolled x 4) |
6.016 |
6.012 |
1.0 |
██▋ |
scalar (long div) |
9.865 |
8.397 |
0.7 |
█▉ |
scalar (long div, autovect) |
0.581 |
0.578 |
10.4 |
███████████████████████████▍ |
SSE |
0.593 |
0.586 |
10.3 |
███████████████████████████ |
SSE (no rounding) |
0.476 |
0.473 |
12.7 |
█████████████████████████████████▍ |
SSE (cvtt) |
0.487 |
0.484 |
12.4 |
████████████████████████████████▋ |
SSE (rcp) |
0.504 |
0.498 |
12.1 |
███████████████████████████████▊ |
SSE long div |
1.247 |
1.240 |
4.8 |
████████████▊ |
AVX2 |
0.546 |
0.518 |
11.6 |
██████████████████████████████▌ |
AVX2 (cvtt) |
0.447 |
0.442 |
13.6 |
███████████████████████████████████▊ |
AVX2 (rcp) |
0.445 |
0.431 |
13.9 |
████████████████████████████████████▊ |
AVX2 long div |
0.641 |
0.635 |
9.5 |
████████████████████████▉ |
AVX512 (cvtt) |
0.827 |
0.814 |
7.4 |
███████████████████▍ |
AVX512 (rcp) |
0.507 |
0.503 |
12.0 |
███████████████████████████████▍ |
AVX512 long div |
0.398 |
0.396 |
15.2 |
████████████████████████████████████████ |
Исходный код
Пример реализации выложен на GitHub.
ссылка на оригинал статьи https://habr.com/ru/articles/869094/
Добавить комментарий