Skip to content

[Type] Tensor 24#561

Merged
hughperkins merged 330 commits into
mainfrom
hp/tensor-stork-24
Apr 28, 2026
Merged

[Type] Tensor 24#561
hughperkins merged 330 commits into
mainfrom
hp/tensor-stork-24

Conversation

@hughperkins

Copy link
Copy Markdown
Collaborator

Issue: #

Brief Summary

copilot:summary

Walkthrough

copilot:walkthrough

…or-stork-14

# Conflicts:
#	docs/source/user_guide/tensor.md
#	python/quadrants/__init__.py
#	tests/python/test_tensor_grad.py
When qd.tensor(..., backend=NDARRAY, layout=..., needs_grad=True) is
called, impl.ndarray(..., needs_grad=True) allocates a same-shape
companion grad ndarray and wires it via _set_grad. Only the primal was
being passed through _with_layout, leaving the grad untagged. As a
result, a kernel write to x.grad[i, j, ...] on a non-identity layout
would bypass the canonical->physical subscript rewrite and land in the
wrong physical slot.

Fix: after tagging the primal, propagate the same layout onto
arr.grad when present. Tests exercise the tag propagation directly
(_qd_layout equality on primal + grad) and via an end-to-end kernel
roundtrip on every rank-3 permutation.
… layout grad tests

Ndarray.to_numpy() returns the physical (permuted) buffer, as the
existing test_factory_layout_rank2_value_check / rank3 tests already
demonstrate. The two new ndarray grad tests in test_tensor_layout_grad.py
indexed the result with canonical indices, which yielded the wrong slot
on rank 2 (assert primal[1, 2] == 12.0 read 21.0) and went out of
bounds on most rank-3 permutations.

Translate canonical -> physical via the layout permutation when
asserting, mirroring the pattern used in
test_factory_layout_rank3_all_permutations. Field-backend grad tests
keep using canonical indexing (qd.field hides the permutation in
to_numpy()).

Made-with: Cursor
… / qd.Matrix.tensor

The two top-level dispatcher functions added alongside qd.tensor() in the
original PR 3 turned out to be redundant with the symmetry that the rest
of the matrix/vector API uses (qd.Vector.field, qd.Vector.ndarray, ...).
Genesis never adopted them; the user-facing docs already pointed at the
classmethod form.

Rename them to private impls (_tensor_vec, _tensor_mat) and drop them
from quadrants._tensor.__all__, so they no longer leak into qd.*.
Surface the per-tensor backend dispatch as qd.Vector.tensor and
qd.Matrix.tensor classmethods on Vector / Matrix (in lang/matrix.py),
both delegating to the private impls. The dispatch logic stays in one
place; the public API gains the Vector.tensor / Matrix.tensor symmetry
with the existing .field / .ndarray classmethods.

Tests:
- All vec/mat factory tests rewritten to call qd.Vector.tensor /
  qd.Matrix.tensor; same coverage (default/explicit FIELD, NDARRAY,
  invalid backend, kernel roundtrip on each backend).
- New regression test_tensor_vec_mat_not_public asserts the old names
  are gone from the qd.* surface.
- test_api.py: drop tensor_vec / tensor_mat from the expected set.
…r-stork-4

# Conflicts:
#	python/quadrants/_tensor.py
#	tests/python/test_api.py
…r-stork-7

# Conflicts:
#	python/quadrants/_tensor.py
…nsor

Follow-up to the PR-3 privatization of tensor_vec / tensor_mat. The
Vector/Matrix needs_grad tests added in this branch were calling the
now-private qd.tensor_vec / qd.tensor_mat. Update them to the public
qd.Vector.tensor / qd.Matrix.tensor classmethod surface.

No behavioural change — same factories under the hood (Vector.tensor /
Matrix.tensor delegate to _tensor_vec / _tensor_mat).
… PR-6 merge

The PR-3 privatization renamed tensor_vec / tensor_mat to private impls
in _tensor.py. When merging PR 6 forward into PR 7 in this branch's
history, a conflict-resolution slip restored HEAD's copy of the file
(checkout --ours) which still had the old top-level names + the old
__all__. The Vector.tensor / Matrix.tensor classmethods on PR 7 import
_tensor_vec / _tensor_mat, so without this fix calling them raises
ImportError.

