Reduce las filas de la matriz con CUDA

Windows 7, NVidia GeForce 425M. 

Escribí un código CUDA simple que calcula las sums de fila de una matriz. La matriz tiene una representación unidimensional (puntero a un flotador).

La versión en serie del código está debajo (tiene 2 bucles, como se esperaba):

 void serial_rowSum (float* m, float* output, int nrow, int ncol) { float sum; for (int i = 0 ; i < nrow ; i++) { sum = 0; for (int j = 0 ; j < ncol ; j++) sum += m[i*ncol+j]; output[i] = sum; } } 

Dentro del código CUDA, llamo a la función kernel barriendo la matriz por filas. A continuación, el fragmento de llamada del kernel:

 dim3 threadsPerBlock((unsigned int) nThreadsPerBlock); // has to be multiple of 32 dim3 blocksPerGrid((unsigned int) ceil(nrow/(float) nThreadsPerBlock)); kernel_rowSum<<>>(d_m, d_output, nrow, ncol); 

y la función kernel que realiza la sum paralela de las filas (aún tiene 1 bucle):

 __global__ void kernel_rowSum(float *m, float *s, int nrow, int ncol) { int rowIdx = threadIdx.x + blockIdx.x * blockDim.x; if (rowIdx < nrow) { float sum=0; for (int k = 0 ; k < ncol ; k++) sum+=m[rowIdx*ncol+k]; s[rowIdx] = sum; } } 

Hasta aquí todo bien. Los resultados seriales y paralelos (CUDA) son iguales.

El punto es que la versión de CUDA tarda casi el doble de tiempo que la de serie para calcular, incluso si cambio el parámetro nThreadsPerBlock : nThreadsPerBlock de 32 a 1024 (número máximo de hilos por bloque permitido para mi tarjeta).

IMO, la dimensión de la matriz es lo suficientemente grande como para justificar la paralelización: 90,000 x 1,000 .

A continuación, nThreadsPerBlock el tiempo transcurrido para las versiones en serie y paralelas utilizando diferentes nThreadsPerBlock . Tiempo informado en msec sobre un promedio de 100 muestras:

Matriz: nrow = 90000 x ncol = 1000

Serie: Tiempo promedio transcurrido por muestra en mseg ( 100 muestras): 289.18 .

CUDA ( 32 ThreadsPerBlock): Tiempo promedio transcurrido por muestra en mseg ( 100 muestras): 497.11 .

CUDA ( 1024 ThreadsPerBlock): tiempo promedio transcurrido por muestra en mseg ( 100 muestras): 699.66 .

Por si acaso, la versión con nThreadsPerBlock es la más rápida / más lenta.

Entiendo que hay una especie de sobrecarga al copiar de Host a Dispositivo y viceversa, pero tal vez la lentitud se debe a que no estoy implementando el código más rápido.

Ya que estoy lejos de ser un experto en CUDA:

¿Estoy codificando la versión más rápida para esta tarea? ¿Cómo podría mejorar mi código? ¿Puedo deshacerme del bucle en la función kernel?

Cualquier pensamiento apreciado.

EDIT 1

Aunque describo un rowSum estándar, estoy interesado en la operación AND / OR de filas que tienen valores (0;1} , como rowAND / rowOR . Dicho esto, no me permite explotar el multiplicador cuBLAS por 1 ‘s Truco de vectores COL columna, según lo sugerido por algunos comentaristas.

EDIT 2

Como sugieren los usuarios otros usuarios y aquí endosados:

Olvídese de intentar escribir sus propias funciones , use la biblioteca Thrust en su lugar y la magia viene.

Como mencionó, necesita un algoritmo de reducción general que no sea solo sum. Trataré de dar 3 enfoques aquí. El enfoque del núcleo puede tener el mayor rendimiento. El enfoque de empuje es más fácil de implementar. El enfoque cuBLAS funciona solo con sum y tiene un buen rendimiento.

Enfoque Kernel

Aquí hay un documento muy bueno que presenta cómo optimizar la reducción paralela estándar. La reducción estándar se puede dividir en 2 etapas.

  1. Los múltiples bloques de hilos reducen una parte de los datos;
  2. Un bloque de hilo se reduce del resultado de la etapa 1 al elemento final 1.

Para su problema de reducción múltiple (reducir filas de tapetes), solo la etapa 1 es suficiente. La idea es reducir 1 fila por bloque de hilos. Para otras consideraciones, como multirruta por bloque de subprocesos o 1 fila por múltiples bloques de subprocesos, puede consultar el documento provisto por @Novak . Esto puede mejorar más el rendimiento, especialmente para matrices con mala forma.

Enfoque de empuje

