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 = KernelInterface

KernelAbstractions 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.

KernelAbstractions 0.10

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.

Note

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.

source

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 with device!) 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. synchronize waits for that queue, and record_event/wait_event order 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 x is 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.

RequiredOptional (fallback)
Backendsubtype Backend; get_backend for its array type
Memoryallocate, copyto!allocate(...; unified=true) (throws), pagelock! (missing), unsafe_free! (no-op)
Executionsynchronize (cooperative)record_event/wait_event (synchronize), priority! (no-op)
Deviceswith more than one device: ndevices, device, device!, device(backend, A)all four (a single device)
Queriesmax_work_group_size (for the backend and for a kernel), max_work_group_dims, max_num_groupslaunch_configuration (the limit), multiprocessor_count (0), functional (missing), versioninfo
Capabilitiessupports_float64, supports_atomics, supports_unified, supports_subgroups, supports_shuffle (all false)
Compilationargconvert, kernel_function, launch
Deviceget_local_id, get_group_id, get_local_size, get_num_groups, localmemory, barrierget_global_id, get_global_size (derived from the primitive queries), _print (host print)
Sub-groupsif 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
Backend

Abstract 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)
source
KernelInterface.get_backend — Function
get_backend(A::AbstractArray)::Backend

Get a Backend instance suitable for array A.

Note

Backend implementations must provide get_backend for their custom array type. It should be the same as the return type of allocate

source

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.

Note

Backend implementations must implement:

@device_override get_local_id(::Type{T})::@NamedTuple{x::T, y::T, z::T} where {T}

The zero-argument form forwards to get_local_id(Int).

source
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.

Note

Backend implementations must implement:

@device_override get_group_id(::Type{T})::@NamedTuple{x::T, y::T, z::T} where {T}

The zero-argument form forwards to get_group_id(Int).

source
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.

Note

Backend implementations must implement:

@device_override get_local_size(::Type{T})::@NamedTuple{x::T, y::T, z::T} where {T}

The zero-argument form forwards to get_local_size(Int).

source
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.

Note

Backend implementations must implement:

@device_override get_num_groups(::Type{T})::@NamedTuple{x::T, y::T, z::T} where {T}

The zero-argument form forwards to get_num_groups(Int).

source
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.

Note

The fallback derives this from the primitive queries. Backend implementations with a native builtin should override it, returning the same values:

@device_override get_global_id(::Type{T})::@NamedTuple{x::T, y::T, z::T} where {T}
source
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.

Note

The fallback derives this from the primitive queries. Backend implementations with a native builtin should override it, returning the same values:

@device_override get_global_size(::Type{T})::@NamedTuple{x::T, y::T, z::T} where {T}
source

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 are 1: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])::T

The 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.

Note

Backend implementations that support sub-groups must implement:

@device_override get_sub_group_size(::Type{T})::T where {T}

The zero-argument form forwards to get_sub_group_size(Int).

source
KernelInterface.get_num_sub_groups — Function
get_num_sub_groups([::Type{T}=Int])::T

The 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.

Note

Backend implementations that support sub-groups must implement:

@device_override get_num_sub_groups(::Type{T})::T where {T}

The zero-argument form forwards to get_num_sub_groups(Int).

source
KernelInterface.get_sub_group_id — Function
get_sub_group_id([::Type{T}=Int])::T

The 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.

Note

Backend implementations that support sub-groups must implement:

@device_override get_sub_group_id(::Type{T})::T where {T}

The zero-argument form forwards to get_sub_group_id(Int).

source
KernelInterface.get_sub_group_local_id — Function
get_sub_group_local_id([::Type{T}=Int])::T

The 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.

Note

Backend implementations that support sub-groups must implement:

@device_override get_sub_group_local_id(::Type{T})::T where {T}

The zero-argument form forwards to get_sub_group_local_id(Int).

source

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).

Note

Backend implementations must implement:

@device_override barrier()
source
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().

Note

Backend implementations that support sub-groups must implement:

@device_override sub_group_barrier()
source

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.

Note

Backend implementations must implement:

@device_override localmemory(::Type{T}, ::Val{Dims}) where {T, Dims}
source

Communication

KernelInterface.shfl_down — Function
shfl_down(val::T, offset::Integer)::T

Return 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.

Note

Backend implementations must implement this for every T for which supports_shuffle returns true:

@device_override shfl_down(val::T, offset::Integer) where T
source

Printing

KernelInterface._print — Function
_print(args...)

Print args from a kernel; the backend hook behind KernelAbstractions.@print.

Note

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.

source

_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)::AbstractArray

Allocate 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.

Note

Backend implementations must implement allocate(::NewBackend, T, dims::Tuple) Backend implementations should implement allocate(::NewBackend, T, dims::Tuple; unified::Bool=false)

source
KernelInterface.zeros — Function
zeros(::Backend, Type, dims...; unified=false)::AbstractArray

Allocate an array with allocate and fill it with zeros.

This is generic: backends implement allocate (and fill! for their array type).

source
KernelInterface.ones — Function
ones(::Backend, Type, dims...; unified=false)::AbstractArray

Allocate an array with allocate and fill it with ones.

This is generic: backends implement allocate (and fill! for their array type).

source
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!.

Warning

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)
end
Note

On some backends it may be necessary to first call pagelock! on host memory to enable fully asynchronous behavior w.r.t to the host.

Note

Backends must implement this function, for host-to-device, device-to-host and device-to-device copies.

source
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.

Note

Backends may implement this function.

source
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.

Note

