DEV Community

Javad
Javad

Posted on

Advanced GPU Optimization — Complete Technical Guide

Hey Dev Community! 👋✨

I’m glad you’re here. This is the ultimate, end‑to‑end reference on advanced GPU optimization and heterogeneous compute: deep technical explanations, practical tutorials, complete code examples, and advanced performance techniques for CUDA, OpenCL, Vulkan, Metal, SYCL/DPC++, HIP, HLSL, GLSL, WGSL. Read it as a single long reference or jump to the section you need.

Scope and intent: this guide explains hardware and software concepts you must understand to write high‑performance GPU code, shows idiomatic and optimized examples for each API, and gives practical tuning and profiling advice. It’s dense and technical by design.


Table of contents

  1. Hardware and execution model
  2. Memory hierarchy and data movement
  3. Parallel execution and performance primitives
  4. CUDA deep dive — concepts, code, optimization, profiling
  5. OpenCL deep dive — concepts, code, optimization, profiling
  6. Vulkan compute deep dive — concepts, code, optimization, profiling
  7. Metal deep dive — concepts, code, optimization, profiling
  8. SYCL / DPC++ deep dive — concepts, code, optimization, profiling
  9. HIP deep dive — concepts, code, optimization, profiling
  10. Shader languages HLSL GLSL WGSL — compute usage and examples
  11. Cross‑API patterns and portability strategies
  12. Advanced optimization checklist and profiling workflow
  13. Final notes and next steps

1 Hardware and execution model

What a GPU is

  • A GPU is a throughput‑oriented processor composed of many execution units (cores) grouped into compute clusters (SMs on NVIDIA, CUs on AMD). Each cluster contains vector ALUs, special function units, registers, and a small fast on‑chip memory (shared/local). GPUs are designed to run thousands of threads concurrently to hide memory latency.

Execution model

  • Threads / Work‑items: smallest execution unit.
  • Warps / Wavefronts: groups of threads that execute the same instruction in lockstep (NVIDIA warp = 32 threads; AMD wavefront = 64 threads historically).
  • Blocks / Work‑groups: groups of threads that can share on‑chip memory and synchronize.
  • Grids / NDRange: the full set of blocks/work‑groups dispatched for a kernel.

Key hardware metrics

  • Peak FLOPS: theoretical compute capability.
  • Memory bandwidth: GB/s between device memory and GPU.
  • Register file size per SM: limits per‑thread register usage.
  • Shared/local memory per SM: on‑chip scratchpad for tiling.
  • Occupancy: fraction of hardware resources actively used.

2 Memory hierarchy and data movement

Hierarchy (fast → slow)

  • Registers (per thread)
  • Shared / local memory (per block/work‑group)
  • L1 cache (per SM)
  • L2 cache (device wide)
  • Device memory (GDDR/HBM)
  • Host memory (DDR)
  • Storage

Important concepts

  • Coalesced accesses: contiguous threads should access contiguous addresses to maximize memory throughput.
  • Bank conflicts: shared/local memory is banked; access patterns that map multiple threads to the same bank serialize accesses.
  • Cache locality: reuse data in registers/shared memory to avoid global memory traffic.
  • Pinned host memory: page‑locked host memory enables faster DMA transfers.
  • Unified memory: single virtual address space (hardware or runtime managed) that simplifies programming but can hide expensive migrations.

Data movement costs

  • Device memory latency is high; hide it with parallelism and reuse.
  • PCIe/NVLink transfers between host and device are expensive; overlap transfers with compute and minimize transfers.

3 Parallel execution and performance primitives

Amdahl vs Gustafson

  • Use Gustafson’s view: scale problem size to utilize parallel hardware. But be mindful of serial bottlenecks.

Key performance primitives

  • Tiling: break large problems into tiles that fit in shared memory/registers.
  • Loop unrolling: reduce loop overhead and enable instruction scheduling.
  • Vectorization: use vector types or SIMD intrinsics where available.
  • Memory prefetching: load data into shared memory/registers before use.
  • Avoid divergence: minimize branches inside warps/wavefronts.
  • Occupancy tuning: balance registers, shared memory, and threads per block to maximize active warps.

4 CUDA deep dive

