Skip to content

Sort module attributes when flattening - #100

Draft
cemde wants to merge 1 commit into
liukidar:mainfrom
cemde:fix/76-treedef-order
Draft

Sort module attributes when flattening#100
cemde wants to merge 1 commit into
liukidar:mainfrom
cemde:fix/76-treedef-order

Conversation

@cemde

@cemde cemde commented Aug 9, 2026

Copy link
Copy Markdown
Collaborator

Bug

return tuple(module.__dict__.values()), tuple(module.__dict__.keys())

BaseModule flattened on __dict__ insertion order, which is the order a constructor happened to assign attributes in.

Impact

Two instances of the same class built by different code paths get different treedefs:

class M(pxc.Module):
    def __init__(self, swap):
        if swap: self.b, self.a = Param(1.0), Param(2.0)
        else:    self.a, self.b = Param(2.0), Param(1.0)

jtu.tree_structure(M(False)) == jtu.tree_structure(M(True))   # False

They cannot be tree_maped together, and passing one then the other to a jitted function forces a recompile. A module is documented as flattening "as if it were a dictionary", and jax's own dict flattening is deliberately key-order-insensitive for exactly this reason.

Fix

Sort the keys in both flatten variants. unflatten_module rebuilds __dict__ by zipping aux keys with children, so it consumes whatever order flatten emits and stays consistent automatically.

Why a test changed

test_extract_yields_one_entry_per_dynamic_param_in_traversal_order asserted that extracted[0] was w, the first-assigned attribute. Its docstring states the contract as "extraction has to be deterministic and skip statics"; which deterministic order is precisely what this PR changes, so the positional literals recorded incidental behaviour rather than the contract. No order normalisation of any kind could preserve them. The two expected values are swapped and the docstring now says why.

Verified

A full two-moons run is bit-identical to main across per-batch energies, test accuracy and every weight elementwise. tree_ref/tree_unref still restores object identity for an aliased param, and a jitted function called with a wb-ordered and a bw-ordered instance now traces once instead of twice. Old checkpoints still load, since keys are jtu.keystr attribute paths rather than positions.

Reordering a float32 reduce can move the last bits of a sum. It does not on the tutorial, whose module attributes are already alphabetical and whose Vodes live in a list, but a constructed worst case shifts the total energy by 5e-6 relative. Inherent to any order normalisation.

Closes #76

@cemde
cemde force-pushed the fix/76-treedef-order branch from c5d78d3 to 854e696 Compare August 9, 2026 14:41
@cemde
cemde marked this pull request as draft August 9, 2026 14:49
BaseModule flattened on __dict__ insertion order, so two instances of the
same class whose constructor assigned attributes in a different order got
different treedefs: they could not be tree_mapped together and forced a jit
recompile. Sort the keys, as jax does for dictionaries.

Traversal order is now alphabetical rather than by assignment, so the
expected sequence in test_extract_yields_one_entry_per_dynamic_param_in_
traversal_order is updated to match.
@cemde
cemde force-pushed the fix/76-treedef-order branch from 854e696 to 07fe496 Compare August 9, 2026 18:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Module treedefs depend on attribute assignment order

1 participant