La multirreducción general se puede realizar mediante thrust::reduction_by_key en unos minutos. Puede encontrar algunas discusiones aquí Determinando el elemento mínimo y su posición en cada columna de matriz con CUDA Thrust .

Sin embargo, thrust::reduction_by_key no supone que cada fila tenga la misma longitud, por lo que obtendrá una penalización de rendimiento. Otra publicación ¿Cómo normalizar columnas de matriz en CUDA con un rendimiento máximo? da comparación de perfiles entre thrust::reduction_by_key y cuBLAS enfoque en la sum de las filas. Puede darle una comprensión básica sobre el rendimiento.

enfoque CuBLAS

La sum de filas / columnas de una matriz A se puede ver como una multiplicación matriz-vector donde los elementos del vector son todos uno. se puede representar mediante el siguiente código de matlab.

 y = A * ones(size(A,2),1); 

donde y es la sum de las filas de A.

cuBLAS libary proporciona una función de multiplicación de matriz-vector de alto rendimiento cublasgemv() para esta operación.

El resultado del tiempo muestra que esta rutina es solo 10 ~ 50% más lenta que simplemente leer todos los elementos de A una vez, lo que puede verse como el límite superior teórico del rendimiento para esta operación.

Si este es el scope (sumndo las filas) de las operaciones que necesita hacer con estos datos, no esperaría un beneficio considerable de la GPU. Tiene exactamente una operación aritmética por elemento de datos, y para eso está pagando el costo de transferir ese elemento de datos a la GPU. Y más allá de un determinado tamaño de problema (lo que sea necesario para mantener la máquina ocupada) no se obtiene un beneficio adicional de los tamaños de problema más grandes, porque la intensidad aritmética es O (n).

Este no es un problema particularmente emocionante para resolver en la GPU.

Pero tal como lo ha indicado Talonmies, usted tiene un problema coalescente en la forma en que lo ha diseñado, lo que ralentizará aún más las cosas. Echemos un vistazo a un pequeño ejemplo:

  C1 C2 C3 C4 R1 11 12 13 14 R2 21 22 23 24 R3 31 32 33 34 R4 41 42 43 44 

Arriba hay un simple ejemplo pictórico de una pequeña porción de su matriz. El almacenamiento de datos de la máquina es tal que los elementos (11), (12), (13) y (14) se almacenan en ubicaciones de memoria adyacentes.

Para el acceso combinado , queremos un patrón de acceso tal que las ubicaciones de memoria adyacentes se soliciten a partir de la misma instrucción, ejecutada a lo largo de la urdimbre.

Necesitamos pensar en la ejecución de su código desde el punto de vista de un warp , es decir, 32 hilos ejecutándose en lock-step. ¿Qué está haciendo tu código? ¿Qué elementos está recuperando (pidiendo) en cada paso / instrucción? Echemos un vistazo a esta línea de código:

  sum+=m[rowIdx*ncol+k]; 

Los hilos adyacentes en la urdimbre tienen valores adyacentes (es decir, consecutivos) para rowIdx ya que ha creado esa variable. Entonces, cuando k = 0, ¿qué elemento de datos solicita cada subproceso cuando intentamos recuperar el valor m[rowIdx*ncol+k] ?

En el bloque 0, el subproceso 0 tiene un rowIdx de 0. El subproceso 1 tiene un rowIdx de 1, etc. Por lo tanto, los valores solicitados por cada subproceso en esta instrucción son:

 Thread: Memory Location: Matrix Element: 0 m[0] (11) 1 m[ncol] (21) 2 m[2*ncol] (31) 3 m[3*ncol] (41) 

¡Pero esto no es un acceso combinado! Los elementos (11), (21), etc. no están adyacentes en la memoria. Para el acceso combinado, nos gustaría que la fila del elemento de la matriz se lea así:

 Thread: Memory Location: Matrix Element: 0 m[?] (11) 1 m[?] (12) 2 m[?] (13) 3 m[?] (14) 

Si luego trabajas hacia atrás para determinar el valor de ? debería ser, se te ocurrirá una instrucción como esta:

  sum+=m[k*ncol+rowIdx]; 

Esto le dará acceso combinado, pero no le dará la respuesta correcta, porque ahora estamos sumndo columnas de matriz en lugar de filas de matriz. Podemos solucionar esto reorganizando su almacenamiento de datos para que esté en orden de columna principal en lugar de orden de fila mayor. (Deberías ser capaz de buscar ideas en google, ¿no?) Conceptualmente, esto es equivalente a transponer tu matriz m . Si esto es conveniente para usted o no, está fuera del scope de su pregunta, como yo lo veo, y no es realmente un problema de CUDA. Puede ser una tarea sencilla para usted, ya que está creando la matriz en el host o transfiriendo la matriz de un host a otro. Pero, en resumen, no conozco una forma de sumr las filas de la matriz con un 100% de acceso combinado, si la matriz está almacenada en orden mayor de fila. (Podría recurrir a una secuencia de reducciones de filas, pero eso me parece doloroso).

