[Type] Tensor 24#561
Conversation
…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.
… test on Mac 3.13 Made-with: Cursor
… 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.
6faea61 to
601df67
Compare
| _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`` / |
There was a problem hiding this comment.
🔴 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()Tape.__enter__runsisinstance(self.loss, Field)→ False (Tensor is not a Field subclass).isinstance(self.loss, Ndarray)→ False.- Falls through to the torch branch at line 217.
self.loss.numel()raisesAttributeError: 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.
| - :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). |
There was a problem hiding this comment.
🟡 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
- Open
python/quadrants/_tensor.py, jump to line 82. Observe:'slightly slower at runtime but avoids kernel recompilation when sizes change.' - Open
docs/source/user_guide/tensor.md, jump to line 16. Observe:'Slower at runtime but avoids recompilation when sizes change.' grep -n 'slightly\|Slightly' docs/source/user_guide/tensor.mdreturns no matches;grep -n 'slightly' python/quadrants/_tensor.pyreturns 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
Coverage Report (
|
| 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).
# Conflicts: # .github/workflows/linux.yml
- _func_base.py: re-add ~20ns penalty comment on field.type temporary - _ndarray.py: re-add runtime registration intent comment
# Conflicts: # .github/workflows/linux.yml
| if annotation is _TensorClass: | ||
| if type(arg) in _TENSOR_WRAPPER_TYPES: | ||
| arg = arg._unwrap() |
There was a problem hiding this comment.
🟡 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 85VectorTensor.__init__at line 293MatrixTensor.__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
- User calls a kernel with a
qd.Tensorargument:fill(t)wheret = qd.tensor(...)→type(t) is Tensor. - Because
texists,_any_tensor_constructedis True (set duringt's__init__). - Line 84 evaluates
True and type(t) in _TENSOR_WRAPPER_TYPES→ True; line 86 unwraps to the bare impl. type(arg)is nowNdarray(orScalarField,MatrixField, etc.), not in_TENSOR_WRAPPER_TYPES.- Control reaches line 93. If the annotation is
qd.Tensor, line 94's predicatetype(arg) in _TENSOR_WRAPPER_TYPESevaluates 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.
| 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") |
There was a problem hiding this comment.
🟡 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
pytestevaluates theskipifmarker during test collection. Ifsys.platform == "darwin", it tags the test as skipped and never invokes the body.- If
sys.platform != "darwin", the marker does nothing and the body runs — but inside the body,sys.platformis still not "darwin" (it's a constant for the lifetime of the process). - So at line 22,
sys.platform == "darwin"isFalsefor every reachable execution of the function. Python short-circuits theand, thepytest.skipis 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.)
| 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), | ||
| ) |
There was a problem hiding this comment.
🔴 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 None→ False (it isobject.__hash__) @dataclasses.dataclass()(mutable):__hash__ is None→ True@dataclasses.dataclass(frozen=True):__hash__ is None→ False@qd.data_oriented:__hash__ is None→ False
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 reusedWalking the launch path:
_predeclare_struct_ndarrayswalkssvia theelsebranch (hasattr(val, "__dict__")is True for@qd.data_orientedinstances) and registerss.valsinstruct_ndarray_launch_info. ✓- First
fill(s):args_hash = (id(t_kernel), id(s)). The new filter at line 470 evaluatestype(s).__hash__ is None→ False becauseS.__hash__isobject.__hash__. So_mutable_nd_cached_valis[]. Cache miss path runs_set_struct_ndarray_args, which appends ndA'sobj.arrdevice pointer tolaunch_ctx_buffer[_QD_ARRAY].is_launch_ctx_cacheablestays True; the launch_ctx is cached. 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).- Second
fill(s):args_hashis unchanged (id(s) stable, _mutable_nd_cached_val still empty).populate_launch_ctx_from_cachereturns True. The entire arg-setup block at lines 460-515 is skipped — including_set_struct_ndarray_argsat line 488. The cachedlaunch_ctx(built whens.valswas ndA) is copied verbatim and the kernel writes through ndA's device pointer. 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
| 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) |
There was a problem hiding this comment.
🔴 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 → AttributeErrorWalking the code:
- Call 1:
_tensor_unwrap_indicesisNone. The init branch scanspy_args, findstype(t) is Tensormatches, caches_indices = (0,), and unwrapstto the bareNdarray. Kernel runs. - User calls
fill(t._unwrap()).py_args = (bare_ndarray,). _indicesis truthy(0,), control enters theelifbranch.py_args_l[0]._implis evaluated on a bareNdarray→AttributeError.
Asymmetry
The bug is direction-asymmetric:
- bare-then-wrapper (works): first call caches
_indices = ()(empty tuple — falsy). Theelif _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.
Coverage Report (
|
| 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
Coverage Report (
|
| File | Coverage | Missing |
|---|
Diff coverage: 0% · Overall: 73% · 0 lines, 0 missing
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.
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.
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.
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.
* [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>


Issue: #
Brief Summary
copilot:summary
Walkthrough
copilot:walkthrough