Skip to content

Commit c3d2644

Browse files
authored
Break the exir capture <-> program/verifier import cycle (#24799)
### Summary `exir/capture/__init__.py` loads `_capture.py`, which imported `executorch.exir.program` and `executorch.exir.verification.verifier` at module scope. `verifier.py`, `program/_program.py` and `_serialize/_serialize.py` all import `executorch.exir.capture._config`, so each of them ran `capture/__init__.py` and looped back into itself while still half-initialised. Normally `exir/__init__.py` loads `capture` first, so the cycle stays hidden. Under lazy imports (e.g. Cinder lazy imports on Python 3.14) the first import can enter through one of those modules instead, for example via `backends/xnnpack/xnnpack_preprocess.py` -> `verifier`, and fails: ``` ImportError: cannot import name 'EXIRATenDialectVerifierBase' from partially initialized module 'executorch.exir.verification.verifier' (most likely due to a circular import) ``` `_capture.py` uses `ExirExportedProgram`, `_transform`, `HackedUpExportedProgramDONOTUSE` and `EXIRATenDialectVerifierBase` only inside `capture()` and `_capture_legacy_do_not_use()`, so those imports move into the two functions, with a `TYPE_CHECKING` import for the return annotations. No public import paths change. The separate `exir/backend/backend_api.py` <-> `exir/program/_program.py` cycle is not addressed here. This PR was authored with AI assistance (Claude Code). ### Test plan New `exir/tests/test_import_order.py` (collected by OSS pytest, plus a Buck target) imports `executorch.exir.verification.verifier`, `executorch.exir.program` and `executorch.exir._serialize._serialize` in a fresh interpreter whose `executorch.exir` is a bare package, so `exir/__init__.py` doesn't run first. On main all three fail with a circular-import error; with this change all three pass. That stub imports eagerly, so on main it reports a different name than the lazy-import traceback above (e.g. `EXIRATenDialectVerifier` from `_program.py`); same cycle. Smoke-tested `capture()` (with and without `enable_aot`). `_capture_legacy_do_not_use()` already fails on main with `ExportedProgram.__init__() got an unexpected keyword argument 'verifier'`, and fails the same way with this change. `python -m unittest executorch.exir.verification.test.test_verifier executorch.exir.tests.test_passes executorch.backends.xnnpack.test.ops.test_add executorch.backends.xnnpack.test.ops.test_conv2d`: same results as main (3 errors, `test_to_out_variant_none_output` and two `test_qs8_conv*`, which also error on main in my environment). Not yet verified in a real Cinder lazy-imports Python 3.14 runtime. cc @JacobSzwejbka @angelayi
1 parent 02e0d3a commit c3d2644

3 files changed

Lines changed: 64 additions & 6 deletions

File tree

‎exir/capture/_capture.py‎

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,21 +9,18 @@
99
from collections import namedtuple
1010
from contextlib import contextmanager
1111
from types import MethodType
12-
from typing import Any, Callable, cast, List, Optional, Tuple
12+
from typing import Any, Callable, cast, List, Optional, Tuple, TYPE_CHECKING
1313

1414
import torch
1515
from executorch.exir.capture._config import CaptureConfig
1616
from executorch.exir.error import ExportError, ExportErrorType, InternalError
17-
from executorch.exir.program import ExirExportedProgram
18-
from executorch.exir.program._program import _transform, HackedUpExportedProgramDONOTUSE
1917
from executorch.exir.tracer import (
2018
_default_decomposition_table,
2119
dispatch_trace,
2220
dynamo_trace,
2321
flatten_output,
2422
Value,
2523
)
26-
from executorch.exir.verification.verifier import EXIRATenDialectVerifierBase
2724
from torch import _guards
2825
from torch._dispatch.python import enable_python_dispatcher
2926
from torch._export.passes import ReplaceViewOpsWithViewCopyOpsPass
@@ -46,6 +43,11 @@
4643
from torch.fx.experimental.symbolic_shapes import ShapeEnv
4744
from torch.utils import _pytree as pytree
4845

46+
# exir.program and exir.verification.verifier import executorch.exir.capture
47+
# (via capture._config), so importing them here at module scope is circular.
48+
if TYPE_CHECKING:
49+
from executorch.exir.program import ExirExportedProgram
50+
4951

5052
Val = Any
5153

@@ -59,7 +61,7 @@
5961

6062

6163
@compatibility(is_backward_compatible=False)
62-
def _capture_legacy_do_not_use(f, args) -> ExirExportedProgram:
64+
def _capture_legacy_do_not_use(f, args) -> "ExirExportedProgram":
6365
"""
6466
This is a legacy API that should be avoided. Prefer to use capture() instead.
6567
"""
@@ -69,6 +71,9 @@ def _capture_legacy_do_not_use(f, args) -> ExirExportedProgram:
6971
DeprecationWarning,
7072
stacklevel=1,
7173
)
74+
from executorch.exir.program import ExirExportedProgram
75+
from executorch.exir.program._program import HackedUpExportedProgramDONOTUSE
76+
from executorch.exir.verification.verifier import EXIRATenDialectVerifierBase
7277

