Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
2b1509c
[lang] data_oriented: add template_primitives=False decorator option
hughperkins Jun 27, 2026
9f9dd1a
[lang] data_oriented: lift template_primitives=False primitives to ru…
hughperkins Jun 27, 2026
a84d13d
[test] data_oriented: tests for template_primitives=False runtime pri…
hughperkins Jun 27, 2026
c056393
[doc] data_oriented: document template_primitives=False runtime primi…
hughperkins Jun 27, 2026
1ccdd49
[lang] data_oriented: key template_primitives=False primitives by typ…
hughperkins Jun 27, 2026
79aa25b
[lang] data_oriented: avoid per-launch overhead for non-lifted kernels
hughperkins Jun 27, 2026
24e28a4
Merge remote-tracking branch 'origin/main' into hp/data-oriented-temp…
hughperkins Jul 17, 2026
ee7076a
[doc] data_oriented: replace em-dashes with ASCII hyphens (non-ascii …
hughperkins Jul 17, 2026
9846cfa
[Data oriented] Fix PR doc-quality checks: unwrap md, drop IR jargon,…
hughperkins Jul 17, 2026
b3103c8
[Data oriented] doc quality: define default int/float via qd.init(), …
hughperkins Jul 17, 2026
0d76c0c
Merge branch 'main' into hp/data-oriented-template-primitives
hughperkins Jul 17, 2026
16b981a
[Data oriented] doc: clarify lifted primitives are Python-mutable, re…
hughperkins Jul 17, 2026
c6478e4
[Data oriented] factor out predeclare_struct_primitives into its own …
hughperkins Jul 17, 2026
9c89e6a
[Data oriented] drop redundant __future__ annotations import (pylint …
hughperkins Jul 17, 2026
1baa637
[doc] fastcache: replace compiler 'front-end' jargon with plain langu…
hughperkins Jul 17, 2026
f5252e0
[Data oriented] doc: lifted primitive dtype is fixed at first compile…
hughperkins Jul 20, 2026
60ac76c
[Data oriented] reject float bound to int-lifted primitive (avoid sil…
hughperkins Jul 20, 2026
0b38a86
[Data oriented] test: cover int->float mutation guard + lossless coer…
hughperkins Jul 20, 2026
52eae81
[doc] fastcache: clarify data_oriented/template/dataclass member acce…
hughperkins Jul 20, 2026
5e4a8f0
[doc] fastcache: scope capture clarification to data_oriented/datacla…
hughperkins Jul 20, 2026
3123616
[doc] fastcache: split capture clarification into data_oriented vs da…
hughperkins Jul 20, 2026
683ecaf
[doc] fastcache: simplify capture note to data_oriented; add Advanced…
hughperkins Jul 20, 2026
2c341b7
[doc] fastcache: add dataclass case to capture note (typed-param fiel…
hughperkins Jul 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 32 additions & 1 deletion docs/source/user_guide/compound_types.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,37 @@ Simulation(100).step() # compiles kernel #1 with n=100 baked in
Simulation(200).step() # compiles kernel #2 with n=200 baked in
```

#### Runtime primitives: `template_primitives=False`

If you want to change a primitive member's value **from Python between launches without triggering a recompile**, decorate the class with `@qd.data_oriented(template_primitives=False)`. Every primitive member the kernel actually accesses (`int`, `float`, `bool`, including those reached through nested `dataclasses.dataclass` / `@qd.data_oriented` members) is then lifted into a runtime scalar kernel argument and read fresh on every launch, instead of being compiled into the kernel as a constant. The member stays read-only inside the kernel (it is a kernel argument, not a writable variable): you mutate it in Python, and the kernel sees the new value on the next launch.

```python
@qd.data_oriented(template_primitives=False)
class Simulation:
def __init__(self):
self.n = 100
self.x = qd.ndarray(qd.f32, shape=(256,))

@qd.kernel
def step(self):
for i in range(self.n):
self.x[i] += 1.0

sim = Simulation()
sim.step() # compiles once; n is a runtime kernel argument
sim.n = 200 # no recompilation
sim.step() # the new value of n takes effect immediately
```

Notes and restrictions:

- **dtype** follows the runtime defaults - an `int` / `bool` member becomes the default integer type (`qd.i32`, unless you override the runtime default integer type in `qd.init()`) and a `float` member becomes the default float type (`qd.f32`, unless you override the runtime default float type in `qd.init()`). A member whose value falls outside the default integer range will overflow where a baked literal would not; if you need exact wide-integer constants, keep the default (baked) behaviour.
- **dtype is fixed at first compile**: the kernel-argument dtype is chosen from the member's Python type the first time the kernel compiles, and is *not* re-specialised if you later reassign the member to a different type. The live value is coerced to that dtype on every launch, so binding a `float` to a member that was first seen as an `int` truncates it (exactly as passing a `float` to an `int`-typed kernel argument would). Keep a lifted member's type stable across launches; if the type itself must vary, use the default (baked) behaviour, which re-specialises the kernel per type.
- **Pruning**: only the primitives the kernel actually reads are turned into kernel arguments, so a class with many primitive members does not blow up the kernel argument count.
- **`qd.static` is an error**: a lifted primitive cannot be used inside [`qd.static(...)`](static.md), because that context requires a compile-time constant. Doing so raises `QuadrantsSyntaxError`. Use the default `template_primitives=True` for values that must be baked (e.g. unrolled loop bounds).
- **No re-specialisation on value change**: because the value is a runtime argument, mutating it never triggers a recompile. Distinct instances of the class still compile separately (the kernel is keyed per instance), exactly as with the default.
- This is **opt-in**: the default `@qd.data_oriented` continues to bake primitive members as shown above.

### Tensor members

`@qd.data_oriented` classes may hold tensor members of any backend: `qd.field`, `qd.ndarray`, or [qd.Tensor](tensor.md).
Expand Down Expand Up @@ -217,7 +248,7 @@ state.step()

### Under the hood

Like `dataclasses.dataclass`, a `@qd.data_oriented` object is Python-only the compiler flattens it into individual kernel parameters and the object itself has no kernel-side representation. Unlike `dataclasses.dataclass` it needs no member annotations: the compiler reads the live instance's attributes directly. Primitive members are baked into the kernel as constants, so each distinct primitive value compiles a new specialized kernel.
Like `dataclasses.dataclass`, a `@qd.data_oriented` object is Python-only - the compiler flattens it into individual kernel parameters and the object itself has no kernel-side representation. Unlike `dataclasses.dataclass` it needs no member annotations: the compiler reads the live instance's attributes directly. Primitive members are baked into the kernel as constants by default, so each distinct primitive value compiles a new specialized kernel - unless the class is decorated `@qd.data_oriented(template_primitives=False)`, in which case the accessed primitives become runtime kernel arguments (see [Runtime primitives](#runtime-primitives-template_primitivesfalse) above).

## qd.dataclass / qd.types.struct

Expand Down
22 changes: 17 additions & 5 deletions docs/source/user_guide/fastcache.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@

Fastcache reduces the time it takes to load cached kernels when a new Python process starts.

The standard [offline cache](init_options.md#offline_cache) already persists compiled kernels to disk. However, loading a cached kernel still requires some initial front-end parsing steps, which take non-negligible time. For applications with many kernels this front-end overhead alone can take several seconds.
The standard [offline cache](init_options.md#offline_cache) already persists compiled kernels to disk. However, loading a cached kernel still requires re-reading and re-analyzing the Python kernel source (parsing it and preparing it for the compiler) before the saved artifact can be matched and reused, which takes non-negligible time. For applications with many kernels this parsing overhead alone can take several seconds.

Fastcache bypasses that front-end work. It computes a cheap cache key from the kernel source text, argument types, and compiler config, and uses it to load the compiled artifact directly.
Fastcache bypasses that parsing and analysis work. It computes a cheap cache key from the kernel source text, argument types, and compiler config, and uses it to load the compiled artifact directly.

On a Genesis simulator benchmark (`single_franka_envs.py`, Ubuntu 24.04, NVIDIA 5090):

Expand Down Expand Up @@ -76,6 +76,10 @@ def good_kernel(a: qd.types.NDArray[qd.f32, 1]) -> None:

Sub-functions called by the kernel are also checked - they must not capture external state either.

Reaching data through the members of a [`@qd.data_oriented`](compound_types.md#qddata_oriented) parameter is **not** capture, even though the Python source (e.g. `self.x` inside a data-oriented method) reads like member access rather than a parameter. The object is itself an explicit kernel parameter, and the compiler resolves each accessed member at compile time - either passing it as a real kernel argument (e.g. a `qd.ndarray` member) or compiling it into the kernel (e.g. a primitive or `qd.field` member); see [Compound-type cache keying](#compound-type-cache-keying) for exactly how each member kind is treated and keyed. Either way the member is parameter-derived, not a free variable captured from the enclosing Python scope, which is what the purity check forbids.

The same holds for a [`dataclasses.dataclass`](compound_types.md#dataclassesdataclass) passed as a typed parameter: the compiler flattens each declared field into its own kernel parameter, so `params.dt` inside the kernel is a parameter access, not capture. By default a primitive field is passed as a runtime scalar argument - only its type enters the fastcache key, not its value (annotate the field with `FIELD_METADATA_CACHE_VALUE` to bake the value in instead) - and an ndarray field becomes a runtime external tensor. See [Compound-type cache keying](#compound-type-cache-keying) for the full keying rules.

**Exemptions:** The following may be accessed from the enclosing scope without violating purity:

| Allowed capture | Why |
Expand All @@ -98,7 +102,7 @@ Fastcache supports the following parameter types:
| `torch.Tensor` | Yes | dtype, ndim |
| `numpy.ndarray` | Yes | dtype, ndim |
| [`dataclasses.dataclass`](compound_types.md#dataclassesdataclass) | Yes | member types recursively; member values if annotated with `FIELD_METADATA_CACHE_VALUE` (see [Appendix - compound-type cache keying](#compound-type-cache-keying)) |
| [`@qd.data_oriented`](compound_types.md#qddata_oriented) objects | Yes | member types recursively; primitive member types and values baked into kernel (see [Appendix - compound-type cache keying](#compound-type-cache-keying)) |
| [`@qd.data_oriented`](compound_types.md#qddata_oriented) objects | Yes | member types recursively; primitive member types and values baked into kernel, unless declared `template_primitives=False`, in which case primitive members contribute their *type* only (see [Appendix - compound-type cache keying](#compound-type-cache-keying)) |
| `qd.Template` primitives (int, float, bool) | Yes | type and value (baked into kernel) |
| Non-template primitives (int, float, bool) | Yes | type only |
| `enum.Enum` | Yes | name and value |
Expand Down Expand Up @@ -145,6 +149,14 @@ print(obs.cache_stored) # True if the compiled kernel was stored to cach

On the first run you'll see `cache_stored=True` but `cache_loaded=False`. On the second run (after `qd.init`), `cache_loaded=True`.

### Why `qd.field` disables fastcache

Fields are allocated contiguously in a single global memory region, one after another. A compiled kernel bakes in the memory bindings (offsets into that region) of the fields it accesses. Because all fields share one layout, redimensioning *any* field - including one completely unrelated to the fields this kernel touches - shifts the offsets of every field allocated after it, so the bindings baked into an already-compiled kernel no longer point at the right memory.

Fastcache reuses a compiled kernel across separate Python processes, keyed only on the kernel source, argument types, and compiler config. That key cannot capture the global field layout, which depends on the sizes and allocation order of *all* fields created in the program - state that is external to this kernel and can differ from run to run. So a kernel loaded from cache in a later process could carry field bindings that no longer match the current layout, silently reading or writing the wrong memory.

For this reason a kernel that touches any `qd.field` - directly, or through a `@qd.data_oriented` or other compound-type member - can never use fastcache: fastcache is disabled for that call and the kernel falls back to normal compilation. If you need fastcache, use `qd.ndarray` instead: an ndarray is passed as a runtime parameter (a data pointer bound at launch), so no field-layout state is baked into the kernel.

## Appendix

### Compound-type cache keying
Expand All @@ -154,10 +166,10 @@ As part of generating the fastcache cache key, fastcache hashes each kernel para
**`@qd.data_oriented`:** each attribute in `vars(obj)` is hashed. For each child:

- `qd.ndarray` member - `(dtype, ndim, layout)` is included in the cache key. Element values are not.
- Primitive (`int` / `float` / `bool` / `enum.Enum`) member - value is baked into the kernel (same semantics as a `qd.Template` primitive). Two instances of the same class with different primitive member values get different cache entries.
- Primitive (`int` / `float` / `bool` / `enum.Enum`) member - value is baked into the kernel (same semantics as a `qd.Template` primitive). Two instances of the same class with different primitive member values get different cache entries. **Exception:** if the class is declared `@qd.data_oriented(template_primitives=False)`, primitive members are lifted to runtime scalar args rather than baked, so only their *type* contributes to the cache key - two instances with different primitive values share one cache entry and changing a value does not recompile.
- Nested `@qd.data_oriented` member - recurses.
- Nested `dataclasses.dataclass` member - recurses (with the dataclass rules below).
- `qd.field` member - fastcache is disabled for the entire kernel call. The kernel still runs via normal compilation; a warn-level log line is emitted.
- `qd.field` member - fastcache is disabled for the entire kernel call. The kernel still runs via normal compilation; a warn-level log line is emitted. See [Why `qd.field` disables fastcache](#why-qdfield-disables-fastcache) for the reason.

**`dataclasses.dataclass`:** each declared member is hashed. For each member, only the *type* is included in the cache key by default - **not** the value. To include a member's value, annotate it:

Expand Down
14 changes: 12 additions & 2 deletions python/quadrants/lang/_fast_caching/args_hasher.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
from ..field import ScalarField
from ..kernel_arguments import ArgMetadata
from ..matrix import MatrixField, MatrixNdarray, VectorNdarray
from ..util import is_data_oriented
from ..util import is_data_oriented, wants_runtime_primitives
from .hash_utils import hash_iterable_strings

_FIELD_TYPES = (ScalarField, MatrixField)
Expand All @@ -39,6 +39,12 @@

_DC_REPR_NONE = object()

# arg_meta used when walking the children of a ``@qd.data_oriented(template_primitives=False)`` object. Its annotation
# is non-Template (so primitive members contribute their *type* only, not their value, since they are lifted to runtime
# scalar args rather than baked into the kernel) and non-Tensor (so a stray ``qd.field`` child still triggers the
# warn-and-disable path, exactly as for a normal data_oriented object).
_NON_TEMPLATE_CHILD_META = ArgMetadata(None, "")


class FastcacheSkip(enum.Enum):
"""Why fastcache does not apply to this call."""
Expand Down Expand Up @@ -181,8 +187,12 @@ def stringify_obj_type(
_dict = _asdict()
except AttributeError:
_dict = obj.__dict__
# A normal @qd.data_oriented bakes primitive members into the kernel (value in the cache key); one declared
# with template_primitives=False lifts them to runtime args (type only - value must NOT enter the key, or it
# would recompile on every value change, defeating the feature). Decide per object, since the flag is per class.
child_meta = _NON_TEMPLATE_CHILD_META if wants_runtime_primitives(obj) else ArgMetadata(Template, "")
for k, v in _dict.items():
_child_repr = stringify_obj_type(raise_on_templated_floats, (*path, k), v, ArgMetadata(Template, ""))
_child_repr = stringify_obj_type(raise_on_templated_floats, (*path, k), v, child_meta)
if _child_repr is None:
if _should_warn:
_logging.warn(
Expand Down
9 changes: 9 additions & 0 deletions python/quadrants/lang/ast/ast_transformer.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
LoopStatus,
ReturnStatus,
get_decorator,
maybe_lifted_primitive,
)
from quadrants.lang.ast.ast_transformers.call_transformer import CallTransformer
from quadrants.lang.ast.ast_transformers.checkpoint_transformer import (
Expand Down Expand Up @@ -757,6 +758,10 @@ def build_Attribute(ctx: ASTTransformerFuncContext, node: ast.Attribute):
node.ptr = getattr(tensor_ops, node.attr)
setattr(node, "caller", node.value.ptr)
elif dataclasses.is_dataclass(node.value.ptr):
lifted = maybe_lifted_primitive(ctx, node.value.ptr, node.attr)
if lifted is not None:
node.ptr = lifted
return node.ptr
node.ptr = getattr(node.value.ptr, node.attr)
from quadrants._tensor_wrapper import ( # pylint: disable=C0415
Tensor as _TensorClass,
Expand All @@ -776,6 +781,10 @@ def build_Attribute(ctx: ASTTransformerFuncContext, node: ast.Attribute):
count, dtype, naming_fn = groups[node.attr]
node.ptr = _UnpackedVectorRef(node.value.ptr, node.attr, count, dtype, naming_fn)
return node.ptr
lifted = maybe_lifted_primitive(ctx, node.value.ptr, node.attr)
if lifted is not None:
node.ptr = lifted
return node.ptr
node.ptr = getattr(node.value.ptr, node.attr)
# ``qd.Tensor`` wrappers reached via attribute access on a ``@qd.data_oriented`` struct field at AST-build
# time. The IR layer downstream (``build_Subscript`` -> ``impl.subscript``) only knows about ``Ndarray`` /
Expand Down
50 changes: 50 additions & 0 deletions python/quadrants/lang/ast/ast_transformer_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,20 @@ def __init__(
self.pass_idx: int = pass_idx
self.ndarray_to_any_array: dict[int, Any] = {}
self.struct_ndarray_launch_info: list[tuple] = []
# Lifted-primitive support for ``@qd.data_oriented(template_primitives=False)``. Mirrors the two ndarray
# structures above, plus a provenance map driving the two-pass pruning.
# * ``struct_primitive_provenance`` maps ``(id(parent_obj), attr_name)`` to ``(flat_name, arg_idx, attr_chain,
# kind)`` for every reachable primitive member of a flagged template arg. Built (cheaply, no kernel-arg
# declaration) on every compilation pass. ``build_Attribute`` uses it to ``mark_used`` the accessed
# primitives during the non-enforcing discovery pass.
# * ``struct_primitive_to_expr`` maps the same key to the arg-load ``Expr``. Populated only in the enforcing
# pass, and only for primitives actually accessed by the body (pruning), so ``build_Attribute`` can return
# the runtime scalar arg instead of baking the value.
# * ``struct_primitive_launch_info`` records ``(arg_id, template_arg_idx, attr_chain, kind)`` tuples (``kind``
# in ``{'f', 'i', 'u'}``) so the launch path can read the live value and bind it.
self.struct_primitive_provenance: dict[tuple[int, str], tuple] = {}
self.struct_primitive_to_expr: dict[tuple[int, str], Any] = {}
self.struct_primitive_launch_info: list[tuple] = []
# Caller-side `loop_depth` snapshot for the in-flight `@qd.func` invocation. Each func compile creates a fresh
# `ASTTransformerFuncContext` with `loop_depth = 0`, so without this snapshot a non-static `range(...)` loop
# inside a func body would not see any outer for-loops in the caller and would skip the backward-mode dynamic-
Expand Down Expand Up @@ -447,3 +461,39 @@ def get_decorator(ctx: ASTTransformerFuncContext, node) -> str:
if ASTResolver.resolve_to(node.func, wanted, ctx.global_vars):
return name
return ""


def maybe_lifted_primitive(ctx: ASTTransformerFuncContext, parent: Any, attr: str):
"""Handle access to a primitive member that ``@qd.data_oriented(template_primitives=False)`` lifted into a
runtime scalar kernel arg (see ``predeclare_struct_primitives``).

Returns the arg-load ``Expr`` to substitute for the baked value in the enforcing pass; returns ``None`` when
the attribute is not a lifted primitive, or during the non-enforcing discovery pass (where the access is
instead recorded via ``mark_used`` and the caller falls back to baking the throw-away discovery-pass IR).

Raises ``QuadrantsSyntaxError`` if the lifted primitive is used inside ``qd.static(...)``: that context
requires a compile-time constant, which a runtime-lifted primitive is not. Under this flag there is no
per-attribute baked escape hatch, so a ``qd.static`` use is unambiguously a mistake and we surface it loudly
rather than silently baking a value that will not track mutations.
"""
provenance = ctx.global_context.struct_primitive_provenance
if not provenance:
return None
key = (id(parent), attr)
entry = provenance.get(key)
if entry is None:
return None
if ctx.is_in_static_scope():
raise QuadrantsSyntaxError(
f"'{attr}' is a primitive member of a @qd.data_oriented(template_primitives=False) object, so it is "
f"a runtime kernel argument and cannot be used inside qd.static(). Either remove qd.static(), or "
f"decorate the class @qd.data_oriented (the default, template_primitives=True) to bake '{attr}' as a "
f"compile-time constant."
)
pruning = ctx.global_context.pruning
if not pruning.enforcing:
# Record under the kernel's func id (see ``predeclare_struct_primitives``) so the enforcing pass declares
# this primitive and the existing fastcache serialisation captures it.
pruning.mark_used(pruning.KERNEL_FUNC_ID, entry[0])
return None
return ctx.global_context.struct_primitive_to_expr.get(key)
Loading
Loading