CUDA প্রোগ্রামিং মডেল — হার্ডওয়্যারকে কোড দিয়ে address করা
CUDA Programming Model
একটা `__global__` kernel, `<<<numBlocks, blockSize>>>` launch, আর `blockIdx.x * blockDim.x + threadIdx.x` — CUDA প্রোগ্রামিং-এর সবচেয়ে গুরুত্বপূর্ণ লাইন। সম্পূর্ণ vector-add উদাহরণ, শেয়ার্ড মেমরি, আর OpenCL-এর সাথে তুলনা।
আগে এটা বুঝি
গত লেসনে আমরা দেখেছি GPU হার্ডওয়্যার কেমন গঠিত — SM, warp, SIMT, memory hierarchy। কিন্তু হার্ডওয়্যার জানা আর সেটাকে address করা এক জিনিস নয়। প্রশ্নটা এখন হলো: একজন প্রোগ্রামার হিসেবে আপনি কীভাবে বলবেন “এই ২০ লক্ষ pixel-এর প্রতিটার জন্য একটা thread চালাও, আর প্রতিটা thread জানুক তার নিজের কাজের ভাগ কোনটা”?
CUDA (Compute Unified Device Architecture) হলো NVIDIA-র উত্তর এই প্রশ্নের — C++-এর একটা extension যা GPU-তে চলা কোড লেখার syntax দেয়। এই লেসনে আমরা একটা সম্পূর্ণ, শুরু থেকে শেষ পর্যন্ত কাজ করা প্রোগ্রাম লিখব, আর প্রতিটা লাইনের সাথে গত লেসনের হার্ডওয়্যার ধারণাগুলো মিলিয়ে দেখব।
মূল ধারণা
Kernel — GPU-তে চলা একটা function
একটা kernel হলো একটা function যা GPU-তে (device-এ) চলে,
কিন্তু CPU (host) থেকে ডাকা হয়। __global__ keyword দিয়ে
চিহ্নিত করা হয়:
__global__ void vecAdd(const float *A, const float *B, float *C, int n) {
int i = blockIdx.x * blockDim.x + threadIdx.x;
if (i \< n) {
C[i] = A[i] + B[i];
}
}লক্ষ্য করুন — এই function-এর ভেতরে কোনো loop নেই যা nটা element-এর
উপর দিয়ে যায়। কারণ loop-টা নেই — এটা হার্ডওয়্যারে আছে। এই
একই function-এর কোড একসাথে হাজার হাজার copy GPU-তে চলবে,
প্রতিটা copy একটা ভিন্ন i নিয়ে। এটাই gpu-architecture লেসনের
SIMT মডেল বাস্তবে — প্রোগ্রামার একটা scalar-দেখতে function লেখেন,
হার্ডওয়্যার সেটাকে হাজার হাজার lane-এ চালায়।
Launch syntax — তিনটা angle bracket
Host থেকে একটা kernel ডাকা হয় এই বিশেষ syntax দিয়ে:
vecAdd<<<numBlocks, blockSize>>>(d_A, d_B, d_C, n);এই launch syntax বলছে কতগুলো thread launch করতে হবে, আর তাদের কীভাবে সংগঠিত করতে হবে — গত লেসনের SM/warp হার্ডওয়্যারের সাথে সরাসরি সম্পর্কিত একটা hierarchy দিয়ে:
Grid (পুরো kernel launch)
├── Block ০ (blockIdx.x = 0) ── একটা SM-এ schedule হয়
│ ├── Thread ০ (threadIdx.x = 0)
│ ├── Thread ১
│ ├── ...
│ └── Thread blockDim.x−১
├── Block ১ (blockIdx.x = 1) ── অন্য (বা একই) SM-এ
│ └── ...
└── Block numBlocks−১
প্রতিটা Block-এর ভেতরের thread-গুলো ৩২-জনের warp-এ ভাগ হয়
(gpu-architecture লেসন) — এই ভাগাভাগি hardware করে, automatically।- Thread — একটা independent execution unit, একটা
iনিয়ে কাজ করে - Block — thread-দের একটা দল যারা একসাথে একটা SM-এ চলে, তাই
__shared__memory ভাগাভাগি করতে পারে (এই লেসনের পরের অংশে) - Grid — একটা kernel launch-এর সব block একসাথে
blockSize (কতগুলো thread প্রতি block-এ) সাধারণত ১২৮, ২৫৬,
বা ৫১২ বেছে নেওয়া হয় — সবসময় ৩২-এর গুণিতক (গত লেসনের
misconception মনে করুন: গুণিতক না হলে warp-এর একটা অংশ নষ্ট হয়)।
numBlocks হিসাব করা হয় মোট কাজের পরিমাণ থেকে:
int numBlocks = (n + blockSize - 1) / blockSize; // ceiling divisionGlobal thread index — CUDA-র সবচেয়ে গুরুত্বপূর্ণ লাইন
int i = blockIdx.x * blockDim.x + threadIdx.x;এই একটা লাইনই প্রতিটা CUDA প্রোগ্রামে থাকে, আর এটা বোঝাটাই সবচেয়ে জরুরি। প্রতিটা thread জানে তিনটা জিনিস (built-in variable হিসেবে দেওয়া থাকে):
threadIdx.x— এই thread-টা তার নিজের block-এর ভেতরে কততম (০থেকেblockDim.x − ১)blockIdx.x— এই thread-এর block-টা পুরো grid-এর ভেতরে কততমblockDim.x— প্রতিটা block-এ কতগুলো thread আছে
সমস্যা: threadIdx.x একা যথেষ্ট নয়, কারণ প্রতিটা block-এর
মধ্যে এটা আবার ০ থেকে শুরু হয় — Block ০-এর thread ৫ আর
Block ১-এর thread ৫, দুটোরই threadIdx.x = 5, কিন্তু তাদের
array-তে ভিন্ন element নিয়ে কাজ করার কথা। দরকার একটা globally
unique index।
blockDim.x = 4 (প্রতি block-এ ৪টা thread ধরি, সরলতার জন্য)
Block 0: [t0][t1][t2][t3] global index = 0*4+0, 0*4+1, 0*4+2, 0*4+3 → 0,1,2,3
Block 1: [t0][t1][t2][t3] global index = 1*4+0, 1*4+1, 1*4+2, 1*4+3 → 4,5,6,7
Block 2: [t0][t1][t2][t3] global index = 2*4+0, ... → 8,9,10,11
সাধারণ formula: i = blockIdx.x * blockDim.x + threadIdx.x
^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^
আগের সব block-এর মোট এই block-এর
thread সংখ্যা "টপকে যাওয়া" ভেতরের অবস্থানblockIdx.x * blockDim.x অংশটা বলছে “আমার আগে কতগুলো সম্পূর্ণ
block আছে, প্রতিটায় blockDim.x টা thread — সেই সবগুলো টপকে
যাও।” তারপর + threadIdx.x বলছে “এখন আমার নিজের block-এর ভেতরে
আমার অবস্থান যোগ করো।” এটা ঠিক সেই একই গাণিতিক প্যাটার্ন যা একটা
২-মাত্রার array-কে ১-মাত্রায় flatten করতে ব্যবহার হয় (row * width + col) — শুধু “row” এখানে “block”, “width” এখানে
“blockDim”।
ভেতরে কী ঘটছে
Launch করার পর আসলে কী ঘটে
Kernel launch লেখার পর CPU একটা asynchronous
request পাঠায় GPU driver-কে — CPU সাথে সাথেই পরের লাইনে চলে যায়
(kernel শেষ হওয়ার জন্য অপেক্ষা করে না, যদি না explicitly
cudaDeviceSynchronize() ডাকা হয়)।
GPU-র পাশে:
- Block scheduler প্রতিটা block-কে একটা উপলব্ধ SM-এ বরাদ্দ করে (গত লেসনের occupancy সীমার সাপেক্ষে — কতগুলো block একটা SM-এ একসাথে আঁটবে)
- প্রতিটা SM তার বরাদ্দকৃত block-এর thread-গুলোকে
৩২-এর দলে (warp) ভাগ করে - Warp scheduler প্রতি cycle-এ একটা প্রস্তুত warp বেছে চালায়; memory-র জন্য অপেক্ষারত warp সরিয়ে অন্য warp চালানো হয় (latency hiding, গত লেসন)
- একটা block শেষ হলে সেই SM-এর জায়গায় নতুন block আসতে পারে (যদি grid-এ আরও block অপেক্ষা করে থাকে)
- host code: vecAdd<<<4096,256>>>(...)CPU driver-কে asynchronous launch request পাঠায়
- GPU driver / block scheduler৪০৯৬টা block-কে উপলব্ধ SM-এ বরাদ্দ শুরু করে
- SMপ্রতিটা block-এর ২৫৬ thread-কে ৮টা warp-এ ভাগ করে
- Warp schedulerপ্রতি cycle-এ একটা প্রস্তুত warp নির্বাচন করে চালায়
- CUDA core (ALU)নির্বাচিত warp-এর ৩২ lane-এর instruction execute করে
সম্পূর্ণ প্রোগ্রাম — host থেকে device, আবার host-এ
একটা kernel একা কিছু করতে পারে না — data-কে CPU-র memory (host) থেকে GPU-র memory (device)-এ পাঠাতে হয়, কাজ শেষে ফেরত আনতে হয়। এখানে সম্পূর্ণ, বাস্তব vector-add প্রোগ্রাম:
#include <stdio.h>
#include <stdlib.h>
#include <cuda_runtime.h>
#define N 1048576 /* ~১০ লক্ষ element (2^20) */
#define THREADS_PER_BLOCK 256
__global__ void vecAdd(const float *A, const float *B, float *C, int n) {
int i = blockIdx.x * blockDim.x + threadIdx.x;
if (i \< n) {
C[i] = A[i] + B[i];
}
}
int main(void) {
size_t bytes = N * sizeof(float);
/* ১. Host memory allocate ও ভরাট করা */
float *h_A = (float *)malloc(bytes);
float *h_B = (float *)malloc(bytes);
float *h_C = (float *)malloc(bytes);
for (int i = 0; i \< N; i++) {
h_A[i] = 1.0f;
h_B[i] = 2.0f;
}
/* ২. Device memory allocate করা — GPU-র নিজস্ব DRAM-এ */
float *d_A, *d_B, *d_C;
cudaMalloc((void **)&d_A, bytes);
cudaMalloc((void **)&d_B, bytes);
cudaMalloc((void **)&d_C, bytes);
/* ৩. Host থেকে device-এ copy — PCIe/NVLink-এর মধ্য দিয়ে */
cudaMemcpy(d_A, h_A, bytes, cudaMemcpyHostToDevice);
cudaMemcpy(d_B, h_B, bytes, cudaMemcpyHostToDevice);
/* ৪. Kernel launch */
int numBlocks = (N + THREADS_PER_BLOCK - 1) / THREADS_PER_BLOCK;
vecAdd<<<numBlocks, THREADS_PER_BLOCK>>>(d_A, d_B, d_C, N);
/* ৫. ফলাফল device থেকে host-এ ফেরত */
cudaMemcpy(h_C, d_C, bytes, cudaMemcpyDeviceToHost);
printf("h_C[0] = %f (আশা করি 3.0)\n", h_C[0]);
/* ৬. পরিষ্কার — device ও host memory উভয়ই */
cudaFree(d_A); cudaFree(d_B); cudaFree(d_C);
free(h_A); free(h_B); free(h_C);
return 0;
}- h_A, h_B (host RAM)malloc() দিয়ে বরাদ্দ — CPU-র সাধারণ heap memory
- cudaMemcpy(..., HostToDevice)PCIe বাসের মধ্য দিয়ে কপি — সাধারণত সবচেয়ে ধীর ধাপ
- d_A, d_B, d_C (device DRAM)cudaMalloc() দিয়ে বরাদ্দ — GPU-র GDDR/HBM memory
- কার্নেল executionহাজার হাজার thread সমান্তরালে C[i] = A[i]+B[i] করে
- cudaMemcpy(..., DeviceToHost)ফলাফল আবার PCIe দিয়ে ফেরত
প্রতিটা মেমরি ধরনের ভূমিকা:
| Function | কাজ |
|---|---|
cudaMalloc(&ptr, bytes) | Device memory বরাদ্দ — malloc()-এর device সংস্করণ |
cudaMemcpy(dst, src, bytes, kind) | Copy — kind বলে দিক: cudaMemcpyHostToDevice বা cudaMemcpyDeviceToHost |
cudaFree(ptr) | Device memory মুক্ত করা — free()-এর device সংস্করণ |
Shared memory ও __syncthreads() — block-এর ভেতরে সহযোগিতা
গত লেসনে দেখেছি shared memory একটা block-এর সব thread ভাগাভাগি
করে, কিন্তু programmer-managed — hardware automatically সিদ্ধান্ত
নেয় না। __shared__ keyword দিয়ে ঘোষণা করা হয়:
__global__ void blockSum(const float *input, float *blockSums, int n) {
__shared__ float cache[THREADS_PER_BLOCK]; /* এই block-এর সব thread ভাগ করে */
int tid = threadIdx.x;
int i = blockIdx.x * blockDim.x + tid;
/* ধাপ ১ — global memory থেকে shared memory-তে লোড */
cache[tid] = (i \< n) ? input[i] : 0.0f;
__syncthreads(); /* বাধ্যতামূলক — নিচে ব্যাখ্যা */
/* ধাপ ২ — cache-এর ভেতরে tree-আকৃতির যোগফল (বিস্তারিত পরের লেসনে) */
for (int stride = blockDim.x / 2; stride > 0; stride /= 2) {
if (tid \< stride) {
cache[tid] += cache[tid + stride];
}
__syncthreads(); /* প্রতিটা ধাপের পরে — পরের ধাপ শুরুর আগে */
}
/* ধাপ ৩ — শুধু thread ০ চূড়ান্ত ফলাফল লেখে */
if (tid == 0) {
blockSums[blockIdx.x] = cache[0];
}
}কেন __syncthreads() ছাড়া চলবে না: একটা block-এর সব thread
একসাথে, lockstep-এ চলে না (বিভিন্ন warp ভিন্ন গতিতে এগোতে পারে,
বিশেষ করে memory access-এর সময়)। cache[tid] = ... লেখার পরপরই
cache[tid + stride] পড়া শুরু করলে — যদি অন্য warp তখনও তার
cache[...] না লিখে থাকে — আপনি stale বা undefined data
পড়বেন। __syncthreads() একটা barrier: block-এর প্রতিটা
thread এই লাইনে না পৌঁছানো পর্যন্ত কেউ এগোবে না। এটা lesson
৪-এ (atomics/memory ordering) দেখা memory barrier ধারণারই একটা
রূপ, শুধু এখানে scope একটা GPU thread block-এ সীমাবদ্ধ, পুরো
system-এ নয়।
উদাহরণ
পুরো চিত্র একসাথে — N = 1000, blockSize = 256
সংখ্যা দিয়ে পুরো প্রবাহটা অনুসরণ করি।
numBlocks = (1000 + 256 - 1) / 256 = 1255 / 256 = 4 (integer division)
মোট launch হওয়া thread = 4 × 256 = 1024, কিন্তু কাজ আছে মাত্র
1000টার জন্য। শেষ 24টা thread (i = 1000 থেকে 1023)
if (i \< n)-এ আটকে যাবে, কিছু করবে না — নিরাপদ, কারণ bounds
check আছে।
| Block | threadIdx.x পরিসর | global i পরিসর | সক্রিয় thread |
|---|---|---|---|
| 0 | 0–255 | 0–255 | ২৫৬ (সব) |
| 1 | 0–255 | 256–511 | ২৫৬ (সব) |
| 2 | 0–255 | 512–767 | ২৫৬ (সব) |
| 3 | 0–255 | 768–1023 | ২৩২ (i \< 1000 মানা, বাকি ২৪টা idle) |
চতুর্থ block-এর শেষ warp-টায় (threadIdx.x = 224 থেকে 255,
অর্থাৎ i = 992 থেকে 1023) কিছু thread কাজ করবে (i \< 1000)
আর কিছু করবে না — এটা গত লেসনের warp divergence-এরই একটা
উদাহরণ, যদিও এখানে ক্ষতিটা নগণ্য (শুধু একটা boundary warp, একবার)।
নিজে চালিয়ে দেখুন
Vector Add — compile, চালান, timing মাপুন
Google Colab-এ notebook খুলুন, Runtime → Change runtime type → T4 GPU বেছে নিন, তারপর:
%%writefile vecadd.cu
#include <stdio.h>
#include <stdlib.h>
#include <cuda_runtime.h>
#define THREADS_PER_BLOCK 256
__global__ void vecAdd(const float *A, const float *B, float *C, int n) {
int i = blockIdx.x * blockDim.x + threadIdx.x;
if (i \< n) C[i] = A[i] + B[i];
}
void run(int n) {
size_t bytes = n * sizeof(float);
float *h_A = (float *)malloc(bytes), *h_B = (float *)malloc(bytes), *h_C = (float *)malloc(bytes);
for (int i = 0; i \< n; i++) { h_A[i] = 1.0f; h_B[i] = 2.0f; }
float *d_A, *d_B, *d_C;
cudaMalloc((void **)&d_A, bytes);
cudaMalloc((void **)&d_B, bytes);
cudaMalloc((void **)&d_C, bytes);
cudaEvent_t start, stop;
cudaEventCreate(&start); cudaEventCreate(&stop);
cudaEventRecord(start);
cudaMemcpy(d_A, h_A, bytes, cudaMemcpyHostToDevice);
cudaMemcpy(d_B, h_B, bytes, cudaMemcpyHostToDevice);
int numBlocks = (n + THREADS_PER_BLOCK - 1) / THREADS_PER_BLOCK;
vecAdd<<<numBlocks, THREADS_PER_BLOCK>>>(d_A, d_B, d_C, n);
cudaMemcpy(h_C, d_C, bytes, cudaMemcpyDeviceToHost);
cudaEventRecord(stop);
cudaEventSynchronize(stop);
float ms = 0;
cudaEventElapsedTime(&ms, start, stop);
printf("n=%10d total (transfer+kernel) = %8.3f ms h_C[0]=%.1f\n", n, ms, h_C[0]);
cudaFree(d_A); cudaFree(d_B); cudaFree(d_C);
free(h_A); free(h_B); free(h_C);
}
int main(void) {
int sizes[] = {1000, 100000, 1000000, 10000000, 100000000};
for (int s = 0; s \< 5; s++) run(sizes[s]);
return 0;
}!nvcc -O3 vecadd.cu -o vecadd && ./vecaddসাধারণ output (T4 GPU-তে, ধারা নির্দেশক — আসল সংখ্যা hardware-ভেদে বদলাবে):
n= 1000 total (transfer+kernel) = 0.145 ms h_C[0]=3.0
n= 100000 total (transfer+kernel) = 0.187 ms h_C[0]=3.0
n= 1000000 total (transfer+kernel) = 0.652 ms h_C[0]=3.0
n= 10000000 total (transfer+kernel) = 4.981 ms h_C[0]=3.0
n= 100000000 total (transfer+kernel) = 45.203 ms h_C[0]=3.0লক্ষ্য করার মতো: n=1000-এ সময় প্রায় n=100000-এর সমান —
কারণ ছোট n-এ kernel execution সময় নগণ্য, পুরো সময়টাই
kernel launch overhead ও memory transfer-এর fixed cost (~০.১ ms
পরিসরে)। n বড় হওয়ার সাথে সাথে সময় প্রায় রৈখিকভাবে বাড়ে —
তখন memory bandwidth-ই bottleneck (transfer + kernel দুটোই n-এর
সমানুপাতিক)।
nvprof/nsys দিয়ে transfer আর kernel সময় আলাদা করে দেখুন:
!nsys nvprof ./vecaddপ্রায়ই দেখা যায় ছোট থেকে মাঝারি n-এ memory transfer kernel
execution-এর চেয়ে বেশি সময় নেয় — এটাই কেন বাস্তব GPU প্রোগ্রামে
“একবার copy করো, বহুবার GPU-তেই কাজ করো” (kernel fusion, একাধিক
kernel একই data-তে chain করা) একটা মূল optimization কৌশল।
host↔device memory transfer আর kernel execution — কোনটা আসলে বেশি সময় নেয়, আর ছোট `n`-এ GPU কেন CPU-র চেয়ে ধীর হতে পারে।
নিজে বানান
Block-wise Sum Reduction (Shared Memory)
- উপরের blockSum kernel-টা সম্পূর্ণ প্রোগ্রামে বসান — host memory allocate, device-এ copy
- প্রতিটা block তার নিজের partial sum blockSums[] array-তে লিখবে
- Host-এ ফিরে এসে সেই blockSums[]-এর যোগফল নিন (CPU-তে, ছোট array বলে সস্তা)
- যাচাই করুন মোট যোগফল CPU-তে সরাসরি যোগ করা ফলাফলের সাথে মেলে
- THREADS_PER_BLOCK পাল্টে (128, 256, 512) সময়ের উপর প্রভাব মাপুন
এই লেসনে দেখা blockSum kernel-টাকে সম্পূর্ণ প্রোগ্রামে রূপান্তর
করুন:
#include <stdio.h>
#include <stdlib.h>
#include <cuda_runtime.h>
#define THREADS_PER_BLOCK 256
__global__ void blockSum(const float *input, float *blockSums, int n) {
__shared__ float cache[THREADS_PER_BLOCK];
int tid = threadIdx.x;
int i = blockIdx.x * blockDim.x + tid;
cache[tid] = (i \< n) ? input[i] : 0.0f;
__syncthreads();
for (int stride = blockDim.x / 2; stride > 0; stride /= 2) {
if (tid \< stride) cache[tid] += cache[tid + stride];
__syncthreads();
}
if (tid == 0) blockSums[blockIdx.x] = cache[0];
}
int main(void) {
int n = 1000000;
int numBlocks = (n + THREADS_PER_BLOCK - 1) / THREADS_PER_BLOCK;
float *h_input = (float *)malloc(n * sizeof(float));
float *h_blockSums = (float *)malloc(numBlocks * sizeof(float));
for (int i = 0; i \< n; i++) h_input[i] = 1.0f; /* যোগফল n হওয়ার কথা */
float *d_input, *d_blockSums;
cudaMalloc((void **)&d_input, n * sizeof(float));
cudaMalloc((void **)&d_blockSums, numBlocks * sizeof(float));
cudaMemcpy(d_input, h_input, n * sizeof(float), cudaMemcpyHostToDevice);
blockSum<<<numBlocks, THREADS_PER_BLOCK>>>(d_input, d_blockSums, n);
cudaMemcpy(h_blockSums, d_blockSums, numBlocks * sizeof(float), cudaMemcpyDeviceToHost);
/* চূড়ান্ত reduction — মাত্র numBlocks টা সংখ্যা, CPU-তেই সস্তা */
double total = 0.0;
for (int b = 0; b \< numBlocks; b++) total += h_blockSums[b];
printf("total = %.1f (আশা করি %d)\n", total, n);
cudaFree(d_input); cudaFree(d_blockSums);
free(h_input); free(h_blockSums);
return 0;
}লক্ষ্য করুন প্যাটার্নটা: GPU একধাপে সম্পূর্ণ ১০ লক্ষ সংখ্যাকে
একটামাত্র সংখ্যায় নামায়নি — সে প্রতিটা block-কে তার নিজের অংশের
যোগফল বের করতে দিয়েছে (numBlocks ≈ ৩৯০৬টা partial sum), তারপর
সেই ছোট array-টা CPU-তে সহজে যোগ করা হয়েছে। এই “প্রথমে সমান্তরালে
ছোট করো, তারপর বাকিটা সস্তায় সিরিয়ালি শেষ করো” প্যাটার্নটাই পরের
লেসনের বিষয় — reduce pattern, তার নিজস্ব নামে।
নিজে বাড়ান: numBlocks যদি এখনও বড় হয় (হাজার হাজার), CPU-তে
সেই যোগফল করাও ধীর হতে পারে — একটা দ্বিতীয় kernel launch করে
blockSums[]-কেও আবার reduce করুন, recursively, যতক্ষণ না একটামাত্র
সংখ্যা থাকে। এটাই বাস্তব GPU reduction library (যেমন CUB) করে।
বাস্তব সিস্টেমে
CUDA প্রোগ্রামিং মডেল যেখানে ব্যবহৃত হয়
cuBLAS/cuDNN। PyTorch/TensorFlow-এর matmul() বা conv2d()
কল করলে, নিচে NVIDIA-র hand-tuned CUDA kernel চলে — cuBLAS
(matrix multiply) আর cuDNN (convolution)। এই library-গুলোর
kernel-এ shared memory tiling, warp-level primitive, আর
Tensor Core instruction ব্যবহার হয় — এই লেসনে শেখা প্রতিটা
concept, শুধু বহুগুণ বেশি optimize করা।
FFmpeg NVENC। ভিডিও এনকোডিং-এর motion estimation ধাপ CUDA
kernel হিসেবে চলে GPU-সমর্থিত FFmpeg build-এ — প্রতিটা 16×16
pixel block স্বাধীনভাবে search করা, ঠিক এই লেসনের vector-add-এর
মতোই একটা map pattern।
Password cracking (hashcat)। একটা hash function (bcrypt,
SHA-256) লক্ষ লক্ষ candidate password-এর উপর চালানো embarrassingly
parallel — প্রতিটা candidate একটা independent thread, এই লেসনের
vecAdd-এর গঠনগত সমতুল্য, শুধু + এর বদলে hash function।
আণবিক সিমুলেশন (GROMACS, AMBER)। প্রতিটা কণার উপর বলের হিসাব একটা kernel, প্রতিটা timestep-এ বারবার launch হয় — host-device transfer কমাতে data GPU memory-তেই বহু timestep ধরে থেকে যায় (শুধু শুরুতে ও শেষে transfer)।
OpenCL — cross-vendor বিকল্প। CUDA শুধু NVIDIA GPU-তে চলে। OpenCL (Khronos Group) একই core ধারণাগুলো vendor-neutral ভাষায় প্রকাশ করে — AMD, Intel, ARM GPU, এমনকি CPU-তেও চলতে পারে। Vocabulary ভিন্ন কিন্তু ধারণা হুবহু এক:
| CUDA | OpenCL | ধারণা |
|---|---|---|
| Kernel | Kernel | GPU-তে চলা function |
| Thread | Work-item | একটা execution unit |
| Block | Work-group | thread/work-item-এর cooperative দল |
| Grid | NDRange | পুরো launch |
__shared__ | __local | block/work-group-শেয়ার্ড memory |
__syncthreads() | barrier(CLK_LOCAL_MEM_FENCE) | block-scoped sync |
threadIdx, blockIdx | get_local_id(), get_group_id() | index query |
trade-off: CUDA-র tooling (profiler, library, compiler optimization) NVIDIA হার্ডওয়্যারে সাধারণত OpenCL-এর চেয়ে পরিণত ও দ্রুত, কিন্তু vendor lock-in। OpenCL portable, কিন্তু প্রতিটা vendor-এর জন্য আলাদা tuning প্রায়ই দরকার হয়। AMD-র ROCm/HIP আরেকটা বিকল্প — CUDA syntax-এর প্রায় সরাসরি অনুবাদ, কিন্তু AMD GPU-তে চলে।
Apple Metal / Microsoft DirectCompute / WebGPU। প্রতিটা বড় platform-এর নিজস্ব compute API আছে — একই মৌলিক ধারণা (kernel/thread/block hierarchy), ভিন্ন syntax। WebGPU browser-এ JavaScript থেকে GPU compute চালানোর উদীয়মান standard।
যে ভুলগুলো সবাই করে
“CUDA kernel কল করা মানে একটা সাধারণ function call, শুধু GPU-তে।”
একটা CUDA kernel call একটা সাধারণ function call-এর মতো দেখতে হলেও, আচরণ সম্পূর্ণ ভিন্ন:
১. এটা asynchronous — host সাথে সাথেই পরের লাইনে চলে যায়,
kernel শেষ হওয়ার জন্য অপেক্ষা করে না (যদি না
cudaDeviceSynchronize() বা blocking cudaMemcpy ডাকা হয়)।
২. এটা একবার নয়, হাজার হাজার বার চলে — প্রতিটা thread-এর জন্য একবার, সবগুলো (আদর্শভাবে) সমান্তরালে।
৩. Kernel-এর arguments-এ থাকা pointer-গুলো device memory-র ঠিকানা হতে হবে — সরাসরি host array pass করলে undefined behavior।
এই তিনটা পার্থক্যই ভুললে সবচেয়ে সাধারণ CUDA bug হয়: host code kernel-এর ফলাফল ব্যবহার করার চেষ্টা করছে, কিন্তু kernel তখনও শেষ হয়নি (synchronization ভুলে যাওয়া), অথবা host pointer আর device pointer গুলিয়ে ফেলা।
“Shared memory ব্যবহার করলেই performance ভালো হয়ে যায়।”
Shared memory দরকার তখনই যখন একাধিক thread একই data বারবার ব্যবহার করে — তখন global memory থেকে একবার লোড করে shared memory-তে রেখে দিলে বারবার ধীর global memory access এড়ানো যায়।
কিন্তু যদি প্রতিটা thread শুধু তার নিজস্ব, একবার-ব্যবহৃত data
নিয়ে কাজ করে (যেমন এই লেসনের সরল vecAdd — প্রতিটা A[i],
B[i] ঠিক একবারই পড়া হয়, কোনো thread আরেকটার data দরকার করে
না) — তাহলে shared memory কোনো লাভ দেয় না, শুধু বাড়তি জটিলতা
আর __syncthreads()-এর overhead যোগ করে, আর সীমিত shared memory
budget খেয়ে occupancy কমাতে পারে (গত লেসনের occupancy আলোচনা)।
নিয়ম: shared memory তখনই ব্যবহার করুন যখন data reuse আছে — reduction, stencil computation, matrix tiling-এর মতো pattern-এ, যেখানে একই data একাধিক thread একাধিকবার পড়ে।
“OpenCL আর CUDA সম্পূর্ণ ভিন্ন প্রযুক্তি, আলাদা করে শিখতে হবে।”
Vocabulary ভিন্ন (kernel/work-item/work-group বনাম kernel/thread/block), API call-এর নাম ভিন্ন, কিন্তু অন্তর্নিহিত hardware model অভিন্ন — কারণ underlying GPU হার্ডওয়্যারই একই ধরনের (SM-সদৃশ compute unit, warp/wavefront-সদৃশ lockstep group, global/local memory hierarchy)। যিনি CUDA বোঝেন, তার জন্য OpenCL শেখা মূলত একটা vocabulary mapping শেখা (উপরের টেবিল দ্রষ্টব্য), নতুন করে পুরো মানসিক মডেল তৈরি করা নয়।
বুঝেছেন কি না দেখুন
1একটা array-এর দৈর্ঘ্য n = 5000, blockSize = 512। numBlocks
কত হবে? মোট কতগুলো thread launch হবে, আর কতগুলো thread শেষ পর্যন্ত
if (i \< n)-এ আটকে কিছুই করবে না?
প্রয়োগ
n = 5000, blockSize = 512। numBlocks
কত হবে? মোট কতগুলো thread launch হবে, আর কতগুলো thread শেষ পর্যন্ত
if (i \< n)-এ আটকে কিছুই করবে না?Ceiling division formula দিয়ে হাতে: (5000 + 512 - 1) / 512 = 5511 / 512 = 10 (integer division, ভাগশেষ বাতিল)।
মোট launch হওয়া thread = 10 × 512 = 5120।
কাজ আছে 5000টার জন্য, তাই 5120 - 5000 = 120টা thread
if (i \< n)-এ আটকে যাবে (তাদের i হবে 5000 থেকে 5119)।
এই 120টা thread শেষ, দশম block-এই পড়বে (block ৯, যেহেতু
0-indexed): এই block-এর i পরিসর 4608–5119, তার মধ্যে
4608–4999 (৩৯২টা) কাজ করে, 5000–5119 (১২০টা) করে না।
এটা সেই block-এর কিছু warp-এ divergence তৈরি করবে (যে warp-এ
সীমানাটা পড়ে)।
2i = blockIdx.x * blockDim.x + threadIdx.x-এর বদলে যদি কেউ ভুল
করে i = threadIdx.x * blockDim.x + blockIdx.x লেখে, কী সমস্যা
হবে?
যুক্তি
i = blockIdx.x * blockDim.x + threadIdx.x-এর বদলে যদি কেউ ভুল
করে i = threadIdx.x * blockDim.x + blockIdx.x লেখে, কী সমস্যা
হবে?এটা compile হবে (syntactically সঠিক), কিন্তু ভুল, non-unique
index তৈরি করবে বেশিরভাগ ক্ষেত্রে ভিন্ন thread-কে একই i
দিয়ে, আর কিছু i বাদ পড়ে যাবে।
উদাহরণ: blockDim.x = 256, numBlocks = 4 ধরি।
সঠিক formula-য়: Block ১, thread ৫ → i = 1×256+5 = 261।
ভুল formula-য়: Block ১, thread ৫ → i = 5×256+1 = 1281। কিন্তু
blockIdx.x সর্বোচ্চ 3 (৪টা block, 0–3), তাই ভুল formula-র
আউটপুট রেঞ্জ হয়ে যায় threadIdx.x × 256 + [0,1,2,3] — মানে
0, 1, 2, 3, 256, 257, 258, 259, 512, ... — বেশিরভাগ সংখ্যা
(4 থেকে 255, 260 থেকে 511, ইত্যাদি) কখনো তৈরিই হবে
না, আর কিছু ছোট সংখ্যা একাধিক thread থেকে ডুপ্লিকেট হয়ে
তৈরি হবে না বটে (এই নির্দিষ্ট উদাহরণে duplicate নেই আসলে, কিন্তু
বেশিরভাগ ইনডেক্স ফাঁকা থেকে যায়)।
ফলাফল: C array-এর বেশিরভাগ element কখনো লেখাই হবে না
(garbage/আগের মান থেকে যাবে), আর যেগুলো লেখা হবে সেগুলো ভুল
জায়গায়। এটা silent — কোনো crash হবে না, শুধু ভুল output — যা এই
ধরনের bug-কে বিশেষভাবে বিপজ্জনক করে তোলে।
শিক্ষা: formula-র প্রতিটা অংশের ভূমিকা বুঝে লেখা জরুরি —
blockIdx.x * blockDim.x অংশটা “আগের সব block টপকে যাওয়া”
বোঝায়, এটা threadIdx.x-এর সাথে গুণ করলে সেই অর্থ সম্পূর্ণ
ভেঙে পড়ে।
3blockSum kernel-এ প্রতিটা tree-reduction ধাপের পরে __syncthreads()
আছে। যদি এটা বাদ দেওয়া হয় (শুধু loop-এর বাইরে একবার রাখা হয়),
কী ভুল হতে পারে?
যুক্তি
blockSum kernel-এ প্রতিটা tree-reduction ধাপের পরে __syncthreads()
আছে। যদি এটা বাদ দেওয়া হয় (শুধু loop-এর বাইরে একবার রাখা হয়),
কী ভুল হতে পারে?Race condition — ভুল ফলাফল, এবং bug-টা মাঝেমধ্যে দেখা দেবে, সবসময় নয় (যা একে debug করা কঠিন করে তোলে)।
Tree reduction-এর প্রতিটা ধাপ আগের ধাপের ফলাফলের উপর নির্ভর করে:
for (int stride = blockDim.x / 2; stride > 0; stride /= 2) {
if (tid \< stride) cache[tid] += cache[tid + stride];
__syncthreads(); /* এটা বাদ দিলে... */
}ধাপ stride=128-এ thread ০ পড়ে cache[128]। কিন্তু thread
128 হয়তো তখনও তার নিজের stride=256-এর ধাপ শেষ করেনি (বিভিন্ন
warp ভিন্ন গতিতে এগোয়)। __syncthreads() ছাড়া thread ০
cache[128]-এর পুরনো মান পড়তে পারে, নতুন (আপডেটেড) মান নয়।
এটা non-deterministic — কখনো কাকতালীয়ভাবে warp scheduling এমনভাবে হবে যে সব thread ঠিক সময়ে পৌঁছাবে আর সঠিক উত্তর আসবে, কখনো আসবে না। এই ধরনের bug production-এ মাসের পর মাস লুকিয়ে থাকতে পারে, তারপর হঠাৎ hardware/driver বদলালে বা load বেশি হলে দেখা দেয়।
নিয়ম: shared memory-তে যেকোনো ধাপ যদি আগের ধাপের লেখার উপর
নির্ভর করে (এখানে প্রতিটা ধাপই করে), প্রতিটা ধাপের মাঝে
__syncthreads() বাধ্যতামূলক, ঐচ্ছিক অপ্টিমাইজেশন নয়।
4আপনার একটা kernel আছে যার প্রতিটা thread শুধু নিজের input[i]
পড়ে, একটা গাণিতিক হিসাব করে, নিজের output[i]-তে লেখে — কোনো
thread অন্য কোনো thread-এর data স্পর্শ করে না। এখানে কি
__shared__ memory ব্যবহার করা উচিত? কেন বা কেন নয়?
ডিজাইন
input[i]
পড়ে, একটা গাণিতিক হিসাব করে, নিজের output[i]-তে লেখে — কোনো
thread অন্য কোনো thread-এর data স্পর্শ করে না। এখানে কি
__shared__ memory ব্যবহার করা উচিত? কেন বা কেন নয়?না, দরকার নেই — এবং যোগ করলে সম্ভবত ক্ষতিই হবে।
Shared memory-র সুবিধা আসে data reuse থেকে — একই data যদি একাধিক thread একাধিকবার পড়ে, তাহলে একবার global memory থেকে shared memory-তে এনে রাখলে বাকিরা সস্তায় সেটা ব্যবহার করতে পারে।
এই প্রশ্নের kernel-এ প্রতিটা input[i] ঠিক একবারই, ঠিক
একটা thread দ্বারা পড়া হয় — কোনো reuse নেই। Shared memory
যোগ করলে:
১. প্রতিটা thread-কে প্রথমে input[i]-কে shared memory-তে কপি
করতে হবে (একটা বাড়তি write + __syncthreads()), তারপর সেখান
থেকে পড়তে হবে — নেট বেশি কাজ, কম নয়।
২. Shared memory ব্যবহার SM-এর সীমিত shared-memory বাজেট খরচ করে, যা occupancy কমাতে পারে (গত লেসনের occupancy calculator) — অর্থাৎ কম warp resident থাকবে, latency hiding খারাপ হবে।
৩. __syncthreads() একটা barrier — অপ্রয়োজনীয় জায়গায় এটা যোগ
করা মানে সব thread-কে অপ্রয়োজনে একে অপরের জন্য অপেক্ষা করানো।
সঠিক সিদ্ধান্ত: এই kernel-টা global memory থেকে সরাসরি পড়ে,
সরাসরি লিখুক — কোনো shared memory দরকার নেই। এটাই আসলে vector-add
kernel-এর ঠিক এই কারণেই কোনো __shared__ নেই — এটা pure map
pattern, কোনো data reuse নেই, তাই shared memory যোগ করাটা
“misconception” section-এর দ্বিতীয় ভুল ধারণাটাই হবে।
এরপর কী
এরপর কী
আমরা এখন CUDA-তে দুই ধরনের kernel লিখেছি: vecAdd (প্রতিটা
element স্বাধীন, কোনো cooperation দরকার নেই) আর blockSum
(thread-দের একটা tree-structured cooperation-এ shared memory
ব্যবহার করে একটা ফলাফলে একত্রিত হতে হয়)।
এই দুটোই আসলে দুইটা নাম-করা, বহু-ব্যবহৃত parallel pattern-এর
concrete উদাহরণ — এত সাধারণ যে এদের নিজস্ব নাম আছে, আর একবার
নাম চিনতে পারলে নতুন সমস্যা দেখেও চিনতে পারবেন কোন pattern কাজে
লাগবে। vecAdd হলো map। blockSum-এর tree reduction হলো
reduce। পরের লেসনে আমরা এই দুটোকে formalize করব, আর তৃতীয়
একটা pattern দেখব যেটা দেখতে সিরিয়াল মনে হয় কিন্তু আসলে চমৎকারভাবে
সমান্তরাল করা যায় — scan (prefix sum)।
আরও পড়ুন
- NVIDIA CUDA C++ Programming Guide — NVIDIA · Kernel syntax, memory model, ও shared memory-র প্রামাণ্য বিবরণ
- Programming Massively Parallel Processors, 4th ed. — Kirk & Hwu · CUDA প্রোগ্রামিং মডেল শেখানোর সবচেয়ে পরিচিত পাঠ্যবই
- Khronos OpenCL Specification — Khronos Group · Cross-vendor compute API-র প্রামাণ্য স্পেসিফিকেশন