Nahajate se tukaj

Programiranje GPU z CUDA

CUDA (Compute Unified Device Architecture) je paralelna računska arhitektura razvita v NVIDIA in se izvaja v grafičnih karticah (GPU).

Motivacija in prvi koncepti

  • Razumevanje osnov
  • Meritve performans

GPU so hitri. Njihova zmogljivost raste hitreje (2x/leto) kot pri normalnih CPU (1.5x/leto). GPU so namenjene za računsko intentivne aplikacije. Glavni namen GPU je prav zaprev za industrijo računalniških igric, vendar se lahko to uporabi tudi za specializirane znanstvene simulacije. Tudi število člankov na tem področju raste. Zmogljivost in performansa GPU se je v zadnjem času primerna za splošno programiranje. Za začetnike se priporočati naslednji dve knjigi:

Uvod

  • CUDA je primerna za probleme, ki so zelo  probleme ki so zelo paralelni. Se pravi da med deli ni povezave.
  • Ker je isti program izvajan v vsakem podatkovnem elementu, je pri tem malo zahtev za zahtevne poteke progama.
  • CUDA tako ni namenjena za vsakovrstne probleme

 Programski model

  • Program v CUDA se sestoji iz ene ali več faz, ki se izvajajo na katerikolem CPU ali na enoti kot je GPU
  • Koda je napisana v ANSI C razširjena z ukazi
  • Jedro izvaja veliko število niti, da izrabi praralelizem podatkov
  • Niti (threads) na GPU so lažje kot pri normalnem CPU
  • Programska struktura v CUDA:
    • Izvajanje začne host
    • Ko je funkcija izvedeba se izvajanje prenese v enoto GPU z velikim številom niti
    • Vse niti vzpostavljene z jedrom so imenovane grid
  • Promet na vodilu PCI mora biti minimalen. Za CUDA se podatki prenašajo iz host v napravo. Ti prenosi podatkov so potratni v smislu performanse in jih je potrebno minimirati
    • Kompleksnost operacij mora opravičevati ceno prenosa podatkov v GPU.
    • Podatki naj se hranijo v GPU čimdlje saj se s tem minimira prenose. To pomeni da naj se vmesni rezultati hranijo na GPU in ne prenašajo na host in nato naza za nadaljnje računanje.
  •  Glavnik kandidati za paralelizacijo z GPU so zanke FOR.

 

Primer izvajanja jedra v katerem ni vidna FOR zanka, saj le to prevzema funkcija jedra.

// Definicija jedra
__global__ void VecAdd(float *Am float *B, float *B)
{
  int i = threadIdx.x;
  C[i] = A[i] + B[i];
}

int main()
{
  // Izvajanje jedra
  VecAdd<<<1, N>>>(A, B, C);
}

 Z threadId vsaka nit izvaja jedro z unikatnim ID, ki je dosegljivo v jedru s spremenljivko threadIdx.. Nit lahko vidimo kot eno ali večdimenzionalno polje. Naslednja koda sešteje dve matriki in shrani rezultat v C.

__global__ void MatAdd(...)
{
  int i = threadIdx.x;
  int j = threadIdx.y;
  C[i][j] = A[i][j] + B[i][j];
}
int main()
{
  dim3 dimBlock(N, N);
  MatAdd<<<1, dimBlock>>>(A, B, C);
}

Dimenzionalnost mreže (grid) se podaja v prvem argumentu sintakse   <<< ... >>> v kateri kličemo jedro.

__global__ void MatAdd(float A[N],float B[N], float C[N])
{
   int i = blockIdx.x * blockDim.x + threadIdx.x;
   if (i < N) C[i] = A[i] + B[i]; // Pazi na primer ko je N=1000!!
}
N = 252 * 16;
N = 1000; // alternativa
int main()
{
  
  dim3 dimBlock=256;
  dim3 dimGrid = (N + dimBlock.x - 1) / dimBlock.x ;
  dim3 dimGrid = 16 ; //  alternativa
  MatAdd<<<dimGrid, dimBlock>>>(A, B, C);
}

