Runtime API¶
This page is the contract for Tack's host-side surface: choosing devices, creating and moving arrays, reductions, sharing memory with other libraries, inspecting generated code, the decorators, and the rules for calling a kernel. Each entry gives the signature, what is guaranteed, what the caller must ensure, and which exception a violation raises. What a kernel computes is defined by the kernel language contract. What each backend supports is defined in Backend capabilities.
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. They use the labels
defined in Contracts.
Devices¶
Defined in packages/tack-core/src/tack/runtime/dispatch.py and
re-exported as tack.device, tack.Device, tack.device_count, tack.set_default_device,
tack.get_default_device, tack.set_debug, tack.sync and
tack.sync_async.
tack.device(spec="cpu", **options)¶
Returns the tack.Device that spec names. A Device passed as spec is
returned as it is.
spec |
"cpu", "metal", "cuda", "hip" or "level_zero". The numbered types (cuda, hip, level_zero) also take "type:N", such as "cuda:1": device N as its runtime numbers it (the CUDA device ordinal, the HIP device id, the index among the first Level Zero driver's devices). A bare "cuda" or "hip" is the device the calling thread already uses: the device of a CUDA context another library made current on the thread, or HIP's current device (hipGetDevice()), else 0. "level_zero" means "level_zero:0". cpu and metal are written bare |
**options |
Forwarded to the backend constructor. Each option must be in that backend's device_options. CPU declares num_threads, which takes precedence over TACK_CPU_THREADS, and Level Zero declares external_context (the driver, device and context handles of a context owned by another library). The other backends declare none |
Current behavior:
- Without options, each
(type, index)is made on first use and is the sameDeviceon every later call. Its compiled kernels and its arrays are shared by everything that asks for it. - With options, every call makes a new device, which is not equal to the one without options or to another made with the same options.
- Every device of
cuda,hipandlevel_zerois reachable;tack.device_count(type)says how many there are. A bare spec is resolved at each call, so after another library changes the thread's device,"cuda"or"hip"names the other device. - Each CUDA device has its own context, and each Level Zero device its own
context unless
external_contextnames one. A CUDA context already current on the calling thread, such as one an embedding framework created, is adopted for its device and never destroyed. It must be current before the first call that names that device: the device is made once, so a context made current later is not adopted. - Otherwise Tack works in the device's primary context, the one the CUDA runtime and libraries on it use (PyTorch, CuPy, Warp), retained when the device is made and released, never destroyed, when the last backend on it goes. Made while no context is current, it stays current on that thread; made while another is current, it is not, and the thread keeps its own. As long as Tack holds one context and it is current where it was made, nothing is switched. Once it holds two, or a context is not current on the thread that made its device, every CUDA operation makes its own context current for its duration and restores the thread's afterwards. HIP does the same with its current device once a device other than 0, or two devices, are in use. See Calling a kernel.
Errors:
| Condition | Exception |
|---|---|
spec neither a string nor a Device |
TypeError: "a device is a Device or a string such as 'cpu' or 'cuda:0', not ..." |
Options with a Device |
TypeError: "options make a new device: pass a device spec, not a Device" |
| Unknown type | ValueError: "Unknown device type: 'vulkan'. Available: cpu, cuda, hip, level_zero, metal" |
A number on cpu or metal, or a non-numeric suffix |
ValueError: "'cpu:0' is not a device: write 'cpu'" (with "or 'cuda:N'" for numbered types) |
| A device past the last | ValueError: "cuda:2: this machine has 2 CUDA devices" (Level Zero: "the first Level Zero driver has ..."). Raised by the constructor, so it propagates unwrapped |
external_context with a number other than its device's |
ValueError: "level_zero:1: the external_context's device is level_zero:0; ..." |
Missing Python dependency (an ImportError from the backend module or constructor) |
RuntimeError: "Cannot start the 'hip' device: missing dependency." followed by the original error and install instructions. The original exception is chained |
Option not in device_options |
ValueError: "The 'metal' device does not accept the option(s) num_threads. Accepted: none." Raised after the backend module is imported, so a missing dependency is reported first |
Device or runtime failure (a RuntimeError from the constructor) |
RuntimeError: "Cannot start the 'cuda' device on Linux." followed by the original message and install instructions. Chained |
Any other constructor failure, such as ValueError for an incomplete external_context or a non-integer TACK_CPU_THREADS |
Propagates unwrapped |
tack.Device¶
| Member | Meaning |
|---|---|
d.type |
"cpu", "metal", "cuda", "hip" or "level_zero" (the backend's name) |
d.index |
Which of the type's devices (the backend's ordinal): N in "cuda:N". A bare spec gives the device it resolved to, so str(tack.device("cuda")) may be "cuda:1". 0 for cpu and metal |
d.synchronize() |
Waits for every kernel launched on this device so far, and raises a queued launch's error |
str(d) |
The spec: "cuda:0" for numbered types, "cpu" and "metal" bare. repr(d) is Device('cuda:0') |
d == e |
True when both are the same backend; hashable on the same terms |
with d: |
Makes d the default device inside the block, on this thread only. Blocks nest; leaving one restores the previous default |
tack.device_count(type)¶
Returns how many devices of type the machine has, without making any:
tack.device(f"{type}:{n}") is valid for n below the count. The CPU
counts 1, and Metal 1 where there is a Metal GPU. A type whose backend cannot
load or start (a missing dependency, no driver) counts 0. An unknown type
raises ValueError as tack.device does.
tack.set_default_device(spec="cpu", **options)¶
Makes tack.device(spec, **options) the default device and returns it. The
default device is where arrays are allocated when no device= is given,
and where kernels with no arrays run. It is process-wide; a with block
overrides it on its own thread.
Current behavior:
- With nothing set, the default is the CPU, chosen at the first
allocation, launch or
get_default_device()call. - Without options,
set_default_devicebuilds nothing new: the device is the onetack.device(spec)always returns. Arrays made before a switch keep their device and keep working, and kernels given them run there (below). Compiled kernels survive a switch and back. TACK_NO_REINIT: if a default is already set (including the CPU default picked by an earlier allocation or launch) and this variable is switched on,set_default_devicereturns the current default. It ignoresspecandoptions, and it doesn't validate either. The variable is read bydispatch.env_flag: unset, empty,0,false,noandoff(in any case, ignoring surrounding spaces) mean off, and any other value means on. This exists for embedding, for example inside an ANARI device that shares the process.
Errors are those of tack.device. If the device cannot be made, the
previous default stays.
tack.get_default_device()¶
Returns the default device: the innermost with block's on this thread,
else the one set_default_device chose, else the CPU.
tack.runtime.dispatch.get_backend() returns the same device's Backend.
Where arrays live and kernels run¶
- Every array records its device:
a.deviceis atack.Device. Every constructor takesdevice=(aDeviceor a spec):empty,zeros,ones,full,arange,array,asarray,array_from_ptrandmemory_space, which default to the default device;empty_like,zeros_like,ones_likeandfull_like, which default to a Tack prototype's device; andfrom_dlpack, which defaults to the default device when that can wrap the tensor, else the tensor's own device. a.to_device(device)returnsaitself when its memory is usable ondevice, else a copy made through the host.tack.array(a, device=...)andtack.asarray(a, device=...)move a Tack array the same way.- Copies,
astype,reshape, reductions,fill,concat(the first array's device) and textures stay on their array's device. - Launch placement: a kernel runs on the default device when that
device can address all its arrays, or when it has none; otherwise on
the arrays' own device. A device can address an array when both are in
one memory domain (
Backend.memory_domain): two CPU devices with different thread counts share one, so a defaulttack.device("cpu", num_threads=1)runs kernels on CPU arrays made before it. A@tack.device_classobject's arrays choose the device when no plain array does, and must agree with it.tack.inspectmakes the same choice. - Arrays in two memory domains raise
ValueError: "Kernel 'k' was given arrays on two devices (cpu and cuda:0); a launch runs on one. Copy them to one device first (a.to_device(...))." The check runs on the dispatch fast path too. tack.algorithmsfunctions allocate their temporaries and launch on their first array argument's device. They,tack.copytofrom a Tack array andtack.concatrefuse arrays in two memory domains under their own name: "exclusive_scan() was given arrays on two devices (cpu and cuda:0); ...", not the name of an internal kernel.- An array holds its device, so a device's backend (and a CUDA context it created) lives at least as long as any of its arrays.
tack.set_debug(enabled=True)¶
Turns debug mode on or off for every kernel launched from then on.
TACK_DEBUG (a boolean flag, read like TACK_NO_REINIT) turns it on when
Tack is imported. tack.runtime.dispatch.debug_enabled() reports the
current setting.
Debug mode checks every index of every array access, one dimension at a time: an array's leading dimension against its length, the others against theirs, a flat index against the element count (a component count for a scalar atomic on a vector array), and a local or shared array index against its size. Constant tables and textures clamp and are not checked.
- An access with an index outside its extent is not performed: a load gives 0 of its type, an atomic's value is 0, and a store or atomic does nothing. The rest of the launch runs.
- After the launch,
Kernel.__call__raisesIndexErrorfor the first violation it recorded: the kernel (and the device function an access was inlined from), the file, line and column, the access as written, the index, its dimension when there are several, the extent, and how many more accesses were skipped. Which violation is first is unspecified when several threads go out of bounds. - Kernels lower and compile separately in debug mode, and the extents are run-time arguments: debug mode compiles no variant per array length. Each launch is checked when it returns, which on the GPU backends means waiting for it, since their launches are otherwise queued. Launches from different threads report their own violations.
tack.inspectshows the code a debug launch runs.
Implemented in lang/ir_bounds.py (the checks and guards),
runtime/debug_bounds.py (the hidden arguments and the error) and the
code generators (codegen/bounds.py, llvm_gen.py).
Arrays¶
tack.Array is defined in packages/tack-core/src/tack/lang/array.py.
Its dtype describes one scalar, vector or matrix element; shape is the
array's dimensions, excluding component dimensions. size counts elements.
scalar_dtype, scalar_size and storage_shape describe the scalar storage.
The buffer belongs to the array's device (a.device; see
Where arrays live and kernels run).
tack.dataset.Field is a separate class coupling a space with coefficient values.
Creation¶
| Function | Contract |
|---|---|
tack.empty(shape, dtype=f32, *, device=None, exportable=False) |
Allocates on device, by default the default device. An int shape becomes a 1-tuple. exportable=True requests memory that can be exported without a copy (CUDA only) |
tack.zeros(shape, dtype=f32, *, device=None), tack.ones(...), tack.full(shape, fill_value, dtype=None, *, device=None) |
empty() followed by fill(). zeros skips the fill where the buffer starts zeroed (starts_zeroed: a new Metal buffer, which Metal clears; not one reused from its pool). full without a dtype makes f32 from a Python float and i32 from an int (i64 if the value needs it); a sequence fill_value needs an element dtype |
tack.arange([start,] stop[, step], dtype=None, *, device=None) |
Built in NumPy and copied in. Without a dtype, integer arguments give i32 (i64 if a value needs it) and a float argument gives f32 |
tack.array(obj, dtype=None, *, device=None) |
Copies values into independent storage. Without dtype, uses all input dimensions; a NumPy array keeps its dtype, Python floats give f32 and Python ints i32 (i64 if a value needs it). An element dtype consumes matching trailing component dimensions; a mismatch raises ValueError. Existing Tack arrays copy on their device, or through the host to another device |
tack.asarray(obj, dtype=None, *, device=None) |
obj itself when it is a Tack array, dtype is None or equals its dtype, and device is None or can address it; otherwise a converted or moved array |
tack.empty_like(prototype, dtype=None, *, device=None) |
Allocates independent, writable storage on device, by default a Tack prototype's device (else the default device), without reading or copying values. Accepts a NumPy array or Tack array; takes its dtype unless overridden. A scalar override of a vector/matrix prototype keeps the element dimensions. NumPy arrays produce scalar arrays with the array's shape. Contents are unspecified |
tack.zeros_like, tack.ones_like, tack.full_like(prototype, fill_value, dtype=None) |
empty_like() followed by fill(), as for zeros, ones and full |
tack.concat(arrays) |
A new 1-D array holding all elements in order, copied on the first array's device |
tack.texture3d(array, shape=None, interp="linear") |
A Texture3D holding a copy of the array taken at creation, for sample(u, v, w) in kernels. tex.update() copies the array again. See Textures |
Current behavior:
- A new array's contents are unspecified until a kernel,
tack.copytoorfillwrites them (the language contract); usetack.zerosfor zeros. The CPU leaves a new array untouched, so that the kernel that writes it touches its pages first; the GPU backends currently zero new buffers, which is not a promise. - On the CPU, a fill of 256 KiB or more runs as a kernel through the
dispatch, in parallel when the threading policy fans it out, and stores
and refuses exactly what
ndarray.fillwould. - Allocation doesn't check the dtype against
supported_dtypes. Anf64array can be allocated on Metal, and the dispatch check rejects it when a kernel uses it. - There is no
tack.fields()function. - Vector/matrix arrays expose their array shape and element dtype on
both host and device.
numpy()appends component dimensions.
Required caller constraints:
dtypeis a Tack scalar or element type, or anythingnp.dtype()accepts that maps to a Tack scalar type (np.float32,"int64";floatmeans f64, as in NumPy). Other values raiseTypeError.- Shapes contain integer, nonnegative extents. Noninteger extents raise
TypeError; negative extents raiseValueError.
Errors:
| Condition | Exception |
|---|---|
A dtype, given or inferred, that has no Tack type, such as float16 |
TypeError: "Unsupported numpy dtype: float16" |
| Boolean host values without a dtype | TypeError: "Tack has no boolean arrays; ..." |
empty_like (or another *_like) with a prototype other than a NumPy array or Tack array |
TypeError: "expected a Tack array or NumPy array as the prototype" |
concat([]) |
ValueError |
concat with mixed dtypes |
TypeError |
texture3d on an array that isn't f32 |
ValueError: "texture3d requires an f32 array, got f64; convert it first with array.astype(tack.f32)" |
texture3d without a 3-D shape |
ValueError: "texture3d requires a 3D shape" |
texture3d with a shape whose W*H*D differs from the array's size |
ValueError naming both element counts |
texture3d with interp other than "linear" |
ValueError: "texture3d supports interp='linear' only, ..." |
Host operations¶
| Operation | Guarantee | Errors |
|---|---|---|
tack.copyto(a, src) |
With host src (a NumPy array, sequence or scalar): converts it to a's scalar dtype with np.asarray, an unchecked cast, and copies it in. Its shape must be a.numpy()'s shape or broadcast to it, as in np.copyto; a flat array is not accepted for a vector or matrix array. Works on reshaped views. With a Tack array src of the same shape and scalar count on a's device: a device copy (src.to_device(a.device) brings one from another device) |
RuntimeError if a is read-only. ValueError "copyto() into an array whose values have shape (8,): cannot broadcast shape (4,)", for a Tack src of another shape, or "copyto() was given arrays on two devices ..." for one on another device. TypeError if a is not a Tack array |
a.numpy(), np.asarray(a) |
Returns a new NumPy array with the scalar component dtype and component axes appended to a.shape. Never a view, including on Metal; np.asarray(a, copy=False) raises ValueError |
— |
a.fill(value) |
Sets every element to a scalar, or, for a vector or matrix array, to one element's components | RuntimeError if read-only. TypeError for a sequence on a scalar array; ValueError for a sequence of the wrong element shape |
a[i], a[i, j], a[()] |
Reads one element: a Python number, or a NumPy array for a vector or matrix element. a[()] reads a 0-d array, as in NumPy. There is no host assignment: a[i] = x and iteration raise TypeError |
TypeError for anything but one integer per dimension, including a[None], whose message names a[()]; IndexError out of range |
a.reshape(*shape) |
A metadata-only view that shares the buffer, the element type, the writability and any DLPack hold. Takes a tuple or separate integers; one extent may be -1 |
ValueError if the element count differs or more than one extent is -1 |
a.copy() / a.astype(dtype) |
A new array on a's device, filled by a device copy kernel. astype converts with the kernel language's conversion rules; a scalar dtype keeps a vector or matrix element's dimensions |
Kernel errors, if any. TypeError if astype would change element dimensions |
a.shape, a.ndim, a.size, len(a), a.nbytes |
shape, ndim and size count elements, not components. len is shape[0]. nbytes is the storage in bytes |
TypeError from len of a 0-d array |
a.export_memory() |
Returns an ExportedMemory handle for cross-API sharing. On CUDA, an array allocated without exportable=True is copied once into exportable memory, so the export is a snapshot |
RuntimeError on CPU, HIP and Level Zero |
Current behavior: on Metal, CUDA, HIP and Level Zero a kernel call
returns once the launch is queued, not once it has run. Host operations
keep this invisible: numpy, a[i] and reductions wait for the
launches before them; copyto and fill take effect after them (on
CUDA, HIP and Level Zero they are queued in order; on Metal they wait
first). A queued launch's error, such as a device fault, is raised at the
next wait rather than by its own call. An array dropped while a queued
launch uses it is freed after that launch.
Required caller constraint: an array is synchronized at kernel-call
and host-operation boundaries only. Host reads and writes between calls
are safe. Host access during a call from another thread is not. Memory
that another library reads or writes without a Tack call in between must
be ordered with tack.sync(), except where Tack does it: a DLPack export
and a wrapped pointer make every launch that uses them complete before it
returns (see DLPack).
tack.sync()¶
Defined in runtime/dispatch.py (sync). Waits until every kernel
launched so far, on every device, has finished, by calling each backend's
Backend.synchronize(); a no-op on the CPU, whose launches complete before
returning. d.synchronize() waits for one device. Most code never needs it, since reading an array waits already.
It is for timing kernels, and for memory shared with another library that
reads it without Tack. It raises the error of a queued launch that
failed.
tack.sync_async() (runtime/aio.py) is the asyncio form, for every
device as tack.sync is: awaited, it waits in one of Tack's worker threads for the launches made before the
call, not for later ones, and raises a queued launch's error at the
await, after waiting for the other devices; await a.numpy_async() does the same for a read. A backend
waits off the loop through Backend.async_marker(); without one (the
CPU), the wait is inline.
Reductions¶
f.sum(), f.min(), f.max() and f.mean() reduce every element and
return a Python float. Integer arrays included.
sum |
min / max |
mean |
|
|---|---|---|---|
| Empty array | 0.0 |
ValueError: "min reduction requires a nonempty array" |
nan |
| Path | On the device when the array's device has supports_device_reductions and the array is f32. Otherwise NumPy on the host |
same | sum() / size in host binary64 |
Accumulation precision, NaN and signed-zero rules, permitted reordering and the error budget are Required items of the reduction contract. A reduction runs on the array's device, whatever the default.
Pointer interop¶
tack.array_from_ptr(ptr, shape, dtype, *, device=None, writable=False)¶
Wraps existing memory as an array without copying. shape counts
elements; dtype may be an element type such as tack.vec3(). Tack never frees it.
| Backend | What ptr must be |
|---|---|
| CPU | An integer address or a NumPy array |
| Metal | An MTLBuffer object. Any other value raises TypeError ("the Metal backend wraps an MTLBuffer object, not int ...") |
| CUDA, HIP | A device address: an int, or anything int() accepts, such as a NumPy integer or a CUdeviceptr |
| Level Zero | A USM device or shared address in the same forms, allocated in Tack's context |
Guarantee: on a backend that declares device_memory_spaces (CUDA,
HIP and Level Zero), every pointer is converted with as_address
(runtime/kernel_utils.py) and classified with memory_space(). A value
that isn't an address raises TypeError ("array_from_ptr() on the CUDA
backend takes a device address ..."), and a pointer outside those spaces
raises ValueError ("Pointer is in 'cpu' memory but the device is
'CUDA'. ..."). On Level Zero the driver answers for pointers from any
context on the device, so the check can't detect a pointer from a
different context.
Guarantee: an array created with writable=False, the default, is
read-only on the host and in kernels. fill() and tack.copyto() raise
RuntimeError. A dispatch that binds it to a parameter the kernel may
store to, or use as an atomic target, raises ValueError before the
launch: "Kernel 'k': parameter 'out' may be written, but its array is
read-only. ...". A parameter whose stores can't be traced counts as
written.
Required caller constraints:
- Keep the memory alive and unmoved for the array's lifetime.
- Provide at least
prod(shape) * itemsizecontiguous bytes.
tack.memory_space(ptr, *, device=None)¶
Delegates to device's memory_space() (by default the default device's). CPU and Metal always
answer "cpu". CUDA answers "cuda", "cuda_pinned", "cuda_managed" or
"cpu". HIP answers "hip", "hip_pinned", "hip_managed" or "cpu".
Level Zero answers "level_zero" or "cpu". A value that can't be a
64-bit address answers "cpu" without querying the driver. On Level Zero,
a failed driver query raises RuntimeError instead of being reported as
host memory.
DLPack¶
Defined in packages/tack-core/src/tack/lang/dlpack.py.
Export: Array.__dlpack__(*, stream=None, max_version=None, dl_device=None, copy=None)
and Array.__dlpack_device__().
- Zero-copy. The capsule keeps the array alive until the consumer calls the deleter.
- A consumer passing
max_version >= (1, 0)receives a versioned capsule that carries the read-only flag for non-writable arrays. The legacy capsule can't express read-only. - The device type is
kDLCPUon CPU and Metal,kDLCUDAon CUDA,kDLROCMon HIP, andkDLOneAPIon Level Zero. The device id is always0. - Strides are C-contiguous. All ten Tack dtypes are exported.
copy=TrueraisesBufferError.streamanddl_deviceare ignored.- On Metal, CUDA, HIP and Level Zero the export waits for the launches
queued so far, and from then on every launch that uses the array
completes before it returns (
DeviceBuffer.share()), so the consumer may read the memory at once and after later launches.
Import: tack.from_dlpack(source, *, device=None, copy=False). The array
belongs to device. By default that is the default device when it can wrap
the tensor, else the tensor's own device, as the array API's from_dlpack
keeps an array on its device: CUDA memory imported with the CPU as default
is a CUDA array, and host memory imported with Metal as default (which
cannot wrap a host pointer) is a CPU array. Neither is copied.
sourceis a capsule or an object with__dlpack__. A versioned capsule is requested first.- With
copy=False, the array shares the tensor's memory and holds the capsule until the array and every view of it are collected. Then the producer's deleter runs exactly once. - A read-only versioned tensor produces a non-writable array, which
host writes and kernel stores refuse as for
array_from_ptr. - As for
array_from_ptr, a launch that uses the imported array completes before it returns. - With
copy=True, the source is read throughnp.from_dlpackand copied into a new array withtack.array. That works only for sources that NumPy can read on the host.
| Condition | Exception |
|---|---|
| Capsule already consumed | ValueError |
| Null data pointer | ValueError |
| Non-C-contiguous strides | ValueError: "only C-contiguous tensors can be wrapped without copying; ..." |
DLPack dtype without a Tack type, or lanes != 1 |
TypeError |
| Unknown device type | ValueError |
kDLMetal tensor |
ValueError: "Metal DLPack tensors (kDLMetal) cannot be imported: ..." |
Device type that device can't wrap |
RuntimeError naming the backend and the device type, followed by dlpack_refusal_note |
Wrapped pointer outside device_memory_spaces |
ValueError from array_from_ptr |
Current behavior: Metal has no zero-copy import. Host tensors
(kDLCPU) are refused with an explanation, because wrapping a host pointer
as an MTLBuffer without a copy needs page alignment. kDLMetal tensors
are refused up front on every backend, because their data is an opaque
MTLBuffer handle plus a byte offset, which nothing in Tack can wrap yet.
On Metal, use copy=True with a host tensor.
VTK¶
tack.interop.vtk (package tack-vis) provides vtk_to_array(vtk_array, flatten=False),
array_to_vtk(array, n_components=None, name=None) and init_level_zero().
Both directions are zero-copy through DLPack and need VTK with
vtkmodules.util.dlpack_support. Without it they raise RuntimeError.
array_to_vtk validates the layout before importing VTK and raises
ValueError for a contradictory n_components or for arrays with more
than two dimensions.
Required for Level Zero: start Tack with
tack.interop.vtk.init_level_zero() instead of
tack.set_default_device("level_zero"). It makes a Level Zero device in
the context that VTK's device uses the default, and returns it, so that
Tack allocates there. Exporting an array of a Level Zero device in another
context, or importing while the default Level Zero device is in one,
raises RuntimeError. The
check is necessary because the driver accepts pointers from any context.
See Interoperability.
Communication between blocks¶
Defined in packages/tack-core/src/tack/communication/. A guide:
Communicate Between Blocks.
Blocks and worlds. Blocks(devices, world=None) holds this rank's
blocks, one per entry of devices. Global ids run over every rank's
blocks, contiguous by rank (rank 0's first), and Blocks.local is the
range of this rank's. World() is one process; World(comm, gpu_aware=False)
takes an mpi4py communicator, or any object with Dup, Get_rank,
Get_size, allgather, alltoall, Isend and Irecv, and communicates
on comm.Dup(), so its messages never meet the caller's. Tack imports no
MPI module.
- Collective. Every call -- the
Blocks,PlanandRoutingconstructors,exchange,all_gather,all_reduce,exclusive_scan,link_by_bounds,Plan.run,Routing.send,Routing.reply,sort_by_key-- is made by every rank of the world, in the same order. Arguments keyed by global id name only this rank's blocks; the collectives andRoutingneed an entry for every one of them. - Independent of the assignment.
all_reduce,all_gatherandexclusive_scancombine in order of global id, so their results, a floating-point sum's bits included, do not depend on which rank holds which block.exchangereturns each block's messages in order of source;sort_by_keyandRoutingdecide by global id too. - Exchange.
exchange({source: {destination: {name: Array}}})returns{destination: {source: {name: Array}}}for every block on this rank, an empty dict where nothing came. A sent array is on its source block's device; a received one on its destination block's, the sent array itself where that device addresses its memory, so a sender must not write to what it sent. Element types and shapes arrive as sent. Every MPI request is complete whenexchangereturns. - Transport. Between ranks, a world stages each rank pair's arrays
through one host buffer. With
gpu_aware=Trueit hands MPI the arrays themselves through DLPack, or a device copy inBackend.allocate_mpi_array's memory whereBackend.mpi_directsays the MPI may not take them (CUDA's and HIP's stream-ordered pools), and receives into such memory. A GPU-aware MPI uses the device current when MPI starts: make the rank's device before importingmpi4py.MPI. - Plans.
Plan(blocks, sends, receives)checks, across ranks, that every send has a receive of its length and the other way round.Plan.run(values)writes received elements in place and leaves the others; a block sending to itself reads before anything is written. - Routing. A point is asked of every block whose box holds it, with a
relative slack of 1e-6, and goes to the block whose
holdsgives the highest rank, the lowest block on a tie, or nowhere (-1) where every rank is 0. Coordinates travel as f64 bits.senddelivers in order of origin block and then of the points there;replyanswers in that order, zero for a point that went nowhere. - Sorting.
sort_by_keytakes one-dimensionali32,u32,i64oru64keys of one type on every block. Block 0 ends with the smallest keys, each block ascending; every copy of a key ends on one block; the sort is stable, by block and then index.
| Condition | Exception |
|---|---|
| A global id out of range, or naming another rank's block where this rank's is needed | ValueError |
A collective or Routing missing an entry for one of this rank's blocks |
ValueError |
| A message or value that is not a dict of names to Tack arrays, or a structure of arrays | TypeError |
| An array not on its block's device | ValueError |
A Plan whose sends and receives disagree in length across blocks |
ValueError |
Plan.run with element types that differ between blocks |
TypeError |
sort_by_key keys of another type, or of different types on different blocks |
TypeError |
all_reduce with an operation other than "sum", "min" or "max" |
ValueError |
World(gpu_aware=True) without a communicator |
ValueError |
Inspection¶
tack.inspect(kernel, *args, mode="source")¶
Defined in packages/tack-core/src/tack/lang/inspect_kernel.py. Runs the
compilation pipeline for these arguments on the device a call would run on
(see launch placement) and returns
a string. It doesn't execute the kernel, doesn't compile a GPU variant, and
doesn't populate the backend's variant cache.
mode |
Returns |
|---|---|
"ir" |
Tack IR after resolution, inference, scalar localization, optimization and type annotation (ir.dump), before GPU scalar packing |
"source" |
The backend's source. On CPU, LLVM IR for the variant these arguments would select, including the disjoint-array specialization. On Metal, CUDA, HIP and Level Zero, MSL, CUDA C, HIP C or OpenCL C after scalar packing |
"optimized" |
CPU only: the LLVM module after the backend's O3 pipeline. On any other backend it raises ValueError ("inspect mode 'optimized' is CPU only: ..."), after the kernel's own checks have run, because the GPU backends' optimizers run inside vendor compilers whose output is never shown |
Guarantees: inspection enforces the same dtype, workgroup-support,
atomic, alignment, participation and workgroup launch-count checks as
dispatch, and it raises the frontend's errors. It records texture extents through the
backend's _store_texture_shapes(), so on HIP and Level Zero devices
without texture hardware the source it shows uses software sampling, as
dispatch would. See
the note on inspection
for the dispatch checks it skips.
Errors: TypeError "Expected a @tack.kernel, got function" when the
first argument isn't a Kernel. ValueError "Unknown inspect mode:
'ptx'. Use 'ir', 'source', or 'optimized'." Unlike dispatch, inspection
doesn't wrap errors. For example, a workgroup primitive on CPU raises
NotImplementedError directly, and an unsupported array dtype raises the
dtype check's TypeError.
Decorators¶
@tack.kernel¶
Returns a Kernel (lang/kernel.py). Decoration reads the function's
source with read_source (lang/func.py, a wrapper around
inspect.getsource) and parses it. Current behavior:
nothing is validated or compiled at decoration. Source validation, name
resolution and lowering happen at the first dispatch or inspection, and
their errors are raised there.
| Condition | Exception, and when |
|---|---|
Source isn't readable (exec(), a bare REPL, python -c before Python 3.13) |
RuntimeError at decoration, which explains where kernels must be defined |
Unsupported construct, including calls to functions that aren't statically bound @tack.funcs or intrinsics |
UnsupportedSyntaxError at first dispatch or inspection: "Kernel 'k': unsupported Assert at line 4, column 9". Lines refer to the dedented captured source |
| Free name that is neither a parameter nor assigned | NameError at first dispatch or inspection, naming the kernel or inlined device function and its position |
@tack.func¶
Returns a Func (lang/func.py) that is inlined at each call site in a
kernel. Device-function binding, return restructuring and recursion
rejection are Required items of the language contract. See
Source and supported constructs.
| Condition | Exception |
|---|---|
Decorating something that isn't a def, such as a lambda |
TypeError at decoration |
| Source isn't readable | RuntimeError at decoration, the same explanation as for kernels, naming the device function. The OSError is chained |
| Calling the function from Python | RuntimeError: "@tack.func 'f' cannot be called from Python. ..." |
@tack.device_class¶
@tack.device_class (lang/device_class.py) marks a class so that its
instances can be passed as kernel arguments, and it collects the class's
@tack.func methods. When an instance is passed:
| Attribute | Becomes | Recompiles when changed? |
|---|---|---|
Class-level int or float defined on the class itself |
A compile-time constant | Yes. The value, with its type and float bit pattern, is part of the key |
| Instance attribute that shadows such a class attribute | A compile-time constant with the instance's value | Yes |
Instance int or float |
A runtime scalar parameter | Only when its inferred type changes. The attribute names are in the key, and the values are not |
Instance Array |
An extra array parameter | Only when dtype, shape or vector width changes |
Names starting with _, and attributes of other types |
Ignored | — |
Calling a method that isn't a @tack.func, or referencing a self.
attribute that is none of the above, raises ValueError at first
dispatch. Specializations are keyed on the class object itself, so two
classes with the same name don't share variants. When a class is garbage
collected, its variants are dropped.
Instances are recognized by argument type (the class's _device_class
flag), so a parameter that takes one needs no annotation.
Calling a kernel¶
Kernel.__call__(*args) dispatches on the device its arrays choose (see
launch placement) and returns
None, or, for a kernel that ends in an annotated return, that value,
read from a hidden one-element array (which waits for the launch). On the
CPU the call returns after the kernel completes; on the GPU backends once
it is queued (see Host operations).
Arguments, matched positionally against the parameters:
| Argument | Treated as |
|---|---|
Array |
An array parameter. Its dtype must be in supported_dtypes |
Texture3D |
A sampled texture parameter. The kernel samples the texture's own copy, not its array |
int, bool or NumPy integer |
A scalar: i32 if it fits, else i64, else u64. Outside the u64 range raises TypeError |
float or NumPy floating |
A scalar: f64 if any array argument is f64, otherwise f32. Textures are always f32. A NumPy float64 with only f32 arrays becomes f32 |
@tack.device_class instance |
Expanded as described above |
| Anything else | TypeError: "Unsupported argument type for parameter 'a': |
Current behavior:
- Changing a scalar's value never recompiles. Changing its inferred type, an array's dtype or vector width, a baked shape dimension, or a texture extent compiles a new variant. See Specialization and compilation identity.
- One kernel may be dispatched from several threads concurrently. Each
thread's dispatch uses its own arguments. On CUDA and HIP, dispatches of
the same compiled variant are serialized by a per-variant lock,
KernelVariant.dispatch_lock, held from packing the scalars until the launch is queued, so different variants still dispatch concurrently. Metal launches own their argument buffer and scalars and are encoded one at a time under the backend's batch lock. Level Zero serializes all launches, reductions and copies on one backend lock (_launch_lock). CPU binds arguments per call and takes no lock, but fan-outs through its JIT'd pool run one at a time.test_concurrent_dispatch.pycovers this. - Required caller constraint: on CUDA with one device in use, Tack's
context is current only on the thread that made the device (the first
tack.device("cuda"),set_default_device("cuda")or allocation on it). Another thread must make it current, for example withcuCtxSetCurrent(backend._context), before dispatching. Otherwise the launch fails withCUDA_ERROR_INVALID_CONTEXT. Once Tack switches contexts (two devices; see Devices), every operation makes its own context current, from any thread.
Rejected:
- Keyword arguments.
resolve_variantraisesNotImplementedError("Keyword arguments not supported in kernels"), which reaches the caller asRuntimeError: "Kernel 'k' failed on CPUBackend: Keyword arguments not supported in kernels". - A wrong argument count.
TypeError: "Kernel 'k' expects 2 arguments, got 1". Arguments are counted after template expansion. - A read-only array where the kernel may store.
ValueErrorbefore the launch. See Pointer interop.
Environment variables¶
| Variable | Read by | Effect |
|---|---|---|
TACK_CPU_THREADS |
CPUBackend.__init__ |
CPU worker count. Read each time a CPU backend is constructed, unless tack.device("cpu", num_threads=...) gives one. Values below 1 become 1. 1 runs everything on the calling thread. A non-integer raises ValueError from tack.device |
TACK_CPU_POLICY |
CPUBackend.__init__ |
Accepted for old scripts only: unset, empty or v2, the one threading policy since v1 was removed. Any other value, v1 included, raises ValueError from tack.device |
TACK_CPU_MARGIN |
CPUBackend.__init__ |
Float override of the threading decision's safety margin |
TACK_CPU_POOL |
CPUBackend.__init__ |
python (or 0, off, no, false) fans out through a ThreadPoolExecutor instead of the JIT'd pool, which is the default wherever it can be made (not on Windows) |
TACK_CPU_POOL_SPIN |
The JIT'd pool, when it is made | How many pause iterations a pool worker spins before sleeping (default 20000). For experiments |
TACK_CPU_MALLOC |
CPUBackend.__init__ |
Linux. A boolean flag read like TACK_NO_REINIT. When on, the first CPU backend sets glibc's malloc thresholds so freed blocks keep their pages (cpu._tune_malloc), process-wide. Off by default |
TACK_DEBUG |
import tack (runtime/dispatch.py) |
A boolean flag read like TACK_NO_REINIT: turns on debug mode's bounds checks. tack.set_debug overrides it (above) |
TACK_NO_REINIT |
tack.set_default_device |
A boolean flag (dispatch.env_flag): when on, set_default_device leaves an existing default alone. 0, false, no, off and empty mean off. See above |
TACK_CACHE |
The disk cache, at the first kernel compiled | write (default), read, bundle (default under MPI) or off; also 0, false, no, none for off. Any other value raises ValueError. See The Kernel Cache |
TACK_CACHE_SIZE |
The disk cache, at the first kernel compiled | The most the entries of every Tack version may take: bytes, a size such as 500M or 2G (binary units), or unlimited; default 1G. Past it, a process that stores entries deletes the least recently used, at most once an hour. Any other value raises ValueError |
TACK_CACHE_DIR |
The disk cache, at the first kernel compiled | The cache directory. Default: the platform's per-user cache directory. It must belong to the user and be writable by nobody else, or it is not used (with a RuntimeWarning) |
TACK_DUMP_MSL |
Metal kernel compilation | Metal only. A boolean flag read like TACK_NO_REINIT. When on, writes each compiled kernel's MSL to /tmp/tack_<entry>.msl and prints the path. A variant loaded from the disk cache is not generated, so not dumped. Other backends ignore it. Use tack.inspect instead |
TACK_REQUIRE_CLANG |
Test suite (tests/compiler_tools.py) |
Any value other than empty or 0 turns missing or unusable Clang tooling into a test failure instead of a skip |
TACK_CLANG, TACK_CLANGXX |
Test suite | An explicit Clang driver for OpenCL/C and C++ host checks. An explicit choice that fails its preflight is a failure, and there is no fallback to PATH |
TACK_EXAMPLES_ARCH |
Test suite (tests/test_examples.py) |
Backend for the slow example sweep. The default is cpu |
The CPU threading variables are tuning controls, not part of the language contract. They change scheduling and never change results of race-free programs. See CPU Threading Policy.
tack.cache¶
| Function | Effect |
|---|---|
configure(directory=None, mode=None, max_size=None) |
Sets the directory, mode and size limit for this process over TACK_CACHE_DIR, TACK_CACHE and TACK_CACHE_SIZE; None leaves one to the environment. Takes effect at the next kernel compiled |
settings() |
(directory, mode, MPI rank or None) as they apply now |
size_limit() |
The size limit in bytes as it applies now, or None for no limit |
load_bundle(path=None, comm=None) |
Reads a bundle (by default the directory's, for this Tack version) into the process. With an mpi4py communicator it is collective: rank 0 reads and broadcasts. Returns the number of entries |
pack(directory=None) |
Writes this Tack version's entries into one bundle file; returns (path, count) |
prune(directory=None, max_size=None) |
Deletes the least recently used entries of every Tack version until they take at most 90% of max_size (default: the size limit), and abandoned temporary files; keeps bundles. Returns (files removed, bytes left) |
clear(directory=None, all_versions=False) |
Deletes this Tack version's entries and bundle, or every version's (only the names the cache makes); returns the number of files removed |
describe(directory=None) |
The settings, entry count, size and bundle, as a dict |
stats(), reset_stats() |
Hit, miss and write counters for this process |
python -m tack.cache info | pack | prune [--max-size SIZE] | clear [--all] runs the same from a shell.
Exceptions¶
Tack defines one exception class of its own, UnsupportedSyntaxError
(lang/source_validation.py). It subclasses NotImplementedError, which
in Python subclasses RuntimeError.
Kernel.__call__ translates exceptions as follows:
- It re-raises
UnsupportedSyntaxErrorunchanged. - It re-raises a
TypeErroras aTypeErrorprefixed withKernel '<name>':, unless the message already starts with that name, so the kernel is named once. - It re-raises any other
RuntimeError, includingNotImplementedError, asRuntimeError("Kernel '<name>' failed on <BackendClass>: ..."), dropping an innerKernel '<name>':prefix. For compiler failures, it uses a short "failed to compile" form that lists the error lines. - It passes everything else through unchanged, notably
ValueErrorandNameError.
Translated exceptions keep the original as __cause__.
| Exception | Raised for | Where |
|---|---|---|
UnsupportedSyntaxError |
Syntax outside the kernel language, in a kernel or a device function, including unreachable statements; a kernel without exactly one parallel loop directly in its body; a store, atomic, barrier, block reduction or print outside the parallel loop, including through an inlined device function or template method |
First dispatch or inspection (frontend) |
NameError |
A name that is neither a parameter nor assigned. Kernels don't capture Python globals | First dispatch or inspection (frontend) |
TypeError |
A device spec that is neither a string nor a Device, options with a Device, argument count or type, an integer outside 64 bits, an array dtype unsupported by the backend, an unsupported atomic dtype or target, the MSL generator receiving f64, inspect of a non-kernel, @tack.func on a non-function, a dtype argument that is neither a Tack type nor a NumPy dtype with a Tack type, array, empty_like or DLPack dtypes, an invalid *_like prototype, concat dtypes, copyto into a non-array, host element assignment or iteration, a non-MTLBuffer on Metal, an array_from_ptr pointer that isn't an address on CUDA, HIP or Level Zero |
Dispatch (cold), inspection, host calls |
ValueError |
Unknown device type, device number or option, arrays on two devices in one launch, an unknown TACK_CPU_POLICY, incomplete external_context, workgroup participation, launch counts or device group size, a launch or reduction larger than one grid can index, atomic alignment, a read-only array the kernel may store to, array_from_ptr memory space, shape mismatches (including copyto shapes that do not broadcast), reshape, texture3d arguments, empty min/max, empty concat, unknown inspect mode or "optimized" off CPU, template method or attribute errors, malformed or kDLMetal DLPack tensors |
Device creation, dispatch (cold or every call), inspection, host calls |
NotImplementedError |
Workgroup primitives on CPU, keyword arguments, and the few lowering checks that source validation doesn't already report as UnsupportedSyntaxError |
Raised directly by inspection. Wrapped in RuntimeError at dispatch |
RuntimeError |
Device start-up failures, compilation and launch failures, unreadable kernel or device-function source, host writes to read-only arrays, calling device-only functions (tack.shared, tack.atomic_add, @tack.funcs) from Python, DLPack device refusals, export_memory on unsupported backends, tack.interop.vtk requirements, and IR verification failures (IRVerificationError, a RuntimeError subclass, raised when a pass leaves IR that breaks a structural invariant) |
Everywhere |
BufferError |
__dlpack__(copy=True) |
Export |
IndexError |
In debug mode, a launch that indexed an array out of bounds; host indexing f[i] outside the array |
After the launch, from Kernel.__call__; host calls |
Current behavior: every dispatch rejection listed on this page and in
Backend capabilities is raised before the kernel
launches, so a rejected call leaves array storage unchanged.
test_workgroup_contract.py tests this property for workgroup rejections,
test_atomic_contract.py for alignment, test_launch_limits.py for
oversized launches, and test_outside_parallel_loop.py for effects outside
the parallel loop.