GPU, CUDA, PyCuda
Bilgisayarımızda hesapları yapan işlemci var, bu işlemci son zamanlarda çok çekirdekli hale de gelmeye başladı. Fakat bilgisayarımızda işlem yapan çok kuvvetli bir parça daha var: grafik işlemci (GPU).GPU paralel işlem açısından neredeyse pür mikroişlemci kadar kuvvetlidir, hatta bazı açılardan daha hızlıdır, çünkü tarihsel sebeplerle parallelliğe daha fazla yatkın olması gerekmiştir. GPU bir görüntünün hızla çizilmesi (rendering) için piksel bazında paralelliğe gitmek zorundaydı ve NVİDİA şirketinin ürünleri için artık bu normal bir operasyondur.
Araştırmacılar bu paralellikten istifade etmeye karar vermişler, ve grafiksel olmayan hesap işlemlerini sanki öyleymiş gibi GPU'ya sunuyorlar, ve cevabı geri tercüme ediyorlar, böylece GPU'nun hızlı paralel işlemci özelliğinden faydalanıyorlar. Pek çok matematiksel hesabı bu şekilde yaptıran olmuş, mesela matris çarpımı, PDE çözümü, simülasyon.
NVidia şirketi grafik kartlarının GPU'suna erişim için CUDA diye bir kütüphane sağlıyor. Onun üstüne PyCUDA ile Python bazlı erişim de var. Bazı ülkelerin üniversitelerinde CUDA eğitimini müfredata dahil edilmiş. Dikkat: NVidia kartı piyasadaki grafik kartlarından bir tanesidir, her laptop üzerinde NVidia olmayabilir (fakat NVidia piyasadaki en ünlülerden birisi, bunu da ekleyelim). Şimdilik GPU kodlaması için NVidia kartına sahip bir bilgisayar lazım.
Fakat GPU bazlı kodlama oldukca popüler hale geldi ki video kart üreticisi NVidia dışarıdan bilgisayara dahil edilebilen ayrı bir ufak GPU ünitesi bile yarattı. Ünitenin ismi Jetson kartı.
https://developer.nvidia.com/buy-jetson
Kart üzerinde Ubuntu işleyebiliyor, network aktarıcısı (router) dahil edilip hemen ssh ile alete bağlanabiliyorsunuz. Yapay öğrenim ile uğraşanlar için müthiş haber GPU üzerinde mevcut 192 çekirdek aşırı seviyede paralellik isteyen deep learning yaklaşımı için biçilmiş kaftan, ki burada birisi denemiş, ve nasıl yapılabileceği anlatılıyor. Blog sahibi Pete Warden imaj üzerinde yapay öğrenim algoritmaları kullanma konusunda uzmanlardan biridir, bu iş için kurduğu şirketini geçende Google satın aldı. Tabii ki Jetson üzerinde direk CUDA kodlaması da yapılabilir.
CUDA
Pek çok türde paralelellik, eşzamanlılaştırma tekniği var. Mesela disk bazlı çalışan eşle/indirge bunlardan biri. Her teknik paralleliği hangi birim üzerinde, nerede, ne zaman yaptırdığı bağlamında birbirinden farklı. GPU kodlaması SIMD yaklaşımını benimser, SIMD = Single Instruction Multiple Data, yani Tek Komut Pek Çok Veri yaklaşımı. SIMD ile bir işlem, ki bu çarpma, toplama, vs gibi temel işlemler ya da onların toplamı olan bir hesap ünitesi olabilir, birden fazla veri noktası üzerinde aynı anda uygulanır. Bu yaklaşımın grafik kartları, grafikleme için nasıl faydalı olacağını görmek zor değil, çünkü grafik kartları için veri, görüntü pikselleri, ve her piksel üzerinde, resim kare kare oluşturulurken tek bir işlemi aynı anda uygulamak faydalı olur. Bu işlem transformasyon olabilir, ki aynı matrisi çarpmayı gerektirir, işin takip etme (ray tracing) olabilir, vs.
CUDA NVidia'nın kendi kartlarına programcı erişimi sağlayan bir
arayüzdür. Python üzerinde PyCuda üzerinden erişilebilir. NVidia'nın
nvcc
adlı genişletilmiş C++ derleyicisi aynı şekilde CUDA
kodlamasına C++ üzerinden izin verir.
Turkiye'de Alım
NVidia grafik kartına sahip bir makina fazla pahalı değil, özellikle yıllarca önce bir süperbilgisayar seviyesinde işletim gücüne eriştiğimizi düşünürsek. Bir oyun makinası PC ile beraber alınabilir, ya da, mesela harici Jetson kartı 70-100 dolar arasında.
GPU Var mı Kontrol
Dizüstü ya da masaüstü bilgisayarında acaba dışarıdan kodlanabilen GPU
var mı? Kontrol etmenin en kolay yolu Chromium tarayıcısına
sormak. Tarayıcı bu tür bilgilere sahip çünkü kendisi de mümkün olan
tüm hızlandırıcıları kullanmak istiyor, neyse tarayıcıya gidip adres
çubuğunda chrome://gpu
yazarsak gösterilen raporda eğer varsa CUDA
yetenekli kart gösterilecektir.
CUDA ve Collab
Geliştiriciler için bir diğer seçenek Google bulutu üzerinde barındırılan not defteri servisi Google Collab. Bu servis Jupyter teknolojisine İnternet üzerinden erişim sağlıyor denebilir.
Collab'a girip bir not defteri yaratalım, ve menüde Edit | Notebook
settings
seçelim, burada Hardware accelerator
seçimi var. Bu seçimi
GPU
haline getirelim. Bu kadar basit! Arka plandaki bir GPU
havuzundan Google size bir GPU atamış olacaktır.
Dikkat: Jupyter üzerinden GPU kullanımı direk kendi kartımız üzerinden kullanıma oranla yavaş olablir. Ama 10/20 kat hızlandırmayı hala Google Collab ile deneyimlemek mümkün.
Şimdi pycuda
kuralım. Bu işlemi aynı not defterini ilk kez
açtığımızda her seferinde tekrar yapmamız lazım. Çünkü arka planda
bize Google tarafından bir makina atanıyor, ve eski makinanın kurulumu
yokolmuş oluyor, her seferinde yeni bir makinada, yeni bir süreçteyiz.
!pip install pycuda
Kurulum bitince artık pycuda çağrıları yapabiliriz. Acaba makinamızda ne tür bir GPU var?
import pycuda.driver as drv
drv.init()
for i in range(drv.Device.count()):
gpu_device = drv.Device(i)
print ('Device {}: {}'.format( i, gpu_device.name() ))
compute_capability = float( '%d.%d' % gpu_device.compute_capability() )
print ('\t Compute Capability: {}'.format(compute_capability))
print ('\t Total Memory: {} megabytes'.format(gpu_device.total_memory()//(1024**2)))
Device 0: Tesla T4
Compute Capability: 7.5
Total Memory: 15079 megabytes
Fena bir kart değil, >2500 çekirdeği var.
En basit işlemle başlayalım. Bir vektör üzerindeki sayıları 2 ile çarpalım.
from time import time
import pycuda.autoinit
from pycuda import gpuarray
host_data = np.array(range(10**7),dtype=np.float32)
device_data = gpuarray.to_gpu(host_data)
t1 = time()
device_data_x2 = np.float(2) * device_data
t2 = time()
host_data_x2 = device_data_x2.get()
print ('GPU %0.8f saniye.' % (t2-t1))
t1 = time()
host_data_x2_cpu = host_data * np.float(2)
t2 = time()
print ('CPU %0.8f saniye.' % (t2-t1))
GPU 0.00174046 saniye.
CPU 0.00841069 saniye.
GPU ile CPU arasında 8 kat civarı fark var, 10 milyon tane sayıyı ikiye çarpmak için.
Not 1: Jupyter ortamında pycuda kodlarının daha yavaş işlediği tecrübelenmiştir, bu hız karşılaştırmasını nihai olarak görmemek lazım.
Not 2: pycuda bir kodu ilk işlettiğinde onu bir derleme sürecinden geçirir, ama ikinci sefer aynı kodu görünce bunu yapmaz [1]. Bu sebeple ikinci, üçüncü, vs. işletim daha hızlı olacaktır.
Kodu incelersek gpuarray.to_gpu
ile GPU'ya veriyi gönderdik. Daha
sonra np.float(2) * device_data
ile çarpma işlemi GPU üzerinde
yapıldı. Tabii arka planda Python bazı tutkallama işi yaptı mesela *
işlemi büyük ihtimalle belli tipler için üste tanımlı (overloaded), ve
gpuarray
gibi özel tipler söz konusu olunca arka planda GPU üzerinde
ek işlemler yapılacağı biliniyor.
Yapılan işlem çarpma, ve GPU bu her çarpma işlemini aynı anda, mümkün olduğu kadar fazla vektör öğesi üzerinde işletti.
Çekirdek (Kernel) Kod Kullanımı
Daha direk bir yöntemi görelim. Aslında CUDA kodları çekirdek kod
temelli işler, dışarıdan programcının verdiği bir kod, veri üzerinde
(mümkün olduğu kadar paralel bir şekilde) işletilir / uygulanır. Bu
Python'un map
, ya da Pandas apply
operasyonuna benzer.
import numpy as np
import pycuda.autoinit
from pycuda import gpuarray
from time import time
from pycuda.elementwise import ElementwiseKernel
host_data = np.float32( np.random.random(10**7) )
gpu_2x_ker = ElementwiseKernel(
"float *in, float *out",
"out[i] = 2*in[i];",
"gpu_2x_ker")
t1 = time()
host_data_2x = host_data * np.float32(2)
t2 = time()
print ('CPU: %f' % (t2 - t1))
device_data = gpuarray.to_gpu(host_data)
device_data_2x = gpuarray.empty_like(device_data)
t1 = time()
gpu_2x_ker(device_data, device_data_2x)
t2 = time()
from_device = device_data_2x.get()
print ('GPU: %f' % (t2 - t1))
CPU: 0.007268
GPU: 0.079255
Üstte ElementwiseKernel
objesine üç tane parametre verdik. Bunlardan
ilki çekirdeğe / fonksiyona verilecek parametreler, bunların ilki
giriş verisi, ikincisi çıkış verisi, sözdizim C dili sözdizimine
benziyor dikkat edilirse, C ile *ptr
ile tanımlanan değişkene
ptr[0]
, ptr[1]
, vs ile erişilebilir, göstergeç aritmetiği
uygulanabilir. Ayrıca çıkış vektörünün verisini "içeriden" alabilmek
için gpuarray.empty_like
ile onu dışarıda önceden tanımlamamız
gerekti.. Bu vektöre bir yer açtık, o yerdeki vektörün değerleri GPU
tarafından dolduruldu.
Metodun ana kodu ikinci parametrede, burada giriş vektör öğesi
üzerinde hangi işlem yapılıp hangi çıkış öğeye atandığı
kodlanıyor. İndis i
ile vektör öğesine erişiliyor, kod işlediğinde
her çekirdek eşzamanlı olarak tek bir öğe üzerinde işlem yapacak, buna
dikkat. GPU parallelliğinin temeli bu.
Bu [1]'den alınan bir giriş kodu tabii, parallelliğin bazı detayları
arka planda saklanmış, Python için en azından. Mesela kod içinde
referans edilen i
bir iş parçacığı (thread) indisidir, bu indislerin
eldeki N vektör öğesi için 0 ila N arası değerler olacağına emin
miyiz? Kodun işleyişi buna bağlı çünkü. Ayrıca N tane ayrı iş
parçacığı oluşturulacağını biliyor muyuz?
C ile iki vektörü toplayan bir kod suna benzer,
__global__ void VecAdd(float* A, float* B, float* C)
{
int i = threadIdx.x;
C[i] = A[i] + B[i];
}
int main()
{
...
VecAdd<<<1, N>>>(A, B, C);
...
}
Burada <<<1, N>>>
tanımındaki N
ile kaç iş parçacığı olacağı
önceden tanımlandı. Üstteki gibi basit bir Python çağrısı var ise, bu
tanım arka planda yapılıyor olmalı. Indis 0-N de aynı şekilde
garantileniyor muhakkak.
Mandelbrot Kümesi
Fraktal resimleri üretmek için Mandelbrot yaklaşımı kullanılabilir, [4]'te konunun matematiğinden bahsedilildi. Orada gösterilen yaklaşımda her hücre üzerinde teker teker bir hesap yapıldığını görüyoruz. Bu hesabı GPU üzerinde eşzamanlı olarak işletmek mümkün.
import numpy as np
import pycuda.autoinit
from pycuda import gpuarray
from pycuda.elementwise import ElementwiseKernel
mandel_ker = ElementwiseKernel(
"pycuda::complex<float> *lattice, float *mandelbrot_graph, int max_iters, float upper_bound",
"""
mandelbrot_graph[i] = 1;
pycuda::complex<float> c = lattice[i];
pycuda::complex<float> z(0,0);
for (int j = 0; j < max_iters; j++)
{
z = z*z + c;
if(abs(z) > upper_bound) {
mandelbrot_graph[i] = 0;
break;
}
}
""",
"mandel_ker")
width, height, real_low, real_high, imag_low, imag_high, max_iters, upper_bound = 512,512,-2,2,-2,2,256, 2
real_vals = np.matrix(np.linspace(real_low, real_high, width), dtype=np.complex64)
imag_vals = np.matrix(np.linspace( imag_high, imag_low, height), dtype=np.complex64) * 1j
mandelbrot_lattice = np.array(real_vals + imag_vals.transpose(), dtype=np.complex64)
mandelbrot_lattice_gpu = gpuarray.to_gpu(mandelbrot_lattice)
mandelbrot_graph_gpu = gpuarray.empty(shape=mandelbrot_lattice.shape, dtype=np.float32)
mandel_ker( mandelbrot_lattice_gpu, mandelbrot_graph_gpu, np.int32(max_iters), np.float32(upper_bound))
mandelbrot_graph = mandelbrot_graph_gpu.get()
from matplotlib import pyplot as plt
fig = plt.figure(1)
plt.imshow(mandelbrot_graph, cmap='inferno')
x,y değerleri düz birer tek boyutlu vektördü, onları üst üste
istifleyip tek bir vektör ile GPU'ya gönderdik, böylece birbirine eş
olan x,y değerleri aynı indis üzerinden GPU içinden erişilir hale
geldi. Sonra, çekirdek içinde, özyineli döngüye girdik, bu döngü her
öge için mümkün olduğu kadar farklı GPU çekirdeği üzerinde koşturmayı
yapacak. Tabii max_iters
döngüsü seri, senkron olarak işleyecek
fakat her x,y hücresinin paralel işlenmesi çok büyük bir ilerleme ve
hakikaten de Mandelbrot GPU kodu hızlı işliyor.
Üstteki kodda tam tekmilli program ifadeleri kullanabildiğimizi
görüyoruz, mesela for for
döngüsü. Sözdizim C temelli ve hata
ayıklama süreci biraz uğraştırabilir çok çetrefil denemez.
Bir çekirdek çağrısı işletmenin tek yolu ElementwiseKernel
değil,
aslında o yöntem en kolay olanı. Şimdi işleri biraz daha
zorlaştıralım, bir çekirdek fonksiyonu yazalım.
Ne zaman bu tür bir fonksiyon yazarsak ondan önce __global__
kelimesini kullanmamız gerekir. Dönüş tipi hep void
olur çünkü
döndürelecek veriyi zaten çıkış parametresi olarak
tanımlayacağız. Kodlayacağımız yine vektörü skalar ile çarpmak olacak
ama bu sefer skaların kendisi bir parametre.
import pycuda.autoinit, time
import pycuda.driver as drv
import numpy as np
from pycuda import gpuarray
from pycuda.compiler import SourceModule
ker = SourceModule("""
__global__ void scalar_multiply_kernel(float *outvec, float scalar, float *vec)
{
int i = threadIdx.x;
outvec[i] = scalar*vec[i];
}
""")
scalar_multiply_gpu = ker.get_function("scalar_multiply_kernel")
testvec = np.random.randn(512).astype(np.float32)
testvec_gpu = gpuarray.to_gpu(testvec)
outvec_gpu = gpuarray.empty_like(testvec_gpu)
t1 = time.time()
scalar_multiply_gpu( outvec_gpu, np.float32(2), \
testvec_gpu, block=(512,1,1),
grid=(1,1,1))
t2 = time.time()
outvec_gpu.get()
print ('total time to compute on GPU: %f' % (t2 - t1))
Bu kodda bir diğer fark threadIdx.x
ile eşzamanlı iş parçacığı
(thread) kimlik no'sunu çağrı yaparak kendimizin almış olmamız. Burada
bir soru acaba her ögeye yetecek kadar iş parçacığı (ki onların özgün
kimlik no'su) mevcut olacak mıdır, çünkü bu kimlik indeks olarak
vektör öğelerine erişmek için kullanılıyor? Cevap evet,
scalar_multiply_gpu
çağrısına bakarsak orada 512 tane iş parçacığı
tanımlandı, vektörün büyüklüğü de aynı.
NVCC
Eğer Çollab içinde nvcc
işletmek istersek, bunu ünlem ile yapabiliriz,
!nvcc --version
nvcc: NVIDIA (R) Cuda compiler driver
Copyright (c) 2005-2019 NVIDIA Corporation
Built on Sun_Jul_28_19:07:16_PDT_2019
Cuda compilation tools, release 10.1, V10.1.243
C++ kodu yazıp derlemek
%%writefile test.cpp
#include <stdio.h>
//Main function
int main(int Argc,char* Args[]){
printf("hello collab");
return 0;
}
Writing test.cpp
%%script bash
g++ -std=gnu++17 test.cpp -o test
ls -laX
./test
total 32
drwxr-xr-x 1 root root 4096 Dec 21 17:29 sample_data
-rwxr-xr-x 1 root root 8304 Dec 25 10:05 test
drwxr-xr-x 1 root root 4096 Dec 25 10:05 .
drwxr-xr-x 1 root root 4096 Dec 25 10:04 ..
drwxr-xr-x 1 root root 4096 Dec 21 17:29 .config
-rw-r--r-- 1 root root 109 Dec 25 10:05 test.cpp
hello collab
Bu derlemek işlemini nvcc
ile de yapabilirdik.
Kaynaklar
[1] Tuomanen, Hands-On GPU Programming with Python and CUDA
[2] https://colab.research.google.com/
[3] https://gist.github.com/jfpuget/60e07a82dece69b011bb
[4] [Gayri Lineer Dinamik, Ders 19](https://burakbayramli.github.io/dersblog/chaos/chaos19/ders19.html)
[5] https://developer.nvidia.com/blog/even-easier-introduction-cuda/
[6] [Jetson Nano](nvidia-jetson-nano-2GB-wifi.html)
Yukarı