Order free indices by creation, not by memory address - #291
Merged
Merged
Conversation
pbrubeck
force-pushed
the
pbrubeck/gem-deterministic-index-order
branch
2 times, most recently
from
September 6, 2026 16:34
2e84a89 to
6644978
Compare
gem.unique sorted free indices with key=id, and Index.__lt__ compared addresses. Every GEM node routes its free_indices through unique, so the index order of a whole expression tree followed the addresses CPython happened to hand out, and replace_indices_indexed sorts substitution pairs on the same comparison. Two processes compiling the same form could lay the same loop nest out differently and emit different code. Under MPI that difference deadlocks Firedrake. The generated code feeds LocalKernel._immutable_cache_key, which feeds GlobalKernel.cache_key, so ranks that laid a nest out differently disagree about the key for PyOP2's in-memory compile_global_kernel cache. PyOP2 reconciles a hit/miss disagreement for disk caches but assumes in-memory caches always agree, so the ranks that miss descend alone into the disk cache's collective broadcast and wait there for the ranks that hit and moved on. Index.count is assigned in creation order and is unique within a process, so ordering on it gives every process the same order. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
pbrubeck
force-pushed
the
pbrubeck/gem-deterministic-index-order
branch
from
September 6, 2026 16:40
6644978 to
30f1eac
Compare
connorjward
approved these changes
Sep 7, 2026
connorjward
left a comment
There was a problem hiding this comment.
@JHopeCollins this may be the cause of that code generation issue you had with asQ all that time ago.
| def __lt__(self, other): | ||
| # Allow sorting of free indices in Python 3 | ||
| return id(self) < id(other) | ||
| return self.count < other.count |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
gem.uniquesorted free indices withkey=id, andIndex.__lt__comparedaddresses. Every GEM node routes its
free_indicesthroughunique(
NodeMeta.__call__), so the index order of a whole expression tree followedthe addresses CPython happened to hand out. Two processes compiling the same
form could lay the same loop nest out differently and emit different code.
Under MPI that difference deadlocks Firedrake. The generated code feeds
LocalKernel._immutable_cache_key, which feedsGlobalKernel.cache_key, soranks that laid a nest out differently disagree about the key for PyOP2's
in-memory
compile_global_kernelcache. PyOP2 reconciles a hit/missdisagreement for disk caches but assumes in-memory caches always agree, so the
ranks that miss descend alone into the disk cache's collective broadcast and
wait there for the ranks that hit and moved on. A 32-rank Firedrake multigrid
run on a cold cache hung there; with this branch it completes.
There are two sites, not one.
uniqueis the obvious one. The other isreplace_indices_indexedingem/optimise.py, which sorts substitution pairskeyed on an
Indexand so ordered them by address throughIndex.__lt__.Index.countis assigned in creation order and is unique within a process, soordering on it gives every process the same order.
What is still not deterministic
This makes the ordering of indices reproducible across processes. It does not
make GEM's hashing reproducible, and that distinction is worth stating plainly
because the two are easy to conflate.
Index.__hash__is still inherited fromobject, so it derives from theaddress. Any set or dict of indices that is iterated without sorting therefore
still iterates in a process-dependent order. The same is true more widely:
VariableIndex.__hash__ishash((type(self), self.expression))and everyexpression node hashes through
Node.get_hash, andhash(type)is itselfidentity-based.
Keying
Index.__hash__oncountwas tried and deliberately left out, for tworeasons. It breaks
test_macro_multigrid_biharmonic[HCT]and[HCT-red]inFiredrake, which start failing an internal TSFC assertion on leftover free
indices. And it would not buy determinism in general anyway:
hashof astris randomised per process, so neither
hash((type(self), count))norhash((type(self).__name__, count))is stable across processes, and only abare
hash(count)is. UFL has the same hole —Index.__hash__there ishash(("Index", self._count)), which differs between processes for the samecount — though UFL is immune to the bug fixed here, because its equality and
ordering are count-based rather than address-based.
So this branch closes the ordering hole that was causing a hang, and leaves the
hashing question open.
Testing
test/gem/test_index.pyis new; both tests fail onmainand pass here.make srclintis clean, and the FIAT, FInAT and GEM suites pass.On the Firedrake side,
tests/firedrake/{tsfc,multigrid,macro}was run pairedagainst
main's GEM — both variants warmed, separate caches, failures comparedby name — and the failure sets are identical, so nothing here regresses.
Assisted by Claude Code (Claude Opus 5).