GPU-ohjelmoinnissa, erityisesti suurten tietomäärien käsittelyssä, lohkojen ja ruudukon kokoonpanon valinta on yksi tärkeimmistä suorituskykyyn vaikuttavista tekijöistä. CUDA-arkkitehtuurin massiivinen rinnakkaisuus mahdollistaa suurten tietomäärien nopean käsittelyn, mutta laitteiston rajoitukset, kuten lohkojen yhteinen muisti, rekistereiden käyttö ja samanaikaisesti käytettävien säikeiden määrä multiprosessorissa, tarkoittavat, ettei kaikki lohko- ja ruudukkoasetukset tuota optimaalista suorituskykyä. Huonot valinnat voivat johtaa GPU-resurssien alikäyttöön, muistikaasuihin tai jopa ytimien epäonnistumiseen laitteiston rajojen ylittyessä.

Hyvin toimivat ytimet saavuttavat tarkasti tasapainon: lohkot ovat tarpeeksi suuria pitämään kaikki SM:t (streaming multiprocessorit) täysimääräisesti käytössä, mutta eivät niin suuria, että ne kuluttaisivat kaikki laitteiston resurssit. Tavoitteena on maksimoida käyttöaste – eli se osuus GPU-resursseista, joka on aktiivisesti käytössä laskentatehtävissä millä tahansa hetkellä.

CUDA:ssa säikeet on organisoitu hierarkkisesti. Yksi taso on lohko, joka on säikeiden ryhmä, joka voi yhteistyössä käyttää yhteistä muistia ja synkronointia (__syncthreads()). Kaikki säikeet lohkossa suoritetaan samalla SM:llä. Toinen taso on ruudukko, joka on koko lohkojen joukko, joka on lanseerattu ytimen toimesta ja kattaa koko tietomäärän. Lohkon ja ruudukon koon valinta vaikuttaa siihen, miten työ jaetaan ja kartoitetaan laitteistolle.

Yleinen käytäntö on valita lohkokoot, jotka ovat 32:n (warp size) monikertoja, kuten 128, 256 tai 512 säiettä lohkossa. Optimaalinen arvo riippuu siitä, kuinka paljon rekistereitä ydin käyttää, kuinka paljon yhteistä muistia on käytettävissä ja kuinka suuri tietomäärä on.

Optimaalinen lohko- ja ruudukon koko määräytyy kolmen päätekijän mukaan:

  1. Kokonaisongelman koko (kuinka monta elementtiä tai tehtävää käsitellään)

  2. Laitteiston rajoitukset (suurin säieiden määrä lohkossa, käytettävissä oleva yhteinen muisti, rekistereiden määrä)

  3. Ytimen resurssien käyttö (kuinka paljon yhteistä muistia ja kuinka monta rekisteriä kukin säie tai lohko käyttää)

Aloituskohtana voidaan käyttää NVIDIA:n tarjoamaa CUDA-occupancy-laskuria, joka mallintaa kuinka monta lohkoa voi suorittaa samanaikaisesti jokaisella SM:llä ytimen resurssien käytön mukaan.

Optimaalisen kokoonpanon valinnassa voidaan käyttää esimerkkinä yksinkertaista ohjelmaa, joka käsittelee suurta 1D-taulukkoa:

python
import numpy as np import cupy as cp N = 10_000_000 # Kokonaismäärä a = cp.random.rand(N).astype(cp.float32) b = cp.random.rand(N).astype(cp.float32) c = cp.empty_like(a) threads_per_block = 256 blocks_per_grid = (N + threads_per_block - 1) // threads_per_block kernel_code = """ extern "C" __global__ void vector_add(const float* a, const float* b, float* c, int n) { int idx = blockDim.x * blockIdx.x + threadIdx.x; if (idx < n) { c[idx] = a[idx] + b[idx]; } } """ mod = cp.RawModule(code=kernel_code) vector_add = mod.get_function("vector_add") vector_add( (blocks_per_grid,), (threads_per_block,), (a, b, c, N) )

Tässä käytetään lohkokohtaisesti 256 säiettä, mutta on tärkeää varmistaa, että tämä ei ylitä GPU:n sallimaa maksimimäärää (yleensä 1024 säiettä/lohko). Tämän jälkeen voidaan arvioida kokoonpanon tehokkuus ja tutkia, kuinka suorituskyky muuttuu eri lohkokokojen mukaan.