Dimenzionalnost se lahko prenese še višje na več GPU, kjer imamo tudi blockID in threadId.

Hierarhija pomnilnika

  • CPU in GPU imata ločene spominske prostore
  • Host (CPU) upravlja s spominom kartice (GPU)
  • cudaMalloc(void ** pointerm size_t bytes) - Kličemo iz računalnika, da alociramo globalni spomin.
  • Kopiranje spomina je možno v vse smeri z:
    • cudaMemcpyHostToDevice
    • cudaMemcpyDeviceToHost
    • cudaMemcpyDeviceToDevice
    • cudaMemcpyHostToHost

Primer kopiranja:

int main(void)
{
  float *a_h, *b_h; // poratki v računalniku CPU
  float *a_d, *b_d; // podatki v kartici GPU
  int N = 14, nBytes, i ;
  nBytes = N*sizeof(float);
  a_h = (float *)malloc(nBytes);
  b_h = (float *)malloc(nBytes);
  cudaMalloc((void **) &a_d, nBytes);
  cudaMalloc((void **) &b_d, nBytes);
  for (i=0, i<N; i++)           
     a_h[i] = 100.f + i;
  cudaMemcpy(a_d, a_h, nBytes, cudaMemcpyHostToDevice);
  cudaMemcpy(b_d, a_d, nBytes, cudaMemcpyDeviceToDevice);
  cudaMemcpy(b_h, b_d, nBytes, cudaMemcpyDeviceToHost);
  for (i=0; i< N; i++)       
     assert( a_h[i] == b_h[i] );
  free(a_h); free(b_h); cudaFree(a_d); cudaFree(b_d);
  return 0;
}

Označevanje funkcij

  • Jedro ima označbo __global__
    • Funkcija je klicana iz računalnika in je izvajana v kartici
    • Mora vrniti void
  • Druge CUDA označbe so:
    • __device__ - funkcija ki jo kliče kartica in je v kartici
    • __host__ - privzeta funkcija na računalniku

Označevanje spremenljivk

  • __device__ - shranjeno v globalnem spominu GPU in dosegljivo za vse niti
  • __constant__ - samo za branje v GPU
  • __shared__ - Hiter pomnilnik za rabo v jedru. Dosegljiv iz vseh niti.

Poganjanje jeder

Sintaksa kernel<<<dim3 dG, dim3 dB>>>(...) mogoča naslednje konfiguracije:dG - dimension and size of grid in blocks

  • dG - dimenzije grid in blokov
    • Dimenzija v  x in y
    • bloki se poganjajo na gridu: dG.x * dG.y
    • hardverska omejitev 65,535 blokov na dimenzijo
  • dB - dimenzija in velkost blokov v nitih
    • Tri-dimenzionalno: x, y, and z. zaenkrat samo 2D x in y čeprav jezik predvideva tudi z za prihodnje arhitekture
    • Število niti v bloku je: dB.x * dB.y * dB.z
    • Primeri: (512,1,1), (8,16,2) or (16,16,2)
    • Ni dovoljeno: (32,32,1)
    • hardverska omejitev 65,535 blokov na dimenzijo

 

 Prvi program

#include <stdio.h>
#include <cuda.h>
// Jedro ki ga izvaja CUDA kartica
__global__ void square_array(float *a, int N)
{
}

// glavna rutina, ki se izvaja na računalniku (host)
int main(void)
{
   // Kazalec na host in polja v kartici
   // Število elementov v poljih
   // Alokacija polj na hostu
   // Alokacija polj na kartici
   // Inicializacija polja na hostu in kopiranje v CUDA kartico
   //  Računaj na kartici
   // Poberi rezultate iz kartice in shrani v računalnik
   // Izpiši rezultate
   // Čiščenje
} 

