Multiplicación de matrices en PyCUDA

En la primer parte de las notas, dedicada a CUDA tuvimos la oportunidad de ver códigos para llevar a cabo la multiplicación de matrices. Ahora es tiempo de hacer lo propio, pero en PyCUDA. Mostramos dos códigos, uno con el algoritmo usual y el segundo con el algoritmo de multiplicación por teselas. A continuación mostramos los dos códigos con sus respectivos tiempos de ejecución.


In [1]:
%%time

import numpy as np
from pycuda import driver, compiler, gpuarray, tools

# -- inicializamos el dispositivo
import pycuda.autoinit

plantilla_codigo_kernel = """
__global__ void MatrizMulKernel(float *a, float *b, float *c)
{
    // 2D Thread ID (suponiendo que solo se ejecuta *un* bloque)
    int tx = threadIdx.x;
    int ty = threadIdx.y;

    // Pvalor se usa para guardar el elemento de la matriz
    // que es calculado por el thread
    float Pvalor = 0;
 
    // Cada thread carga un renglon de M y una columna de N,
    // para producir un elemento de P.
    for (int k = 0; k < %(TAMANIO_MATRIZ)s; ++k) {
        float Aelemento = a[ty * %(TAMANIO_MATRIZ)s + k];
        float Belemento = b[k * %(TAMANIO_MATRIZ)s + tx];
        Pvalor += Aelemento * Belemento;
    }

    // Escribe la matriz a la memoria del device;
    // cada thread escribe un elemento.
    c[ty * %(TAMANIO_MATRIZ)s + tx] = Pvalor;
}
"""

# definimos en tamaño de la matriz
TAMANIO_MATRIZ = 4

# creamos dos matrices aleatorias
a_cpu = np.random.randn(TAMANIO_MATRIZ, TAMANIO_MATRIZ).astype(np.float32)
b_cpu = np.random.randn(TAMANIO_MATRIZ, TAMANIO_MATRIZ).astype(np.float32)

# calculamos una referencia en el CPU para verificar el cálculo en el GPU
c_cpu = np.dot(a_cpu, b_cpu)

# transferimos del host (CPU) al device (GPU) 
a_gpu = gpuarray.to_gpu(a_cpu) 
b_gpu = gpuarray.to_gpu(b_cpu)

# creamos un arreglo en gpu para guardar el resultado (C = A*B)
c_gpu = gpuarray.empty((TAMANIO_MATRIZ, TAMANIO_MATRIZ), np.float32)

# jalamos el código del kernel
# especificando la constante TAMANIO_MATRIZ
kernel_codigo = plantilla_codigo_kernel % {
    'TAMANIO_MATRIZ': TAMANIO_MATRIZ 
    }

# compilamos el código del kernel
mod = compiler.SourceModule(kernel_codigo)

# jalamos la función del kernel compilado
matrixmul = mod.get_function("MatrizMulKernel")

# llamamos al kernel en la tarjeta
matrixmul(
    # entradas
    a_gpu, b_gpu, 
    # salidas
    c_gpu, 
    # bloque de TAMANIO_MATRIZ X TAMANIO_MATRIZ threads
    block = (TAMANIO_MATRIZ, TAMANIO_MATRIZ, 1),
    )

# imprimimos los resultados
print ("-" * 80)
print ("Matriz A (GPU):")
print (a_gpu.get())

print ("-" * 80)
print ("Matriz B (GPU):")
print (b_gpu.get())

print ("-" * 80)
print ("Matriz C (GPU):")
print (c_gpu.get())

print ("-" * 80)
print ("Diferencia CPU-GPU:")
print (c_cpu - c_gpu.get())

np.allclose(c_cpu, c_gpu.get())


--------------------------------------------------------------------------------
Matriz A (GPU):
[[ 0.42087126 -0.45696247  1.40747416 -0.01336118]
 [-0.27291611  0.54827404 -0.95525765 -0.16676982]
 [ 1.22548676 -0.35120484 -0.13768797 -1.89706707]
 [-1.2680856  -0.30036467 -0.58157474  0.50693828]]
