Support typing.Final[T] on dataclass fields as compile-time templates - #842
Support typing.Final[T] on dataclass fields as compile-time templates#842hughperkins wants to merge 53 commits into
Conversation
…plates Lets users mark selected fields of a plain frozen @dataclasses.dataclass config as ``typing.Final[T]`` to signal that the field's value must be baked as a compile-time constant in the compiled kernel - replacing the @qd.data_oriented + qd.Template pattern for static configs, and letting ``qd.static(config.field)`` work on plain dataclasses without opting the class into data_oriented machinery. Three coordinated code changes: 1. _dataclass_util.py: adds ``is_final_annotation`` / ``unwrap_final`` helpers backed by ``typing.get_origin``. 2. FunctionDefTransformer._transform_kernel_arg: for a Final field, binds the flat name (e.g. ``__qd_config__qd_dt``) to the actual Python value read off the dataclass instance passed to the kernel. Skips the runtime scalar-arg declaration so ``cook_dtype(Final[int])`` no longer fires. 3. _template_mapper_hotpath._extract_arg: for a Final field, folds the actual value directly into the spec key. Distinct Final values compile distinct kernels; identical values share. 4. _func_base._recursive_set_args (and ``_get_frozen_dc_plan``): skips Final fields at launch since they carry no runtime arg slot. Tests cover baseline (``qd.static(config.dt)``), recompilation on Final value change, mixed Final + non-Final fields, and Final scalar next to an ndarray field in the same dataclass. All 4 tests pass on x64 and cuda; existing 122 tests in test_py_dataclass.py still pass; test_ad_dataclass.py + test_kernel_impl_dataclass.py still pass. Scoped as prototype for PR-A. Not yet covered: Final on nested dataclass fields (currently the assertion at ``arg_value is not None`` should still hold via getattr recursion, worth an explicit test), @qd.func expansion path (``expand_func_arguments`` / ``FlattenAttributeNameTransformer``), bare ``typing.Final`` (raises), string annotations from ``from __future__ import annotations`` (unchanged - Quadrants already assumes resolved ``field.type``).
…dataclass Second follow-up commit for PR-A. Covers three additional scenarios that the first commit missed: 1. **``@qd.func`` propagation.** When a kernel body calls a ``@qd.func`` with a dataclass argument containing ``Final[T]`` fields, the call-site ``_expand_Call_dataclass_args`` expansion resolves each Final flat name via ``build_Name`` to a Python value (bound in the caller by the kernel-def step). But on the callee's side, ``expand_func_arguments`` produces an ``ArgMetadata(annotation=Final[T])`` and ``_transform_func_arg`` had no branch for it - falling through to ``impl.expr_init_func(data)`` which turned the value back into a runtime ``Expr`` and broke ``qd.static``. Added an early ``is_final_annotation`` check that binds ``data`` directly (mirroring the ``annotations.template`` branch). 2. **Kernel-caching correctness.** Added a regression test asserting that two ``SimConfig`` instances with the same Final-field value share a compiled kernel (``template_mapper.mapping`` stays at size 1), while a third launch with a different Final value grows it to 2. Guards against accidentally hashing the instance rather than the value. 3. **Nested dataclass with Final field.** Already worked thanks to the recursive ``_transform_kernel_arg`` call threading ``getattr(arg_value, field.name)``; added an explicit regression test (``Outer`` -> ``Inner.scale: Final[float]`` + ``Outer.bias: Final[float]``) to lock the behaviour in. Tests: all 14 new tests pass on x64 + cuda (7 tests x 2 archs). 166 total tests in test_py_dataclass.py + test_ad_dataclass.py + test_kernel_impl_dataclass.py still pass.
Addresses review decisions on PR-A. The significant item is a soundness fix. **Fastcache soundness bug (fixed).** ``args_hasher.dataclass_to_repr`` only appended a dataclass field's *value* to the cache key when the field carried ``FIELD_METADATA_CACHE_VALUE`` metadata. A ``Final[T]`` field is baked into the compiled kernel, so its value must be in the key too - otherwise a kernel compiled with ``offset=7`` baked in is loaded from the offline cache in a later process for a config carrying ``offset=100``, silently returning 7. The in-process template-mapper spec key already discriminated on the value, which is why the first round of tests passed. Repro'd, fixed, and covered by ``test_final_field_value_is_part_of_offline_fastcache_key`` (two ``qd.init`` cycles over one ``offline_cache_file_path``). **Validation, cached per dataclass type (decisions A, B, D, G).** Adds ``final_field_names(dc_type)``, which validates on first sighting and memoises the resulting ``frozenset`` of Final field names in ``_final_plan_cache``: - ``T`` in ``Final[T]`` must be ``bool`` / ``int`` / ``float`` / ``str`` or an ``enum.Enum`` subclass. Arrays, ``qd.dataclass`` structs, ``qd.Tensor``, nested dataclasses, ``qd.Template`` and arbitrary objects are rejected with a tailored remediation hint. (A - the type set is what the three Genesis static-config classes need; see below.) - A ``Final`` field on a class with ``__hash__ is None`` (plain non-frozen ``@dataclass``) is a hard error; ``frozen=True`` and ``unsafe_hash=True`` are accepted. (B) - A ``Final``-like special form that is not ``typing.Final``, and a string annotation containing "Final" (from ``from __future__ import annotations``), both raise rather than silently lowering the field as a runtime arg. (D) **Hot path pays nothing (decision E).** All reflection moved off the per-launch path: callers do one ``dict.get`` keyed on the dataclass type and, when the result is empty (every dataclass not using the feature), run the pre-existing code verbatim. No ``isinstance`` / ``typing.get_origin`` / ``dataclasses.fields`` per launch. Measured on ``_extract_arg`` with 12 fields, best of 9, 3 runs: - steady state (frozen dataclass, ``arg._key`` cache hit - the Genesis shape): -0.0 / -0.8 / +0.7 ns per call, i.e. zero within noise - cold walk on a non-hashable dataclass (walked every launch): +12.6 / +36.7 / +74.2 ns on a ~4900ns baseline, ~+0.8%, and not a shape Genesis uses - the Final branch itself is ~40ns *faster* per field than the non-Final walk, since it reads ``getattr`` directly instead of recursing into ``_extract_arg`` **Genesis survey (answers A).** The three affected classes in ``genesis/utils/array_class.py`` are ``RigidSimStaticConfig`` (36 fields), ``ColliderStaticConfig`` (6) and ``GJKStaticConfig`` (1) - every field is ``int`` or ``bool``, so the accepted type set covers them. Several ``int`` fields hold ``IntEnum`` members (``integrator``, ``constraint_solver``, ``PARA_LEVEL``, ``CCD_ALGORITHM_CODE``); validation is on the declared annotation so ``Final[int]`` holding an ``IntEnum`` is accepted with no per-launch type check. Covered by ``test_final_field_int_annotation_holding_intenum_value``. **Docs (decision H).** ``compound_types.md`` gains a "Compile-time constant fields: ``typing.Final``" section under ``dataclasses.dataclass``, plus overview table and "how to choose" entries pointing static-configuration objects at this pattern instead of ``@qd.data_oriented`` + ``qd.Template``. Tests: 24 Final tests (12 x 2 archs) pass; 176 total across test_py_dataclass.py + test_ad_dataclass.py + test_kernel_impl_dataclass.py, plus the template / data_oriented / cache suites. pre-commit -a clean; check_non_ascii and the 120c wrapping audit clean. Known Genesis-side migration blocker (not addressed here, Quadrants-only PR per decision F): the three classes use ``metaclass=AutoInitMeta``, explicitly a *mutable* dataclass, and ``tests/test_rigid_physics.py::test_cholesky_tiling`` mutates ``_static_rigid_sim_config`` after build. Production code never mutates them, so the migration needs that one test rewritten to build a fresh instance.
``_extract_arg`` calls ``final_field_names`` with its loosely-typed ``annotation`` parameter (an ``AnnotationType`` union covering every kernel-arg annotation shape), having already established that it is a dataclass type via the ``__dataclass_fields__`` probe immediately above. Pyright cannot follow that narrowing and reported ``"NdarrayType" is not assignable to "type"``. Widened the parameter to ``Any`` rather than narrowing at the call site: a ``typing.cast`` would be a real function call on a per-launch path, which is the thing this whole design is built to avoid. Documented the reasoning on the helper. pyright is now clean on all five files this PR touches. The 3 errors remaining in a local repo-wide run are all in untouched files (kernel.py, kernel_checkpoint.py) and come from a stale local ``quadrants_python.pyi``; CI copies a freshly built stub from ``dist/`` before running pyright.
Three genuine CI catches, all in prose I added. **Line wrapping** (two lines wrapped near the 80c AI default rather than 120c): - ``_dataclass_util.py`` module docstring wrapped at 76c - ``test_py_dataclass.py`` fastcache regression-test docstring wrapped at 80c Also tightened two further comment runs the same audit surfaced (``_dataclass_util.py`` typing_extensions note at 97c, ``_func_base.py`` plan comment at 101c). Every added Python line is now <= 120c with no mid-paragraph break that could have fit the next word. My local audit missed these because ``find_underwrapped.py`` reports the max width per *run*, which masks a single short line inside an otherwise well-packed block - exactly the shape CI flagged. Added a per-line checker (tokenize-based, so code lines are never mistaken for prose) and confirmed only two candidates remain, both correct style: a bare ``#`` paragraph separator and a trailing inline comment on a field declaration. **Doc quality**: ``compound_types.md`` used ``qd.Tensor`` in the new "Compile-time constant fields" restrictions list at line 188, but the term is not linked until line 271. Linked it at first use as ``[qd.Tensor](tensor.md)``. Verified: pyright clean on all five touched files, check_non_ascii clean, pre-commit -a clean, 138 tests pass in test_py_dataclass.py. Note on the previous run's other 12 red checks: 5 were "Set up job" runner-provisioning flakes, and 7 were jobs *cancelled* (not failed) when my re-runs superseded them mid-flight - the Linux build, for instance, completed build and install successfully before its test step was cancelled. This push supersedes all of it with one clean run.
CI status note for reviewersThere are red checks on this PR, but no genuine code failures. Every check that produced a real verdict on the current SHA (
Three checks never rendered a verdict:
Contributing causes, in order:
Two genuine failures did occur earlier in the PR's history and are fixed in
Verified locally on Happy to re-trigger the three outstanding checks once CI is calmer. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: cd5746ffd5
ℹ️ 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".
|
Agent wrote: Correction: the builds already passed on this SHAFollowing up on my previous note. The
Attempt 2 is fully green for Mac OS (4/4) and has zero failures for Manylinux (7 pass, 1 cancelled). The only genuine build failures anywhere are the 4 on Manylinux attempt 1, which were the GitHub Actions incident ( Attempts 3 and 4 are mine: I re-ran these workflows trying to clear stale red entries, and each re-run was cancelled while queued - overwriting a green attempt-2 result with cancellations. Since GitHub surfaces only the latest attempt in the checks list, that is why the PR displays red. My mistake, and I have stopped re-running. Net: zero genuine code failures on this SHA across every workflow. Every check that produced a verdict passed, and the builds are green on attempt 2. A reviewer wanting a clean board can re-run these two workflows once when runner capacity is healthy, or push any trivial commit to trigger one fresh full run. |
Two genuine CI findings on the post-merge SHA, both correct. **Test coverage check**: ``unwrap_final`` was a new public function with zero callers and zero tests - dead code. It was written early in the POC, but ``_validate_final_inner_type`` ended up calling ``typing.get_args`` directly because it also needs the arity checks (bare ``Final``, multiple type args) that ``unwrap_final`` does not provide. Deleted rather than retro-fitting a test for a function nothing uses. **Doc quality check**: ``compound_types.md`` used "in-process specialization key", Quadrants-internal jargon that appears nowhere else in the docs (including the linked ``fastcache.md``). Reworded to describe the behaviour in user terms - Quadrants picks which compiled kernel to reuse by looking at the field's value, in-process and in the on-disk fastcache. Also replaced "lower the field as a runtime argument" with plainer wording, since "lower" is compiler jargon that the same rule would likely flag next. Verified on the merged SHA: 24 Final tests pass, pyright clean, check_non_ascii clean, pre-commit clean, comment-wrapping audit clean.
The bullet restated the same fact three ways - that the value drives cache lookup, that changing it recompiles, and that equal values share a kernel are all one idea. Cut to the single sentence that carries it, matching the length of the surrounding bullets.
|
@codex review |
The class-level behavior scan only walked Enum-subclass bases, so a non-enum mixin (``class Mode(Labels, enum.Enum)`` with ``Labels.label``) was skipped: ``cfg.mode.label`` is observable at compile time yet absent from the Final key, so mutating the class var or two same-named factory mixins would reuse a stale kernel. Skip only baked base types (the mixed-in primitive / object / NumPy) and the library's own enum bases; inspect every user-authored base, enum or not. Verified plain Enum/IntEnum/StrEnum/IntFlag still accepted on 3.10/3.12/ 3.13. Adds a non-enum-mixin regression test.
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 926644f047
ℹ️ 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".
The class-behavior scan skipped every ``_x_`` name as enum bookkeeping, which also exempted user-authored sunder hooks (``_missing_``, ``_repr_html_`` on 3.13+, an overriding ``_numeric_repr_``) - observable behavior on the baked member that the module/name/value key cannot capture. Build an allowlist of machinery-generated names by probing a plain class and each framework enum kind with real class syntax at import (so it tracks the running Python, including 3.13's new ``__firstlineno__`` / ``__static_attributes__`` / ...), and reject any sunder/dunder in a user dict that is not in it. Verified plain Enum/IntEnum/StrEnum/IntFlag and custom str-mixin enums stay accepted on 3.10/3.12/3.13. Adds a ``_missing_`` regression test.
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 7a9e4d33f2
ℹ️ 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".
Codex: id(cls) in the offline (cross-process) key is only process-local and can repeat at the same address in another worker, so two same-named dynamic enum/primitive classes could serialize identical offline keys and one could load a kernel baked for the other's distinct class - contrary to the documented "dynamic classes don't reuse another process's cached kernel". Fold a per-process nonce into the offline id component for non-resolvable classes so such a key is unique to this process (guaranteed cross-process miss, never a wrong reuse); id(cls) still separates distinct dynamic classes within a process, and the in-process live key stays nonce-free. Also fix a pylint no-member on enum.StrEnum by resolving it via getattr. Adds a process-nonce regression test.
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 5304ae539b
ℹ️ 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".
Addresses two Codex P2s on the Final-field key path: - Live spec key: switching the class-identity component to bare id(cls) dropped the strong reference, so a dynamic factory class could be GC'd after launch and its address recycled by the next same-named class, colliding on the mapper key. Now key on (id(cls), cls): id keeps metaclass-== immunity while the retained class object pins the id for the specialization's lifetime. The offline key stays process-stable (nonce/None) and never holds the class object. - _final_plan_cache / _final_path_cache: keyed on id(type) with a stored strong ref and an identity (`is`) check, so a metaclass making two distinct dataclass types compare equal can no longer merge their (possibly different) Final schemas. Adds regression tests for live-key class retention and for identity-keyed plan lookup under metaclass-equal dataclass types.
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 2acf20ab7e
ℹ️ 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".
Codex P2: embedding the retained class object directly in the live key made the whole spec key unhashable when the class's metaclass sets __hash__ = None, so TemplateMapper.lookup's self.mapping[key] would raise TypeError instead of compiling the valid Final value. Wrap the strong class reference in a _ClassRef identity token that hashes via object.__hash__ and compares by `is`, never delegating to the metaclass. It still pins the class (so its id can't be recycled) while keeping the key hashable and immune to a custom metaclass __eq__. Adds a regression test with a __hash__ = None metaclass asserting the live key is hashable, usable as a dict key, and keeps distinct classes apart.
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: ef35d94d84
ℹ️ 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".
…y type nature Two Codex P2s on the Final-field path: - _first_final_path's recursion guard tracked visited dataclass types in a set keyed by equality. A metaclass making a nested inner type compare equal to its mutable outer type made the inner look "already visited", short-circuiting the walk so a mutable ancestor of a Final leaf slipped past rejection. Track visited types by id() (stable: every type on the path is held alive by the walk). - _is_baked_base_type identified NumPy scalar bases from the mutable __module__ string, so a user subclass spoofing __module__ = "numpy" was treated as a trusted base (its state/behavior went uninspected and its class-identity token was dropped). Identify a genuine NumPy base by inheritance + type nature instead: issubclass(np.generic) and no Py_TPFLAGS_HEAPTYPE (NumPy's scalar types are C static types; a user subclass is always a heap type). Adds regression tests for both (metaclass-equal nested dataclass; int subclass with observable __eq__ spoofing __module__).
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 6f7c649bb0
ℹ️ 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".
…uous-parens - Codex P2: an enum with a custom EnumMeta subclass can carry observable class-level state/behavior (e.g. type(Mode).label, read as cfg.mode.__class__.label) that the key does not capture, since the metaclass is not on enum_cls.__mro__. _enum_class_behavior_attr now also walks the enum's metaclass MRO, inspecting user-authored metaclass layers (the framework EnumMeta/EnumType/type/object are skipped, so plain enums are unaffected). Extracted the per-class inspection into _observable_class_dict_attr and reused it for both walks. - Linters: fix pylint C0325 (superfluous-parens after 'not') introduced in the previous _is_baked_base_type change. Adds a regression test for an enum with observable metaclass state (rejected) alongside plain/Int enums (accepted).
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 684d795ebc
ℹ️ 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".
Codex P2: _reject_stateful_primitive_subclass only walked the subclass MRO, so observable state on a custom metaclass (e.g. UnitMeta.label, read as cfg.x.__class__.label) was missed - the value was accepted, and mutating that metaclass attribute reused a stale specialization. Now walk the subclass's metaclass MRO too, mirroring the enum validation. Shared _observable_metaclass_attr powers both the primitive and enum metaclass walks. It exempts the identity dunders __eq__/__ne__/__hash__ (the class-identity key uses id/is/object.__hash__ via _ClassRef, so a metaclass overriding those can neither collapse distinct classes nor make the key unhashable - the two behaviors earlier tests rely on) and rejects any other observable metaclass attribute the fixed key cannot capture. Adds a regression test: a metaclass class var (label) is rejected; a metaclass overriding only __eq__/__hash__ stays accepted.
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 778a33761e
ℹ️ 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".
…ass equality Two P2 follow-ups on the Final-field key mechanism: - The per-process nonce folded into a dynamic class's offline fastcache key was minted once at import, so a ``fork``ed child inherited both the string and the parent's allocator state - two workers could serialize identical offline keys for distinct same-qualified dynamic classes. Reseed it via ``os.register_at_fork(after_in_child=...)`` (``uuid4`` re-reads OS entropy, so each child differs); ``spawn`` re-imports and reseeds anyway. - Drop the ``__eq__``/``__ne__``/``__hash__`` exemption when inspecting a custom metaclass on a Final enum / primitive subclass. Identity-safe keying (via ``_ClassRef``) prevents dict collisions but the operator is still observable through ``qd.static(cfg.x.__class__ == Expected)``, and mutating the state it consults leaves the fixed key stale. Such metaclasses are now rejected; the ``_ClassRef`` identity hashing remains as defense-in-depth (and for reloads).
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 3d5f642f6d
ℹ️ 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".
| # this early return hand back the user's value instead of the real spec key. One limitation is that | ||
| # storing ``_qd_spec_key`` is impossible for dataclasses enforcing 'slots=True', but that is not | ||
| # the default and almost never used in practice. | ||
| return arg._qd_spec_key |
There was a problem hiding this comment.
Revalidate cached keys after enum class mutation
When the same frozen config is relaunched after its previously plain enum class is monkey-patched, this early return bypasses _reject_stateful_enum_member() and reuses the old specialization. For example, a kernel that bakes qd.static(cfg.mode == 1) can compile with inherited enum equality, then Mode.__eq__ can be replaced before the next launch; the cached key still selects the branch compiled under the old behavior instead of rejecting the now-behaviorful enum. Fresh evidence after the earlier class-behavior fixes is that Python permits adding such an override after validation, while validation only occurs below this cached return; avoid caching validation-sensitive enum/subclass keys or revalidate them before returning.
Useful? React with 👍 / 👎.
|
|
||
| A `dataclasses.dataclass` is a Python-only container. The compiler reads it at compile time and flattens its members into individual kernel parameters — the container itself has no memory layout and doesn't exist on the kernel side. Inside a kernel, tensor members are read-write through indexing (`s.x[i] = ...`), but the member *binding* itself (`s.x = other_tensor`) cannot be reassigned from inside a kernel. | ||
|
|
||
| **Reserved field names (`_qd_` prefix).** To make repeated launches fast, Quadrants stores small internal caches directly on the dataclass instances you pass to a kernel - for example the key that selects which compiled kernel to reuse. These attributes always begin with `_qd_`, so that prefix is reserved: do not give a dataclass field a name starting with `_qd_`. Such a field would shadow Quadrants' internal state, which can make a kernel silently reuse a value from an earlier launch. Any other name (including a leading underscore, like `_key`) is fine. |
There was a problem hiding this comment.
this seems way too much detail. can prbablly replace all of "To make repeated launches fast, Quadrants stores small internal caches directly on the dataclass instances you pass to a kernel - for example the key that selects which compiled kernel to reuse. These attributes always begin with _qd_, so that prefix is reserved: do not give a dataclass field a name starting with _qd_. Such a field would shadow Quadrants' internal state, which can make a kernel silently reuse a value from an earlier launch. Any other name (including a leading underscore, like _key) is fine." With "Created fields with names beginning with _qd_ is unsupported and will give undefined behavior."
Summary
Adds
typing.Final[T]as a per-field annotation on frozen@dataclasses.dataclasskernel arguments, marking the field's value as a compile-time constant baked into the compiled kernel. This makesqd.static(config.field)and compile-time branch elimination work on a plain dataclass, with no need to opt the class into@qd.data_oriented.The motivating use case is static configuration objects: bags of flags and sizes fixed once at setup time and used to specialize kernels. Today the only way to get compile-time member reads on such a config is to declare it
@qd.data_orientedand pass it viaqd.Template, which opts the whole class into the data-oriented machinery and its per-instance handling inTemplateMapper.lookup.Final[T]gives the same compile-time semantics per field, on an ordinary frozen dataclass.Example
Previously this required:
Semantics
For each
Final[T]field on a dataclass kernel argument:config.fieldinside a kernel body (or inside a@qd.funccalled from one) resolves at AST-build time to the field's actual Python value.Rejected with actionable errors:
__hash__ is None)frozen=Trueandunsafe_hash=Trueare acceptedFinal[T]whereTis notbool/int/float/str/ anenum.EnumsubclassTmust be meaningful as a compile-time literal and hash andreprby value, stably across processesFinal[<nested dataclass>]FinalinsteadFinal[NdarrayType],Final[qd.Tensor],Final[MatrixType],Final[StructType]Final[qd.Template]Final(from __future__ import annotations)Finalthrough an unresolved string and would otherwise silently treat the field as a runtime argumentFinal-like special form that is nottyping.Finaltyping.Finalonly;typing_extensions.Finalaliases it on all supported Python versions and is accepted transparentlyImplementation
_dataclass_util.py-is_final_annotation, plusfinal_field_names(dc_type), which validates a dataclass on first sighting and memoises the resultingfrozensetof Final field names in_final_plan_cache.FunctionDefTransformer._transform_kernel_arg- binds each Final field's flattened name (__qd_config__qd_dt) to the actual Python value read off the instance, and skips the runtime scalar-arg declaration.FunctionDefTransformer._transform_func_arg- mirrors that binding for@qd.funcarguments whose flattened annotation isFinal[T], so a func called from a kernel body sees the baked value rather than anExpr._template_mapper_hotpath._extract_arg- folds Final values into the specialization key._fast_caching/args_hasher.dataclass_to_repr- folds Final values into the offline fastcache key._func_base._get_frozen_dc_plan- excludes Final fields from the launch plan.Performance
All reflection happens once per dataclass type, never per launch. Callers on the hot path do a single
dict.getkeyed on the dataclass type; when the result is empty - every dataclass that does not use the feature, i.e. all existing code - they run the pre-existing code path verbatim. Noisinstance,typing.get_originordataclasses.fieldscall occurs per launch.Measured on
_extract_argwith 12 fields, best of 9, across 3 independent runs:arg._keycache hit4900 ns baseline (+0.8%)getattrinstead of recursing into_extract_arg)The steady-state row is the shape that matters for existing workloads: a frozen dataclass whose extracted key is cached on the instance. It is unaffected.
Tests
12 tests in
tests/python/test_py_dataclass.py(24 parametrisations across x64 and cuda):test_final_field_bakes_as_compile_time_constant_via_qd_staticqd.staticon a Final field compiles and computes correctlytest_final_field_value_change_triggers_recompilationtest_final_field_identical_values_share_compiled_kerneltest_final_field_value_is_part_of_offline_fastcache_keyqd.initcyclestest_final_and_non_final_fields_mixtest_final_field_with_ndarray_siblingtest_final_field_on_nested_dataclasstest_final_field_propagates_through_qd_func_call@qd.funcsees the baked valuetest_final_field_int_annotation_holding_intenum_valueFinal[int]holding anIntEnummembertest_final_field_on_non_frozen_dataclass_is_rejectedunsafe_hash=Trueacceptedtest_final_field_rejects_non_bakeable_inner_typesT, with its remediation hinttest_final_field_string_annotation_is_rejectedExisting suites verified green on x64 and cuda:
test_py_dataclass.py,test_ad_dataclass.py,test_kernel_impl_dataclass.py, plus the template,data_orientedand cache suites.Docs
docs/source/user_guide/compound_types.mdgains a "Compile-time constant fields:typing.Final" section underdataclasses.dataclass, covering the semantics and restrictions above. The overview table and the "How to choose a compound type?" list both point static-configuration objects at this pattern.Follow-up
A separate Genesis change can migrate the static-config classes currently using
@qd.data_oriented+qd.Template(RigidSimStaticConfig,ColliderStaticConfig,GJKStaticConfigingenesis/utils/array_class.py) to frozen dataclasses withFinal[T]fields. Every field on those three classes isintorbool, so they are covered by the accepted type set; severalint-annotated fields holdIntEnummembers, which is supported. Those classes currently usemetaclass=AutoInitMeta, which is explicitly a mutable dataclass, and one test (test_rigid_physics.py::test_cholesky_tiling) mutates_static_rigid_sim_configafter build, so that test needs to construct a fresh instance instead. Production code never mutates them.