LUNAROPS · OPERATIONAL UPLINK 100% UPTIME 1,247d POSTS 893 JEFF.MOON@LUNAROPS.DEV UTC --:--:--

GPU Programming Without CUDA: OpenCL, ROCm, Vulkan, Metal, and WebGPU

gpuopenclrocmvulkanmetalwebgpucomputeperformanceamd

GPU Programming Without CUDA: OpenCL, ROCm, Vulkan, Metal, and WebGPU

CUDA is the dominant GPU compute platform, and for good reason — NVIDIA’s toolchain, libraries (cuBLAS, cuDNN, NCCL), and ecosystem are genuinely excellent. But CUDA only runs on NVIDIA hardware. If you’re targeting AMD GPUs, Apple Silicon, Intel Arc, mobile devices, or the browser, you need something else.

This post covers the full non-CUDA landscape: what each platform is, what hardware it targets, where it excels, and enough working code to understand the programming model. We’ll also cover abstraction layers that let you write once and run on multiple backends.


The GPU Compute Landscape

A quick orientation before diving in:

Platform Vendor Hardware Primary Use
CUDA NVIDIA NVIDIA GPUs only ML training, HPC, scientific compute
ROCm / HIP AMD AMD GPUs (+ NVIDIA via translation) ML training, HPC
OpenCL Khronos NVIDIA, AMD, Intel, CPUs, FPGAs Portable compute, embedded
Metal Compute Apple Apple Silicon, AMD in older Macs iOS/macOS apps, ML on Apple
Vulkan Compute Khronos Any Vulkan-capable GPU Games, graphics pipelines + compute
WebGPU W3C/browsers Any GPU via browser Browser apps, portable compute
SYCL / oneAPI Intel/Khronos Intel GPUs, AMD, NVIDIA, CPUs HPC portability

OpenCL: The Portable Option

OpenCL (Open Computing Language) is the ISO-standardized, cross-vendor GPU compute API. It runs on NVIDIA, AMD, Intel GPUs, Apple Silicon, CPUs, FPGAs, and DSPs. If you need code that runs on hardware you don’t control, OpenCL is the most universal option.

The tradeoff: OpenCL is more verbose than CUDA, vendor implementations vary in quality, and it doesn’t have an ecosystem of pre-built libraries comparable to cuDNN. It’s also perpetually one major version behind what any single vendor’s hardware could support.

The OpenCL Programming Model

OpenCL has a host-device model similar to CUDA:

  • Host: your CPU program, manages memory and launches kernels
  • Device: the GPU (or other accelerator)
  • Kernel: a function that runs in parallel on the device, written in OpenCL C (a C99 subset with extensions)

The key objects:

  • cl_platform_id — a vendor’s OpenCL implementation (e.g., “AMD”, “Intel”)
  • cl_device_id — a specific compute device
  • cl_context — groups devices and manages memory
  • cl_command_queue — serializes operations on a device
  • cl_program — compiled kernels
  • cl_kernel — a single kernel entry point
  • cl_mem — a buffer on the device

A Complete OpenCL Example: Vector Addition

Kernel (OpenCL C):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// vadd.cl
__kernel void vector_add(
    __global const float* a,
    __global const float* b,
    __global float* c,
    const int n)
{
    int gid = get_global_id(0);
    if (gid < n) {
        c[gid] = a[gid] + b[gid];
    }
}

Host program (C):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

#ifdef __APPLE__
#include <OpenCL/opencl.h>
#else
#include <CL/cl.h>
#endif

#define N 1024 * 1024

const char *kernel_source =
"__kernel void vector_add("
"    __global const float* a,"
"    __global const float* b,"
"    __global float* c,"
"    const int n)"
"{"
"    int gid = get_global_id(0);"
"    if (gid < n) c[gid] = a[gid] + b[gid];"
"}";

