Kernel language contract (draft)¶
This is the draft contract for compiler hardening, updated for the 0.6.0
release on 2026-10-28.
It defines the intended portable kernel model, identifies known violations,
and separates decisions still open for discussion. It is not a claim that
the current implementation satisfies every requirement below. The baseline
was reviewed at 05174e1.
Tack is a language for numerical kernels expressed using Python syntax. Its goals are predictable numerical results, portable CPU/GPU execution, and a compiler small enough to maintain. It does not implement arbitrary Python, provide autograd, or replace NumPy's general-purpose array API.
Status and interpretation¶
- Required means an intended correctness property. A current violation is a compiler defect, including when existing examples happen to work.
- Current behavior describes the implementation without making that behavior a permanent language guarantee.
- Proposed means a policy this draft recommends but that needs agreement before becoming a public guarantee.
- Open means no portable result is promised by this draft for that case. An open decision is not permission to change existing behavior silently.
Known defects are listed under Regression baseline. This draft governs the hardening work where older guides disagree. The guides remain useful examples, but are not an exhaustive semantics specification.
Source and supported constructs¶
Required: the frontend must either translate a construct according to its defined semantics or reject it with a diagnostic. Silently dropping a statement, operand, or control-flow effect is not acceptable. Diagnostics identify the construct, kernel or device function, and source location. The frontend validates original source before lowering, including unreachable statements in device functions. Locations currently refer to the captured, dedented source, rather than absolute file coordinates.
The current language surface includes the following families. Inclusion does not imply that every Python use of the syntax is supported or that all corner cases have been validated.
| Family | Kernel surface | Boundary |
|---|---|---|
| Values | Numeric literals, scalar parameters, array loads, local variables, tack.constant names (scalars, vectors, matrices), vectors and matrices up to 4×4 |
Fixed-width Tack types, not arbitrary Python objects. Assigning to a scalar parameter makes it a per-iteration local (LC6). Vectors and matrices are scalarized; see Vectors and matrices |
| Arithmetic | Arithmetic, comparisons, Boolean expressions, explicit casts, listed math builtins, @ on vectors and matrices |
Numerical and evaluation rules below. Comparisons and Boolean expressions of vectors act per component |
| Assignments | Local assignment, augmented assignment, array stores, tuple assignment to names, array elements and components | Storage and ordering rules below |
| Control flow | range, tack.ndindex over sizes or (start, end) pairs, nested sequential loops, while, if/elif/else, conditional expressions, break, continue |
One top-level parallel loop; statements outside it only bind locals, load arrays and declare arrays |
| Composition | @tack.func inlining, returning one or several values (or another call's several values); a kernel's trailing return; @tack.device_class templates, passed to @tack.funcs and methods as well as to kernels, with inheritance, device functions held as attributes or under @staticmethod, kernels as methods, and indexing through @tack.func __getitem__, __setitem__ and __len__ (obj[i], obj[i] = v, obj[i] += v, len(obj); several indices as one vector; instance tuples of numbers read as obj.shape[k] with a literal k) |
Static source transformation, not arbitrary Python calls. A return ends the function on its path; one inside a loop is rejected (LC7) |
| Storage | Scalar arrays, vector arrays, matrix arrays, local arrays, shared memory, 3D textures | Backend capability restrictions apply |
| Parallel primitives | Atomics, barriers, thread index, block reductions | Workgroup requirements below |
Python exceptions, try, context managers, generators, and runtime Python
object manipulation are outside this baseline and rejected. So are imports,
assertions (LC4), nested definitions, comprehensions, annotated assignments,
keyword/starred arguments, and unsupported operators. Docstrings and pass
are explicit no-ops. Parameter annotations and decorators remain host
metadata; ordinary positional parameters without defaults are supported.
Kernels do not capture numerical values from the enclosing Python scope: reading a name
that is not a parameter and is never assigned raises NameError with the
kernel (or inlined device function) and source position. The exception is
explicit: a name bound to a tack.constant in the function's globals or
closure, or reached as an attribute of a module, reads as that constant's
value, a literal fixed where the constant was declared. Untyped, it
follows the literal rules; declared with a dtype, it is that literal under
the corresponding cast. A parameter or local of the same name shadows it.
A kernel reads the constant its name was bound to when the kernel was
first lowered. math.pi, math.e and math.tau read as literals in the
same way. A for loop's
variable is a binding that ends with its loop: reading the name after the
loop, before it is assigned again, raises NameError with the read's
position (Python would give the last value iterated; copy it to another
name inside the loop instead). Sibling loops may share a variable, and the
name may be assigned as an ordinary local afterwards. Local and shared
arrays are storage, not values: binding another name to one (view = tmp)
is rejected, while indexing it or passing it to a device function is
supported.
Supporting device assertions later would require changing this contract
and its rejection test together. Barriers are statement-only; an atomic
is a statement or an expression whose value is the element's old value.
Required: device-function binding. A static @tack.func call resolves
to the function object bound in the defining kernel or device function's
Python globals or closure. Imported aliases and module-qualified calls are
supported. Each nested callee uses its own defining namespace; arguments
use the caller's namespace. Importing another module with a same-named
function must not change either cold or cached compilation. Local names
and parameters shadow external callable bindings; runtime function values
and ordinary Python callables are rejected rather than resolved by name.
Resolution follows module dictionaries only and does not evaluate object
properties or arbitrary Python expressions. Recursive device calls are
rejected with a source diagnostic.
Template methods retain their original defining bindings after self
resolution. Generated method calls use a transformation-local map and a
source marker, so a user function with the same generated spelling remains
distinct. No process-wide device-function registry participates in
validation or lowering. Static bindings are consulted when an IR
specialization is first built; cached IR preserves the inlined bodies.
Rebinding a callable afterward is not a supported way to update compiled
kernels; recreate the kernel to capture changed bindings.
Required: identifier preservation. Python-legal parameter, local, loop, allocation and kernel names retain their binding identity even when they are target-language keywords, builtins or compiler helper names. GPU code generation encodes every binding into a dedicated namespace and all backends use a shared encoding for kernel entry lookup, including Unicode names. Generated lowering temporaries, vector components, template parameters and packed scalar buffers allocate fresh spellings before code generation. These internal spellings are not a public ABI; argument order and resource binding indices stay unchanged.
Current behavior: kernels are compiled from inspectable source; a bare
REPL or dynamically generated function may not provide that source. Kernel
arguments are positional. A kernel whose last statement is return expr,
with a return annotation naming the type (-> tack.f32, -> float for
f32, -> int for i32), gives the host that value: after the launch, a
one-thread epilogue kernel runs the statements outside the parallel loop
and stores the expression into a hidden one-element array of that type,
which the call reads and returns as a Python number. The expression may
read arrays, arguments, constants and locals bound outside the loop, not
a local the loop assigns. A kernel without one returns None. Device
functions can return supported scalar, vector or tuple values for inlining.
Execution and ordering¶
Required: the portable baseline has one top-level parallel range or
ndindex iteration space. Each iteration owns its local variables. There
is no defined order among different parallel iterations. Algorithms must
avoid conflicting cross-iteration memory accesses unless they use the
appropriate supported synchronization or atomic operation.
Required: the parallel loop is a for statement directly in the
kernel body. A kernel without one, with a second one, or with one nested
in a branch or while is rejected with a source diagnostic. Statements
outside the parallel loop, before or after it, may be evaluated any
number of times per launch: once per GPU thread, and on CPU once per
chunk, threading probe or timing sample. They may therefore only assign
local variables, load arrays, and declare shared or local arrays, which
is unobservable however often it runs. Stores to arrays,
atomics, barriers, block reductions and print there are rejected with
the kernel or device function and source position, including inside
top-level branches and while loops and when they arrive through an
inlined @tack.func or template method. Every parallel iteration starts
from the values those statements bind; an iteration that reassigns such
a local does not change what another iteration sees.
Within one iteration, statements and nested sequential loops obey program order. A load observes an earlier store to the same location by that iteration, absent a conflicting access from another iteration. This holds across loop iterations, conditionals, and inlined device-function bodies. An empty sequential loop does not execute its body. See LC1.
Required: scalar operands and function arguments evaluate from left to
right. Inlined calls retain their original execution position. and and
or short-circuit; a conditional expression evaluates only its selected
arm. Chained comparisons stop at the first false comparison and evaluate
the middle operands' effects once. Ordinary assignment evaluates its value
before its store index. Augmented assignment evaluates its index once and
reads the old value before evaluating the right-hand side. A tuple
assignment evaluates its whole right-hand side and then assigns its
targets from left to right, so a subscript target's index sees the names
assigned before it and not those after. A vector
assignment evaluates every component of its right-hand side before it
assigns any: v = v.cross(w) and a[i] = a[i].cross(b[i]) read the old
components, including when the arrays read and written share storage.
Sequential
range arguments are evaluated once before entering the loop, including
when its body changes a bound or step variable.
The top-level range(start, end) executes exactly the indices in
[start, end), and no iteration when that interval is empty or reversed.
Backends launch their grids from zero, so the frontend moves a nonzero start
into the body; the length of the interval remains a per-dispatch value and
does not specialize the compiled kernel. See LC5.
Required: every index of the interval executes, however long it is,
or the launch is refused before anything runs. CUDA and HIP compute the
iteration index in 64 bits from the block and thread numbers, and Level
Zero from the work-group number (get_group_id), because Intel's
get_global_id wraps at 2^32. A launch
longer than one grid of the backend can index raises ValueError naming
the kernel, the count and the limit. CUDA admits its device's maximum
gridDim.x times 256 iterations; HIP also at most 2^32 - 256, because
the AMD dispatch packet holds the work-item count in 32 bits; Metal 2^32,
the range of its uint thread position; Level Zero its device's maximum
group count times the workgroup size.
Early exits must preserve the defined control flow. continue ends the
current iteration of its nearest enclosing loop, including the top-level
parallel one, where it skips the rest of that iteration only (LC8). This
frontend rejects break from the parallel loop: there is no ordered prefix
of parallel iterations to stop. A kernel return anywhere but as the last
statement is rejected. Positive range steps are supported, and literal
zero or negative steps are rejected. A dynamic step must be positive;
runtime validation of that caller constraint remains open. Cross-iteration
communication requires further validation before joining the portable
baseline. Outer launch bounds must be resolvable from host arguments and
array metadata.
Required: optimizations preserve the observable behavior of every defined, race-free program, whether or not a current example uses that pattern. In particular, an invariant address does not make the stored value invariant. Moving a computation must also respect whether it executes at all; moving a load out of a zero-trip loop can introduce an invalid access. When a pass cannot establish safety, it must leave the computation in place.
Current implementation: Tack performs conservative copy propagation, retaining assignments and replacing only subsequent uses when neither the copy nor its source is rebound anywhere in the kernel. One kernel-wide assignment summary, including loop and allocation bindings, is reused in nested blocks. This deliberately leaves some block-local copies to the backend compiler instead of recounting every subtree. Custom load hoisting and CSE are disabled until memory and control-flow analyses can establish their safety. LLVM and the vendor compilers still perform their own optimizations.
Memory and aliasing¶
Arrays (kernel arguments that are tack.Arrays) are typed, shaped
storage associated with a backend. A new array holds unspecified contents
until a kernel, tack.copyto or fill writes them; tack.zeros, ones
and full make filled arrays.
A subscript of
an array gives one index per dimension, or a single flat, row-major index;
a vector of integers stands for one index per component. Binding an array
whose number of dimensions differs from the number of indices a kernel
gives it raises TypeError at the call, naming the source position.
Indices are not checked against the array's extents, except in debug
mode (below). An array view or
an imported pointer can share storage with another array. Host code reads
one element with f[i, j] (a Python number, or a NumPy array of a vector
or matrix element) and may not write one; DeviceBuffer.read_range
defines the copy, which is a slice of host-visible memory on CPU and
Metal and a copy of just the element on CUDA, HIP and Level Zero. Object identity
does not establish that two arrays have different backing allocations.
Callers must keep external storage alive for its use and must pass, in one
launch, arrays in one device's memory; a launch runs on that device (the
default device when it can address them).
Required: support overlapping array arguments by default, preserving
program order within each iteration. This includes passing the same array
twice and passing distinct views over the same storage. It does not make
cross-iteration data races valid. LC2 tests this policy. LLVM array parameters
carry no noalias promise; CUDA/HIP and OpenCL array pointers carry no
__restrict__ or restrict promise. Metal loads array pointers from one
argument buffer, so overlapping arrays are not passed as separate device-buffer
kernel arguments.
Disjoint-storage specialization (CPU). The CPU backend may compile a
second variant whose array pointers carry noalias, and uses it only for
calls it has checked. The check runs on every dispatch and compares the byte
ranges of the array arguments, never Python object identity: a call
qualifies when no array the kernel stores to, directly, atomically, or
through an inlined device function, shares a byte with any other array
argument. Arrays that are only read may overlap each other. Calls that do
not qualify run the variant without the promise, so the overlap policy above
is unchanged and the two variants must agree on every race-free program.
The qualification is part of the compiled-variant key. test_disjoint_arrays.py
covers the analysis, partial overlaps of imported storage, the generated
signatures, and switching between overlapping and disjoint calls.
Textures. tack.texture3d(array, shape=(W, H, D)) copies the array's
data when it is created, on every backend. Later writes to the array,
whether kernel stores, tack.copyto, or external and DLPack writes, are not
visible to the texture until tex.update() copies the array again; sampling
after that call sees the array's data as of the call. The copy belongs to
the texture: a hardware image on CUDA, Metal, HIP with image support and
Level Zero with samplers, otherwise a private array that generated code
interpolates in software. It is released with the texture, and is never
looked up by device address, so one texture cannot be served another's
data. The array must be f32 with exactly W·H·D elements, x varying fastest;
only interp='linear' is implemented, and other modes are rejected.
test_texture_snapshot.py covers this on every available backend.
Required caller constraints for this baseline: access only in-bounds
elements and initialized values; write only to writable storage. Dispatch
refuses a read-only array (array_from_ptr's default, or a DLPack import
flagged read-only) bound to a parameter the kernel may store to or update
atomically; when a store cannot be traced to a parameter, every array
argument counts as written. That is a check of the binding, not of accesses:
writable arrays aliasing read-only storage are not detected. Indices are
not checked, and an out-of-bounds access has no defined result. Debug mode
(tack.set_debug(True) or TACK_DEBUG=1) is the exception: it checks
every index of every array access against its extent, one
dimension at a time (so a[i, j + 1] past the last column is caught even
when its flat index lands inside the array), skips an access outside it (a
load gives 0, a store or atomic does nothing), and makes the call raise
IndexError naming the first such access; see the
runtime API. It is a diagnostic, compiled
separately; it does not bring out-of-bounds accesses into the baseline.
Negative indices are outside the portable baseline; Python's
wraparound indexing is not promised.
Types and numerical behavior¶
Arrays have explicit fixed-width integer or floating-point element types. Backend capabilities restrict which types can execute. Unsupported types must be rejected rather than silently narrowed to a supported type.
CPU array loads and stores promise only byte alignment, since imported buffers may be unaligned and byte-sized elements do not preserve four-byte alignment. The numerical regression checks imported buffers at a one-byte offset for all ten scalar types and verifies that emitted LLVM does not claim stronger alignment than the storage provides.
Current behavior: Python floating-point arguments default to f32 unless
an f64 array argument establishes an f64 context. Float literals are weakly
typed and take the floating type of the operand they meet; the rule is
under the floating-point execution policy below. Integer scalar arguments
use i32, i64, or u64 based on magnitude; values outside the supported
64-bit integer ranges are rejected. Signed integer literals are classified
by their complete value, so -9223372036854775808 fits i64. Locals receive one storage type
derived from their assignments. These are Tack rules, not Python's dynamic
typing rules. The hardening work must validate that inferred expression
types and emitted operations agree, including signedness and narrowing.
Comparisons, not, and, and or produce normalized i32 values 0 or 1.
In particular, Tack's and and or return Boolean values, rather than the
selected operand that Python returns for numeric operands. Boolean literals
also have numeric values 0 and 1. Short-circuiting governs execution
independently of this result-type rule. Stage three corrects CPU results
that previously sign-extended a true LLVM i1 to -1.
For the initial regression baseline, numerical expectations use small, representable integers and exact f32 values.
Required: integer floor division and remainder. For integer operands,
a // b rounds the mathematical quotient toward negative infinity. The
remainder a % b satisfies a == (a // b) * b + (a % b), has magnitude
less than abs(b), and is zero or has the divisor's sign. For example,
-7 // 3 == -3, -7 % 3 == 2, 7 // -3 == -3, and 7 % -3 == -2.
This follows Python's integer arithmetic rules.
Both operators use integer arithmetic, including i64/u64 values beyond
floating-point precision. Operands evaluate once in the ordering defined
above. Unsigned operands use unsigned division and remainder.
The operation uses the type annotation pass's promoted integer type; both operands are converted to that type before arithmetic, and the result has that type. This increment defines results when those conversions preserve both input values and the quotient fits the promoted type. Integer promotion now preserves both full input ranges, as defined below. In particular, i32 with u32 promotes to i64.
Required caller constraints: the evaluated divisor is nonzero. For a
signed promoted type, the minimum representable value with divisor -1
is excluded for both // and %; the quotient is unrepresentable and
LLVM's signed remainder also excludes this pair.
Runtime exceptions or defined overflow results for these cases are not
promised. Guarding an operation with if or a conditional expression must
avoid executing it on the unselected path. Floating-point // and % are
outside this integer guarantee.
test_integer_division.py compares against Python integer arithmetic on
every available backend, including exhaustive valid i8 pairs, all integer
widths, unsigned high-bit values, boundary and seeded random inputs,
nested/local expressions, literal and lossless mixed-type promotion,
single evaluation of inlined operands, and guarded zero divisors. CUDA,
HIP, Metal, and OpenCL generators share typed C-family helpers; LLVM emits
signed truncating operations with floor correction, or unsigned operations.
Required: floating floor division and remainder. With either operand
floating, a // b and a % b convert both operands to the promoted
floating type: f64 if present, otherwise f32. Both results have that type;
// returns an integer-valued float, without narrowing to an integer.
An f64 output array alone does not widen f32 array operands; explicit f64
casts do. Mixed integer operands can lose precision in this conversion.
The operations use a truncating floating remainder, corrected to the
divisor's sign. The quotient is reconstructed from that remainder and
snapped to a nearby integral floating value, following
CPython's floating division/remainder algorithm.
For finite operands this preserves Python-style floor/sign behavior near
rounded division boundaries; merely applying floor(a / b) is insufficient.
Floating rounding may make a corrected remainder equal to the divisor's
magnitude or prevent exact reconstruction of a from q*b + r.
This does not promise exact real arithmetic or general bitwise agreement.
A zero remainder has the divisor's sign. A zero quotient has the sign of
the true quotient, including when the dividend is signed zero. NaN in
either operand, or an infinite dividend, produces NaN for both operations
(NaN sign/payload is unspecified). With a finite dividend and infinite
divisor, zero dividends produce the signed zeros above; a nonzero dividend
of the same sign produces signed-zero quotient and the original dividend
as remainder, while opposite signs produce -1.0 and the signed infinite
divisor as remainder. Overflow of a finite reconstructed quotient remains
floating infinity; it must not go through an invalid float-to-integer cast.
Floating negation flips zero's sign. Floating != and truth tests treat
NaN as unequal/nonzero, so a nonzero-divisor guard does not accidentally
discard a NaN operation on CPU. Other ordered comparisons with NaN are
false. These expression rules apply to ordinary kernels as well as the
operator helpers; see the floating-point execution policy below.
Required caller constraint: the evaluated divisor is nonzero. No
device ZeroDivisionError is promised; guards must preserve the stated
evaluation/side-effect ordering. Portable guarantees here exclude denormal
inputs and nonzero denormal intermediate/results, as specified by the
floating-point execution policy below. Extending denormal support would
require a capability policy or fallback.
Operands evaluate once, left to right, before the typed arithmetic.
All CUDA kernels compile without --use_fast_math; Metal disables
fastMathEnabled for all kernels. LLVM helpers use ordinary floating
instructions without fast-math flags; HIP/OpenCL retain default math
settings without unsafe options, except that HIP limits contraction to
one expression (-ffp-contract=on). The previous increment restricted safe
math to kernels with floating // or %; the execution policy below
extends it to every kernel, including runtime reduction sources. These
settings follow the
NVRTC fast-math options
and Metal compile options.
Behavior change: CPU floating % previously used dividend-signed
truncating remainder, and its // directly floored the rounded division.
GPU floating % could emit an invalid integer-only C % expression;
GPU // forced f32 division and then narrowed to a 64-bit integer, losing
f64 precision and overflowing for large/nonfinite results. All five
emitters now preserve the annotated floating result type.
test_float_division.py compares to NumPy's typed divmod after explicit
operand conversion. It covers signs, rounded multiples and neighbours,
large/overflowed quotients, seeded finite inputs, NaNs/infinities/signed
zeros, every floating/integer type pair, explicit precision, nested
expressions, single evaluation and guarded zero divisors. Expected zeros
and exceptional classes/signs match exactly; tested finite nonzero results
use a four-ULP tolerance. This is a regression bound for these supported
inputs, not the still-open general arithmetic accuracy guarantee.
Required: fixed-width arithmetic and integer conversion. Integer +,
-, *, unary negation, and bitwise operations produce the low N bits at
the annotated result width. Signed types interpret those bits as two's
complement; unsigned types interpret them as nonnegative integers. Thus
i8 127 + 1 gives -128, and u16 65535 * 65535 gives 1. Each nested
operation wraps before its result is used by another operation. Integer
abs preserves its input type; the signed minimum therefore remains the
minimum under this wrapping policy. Unsigned abs is the identity.
Integer min, max, and comparisons use exact integer values and the
promoted operand type, including unsigned high-bit values.
Promotion chooses the narrowest integer type that preserves both full operand ranges. Equal signedness chooses the wider type. Mixed signedness chooses a signed type with sufficient width: i8 with u16 gives i32, and i16 with u32 gives i64. Mixing any signed type with u64 is rejected with an explicit-cast diagnostic. This applies to arithmetic, comparisons, conditional arms, and joining assignments to one local variable. An explicit cast can express intentional wrapping before promotion. True division and integer power have the distinct result-type rules below.
Integer-to-integer casts and integer array stores reduce the mathematical
value modulo 2^N at the destination width, then interpret the resulting
bits using the destination signedness. Widening therefore respects the
source signedness: i8 -1 to u64 gives 2^64 - 1, while u8 255
to i64 gives 255. Narrowing and changing signedness are defined even
when the original value cannot be represented by the destination type.
Shifts preserve the left operand's type, independently of the count type.
Left shifts wrap at that width; signed right shifts fill with the sign bit,
and unsigned right shifts fill with zero. Required caller constraint:
the count is an integer in [0, N). Invalid evaluated counts have no
portable result or promised runtime exception. Guarded unselected shifts
must not execute.
Floating-to-integer conversions truncate toward zero. Required caller constraints: the input is finite and its truncated value fits the destination type. NaNs, infinities, and out-of-range values have no portable result or promised runtime exception. This increment tests unsigned conversions and signedness through locals, but does not establish a general floating-point rounding or optimization policy.
test_integer_semantics.py uses Python big integers as the oracle for
wrapping, all 64 integer cast pairs, mixed promotion, comparisons, exact
integer builtins, shifts, and complete-width literals/scalars. LLVM uses
the annotated width, source signedness for extension, and unsigned
operations where appropriate. Its wrapping arithmetic has no nsw/nuw
flags; see the LLVM arithmetic specification.
The C-family generators use unsigned carriers of at least 32 bits to avoid
signed overflow caused by implicit integer promotion, then reconstruct
signed values using representable casts. Metal uses as_type to reinterpret
the bits. Signed right shift is emitted without relying on C's
implementation-defined behavior. See C11 draft N1570 sections 6.3.1.3,
6.3.1.4, 6.5, and 6.5.7 in the
language specification.
On the tested M1 Max, pipeline compilation crashes when wrapping 64-bit
additions are optimized inside loops with runtime bounds. Metal emits a
separate noinline i64/u64 addition helper for assignments that update a local
using its previous value in those loop bodies. Other operations and
additions remain inline. The dynamic-bound
regression covers empty ranges, overflowing i32 bounds, and steps 1 and 2;
the workaround was validated on an Apple M3 as well as the M1 Max: 33
tests that emit the helper pass there. Its performance cost is unmeasured.
On the tested Intel Data Center GPU Max 1100 (IGC 2.7.11), the device compiler
widened the wrapped negation or abs of a signed minimum as its magnitude.
Level Zero emits signed negation below 32 bits and signed abs below 64 bits
as separate noinline helpers, so the result keeps the wrapping policy above
when it is later converted to a wider type. Other operations remain inline.
The CUDA JIT of drivers before CUDA 13.3 (ptxas 12.0 to 13.2) has the same
fault for i16 negation and abs; CUDA negates i8 and i16 values with a
32-bit inline-PTX subtraction, so the device compiler never sees a 16-bit
negation.
Required: true division. Integer a / b converts each operand to f32
and produces an f32 quotient, including signed/u64 pairs. It does not
truncate to an integer. For example, 7 / 2 gives 3.5, and (7 / 2) * 2
gives 7.0. This follows Python's distinction between true and floor
division, with Tack's default floating-point precision. To request f64 on
a capable backend, cast explicitly: tack.f64(a) / tack.f64(b). An f64
destination array alone does not widen the division. With a floating-point
operand, / uses the promoted floating type: f64 if present, otherwise f32.
Required caller constraint for integer operands: the evaluated divisor is nonzero. Guarded unselected divisions must not execute. Storing a fractional result in an integer array follows the floating-to-integer conversion constraints above. Large integer inputs may lose precision during conversion, and this increment does not promise bitwise-identical floating results or settle the general rounding/optimization policy.
Required: integer power. For two integer operands, a ** e and
two-argument pow(a, e) preserve the base's type independently of the
exponent's type. For nonnegative e, they produce the exact mathematical
power modulo 2^N, interpreted using the base's signedness. Intermediate
products wrap, without passing through floating-point pow. Thus i8
3 ** 5 gives -13; an i8 base with a u64 exponent still produces i8.
0 ** 0 is 1. Every nonnegative exponent representable by its integer
type is supported, including u64's full range. Both operands evaluate once,
left to right, and guarded unselected operations must not execute.
Required caller constraint: an evaluated integer exponent is
nonnegative. Negative integer literals are rejected with a diagnostic;
dynamic negative integer exponents have no portable result or promised
runtime exception. Cast the base to floating point to use negative powers:
tack.f32(a) ** e. With either operand floating, ** and pow convert
both operands to the promoted floating precision and use floating-point
power. Its accuracy and exceptional-input policy remain open.
Behavior change: prior integer / used truncating C-family division
or a CPU floating result narrowed back to its integer annotation. Prior
integer ** used floating pow and could lose large results; integer
pow could also disagree with **. Use // for integer floor division,
or an explicit integer cast of / for truncation toward zero within the
conversion domain. Use a floating cast for floating power. Result types
are now consistent between annotation and all five emitters.
test_division_and_power.py uses Python modular exponentiation as an exact
oracle across all 64 integer base/exponent type pairs, exhaustive small
domains, high-bit exponents, and nested expressions. Division checks
fractional results, signedness, explicit f64 precision, side effects,
guards, and large integer inputs with a four-ULP regression tolerance.
That tolerance is for these tested inputs, not a general floating accuracy
guarantee. All GPU generators share unsigned-carrier power helpers; LLVM
uses an internal typed helper. Exponentiation by squaring bounds execution
to at most 64 iterations, including outside-domain negative signed counts.
Floating-point execution policy¶
Required baseline: f32 and f64 expressions use their annotated
precision. Floating operands promote to f64 if present, otherwise f32;
an output array does not widen the input computation. Floating math
builtins convert arguments to their annotated result precision before
the call and return that precision before enclosing arithmetic. Integer
arguments to sqrt, trigonometric, exponential and logarithmic functions
convert to f32 unless another argument is f64. Integer abs, min, max
and power retain the integer rules above. For example, storing sqrt(i64)
in an f64 array widens an f32 result; sqrt(tack.f64(i64)) requests f64.
Required: float literals are weakly typed. Following NumPy's
NEP 50 treatment
of Python scalars, a float literal takes its precision from what it meets.
A literal expression is a numeric literal, or a unary +/-, binary
arithmetic operator or math builtin applied only to literal expressions,
or a conditional expression whose two arms are literal expressions (its
condition may be anything). A literal expression containing a float
literal is weak. A weak expression takes floating type T when it is:
- an operand of binary arithmetic or a comparison whose other operand is a non-weak expression of floating type T;
- an argument of a math builtin, including
min,maxandpow, whose result type T comes from a non-weak floating argument; - an arm of a conditional expression whose other arm has floating type T;
- the operand of an explicit
tack.f32/tack.f64cast to T; or - a value stored to a T array, used by a floating atomic on a T array, or assigned to a local whose settled type is T.
Taking T converts each float literal in the expression once, from its
exact Python value, directly to T, and performs the expression's floating
operations in T. Integer literals and integer subexpressions inside it keep
the integer rules and convert at the operation that uses them. Literal-only
subexpressions are not folded at compile time; they are evaluated in T.
With an f64 x, x * 0.1, x * -0.1, x * (0.1 + 0.2) and
x * (1.0 / 3.0) therefore equal NumPy's f64 results, x < 0.1 compares
with the f64 nearest 0.1, and tack.f64(0.1) is that double rather than a
widened f32. tack.f32(0.1) rounds once to f32.
A weak expression that meets none of these keeps the f32 default:
standing alone, as a math argument beside only literals or integers, or
combined with an integer value. Such a combination is not weak: i * 0.1
is an f32 value even where it later meets f64. Integer / between integer
literals contains no float literal, so x * (1 / 3) uses the f32 quotient
of the division rule; write 1.0 / 3.0. A local assigned exactly
once, to a weak expression, as in a = 0.1, is that expression wherever
it is read, so each read takes the precision of what it meets: with an
f64 x, a = 0.1; y = x * a is x * 0.1. That includes a device
function's literal results bound to the caller's names, such as a
parametric center returned as a vector of literals. A conditional
expression counts only when its condition reads nothing but constants. A
local with more than one assignment has one type, the join of its
assignments: when another assignment makes it f64, every literal assigned
to it converts exactly. A local every one of whose assignments is a weak
expression -- a device function returning literals from several
branches, say -- takes the kernel's float precision, as a float scalar
argument does: f64 when any array argument is f64, otherwise f32. Class-level
@tack.device_class constants are substituted as literals and follow the
same rule. A conversion to an integer type, such as int(2.5), still
evaluates the literal as f32.
An expression without f64 operands is unaffected: each literal still
rounds once to f32 and the generated code is unchanged. Generators emit an
f64 literal with full precision: LLVM a double constant from the Python
value, CUDA, HIP and OpenCL an unsuffixed decimal from Python's repr,
which round-trips exactly. MSL has no f64.
Behavior change: every float literal used to be f32, including inside
f64 expressions and under tack.f64(...), which widened the rounded f32
value. f64 kernels that use float literals now produce different low bits,
and comparisons with a literal change for values between its f32 and f64
roundings. test_float_literals.py compares f64 expressions, casts,
atomics and the f32-default cases with NumPy on every f64 backend, checks
that f32 results are unchanged on every backend, checks the emitted
constants of all five generators, and executes the CUDA/HIP generators'
f64 output on the host.
The caller must leave the CPU floating-point environment at its default
round-to-nearest, ties-to-even mode, with exceptions untrapped. Kernels do
not change or restore that environment. Basic arithmetic uses the backend's
normal floating operations; f32 division and square root are not promised
to be correctly rounded on every GPU. Overflow remains floating infinity
rather than narrowing through an integer. Floating / by signed zero
produces signed infinity for a finite nonzero numerator, and zero/zero
produces NaN; it does not raise a Python exception. This does not extend
the valid divisor domain of integer division or floating ///%.
Required exceptional behavior: ordinary operations preserve the
specified NaN/infinity classes, comparisons, truth tests and signed zeros.
In particular, NaN != NaN is true; other comparisons with NaN are false;
NaN is true in a condition; multiplying infinity by zero or subtracting
infinity from itself produces NaN. An optimizer must not replace x-x
or x*0 by positive zero for arbitrary floating inputs. Unary negation
flips zero's sign; abs/fabs clear it. floor and ceil preserve signed
zero and nonfinite classes. sqrt preserves signed zero and positive
infinity and returns NaN for negative nonzero inputs. NaN payloads/signs,
signaling versus quiet NaNs, exception flags and traps are outside the
portable API.
Floating min/max prefer the numeric operand when exactly one input is
NaN and return NaN when both are NaN. This is a numerical-kernel rule,
rather than Python's order-dependent handling of NaNs in min/max.
When both inputs compare equal to zero, either input zero sign is allowed.
Reduction and atomic min/max are separate operations and do not acquire
these guarantees from the scalar builtin rule.
Permitted contraction: a backend may fuse adjacent multiply/add or
multiply/subtract operations into one rounding. Contraction can change
low bits, cancellation, intermediate overflow and zero signs. It is
permitted, not required; CPU and GPU need not choose the same result.
Fusion stays inside one expression: a value bound to a name, or returned
from a device function, is rounded once, and every read of it sees that
value, so x - x is zero for a finite x.
General reassociation of addition or multiplication is not permitted:
(a+b)+c and a+(b+c) retain their grouping. No cross-backend bitwise
reproducibility or rounding of every source intermediate is promised.
Tests use an exact rational oracle to accept either the separate or fused
result for a contraction-sensitive expression, and separately require
parenthesized sums with cancellation to retain their order.
Compiler configuration: CUDA explicitly selects --ftz=false,
--prec-div=true, --prec-sqrt=true, and --fmad=true, without
--use_fast_math. Metal sets fastMathEnabled false. Both apply to
ordinary kernels and runtime reduction sources. CPU emits no LLVM
fast-math flags; HIP and OpenCL receive no unsafe/relaxed-math options, and
OpenCL asks for correctly rounded f32 division and square root
(-cl-fp32-correctly-rounded-divide-sqrt).
HIP compiles with -ffp-contract=on: hipRTC's default, fast, fuses
across statements, and on AMD GPUs fused a product into one read of a
variable while another read it rounded.
These choices preserve classes and grouping while retaining permitted
backend contraction. There is currently one policy, so no mutable math
mode is omitted from the variant cache key and no per-dispatch scan is
needed. A future selectable mode must participate in specialization
identity and preserve any operator-specific restrictions.
Portable domain and accuracy: nonzero denormal inputs, intermediates and results remain outside this baseline. Safe math does not imply that all hardware supports gradual underflow. Denormal capability reporting or a software fallback would be needed to extend this domain. Math builtins use standard backend routines, rather than deliberately selected fast/native approximations. Except for the class/sign rules above, transcendental and floating-power exceptional inputs outside their finite mathematical domains have no portable result promise. Global error bounds vary by function, precision and backend; there is no blanket ULP bound. Algorithms requiring tighter accuracy must establish their own domain and error budget. See OpenCL numerical compliance and the CUDA math reference.
test_float_semantics.py checks CPU/GPU execution without introducing
// or % to select stricter compilation. It covers seeded normal inputs,
exceptional arithmetic, signed zeros, unsafe identity folds, comparisons
and truth, scalar min/max, parenthesized cancellation and permitted
contraction, sqrt edge cases, all fifteen math/power forms on bounded
domains, mixed integer/float arguments, f32 libm result rounding and
explicit widening. Standalone arithmetic/sqrt finite checks use four ULP;
the bounded math smoke checks use eight ULP against typed NumPy. These
are stated regression bounds for their tested domains, not an exhaustive
proof or a new general math-library guarantee. Host-sanitized CUDA/HIP
checks and OpenCL syntax checks supplement hardware testing.
Behavior change: CUDA's unconditional fast-math option and Metal's
default fast mode are removed. Programs may observe different low bits,
images and throughput. The prior Metal mode demonstrably replaced
nonfinite identity expressions with zero, folded NaN self-comparisons to
false, and reassociated parenthesized additions. CPU libm calls previously
failed while declaring an empty intrinsic; they now call the precision-
appropriate external tanf/tan, asinf/asin, acosf/acos,
atanf/atan and atan2f/atan2 symbols. GPU math arguments explicitly
convert to their annotated type, avoiding ambiguous OpenCL overloads for
integer and mixed arguments. No performance improvement is claimed.
Random numbers¶
Required: tack.random.seed(index, stream) is a pure function of its
two integers, advance of its state, and uniform of its state, in
wrapping u32 arithmetic with the result's top 24 bits scaled by 2^-24:
their values are identical on every backend and equal to the module's
NumPy mirrors. normal, direction2 and direction3 apply log,
sqrt, cos and sin to such values and inherit those functions'
rounding. No hidden state exists; two kernels that seed alike draw alike.
Vectors and matrices¶
Required: a vector (tack.vec2/vec3/vec4(...) from N scalars, one
scalar for every component or one N-vector; a list of scalars written
alone; an element of a tack.Array with a vector element dtype) and a
matrix of at most 4×4 (tack.mat2/mat3/mat4(...) from rows that are
lists of scalars or vectors, or from N*N scalars in row-major order;
tack.mat(row, ...); tack.identity(n) for a literal n; an element of
a tack.Array with a matrix element dtype) are values
made of scalar components, in row-major order for a matrix. Every
operation on them is defined as the corresponding scalar operations on
their components, each of which follows the numerical rules above. They
have no representation of their own in generated code.
Arithmetic operators, the math builtins, explicit casts and conditional
expressions apply to each component. A scalar operand is used for every
component. Two vector or matrix operands must have the same shape; a
vector and a matrix with the same number of components do not. A
conditional expression evaluates its condition once and selects whole
values. A comparison with a vector or matrix operand yields a mask, a
vector or matrix of i32 0/1 of that shape with one comparison per
component (a scalar operand compares with every component); a chain
(lo <= v < hi) evaluates every link and combines them per component
with and. and, or and not over masks act per component and
evaluate every operand. any(mask) and all(mask) reduce a mask to one
truth value; tack.select(mask, a, b) takes a where the mask's
component is nonzero and b elsewhere, a scalar mask selecting whole
values and a scalar arm standing for every component. Conditions of if
and while, loop bounds and conditional expressions take scalars: a mask
there is rejected with the source position and the remedy, as is a vector
in any other position that requires a single value.
a @ b multiplies matrices; a vector on the right is a column and on the
left a row, giving a vector; two vectors give their dot product. The
shapes must agree. Each entry of the result is the sum of its products in
index order. norm, norm_sqr, dot, cross, normalized, sum,
min, max and outer_product are methods of a vector, the first two
and sum also of a matrix; norm(eps) is the square root of
norm_sqr() + eps; transpose of any matrix, trace of a square
one, determinant and inverse of 2×2 and 3×3 ones. inverse divides
the adjugate by the determinant and does not test it.
A component is selected by an index (v[c], m[i, j]) or, for the first
four components of a vector, by name (v.x, v.y, v.z, v.w), on a
variable, on an array element (vf[i][c]) or on any vector-valued
expression. A literal index is checked when the kernel is lowered and may
count from the end. A runtime index into a vector must lie in [0, n):
outside that range a read yields the last component and a store has no
effect. Runtime row and column indices of a matrix must be in range.
An assignment of a vector or matrix evaluates every component of its right-hand side before it assigns any (see Execution and ordering). A store to an array element requires the element's shape, or a scalar, which is stored to every component. Storing a vector into scalar storage is rejected.
Tests: test_new_features.py (vectors), test_matrix.py.
Array and parallel reductions¶
Array.sum(), min(), max() and mean() reduce all logical elements
and return Python float values. Reshaping an array does not select an axis
or change the set of elements. CPU uses NumPy; GPU backends reduce eligible
f32 arrays on the device and use the shared NumPy fallback for other dtypes.
Allocation and array/backend ownership requirements still apply. CUDA,
HIP and Level Zero pass the element count to the device in 64 bits and
reject a reduction longer than one launch can index, as for kernels
below. Metal's reduction kernel holds the count and thread position in 32
bits, so Metal reduces arrays of 2^32 or more elements through NumPy.
Accumulation precision: f32 sums accumulate in f32, and f64 sums in
f64. Integer sums promote signed inputs to i64 and unsigned inputs to u64,
independently of the host word size, and wrap modulo 2^64 in that accumulator.
The accumulated value, or selected integer extremum, is then converted to
a Python float; integer results beyond binary64's exact range can round.
mean() divides that returned sum by the element count in host binary64;
it does not request a wider or compensated accumulator. Integer means
therefore inherit integer-sum wrapping.
Empty and exceptional inputs: an empty sum returns positive zero,
an empty mean returns NaN, and empty min/max raise ValueError. These
results require no device launch or storage read. This does not promise
that every backend allocator can create a zero-byte buffer. Floating
min/max propagate any NaN, accept both infinities and the full normal
finite range, and resolve zero ties independently of order: minimum prefers
negative zero and maximum positive zero. An all-negative-zero maximum
remains negative zero; an all-positive-zero minimum remains positive zero.
NaN payload/sign preservation is not promised. The selected non-NaN
extremum is exact in the input's storage precision.
Floating sums propagate NaNs and combine infinities using floating addition. Opposite infinities give NaN; an infinity retains its sign when other partial sums remain finite. Overflow can depend on grouping, including whether an intermediate becomes infinite before cancellation. There is no portable finite-result promise when intermediates overflow. A zero sum may have either zero sign. Nonzero denormal inputs/intermediates/results remain outside the floating baseline above.
Order and reproducibility: floating summation may reorder and regroup elements, and may produce different low bits across calls, devices, backends, compiler versions or implementations. Current GPU array sums use 256-lane trees followed by atomically accumulated group partials; group arrival order is unspecified. No deterministic or compensated sum mode is currently exposed. Repeated equality in a test does not establish a reproducibility guarantee. Floating extrema are order-independent under the class/zero rules above, apart from NaN representation. Integer array reductions have the defined accumulator/conversion behavior independently of reduction order.
For n finite floating inputs with no overflow or nonzero underflow in any
partial sum, round-to-nearest addition, and n*u < 1, the supported absolute
error budget is abs(computed_sum - exact_sum) <= gamma_n * sum(abs(x)),
where gamma_n = n*u/(1-n*u), u = 2^-24 for f32 and 2^-53 for f64.
This conservative addition bound covers tree and serial accumulation.
It does not promise a small relative or ULP error near cancellation.
The exact sum is over stored input values; input conversion and errors in
expressions producing terms require separate budgets. Mean inherits the
sum error divided by n plus host division rounding. See
Higham, The Accuracy of Floating Point Summation,
especially the general summation analysis in section 3.
Kernel reductions: block_sum, block_min and block_max currently
require f32 arguments and return f32. Integer/f64 arguments require an
explicit tack.f32(...) conversion; they are rejected rather than silently
narrowed or given an inconsistent annotation. On correctly participating
GPU workgroups, block extrema use the same NaN/zero rules as array extrema,
and block sums permit order variation with the same addition budget over
their contributed terms. The tested GPU domain here is fully participating
256-lane groups. Full participation, launch sizes and partial groups
remain the separate workgroup contract below. CPU rejects block reductions
because it has no workgroup execution model.
User reductions combining atomic_add with block partials likewise have
unspecified accumulation order. Atomic min/max are separate backend
primitives. Their portable floating domain is finite stored values and
contributed operands, including numeric zero, with an unspecified result
sign on a zero tie; NaNs and infinities are outside it. The array/block
extrema guarantees do not imply those atomic semantics. Atomic scope and supported widths are defined below;
workgroup participation follows its separate contract. The statistical algorithms in
tack.algorithms.stats form their terms in the inputs' precision (f64 if any
input is f64) and reduce them as arrays, so array reductions' accuracy and
repeatability rules apply to them; histogram counts bins with atomics.
test_reduction_semantics.py covers full-range extrema, NaNs and zero ties
across groups and tails, exact and bounded-error sums, allowed cancellation
groupings, nonfinite classes, host fallback types and empty arrays, explicit
block precision, and complete GPU groups. Host-sanitized helpers and full
CUDA/HIP/OpenCL syntax checks supplement actual backend execution.
The following extensions remain open before broader numerical conformance claims:
| Question | Current evidence | Decision needed |
|---|---|---|
| Remaining arithmetic domains | Fixed-width wrapping, casts/promotion, valid shifts, true division, integer power, and floating ///% are defined |
Any extension beyond the stated invalid-operation and denormal constraints |
| Floating-point extensions | Safe math, annotated precision, classes/signs, grouping and permitted contraction are defined above | Denormal support and tighter function/domain-specific accuracy or reproducibility guarantees |
| Reduction extensions | Accumulator precision, exceptional classes, extrema ties, permitted order variation and an absolute addition budget are defined above | Optional deterministic/compensated modes and wider statistical accumulators |
The differential tests cover small exact integer results and an exact floating-point promotion case; they do not settle the open numerical policies by recording accidental outputs. Defined integer results should match exactly across backends. Floating-point comparisons need stated tolerances and supported input domains rather than a general bitwise-equality promise.
Workgroups and synchronization¶
Shared memory, barriers, thread indices, and block reductions require an explicit workgroup execution model. A barrier orders participating threads within its workgroup, fencing both shared and global array memory; it is not a global barrier between parallel iterations on different workgroups. Programs must not rely on divergent participation or uninitialized shared memory.
Target support: backends declare supports_workgroups (CPU: false;
Metal/CUDA/HIP/Level Zero: true). CPU rejects kernels containing shared,
shared_like, barrier, thread_id, block_sum, block_min or block_max
before compilation or execution. Inspection in every mode and direct LLVM
generation enforce the same restriction. The diagnostic names the kernel,
backend and required primitives. Dispatch wraps the NotImplementedError
in its usual kernel RuntimeError; inspection and direct generation raise
NotImplementedError directly.
This is a structural requirement, including nested branches and inlined
device functions, even if the primitive would be unreachable at runtime.
CPU does not emulate workgroups. Use local_array or local_array_like
for private scratch arrays; these remain supported on CPU, as do ordinary
scalar kernels, host array reductions and supported atomic operations.
GPU launch domain: barriers and block reductions require complete
256-lane workgroups. Positive logical iteration counts must be divisible
by 256, including normalized stepped ranges and ndindex. Cold dispatch
rejects partial groups before compilation; cached dispatch rechecks every
count before execution without repeating analysis. Zero/negative counts
run nothing. Metal pipeline limits and Level Zero X/total device limits
must admit 256 lanes; smaller groups are rejected. CUDA/HIP launch width
matches the generated reduction tree. Shared-memory/thread-ID kernels
without collectives retain partial-grid support. Bounds and initialized
shared values remain the user's responsibility.
GPU participation domain: workgroup_participation.py conservatively
proves uniform control flow. Constants, scalar arguments, shape queries,
immutable runtime scalar packs and collective results are uniform across
a workgroup. Lane indices and ordinary memory/texture loads are varying.
Assignments and branch joins propagate this classification; loop fixed
points account for loop-carried conditions and varying break/continue
paths, including exits after a barrier that affect subsequent iterations.
Uniform scalar branches/loops and varying memory updates that reconverge
before a barrier are supported. Varying loops without collectives may
reconverge before a later barrier. Device functions are checked after inlining.
Collectives must occur inside the parallel body. Reductions in ternary
branches, later short-circuit operands or while conditions are rejected
even for uniform predicates: current source generators cannot preserve
their guarded or repeated evaluation. Move the reduction into an explicit
supported statement sequence. Other unproven cases, including field-loaded
conditions and mathematically uniform expressions such as i // 256, are
rejected too. This is a conservative domain, not a complete uniformity prover.
Failures raise ValueError naming the kernel, primitive and IR location or
launch count/size. Public inspection checks participation and logical counts.
Direct GPU generators check mutable IR afresh; their callers must validate
eventual launch counts and device limits. Variant construction checks before
optimization/packing; packed scalar parameters carry _is_scalar_pack to
preserve uniformity. The full-group requirement is cached on KernelVariant.
The all-participants requirement follows the CUDA synchronization specification and OpenCL C barrier specification. Metal can produce smaller final groups with nonuniform dispatch; see Apple's grid-size guidance. The fixed width is Tack's implementation domain, not a general hardware limit. No automatic padding or CPU workgroup emulator is supplied.
Generated block reductions include a barrier after every lane has read the result, before the shared array can be reused by another loop iteration. Callers need no extra barrier to protect the collective's result broadcast.
Remaining limits: this analysis does not establish race freedom, initialization, bounds safety or termination, or synchronize different workgroups. Hardware confirmation is required per backend; CPU results do not validate cooperative GPU execution.
Atomic array updates¶
atomic_add, atomic_min and atomic_max act on one scalar element of a
global array. As a statement the old value is dropped; as an expression
(slot = tack.atomic_add(counter, 0, 1)) the value is the element's value
immediately before this update, as the hardware atomic returns it, and
the atomic takes effect in source order among the expression's operands:
it is performed before the expression that contains it is evaluated and
after the operands to its left. On a vector array with a vector value the
expression's value is the vector of the components' old values, each from
its own atomic.
The index is a flat, row-major index, or a tuple with one index per
dimension, in which a vector of integers supplies one index per component.
On a vector or matrix array, a value of the element's shape updates the
element addressed by that index with one atomic operation per component;
the element as a whole is not updated atomically, and the element index
and any component that loads from an array are evaluated before the first
of them. A scalar value on such an array addresses one component by its
flat index, except on a structure-of-arrays array (layout="soa"), whose
components have no common flat index; there it is refused.
Backend.supported_atomic_dtypes declares the implemented add/min/max domain,
independently of the ordinary array types or workgroup capability:
| Target | Atomic array types, for all three operations |
|---|---|
| CPU | i8/u8, i16/u16, i32/u32, i64/u64, f32/f64 |
| CUDA / HIP | i32/u32, i64/u64, f32/f64 |
| Metal / Level Zero | i32/u32, f32 |
Metal/Level Zero 64-bit atomics and GPU 8/16-bit atomics are outside this
implementation domain, even when ordinary arrays of those widths are
supported. No reinterpretation as a neighbouring wider array is allowed.
Private/shared arrays, textures, scalar arguments, and targets that cannot
be traced uniquely to a global array parameter are rejected. Field-pointer
copies introduced by device-function inlining are traced to that parameter.
The checker runs before optimization, including structurally unreachable
operations and empty grids. Public inspection in every mode enforces the
same target domain. Direct generators check mutable IR afresh and do not
trust stale atomic dtype annotations. Unsupported operations/types/targets
raise TypeError naming the kernel, target, operation and reason.
Atomic targets require natural alignment (1/2/4/8 bytes for their width).
Allocated arrays satisfy this; imported array addresses are checked before
cold compilation and on every cached dispatch. Unaligned targets raise
ValueError before updating storage. Cached variants retain parameter indices
and alignment requirements, not addresses; changed arguments are checked
without repeating IR analysis. Direct-codegen callers must validate the
addresses used by their eventual launch. Index bounds, allocated extent,
initialization and valid floating-to-integer conversion remain caller
requirements. Ordinary non-atomic array accesses retain their existing
support for unaligned imported storage.
The contributed scalar is converted to the array's dtype once before the read-modify-write operation, using the existing scalar conversion domain. Integer addition wraps modulo the target width. Integer extrema compare with the target's signedness, including unsigned values above the signed maximum. Floating addition rounds at array precision with unspecified interleaving; exact reduction order and bitwise reproducibility are not promised. Normal floating arithmetic constraints, including the denormal exclusion, apply. Floating atomic extrema's portable domain is finite stored values and contributed operands, including numeric zero. Both zero signs compare equal; the sign of a zero result on a tie is unspecified. NaNs and infinities remain outside that extrema domain; array/block extrema's stronger exceptional-value policy does not apply to user atomics.
Ordering and scope: updates to the same element are indivisible among
kernel participants across CPU worker threads or GPU workgroups on the
executing device. The portable memory order is relaxed. An atomic update
is not an acquire/release fence for other arrays or a publication flag, and
there is no system-scope guarantee spanning concurrent host accesses or
other devices. Do not mix ordinary reads/writes with conflicting atomic
updates during a kernel unless appropriate workgroup synchronization orders
them. Workgroup barriers order shared and global array memory among that
workgroup's lanes; they do not synchronize different workgroups. Launches
on one backend take effect in call order: a kernel call sees every effect
of the calls before it. Metal, CUDA, HIP and Level Zero queue launches and
return before they run, and the host waits for them before it reads array
memory (numpy(), indexing, reductions, DLPack export) or at
tack.sync(). A later kernel call or a host read is the supported way to
consume a cross-workgroup result.
Atomics alone do not require a complete workgroup or uniform participation;
partial final groups and lane-dependent atomic branches are permitted.
CPU uses LLVM monotonic atomic read-modify-write instructions, including
unsigned extrema, and integer-bit compare-and-swap loops for floating min/max.
CUDA/HIP 64-bit operations use 64-bit integer CAS, avoiding optional native
f64-add and signed-i64-add overloads; f32 extrema also begin with an atomic
read. Metal uses correctly typed atomic_int/atomic_uint and f32 atomics.
OpenCL uses explicit relaxed, device-scope C11 atomics, including the final
combine in native array reductions. Its former legacy functions guarantee
only workgroup scope in the specification, which is insufficient for that
combine. Compare-and-swap retries compare integer bits and capture the
contributed value once; they do not repeat the user's value expression.
Explicit Metal/OpenCL user barriers fence global as well as shared memory;
internal shared-only reduction-tree barriers retain their narrower fences.
The ordering/lowering choices follow the
LLVM atomic instructions,
CUDA atomic specification,
HIP atomic specification,
and OpenCL atomic specification.
test_atomic_contract.py covers unsigned boundaries, wrapping/conversion,
actual contended CPU workers, GPU updates across groups and tails, aliases
and inlined targets, cached alignment checks, unsupported target rejection,
global-array publication within a workgroup, LLVM verification and GPU
source compilation checks. Device hardware confirmation is required for
CUDA/HIP/Level Zero; host syntax checks do not establish atomic scheduling
or memory-order behavior on those devices.
Specialization and compilation identity¶
Required: reusing compiled code must have the same defined behavior as compiling the current call independently. Every fact baked into code or its calling convention must participate in specialization identity, including argument category and dtype, vector width, relevant shape/stride constants, texture lowering, and template structure/constants. See LC3.
Conversely, changing runtime scalar values or an outer launch length alone should not force recompilation. A dimension referenced inside the kernel body can require specialization even when that dimension also sets the launch length. Class-level template constants and instance-level runtime scalars retain their distinct roles.
The current key includes argument dtypes and array/scalar/texture categories,
vector widths and matrix shapes (a 2×2 matrix array and a 4-vector array
compile separately), each vector or matrix array's layout (interleaved or
structure of arrays), texture extents, and resolved shape dependencies.
Template identity includes the actual class, typed constants (inherited
ones included), array metadata, runtime scalar attribute names, and the
identity of each device function held as an attribute. A tack.constant
is a literal in the lowered kernel and needs no key entry; a kernel reads
the constant its name was bound to when it was first lowered. Floating-point constants use their bit
patterns, distinguishing signed zeros and making NaN cache keys stable.
Different classes with identical names can have different methods, and
adding a runtime attribute changes the parameter
layout even when its value is not a compilation constant.
Debug mode is part of the identity: a kernel lowered with bounds checks never shares IR or compiled code with the same kernel lowered without.
The frontend's cached IR template must remain pristine. Mutating passes operate on a variant's copy. A backend cache must belong to the backend configuration that compiled its entries; compiled code must not outlive the resources needed to execute it.
The same requirement holds across processes. The disk cache keys lowered IR by content rather than identity (the kernel's source and closure, template classes by their MRO names, method sources and constants, Tack's own version) and uses an entry only if every name lowering resolved still resolves to the same thing; it keys compiled code by that entry, the argument types and baked-in shapes, and the backend's target (the device, compiler and driver versions and compile options). Within one process a kernel still reads the constant its name was bound to when it was first lowered; a new process lowers again if that binding changed.
Regression baseline¶
packages/tack-core/tests/test_compiler_contract.py contains the executable
baseline. Expected results come from simple arithmetic or NumPy calculations,
not from recording Tack's current output.
| ID | Requirement | CPU evidence at the baseline |
|---|---|---|
| LC1 | Sequential array loads observe preceding writes | Three increments produce 1; bypassing Tack IR optimization produces 3 |
| LC2 | Support for overlapping arguments | Write 1 through a, write 2 through aliased b, read a: returns 1 |
| LC3 | Vector widths specialize independently | Width 2 followed by width 3 reuses the first variant and gives incorrect squared norms |
| LC4 | Unsupported statements are rejected | assert False disappears from transformed IR without a diagnostic |
| LC5 | The top-level range honors its start | range(3, 7) over eight elements writes indices 0–6; a stencil's x[i - 1] reads before the buffer |
| LC6 | Assignment to a scalar parameter takes effect | value = value + 1 is lost on GPU, where every read of the name is rewritten to the packed scalar; on CPU an assignment inside a branch or loop reaches only the reads emitted after it |
| LC7 | A return in an inlined function ends that function |
if a > limit: return limit followed by return a always yields a |
| LC8 | continue advances its loop |
Any continue in a for loop fails LLVM verification on CPU; in the top-level loop it does not compile on GPU |
The suite includes empty/single-iteration and distinct-buffer controls, same-field and reshape-view aliases, and both orders of vector-width changes. Vector inputs have enough allocated storage to keep even the incorrect cached indexing in bounds. The mutation reference bypasses Tack's IR optimizations only; LLVM/vendor optimization remains active.
Stage one recorded these defects on CPU; subsequent Linux testing reproduced LC1–LC3 on CUDA. Stage two removes their expected-failure markers: all numerical cases are now ordinary assertions on every available backend.
Metal validation, 2026-10-03: at 922b642, four overlap-contract cases
failed numerically on Apple M1 Max: same-field and reshape-view ordered
writes, inner-loop mutation through an alias, and a read after an alias store.
The same failures reproduce at the pre-stage-four e265e7f. Direct MSL kernel
buffer arguments must be disjoint under the Metal language specification,
section 5.2.
Metal now uses indirect array pointers in one argument buffer, supported since
Metal 2, rather than separate device-buffer arguments. The runtime refreshes
references on each cached dispatch and explicitly declares resource residency.
All four original failures pass on Apple M1 Max, with additional coverage for
changing alias relationships, all supported dtypes through imported buffer
wrappers, mixed scalar packs, and aliased atomic updates. Assertions remain
ordinary tests and vendor optimization remains enabled.
The suite also covers zero-trip local assignments, while-loop mutation,
mutation through aliases, CSE across alias stores, and copy propagation's
statement order, loop steps, and loop-variable bindings.
CPU and HIP validation, 2026-10-04: at fea2ad7, on one AMD Instinct
MI300X VF (gfx942) with ROCm 7.0.2, hip-python 7.2.2, and an Intel Xeon
Platinum 8568Y+ host (one socket, 20 cores, Linux 6.8). Both backends
initialized explicitly. The contract, source-validation, differential,
stage-five semantics, variant-cache, IR-clone and identifier suites gave
772 passed, 3 skipped on CPU and 518 passed, 0 skipped under
-k hip. Of the HIP selection, 463 cases executed on the device; 53 are
host-side checks of generated HIP source, and 2 are CPU cases that match
only because relationships contains hip. With ROCm's clang on PATH,
the 40 clang-gated OpenCL/C++ syntax checks also ran; without it they skip.
The three CPU skips are a Metal f64 generator case and two GPU-only block
reduction patterns. There were no failures, expected failures, or
numerical differences. All 45 runnable examples passed on each backend
(four need optional oidn, imgui_bundle, VTK, or a Metal/CUDA backend),
as did validate_all.py.
LC5 was found on CUDA hardware testing of stage two and predates it: every
backend resolved only the end of the top-level range. It is fixed, with
cases for interior, single-element, empty, and reversed intervals, a
dimension-derived bound at two lengths, and an empty range(n). The last
previously failed at launch on CUDA with a driver error rather than a wrong
result; the GPU backends now return before launching an empty grid.
LC6–LC8 were found by reviewing stage two with hand-written kernels and
also predate it. All three are fixed. A scalar parameter the kernel assigns
to is renamed, at variant build, to a local seeded from the parameter at
the top of each iteration. Inlining restructures a function so that every
return is the last statement on its path. CPU loops gained a latch block
for continue, and a continue of the top-level loop leaves the kernel on
GPU. One stage-two case, a parameter read before and after its
reassignment, passed on GPU only because copy propagation rewrote the read;
it now has a twin with Tack's passes bypassed.
There are 35 numerical cases per backend, plus six host-side cases:
LC4, rejection of return inside a loop, and one generated-source check
per GPU generator for the top-level continue.
test_variant_cache.py covers array/scalar calling conventions, same-named
template classes, typed template constants including signed zero, and changes
to runtime template attribute layouts.
Stage three fixes LC4 and removes its expected-failure marker. All contract
cases are ordinary assertions. test_source_validation.py adds rejection
and diagnostic coverage, while test_differential.py runs identical source
as a compiled kernel and as serial Python over independent NumPy arrays.
It compares every array, including arrays mutated by inlined calls. Its
generated cases use a fixed grammar and reproducible seed IDs, varying
bounds, positive steps, branches, break, and continue. They exclude
atomics, cooperative workgroups, vector methods, and open numerical cases.
The differential tests exposed and fixed eager guarded calls, discarded condition-call statements, delayed operand loads, repeated augmented-store indices, and changing sequential range bounds. CPU lowering now uses control-flow joins for Boolean and conditional expressions. Native GPU expressions already short-circuit; their inlined statement effects now stay inside the corresponding guards in shared IR. CPU validation alone does not establish hardware correctness; each backend needs the runs below.
Run the baseline on all locally discoverable backends:
uv run --no-sync pytest packages/tack-core/tests/test_compiler_contract.py -v -rxX
Run frontend and differential coverage as ordinary assertions:
uv run --no-sync pytest packages/tack-core/tests/test_source_validation.py packages/tack-core/tests/test_differential.py -v
For a GPU handoff, initialize the requested backend explicitly first, then select its collected tests. Initialization must succeed and the test output must show that backend's cases; deselection or a skip is not validation. For example, after installing the CUDA extra and runtime:
uv run --no-sync python -c 'import tack; tack.set_default_device("cuda")' && \
uv run --no-sync pytest packages/tack-core/tests/test_compiler_contract.py packages/tack-core/tests/test_differential.py -k cuda -v -rxX
Use "hip" / -k hip or "level_zero" / -k level_zero for those
backends. --no-sync preserves the existing environment's backend extras.
The shared-frontend LC4 test runs in the unfiltered command. Record backend,
device, runtime/binding versions, pass/fail/xfail counts, and numerical
differences. Do not waive numerical failures by adding expected-failure
markers, except for a vendor toolchain defect under the conditions in
Conformance.
Subsequent stages¶
Stage one established the draft and reproductions. Stage two disables unsafe transformations, supports overlapping arguments, and repairs specialization identity. Stage three adds strict frontend rejection with source context, defines guarded expression execution and Boolean values, and brings forward differential/generated-program tests. Stage four adds shared structural traversal and verification after lowering, resolution, inference, scalar localization, optimization, GPU packing, and type annotation. These checks run at template/variant construction and during inspection, preserving the cache-hit dispatch path. They check structure, binding existence, loop targets, and required resolution/type metadata; they do not prove definite assignment, bounds safety, or barrier uniformity. Verifier role and attribute tables are precomputed, while all checks still run at their pass boundaries. Broader testing and the numerical/capability decisions above remain subsequent work.
Stage five has implemented integer floor division/remainder, fixed-width arithmetic and conversions, true division, integer power, and floating floor division/remainder, including their promotion domains and caller constraints above. The floating-point baseline now defines safe compilation, precision, exceptional classes, grouping and permitted contraction, with explicit denormal and accuracy limits. Reduction order/accuracy guarantees remain within stage five. The workgroup/capability contract remains a separate stage.