Skip to content

Array views

Berk Geveci edited this page Nov 9, 2026 · 1 revision

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.0

Why

Tack 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_cells and average_down concatenate 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 of ghost_cells' 28 and 4 s of average_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), on vis/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.

What a view is

  • 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.Strided reads 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, a CellLocator from 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; use tack.copyto(a[i:j], x) or fill.

How it works

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.zeros of 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.
  • 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.

Tests

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.

Related

  • #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.

Clone this wiki locally