KernelAbstractions.jl
repository·main·Indexed 19 days ago
https://github.com/juliagpu/kernelabstractions.jlA Julia package providing a unified interface for writing high-performance, heterogeneous kernels that target multiple execution backends, including NVIDIA CUDA (via CUDA.jl), AMD ROCm (via AMDGPU.jl), Intel oneAPI (via oneAPI.jl), Apple Metal (via Metal.jl), and CPU. It offers a minimal abstraction layer with kernel language macros like @kernel and @index, memory management tools, and integration with Atomix.jl for thread-safe atomic operations.
What's inside KernelAbstractions.jl
- KernelAbstractions (KA) is a Julia package designed to enable the writing of GPU-like kernels that can target multiple different execution backends. It serves as a minimal and performant abstraction layer for writing heterogeneous code, allowing developers to write a single kernel that can run on various hardware architectures without rewriting the core logic for each.
What is KernelAbstractions.jl
mainKernelAbstractions.jl(KA) is a performance-portable library that allows you to write GPU-like kernels targeting different execution backends. It emulates GPU semantics to provide a consistent programming model across both CPU and GPU hardware.Key Concepts:
- Performance Portability: You write code once, and it can run on various backends (like CUDA or CPU).
- GPU Semantics on CPU: To maintain a consistent model, KA emulates GPU constructs on the CPU. Some constructs like
@synchronizemight be ignored on the CPU, while others like@localmemmight be swapped for CPU-equivalent structures (e.g.,MVector). This may result in the CPU performing extra work to maintain the programming model, though it remains fast. - Implicit Ordering: As of version 0.9, the event system has been removed; kernels are now implicitly ordered.
Semantics of KernelAbstractions.synchronize
mainWhen implementing a backend for
KernelAbstractions.jl, theKernelAbstractions.synchronizefunction must be cooperative.This means the implementation cannot block inside an external library (such as a C or Fortran library). Instead, it must implement a cooperative wait that
yields the current task, returning the scheduling slice to the Julia runtime. This cooperative behavior is critical for allowing the overlapping of communication and computation, particularly when using MPI.Understand `@synchronize` and convergent execution requirements
mainIn KernelAbstractions (specifically since v0.9.34), the
@synchronizeconstruct requires convergent execution.The Issue: Most GPU implementations execute workgroups on static blocks. If a kernel's
ndrangeis smaller than the static block size (e.g.,ndrange=(32, 30)on a(32, 32)block), KA previously inserted dynamic bounds-checks. This caused implicit divergent execution when calling@synchronize, which can lead to mis-compilations on backends like OpenCL.The Solution: KA now lowers kernels to ensure
@synchronizeis called in a way that respects convergence. If you need to avoid the implicit bounds-checking behavior that might cause divergence, you can use theunsafe_indices=trueflag on your kernel, but you must manually derive your global indices using@index(Group)and@index(Local)instead of relying on@index(Global).# Example of a kernel using unsafe_indices to avoid implicit bounds-check divergence @kernel unsafe_indices=true function localmem(A) N = @uniform prod(@groupsize()) gI = @index(Group, Linear) i = @index(Local, Linear) lmem = @localmem Int (N,) lmem[i] = i @synchronize I = (gI - 1) * N + i if i <= N && I <= length(A) A[I] = lmem[N - i + 1] end endSupported execution backends in KernelAbstractions.jl
mainKernelAbstractions.jl supports several hardware backends through specific driver packages. Depending on your hardware, you can target:
- NVIDIA CUDA (via
CUDA.jl) - AMD ROCm (via
AMDGPU.jl) - Intel oneAPI (via
oneAPI.jl) - Apple Metal (via
Metal.jl)
To use a specific backend, you must ensure the corresponding driver package is installed and available in your Julia environment.
- NVIDIA CUDA (via
Overlap kernel execution with host work using Tasks
mainYou can enqueue multiple kernels on the same backend before calling
synchronize. To overlap kernel execution with other asynchronous host work, use Julia's task-based parallelism viaThreads.@spawn.On GPU backends,
synchronize(backend)is cooperative: it yields to the Julia scheduler rather than blocking the entire thread, allowing other Julia tasks to make progress while the kernel runs on the hardware.function exchange_and_compute!(backend, A, B) recv = Threads.@spawn begin mul2_kernel(backend, 64)(A, ndrange=length(A)) synchronize(backend) # cooperative on GPU backends end send = Threads.@spawn begin mul2_kernel(backend, 64)(B, ndrange=length(B)) synchronize(backend) end wait(recv) wait(send) endManage local memory and synchronization in kernels
mainLocal Memory
Use the
@localmemmacro to declare storage shared by all work items in a workgroup. Note: Currently, only static local memory is supported. The allocation size must be known at compile time (e.g.,@localmem Int (32,)or@localmem Int (N,)whereN = prod(@groupsize())and the workgroup size is fixed during kernel construction).Synchronization
If different work items perform reads and writes to the same local memory, you must separate these operations with the
@synchronizemacro to avoid race conditions.Other Scopes
@private: Declares per-work-item storage that survives across@synchronizestatements.@uniform: Evaluates an expression outside the work-item scope so it can be reused across@synchronizestatements.- For scratch storage that does not need to survive across
@synchronize, use anMArrayinstead.
using KernelAbstractions @kernel function reverse_block!(A) I = @index(Global, Linear) i = @index(Local, Linear) N = @uniform prod(@groupsize()) buf = @localmem Int (N,) buf[i] = i @synchronize() @inbounds A[I] = buf[N - i + 1] end A = collect(1.0:16.0) backend = CPU() reverse_block!(backend, 8, size(A))(A) synchronize(backend)Differences between KernelAbstractions and CUDA.jl/AMDGPU.jl
mainWhen using KernelAbstractions instead of direct backend libraries like
CUDA.jlorAMDGPU.jl, note the following semantic differences:- Automatic Bounds-Checking: Kernels are automatically bounds-checked against either the dynamic or statically provided
ndrange. - Return Values: Kernels implicitly return
nothing.
- Automatic Bounds-Checking: Kernels are automatically bounds-checked against either the dynamic or statically provided
KernelAbstractions Terminology
mainKernelAbstractions.jl uses terminology inspired by CUDA to describe parallel execution models:
- Workgroup: A group of threads acting in parallel (often in lockstep). In NVIDIA CUDA, this is called a block. For GPUs, workgroup sizes are typically around 256; for CPUs, they are usually multiples of the natural vector-width.
- ndrange: The total number of work items in the execution. In NVIDIA CUDA, this is called a grid. If the workgroup size is 1 (non-parallel execution), the ndrange represents the number of items to iterate over in a loop.
Profile kernel performance with Nsight Compute
mainTo profile the performance of your kernels (such as measuring memory throughput and SM utilization), you can use the
nv-nsight-cu-clitool. This is particularly useful for analyzing the 'Speed of Light' metrics on NVIDIA GPUs.Run the following command to profile the performance example provided in the repository:
nv-nsight-cu-cli --nvtx --profile-from-start=off --section=SpeedOfLight --section=julia --project=examples examples/performance.jlLaunch a kernel on a GPU backend
mainTo run kernels on a GPU, you must use arrays provided by the specific backend (e.g.,
CuArrayfor CUDA,ROCArrayfor AMD,oneArrayfor oneAPI, orMtlArrayfor Metal).You can use
get_backend(A)to automatically retrieve the correct backend from a device array. The kernel is then constructed with that backend and launched using thendrangekeyword.using CUDA: CuArray A = CuArray(ones(1024, 1024)) backend = get_backend(A) # Launching on the backend mul2_kernel(backend, 64)(A, ndrange=size(A)) synchronize(backend) @assert all(A .== 2)Launch kernels with dynamic or static configurations
mainKernels are constructed by calling the kernel function on a backend and then launched with an
ndrange. There are three primary ways to configure the launch:- Dynamic sizes: Supply the
ndrange(and optionallyworkgroupsize) at launch time. - Static workgroup size: Specify the workgroup size during kernel construction, but supply
ndrangeat launch. - Static workgroup size and ndrange: Specify both during construction for fewer runtime checks and better specialization.
Always call
synchronize(backend)before reading results on the host. You can obtain the backend from an array usingget_backend(array).# 1. Dynamic sizes kernel = my_kernel(backend) kernel(A, ndrange=size(A)) # 2. Static workgroup size kernel = my_kernel(backend, 256) kernel(A, ndrange=size(A)) # 3. Static workgroup size and ndrange kernel = my_kernel(backend, 32, size(A)) kernel(A)- Dynamic sizes: Supply the