CUDA tarjoaa myös occupancy-laskurin, joka on verkkopohjainen työkalu, joka kertoo kuinka monta lohkoa voi suorittaa samanaikaisesti annetulla kokoonpanolla. Tämä työkalu auttaa arvioimaan lohkon koon, käytettävän yhteisen muistin ja rekistereiden määrän vaikutuksia.

Suorituskyvyn mittaamiseen voidaan käyttää ajastusta eri lohkokokojen osalta. Tällä tavoin voidaan tunnistaa optimaalinen lohkoko, joka tuottaa parhaan läpimenon tietyllä laitteistolla. Esimerkiksi:

python
import time
for block_size in [64, 128, 256, 512, 1024]: grid_size = (N + block_size - 1) // block_size c_test = cp.empty_like(a) start = time.time() vector_add( (grid_size,), (block_size,), (a, b, c_test, N) ) cp.cuda.Stream.null.synchronize() end = time.time() print(f"Block size {block_size}, Time: {end - start:.5f} seconds")

Tässä kokeillaan useita lohkokoja ja mitataan suoritusaika, jolloin voidaan nähdä, millä koon tasolla suorituskyky on paras.

GPU-ohjelmoinnissa globaalin muistin liikenne on usein suorituskyvyn pullonkaula. Kun säikeet joutuvat hakemaan tietoa globaalista muistista, ne kokevat viiveitä, jotka voivat olla huomattavasti suurempia kuin itse laskentatehtävien suorittaminen. Jos useat säikeet tarvitsevat saman datasetin käsittelyä, kuten naapuridatan tai matriisin tai kuvan käsittelyn, globaalin muistin hakukutsut voivat nopeasti kerryttää viiveitä.

Yksi ratkaisu tähän on CUDA:n yhteinen muisti, joka tarjoaa pienen, käyttäjän hallitseman, nopean muistin alueen, joka on paikallinen jokaiselle lohkolle. Jos data ladataan globaalista muistista yhteiseen muistiin vain kerran ja kaikki säikeet voivat käyttää tätä yhteistä kopiota, se vähentää muistiliikennettä ja parantaa ytimen läpimenoa. Tämä tekniikka tunnetaan puskurointina. Puskurointi on käytännössä tapa tallentaa osa globaalista muistista, jotta eri säikeet voivat käyttää sitä toistuvasti ilman, että niiden täytyy mennä takaisin globaaliin muistiin.

1D stencil-laskennassa, jossa jokainen tuloselementti riippuu omasta arvostaan ja naapureidensa arvoista taulukossa, yhteinen muisti voi olla erittäin tehokas. Jos jokainen säie hakisi naapurinsa globaalista muistista erikseen, hakemukset toistuisivat monia kertoja. Käyttämällä yhteistä muistia, jokainen lohko voi yhdessä ladata alueen, joka kattaa kaikkien säikeiden tarpeet, mukaan lukien naapurit.

python
import numpy as np import pycuda.autoinit import pycuda.driver as drv import pycuda.gpuarray as gpuarray from pycuda.compiler import SourceModule N = 1024 * 1024 radius = 3 # Kuinka monta naapuria vasemmalla ja oikealla input_host = np.random.rand(N).astype(np.float32) output_host = np.zeros_like(input_host) input_gpu = gpuarray.to_gpu(input_host) output_gpu = gpuarray.empty_like(input_gpu)

Tässä esimerkissä data valmistellaan, ja GPU:lle siirretään taulukot, joita käytetään stencil-laskennassa. Tämä voi olla tyypillinen tapa käyttää yhteistä muistia tehokkaasti.

Miksi ja miten jakamista muistia käytetään tehokkaasti GPU-koodissa?

GPU:n ohjelmointi on usein haasteellista, koska laitteiston optimaalinen hyödyntäminen vaatii hyvää muistinhallintaa ja laskentatehon tasapainottamista. Erityisesti muistin käyttö ja sen hallinta ovat keskeisiä tekijöitä suorituskyvyn parantamisessa, ja yksi tehokkaimmista tavoista optimoida muistinhallintaa on käyttää jaettua muistia (shared memory). Tämä muistityyppi mahdollistaa tietojen jakamisen lohkojen sisällä, mikä vähentää globaaliin muistiin pääsyä ja parantaa suorituskykyä.