Posamezni deli programa so:

Jedro ki ga izvaja CUDA kartica

__global__ void square_array(float *a, int N)
{
  int idx = blockIdx.x * blockDim.x + threadIdx.x;
  if (idx<N) a[idx] = a[idx] * a[idx];
}

Kazalec na polja v hostu in v kartici

int main(void)
{
   float *a_h, *a_d; // Kazalca na polja v host & device
   const int N = 10; // Število elementov v polju
.
.
.
}

Alokacija spomina

size_t size = N * sizeof(float);
  a_h = (float *)malloc(size); // Alociraj polje na hostu
  cudaMalloc((void **) &a_d, size); // alociraj polje na kaAllocate array on device


Inicializacija polja na hostu in kopiranje v CUDA kartico

for (int i=0; i<N; i++) a_h[i] = (float)i;
    cudaMemcpy(a_d, a_h, size, cudaMemcpyHostToDevice);


Računaj na kartici

int block_size = 4;
   int n_blocks = N/block_size + (N%block_size == 0 ? 0:1);
   square_array <<< n_blocks, block_size >>> (a_d, N);


Poberi rezultate iz kartice in shrani v računalnik

cudaMemcpy(a_h, a_d, size, cudaMemcpyDeviceToHost);


Izpiši rezultate in počisti spomin

for (int i=0; i<N; i++) printf("%d %f\n", i, a_h[i]);
   // Čiščenje
   free(a_h); cudaFree(a_d);

Prevajanje in poganjanje programa a.cu

$ nvcc a.cu -o a
$ ./a
0 0.000000
1 1.000000
2 4.000000
3 9.000000
4 16.000000
5 25.000000
6 36.000000
7 49.000000
8 64.000000
9 81.000000

Hardverske secifikacije

Naslednji program izpiše lastnosti CUDA kartice:

#include <stdio.h>
#include <cuda.h>

int main(void)
{
 cudaDeviceProp prop;
 int count;
 cudaGetDeviceCount( &count );
 for (int i=0; i< count; i++) {
  cudaGetDeviceProperties( &prop, i ) ;
  printf( " --- General Information for device %d ---\n", i );
  printf( "Name: %s\n", prop.name );
  printf( "Compute capability: %d.%d\n", prop.major, prop.minor );
  printf( "Clock rate: %d\n", prop.clockRate );
  printf( "Device copy overlap: " );
  if (prop.deviceOverlap)
    printf( "Enabled\n" );
  else
    printf( "Disabled\n");
  printf( "Kernel execution timeout : " );
  if (prop.kernelExecTimeoutEnabled)
    printf( "Enabled\n" );
  else
    printf( "Disabled\n" );
  printf( " --- Memory Information for device %d ---\n", i );
  printf( "Total global mem: %ld\n", prop.totalGlobalMem );
  printf( "Total constant Mem: %ld\n", prop.totalConstMem );
  printf( "Max mem pitch: %ld\n", prop.memPitch );
  printf( "Texture Alignment: %ld\n", prop.textureAlignment );
  printf( " --- MP Information for device %d ---\n", i );
  printf( "Multiprocessor count: %d\n",
  prop.multiProcessorCount );
  printf( "Shared mem per mp: %ld\n", prop.sharedMemPerBlock );
  printf( "Registers per mp: %d\n", prop.regsPerBlock );
  printf( "Threads in warp: %d\n", prop.warpSize );
  printf( "Max threads per block: %d\n", prop.maxThreadsPerBlock );
  printf( "Max thread dimensions: (%d, %d, %d)\n",
                prop.maxThreadsDim[0], prop.maxThreadsDim[1],
  prop.maxThreadsDim[2] );
  printf( "Max grid dimensions: (%d, %d, %d)\n",
  prop.maxGridSize[0], prop.maxGridSize[1],
  prop.maxGridSize[2] );
  printf( "\n" );
         }
}