Re-apply the rename:
- _tensor.py: drop the dead public tensor_vec / tensor_mat function
  defs, restore the private _tensor_vec / _tensor_mat impls, drop the
  two stale entries from __all__ (already done correctly via PR 4
  merge resolution but the function defs hadn't followed).
- docs/source/user_guide/tensor.md: switch the "Vector and matrix
  tensors" section and the needs_grad note to qd.Vector.tensor /
  qd.Matrix.tensor wording.

No new functionality, no test changes — just bringing PR 7 in line
with what PRs 5 and 6 already had. Will cascade-merge into PRs 8-14
which still carry the same regression.
…or-stork-14

# Conflicts:
#	docs/source/user_guide/tensor.md
#	python/quadrants/_tensor.py
#	tests/python/test_api.py
- Apply black/ruff fixes (import sorting, formatting, drop unused pytest)
- Drop broken #fields/#ndarrays anchor links in tensor.md

Made-with: Cursor
… / qd.Matrix.tensor

The "Vector and matrix tensors" section in tensor.md (introduced
alongside the original tensor_vec / tensor_mat dispatchers in this
branch) still pointed at qd.tensor_vec / qd.tensor_mat — names that
the privatization commit moved to private impls and dropped from the
public API. Update the section text and code samples to use the new
qd.Vector.tensor / qd.Matrix.tensor classmethod surface, matching
what the rest of the doc already does on later branches.

Cosmetic only; no API or test changes.
copy_from: compare canonical .shape instead of physical .arr.shape so
cross-layout copies with matching canonical shapes work (the underlying
kernel already handles mixed layouts via per-side AST permutation).

__deepcopy__: propagate _qd_layout on ScalarNdarray, MatrixNdarray, and
VectorNdarray so the copy preserves canonical shape and kernel indexing.
Comment on lines +206 to +220
_validate_kwargs(kwargs, factory_name="qd.tensor", accepted=_SCALAR_ACCEPTED_KWARGS)
backend = _coerce_backend(backend)
forwarded = {k: v for k, v in kwargs.items() if k != "backend"}
# pylint: disable-next=import-outside-toplevel # late import to break circular dependency
from quadrants.lang import impl

shape_t = (shape,) if isinstance(shape, int) else tuple(shape)
order = _layout_to_order(layout, len(shape_t)) if layout is not None else None

if backend is Backend.FIELD:
if order is not None:
forwarded["order"] = order
f = impl.field(dtype, shape, **forwarded)
# The canonical->physical layout permutation is attached by ``_field`` itself via ``_qd_layout`` (identical
# attribute to the one ``Ndarray`` uses). The AST subscript rewrite in ``build_Subscript`` /

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 qd.ad.Tape and qd.ad.FwdMode break when given qd.Tensor wrappers (which is what qd.tensor(...) now returns post stork-19): the isinstance(loss, Field/Ndarray) checks at ad/_ad.py:193,208,256 and the bare isinstance(ls, ScalarField) / isinstance(self.param, ScalarField) asserts at ad/_ad.py:439,447 all fail because qd.Tensor uses composition, not inheritance, and falls through to a torch path that calls non-existent .numel() / .requires_grad. The user guide (docs/source/user_guide/tensor.md) recommends qd.tensor(..., needs_grad=True) as the unified replacement, so the documented migration path raises a confusing AttributeError on any reverse-mode autodiff. Fix is a one-line defensive if isinstance(self.loss, Tensor): self.loss = self.loss._unwrap() (and same for self.param / ls) before each isinstance dispatch in Tape.enter, Tape.grad, and FwdMode.enter.

Extended reasoning...

What the bug is

After this PR, qd.tensor(...), qd.Vector.tensor(...), and qd.Matrix.tensor(...) always return a qd.Tensor wrapper around the bare impl (see _tensor.py:206/209/220 calling _wrap_impl(...)). The wrapper is composition-based — it stores the impl as self._impl and exposes a fixed whitelisted surface (shape, dtype, layout, to_numpy, from_numpy, to_torch, from_torch, to_dlpack, fill, copy_from, _unwrap, __getitem__, __setitem__, grad, __reduce__). It is not a subclass of Field or Ndarray, and it has no __getattr__ forwarding, no numel, and no requires_grad.

Meanwhile, python/quadrants/ad/_ad.py (untouched by this PR) does raw isinstance dispatch:

  • Tape.__enter__ (lines 193-226):
    if isinstance(self.loss, Field):
        ...
    elif isinstance(self.loss, Ndarray):
        ...
    else:
        import torch
        if self.loss.numel() != 1:           # <-- AttributeError on qd.Tensor
            raise RuntimeError(...)
        if not self.loss.requires_grad:      # <-- AttributeError on qd.Tensor
            ...
  • Tape.grad (line 256):
    if isinstance(self.loss, (Field, Ndarray)):
        self.loss.grad.fill(1.0)
    else:
        import torch
        if self.loss.grad is None:           # qd.Tensor has .grad but it is a wrapper, not a torch tensor
            self.loss.grad = torch.ones_like(self.loss)
        else:
            with torch.no_grad():
                self.loss.grad.fill_(1.0)    # wrapper has no fill_
  • FwdMode.__enter__ (lines 432-447):
    for ls in self.loss:
        assert isinstance(ls, ScalarField)   # bare assert, no message
    ...
    assert isinstance(self.param, ScalarField)

A qd.Tensor wrapper passes none of these isinstance checks (Tensor is composition-based, not a subclass) and trips the torch fallback or the bare assert.

Step-by-step proof (Tape)

loss = qd.tensor(qd.f32, shape=(), backend=qd.Backend.FIELD, needs_grad=True)
# loss is qd.Tensor(ScalarField); type(loss) is qd.Tensor.

with qd.ad.Tape(loss):
    func()
  1. Tape.__enter__ runs isinstance(self.loss, Field) → False (Tensor is not a Field subclass).
  2. isinstance(self.loss, Ndarray) → False.
  3. Falls through to the torch branch at line 217.
  4. self.loss.numel() raises AttributeError: Tensor object has no attribute numel.

The same fallthrough recurs in Tape.grad() at line 256: self.loss.grad returns a Tensor wrapper (post stork-19 Tensor.grad lazily wraps the impls grad, see _tensor_wrapper.py:225-230), then self.loss.grad.fill_(1.0) raises because the wrapper has no fill_ method (only fill).

Step-by-step proof (FwdMode)

loss = qd.tensor(qd.f32, shape=(), backend=qd.Backend.FIELD, needs_grad=True)
param = qd.tensor(qd.f32, shape=(), backend=qd.Backend.FIELD, needs_grad=True)

with qd.ad.FwdMode(loss=loss, param=param):
    ...

FwdMode.__enter__ does assert isinstance(ls, ScalarField) (line 439) and assert isinstance(self.param, ScalarField) (line 447). Both fail bare-AssertionError with no diagnostic.

Why existing code does not prevent it

The wrapper-unwrap hooks in Kernel.__call__ (kernel.py:614-624), _recursive_set_args (_func_base.py:498-505), the args hasher (args_hasher.py:88-91), and build_Attribute (ast_transformer.py) all defensively unwrap qd.Tensor wrappers before downstream code observes them. The autograd module pre-dates the wrapper class promotion (stork-19) and was not updated in this PR. None of the new tests in tests/python/test_tensor_grad.py exercise qd.ad.Tape / FwdMode against a wrapper-allocated loss/param; that file only checks that needs_grad=True allocates a companion grad ndarray.

Impact

User-visible breakage on a documented public API. The PRs own user guide (docs/source/user_guide/tensor.md) recommends:

Kernels write through canonical indices on both primal and grad.

a = qd.tensor(qd.f32, shape=(4,), needs_grad=True)
assert a.grad is not None

Following that recommendation and then using qd.ad.Tape(a) for reverse-mode autodiff (the documented Quadrants pattern in docs/source/user_guide/) raises AttributeError: Tensor object has no attribute numel — a confusing internal error rather than a clean type complaint. Pre stork-19 qd.tensor() returned bare impls and this worked; post stork-19 it does not.

Fix

In Tape.__enter__, Tape.grad, and FwdMode.__enter__, defensively unwrap a qd.Tensor wrapper before the isinstance checks:

from quadrants._tensor_wrapper import Tensor as _TensorClass
if isinstance(self.loss, _TensorClass):
    self.loss = self.loss._unwrap()

Same one-liner before the param and per-ls checks in FwdMode. Idempotent for non-wrapped values; preserves the existing isinstance-based dispatch.

Comment on lines +82 to +83
- :attr:`NDARRAY` (``qd.ndarray``): slightly slower at runtime but avoids kernel recompilation when sizes change.
Best for tensors whose shape varies frequently (e.g. dynamic batch sizes, growing buffers).

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The Backend.NDARRAY enum docstring at python/quadrants/_tensor.py:82 still reads 'slightly slower at runtime but avoids kernel recompilation when sizes change', but docs/source/user_guide/tensor.md was updated to drop 'slightly' after reviewer pushback (observed up to 30% slowdown on real benchmarks). The Sphinx-rendered Backend reference now contradicts the user guide; replace 'slightly slower' with 'slower' to match the prose-level fix already applied to tensor.md.

Extended reasoning...

What the bug is

The Backend.NDARRAY enum docstring at python/quadrants/_tensor.py:82 still contains the legacy wording:

- :attr:`NDARRAY` (``qd.ndarray``): slightly slower at runtime but avoids kernel recompilation when sizes change.

Meanwhile docs/source/user_guide/tensor.md:16 was updated in this PR to read:

| `qd.Backend.NDARRAY` | `qd.ndarray` | Slower at runtime but avoids recompilation when sizes change. |

— the word slightly has been intentionally dropped.

How it manifests

The Sphinx autodoc build pulls the Backend enum docstring into the rendered API reference, so users browsing the Backend class reference page see 'slightly slower' while the user-guide prose says 'Slower'. This is a direct, user-visible contradiction between two pieces of documentation that both describe the same backend trade-off.

The PR conversation

Reviewer duburcqa explicitly requested removing 'Slightly' (inline-comment 3147237145, 2026-04-27T12:23:55Z):

I would remove 'Slightly'. Just the right amount of information to get your attention while being frustrated of any idea how much slower it is. In practice I observe up to 30% on some benchmarks, which is arguably not negligible.

The PR author hughperkins agreed (inline-comment 3147261172, 2026-04-27T12:27:46Z):

Yes, strongly agree. Will update.

The user-guide edit landed (commit ca4621e per the timeline shows the user-guide change), but the matching one-word fix in the Backend enum docstring was missed.

Why existing code does not catch this

It's a pure docstring/wording inconsistency between two source files. Nothing functional touches it; only Sphinx autodoc + manual reading of both files reveals the contradiction.

Step-by-step proof

  1. Open python/quadrants/_tensor.py, jump to line 82. Observe: 'slightly slower at runtime but avoids kernel recompilation when sizes change.'
  2. Open docs/source/user_guide/tensor.md, jump to line 16. Observe: 'Slower at runtime but avoids recompilation when sizes change.'
  3. grep -n 'slightly\|Slightly' docs/source/user_guide/tensor.md returns no matches; grep -n 'slightly' python/quadrants/_tensor.py returns line 82.

Impact

User-facing documentation contradiction. The author has already publicly committed to the fix; this comment surfaces the missed instance for the follow-up commit.

How to fix

One-word edit at python/quadrants/_tensor.py:82:

-  - :attr:`NDARRAY` (``qd.ndarray``): slightly slower at runtime but avoids kernel recompilation when sizes change.
+  - :attr:`NDARRAY` (``qd.ndarray``): slower at runtime but avoids kernel recompilation when sizes change.

This makes the Sphinx-rendered Backend API reference consistent with the user-guide wording the author already agreed to.

🔬 also observed by verify-runtime

…or-stork-25

# Conflicts:
#	python/quadrants/lang/_ndarray.py
@github-actions

Copy link
Copy Markdown

Coverage Report (7438d78a6)

Metric Value
Diff coverage (changed lines only) 89%
Overall project coverage 73%

Total: 4068 lines, 445 missing, 89% covered

🔴 python/quadrants/__init__.py (0%)
🔴   32  from quadrants._tensor import *
🔴   33  from quadrants._tensor_wrapper import (
     34      MatrixTensor,
     35      Tensor,
     36      VectorTensor,
     37      wrap,
     38  )
     39  
     40  # Back-compat aliases for stork-17/18 opt-in names. Drop in a future stork branch once downstream call sites have
     41  # switched to the unprefixed names.
🔴   42  _Tensor = Tensor
🔴   43  _VectorTensor = VectorTensor
🔴   44  _MatrixTensor = MatrixTensor
🔴   45  _wrap = wrap
     68      "Backend",
     69      "Tensor",
     76      "tensor",
🟢 python/quadrants/_kernels.py (100%)
     49      # Iterate via ``arr`` (always untagged, canonical-shaped) so that subscripting ``ndarray[I]`` on a layout-tagged
     50      # source applies the canonical->physical permutation correctly and writes land at the canonical positions in
     51      # ``arr``. For untagged sources both sides share a shape so behaviour is identical to ``grouped(ndarray)``.
🟢   52      for I in grouped(arr):
    105      # Symmetric to ``ndarray_to_ext_arr``: iterate via the untagged, canonical-shaped ``arr``. ``ndarray[I]`` then
    106      # permutes I from canonical to physical on layout-tagged destinations.
🟢  107      for I in grouped(arr):
🔴 python/quadrants/_tensor.py (78%)
      1  """Tensors: per-tensor backend and layout.
      2  
      3  This module is the user-facing entry point for selecting a tensor backend (``qd.field`` vs ``qd.ndarray``) and an
      4  optional physical memory layout on a per-tensor basis.
      5  
      6  See ``docs/source/user_guide/tensor.md`` for the user guide.
      7  
      8  ``qd.Tensor`` is the wrapper *class* defined in ``_tensor_wrapper.py``; it doubles as the polymorphic kernel-argument
      9  annotation. The dispatch sites that previously keyed off the ``_TensorAnnotation`` singleton type now check
     10  ``annotation is Tensor`` (the class). See ``_func_base.py``, ``_template_mapper_hotpath.py`` and
     11  ``function_def_transformer.py``.
     12  """
     13  
     14  # pylint: disable=import-outside-toplevel
     15  # (Late imports below are intentional, to break circular import cycles between the tensor entry point and the
     16  # lang/types subpackages.)
     17  
🔴   18  from enum import IntEnum
     19  
     20  # Re-export so ``from quadrants._tensor import *`` still binds ``Tensor`` — keeps the wildcard import in
     21  # ``__init__.py`` simple and atomic.
🔴   22  from quadrants._tensor_wrapper import Tensor
🔴   23  from quadrants._tensor_wrapper import wrap as _wrap_impl
     24  
🔴   25  __all__ = [
     26      "Backend",
     27      "Tensor",
     28      "tensor",
     29  ]
     30  
     31  # Marker tuples prefixed onto cache keys to keep field-resolved and ndarray-resolved instantiations of the same
     32  # qd.Tensor slot distinct.
🔴   33  _TENSOR_T_FIELD_MARKER = "__qd_tensor_t_field__"
🔴   34  _TENSOR_T_NDARRAY_MARKER = "__qd_tensor_t_ndarray__"
     35  
     36  # ----------------------------------------------------------------------------
     37  # Internal: attach layout metadata to an existing Ndarray.
     38  #
     39  # Public API for ndarray + non-identity layout lands in an earlier change (the qd.tensor(..., backend=NDARRAY,
     40  # layout=...) path is currently gated by NotImplementedError). Until then, this private helper exists so the AST
     41  # subscript-rewrite plumbing can be exercised end-to-end in tests without changing the user-facing factory signature.
     42  # ----------------------------------------------------------------------------
     43  
     44  
🔴   45  def _with_layout(ndarray, layout):
     46      """Tag ``ndarray`` with a canonical-axis permutation. Internal.
     47  
     48      Accepts either a bare ``Ndarray`` or a ``Tensor`` wrapper around one; in the wrapper case the tag goes on the
     49      underlying impl so the kernel-arg unwrap hook (and the AST rewrite that gates on ``_qd_layout``) sees it.
     50  
     51      If a companion ``grad`` ndarray exists (allocated by ``needs_grad=True``), the tag is propagated to it so kernel
     52      code reading ``x.grad[...]`` goes through the same canonical->physical AST rewrite as ``x[...]``.
     53      """
     54      # Unwrap Tensor wrappers transparently. Imported lazily to dodge the _tensor_wrapper -> _tensor cycle.
🟢   55      from quadrants._tensor_wrapper import (  # pylint: disable=reimported
     56          Tensor as _TensorWrapper,
     57      )
     58  
🟢   59      if isinstance(ndarray, _TensorWrapper):
🟢   60          ndarray = ndarray._unwrap()
     61  
🟢   62      layout = tuple(layout)
🟢   63      ndim = len(ndarray.shape)
🟢   64      if len(layout) != ndim:
🟢   65          raise ValueError(f"layout has {len(layout)} entries but ndarray has {ndim} dims")
🟢   66      if sorted(layout) != list(range(ndim)):
🟢   67          raise ValueError(f"layout={layout!r} is not a permutation of range({ndim})")
🟢   68      ndarray._qd_layout = layout
🟢   69      grad = getattr(ndarray, "grad", None)
🟢   70      if grad is not None and getattr(grad, "_qd_layout", None) != layout:
🟢   71          grad._qd_layout = layout
🟢   72      return ndarray
     73  
     74  
🔴   75  class Backend(IntEnum):
     76      """Tensor storage backend.
     77  
     78      Each value selects one of Quadrants' two underlying tensor implementations:
     79  
     80      - :attr:`FIELD` (``qd.field``): faster at runtime; recompiles kernels whenever any dimension size changes. Best for
     81        tensors whose shape is effectively static across a run.
     82      - :attr:`NDARRAY` (``qd.ndarray``): slightly slower at runtime but avoids kernel recompilation when sizes change.
     83        Best for tensors whose shape varies frequently (e.g. dynamic batch sizes, growing buffers).
     84  
     85      The choice is made per tensor at allocation time. A single program can freely mix both backends.
     86      """
     87  
🔴   88      FIELD = 0
🔴   89      NDARRAY = 1
     90  
     91  
🔴   92  def _coerce_backend(backend):
🟢   93      if isinstance(backend, Backend):
🟢   94          return backend
🟢   95      try:
🟢   96          return Backend(backend)
🟢   97      except (ValueError, TypeError) as e:
🟢   98          valid = ", ".join(f"qd.Backend.{m.name}" for m in Backend)
🟢   99          raise ValueError(f"backend={backend!r} is not a valid qd.Backend; expected one of {valid}") from e
    100  
    101  
    102  # Kwargs explicitly accepted by the unified tensor factories (in addition to the positional ``dtype`` / ``shape`` /
    103  # ``n`` / ``m``). The factories hard-validate against these sets so typos and backend-specific options don't silently
    104  # work on one backend and raise cryptic errors deep in the other. Users who need backend-specific knobs (e.g.
    105  # ``offset=`` for field offset indexing, ``order=`` for SoA layouts) should call ``qd.field`` / ``qd.ndarray`` directly
    106  # — they have explicitly opted out of the unified tensor API.
    107  #
    108  # ``layout=`` is on the scalar ``qd.tensor`` factory only; the Vector/Matrix factories reject it because layout
    109  # semantics over an extra element axis are out of scope for now.
🔴  110  _SCALAR_ACCEPTED_KWARGS = frozenset({"backend", "needs_grad", "layout"})
🔴  111  _VEC_MAT_ACCEPTED_KWARGS = frozenset({"backend", "needs_grad"})
    112  
    113  
🔴  114  def _validate_kwargs(kwargs, *, factory_name, accepted):
🟢  115      if "order" in kwargs:
🟢  116          if "layout" in accepted:
🟢  117              raise TypeError(f"{factory_name}(...) does not accept order=; pass layout=(...) instead")
🟢  118          raise TypeError(
    119              f"{factory_name}(...) does not accept order= (or layout=); "
    120              "use the underlying qd.Vector.field / qd.Matrix.field for non-default storage order"
    121          )
🟢  122      extra = set(kwargs) - accepted
🟢  123      if extra:
🟢  124          accepted_str = ", ".join(sorted(accepted | {"dtype", "shape"}))
🟢  125          raise TypeError(
    126              f"{factory_name}() got unexpected keyword argument(s) " f"{sorted(extra)!r}; accepted: {accepted_str}"
    127          )
    128  
    129  
🔴  130  def _layout_to_order(layout, ndim):
    131      """Validate ``layout`` and translate it to the ``order=`` string accepted by :func:`quadrants.field`.
    132  
    133      ``layout`` is a tuple of ``ndim`` ints — a permutation of ``range(ndim)`` — listing the *canonical* axis index at
    134      each successive memory-nesting level, outermost first. ``layout=(1, 0)`` for a 2-D tensor means axis 1 is the
    135      outer SNode, axis 0 is the inner one (i.e. transposed storage), which translates to ``order='ji'``.
    136  
    137      Returns ``None`` for the identity permutation, so the caller can omit ``order=`` entirely (matches the unsuffixed
    138      default).
    139      """
🟢  140      if not isinstance(layout, tuple):
🔴  141          layout = tuple(layout)
🟢  142      if len(layout) != ndim:
🟢  143          raise ValueError(f"layout has {len(layout)} entries but shape has {ndim} " f"dimensions; they must match")
🟢  144      if sorted(layout) != list(range(ndim)):
🟢  145          raise ValueError(f"layout={layout!r} is not a permutation of range({ndim})")
🟢  146      if layout == tuple(range(ndim)):
🟢  147          return None  # identity layout — no order= needed
🟢  148      return "".join(chr(ord("i") + axis) for axis in layout)
    149  
    150  
🔴  151  def tensor(dtype, shape, *, backend=Backend.NDARRAY, layout=None, **kwargs):
    152      """Allocate a tensor on the chosen backend, optionally with a custom physical layout.
    153  
    154      Thin dispatcher over :func:`quadrants.field` and :func:`quadrants.ndarray` that selects between the two via the
    155      :class:`Backend` enum.
    156  
    157      Args:
    158          dtype: Element data type (e.g. ``qd.f32``, ``qd.i32``, or a compound type from ``qd.types``).
    159          shape: Shape of the tensor as an ``int`` or tuple of ``int``.
    160          backend (Backend, optional): Storage backend. Defaults to :attr:`Backend.NDARRAY`.
    161          layout (tuple of int, optional): Permutation of canonical axes describing the physical memory nesting order,
    162              outermost first. For a rank-N tensor, must be a permutation of ``range(N)``. ``None`` (default) and the
    163              identity permutation both mean "natural row-major-like layout" (no permutation is applied).
    164  
    165              ``shape`` is always interpreted as the **canonical** shape (the shape you index inside kernels). The
    166              underlying allocation is automatically sized to the permuted *physical* shape, and kernel subscripts
    167              ``x[i, j, ...]`` are rewritten to hit the right physical slot. Supported on both ``Backend.FIELD`` and
    168              ``Backend.NDARRAY``.
    169  
    170      Returns:
    171          A ``ScalarField`` when ``backend == Backend.FIELD``, or an ``Ndarray`` when ``backend == Backend.NDARRAY``. In
    172          both cases ``.shape`` reports the canonical shape; the physical layout is managed transparently.
    173  
    174      Example::
    175  
    176          >>> import quadrants as qd
    177          >>> qd.init(arch=qd.x64)
    178          >>> a = qd.tensor(qd.f32, shape=(4, 5))                       # default layout
    179          >>> b = qd.tensor(qd.f32, shape=(4, 5), layout=(1, 0))        # transposed storage
    180          >>> c = qd.tensor(qd.f32, shape=(4, 5), backend=qd.Backend.NDARRAY)
    181          >>> d = qd.tensor(qd.f32, shape=(4, 5), backend=qd.Backend.NDARRAY,
    182          ...               layout=(1, 0))                              # transposed ndarray
    183  
    184      Raises:
    185          ValueError: If ``backend`` is not a valid :class:`Backend` member, or if ``layout`` is not a permutation of
    186              ``range(len(shape))``.
    187          TypeError: If any keyword argument outside the accepted set is passed (see ``_SCALAR_ACCEPTED_KWARGS``).
    188      """
🟢  189      from quadrants.lang._ndarray import (
    190          Ndarray,  # pylint: disable=import-outside-toplevel
    191      )
🟢  192      from quadrants.lang.field import Field  # pylint: disable=import-outside-toplevel
    193  
🟢  194      if isinstance(dtype, (Ndarray, Field, Tensor)):
🔴  195          raise TypeError(
    196              f"qd.tensor() allocates a new tensor; to wrap an existing {type(dtype).__name__}, use qd.wrap(impl) instead"
    197          )
🟢  198      if layout is not None:
🟢  199          from quadrants.lang.matrix import (
    200              MatrixType,  # pylint: disable=import-outside-toplevel
    201          )
    202  
🟢  203          if isinstance(dtype, MatrixType):
🟢  204              raise TypeError(
    205                  "layout= is not supported with compound dtypes (vector/matrix). "
    206                  "Use qd.Vector.tensor(...) or qd.Matrix.tensor(...) without layout= instead."
    207              )
🟢  208      _validate_kwargs(kwargs, factory_name="qd.tensor", accepted=_SCALAR_ACCEPTED_KWARGS)
🟢  209      backend = _coerce_backend(backend)
🟢  210      forwarded = {k: v for k, v in kwargs.items() if k != "backend"}
    211      # pylint: disable-next=import-outside-toplevel  # late import to break circular dependency
🟢  212      from quadrants.lang import impl
    213  
🟢  214      shape_t = (shape,) if isinstance(shape, int) else tuple(shape)
🟢  215      order = _layout_to_order(layout, len(shape_t)) if layout is not None else None
    216  
🟢  217      if backend is Backend.FIELD:
🟢  218          if order is not None:
🟢  219              forwarded["order"] = order
🟢  220          f = impl.field(dtype, shape, **forwarded)
    221          # The canonical->physical layout permutation is attached by ``_field`` itself via ``_qd_layout`` (identical
    222          # attribute to the one ``Ndarray`` uses). The AST subscript rewrite in ``build_Subscript`` /
    223          # ``build_struct_for`` reads it to permute user-supplied canonical indices into physical storage order;
    224          # ``Field.layout`` reads the same attribute for introspection.
🟢  225          return _wrap_impl(f)
🟢  226      if backend is Backend.NDARRAY:
🟢  227          if order is None:
🟢  228              return _wrap_impl(impl.ndarray(dtype, shape, **forwarded))
    229          # Non-identity layout: allocate at the physical (permuted) shape and tag the result so the kernel-side
    230          # subscript rewrite picks up the canonical -> physical translation.
🟢  231          assert layout is not None  # implied by `order is not None`
🟢  232          layout_t = tuple(layout)
🟢  233          physical_shape = tuple(shape_t[axis] for axis in layout_t)
🟢  234          arr = impl.ndarray(dtype, physical_shape, **forwarded)
    235          # _with_layout propagates the tag to the companion grad ndarray if one was allocated by needs_grad=True, so
    236          # kernel code reading x.grad[i, j, ...] goes through the same canonical->physical AST rewrite as the primal
    237          # access.
🟢  238          _with_layout(arr, layout_t)
🟢  239          return _wrap_impl(arr)
🔴  240      raise AssertionError(f"unhandled Backend member: {backend!r}")
    241  
    242  
🔴  243  def _tensor_vec(n, dtype, shape, *, backend=Backend.NDARRAY, **kwargs):
    244      """Private impl backing ``qd.Vector.tensor``.
    245  
    246      Dispatcher over ``qd.Vector.field`` and ``qd.Vector.ndarray`` selected by the ``backend=`` keyword. Not part of
    247      the public API — call ``qd.Vector.tensor(...)`` instead. Hard-validates kwargs against ``_VEC_MAT_ACCEPTED_KWARGS``
    248      (no ``layout=`` — layout semantics over an extra element axis are out of scope for now).
    249      """
🟢  250      _validate_kwargs(kwargs, factory_name="qd.Vector.tensor", accepted=_VEC_MAT_ACCEPTED_KWARGS)
🟢  251      backend = _coerce_backend(backend)
🟢  252      forwarded = {k: v for k, v in kwargs.items() if k != "backend"}
    253      # pylint: disable-next=import-outside-toplevel  # late import to break circular dependency
🟢  254      from quadrants._tensor_wrapper import VectorTensor
🟢  255      from quadrants.lang.matrix import Vector
    256  
🟢  257      if backend is Backend.FIELD:
🟢  258          return VectorTensor(Vector.field(n, dtype, shape, **forwarded))
🟢  259      if backend is Backend.NDARRAY:
🟢  260          return VectorTensor(Vector.ndarray(n, dtype, shape, **forwarded))
🔴  261      raise AssertionError(f"unhandled Backend member: {backend!r}")
    262  
    263  
🔴  264  def _tensor_mat(n, m, dtype, shape, *, backend=Backend.NDARRAY, **kwargs):
    265      """Private impl backing ``qd.Matrix.tensor``.
    266  
    267      Dispatcher over ``qd.Matrix.field`` and ``qd.Matrix.ndarray`` selected by the ``backend=`` keyword. Not part of
    268      the public API — call ``qd.Matrix.tensor(...)`` instead. Hard-validates kwargs against ``_VEC_MAT_ACCEPTED_KWARGS``
    269      (no ``layout=`` — layout semantics over an extra element axis are out of scope for now).
    270      """
🟢  271      _validate_kwargs(kwargs, factory_name="qd.Matrix.tensor", accepted=_VEC_MAT_ACCEPTED_KWARGS)
🟢  272      backend = _coerce_backend(backend)
🟢  273      forwarded = {k: v for k, v in kwargs.items() if k != "backend"}
    274      # pylint: disable-next=import-outside-toplevel  # late import to break circular dependency
🟢  275      from quadrants._tensor_wrapper import MatrixTensor
🟢  276      from quadrants.lang.matrix import Matrix
    277  
🟢  278      if backend is Backend.FIELD:
🟢  279          return MatrixTensor(Matrix.field(n, m, dtype, shape, **forwarded))
🟢  280      if backend is Backend.NDARRAY:
🟢  281          return MatrixTensor(Matrix.ndarray(n, m, dtype, shape, **forwarded))
🔴  282      raise AssertionError(f"unhandled Backend member: {backend!r}")
🔴 python/quadrants/_tensor_wrapper.py (67%)
      1  """``qd.Tensor``: backend-agnostic tensor wrapper.
      2  
      3  A thin Python wrapper around an underlying ``Ndarray`` or ``ScalarField`` impl. Makes backend symmetry a *type*
      4  property rather than something we police test by test: the wrapper exposes a fixed whitelisted surface uniformly,
      5  regardless of which impl it contains.
      6  
      7  Promoted to ``qd.Tensor`` in ``hp/tensor-stork-19``: the class is now the public name, doubles as the polymorphic
      8  kernel-arg annotation (``def f(x: qd.Tensor): ...``), and is what ``qd.tensor()``, ``qd.Vector.tensor()``,
      9  ``qd.Matrix.tensor()`` return. Bare impls are still reachable via ``qd.field``, ``qd.ndarray``, ``qd.Vector.field`` etc.
     10  
     11  Surface:
     12  
     13  - Introspection: ``shape``, ``dtype``, ``layout``, ``_unwrap()``.
     14  - Layout-aware host-side ``__getitem__`` / ``__setitem__`` — permutes the canonical user key to the physical slot on
     15    layout-tagged ndarrays. Fixes gotcha B from the design doc (§8.11).
     16  - Symmetric pickle via ``__reduce__`` — round-trips through ``to_numpy()`` so it works uniformly on both backends
     17    (Field, which never supported pickle upstream, is picklable through the wrapper).
     18  - Forwards for ``to_numpy`` / ``from_numpy`` / ``to_torch`` / ``from_torch`` / ``to_dlpack`` / ``fill`` /
     19    ``copy_from`` — already layout-aware on both backends after stork-15/16, the wrapper delegates.
     20  - Lazy-wrapped ``.grad`` — returns a ``Tensor`` wrapping ``impl.grad`` (identity-stable via
     21    ``functools.cached_property``).
     22  - ``VectorTensor`` / ``MatrixTensor`` subclasses carrying ``element_shape``.
     23  
     24  Out of scope:
     25  - Genesis migration (stork-20): rewrite Genesis ``isinstance`` sites from ``(qd.Field, qd.Ndarray)`` to
     26    ``qd.Tensor``, switch its tensor allocations to ``qd.tensor()``.
     27  
     28  See ``perso_hugh/doc/quadrants-tensor.md`` §8.11 / §8.12.
     29  """
     30  
     31  # pylint: disable=import-outside-toplevel
     32  # (Late imports throughout are intentional, to break circular import cycles between the tensor wrapper and the
     33  # lang/types subpackages.)
🔴   34  from __future__ import annotations
     35  
🔴   36  import typing
🔴   37  from functools import cached_property
     38  
🔴   39  __all__ = [
     40      "Tensor",
     41      "VectorTensor",
     42      "MatrixTensor",
     43      "wrap",
     44  ]
     45  
     46  
     47  # PERF-CRITICAL: This flag is checked on every kernel arg in _template_mapper_hotpath._extract_arg,
     48  # kernel.Kernel.__call__, _func_base._inject_template_globals, and args_hasher.stringify_obj_type.
     49  # It gates the isinstance(arg, Tensor) unwrap so that programs which never construct a qd.Tensor pay zero Python
     50  # overhead for the check. Removing this flag or the guards that read it causes a measurable ~4% CPU regression on
     51  # Genesis benchmarks (see regression_2026apr23_stork_log.md).
🔴   52  _any_tensor_constructed = False
     53  
     54  
🔴   55  def _is_identity(layout: typing.Optional[typing.Tuple[int, ...]]) -> bool:
🟢   56      if layout is None:
🟢   57          return True
🟢   58      return tuple(layout) == tuple(range(len(layout)))
     59  
     60  
🔴   61  class Tensor:
     62      """Backend-agnostic tensor wrapper. The public ``qd.Tensor`` class.
     63  
     64      Holds a reference to an underlying impl (``Ndarray`` or ``Field``) and forwards a whitelisted surface. Layout-aware
     65      host-side indexing lives here: the AST-level canonical->physical rewrite only fires inside ``@qd.kernel`` bodies,
     66      so ``t[i, j]`` at host scope on a layout-tagged ndarray would otherwise hit the physical slot.
     67  
     68      Construct via ``qd.tensor(...)`` (or its ``qd.Vector.tensor`` / ``qd.Matrix.tensor`` siblings). The wrapper rejects
     69      double-wrapping: the impl must be a bare ``Ndarray`` or ``Field``.
     70  
     71      Doubles as a kernel parameter annotation: ``def k(x: qd.Tensor)`` accepts either a Field or an Ndarray; dispatch
     72      happens at extract time. See ``_template_mapper_hotpath._extract_arg``.
     73      """
     74  
     75      # ``cached_property`` requires ``__dict__``, so no ``__slots__``.
     76  
🔴   77      def __init__(self, impl: typing.Any) -> None:
🟢   78          from quadrants.lang._ndarray import Ndarray
🟢   79          from quadrants.lang.field import Field
     80  
🟢   81          if not isinstance(impl, (Ndarray, Field)):
🟢   82              raise TypeError(f"Tensor(impl) requires an Ndarray or Field; got {type(impl).__name__}")
🟢   83          self._impl: typing.Any = impl
     84          global _any_tensor_constructed  # noqa: PLW0603
🟢   85          _any_tensor_constructed = True  # see comment on the flag definition above
     86  
     87      # ------------------------------------------------------------------
     88      # Identity / debug
     89      # ------------------------------------------------------------------
     90  
🔴   91      def __repr__(self) -> str:
🟢   92          layout = self.layout
🟢   93          layout_repr = "" if layout is None else f", layout={layout!r}"
🟢   94          return f"Tensor(shape={self.shape!r}, dtype={self.dtype!r}, " f"backend={self._backend_name()}{layout_repr})"
     95  
🔴   96      def _backend_name(self) -> str:
🟢   97          from quadrants.lang._ndarray import Ndarray
     98  
🟢   99          return "NDARRAY" if isinstance(self._impl, Ndarray) else "FIELD"
    100  
🔴  101      def _backend_enum(self) -> typing.Any:
🟢  102          from quadrants._tensor import Backend
🟢  103          from quadrants.lang._ndarray import Ndarray
    104  
🟢  105          return Backend.NDARRAY if isinstance(self._impl, Ndarray) else Backend.FIELD
    106  
    107      # ------------------------------------------------------------------
    108      # Whitelisted introspection
    109      # ------------------------------------------------------------------
    110  
🔴  111      @property
🔴  112      def shape(self) -> typing.Tuple[int, ...]:
🟢  113          return tuple(self._impl.shape)
    114  
🔴  115      @property
🔴  116      def dtype(self) -> typing.Any:
🟢  117          return self._impl.dtype
    118  
🔴  119      @property
🔴  120      def layout(self) -> typing.Optional[typing.Tuple[int, ...]]:
    121          # Forwards to the impl's ``layout`` property (symmetric across backends after stork-16).
🟢  122          return self._impl.layout
    123  
    124      # ------------------------------------------------------------------
    125      # Internal escape hatch
    126      # ------------------------------------------------------------------
    127  
🔴  128      def _unwrap(self) -> typing.Any:
    129          """Return the underlying impl. Used by the kernel-arg unwrap hook in ``Kernel.__call__`` so the JIT cache keys
    130          off impl identity.
    131          """
🟢  132          return self._impl
    133  
    134      # ------------------------------------------------------------------
    135      # Layout-aware host indexing (fixes gotcha B)
    136      # ------------------------------------------------------------------
    137  
🔴  138      def _host_physical_layout(self) -> typing.Optional[typing.Tuple[int, ...]]:
    139          """Return the permutation to apply to canonical host keys.
    140  
    141          Only ``Ndarray`` needs it: its Python-scope ``__getitem__`` / ``__setitem__`` pass the key directly to the
    142          host accessor, and the canonical->physical rewrite only fires inside ``@qd.kernel``.
    143  
    144          ``Field`` already translates canonical indices via the SNode hierarchy (``order=``) on every host access, so we
    145          return ``None`` and fall through to a plain delegation.
    146          """
🟢  147          from quadrants.lang._ndarray import Ndarray
    148  
🟢  149          if not isinstance(self._impl, Ndarray):
🟢  150              return None
🟢  151          layout = getattr(self._impl, "_qd_layout", None)
🟢  152          if _is_identity(layout):
🟢  153              return None
🟢  154          assert layout is not None
🟢  155          return tuple(layout)
    156  
🔴  157      @staticmethod
🔴  158      def _permute_key(key: typing.Any, layout: typing.Tuple[int, ...]) -> typing.Tuple[int, ...]:
    159          """Translate a user-supplied canonical key to physical coords.
    160  
    161          ``physical[p] = canonical[layout[p]]`` by the convention that ``layout[p]`` is the canonical axis at physical
    162          nesting level ``p`` (outermost first). Only full-rank keys are supported at host scope; partial / slice
    163          indexing is out of scope for the wrapper and would fall out of ``Ndarray``'s own API anyway.
    164          """
🟢  165          if isinstance(key, int):
    166              # Rank-1 only; for rank>1 we require a tuple/list.
🔴  167              if len(layout) != 1:
🔴  168                  raise TypeError(f"layout-tagged Tensor requires a full tuple key; got int for rank {len(layout)}")
🔴  169              return (key,)
🟢  170          key_t = tuple(key)
🟢  171          if len(key_t) != len(layout):
🔴  172              raise TypeError(f"layout-tagged Tensor key has {len(key_t)} entries but rank is {len(layout)}")
🟢  173          return tuple(key_t[layout[p]] for p in range(len(layout)))
    174  
🔴  175      def __getitem__(self, key: typing.Any) -> typing.Any:
🟢  176          layout = self._host_physical_layout()
🟢  177          if layout is None:
🟢  178              return self._impl[key]
🟢  179          return self._impl[self._permute_key(key, layout)]
    180  
🔴  181      def __setitem__(self, key: typing.Any, value: typing.Any) -> None:
🟢  182          layout = self._host_physical_layout()
🟢  183          if layout is None:
🟢  184              self._impl[key] = value
🟢  185              return
🟢  186          self._impl[self._permute_key(key, layout)] = value
    187  
    188      # ------------------------------------------------------------------
    189      # Interop forwards (layout-aware on both impls already)
    190      # ------------------------------------------------------------------
    191  
🔴  192      def to_numpy(self, dtype: typing.Any = None) -> typing.Any:
🟢  193          if dtype is None:
🟢  194              return self._impl.to_numpy()
🟢  195          return self._impl.to_numpy(dtype=dtype)
    196  
🔴  197      def from_numpy(self, arr: typing.Any) -> None:
🟢  198          self._impl.from_numpy(arr)
    199  
🔴  200      def to_torch(self, device: typing.Any = None) -> typing.Any:
🔴  201          if device is None:
🔴  202              return self._impl.to_torch()
🔴  203          return self._impl.to_torch(device=device)
    204  
🔴  205      def from_torch(self, arr: typing.Any) -> None:
🔴  206          self._impl.from_torch(arr)
    207  
🔴  208      def to_dlpack(self) -> typing.Any:
🔴  209          return self._impl.to_dlpack()
    210  
🔴  211      def fill(self, value: typing.Any) -> None:
🟢  212          self._impl.fill(value)
    213  
🔴  214      def copy_from(self, other: typing.Any) -> None:
    215          # Accept either another ``Tensor`` or a bare impl (convenience: lets Genesis-style code pass raw fields
    216          # during the migration).
🟢  217          if isinstance(other, Tensor):
🟢  218              other = other._impl
🟢  219          self._impl.copy_from(other)
    220  
    221      # ------------------------------------------------------------------
    222      # Gradient: lazy wrap so ``t.grad`` is identity-stable.
    223      # ------------------------------------------------------------------
    224  
🔴  225      @cached_property
🔴  226      def grad(self) -> typing.Optional["Tensor"]:
🟢  227          g = getattr(self._impl, "grad", None)
🟢  228          if g is None:
🟢  229              return None
🟢  230          return wrap(g)
    231  
    232      # ------------------------------------------------------------------
    233      # Pickle (symmetric across backends)
    234      # ------------------------------------------------------------------
    235  
🔴  236      def __reduce__(self) -> typing.Tuple[typing.Any, typing.Tuple[typing.Any, ...]]:
    237          """Serialize via canonical-view numpy + backend metadata.
    238  
    239          Works uniformly on both backends: the upstream ``Field`` never supported pickle because it needs
    240          runtime-allocated SNodes, but the wrapper bypasses that by reconstructing via ``qd.tensor(...)`` +
    241          ``from_numpy(...)`` on the other side — ``qd.tensor`` handles the SNode allocation through the usual factory
    242          path.
    243  
    244          Only *scalar* tensors are supported here. Vector/matrix wrappers override this (they need to encode element
    245          shape too).
    246          """
🟢  247          backend_int = int(self._backend_enum())
🟢  248          shape = tuple(self._impl.shape)
🟢  249          layout = self.layout  # ``None`` for identity, else permutation tuple
🟢  250          data = self._impl.to_numpy()
🟢  251          return (
    252              _rebuild_scalar_tensor,
    253              (backend_int, self._impl.dtype, shape, layout, data),
    254          )
    255  
    256  
🔴  257  def _element_shape_of(impl: typing.Any) -> typing.Tuple[int, ...]:
    258      """Return the per-element shape of a vector/matrix impl.
    259  
    260      ``VectorNdarray`` / ``MatrixNdarray`` expose ``element_shape`` directly. ``MatrixField`` (which backs
    261      ``qd.Vector.field`` via ``m == 1, ndim == 1`` and ``qd.Matrix.field`` via ``ndim == 2``) doesn't, so we derive it
    262      from its ``n``/``m``/``ndim`` attributes.
    263      """
🟢  264      from quadrants.lang.matrix import MatrixField
    265  
🟢  266      if isinstance(impl, MatrixField):
🟢  267          if impl.ndim == 0:
🔴  268              return ()
🟢  269          if impl.ndim == 1:
🟢  270              return (impl.n,)
🟢  271          return (impl.n, impl.m)
🟢  272      return tuple(impl.element_shape)
    273  
    274  
🔴  275  class VectorTensor(Tensor):
    276      """Wrapper for vector-element tensors (``qd.Vector.tensor(...)`` output).
    277  
    278      Accepts either a ``VectorNdarray`` or a ``MatrixField`` allocated via ``qd.Vector.field(...)`` (``m == 1,
    279      ndim == 1``). ``element_shape`` is ``(n,)``.
    280      """
    281  
🔴  282      def __init__(self, impl: typing.Any) -> None:
🟢  283          from quadrants.lang.matrix import MatrixField, VectorNdarray
    284  
🟢  285          if isinstance(impl, VectorNdarray):
🟢  286              pass
🟢  287          elif isinstance(impl, MatrixField) and impl.ndim == 1:
🟢  288              pass
    289          else:
🔴  290              raise TypeError(f"VectorTensor requires a vector-element impl; got {type(impl).__name__}")
🟢  291          self._impl: typing.Any = impl
    292          global _any_tensor_constructed  # noqa: PLW0603
🟢  293          _any_tensor_constructed = True
    294  
🔴  295      @property
🔴  296      def element_shape(self) -> typing.Tuple[int, ...]:
🟢  297          return _element_shape_of(self._impl)
    298  
🔴  299      def __reduce__(self) -> typing.Tuple[typing.Any, typing.Tuple[typing.Any, ...]]:
🟢  300          backend_int = int(self._backend_enum())
🟢  301          shape = tuple(self._impl.shape)
🟢  302          element_shape = _element_shape_of(self._impl)
🟢  303          data = self._impl.to_numpy()
🟢  304          return (
    305              _rebuild_vector_tensor,
    306              (backend_int, self._impl.dtype, shape, element_shape, data),
    307          )
    308  
    309  
🔴  310  class MatrixTensor(Tensor):
    311      """Wrapper for matrix-element tensors (``qd.Matrix.tensor(...)`` output).
    312  
    313      Accepts either a ``MatrixNdarray`` or a ``MatrixField`` with ``ndim == 2``. ``element_shape`` is ``(n, m)``.
    314      """
    315  
🔴  316      def __init__(self, impl: typing.Any) -> None:
🟢  317          from quadrants.lang.matrix import MatrixField, MatrixNdarray
    318  
🟢  319          if isinstance(impl, MatrixNdarray):
🟢  320              pass
🟢  321          elif isinstance(impl, MatrixField) and impl.ndim == 2:
🟢  322              pass
    323          else:
🔴  324              raise TypeError(f"MatrixTensor requires a matrix-element impl; got {type(impl).__name__}")
🟢  325          self._impl: typing.Any = impl
    326          global _any_tensor_constructed  # noqa: PLW0603
🟢  327          _any_tensor_constructed = True
    328  
🔴  329      @property
🔴  330      def element_shape(self) -> typing.Tuple[int, ...]:
🟢  331          return _element_shape_of(self._impl)
    332  
🔴  333      def __reduce__(self) -> typing.Tuple[typing.Any, typing.Tuple[typing.Any, ...]]:
🟢  334          backend_int = int(self._backend_enum())
🟢  335          shape = tuple(self._impl.shape)
🟢  336          element_shape = _element_shape_of(self._impl)
🟢  337          data = self._impl.to_numpy()
🟢  338          return (
    339              _rebuild_matrix_tensor,
    340              (backend_int, self._impl.dtype, shape, element_shape, data),
    341          )
    342  
    343  
    344  # PERF-CRITICAL: Hotpath code uses ``type(arg) in _TENSOR_WRAPPER_TYPES`` instead of ``isinstance(arg, Tensor)``
    345  # because ``type(x) is cls`` is a single pointer comparison (~10 ns) whereas ``isinstance`` walks the MRO for
    346  # non-matching types (~100–200 ns). With ~43 struct fields checked per kernel invocation in Genesis, the cumulative
    347  # savings are significant. Keep this tuple in sync with the Tensor class hierarchy.
🔴  348  _TENSOR_WRAPPER_TYPES = (Tensor, VectorTensor, MatrixTensor)
    349  
    350  
    351  # ----------------------------------------------------------------------
    352  # Public helpers
    353  # ----------------------------------------------------------------------
    354  
    355  
🔴  356  def wrap(impl: typing.Any) -> "Tensor":
    357      """Wrap an impl in the most specific ``Tensor`` subclass we can.
    358  
    359      Used internally (e.g. by lazy ``.grad``) and by tests.
    360      """
🟢  361      from quadrants.lang.matrix import MatrixField, MatrixNdarray, VectorNdarray
    362  
🟢  363      if isinstance(impl, VectorNdarray):
🟢  364          return VectorTensor(impl)
🟢  365      if isinstance(impl, MatrixNdarray):
🟢  366          return MatrixTensor(impl)
🟢  367      if isinstance(impl, MatrixField):
🟢  368          if impl.ndim == 1:
🟢  369              return VectorTensor(impl)
🟢  370          if impl.ndim == 2:
🟢  371              return MatrixTensor(impl)
    372          # ndim == 0: scalar-like; fall through to base Tensor.
🟢  373      return Tensor(impl)
    374  
    375  
    376  # ----------------------------------------------------------------------
    377  # Pickle reconstructors (module-level so pickle can find them by name)
    378  # ----------------------------------------------------------------------
    379  
    380  
🔴  381  def _rebuild_scalar_tensor(
    382      backend_int: int,
    383      dtype: typing.Any,
    384      shape: typing.Tuple[int, ...],
    385      layout: typing.Optional[typing.Tuple[int, ...]],
    386      data: typing.Any,
    387  ) -> "Tensor":
🟢  388      import quadrants as qd
    389  
🟢  390      backend = qd.Backend(backend_int)  # type: ignore[reportOptionalCall]
🟢  391      kwargs: typing.Dict[str, typing.Any] = {"backend": backend}
🟢  392      if layout is not None:
🟢  393          kwargs["layout"] = layout
    394      # ``qd.tensor()`` already returns a Tensor wrapper post stork-19.
🟢  395      t = qd.tensor(dtype, shape, **kwargs)  # type: ignore[reportOptionalCall]
🟢  396      t.from_numpy(data)  # type: ignore[reportAttributeAccessIssue]
🟢  397      return t  # type: ignore[reportReturnType]
    398  
    399  
🔴  400  def _rebuild_vector_tensor(
    401      backend_int: int,
    402      dtype: typing.Any,
    403      shape: typing.Tuple[int, ...],
    404      element_shape: typing.Tuple[int, ...],
    405      data: typing.Any,
    406  ) -> "VectorTensor":
🟢  407      import quadrants as qd
    408  
🟢  409      backend = qd.Backend(backend_int)  # type: ignore[reportOptionalCall]
🟢  410      (n,) = element_shape
🟢  411      t = qd.Vector.tensor(n, dtype, shape, backend=backend)  # type: ignore[reportAttributeAccessIssue]
🟢  412      t.from_numpy(data)
🟢  413      return t
    414  
    415  
🔴  416  def _rebuild_matrix_tensor(
    417      backend_int: int,
    418      dtype: typing.Any,
    419      shape: typing.Tuple[int, ...],
    420      element_shape: typing.Tuple[int, ...],
    421      data: typing.Any,
    422  ) -> "MatrixTensor":
🟢  423      import quadrants as qd
    424  
🟢  425      backend = qd.Backend(backend_int)  # type: ignore[reportOptionalCall]
🟢  426      n, m = element_shape
🟢  427      t = qd.Matrix.tensor(n, m, dtype, shape, backend=backend)  # type: ignore[reportAttributeAccessIssue]
🟢  428      t.from_numpy(data)
🟢  429      return t
🔴 python/quadrants/lang/_fast_caching/args_hasher.py (78%)
🔴    9  from quadrants import _logging, _tensor_wrapper
🔴   10  from quadrants._tensor_wrapper import _TENSOR_WRAPPER_TYPES
     76      # ``qd.Tensor`` wrappers passed as struct fields. The top-level kernel-arg unwrap hook in ``Kernel.__call__`` strips
     77      # wrappers off positional / keyword args before the fastcache hasher sees them, but the dataclass / data-oriented
     78      # walkers below (``dataclass_to_repr`` and the ``is_data_oriented`` branch) do raw ``getattr`` to fetch struct
     79      # fields, so a wrapper stored as a struct field arrives here un-stripped. Without this branch the hasher falls
     80      # through to the ``[FASTCACHE][PARAM_INVALID]`` warning and disables the fast path for the whole call. See
     81      # ``perso_hugh/doc/quadrants-tensor.md`` §8.14.
     82      # ``qd.Tensor`` wrappers: unwrap to the bare impl so the type checks below match. After unwrap, ``_qd_layout`` (if
     83      # any) is on the impl.
     84      #
     85      # PERF-CRITICAL: The _any_tensor_constructed guard makes this check zero-cost when no qd.Tensor has been created.
     86      # ``type(obj) in _TENSOR_WRAPPER_TYPES`` is used instead of ``isinstance`` because it is a pointer comparison (~10
     87      # ns) vs an MRO walk (~100–200 ns). Do not replace with isinstance or remove the guard.
🟢   88      if (
     89          _tensor_wrapper._any_tensor_constructed and type(obj) in _TENSOR_WRAPPER_TYPES
     90      ):  # pyright: ignore[reportOptionalMemberAccess]
🟢   91          obj = obj._unwrap()  # pyright: ignore[reportAttributeAccessIssue]
🟢   93      _layout = getattr(obj, "_qd_layout", None)
🟢   94      _layout_tag = "" if _layout is None else f"-L{_layout!r}"
🟢   96          return f"[nd-{obj.dtype}-{len(obj.shape)}{_layout_tag}]"  # type: ignore[arg-type]
🟢   98          return f"[ndv-{obj.n}-{obj.dtype}-{len(obj.shape)}{_layout_tag}]"  # type: ignore[arg-type]
🟢  105          return f"[ndm-{obj.m}-{obj.n}-{obj.dtype}-{len(obj.shape)}{_layout_tag}]"  # type: ignore[arg-type]
🔴 python/quadrants/lang/_func_base.py (72%)
🔴   21  from typing import TYPE_CHECKING, Any, Callable, DefaultDict, Type, cast
🔴   25  from quadrants import _tensor_wrapper
     26  
🔴   34  from quadrants._tensor_wrapper import _TENSOR_WRAPPER_TYPES
🔴   35  from quadrants._tensor_wrapper import Tensor as _TensorClass
     69  # Default ndarray annotation used when qd.Tensor resolves to the ndarray branch at launch time. Defined at module
     70  # scope to avoid per-call alloc.
🔴   71  _TENSOR_T_NDARRAY_LAUNCH_ANNOTATION = ndarray_type.NdarrayType()
     72  
🟢  178                  elif isinstance(annotation, template):
    179                      # Catch Template subclasses.
🔴  180                      pass
🟢  181                  elif annotation is _TensorClass:
    182                      # ``qd.Tensor`` (the wrapper class) used as the polymorphic kernel-arg annotation. Behaves like a
    183                      # template slot upfront; the actual dispatch happens at extract-time / AST-build-time.
🟢  184                      pass
🟢  196              if arg.annotation == template or isinstance(arg.annotation, template) or arg.annotation is _TensorClass:
🟢  219              anno = parameter.annotation
🟢  220              if is_dataclass(anno):
🟢  221                  _kernel_impl_dataclass.populate_global_vars_from_dataclass(
    222                      parameter_name,
    223                      anno,
    224                      py_args[i],
    225                      global_vars=global_vars,
    226                  )
🟢  227              elif (anno is template or isinstance(anno, template) or anno is _TensorClass) and is_dataclass(py_args[i]):
    230                      type(py_args[i]),
    233                      populate_all_fields=True,
    308              global_context=global_context,  # type: ignore[arg-type]
    312              func=self,  # type: ignore[arg-type]
    491          # ``qd.Tensor`` wrappers passed as struct fields. The top-level kernel-arg unwrap hook in ``Kernel.__call__``
    492          # strips wrappers off positional / keyword args before they reach the template-mapper or this dispatch path, but
    493          # it does **not** walk into struct args. When the recursion below descends into a ``@qd.data_oriented`` (or
    494          # plain dataclass) struct field whose value is a wrapper, we land here with ``needed_arg_type`` set to whatever
    495          # annotation the struct declared on the field (e.g. ``NdarrayType``) and ``v`` set to a ``Tensor`` instance.
    496          # Unwrap defensively so the rest of the function sees the bare impl, matching what callers expect post-stork-19.
    497          # Idempotent for top-level args (already unwrapped).
    498          #
    499          # PERF-CRITICAL: The _any_tensor_constructed guard makes this check zero-cost when no qd.Tensor has been
    500          # created. ``type(v) in _TENSOR_WRAPPER_TYPES`` is used instead of ``isinstance`` because it is a pointer
    501          # comparison (~10 ns) vs an MRO walk (~100–200 ns). Do not replace with isinstance or remove the guard.
🟢  502          if (
    503              _tensor_wrapper._any_tensor_constructed and type(v) in _TENSOR_WRAPPER_TYPES
    504          ):  # pyright: ignore[reportOptionalMemberAccess]
🟢  505              v = v._unwrap()
    506  
    510          # qd.Tensor value-dispatch at launch time. Re-target the annotation to the concrete branch resolved from the
    511          # runtime value, then fall through to the existing dispatch logic. Wrapper instances are unwrapped earlier (in
    512          # ``Kernel.__call__``, plus the defensive in-struct unwrap immediately above); by the time we get here ``v``
    513          # is always the bare impl.
🟢  514          if needed_arg_type is _TensorClass:
🟢  515              if type(v) in _TENSOR_WRAPPER_TYPES:
🔴  516                  v = v._unwrap()
🟢  517              if isinstance(v, Ndarray):
🟢  518                  needed_arg_type = cast(Type, _TENSOR_T_NDARRAY_LAUNCH_ANNOTATION)
🟢  519                  needed_arg_type_id = id(needed_arg_type)
🟢  520                  needed_arg_basetype = type(needed_arg_type)
    521                  # Re-widen v to avoid pyright narrowing it to Ndarray for the remainder of the function (the dispatch
    522                  # logic below treats v as Any and inspects attributes that don't exist on Ndarray).
🟢  523                  v = cast(Any, v)
    524              else:
    525                  # Field/SNode/scalar template: launch path is a no-op (templates don't set kernel args).
🟢  526                  return 0, True
    527  
    589              # Element shapes are already specialized in Quadrants codegen. The shape information for element dims are no
    590              # longer needed. Therefore we strip the element shapes from the shape vector, so that it only holds "real"
    591              # array shapes.
🟢 python/quadrants/lang/_kernel_impl_dataclass.py (91%)
🔴    5  from quadrants._tensor_wrapper import Tensor as _TensorClass
🟢   80          dc_type = None
🟢   82              dc_type = parameter.annotation
🟢   83          elif param_name in ctx.template_vars and dataclasses.is_dataclass(ctx.template_vars[param_name]):
🟢   84              dc_type = type(ctx.template_vars[param_name])
🟢   85          if dc_type is not None:
🟢   86              for field in dataclasses.fields(dc_type):
    217      populate_all_fields: bool = False,
    228                  populate_all_fields=populate_all_fields,
🟢  230          elif field.type is _TensorClass or util.is_qd_template(field.type):
🟢  231              child_value = child_value._unwrap() if isinstance(child_value, _TensorClass) else child_value
🟢  232              global_vars[flat_name] = child_value
🟢  233          elif populate_all_fields:
🔴 python/quadrants/lang/_ndarray.py (41%)
🔴   31  def _invert_layout(layout):
     32      """Return the inverse permutation of ``layout`` as a tuple.
     33  
     34      ``layout`` lists the *canonical* axis index at each successive physical-memory axis (outermost first). The inverse
     35      maps each canonical axis back to the physical-memory axis it lives on, which is exactly what numpy's
     36      ``transpose(axes=)`` and DLPack's ``shape`` / ``strides`` arrays consume.
     37      """
🟢   38      n = len(layout)
🟢   39      inv = [0] * n
🟢   40      for src, dst in enumerate(layout):
🟢   41          inv[dst] = src
🟢   42      return tuple(inv)
     43  
     44  
🔴   45  def _is_identity_layout(layout):
     46      """``True`` if ``layout`` is ``None`` or the identity permutation."""
🟢   47      return layout is None or layout == tuple(range(len(layout)))
     48  
     49  
     58      # PERF-CRITICAL: This class attribute lets hotpath code access ``arg._qd_layout`` directly instead of ``getattr(arg,
     59      # "_qd_layout", None)``. Direct attribute access is measurably faster on the per-kernel-arg path in
     60      # _template_mapper_hotpath._extract_arg. Instance-level _qd_layout (set during layout-tagged allocation) shadows
     61      # this default.
🔴   62      _qd_layout: tuple[int, ...] | None = None
     63  
     66          # `_physical_shape` is the underlying storage shape (matches the C++ ndarray buffer). `shape` is exposed
     67          # as a property: when a layout tag (`_qd_layout`) is present it returns the *canonical* shape the user
     68          # indexes inside kernels; otherwise it returns the physical shape (which is the same thing).
🟢   69          self._physical_shape = None
     77          """Pickle support. Gradients (``.grad``) are not preserved because they are considered transient computation
     78          state."""
     90          """Export this ndarray as a DLPack capsule.
     91  
     92          The returned capsule carries the *canonical* shape and a permuted strides array on layout-tagged ndarrays, so
     93          consumers (`torch.utils.dlpack.from_dlpack`, etc.) see a transposed view of the physical buffer with no data
     94          movement. On untagged ndarrays this is byte-identical to the legacy export.
     95  
     96          Mirrors the field-backend behaviour: ``to_dlpack()`` on a field allocated via ``qd.tensor(...,
     97          backend=qd.Backend.FIELD, layout=...)`` (translated to ``order=...``) likewise returns a canonical view via
     98          permuted strides — see the C++ ``field_to_dlpack`` SNode-walk path.
     99          """
🟢  102          layout = getattr(self, "_qd_layout", None)
🟢  103          if _is_identity_layout(layout):
🟢  104              return impl.get_runtime().prog.ndarray_to_dlpack(self, self.arr)
🔴  105          return impl.get_runtime().prog.ndarray_to_dlpack(self, self.arr, list(layout))
🟢  114          self._physical_shape = None
    117  
🔴  118      @property
🔴  119      def layout(self):
    120          """Canonical-axis-permutation tuple, or ``None`` for identity.
    121  
    122          Mirrors :attr:`Field.layout`: returns the same value the caller passed to ``qd.tensor(..., layout=...)`` (or
    123          ``None`` if that kwarg was omitted / was the identity permutation). Lets downstream code introspect the
    124          physical layout without having to know which backend produced the tensor.
    125          """
🟢  126          layout = getattr(self, "_qd_layout", None)
🟢  127          if _is_identity_layout(layout):
🟢  128              return None
🟢  129          return tuple(layout)
    130  
🔴  131      @property
🔴  132      def shape(self):
    133          """Canonical shape the user sees and indexes inside kernels.
    134  
    135          On a layout-tagged ndarray (``_qd_layout`` set), the underlying buffer is allocated at the *physical* (permuted)
    136          shape; this property inverts the layout permutation so callers see the canonical shape they passed to
    137          ``qd.tensor(..., shape=)``.
    138  
    139          On an untagged ndarray (no layout, or identity layout) physical and canonical coincide and this returns the
    140          physical shape.
    141  
    142          ``to_numpy()`` / ``to_torch()`` / ``to_dlpack()`` also return canonical views (with ``_qd_layout`` reflected
    143          as a non-identity stride pattern on the DLPack side); the layout is purely an internal performance hint.
    144          """
🟢  145          phys = self._physical_shape
🟢  146          layout = getattr(self, "_qd_layout", None)
🟢  147          if phys is None or layout is None:
🟢  148              return phys
🟢  149          inv = _invert_layout(layout)
🟢  150          return tuple(phys[inv[i]] for i in range(len(phys)))
🟢  153          return NdarrayTypeMetadata(self.element_type, self._physical_shape, self.grad is not None)
    210          Returns the *canonical* view: the output array has ``self.shape`` (the user-facing shape passed to
    211          ``qd.tensor(..., shape=)``) and is filled by a kernel whose canonical iteration is mapped to the underlying
    212          physical buffer through the AST layout-permutation. Untagged ndarrays see canonical == physical and pay no extra
    213          cost.
    214  
    216              numpy.ndarray: The result numpy array, in canonical axis order.
🟢  218          arr = np.zeros(shape=tuple(self.shape), dtype=to_numpy_type(self.dtype))
    246          ``arr.shape`` is validated against the *canonical* shape (what ``self.shape`` reports). The
    247          ``ext_arr_to_ndarray`` kernel iterates ``arr`` canonically and writes through ``ndarray[I]``, so on
    248          layout-tagged destinations the AST permutation routes canonical positions into the underlying physical buffer
    249          without any python-side transpose.
    250  
    252              arr (numpy.ndarray): The source numpy array, in canonical axis order.
🟢  256          canonical_shape = tuple(self.shape)
🟢  257          if canonical_shape != tuple(arr.shape):
🟢  258              raise ValueError(f"Mismatch shape: {canonical_shape} expected, but {tuple(arr.shape)} provided")
🔴  267      @python_scope
🔴  268      def _ndarray_matrix_to_torch(self, as_vector, device=None):
    269          """Mirror of ``_ndarray_matrix_to_numpy`` that fills a torch tensor instead of a numpy array.
    270  
    271          Allocates a ``torch.zeros`` of ``self.arr.total_shape()`` (the canonical-major shape including the trailing
    272          element-axis extents) and dispatches the same ``ndarray_matrix_to_ext_arr`` bridge kernel — torch tensors
    273          expose the same external-array interface to the kernel as numpy arrays do.
    274          """
🔴  275          import torch  # pylint: disable=C0415
    276  
🔴  277          from quadrants.lang.util import to_pytorch_type  # pylint: disable=C0415
    278  
🔴  279          out = torch.zeros(size=tuple(self.arr.total_shape()), dtype=to_pytorch_type(self.dtype), device=device)
🔴  280          from quadrants._kernels import (  # pylint: disable=C0415
    281              ndarray_matrix_to_ext_arr,  # pylint: disable=C0415
    282          )
    283  
🔴  284          layout_is_aos = 1
🔴  285          ndarray_matrix_to_ext_arr(self, out, layout_is_aos, as_vector)
🔴  286          impl.get_runtime().sync()
🔴  287          return out
    288  
🔴  289      @python_scope
🔴  290      def _ndarray_matrix_from_torch(self, arr, as_vector):
    291          """Mirror of ``_ndarray_matrix_from_numpy`` that ingests a torch tensor. ``.contiguous()`` is forced so the
    292          bridge kernel sees a tightly-packed external array."""
🔴  293          contig = arr.contiguous()
🔴  294          if tuple(self.arr.total_shape()) != tuple(contig.shape):
🔴  295              raise ValueError(
    296                  f"Mismatch shape: {tuple(self.arr.total_shape())} expected, but {tuple(contig.shape)} provided"
    297              )
🔴  298          from quadrants._kernels import (  # pylint: disable=C0415
    299              ext_arr_to_ndarray_matrix,  # pylint: disable=C0415
    300          )
    301  
🔴  302          layout_is_aos = 1
🔴  303          ext_arr_to_ndarray_matrix(contig, self, layout_is_aos, as_vector)
🔴  304          impl.get_runtime().sync()
    305  
🟢  358          assert tuple(self.shape) == tuple(
    359              other.shape
    360          ), f"copy_from shape mismatch: destination {tuple(self.shape)} vs source {tuple(other.shape)}"
🟢  431          self._physical_shape = tuple(self.arr.shape)
🔴  449      def to_numpy(self, dtype=None):
    450          """Return a canonical-view NumPy array.
    451  
    452          ``dtype``: optional numpy dtype to cast the result to (matches :meth:`Field.to_numpy`'s signature). ``None``
    453          keeps the native ndarray dtype.
    454          """
🟢  455          arr = self._ndarray_to_numpy()
🟢  456          if dtype is not None and arr.dtype != dtype:
🟢  457              arr = arr.astype(dtype)
🟢  458          return arr
🔴  464      @python_scope
🔴  465      def to_torch(self, device=None):
    466          """Return a canonical-view torch tensor.
    467  
    468          Mirrors :meth:`Field.to_torch`. The destination is a ``torch.zeros(self.shape, ...)`` allocation that the
    469          bridge kernel writes into via the canonical iteration path, so layout-tagged ndarrays produce a canonical
    470          view just like ``to_numpy()`` does.
    471          """
🔴  472          import torch  # pylint: disable=C0415
    473  
🔴  474          from quadrants.lang.util import to_pytorch_type  # pylint: disable=C0415
    475  
🔴  476          canonical_shape = tuple(self.shape)
🔴  477          out = torch.zeros(size=canonical_shape, dtype=to_pytorch_type(self.dtype), device=device)
🔴  478          from quadrants._kernels import ndarray_to_ext_arr  # pylint: disable=C0415
    479  
🔴  480          ndarray_to_ext_arr(self, out)
🔴  481          impl.get_runtime().sync()
🔴  482          return out
    483  
🔴  484      @python_scope
🔴  485      def from_torch(self, arr):
    486          """Load all values from a torch tensor.
    487  
    488          The torch tensor must have the same canonical shape as ``self``; a non-contiguous source is materialised via
    489          ``.contiguous()`` so the bridge kernel sees a tightly packed external array.
    490          """
🔴  491          contig = arr.contiguous()
🔴  492          canonical_shape = tuple(self.shape)
🔴  493          if canonical_shape != tuple(contig.shape):
🔴  494              raise ValueError(f"Mismatch shape: {canonical_shape} expected, but {tuple(contig.shape)} provided")
🔴  495          from quadrants._kernels import ext_arr_to_ndarray  # pylint: disable=C0415
    496  
🔴  497          ext_arr_to_ndarray(contig, self)
🔴  498          impl.get_runtime().sync()
    499  
🟢  501          ret_arr = ScalarNdarray(self.dtype, self._physical_shape)
🟢  502          if self._qd_layout is not None:
🔴  503              ret_arr._qd_layout = self._qd_layout

Coverage report truncated to fit GitHub comment size limit.

When a mutable struct's ndarray attribute is reassigned between kernel
calls, the cache key (based on struct id()) doesn't change, causing stale
device pointers to be reused. Fix by folding live ndarray id(s) into the
args_hash — but only for mutable structs.

Frozen dataclass fields cannot be reassigned, so id(struct) in args_hash
is already sufficient. A last-seen cache (single tuple comparison per
call) avoids dict.get() overhead on the hot path.

Benchmarked on hp/tensor-genesis-1 (anymal_zero cpu, rtx-mid 16 cores):
stork-25 baseline avg 6642 FPS, stork-26b avg 6631 FPS (-0.2%, noise).
- _func_base.py: re-add ~20ns penalty comment on field.type temporary
- _ndarray.py: re-add runtime registration intent comment
@hughperkins

Copy link
Copy Markdown
Collaborator Author

benchmarks for Genesis on 26b that was just fast-forward merged in:

20260428_0609_stork26b_gen2_ndarray 20260428_0609_stork26b_gen2

Comment on lines +93 to +95
if annotation is _TensorClass:
if type(arg) in _TENSOR_WRAPPER_TYPES:
arg = arg._unwrap()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The redundant type(arg) in _TENSOR_WRAPPER_TYPES checks at _template_mapper_hotpath.py:93-95 and _func_base.py:514-516 are unreachable: any Tensor instance existing implies _tensor_wrapper._any_tensor_constructed is True (every __init__ sets it and nothing resets it), so the top-level unwrap above already fires whenever the inner check would. The PR's own comment at lines 91-92 acknowledges arg is always a bare impl by the time we get here, yet the guard remains and the PERF-CRITICAL comment block claims it is load-bearing on a hotpath where it cannot fire. Cleanup nit, not a correctness issue — recommend removing both dead blocks (and the misleading 'do not replace with isinstance' wording on the unreachable copy).

Extended reasoning...

What the dead code is

_extract_arg in python/quadrants/lang/_template_mapper_hotpath.py opens with an unconditional unwrap at lines 84-87:

if (_tensor_wrapper._any_tensor_constructed and type(arg) in _TENSOR_WRAPPER_TYPES):
    arg = arg._unwrap()

Then, immediately inside if annotation is _TensorClass: (line 93), the same predicate is rechecked:

if annotation is _TensorClass:
    if type(arg) in _TENSOR_WRAPPER_TYPES:
        arg = arg._unwrap()
    arg_type = type(arg)
    ...

The same pattern repeats in python/quadrants/lang/_func_base.py: lines 502-505 do the gated unwrap, then lines 514-516 redo if type(v) in _TENSOR_WRAPPER_TYPES: v = v._unwrap() inside the needed_arg_type is _TensorClass branch.

Why it is unreachable

The flag _any_tensor_constructed (defined at _tensor_wrapper.py:52) is set to True inside every wrapper __init__:

  • Tensor.__init__ sets it at line 85
  • VectorTensor.__init__ at line 293
  • MatrixTensor.__init__ at line 327

It is never reset (qd.reset() does not touch it; grep _any_tensor_constructed = False only matches the initial module-level definition). So as soon as any wrapper instance has been constructed, the flag is True for the rest of the process lifetime — and obviously, for type(arg) in _TENSOR_WRAPPER_TYPES to be True, arg must be such an instance, which means the flag was set when arg was constructed. The two predicates are thus equivalent in practice; whenever the inner check at line 94 (or _func_base.py:515) would be True, the outer guard already fired and unwrapped arg to a bare impl, so by the inner check type(arg) in _TENSOR_WRAPPER_TYPES is False (the impl is an Ndarray or Field, not in the wrapper tuple).

Step-by-step proof

  1. User calls a kernel with a qd.Tensor argument: fill(t) where t = qd.tensor(...)type(t) is Tensor.
  2. Because t exists, _any_tensor_constructed is True (set during t's __init__).
  3. Line 84 evaluates True and type(t) in _TENSOR_WRAPPER_TYPES → True; line 86 unwraps to the bare impl.
  4. type(arg) is now Ndarray (or ScalarField, MatrixField, etc.), not in _TENSOR_WRAPPER_TYPES.
  5. Control reaches line 93. If the annotation is qd.Tensor, line 94's predicate type(arg) in _TENSOR_WRAPPER_TYPES evaluates to False — the unwrap at line 95 is unreachable.

The PR's own comment at lines 91-92 explicitly states "arg is always a bare impl by the time we get here (Kernel.__call__ unwraps Tensor instances)", confirming the author understood this when keeping the redundant block as defensive code. The matching comment at _func_base.py:511-513 says the same.

Why this is worth flagging

The PERF-CRITICAL comment block on the wrapper-type tuple at _tensor_wrapper.py:344-347 warns against replacing type(x) in _TENSOR_WRAPPER_TYPES with isinstance because the former is ~10 ns vs ~100-200 ns for the MRO walk. That cost framing makes sense at the outer unwrap (which actually fires), but the inner unwrap pays the same ~10 ns per call on every qd.Tensor-annotated argument with no possible behavioral effect, contradicting the framing of the surrounding code as a perf-critical hotpath. Removing the inner blocks and tightening the comments aligns the code with the documented invariant.

Impact

No correctness bug; this is purely a code-quality / documentation-coherence nit. Severity nit because:

  • Zero functional impact, no observable behavior difference
  • Per-call cost is ~10 ns negligible
  • 6-line cleanup opportunity across two files
  • Removing the dead blocks would not change any behavior, only the line count and the (currently misleading) PERF-CRITICAL framing of unreachable code

Suggested fix

In both files, drop the inner if type(arg) in _TENSOR_WRAPPER_TYPES: arg = arg._unwrap() (and v equivalent) and the misleading PERF-CRITICAL paragraph that suggests the inner check is load-bearing. Keep the comment that says arg is always a bare impl by the time we get here since it now describes what the surrounding code actually relies on.

Comment on lines +22 to +23
if sys.platform == "darwin" and qd.lang.impl.current_cfg().arch == qd.vulkan:
pytest.skip("Vulkan on macOS uses MoltenVK with unified memory; same OOM-kill risk as Metal")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Lines 22-23 contain an unreachable if sys.platform == "darwin" and ... == qd.vulkan: pytest.skip(...) block. The outer @pytest.mark.skipif(sys.platform == "darwin", ...) decorator at lines 10-13 already removes darwin from the runs at collection time, so sys.platform == "darwin" can never be True inside the function body — the inner skip is dead code. Either remove the inner if-block, or tighten the outer skipif to a Metal-only condition so the inner Vulkan-on-darwin branch can fire.

Extended reasoning...

What the bug is

The new if sys.platform == "darwin" and qd.lang.impl.current_cfg().arch == qd.vulkan: pytest.skip(...) block at tests/python/test_fail_device_memory_allocation.py:22-23 is unreachable. The function is decorated at lines 10-13 with:

@pytest.mark.skipif(
    sys.platform == "darwin",
    reason="FIXME: macOS unified memory swaps to disk instead of OOMing, causing this test to hang",
)

pytest.mark.skipif is evaluated at collection / setup time, before any code in the function body executes. sys.platform is a process-level constant — it does not change during execution. So by the time control reaches the function body, the outer skipif has already guaranteed sys.platform != "darwin", and the inner sys.platform == "darwin" and ... predicate is structurally always False.

Step-by-step proof

  1. pytest evaluates the skipif marker during test collection. If sys.platform == "darwin", it tags the test as skipped and never invokes the body.
  2. If sys.platform != "darwin", the marker does nothing and the body runs — but inside the body, sys.platform is still not "darwin" (it's a constant for the lifetime of the process).
  3. So at line 22, sys.platform == "darwin" is False for every reachable execution of the function. Python short-circuits the and, the pytest.skip is never called, and the if-block is pure dead code.

The accompanying comment at line 21 ("Vulkan on macOS uses MoltenVK (Metal under the hood) with the same unified-memory OOM risk") describes a real concern, but the inner check that's supposed to act on it is unreachable.

Why existing code does not prevent it

This is a pure logic / hygiene issue introduced by the PR — no other code path normalises away the redundant check. The author's intent (skip Vulkan-on-darwin specifically because it goes through MoltenVK) is reasonable, but with the wider skipif(sys.platform == "darwin") already in place, the narrower check has nothing to do.

Impact

No functional impact: the dead code always evaluates False, so behaviour is identical to the same file with the if-block removed. The cost is purely cognitive — a future reader who sees the inner check will reasonably infer that the outer skipif must allow some darwin/vulkan combination to reach the body, when in fact it does not. This is more likely to mislead than help.

How to fix

Two reasonable options:

(a) Remove the dead if-block (simplest, matches current observable behaviour):

@pytest.mark.skipif(
    sys.platform == "darwin",
    reason="FIXME: macOS unified memory swaps to disk instead of OOMing, ...",
)
@test_utils.test(arch=qd.gpu)
def test_huge_allocation_fail_at_allocate_time():
    ...
    with pytest.raises(RuntimeError):
        ...

(b) Tighten the outer skipif so the inner check can fire (matches the comment's intent):

@pytest.mark.skipif(
    sys.platform == "darwin" and qd.lang.impl.current_cfg().arch == qd.metal,
    reason="...",
)
@test_utils.test(arch=qd.gpu)
def test_huge_allocation_fail_at_allocate_time():
    if sys.platform == "darwin" and qd.lang.impl.current_cfg().arch == qd.vulkan:
        pytest.skip("Vulkan on macOS uses MoltenVK with unified memory; same OOM-kill risk as Metal")
    ...

(Note: option (b) requires care because qd.lang.impl.current_cfg().arch is only valid after qd.init runs, which the @test_utils.test fixture handles — fine inside the body but not in the decorator. In practice option (a) is the cleaner fix.)

Comment on lines +465 to +481
if key != self._mutable_nd_cached_key:
if self._struct_ndarray_launch_info_by_key:
struct_nd_info = self._struct_ndarray_launch_info_by_key.get(key)
if struct_nd_info:
self._mutable_nd_cached_val = [
(idx, chain) for _, idx, chain in struct_nd_info if type(args[idx]).__hash__ is None
]
else:
self._mutable_nd_cached_val = []
else:
self._mutable_nd_cached_val = []
self._mutable_nd_cached_key = key
if self._mutable_nd_cached_val:
args_hash = (
*args_hash,
*(id(self._resolve_struct_ndarray(args, idx, chain)) for idx, chain in self._mutable_nd_cached_val),
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Stale-cache guard at kernel.py:465-481 misses @qd.data_oriented (the canonical Genesis pattern). The filter type(args[idx]).__hash__ is None only catches mutable @dataclasses.dataclass instances; @qd.data_oriented inherits object.hash (a method-wrapper, not None), so its args are excluded from the nested-ndarray fold-in and args_hash falls back to id(struct). Reassigning s.vals = ndB after fill(s) keeps id(s) stable, the cache replays the launch_ctx with ndA's device pointer, and writes silently corrupt the wrong buffer. Fix per codex P1 #3138927092: drop the filter and fold ndarray ids in for any struct with struct_ndarray_launch_info entries — reassignment naturally invalidates the cache because id(ndA) != id(ndB).

Extended reasoning...

What the bug is

The new stale-cache guard at kernel.py:465-481 was added to address codex P1 inline-comment #3138927092, which warned that mutable struct args reassigning ndarray attributes between calls would silently reuse cached launch_ctx pointers. The guard folds nested ndarray identities into args_hash only for struct args whose type satisfies type(args[idx]).__hash__ is None:

self._mutable_nd_cached_val = [
    (idx, chain) for _, idx, chain in struct_nd_info if type(args[idx]).__hash__ is None
]

This predicate is too narrow: __hash__ is None is Python's auto-set marker for mutable @dataclasses.dataclass() instances (Python sets __hash__ = None when __eq__ is defined without an explicit __hash__). It does NOT match @qd.data_oriented classes, which the decorator (kernel_impl.py:278-337) leaves with the inherited object.__hash__ (a method-wrapper, not None). Plain Python classes with __dict__ and @dataclass(eq=False) variants are also excluded.

Verified empirically:

  • Plain class: type(S()).__hash__ is NoneFalse (it is object.__hash__)
  • @dataclasses.dataclass() (mutable): __hash__ is NoneTrue
  • @dataclasses.dataclass(frozen=True): __hash__ is NoneFalse
  • @qd.data_oriented: __hash__ is NoneFalse

Step-by-step proof (the canonical Genesis pattern)

@qd.data_oriented
class S:
    def __init__(self, vals):
        self.vals = vals

ndA = qd.tensor(qd.i32, shape=(4,), backend=qd.Backend.NDARRAY)
ndB = qd.tensor(qd.i32, shape=(4,), backend=qd.Backend.NDARRAY)
s = S(vals=ndA)

@qd.kernel
def fill(st: qd.template()):
    for i in range(4):
        st.vals[i] = 7

fill(s)              # 1st call: launch_ctx cached with ndA's device pointer
s.vals = ndB         # reassign; ndA still alive (still held by `ndA` local)
fill(s)              # cache hit on (id(t_kernel), id(s)): ndA pointer reused

Walking the launch path:

  1. _predeclare_struct_ndarrays walks s via the else branch (hasattr(val, "__dict__") is True for @qd.data_oriented instances) and registers s.vals in struct_ndarray_launch_info. ✓
  2. First fill(s): args_hash = (id(t_kernel), id(s)). The new filter at line 470 evaluates type(s).__hash__ is NoneFalse because S.__hash__ is object.__hash__. So _mutable_nd_cached_val is []. Cache miss path runs _set_struct_ndarray_args, which appends ndA's obj.arr device pointer to launch_ctx_buffer[_QD_ARRAY]. is_launch_ctx_cacheable stays True; the launch_ctx is cached.
  3. s.vals = ndB (works on @qd.data_oriented; ndA still alive so the cache eviction tracker doesn't fire — its weakref to ndA.arr is still valid).
  4. Second fill(s): args_hash is unchanged (id(s) stable, _mutable_nd_cached_val still empty). populate_launch_ctx_from_cache returns True. The entire arg-setup block at lines 460-515 is skipped — including _set_struct_ndarray_args at line 488. The cached launch_ctx (built when s.vals was ndA) is copied verbatim and the kernel writes through ndA's device pointer.
  5. assert ndB.to_numpy().tolist() == [7, 7, 7, 7]FAILS (ndB is unchanged); assert ndA.to_numpy().tolist() == [7, 7, 7, 7] → passes (wrote to wrong buffer).

Why existing safeguards do not catch it

The new test_frozen_dc_dispatch_cache.py uses only @dataclasses.dataclass(frozen=True) for the NDARRAY backend; @qd.data_oriented is exercised with the FIELD backend, which doesn't go through struct_ndarray_launch_info. The combination @qd.data_oriented + reassignable ndarray field — the canonical Genesis pattern — is not covered.

The template_mapper key (instance_id) walks the dataclass via the fastcache hasher and only sees field type+shape+dtype+layout, not identity, so ndA and ndB produce identical mapper keys. Cache eviction is based on weakrefs to the cached arrays, not the live struct attributes, so as long as ndA stays alive (held by any local), eviction never fires.

Impact

Silent data corruption on a documented public path. Genesis uses @qd.data_oriented extensively for state structs whose ndarray attributes get reassigned between simulation steps (resize, replay, etc). Users get no error, no warning — writes silently land in the previous buffer.

Fix

The filter contract is wrong: "is hashable" and "has immutable fields" are different properties. @qd.data_oriented is hashable-by-id BUT has mutable attributes.

Cheapest correct fix: drop the filter and always fold nested ndarray ids into args_hash for any struct with struct_ndarray_launch_info entries. Reassignment naturally invalidates the cache because id(ndA) != id(ndB), so legitimate stable callers (no reassignment) hit the same cache entry as before — zero cost.

Alternative (per codex P1 #3138927092): set is_launch_ctx_cacheable = False whenever _set_struct_ndarray_args runs. This sidesteps the filter entirely at the cost of always missing on struct-ndarray paths.

🔬 also observed by 3138927092

Comment on lines +665 to +679
if _tensor_wrapper._any_tensor_constructed: # pyright: ignore[reportOptionalMemberAccess]
_indices = self._tensor_unwrap_indices
if _indices is None:
_indices = tuple(i for i, a in enumerate(py_args) if type(a) in _TENSOR_WRAPPER_TYPES)
self._tensor_unwrap_indices = _indices
if _indices:
py_args_l = list(py_args)
for i in _indices:
py_args_l[i] = py_args_l[i]._impl # pyright: ignore[reportAttributeAccessIssue]
py_args = tuple(py_args_l)
elif _indices:
py_args_l = list(py_args)
for i in _indices:
py_args_l[i] = py_args_l[i]._impl # pyright: ignore[reportAttributeAccessIssue]
py_args = tuple(py_args_l)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Cached _tensor_unwrap_indices blindly accesses ._impl on subsequent calls, crashing with AttributeError when an arg position that previously held a qd.Tensor wrapper now holds a bare Ndarray or Field. Kernel annotations like qd.Tensor, qd.template(), and qd.types.ndarray() are documented to accept either form polymorphically (_extract_arg even has a defensive unwrap branch for this), so any user who calls f(t) then f(t._unwrap()), or any Genesis-style mixed call site, hits the crash. Fix: add if type(py_args_l[i]) in _TENSOR_WRAPPER_TYPES: inside the elif loop, mirroring the defensive unwrap at _func_base.py:502-505 — or drop the cache (the per-arg type(a) in _TENSOR_WRAPPER_TYPES is a cheap pointer compare on a 3-tuple).

Extended reasoning...

What goes wrong

In Kernel.__call__ (kernel.py:665-679), _tensor_unwrap_indices is populated on the first call by scanning py_args for _TENSOR_WRAPPER_TYPES. On every subsequent call the elif _indices: branch runs:

elif _indices:
    py_args_l = list(py_args)
    for i in _indices:
        py_args_l[i] = py_args_l[i]._impl   # ← unconditional
    py_args = tuple(py_args_l)

The access is unguarded. If position i held a qd.Tensor wrapper on call 1 (so i got cached) but holds a bare Ndarray / Field on call 2, py_args_l[i]._impl raises AttributeError — neither bare class defines _impl (verified by grep on _ndarray.py and field.py; only the wrapper sets self._impl in __init__).

The code's own justification comment on lines 659-664 acknowledges the assumption — "a user who passes qd.Tensor(impl) at position i will do so on every call" — but never enforces it. The annotations the comment lists (qd.Tensor, qd.template(), qd.types.ndarray()) all accept either form polymorphically: _extract_arg (template_mapper_hotpath.py:84-95) has its own defensive unwrap because both forms are valid, and _recursive_set_args repeats the same pattern at _func_base.py:502-505. The cache here is the only path without that guard.

Step-by-step proof

@qd.kernel
def fill(x: qd.Tensor): ...

t = qd.tensor(qd.f32, shape=(4,))   # qd.tensor() returns a Tensor wrapper post stork-19
fill(t)                              # 1st call: _indices=(0,), t._impl read OK
fill(t._unwrap())                    # 2nd call: bare Ndarray at position 0
                                     #   → py_args_l[0]._impl → AttributeError

Walking the code:

  1. Call 1: _tensor_unwrap_indices is None. The init branch scans py_args, finds type(t) is Tensor matches, caches _indices = (0,), and unwraps t to the bare Ndarray. Kernel runs.
  2. User calls fill(t._unwrap()). py_args = (bare_ndarray,).
  3. _indices is truthy (0,), control enters the elif branch.
  4. py_args_l[0]._impl is evaluated on a bare NdarrayAttributeError.

Asymmetry

The bug is direction-asymmetric:

  • bare-then-wrapper (works): first call caches _indices = () (empty tuple — falsy). The elif _indices: branch is skipped on every subsequent call regardless of input type. Wrapper args on later calls get unwrapped by the downstream defensive guard at _func_base.py:502-505.
  • wrapper-then-bare (crashes): first call caches (0,), second call hits the unguarded ._impl.

Neither Kernel.reset() (kernel.py:333) nor qd.reset() clears _tensor_unwrap_indices. The cache survives runtime teardowns until a fresh Kernel object is constructed.

Why nothing else catches it

The crash is in Kernel.__call__ before the template mapper, before _extract_arg, before _recursive_set_args. The downstream defensive unwraps cannot intercept it. None of the new tests in the PR mix wrapper and bare impls at the same kernel-arg position — test_tensor_dispatch_same_kernel_both_backends only switches between two wrappers, both pointing to _TENSOR_WRAPPER_TYPES.

Impact

Hard AttributeError crash (no fallback, no diagnostic) on a reachable public-API pattern: any user who saves a wrapper, calls _unwrap() for a non-kernel purpose, and then re-passes the bare impl; any Genesis migration mid-state where some call sites still use qd.ndarray(...)/qd.field(...) while others use qd.tensor(). The wrapper class is brand new in this PR, so this is regression territory the PR introduces, not pre-existing behaviour.

Fix

One-line guard inside the elif, mirroring the pattern already used at _func_base.py:502-505 and _template_mapper_hotpath.py:84-87:

elif _indices:
    py_args_l = list(py_args)
    for i in _indices:
        if type(py_args_l[i]) in _TENSOR_WRAPPER_TYPES:
            py_args_l[i] = py_args_l[i]._impl
    py_args = tuple(py_args_l)

Alternative: drop the cache entirely and re-scan on every call. The check type(a) in _TENSOR_WRAPPER_TYPES is a single pointer compare on a 3-tuple per arg — the per-call cost the cache was added to avoid is in the same ballpark as the defensive guard itself.

@github-actions

Copy link
Copy Markdown

Coverage Report (c53d4feac)

File Coverage Missing
🔴 python/quadrants/__init__.py 0% 32-33,42-45
🟢 python/quadrants/_kernels.py 100%
🔴 python/quadrants/_tensor.py 78% 18,22-23,25,33-34,45,75,88-89,92,110-111,114,130,141,151,195,240,243,261,264,282
🔴 python/quadrants/_tensor_wrapper.py 67% 34,36-37,39,52,55,61,77,91,96,101,111-112,115-116,119-120,128,138,157-158,167-169,172,175,181,192,197,200-203,205-206,208-209,211,214,225-226,236,257,268,275,282,290,295-296,299,310,316,324,329-330,333,348,356,381,400,416
🔴 python/quadrants/lang/_fast_caching/args_hasher.py 69% 9-10,37,40,50,52,61-62,72-73
🟢 python/quadrants/lang/_func_base.py 83% 21,25,34-35,71,101,104,117,127,135,242,578
🟢 python/quadrants/lang/_kernel_impl_dataclass.py 91% 5
🔴 python/quadrants/lang/_ndarray.py 41% 31,45,62,106,119-120,132-133,268-269,276,278,280-281,285-288,290-291,294-296,299,303-305,450,465-466,473,475,477-479,481-483,485-486,492-496,498-499,504
🔴 python/quadrants/lang/_template_mapper_hotpath.py 68% 32,34,38-39,57,95
🟢 python/quadrants/lang/any_array.py 91% 26
🟢 python/quadrants/lang/ast/ast_transformer.py 80% 262-263,276,281,644-645,723-724,728-730
🟢 python/quadrants/lang/ast/ast_transformer_utils.py 100%
🟢 python/quadrants/lang/ast/ast_transformers/call_transformer.py 100%
🟢 python/quadrants/lang/ast/ast_transformers/function_def_transformer.py 83% 11,15,72-74,190-191,213-214,225,252,260-261,264
🔴 python/quadrants/lang/field.py 41% 4,34-35,38-39,42-43,54-55,62,78-92,95-102,168-169,185,356,360,371
🟢 python/quadrants/lang/impl.py 92% 18,216
🔴 python/quadrants/lang/kernel.py 75% 19,34,475,478,595-596,598-603,605-606,627
🟢 python/quadrants/lang/kernel_arguments.py 100%
🔴 python/quadrants/lang/matrix.py 64% 965,970,980,987,989,994,1032-1033,1208,1243-1244,1293-1294,1302,1306,1820,1850-1851,1854,1856-1857,1860,1866,1947,1980-1981,1984,1986-1987,1989,1995
🟢 python/quadrants/lang/snode.py 100%
🟢 tests/python/quadrants/lang/fast_caching/test_args_hasher.py 100%
🟢 tests/python/quadrants/lang/test_dlpack.py 100%
🟢 tests/python/test_api.py 100%
🔴 tests/python/test_fail_device_memory_allocation.py 50% 23
🟢 tests/python/test_field_layout_flat_snode.py 91% 173-175,177-180,182-183,185-186,188-190
🟢 tests/python/test_frozen_dc_dispatch_cache.py 99% 262-263
🟢 tests/python/test_tensor_annotation.py 100%
🟢 tests/python/test_tensor_annotation_in_func.py 100%
🟢 tests/python/test_tensor_backend.py 100%
🔴 tests/python/test_tensor_backend_symmetry.py 76% 61-62,64,66-69,73-76,78-85,93-94,96-98
🟢 tests/python/test_tensor_factory.py 100%
🟢 tests/python/test_tensor_factory_layout_ndarray.py 100%
🟢 tests/python/test_tensor_factory_vec_mat.py 100%
🟢 tests/python/test_tensor_grad.py 100%
🟢 tests/python/test_tensor_layout.py 99% 110
🟢 tests/python/test_tensor_layout_cache.py 100%
🟢 tests/python/test_tensor_layout_grad.py 100%
🟢 tests/python/test_tensor_layout_host_indexing.py 100%
🟢 tests/python/test_tensor_layout_interop.py 90% 90,189-192,194-196,204-207,209-211,219-222,224-225,228-229,382,564-566,640-641,643-647,649,651-657
🔴 tests/python/test_tensor_layout_physical_bytes.py 24% 54-58,62,66-69,87-88,90-91,93-96,98-99,101-102,110-111,113-114,116-120,122,139-140,142-145,147-149,151-152,154,167-168,170-171,173,175-178,180-183,187-190,192-195,197-198,200,211-212,214-215,217-221,223,235-236,238-239,241-245,247-249,252
🟢 tests/python/test_tensor_ndarray_layout_aliasing.py 100%
🟢 tests/python/test_tensor_ndarray_layout_augassign.py 100%
🟢 tests/python/test_tensor_ndarray_layout_higher_rank.py 99% 34
🟢 tests/python/test_tensor_ndarray_layout_subscript.py 100%
🟢 tests/python/test_tensor_pr560_fixes.py 100%
🟢 tests/python/test_tensor_wrapper_in_struct.py 100%
🟢 tests/python/test_tensor_wrapper_kernel.py 100%
🟢 tests/python/test_tensor_wrapper_skeleton.py 100%
🟢 tests/python/test_tensor_wrapper_surface.py 93% 77-83,86-87,97-101

Diff coverage: 89% · Overall: 73% · 4394 lines, 470 missing

Full annotated report

@hughperkins hughperkins merged commit 6f04796 into main Apr 28, 2026
10 checks passed
@hughperkins hughperkins deleted the hp/tensor-stork-24 branch April 28, 2026 18:00
@github-actions

Copy link
Copy Markdown

Coverage Report (fcda24627)

File Coverage Missing

Diff coverage: 0% · Overall: 73% · 0 lines, 0 missing

Full annotated report

hughperkins added a commit that referenced this pull request May 16, 2026
Update compound_types.md to reflect what landed in #561 [Type] Tensor 24 (which added
``_predeclare_struct_ndarrays``) and what's fixed in this PR (the nested + mutation cases). The
old "no" cell predated the Tensor 24 infrastructure by ~6 weeks and was already inconsistent with
the in-tree error message in ``python/quadrants/lang/impl.py`` which lists "@qd.data_oriented /
frozen-dataclass template" as the supported route for ndarrays inside structs.

Add an ndarray-member example under the @qd.data_oriented section.
hughperkins added a commit that referenced this pull request May 16, 2026
Update compound_types.md to reflect what landed in #561 [Type] Tensor 24 (which added
``_predeclare_struct_ndarrays``) and what's fixed in this PR (the nested + mutation cases). The
old "no" cell predated the Tensor 24 infrastructure by ~6 weeks and was already inconsistent with
the in-tree error message in ``python/quadrants/lang/impl.py`` which lists "@qd.data_oriented /
frozen-dataclass template" as the supported route for ndarrays inside structs.

Add an ndarray-member example under the @qd.data_oriented section.
hughperkins added a commit that referenced this pull request May 16, 2026
Update compound_types.md to reflect what landed in #561 [Type] Tensor 24 (which added
``_predeclare_struct_ndarrays``) and what's fixed in this PR (the nested + mutation cases). The
old "no" cell predated the Tensor 24 infrastructure by ~6 weeks and was already inconsistent with
the in-tree error message in ``python/quadrants/lang/impl.py`` which lists "@qd.data_oriented /
frozen-dataclass template" as the supported route for ndarrays inside structs.

Add an ndarray-member example under the @qd.data_oriented section.
hughperkins added a commit that referenced this pull request May 19, 2026
Update compound_types.md to reflect what landed in #561 [Type] Tensor 24 (which added
``_predeclare_struct_ndarrays``) and what's fixed in this PR (the nested + mutation cases). The
old "no" cell predated the Tensor 24 infrastructure by ~6 weeks and was already inconsistent with
the in-tree error message in ``python/quadrants/lang/impl.py`` which lists "@qd.data_oriented /
frozen-dataclass template" as the supported route for ndarrays inside structs.

Add an ndarray-member example under the @qd.data_oriented section.
npoulad1 added a commit to ROCm/quadrants that referenced this pull request Jun 8, 2026
* [Misc] Warn user to disable caching when print_ir/QD_DUMP_IR enabled (Genesis-Embodied-AI#425)

Co-authored-by: v01dxyz <v01dxyz@v01d.xyz>

* [Build] Pin torch version to CUDA 12.8 for CUDA tests (Genesis-Embodied-AI#428)

* [Misc] Fixing up taichi-dev urls (Genesis-Embodied-AI#429)

* [Perf] Rename cuda_graph to gpu_graph across the codebase (Genesis-Embodied-AI#430)

* Misc: fix typo integeral -> integral (Genesis-Embodied-AI#434)

Co-authored-by: v01dxyz <v01dxyz@v01d.xyz>

* [Perf] CUDA graph 4: call from multiple locations (Genesis-Embodied-AI#420)

* [Bug] Fix fastcache not restoring graph_do_while_arg (Genesis-Embodied-AI#435)

* [Perf] Cache last-call result in perf_dispatch for single-compatible case (Genesis-Embodied-AI#438)

* Fix gpu_graph fallback on old Nvidia GPU. (Genesis-Embodied-AI#443)

* Fix shared memory offset not reset between CUDA kernels. (Genesis-Embodied-AI#442)

* [Misc] Allow disabling GPU graph via QD_GPU_GRAPH=0 env var (Genesis-Embodied-AI#439)

* [Misc] Add named top-level loops (Genesis-Embodied-AI#440)

* [Misc] Rename gpu_graph to graph (Genesis-Embodied-AI#446)

* [Misc] Add cross-platform shuffle (Genesis-Embodied-AI#447)

* [Bug] Fix graph_do_while on Windows: search for cudadevrt.lib (Genesis-Embodied-AI#456)

* [Bug] Also search default CUDA toolkit install location on Windows (Genesis-Embodied-AI#461)

* [SPIRV] Feature Parity Atomics & Shared Array (Genesis-Embodied-AI#432)

* [Misc] Change clang format to 120 characters (Genesis-Embodied-AI#463)

* [Misc] CUDA graph 5 Add fatbin (Genesis-Embodied-AI#464)

* [Bug] Reuse VkInstance across init/reset cycles (Genesis-Embodied-AI#465)

* [Perf] Tiles 1: _load, _store, _eye_ (Genesis-Embodied-AI#466)

* [Misc] Remove dead InternalFuncStmt type_check override (Genesis-Embodied-AI#471)

* [Perf] Tiles 2: add cholesky and ger (Genesis-Embodied-AI#472)

* [Perf] Tiles 2b: add triangular solve (Genesis-Embodied-AI#474)

* [Misc] Refactor: use _get_col/_set_col in tiles load/store/init (Genesis-Embodied-AI#475)

* [Build] Fix flaky test_clock_accuracy (Genesis-Embodied-AI#436)

* Fix AARCH64 emitting invalid asm in CUDA kernels. (Genesis-Embodied-AI#473)

Co-authored-by: Hugh Perkins <hughperkins@gmail.com>

* [AMDGPU] Enable HIP memory pool and surface pool-exhaustion errors. (Genesis-Embodied-AI#485)

* [AMDGPU] Scope hsaco tmp dir per-user to avoid collisions. (Genesis-Embodied-AI#484)

* [Perf] Tiles 3: Add slice syntax, qd.outer() and initial doc (Genesis-Embodied-AI#477)

* [AMDGPU] Fix gradient computation. (Genesis-Embodied-AI#486)

* Enable all backends that are supported in unit tests. (Genesis-Embodied-AI#488)

* Fix SPIRV ID overflow for large kernels due to autodiff. (Genesis-Embodied-AI#489)

* [Misc] Fix purity checker to allow accessing constants from quadrants modules (Genesis-Embodied-AI#487)

* [Misc] Increase tolerance for clock monotonic test (Genesis-Embodied-AI#492)

* [CI] Serialize api doc workflow (Genesis-Embodied-AI#494)

* [CI] Increase tolerance for clock test (Genesis-Embodied-AI#506)

* [CI] Increase clock test tolerance to 20% (Genesis-Embodied-AI#509)

* [Perf] Add tensor_type parametrization to tile16 tests (Genesis-Embodied-AI#504)

* [Perf] Tiles 4b: Migrate tiles16 tests to enable fastcache (Genesis-Embodied-AI#505)

* [Perf] Tiles 4c: add Tiles16x16 proxy (Genesis-Embodied-AI#507)

* [Perf] Tiles 4d: Consolidate slice error tests using parametrize (Genesis-Embodied-AI#508)

* [Perf] Tiles 4: add SharedArray slice support (Genesis-Embodied-AI#482)

* [Perf] Tiles 5: add Cholesky benchmark demo (Genesis-Embodied-AI#483)

* [Doc] Add user guide page for subgroup shuffle (Genesis-Embodied-AI#512)

* [Perf] Implement cross-platform shuffle_down (Genesis-Embodied-AI#510)

* [Perf] Add portable subgroup reduce_add and reduce_all_add (Genesis-Embodied-AI#511)

* [Perf] Add first warmup config to perf dispatch (Genesis-Embodied-AI#422)

* [AutoDiff] Autodiff 1: Add baseline adstack regression test for unary_collections (Genesis-Embodied-AI#500)

* [AutoDiff] Autodiff 2: Implement derivative for tan (Genesis-Embodied-AI#501)

* [AutoDiff] Autodiff 3: Recompute tanh/exp on the operand in the reverse pass (Genesis-Embodied-AI#502)

* [AutoDiff] Autodiff 4: Mark rsqrt as non-linear for adstack promotion (Genesis-Embodied-AI#503)

* [AutoDiff] Autodiff 5: Fix adjoint-alloca placement for GlobalLoads outside the current range-for (Genesis-Embodied-AI#496)

* [AutoDiff] Autodiff 6: Adstack regression tests (Genesis-Embodied-AI#491)

* [AutoDiff] Autodiff 7: Fix header size in AdStackAllocaStmt to match u64 runtime layout (Genesis-Embodied-AI#534)

* [AutoDiff] Autodiff 8: Surface LLVM adstack push/pop overflow as a Python exception (Genesis-Embodied-AI#535)

* [AutoDiff] Autodiff 9: Guard against LLVM worker-thread stack overflow from large per-task adstack budget (Genesis-Embodied-AI#495)

* [AutoDiff] Autodiff 10: Implement adstack for SPIR-V (Genesis-Embodied-AI#490)

* [AutoDiff] Autodiff 11: Latent adstack-adjacent fixes (AMDGPU hipFree, flush() keeps ctx_buffers_, always-preallocate) (Genesis-Embodied-AI#536)

* [Doc] Add AGENTS.md with instructions for AI agents (Genesis-Embodied-AI#541)

* [Bug] Abort kernel execution on assertion failure instead of segfaulting (Genesis-Embodied-AI#419)

* [Type] ndarray typing 1: Add eval_str=True to inspect.signature() calls (Genesis-Embodied-AI#411)

* [CI] Suppress reportPrivateImportUsage in torch-using files (Genesis-Embodied-AI#552)

* [Misc] QD_DUMP_IR dumps to files with the task_id added to the filename (Genesis-Embodied-AI#441)

* [Type] ndarray typing 2: Fix NDArray single-arg subscript crash (Genesis-Embodied-AI#412)

* [Test] Flush xdist channel before worker exit so test failure reports are visible (Genesis-Embodied-AI#555)

* [CI] Reduce test retries on CI from 3 to 1. (Genesis-Embodied-AI#554)

* [AutoDiff] Autodiff 12: Heap-backed adstack on LLVM backends (CPU/CUDA/AMDGPU) (Genesis-Embodied-AI#537)

* [AutoDiff] Autodiff 13: Heap-backed adstack on SPIR-V backends (Metal, Vulkan) (Genesis-Embodied-AI#493)

* [AutoDiff] Autodiff 14: Resolve bounded-inner-loop adstacks without default_ad_stack_size fallback (Genesis-Embodied-AI#539)

* [SPIRV] Vulkan SPIR-V correctness: atomic-view aliasing, PSB stride, narrow storage caps, u1 cast, per-init layer recheck (Genesis-Embodied-AI#513)

* [Build] Autodiff 15: Replace 2022 MoltenVK pin with LunarG Vulkan SDK fetch and sanitise MoltenVK cap advertisement (Genesis-Embodied-AI#551)

* [Test] Suppress stock pytest-timeout to avoid conflict with pytest_hardtle (Genesis-Embodied-AI#557)

* [Vulkan] Use SDK validation layer for debugPrintf instead of apt package (Genesis-Embodied-AI#562)

* [Test] Fix flaky perf_dispatch tests by increasing work amounts (Genesis-Embodied-AI#559)

* [Test] Add --maxfail CLI option to run_tests.py (default 20) (Genesis-Embodied-AI#558)

* [CI] Vulkan debug printf fix to address flaky tests (Genesis-Embodied-AI#563)

* [Docs] Add a new page to help for first time contributors (Genesis-Embodied-AI#426)

Authored-by: v01dxyz <v01dxyz@v01d.xyz>

* [AutoDiff] Autodiff 16: Resolve reverse-mode adstack depths per-launch via runtime-evaluated SizeExpr (Genesis-Embodied-AI#543)

* Fix: raise error if device memory allocation fails (Genesis-Embodied-AI#451) (Genesis-Embodied-AI#453)

Co-authored-by: v01dxyz <v01dxyz@v01d.xyz>
Co-authored-by: Hugh Perkins <hughperkins@gmail.com>

* [CI] Add CI job to check line wrapping of comments and docs (Genesis-Embodied-AI#564)

* [Misc] Add coverage report to PRs, including kernels (Genesis-Embodied-AI#470)

* [CI] CI wrap check feeds only diffs to agent (Genesis-Embodied-AI#567)

* Skip 'flaky' test on MacOS CI. (Genesis-Embodied-AI#573)

* [Test] Fix missing `import sys` in test_fail_device_memory_allocation (Genesis-Embodied-AI#574)

* [CI] Fix Vulkan debugPrintf flake with session-scoped warmup (Genesis-Embodied-AI#571)

* [AutoDiff] determine_ad_stack_size: replace whole-CFG Bellman-Ford with SCC + DAG DP (Genesis-Embodied-AI#575)

* [Test] Fix macOS OOM skip reason to describe actual root cause (Genesis-Embodied-AI#576)

* [Lang] whole_kernel_cse: 2.5x compile time speedup on large kernels (Genesis-Embodied-AI#577)

* [CI] Add CI check for unnecessarily deleted comments (Genesis-Embodied-AI#570)

* [CI] Migrate coverage report to github Check page (Genesis-Embodied-AI#566)

* [Lang] Skip IR verifier between passes unless debug=true (Genesis-Embodied-AI#579)

* [Lang] Inline AdStack ops on release LLVM codegen: dramatically reduces compile time for adstack-enabled reverse-mode kernels (Genesis-Embodied-AI#584)

* [CUDA] Honor offline_cache=False end-to-end so QD_OFFLINE_CACHE=0 actually gives a cold compile (Genesis-Embodied-AI#580)

* [Type] Tensor 24 (Genesis-Embodied-AI#561)

Co-authored-by: hugh <hugh@slurm-login-0.slurm-login.tenant-slurm.svc.cluster.local>

* [Lang] auto_diff host-walk reductions: dramatically faster front-end compile time on adstack-enabled reverse-mode kernels (Genesis-Embodied-AI#587)

* [AutoDiff] Speed up reverse-mode kernel launches on GPU backends (Genesis-Embodied-AI#578)

* [Vulkan] Move adstack-sizer scratch out of Function-scope memory to fix SPIR-V pipeline build failures (Genesis-Embodied-AI#588)

* [AutoDiff] Improve diagnosis of unsupported reverse-mode AD patterns (Genesis-Embodied-AI#590)

* [Bug] Fix: promote Ndarray to AnyArray in build_Name for flattened struct fields (Genesis-Embodied-AI#592)

* [SPIR-V] Shrink reverse-grad kernel MSL by ~50% (Genesis-Embodied-AI#591)

* [CI] Add CI check that PR changes have test coverage (Genesis-Embodied-AI#596)

* [Perf] Enable zero-copy in to_torch() and to_numpy() (Genesis-Embodied-AI#450)

* Add BufferView: safe sub-range ndarray access for kernels (Genesis-Embodied-AI#585)

Co-authored-by: alanray-tech <alanray-tech@users.noreply.github.com>
Co-authored-by: Hugh Perkins <hughperkins@gmail.com>

* [Doc] Add user-facing fastcache documentation (Genesis-Embodied-AI#597)

Co-authored-by: hugh <hugh@slurm-login-0.slurm-login.tenant-slurm.svc.cluster.local>

* [Misc] Upgrade to enable v1 dlpack so to_numpy(copy=False) writable (Genesis-Embodied-AI#598)

Co-authored-by: root <root@rtx-209-201.slurm-compute.tenant-slurm.svc.cluster.local>

* [AutoDiff] Cut reverse-mode adstack memory usage 10x on all backends (Genesis-Embodied-AI#599)

* [Misc] Add CI check for feature file factorization (Genesis-Embodied-AI#606)

* [Perf] Skip _recursive_set_args for all-Field frozen dataclass structs (Genesis-Embodied-AI#607)

Co-authored-by: Cursor <cursoragent@cursor.com>

* [AutoDiff] SNode-arm bound-expr capture rejects fold-attack gate indices (Genesis-Embodied-AI#610)

* [Misc] Suppress field fastcache warning for qd.Tensor (Genesis-Embodied-AI#615)

Co-authored-by: Cursor <cursoragent@cursor.com>

* [AutoDiff] Adstack heap: clip reducer count by per-task loop trip count (compile-time and SizeExpr-evaluated) (Genesis-Embodied-AI#611)

* [Misc] Forward copy= through qd.Tensor, add copy=None option (Genesis-Embodied-AI#616)

Co-authored-by: Cursor <cursoragent@cursor.com>

* [Doc] Update README (Genesis-Embodied-AI#617)

Co-authored-by: Cursor <cursoragent@cursor.com>

* [CI] Fix coverage report showing def lines as uncovered (Genesis-Embodied-AI#623)

Co-authored-by: Cursor <cursoragent@cursor.com>

* [Perf] Generic launcher: persistent context, JIT-pointer reuse, Metal compute encoder, LLVM-GPU async memory ops (Part 1/2) (Genesis-Embodied-AI#619)

* [CI] Encode Python-first testing policy in coverage-check prompt (Genesis-Embodied-AI#622)

Co-authored-by: Cursor <cursoragent@cursor.com>

* [CI] Add PR Line change report (Genesis-Embodied-AI#624)

Co-authored-by: Cursor <cursoragent@cursor.com>

* [CI] Disable quadrants pytest plugin during quadrants internal coverage runs (Genesis-Embodied-AI#629)

Co-authored-by: Cursor <cursoragent@cursor.com>

* [AutoDiff] Adstack load+store eliminations: EliminateRecomputableAdStackPushes pass + leaf extensions (Genesis-Embodied-AI#621)

* [CI] Simplify coverage PR comment to a single linked line (Genesis-Embodied-AI#630)

* [CUDA] Add AGX Thor, SM_110 (Genesis-Embodied-AI#631)

Co-authored-by: Johnny Nunez and Hugh Perkins

* [CI] Lines changed report: collapse PR comment to a single linked totals line (Genesis-Embodied-AI#632)

* [FEATURE] Support external Metal command queue via qd.init (Genesis-Embodied-AI#618)

Co-authored-by: Cursor <cursoragent@cursor.com>

* [Perf] Cache adstack-sizer metadata per task across SPIR-V + LLVM-GPU; per-snode / DeviceAllocation invalidation (Part 2/2) (Genesis-Embodied-AI#620)

* [AutoDiff] Disable EliminateRecomputableAdStackPushes pending mutated-SNode chain-leaf fix (Genesis-Embodied-AI#633)

* [AutoDiff] Adstack chain-clone safety: mutated-SNode leaf reject + load_top consumer-aware guard (Genesis-Embodied-AI#634)

* [Docs] Add user-guide page for qd.simt.block.* primitives (Genesis-Embodied-AI#638)

* [Docs] Expand qd.simt.subgroup user-guide page to cover every op (Genesis-Embodied-AI#639)

* [Perf] Streams 1-4 (Genesis-Embodied-AI#410)

* [Docs] Add user-guide page for matrix decompositions and solvers (Genesis-Embodied-AI#643)

* [Bug] Revert "[Perf] Streams 1-4 (Genesis-Embodied-AI#410)" (Genesis-Embodied-AI#650)

* [Docs] Add user-guide page for atomics and bit operations (Genesis-Embodied-AI#640)

* [Docs] Add user-guide page for qd.simt.grid.* primitives (Genesis-Embodied-AI#641)

* [AutoDiff] Adstack max-reducer: parallel multi-axis MaxOverRange dispatch (Genesis-Embodied-AI#635)

* [AMDGPU] Fix amdgpu parallel rand init (Genesis-Embodied-AI#658)

* [Perf] Adstack: skip max-reducer recognizer on CPU + lift host-eval cap (Genesis-Embodied-AI#655)

* [Perf] Re-land Streams 1-4 with bug fixes (Genesis-Embodied-AI#653)

* [AMDGPU] Apply device_memory_GB=0.3 cap to AMDGPU tests (Genesis-Embodied-AI#659)

* [Perf] Per-launch host sync: drop wait_idle on SPIR-V, pin stream and drop stream_synchronize on CUDA/AMDGPU (Genesis-Embodied-AI#654)

* [AMDGPU] Unload hipModule_t in JITModuleAMDGPU destructor (Genesis-Embodied-AI#660)

* [AMDGPU] Trim default mempool on qd.reset() (Genesis-Embodied-AI#669)

* [AMDGPU] Hoist rand-state buffer to process lifetime (Genesis-Embodied-AI#668)

* [Streams] Use events for streams serialization on AMDGPU and CUDA (Genesis-Embodied-AI#667)

* [Perf] Adstack max-reducer: launch cache + zero-copy result map; content-stable registry_id (Genesis-Embodied-AI#671)

* [SPIR-V] dispatch_max_reducers: register each task with the real kernel name (Genesis-Embodied-AI#675)

* [AutoDiff] Debug-mode field/grad/dual: dtype, layout, and access-time invariants (Genesis-Embodied-AI#677)

* [Docs] Add user-guide page for qd.algorithms.* device-wide algorithms (Genesis-Embodied-AI#642)

Co-authored-by: alanray-tech <alan.ray@genesis-ai.company>

* [Docs] Doc for existing atomics: switch support table to per-backend columns (Genesis-Embodied-AI#657)

Co-authored-by: alanray-tech <alan.ray@genesis-ai.company>

* [GPU] Cross gpu atomics (Genesis-Embodied-AI#666)

Co-authored-by: alanray-tech <alan.ray@genesis-ai.company>

* [GPU] Make block operations portable cross-gpu (Genesis-Embodied-AI#664)

* [Perf] CPU LLVM adstack-cache: skip per-launch bump-writes + ndarray_shapes capture on forward-only handles (Genesis-Embodied-AI#685)

* [GPU] Cross-GPU for grid ops (Genesis-Embodied-AI#670)

* [Math] Make bitop operations portable cross-gpu (Genesis-Embodied-AI#662)

* [AMDGPU] Always use wave64, on both RDNA and CDNA (Genesis-Embodied-AI#687)

* [AMDGPU] Use syncscope("agent") for atomix xor to avoid CAS livelock (Genesis-Embodied-AI#672)

* [GPU] New bit ops for QIPC (Genesis-Embodied-AI#679)

* [GPU] Subgroup ops cross-gpu (Genesis-Embodied-AI#665)

* [Graph] Rename CUDA Graph to Graph in docs (Genesis-Embodied-AI#691)

* [SPIR-V] Fix FIFO-queue ordering when sharing command queue. (Genesis-Embodied-AI#694)

* [Atomics] New QIPC ops for atomics (Genesis-Embodied-AI#690)

* Pass dataclass sub-structs into qd.func (Genesis-Embodied-AI#698)

* [AMDGPU] HIP graph runtime support for @qd.kernel(graph=True) (Genesis-Embodied-AI#692)

* [CI] Add per-file timing report to Mac Metal test job (Genesis-Embodied-AI#695)

Co-authored-by: Cursor <cursoragent@cursor.com>

* [CI] Enable kernel disk cache during tests (Genesis-Embodied-AI#696)

* [Math] New QIPC ops for single-threaded linalg (Genesis-Embodied-AI#683)

* [BREAKING][GPU] New QIPC ops for subgroups (Genesis-Embodied-AI#676)

* [GPU] New QIPC ops for block (Genesis-Embodied-AI#684)

* [GPU] New device-level ops for QIPC (Genesis-Embodied-AI#693)

* [algorithms] PrefixSumExecutor: drop unused GRID_SZ local (Genesis-Embodied-AI#701)

* [block] sync(): fix unsupported-arch error message (Genesis-Embodied-AI#700)

* [volatile_load] add qd.volatile_load primitive (closes Genesis-Embodied-AI#648) (Genesis-Embodied-AI#702)

* [AutoDiff] Reject recycled identity_key in AdStackCache::register_adstack_sizing_info (Genesis-Embodied-AI#708)

* [Vulkan] Declare GroupNonUniform SPIR-V caps and enable shaderSubgroupExtendedTypes (Genesis-Embodied-AI#707)

* Fix duplicate HIP graph driver-function declarations after v1.0.0 merge

The amd-integration fork had cherry-picked the HIP graph driver functions
(graph_create / graph_destroy / graph_add_kernel_node / graph_instantiate /
graph_exec_destroy / graph_launch), and upstream v1.0.0 added the same set.
The per-file 3-way merge appended both copies into
amdgpu_driver_functions.inc.h, producing redeclaration errors that broke the
AMDGPU RHI/runtime compile. Drop the upstream duplicate block; the signatures
are identical to the fork's existing declarations.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Fix AMDGPU launcher coherence and num_instructions visibility after v1.0.0 merge

- kernel_launcher.cpp: the 3-way merge spliced upstream v1.0.0's launch_llvm_kernel
  rewrite (ephemeral arg/context buffers, explicit-stream path, AmdgpuDefaultStream
  PinGuard) onto the AMD fork's kernarg-by-value + persistent-scratch design,
  leaving references to undefined `ephemeral_context_ptr`. Restore the fork's
  coherent launch_llvm_kernel verbatim; it calls the (already merged) enhanced
  launch_offloaded_tasks, which keeps the max-reducer dispatch and stream-parallel
  groups adapted onto the AMD launch path.
- llvm_context.h: both the fork and upstream added `num_instructions`; the merge
  kept upstream's private placement, but the AMDGPU codegen force-inline heuristic
  calls it statically from outside the class. Move it back to the public section.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Restore async result D2H and hoist kernarg vectors in AMDGPU launcher

The v1.0.0 merge resolution regressed two amd-integration baseline
optimizations in launch_llvm_kernel / launch_offloaded_tasks:

  - The per-launch result-buffer copy was a blocking memcpy_device_to_host,
    forcing a host stall on every value-returning launch and serializing the
    GPU pipeline. Restore the async D2H (the caller synchronizes lazily when it
    needs the value); external-array transfers still stream_synchronize once
    before reading back.

  - launch_task constructed the kernarg std::vectors from initializer lists
    ({kernarg_payload} / {kernarg_size}) on every dispatch (heap alloc + free
    per launch). Hoist arg_ptrs/arg_sizes out of the per-task launch and reuse.

Co-authored-by: Cursor <cursoragent@cursor.com>

* amdgpu: default to LDS permlane64 emulation; drop host-x86 barrier asm on retarget

Two AMDGPU JIT-compile crashes surfaced after the v1.0.0 merge pulled in the QIPC subgroup
ops (Genesis-Embodied-AI#676), which made the rigid constraint solver's wave-cooperative reductions route through
`amdgpu_cross_half_shuffle_i32`. Both manifested as a SIGSEGV inside
`llvm::SIInstrInfo::getInstSizeInBytes` during `JITSessionAMDGPU::compile_module_to_hsaco`
(i.e. at first kernel launch), and reproduce on gfx942 / MI300X. Baseline 0.4.6 never emitted
these constructs, which is why it was unaffected.

1. Native `llvm.amdgcn.permlane64` lowering crashes the bundled LLVM 22.1.0 AMDGPU backend.
   Default `amdgpu_permlane64` to the existing LDS-roundtrip software emulation on every target
   (it produces identical results). Add `QD_AMDGPU_USE_NATIVE_PERMLANE64=1` to opt back into the
   native instruction once the backend bug is fixed; the old `QD_AMDGPU_FORCE_PERMLANE64_FALLBACK`
   is now the default and still honored. This is the actual crash fix.

2. The runtime module is compiled by the host x86_64 clang and only retargeted to amdgcn here, so
   `amdgpu_cross_half_shuffle_i32`'s `__asm__ volatile("" : "+v"(byte))` optimization barrier carries
   x86 flag clobbers (`~{dirflag},~{fpsr},~{flags}`) that are meaningless on AMDGPU. The IR verifies
   but the empty-body INLINEASM is invalid on the amdgcn target. Neutralize empty-body barrier asm
   during retarget (forward the tied value, then erase) so no stale host asm reaches codegen. On the
   wave64 targets we ship `ds_bpermute` already addresses the full wave, so the hint is a no-op.

Co-authored-by: Cursor <cursoragent@cursor.com>

* style: apply clang-format (v19.1.7) to AMDGPU fn_attrs and launcher sources

CI pre-commit's clang-format hook reformatted these files (long
declarations/lambda signatures collapsed onto single lines per the repo's
clang-format config). Apply the same formatting so the hook passes.

No functional changes.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(amdgpu): use CreateNeg for branchless i32 sgn instead of CreateSub(0, input)

clang-tidy (modernize-use-nullptr, -warnings-as-errors) flagged
`builder->CreateSub(0, input)` in the i32 sgn path: the literal `0` binds to
the `llvm::Value*` LHS parameter as a null pointer, not an integer zero.
Replace with `builder->CreateNeg(input)`, which emits `0 - input` with a proper
zero constant -- identical intended semantics, and clang-tidy clean.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Robert Dazi <14996868+v01dXYZ@users.noreply.github.com>
Co-authored-by: v01dxyz <v01dxyz@v01d.xyz>
Co-authored-by: Hugh Perkins <hughperkins@gmail.com>
Co-authored-by: Alexis DUBURCQ <alexis.duburcq@gmail.com>
Co-authored-by: hugh <hugh@slurm-login-0.slurm-login.tenant-slurm.svc.cluster.local>
Co-authored-by: alanray-tech <alan.ray@genesis-ai.company>
Co-authored-by: alanray-tech <alanray-tech@users.noreply.github.com>
Co-authored-by: root <root@rtx-209-201.slurm-compute.tenant-slurm.svc.cluster.local>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Johnny <johnnynuca14@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants