Shapes & strides¶
Each dimension's size and stride may be static (a compile-time constant) or dynamic (a run-time value) — independently. Static values fold into machine code; dynamic ones are carried at run time. One kernel source covers every combination.
shape<...> — the sizes¶
shape<...> lists the per-dimension sizes of a tensor. Its int64_t index type
matches DLPack's, so it drops onto ndarray bindings directly. (Under the hood it is
teeny's spelling of cuda::std::extents<int64_t, ...> — see
mdspan vs teeny.)
shape<2,3,4> // fully static 2×3×4
shape<-1,3> // dynamic rows, static 3 columns (-1 == dynamic)
shape<dynamic_extent,3> // the same type, spelled out
A dynamic dimension is -1 (numpy-style) or dynamic_extent. Construct a tensor
by supplying only the dynamic sizes:
auto a = wrap(ptr, shape<2,3,4>{}); // all static — nothing to supply
auto b = wrap(ptr, shape<-1,3>{n}); // supply the one dynamic size, n
auto c = wrap(ptr, shape<-1,-1>{r, k}); // supply both
auto d = wrap(ptr, rank<2>{r, k}); // rank<N> is the fully-dynamic rank-N shape
rank<N> is the fully-dynamic shape of rank N, so rank<2> is exactly
shape<-1,-1>. (There is no rank{r, k} / shape{r, k} shortcut: class-template
argument deduction does not apply to alias templates in C++17, so you always give
the rank — rank<2>{r, k} or shape<-1,-1>{r, k}.)
Query sizes with shape:
t.rank(); // number of dimensions (static)
t.numel(); // total element count
t.shape(); // the whole shape as an array-like accessor
t.shape(1); // RUNTIME lookup -> the size of axis 1 (an index_type value)
t.shape(Int<0>()); // STATIC lookup -> an integral_constant when that axis is static
// (so it folds into later arithmetic)
t.shape(Int<-1>()); // negative axis: the last dimension
(These are teeny's numpy/pytorch-flavoured spellings. The underlying mdspan
spellings — t.extent(d), t.extents() — still exist as an interop escape hatch;
see mdspan vs teeny.)
strides<...> — the strides¶
The default layout is ccontiguous (C-order). In this case, strides are computed
automatically from the shape and do not need to be provided. For specific strides —
a padded row, a channel-last view, a reversed axis — use strides<...>, the stride
analogue of shape<...>:
auto a = wrap(ptr, shape<3,4>{}, strides<4,1>{}); // row stride 4 (padding), col 1
auto b = wrap<dynamic_stride,1>(ptr, shape<-1,4>{n}, {4}); // outer runtime, inner 1 (folds)
- Known strides become compile-time constants baked into the code, and only the
dynamic ones are stored. A fully-static
strides<...>mapping is empty, so astrides<>view is exactly a pointer. - Strides are signed:
strides<-4,1>is a real stride of −4 (a reversed view), not dynamic. A runtime stride is the sentineldynamic_stride.
Query a stride, static when derivable:
t.stride(0); // runtime
t.stride(Int<1>()); // a compile-time constant for a strides<> layout, a
// contiguous static shape, or a contiguous layout's
// always-unit stride (even under a dynamic shape)
A strides<...> tensor is fully sliceable. Every view op — operator()
slicing, slice_along, permute, flip, squeeze/unsqueeze, peel — works on
any source layout including strides<...>, and folds the output strides the same
way. Slicing a contiguous static tensor keeps folded compile-time strides. (If you
know mdspan, the mdspan vs teeny page explains how strides<>
relates to layout_static_stride and why teeny builds these views by hand.)
Those views are also usable from CUDA device code: a strides<...> view passes
into a kernel by value and indexes there exactly like a contiguous one, so you can
slice on the host and hand the slice to a kernel, or slice again inside it.
The static/runtime idiom¶
The API accepts runtime integers, static integers (integral_constant), and
slices of either, and returns the matching output type. alias.h provides short
names for these static integers: Int<V>, Long<V>, Size<V>, UInt<V>,
Int32<V>, Int64<V>, Diff<V>, Bool<V>. Each static type converts implicitly
to a runtime value and carries a ::value.
Rule of thumb: pass Int<k>() to make the compiler fold; pass a plain
int/long when the value is only known at run time.
t.extent(Int<0>()); // -> integral_constant<…,2> (a constant, folds)
t.extent(runtime_d); // -> int64_t (a value)
The index type — shape32 / reindex¶
Every offset computation runs in the shape's index_type (int64_t for
shape<...>, matching DLPack). At a kernel boundary you can narrow it to 32-bit
without a copy:
reindex preserves the layout — same pointer and layout kind, with the shape and
any dynamic strides narrowed to the new width (a strides<...> literal pack is
unchanged). It's the index-width twin of recast (which recovers static shape
values): orthogonal, and they compose. shape32<...> == shape_as<int32_t, ...>
is the int32-indexed shape; shape_as<Idx, ...> picks any index type.
The payoff is on the device: a dynamic-stride view carries its strides by value, so
halving their width cuts the register footprint (rank-2: 40 → 24 bytes) and runs the
address math in 32-bit. Guard it with t.index_fits<int32_t>() and only narrow when
it says yes. See Performance.
index_fits<Idx2>() answers the whole question reindex<Idx2>() needs: every
element offset fits Idx2, and every axis's size is a number Idx2 can hold. The
offset half is a signed-reach check, so negative-stride (flipped) and broadcast views
are measured exactly. The size half matters just as much, because narrowing rewrites
the shape too — and the shape is what every loop downstream counts up to. A size that
didn't survive the narrowing would come back truncated, or even negative, and the
loop would quietly do the wrong amount of work (or none at all). A broadcast
(stride-0) axis is where the two halves come apart: it reaches nothing whatever its
size, so only the size half can catch it.
Each extent and stride is read in the view's own index type, so a shape you
spelled as shape_as<uint64_t, …> is measured exactly, whatever its values: nothing
is quietly reinterpreted as a signed number on the way in. A statically sized
axis too big for the target index type needs no runtime check at all — narrowing it
is a compile error. That applies when you ask for the narrowing directly;
dispatch_index<Idx2>(v, f), which only offers it as one of two arms, drops the
impossible arm at compile time and hands f the wide view instead — see
Dispatch.
The rank-erased anyrank carrier answers the same two calls
(at.index_fits<int32_t>() / at.reindex<int32_t>(), free form: index_fits<int32_t>(at)
/ reindex<int32_t>(at)), so a device boundary can narrow the whole carrier once —
before the launch — instead of view by view.
Mixing widths in a broadcast¶
When two operands of different index widths meet in a broadcast (a + b,
a < b, …), the result takes an index type that can represent every extent and
stride value either operand can name. With both operands signed — as every teeny
shape (shape, shape32, rank) is — that is simply the wider of the two: an
int32-indexed view plus an int64-indexed one yields an int64-indexed result,
in either order. This is lossless (the broadcast engine already runs its offset math
in the result's index type) and avoids silently truncating the wide operand's strides
to the narrow width. Two equal-width operands are unchanged; note that broadening
cannot rescue two int32 operands whose broadcast span overflows int32 (an
outer-product stretch) — that stays the caller's concern, guarded by
index_fits/dispatch_index at the boundary.
If the two operands disagree in signedness (only reachable by spelling an
unsigned shape_as<unsigned, …> yourself), width alone cannot answer that question,
so the result steps up to a signed type wide enough for both ranges: int16 +
uint32 yields an int64-indexed result, and so does int32 + uint32, where
neither operand's own type covers the other's. Keeping the result signed also keeps
it a first-class teeny tensor — flip() and a negative-step slice both require a
signed index type.
The two-operand reductions dot(a, b) and sqdist(a, b)/dist(a, b) follow the
same rule: they produce a scalar rather than a tensor, but their offset math also
runs in a type that covers both operands, so mixing an int32-indexed operand with
an int64-indexed one is safe in either order — and, as below, so is mixing
signedness. So does allclose(a, b), which likewise walks both operands and hands
back a plain bool.
The in-place ops (a.add_(b), a.copy_(b), a *= b, …) and a caller-supplied
into(dest) are the one place where the destination cannot simply take the wider
type — a is your tensor, and its index width is part of its own type. There the
offset math runs in the widest of the types in play (the destination's and every
operand's), while every tensor keeps its own: the widening is internal, so
a.add_(b) still hands you back a with a's index type unchanged. Mixing widths
in place is safe in either direction — a narrow-indexed destination combined with a
wide-indexed right-hand side does not truncate that operand's strides, and a
wide-indexed destination with a narrow right-hand side was never at risk.
That covers every producer that takes an into(dest), not only the two-operand
elementwise ones: a scalar right-hand side (a.mul(2.0, into(y)),
minimum(a, 1.0, into(y))) and a unary op (exp(a, into(y)), clamp(a, lo, hi,
into(y))) each walk a source and a destination, so they too run their offsets in a
type covering both. Called without into(dest), those producers allocate their
result from the source's own shape type, so the widths already agree and there is
nothing to widen.
Mixing signedness is safe too. Width alone does not settle how those offsets are
computed: shape_as<Idx, ...> accepts an unsigned index type, while a flipped or
negative-step view has negative strides (which is exactly why such a view needs a
signed index type). Where an unsigned-indexed tensor meets a signed-indexed one, the
shared offset math runs in a signed type wide enough for both sides, so a stride of
-1 stays -1 instead of turning into a huge positive offset. As above this is
internal — every tensor keeps its own index type — and the ordinary case, where every
tensor in the expression shares one signedness, computes exactly as it did before.
This holds everywhere two or more tensors share one pass: the elementwise broadcast
ops above, and equally dot/sqdist/dist, where a flipped (reversed) signed-indexed
operand next to an unsigned-indexed one is safe in either operand order.