int main() {
    cl_platform_id platform;
    cl_device_id device;
    cl_context context;
    cl_command_queue queue;
    cl_program program;
    cl_kernel kernel;
    cl_mem buf_a, buf_b, buf_c;
    cl_int err;

    // 1. Get platform and device
    clGetPlatformIDs(1, &platform, NULL);
    clGetDeviceIDs(platform, CL_DEVICE_TYPE_GPU, 1, &device, NULL);

    // 2. Create context and command queue
    context = clCreateContext(NULL, 1, &device, NULL, NULL, &err);
    queue = clCreateCommandQueueWithProperties(context, device, 0, &err);

    // 3. Compile kernel
    program = clCreateProgramWithSource(context, 1, &kernel_source, NULL, &err);
    clBuildProgram(program, 1, &device, NULL, NULL, NULL);
    kernel = clCreateKernel(program, "vector_add", &err);

    // 4. Allocate and fill host data
    float *h_a = malloc(N * sizeof(float));
    float *h_b = malloc(N * sizeof(float));
    float *h_c = malloc(N * sizeof(float));
    for (int i = 0; i < N; i++) { h_a[i] = i; h_b[i] = i * 2; }

    // 5. Create device buffers and copy data
    buf_a = clCreateBuffer(context, CL_MEM_READ_ONLY | CL_MEM_COPY_HOST_PTR,
                           N * sizeof(float), h_a, &err);
    buf_b = clCreateBuffer(context, CL_MEM_READ_ONLY | CL_MEM_COPY_HOST_PTR,
                           N * sizeof(float), h_b, &err);
    buf_c = clCreateBuffer(context, CL_MEM_WRITE_ONLY,
                           N * sizeof(float), NULL, &err);

    // 6. Set kernel arguments and launch
    int n = N;
    clSetKernelArg(kernel, 0, sizeof(cl_mem), &buf_a);
    clSetKernelArg(kernel, 1, sizeof(cl_mem), &buf_b);
    clSetKernelArg(kernel, 2, sizeof(cl_mem), &buf_c);
    clSetKernelArg(kernel, 3, sizeof(int), &n);

    size_t global_size = N;
    size_t local_size = 256;
    clEnqueueNDRangeKernel(queue, kernel, 1, NULL,
                           &global_size, &local_size, 0, NULL, NULL);

    // 7. Read result back
    clEnqueueReadBuffer(queue, buf_c, CL_TRUE, 0,
                        N * sizeof(float), h_c, 0, NULL, NULL);

    printf("c[0] = %.1f (expected %.1f)\n", h_c[0], h_a[0] + h_b[0]);
    printf("c[1023] = %.1f (expected %.1f)\n", h_c[1023], h_a[1023] + h_b[1023]);

    // Cleanup
    clReleaseMemObject(buf_a); clReleaseMemObject(buf_b); clReleaseMemObject(buf_c);
    clReleaseKernel(kernel); clReleaseProgram(program);
    clReleaseCommandQueue(queue); clReleaseContext(context);
    free(h_a); free(h_b); free(h_c);
    return 0;
}
1
2
3
4
# Compile
gcc -o vadd vadd.c -lOpenCL
# macOS
gcc -o vadd vadd.c -framework OpenCL

OpenCL Memory Model

OpenCL defines a hierarchy of memory spaces (similar to CUDA):

Memory Type OpenCL Qualifier Scope Speed
Global __global All work-items Slowest
Local __local Work-group (like CUDA shared memory) Fast
Private (default) Single work-item (like CUDA registers) Fastest
Constant __constant Read-only global, cached Fast reads
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
__kernel void matrix_multiply(
    __global const float* A,
    __global const float* B,
    __global float* C,
    __local float* tile_A,   // work-group shared memory
    __local float* tile_B,
    const int N)
{
    int row = get_global_id(0);
    int col = get_global_id(1);
    int local_row = get_local_id(0);
    int local_col = get_local_id(1);
    int tile_size = get_local_size(0);

    float sum = 0.0f;
    for (int t = 0; t < N / tile_size; t++) {
        // Load tile into local memory (fast, shared within work-group)
        tile_A[local_row * tile_size + local_col] = A[row * N + t * tile_size + local_col];
        tile_B[local_row * tile_size + local_col] = B[(t * tile_size + local_row) * N + col];
        barrier(CLK_LOCAL_MEM_FENCE);  // sync before use

        for (int k = 0; k < tile_size; k++)
            sum += tile_A[local_row * tile_size + k] * tile_B[k * tile_size + local_col];
        barrier(CLK_LOCAL_MEM_FENCE);  // sync before next tile
    }
    C[row * N + col] = sum;
}

