Repository navigation
Array views
Issue #335, 0.8. Status: in review, #393.
a[i:j] is an array of rows i to j of a's first dimension, over the
same memory, as in NumPy, on every backend: the CPU, CUDA, HIP, Level Zero,
Metal and Warp. Kernels, fill, tack.copyto, reductions, copies and DLPack
take a view as they take any array, and what they write lands in a.
rows = tack.zeros((6, 3))
middle = rows[2:4] # rows 2 and 3, the same memory
middle.fill(1.0)
assert rows.numpy()[3, 0] == 1.0 and rows.numpy()[4, 0] == 0.0Tack had one kind of view, reshape (the same memory under another shape),
and no way to address part of an array's memory as an array of its own.
Code that wanted that copied:
-
AMR.
ghost_cellsandaverage_downconcatenate every box's array into one buffer per device, fill it, and copy the results back box by box. On AMR-Wind's five levels (15,598 boxes, CPU), concatenation, copies back and placing were about 8 s ofghost_cells' 28 and 4 s ofaverage_down's 11. With views, each box's values are a stretch of its level's buffer, filled in place. That is the AMR level-buffers work (#336), onvis/amr-level-buffers, which builds on this. - Pieces, sorts and scans can use views the same way: a run of a sorted array, a piece's share of a concatenated array.
-
Rows of the first dimension, one contiguous stretch.
- Bounds follow Python's: negative from the end, clamped to the array.
- A step other than 1 is refused.
tack.arrays.Stridedreads every k-th element without copying. - An empty range gives a new empty array.
- A view keeps the array it views alive. A view of a view is a view of the root.
- A view is read-only if its root is (an imported read-only DLPack tensor, for example), and it keeps a DLPack import's hold on its source.
-
Writes count against the whole allocation. A write through any view
counts as a write to all of the root's memory (
DeviceBuffer.version), so anything derived from the values notices it, aCellLocatorfrom positions for example. - Structure-of-arrays arrays slice component by component: a view of each component, gathered as an SOA array.
-
What a view refuses:
-
export_memory, since it has no allocation of its own; export the root, or a copy; - assignment through a slice,
a[i:j] = x. Tack has no slice assignment; usetack.copyto(a[i:j], x)orfill.
-
A view's buffer is an instance of a subclass of its root's buffer class
(_view_class: _View first, then the root's class, named <Root>View).
Its attributes are the root's, with two kinds of changes:
- the backend's
_view_fields(start, count)replace the pointer and the size; - what concerns the allocation rather than the stretch stays with the root: the write count, being handed outside Tack, freeing, pooling, cached addresses.
Because the view is of the root's own class, every method of every backend (binding to a kernel, host reads and writes, fills, copies) runs unchanged on the shifted pointer.
| backend | a view's storage |
|---|---|
| CPU | a NumPy slice of the root's array |
| CUDA, HIP, Level Zero | the root's device pointer plus the offset (pointer_view_fields) |
| Metal | an offset into the root's MTLBuffer, passed by every argument-buffer binding, blit and reduction, and a slice of the host view |
| Warp | a non-owning wp.array at the offset |
What _View changes:
- It frees nothing. The root frees its memory once no view holds it; HIP's cache and Metal's pool see only the root.
-
It never starts zeroed, so
tack.zerosof it fills. -
It shares the root's write count (
version) and its_shared. So memory handed out through one view is treated as handed out for all of them, and launches over any part of it complete before returning. - Code that chooses by a buffer's class name reads the root's, for example DLPack finding a buffer's device.
Dispatch needs no change for views:
- The CPU's disjointness check compares byte ranges, the views' own:
- views of separate rows of one array are disjoint, so a kernel given
them can still compile with
noalias; - a view and its root, or two overlapping views, are not.
- views of separate rows of one array are disjoint, so a kernel given
them can still compile with
- A view's signature for the dispatch fast path is its dtype, shape and layout, like any array's.
- Debug mode bounds-checks against the view's rows, not its root's: an index past the view but inside the root is reported.
test_array_views.py covers every device the run has:
- reads, bounds, empty ranges, and rows of multidimensional, vector and matrix arrays;
- views of views;
- the forms refused: steps, a zero-dimensional array, an index beside the
slice, assignment through a slice,
export_memory; - kernels writing only the view's rows, and host writes reaching the parent;
- several views of one array in one kernel;
- atomics into a view;
- reductions, copies and reshapes;
- DLPack, including a Metal view handed out and coming back at its offset;
- SOA rows;
- write counts across views, the root kept alive, read-only roots, the
root's
_shared; - the CPU's disjointness with disjoint and overlapping views;
- debug mode's bounds checks.
The dispatch signature property test draws views among its arrays. Metal was run on its own on 2026-11-06.
-
#336, AMR at scale: the level buffers this was made for
(
vis/amr-level-buffers). - Device memory plan (#387): a view keeps a copy of its root's device pointer, shifted. Spilling a root must therefore either leave a root with live views alone, or have views take their pointer from the root at each use. Decide this when spilling is built.