--------------------------------------------------------------------------------
Matriz B (GPU):
[[-0.71905339  1.20476782 -0.23252736  0.3357895 ]
 [-1.27571738  1.37000728  0.8551234  -0.94778675]
 [ 0.36710623  0.4699367   0.88681698 -1.34979498]
 [ 0.14133705 -1.00428557 -1.04021072 -0.0901983 ]]
--------------------------------------------------------------------------------
Matriz C (GPU):
[[ 0.79513013  0.55585241  0.77344704 -1.32416928]
 [-0.87745327  0.14091277 -0.14136051  0.69315511]
 [-0.75182426  2.83576632  1.26596272  1.10133564]
 [ 1.15315115 -2.72166443 -1.00505722  0.59815353]]
--------------------------------------------------------------------------------
Diferencia CPU-GPU:
[[ -5.96046448e-08   0.00000000e+00  -5.96046448e-08   0.00000000e+00]
 [ -5.96046448e-08  -2.98023224e-08   0.00000000e+00   0.00000000e+00]
 [  0.00000000e+00   0.00000000e+00   0.00000000e+00  -1.19209290e-07]
 [  1.19209290e-07   0.00000000e+00  -1.19209290e-07  -5.96046448e-08]]
CPU times: user 70.2 ms, sys: 59.5 ms, total: 130 ms
Wall time: 79.6 ms

In [2]:
%%time

from __future__ import division

import numpy as np
from numpy import linalg as la
from pycuda import driver, compiler, gpuarray, tools

# -- inicializamos el dispositivo
import pycuda.autoinit

plantilla_kernel_codigo = """
__global__ void MatrizMulKernel(float *A, float *B, float *C)
{

  const uint wA = %(TAMANIO_MATRIZ)s;
  const uint wB = %(TAMANIO_MATRIZ)s;  
  
  // Indice de bloque
  const uint bx = blockIdx.x;
  const uint by = blockIdx.y;

  // Indice de thread
  const uint tx = threadIdx.x;
  const uint ty = threadIdx.y;

  // Indice de la primer submatriz de A procesada por el bloque
  const uint aBegin = wA * %(TAMANIO_BLOQUE)s * by;
  // Indice de la ultima submatriz de A procesada por el bloque
  const uint aEnd = aBegin + wA - 1;
  // Tamanio de paso para iterar sobre las submatrices de A
  const uint aStep = %(TAMANIO_BLOQUE)s;

  // Indice de la primer submatriz de B procesada por el bloque
  const uint bBegin = %(TAMANIO_BLOQUE)s * bx;
  // Tamanio de paso para iterar sobre las submatrices de B
  const uint bStep = %(TAMANIO_BLOQUE)s * wB;

  // El elemento de cada submatriz que el calculado por el thread
  float Csub = 0;
  // Ciclo sobre las submatrices de A y B
  for (int a = aBegin, b = bBegin;
       a <= aEnd;
       a += aStep, b += bStep) 
    {
      // Memoria compartida para la submatriz de A
      __shared__ float As[%(TAMANIO_BLOQUE)s][%(TAMANIO_BLOQUE)s];
      // Memoria compartida para la submatriz de B
      __shared__ float Bs[%(TAMANIO_BLOQUE)s][%(TAMANIO_BLOQUE)s];

      // Pasar las matrices de memoria global a compartida
      As[ty][tx] = A[a + wA * ty + tx];
      Bs[ty][tx] = B[b + wB * ty + tx];
      // Sincronizar para asegurarnos de que se cargan completamente
      // las matrices antes de comenzar a calcular
      __syncthreads();

      // Multiplicar las dos matrices
      for (int k = 0; k < %(TAMANIO_BLOQUE)s; ++k)
        Csub += As[ty][k] * Bs[k][tx];

      // Sincronizar para asegurarse de que el calculo anterior
      // ha finalizado
      __syncthreads();
    }

  // Escribir la submatriz a la memoria global
  const uint c = wB * %(TAMANIO_BLOQUE)s * by + %(TAMANIO_BLOQUE)s * bx;
  C[c + wB * ty + tx] = Csub;
}
"""

# definimos el tamaño de la matriz
TAMANIO_MATRIZ = 4

# definimos el tamaño de los bloques y las teselas
TAMANIO_TILE = 2
TAMANIO_BLOQUE = TAMANIO_TILE