Concepts and memory model

  • Thread hierarchy: thread, block, grid.
  • Memory spaces: global, shared, local, constant, texture.
  • Streams: asynchronous queues for kernels and transfers.
  • Events: synchronization points for timing and ordering.
  • Unified Memory: cudaMallocManaged for simplified programming.

Example 1 — highly optimized tiled matrix multiply (complete)

Build: nvcc -O3 -arch=sm80 matmulcuda.cu -o matmul_cuda (adjust -arch to your GPU)

`cpp
// matmul_cuda.cu

include

include

define TILE 32

global void matmultiled(const float* restrict_ A,
const float* restrict B,
float* restrict C,
int N) {
shared float sA[TILE][TILE];
shared float sB[TILE][TILE];

int row = blockIdx.y * TILE + threadIdx.y;
int col = blockIdx.x * TILE + threadIdx.x;
float acc = 0.0f;

for (int t = 0; t < (N + TILE - 1) / TILE; ++t) {
    int aCol = t * TILE + threadIdx.x;
    int bRow = t * TILE + threadIdx.y;
    sA[threadIdx.y][threadIdx.x] = (row < N && aCol < N) ? A[row * N + aCol] : 0.0f;
    sB[threadIdx.y][threadIdx.x] = (bRow < N && col < N) ? B[bRow * N + col] : 0.0f;
    syncthreads();

    #pragma unroll
    for (int k = 0; k < TILE; ++k) acc += sA[threadIdx.y][k] * sB[k][threadIdx.x];
    syncthreads();
}

if (row < N && col < N) C[row * N + col] = acc;
Enter fullscreen mode Exit fullscreen mode

}

// Host code omitted for brevity: allocate device memory, copy, launch kernel with dim3 grid((N+TILE-1)/TILE, (N+TILE-1)/TILE), dim3 block(TILE,TILE), synchronize, copy back.
`

Advanced optimizations explained

  • Shared memory tiling reduces global loads from O(N^3) to O(N^3 / TILE) effective global traffic.
  • Padding and boundary checks avoid out‑of‑bounds reads.
  • restrict hints to compiler for aliasing.
  • #pragma unroll helps the compiler unroll inner loops for instruction throughput.
  • Launch configuration: choose TILE to match shared memory and register constraints; TILE=32 aligns with warp size for coalescing.

Example 2 — streams and overlapping transfers

cpp
// Use multiple streams and chunked transfers to overlap H2D, kernel, D2H
cudaStream_t s1, s2;
cudaStreamCreate(&s1); cudaStreamCreate(&s2);
// allocate device buffers for chunk0 and chunk1
// cudaMemcpyAsync(..., s1); kernel<<<..., s1>>>(...); cudaMemcpyAsync(..., s1);
// cudaMemcpyAsync(..., s2); kernel<<<..., s2>>>(...); cudaMemcpyAsync(..., s2);

Tensor cores and mixed precision

  • Use wmma APIs or cuBLAS cublasGemmEx to use tensor cores. Convert inputs to FP16 and accumulate in FP32 when needed. Mixed precision yields large speedups on supported hardware.

Profiling and tools

  • Nsight Systems for system‑level timeline.
  • Nsight Compute for kernel metrics (memory throughput, occupancy, instruction mix).
  • nvprof deprecated; use Nsight.
  • Metrics to watch: achieved occupancy, DRAM throughput, L2 hit rate, warp execution efficiency, memory load/store efficiency.

5 OpenCL deep dive

Concepts and memory model

  • Platform / Device / Context / Command queue model.
  • Kernels written in OpenCL C; compiled at runtime or offline.
  • Memory spaces: global, local, private, constant.
  • NDRange defines global and local sizes.

Example — tiled matmul kernel and host orchestration

Kernel (OpenCL C):

