# CUDA Finite Difference Phase Field Code - A Tutorial Guide ## Table of Contents 1. [Introduction](#introduction) 2. [Understanding the Physics](#understanding-the-physics) 3. [CUDA Concepts for Beginners](#cuda-concepts-for-beginners) 4. [Step-by-Step Code Walkthrough](#step-by-step-code-walkthrough) 5. [Visualization of Parallel Execution](#visualization-of-parallel-execution) 6. [Performance Optimization](#performance-optimization) 7. [Running the Code](#running-the-code) 8. [Common Pitfalls and Solutions](#common-pitfalls-and-solutions) 9. [Advanced Topics](#advanced-topics) 10. [Appendix: Quick Reference](#appendix-quick-reference) --- ## Introduction This document provides a comprehensive tutorial on parallel computing using CUDA (Compute Unified Device Architecture) through a finite difference code that solves the Cahn-Hilliard equation—a fundamental model for phase separation in materials science. The code demonstrates how to take a serial computational problem and parallelize it across thousands of lightweight threads on a GPU. ### What You Will Learn * How to write and launch CUDA kernels * The grid / block / thread execution model * Managing separate host (CPU) and device (GPU) memory * Data management with flattened 1D arrays on the device * Performance optimization techniques for GPU computing --- ## Understanding the Physics The Cahn-Hilliard equation describes phase separation in binary alloys: $$\frac{\partial c}{\partial t} = \nabla \cdot \left[M \nabla \left(\frac{\delta F}{\delta c}\right)\right]$$ where: * **$c$**: Concentration field * **$M$**: Mobility * **$F$**: Free energy functional The code uses a 5-point finite difference stencil to discretize this equation spatially: $$\nabla^2 c = \frac{c[i+1,j] + c[i-1,j] + c[i,j+1] + c[i,j-1] - 4c[i,j]}{\Delta x \cdot \Delta y}$$ ### Why Parallelize with CUDA? The 2D domain is $128 \times 128$ grid points, evolved for $5000$ time steps. For larger, realistic simulations: * **Serial:** every grid point updated one at a time on the CPU — **too slow** to scale to fine grids or long runs. * **CUDA Parallel:** one GPU thread is assigned to each grid point; thousands of points are updated **at the same instant**, in a single kernel launch. --- ## CUDA Concepts for Beginners ### What is CUDA? CUDA is NVIDIA's platform for writing programs that run on the GPU. Instead of a handful of CPU cores, a GPU offers thousands of small, simple cores. CUDA lets you write a function called a **kernel** that a huge number of threads execute simultaneously, each working on its own piece of data. ### Key Concepts | Concept | Analogy | In Our Code | | :--- | :--- | :--- | | **Thread** | One assembly-line worker | Handles a single grid point `(i, j)` | | **Block** | A team of workers | A `16 x 16` tile of threads (`threadsPerBlock`) | | **Grid** | The whole factory floor | All blocks needed to cover the `128 x 128` domain (`numBlocks`) | | **Kernel** | The job instructions handed to every worker | `__global__` functions like `compute_first_laplacian_kernel` | | **Host / Device** | The office vs. the factory floor | CPU (`h_` arrays) vs. GPU (`d_` arrays) | ### Grid / Block / Thread Execution Model CUDA Diagram ## Step-by-Step Code Walkthrough ### Step 1: Include CUDA and Standard Headers ```cpp #include #include #include #include #include #include #include #include #include using namespace std::chrono; ``` #### What happens here? * ``: CUDA runtime API header — declares `cudaMalloc`, `cudaMemcpy`, kernel launch syntax, and error-handling functions. * The other headers are standard C++: file output (`ch.dat`), random noise generation, timing, and formatted printing. * `using namespace std::chrono` lets the code call `high_resolution_clock::now()` without the `std::chrono::` prefix. ### Step 2: Physical and Simulation Parameters ```cpp constexpr int Nx = 128; constexpr int Ny = 128; constexpr double dx = 1.0; constexpr double dy = 1.0; constexpr int nsteps = 5000; constexpr int nprint = 1000; constexpr double dt = 0.01; constexpr double con_0 = 0.4; constexpr double mobility = 1.0; constexpr double grad_coef = 0.5; constexpr double noise = 0.02; constexpr double A = 1.0; ``` #### Important Points: * `Nx = Ny = 128`: 16,384 grid points total — small enough to fit in a handful of thread blocks. * `nsteps = 5000`, `nprint = 1000`: run 5000 time steps, printing progress every 1000. * `con_0`, `mobility`, `grad_coef`, `noise`, `A`: the same material/physics constants used in the MPI and OpenMP versions. ### Step 3: CUDA Error Checking Macro ```cpp #define CUDA_CHECK(call) \ do { \ cudaError_t err = call; \ if (err != cudaSuccess) { \ std::cerr << "CUDA error in " << __FILE__ << " line " << __LINE__ \ << ": " << cudaGetErrorString(err) << std::endl; \ exit(EXIT_FAILURE); \ } \ } while(0) ``` #### What happens here? * `do { ... } while(0)`: makes the macro behave like a single statement, even followed by a semicolon. * `cudaError_t err = call`: runs the CUDA call and captures its return code. * `cudaGetErrorString(err)`: converts the error code into a human-readable message. * Wrapping every CUDA call in `CUDA_CHECK(...)` is standard practice — GPU errors otherwise fail silently. ### Step 4: Kernel 1 - Free Energy Derivative and First Laplacian ```cpp __global__ void compute_first_laplacian_kernel( const double* con, double* dfdcon, double* lap_con, int nx, int ny, double dx, double dy, double A_val, double grad_coef_val) { int i = blockIdx.x * blockDim.x + threadIdx.x; int j = blockIdx.y * blockDim.y + threadIdx.y; if (i < nx && j < ny) { int ip = (i + 1) % nx; int im = (i - 1 + nx) % nx; int jp = (j + 1) % ny; int jm = (j - 1 + ny) % ny; double c = con[i * ny + j]; dfdcon[i * ny + j] = A_val * (2.0 * c * (1.0 - c) * (1.0 - c) - 2.0 * c * c * (1.0 - c)); lap_con[i * ny + j] = (con[ip * ny + j] + con[im * ny + j] + con[i * ny + jp] + con[i * ny + jm] - 4.0 * c) / (dx * dy); } } ``` #### What happens here? * `__global__`: marks this as a kernel — it runs on the GPU and is launched from the CPU. * `blockIdx`, `blockDim`, `threadIdx`: built-in CUDA variables that let each thread compute its own unique `(i, j)` grid coordinate. * `if (i < nx && j < ny)`: boundary check — since the grid isn't always an exact multiple of the block size, some threads at the edges have no valid grid point and simply do nothing. * `ip`, `im`, `jp`, `jm`: periodic boundary neighbors, computed with modulo arithmetic (wrap-around), the same idea as the MPI and OpenMP versions. * `con[i * ny + j]`: 1D flattened indexing — CUDA device arrays are allocated as flat `double*` buffers, so a 2D index is mapped with `row * columns + column`. * `dfdcon[...]`: derivative of the double-well free energy $f(c) = Ac^2(1-c)^2$, driving separation into $c \approx 0$ and $c \approx 1$ phases. * `lap_con[...]`: the 5-point Laplacian stencil, computed independently by every thread with no communication needed. ### Step 5: Kernel 2 - Chemical Potential and Time Integration ```cpp __global__ void compute_second_laplacian_kernel( const double* con, const double* dfdcon, const double* lap_con, double* dummy_con, double* lap_dummy, double* con_new, int nx, int ny, double dx, double dy, double grad_coef_val, double dt_val, double mobility_val) { int i = blockIdx.x * blockDim.x + threadIdx.x; int j = blockIdx.y * blockDim.y + threadIdx.y; if (i < nx && j < ny) { int ip = (i + 1) % nx; int im = (i - 1 + nx) % nx; int jp = (j + 1) % ny; int jm = (j - 1 + ny) % ny; dummy_con[i * ny + j] = dfdcon[i * ny + j] - grad_coef_val * lap_con[i * ny + j]; lap_dummy[i * ny + j] = (dummy_con[ip * ny + j] + dummy_con[im * ny + j] + dummy_con[i * ny + jp] + dummy_con[i * ny + jm] - 4.0 * dummy_con[i * ny + j]) / (dx * dy); double new_val = con[i * ny + j] + dt_val * mobility_val * lap_dummy[i * ny + j]; con_new[i * ny + j] = fmax(0.00001, fmin(0.99999, new_val)); } } ``` #### Why a second kernel? * `dummy_con`: the chemical potential $\mu = \frac{\partial f}{\partial c} - \kappa \nabla^2 c$, needed before it can itself be differentiated. * `lap_dummy`: $\nabla^2 \mu$, computed with the same 5-point stencil, now on the chemical potential field. * `con_new[...] = con + dt \cdot M \cdot \nabla^2 \mu$: explicit forward-Euler time step, clamped with `fmax`/`fmin` to keep the concentration physically bounded between `0.00001` and `0.99999`. * The two kernels are separate because `lap_dummy` depends on `dummy_con` values from *neighboring* threads — every thread must finish writing `dummy_con` before any thread reads its neighbors' values, and a kernel launch is the simplest way to guarantee that synchronization across the whole grid. ### Step 6: Host Output Function ```cpp void output_concentration_on_file(const double* con, int nx, int ny, const std::string& filename = "ch.dat") { std::ofstream outfile(filename); if (!outfile.is_open()) { std::cerr << "Error opening file: " << filename << std::endl; return; } outfile << std::fixed << std::setprecision(6); for (int i = 0; i < nx; ++i) { for (int j = 0; j < ny; ++j) { outfile << con[i * ny + j] << (j < ny - 1 ? " " : ""); } outfile << "\n"; } outfile.close(); std::cout << "Results written to: " << filename << std::endl; } ``` #### Important Points: * This is a plain host (CPU) function — no `__global__` keyword, so it runs only on the CPU, after results are copied back from the GPU. * `std::setprecision(6)`: writes 6 digits after the decimal point in fixed-point notation. * The ternary `(j < ny - 1 ? " " : "")` separates values with spaces but avoids a trailing space at the end of each row. ### Step 7: Host Memory Allocation and Initialization ```cpp size_t N = Nx * Ny; size_t bytes = N * sizeof(double); double* h_con = new double[N]; double* h_con_new = new double[N]; double* h_dfdcon = new double[N]; double* h_lap_con = new double[N]; double* h_dummy_con = new double[N]; double* h_lap_dummy = new double[N]; std::mt19937 rng(12345); std::uniform_real_distribution dist(0.0, 1.0); for (int i = 0; i < Nx; ++i) { for (int j = 0; j < Ny; ++j) { double r = dist(rng); h_con[i * Ny + j] = con_0 + noise * (0.5 - r); } } ``` #### Important Points: * `h_` prefix denotes host (CPU) memory, allocated with plain `new[]` as flat arrays. * `std::mt19937 rng(12345)`: a fixed seed, so every run produces the same initial noise — useful for reproducibility (unlike the MPI version, there is only one process, so no per-rank seed offset is needed). * Initialization runs serially on the CPU before anything is sent to the GPU. ### Step 8: Device Memory Allocation and Host-to-Device Copy ```cpp double* d_con; double* d_con_new; double* d_dfdcon; double* d_lap_con; double* d_dummy_con; double* d_lap_dummy; CUDA_CHECK(cudaMalloc(&d_con, bytes)); CUDA_CHECK(cudaMalloc(&d_con_new, bytes)); CUDA_CHECK(cudaMalloc(&d_dfdcon, bytes)); CUDA_CHECK(cudaMalloc(&d_lap_con, bytes)); CUDA_CHECK(cudaMalloc(&d_dummy_con, bytes)); CUDA_CHECK(cudaMalloc(&d_lap_dummy, bytes)); CUDA_CHECK(cudaMemcpy(d_con, h_con, bytes, cudaMemcpyHostToDevice)); ``` #### Important Points: * `d_` prefix denotes device (GPU) memory — a completely separate address space from the host. * `cudaMalloc(&d_con, bytes)`: allocates `bytes` of GPU global memory and stores the resulting pointer in `d_con`. * `cudaMemcpy(..., cudaMemcpyHostToDevice)`: only the initial concentration field needs to be uploaded — the other five arrays are computed entirely on the GPU. ### Step 9: Kernel Launch Configuration ```cpp dim3 threadsPerBlock(16, 16); dim3 numBlocks((Nx + threadsPerBlock.x - 1) / threadsPerBlock.x, (Ny + threadsPerBlock.y - 1) / threadsPerBlock.y); ``` #### Important Points: * `dim3`: CUDA's built-in 3-component (x, y, z) dimension type. * `threadsPerBlock(16, 16)`: 256 threads per block, a typical size for 2D stencil problems. * `numBlocks`: computed with ceiling division so the grid of blocks fully covers `Nx x Ny`, even when it doesn't divide evenly — e.g. `(128 + 16 - 1) / 16 = 8` blocks per dimension here. ### Step 10: The Evolution Loop ```cpp for (int tsteps = 1; tsteps <= nsteps; tsteps++) { compute_first_laplacian_kernel<<>>( d_con, d_dfdcon, d_lap_con, Nx, Ny, dx, dy, A, grad_coef); CUDA_CHECK(cudaGetLastError()); compute_second_laplacian_kernel<<>>( d_con, d_dfdcon, d_lap_con, d_dummy_con, d_lap_dummy, d_con_new, Nx, Ny, dx, dy, grad_coef, dt, mobility); CUDA_CHECK(cudaGetLastError()); std::swap(d_con, d_con_new); if (tsteps % nprint == 0) { std::cout << "Done steps = " << tsteps << std::endl; } } ``` #### What happens here? * `kernel<<>>(...)`: the triple angle-bracket syntax launches the kernel across the whole grid — this line hands work to the GPU and returns immediately (asynchronous launch). * `CUDA_CHECK(cudaGetLastError())`: checks for launch-configuration errors right after each kernel call, since kernel launches don't return an error code directly. * `std::swap(d_con, d_con_new)`: swaps the two *device pointers* on the host side — an O(1) operation with no GPU memory copy involved, exactly like the pointer swap in the MPI and OpenMP versions. * The second kernel implicitly waits for the first to finish, because both are issued on the GPU's default stream, which executes kernels in the order they were launched. ### Step 11: Final Data Transfer and Cleanup ```cpp CUDA_CHECK(cudaMemcpy(h_con, d_con, bytes, cudaMemcpyDeviceToHost)); auto end_time = high_resolution_clock::now(); auto duration = duration_cast(end_time - start_time); std::cout << " Time = " << duration.count() / 1000.0 << " seconds." << std::endl; output_concentration_on_file(h_con, Nx, Ny); CUDA_CHECK(cudaFree(d_con)); CUDA_CHECK(cudaFree(d_con_new)); CUDA_CHECK(cudaFree(d_dfdcon)); CUDA_CHECK(cudaFree(d_lap_con)); CUDA_CHECK(cudaFree(d_dummy_con)); CUDA_CHECK(cudaFree(d_lap_dummy)); delete[] h_con; delete[] h_con_new; delete[] h_dfdcon; delete[] h_lap_con; delete[] h_dummy_con; delete[] h_lap_dummy; return EXIT_SUCCESS; ``` #### Important Points: * `cudaMemcpy(..., cudaMemcpyDeviceToHost)`: copies the final concentration field back to CPU memory so it can be written to `ch.dat`. * `cudaFree(...)`: releases GPU memory — every `cudaMalloc` needs a matching `cudaFree`. * `delete[] h_...`: releases the corresponding CPU memory — every `new[]` needs a matching `delete[]`. * Skipping either kind of cleanup leaks memory on the host or the device, respectively. --- ## Visualization of Parallel Execution ### Per-Timestep Kernel Flow: CUDA Diagram --- ## Performance Optimization ### Memory Coalescing #### Why indexing order matters: GPU memory bandwidth is much higher when neighboring threads read/write neighboring memory addresses in the same instruction — this is called a **coalesced** access. ```cpp // con[i * ny + j] with j varying fastest across threadIdx.x // is well-coalesced when threads are laid out along x = j ``` * `blockDim(16, 16)` maps `threadIdx.x` to consecutive `j` values when the array is indexed `i * ny + j`. * Threads in the same warp (32 consecutive threads) should touch consecutive addresses — this code's flattened row-major layout supports that. ### Occupancy and Block Size | Block Size | Threads/Block | Trade-off | | :--- | :--- | :--- | | `8 x 8` | 64 | More blocks, more scheduling overhead | | `16 x 16` | 256 | Good default for 2D stencils (used here) | | `32 x 32` | 1024 | Maximum threads/block on most GPUs; can limit occupancy from register/shared-memory pressure | #### Example: trying a different block size ```cpp dim3 threadsPerBlock(32, 8); // 256 threads, different aspect ratio dim3 numBlocks((Nx + threadsPerBlock.x - 1) / threadsPerBlock.x, (Ny + threadsPerBlock.y - 1) / threadsPerBlock.y); ``` ### Reducing Kernel Launch Overhead * Each kernel launch has a small fixed overhead (a few microseconds). * With `nsteps = 5000` and 2 kernels per step, this code issues 10,000 launches — negligible individually, but worth knowing about if `Nx`/`Ny` were much smaller. * Fusing both kernels into one would remove one launch per step, at the cost of needing an explicit device-wide synchronization (e.g. a grid-wide barrier via cooperative groups) partway through, since `lap_dummy` depends on every thread's `dummy_con` write completing first. ### Asynchronous Execution ```cpp compute_first_laplacian_kernel<<>>(...); CUDA_CHECK(cudaGetLastError()); // Only checks the launch, not completion ``` * Kernel launches return to the CPU immediately; the GPU executes in the background. * The default stream serializes kernel 1 → kernel 2 → the next iteration's kernel 1, so results stay correct without extra synchronization calls in this code. * `cudaMemcpy` (without `Async`) does block until the copy completes, which is why it's safe to read `h_con` right after the final copy. --- ## Running the Code ### Compilation Using the NVIDIA CUDA compiler: ```bash nvcc -std=c++17 -o main main.cu ``` ### Running **Basic Run:** ```bash ./main ``` ### Performance Monitoring #### Using NVIDIA tools: ```bash # Profile kernel execution and memory transfers nsys profile ./main # Detailed kernel-level metrics (occupancy, memory throughput) ncu ./main # Quick GPU utilization check while running nvidia-smi ``` #### Output Interpretation: ``` Done steps = 1000 Done steps = 2000 Done steps = 3000 Done steps = 4000 Done steps = 5000 --------------------------------- Time = 4.128 seconds. --------------------------------- Results written to: ch.dat ``` --- ## Common Pitfalls and Solutions ### 1. Forgetting to Check Kernel Launch Errors **Problem:** ```cpp compute_first_laplacian_kernel<<>>(...); // No error check — a bad launch configuration fails silently ``` **Solution:** ```cpp compute_first_laplacian_kernel<<>>(...); CUDA_CHECK(cudaGetLastError()); // Catches launch-time errors immediately ``` ### 2. Out-of-Bounds Threads **Problem:** ```cpp int i = blockIdx.x * blockDim.x + threadIdx.x; int j = blockIdx.y * blockDim.y + threadIdx.y; con[i * ny + j] = 0.0; // No bounds check — writes past the array when nx isn't a multiple of blockDim ``` **Solution:** ```cpp if (i < nx && j < ny) { con[i * ny + j] = 0.0; // Safe: extra edge threads simply do nothing } ``` ### 3. Mismatched Host/Device Pointers **Problem:** ```cpp double* d_con; compute_first_laplacian_kernel<<<...>>>(h_con, ...); // Passing a host pointer to a kernel! ``` **Solution:** ```cpp // Always pass device pointers (the d_ prefixed ones) to kernels compute_first_laplacian_kernel<<<...>>>(d_con, ...); ``` Dereferencing a host pointer on the GPU (or vice versa) causes a crash or silent corruption — CUDA does not check this for you. ### 4. Leaking GPU Memory **Problem:** Allocating with `cudaMalloc` but never calling `cudaFree`. **Solution:** Match every allocation with a matching free, ideally with `CUDA_CHECK` around both: ```cpp CUDA_CHECK(cudaMalloc(&d_con, bytes)); // ... use d_con ... CUDA_CHECK(cudaFree(d_con)); ``` ### 5. Race Conditions Between Kernels **Problem:** Reading a neighbor's value before it has been written for the current step. **Solution:** Split the computation into separate kernel launches (as this code does) — the CUDA runtime guarantees kernel *B* only starts after kernel *A* completes on the same stream, giving you a free synchronization point between the "write" and "read neighbors" phases. --- ## Advanced Topics ### Shared Memory Tiling Threads in the same block can cooperate through fast on-chip shared memory instead of repeatedly hitting global memory: ```cpp __shared__ double tile[18][18]; // 16x16 block + 1-cell halo on each side // Load tile cooperatively, __syncthreads(), then compute the stencil from the tile ``` ### CUDA Streams For overlapping computation with data transfer, or running independent kernels concurrently: ```cpp cudaStream_t stream1, stream2; cudaStreamCreate(&stream1); cudaStreamCreate(&stream2); kernelA<<>>(...); kernelB<<>>(...); ``` ### Unified Memory Simplifies host/device memory management at some performance cost: ```cpp double* con; cudaMallocManaged(&con, bytes); // Accessible from both host and device // No explicit cudaMemcpy needed — the driver migrates pages automatically ``` ### Multi-GPU with CUDA + MPI Combine with MPI for cluster-level, multi-GPU parallelism: ```cpp MPI_Init(&argc, &argv); int rank; MPI_Comm_rank(MPI_COMM_WORLD, &rank); cudaSetDevice(rank % num_gpus_per_node); compute_first_laplacian_kernel<<>>(...); MPI_Finalize(); ``` --- ## Appendix: Quick Reference ### CUDA Keywords | Keyword | Purpose | | :--- | :--- | | `__global__` | Function runs on GPU, callable from CPU (a kernel) | | `__device__` | Function runs on GPU, callable only from GPU code | | `__host__` | Function runs on CPU (the default) | | `__shared__` | Variable stored in fast on-chip block-shared memory | | `__syncthreads()` | Barrier synchronizing all threads within a block | ### Built-in Thread/Block Variables | Variable | Purpose | | :--- | :--- | | `threadIdx.x/y/z` | Index of the thread within its block | | `blockIdx.x/y/z` | Index of the block within the grid | | `blockDim.x/y/z` | Number of threads per block | | `gridDim.x/y/z` | Number of blocks per grid | ### Memory Management Functions | Function | Purpose | | :--- | :--- | | `cudaMalloc(ptr, bytes)` | Allocate device (GPU) memory | | `cudaFree(ptr)` | Free device memory | | `cudaMemcpy(dst, src, bytes, dir)` | Copy memory (host↔device) | | `cudaMemcpyHostToDevice` | Transfer direction: CPU → GPU | | `cudaMemcpyDeviceToHost` | Transfer direction: GPU → CPU | | `cudaMallocManaged(ptr, bytes)` | Allocate unified memory (host + device) | | `cudaGetLastError()` | Retrieve the last kernel-launch error | | `cudaGetErrorString(err)` | Convert an error code to a readable string | ### Kernel Launch Syntax | Syntax | Purpose | | :--- | :--- | | `kernel<<>>(...)` | Launch a kernel across `blocks` x `threads` | | `dim3 threadsPerBlock(16, 16)` | Define a 2D block shape | | `dim3 numBlocks(nx, ny)` | Define the number of blocks in the grid | --- ```{toctree} :maxdepth: 1 :caption: CUDA Topics