the short version

A GPU program has two halves. The kernel is a function that runs on the device, once per thread, and is written as if you were a single thread. The host code runs on the CPU and does the logistics: allocate device memory, copy data across, launch the kernel over a grid of threads, copy results back, free everything.

The surprise for most people is how little of this differs between Nvidia and AMD. The kernel body is essentially identical. Almost all of the portability work lives in the host half.

the kernel

Vector addition is the canonical first kernel because it is the simplest thing with real parallelism: every output element is independent of every other one, which is exactly the shape the hardware wants.


__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];
    }
}
            
That is valid CUDA and valid HIP, unchanged. __global__ marks a function as a kernel callable from the host, and blockIdx, blockDim and threadIdx are named identically in both. The if (i < n) guard is not optional, and the next page explains why.

launching it

Here the dialects diverge slightly, and only in syntax.


// CUDA
vecAdd<<<numBlocks, threadsPerBlock>>>(d_a, d_b, d_c, n);

// HIP, portable macro form
hipLaunchKernelGGL(vecAdd, numBlocks, threadsPerBlock, 0, 0, d_a, d_b, d_c, n);

// HIP also accepts the CUDA-style form when built with hipcc
vecAdd<<<numBlocks, threadsPerBlock>>>(d_a, d_b, d_c, n);
            
The two extra zeros in the macro form are the dynamic shared memory size and the stream. CUDA takes the same two as optional third and fourth arguments inside the chevrons. Note that the launch is asynchronous in both: control returns to the host immediately, which is covered under what a kernel launch physically does.

the five host-side steps

Every GPU program of this shape does the same five things, and this is where the API names actually differ.

StepCUDAHIP
Allocate on the devicecudaMallochipMalloc
Copy host to devicecudaMemcpyhipMemcpy
LaunchchevronshipLaunchKernelGGL or chevrons
Copy device to hostcudaMemcpyhipMemcpy
FreecudaFreehipFree

The pattern is a straight cuda to hip rename across the board, which is deliberate and is what makes automated porting viable.

the whole thing


#include <hip/hip_runtime.h>   // or <cuda_runtime.h>
#include <vector>
#include <cstdio>

__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()
{
    const int n = 1 << 20;
    const size_t bytes = n * sizeof(float);

    std::vector<float> h_a(n, 1.0f), h_b(n, 2.0f), h_c(n);

    float *d_a, *d_b, *d_c;
    hipMalloc(&d_a, bytes);
    hipMalloc(&d_b, bytes);
    hipMalloc(&d_c, bytes);

    hipMemcpy(d_a, h_a.data(), bytes, hipMemcpyHostToDevice);
    hipMemcpy(d_b, h_b.data(), bytes, hipMemcpyHostToDevice);

    const int threadsPerBlock = 256;
    const int numBlocks = (n + threadsPerBlock - 1) / threadsPerBlock;
    vecAdd<<<numBlocks, threadsPerBlock>>>(d_a, d_b, d_c, n);

    hipMemcpy(h_c.data(), d_c, bytes, hipMemcpyDeviceToHost);

    printf("c[0] = %f, c[n-1] = %f\n", h_c[0], h_c[n-1]);

    hipFree(d_a); hipFree(d_b); hipFree(d_c);
    return 0;
}
            
The block count uses the standard round-up idiom, (n + threads - 1) / threads, so the last block covers the remainder. That block will have threads whose index runs past n, which is precisely what the guard inside the kernel catches.

what to notice

Three things are worth carrying forward from this small program.

The copy is not free. This kernel does one addition per two loaded floats, which is almost no arithmetic per byte moved. Vector addition is bandwidth-bound and always will be, and on a problem this small the host transfers cost far more than the arithmetic. It is a teaching example, not a demonstration of speedup.

No error was checked. Every call above returns a status code and this program ignores all of them, which is why a broken GPU program so often fails silently and far from the cause.

The grid was sized to the data. One thread per element is the obvious mapping and not always the best one; the grid-stride alternative is on the next page.

build and run

nvcc -O3 -arch=sm_90 vecadd.cu -o vecadd | https://docs.nvidia.com/cuda/cuda-compiler-driver-nvcc/ | build for a specific Nvidia architecture; see the compiling page for what -arch actually selects |'fk_nvcc'
hipcc -O3 --offload-arch=gfx942 vecadd.cpp -o vecadd | https://rocm.docs.amd.com/projects/HIP/en/latest/ | the AMD equivalent, targeting a specific gfx ISA |'fk_hipcc'
hipcc -O3 --offload-arch=native vecadd.cpp -o vecadd | https://rocm.docs.amd.com/projects/HIP/en/latest/ | build for whatever device is installed, convenient while developing on one machine |'fk_native'

related topics

Thread Indexing and Grid-Stride Loops — the index formula and the guard, properly explained.
Compiling GPU Code — what nvcc and hipcc actually produce, and what the arch flags select.
What a Kernel Launch Physically Does — what happens on the device after that chevron line runs.

reference

NVIDIA CUDA C++ Programming Guide
AMD HIP documentation
CUDA Runtime API reference