Backend Implementations¶
Every backend turns the same typed, optimized Tack IR into native code and runs it. What differs is the toolchain, how a device and context are chosen, how arguments reach the kernel, and which hardware features exist. This page walks through each backend in the order a kernel meets it — initialization, compilation, launch — and closes with the workarounds each one carries and why.
The shared front half — resolve_variant() in
packages/tack-core/src/tack/runtime/kernel_utils.py, which turns a call
into a cached compiled variant — is described in Compilation
Pipeline and Specialization and
Caching. Each backend's execute() calls it
with a build callback, and that callback is where this page begins.
The Backend contract¶
All five backends subclass Backend (runtime/backend.py); the four GPU
backends do so through GPUBackend (runtime/gpu_backend.py), which turns a
call into a queued launch the same way for all of them and leaves each the
hooks where they differ: how a kernel is compiled and launched, which lock a
launch holds, how its packed scalars are owned, and whether it must complete
before returning. Their code generators likewise share CFamilyCodeGen
(codegen/c_family.py). It declares
three required methods — allocate_array(), wrap_ptr() and execute() —
and a set of capability attributes that callers read instead of probing
with hasattr. Optional methods have defaults that suit a backend whose
launches complete before execute() returns: synchronize() waits for
every launch queued so far and is a no-op by default (tack.sync() calls
it; see Launches are queued); fill_array()
returns false, leaving Array.fill to the buffer (the CPU fills large
arrays through its own dispatch); and cache_target() returns None,
which keeps a backend's compiled variants off the disk cache, while
variant_artifact() and load_variant() store and rebuild them (see
Specialization and Caching).
DeviceBuffer likewise has synchronize(), a wait for the launches
queued on the buffer's device, and share(), which hands the storage
outside Tack: it waits now, and makes every later launch that uses the
buffer complete before returning. Anything derivable is derived: supports_f64 is a property
over supported_dtypes, and supported_atomic_dtypes is a property that
intersects the per-target table ATOMIC_DTYPES (lang/atomic_support.py)
with supported_dtypes, so neither can disagree with the array types.
| Attribute | CPU | Metal | CUDA | HIP | Level Zero |
|---|---|---|---|---|---|
name |
cpu |
metal |
cuda |
hip |
level_zero |
display_name |
CPU | Metal | CUDA | HIP | Level Zero |
supported_dtypes |
all ten | all but f64 |
all ten | all ten | nine, plus f64 if the device reports it |
supports_f64 (derived) |
yes | no | yes | yes | per device |
supports_device_reductions |
no | yes | yes | yes | yes |
supports_workgroups |
no | yes | yes | yes | yes |
supported_atomic_dtypes (derived) |
all ten | i32, u32, f32 |
i32, u32, i64, u64, f32, f64 |
as CUDA | i32, u32, f32 |
device_memory_spaces |
— | — | cuda, cuda_pinned, cuda_managed |
hip, hip_pinned, hip_managed |
level_zero |
device_options |
num_threads |
— | — | — | external_context |
dlpack_refusal_note |
— | set | — | — | — |
"All ten" is i8, u8, i16, u16, i32, u32, i64, u64, f32,
f64. supported_dtypes is a frozenset on every backend. device_options lists the keyword arguments tack.device() forwards to
the backend's constructor; tack.device() (runtime/dispatch.py) raises
ValueError for any other keyword before constructing anything, so a
misspelled option cannot be silently dropped. The user-facing summary of
these capabilities is in Backends; the
normative statements are in Backend
Capabilities.
Comparison¶
| CPU | Metal | CUDA | HIP | Level Zero | |
|---|---|---|---|---|---|
| Codegen | llvm_gen.py → LLVM IR |
msl_gen.py → MSL |
cuda_gen.py → CUDA C |
hip_gen.py → HIP C |
opencl_gen.py → OpenCL C |
| Compiler | llvmlite, O3 pipeline, MCJIT | newLibraryWithSource |
NVRTC → PTX | hipRTC → code object | libocloc → SPIR-V → zeModuleCreate |
| Loaded with | create_mcjit_compiler |
newComputePipelineState |
cuModuleLoadData |
hipModuleLoadData |
zeModuleCreate + zeKernelCreate |
| Launch | ctypes call over [start, end) chunks |
dispatchThreads |
cuLaunchKernel |
hipModuleLaunchKernel |
zeCommandListAppendLaunchKernel |
| Group size | — (thread pool) | min(pipeline max, 256) |
256 | 256 | min(256, maxGroupSizeX, maxTotalGroupSize) |
| Array binding | typed pointer argument, passed as an address | pointer in one argument buffer | pointer argument | pointer argument | __global pointer argument |
| Scalar binding | by value | packed, into a new buffer per launch | packed into the variant's pack arrays | as CUDA | as CUDA |
| Launch returns | after the range has run | once encoded into the open command buffer | once queued on the context's stream | once queued on the device's stream | once appended to the asynchronous immediate command list |
| Host waits with | — | waitUntilCompleted on each committed command buffer |
cuStreamSynchronize |
hipStreamSynchronize |
zeCommandListHostSynchronize |
| Disk-cache artifact | object code | MSL source | PTX | code object | native binary and SPIR-V |
| 3D textures | software, in LLVM | hardware | hardware | hardware, software without image support | hardware, software without samplers |
Array.sum/min/max |
NumPy | device kernel for f32 |
device kernel for f32 |
device kernel for f32 |
device kernel for f32 |
CPU — LLVM through llvmlite¶
Initialization. CPUBackend.__init__ (runtime/cpu.py) chooses the
thread count from TACK_CPU_THREADS, or else _physical_core_count(),
which asks each platform for physical cores rather than logical processors.
It creates the variant cache and, at the first fan-out, reaches the
process's thread pool: the pool JIT'd from LLVM IR in cpu_pool.py, or a
ThreadPoolExecutor with TACK_CPU_POOL=python, on Windows, or where the
pool cannot be made (CPU Threading Policy).
With TACK_CPU_MALLOC=1 on Linux it also tells glibc to keep freed pages.
There is no device to select. allocate_array returns uninitialized
NumPy storage, first touched by the kernel that writes it.
Compilation. _compile_kernel generates an llvmlite.ir module with
generate_llvm_ir, parses and verifies it with llvm.parse_assembly and
mod.verify(), then optimizes and JIT-compiles:
_create_target_machine()targets the default triple withllvm.get_host_cpu_name(), the host's feature string, andopt=3._optimize_module()runs the new pass manager's O3 pipeline (create_pipeline_tuning_options(3)) with loop vectorization, SLP vectorization, loop unrolling and loop interleaving enabled.- The JIT is
llvm.create_mcjit_compilerwith a second target machine, because MCJIT takes ownership of the one it is given.
No fast-math flags are emitted, so floating-point semantics follow
Numerical Semantics. The entry point is looked up
by kernel_entry_name() (codegen/identifiers.py), which prefixes
tack_kernel_ and encodes the name, because llvmlite's symbol lookup needs
ASCII and a kernel must not collide with a libm function.
Calling convention. The generated function is
void tack_kernel_<name>(T0* array0, T1 scalar1, ..., int64 __loop_start__, int64 __loop_end__)
Arrays are pointers, scalars are passed by value, and the parallel loop runs
over [__loop_start__, __loop_end__). CompiledKernel builds a
ctypes.CFUNCTYPE for this signature once, with c_void_p for each array.
bind() marshals the array addresses (NumpyBuffer.address, computed once
per array) and scalars once per dispatch; call_range() then runs each
chunk with its own bounds, because chunks run concurrently. The JIT'd pool
does not call through ctypes at all: cpu_pool.chunk_wrapper JITs, once per
compiled kernel and under a lock, a __tack_chunk(pack, start, end) that
loads the arguments from 8-byte slots (pack_args) and calls the kernel at
its address.
Disk cache. set_object_cache captures the object code MCJIT emits, and
variant_artifact stores it; load_variant adds it to a new engine with
add_object_file, which takes about 0.4 ms and runs neither LLVM's
optimizer nor its code generator.
Launch. Whether a dispatch runs serially or fans out across the pool is
decided by measurement, not a fixed threshold; CPU Threading
Policy describes it in full. A second compiled variant
may exist per specialization, with noalias array pointers, for calls whose
arrays are proven not to overlap — see Memory and
Aliasing.
Features. supports_workgroups is false, so kernels using shared
memory, barriers, local thread IDs or block reductions are rejected before
compilation. Textures are sampled in software: LLVMCodeGen emits the
trilinear interpolation inline over the texture's private copy of its
array, with the extent baked in as constants.
Reductions use NumPy on the host — the data is already there. Loop indices
are 64-bit.
Metal¶
Initialization. MetalBackend.__init__ (runtime/metal.py) raises
ImportError if pyobjc-framework-Metal is missing, takes
MTLCreateSystemDefaultDevice() (RuntimeError if there is none), and
creates one command queue. There are no options.
Compilation. _compile_kernel generates MSL with
generate_msl_source, compiles it with newLibraryWithSource_options_error_
using an MTLCompileOptions whose fastMathEnabled is false, looks up the
entry function, and builds a compute pipeline state. Any of the three steps
failing raises RuntimeError; a library failure includes the MSL source.
Setting TACK_DUMP_MSL (a boolean flag read by dispatch.env_flag, so 0,
false, no and off leave it off) writes the source to
/tmp/tack_<entry>.msl.
Arguments. Array pointers go into one argument buffer — the mechanism
that makes overlapping arrays legal in MSL, explained in Memory and
Aliasing.
f64 never reaches the generator in normal dispatch, because it is not in
supported_dtypes; MSLCodeGen also raises TypeError for an f64
parameter on its own, since Apple GPUs do not implement double precision.
Launch. The threadgroup width is
min(pipeline.maxTotalThreadsPerThreadgroup(), 256). The grid is exactly
loop_end threads (dispatchThreads_threadsPerThreadgroup_), so the last
threadgroup may be partial and the kernel needs no bounds guard. Each
dispatch encodes a compute pass into the backend's open command buffer
(_open_batch), under the backend's batch lock, and returns. The buffer is
committed every _BATCH (64) launches, with at most _IN_FLIGHT (4)
committed ones outstanding; synchronize() commits the open one and waits
for all of them, and a command-buffer error raises RuntimeError there,
at the next host access, rather than at the launch's call. Each launch
owns what it reads: a fresh argument buffer, its packed scalars in a buffer
of their own (_Uniforms), and references to its arrays until it
completes. A launch that binds memory handed outside Tack (_shared: an
exported or wrapped buffer) completes before returning. An
empty range returns before encoding anything. The loop index is declared
long but initialized from [[thread_position_in_grid]], which Metal
types as uint, so one dispatch covers at most 2³² iterations;
check_launch_size() refuses a longer one against _MAX_LAUNCH with
ValueError before encoding.
CUDA¶
Initialization. CUDABackend.__init__ (runtime/cuda_backend.py) calls
cuInit(0) and cuDeviceGet(0). If a context is already current on the
calling thread — an embedding application such as an AMReX simulation made
one — Tack adopts it. Otherwise it retains the device's primary context
(cuDevicePrimaryCtxRetain), which the CUDA runtime and the libraries on it
(PyTorch, CuPy, Warp) use: until 2026-10 Tack created its own with
cuCtxCreate, and its next launch after one of those libraries made the
primary context current failed with CUDA_ERROR_INVALID_HANDLE (found
comparing with Warp). Either way the context gets a _ContextToken that
counts the backends sharing it and records whether Tack owns it; when an
owned context's last backend goes away, a primary context is released
(others may hold it) and any other destroyed, and buffers allocated in it
are marked dead first.
Memory and Aliasing
explains why.
One thread's context
The context is current on the thread that made the device (the first
tack.device("cuda"), whichever call made it). The
launch path does not make it current on other threads, so a thread that
dispatches must first call cuCtxSetCurrent with the backend's
context, or its launch fails with CUDA_ERROR_INVALID_CONTEXT.
Compilation. _compile_ptx compiles with NVRTC using exactly these
options, the module constant _NVRTC_OPTIONS, which the disk cache's
target also names:
| Option | Why |
|---|---|
--ftz=false |
keep denormals rather than flushing to zero |
--prec-div=true |
IEEE-rounded division |
--prec-sqrt=true |
IEEE-rounded square root |
--fmad=true |
allow multiply-add contraction, which the contract permits; the generator emits a product outside a sum as __fmul_rn/__dmul_rn, since NVRTC also fuses across statements |
--extra-device-vectorization |
let NVRTC vectorize more aggressively |
--use_fast_math is never passed. No target architecture is given, so
NVRTC emits PTX for its default virtual architecture and the driver
JIT-compiles that PTX in cuModuleLoadData. A compile failure raises
RuntimeError with the NVRTC log and the source; the program is destroyed
on both paths.
The first compile in a process checks nvrtcVersion and warns if NVRTC is
older than 13 (runtime/nvrtc_version.py). NVRTC 12.0 miscompiled
tack-examples' lattice Boltzmann example on an H100 (density off by up to
1.6e-2; 2.3e-6 with 13.2), and was not reduced to a kernel, so there is no
workaround to apply. It also left the compiling thread in the C locale,
which _compile_keeping_thread_locale undoes. The check runs at the first
compile rather than when the device is made, so a machine without NVRTC fails where
it did before.
Launch. Block size is WORKGROUP_SIZE (256, from
lang/workgroup_participation.py) and the grid is
(loop_end + 255) // 256 blocks. The kernel receives the loop end as a
long long __n__ parameter and begins
long long i = (long long)blockIdx.x * blockDim.x + threadIdx.x;
if (i >= __n__) return;
Locals and loop indices are long long. The built-ins are 32-bit
unsigned, so blockIdx.x is widened before the multiply; a 32-bit
product would wrap once a launch reached 2³² threads. At initialization the
backend reads CU_DEVICE_ATTRIBUTE_MAX_GRID_DIM_X, and a launch of more
than that many blocks of 256 is refused by check_launch_size() with
ValueError. Arguments are passed as a ctypes array of pointers to argument
values, and the launch is queued on the context's stream (_ContextToken
makes one per context, with cuStreamCreate, on first use) and returns. An
empty range returns before launching, since cuLaunchKernel rejects an
empty grid. Buffers are stream-ordered: cuMemAllocAsync and
cuMemFreeAsync on that stream, from the device's default memory pool
with its release threshold raised, so freed memory is handed back out
without a device round trip, and an array dropped while a queued launch
still uses it is freed after that launch. A device without memory pools
(a GPU in TCC mode on Windows) allocates with cuMemAlloc instead; since
cuMemFree waits for the whole device, a dropped buffer is queued on its
context and freed once the stream is idle, at the next allocation or sync
(_free_dropped). from_numpy, fill and the
allocation's zeroing are queued in order with the launches; to_numpy and
read_range queue their copy and wait with cuStreamSynchronize.
Disk cache. The PTX NVRTC made is stored with the entry name, the
parameter layout and the scalar pack layout, and loaded with
cuModuleLoadData; load_variant makes the pack arrays anew from the
call's arguments, since they are device memory of the process.
Export. allocate_array(..., exportable=True) returns an
ExportableCUDABuffer, allocated through the virtual memory management API
(cuMemCreate / cuMemMap) so it can be shared as an OS handle; see
Interoperability.
HIP¶
Initialization. HIPBackend.__init__ (runtime/hip_backend.py) calls
hipInit(0) and uses the current device from hipGetDevice(). Tack does
not create or destroy a context; the HIP runtime manages it. The backend
then asks whether the device has texture hardware at all
(hipDeviceAttributeImageSupport) and, if so, the smallest of its maximum
3D texture extents.
Compilation. _compile_code_object creates a hipRTC program and
compiles it with the options in _HIPRTC_OPTIONS, which is one:
-ffp-contract=on. hipRTC's default is clang's for HIP,
-ffp-contract=fast, which fuses a multiply into an add or subtract in a
later statement; on AMD GPUs it did so where the product had another use,
so one variable held two values (with p = k * 0.2, p - p came out as
the product's rounding error instead of zero), and a contour of a computed
array took finite values for infinite ones. on fuses only inside one
expression, which is what the contract permits. hipRTC targets the
current device by default, and no unsafe or relaxed math option is
requested. The result is a code object loaded with hipModuleLoadData.
The disk cache stores that code object, machine code for one
architecture, with the entry name, parameter and scalar pack layouts, and
load_variant remakes the pack arrays from the call's arguments.
HIPCodeGen (codegen/hip_gen.py) is the CUDA generator with two
overrides: it prepends #include <hip/hip_runtime.h>, and it spells the
texture handle hipTextureObject_t (_TEXTURE_OBJECT_TYPE), because
hipRTC rejects CUDA's cudaTextureObject_t.
Launch. Identical geometry to CUDA — 256-thread blocks, a ceiling-divided
grid, the same 64-bit index, guard and locals — through
hipModuleLaunchKernel, queued as on CUDA. HIP has no context object to
hold the stream, so it belongs to the device (_Queue, a blocking stream
made on first use, so it stays ordered with the null stream), and each
buffer keeps a reference to it. Buffers come from hipMalloc through a
cache of dropped arrays' buffers (buffer_cache.BufferCache, by capacity
class, at most a quarter of the device): every use of a buffer is queued on
the one stream, so a dropped array's work runs before its buffer's next
array's, which is what a stream-ordered pool promises, without a
hipFree (which waits for the device) per array. ROCm 7.1.1's pool
(hipMallocAsync, its release threshold raised) slowed with every
allocation it had made -- over the 512 eta pieces' global ids from 63 to
600 µs a call, 2.7 ms when trimmed after each step, where hipMalloc stayed
at 17 µs -- and stalled that port for two hours (illyad, 2026-10-27);
TACK_HIP_ALLOCATOR=pool still selects it. A host write from
pageable memory is queued behind the launches; measured on ROCm 7.1, HIP
stages a small one (16 KB) before from_numpy returns and waits for the
stream before a large one (4 MB), so either way the array gets what the
array held when the call returned (test_hip_async.py). The launch
limit (_query_max_launch) is the device's maximum gridDim.x times 256,
capped at 2³² − 256, because the dispatch packet on AMD GPUs holds the
grid's total work-item count in 32 bits.
hip-python 10 is a compatibility package over rocm.bindings (PyPI's newest, so the extra's lower bound resolves to it), and four things moved: no hip.LONG_VERSION (VERSION instead; _HIP_PYTHON_VERSION reads either), no hip.chip (the runtime library for the ctypes waits comes from rocm.bindings.util.paths.get_library_path, _runtime_library), a function whose first C parameter is an output struct allocates and returns it, (err, struct), instead of taking it (hipPointerGetAttributes, hipGetDeviceProperties; _call_with_output reads which from the binding's signature); and a struct's character arrays (hipDeviceProp_t.name, gcnArchName) are str, not bytes (_text). Before this, importing LONG_VERSION failed, so HIP read as unavailable and every HIP test skipped. With them the suite passes on hip-python 10.0 against ROCm 7.1.1 (MI210, 2026-10-15) as on 7.2.2.
Known toolchain defect. ROCm 7.0.2's device compiler (AMD clang 20,
embedded in hipRTC's comgr) miscompiles one generated integer expression
at -O1 and above: a 64-bit signed -253 < -1 inside the full expression
evaluates false. The generated source is free of undefined behavior (UBSan
under clang and gcc, -O0 to -O3) and is correct on CUDA and under
ROCm's clang 23, so it's a compiler defect and not a code generation one.
It depends on the surrounding expression, so Tack doesn't try to avoid it.
ROCm 7.1.1, still AMD clang 20, does the same: on an MI210 (gfx90a) the
generated kernel, built by hipcc into a host program with no Tack
involved, gives the right answer at -O0 and the wrong one at -O1 to
-O3. test_integer_expression_differential.py marks that one case as a
strict expected failure when the HIP runtime reports ROCm 7.0 or 7.1
(hipRuntimeGetVersion()). hiprtcVersion() can't identify the release:
it reports 9.0 on ROCm 7.0.2. A fixed toolchain reports XPASS, and the
mark is then removed.
Level Zero¶
Initialization. LevelZeroBackend.__init__
(runtime/level_zero_backend.py) loads libze_loader.so through ctypes and
calls zeInit(0). Without options it takes the first driver and that
driver's first device and creates its own context. With
external_context={"driver", "device", "context"} it adopts all three
handles from another library instead — see
Interoperability.
_query_device then reads:
| Query | Used for |
|---|---|
zeDeviceGetProperties |
deviceId, passed to ocloc -device; the device name |
zeDeviceGetComputeProperties |
maxGroupSizeX and maxTotalGroupSize, which cap the group size, and maxGroupCountX, which with it bounds one launch |
zeDeviceGetImageProperties |
maxImageDims3D, and maxSamplers > 0 as "has texture samplers" |
zeDeviceGetModuleProperties |
fp64flags != 0 adds f64 to supported_dtypes |
zeDeviceGetCommandQueueGroupProperties |
the first queue group with the compute flag |
supported_dtypes is assigned on the instance, shadowing the class
attribute, so supports_f64 follows the device automatically.
_create_queues makes the backend's queue: one asynchronous immediate
command list, to which every launch, fill and copy is appended, each
followed by a barrier (_ordered) that keeps them in program order. A
driver without zeCommandListHostSynchronize (API 1.6) gets a synchronous
immediate list instead, every append completing at once. The list, and
each kernel's argument state, are shared by every dispatch on the backend,
and Level Zero forbids using them from two threads at once, so a reentrant
_launch_lock is held around every launch, native reduction and host
copy.
Compilation. OpenCL C from generate_opencl_source is compiled to
SPIR-V in process by _compile_to_spirv, which calls oclocInvoke in
libocloc.so with
compile -spv_only -device 0x<deviceId> -options "-cl-std=CL2.0 -cl-fp32-correctly-rounded-divide-sqrt" -file kernel.cl
and passes the source from memory, null-terminated, because ocloc treats
in-memory sources as C strings. The options are the module constant
_OCLOC_OPTIONS. The .spv output is handed to _create_module, which
calls zeModuleCreate as ZE_MODULE_FORMAT_IL_SPIRV with build flags
_MODULE_BUILD_FLAGS (the same OpenCL options, _OPENCL_OPTIONS); a failure raises RuntimeError
with the module build log and the source. zeKernelCreate then returns the kernel handle. When the
source uses double, the generator adds
#pragma OPENCL EXTENSION cl_khr_fp64 : enable.
The cost of ocloc. Compilation on this backend is dominated by
libocloc itself. Measured on an Intel Data Center GPU Max 1100 on
2026-10-04, idle, an ocloc call took about 191 ms regardless of kernel size,
against 0.2 ms for zeModuleCreate; llvmlite on the CPU backend compiled
the same kernel shapes in 21–55 ms. The floor is startup inside the
library, not code generation. The disk cache is what avoids it: a variant
is stored as the native binary IGC made at zeModuleCreate
(zeModuleGetNativeBinary) beside the SPIR-V, and loaded as
ZE_MODULE_FORMAT_NATIVE, about 0.2 ms a module in a new process; a binary the
driver refuses loads from the SPIR-V instead (counted as
native_refused). SPIR-V alone still went through IGC at zeModuleCreate,
about 50 ms a kernel, unless the driver's own persistent cache, which lives
outside TACK_CACHE_DIR and is off or unwritable on many clusters, held
the result. The cache target therefore names the device and its IP version
(which names its stepping), the driver, ocloc's version and options, the
module build flags and f64 support. With the disk cache off, a device made
with options recompiles the same sources, because the in-memory variant
cache belongs to a backend instance; the plain tack.device("level_zero")
is one backend for the process, so switching the default away and back
keeps its variants.
Launch. The group size is min(256, maxGroupSizeX, maxTotalGroupSize).
Each argument is set with zeKernelSetArgumentValue, followed by the loop
end as a 64-bit __n__; the kernel starts with
long i = (long)get_group_id(0) * (long)get_local_size(0) + (long)get_local_id(0);
if (i >= __n__) return;, because this driver's get_global_id(0) wraps
at 2^32 (see Parallel execution). Dispatch appends
the launch with ceil(loop_end / group_size) groups to the queue, followed
by a barrier, and returns; the kernel's arguments are captured when the
launch is appended, so the next call may set its own. The host waits with
zeCommandListHostSynchronize where it reads an array or calls
tack.sync(). Ordering by barriers was chosen over an in-order list
(ZE_COMMAND_QUEUE_FLAG_IN_ORDER): on driver 25.05.32567 an in-order list,
immediate or regular, cost about 130 µs of device time per kernel, a
barrier about 5. from_numpy queues a copy of a private snapshot of the
array, so the caller may change it at once; host memory a queued copy or
fill reads is held until the next wait, which comes early past 1024 objects
or 256 MiB. Arrays are freed with zeMemFreeExt's deferred policy, after
the work queued so far, where the driver has the extension; otherwise
freeing waits for the queue. An empty
range returns first; a negative count would wrap as an unsigned group count.
The group count is a ctypes uint32 array that truncates rather than
rejects, so a launch of more than maxGroupCountX groups is refused by
check_launch_size() first.
LevelZeroBackend.__del__ intentionally does nothing: Level Zero cleanup
at interpreter shutdown can crash because the driver may already be
unloaded.
Launches are queued¶
The CPU's dispatch returns once the range has run. The four GPU backends queue a launch and return: Metal into the open command buffer, CUDA on one stream per context, HIP on one stream per device, Level Zero on one asynchronous immediate command list. Waiting after every launch kept Python from preparing launch n + 1 while launch n ran, and for short launches the host side was most of the call: on Metal a wait cost about 0.3 ms; a contour of a million cells on an RTX 4060 Ti went from 9.6 to 7.1 ms, about the host cost of its 31 launches alone; on an Intel Max 1100 a hundred small launches went from 8.7 to 2.6 ms.
- Order. Launches, fills and host-to-device copies on one backend run
in the order they were made. On CUDA, HIP and Level Zero
from_numpyandfillare queued with the launches; Metal'sMetalBuffermethods wait for every live Metal backend's launches (_synchronize_all) before touching the shared memory. - Waits. The host waits where it reads an array (
to_numpy,read_rangeand so host indexing, reductions), where memory leaves Tack (share(), below), after each launch in debug mode (its check reads the errors array), atDevice.synchronize(), and attack.sync(), which calls every live backend'ssynchronize(). A launch's error surfaces at the next wait rather than at its call. - Lifetimes. An array Python drops while a queued launch still uses it outlives that launch: CUDA and HIP allocate and free in stream order, Level Zero frees with the driver's deferred policy, and a Metal launch holds references to its arrays until it completes.
- Memory outside Tack. A wrapped pointer (
wrap_ptr, soarray_from_ptrand DLPack imports), a DLPack export and Metal'sexport_memory()mark the buffer shared (DeviceBuffer.share()): the export waits for the queued launches, and every later launch that binds a shared buffer completes before it returns, because the other library may read the memory with no Tack call in between. CUDA'sexport_memory()of an ordinary array waits, then copies it once into exportable memory. CUDA, HIP and Level Zero also complete a launch that samples a hardware texture, whose image could otherwise be destroyed before the launch read it.
test_cuda_async.py, test_hip_async.py and test_level_zero_async.py
cover order across launches and fills, per-launch scalars, host writes
while the queue is busy, dropped temporaries, waits and shared buffers;
test_metal.py covers Metal's order across batches, per-launch scalars
and host writes.
How each backend queues and waits, as measured¶
The measurements behind each backend's queue and waits, kept from the developer notes:
- CUDA:
CUDABufferholds a stream-ordered device pointer (cuMemAllocAsyncon the context's stream, freed withcuMemFreeAsync, so a temporary dropped while a queued launch still uses it is freed after that launch, and the driver's pool, with its release threshold raised, hands memory back out without a round trip). Every launch, fill and copy goes through that one stream (_ContextToken.queue()) in order and returns once queued;to_numpy,read_rangeand reductions wait for the stream (_drain) and then copy: a copy to pageable host memory waits inside the driver with the context held, so no other thread could launch meanwhile (one launch per 910 ms wait on an H100, driver 595), while a thread incuStreamSynchronizeholds nothing a launch needs (511 launches at 43 µs);from_numpyandfillqueue and return;tack.sync()is a stream synchronize, and a launch's error surfaces at the next wait. A launch's scalar upload is a pageablecuMemcpyHtoDAsync, staged by the driver; behind a long kernel the staging ring fills after about 510 queued launches and the next upload waits for the kernel. A buffer handed outside Tack (wrap_ptr, DLPack export,share()) completes every launch that uses it before the launch returns, and so does a texture launch, since its image could be destroyed before a queued launch read it. Seetest_cuda_async.py. - HIP:
HIPBufferholds a stream-ordered device pointer, as CUDA's does: one blocking stream per device (_Queue,_queue()inhip_backend.py; ordered with the null stream), memory fromhipMallocthrough a cache of dropped arrays' buffers (BufferCache;TACK_HIP_ALLOCATOR=poolfor the stream-ordered pool). Launches, fills and copies are queued on it and return;to_numpy,read_range, reductions andtack.sync()wait on an event recorded after the queued work (_StreamMarker,_wait_stream), through ctypes on the runtime library (_runtime), neverhipStreamSynchronize: hip-python's wrappers hold the GIL while the runtime waits (another thread ran 6 times during a 357 ms wait, 353 through ctypes), and ROCm 7.1's stream synchronize holds the stream, so another thread's launch on it took the whole wait (350 ms; 60 µs waiting on an event). A copy to pageable host memory waits insidehipMemcpyAsyncthe same way, so a read waits on an event first (about 8 µs idle). A launch's error surfaces at the next wait. A host write from pageable memory may be queued: HIP stages a small one before returning and waits for the stream before a large one (16 KB staged, 4 MB waited, ROCm 7.1).wrap_ptr, DLPack export (share()) and texture launches complete before returning. A device without memory pools falls back tohipMalloc/hipFree. Seetest_hip_async.py. - Level Zero:
L0Bufferholds a device pointer (zeMemAllocDevice). Every launch, fill and copy is appended to the backend's one asynchronous immediate command list (_queue) and returns once queued, each followed by a barrier (_ordered), which keeps program order: Intel's in-order lists (ZE_COMMAND_QUEUE_FLAG_IN_ORDER) cost about 100 µs of device time per kernel on driver 25.05, a barrier about 5.to_numpy,read_rangeand reductions queue a copy and a marker under_launch_lockand wait on the marker's event outside it (_ListMarker), so other threads launch meanwhile;tack.sync()iszeCommandListHostSynchronize;from_numpycopies the array privately and queues; copies to the device up to 16 MiB and from it up to 64 KiB go through pooled USM host memory (_HostStaging, guarded by_launch_lock), since from pageable memory Intel's driver does not return from appending one (to the device up to 4 MiB, from it up to 1 KiB, driver 25.05) until the queue drains, which made a launch's scalar upload wait for the kernels before it; host memory a queued copy or fill reads is held (_hold) until the next wait (a marker releases the holds before it), which comes early past 1024 objects or 256 MiB. Arrays are freed withzeMemFreeExt(DEFER_FREE), after the work queued so far (_free; without the extension, wait then free).share(),wrap_ptr, DLPack export and textures complete their launches before returning, as on CUDA. A driver withoutzeCommandListHostSynchronizegets a synchronous list. Seetest_level_zero_async.py. asyncio(runtime/aio.py, optional):await tack.sync_async()andawait array.numpy_async()wait in one of Tack's worker threads (tack-wait, 4) and resume the coroutine, so an event loop (trame, Jupyter, a server) keeps running while the device works; kernel calls stay plain calls. A backend opts in withBackend.async_marker()-- an object whose blockingwait()covers every launch queued so far and raises a launch's error: CUDA records a blocking-sync event on the context's stream (_StreamMarker), Metal commits the open batch and waits on the last command buffer (_CommandBufferMarker), Level Zero appends a barrier signalling a host-visible event from the backend's pools, reset and reused after the wait (_ListMarker,_take_event), HIP records a blocking-sync event on the device's stream and waits through ctypes (_StreamMarker) -- and buffers withDeviceBuffer.waits_off_threadandthread_binding()(CUDA makes its context current in the worker,_BoundContext; HIP makes the buffer's device current,_BoundDevice). Without a marker (the CPU) the wait is inline: correct, and the loop simply waits. The mark is taken at the call, so launches queued later are not waited for. cuda-bindings releases the GIL incuEventSynchronize: during a 490 ms kernel another task ran 436 times awaitingsync_asyncand 0 times undertack.sync(); Level Zero's ctypes calls release it too (311-384 times during a 685 ms kernel on a Max 1100); hip-python's do not, hence HIP's ctypes waits (1353 times during a 1.45 s kernel on an MI210). Seetest_asyncio.py.
Scalar arguments on the GPU¶
The CPU passes scalars by value. The four GPU backends instead run
pack_scalars (lang/ir_pack_scalars.py) on a copy of the variant's IR in
their build callback: every scalar parameter is replaced by a load from a
small array, one array per scalar dtype (__pack_f32__, __pack_i32__,
...), marked _is_scalar_pack. The pass was written for Metal, whose
kernels have a limited number of buffer bindings; packing makes the count
one per scalar type instead of one per scalar, and the other GPU backends
share the code path.
On CUDA, HIP and Level Zero the pack arrays are allocated once, when the
variant is built (_create_pack_arrays in runtime/kernel_utils.py), and
stored in the variant's payload. Each dispatch writes the new values with
_update_pack_arrays, a host-to-device copy queued after the launches
before it, so a queued launch still reads the values it was given. Metal
keeps no pack arrays: each launch copies its values into a new buffer
(_Uniforms) that the launch owns, since a shared pack rewritten for the
next call could change under a launch that has not run yet. Changing a
scalar's value therefore never recompiles. The loop range is resolved from
the unpacked IR, because packing rewrites the parameter list that the
range expression refers to.
Since the pack arrays belong to the variant, two threads dispatching the
same variant at once would each write their arguments into the same
buffers, and a launch could run with the other call's scalars. Reproduced
on CUDA with four threads, 248 of 400 dispatches computed with another
thread's scalars. CUDA and HIP therefore hold KernelVariant.dispatch_lock
from the pack update until the launch is queued, so different variants
still dispatch concurrently; uncontended, the lock cost about 0.25 µs
against a 95 µs CUDA dispatch. Level Zero takes its backend-wide
_launch_lock instead (see Level Zero). Metal launches own
their argument buffer and scalars and encode one at a time under the
backend's batch lock. The CPU binds arguments per call and takes no lock.
Textures¶
tack.texture3d(array, shape) creates a Texture3D (lang/array.py)
holding a copy of the array; in a kernel, tex.sample(u, v, w)
interpolates trilinearly at normalized coordinates, where 0 and 1 are
the centers of the first and last texels. Hardware samplers put texel
centers at (i + 0.5) / N, so every hardware path rewrites each coordinate
to (u * (N - 1) + 0.5) / N.
Storage. The texture copies its array when it is created, on every
backend, and tex.update() copies it again; writes to the array in
between don't reach it. The copy is storage the texture owns, in
tex._storage. Backend.texture_in_hardware(shape_3d) chooses it: where
the answer is true, create_texture_image() returns a backend image
object (MetalTextureImage, CUDATextureImage, HIPTextureImage or
L0TextureImage) whose upload(array) does the copy; otherwise the
texture allocates a private f32 array and update() copies into it with
the device copy kernel. Only one extra copy is held per texture. Images
are built eagerly and updated in place, so their texture objects stay
valid, and they free their device objects when the texture is collected
(CUDA's only while its context is alive). At dispatch, bind_textures()
(runtime/kernel_utils.py) replaces each Texture3D argument with its
storage, never its array. No compiled kernel caches texture objects.
| Backend | Hardware path | Software fallback when |
|---|---|---|
| CPU | none | always |
| Metal | MTLTexture (3D, R32Float), filled by a blit from the array's buffer; sampler(normalized, linear, clamp_to_edge) |
never |
| CUDA | 3D CUDA array (one 32-bit float channel) filled with cuMemcpy3D; texture object with linear filtering, clamp, normalized coordinates |
never |
| HIP | hipMalloc3DArray + hipMemcpy3D; texture object as on CUDA |
the device reports no image support, or an extent exceeds its 3D texture limit |
| Level Zero | zeImageCreate (3D, 32-bit float), filled through a host staging allocation; CLK_NORMALIZED_COORDS_TRUE \| CLK_ADDRESS_CLAMP_TO_EDGE \| CLK_FILTER_LINEAR |
maxSamplers == 0 (Xe-HPC), or an extent exceeds maxImageDims3D |
The fallback decision changes the generated code, so HIP and Level Zero
ask texture_in_hardware() again in their own _store_texture_shapes,
passed to resolve_variant, which runs before the variant key is built.
The texture and the variant therefore agree on which storage is bound.
Falling back clears the parameter's _is_texture flag and the generator
emits a software trilinear helper over the texture's private array instead.
tack.inspect calls the same hook, so it shows the code dispatch compiles.
CDNA parts — gfx940/941/942, the MI300 family — have no texture hardware,
and hipRTC refuses tex3D outright there ("The image/texture API not
supported on the device"), which is why HIP asks
hipDeviceAttributeImageSupport rather than assuming. Xe-HPC similarly has
no samplers; filtered image reads would be driver-emulated.
Why a snapshot on every backend. Hardware samplers read an image, not
the array, so on those paths a texture is a copy whatever Tack does. The
only portable rule is therefore that a texture is a copy everywhere, taken
at points the program chooses: at creation and on update(). Owning the
image in the texture, rather than caching it in a compiled kernel under
the array's device address, means no texture is ever handed another's
image (a new array that reuses a freed array's address, or a second
texture over the same array, gets its own copy), and the image is released
with its texture. The images are single-channel 32-bit float, so
texture3d() rejects non-f32 arrays rather than reinterpreting them,
and it rejects shapes whose W*H*D differs from the array's size, which
the copies would read past. render_volume() calls update() before each
render, since the mixed-scene path tracer reads the volume's array
directly. test_texture_snapshot.py pins these rules.
Device reductions¶
Array.sum(), min() and max() call backend.reduce_array() when
supports_device_reductions is true and otherwise reduce with NumPy on the
host. All four GPU backends implement reduce_array only for f32 arrays,
using a source from array_reduction_source(dialect, op)
(codegen/reductions.py): a 256-lane tree in each group, followed by
unordered atomic accumulation of the partial results. Any other dtype falls
back to NumPy, as does Level Zero when the device's group-size limits are
below 256. CUDA, HIP and Level Zero pass the element count as a 64-bit
argument and check it with check_launch_size(). Metal's kernel holds the
count in 32 bits, so Metal reduces an array of 2³² or more elements with
NumPy over its shared buffer. An empty array returns 0.0 for sum and raises ValueError
for min and max on every backend. The ordering and accuracy promises are
in the contract.
Workarounds and why they exist¶
Generated code and runtime calls shaped around a vendor defect, each with the vendor and version it was found on and the test that guards it, are recorded in Vendor workarounds, so that each can be retired when a fix ships.
See also¶
- Parallel Execution — the execution model these launches implement.
- Memory and Aliasing — the buffers and the aliasing rules each generator honors.
- Interoperability — sharing memory with other libraries.
- Conformance and Validation — how each backend is validated on hardware.