Concisely describe the proposed feature
Quadrants should support optional non-template kernel arguments spelled T | None, defaulting to None when unset, and should specialize the compiled kernel on presence versus absence so that the unused code path is never traced. Concretely, this should be valid and supported:
@qd.kernel
def apply_at(out: qd.types.NDArray[qd.f32, 2], point: qd.Tensor | None = None):
for i in range(out.shape[0]):
if qd.static(point is not None):
... # use point[i]
else:
... # use the default anchor
The motivating case is the application point of a force applied to a geometry. The point can be left unspecified (the center of mass is used), given as an absolute position, or given as an offset relative to the center of mass. Each variant wants a different arithmetic path, and the unspecified variant wants no arithmetic at all rather than adding zero or multiplying by one. Today the choice is between duplicating the kernel once per variant, which multiplies the maintenance burden and lets the copies drift, or keeping a single kernel that pays for arithmetic it does not need in the common case.
The real-world instance of this is Genesis-Embodied-AI/genesis-world#3143, which adds an application point to the external force API and hits exactly this trade-off in the kernel behind it.
What works today
The underlying mechanism already exists, but it is not spellable the way the code above reads, and it is not covered by any test.
A qd.template() slot accepts None and specializes on it, since a template argument is part of the specialization key by construction. That is not a usable answer here: the same slot rejects an ndarray outright with Ndarray shouldn't be passed in via 'qd.template()' (python/quadrants/lang/_template_mapper_hotpath.py:268), so an argument that is a tensor when present cannot go through it. The only route that accepts both forms today is qd.Tensor.
qd.Tensor is a value-dispatched argument slot: _extract_arg routes ndarray-shaped values through the ndarray feature path and everything else through the template path (python/quadrants/lang/_template_mapper_hotpath.py:237), so a None argument lands in the specialization key. FunctionDefTransformer._decl_and_create_variable then binds the kernel-scope name to the literal Python None (python/quadrants/lang/ast/ast_transformers/function_def_transformer.py:82), and qd.static folds the branch at AST build time.
So the following runs correctly on CPU at 47e9dfb5:
import numpy as np
import quadrants as qd
qd.init(arch=qd.cpu)
@qd.kernel
def add_bias(out: qd.types.NDArray[qd.f32, 1], bias: qd.Tensor):
for i in range(out.shape[0]):
if qd.static(bias):
out[i] = qd.f32(i) + bias[i]
else:
out[i] = qd.f32(i)
out = qd.ndarray(qd.f32, shape=(4,))
bias = qd.ndarray(qd.f32, shape=(4,))
bias.from_numpy(np.full(4, 100.0, dtype=np.float32))
for arg in (None, bias, None, qd.Tensor(bias)):
add_bias(out, arg)
print(f"{type(arg).__name__:8s} -> {out.to_numpy()}")
print("specializations:", len(add_bias._primal.mapper.mapping))
NoneType -> [0. 1. 2. 3.]
ScalarNdarray -> [100. 101. 102. 103.]
NoneType -> [0. 1. 2. 3.]
Tensor -> [100. 101. 102. 103.]
specializations: 2
Exactly two specializations, correct results on every call, and no thrashing when alternating between the two forms. The absent branch is genuinely never traced rather than eliminated later: replacing its body with bias.no_such_attribute[i] still compiles and runs when None is passed.
The workaround does not extend to external arrays
An argument that has to accept a torch tensor when present cannot use any of this. The two annotations that could carry such an argument fail on opposite ends, and there is no third option covering both:
| annotation |
None |
torch.Tensor |
qd.types.ndarray(dtype=qd.f32, ndim=1) |
QuadrantsRuntimeTypeError: Invalid type for argument bias, got None |
works |
qd.Tensor |
works |
ValueError: Input to qd.static must be compile-time constants or global pointers, instead of <class 'torch.Tensor'> |
A torch tensor reaches an qd.types.ndarray() slot through the external arrays path, which reads shape = getattr(arg, "shape", None) and raises as soon as that comes back None (python/quadrants/lang/_template_mapper_hotpath.py:335-338). None is therefore indistinguishable from any other invalid argument at that point and never reaches the specialization key.
Conversely torch.Tensor is neither an Ndarray nor an AnyArray, so a qd.Tensor slot falls through to the template path and binds the raw torch object as a template value, which qd.static then refuses (python/quadrants/lang/impl.py:1331). A second problem sits behind that error: the specialization count for None plus two distinct torch tensors plus one quadrants ndarray comes out at four, because each torch tensor object becomes its own key. Even with the static check relaxed, that path would recompile the kernel once per tensor instance.
There is also no conversion route out of this. from_torch copies into an already allocated quadrants ndarray, and dlpack support is export only (to_dlpack), with no import side, so a torch tensor cannot be adopted as an Ndarray and then routed through the qd.Tensor slot.
So the mechanism described above covers exactly one of the two argument families that can carry a tensor, and callers holding external arrays are left with the duplicate the kernel or pay for the no-op arithmetic choice.
Why this is not satisfactory
Everything above is reached by working around the type system rather than through it.
| gap |
current behaviour |
reference |
qd.static(x is not None) |
rejected with Operator "is not" in Quadrants scope is not supported. Is and IsNot are refused before the static-scope check, so the natural predicate cannot be written at all |
python/quadrants/lang/ast/ast_transformer.py:945 |
| the predicate one has to write instead |
qd.static(x), which tests truthiness rather than presence |
|
bias: qd.Tensor | None |
rejected with Invalid type annotation (argument 1) of Quadrants kernel |
python/quadrants/lang/_func_base.py:281 |
bias: qd.Tensor = None |
rejected with Quadrants kernels do not support default values for arguments, so every call site has to pass None explicitly or wrap the kernel in a thin Python function |
python/quadrants/lang/_func_base.py:235 |
| test and documentation coverage |
none. No test passes None into a qd.Tensor or qd.template() slot, and neither static.md nor tensor_types.md mentions the behaviour, so it can regress without anything failing |
|
The truthiness fallback is also a trap waiting to be sprung. It is safe for the tensor families, since Ndarray, Field and Tensor define neither __bool__ nor __len__ and a live instance is therefore always truthy, but the same idiom is silently wrong for an integer template argument equal to zero or for an empty container. A reader has no way to tell which case they are looking at.
Finally, the union spelling is exactly the one a type checker wants. qd.Tensor | None is a valid type expression, so accepting it also removes an annotation form that downstream projects currently cannot write, which is the same class of problem as #831.
Describe the solution you'd like
- Allow
Is and IsNot inside a static scope by moving them next to In and NotIn in ops_static in build_Compare, and keep rejecting them in quadrants scope. This alone makes qd.static(x is not None) express the intent, instead of the truthiness workaround.
- Accept
T | None and Optional[T] as kernel parameter annotations for the non-template argument families, that is qd.Tensor and qd.types.NDArray, and route the None value through the template path so the argument specializes on presence. qd.types.NDArray | None matters most here, since it is the only annotation that accepts an external array and it is the one case with no workaround at all today. qd.Template can be accepted in the same form for uniformity, although a template slot already handles None.
- Allow
= None as a default for a parameter whose annotation admits None, so an unset argument can be omitted at the call site.
- Add tests pinning the specialization count and the absent branch never being traced, plus a short section in
static.md, so this becomes a supported contract rather than behaviour that happens to work.
Items 1 and 3 are small and independent of each other. Item 2 is the one that makes the kernel signature read the way it should.
Additional comments
Verified against quadrants 1.1.4 at commit 47e9dfb5, arch=arm64 CPU, with the offline cache disabled. The offline cache path with a None argument slot has not been exercised.
Concisely describe the proposed feature
Quadrants should support optional non-template kernel arguments spelled
T | None, defaulting toNonewhen unset, and should specialize the compiled kernel on presence versus absence so that the unused code path is never traced. Concretely, this should be valid and supported:The motivating case is the application point of a force applied to a geometry. The point can be left unspecified (the center of mass is used), given as an absolute position, or given as an offset relative to the center of mass. Each variant wants a different arithmetic path, and the unspecified variant wants no arithmetic at all rather than adding zero or multiplying by one. Today the choice is between duplicating the kernel once per variant, which multiplies the maintenance burden and lets the copies drift, or keeping a single kernel that pays for arithmetic it does not need in the common case.
The real-world instance of this is Genesis-Embodied-AI/genesis-world#3143, which adds an application point to the external force API and hits exactly this trade-off in the kernel behind it.
What works today
The underlying mechanism already exists, but it is not spellable the way the code above reads, and it is not covered by any test.
A
qd.template()slot acceptsNoneand specializes on it, since a template argument is part of the specialization key by construction. That is not a usable answer here: the same slot rejects an ndarray outright withNdarray shouldn't be passed in via 'qd.template()'(python/quadrants/lang/_template_mapper_hotpath.py:268), so an argument that is a tensor when present cannot go through it. The only route that accepts both forms today isqd.Tensor.qd.Tensoris a value-dispatched argument slot:_extract_argroutes ndarray-shaped values through the ndarray feature path and everything else through the template path (python/quadrants/lang/_template_mapper_hotpath.py:237), so aNoneargument lands in the specialization key.FunctionDefTransformer._decl_and_create_variablethen binds the kernel-scope name to the literal PythonNone(python/quadrants/lang/ast/ast_transformers/function_def_transformer.py:82), andqd.staticfolds the branch at AST build time.So the following runs correctly on CPU at
47e9dfb5:Exactly two specializations, correct results on every call, and no thrashing when alternating between the two forms. The absent branch is genuinely never traced rather than eliminated later: replacing its body with
bias.no_such_attribute[i]still compiles and runs whenNoneis passed.The workaround does not extend to external arrays
An argument that has to accept a torch tensor when present cannot use any of this. The two annotations that could carry such an argument fail on opposite ends, and there is no third option covering both:
Nonetorch.Tensorqd.types.ndarray(dtype=qd.f32, ndim=1)QuadrantsRuntimeTypeError: Invalid type for argument bias, got Noneqd.TensorValueError: Input to qd.static must be compile-time constants or global pointers, instead of <class 'torch.Tensor'>A torch tensor reaches an
qd.types.ndarray()slot through the external arrays path, which readsshape = getattr(arg, "shape", None)and raises as soon as that comes backNone(python/quadrants/lang/_template_mapper_hotpath.py:335-338).Noneis therefore indistinguishable from any other invalid argument at that point and never reaches the specialization key.Conversely
torch.Tensoris neither anNdarraynor anAnyArray, so aqd.Tensorslot falls through to the template path and binds the raw torch object as a template value, whichqd.staticthen refuses (python/quadrants/lang/impl.py:1331). A second problem sits behind that error: the specialization count forNoneplus two distinct torch tensors plus one quadrants ndarray comes out at four, because each torch tensor object becomes its own key. Even with the static check relaxed, that path would recompile the kernel once per tensor instance.There is also no conversion route out of this.
from_torchcopies into an already allocated quadrants ndarray, and dlpack support is export only (to_dlpack), with no import side, so a torch tensor cannot be adopted as anNdarrayand then routed through theqd.Tensorslot.So the mechanism described above covers exactly one of the two argument families that can carry a tensor, and callers holding external arrays are left with the duplicate the kernel or pay for the no-op arithmetic choice.
Why this is not satisfactory
Everything above is reached by working around the type system rather than through it.
qd.static(x is not None)Operator "is not" in Quadrants scope is not supported.IsandIsNotare refused before the static-scope check, so the natural predicate cannot be written at allpython/quadrants/lang/ast/ast_transformer.py:945qd.static(x), which tests truthiness rather than presencebias: qd.Tensor | NoneInvalid type annotation (argument 1) of Quadrants kernelpython/quadrants/lang/_func_base.py:281bias: qd.Tensor = NoneQuadrants kernels do not support default values for arguments, so every call site has to passNoneexplicitly or wrap the kernel in a thin Python functionpython/quadrants/lang/_func_base.py:235Noneinto aqd.Tensororqd.template()slot, and neitherstatic.mdnortensor_types.mdmentions the behaviour, so it can regress without anything failingThe truthiness fallback is also a trap waiting to be sprung. It is safe for the tensor families, since
Ndarray,FieldandTensordefine neither__bool__nor__len__and a live instance is therefore always truthy, but the same idiom is silently wrong for an integer template argument equal to zero or for an empty container. A reader has no way to tell which case they are looking at.Finally, the union spelling is exactly the one a type checker wants.
qd.Tensor | Noneis a valid type expression, so accepting it also removes an annotation form that downstream projects currently cannot write, which is the same class of problem as #831.Describe the solution you'd like
IsandIsNotinside a static scope by moving them next toInandNotIninops_staticinbuild_Compare, and keep rejecting them in quadrants scope. This alone makesqd.static(x is not None)express the intent, instead of the truthiness workaround.T | NoneandOptional[T]as kernel parameter annotations for the non-template argument families, that isqd.Tensorandqd.types.NDArray, and route theNonevalue through the template path so the argument specializes on presence.qd.types.NDArray | Nonematters most here, since it is the only annotation that accepts an external array and it is the one case with no workaround at all today.qd.Templatecan be accepted in the same form for uniformity, although a template slot already handlesNone.= Noneas a default for a parameter whose annotation admitsNone, so an unset argument can be omitted at the call site.static.md, so this becomes a supported contract rather than behaviour that happens to work.Items 1 and 3 are small and independent of each other. Item 2 is the one that makes the kernel signature read the way it should.
Additional comments
Verified against quadrants 1.1.4 at commit
47e9dfb5,arch=arm64CPU, with the offline cache disabled. The offline cache path with aNoneargument slot has not been exercised.