Na kartici, kjer smo izvajali teste je program izpisal:

--- General Information for device 0 ---
Name: GeForce GTX 295
Compute capability: 1.3
Clock rate: 1242000
Device copy overlap: Enabled
Kernel execution timeout : Disabled
 --- Memory Information for device 0 ---
Total global mem: 939261952
Total constant Mem: 65536
Max mem pitch: 262144
Texture Alignment: 256
 --- MP Information for device 0 ---
Multiprocessor count: 30
Shared mem per mp: 16384
Registers per mp: 16384
Threads in warp: 32
Max threads per block: 512
Max thread dimensions: (512, 512, 64)
Max grid dimensions: (65535, 65535, 1)

 --- General Information for device 1 ---
Name: GeForce GTX 295
Compute capability: 1.3
Clock rate: 1242000
Device copy overlap: Enabled
Kernel execution timeout : Disabled
 --- Memory Information for device 1 ---
Total global mem: 939261952
Total constant Mem: 65536
Max mem pitch: 262144
Texture Alignment: 256
 --- MP Information for device 1 ---
Multiprocessor count: 30
Shared mem per mp: 16384
Registers per mp: 16384
Threads in warp: 32
Max threads per block: 512
Max thread dimensions: (512, 512, 64)
Max grid dimensions: (65535, 65535, 1)

 --- General Information for device 2 ---
Name: GeForce GTX 295
Compute capability: 1.3
Clock rate: 1242000
Device copy overlap: Enabled
Kernel execution timeout : Disabled
 --- Memory Information for device 2 ---
Total global mem: 939261952
Total constant Mem: 65536
Max mem pitch: 262144
Texture Alignment: 256
 --- MP Information for device 2 ---
Multiprocessor count: 30
Shared mem per mp: 16384
Registers per mp: 16384
Threads in warp: 32
Max threads per block: 512
Max thread dimensions: (512, 512, 64)
Max grid dimensions: (65535, 65535, 1)

 --- General Information for device 3 ---
Name: GeForce GTX 295
Compute capability: 1.3
Clock rate: 1242000
Device copy overlap: Enabled
Kernel execution timeout : Disabled
 --- Memory Information for device 3 ---
Total global mem: 939261952
Total constant Mem: 65536
Max mem pitch: 262144
Texture Alignment: 256
 --- MP Information for device 3 ---
Multiprocessor count: 30
Shared mem per mp: 16384
Registers per mp: 16384
Threads in warp: 32
Max threads per block: 512
Max thread dimensions: (512, 512, 64)
Max grid dimensions: (65535, 65535, 1)

Deljeni spomin

Je hitrejši kot lokalni in globalni spomin(100x).  Deljeni spomin deklariramo z __shared__. Velikost bloka v deljenem spominu je omejena na 16x16 oz TIME_DIM=16.

Primerjava z in brez deljenega spomina

__global__ void coalescedMultiply(float *a, float* b, float *c, int N)
{
  __shared__ float aTile[TILE_DIM][TILE_DIM];
  int row = blockIdx.y * blockDim.y + threadIdx.y;
  int col = blockIdx.x * blockDim.x + threadIdx.x;
  float sum = 0.0f;
  aTile[threadIdx.y][threadIdx.x] = a[row*TILE_DIM+threadIdx.x];
  for (int i = 0; i < TILE_DIM; i++) {
    sum += aTile[threadIdx.y][i]* b[i*N+col];
  }
  c[row*N+col] = sum;
}
// Brez deljenega spomina
__global__ void simpleMultiply(float *a, float* b, float *c, int N)
{
  int row = blockIdx.y * blockDim.y + threadIdx.y;
  int col = blockIdx.x * blockDim.x + threadIdx.x;
  float sum = 0.0f;
  for (int i = 0; i < TILE_DIM; i++) {
    sum += a[row*TILE_DIM+i] * b[i*N+col];
  }
  c[row*N+col] = sum;
}