Kuvitellaan, että meillä on 1D-taulukko, joka pitää käsitellä useilla säikeillä. Ilman jaettua muistia jokainen säie lukisi samat arvot globaaliin muistiin monia kertoja, mikä johtaisi turhiin muistioperaatioihin ja hitaampaan suoritukseen. Jaetun muistin avulla sen sijaan jokainen lohko lataa tarvittavat arvot muistiin kerran ja jakaa ne kaikille säikeille, mikä minimoi tarpeettoman globaalin muistin käytön.

Esimerkissä, jossa käytetään jaettua muistia, säikeet lataa lohkon keskiosan ja “halo-alueet” naapureistaan ja laskee tulokset jaetusta muistista. Tämä ratkaisu vähentää tarpeettomien globaalin muistin lukuoperaatioiden määrää ja mahdollistaa laskentatehon keskittämisen. Esimerkiksi:

cpp
#define RADIUS {radius}
#define BLOCK_SIZE 256 __global__ void stencil_1d(const float *input, float *output, int N) { __shared__ float smem[BLOCK_SIZE + 2 * RADIUS]; int tid = threadIdx.x; int global_idx = blockIdx.x * BLOCK_SIZE + tid; int smem_idx = tid + RADIUS; if (global_idx < N) smem[smem_idx] = input[global_idx]; if (tid < RADIUS && global_idx >= RADIUS) smem[smem_idx - RADIUS] = input[global_idx - RADIUS]; if (tid >= BLOCK_SIZE - RADIUS && (global_idx + RADIUS) < N) smem[smem_idx + RADIUS] = input[global_idx + RADIUS]; __syncthreads(); if (global_idx >= RADIUS && global_idx < N - RADIUS) { float sum = 0.0f; for (int j = -RADIUS; j <= RADIUS; ++j) sum += smem[smem_idx + j]; output[global_idx] = sum / (2 * RADIUS + 1); } }

Tässä esimerkissä lohko lataa keskitetyt arvot sekä vasemman ja oikean "halo"-alueen arvojen kanssa ja käyttää niitä laskentaan. Tämä on esimerkki siitä, miten jaettu muisti voi vähentää muistin käyttöä ja parantaa suorituskykyä.

Kerneliä suoritettaessa muistihallinta on tärkeä tekijä, ja se vaikuttaa merkittävästi suorituksen nopeuteen. Erityisesti muistinkäytön optimointi voi muuttaa muistiin sidotut kernelit laskentapainotteisiksi, jolloin suorituskyky paranee.

Muistin käyttö ja kaistanleveyden optimointi

GPU-ohjelmoinnin kehittyessä muistinkäytön ja kaistanleveyden hallinta on tullut entistä tärkeämmäksi. Kun useat säikeet pyytävät globaalia muistia, GPU:n laitteistot yrittävät yhdistää nämä pyynnöt mahdollisimman vähäisiksi muistioperaatioiksi. Jos säikeet lukevat muistia järjestelmällisesti ja yhtenäisesti, laitteisto pystyy hakemaan tarvitut arvot yhdellä kertaa, mikä maksimoi kaistanleveyden käytön. Jos pyynnöt ovat hajanaisia, laitteisto voi joutua käynnistämään erilliset muistioperaatiot jokaiselle säikeelle, mikä hidastaa suorituskykyä.

Tätä ilmiötä kutsutaan "uncoalesced access" -muistiin pääsytavaksi, joka voi aiheuttaa merkittäviä suorituskykyongelmia. Yksi yleisimmistä ja kalliimmista tehottomuuksista GPU-ohjelmoinnissa on juuri tämä kaistanleveyden väärinkäyttö. Suorituskyvyn maksimoimiseksi on tärkeää, että säikeet, jotka kuuluvat samaan "warp"-ryhmään (yleensä 32 säiettä), pääsevät käsiksi peräkkäisiin muistiosoitteisiin aina kun mahdollista.

Esimerkiksi seuraavassa ohjelmassa verrataan kaistanleveyttä ja sen optimointia käyttämällä "coalesced" ja "uncoalesced" muistioperaatioita. Coalesced-versio on huomattavasti nopeampi, koska laitteisto pystyy yhdistämään muistipyynnöt ja hakemaan muistia vain yhden operaation avulla:

cpp
__global__ void copy_coalesced(const float *input, float *output, int N) { int idx = blockIdx.x * blockDim.x + threadIdx.x; if (idx < N) output[idx] = input[idx]; }

