Backend capabilities¶
Tack compiles one kernel source for five targets, but the targets don't
all offer the same things. Metal has no f64, the CPU has no workgroups,
and atomics are narrower on some GPUs than ordinary arrays. This page is
the contract for those differences. It covers what a backend declares, what
each declaration means, what each one rejects and when, and the subset of
the language that runs everywhere.
All statements describe release candidate 23d6e1d, updated for the
queued launches, disk cache, debug mode and CPU pool merged through
b94af23, and for the device model of bba0846. The declarations live
in packages/tack-core/src/tack/runtime/backend.py (class Backend) and in
the five subclasses in runtime/cpu.py, runtime/metal.py,
runtime/cuda_backend.py, runtime/hip_backend.py and
runtime/level_zero_backend.py. For the kernel semantics that these
capabilities gate, see the
kernel language contract. For the
implementation story, see
Backend Implementations.
The Backend contract¶
Required: every backend subclasses Backend and declares its
capabilities as attributes. Callers read those attributes and don't probe
for methods with hasattr or compare backend class names. Anything that
can be derived is derived, so two declarations can't disagree.
Required methods¶
| Method | Contract |
|---|---|
allocate_array(dtype, shape, exportable=False) |
Allocates backend storage and returns a DeviceBuffer. exportable=True asks for memory that Array.export_memory() can share without a copy. Only CUDA acts on the flag, and the other backends ignore it |
wrap_ptr(ptr, dtype, shape) |
Wraps existing memory as a DeviceBuffer without copying or taking ownership. What counts as ptr is backend-specific. See Pointer interop |
execute(kernel, args, kwargs) |
Compiles or reuses a variant and runs it. All five backends delegate to resolve_variant() in runtime/kernel_utils.py, which is where every capability check below runs |
The base class raises NotImplementedError for all three.
Optional methods with defaults¶
| Method | Default | Overridden by |
|---|---|---|
synchronize() |
Does nothing, which is correct for a backend whose launches complete before execute() returns. Otherwise it waits for every launch, fill and copy queued so far, and a queued launch's error is raised here. tack.sync() calls it on every backend, Device.synchronize() on one |
Metal, CUDA, HIP and Level Zero, which queue launches (see Launches) |
fill_array(array, value) |
Returns False, and Array.fill fills the buffer in place |
CPU, which runs a fill of 256 KiB or more of its own buffers as a kernel through its dispatch, so the threads first-touch the pages |
cache_target() |
Returns None: compiled variants are not stored on disk, only lowered IR. Otherwise a string naming what compiled code depends on besides the kernel (the device, the compiler and driver versions, the compile options) |
all five |
variant_artifact(payload), load_variant(ir_func, artifact, effective_args) |
Raise NotImplementedError. Called only when cache_target() is not None: the picklable part of a compiled variant, and the payload rebuilt from it in another process. effective_args lets a GPU backend remake its scalar pack buffers |
all five |
memory_space(ptr) |
Returns "cpu", which is correct for backends whose allocations are host-addressable |
CUDA, HIP and Level Zero, which ask their driver. CPU overrides it with the same answer |
reduce_array(array, op) |
Raises NotImplementedError. Called only when supports_device_reductions is true |
Metal, CUDA, HIP and Level Zero |
texture_in_hardware(shape_3d) |
Returns False: a Texture3D keeps a private f32 array and the generated code interpolates it in software |
Metal and CUDA (always True), HIP and Level Zero (per device and extent) |
create_texture_image(shape_3d) |
Raises NotImplementedError. Called only when texture_in_hardware() is true. Returns an image object with upload(array), owned by one Texture3D |
Metal, CUDA, HIP and Level Zero |
_store_texture_shapes(ir_func, args) |
Records each Texture3D argument's extents on its parameter |
HIP and Level Zero, which clear the parameter's _is_texture flag when texture_in_hardware() is false |
Attributes¶
| Attribute | Kind | Meaning |
|---|---|---|
name |
declared | Device type, as a device spec writes it (tack.device("cuda")) and Device.type reports it: cpu, metal, cuda, hip or level_zero |
ordinal |
declared | Which of the type's devices the backend drives (Device.index). Always 0 for now |
display_name |
declared | How messages spell the backend, if different from name |
label |
derived | display_name or name. Use it in messages |
supported_dtypes |
declared | A frozenset of the scalar types accepted as array argument dtypes. Dispatch checks every array argument against this set |
supports_f64 |
derived | f64 in supported_dtypes |
supports_device_reductions |
declared | True when Array.sum(), min() and max() can run on the device. When false they run in NumPy on the host. It selects a path and never rejects anything |
supports_workgroups |
declared | Native workgroup execution for shared, shared_like, barrier, thread_id and block_sum/block_min/block_max. It does not promise uniform participation, atomic support or safe partial workgroups |
supported_atomic_dtypes |
derived | Array dtypes that support atomic_add, atomic_min and atomic_max. This is the backend's entry in ATOMIC_DTYPES (lang/atomic_support.py) intersected with supported_dtypes |
device_options |
declared | Keyword options that tack.device() forwards to the constructor. tack.device() rejects any other option |
memory_domain |
derived | What the backend's arrays are valid in, (name, ordinal). Level Zero adds its context handle. A backend runs kernels on another backend's arrays when the domains are equal (two CPU devices with different thread counts); arrays from two domains in one launch are refused |
device_memory_spaces |
declared | memory_space() results that array_from_ptr() accepts. When the set is non-empty, every pointer must be an address (anything int() accepts) and is checked. An empty set means that the backend doesn't distinguish memory spaces and nothing is checked |
dlpack_refusal_note |
declared | Text appended to the error when a DLPack import is refused, for backends whose reason the device type doesn't convey |
Because supported_atomic_dtypes is an intersection, a device without
f64 also loses f64 atomics. That matters on Level Zero, where
supported_dtypes depends on the device.
Capability matrix¶
Read from the backend classes at 23d6e1d. "All ten" means i8, u8,
i16, u16, i32, u32, i64, u64, f32 and f64.
| CPU | Metal | CUDA | HIP | Level Zero | |
|---|---|---|---|---|---|
name / label |
cpu / CPU |
metal / Metal |
cuda / CUDA |
hip / HIP |
level_zero / Level Zero |
supported_dtypes |
All ten | All except f64 |
All ten | All ten | All except f64, plus f64 when the device reports it |
supports_f64 |
yes | no | yes | yes | per device |
supports_device_reductions |
no (NumPy) | yes | yes | yes | yes |
supports_workgroups |
no | yes | yes | yes | yes |
supported_atomic_dtypes |
All ten | i32, u32, f32 |
i32, u32, i64, u64, f32, f64 |
i32, u32, i64, u64, f32, f64 |
i32, u32, f32 |
device_options |
num_threads |
none | none | none | external_context |
device_memory_spaces |
none (unchecked) | none (unchecked) | cuda, cuda_pinned, cuda_managed |
hip, hip_pinned, hip_managed |
level_zero |
dlpack_refusal_note |
none | set | none | none | none |
| 3D textures | software trilinear over a private copy | hardware | hardware | hardware if the device has image support and the extents fit, else software | hardware if the device has samplers and the extents fit, else software |
| Largest launch (iterations) | no limit | 2^32 | max gridDim.x × 256 |
max gridDim.x × 256, capped at 2^32 − 256 |
maxGroupCountX × the workgroup size |
Array.export_memory() |
no | yes (mtl_buffer) |
yes (OS handle) | no | no |
| DLPack export device | kDLCPU |
kDLCPU (host view of unified memory) |
kDLCUDA |
kDLROCM |
kDLOneAPI |
| DLPack zero-copy import | kDLCPU, kDLCUDAHost, kDLROCMHost |
none in practice (see DLPack) | kDLCUDA, kDLCUDAManaged |
kDLROCM |
kDLOneAPI |
| A kernel call returns | after the range has run | once queued | once queued | once queued | once queued (with a driver older than API 1.6, after it completes) |
| Disk-cache artifact | object code | MSL source | PTX | code object | native binary, with SPIR-V to fall back on |
| Devices | the host | MTLCreateSystemDefaultDevice() |
every device: "cuda:N" is ordinal N; bare "cuda" the device of a current context Tack did not make, else 0 |
every device: "hip:N" is device N; bare "hip" is hipGetDevice() |
every device of the first driver: "level_zero:N" is the Nth; or the one in external_context |
| Python requirements | llvmlite ([cpu] extra) |
pyobjc-framework-Metal ([metal]) |
cuda-python>=13.2.0 ([cuda]) |
hip-python>=7.0, Linux x86_64 only ([hip]) |
none. The [level_zero] extra is empty |
| System requirements | none | macOS with a Metal GPU | NVIDIA driver and NVRTC (13 or newer; older warns at the first compile) | ROCm with hipRTC | libze_loader.so and libocloc.so (Intel compute runtime) |
Level Zero and f64¶
Current behavior: LevelZeroBackend._query_device() runs during
construction and calls zeDeviceGetModuleProperties. It then shadows the
class attribute with an instance frozenset: all ten types when the
reported fp64flags is nonzero, else the nine non-f64 types. supports_f64 and
supported_atomic_dtypes follow automatically. To find out what the
current device supports, ask:
import tack
from tack.runtime.dispatch import get_backend
tack.set_default_device("level_zero")
be = get_backend()
print(be.supports_f64, sorted(t.name for t in be.supported_atomic_dtypes))
Textures¶
On every backend, a texture samples the data its array held when
tack.texture3d() created it, or when tex.update() last copied it (see
Memory and aliasing
in the language contract). Writes to the array in between, whether by kernels,
tack.copyto, or external and DLPack writers, don't reach the texture.
The copy is storage the Texture3D owns (tex._storage): a hardware image
from create_texture_image() where texture_in_hardware() is true, else a
private f32 array. Dispatch binds that storage through bind_textures()
(runtime/kernel_utils.py), never the source array. No compiled variant
caches images, and an image is released with its texture. Two textures over
one array are separate copies.
tack.texture3d() raises ValueError for an array that isn't f32,
because every hardware path builds a single-channel 32-bit float image
(R32Float on Metal, CU_AD_FORMAT_FLOAT on CUDA and HIP,
ZE_IMAGE_FORMAT_TYPE_FLOAT on Level Zero). It also rejects an interp
other than "linear", since sampling is trilinear on every backend, and a
shape whose W*H*D differs from the array's size.
The variant key records which texture path a call uses, because the path
changes the generated code. HIP and Level Zero answer
texture_in_hardware() per device and extent, and their
_store_texture_shapes() asks it again before the key is built, so a
texture always holds the storage its compiled kernel binds:
- HIP uses hardware sampling when
hipDeviceAttributeImageSupportis nonzero and every extent is within the smallest reported maximum 3D texture extent. If any reported maximum is non-positive, the extents are treated as unbounded. CDNA parts such as MI300 report no image support, so they sample in software. - Level Zero uses hardware sampling when the device reports
maxSamplers > 0and every extent is at mostmaxImageDims3D. Xe-HPC (Ponte Vecchio) reports no samplers.
Metal and CUDA always answer true. CPU uses the base class's false.
Launches¶
Current behavior: the CPU's kernel call returns once the range has run.
Metal, CUDA, HIP and Level Zero queue the launch and return: Metal into an
open command buffer, CUDA on one stream per context, HIP on one stream per
device, Level Zero on one asynchronous immediate command list with a
barrier after each command (a driver without zeCommandListHostSynchronize
gets a synchronous list). On every backend, launches, fills and host
writes take effect in the order they were made, host reads wait for the
launches before them, an array dropped while a queued launch uses it
outlives that launch, and a launch that binds memory handed outside Tack
(a wrapped pointer, a DLPack export) completes before it returns. CUDA,
HIP and Level Zero also complete a launch that samples a hardware texture.
A queued launch's error is raised at the next wait. The user-facing rules
are in Runtime API; the implementation
is in Backend Implementations.
What each capability gates¶
All dispatch-time checks run inside resolve_variant(). "Cold" means the
first call with a new variant key, before optimization, code generation and
compilation. "Every dispatch" also covers cached calls, and those checks run
before launch.
| Capability | Checked by | When | Rejection |
|---|---|---|---|
device_options |
tack.device() in runtime/dispatch.py |
When a device is made with options, after the backend module imports and before construction | ValueError: "The 'metal' device does not accept the option(s) num_threads. Accepted: none." The default device is unchanged |
supported_dtypes |
check_dispatch_types() in lang/type_inference.py |
Cold dispatch and inspection. A new dtype always produces a new key | TypeError naming the kernel once: "Kernel 'k': parameter 'data' has dtype tack.f64, which is not supported on Metal. Supported dtypes: ..." |
supports_workgroups |
check_workgroup_support() in lang/workgroup_support.py |
Every dispatch, before the key lookup. The feature scan is memoized on the IR template. Also runs during inspection and direct LLVM generation | NotImplementedError naming the kernel, backend and primitives, with the hint "use local_array or local_array_like". Dispatch wraps it in RuntimeError ("Kernel 'k' failed on CPUBackend: ..."). Inspection raises it directly |
| Workgroup participation | check_workgroup_participation() in lang/workgroup_participation.py |
Cold dispatch and inspection, on backends with supports_workgroups |
ValueError: "Kernel 'k': barrier at <IR path> requires uniform workgroup participation: ..." |
| Full 256-lane groups | check_workgroup_launch() |
Cold dispatch, every cached dispatch, and inspection, for kernels with barriers or block reductions | ValueError: "...workgroup collectives require complete 256-lane groups; iteration count N would create a partial workgroup." |
| Device group size | CompiledMetalKernel and CompiledL0Kernel |
At compilation, for collective kernels, when the Metal pipeline or Level Zero device allows fewer than 256 lanes | ValueError: "...workgroup collectives require 256 lanes; this pipeline/device selects n." |
supported_atomic_dtypes |
check_atomic_support() in lang/atomic_support.py |
Cold dispatch and inspection in every mode, including structurally unreachable atomics and empty grids | TypeError: "Kernel 'k': metal atomic_add does not support target dtype tack.i64". The same check rejects targets that aren't global array parameters |
| Atomic alignment | check_atomic_alignment() |
Every dispatch and inspection | ValueError: "Kernel 'k': atomic target parameter i requires n-byte alignment" |
| Read-only arrays | check_writable_arrays() in runtime/kernel_utils.py, over the indices the variant recorded in KernelVariant.written_arrays |
Every dispatch | ValueError: "Kernel 'k': parameter 'out' may be written, but its array is read-only. ..." |
| Launch size | check_launch_size() in runtime/kernel_utils.py, against the limit in the capability matrix |
Every GPU dispatch, and every native f32 reduction on CUDA, HIP and Level Zero |
ValueError: "Kernel 'k': N iterations exceed the M that one CUDA launch can index; split the work across several launches." A reduction is named "Array sum()" and so on. Metal reduces arrays of 2^32 or more elements through NumPy instead |
device_memory_spaces |
array_from_ptr() in lang/array.py |
When wrapping, for any ptr, on backends with a non-empty set |
TypeError if ptr isn't an address. ValueError: "Pointer is in 'cpu' memory but the device is 'CUDA'. array_from_ptr() requires a device pointer. ..." |
| DLPack device type | dlpack_to_array() in lang/dlpack.py |
On import, when device= names a device that cannot wrap the tensor (without it, the tensor's own device is used) |
RuntimeError naming the device and DLPack device type, followed by dlpack_refusal_note if set. ValueError for an unknown device type and for kDLMetal |
supports_device_reductions |
Array._reduce() |
Never rejects | Selects the device or NumPy path. See Reductions |
| Texture hardware | texture_in_hardware(), at texture3d() and in _store_texture_shapes() (HIP, Level Zero) |
At texture creation, and before the key is built | Never rejects. Falls back to software sampling |
A TypeError raised during dispatch reaches the caller prefixed with
Kernel '<name>': unless its message already names the kernel, and with
the original exception chained. ValueError passes through unchanged. See
Exceptions.
For the full text of the workgroup and atomic rules, see Workgroups and synchronization and Atomic array updates.
What inspection does not check
tack.inspect() runs the dtype, workgroup, participation,
workgroup-launch, atomic and alignment checks above, and it makes the
texture decision through the backend's _store_texture_shapes(), so
the source it shows is the source dispatch would compile. It doesn't
check read-only arrays or the backend's launch-size limit, which
belong to a launch rather than to the generated code.
Device reductions¶
supports_device_reductions does not mean that every reduction runs on the
device. Current behavior: on every GPU backend, reduce_array() reduces
only f32 arrays natively, and other dtypes use the shared NumPy fallback.
Level Zero also falls back when the device's maximum group size is below
256. Empty arrays return without a device launch. CUDA, HIP and Level Zero
pass the element count as a 64-bit kernel argument and reject an array
larger than one launch can index. Metal's reduction kernel holds the count
and thread position in 32 bits, so Metal reduces an array of 2^32 or more
elements with NumPy over the shared buffer. The
reduction contract
defines results, precision and ordering for both paths.
Backend notes¶
CPU. There is no workgroup model, and the CPU doesn't emulate one.
tack.device("cpu", num_threads=n) makes a CPU device with that worker count;
it shares memory with the other CPU devices.
Kernels that use shared memory, barriers, thread_id or block reductions
are rejected even when the primitive is unreachable. For private scratch
storage, use local_array or local_array_like. Parallelism comes from a
thread pool JIT'd from LLVM IR, or a ThreadPoolExecutor with
TACK_CPU_POOL=python, on Windows, or where the pool cannot be made. See
CPU Threading Policy. The CPU
backend also compiles a separate noalias variant for calls whose written
arrays provably don't overlap other arrays. See
Memory and Aliasing.
Metal. There is no f64, because Apple GPUs don't implement double
precision. Arrays are shared buffers in unified memory, so memory_space()
answers "cpu" for them and array_from_ptr() wraps an MTLBuffer
object, not an address. Collectives need a pipeline whose
maxTotalThreadsPerThreadgroup is at least 256.
CUDA. If a CUDA context is already current on the device, for example
one that a simulation framework created, the backend adopts it and never
destroys it. Otherwise it creates one for the device. With a second device
(or a context not current where it was made), every operation makes its
context current for its duration. exportable=True allocates through
the CUDA virtual memory management API, so export_memory() can hand out
an OS handle without a copy.
HIP. The code generator is the CUDA generator with a different include
and texture handle type. hipRTC compiles with -ffp-contract=on, so
contraction stays inside one expression. Texture hardware is queried at
initialization (see Textures).
Level Zero. f64 depends on the device (see
Level Zero and f64). external_context takes a
mapping with non-null driver, device and context handles, so that
Tack allocates in a context that another library owns. A missing or null
handle raises ValueError. For VTK, call
tack.interop.vtk.init_level_zero() instead of
tack.set_default_device("level_zero"). See
Interoperability.
Portable subset¶
A program stays within every backend's capabilities if it keeps to the following rules. Everything else in the kernel language contract still applies.
| Area | Portable on all five backends |
|---|---|
| Array and scalar dtypes | i8, u8, i16, u16, i32, u32, i64, u64, f32. Don't use f64, which Metal and some Level Zero devices lack. A Python float scalar becomes f64 only when an array argument is f64 |
| Atomics | atomic_add, atomic_min and atomic_max on i32, u32 or f32 global arrays, as statements |
| Launch size | At most 2^32 − 256 iterations per launch, HIP's cap, unless the CUDA or Level Zero device reports a lower limit. Split larger work across launches |
| Workgroups | None. If you don't need the CPU, collectives are portable across the four GPU backends in complete 256-lane groups with proven-uniform participation |
| Scratch storage | local_array and local_array_like |
| Textures | f32 sources, refreshed with tex.update() after the array changes |
| Reductions | Array.sum(), min(), max() and mean() on any dtype. Only f32 is reduced natively on the device |
| Interop | Backend-specific by nature. DLPack device types and pointer kinds differ per backend |
To keep code portable, branch on declared capabilities rather than on
backend names, as the test fixtures in conftest.py do:
from tack.runtime.dispatch import get_backend
be = get_backend()
dtype = tack.f64 if be.supports_f64 else tack.f32
use_block_sum = be.supports_workgroups
use_atomic_i64 = tack.i64 in be.supported_atomic_dtypes
test_backend_contract.py checks that the declarations match behavior. For
example, it checks that f64 dispatch agrees with supports_f64, that
reductions agree with supports_device_reductions, and that the memory
spaces are self-consistent. See Conformance.