c
kernel void matmultiled(_global const float* A,
global const float* B,
global float* C,
const int N) {
local float sA[32][32];
local float sB[32][32];
int localx = getlocal_id(0);
int localy = getlocal_id(1);
int groupx = getgroup_id(0);
int groupy = getgroup_id(1);
int row = groupy * 32 + localy;
int col = groupx * 32 + localx;
float acc = 0.0f;
for (int t = 0; t < (N + 31) / 32; ++t) {
int aCol = t * 32 + local_x;
int bRow = t * 32 + local_y;
sA[localy][localx] = (row < N && aCol < N) ? A[row * N + aCol] : 0.0f;
sB[localy][localx] = (bRow < N && col < N) ? B[bRow * N + col] : 0.0f;
barrier(CLKLOCALMEM_FENCE);
for (int k = 0; k < 32; ++k) acc += sA[localy][k] * sB[k][localx];
barrier(CLKLOCALMEM_FENCE);
}
if (row < N && col < N) C[row * N + col] = acc;
}

Host notes

  • Create context for chosen device, build program, create buffers with CLMEMREADONLY | CLMEMCOPYHOSTPTR or use CLMEMUSEHOST_PTR for zero copy where supported.
  • Choose globalworksize = {ceil(N/32)32, ceil(N/32)32} and localworksize = {32,32}.

Optimization tips

  • Use vector types (float4) to leverage SIMD on CPU devices.
  • Tune localworksize per device; some devices prefer 64×1 or 256×1.
  • Use device‑specific compiler options (e.g., -cl-fast-relaxed-math) carefully.

Profiling

  • Vendor tools: AMD CodeXL / ROCm profiler, Intel OpenCL tools, NVIDIA OpenCL profiling via Nsight.

6 Vulkan compute deep dive

Concepts

  • Vulkan is explicit: you manage memory, synchronization, descriptor sets, pipelines, and command buffers. Compute shaders are compiled to SPIR‑V.
  • Descriptor sets bind buffers and images to shaders; efficient descriptor usage reduces CPU overhead.
  • Command buffers let you batch work and submit it to the GPU with minimal driver overhead.

Example — compute shader and host flow

GLSL compute shader (SPIR‑V):

`glsl

version 450
layout(localsizex = 32, localsizey = 32) in;
layout(set = 0, binding = 0) readonly buffer A { float a[]; };
layout(set = 0, binding = 1) readonly buffer B { float b[]; };
layout(set = 0, binding = 2) writeonly buffer C { float c[]; };
layout(push_constant) uniform Push { int N; };

void main() {
uint row = gl_GlobalInvocationID.y;
uint col = gl_GlobalInvocationID.x;
float sum = 0.0;
for (uint k = 0; k < uint(N); ++k) sum += a[row N + k] b[k * N + col];
c[row * N + col] = sum;
}
`

Host responsibilities

  • Create VkBuffer with VKBUFFERUSAGESTORAGEBUFFER_BIT.
  • Allocate memory with proper alignment and memory type (device local vs host visible).
  • Create descriptor set layout and pipeline layout.
  • Record command buffer: bind pipeline, bind descriptor sets, vkCmdDispatch.
  • Use fences and semaphores for synchronization.

Optimization strategies

  • Use persistent mapped memory for frequent host writes.
  • Use staging buffers for device local memory transfers.
  • Minimize descriptor set updates; use dynamic offsets or preallocated descriptor sets.
  • Use subgroup operations (SPVKHRsubgroup) for warp‑level intrinsics where available.

Profiling

  • Use vendor tools (NVIDIA Nsight Graphics, RenderDoc for debugging, GPUView on Windows) and Vulkan validation layers.

7 Metal deep dive

Concepts

  • Metal is Apple’s low‑level API with tight integration to Apple GPUs and unified memory on Apple Silicon.
  • Threadgroups are equivalent to CUDA blocks; threadgroup memory is shared memory.

Example — MSL compute kernel

`cpp

include
using namespace metal;

kernel void matmul_tiled(const device float* A [[buffer(0)]],
const device float* B [[buffer(1)]],
device float* C [[buffer(2)]],
constant int& N [[buffer(3)]],
uint2 gid [[threadpositionin_grid]],
uint2 tid [[threadpositionin_threadgroup]],
threadgroup float sA[32][32],
threadgroup float sB[32][32]) {
int row = gid.y;
int col = gid.x;
float acc = 0.0f;
for (int t = 0; t < (N + 31) / 32; ++t) {
int aCol = t * 32 + tid.x;
int bRow = t * 32 + tid.y;
sA[tid.y][tid.x] = (row < N && aCol < N) ? A[row * N + aCol] : 0.0f;
sB[tid.y][tid.x] = (bRow < N && col < N) ? B[bRow * N + col] : 0.0f;
threadgroupbarrier(memflags::mem_threadgroup);
for (int k = 0; k < 32; ++k) acc += sA[tid.y][k] * sB[k][tid.x];
threadgroupbarrier(memflags::mem_threadgroup);
}
if (row < N && col < N) C[row * N + col] = acc;
}
`