OpenCL 3.0 and the Current State

OpenCL 3.0 (released 2020) took a pragmatic turn: it made most features optional and allowed vendors to implement only what their hardware supports. This solved the compliance problem but created a new one — you need to query at runtime which features are available.

The practical reality in 2026: OpenCL is well-supported on AMD (via ROCm), Intel (Arc GPUs and integrated graphics), and Apple Silicon (Metal-backed OpenCL). NVIDIA’s OpenCL support is frozen at OpenCL 3.0 with only mandatory features — they have no incentive to improve the competition.


ROCm and HIP: AMD’s CUDA Alternative

ROCm (Radeon Open Compute) is AMD’s open-source compute platform. HIP (Heterogeneous-compute Interface for Portability) is the CUDA-like programming API within ROCm.

HIP’s killer feature: it’s syntactically very similar to CUDA, and AMD provides a translation tool (hipify) that converts CUDA code to HIP automatically. The same HIP code compiles for AMD GPUs (via ROCm/HCC) and NVIDIA GPUs (via CUDA as a backend).

HIP Programming Model

HIP mirrors CUDA almost exactly:

CUDA HIP
cudaMalloc hipMalloc
cudaMemcpy hipMemcpy
__global__ __global__
threadIdx.x hipThreadIdx_x
blockIdx.x hipBlockIdx_x
__syncthreads() __syncthreads()
cudaStream_t hipStream_t
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
// vector_add.hip.cpp
#include <hip/hip_runtime.h>
#include <stdio.h>

#define N (1024 * 1024)

__global__ void vector_add(const float* a, const float* b, float* c, int n) {
    int idx = blockIdx.x * blockDim.x + threadIdx.x;
    if (idx < n) {
        c[idx] = a[idx] + b[idx];
    }
}

int main() {
    float *h_a, *h_b, *h_c;
    float *d_a, *d_b, *d_c;
    size_t size = N * sizeof(float);

    // Allocate host memory
    h_a = (float*)malloc(size);
    h_b = (float*)malloc(size);
    h_c = (float*)malloc(size);
    for (int i = 0; i < N; i++) { h_a[i] = i; h_b[i] = i * 2.0f; }

    // Allocate device memory
    hipMalloc(&d_a, size);
    hipMalloc(&d_b, size);
    hipMalloc(&d_c, size);

    // Copy to device
    hipMemcpy(d_a, h_a, size, hipMemcpyHostToDevice);
    hipMemcpy(d_b, h_b, size, hipMemcpyHostToDevice);

    // Launch kernel
    int threads = 256;
    int blocks = (N + threads - 1) / threads;
    hipLaunchKernelGGL(vector_add, dim3(blocks), dim3(threads), 0, 0,
                       d_a, d_b, d_c, N);

    // Copy result back
    hipMemcpy(h_c, d_c, size, hipMemcpyDeviceToHost);
    printf("c[0] = %.1f\n", h_c[0]);

    hipFree(d_a); hipFree(d_b); hipFree(d_c);
    free(h_a); free(h_b); free(h_c);
    return 0;
}
1
2
3
4
5
# Compile for AMD GPU
hipcc -o vector_add vector_add.hip.cpp

# Compile for NVIDIA GPU (using CUDA backend)
hipcc --platform nvidia -o vector_add vector_add.hip.cpp

Converting CUDA to HIP

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# Install hipify tools
sudo apt install hipify-clang  # from ROCm repo

# Convert a single file
hipify-clang cuda_code.cu -o hip_code.cpp