7378
graph_module = dispatch_trace(f, args)
7479
flat_args = tuple(pytree.tree_flatten(args)[0])
@@ -167,12 +172,16 @@ def capture( # noqa: C901
167172
args: Tuple[Value, ...],
168173
config: Optional[CaptureConfig] = None,
169174
dynamic_shapes: Optional[List[Any]] = None,
170-
) -> ExirExportedProgram:
175+
) -> "ExirExportedProgram":
171176
warnings.warn(
172177
"This function is now deprecated, please use `torch.export and exir.to_edge` instead. ",
173178
DeprecationWarning,
174179
stacklevel=1,
175180
)
181+
from executorch.exir.program import ExirExportedProgram
182+
from executorch.exir.program._program import _transform
183+
from executorch.exir.verification.verifier import EXIRATenDialectVerifierBase
184+
176185
if not isinstance(args, tuple):
177186
raise ExportError(
178187
ExportErrorType.INVALID_INPUT_TYPE,

‎exir/tests/targets.bzl‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,16 @@ def define_common_targets(is_fbcode = False):
114114
],
115115
)
116116

117+
python_unittest(
118+
name = "import_order",
119+
srcs = [
120+
"test_import_order.py",
121+
],
122+
deps = [
123+
"//executorch/exir:lib",
124+
],
125+
)
126+
117127
python_unittest(
118128
name = "tensor",
119129
srcs = [

‎exir/tests/test_import_order.py‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# Copyright (c) Meta Platforms, Inc. and affiliates.
2+
# All rights reserved.
3+
#
4+
# This source code is licensed under the BSD-style license found in the
5+
# LICENSE file in the root directory of this source tree.
6+
7+
# pyre-strict
8+
import subprocess
9+
import sys
10+
import unittest
11+
12+
_IMPORT_WITHOUT_EXIR_INIT = """
13+
import importlib, importlib.util, sys, types
14+
15+
spec = importlib.util.find_spec("executorch.exir")
16+
exir = types.ModuleType("executorch.exir")
17+
exir.__path__ = spec.submodule_search_locations
18+
sys.modules["executorch.exir"] = exir
19+
importlib.import_module(sys.argv[1])
20+
"""
21+
22+
23+
class TestImportOrder(unittest.TestCase):
24+
def test_import_without_exir_init(self) -> None:
25+
# Lazy imports can defer executorch/exir/__init__.py, so each of these
26+
# must import cleanly as the first executorch.exir module.
27+
for module in (
28+
"executorch.exir.verification.verifier",
29+
"executorch.exir.program",
30+
"executorch.exir._serialize._serialize",
31+
):
32+
with self.subTest(module=module):
33+
completed = subprocess.run(
34+
[sys.executable, "-c", _IMPORT_WITHOUT_EXIR_INIT, module],
35+
capture_output=True,
36+
text=True,
37+
timeout=300,
38+
)
39+
self.assertEqual(completed.returncode, 0, completed.stderr)

0 commit comments

Comments
 (0)