Optimization notes

  • On Apple Silicon, unified memory means fewer explicit copies; still prefer threadgroup memory for reuse.
  • Use Xcode GPU Frame Capture and Metal System Trace for profiling.

8 SYCL / DPC++ deep dive

Concepts

  • SYCL is single‑source C++ for heterogeneous devices. It supports buffers/accessors and USM (Unified Shared Memory).
  • SYCL maps to OpenCL, Level Zero, or vendor backends.

Example — SYCL tiled matmul with USM

`cpp

include
using namespace sycl;

int main() {
const int N = 1024;
queue q{default_selector{}};
float A = malloc_shared(NN, q);
float B = malloc_shared(NN, q);
float C = malloc_shared(NN, q);
// initialize A,B
range<2> global(N, N);
range<2> local(32, 32);
q.submit(& {
h.parallelfor(ndrange<2>(global, local), = {
int row = it.getglobalid(0);
int col = it.getglobalid(1);
float acc = 0;
for (int k = 0; k < N; ++k) acc += A[rowN + k] B[k*N + col];
C[row*N + col] = acc;
});
}).wait();
// cleanup
free(A, q); free(B, q); free(C, q);
}
`

Optimization tips

  • Use nd_range with appropriate local sizes.
  • For performance, prefer USM on platforms that support efficient device access.
  • Use sycl::sub_group for warp‑level operations where supported.

Profiling

  • Use Intel VTune, CodeXL, or vendor tools depending on backend.

9 HIP deep dive

Concepts

  • HIP provides a CUDA‑like API that can target AMD ROCm or NVIDIA. Porting CUDA to HIP is often straightforward with hipify.

Example — HIP tiled matmul (same pattern as CUDA)

`cpp

include

define TILE 32
global void matmul_hip(const float A, const float B, float* C, int N) {
shared float sA[TILE][TILE];
shared float sB[TILE][TILE];
int row = blockIdx.y * TILE + threadIdx.y;
int col = blockIdx.x * TILE + threadIdx.x;
float acc = 0;
for (int t = 0; t < (N + TILE - 1) / TILE; ++t) {
int aCol = t * TILE + threadIdx.x;
int bRow = t * TILE + threadIdx.y;
sA[threadIdx.y][threadIdx.x] = (row < N && aCol < N) ? A[row*N + aCol] : 0.0f;
sB[threadIdx.y][threadIdx.x] = (bRow < N && col < N) ? B[bRow*N + col] : 0.0f;
syncthreads();
#pragma unroll
for (int k = 0; k < TILE; ++k) acc += sA[threadIdx.y][k] * sB[k][threadIdx.x];
syncthreads();
}
if (row < N && col < N) C[row*N + col] = acc;
}
`

Optimization notes

  • Use ROCm libraries (rocBLAS) for BLAS operations on AMD.
  • Profile with rocprof and ROCm tools.

10 Shader languages HLSL GLSL WGSL — compute usage

HLSL (DirectX compute)

  • Use [numthreads(x,y,z)] to declare workgroup size. Use RWStructuredBuffer or ByteAddressBuffer for storage.

GLSL (Vulkan compute)

  • Use layout(localsizex = X, localsizey = Y) and buffer blocks for storage buffers.

WGSL (WebGPU)

  • Modern, safe shader language for browsers. Use @compute and @workgroup_size.

Example WGSL compute (matrix multiply)

wgsl
@group(0) @binding(0) var<storage, read> A: array<f32>;
@group(0) @binding(1) var<storage, read> B: array<f32>;
@group(0) @binding(2) var<storage, read_write> C: array<f32>;
@compute @workgroup_size(32, 32)
fn main(@builtin(globalinvocationid) gid: vec3<u32>) {
let row = gid.y;
let col = gid.x;
var sum: f32 = 0.0;
for (var k: u32 = 0u; k < N; k = k + 1u) {
sum = sum + A[row N + k] B[k * N + col];
}
C[row * N + col] = sum;
}

