Модульная арифметика на gpu

Я работаю над алгоритмом GPU, который должен выполнять множество модульных вычислений. В частности, различные операции над матрицами в конечном поле, которые в конечном счете (a * b - c * d) mod m или (a * b + c) mod m, где a, b, c и d - вычеты по модулю m, а m - 32-битное простое число.

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

Я ценю, если кто-нибудь может дать мне представление о том, как реализовать эффективные модульные вычисления с CUDA?

Чтобы узнать, как это реализовано на CUDA, я использую следующий фрагмент кода:

__global__ void mod_kernel(unsigned *gout, const unsigned *gin) {

unsigned tid = threadIdx.x;
unsigned a = gin[tid], b = gin[tid * 2], m = gin[tid * 3];

typedef unsigned long long u64;

__syncthreads();
unsigned r = (unsigned)(((u64)a * (u64)b) % m);
__syncthreads();
gout[tid] = r;
}

Этот код не должен работать, я просто хотел посмотреть, как модульное сокращение реализованной на CUDA.

Когда я разбираю это с помощью cuobjdump --dump-sass (спасибо njuffa за советы!), я вижу следующее:

/*0098*/     /*0xffffdc0450ee0000*/     BAR.RED.POPC RZ, RZ;
/*00a0*/     /*0x1c315c4350000000*/     IMUL.U32.U32.HI R5, R3, R7;
/*00a8*/     /*0x1c311c0350000000*/     IMUL.U32.U32 R4, R3, R7;
/*00b0*/     /*0xfc01dde428000000*/     MOV R7, RZ;
/*00b8*/     /*0xe001000750000000*/     CAL 0xf8;
/*00c0*/     /*0x00000007d0000000*/     BPT.DRAIN 0x0;
/*00c8*/     /*0xffffdc0450ee0000*/     BAR.RED.POPC RZ, RZ;

Обратите внимание, что между двумя вызовами на bar.red.popc есть вызов процедуры 0xf8, которая реализует некоторый сложный алгоритм (около 50 инструкций или даже больше). Не сдерживая, что мода (%) работает медленно

Ответы

Ответ 1

Некоторое время назад я много экспериментировал с модульной арифметикой на графическом процессоре. На GPU Fermi вы можете использовать арифметику с двойной точностью, чтобы избежать дорогостоящих операций div и mod. Например, модульное умножение может быть выполнено следующим образом:

// fast truncation of double-precision to integers
#define CUMP_D2I_TRUNC (double)(3ll << 51)
// computes r = a + b subop c unsigned using extended precision
#define VADDx(r, a, b, c, subop) \
    asm volatile("vadd.u32.u32.u32." subop " %0, %1, %2, %3;" :  \
            "=r"(r) : "r"(a) , "r"(b), "r"(c));

// computes a * b mod m; invk = (double)(1<<30) / m
__device__ __forceinline__ 
unsigned mul_m(unsigned a, unsigned b, volatile unsigned m,
    volatile double invk) { 

   unsigned hi = __umulhi(a*2, b*2); // 3 flops
   // 2 double instructions
   double rf = __uint2double_rn(hi) * invk + CUMP_D2I_TRUNC;
   unsigned r = (unsigned)__double2loint(rf);
   r = a * b - r * m; // 2 flops

   // can also be replaced by: VADDx(r, r, m, r, "min") // == umin(r, r + m);
   if((int)r < 0) 
      r += m;
   return r;
}

Однако это работает только для 31-битного целочисленного модуля (если 1 бит не является критическим для вас) и вам также необходимо предварительно преподносить "invk" заранее. Это дает абсолютный минимум инструкций, которые я могу достичь, т.е.:

SHL.W R2, R4, 0x1;
SHL.W R8, R6, 0x1;
IMUL.U32.U32 R4, R4, R6;
IMUL.U32.U32.HI R8, R2, R8;
I2F.F64.U32 R8, R8;
DFMA R2, R2, R8, R10;
IMAD.U32.U32 R4, -R12, R2, R4;
ISETP.GE.AND P0, pt, R4, RZ, pt;
@!P0 IADD R4, R12, R4;

Для описания алгоритма вы можете взглянуть на мою статью: gpu_resultants. Здесь также объясняются другие операции, такие как (xy - zw) mod m.

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

