[Misc] Reject ndarray struct passed to template-annotated kernel param#608
[Misc] Reject ndarray struct passed to template-annotated kernel param#608hughperkins wants to merge 3 commits intomainfrom
Conversation
ae92ccc to
2003fbb
Compare
Passing structs containing ndarrays through qd.template() bypasses argument pruning — every ndarray field gets registered regardless of whether the kernel uses it — and inflates the cached launch context, causing a measured 42% launch overhead on real workloads. Raise a clear QuadrantsCompilationError guiding users to use a concrete dataclass type annotation instead. Co-authored-by: Cursor <cursoragent@cursor.com>
2003fbb to
337795f
Compare
There was a problem hiding this comment.
Additional findings (outside current diff — PR may have been updated during review):
-
🟡
python/quadrants/lang/ast/ast_transformers/function_def_transformer.py:192-198— This PR removes the only writer ofctx.global_context.struct_ndarray_launch_info(the inner_register_ndarrayhelper) and the only writer ofctx.global_context.ndarray_to_any_array(thecache[key] = arrline in the same helper), leaving roughly half a dozen named pieces of infrastructure as dead/unreachable code:_struct_ndarray_launch_info_by_key(kernel.py:324), the launch-time read at kernel.py:435-437, the entire stale-cache guard at kernel.py:466-481, the_set_struct_ndarray_argscall at kernel.py:511-513, the static helpers_resolve_struct_ndarray(kernel.py:596) and_set_struct_ndarray_args(kernel.py:606), thestruct_ndarray_launch_infofield onASTTransformerGlobalContext(ast_transformer_utils.py:185),_promote_ndarray_if_declared(ast_transformer.py:649), and thendarray_to_any_arraylookup branches in impl.py:212, function_def_transformer.py:276, and function_def_transformer.py:298. Not a functional bug — failing paths still fail and succeeding paths still succeed — but a meaningful chunk of code is now unreachable, and the comment at impl.py:207-208 ("AnyArray cache populated by _predeclare_struct_ndarrays during kernel compilation") is now actively misleading. Suggest removing this infrastructure as part of this PR or a follow-up before it lands.Extended reasoning...
What is dead and why
This PR replaces the body of
_predeclare_struct_ndarrayswith code that only raisesQuadrantsCompilationError. The two helpers it removed — the inner_register_ndarrayand the implicitcache[key] = arr/launch_info.append(...)writes — were the sole producers of two pieces of compile-context state:ctx.global_context.struct_ndarray_launch_info(initialized to[]in ast_transformer_utils.py:185)ctx.global_context.ndarray_to_any_array(initialized to{}in ast_transformer_utils.py:184)
After this PR, grep confirms there are zero remaining writers to either. So both are permanently empty for the lifetime of any kernel compile.
The infrastructure left dangling
struct_ndarray_launch_infowas consumed by an entire stale-cache-guard subsystem inKernel:- kernel.py:324 —
self._struct_ndarray_launch_info_by_key: dict[CompiledKernelKeyType, list] = {}is still initialized. - kernel.py:435-437 — at materialize-time,
self._struct_ndarray_launch_info_by_key[key] = getattr(ctx.global_context, "struct_ndarray_launch_info", []). Thegetattralways returns[]now. - kernel.py:466-481 — the mutable-ndarray stale-cache guard. The outer
if self._struct_ndarray_launch_info_by_key:(dict-truthy) is still True after at least one kernel materializes, but the innerif struct_nd_info:(list-truthy) at 468 is always False because the value is[]. Soself._mutable_nd_cached_valis always assigned[], and theif self._mutable_nd_cached_val:block at 477-481 is unreachable. - kernel.py:511-513 —
struct_nd_info = self._struct_ndarray_launch_info_by_key.get(key)always returns[](falsy), so_set_struct_ndarray_argsis never called. - kernel.py:596
_resolve_struct_ndarrayand kernel.py:606_set_struct_ndarray_args— only invoked from kernel.py:480 and kernel.py:513 respectively, both unreachable per above. The static methods themselves are dead. - ast_transformer_utils.py:185 — the field initializer is now write-only-by-default and read-but-always-empty.
ndarray_to_any_arraywas consumed at three sites:- impl.py:209-220 —
subscriptchecks the cache to promote a bareNdarrayto anAnyArrayproxy. Thegc.ndarray_to_any_array.get(id(value))lookup at 212 always returnsNone, so the only outcome for a bareNdarrayreaching this code path is theNdarray ... not registered as a kernel parameterraise at 216-220. - ast_transformer.py:649-658 —
_promote_ndarray_if_declaredis now a no-op for every input (it always returnsvalueunchanged). - function_def_transformer.py:276, 298 —
_transform_func_arglooks up promoted ndarrays forqd.Tensor/ dataclass-field cases.promotedis alwaysNoneso the ternary always falls through to the originaldata/data_child.
The misleading comment
impl.py:207-208 still says:
# Ndarray from struct template field: resolve via the AnyArray cache populated by _predeclare_struct_ndarrays
# during kernel compilation.This comment is now actively wrong:
_predeclare_struct_ndarraysno longer populates that cache (it raises). A future reader stepping throughsubscriptwill follow the comment expecting a cache hit, then be confused thatget(...)returnsNonefor every input.Step-by-step proof for one path
Take the launch hot path for any kernel that compiles successfully under this PR:
materializeruns_predeclare_struct_ndarrays, which either raises (template struct contains an ndarray) or returns silently without writing to either context dict.- After successful materialization, kernel.py:435-437 runs:
self._struct_ndarray_launch_info_by_key[key] = getattr(ctx.global_context, "struct_ndarray_launch_info", [])→ assigns[]. - On
launch_kernel, kernel.py:466if self._struct_ndarray_launch_info_by_key:is True (dict has the key), so we enter the branch. - kernel.py:467
struct_nd_info = self._struct_ndarray_launch_info_by_key.get(key)→[]. - kernel.py:468
if struct_nd_info:is False (empty list), so we hit theelseat 472 →self._mutable_nd_cached_val = []. - kernel.py:477
if self._mutable_nd_cached_val:is False, so kernel.py:478-481 (the_resolve_struct_ndarraycall site) is skipped. - kernel.py:511
struct_nd_info = self._struct_ndarray_launch_info_by_key.get(key)→[]again. - kernel.py:512
if struct_nd_info:is False, so kernel.py:513 (the_set_struct_ndarray_argscall site) is skipped. _resolve_struct_ndarrayand_set_struct_ndarray_argstherefore never execute. They are unreachable.
Severity & fix
This is not a behavioral bug — the kernel still launches correctly, and the new
raisecovers the path that this infrastructure used to serve. It is, however, a meaningful amount of unreachable code (one instance dict, one global-context field, one static helper, one set-args helper, one lookup helper, three call sites, and a misleading docstring comment) that AGENTS.md asks PRs to keep tidy. Suggested cleanup:- Delete
_struct_ndarray_launch_info_by_key,_mutable_nd_cached_key,_mutable_nd_cached_val,_resolve_struct_ndarray,_set_struct_ndarray_args, and the kernel.py:435-437 / 466-481 / 511-513 read sites. - Delete
ASTTransformerGlobalContext.struct_ndarray_launch_infoandndarray_to_any_array(or keep the latter and add a# always empty after PR #608comment if you want to defer). - Delete
_promote_ndarray_if_declaredand itsndarray_to_any_array-using callers infunction_def_transformer.pyand thesubscriptlookup in impl.py — replace impl.py:209-220 with the bareraisesince that is the only outcome now. - Either fix or delete the impl.py:207-208 comment.
Either do this in this PR or open an immediate follow-up so the cleanup does not get lost.
-
🟡
python/quadrants/lang/ast/ast_transformers/function_def_transformer.py:209-216— Error message in_predeclare_struct_ndarrayshard-codes "annotated asqd.template()" and advises "Use a concrete struct annotation instead ofqd.template()", but the entry guard at lines 238-240 also acceptsqd.Tensorannotations. When a user writesx: qd.Tensorand passes a struct/dataclass containing a bareNdarrayattribute, the raised message references an annotation the user never wrote. Fix by formatting the wording from the actualarg_meta.annotation(e.g. emit "qd.Tensor" vs "qd.template()").Extended reasoning...
What's wrong
_predeclare_struct_ndarraysbuilds a list of parameters to walk using:is_template = anno is annotations.template or isinstance(anno, annotations.template) is_tensor_anno = anno is _TensorClass if not (is_template or is_tensor_anno): continue
So both
qd.template()-annotated andqd.Tensor-annotated parameters can reach_walk_obj. But the two newraise QuadrantsCompilationError(...)sites inside_walk_obj(the dataclass branch at L209-216 and the__dict__branch at L226-233) hard-code:Kernel parameter '{param_name}' is annotated as qd.template(), but ...…and the remediation hint says:
Use a concrete struct annotation (e.g. a @dataclass type hint) instead of qd.template() for struct parameters that contain ndarrays.When the user actually wrote
x: qd.Tensor, both the diagnosis ("annotated asqd.template()") and the advice ("instead ofqd.template()") are factually wrong and will confuse anyone debugging.Reachability — step-by-step proof
- User writes a kernel
def k(x: qd.Tensor): ...and passes a dataclass instance whose field is a bareqd.ndarray(i.e. not aqd.Tensorwrapper). _template_mapper_hotpath._extract_argroutes non-Ndarrayvalues forqd.Tensorannotations through_TENSOR_T_FIELD_MARKER, so the kernel proceeds to_predeclare_struct_ndarrays.- In the loop,
anno is _TensorClass⇒is_tensor_anno=True. The guard passes. val = ctx.py_args[i]is the dataclass; it is not a_TensorClass(so no unwrap), not an_ndarray.Ndarray(so nocontinue), is a dataclass ⇒_walk_obj(val, i, ())runs._walk_objfinds theNdarrayfield and raises with the hard-coded "annotated as qd.template()" message — even though the user wroteqd.Tensor.
Addressing the refutation
One verifier argued the qd.Tensor branch is unreachable in practice, claiming a non-
Tensorstruct passed to aqd.Tensor-annotated arg would error elsewhere first. That objection only covers theTensor → Field._unwrap()sub-path (where Field's__dict__indeed contains noNdarrays). It does not cover the more direct path above, where a plain dataclass-with-ndarray value is passed tox: qd.Tensorand routes through_TENSOR_T_FIELD_MARKER. Two other verifiers traced exactly this path and confirmed reachability. Even if reachability were marginal, theis_tensor_annobranch was deliberately added to the guard — emitting a wrong-annotation message in any branch the guard explicitly admits is a self-inconsistency.Impact
Error-message correctness only; no behavioral or correctness regression. Just user confusion in the
qd.Tensorcase.Fix
Compute an annotation string once per param (e.g.
anno_str = "qd.Tensor" if is_tensor_anno else "qd.template()") and pass it into_walk_obj, then format both raise sites with that string. The remediation hint should likewise be conditional (or simply drop the "instead ofqd.template()" clause).🔬 also observed by verify-runtime
- User writes a kernel
-
🔴
python/quadrants/lang/ast/ast_transformers/function_def_transformer.py:207-216— The new rejection in_predeclare_struct_ndarraysfires forqd.Tensor-annotated dataclass fields whose runtime value is NDARRAY-backed, regressing the previously supported pattern exercised bytests/python/test_tensor_annotation_in_func.py::test_tensor_struct_field_kernel_data_oriented[ndarray]and::test_tensor_struct_vector_field_roundtrip[ndarray](both callqd.tensor(..., backend=qd.Backend.NDARRAY)and stash the wrapper on a frozen dataclass passed viaqd.template()). The check unwraps theTensorwrapper before theisinstance(_ndarray.Ndarray)test, so the wrapper is indistinguishable from a rawqd.ndarray. Fix: skip when the value was aqd.Tensorwrapper (capture wrap state before_unwrap()), or only reject when the field is a bareqd.ndarray. The same flaw exists in the non-dataclass__dict__branch.Extended reasoning...
What the bug is
FunctionDefTransformer._predeclare_struct_ndarraysnow rejects any template-annotated struct that contains anNdarrayimpl. Inside_walk_obj(lines 207-216), the code does:child = getattr(obj, field.name) if isinstance(child, _TensorClass): child = child._unwrap() if isinstance(child, _ndarray.Ndarray): raise QuadrantsCompilationError(...)
The order is the problem:
_unwrapis called before theisinstance(Ndarray)test, so aqd.Tensorwrapper holding an Ndarray impl is treated identically to a bareqd.ndarray.Why
qd.Tensorwas previously fineqd.tensor(dtype, shape=..., backend=Backend.NDARRAY)returns_wrap_impl(impl.ndarray(...))(python/quadrants/_tensor.py:228), i.e. aTensorwrapper whose_implis aScalarNdarray/VectorNdarray/MatrixNdarray(all subclasses of_ndarray.Ndarray). Pre-PR, this path went through_register_ndarrayand worked viandarray_to_any_arrayresolution. The FIELD-backend variant accidentally still works post-PR only becauseTensor._unwrap()of a FIELD-backed tensor returns aField(not anNdarray), so the new isinstance check skips it. The new rejection therefore silently changes the meaning of aqd.Tensor-typed dataclass field based on the runtime backend the caller picked — clearly unintended.Step-by-step proof using an existing test
tests/python/test_tensor_annotation_in_func.py:56(test_tensor_struct_field_kernel_data_orientedparametrized withbackend=Backend.NDARRAY):t = qd.tensor(qd.i32, shape=(6,), backend=qd.Backend.NDARRAY)→_wrap_impl(impl.ndarray(...))→ aTensorwrapper,t._implis aScalarNdarray.@dataclasses.dataclass(frozen=True) class S: vals: qd.Tensor;s = S(vals=t).@qd.kernel def fill(st: qd.template()): ...;fill(s).- In
_predeclare_struct_ndarrays:anno = qd.template()→is_template = True;val = sis a dataclass →_walk_obj(s, 0, ())runs. - Field
vals→child = t(_TensorClass);child = child._unwrap()returns theScalarNdarrayimpl. isinstance(child, _ndarray.Ndarray)is True → raisesQuadrantsCompilationError.
The same chain breaks
test_tensor_struct_vector_field_roundtrip[ndarray](tests/python/test_tensor_annotation_in_func.py:226) and any other[ndarray]parametrization that stuffs an NDARRAY-backedqd.Tensorinto a dataclass / data_oriented struct passed viaqd.template(). The PR does not modify the test file, so CI on the NDARRAY parametrizations regresses.Inconsistency that confirms the bug is unintended
At the top level in this same function (lines 242-246) the code explicitly handles a Tensor wrapper holding an Ndarray and
continues without raising — the rejection only fires inside structs. There is no good reason for the same wrapper-around-Ndarray to be allowed at the top level but rejected one level of nesting deeper.Impact and fix
Impact: blocks the PR, since two existing tests fail on the
[ndarray]parametrization, and any user code following the documentedqd.Tensor-as-struct-field pattern (pinned as a stork-23 invariant in the test module's docstring) breaks based on the runtime backend choice.Fix: distinguish
qd.Tensorwrappers from rawqd.ndarrayby capturing the wrap state before unwrapping, e.g.:child = getattr(obj, field.name) was_tensor_wrapper = isinstance(child, _TensorClass) if was_tensor_wrapper: child = child._unwrap() if not was_tensor_wrapper and isinstance(child, _ndarray.Ndarray): raise QuadrantsCompilationError(...)
The same change is needed in the non-dataclass
__dict__branch (lines 226-233). Equivalently, you could testfield.type is qd.Tensorand only reject when the annotation isqd.ndarray. Either preserves the user's annotation-level intent rather than gating the check on the runtime backend.
Coverage Report (
|
| File | Coverage | Missing |
|---|---|---|
🟢 python/quadrants/lang/ast/ast_transformers/function_def_transformer.py |
100% | |
🟢 tests/python/test_tensor_annotation_in_func.py |
100% |
Diff coverage: 100% · Overall: 67% · 31 lines, 0 missing
The 5 ndarray-backend variants of struct-via-template tests now assert that passing a struct containing ndarrays through qd.template() raises QuadrantsCompilationError. Field-backend variants are unchanged. Co-authored-by: Cursor <cursoragent@cursor.com>
a44badc to
733a160
Compare
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 22d4341ceb
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| raise QuadrantsCompilationError( | ||
| f"Kernel parameter '{param_name}' is annotated as qd.template(), but " | ||
| f"'{param_name}.{attr_path}' is a qd.ndarray. Passing ndarrays through " | ||
| f"template structs is not supported because it bypasses argument pruning " | ||
| f"and degrades launch performance. Use a concrete struct annotation " | ||
| f"(e.g. a @dataclass type hint) instead of qd.template() for struct " |
There was a problem hiding this comment.
Document new template-struct ndarray compile failure
This change introduces a user-visible API behavior change by turning previously valid qd.template() struct usage with qd.ndarray fields into a hard QuadrantsCompilationError in _predeclare_struct_ndarrays, but the commit does not update any user docs under docs/. Per AGENTS.md ("Keep user-facing docs in sync with public API changes"), this should be documented so users understand the new restriction and migration path.
Useful? React with 👍 / 👎.
Coverage Report (
|
| File | Coverage | Missing |
|---|---|---|
🟢 python/quadrants/lang/ast/ast_transformers/function_def_transformer.py |
100% | |
🟢 tests/python/test_tensor_annotation_in_func.py |
100% |
Diff coverage: 100% · Overall: 67% · 31 lines, 0 missing
Issue: #
Brief Summary
copilot:summary
Walkthrough
copilot:walkthrough