Kuten esimerkistä voidaan huomata, muistipyynnöt on järjestetty siten, että peräkkäiset säikeet käyttävät peräkkäisiä muistiosoitteita. Tämä mahdollistaa kaistanleveyden täyden hyödyntämisen ja parantaa suorituskykyä huomattavasti.

Tietojen järjestäminen ja muistin rakenne

Coalescingin maksimoimiseksi on tärkeää järjestää data niin, että peräkkäiset säikeet pääsevät käsiksi peräkkäisiin muistiosoitteisiin. Tietorakenteiden järjestämisellä on suuri merkitys kaistanleveyden hyödyntämisessä. Esimerkiksi "array-of-structures" (AoS) -muoto, jossa eri kentät ovat sekoittuneita, voi estää coalescingin, kun taas "structure-of-arrays" (SoA) -muoto, jossa jokainen kenttä tallennetaan erillisiin taulukkoihin, on usein tehokkaampi.

Tällöin muistinhallinta ja datan järjestäminen oikein voivat muuttaa huonosti suoriutuvat kernelit tehokkaiksi, jolloin GPU-ohjelmat voivat skaalautua paremmin datan koon ja laskentatehon vaatimusten kasvaessa.

GPU-resurssien hyödyntäminen: Occupancy

GPU:n optimoinnissa ei ole kyse pelkästään muistinkäytöstä, vaan myös laitteiston resurssien, kuten säikeiden ja rekisterien, tehokkaasta hyödyntämisestä. "Occupancy" on mittari, joka kuvaa, kuinka monta säiettä on aktiivisia suhteessa siihen, kuinka monta säiettä voisi olla mahdollisesti käynnissä. Korkea occupancy ei aina takaa hyvää suorituskykyä, mutta se on tärkeä tekijä, sillä se osoittaa, kuinka hyvin GPU:n resurssit on saatu täyttymään. Matala occupancy taas tarkoittaa, että suuri osa GPU:n resursseista jää käyttämättömäksi, mikä hidastaa suoritusta.

Tämän vuoksi optimaalinen suorituskyky saavutetaan vain, jos muistinhallinta on tehokasta, säikeet on järjestetty oikein ja laitteiston kaikki resurssit hyödynnetään täysimääräisesti.

Kuinka yhdistää PyCUDA ja CuPy tehokkaasti datan käsittelyssä

GPU:n tehokas käyttö datan käsittelyyn ja laskentatehtäviin on tärkeä osa modernia tieteellistä tutkimusta, insinööritöitä ja analytiikkaa. Usein on kuitenkin tarpeen yhdistää useita työkalupakkeja, jotta voidaan saada paras mahdollinen hyöty eri kirjastojen tarjoamista ominaisuuksista. Tässä käsitellään, kuinka yhdistää PyCUDA ja CuPy saumattomasti tehokkaan GPU-laskennan toteuttamiseksi.

PyCUDA ja CuPy – miksi yhdistää?

PyCUDA ja CuPy tarjoavat kaksi erilaista lähestymistapaa GPU-ohjelmointiin. PyCUDA antaa mahdollisuuden käyttää GPU:n matalan tason ominaisuuksia, kuten dynaamista kernelin käännöstä ja suoraa pääsyä CUDA-ominaisuuksiin. Se mahdollistaa hienosäädetyn kontrollin muistin hallinnassa ja CUDA-koodin kirjoittamisessa Pythonin avulla. CuPy puolestaan tarjoaa NumPy:n kaltaisen API:n, joka on suunniteltu nopeisiin elementtikohtaisiin operaatioihin ja korkeantason matematiikkaan, mutta se ei tarjoa samanlaista hienosäätöä muistinhallinnassa.

Joitakin tilanteita, joissa molempia kirjastoja tarvitaan, ovat esimerkiksi silloin, kun on aloitettava projekti PyCUDA:lla mukautetun kernelin tai muistin varauksen vuoksi, mutta myöhemmin siirrytään CuPy:n tarjoamiin käteviin slice- ja broadcast-tekniikoihin. Tällöin datan jakaminen näiden kahden kirjaston välillä on ratkaisevan tärkeää. Tavoitteena on välttää tarpeettomia kopioita laitteelta isäntäkoneelle ja pitää kaikki data GPU:lla.

PyCUDA ja CuPy yhteensopivuus