unsigned r = (unsigned)(((u64)a * (u64)b) % m);

против оптимизированной версии с mul_m.

Модульная арифметика со значением по умолчанию%:

low_deg: 11; high_deg: 2481; bits: 10227
nmods: 330; n_real_pts: 2482; npts: 2495

res time: 5755.357910 ms; mod_inv time: 0.907008 ms; interp time: 856.015015 ms; CRA time: 44.065857 ms
GPU time elapsed: 6659.405273 ms; 

Модульная арифметика с mul_m:

low_deg: 11; high_deg: 2481; bits: 10227
nmods: 330; n_real_pts: 2482; npts: 2495

res time: 1100.124756 ms; mod_inv time: 0.192608 ms; interp time: 220.615143 ms; CRA time: 10.376352 ms
GPU time elapsed: 1334.742310 ms; 

Так что в среднем он примерно в 5 раз быстрее. Обратите также внимание на то, что вы можете не увидеть ускорение, если вы просто оцените исходную арифметическую производительность, используя ядро ​​с множеством операций mul_mod (например, пример saxpy). Но в реальных приложениях с логикой управления, барьерами синхронизации и т.д. Ускорение очень заметно.

Ответ 2

Высокопроизводительный графический процессор Fermi (например, GTX 580), скорее всего, даст вам лучшую производительность среди карт доставки для этого. Вы бы хотели, чтобы все 32-разрядные операнды имели тип "unsigned int" для лучшей производительности, так как для обработки подписанных разделов и модулей были некоторые дополнительные накладные расходы.

Компилятор генерирует очень эффективный код для деления и по модулю с фиксированным делителем. Насколько я помню, обычно это правило от трех до пяти инструкций машинных инструкций на Ферми и Кеплере. Вы можете проверить сгенерированный SASS (машинный код) с помощью cuobjdump --dump-sass. Возможно, вы сможете использовать шаблонные функции с постоянными делителями, если вы используете только несколько разных делителей.

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

[Добавлено позже, после выяснения вопроса:]

Беззнаковое 64-битное деление и модуляция с переменным делителем, с другой стороны, называются подпрограммами около 65 инструкций для Ферми и Кеплера. Они близки к оптимальным. На Fermi это по-прежнему достаточно конкурентноспособно с аппаратными реализациями (обратите внимание, что 64-разрядные целочисленные деления не очень быстрые на процессорах, которые обеспечивают это как встроенную инструкцию). Ниже приведен некоторый код, который я опубликовал на форумах NVIDIA некоторое время назад для задачи, описанной в разъяснении. Это позволяет избежать дорогостоящего деления, но предполагает, что довольно большие партии операндов разделяют один и тот же divisior. Он использует арифметику с двойной точностью, что особенно важно для графических процессоров класса Tesla (в отличие от потребительских карт). Я только пропустил тест кода, вы можете проверить его более тщательно, прежде чем развертывать его.

// Let b, p, and A[i] be integers < 2^51
// Let N be a integer on the order of 10000
// for i from 1 to N
// A[i] <-- A[i] * b mod p

/*---- kernel arguments ----*/
unsigned long long *A;
double b, p; /* convert from unsigned long long to double before passing to kernel */
double oop;  /* pass precomputed 1.0/p to kernel */

/*---- code inside kernel -----*/
double a, q, h, l, rem;
const double int_cvt_magic = 6755399441055744.0; /* 2^52+2^51 */

a = (double)A[i];

/* approximate quotient and round it to the nearest integer */
q = __fma_rn (a * b, oop, int_cvt_magic);
q = q - int_cvt_magic;

/* back-multiply, representing p*q as a double-double h:l exactly */
h = p * q;
l = __fma_rn (p, q, -h);

/* remainder is double-width product a*b minus double-double h:l */
rem = __fma_rn (a, b, -h);
rem = rem - l;

/* remainder may be negative as quotient rounded; fix if necessary */
if (rem < 0.0) rem += p;

A[i] = (unsigned long long)rem;

Ответ 3

Есть трюки для эффективного выполнения операций mod, но если только m является radix 2.

Например, x mod y == x и (y-1), где y равно 2 ^ n. Выполнение побитовой операции является самым быстрым.

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

Эффективное вычисление мод