No es raro, cuando estamos pensando en formas de acelerar el código en la GPU, considerar la posibilidad de reorganizar nuestro almacenamiento de datos para facilitar la GPU. Este es un ejemplo.

Y, sí, lo que estoy esbozando aquí todavía conserva un bucle en el kernel.

Como comentario adicional, sugeriría que se sincronicen las porciones de copia de datos y las porciones de kernel (cálculo) por separado. No puedo decir a partir de su pregunta si está sincronizando solo el kernel o la operación completa (GPU), incluidas las copias de datos. Si calcula el tiempo de las copias de datos por separado, puede descubrir que solo el tiempo de copia de datos excede su tiempo de CPU. Cualquier esfuerzo puesto en la optimización de su código CUDA no afectará el tiempo de copia de datos. Este podría ser un punto de datos útil antes de dedicar mucho tiempo a esto.

La reducción de las filas de una matriz se puede resolver mediante el uso de CUDA Thrust de tres maneras (pueden no ser las únicas, pero abordar este punto está fuera del scope). Como también es reconocido por el mismo OP, usar CUDA Thrust es preferible para este tipo de problema. Además, es posible un enfoque usando cuBLAS .

ENFOQUE # 1 – reduce_by_key

Este es el enfoque sugerido en esta página de ejemplo de Thrust . Incluye una variante usando make_discard_iterator .

ENFOQUE # 2 – transform

Este es el enfoque sugerido por Robert Crovella en CUDA Thrust: reduce_by_key en solo algunos valores en una matriz, basado en valores en una matriz “clave” .

ENFOQUE # 3 – inclusive_scan_by_key

Este es el enfoque sugerido por Eric en Cómo normalizar columnas de matriz en CUDA con un rendimiento máximo. .

ENFOQUE # 4 – cublasgemv

Utiliza cuBLAS gemv para multiplicar la matriz relevante por una columna de 1 ‘s.

EL CÓDIGO COMPLETO

