diff --git a/python/egglog/egraph_state.py b/python/egglog/egraph_state.py index 876466e1..726a681b 100644 --- a/python/egglog/egraph_state.py +++ b/python/egglog/egraph_state.py @@ -858,8 +858,12 @@ def _exprs_multiple_parents(typed_expr: TypedExprDecl) -> list[TypedExprDecl]: """ Returns all expressions that have multiple parents (a list but semantically just an ordered set). """ - to_traverse = {typed_expr} - traversed = set[TypedExprDecl]() + # Traverse with a deterministic LIFO worklist instead of a set of objects: + # popping from a set iterates in id()-hash order (memory addresses), which + # varies per process and made the hoisting order (and hence the $__expr_N + # let numbering and e-class ids) nondeterministic across runs. + to_traverse = [typed_expr] + traversed: set[TypedExprDecl] = set() traversed_twice = list[TypedExprDecl]() while to_traverse: typed_expr = to_traverse.pop() @@ -869,9 +873,9 @@ def _exprs_multiple_parents(typed_expr: TypedExprDecl) -> list[TypedExprDecl]: traversed.add(typed_expr) expr = typed_expr.expr if isinstance(expr, CallDecl): - to_traverse.update(expr.args) + to_traverse.extend(expr.args) elif isinstance(expr, PartialCallDecl): - to_traverse.update(expr.call.args) + to_traverse.extend(expr.call.args) return traversed_twice diff --git a/python/tests/test_deterministic_hoisting.py b/python/tests/test_deterministic_hoisting.py new file mode 100644 index 00000000..42e62aab --- /dev/null +++ b/python/tests/test_deterministic_hoisting.py @@ -0,0 +1,56 @@ +""" +Expression hoisting must be deterministic across processes. + +`_exprs_multiple_parents` used to traverse the expression DAG with a set of +``TypedExprDecl`` objects and ``.pop()`` from it, so the traversal order -- +and with it the order in which shared subterms were hoisted into +``$__expr_N`` let-bindings -- depended on id()-based hashing (memory +addresses). Identical programs were therefore lowered into different +(equivalent) e-graphs in different processes, making ``serialize()`` output +nondeterministic. +""" + +import subprocess +import sys + +SCRIPT = """ +from __future__ import annotations + +import hashlib + +from egglog import EGraph, Expr, StringLike + + +class B(Expr): + @classmethod + def var(cls, name: StringLike) -> B: ... + + def __and__(self, o: B) -> B: ... + + def __or__(self, o: B) -> B: ... + + def __invert__(self) -> B: ... + + +eg = EGraph() +x, y = B.var("x"), B.var("y") +shared = x & y +expr = shared | y +for _ in range(24): + expr = (shared & expr) | (expr & (shared | x)) +eg.let("$e", expr) +print(hashlib.sha256(eg._serialize().to_json().encode()).hexdigest()) +""" + + +def test_serialization_is_deterministic_across_processes() -> None: + hashes = [] + for _ in range(3): + proc = subprocess.run( + [sys.executable, "-c", SCRIPT], + capture_output=True, + text=True, + check=True, + ) + hashes.append(proc.stdout.strip()) + assert len(set(hashes)) == 1, f"nondeterministic serialization: {hashes}"