KernelInterface
KernelInterface (conventionally imported as KI) is the low-level API that backends implement, and that KernelAbstractions builds its higher-level kernel language on top of.
It ships as a standalone package under lib/KernelInterface with no dependencies outside the standard library, so a backend can implement the interface without taking on KernelAbstractions or its compiler stack:
using KernelInterface
const KI = KernelInterfaceKernelAbstractions re-exports it, so KernelAbstractions.KernelInterface and KernelAbstractions.KI refer to the same module. This includes the Backend type hierarchy and the host-side management API: these are defined here in KernelInterface, and KernelAbstractions.Backend, KernelAbstractions.allocate, KernelAbstractions.synchronize and so on are the same objects, so user code keeps using them through KernelAbstractions unchanged.
This only holds for KernelAbstractions 0.10 and later. KernelAbstractions 0.9 predates KernelInterface and defines its own Backend, allocate, synchronize, etc. — those are different functions and types from the KernelInterface ones. Methods added to one are not seen by the other, so a backend targeting both must implement both. KernelAbstractions 0.10 is based on KernelInterface, so any KernelInterface functionality does not need to be reimplemented for KernelAbstractions.
Most of the device-side functions below are stubs with no methods. They exist so that backends can add device-side implementations with GPUCompiler.@device_override, and so kernels can call them generically. Calling one without a backend that implements it is a MethodError.
KernelInterface.KernelInterface — Module
KernelInterface
The KernelInterface (or KI) module defines the API interface for backends to define various lower-level device and host-side functionality. The KI interface is used to define the higher-level device-side functionality in KernelAbstractions.
Both provide APIs for host and device-side functionality, but KI focuses on lower-level functionality that is shared amongst backends, while KernelAbstractions provides higher-level functionality such as writing kernels that work on arrays with an arbitrary number of dimensions, or convenience functions like allocating arrays on a backend.
Semantics
A few rules hold throughout the interface:
- Execution is task-local. A backend value (e.g.
CUDABackend()) identifies a backend and its configuration, such as compiler options. Each Julia task has an active device per backend (selected withdevice!) and a queue on it. Host-side queries and compilation use the active device, allocations go to it, and copies and launches go to the calling task's queue.synchronizewaits for that queue, andrecord_event/wait_eventorder work across queues. Switching devices doesn't synchronize. - Compiled kernels belong to a device. Queries on a
Kernel(max_work_group_size,launch_configuration) answer for the device it was compiled for. Launching it after switching to another device either works or throws, but never runs on the wrong device. - Indices are 1-based, and
xis the fastest-varying dimension. - Capabilities default to "unsupported". A backend that doesn't implement a
supports_*query never claims support.
Contract
What a backend implements, at a glance. The docstrings below have the details.
| Required | Optional (fallback) | |
|---|---|---|
| Backend | subtype Backend; get_backend for its array type | |
| Memory | allocate, copyto! | allocate(...; unified=true) (throws), pagelock! (missing), unsafe_free! (no-op) |
| Execution | synchronize (cooperative) | record_event/wait_event (synchronize), priority! (no-op) |
| Devices | with more than one device: ndevices, device, device!, device(backend, A) | all four (a single device) |
| Queries | max_work_group_size (for the backend and for a kernel), max_work_group_dims, max_num_groups | launch_configuration (the limit), multiprocessor_count (0), functional (missing), versioninfo |
| Capabilities | supports_float64, supports_atomics, supports_unified, supports_subgroups, supports_shuffle (all false) | |
| Compilation | argconvert, kernel_function, launch | |
| Device | get_local_id, get_group_id, get_local_size, get_num_groups, localmemory, barrier | get_global_id, get_global_size (derived from the primitive queries), _print (host print) |
| Sub-groups | if supports_subgroups: sub_group_size, the sub-group queries, sub_group_barrier; if supports_shuffle(backend, T): shfl_down for T |
Everything else, such as zeros, ones, the launch-keyword handling of Kernel and @launch, is generic and not meant to be overridden.
Versioning
- Required methods only change in breaking releases (0.x → 0.x+1).
- Optional methods can be added in any release, with a fallback that is conservative: never claiming support, never wrong. Tests for them pass on the fallback, or are gated on a capability query.
- A patch release may add tests of behavior that was already specified; tests for newly specified behavior are new obligations and wait for a breaking release.
Backends test themselves against the contract with the testsuite in lib/KernelInterface/test:
import KernelInterface
using Test
include(joinpath(pkgdir(KernelInterface), "test", "testsuite.jl"))
Testsuite.testsuite(MyBackend(), MyArray)Backend hierarchy
Backends subtype Backend, and everything else in the interface dispatches on that type. It and the host-side management functions below are re-exported by KernelAbstractions, so their canonical docstrings are on the API page.
KernelInterface.Backend — Type
BackendAbstract supertype for all KernelInterface backends. Backends subtype it directly.
A backend value identifies a backend and its configuration (e.g. compiler options). The device and the queue that operations use are task-local: each task selects its active device with device!. Host-side queries answer for the calling task's active device, and work is queued on the calling task's queue of that device. Use get_backend to obtain the backend of an array and allocate to create storage on a backend.
Example
backend = get_backend(A)
kernel = my_kernel(backend, 256)
kernel(A, ndrange=length(A))
synchronize(backend)KernelInterface.get_backend — Function
get_backend(A::AbstractArray)::BackendGet a Backend instance suitable for array A.
Backend implementations must provide get_backend for their custom array type. It should be the same as the return type of allocate
Device-side API
These are called from inside a kernel. A backend provides each one with
@device_override KI.get_local_id(::Type{T}) where {T} = ...along with the corresponding on-device functionality.
Indexing
All index queries are 1-based and return a named tuple of x, y and z components. They take an optional integer type T for the components, defaulting to Int, so a kernel can request e.g. Int32 indices with KI.get_global_id(Int32). The operands are converted to T before any arithmetic, and the result is the exact value modulo T: a query never throws, and a value that doesn't fit wraps around, as with x % T.
Backends implement the four primitive queries. get_global_id and get_global_size have fallbacks derived from them, which backends with a native builtin (e.g. SPIR-V and Metal) should override.
KernelInterface.get_local_id — Function
get_local_id([::Type{T}=Int])::@NamedTuple{x::T, y::T, z::T}The 1-based index of the work-item within its work-group, as integers of type T.
T is a fixed-width integer type of at most 64 bits (e.g. Int32 or UInt64); the result is the exact value modulo T, as if computed with x % T.
KernelInterface.get_group_id — Function
get_group_id([::Type{T}=Int])::@NamedTuple{x::T, y::T, z::T}The 1-based index of the work-group within the launch, as integers of type T.
See get_local_id for the supported types T.
KernelInterface.get_local_size — Function
get_local_size([::Type{T}=Int])::@NamedTuple{x::T, y::T, z::T}The number of work-items in a work-group, as integers of type T.
See get_local_id for the supported types T.
KernelInterface.get_num_groups — Function
get_num_groups([::Type{T}=Int])::@NamedTuple{x::T, y::T, z::T}The number of work-groups in the launch, as integers of type T.
See get_local_id for the supported types T.
KernelInterface.get_global_id — Function
get_global_id([::Type{T}=Int])::@NamedTuple{x::T, y::T, z::T}The 1-based index of the work-item within the launch, as integers of type T: (get_group_id(T) - 1) * get_local_size(T) + get_local_id(T) per dimension.
See get_local_id for the supported types T.
KernelInterface.get_global_size — Function
get_global_size([::Type{T}=Int])::@NamedTuple{x::T, y::T, z::T}The number of work-items in the launch, as integers of type T: get_local_size(T) * get_num_groups(T) per dimension. For an ndrange launch, this is the ndrange padded to whole work-groups.
See get_local_id for the supported types T.
Sub-groups
Sub-groups are optional (supports_subgroups). A work-group is divided into sub-groups of at most sub_group_size(backend) work-items. Which work-items form a sub-group, how many sub-groups there are, and which of them are partial is unspecified, and differs between devices and work-group shapes. For example, CUDA forms warps from consecutive linear work-item indices, while Intel's CPU OpenCL runtime forms sub-groups per row of a multi-dimensional work-group, so that a 33×2 work-group consists of four sub-groups of 32 and 1 work-items. What KernelInterface guarantees, and backends that report sub-group support have to ensure:
- every work-item has a unique
(get_sub_group_id(), get_sub_group_local_id())pair in its work-group, which doesn't change during the kernel; - the sub-group ids are
1:get_num_sub_groups(), and the lanes of a sub-group are1:get_sub_group_size(); - a 1-D work-group of at most
sub_group_size(backend)work-items is a single sub-group.
In particular, get_num_sub_groups can be larger than cld(prod(get_local_size()), get_max_sub_group_size()). Storage for a value per sub-group has to be sized for up to one sub-group per work-item, and code combining those values has to use get_num_sub_groups() rather than compute the count.
KernelInterface.get_sub_group_size — Function
get_sub_group_size([::Type{T}=Int])::TThe number of work-items in the sub-group, at most the sub-group width (get_max_sub_group_size). Which sub-groups have fewer work-items than the width is unspecified: when the work-group size isn't a multiple of the width, there can be more than one, e.g. one per row of a multi-dimensional work-group.
See get_local_id for the supported types T.
KernelInterface.get_max_sub_group_size — Function
get_max_sub_group_size([::Type{T}=Int])::TThe sub-group width, sub_group_size(backend) on the host.
See get_local_id for the supported types T.
KernelInterface.get_num_sub_groups — Function
get_num_sub_groups([::Type{T}=Int])::TThe number of sub-groups in the work-group. It is at least cld(prod(get_local_size()), get_max_sub_group_size()), but can be larger, since more than one sub-group can be partial. Size storage for a value per sub-group for up to one sub-group per work-item.
See get_local_id for the supported types T.
KernelInterface.get_sub_group_id — Function
get_sub_group_id([::Type{T}=Int])::TThe 1-based index of the sub-group within the work-group, between 1 and get_num_sub_groups. How it relates to get_local_id is unspecified.
See get_local_id for the supported types T.
KernelInterface.get_sub_group_local_id — Function
get_sub_group_local_id([::Type{T}=Int])::TThe 1-based index of the work-item within its sub-group (its lane), between 1 and get_sub_group_size. It doesn't depend on which work-items of the sub-group are active, e.g. in a divergent branch.
See get_local_id for the supported types T.
Barriers
KernelInterface.barrier — Function
barrier()Wait until all work-items of the work-group have reached the barrier. Afterwards, the writes to global and local memory that each work-item made before the barrier are visible to all work-items of the work-group.
This does not order memory between work-groups.
All work-items of a work-group have to reach the same barrier() (not in a divergent branch).
KernelInterface.sub_group_barrier — Function
sub_group_barrier()Like barrier, for the work-items of a sub-group: wait until all work-items of the sub-group have reached the barrier, and make their writes to global and local memory before it visible to the sub-group.
All work-items of a sub-group have to reach the same sub_group_barrier().
Memory
KernelInterface.localmemory — Function
localmemory(::Type{T}, dims)Declare an array of element type T and size dims in memory that is local to a work-group. dims has to be known at compile time.
Every call site of localmemory in a kernel has its own memory, shared by all work-items of a work-group. It is uninitialized, and lives until the work-group finishes. Executing the same call site again, e.g. in a loop, returns the same memory. A function containing a call that is itself called from several places may get the same memory at each of them, or different memory, depending on whether it is inlined: don't rely on either. Use barrier to make writes visible to the other work-items.
Communication
KernelInterface.shfl_down — Function
shfl_down(val::T, offset::Integer)::TReturn val of the work-item offset lanes further in the sub-group, i.e. with get_sub_group_local_id equal to get_sub_group_local_id() + offset. When there is no such work-item, the result is an unspecified value (of type T).
All work-items of the sub-group have to execute shfl_down together (not in a divergent branch), with the same offset.
shfl_down exchanges values, not memory: it is not a memory fence.
Backend implementations must implement this for every T for which supports_shuffle returns true:
@device_override shfl_down(val::T, offset::Integer) where TPrinting
KernelInterface._print — Function
_print(args...)Print args from a kernel; the backend hook behind KernelAbstractions.@print.
Backend implementations should implement:
@device_override _print(args...)A backend that can't print from a kernel defines it to return nothing, and documents that.
The generic fallback prints on the host, which keeps CPU backends working. Val arguments are unwrapped, since KernelAbstractions.@print uses them to pass literal strings through to backends that require compile-time format strings.
_print is the one device-side function with a working host fallback: it prints its arguments with Base.print, unwrapping any Val-wrapped literals. That is what makes KernelAbstractions.@print usable outside of a kernel.
Host-side API
Memory
KernelInterface.allocate — Function
allocate(::Backend, Type, dims...; unified=false)::AbstractArrayAllocate an uninitialized array on the active device of the backend. unified=true allocates unified memory, accessible from the host and the device without explicit copies, if the backend supports it and throws otherwise. Use supports_unified to determine whether it is supported by a backend.
KernelInterface.zeros — Function
zeros(::Backend, Type, dims...; unified=false)::AbstractArrayAllocate an array with allocate and fill it with zeros.
This is generic: backends implement allocate (and fill! for their array type).
KernelInterface.ones — Function
ones(::Backend, Type, dims...; unified=false)::AbstractArrayAllocate an array with allocate and fill it with ones.
This is generic: backends implement allocate (and fill! for their array type).
KernelInterface.copyto! — Function
copyto!(::Backend, dest::AbstractArray, src::AbstractArray)::typeof(dest)Copy the elements of src to dest, ordered with respect to the other work on the calling task's queue: after work queued before the copy, and before work queued after it. Returns dest.
Either array can be a host array or an array of backend. dest and src must have the same length, otherwise an ArgumentError is thrown. Backends only have to support dense (contiguous) arrays with the same element type.
The copy may be asynchronous with respect to the host, but doesn't have to be: it can also block until it has completed. For a simple, synchronous copy, use Base.copyto!.
Because the copy may be asynchronous, the caller has to keep both arrays alive, and not access them from the host, until the copy has completed, e.g. by calling synchronize before using them. A GC.@preserve around copyto! only keeps them alive until the copy is queued:
arr = zeros(64)
GC.@preserve arr begin
copyto!(backend, arr, ...)
# other operations
synchronize(backend)
endOn some backends it may be necessary to first call pagelock! on host memory to enable fully asynchronous behavior w.r.t to the host.
KernelInterface.pagelock! — Function
pagelock!(::Backend, dest::AbstractArray)::Union{Nothing, Missing}Pagelock (pin) a host memory buffer for a backend device. This may be necessary for copyto! to perform asynchronously with respect to the host.
This function returns nothing, or missing if not implemented.
KernelInterface.unsafe_free! — Function
unsafe_free!(x::AbstractArray)Release the memory of an array for reuse by future allocations, reducing pressure on the allocator. The array may not be used afterwards.
This is a hint: releasing the memory is allowed to do nothing.
Execution
KernelInterface.synchronize — Function
synchronize(::Backend)Block the calling task until all work it has queued on the active device of backend has completed.
Backend implementations must implement this function cooperatively, yielding to the Julia scheduler while waiting rather than blocking inside a driver call. It must not wait for work on other queues that the current queue is not ordered after, e.g., by wait_event. See the notes for backend implementations for why.
KernelInterface.record_event — Function
record_event(backend::Backend)Capture the work the calling task has queued on backend's active device before this call, and return a handle for wait_event. Recording need not wait for that work to complete. The handle is only meant for wait_event.
The default implementation calls synchronize and returns nothing. Backends whose queue is task-local may override this to return an event recorded on the current task's queue instead, without blocking the host. Such a backend must then also implement wait_event for the returned type. See the notes for backend implementations.
KernelInterface.wait_event — Function
wait_event(backend::Backend, event)Order the work the calling task subsequently queues on backend's active device after the work captured by event, which was returned by record_event. Returning does not mean that the captured work has completed.
The wait applies to the queue of the device that is active when wait_event is called; switching devices adds no ordering. To order work across a device switch, select the device first and wait afterwards:
event = record_event(backend) # captures work on the current device
device!(backend, 2)
wait_event(backend, event) # orders this task's work on device 2 after itwait_event(::Backend, ::Nothing) is a no-op, matching the default record_event. A backend that returns another event type must implement wait_event for it, either by adding a dependency to the current task's queue or by waiting cooperatively as synchronize does. A backend with more than one device must accept an event recorded on another device, waiting cooperatively if the driver cannot add a cross-device dependency. See the notes for backend implementations for how this ordering should interact with implicit synchronization.
KernelInterface.priority! — Function
priority!(::Backend, prio::Symbol)::NothingSet the priority for the backend stream/queue. This is an optional feature that backends may or may not implement. If a backend shall support priorities it must accept :high, :normal, :low. Where :normal is the default.
Device management
KernelInterface.device — Function
device(backend::Backend)::IntReturn the 1-based index of the currently active device for backend.
device(backend::Backend, A::AbstractArray)::IntReturn the 1-based index of the device that owns the memory of A, independently of the currently active device.
Backends supporting multiple devices must implement this for their array type. The fallback only works for a single device, and throws if ndevices reports more.
KernelInterface.ndevices — Function
ndevices(backend::Backend)::IntReturn the number of devices available to backend.
KernelInterface.device! — Function
device!(backend::Backend, id::Int)::NothingSelect the active device for backend. id is a 1-based device index; an id outside 1:ndevices(backend) throws an ArgumentError.
device! is not a synchronization point: work queued before the switch is not ordered with respect to work queued after it. To order across a switch, either synchronize beforehand, or bracket the switch with record_event and wait_event.
Example
device!(CUDABackend(), 2) # use the second CUDA deviceCapability queries
KernelInterface.functional — Function
functional(::Backend)::Union{Bool, Missing}Queries if the provided backend is functional. This may mean different things for different backends, but generally should mean that the necessary drivers and a compute device are available.
This function should return a Bool or missing if not implemented.
KernelInterface.versioninfo — Function
versioninfo(io::IO=stdout, backend::Backend)::NothingPrint information about backend to io. It is up to the backends to determine what is relevant.
KernelInterface.supports_unified — Function
supports_unified(::Backend)::BoolWhether allocate supports unified=true on the active device: memory that can be accessed from both the host and the device without explicit copies.
KernelInterface.supports_atomics — Function
supports_atomics(::Backend)::BoolWhether kernels on the active device support Atomix.jl's atomic operations: at least add and compare-and-swap on 32-bit integers and floats in global memory.
KernelInterface.supports_float64 — Function
supports_float64(::Backend)::BoolWhether kernels on the active device support Float64 values.
KernelInterface.supports_subgroups — Function
supports_subgroups(::Backend)::BoolWhether kernels on the active device support sub-groups: the sub-group queries (get_sub_group_size etc.), sub_group_barrier, and a fixed sub-group width sub_group_size. See the manual for what KernelInterface guarantees about how work-groups are divided into sub-groups; a backend that can't ensure that reports false.
Which types shfl_down supports is queried separately with supports_shuffle.
KernelInterface.supports_shuffle — Function
supports_shuffle(::Backend, ::Type{T})::BoolWhether kernels on the active device support shfl_down for values of type T.
Limits
KernelInterface.max_work_group_size — Function
max_work_group_size(backend)::Int
max_work_group_size(kernel::Kernel)::IntThe largest number of work-items a work-group can have: on the active device of backend, or for launches of the compiled kernel (which may be lower, e.g. because of the kernel's register use). Launching a larger work-group is an error.
The work-group size that performs best is often smaller; see launch_configuration.
KernelInterface.launch_configuration — Function
launch_configuration(kernel::Kernel; nitems=nothing, max_work_group_size=typemax(Int))::@NamedTuple{workgroupsize::Int}The recommended number of work-items per work-group for launching kernel over nitems work-items in total (nothing if unknown), at most max_work_group_size. This is what an ndrange launch without a workgroupsize uses, passing the number of work-items in the ndrange (saturated at typemax(Int)). nitems and max_work_group_size are positive.
Unlike max_work_group_size, this is advice: backends may base it on occupancy or on the size of the launch, e.g. to prefer more work-groups over larger ones.
Backend implementations may implement:
launch_configuration(kernel::Kernel{<:NewBackend}; nitems::Union{Int, Nothing}=nothing,
max_work_group_size::Int=typemax(Int))::@NamedTuple{workgroupsize::Int}The result has to be positive and at most max_work_group_size and max_work_group_size(kernel). The fallback recommends the largest legal work-group size.
KernelInterface.max_work_group_dims — Function
max_work_group_dims(backend)::NTuple{3, Int}The maximum number of work-items along each dimension of a work-group, for the active device of backend. max_work_group_size bounds their product.
KernelInterface.max_num_groups — Function
max_num_groups(backend)::NTuple{3, Int}The maximum number of work-groups along each dimension of a launch, for the active device of backend.
This is conservative: a launch within these limits works for any work-group size (as long as the number of work-items in each dimension fits an Int), but some backends accept more work-groups for smaller work-groups (e.g. HIP bounds the number of work-items per dimension). The backend's validation at launch time is authoritative.
KernelInterface.sub_group_size — Function
sub_group_size(backend)::IntThe sub-group width of kernels compiled for the active device of backend.
Kernels compiled by kernel_function execute with exactly this width: on the device, get_max_sub_group_size returns it, and full sub-groups have this many work-items. Host code can rely on it, e.g. to pick a Val(N) for a warp-level reduction.
Backend implementations must implement this if supports_subgroups returns true:
sub_group_size(backend::NewBackend)::IntA backend that cannot guarantee the width for every kernel has to report supports_subgroups(backend) = false.
KernelInterface.multiprocessor_count — Function
multiprocessor_count(backend)::IntThe number of multiprocessors (CUDA SMs, AMD CUs, Intel Xe cores, ...) of the active device of backend, or 0 if unknown. The unit differs between backends, so this is only useful for heuristics, e.g. to choose how many work-groups a grid-stride loop launches.
Compilation and launching
KernelInterface.Kernel — Type
Kernel{Backend, Kern}A kernel compiled by kernel_function for backend, wrapping the backend's own kernel object kern. kernel.backend is the backend value that was passed to kernel_function.
Calling a Kernel launches it:
(kernel::Kernel)(args...; numgroups=(), workgroupsize=(), ndrange=(),
max_work_group_size=typemax(Int), kwargs...)args are the host-side arguments, e.g. a CuArray rather than a CuDeviceArray. The backend converts them with argconvert, and the converted types have to match the argument types the kernel was compiled for.
The launch geometry is given in one of three ways:
numgroupsandworkgroupsize: launch exactly that many work-groups of that many work-items. Either defaults to 1.ndrangeandworkgroupsize: launchcld.(ndrange, workgroupsize)work-groups.ndrangealone: the work-group size is chosen withlaunch_configuration, bounded bymax_work_group_sizeand bymax_work_group_dims, and filled first dimension first.
Each is an Integer or a tuple of up to 3 Integers; missing dimensions are 1. ndrange and numgroups are mutually exclusive.
ndrange is rounded up to whole work-groups and is not masked: the kernel runs for every work-item of every launched group, and get_global_size returns the padded size. Kernels have to check their own bounds.
A zero anywhere in ndrange or numgroups launches nothing. Work-group sizes must be positive and fit max_work_group_dims and max_work_group_size(kernel), and the number of work-items in each dimension must fit an Int; anything else throws an ArgumentError before the backend sees it. The number of work-groups is validated by the backend.
Other keyword arguments are passed to launch unchanged. They are backend-specific: a backend throws an error for keywords it doesn't support.
A launch is queued on the calling task's queue of the active device, and returns nothing.
KernelInterface.kernel_function — Function
kernel_function(backend, f::F, tt::TT=Tuple{}; name=nothing, kwargs...)::KernelCompile the callable f for arguments of the (device-side) types tt, for the active device of backend, returning a Kernel. For a higher-level interface, use KernelInterface.@launch.
f is the host-side callable, not converted with argconvert: the backend converts it. For a closure, that matters: it can capture arrays, which its converted form only holds pointers to.
Keyword arguments:
name: override the name that the kernel will have in the generated code.
Other keyword arguments are backend-specific compiler options (e.g. maxthreads for CUDA.jl); backends throw an error for options they don't support.
The returned kernel keeps f alive, but not the arguments: they are passed again at launch.
Backend implementations must implement:
kernel_function(backend::NewBackend, f::F, tt::TT=Tuple{}; name=nothing, kwargs...) where {F,TT}It converts f with argconvert to compile it, and the returned Kernel has to keep f itself alive for as long as it can be launched, since the converted f may only hold pointers to the arrays f captures. A backend that needs to know about those arrays at launch, e.g. to declare them to the device, can convert f again for every launch, as it does for the arguments.
The returned Kernel stores backend itself (not a new default backend), so that options it carries apply to the launch. Kernels must execute with sub-group width sub_group_size(backend) if the backend supports sub-groups.
Launching a kernel after device! switched to a device other than the one it was compiled for must either work, or throw an error: it may never run on the wrong device.
KernelInterface.argconvert — Function
argconvert(backend, arg)Convert arg to its device-side representation, e.g. a CuArray to a CuDeviceArray. Called for every kernel argument, and for the kernel function itself.
It has to be pure: it may be called more than once for the same launch.
KernelInterface.launch — Function
launch(kernel::Kernel, groups::Dims{3}, items::Dims{3}, args::Tuple; kwargs...)Launch kernel with groups work-groups of items work-items each, passing the host-side arguments args, a tuple. This is what calling a Kernel does after validating and normalizing the launch geometry; users call the kernel instead.
groups and items are positive, items fits max_work_group_dims and max_work_group_size(kernel), and groups .* items doesn't overflow Int. kwargs are the keyword arguments of the call that KernelInterface doesn't know.
Backend implementations must implement:
launch(kernel::Kernel{<:NewBackend}, groups::Dims{3}, items::Dims{3}, args::Tuple; kwargs...)It converts args with argconvert (or lets its native launcher do so), and queues the launch on the calling task's queue; it doesn't have to wait for the kernel to complete. To keep launches with many arguments cheap, it should pass args on as a tuple rather than splatting it: Julia doesn't turn a splat of more than 32 elements into a direct call. It must throw for keywords it does not support, and may throw for a number of work-groups the device cannot launch, or for a geometry that backend-specific compiler options of the kernel don't allow.
KernelInterface.@launch — Macro
KI.@launch backend [launch=true] [numgroups=...] [workgroupsize=...] [ndrange=...] [max_work_group_size=...] [kwargs...] f(args...)Compile f(args...) for backend and launch it, like @cuda or @metal do.
f is compiled with kernel_function for the types of the arguments converted with argconvert, and the resulting Kernel is called with the launch keywords numgroups, workgroupsize, ndrange and max_work_group_size, whose meaning is documented there. The arguments are kept alive while the launch is being queued.
Other keyword arguments:
launch: whether to launch the kernel, defaults totrue. Withlaunch=false, the kernel is only compiled and returned, and the launch keywords can't be used: launch it by calling it with the arguments and the launch keywords.nameand any other keyword are passed tokernel_functionas compiler options.
Launch options specific to a backend (such as a CUDA stream) can't be passed to @launch; use launch=false and pass them when calling the kernel.
backend is evaluated once. Returns the Kernel.
function vadd(c, a, b)
i = KI.get_global_id().x
if i <= length(c)
@inbounds c[i] = a[i] + b[i]
end
return
end
KI.@launch backend ndrange=length(c) vadd(c, a, b)Implementing a backend
A backend implements the required methods from the contract, and those optional methods where it can do better than the fallback. In particular:
- Define a backend type subtyping
Backend, and implementget_backendfor its array type. - Extend
Adapt.adapt_storage(::NewBackend, x)so thatadapt(backend, x)moves data to the backend, preferably by delegating to its array type:Adapt.adapt_storage(::NewBackend, x) = adapt(NewArray, x). - Implement
kernel_function, which receives the unconverted callable, and returns aKernelthat holds the backend value it was given and keeps that callable alive. Also implementlaunch, which receives an already validatedNTuple{3, Int}of work-groups and of work-items, and the arguments as a tuple. Pass that tuple on to the native launcher rather than splatting it: Julia doesn't turn a splat of more than 32 elements into a direct call, so kernels with many arguments would be slow to launch. For the PoCL backend, whose kernels hold the compiled kernel and the callable,launchisfunction KI.launch(k::KI.Kernel{POCLBackend}, groups::Dims{3}, items::Dims{3}, args::Tuple) f = k.kern.f GC.@preserve f POCL.launch_and_wait( k.kern.kernel, args; local_size = items, global_size = groups .* items ) return nothing end - Compute the typed index queries with
% T, notT(x): a checked conversion leaves an error branch in every kernel.
The PoCL backend in src/pocl/backend.jl is a complete worked example.
See also the notes for backend implementations.