Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
12 changes: 8 additions & 4 deletions python/egglog/egraph_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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


Expand Down
56 changes: 56 additions & 0 deletions python/tests/test_deterministic_hoisting.py
Original file line number Diff line number Diff line change
@@ -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}"
Loading