Numerical Semantics¶
This page explains how Tack decides what an arithmetic expression means and how each backend is made to compute exactly that. The normative rules live in the kernel language contract, section Types and numerical behavior; this page summarizes each rule, records why it was chosen over the alternatives, and walks through the modules that implement it. Where this page and the contract differ, the contract states the guarantee and this page describes the current code.
The short version: Tack is fixed-width and Python-flavored. Integers
wrap at their annotated width like NumPy arrays; //, % and / follow
Python's definitions rather than C's; floating-point code is compiled with
safe math on every backend; and inputs where no portable answer exists
(zero divisors, oversized shifts, out-of-range float-to-int conversion) are
documented caller constraints rather than checked at run time.
Design goals¶
| Goal | Consequence |
|---|---|
| One answer per defined program, on every backend | Every rule is enforced in Tack's own type annotation and code generation, never delegated to a target language's implicit conversions |
| Python-first semantics where Python's rule is cheap | Floor division, divisor-signed remainder, true division, Boolean results of 0/1 |
| Fixed-width storage, not big integers | Arithmetic wraps modulo 2^N at each node; promotion picks a type that holds both operands |
| No undefined behavior leaking from C | C-family emitters compute in unsigned carriers and reconstruct signed values with representable casts |
| No silent precision loss | Signed-with-u64 mixes are rejected; f32 is never silently narrowed from f64 |
| Small compiler | No runtime checks for division by zero or shift ranges; those are caller constraints |
Where the semantics are decided¶
All backends share one typing pass. Code generators read the result and never re-derive types from the target language.
flowchart LR
A[Call arguments] -->|infer_param_types| B[Parameter types]
B --> C[annotate_types]
C -->|"node.dtype on every expression<br/>_resolved_type on every assignment<br/>_operand_type on comparisons"| D{Backend}
D -->|LLVMCodeGen| E["LLVM IR: typed instructions<br/>and internal helpers"]
D -->|"CUDA / HIP / MSL / OpenCL"| F["C source + shared helpers<br/>integer_ops, integer_division,<br/>float_division, reductions"]
packages/tack-core/src/tack/lang/type_inference.py:infer_param_typestypes the parameters from the actual arguments;promote_typesis the single binary promotion rule.packages/tack-core/src/tack/lang/ir_type_annotate.py:annotate_typessetsdtypeon every expression node,_resolved_typeon everyIRAssignand_operand_typeon everyIRCompare.packages/tack-core/src/tack/codegen/llvm_gen.py:LLVMCodeGenfor CPU.packages/tack-core/src/tack/codegen/integer_ops.py,integer_division.py,float_division.pyandreductions.py: helper emitters shared byCUDACodeGen,HIPCodeGen,MSLCodeGenandOpenCLCodeGen.
Because the annotation is computed per compiled variant (see Specialization and Caching), every dtype a helper depends on is part of the variant key through the argument types.
Typing¶
Parameters and literals¶
infer_param_types gives arrays their element type. Python int
arguments and integer literals go through integer_type_for_value
(lang/types.py): i32 if the value fits, else i64, else u64; anything
outside [-2^63, 2^64 - 1] raises TypeError. Python float arguments are
f32, unless any array argument is f64, in which case they are f64. (A
texture is always f32: tack.texture3d() rejects other arrays.) Float
literals in the kernel body are weakly typed; see
Float literals.
That float-context rule has a consequence worth knowing. An f64 output array does not widen an f32 array computation, but it does make a Python float scalar argument f64, and the scalar then widens the expression it appears in:
@tack.kernel
def scale(a, out, s): # a: f32 array, out: f64 array, s: Python float
for i in range(a.shape[0]):
out[i] = a[i] * s # s is f64 here, so the product is f64
Float literals¶
Float literals are weakly typed, the way NumPy's
NEP 50 treats
Python scalars: a literal takes its precision from what it meets.
annotate_types marks each node of a literal expression (numeric
literals combined only by unary +/-, arithmetic, math builtins and
conditional-expression arms) with _literal, and one containing a float
literal with _weak. A weak expression is annotated f32 until it meets a
non-weak floating operand (_meet, for arithmetic, comparisons and
conditional arms), a math builtin whose result type comes from a non-weak
floating argument, an explicit tack.f32/tack.f64 cast, or a floating
store, atomic or local target (_adopt). It then takes that type
throughout (_retype): each float literal converts once from its exact
Python value, and the expression's operations run at that precision.
Literal-only subexpressions are not folded at compile time.
So with an f64 x, x * 0.1, x * -0.1 and x * (1.0 / 3.0) equal
NumPy's f64 results, and tack.f64(0.1) is the f64 nearest 0.1 rather
than a widened f32. A weak expression that meets none of these keeps the
f32 default: alone, beside only literals or integers, or combined with an
integer value (i * 0.1 is f32). A local assigned once to a literal-only
expression, as in a = 0.1, is propagated as a weak expression wherever
it is read, so x * a retains the literal's full precision when x is f64.
A local assigned only literals more than once uses f64 when the kernel has
an f64 array, as a Python float argument does. An expression without f64
operands annotates exactly as it would with f32 literals, and its
generated code is unchanged.
The generators emit an f64-annotated constant at full precision:
LLVMCodeGen a double constant from the Python value, and the CUDA
generator (and so HIP and OpenCL) an unsuffixed repr, which round-trips
exactly. MSL has no f64. The full rule, including how integer
subexpressions convert, is in the
language contract;
test_float_literals.py pins it.
Binary promotion¶
promote_types(a, b) is used for arithmetic, comparisons (recorded as
_operand_type), conditional-expression arms, integer min/max, and the
join of every assignment to one local variable. Its rule:
- Equal types promote to themselves.
- Any floating operand wins: f64 if present, otherwise f32.
- Integers of the same signedness promote to the wider type.
- Mixed signedness promotes to the narrowest signed type with at least
max(signed.bits, unsigned.bits + 1)bits. Nothing fits for a signed type with u64, so that raisesTypeError("use an explicit integer cast").
| Operands | Result | Why |
|---|---|---|
| i8, u8 | i16 | i16 holds -128..255 |
| i8, u16 | i32 | |
| i32, u32 | i64 | C would choose u32 and turn -1 into 4294967295 |
| i16, u32 | i64 | |
| u8, u64 | u64 | same signedness |
| i64, u64 | TypeError |
no Tack integer holds both ranges |
| i64, f32 | f32 | floating wins; large integers can round |
Why preserve both ranges. C's usual arithmetic conversions make
i32 + u32 unsigned, so a comparison like -1 < 1u is false. Before the
annotations governed every emitter, the GPU generators inherited that rule
implicitly while the CPU generator followed LLVM widths, and the backends
disagreed. Integer promotion that preserves both ranges never changes a
value, so comparisons and min/max operate on mathematical values.
Why reject signed with u64 instead of choosing f64 (NumPy's choice):
an implicit trip through binary64 silently rounds integers above 2^53,
which is exactly the kind of quiet divergence the contract exists to
remove. An explicit tack.i64(...) or tack.u64(...) cast states the
intended wrap.
Result-type rules that differ from promotion¶
_annotate_expr special-cases a few operators:
| Expression | Result type | Notes |
|---|---|---|
int / int |
f32 | True division |
int ** int, pow(int, int) |
the base's type | exponent type ignored; negative literal exponent raises TypeError |
a << n, a >> n |
type of a |
count type does not widen |
comparisons, not, and, or |
i32 | values are 0/1 |
abs(int) |
the argument's type | |
min/max |
promotion of all arguments | |
| other math builtins | f64 if any argument is f64, else f32 | sqrt(i64_value) is f32 |
block_sum/block_min/block_max |
f32 | non-f32 input raises TypeError |
One type per local variable¶
A local gets a single storage slot: an alloca on CPU, one declared C
variable elsewhere. annotate_types therefore runs a fixpoint over all
assignments (at most _MAX_JOIN_ROUNDS rounds; the join only widens, so it
settles quickly) and gives each local the promotion of every type assigned
to it. The motivating bug was total = 0.0 followed by additions from an
f64 array: the first assignment typed total as f32 and every later
addition was narrowed. Loop variables, parameters and allocations are
pinned and do not join. An impossible join (signed with u64) raises
rather than being dropped.
Integer arithmetic¶
Wrapping at every node¶
Rule (contract,
"fixed-width arithmetic and integer conversion"): integer +, -, *,
unary -, ~, &, |, ^ and << produce the low N bits at the node's
annotated width, interpreted as two's complement for signed types. Each
nested operation wraps before its value is used.
| Expression (operand types) | Result |
|---|---|
tack.i8(a + 1) with i8 a = 127 |
-128 (the sum is i32 128; the cast wraps) |
a + a, i8 a = 127 |
-2 |
a * a, u16 a = 65535 |
1 |
abs(a), i8 a = -128 |
-128 |
a >> 1, i8 a = -8 |
-4 (arithmetic) |
a >> 1, u8 a = 200 |
100 (logical) |
a + b, i32 -1, u32 4294967295 |
4294967294 (i64) |
Why wrap. Python integers do not overflow, but a fixed-width compiled
language cannot follow them. The two realistic alternatives were C's
(promote small types to int, leave signed overflow undefined) and
NumPy's (wrap at the array's width). Inheriting C's rule gave different
results on CPU and GPU, and its undefined behavior lets a vendor compiler assume an
overflow never happens, so an overflowing loop counter could be optimized
into anything. Wrapping is what the hardware does, matches NumPy arrays,
and is cheap to express on every target.
LLVM (LLVMCodeGen._emit_binop, _emit_int_binop): operands are
coerced to the annotated width and the operation is emitted as a plain
add/sub/mul/shl without nsw or nuw flags, which LLVM
defines as wrapping. Unsignedness is not part of an LLVM integer type, so
the generator tracks unsigned values (_unsigned_vals) to choose zext
over sext, uitofp over sitofp, lshr over ashr, icmp_unsigned
over icmp_signed, and umin/umax atomics.
C-family (IntegerCodeGen in codegen/integer_ops.py): every integer
operation becomes a typed helper such as __tack_add_i8__. Inside, the
operands are converted to an unsigned carrier of at least 32 bits, the
operation runs there (unsigned arithmetic is defined to wrap in C), and the
result is truncated to the N-bit unsigned type:
__device__ inline unsigned short __tack_mul_u16__(unsigned short a, unsigned short b) {
return ((unsigned short)((((unsigned int)a) * ((unsigned int)b))));
}
__device__ inline signed char __tack_bits_i8__(unsigned char x) {
return x <= 127ULL ? (signed char)x : (signed char)(-1 - (signed char)((unsigned char)~x));
}
__device__ inline signed char __tack_add_i8__(signed char a, signed char b) {
return __tack_bits_i8__(((unsigned char)((((unsigned int)a) + ((unsigned int)b)))));
}
The 32-bit floor on the carrier matters: in C, unsigned short * unsigned
short promotes both operands to signed int, and 65535 * 65535
overflows it, which is undefined. The __tack_bits_* function turns the
N-bit pattern back into a signed value using only casts of in-range values
(out-of-range conversion to a signed type is implementation-defined in C).
Metal has a defined bit reinterpretation, so IntegerCodeGen(bitcast=True)
emits as_type<char>(x) instead. Signed right shift avoids C's
implementation-defined >> on negative values by shifting the complement.
Helpers take their operands as parameters, so each operand is evaluated
exactly once. Only the helpers a kernel uses are emitted.
Metal 64-bit addition workaround
On the M1 Max where it was found, Metal's pipeline compiler crashed
when a wrapping 64-bit addition that updates a local from its own
previous value was optimized inside a loop with a runtime bound.
MSLCodeGen._emit_assign detects that shape and emits a separate
__attribute__((noinline)) i64/u64 add helper for it. The contract
records this as needing validation on other Apple GPUs.
Level Zero negation and abs workaround
On an Intel Data Center GPU Max 1100 with IGC 2.7.11, the wrapped
negation or abs of a signed minimum lost its sign once widened: i16
-(-32768) widened to 32768, and the i8/i16/i32 abs minimum to
its magnitude. OpenCLCodeGen emits neg below 32 bits and abs
below 64 bits as __attribute__((noinline)) helpers, the one shape
that held; see Backend implementations.
Conversions¶
Integer-to-integer casts and integer array stores reduce the value modulo
2^N of the destination and reinterpret with the destination's signedness.
Widening follows the source signedness: i8 -1 to u64 is 2^64 - 1;
u8 255 to i64 is 255. In LLVM, _emit_cast chooses zext/sext from
the source annotation. In C, IntegerCodeGen.convert emits a plain cast
when the destination is unsigned or the conversion widens, and routes
narrowing-to-signed through the unsigned type and __tack_bits_*.
Floating-to-integer conversion truncates toward zero (int(-2.7) == -2),
using fptosi/fptoui on CPU and a C cast elsewhere. The input must be
finite and its truncated value must fit; see
Caller constraints.
Floor division and remainder¶
Rule: for integer operands, a // b rounds toward negative infinity
and a % b has the divisor's sign, so a == (a // b) * b + a % b. The
operation runs in the promoted integer type.
| Expression | Tack (Python) | C truncation |
|---|---|---|
-7 // 3 |
-3 |
-2 |
-7 % 3 |
2 |
-1 |
7 // -3 |
-3 |
-2 |
7 % -3 |
-2 |
1 |
Why Python's rule. Kernel authors write Python and test against NumPy,
which also floors. The divisor-signed remainder is what index arithmetic
wants: (i - 1) % n wraps to n - 1 at i = 0 instead of producing -1
and an out-of-bounds read. Before this rule was enforced, the GPU emitters
truncated toward zero, as C does, contrary to the Python source.
LLVM (LLVMCodeGen._emit_integer_division): unsigned types use
udiv/urem directly. Signed types compute srem and sdiv, then subtract
one from the quotient (or add the divisor to the remainder) when the
remainder is nonzero and its sign differs from the divisor's.
C-family (codegen/integer_division.py): integer_division_expr
emits an unsigned / or % inline, and for signed types a typed helper
with the same correction:
__device__ inline int __tack_floordiv_i32__(int a, int b) {
int q = a / b;
int r = a % b;
int adjust = r != 0 && ((r < 0) != (b < 0));
return q - adjust;
}
Operands are converted to the annotated type before the call, so C's own promotions never pick the operation's signedness.
True division¶
Rule: integer a / b converts each operand to f32 and returns an f32
quotient: 7 / 2 == 3.5. With a floating operand, / uses the promoted
floating type. Request f64 explicitly with tack.f64(a) / tack.f64(b); an
f64 destination array alone does not widen the division.
Why f32 and not f64. Python's / is true division, so returning a
float is the Python-compatible part; truncating integer division (the old
GPU behavior) is now spelled //. The precision follows Tack's default
floating type: Metal has no f64 at all and Level Zero devices may lack it,
so an f64 result would make plain integer / unsupported on those
backends. The price is that integers above 2^24 lose precision in the
conversion. Signed/u64 pairs are allowed here, unlike in integer promotion,
because each operand is converted to f32 independently.
Implementation: _annotate_expr sets the f32 dtype; LLVM coerces both
operands to float and emits fdiv; C-family generators emit
((float)(a)) / ((float)(b)).
Integer power¶
Rule: for two integer operands, a ** e and pow(a, e) have the
base's type and equal the exact power modulo 2^N. i8 3 ** 5 is -13;
u8 20 ** 2 is 144; 0 ** 0 is 1. A negative integer literal exponent
raises TypeError from _check_integer_exponent; cast the base to a
floating type for negative powers.
Why. The old lowering went through floating pow, which loses exact
results above 2^24 (f32) or 2^53 and made ** and pow disagree. Keeping
the base's type matches the wrapping rule: the result is what repeated
wrapping multiplication would give. The exponent's type cannot widen the
result, so an i8 base with a u64 exponent is still i8.
Implementation: exponentiation by squaring. LLVM emits an internal
__tack_pow_<type>__ function (LLVMCodeGen._emit_integer_power) that
converts the exponent to i64 and halves it with a logical shift, so even
a negative dynamic exponent terminates within 64 iterations. The C-family
helper from IntegerCodeGen takes the exponent as an unsigned 64-bit value
and multiplies in the unsigned carrier.
Floating-point arithmetic¶
Precision¶
f32 and f64 expressions compute at their annotated precision; floating
operands promote to f64 if present, otherwise f32. Math builtins convert
their arguments to the annotated precision before the call (all four
C-family generators cast each argument explicitly, which also avoids
OpenCL's overload ambiguity for integer arguments). An integer argument to
sqrt or a trigonometric, exponential or logarithmic function is f32 unless
another argument is f64.
Floating floor division and remainder¶
Rule: with a floating operand, // and % convert both operands to the
promoted floating type and return that type; // returns an integer-valued
float. The algorithm is CPython's: a truncating fmod, corrected to the
divisor's sign, with the quotient reconstructed from it and snapped to the
nearest integral value.
Why not floor(a / b). The rounded quotient can land exactly on an
integer that the true quotient does not reach. In binary64 0.1 is
slightly larger than one tenth, so:
| Expression (f64) | Python and Tack | floor(a / b) |
|---|---|---|
0.5 // 0.1 |
4.0 |
5.0 |
1.0 // 0.1 |
9.0 |
10.0 |
0.5 % 0.1 |
0.09999999999999998 |
Converting to an integer and back was also rejected: the old GPU lowering did that, which overflowed for large or nonfinite quotients and threw away f64 precision.
Implementation: float_division_helpers (codegen/float_division.py)
emits one helper per operator and precision; LLVMCodeGen._emit_float_division
builds the same function in LLVM IR with frem, llvm.floor and
llvm.copysign. The C form for //:
__device__ inline double __tack_floordiv_f64__(double a, double b) {
double r = fmod(a, b);
double q = (a - r) / b;
if (r != 0.0) {
if ((r < 0.0) != (b < 0.0)) {
r += b;
q -= 1.0;
}
}
if (q == 0.0) return copysign(0.0, a / b);
double result = floor(q);
if (q - result > 0.5) result += 1.0;
return result;
}
The call site converts both operands first,
__tack_floordiv_f64__((double)(x), (double)(y)), so integer operands are
converted exactly once and the helper never sees mixed types.
The explicit copysign calls give signed zeros the contract's signs: a zero
remainder takes the divisor's sign and a zero quotient the true quotient's
sign. NaN in either operand or an infinite dividend yields NaN for both
operators; a finite dividend over an infinite divisor follows CPython
(for example -1.0 // inf == -1.0 and -1.0 % inf == inf). These rely on
safe compiler math, described next.
Safe math by default¶
Every kernel on every backend compiles with value-preserving floating-point options:
| Backend | Where | Setting |
|---|---|---|
| CPU | codegen/llvm_gen.py |
no LLVM fast-math flags on any instruction |
| CUDA | runtime/cuda_backend.py, _compile_ptx |
--ftz=false --prec-div=true --prec-sqrt=true --fmad=true, no --use_fast_math |
| Metal | runtime/metal.py, _compile_kernel and _get_reduce_pipeline |
MTLCompileOptions.setFastMathEnabled_(False) |
| HIP | runtime/hip_backend.py, _compile_code_object |
_HIPRTC_OPTIONS: -ffp-contract=on only; no unsafe or relaxed-math option |
| Level Zero | runtime/level_zero_backend.py |
_OPENCL_OPTIONS: -cl-std=CL2.0 -cl-fp32-correctly-rounded-divide-sqrt (f32 division and sqrt correctly rounded, as CUDA's --prec-div/--prec-sqrt); no relaxed-math option |
The same settings apply to the runtime field-reduction kernels.
Why. Under the previous defaults (CUDA --use_fast_math, Metal's
default fast mode) the Metal compiler demonstrably replaced nonfinite
identity expressions such as x - x with zero, folded NaN != NaN to false,
and reassociated parenthesized sums. Those are not low-bit differences;
they change which branch runs. A single safe policy also means no math mode
has to participate in the variant key. The cost is throughput on
arithmetic-heavy kernels, and an opt-in fast mode is an open question; any
such mode would have to join the specialization key.
Contraction is permitted, reassociation is not. --fmad=true lets CUDA
fuse a * b + c into one rounding; other compilers may do the same. Fusing
is usually faster and at least as accurate per operation, and forbidding it
would cost throughput on every GPU, so the contract permits it without
requiring it. CPU and GPU can therefore differ in the last bits of such
expressions. Regrouping (a + b) + c as a + (b + c) is never allowed.
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. hipRTC's default,
-ffp-contract=fast, broke that. It fuses a multiply into an add or
subtract in a later statement, and on AMD GPUs did so where the product
had another use, so one variable held two values: with p = k * 0.2,
p - p came out as the product's rounding error (1.49e-8 for k = 3)
where CPU and CUDA gave zero. A contour that tests finiteness with
a - a != 0.0 then took finite values for infinite ones. HIP therefore
compiles with -ffp-contract=on, which fuses only inside one expression;
the option is part of the disk cache's target, so code objects compiled
without it are not reused. test_float_semantics.py checks p - p for
products returned from a device function, in f32 and f64, on every
backend.
NVRTC's --fmad=true fuses across statements too, but where a product has
one later use: p = a * b; out = p + c became one fma, p never rounded.
p - p cannot show that, since the compiler folds it whether or not it
fuses, so it went unseen until the locator's two searches, inlining the
same functions into different kernels, disagreed at shared vertices on
CUDA. (Fusion inside one expression, which the contract permits, can
still differ between two such kernels; what fixed the locator was a margin
on its "holds the point" test, since which cell held a shared vertex was
rounding noise on every backend.) NVRTC has no per-expression option, so the CUDA generator rounds such
products itself: a floating product that is not an operand of a + or -
is __fmul_rn/__dmul_rn, which NVRTC never fuses, and the products of a
sum stay plain, fusable as the contract permits. A test reads a named
product once in a later sum against its negated rounding, where a fused
result is the product's rounding error instead of zero. Metal passes it
with plain products; OpenCL C contracts per expression by its FP_CONTRACT
rule.
NaN, infinity and signed zero¶
| Behavior | How it is implemented |
|---|---|
NaN != NaN is true; other comparisons with NaN are false |
LLVM uses fcmp unordered for != and ordered for the rest |
| NaN is true in a condition | LLVM _to_i1 tests fcmp une x, 0 |
x - x, x * 0 are not folded to zero |
safe-math settings above |
-x flips the sign of zero; abs clears it |
fneg and llvm.fabs on CPU; fabsf/fabs on CUDA, HIP and OpenCL; abs on Metal |
floor, ceil keep signed zero |
intrinsics; plus the Level Zero f64 workaround below |
Floating / by zero gives signed infinity or NaN |
ordinary IEEE division, no Python exception |
Floating min/max return the numeric operand when exactly one input
is NaN, NaN when both are, and either zero sign on a 0.0/-0.0 tie. CPU
uses llvm.minnum/llvm.maxnum, CUDA/HIP fminf/fmaxf (fmin/fmax for
f64), OpenCL fmin/fmax, Metal min/max on floats. The rule matches
IEEE 754's minNum/maxNum and the native instructions, and it is
order-independent. Python's built-in min is not: min(nan, 1.0) is nan
but min(1.0, nan) is 1.0. Array reductions and atomics have their own,
different extrema rules (below, and in
Parallel Execution).
Intel f64 floor/ceil
On an Intel Data Center GPU Max 1100 with intel-opencl-icd
25.05.32567.17, the double-precision floor and ceil builtins return
+0.0 for floor(-0.0) and ceil(-0.1). OpenCLCodeGen._expr_call
wraps f64 floor/ceil in copysign(result, x). Since floor and
ceil never change the operand's sign, this is exact for every input,
including infinities, and a no-op where the runtime is already correct.
It is applied unconditionally rather than per device.
Denormals and accuracy¶
Nonzero denormal inputs, intermediates and results are outside the portable
domain: CUDA is asked for --ftz=false, but safe math does not imply
gradual underflow on every device. There is no blanket ULP bound for math
builtins; they use the standard backend routines. The tests state their
own regression tolerances (four ULP for arithmetic and sqrt, eight ULP for
bounded transcendental checks), which are not general guarantees.
Reductions¶
Array.sum(), min(), max() and mean() (lang/array.py) reduce all
logical elements regardless of shape and return a Python float. The
contract section is
Array and parallel reductions.
flowchart TD
A["Array._reduce(op)"] --> B{size == 0?}
B -->|yes| C["empty_reduction: sum 0.0,<br/>min/max raise ValueError"]
B -->|no| D{backend.supports_device_reductions?}
D -->|no: CPU| E[reduce_numpy]
D -->|yes| F["backend.reduce_array"]
F --> G{"f32? (Level Zero: and device<br/>admits 256-lane groups)"}
G -->|no| E
G -->|yes| H["native kernel from<br/>array_reduction_source"]
mean() returns NaN for an empty array and otherwise sum() / size in
host binary64.
| Input | sum |
min / max |
|---|---|---|
| empty | 0.0 |
ValueError |
| contains NaN | NaN | NaN |
inf and -inf |
NaN | -inf / inf |
[0.0, -0.0] |
either zero sign | -0.0 / 0.0 |
[-0.0, -0.0] |
either zero sign | -0.0 / -0.0 |
i8 [100, 100] |
200.0 (i64 accumulator) |
|
u64 [2^64 - 1, 2] |
1.0 (wraps modulo 2^64) |
Host path (runtime/reductions.py, reduce_numpy): integer sums use an
explicit i64 or u64 NumPy accumulator, independent of the platform's
default integer, and wrap. Extrema use NumPy's NaN-propagating min/max
and then fix zero ties from the sign bits.
Native path (codegen/reductions.py, array_reduction_source): one
256-lane tree per workgroup in shared memory, then one atomic combine per
group into the output (native atomicAdd or atomic_float for sums, a
compare-and-swap loop for extrema). Out-of-range lanes contribute the
identity: 0.0f, or +inf/-inf written as bit patterns. The extrema
combiner, shared by all four dialects and by block reductions, is:
__device__ inline float tack_reduce_min_f32(float a, float b) {
if (a != a || b != b) return __uint_as_float(0x7fc00000u);
if (a == 0.0f && b == 0.0f)
return __uint_as_float(__float_as_uint(a) | __float_as_uint(b));
return a < b ? a : b;
}
OR-ing the bit patterns of two zeros yields -0.0 if either is negative,
which is the minimum's tie rule; the maximum uses AND, yielding +0.0
unless both are negative. Both are independent of the order in which
groups arrive, which is what makes extrema reproducible while sums are not.
Why the extrema differ from scalar min/max. A reduction asks "what
is the smallest value in this data"; silently skipping a NaN would hide
corrupted input, so NaN propagates. The scalar builtin is used for clamping,
where preferring the number is the useful behavior. Order-independent zero
ties make the reduction result deterministic.
Order and accuracy. The sum's group partials are combined atomically in
arrival order, so low bits can differ between calls. The contract's
absolute error budget is |computed - exact| <= gamma_n * sum(|x|) with
gamma_n = n*u / (1 - n*u) (u = 2^-24 for f32, 2^-53 for f64), valid
when no partial sum overflows or underflows and n*u < 1. It covers both
the tree and the serial NumPy path; there is no compensated or
deterministic sum mode.
Limits. CUDA, HIP and Level Zero pass the element count to the native
kernel as a 64-bit (long long) argument, and the CUDA/HIP kernel forms
its index in 64 bits, so arrays of 2^32 or more elements reduce on the
device. Each checks the count against its launch limit with
check_launch_size() first, and raises ValueError ("Array sum(): ...")
past it. Metal's reduction kernel holds the count, packed as 32-bit
unsigned bits, and its thread position in 32 bits, so Metal reduces an array
of 2^32 or more elements with NumPy over the shared buffer instead. Level
Zero also falls back to the host when its device cannot run 256-lane
groups, because the tree is written for exactly 256 lanes.
Block reductions (tack.block_sum, block_min, block_max) require
f32 input: annotate_types raises TypeError otherwise, and an explicit
tack.f32(...) cast is the fix. They were previously annotated with the
input's type while the GPU code stored float, so the annotation and the
emitted code disagreed. Their execution model is described in
Parallel Execution.
Caller constraints and "no portable result"¶
Some operations have inputs with no portable answer. Tack does not check them at run time; checks would cost every dispatch and a GPU has no good way to raise an exception. The contract calls these caller constraints.
| Operation | Constraint |
|---|---|
integer //, % |
divisor nonzero; not the signed minimum with divisor -1 |
integer / |
divisor nonzero (stated by the contract even though the f32 division itself would produce an infinity or NaN) |
floating //, % |
divisor nonzero |
<<, >> |
count in [0, N) for the left operand's width |
integer ** |
dynamic exponent nonnegative |
| float to integer | input finite and its truncation fits the destination |
range(a, b, step) with a dynamic step |
step positive |
"No portable result" means more than "some unspecified number". The
target compiler may assume the case cannot occur, so the outcome can differ
between backends, between optimization levels, and between a cold compile
and the same code inlined elsewhere. It need not be a value at all: on an
x86-64 host the CPU backend's integer division by zero raised a hardware
trap that terminated the Python process. Guarding the operation is the
supported pattern, and guards are honored: an operation in the unselected
arm of an if, a conditional expression, or a short-circuit operand is not
executed.
for i in range(n):
out[i] = a[i] // b[i] if b[i] != 0 else 0 # division never runs for b == 0
Tests and their oracles¶
Test module (packages/tack-core/tests/) |
Rule pinned | Oracle |
|---|---|---|
test_integer_semantics.py |
wrapping, all 64 integer cast pairs, promotion, shifts, comparisons, exact builtins, full-width literals, one-byte-misaligned imports | Python big integers reduced modulo 2^N; also structural checks that GPU helpers use unsigned arithmetic |
test_integer_division.py |
integer ///% at every width, exhaustive valid i8 pairs, mixed promotion, guarded zero divisors |
Python integer // and % |
test_division_and_power.py |
true division (f32 result, explicit f64), integer power for all 64 base/exponent type pairs | Python modular exponentiation; division with a four-ULP regression tolerance |
test_float_division.py |
floating ///%: signs, boundaries, overflow, NaN/inf/signed zero, every float/int type pair |
NumPy's typed divmod after explicit conversion; exact zeros and classes, four ULP otherwise |
test_float_literals.py |
weak float literals: f64 expressions, casts, atomics, conditional arms and locals; f32 kernels unchanged; exact double literals in LLVM, CUDA, HIP and OpenCL source | NumPy f64 results for the same expressions; generated C++ compiled and run on the host |
test_float_semantics.py |
safe math without //, exceptional classes, unsafe folds, comparisons, scalar min/max, grouping, contraction, math builtins |
NumPy at the same precision; an exact fractions.Fraction oracle accepts either the separate or the fused multiply-add result; generated C++ run under UBSan on the host |
test_reduction_semantics.py |
extrema classes and zero ties across groups and tails, exact and bounded sums, integer accumulators, empty arrays, block f32 requirement | exact expected values; math.fsum with the gamma_n budget; C++/OpenCL syntax checks and UBSan-run helpers |
test_integer_expression_differential.py |
compositions where an intermediate wrap, cast or promotion decides a later operation | an independent oracle written from the contract text with Python integers, sharing no code with the compiler; a deliberately naive wrap-only-at-the-end oracle is shown to disagree |
The integer tests compare exactly: a defined integer program has one answer on every backend. Floating tests state a tolerance and a domain, because the contract does not promise bitwise agreement across backends. Several modules also compile the generated C-family source with Clang on the host (syntax checks, and UndefinedBehaviorSanitizer runs of the helpers); those complement, but do not replace, execution on each GPU. See Conformance and Validation.