Optimization notes for shaders

  • Use vec4/float4 types to pack operations.
  • Avoid dynamic indexing where possible.
  • Use texture units for cached reads if data fits texture semantics.

11 Cross‑API patterns and portability strategies

Design patterns

  • Tiled compute kernels: same tiling idea applies across CUDA/OpenCL/Vulkan/Metal/SYCL/HIP.
  • Double buffering: overlap compute and transfers.
  • Subgroup intrinsics: use warp/subgroup operations for reductions and scans.
  • Use vendor libraries: cuBLAS/rocBLAS/oneMKL for BLAS; cuDNN/MIOpen for deep learning.

Portability approaches

  • Single source + backends: SYCL/oneAPI or DPC++ can target multiple vendors.
  • Abstraction layer: write a small backend layer that maps your compute primitives to CUDA/HIP/OpenCL/Vulkan.
  • Source translation: hipify for CUDA→HIP; SPIR‑V can be a common IR for Vulkan/OpenCL.

12 Advanced optimization checklist and profiling workflow

Step 0 — correctness

  • Validate results with small inputs and CPU reference.

Step 1 — baseline measurement

  • Measure end‑to‑end time and kernel time. Use events for GPU timing.

Step 2 — identify bottleneck

  • Is it compute bound or memory bound? Use profiler to check achieved occupancy and memory throughput.

Step 3 — memory optimizations

  • Coalesce global loads/stores.
  • Use shared/local memory for reuse.
  • Reduce memory footprint to increase cache hits.

Step 4 — compute optimizations

  • Use FMA instructions, vectorize inner loops, unroll loops.
  • Use tensor cores or specialized units when applicable.

Step 5 — parallelism and occupancy

  • Tune threads per block/workgroup to maximize active warps while respecting register/shared memory limits.

Step 6 — overlap and pipeline

  • Use streams/queues to overlap transfers and compute.
  • Use persistent kernels for dynamic workloads.

Step 7 — micro‑optimizations

  • Avoid expensive operations (div/mod) in inner loops.
  • Use intrinsics for fast math where acceptable.

Profiling tools summary

  • NVIDIA: Nsight Systems, Nsight Compute.
  • AMD: ROCm profiler, Radeon GPU Profiler.
  • Intel: VTune, Graphics Performance Analyzers.
  • Cross: RenderDoc (graphics), vendor validation layers.

13 Final notes and next steps

Philosophy

  • High performance is a systems problem: hardware, runtime, compiler, and algorithm must align.
  • Measure first, change one thing at a time, and iterate.

Where to go next

  • Implement a production GEMM using vendor BLAS and compare to your tiled kernel.
  • Explore tensor core programming for mixed precision deep learning.
  • Study memory consistency and synchronization for multi‑GPU and distributed GPU setups.

Appendix — Practical build and run notes

CUDA

  • nvcc -O3 -arch=smXX file.cu -o file (choose smXX for your GPU). Use cudaMemcpyAsync, streams, and cudaEvent_t for timing.

OpenCL

  • Link with OpenCL ICD loader (-lOpenCL). Query platforms and devices, build program with clBuildProgram.

Vulkan

  • Use glslangValidator to compile GLSL to SPIR‑V. Create buffers with VKMEMORYPROPERTYDEVICELOCAL_BIT for best throughput.

Metal

  • Use Xcode and Metal framework. Use MTLBuffer with storageModeShared on Apple Silicon for unified memory.

SYCL

  • Use Intel oneAPI or DPC++ compilers. dpcpp file.cpp -o file.

HIP

  • Use ROCm toolchain or HIP SDK. hipcc file.cpp -o file.

WebGPU / WGSL

  • Use modern browsers with WebGPU enabled. Host code in JavaScript/TypeScript.

I hope you enjoy it! Have nice times!

Top comments (2)

Collapse
 
javadinteger profile image
Javad
Collapse
 
javadinteger profile image
Javad

This post is the first entry in the Advanced GPU Optimization series 🚀
Stay tuned for the next one