Sekä PyCUDA että CuPy hallitsevat laitteen muistia omilla GPU-taulukko-luokillaan:

  • pycuda.gpuarray.GPUArray

  • cupy.ndarray

Molemmat ovat kääreitä CUDA-laitteen osoittimille ja muistialueille, mutta niiden Python-rajapinnat eroavat toisistaan. On kuitenkin mahdollista luoda toinen kirjaston taulukko toisen laitteen muistiosoitteesta ilman lisäkopioita. Tämä mahdollistaa sujuvan datan siirron PyCUDA:n ja CuPy:n välillä, mikä vähentää tarpeettomia laitteen ja isäntäkoneen välisiä siirtoja ja parantaa ohjelman suorituskykyä.

Datan siirtäminen PyCUDA:sta CuPy:iin

Oletetaan, että meillä on PyCUDA:n GPUArray (esimerkiksi mukautetusta kernelistä saatu taulukko). Voimme luoda suoran CuPy:n ndarray-näkymän tälle muistille seuraavasti:

python
import pycuda.gpuarray as gpuarray
import cupy as cp # PyCUDA GPUArray arr_host = np.arange(10_000, dtype=np.float32) arr_gpu_py = gpuarray.to_gpu(arr_host) # CuPy ndarray -näkymä ptr = arr_gpu_py.gpudata # Raaka laiteosoitin shape = arr_gpu_py.shape dtype = arr_gpu_py.dtype arr_cupy = cp.ndarray(shape, dtype=dtype, memptr=cp.cuda.MemoryPointer(cp.cuda.UnownedMemory(int(ptr), arr_gpu_py.nbytes, arr_gpu_py), 0))

Tässä cp.cuda.UnownedMemory käärii PyCUDA:n muistin ilman kopiointia, ja MemoryPointer kertoo CuPy:lle, että sen tulee käyttää tätä muistia omana taulukkodatanaan. Näin PyCUDA:n GPUArray pitää muistialueen elossa niin kauan kuin jompikumpi objekti on olemassa.

Datan siirtäminen CuPy:stä PyCUDA:han

Jos meillä on jo prosessoitua dataa CuPy:ssä ja haluamme siirtää sen PyCUDA:han, voimme luoda PyCUDA:n GPUArray-näkymän seuraavalla tavalla:

python
import pycuda.driver as drv
import pycuda.gpuarray as gpuarray # CuPy ndarray arr_cupy = cp.arange(10_000, dtype=cp.float32) # PyCUDA GPUArray -näkymä ptr = arr_cupy.data.ptr # Raaka muistiosoite shape = arr_cupy.shape dtype = np.dtype(str(arr_cupy.dtype)) gpudata = drv.DeviceAllocation(ptr) arr_gpu_py = gpuarray.GPUArray(shape, dtype, gpudata)

Tässä molemmat objektit viittaavat samaan laitemuistiin, eikä dataa kopioida. Tällä tavoin voidaan tehokkaasti yhdistää PyCUDA ja CuPy laitteistotason optimointiin ja korkeantason laskentaan.

Tärkeää ymmärtää

PyCUDA:n ja CuPy:n yhdistäminen tarjoaa mahdollisuuden yhdistää matalan tason muistinhallinnan ja korkeantason, vähemmän koodia vaativat funktiot, kuten broadcastin ja indeksoinnin. Tämä ei vain paranna ohjelman suorituskykyä, vaan mahdollistaa myös erittäin monimutkaisten laskentatehtävien toteuttamisen tehokkaasti ilman, että tarvitsee huolehtia muistinhallinnan yksityiskohdista liikaa.

On tärkeää ymmärtää, että vaikka tämä tekniikka tarjoaa suuren joustavuuden, se ei sovi kaikkiin tilanteisiin. Mikäli datan siirto laitteelta isäntäkoneelle on tarpeen (esimerkiksi monivaiheisessa prosessoinnissa), saattaa olla parempi valita yksi kirjasto sen mukaan, kumpi on suorituskyvyn kannalta parempi tietyssä vaiheessa. Lisäksi se, kuinka laite muistia hallitaan, vaikuttaa suoraan ohjelman tehokkuuteen, joten on tärkeää ymmärtää, milloin on järkevää siirtää dataa ja milloin ei.

Yhdistämällä PyCUDA:n ja CuPy:n tarjoamat mahdollisuudet voi saavuttaa optimoidun suorituskyvyn GPU-laskennassa samalla, kun koodin ylläpidettävyys säilyy.