# Convert an entire project
hipify-perl --inplace project/src/*.cu

# Most CUDA patterns translate 1:1
# Exceptions: PTX inline assembly, some cuBLAS internals, NVLink

ROCm ML Libraries

ROCm ships equivalents of NVIDIA’s ML libraries:

NVIDIA AMD ROCm
cuBLAS rocBLAS
cuDNN MIOpen
cuFFT rocFFT
cuSPARSE rocSPARSE
NCCL RCCL
Thrust rocThrust

PyTorch and TensorFlow both have ROCm backends:

1
2
3
4
5
6
7
# Install PyTorch for ROCm
pip install torch torchvision torchaudio \
  --index-url https://download.pytorch.org/whl/rocm6.1

# Verify
python -c "import torch; print(torch.cuda.is_available())"  # Returns True on ROCm
python -c "import torch; print(torch.version.hip)"

PyTorch on ROCm uses the same CUDA API from Python (torch.cuda.*) — the ROCm backend is transparent to Python code.

Supported Hardware

ROCm supports AMD’s RDNA 2 (RX 6000 series), RDNA 3 (RX 7000 series), RDNA 4 (RX 9000 series), and CDNA (Instinct MI series) GPUs. The Instinct MI300X is AMD’s flagship for ML training — it’s competitive with the H100 for many workloads and has 192 GB of HBM3 on the MI300X.

1
2
3
4
5
6
# Check ROCm device info
rocm-smi
rocminfo | grep -A5 "Agent [0-9]"

# Check GPU utilization
watch -n 1 rocm-smi

Vulkan Compute: Graphics Pipeline + Compute

Vulkan is a low-level graphics API from Khronos that also exposes GPU compute through compute shaders. It runs on any GPU with a Vulkan driver — NVIDIA, AMD, Intel, ARM Mali, Qualcomm Adreno, and more.

Vulkan Compute makes the most sense when:

  • You’re already using Vulkan for graphics and want to integrate compute
  • You need the exact same code path for iOS/Android, Windows, Linux, and macOS (via MoltenVK)
  • You want to avoid the overhead of CUDA/OpenCL context management in a render loop
  • You need tight interop between graphics and compute (no PCIe copy needed)

The tradeoff: Vulkan has massive boilerplate. A “hello world” compute shader in Vulkan is ~300 lines of C. Most production use wraps it in a higher-level framework.

Compute Shaders in GLSL

Vulkan shaders are written in GLSL (or HLSL) and compiled to SPIR-V bytecode:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// vadd.comp — GLSL compute shader
#version 450

layout(local_size_x = 256) in;

layout(set = 0, binding = 0) readonly buffer InputA {
    float a[];
};
layout(set = 0, binding = 1) readonly buffer InputB {
    float b[];
};
layout(set = 0, binding = 2) writeonly buffer Output {
    float c[];
};

layout(push_constant) uniform PushConstants {
    uint n;
} pc;

void main() {
    uint idx = gl_GlobalInvocationID.x;
    if (idx < pc.n) {
        c[idx] = a[idx] + b[idx];
    }
}
1
2
3
4
5
# Compile to SPIR-V
glslc -fshader-stage=compute vadd.comp -o vadd.spv

# Or with glslangValidator
glslangValidator -V vadd.comp -o vadd.spv

Vulkan Compute Dispatch (Simplified)

The actual Vulkan boilerplate to dispatch this shader is substantial, but the high-level steps are:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// Pseudocode — real Vulkan requires ~300 lines
VkInstance instance = createInstance();
VkPhysicalDevice physDevice = pickPhysicalDevice(instance);
VkDevice device = createLogicalDevice(physDevice);

// Create buffers
VkBuffer bufA = createBuffer(device, N * sizeof(float), VK_BUFFER_USAGE_STORAGE_BUFFER_BIT);
VkBuffer bufB = createBuffer(device, N * sizeof(float), VK_BUFFER_USAGE_STORAGE_BUFFER_BIT);
VkBuffer bufC = createBuffer(device, N * sizeof(float), VK_BUFFER_USAGE_STORAGE_BUFFER_BIT);

// Upload data
copyToBuffer(bufA, h_a, N * sizeof(float));
copyToBuffer(bufB, h_b, N * sizeof(float));

// Create compute pipeline
VkShaderModule shader = loadSPIRV(device, "vadd.spv");
VkPipeline pipeline = createComputePipeline(device, shader, layout);

// Record and submit command buffer
VkCommandBuffer cmd = beginCommandBuffer();
vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, pipeline);
vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, layout, 0, 1, &descriptorSet, 0, NULL);
vkCmdPushConstants(cmd, layout, VK_SHADER_STAGE_COMPUTE_BIT, 0, sizeof(uint32_t), &n);
vkCmdDispatch(cmd, (N + 255) / 256, 1, 1);
endAndSubmitCommandBuffer(cmd, queue);

// Read result
copyFromBuffer(bufC, h_c, N * sizeof(float));

Higher-Level Vulkan Compute: kompute

kompute is a framework that wraps Vulkan compute to eliminate boilerplate:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#include <kompute/Kompute.hpp>

int main() {
    kp::Manager mgr;  // initializes Vulkan automatically

    std::vector<float> h_a(N), h_b(N), h_c(N);
    // ... fill h_a and h_b

    // Create tensors (GPU buffers)
    auto t_a = mgr.tensor(h_a);
    auto t_b = mgr.tensor(h_b);
    auto t_c = mgr.tensor(h_c);

    // Load SPIR-V shader
    auto spirv = readSPIRV("vadd.spv");
    auto algo = mgr.algorithm({t_a, t_b, t_c}, spirv, kp::Workgroup({N/256, 1, 1}));

    // Build and run sequence
    mgr.sequence()
        ->record<kp::OpTensorSyncDevice>({t_a, t_b})
        ->record<kp::OpAlgoDispatch>(algo)
        ->record<kp::OpTensorSyncLocal>({t_c})
        ->eval();

    printf("c[0] = %.1f\n", t_c->data<float>()[0]);
}

Metal Compute: Apple Silicon

Metal is Apple’s GPU API, introduced in 2014. On Apple Silicon (M-series Macs, A-series iPhones/iPads), Metal Compute is the only first-party path to the GPU — there’s no CUDA, no ROCm, no Vulkan (MoltenVK translates Vulkan to Metal, but at overhead cost).

Metal Compute is genuinely excellent. Apple Silicon has a unified memory architecture (GPU and CPU share the same physical RAM), which eliminates the PCIe bottleneck that limits discrete GPU compute. On an M4 Pro with 48 GB unified memory, you can run 30B parameter models that simply don’t fit on a 24 GB VRAM discrete card.

Metal Shading Language

Metal shaders are written in Metal Shading Language (MSL), a C++14 subset:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// vadd.metal
#include <metal_stdlib>
using namespace metal;

kernel void vector_add(
    device const float* a [[buffer(0)]],
    device const float* b [[buffer(1)]],
    device float* c       [[buffer(2)]],
    constant uint& n      [[buffer(3)]],
    uint gid              [[thread_position_in_grid]])
{
    if (gid < n) {
        c[gid] = a[gid] + b[gid];
    }
}

Metal Compute in Swift

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
import Metal
import Foundation

let N = 1024 * 1024

// 1. Get the default GPU
guard let device = MTLCreateSystemDefaultDevice() else { fatalError("No Metal device") }

// 2. Load shader from the default library (compiled at build time)
let library = device.makeDefaultLibrary()!
let function = library.makeFunction(name: "vector_add")!
let pipeline = try! device.makeComputePipelineState(function: function)

// 3. Create command queue
let queue = device.makeCommandQueue()!

// 4. Create buffers (on unified memory - no explicit copy needed!)
let bufA = device.makeBuffer(length: N * MemoryLayout<Float>.size, options: .storageModeShared)!
let bufB = device.makeBuffer(length: N * MemoryLayout<Float>.size, options: .storageModeShared)!
let bufC = device.makeBuffer(length: N * MemoryLayout<Float>.size, options: .storageModeShared)!

// Fill input buffers (direct pointer access - no copy!)
let pA = bufA.contents().bindMemory(to: Float.self, capacity: N)
let pB = bufB.contents().bindMemory(to: Float.self, capacity: N)
for i in 0..<N { pA[i] = Float(i); pB[i] = Float(i) * 2.0 }

// 5. Encode and dispatch
var n = UInt32(N)
let cmdBuf = queue.makeCommandBuffer()!
let encoder = cmdBuf.makeComputeCommandEncoder()!
encoder.setComputePipelineState(pipeline)
encoder.setBuffer(bufA, offset: 0, index: 0)
encoder.setBuffer(bufB, offset: 0, index: 1)
encoder.setBuffer(bufC, offset: 0, index: 2)
encoder.setBytes(&n, length: MemoryLayout<UInt32>.size, index: 3)

let threadsPerGroup = MTLSize(width: pipeline.maxTotalThreadsPerThreadgroup, height: 1, depth: 1)
let numGroups = MTLSize(width: (N + threadsPerGroup.width - 1) / threadsPerGroup.width, height: 1, depth: 1)
encoder.dispatchThreadgroups(numGroups, threadsPerThreadgroup: threadsPerGroup)
encoder.endEncoding()
cmdBuf.commit()
cmdBuf.waitUntilCompleted()

// 6. Read result (no copy needed - unified memory!)
let pC = bufC.contents().bindMemory(to: Float.self, capacity: N)
print("c[0] = \(pC[0])")

MLX: ML on Apple Silicon

Apple’s MLX framework (open-sourced in late 2023) is purpose-built for ML on Apple Silicon. It provides a NumPy-like API with lazy evaluation, unified memory, and Metal acceleration:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
import mlx.core as mx
import mlx.nn as nn

# Arrays live in unified memory — no explicit GPU copies
a = mx.array([1.0, 2.0, 3.0])
b = mx.array([4.0, 5.0, 6.0])
c = a + b  # lazy — not computed yet
mx.eval(c)  # trigger computation on GPU
print(c)  # array([5, 7, 9], dtype=float32)

# Matrix multiplication (dispatches to Metal)
A = mx.random.normal((4096, 4096))
B = mx.random.normal((4096, 4096))
C = A @ B
mx.eval(C)

# Simple neural network
model = nn.Sequential(
    nn.Linear(784, 256),
    nn.ReLU(),
    nn.Linear(256, 10),
)

llama.cpp, Ollama, and LM Studio all use Metal for inference on Apple Silicon. The M4 Max achieves competitive inference speeds with discrete GPUs for 7B–30B models.


WebGPU: GPU Compute in the Browser (and Beyond)

WebGPU is the W3C standard for GPU access from JavaScript (and now Rust, Python, and C++ via native ports). It launched in Chrome in 2023, Firefox in 2024, and Safari/WebKit shortly after.

WebGPU’s compute model is based on Vulkan/Metal/D3D12 — it’s intentionally modern and explicitly excludes the legacy baggage of WebGL (which was based on OpenGL ES 2.0).

WebGPU Compute in JavaScript

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
// WebGPU vector addition
async function vectorAdd() {
  const N = 1024 * 1024;

  // 1. Get GPU adapter and device
  const adapter = await navigator.gpu.requestAdapter();
  const device = await adapter.requestDevice();

  // 2. Define the compute shader (WGSL)
  const shaderModule = device.createShaderModule({
    code: `
      @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(256)
      fn main(@builtin(global_invocation_id) gid: vec3u) {
        let idx = gid.x;
        if (idx < arrayLength(&a)) {
          c[idx] = a[idx] + b[idx];
        }
      }
    `
  });

  // 3. Create buffers
  const bufSize = N * Float32Array.BYTES_PER_ELEMENT;

  const gpuBufA = device.createBuffer({ size: bufSize, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST });
  const gpuBufB = device.createBuffer({ size: bufSize, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST });
  const gpuBufC = device.createBuffer({ size: bufSize, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC });
  const stagingBuf = device.createBuffer({ size: bufSize, usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST });

  // 4. Upload data
  const h_a = new Float32Array(N).map((_, i) => i);
  const h_b = new Float32Array(N).map((_, i) => i * 2);
  device.queue.writeBuffer(gpuBufA, 0, h_a);
  device.queue.writeBuffer(gpuBufB, 0, h_b);

  // 5. Create pipeline and bind group
  const pipeline = device.createComputePipeline({
    layout: 'auto',
    compute: { module: shaderModule, entryPoint: 'main' }
  });

  const bindGroup = device.createBindGroup({
    layout: pipeline.getBindGroupLayout(0),
    entries: [
      { binding: 0, resource: { buffer: gpuBufA } },
      { binding: 1, resource: { buffer: gpuBufB } },
      { binding: 2, resource: { buffer: gpuBufC } },
    ]
  });

  // 6. Encode and submit
  const encoder = device.createCommandEncoder();
  const pass = encoder.beginComputePass();
  pass.setPipeline(pipeline);
  pass.setBindGroup(0, bindGroup);
  pass.dispatchWorkgroups(Math.ceil(N / 256));
  pass.end();

  encoder.copyBufferToBuffer(gpuBufC, 0, stagingBuf, 0, bufSize);
  device.queue.submit([encoder.finish()]);

  // 7. Read result
  await stagingBuf.mapAsync(GPUMapMode.READ);
  const result = new Float32Array(stagingBuf.getMappedRange());
  console.log('c[0]:', result[0]);  // 0
  console.log('c[1]:', result[1]);  // 3
  stagingBuf.unmap();
}

vectorAdd();

WGSL: WebGPU Shading Language

WGSL (WebGPU Shading Language) was designed from scratch for WebGPU. It’s memory-safe, strongly typed, and doesn’t have pointers — by design, to make security analysis tractable in a browser environment.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
// matrix_multiply.wgsl
struct Matrices {
  data: array<f32>,
}

@group(0) @binding(0) var<storage, read> A: Matrices;
@group(0) @binding(1) var<storage, read> B: Matrices;
@group(0) @binding(2) var<storage, read_write> C: Matrices;

struct Params {
  N: u32,
}
@group(0) @binding(3) var<uniform> params: Params;

var<workgroup> tileA: array<f32, 256>;  // 16x16 tile
var<workgroup> tileB: array<f32, 256>;

@compute @workgroup_size(16, 16)
fn main(
  @builtin(global_invocation_id) gid: vec3u,
  @builtin(local_invocation_id) lid: vec3u,
  @builtin(workgroup_id) wid: vec3u,
) {
  let row = gid.y;
  let col = gid.x;
  let N = params.N;
  let tile = 16u;

  var sum = 0.0f;
  for (var t = 0u; t < N / tile; t++) {
    tileA[lid.y * tile + lid.x] = A.data[row * N + t * tile + lid.x];
    tileB[lid.y * tile + lid.x] = B.data[(t * tile + lid.y) * N + col];
    workgroupBarrier();

    for (var k = 0u; k < tile; k++) {
      sum += tileA[lid.y * tile + k] * tileB[k * tile + lid.x];
    }
    workgroupBarrier();
  }
  C.data[row * N + col] = sum;
}

wgpu: Native WebGPU

wgpu is a Rust implementation of the WebGPU API that runs natively (not just in browsers). It translates to Vulkan, Metal, D3D12, and OpenGL ES depending on the platform — the same WGSL shader runs everywhere.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// Cargo.toml
[dependencies]
wgpu = "22"
pollster = "0.3"

// src/main.rs
use wgpu::util::DeviceExt;

async fn run() {
    let instance = wgpu::Instance::default();
    let adapter = instance.request_adapter(&wgpu::RequestAdapterOptions {
        power_preference: wgpu::PowerPreference::HighPerformance,
        ..Default::default()
    }).await.unwrap();

    let (device, queue) = adapter.request_device(&Default::default(), None)
        .await.unwrap();

    let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
        label: None,
        source: wgpu::ShaderSource::Wgsl(include_str!("vadd.wgsl").into()),
    });

    // ... buffer creation, pipeline, dispatch (same pattern as JS API)
}

fn main() {
    pollster::block_on(run());
}

SYCL and Intel oneAPI

SYCL is a Khronos standard for heterogeneous compute built on modern C++. Intel’s oneAPI implements it and targets Intel GPUs (Arc, Xe), CPUs, and FPGAs. It also has backends for AMD (via HIP) and NVIDIA (via CUDA).

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// SYCL vector addition
#include <sycl/sycl.hpp>
#include <vector>

int main() {
    const int N = 1024 * 1024;
    std::vector<float> h_a(N), h_b(N), h_c(N);
    for (int i = 0; i < N; i++) { h_a[i] = i; h_b[i] = i * 2.0f; }

    sycl::queue q(sycl::gpu_selector_v);
    std::cout << "Using: " << q.get_device().get_info<sycl::info::device::name>() << "\n";

    {
        sycl::buffer<float> buf_a(h_a.data(), N);
        sycl::buffer<float> buf_b(h_b.data(), N);
        sycl::buffer<float> buf_c(h_c.data(), N);

        q.submit([&](sycl::handler& h) {
            auto a = buf_a.get_access<sycl::access::mode::read>(h);
            auto b = buf_b.get_access<sycl::access::mode::read>(h);
            auto c = buf_c.get_access<sycl::access::mode::write>(h);

            h.parallel_for(sycl::range<1>(N), [=](sycl::id<1> i) {
                c[i] = a[i] + b[i];
            });
        });
    }  // buffers go out of scope, result copied back

    printf("c[0] = %.1f\n", h_c[0]);
}
1
2
3
4
5
# Compile with Intel oneAPI
icpx -fsycl -o vadd vadd.cpp

# For AMD target
icpx -fsycl -fsycl-targets=amdgcn-amd-amdhsa -Xsycl-target-backend --offload-arch=gfx1100 -o vadd vadd.cpp

Abstraction Layers: Write Once, Run Everywhere

For most applications, you don’t want to target a single GPU API. Several frameworks abstract over the underlying APIs:

PyTorch (Python/C++)

PyTorch is the de facto standard for ML and has backends for CUDA, ROCm, Metal (MPS), and CPU. From Python, the API is identical regardless of backend:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
import torch

# Automatically uses CUDA, ROCm, or MPS based on availability
device = (
    "cuda" if torch.cuda.is_available()
    else "mps" if torch.backends.mps.is_available()
    else "cpu"
)
print(f"Using: {device}")

x = torch.randn(4096, 4096, device=device)
y = torch.randn(4096, 4096, device=device)
z = x @ y  # dispatches to cuBLAS, rocBLAS, or MPS depending on device

ArrayFire

ArrayFire provides a high-level array API with OpenCL, CUDA, and CPU backends:

1
2
3
4
5
6
7
8
#include <arrayfire.h>

int main() {
    af::array a = af::randu(1024, 1024);  // on GPU automatically
    af::array b = af::randu(1024, 1024);
    af::array c = af::matmul(a, b);       // uses best available backend
    af::print("result", c(af::seq(3), af::seq(3)));
}

Taichi

Taichi is a Python-embedded DSL for compute-intensive workloads with backends for CUDA, Metal, Vulkan, OpenCL, and CPU:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
import taichi as ti
ti.init(arch=ti.gpu)  # auto-selects best available GPU backend

@ti.kernel
def vector_add(a: ti.template(), b: ti.template(), c: ti.template()):
    for i in a:
        c[i] = a[i] + b[i]

N = 1024 * 1024
a = ti.field(dtype=ti.f32, shape=N)
b = ti.field(dtype=ti.f32, shape=N)
c = ti.field(dtype=ti.f32, shape=N)

a.from_numpy(np.arange(N, dtype=np.float32))
b.from_numpy(np.arange(N, dtype=np.float32) * 2)
vector_add(a, b, c)
print(c[0])

Choosing the Right Platform

Situation Recommendation
AMD GPU, ML/HPC workloads ROCm + HIP, PyTorch ROCm
Apple Silicon, any workload Metal Compute / MLX
Must run on any GPU (including integrated) OpenCL or WebGPU (wgpu)
Graphics + compute in same pipeline Vulkan Compute
Browser-based GPU compute WebGPU (JavaScript/WGSL)
Intel Arc GPU SYCL / oneAPI
Python ML, don’t care about backend PyTorch (auto-selects CUDA/ROCm/MPS)
Cross-platform desktop app wgpu (Rust) or Taichi (Python)
Existing CUDA code, move to AMD hipify-clang → HIP

The good news for 2026: the ecosystem is much more mature than it was five years ago. ROCm is production-quality for training. Metal performance is genuinely competitive. WebGPU is shipping in all major browsers. You’re no longer forced to use NVIDIA hardware for serious GPU compute — you have real options, and they’re actually good.


Further Reading

Comments