Sukces regresji logistycznej dodał mi pewności siebie, ale wydajność programu była jak cios w brzuch. Naiwna implementacja mnożenia macierzy działała wieczność nawet dla małego zbioru danych (321 × 6). Żaden programista nie lubi wpatrywać się w konsolę, czekając aż program zakończy działanie.
Miałem w swoim komputerze kartę graficzną obsługującą CUDA, która tylko zbierała kurz. Przez dwa lata unikałem jej konfiguracji do celów programistycznych – czyste lenistwo. Ale żeby skalować rozwiązanie, musiałem je pokonać. Musiałem zmusić GPU do wykonywania ciężkich obliczeń.

Spędziłem trochę czasu na szukaniu alternatyw. Wiele wyników wskazywało na ndarray – wydajną bibliotekę Rust do pracy z wielowymiarowymi tablicami. To świetne narzędzie, ale używanie go „z pudełka” wydawało mi się jak oszustwo – kolejna magiczna czarna skrzynka. Moim celem była nauka od podstaw, więc gotowe rozwiązania odpadały. Natrafiłem wtedy na rust-cuda, zestaw narzędzi do integracji programowania GPU z Rust.
I wtedy otworzyła się kolejna królicza nora…
Konfiguracja
Aby wykorzystać potencjał rdzeni CUDA, pierwszym krokiem była konfiguracja środowiska. Na M$ w ogóle nie jest to łatwe zadanie. To właśnie konfiguracja zajmuje najwięcej czasu w całym procesie. Dlatego zachęcam z tego miejsca wszystkich, którzy myślą, że w Linux to tak samo trudne – to nieprawda, na Linux programiści mają raj programistyczny! Konfiguracja dostępu do karty GPU to minuty, o ile w ogóle trzeba coś robić!
Spędziłem chwilę na eksperymentach tylko po to, żeby zobaczyć, jak moja karta graficzna faktycznie coś robi. Nie używam karty graficznej za często.
Zainstalowałem:
- CUDA Toolkit
- cmake
- MSVC 19
- Code – a co, czasami można pobawić się z M$ Billem 😉
W końcu udało mi się uruchomić program mnożenia macierzy z użyciem cuBLAS.
cuBLAS to wysoce zoptymalizowana biblioteka od NVIDIA, będąca implementacją standardowych podprogramów algebry liniowej (BLAS) na procesory graficzne (ang. GPU). Zapewnia błyskawiczne operacje na macierzach i wektorach (szczególnie GEMM), kluczowe dla genAI, uczenia maszynowego i HPC. Jest częścią zestawu CUDA Toolkit, obsługując formaty danych od 16-bitowych (half) do 64-bitowych.
Uff!!! Udało się, co za zabawa z dedykowanymi bibliotekami pod GPU a wszystko po to tylko, aby szybciej mnożyć liczby 🙂
Początkowa konfiguracja zakończona. Wysiłek się opłacił. Spędziłem godzinę bawiąc się przykładami CUDA – dla czystej przyjemności. Mnożenie macierzy 1280 × 960 przez 960 × 640 zajęło zaledwie 0.619 ms i GPU nawet się nie spociło. Poszedłem dalej – 12800 × 9600 razy 9600 × 6400. Wynik mnie zaskoczył: tylko 392.912 ms.
Po raz pierwszy od dwóch lat użyłem mojego GPU NVIDIA dokładnie tak, jak zawsze planowałem.
Odkrywanie sekretu GPU
Byłem ciekawy, skąd bierze się ta prędkość liczenia na GPU. Ogólna odpowiedź była znana – równoległość (ang. SIMD, Single Instruction Multiple Data). Nie byłem jednak pewien mechanizmu działania. Zagłębiłem się w architekturę CUDA: dokumentację, kanał NVIDIA Developers na YouTube i inne materiały. Sporo tego, ale bardzo dobre materiały są w sieci do nauki używania GPU – nie tylko do grania!
Uwaga: Nie jestem specjalistą od sprzętu – poniższe wyjaśnienie jest uproszczone i może zawierać nieścisłości.
GPU (urządzenie) traktowane jest jak zewnętrzny komponent komunikujący się z CPU (hostem). Proces wygląda mniej więcej tak:
- CPU rezerwuje pamięć na GPU (
cudaMalloc) - Dane są kopiowane z hosta do urządzenia (
cudaMemcpy) - CPU uruchamia kernel (funkcję wykonywaną na GPU)
- GPU wykonuje obliczenia równolegle
- Synchronizacja (funkcja t.j.
cudaDeviceSynchronize) - Wyniki wracają do CPU
- Pamięć GPU jest zwalniana
GPU zarządza hierarchią wątków: Grid -> Bloki -> Wątki.
Każdy wątek wykonuje ten sam kod (kernel) na innych danych.
GPU potrafi uruchamiać miliony wątków na sekundę – i stąd bierze się jego wydajność.
Dopóki wątki są od siebie niezależne, mogą działać równolegle i tutaj mamy mega zysk na prędkości obliczeń. W porównaniu do CPU to będzie czasami 1000x szybciej, zależy od przypadku użycia.
Integracja z językiem programowania RUST
Gdy już nacieszyłem się eksperymentami, przyszedł czas na coś bardziej zaawansowanego. Rust nie ma natywnej biblioteki CUDA od NVIDIA, więc musiałem samodzielnie połączyć wszystko przez FFI.
Zacząłem od rust-cuda, ale po kilku godzinach bez sukcesu sięgnąłem po inne opcje. Testowałem cust, rust-gpu i inne propozycje z internetu. Ostatecznie cust zadziałał.
Napisałem swój pierwszy program CUDA – odejmowanie wektorów.
// C
#include <cuda.h>
#include <cuda_runtime.h>
extern "C" __global__
void vectorSub(const float* a, const float* b, float* out, int n) {
int idx = blockIdx.x * blockDim.x + threadIdx.x;
if (idx < n) {
out[idx] = a[idx] - b[idx];
}
}
// RUST
// Load compiled CUDA kernels from PTX module
let ptx = include_str!("../kernels/vector_sub.ptx");
let module = Module::from_ptx(ptx, &[])?;
// Retrieve kernel function references
let sub = module.get_function("vectorSub")?;
// Allocate GPU memory buffers
let d_a = DeviceBuffer::from_slice(&a)?; // First input vector: rows
let d_b = DeviceBuffer::from_slice(&b); // Second input vector: rows
let d_c = DeviceBuffer::from_slice(&vec![0.0f32; rows])?; // Result vector
unsafe {
launch!(sub<<<(grid_rows,1,1), block1d, 0, stream>>>(
d_a.as_device_ptr(),
d_b.as_device_ptr(),
d_c.as_device_ptr(),
rows as i32
))?;
}
d_c.copy_to(&mut c)?;
Uruchomienie zakończyło się sukcesem, a wyniki były poprawne – co oznaczało, że integracja Rust <-> CUDA działa.
Z nową dawką motywacji przeszedłem do kolejnego kroku – mnożenia macierzy.
// C
#include <cuda.h>
#include <cuda_runtime.h>
// CUDA kernel for matrix multiplication
extern "C" __global__ void matrix_mul(float *A, float *B, float *C, int numARows, int numAColumns, int numBColumns)
{
int Row = blockIdx.y * blockDim.y + threadIdx.y;
int Col = blockIdx.x * blockDim.x + threadIdx.x;
if (Row < numARows && Col < numBColumns)
{
float Cvalue = 0.0;
for (int k = 0; k < numAColumns; ++k)
{
Cvalue += A[Row * numAColumns + k] * B[k * numBColumns + Col];
}
C[Row * numBColumns + Col] = Cvalue;
}
}
Zacząłem od małych przypadków:
- 2 × 1 × 1 × 2
- porównanie wyników z CPU
- testy na losowych danych
Gdy wszystko działało poprawnie, przeszedłem do większych danych.
Mnożenie macierzy 1024 × 1024 przez 1024 × 1024 zajęło tylko kilka milisekund. O kurcze, na prawdę jest postęp, szczególnie, że na moim CPU to była nauka cierpliwości, na GPU obliczenia działają w mknieniu oka w Linux.
Wnioski
W końcu się udało i system operacyjny Linux okazał się nieodzownym czynnikiem sukcesu pracy z GPU. Pokonałem lenistwo, włożyłem wysiłek i osiągnąłem cel, który planowałem od dwóch lat. Byłem bardzo podekscytowany możliwością integracji tego rozwiązania z moim repozytorium Machine Learning. Polecam każdemu leniwemu programiście wyjście ze strefy komfortu i zainteresowanie się uczeniem maszynowym. Koniecznie na jakimś praktycznym przykładzie! Powodzenia!