Foundationপ্রথম নীতি থেকে
LEVEL 11লেসন ১২/১৭কঠিন১ ঘণ্টা ৫ মিনিট

CUDA প্রোগ্রামিং মডেল — হার্ডওয়্যারকে কোড দিয়ে address করা

CUDA Programming Model

একটা `__global__` kernel, `<<<numBlocks, blockSize>>>` launch, আর `blockIdx.x * blockDim.x + threadIdx.x` — CUDA প্রোগ্রামিং-এর সবচেয়ে গুরুত্বপূর্ণ লাইন। সম্পূর্ণ vector-add উদাহরণ, শেয়ার্ড মেমরি, আর OpenCL-এর সাথে তুলনা।

এই লেসন শেষে আপনি পারবেন

  • একটা CUDA kernel লিখতে ও `<<<...>>>` syntax দিয়ে launch করতে পারবেন
  • গ্রিড/ব্লক/থ্রেড hierarchy আর global thread index formula নিখুঁতভাবে ব্যাখ্যা করতে পারবেন
  • সম্পূর্ণ host-to-device-to-host memory flow (malloc/memcpy/free) লিখতে পারবেন
  • `__shared__` memory ও `__syncthreads()` দিয়ে একটা block-এর thread-দের সহযোগিতা করাতে পারবেন
  • OpenCL-এর সাথে CUDA-র vocabulary মেলাতে পারবেন এবং vendor lock-in-এর trade-off বুঝবেন

আগে যা বোঝা থাকা দরকার

আগে এটা বুঝি

গত লেসনে আমরা দেখেছি 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।
Grid → Block → Thread — CUDA-র software hierarchy, যা hardware-এর SM/warp-এর উপর ম্যাপ হয়।
  • 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 division

Global 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 সংখ্যা "টপকে যাওয়া"   ভেতরের অবস্থান
Global index formula — কেন গুণ করে যোগ করতে হয়।

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-র পাশে:

  1. Block scheduler প্রতিটা block-কে একটা উপলব্ধ SM-এ বরাদ্দ করে (গত লেসনের occupancy সীমার সাপেক্ষে — কতগুলো block একটা SM-এ একসাথে আঁটবে)
  2. প্রতিটা SM তার বরাদ্দকৃত block-এর thread-গুলোকে ৩২-এর দলে (warp) ভাগ করে
  3. Warp scheduler প্রতি cycle-এ একটা প্রস্তুত warp বেছে চালায়; memory-র জন্য অপেক্ষারত warp সরিয়ে অন্য warp চালানো হয় (latency hiding, গত লেসন)
  4. একটা block শেষ হলে সেই SM-এর জায়গায় নতুন block আসতে পারে (যদি grid-এ আরও block অপেক্ষা করে থাকে)
একটা `vecAdd<<<4096, 256>>>(...)` কল, উপর থেকে নিচে
  1. host code: vecAdd<<<4096,256>>>(...)CPU driver-কে asynchronous launch request পাঠায়
  2. GPU driver / block scheduler৪০৯৬টা block-কে উপলব্ধ SM-এ বরাদ্দ শুরু করে
  3. SMপ্রতিটা block-এর ২৫৬ thread-কে ৮টা warp-এ ভাগ করে
  4. Warp schedulerপ্রতি cycle-এ একটা প্রস্তুত warp নির্বাচন করে চালায়
  5. 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;
}
Data-র জীবনচক্র — host memory থেকে device, আবার host-এ
  1. h_A, h_B (host RAM)malloc() দিয়ে বরাদ্দ — CPU-র সাধারণ heap memory
  2. cudaMemcpy(..., HostToDevice)PCIe বাসের মধ্য দিয়ে কপি — সাধারণত সবচেয়ে ধীর ধাপ
  3. d_A, d_B, d_C (device DRAM)cudaMalloc() দিয়ে বরাদ্দ — GPU-র GDDR/HBM memory
  4. কার্নেল executionহাজার হাজার thread সমান্তরালে C[i] = A[i]+B[i] করে
  5. 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 আছে।

BlockthreadIdx.x পরিসরglobal i পরিসরসক্রিয় thread
00–2550–255২৫৬ (সব)
10–255256–511২৫৬ (সব)
20–255512–767২৫৬ (সব)
30–255768–1023২৩২ (i \< 1000 মানা, বাকি ২৪টা idle)

চতুর্থ block-এর শেষ warp-টায় (threadIdx.x = 224 থেকে 255, অর্থাৎ i = 992 থেকে 1023) কিছু thread কাজ করবে (i \< 1000) আর কিছু করবে না — এটা গত লেসনের warp divergence-এরই একটা উদাহরণ, যদিও এখানে ক্ষতিটা নগণ্য (শুধু একটা boundary warp, একবার)।

নিজে চালিয়ে দেখুন

EXPERIMENT

Vector Add — compile, চালান, timing মাপুন

NVIDIA GPU + nvcc (বা Google Colab-এর ফ্রি GPU)· ২৫ মিনিট

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-র চেয়ে ধীর হতে পারে।

নিজে বানান

BUILD IT

Block-wise Sum Reduction (Shared Memory)

CUDA C++ · ●●●●○
  1. উপরের blockSum kernel-টা সম্পূর্ণ প্রোগ্রামে বসান — host memory allocate, device-এ copy
  2. প্রতিটা block তার নিজের partial sum blockSums[] array-তে লিখবে
  3. Host-এ ফিরে এসে সেই blockSums[]-এর যোগফল নিন (CPU-তে, ছোট array বলে সস্তা)
  4. যাচাই করুন মোট যোগফল CPU-তে সরাসরি যোগ করা ফলাফলের সাথে মেলে
  5. 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 ভিন্ন কিন্তু ধারণা হুবহু এক:

CUDAOpenCLধারণা
KernelKernelGPU-তে চলা function
ThreadWork-itemএকটা execution unit
BlockWork-groupthread/work-item-এর cooperative দল
GridNDRangeপুরো launch
__shared____localblock/work-group-শেয়ার্ড memory
__syncthreads()barrier(CLK_LOCAL_MEM_FENCE)block-scoped sync
threadIdx, blockIdxget_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)-এ আটকে কিছুই করবে না?

প্রয়োগ

numBlocks=⌈5000512⌉=⌈9.77⌉=10\text{numBlocks} = \left\lceil \frac{5000}{512} \right\rceil = \left\lceil 9.77 \right\rceil = 10

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-এ সীমানাটা পড়ে)।

2

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-এর সাথে গুণ করলে সেই অর্থ সম্পূর্ণ ভেঙে পড়ে।

3

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 ব্যবহার করা উচিত? কেন বা কেন নয়?

ডিজাইন

না, দরকার নেই — এবং যোগ করলে সম্ভবত ক্ষতিই হবে।

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-র প্রামাণ্য স্পেসিফিকেশন