Aquí está el código que condensa los dos enfoques. Los archivos Utilities.cu y Utilities.cuh se mantienen aquí y se omiten aquí. El TimingGPU.cu y TimingGPU.cuh se mantienen aquí y también se omiten.

 #include  #include  #include  #include  #include  #include  #include  #include  #include  #include  #include "Utilities.cuh" #include "TimingGPU.cuh" // --- Required for approach #2 __device__ float *vals; /**************************************************************/ /* CONVERT LINEAR INDEX TO ROW INDEX - NEEDED FOR APPROACH #1 */ /**************************************************************/ template  struct linear_index_to_row_index : public thrust::unary_function { T Ncols; // --- Number of columns __host__ __device__ linear_index_to_row_index(T Ncols) : Ncols(Ncols) {} __host__ __device__ T operator()(T i) { return i / Ncols; } }; /******************************************/ /* ROW_REDUCTION - NEEDED FOR APPROACH #2 */ /******************************************/ struct row_reduction { const int Ncols; // --- Number of columns row_reduction(int _Ncols) : Ncols(_Ncols) {} __device__ float operator()(float& x, int& y ) { float temp = 0.f; for (int i = 0; i struct MulC: public thrust::unary_function { TC; __host__ __device__ MulC(T c) : C(c) { } __host__ __device__ T operator()(T x) { return x * C; } }; /********/ /* MAIN */ /********/ int main() { const int Nrows = 5; // --- Number of rows const int Ncols = 8; // --- Number of columns // --- Random uniform integer distribution between 10 and 99 thrust::default_random_engine rng; thrust::uniform_int_distribution dist(10, 99); // --- Matrix allocation and initialization thrust::device_vector d_matrix(Nrows * Ncols); for (size_t i = 0; i < d_matrix.size(); i++) d_matrix[i] = (float)dist(rng); TimingGPU timerGPU; /***************/ /* APPROACH #1 */ /***************/ timerGPU.StartCounter(); // --- Allocate space for row sums and indices thrust::device_vector d_row_sums(Nrows); thrust::device_vector d_row_indices(Nrows); // --- Compute row sums by summing values with equal row indices //thrust::reduce_by_key(thrust::make_transform_iterator(thrust::counting_iterator(0), linear_index_to_row_index(Ncols)), // thrust::make_transform_iterator(thrust::counting_iterator(0), linear_index_to_row_index(Ncols)) + (Nrows*Ncols), // d_matrix.begin(), // d_row_indices.begin(), // d_row_sums.begin(), // thrust::equal_to(), // thrust::plus()); thrust::reduce_by_key( thrust::make_transform_iterator(thrust::make_counting_iterator(0), linear_index_to_row_index(Ncols)), thrust::make_transform_iterator(thrust::make_counting_iterator(0), linear_index_to_row_index(Ncols)) + (Nrows*Ncols), d_matrix.begin(), thrust::make_discard_iterator(), d_row_sums.begin()); printf("Timing for approach #1 = %f\n", timerGPU.GetCounter()); // --- Print result for(int i = 0; i < Nrows; i++) { std::cout << "[ "; for(int j = 0; j < Ncols; j++) std::cout << d_matrix[i * Ncols + j] << " "; std::cout << "] = " << d_row_sums[i] << "\n"; } /***************/ /* APPROACH #2 */ /***************/ timerGPU.StartCounter(); thrust::device_vector d_row_sums_2(Nrows, 0); float *s_vals = thrust::raw_pointer_cast(&d_matrix[0]); gpuErrchk(cudaMemcpyToSymbol(vals, &s_vals, sizeof(float *))); thrust::transform(d_row_sums_2.begin(), d_row_sums_2.end(), thrust::counting_iterator(0), d_row_sums_2.begin(), row_reduction(Ncols)); printf("Timing for approach #2 = %f\n", timerGPU.GetCounter()); for(int i = 0; i < Nrows; i++) { std::cout << "[ "; for(int j = 0; j < Ncols; j++) std::cout << d_matrix[i * Ncols + j] << " "; std::cout << "] = " << d_row_sums_2[i] << "\n"; } /***************/ /* APPROACH #3 */ /***************/ timerGPU.StartCounter(); thrust::device_vector d_row_sums_3(Nrows, 0); thrust::device_vector d_temp(Nrows * Ncols); thrust::inclusive_scan_by_key( thrust::make_transform_iterator(thrust::make_counting_iterator(0), linear_index_to_row_index(Ncols)), thrust::make_transform_iterator(thrust::make_counting_iterator(0), linear_index_to_row_index(Ncols)) + (Nrows*Ncols), d_matrix.begin(), d_temp.begin()); thrust::copy( thrust::make_permutation_iterator( d_temp.begin() + Ncols - 1, thrust::make_transform_iterator(thrust::make_counting_iterator(0), MulC(Ncols))), thrust::make_permutation_iterator( d_temp.begin() + Ncols - 1, thrust::make_transform_iterator(thrust::make_counting_iterator(0), MulC(Ncols))) + Nrows, d_row_sums_3.begin()); printf("Timing for approach #3 = %f\n", timerGPU.GetCounter()); for(int i = 0; i < Nrows; i++) { std::cout << "[ "; for(int j = 0; j < Ncols; j++) std::cout << d_matrix[i * Ncols + j] << " "; std::cout << "] = " << d_row_sums_3[i] << "\n"; } /***************/ /* APPROACH #4 */ /***************/ cublasHandle_t handle; timerGPU.StartCounter(); cublasSafeCall(cublasCreate(&handle)); thrust::device_vector d_row_sums_4(Nrows); thrust::device_vector d_ones(Ncols, 1.f); float alpha = 1.f; float beta = 0.f; cublasSafeCall(cublasSgemv(handle, CUBLAS_OP_T, Ncols, Nrows, &alpha, thrust::raw_pointer_cast(d_matrix.data()), Ncols, thrust::raw_pointer_cast(d_ones.data()), 1, &beta, thrust::raw_pointer_cast(d_row_sums_4.data()), 1)); printf("Timing for approach #4 = %f\n", timerGPU.GetCounter()); for(int i = 0; i < Nrows; i++) { std::cout << "[ "; for(int j = 0; j < Ncols; j++) std::cout << d_matrix[i * Ncols + j] << " "; std::cout << "] = " << d_row_sums_4[i] << "\n"; } return 0; } 

RESULTADOS DE TIEMPO (probado en un Kepler K20c)

 Matrix size #1 #1-v2 #2 #3 #4 #4 (no plan) 100 x 100 0.63 1.00 0.10 0.18 139.4 0.098 1000 x 1000 1.25 1.12 3.25 1.04 101.3 0.12 5000 x 5000 8.38 15.3 16.05 13.8 111.3 1.14 100 x 5000 1.25 1.52 2.92 1.75 101.2 0.40 5000 x 100 1.35 1.99 0.37 1.74 139.2 0.14 

Parece que los enfoques n. ° 1 y n. ° 3 superan al enfoque n. ° 2, excepto en el caso de pequeñas cantidades de columnas. El mejor enfoque, sin embargo, es el enfoque n.º 4, que es significativamente más conveniente que los demás, siempre que el tiempo necesario para crear el plan pueda amortizarse durante el cálculo.