Backend implementations may implement this function for their array type, and should forward it to their own unsafe_free! if they have one. The fallback is a no-op.

source

Execution

KernelInterface.synchronize — Function
synchronize(::Backend)

Block the calling task until all work it has queued on the active device of backend has completed.

Note

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.

source
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.

Note

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.

source
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 it
Note

wait_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.

source
KernelInterface.priority! — Function
priority!(::Backend, prio::Symbol)::Nothing

Set 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.

Note

Backend implementations may implement this function.

source

Device management

KernelInterface.device — Function
device(backend::Backend)::Int

Return the 1-based index of the currently active device for backend.

Note

Backends supporting multiple devices must implement device(backend::Backend)::Int, along with ndevices, device! and device(backend, A). The fallback only works for a single device, and throws if ndevices reports more.

source
device(backend::Backend, A::AbstractArray)::Int

Return the 1-based index of the device that owns the memory of A, independently of the currently active device.

Note

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.

source
KernelInterface.ndevices — Function
ndevices(backend::Backend)::Int

Return the number of devices available to backend.

Note

Backends supporting multiple devices must implement ndevices(backend::Backend)::Int, along with device and device!. The fallback returns 1.

source
KernelInterface.device! — Function
device!(backend::Backend, id::Int)::Nothing

Select 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 device
Note

Backends supporting multiple devices must implement device!(backend::Backend, id::Int), along with ndevices and device. The fallback only works for a single device, and throws if ndevices reports more.

source

Capability 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.

KernelAbstractions v0.9.22

This function was added in KernelAbstractions v0.9.22

source
KernelInterface.versioninfo — Function
versioninfo(io::IO=stdout, backend::Backend)::Nothing

Print information about backend to io. It is up to the backends to determine what is relevant.

Note

Backend implementations may implement this function. If they do so, they should implement versioninfo(io::IO, ::Backend)::Nothing

source
KernelInterface.supports_unified — Function
supports_unified(::Backend)::Bool

Whether allocate supports unified=true on the active device: memory that can be accessed from both the host and the device without explicit copies.

Note

Backend implementations must implement this function if they support unified memory. The fallback returns false.

source
KernelInterface.supports_atomics — Function
supports_atomics(::Backend)::Bool

Whether 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.

Note

Backend implementations must implement this function if they support atomics. The fallback returns false.

source
KernelInterface.supports_float64 — Function
supports_float64(::Backend)::Bool

Whether kernels on the active device support Float64 values.

Note

Backend implementations must implement this function if they support Float64. The fallback returns false.

source
KernelInterface.supports_subgroups — Function
supports_subgroups(::Backend)::Bool

Whether 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.

Note

Backend implementations must implement this function if they support sub-groups. The fallback returns false.

source
KernelInterface.supports_shuffle — Function
supports_shuffle(::Backend, ::Type{T})::Bool

Whether kernels on the active device support shfl_down for values of type T.

Note

Backend implementations must implement this function for the types they support. The fallback returns false.

source

Limits

KernelInterface.max_work_group_size — Function
max_work_group_size(backend)::Int
max_work_group_size(kernel::Kernel)::Int

The 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.

Note

Backend implementations must implement both:

max_work_group_size(backend::NewBackend)::Int
max_work_group_size(kernel::Kernel{<:NewBackend})::Int

The kernel form answers for the device the kernel was compiled for.

source
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.

Note

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.

source
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.

Note

Backend implementations must implement:

max_work_group_dims(backend::NewBackend)::NTuple{3, Int}
source
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.

Note

Backend implementations must implement:

max_num_groups(backend::NewBackend)::NTuple{3, Int}
source
KernelInterface.sub_group_size — Function
sub_group_size(backend)::Int

The 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.

Note

Backend implementations must implement this if supports_subgroups returns true:

sub_group_size(backend::NewBackend)::Int

A backend that cannot guarantee the width for every kernel has to report supports_subgroups(backend) = false.

source
KernelInterface.multiprocessor_count — Function
multiprocessor_count(backend)::Int

The 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.

Note

Backend implementations may implement:

multiprocessor_count(backend::NewBackend)::Int
source

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:

  • numgroups and workgroupsize: launch exactly that many work-groups of that many work-items. Either defaults to 1.
  • ndrange and workgroupsize: launch cld.(ndrange, workgroupsize) work-groups.
  • ndrange alone: the work-group size is chosen with launch_configuration, bounded by max_work_group_size and by max_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.

source
KernelInterface.kernel_function — Function
kernel_function(backend, f::F, tt::TT=Tuple{}; name=nothing, kwargs...)::Kernel

Compile 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.

Note

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.

source
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.

Note

Backend implementations must implement:

argconvert(::NewBackend, arg)
source
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.

Note

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 0.4

Before KernelInterface 0.4, launch received the arguments as varargs, launch(kernel, groups, items, args...; kwargs...).

source
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 to true. With launch=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.
  • name and any other keyword are passed to kernel_function as 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)
source

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:

  1. Define a backend type subtyping Backend, and implement get_backend for its array type.
  2. Extend Adapt.adapt_storage(::NewBackend, x) so that adapt(backend, x) moves data to the backend, preferably by delegating to its array type: Adapt.adapt_storage(::NewBackend, x) = adapt(NewArray, x).
  3. Implement kernel_function, which receives the unconverted callable, and returns a Kernel that holds the backend value it was given and keeps that callable alive. Also implement launch, which receives an already validated NTuple{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, launch is
    function 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
  4. Compute the typed index queries with % T, not T(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.