# creamos dos matrices cuadradas aleatorias
a_cpu = np.random.randn(TAMANIO_MATRIZ, TAMANIO_MATRIZ).astype(np.float32)
b_cpu = np.random.randn(TAMANIO_MATRIZ, TAMANIO_MATRIZ).astype(np.float32)

# calculamos la referencia
c_cpu = np.dot(a_cpu, b_cpu)

# transferimos memoria del host al device
a_gpu = gpuarray.to_gpu(a_cpu) 
b_gpu = gpuarray.to_gpu(b_cpu)

# creamos un arreglo en blanco para guardar el resultado
c_gpu = gpuarray.empty((TAMANIO_MATRIZ, TAMANIO_MATRIZ), np.float32)

# jalamos el código del kernel especificando las constantes
kernel_codigo = plantilla_kernel_codigo % { 
    'TAMANIO_MATRIZ': TAMANIO_MATRIZ,
    'TAMANIO_BLOQUE': TAMANIO_BLOQUE,
    }

# compilamos el kernel
mod = compiler.SourceModule(kernel_codigo)

# jalamos la función del kernel
matrixmul = mod.get_function("MatrizMulKernel")

# llamamos al kernel
matrixmul(
    # entradas
    a_gpu, b_gpu, 
    # salidas
    c_gpu, 
    # malla de varios bloques
    grid = (TAMANIO_MATRIZ // TAMANIO_TILE, TAMANIO_MATRIZ // TAMANIO_TILE),
    # bloque de varios threads
    block = (TAMANIO_TILE, TAMANIO_TILE, 1), 
    )

# Imprimimos los resultados
print "-" * 80
print "Matrix A (GPU):"
print a_gpu.get()

print "-" * 80
print "Matrix B (GPU):"
print b_gpu.get()

print "-" * 80
print "Matrix C (GPU):"
print c_gpu.get()

print "-" * 80
print "Diferencia CPU-GPU:"
print c_cpu - c_gpu.get()
print "Norma L2:", la.norm(c_cpu - c_gpu.get())
np.allclose(c_cpu, c_gpu.get())


--------------------------------------------------------------------------------
Matrix A (GPU):
[[-0.23232929 -2.14635515 -0.33738685 -0.28380695]
 [ 0.97448111  0.18537141 -0.05692802 -0.2662923 ]
 [ 1.2280252  -1.12965894 -0.07786353  0.96599358]
 [-0.02382421 -1.21478784  0.10618835  0.35622698]]
--------------------------------------------------------------------------------
Matrix B (GPU):
[[-0.4823854  -0.45011848 -0.21072748 -1.90480971]
 [ 0.49980554  0.81493664  1.20101392  0.66717857]
 [ 0.73462611 -0.89565551  0.40500295  0.55907327]
 [-0.38914606 -0.77034527  0.24235159 -0.82585317]]
--------------------------------------------------------------------------------
Matrix C (GPU):
[[-1.09809875 -1.12375593 -2.73426819 -0.94370019]
 [-0.31562001 -0.03144109 -0.07030869 -1.54443383]
 [-1.59010434 -2.14776707 -1.41293955 -3.93413877]
 [-0.65628082 -1.34877729 -1.32461798 -0.99992394]]
--------------------------------------------------------------------------------
Diferencia CPU-GPU:
[[  0.00000000e+00   0.00000000e+00   0.00000000e+00   0.00000000e+00]
 [  0.00000000e+00  -3.72529030e-09   0.00000000e+00   0.00000000e+00]
 [  0.00000000e+00   0.00000000e+00   0.00000000e+00   0.00000000e+00]
 [ -5.96046448e-08   0.00000000e+00   0.00000000e+00   0.00000000e+00]]
Norma L2: 5.97209e-08
CPU times: user 3.71 ms, sys: 215 µs, total: 3.92 ms
Wall time: 3.82 ms

Como podemos apreciar, la multiplicación por teselas es más eficiente.

Algo que es importante remarcar es el hecho de que uno estaría tentado a usar double en lugar de float en el kernel, sin embargo aún hay problemas para utilizar variables double en algunas tarjetas (tal es el caso de la tarjeta en que corremos esto).

En las notas de Roberto Zamora podemos ver operaciones más complicadas.

Materiales adicionales