From 1ccefae5f162e0bb6a865ad6e96b412c14883590 Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Sat, 22 Aug 2026 23:55:53 -0700 Subject: [PATCH 01/20] Track a recent ExecuTorch pin instead of 1.4.1 ExecuTorch 1.4.1 ships no linkable C++ runtime: its wheel contains zero shared libraries, its CMake package exports only a static _portable_lib, and no CUDA wheel exists for it on any channel. That is why the runtime wheel rebuilds ExecuTorch from source today, and it is the blocker for shipping only the TensorRT delegate. The prebuilt runtime landed on ExecuTorch main on 2026-08-20, six days after 1.4.1 was tagged, so no release carries it yet. Move the pin to the nightly line that does, keeping the release-line range on installable metadata so the same range prefers 1.5.0 over any dev build the day it ships, with no edit needed. The two pins now have to name one ExecuTorch rather than two that look close, because the delegate compiles headers from the source tree and links the runtime out of the wheel. Every wheel records its source commit, so add a test asserting the pinned commit is the pinned wheel's own git_version. Nothing else was enforcing that, and a mismatch is silent: both pins look plausible and the build succeeds. Deriving the range with a three-field split raised on the nightly form, so derive it from the release line the first two fields name. ExecuTorch's CUDA wheels are published only on the PyTorch nightly index, so every install site gains that channel. No site gains --pre: the pin names an exact dev build, which pip installs from an explicit version without it, and the existing --pre uses here are for torch. CI derives the channel from the row's own CU_VERSION, which keeps the runtime the delegate links to the same CUDA build as the rest of the job. --- .github/workflows/executorch-build-linux.yml | 9 ++- .github/workflows/executorch-test-linux.yml | 6 +- MODULE.bazel | 10 ++- dev_dep_versions.yml | 4 +- docker/MODULE.bazel.docker | 4 +- docker/MODULE.bazel.ngc | 4 +- .../executorch_reference_runner/README.md | 2 +- justfile | 7 +- .../README.md | 6 +- .../pyproject.toml | 2 +- setup.py | 4 + tests/ci/runner.py | 14 +++- .../dynamo/executorch/test_executorch_pin.py | 76 ++++++++++++++++++- toolchains/ci_workspaces/MODULE.bazel.tmpl | 4 +- 14 files changed, 128 insertions(+), 24 deletions(-) diff --git a/.github/workflows/executorch-build-linux.yml b/.github/workflows/executorch-build-linux.yml index f3eab9c2379..77f2e51af03 100644 --- a/.github/workflows/executorch-build-linux.yml +++ b/.github/workflows/executorch-build-linux.yml @@ -79,7 +79,12 @@ jobs: export PATH="${RUNNER_TEMP}/bin:${PATH}" bazel --version - python -m pip install pyyaml "executorch==1.4.1" + # ExecuTorch's CUDA wheels are published only on the PyTorch nightly index, and the + # pin is a dev version, which the requirement itself already admits. + # CU_VERSION selects the row's own channel, which is what keeps the runtime the + # delegate links to the same CUDA build as the rest of the job. + EXECUTORCH_INDEX_URL="https://download.pytorch.org/whl/nightly/${CU_VERSION}" + python -m pip install pyyaml --extra-index-url "${EXECUTORCH_INDEX_URL}" "executorch==1.5.0.dev20260822" export TORCH_TENSORRT_EXECUTORCH_RUNTIME_VERSION="$(python -c 'import importlib.metadata; print(importlib.metadata.version("torch-tensorrt"))')" # The downloaded wheel has to carry the C++ runtime. A wheel built with @@ -126,7 +131,7 @@ jobs: export EXECUTORCH_SOURCE_DIR="$(dirname "${executorch_cmake_location%%:*}")" export EXECUTORCH_ROOT="${EXECUTORCH_SOURCE_DIR}" # this is to verify the end user's workflow - python -m pip install pyyaml "executorch>=1.4.1,<1.5" + python -m pip install pyyaml --extra-index-url "${EXECUTORCH_INDEX_URL}" "executorch>=1.5.0.dev20260822,<1.6" python examples/torchtrt_executorch_example/export_static_shape.py \ --model_path="${RUNNER_TEMP}/torchtrt-reference-runner.pte" .github/scripts/verify-executorch-reference-runner.sh \ diff --git a/.github/workflows/executorch-test-linux.yml b/.github/workflows/executorch-test-linux.yml index 9751f2bb54a..7d5cecaab04 100644 --- a/.github/workflows/executorch-test-linux.yml +++ b/.github/workflows/executorch-test-linux.yml @@ -64,7 +64,11 @@ jobs: chmod +x "${RUNNER_TEMP}/bin/bazel" export PATH="${RUNNER_TEMP}/bin:${PATH}" - python -m pip install pyyaml "executorch==1.4.1" + # ExecuTorch's CUDA wheels live only on the PyTorch nightly index, and the pin is a + # dev version, which the requirement itself already admits. + python -m pip install pyyaml \ + --extra-index-url "https://download.pytorch.org/whl/nightly/${CU_VERSION}" \ + "executorch==1.5.0.dev20260822" # Run the check directly so its exit status is the step's exit status. # Wrapping it in `gdb --batch` reports gdb's own status, which is 0 # whatever the program does, so a SIGSEGV here was passing. diff --git a/MODULE.bazel b/MODULE.bazel index 9ba40c6ab0b..a3db3a9789f 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -45,13 +45,15 @@ new_git_repository = use_repo_rule("@bazel_tools//tools/build_defs/repo:git.bzl" local_torch = use_repo_rule("//toolchains:local_torch.bzl", "local_torch") -# Keep this pin synchronized with the ExecuTorch release installed in -# py/torch-tensorrt-executorch-runtime/README.md. +# This commit must be the one the pinned ExecuTorch wheel was built from, because the delegate +# compiles headers from this tree and links the runtime out of that wheel. Every wheel records +# its source in executorch/version.py as git_version, and tests/py/dynamo/executorch/ +# test_executorch_pin.py asserts the two agree, so bump both pins together. new_git_repository( name = "executorch", build_file = "@//third_party/executorch:BUILD", - # executorch==1.4.1 - commit = "e4d02f41f7909e8ed5bf4a14ffc520d733453d9f", + # executorch==1.5.0.dev20260822 + commit = "b575a5bc2eab6d671197bc7a920edb7fd0b8fbb7", patch_cmds = [ "find . -mindepth 2 \\( -name BUILD -o -name BUILD.bazel \\) -delete", "mkdir executorch && find . -mindepth 1 -maxdepth 1 ! -name executorch ! -name BUILD ! -name BUILD.bazel ! -name REPO.bazel -exec cp -a {} executorch/ \\;", diff --git a/dev_dep_versions.yml b/dev_dep_versions.yml index 07bac57849f..f17b31f1f2f 100644 --- a/dev_dep_versions.yml +++ b/dev_dep_versions.yml @@ -2,5 +2,5 @@ __cuda_version__: "13.2" __tensorrt_version__: "11.2.1" __tensorrt_rtx_version__: "1.6.1" __tensorrt_llm_version__: "0.17.0.post1" -__executorch_version__: "1.4.1" -__executorch_commit__: "e4d02f41f7909e8ed5bf4a14ffc520d733453d9f" +__executorch_version__: "1.5.0.dev20260822" +__executorch_commit__: "b575a5bc2eab6d671197bc7a920edb7fd0b8fbb7" diff --git a/docker/MODULE.bazel.docker b/docker/MODULE.bazel.docker index f0c9b161bb1..b1459e08fa4 100644 --- a/docker/MODULE.bazel.docker +++ b/docker/MODULE.bazel.docker @@ -67,8 +67,8 @@ new_local_repository( new_git_repository( name = "executorch", build_file = "@//third_party/executorch:BUILD", - # executorch==1.4.1 - commit = "e4d02f41f7909e8ed5bf4a14ffc520d733453d9f", + # executorch==1.5.0.dev20260822 + commit = "b575a5bc2eab6d671197bc7a920edb7fd0b8fbb7", patch_cmds = [ "find . -mindepth 2 \\( -name BUILD -o -name BUILD.bazel \\) -delete", "mkdir executorch && find . -mindepth 1 -maxdepth 1 ! -name executorch ! -name BUILD ! -name BUILD.bazel ! -name REPO.bazel -exec cp -a {} executorch/ \\;", diff --git a/docker/MODULE.bazel.ngc b/docker/MODULE.bazel.ngc index fcf70cafa88..6823b9bdc6f 100644 --- a/docker/MODULE.bazel.ngc +++ b/docker/MODULE.bazel.ngc @@ -76,8 +76,8 @@ new_local_repository( new_git_repository( name = "executorch", build_file = "@//third_party/executorch:BUILD", - # executorch==1.4.1 - commit = "e4d02f41f7909e8ed5bf4a14ffc520d733453d9f", + # executorch==1.5.0.dev20260822 + commit = "b575a5bc2eab6d671197bc7a920edb7fd0b8fbb7", recursive_init_submodules = True, patch_cmds = [ "find . -mindepth 2 \\( -name BUILD -o -name BUILD.bazel \\) -delete", diff --git a/examples/executorch_reference_runner/README.md b/examples/executorch_reference_runner/README.md index 1b1ccba4544..3fcd4232c02 100644 --- a/examples/executorch_reference_runner/README.md +++ b/examples/executorch_reference_runner/README.md @@ -44,7 +44,7 @@ torch_tensorrt/bin/example_executorch_runner ```bash # Get the ExecuTorch source snapshot this package is built against. Keep this in sync # with the executorch commit pinned in MODULE.bazel. -EXECUTORCH_REF="${EXECUTORCH_REF:-e4d02f41f7909e8ed5bf4a14ffc520d733453d9f}" +EXECUTORCH_REF="${EXECUTORCH_REF:-b575a5bc2eab6d671197bc7a920edb7fd0b8fbb7}" git clone --filter=blob:none --no-checkout \ https://github.com/pytorch/executorch.git executorch pushd executorch diff --git a/justfile b/justfile index 06f1574b022..df1a9e5a622 100644 --- a/justfile +++ b/justfile @@ -85,7 +85,12 @@ summary *args: # Install optional test deps so model/kernels/quantization/executorch suites run install-test-ext: uv pip install --group test-ext --group kernels --group quantization - uv pip install pyyaml "executorch>=1.4.1,<1.5" + # ExecuTorch's CUDA wheels are only on the PyTorch nightly index, and the pin is a dev + # version, which the requirement itself already admits. cu130 matches the + # torch index this project resolves against by default. + uv pip install pyyaml \ + --extra-index-url https://download.pytorch.org/whl/nightly/cu130 \ + "executorch>=1.5.0.dev20260822,<1.6" # ── Linting ─────────────────────────────────────────────────────────────────── diff --git a/py/torch-tensorrt-executorch-runtime/README.md b/py/torch-tensorrt-executorch-runtime/README.md index d6d627a7b72..a181a783e60 100644 --- a/py/torch-tensorrt-executorch-runtime/README.md +++ b/py/torch-tensorrt-executorch-runtime/README.md @@ -39,7 +39,9 @@ of the wheel runtime contract. ```bash export TensorRT_ROOT=/path/to/TensorRT -python -m pip install pyyaml "executorch==1.4.1" +python -m pip install pyyaml \ + --extra-index-url https://download.pytorch.org/whl/nightly/cu132 \ + "executorch==1.5.0.dev20260822" python -m pip wheel --no-build-isolation --no-deps \ --wheel-dir dist py/torch-tensorrt-executorch-runtime ``` @@ -47,7 +49,7 @@ python -m pip wheel --no-build-isolation --no-deps \ The native build obtains the ExecuTorch source through Bazel; no separate source checkout or `EXECUTORCH_SOURCE_DIR` setting is required. The source commit pinned in `MODULE.bazel` is the revision recorded by the -`executorch==1.4.1` wheel. +`executorch==1.5.0.dev20260822` wheel. The static ExecuTorch and delegate archives are intermediate build inputs; users receive the final native Python module and do not compile anything. diff --git a/py/torch-tensorrt-executorch-runtime/pyproject.toml b/py/torch-tensorrt-executorch-runtime/pyproject.toml index 78e33164c49..bb057bbc4a9 100644 --- a/py/torch-tensorrt-executorch-runtime/pyproject.toml +++ b/py/torch-tensorrt-executorch-runtime/pyproject.toml @@ -6,6 +6,6 @@ requires = [ # environment. # Builds must use --no-build-isolation; see README.md. "torch", - "executorch==1.4.1", + "executorch==1.5.0.dev20260822", ] build-backend = "setuptools.build_meta" diff --git a/setup.py b/setup.py index 3f8a003feba..3a23f32ddfb 100644 --- a/setup.py +++ b/setup.py @@ -208,6 +208,10 @@ def load_dep_info(): # runtime API is not stable across minor releases, and an unbounded floor would resolve a future # minor against a backend built for this one. Patch releases stay allowed because they come off the # same release branch; the exact pin belongs in the runtime package, which does derive it. +# The floor currently names a dev build, because the runtime split the delegate needs does not +# exist in any ExecuTorch release yet: 1.4.1 ships no shared libraries and no CUDA wheel at all. +# That also makes this range prefer a release as soon as one exists, since 1.5.0 sorts above +# every 1.5.0.devN, so nothing here changes on the day it ships. _executorch_major, _executorch_minor = __executorch_version__.split(".")[:2] EXECUTORCH_REQUIREMENT = ( f"executorch>={__executorch_version__}," diff --git a/tests/ci/runner.py b/tests/ci/runner.py index fe98ab34239..7beb9ff3d0e 100644 --- a/tests/ci/runner.py +++ b/tests/ci/runner.py @@ -131,10 +131,22 @@ def _setup_commands(step: str) -> list[tuple[list[str], Path]]: if step == "hub": return [(launcher + ["hub.py"], REPO_ROOT / "tests/modules")] if step == "executorch": + # ExecuTorch's CUDA wheels are published only on the PyTorch nightly index, so the + # channel is needed here. cu130 matches the torch index pyproject.toml resolves + # against by default, rather than dev_dep_versions.yml's __cuda_version__, which this + # file does not read. return [ ( launcher - + ["-m", "pip", "install", "pyyaml", _executorch_requirement()], + + [ + "-m", + "pip", + "install", + "pyyaml", + "--extra-index-url", + "https://download.pytorch.org/whl/nightly/cu130", + _executorch_requirement(), + ], REPO_ROOT, ) ] diff --git a/tests/py/dynamo/executorch/test_executorch_pin.py b/tests/py/dynamo/executorch/test_executorch_pin.py index ecb6458f5da..c00849db521 100644 --- a/tests/py/dynamo/executorch/test_executorch_pin.py +++ b/tests/py/dynamo/executorch/test_executorch_pin.py @@ -8,6 +8,10 @@ right spelling per role is the point: installable metadata that pins exactly would reject a compatible patch release, and a build input that takes a range could resolve an ExecuTorch the artifact was not compiled against. + +Agreeing with the file is necessary but not sufficient, so the last test closes the gap the +other two leave: they only prove the repository is self-consistent, which it would be even +if the wheel and the commit named two different ExecuTorch trees. """ import ast @@ -17,6 +21,8 @@ import sys from pathlib import Path +import pytest + REPO_ROOT = Path(__file__).resolve().parents[4] VERSIONS = REPO_ROOT / "dev_dep_versions.yml" @@ -75,12 +81,22 @@ def _wants_range(path: str, number: int) -> bool: return False +def _release_line(version: str) -> tuple[str, str]: + """Split a pin into its major and minor, for either a release or a nightly. + + ``1.4.1`` and ``1.5.0.dev20260822`` both belong to the release line their first two + fields name, so the range a site gets is derived from those and nothing else. Splitting + on every dot instead assumes three fields and raises on the nightly form. + """ + major, minor = version.split(".")[:2] + return major, minor + + def _expected(path: str, number: int, version: str) -> str: if not _wants_range(path, number): return f"executorch=={version}" - # Assumes X.Y.Z, which is what ExecuTorch releases and what this file records. - major, minor, _ = version.split(".") + major, minor = _release_line(version) return f"executorch>={version},<{major}.{int(minor) + 1}" @@ -148,7 +164,7 @@ def test_derived_requirements_match_the_pin() -> None: # setup.py and tests/ci/runner.py build their requirement from the pin, so the search # above cannot see them. Check the strings they produce instead. version = _versions()["__executorch_version__"] - major, minor, _ = version.split(".") + major, minor = _release_line(version) expected = f"executorch>={version},<{major}.{int(minor) + 1}" assert _setup_py_requirement(version) == expected @@ -169,6 +185,60 @@ def test_derived_requirements_roll_the_minor_over(tmp_path: Path) -> None: assert _runner_requirement(tmp_path) == expected +def test_the_pinned_commit_is_the_pinned_wheels_own_source() -> None: + """The two pins must name one ExecuTorch, not two that happen to be close. + + ``__executorch_version__`` selects the wheel the delegate is built to sit alongside, and + ``__executorch_commit__`` selects the tree it compiles from. Nothing about the two strings + forces them to agree, and a mismatch is invisible: both pins look plausible, the build + succeeds, and the delegate is compiled from one ExecuTorch while running against another. + Every published wheel records the commit it was built from, so the pairing is checkable + rather than a convention. + + Skipped rather than failed whenever the installed wheel is not the one the pin names — not + installed at all, a different member of a floating range, or built without git provenance. + None of those say anything about whether the two pins agree, and this file stays readable + offline. + """ + versions = _versions() + expected_commit = versions["__executorch_commit__"] + expected_version = versions["__executorch_version__"] + + try: + from executorch.version import __version__ as installed_version + from executorch.version import git_version as installed_commit + except ImportError: + pytest.skip("executorch is not installed, so the pinned wheel cannot be read") + + if installed_commit is None: + # ExecuTorch records this as Optional[str] and writes None when it is built outside a + # git checkout. Such a wheel carries no provenance to compare, which is not the pins + # disagreeing. + pytest.skip( + f"the installed ExecuTorch {installed_version} records no source commit, " + "so the pairing cannot be checked against it" + ) + + # The wheel carries a local version label naming its CUDA build (`+cu132`), which the pin + # deliberately omits so one pin serves every CUDA row. Compare the part they share. + if installed_version.split("+")[0] != expected_version: + # A different member of the same range, not a mismatch to report. The range sites + # deliberately float, and while the pin names a dev build the nightly channel gains a + # newer member daily, so any environment that installed through a range arrives here + # with a wheel this check cannot speak about. Only the wheel the pin names carries the + # commit the pin should agree with, so anything else is no evidence either way. + pytest.skip( + f"the installed ExecuTorch is {installed_version}, not the pinned " + f"{expected_version}, so its commit says nothing about whether the pins agree" + ) + + assert installed_commit == expected_commit, ( + f"ExecuTorch {installed_version} was built from {installed_commit}, but " + f"__executorch_commit__ pins {expected_commit}. The delegate would compile against " + "one ExecuTorch and link another." + ) + + def test_every_source_commit_matches_the_pin() -> None: commit = _versions()["__executorch_commit__"] diff --git a/toolchains/ci_workspaces/MODULE.bazel.tmpl b/toolchains/ci_workspaces/MODULE.bazel.tmpl index 68feb7430fb..37d05b29f8d 100644 --- a/toolchains/ci_workspaces/MODULE.bazel.tmpl +++ b/toolchains/ci_workspaces/MODULE.bazel.tmpl @@ -216,8 +216,8 @@ new_local_repository( new_git_repository( name = "executorch", build_file = "@//third_party/executorch:BUILD", - # executorch==1.4.1 - commit = "e4d02f41f7909e8ed5bf4a14ffc520d733453d9f", + # executorch==1.5.0.dev20260822 + commit = "b575a5bc2eab6d671197bc7a920edb7fd0b8fbb7", patch_cmds = [ "find . -mindepth 2 \\( -name BUILD -o -name BUILD.bazel \\) -delete", "mkdir executorch && find . -mindepth 1 -maxdepth 1 ! -name executorch ! -name BUILD ! -name BUILD.bazel ! -name REPO.bazel -exec cp -a {} executorch/ \\;", From 68d3cb6c216961b2cdc44e067f5ac7bed1f58b4f Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Sun, 23 Aug 2026 12:25:19 -0700 Subject: [PATCH 02/20] Install the ExecuTorch the pin names, from the channel that has it The CI installer globs torch_tensorrt*.whl, which also matches the ExecuTorch runtime wheel, whose install_requires names a dev build published only on the nightly channel. With no index on that line the whole pip invocation failed, and because line 1's set -e is commented out the failure was swallowed and the job died later with a confusing ImportError. The two range sites installed a range against the nightly channel, which gains a member every day, so they resolved to whatever was newest while the delegate is compiled from the commit the pin names. Both now request the pin exactly, which is the pairing the drift test exists to check; it was written to skip in exactly the state the ranges produced, so nothing reported it. setup.py keeps its range, because a published requirement has to stay resolvable for users off the same line. The two shapes now differ deliberately and test_derived_requirements_match_the_pin checks each for its own. Six printed install instructions gave a bare pip install of the executorch extra, which cannot resolve a dev pin from PyPI. They name the channel now. The discovery regex saw only == and >=, so a site added with any other PEP 440 operator was invisible to the drift check. It now recognises all of them. --- .github/scripts/install-torch-tensorrt.sh | 5 +- .github/workflows/executorch-build-linux.yml | 5 +- .github/workflows/executorch-test-linux.yml | 5 +- MODULE.bazel | 3 +- .../runtime_performance/saving_models.rst | 5 +- justfile | 12 ++-- py/torch_tensorrt/_compile.py | 6 +- py/torch_tensorrt/executorch/__init__.py | 3 +- tests/ci/runner.py | 7 ++- .../dynamo/executorch/test_executorch_pin.py | 56 ++++++++++++------- 10 files changed, 69 insertions(+), 38 deletions(-) diff --git a/.github/scripts/install-torch-tensorrt.sh b/.github/scripts/install-torch-tensorrt.sh index 306a5c17683..4bbd7ad6d37 100755 --- a/.github/scripts/install-torch-tensorrt.sh +++ b/.github/scripts/install-torch-tensorrt.sh @@ -64,7 +64,10 @@ fi if [[ ${PLATFORM} == win32 ]]; then python -m pip install ${RUNNER_ARTIFACT_DIR}/torch_tensorrt*.whl else - python -m pip install /opt/torch-tensorrt-builds/torch_tensorrt*.whl --use-deprecated=legacy-resolver + # The nightly channel is needed because this glob also matches the ExecuTorch runtime wheel, + # whose install_requires names an ExecuTorch dev build that is published only there. + python -m pip install /opt/torch-tensorrt-builds/torch_tensorrt*.whl --use-deprecated=legacy-resolver \ + --extra-index-url "https://download.pytorch.org/whl/nightly/${CU_VERSION}" fi echo -e "Running test script"; diff --git a/.github/workflows/executorch-build-linux.yml b/.github/workflows/executorch-build-linux.yml index 77f2e51af03..e8e88367c4f 100644 --- a/.github/workflows/executorch-build-linux.yml +++ b/.github/workflows/executorch-build-linux.yml @@ -79,8 +79,9 @@ jobs: export PATH="${RUNNER_TEMP}/bin:${PATH}" bazel --version - # ExecuTorch's CUDA wheels are published only on the PyTorch nightly index, and the - # pin is a dev version, which the requirement itself already admits. + # ExecuTorch's CUDA wheels are published only on the PyTorch nightly index, so the + # channel is needed. No --pre: a specifier naming a prerelease admits prereleases by + # itself, and --pre would apply to every other requirement in the same command too. # CU_VERSION selects the row's own channel, which is what keeps the runtime the # delegate links to the same CUDA build as the rest of the job. EXECUTORCH_INDEX_URL="https://download.pytorch.org/whl/nightly/${CU_VERSION}" diff --git a/.github/workflows/executorch-test-linux.yml b/.github/workflows/executorch-test-linux.yml index 7d5cecaab04..1966ba73080 100644 --- a/.github/workflows/executorch-test-linux.yml +++ b/.github/workflows/executorch-test-linux.yml @@ -64,8 +64,9 @@ jobs: chmod +x "${RUNNER_TEMP}/bin/bazel" export PATH="${RUNNER_TEMP}/bin:${PATH}" - # ExecuTorch's CUDA wheels live only on the PyTorch nightly index, and the pin is a - # dev version, which the requirement itself already admits. + # ExecuTorch's CUDA wheels live only on the PyTorch nightly index, so the channel is + # needed. No --pre: a specifier naming a prerelease admits prereleases by itself, and + # --pre would apply to every other requirement in the same command too. python -m pip install pyyaml \ --extra-index-url "https://download.pytorch.org/whl/nightly/${CU_VERSION}" \ "executorch==1.5.0.dev20260822" diff --git a/MODULE.bazel b/MODULE.bazel index a3db3a9789f..2e94e2189d1 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -48,7 +48,8 @@ local_torch = use_repo_rule("//toolchains:local_torch.bzl", "local_torch") # This commit must be the one the pinned ExecuTorch wheel was built from, because the delegate # compiles headers from this tree and links the runtime out of that wheel. Every wheel records # its source in executorch/version.py as git_version, and tests/py/dynamo/executorch/ -# test_executorch_pin.py asserts the two agree, so bump both pins together. +# test_executorch_pin.py checks the two agree wherever the pinned wheel is the one installed, +# so bump both pins together. new_git_repository( name = "executorch", build_file = "@//third_party/executorch:BUILD", diff --git a/docsrc/user_guide/runtime_performance/saving_models.rst b/docsrc/user_guide/runtime_performance/saving_models.rst index 6470afd72e4..418e65d19b1 100644 --- a/docsrc/user_guide/runtime_performance/saving_models.rst +++ b/docsrc/user_guide/runtime_performance/saving_models.rst @@ -227,8 +227,9 @@ c) ExecuTorch (.pte) The ``executorch`` output format lowers the compiled module to an ExecuTorch ``.pte`` program, delegating the TensorRT engines to the Torch-TensorRT ExecuTorch -backend. It requires the ``executorch`` package (``pip install -"torch_tensorrt[executorch]"``) and is Linux-only. +backend. It requires the ``executorch`` package, from the PyTorch nightly index +(``pip install "torch_tensorrt[executorch]" --extra-index-url +https://download.pytorch.org/whl/nightly/cu130``), and is Linux-only. There are two ways to produce a ``.pte``, and they suit different needs: diff --git a/justfile b/justfile index df1a9e5a622..2b806852dc1 100644 --- a/justfile +++ b/justfile @@ -85,12 +85,16 @@ summary *args: # Install optional test deps so model/kernels/quantization/executorch suites run install-test-ext: uv pip install --group test-ext --group kernels --group quantization - # ExecuTorch's CUDA wheels are only on the PyTorch nightly index, and the pin is a dev - # version, which the requirement itself already admits. cu130 matches the - # torch index this project resolves against by default. + # ExecuTorch's CUDA wheels are only on the PyTorch nightly index, so the channel is needed. + # No --pre: a specifier naming a prerelease admits prereleases by itself, and --pre would + # apply to pyyaml here too. cu130 matches the torch index this project resolves against by + # default. + # + # Exact, not a range: the nightly channel gains a member every day, and the delegate is + # compiled from the commit this version pairs with. uv pip install pyyaml \ --extra-index-url https://download.pytorch.org/whl/nightly/cu130 \ - "executorch>=1.5.0.dev20260822,<1.6" + "executorch==1.5.0.dev20260822" # ── Linting ─────────────────────────────────────────────────────────────────── diff --git a/py/torch_tensorrt/_compile.py b/py/torch_tensorrt/_compile.py index fa70e13c461..32cdab33002 100644 --- a/py/torch_tensorrt/_compile.py +++ b/py/torch_tensorrt/_compile.py @@ -858,7 +858,8 @@ def save( raise ImportError( "Saving in ExecuTorch format requires the executorch package " "with executorch.exir. Install with: pip install " - "\"torch_tensorrt[executorch]\" to use output_format='executorch'." + '"torch_tensorrt[executorch]" --extra-index-url ' + "https://download.pytorch.org/whl/nightly/cu130 to use output_format='executorch'." ) if output_format == "executorch": # Every executorch option is popped above, so a leftover kwarg is a typo. Fail @@ -1406,7 +1407,8 @@ def _save_as_executorch(exp_program: Any, file_path: str, **kwargs: Any) -> None except ImportError: raise ImportError( "ExecuTorch is not installed. Install with: pip install " - "\"torch_tensorrt[executorch]\" to use output_format='executorch'." + '"torch_tensorrt[executorch]" --extra-index-url ' + "https://download.pytorch.org/whl/nightly/cu130 to use output_format='executorch'." ) import torch_tensorrt.dynamo.runtime.meta_ops.register_meta_ops # noqa: F401 diff --git a/py/torch_tensorrt/executorch/__init__.py b/py/torch_tensorrt/executorch/__init__.py index fef0943ce7b..06698989671 100644 --- a/py/torch_tensorrt/executorch/__init__.py +++ b/py/torch_tensorrt/executorch/__init__.py @@ -25,7 +25,8 @@ def __getattr__(name: str) -> NoReturn: raise ImportError( f"Cannot access torch_tensorrt.executorch.{name}: " "ExecuTorch with executorch.exir is required. " - 'Install with: pip install "torch_tensorrt[executorch]"' + 'Install with: pip install "torch_tensorrt[executorch]" ' + "--extra-index-url https://download.pytorch.org/whl/nightly/cu130" ) __all__ = [ diff --git a/tests/ci/runner.py b/tests/ci/runner.py index 7beb9ff3d0e..ae2ba87cf86 100644 --- a/tests/ci/runner.py +++ b/tests/ci/runner.py @@ -27,12 +27,15 @@ def _executorch_requirement() -> str: # Read the pin the way the drift test does, so this file is not a second # place to edit when it moves. Regex rather than yaml: the runner declares # no runtime dependencies of its own and importing it should not add one. + # + # Exact, not a range: the nightly channel gains a member every day, so a + # range would install whatever is newest while the delegate is compiled + # from the pinned commit. Pairing them is the point. text = (REPO_ROOT / "dev_dep_versions.yml").read_text() version = dict(re.findall(r'^(__\w+__): "([^"]+)"', text, re.MULTILINE))[ "__executorch_version__" ] - major, minor = version.split(".")[:2] - return f"executorch>={version},<{major}.{int(minor) + 1}" + return f"executorch=={version}" # Known transient cudagraph/TRT-driver flake signatures. Expand ONLY with diff --git a/tests/py/dynamo/executorch/test_executorch_pin.py b/tests/py/dynamo/executorch/test_executorch_pin.py index c00849db521..de0f1c5ac4d 100644 --- a/tests/py/dynamo/executorch/test_executorch_pin.py +++ b/tests/py/dynamo/executorch/test_executorch_pin.py @@ -9,9 +9,10 @@ compatible patch release, and a build input that takes a range could resolve an ExecuTorch the artifact was not compiled against. -Agreeing with the file is necessary but not sufficient, so the last test closes the gap the -other two leave: they only prove the repository is self-consistent, which it would be even -if the wheel and the commit named two different ExecuTorch trees. +Agreeing with the file is necessary but not sufficient, so +test_the_pinned_commit_is_the_pinned_wheels_own_source closes the gap the others leave: they +only prove the repository is self-consistent, which it would be even if the wheel and the +commit named two different ExecuTorch trees. """ import ast @@ -26,7 +27,14 @@ REPO_ROOT = Path(__file__).resolve().parents[4] VERSIONS = REPO_ROOT / "dev_dep_versions.yml" -REQUIREMENT = re.compile(r"executorch(?:==|>=)[0-9][^\"'\s,`]*(?:,<[0-9.]+)?") +# Every PEP 440 operator, not just the two this repository happens to use, and an optional +# space before it. A site added with a compatible-release or bare-inequality operator is a site +# that drifted from the pin, and it should be visible to the search rather than silently +# exempt. Operators are named through the pattern rather than spelled out in prose here, +# because the search below reads this file too and an example would read as such a site. +REQUIREMENT = re.compile( + r"executorch\s*(?:===|==|>=|<=|~=|!=|<|>)\s*[0-9][^\"'\s,`]*(?:,\s*<[0-9.]+)?" +) # The bazel repository puts the commit on its own line, so this one has to run against file # contents rather than a git grep line. @@ -43,11 +51,10 @@ # Only literal requirements land here. setup.py and tests/ci/runner.py derive theirs from # dev_dep_versions.yml, so the search below no longer sees them and # test_derived_requirements_match_the_pin covers them instead. -RANGE_SITES = frozenset( - { - "justfile", - } -) +# +# Empty today: the only literal range left was the justfile's install recipe, and it installs +# the wheel the delegate is compiled against, so it pins exactly like the rest. +RANGE_SITES: frozenset[str] = frozenset() # A step that exists to reproduce what a user runs belongs to the range group even inside a # file that otherwise pins build inputs, so the marker travels with the line rather than @@ -105,7 +112,9 @@ def test_every_requirement_matches_the_pin() -> None: wrong = [] found = 0 - for line in _git("grep", "-nI", "-E", r"executorch(==|>=)[0-9]").splitlines(): + for line in _git( + "grep", "-nI", "-E", r"executorch ?(===|==|>=|<=|~=|!=|<|>) ?[0-9]" + ).splitlines(): path, number, text = line.split(":", 2) if path == VERSIONS.name: continue @@ -163,12 +172,18 @@ def _runner_requirement(root: Path) -> str: def test_derived_requirements_match_the_pin() -> None: # setup.py and tests/ci/runner.py build their requirement from the pin, so the search # above cannot see them. Check the strings they produce instead. + # + # They want different shapes. setup.py declares what users may install, so it is a range + # over the release line. runner.py installs the wheel CI tests the delegate against, and + # the delegate is compiled from the commit the pin names, so it has to be exact: the + # nightly channel gains a member every day and a range there silently unpairs the two. version = _versions()["__executorch_version__"] major, minor = _release_line(version) - expected = f"executorch>={version},<{major}.{int(minor) + 1}" - assert _setup_py_requirement(version) == expected - assert _runner_requirement(REPO_ROOT) == expected + assert _setup_py_requirement(version) == ( + f"executorch>={version},<{major}.{int(minor) + 1}" + ) + assert _runner_requirement(REPO_ROOT) == f"executorch=={version}" def test_derived_requirements_roll_the_minor_over(tmp_path: Path) -> None: @@ -176,13 +191,13 @@ def test_derived_requirements_roll_the_minor_over(tmp_path: Path) -> None: # Spelled through a variable because the search above reads this file too, and a # written-out requirement here would read as a site that drifted from the pin. version = "1.9.0" - expected = f"executorch>={version},<1.10" (tmp_path / "dev_dep_versions.yml").write_text( f'__executorch_version__: "{version}"\n' ) - assert _setup_py_requirement(version) == expected - assert _runner_requirement(tmp_path) == expected + assert _setup_py_requirement(version) == f"executorch>={version},<1.10" + # No upper bound to roll over, but it must still track the pin it is given. + assert _runner_requirement(tmp_path) == f"executorch=={version}" def test_the_pinned_commit_is_the_pinned_wheels_own_source() -> None: @@ -222,11 +237,10 @@ def test_the_pinned_commit_is_the_pinned_wheels_own_source() -> None: # The wheel carries a local version label naming its CUDA build (`+cu132`), which the pin # deliberately omits so one pin serves every CUDA row. Compare the part they share. if installed_version.split("+")[0] != expected_version: - # A different member of the same range, not a mismatch to report. The range sites - # deliberately float, and while the pin names a dev build the nightly channel gains a - # newer member daily, so any environment that installed through a range arrives here - # with a wheel this check cannot speak about. Only the wheel the pin names carries the - # commit the pin should agree with, so anything else is no evidence either way. + # No evidence either way rather than a mismatch to report: only the wheel the pin names + # carries the commit the pin should agree with. Every install path in this repository + # now requests the pin exactly, so arriving here means the environment was built some + # other way, and that wheel's commit says nothing about whether the two pins agree. pytest.skip( f"the installed ExecuTorch is {installed_version}, not the pinned " f"{expected_version}, so its commit says nothing about whether the pins agree" From 413bf06d3dc1789c1b0b7b9cfcbd2436f94f8b59 Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Sun, 23 Aug 2026 14:53:47 -0700 Subject: [PATCH 03/20] Follow the row's CUDA version, and reach the channel from every example The executorch suite is nightly-only, and the nightly matrix runs cu132 rows as well as cu130 ones, so the fixed cu130 channel in tests/ci/runner.py would install a CUDA 13.0 ExecuTorch into a CUDA 13.2 job. PR builds are pinned to cu130 by filter-matrix.py, which is why watching PR CI could never show this. Derived from CU_VERSION now, with cu130 as the local default, matching what the two workflow files already did. Three example docstrings still printed a bare `pip install -e ".[executorch]"`. That resolved off PyPI before this pin moved to a dev build; it cannot now, so they name the nightly index too. The runtime's ImportError advice and the reference runner README already did. The installer's hardcoded nightly channel gets the reason written down: a .dev wheel exists on no other channel, so deriving it from ${CHANNEL} like the lines above would break the install on exactly the test and release runs the index was added for. --- .github/scripts/install-torch-tensorrt.sh | 4 ++++ .../torchtrt_executorch_example/export_coalesced.py | 3 ++- .../export_dynamic_shape.py | 3 ++- .../export_kv_cache_decode.py | 3 ++- .../export_static_shape.py | 3 ++- tests/ci/runner.py | 11 +++++++---- tests/py/dynamo/executorch/test_executorch_pin.py | 9 +++++++++ 7 files changed, 28 insertions(+), 8 deletions(-) diff --git a/.github/scripts/install-torch-tensorrt.sh b/.github/scripts/install-torch-tensorrt.sh index 4bbd7ad6d37..0a2ab06d2fd 100755 --- a/.github/scripts/install-torch-tensorrt.sh +++ b/.github/scripts/install-torch-tensorrt.sh @@ -66,6 +66,10 @@ if [[ ${PLATFORM} == win32 ]]; then else # The nightly channel is needed because this glob also matches the ExecuTorch runtime wheel, # whose install_requires names an ExecuTorch dev build that is published only there. + # Hardcoded rather than ${CHANNEL} like the lines above: a .dev wheel exists on no other + # channel, so deriving it would break this install on exactly the test and release runs the + # index was added for. It is an extra index, not a replacement, and torch is already + # force-reinstalled from ${INDEX_URL} above, so the pinned torch is not at risk from it. python -m pip install /opt/torch-tensorrt-builds/torch_tensorrt*.whl --use-deprecated=legacy-resolver \ --extra-index-url "https://download.pytorch.org/whl/nightly/${CU_VERSION}" fi diff --git a/examples/torchtrt_executorch_example/export_coalesced.py b/examples/torchtrt_executorch_example/export_coalesced.py index 45c0b1c51c0..71ae586ce91 100644 --- a/examples/torchtrt_executorch_example/export_coalesced.py +++ b/examples/torchtrt_executorch_example/export_coalesced.py @@ -30,7 +30,8 @@ ------------- Install Torch-TensorRT with the ExecuTorch extra before running this example:: - pip install -e ".[executorch]" + pip install -e ".[executorch]" \ + --extra-index-url https://download.pytorch.org/whl/nightly/cu130 ExecuTorch's CUDA backend also needs a CUDA toolkit (``nvcc``) at export time, for the AOTInductor compile. diff --git a/examples/torchtrt_executorch_example/export_dynamic_shape.py b/examples/torchtrt_executorch_example/export_dynamic_shape.py index 28115f696ca..64847c3c50b 100644 --- a/examples/torchtrt_executorch_example/export_dynamic_shape.py +++ b/examples/torchtrt_executorch_example/export_dynamic_shape.py @@ -16,7 +16,8 @@ ------------- Install Torch-TensorRT with the ExecuTorch extra before running this example:: - pip install -e ".[executorch]" + pip install -e ".[executorch]" \ + --extra-index-url https://download.pytorch.org/whl/nightly/cu130 See https://pytorch.org/executorch/stable/getting-started-setup.html for details. """ diff --git a/examples/torchtrt_executorch_example/export_kv_cache_decode.py b/examples/torchtrt_executorch_example/export_kv_cache_decode.py index c8590d98b03..5dcf3476fa4 100644 --- a/examples/torchtrt_executorch_example/export_kv_cache_decode.py +++ b/examples/torchtrt_executorch_example/export_kv_cache_decode.py @@ -18,7 +18,8 @@ ------------- Install Torch-TensorRT with the ExecuTorch extra before running this example:: - pip install -e ".[executorch]" + pip install -e ".[executorch]" \ + --extra-index-url https://download.pytorch.org/whl/nightly/cu130 """ import argparse diff --git a/examples/torchtrt_executorch_example/export_static_shape.py b/examples/torchtrt_executorch_example/export_static_shape.py index ed8bb218da5..eadc36f0d44 100644 --- a/examples/torchtrt_executorch_example/export_static_shape.py +++ b/examples/torchtrt_executorch_example/export_static_shape.py @@ -12,7 +12,8 @@ ------------- Install Torch-TensorRT with the ExecuTorch extra before running this example:: - pip install -e ".[executorch]" + pip install -e ".[executorch]" \ + --extra-index-url https://download.pytorch.org/whl/nightly/cu130 See https://pytorch.org/executorch/stable/getting-started-setup.html for details. """ diff --git a/tests/ci/runner.py b/tests/ci/runner.py index ae2ba87cf86..5ee629eb7f3 100644 --- a/tests/ci/runner.py +++ b/tests/ci/runner.py @@ -135,9 +135,12 @@ def _setup_commands(step: str) -> list[tuple[list[str], Path]]: return [(launcher + ["hub.py"], REPO_ROOT / "tests/modules")] if step == "executorch": # ExecuTorch's CUDA wheels are published only on the PyTorch nightly index, so the - # channel is needed here. cu130 matches the torch index pyproject.toml resolves - # against by default, rather than dev_dep_versions.yml's __cuda_version__, which this - # file does not read. + # channel is needed here. Derived from CU_VERSION rather than fixed, because the + # executorch suite is nightly-only and the nightly matrix runs cu132 rows as well as + # cu130 ones; a fixed channel would install a CUDA 13.0 runtime into a 13.2 job. The + # cu130 default is for a local run with no CU_VERSION set, and matches the torch index + # pyproject.toml resolves against by default. + cuda = os.environ.get("CU_VERSION", "cu130") return [ ( launcher @@ -147,7 +150,7 @@ def _setup_commands(step: str) -> list[tuple[list[str], Path]]: "install", "pyyaml", "--extra-index-url", - "https://download.pytorch.org/whl/nightly/cu130", + f"https://download.pytorch.org/whl/nightly/{cuda}", _executorch_requirement(), ], REPO_ROOT, diff --git a/tests/py/dynamo/executorch/test_executorch_pin.py b/tests/py/dynamo/executorch/test_executorch_pin.py index de0f1c5ac4d..34b2ce63610 100644 --- a/tests/py/dynamo/executorch/test_executorch_pin.py +++ b/tests/py/dynamo/executorch/test_executorch_pin.py @@ -186,6 +186,15 @@ def test_derived_requirements_match_the_pin() -> None: assert _runner_requirement(REPO_ROOT) == f"executorch=={version}" +def test_the_runner_follows_the_row_s_cuda_version() -> None: + # The executorch suite is nightly-only, and the nightly matrix runs cu132 rows as well as + # cu130 ones, so a fixed channel would install a CUDA 13.0 ExecuTorch into a CUDA 13.2 job. + # PRs pin to cu130, which is why this cannot be caught by watching PR CI. + source = (REPO_ROOT / "tests/ci/runner.py").read_text(encoding="utf-8") + assert 'os.environ.get("CU_VERSION", "cu130")' in source + assert "nightly/cu130" not in source, "the channel is hardcoded again" + + def test_derived_requirements_roll_the_minor_over(tmp_path: Path) -> None: # The upper bound is a version, not a decimal: 1.9 has to become 1.10, not 1.1. # Spelled through a variable because the search above reads this file too, and a From 69e0f3b704a522d2a5c4f90cfc01a628258773d5 Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Sun, 23 Aug 2026 17:04:02 -0700 Subject: [PATCH 04/20] Keep the extra resolvable on Windows, and pin the channel docgen installs from Raising the executorch floor to a dev build made `uv lock` fail outright. The lock's win32 required-environment in pyproject.toml resolved the extra from PyPI, whose executorch stops at 1.4.1 -- uv.lock records five win_amd64 wheels for it -- so nothing satisfied the new range and uv errors rather than falling back. Reproduced against a probe project: without a marker uv reports the win32 split unsatisfiable, with one it resolves. The requirement now carries `platform_system == 'Linux'`, the shape EXECUTORCH_RUNTIME_REQUIREMENT already uses, which also stops pip reporting no matching distribution for Windows users of the extra. The delegate is a Linux object and ExecuTorch publishes CUDA wheels for no other platform, so the marker states what was already true. docgen installed the extra with --pre against the nightly channel, so it resolved through the range and took whichever dev build was newest that morning while the delegate compiled from the pinned commit. It names the pin now, read out of dev_dep_versions.yml. The pip line that installs both wheels gets `|| exit 1`. linux-test.yml concatenates this installer ahead of the user script and line 1's `set -e` is commented out, so a failure there was discarded and the job died later with an unrelated-looking ImportError; measured with `false` in place of the pip call, exit was 0 and the user script still ran. Two tests were checking source text rather than behaviour. The CUDA-row test now calls _setup_commands with CU_VERSION set and unset and reads the URL, which catches keeping the os.environ.get line while hardcoding the channel -- the mutation the string match passed. The drift check now asserts the set of files that pin ExecuTorch, because a site changing to bare `executorch` stops matching the search entirely and left the old `assert found` satisfied. Also corrects two claims: 1.4.1 does ship _portable_lib.so, so the comment says its executorch/lib carries no standalone linkable runtime, and no install site gains --pre, since an exact .dev pin needs none. --- .github/scripts/install-torch-tensorrt.sh | 7 +- .github/workflows/docgen.yml | 7 +- setup.py | 11 ++- .../dynamo/executorch/test_executorch_pin.py | 91 ++++++++++++++++--- 4 files changed, 99 insertions(+), 17 deletions(-) diff --git a/.github/scripts/install-torch-tensorrt.sh b/.github/scripts/install-torch-tensorrt.sh index 0a2ab06d2fd..328f4090b89 100755 --- a/.github/scripts/install-torch-tensorrt.sh +++ b/.github/scripts/install-torch-tensorrt.sh @@ -70,8 +70,13 @@ else # channel, so deriving it would break this install on exactly the test and release runs the # index was added for. It is an extra index, not a replacement, and torch is already # force-reinstalled from ${INDEX_URL} above, so the pinned torch is not at risk from it. + # || exit 1 because line 1's `set -exou pipefail` is commented out and linux-test.yml + # concatenates this file ahead of the user script, so a failure here would otherwise be + # discarded and the job would die later with an unrelated-looking ImportError. Scoped to the + # line this change is responsible for; re-enabling set -e for the whole file is a + # pre-existing hazard worth a separate change. python -m pip install /opt/torch-tensorrt-builds/torch_tensorrt*.whl --use-deprecated=legacy-resolver \ - --extra-index-url "https://download.pytorch.org/whl/nightly/${CU_VERSION}" + --extra-index-url "https://download.pytorch.org/whl/nightly/${CU_VERSION}" || exit 1 fi echo -e "Running test script"; diff --git a/.github/workflows/docgen.yml b/.github/workflows/docgen.yml index fb390df6ea7..d31038762b7 100644 --- a/.github/workflows/docgen.yml +++ b/.github/workflows/docgen.yml @@ -42,7 +42,12 @@ jobs: run: echo "sha=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT - name: Build Python Package run: | - python3 -m pip install --pre ".[executorch]" --extra-index-url https://download.pytorch.org/whl/nightly/cu130 + # The pin exactly, not the range the extra expands to: --pre plus a nightly + # channel that gains a member daily would otherwise install whichever dev build + # is newest that morning while the delegate compiles from the pinned commit. + python3 -m pip install --pre ".[executorch]" \ + "executorch==$(python3 -c 'import yaml;print(yaml.safe_load(open("dev_dep_versions.yml"))["__executorch_version__"])')" \ + --extra-index-url https://download.pytorch.org/whl/nightly/cu130 - name: Install uv run: | curl -LsSf https://astral.sh/uv/install.sh | sh diff --git a/setup.py b/setup.py index 3a23f32ddfb..697db1c7a9d 100644 --- a/setup.py +++ b/setup.py @@ -209,13 +209,20 @@ def load_dep_info(): # minor against a backend built for this one. Patch releases stay allowed because they come off the # same release branch; the exact pin belongs in the runtime package, which does derive it. # The floor currently names a dev build, because the runtime split the delegate needs does not -# exist in any ExecuTorch release yet: 1.4.1 ships no shared libraries and no CUDA wheel at all. +# exist in any ExecuTorch release yet: 1.4.1's executorch/lib carries no standalone linkable +# runtime, and no CUDA wheel at all. # That also makes this range prefer a release as soon as one exists, since 1.5.0 sorts above # every 1.5.0.devN, so nothing here changes on the day it ships. _executorch_major, _executorch_minor = __executorch_version__.split(".")[:2] +# Linux-only, and not incidentally: the delegate is a Linux shared object, ExecuTorch publishes +# CUDA wheels for no other platform, and the feature is documented Linux-only. Without the marker +# the extra also has to resolve for the win32 entry in pyproject.toml's uv required-environments, +# where the only candidates are PyPI's, which stop at 1.4.1 -- so raising this floor above that +# makes `uv lock` fail outright rather than pick something older. EXECUTORCH_REQUIREMENT = ( f"executorch>={__executorch_version__}," - f"<{_executorch_major}.{int(_executorch_minor) + 1}" + f"<{_executorch_major}.{int(_executorch_minor) + 1}; " + "platform_system == 'Linux'" ) # TODO: Enable this once the runtime wheel is published to the PyTorch index. # EXECUTORCH_RUNTIME_REQUIREMENT = ( diff --git a/tests/py/dynamo/executorch/test_executorch_pin.py b/tests/py/dynamo/executorch/test_executorch_pin.py index 34b2ce63610..8a7472bde65 100644 --- a/tests/py/dynamo/executorch/test_executorch_pin.py +++ b/tests/py/dynamo/executorch/test_executorch_pin.py @@ -61,6 +61,22 @@ # with the path. USER_WORKFLOW_MARKER = "verify the end user's workflow" +# The files expected to pin ExecuTorch to a version, excluding dev_dep_versions.yml itself. +# Asserted as a set of files rather than a line count because a site that loses its version stops +# matching the search rather than reporting a mismatch, and because the line count legitimately +# differs between this branch and the stacked runtime-wheel change. +_EXPECTED_REQUIREMENT_FILES = { + ".github/workflows/executorch-build-linux.yml", + ".github/workflows/executorch-test-linux.yml", + "MODULE.bazel", + "docker/MODULE.bazel.docker", + "docker/MODULE.bazel.ngc", + "justfile", + "py/torch-tensorrt-executorch-runtime/README.md", + "py/torch-tensorrt-executorch-runtime/pyproject.toml", + "toolchains/ci_workspaces/MODULE.bazel.tmpl", +} + def _git(*arguments: str) -> str: return subprocess.run( @@ -104,7 +120,10 @@ def _expected(path: str, number: int, version: str) -> str: return f"executorch=={version}" major, minor = _release_line(version) - return f"executorch>={version},<{major}.{int(minor) + 1}" + # Only the top-level setup.py carries the Linux marker. It is the site uv resolves for the + # win32 required-environment, where PyPI's candidates stop below this floor. + marker = "; platform_system == 'Linux'" if path == "setup.py" and number > 1 else "" + return f"executorch>={version},<{major}.{int(minor) + 1}{marker}" def test_every_requirement_matches_the_pin() -> None: @@ -112,6 +131,7 @@ def test_every_requirement_matches_the_pin() -> None: wrong = [] found = 0 + seen = set() for line in _git( "grep", "-nI", "-E", r"executorch ?(===|==|>=|<=|~=|!=|<|>) ?[0-9]" ).splitlines(): @@ -121,10 +141,20 @@ def test_every_requirement_matches_the_pin() -> None: expected = _expected(path, int(number), version) for actual in REQUIREMENT.findall(text): found += 1 + seen.add(path) if actual != expected: wrong.append(f"{path}:{number} has {actual}, expected {expected}") assert found, "no ExecuTorch requirement found, so this test is not looking" + # The set of files, not just "nonzero": a site that drops its version entirely stops matching + # the search and silently leaves the result set, which is exactly how a lost pin would look. + assert seen == _EXPECTED_REQUIREMENT_FILES, ( + "the set of files pinning ExecuTorch changed.\n" + f" no longer pinning: {sorted(_EXPECTED_REQUIREMENT_FILES - seen)}\n" + f" newly pinning: {sorted(seen - _EXPECTED_REQUIREMENT_FILES)}\n" + "A file that lost its version does not appear in the search at all, so check for one " + "that now names bare `executorch` before updating the expected set." + ) assert not wrong, "\n ".join(["", *wrong]) @@ -180,19 +210,48 @@ def test_derived_requirements_match_the_pin() -> None: version = _versions()["__executorch_version__"] major, minor = _release_line(version) + # The Linux marker is part of the requirement: the extra has to resolve for the win32 entry + # in pyproject.toml's uv required-environments, where the only candidates are PyPI's and they + # stop below this floor, so without it `uv lock` fails outright. assert _setup_py_requirement(version) == ( - f"executorch>={version},<{major}.{int(minor) + 1}" + f"executorch>={version},<{major}.{int(minor) + 1}; platform_system == 'Linux'" ) assert _runner_requirement(REPO_ROOT) == f"executorch=={version}" -def test_the_runner_follows_the_row_s_cuda_version() -> None: - # The executorch suite is nightly-only, and the nightly matrix runs cu132 rows as well as - # cu130 ones, so a fixed channel would install a CUDA 13.0 ExecuTorch into a CUDA 13.2 job. - # PRs pin to cu130, which is why this cannot be caught by watching PR CI. - source = (REPO_ROOT / "tests/ci/runner.py").read_text(encoding="utf-8") - assert 'os.environ.get("CU_VERSION", "cu130")' in source - assert "nightly/cu130" not in source, "the channel is hardcoded again" +def test_the_runner_follows_the_row_s_cuda_version(monkeypatch) -> None: + """Call the runner and read the URL it builds, rather than matching its source. + + The executorch suite is nightly-only, and the nightly matrix runs cu132 rows as well as cu130 + ones, so a fixed channel would install a CUDA 13.0 ExecuTorch into a CUDA 13.2 job. PRs pin to + cu130, which is why watching PR CI cannot catch it. A source-text assertion could not catch it + either: keeping the ``os.environ.get`` line while hardcoding the URL passes one. + """ + sys.path.insert(0, str(REPO_ROOT / "tests")) + try: + from ci import runner + finally: + sys.path.pop(0) + + def channel_for(cu_version: str | None) -> str: + if cu_version is None: + monkeypatch.delenv("CU_VERSION", raising=False) + else: + monkeypatch.setenv("CU_VERSION", cu_version) + commands = runner._setup_commands("executorch") + urls = [ + argument + for command, _ in commands + for argument in command + if "download.pytorch.org" in argument + ] + assert len(urls) == 1, f"expected one index URL, got {urls}" + return urls[0] + + assert channel_for("cu132").endswith("/nightly/cu132") + assert channel_for("cu130").endswith("/nightly/cu130") + # Unset is a local run, and matches the index pyproject.toml resolves against by default. + assert channel_for(None).endswith("/nightly/cu130") def test_derived_requirements_roll_the_minor_over(tmp_path: Path) -> None: @@ -204,7 +263,10 @@ def test_derived_requirements_roll_the_minor_over(tmp_path: Path) -> None: f'__executorch_version__: "{version}"\n' ) - assert _setup_py_requirement(version) == f"executorch>={version},<1.10" + assert ( + _setup_py_requirement(version) + == f"executorch>={version},<1.10; platform_system == 'Linux'" + ) # No upper bound to roll over, but it must still track the pin it is given. assert _runner_requirement(tmp_path) == f"executorch=={version}" @@ -247,9 +309,11 @@ def test_the_pinned_commit_is_the_pinned_wheels_own_source() -> None: # deliberately omits so one pin serves every CUDA row. Compare the part they share. if installed_version.split("+")[0] != expected_version: # No evidence either way rather than a mismatch to report: only the wheel the pin names - # carries the commit the pin should agree with. Every install path in this repository - # now requests the pin exactly, so arriving here means the environment was built some - # other way, and that wheel's commit says nothing about whether the two pins agree. + # carries the commit the pin should agree with. Every CI install path that builds or + # tests the delegate requests the pin exactly -- the one deliberate range is the + # end-user install rehearsal in executorch-build-linux.yml -- so arriving here usually + # means the environment was built some other way, and that wheel's commit says nothing + # about whether the two pins agree. pytest.skip( f"the installed ExecuTorch is {installed_version}, not the pinned " f"{expected_version}, so its commit says nothing about whether the pins agree" @@ -267,6 +331,7 @@ def test_every_source_commit_matches_the_pin() -> None: wrong = [] found = 0 + seen = set() for path in _git("grep", "-lI", "-E", 'name = "executorch"').split(): for match in BAZEL_COMMIT.finditer((REPO_ROOT / path).read_text()): found += 1 From 9c50fdc0a0351bbc89882ff6e71f0d4bf211357b Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Sun, 23 Aug 2026 19:59:59 -0700 Subject: [PATCH 05/20] Put the index on the two printed installs, and fail the suite when setup fails The reference-runner README and the runtime's ImportError advice both printed `pip install "torch-tensorrt[executorch]"` with no index, and the commit that introduced the pin claimed otherwise. That claim was checked against the wrong branch: the fix existed only on the stacked runtime-wheel change, so this branch kept shipping the bare command. It matters more here than a docs nit, because this branch is what raises the floor above PyPI's newest executorch, so the bare command now cannot resolve at all. All seven printed install instructions carry the channel. The drift checks were counting the wrong thing. The requirement test asserted a set of paths, but two files carry two sites each, so either could drop one and stay in the set: turning `executorch-build-linux.yml:88` or `:128` into bare `executorch` both survived. The commit test only asserted nonzero, so any single MODULE.bazel could switch to `branch = "nightly"` unnoticed. Both now assert a per-file site count through one helper, as a minimum rather than an exact number so it holds on the stacked branch too, which removes one README site. All five mutations are caught and each names the file. Counting also surfaced a fifth commit site the nonzero check could not see: the reference-runner README's EXECUTORCH_REF shell default, correctly pinned but unaccounted for. docgen's pin was invisible to both: it is built by a shell substitution, so `$(` is not a digit and the literal search never saw it, and deleting the line survived. The derived-requirement test now runs the command docgen embeds and compares what it prints. A failed setup step printed `::warning::` and fell through to pytest. Most of the executorch suite gates on pytest.importorskip, so a failed ExecuTorch install skipped those files, left the rest passing, and reported success with a populated junit xml -- green exactly when the suite could not test what it exists to test. Driving the real run_suite with a failing setup step reproduced it, and returning the code makes it red without invoking pytest. Pre-existing, but this branch makes it likely to fire, since a nightly pin is eventually pruned from the channel. The pin tests themselves ran on nightly only, so none of this drift machinery ran on a PR or a push to main -- when a pin actually goes stale. They need no GPU, no ExecuTorch and not even torch, so they move to their own l0 suite in every lane, and the nightly suite excludes them by keyword so nothing runs twice. Also: the reference-runner README no longer says the extra installs the runtime wheel, since that requirement is commented out in setup.py. --- .../executorch_reference_runner/README.md | 14 ++- .../runtime.py | 3 +- tests/ci/runner.py | 13 +- tests/ci/suites.py | 14 +++ .../dynamo/executorch/test_executorch_pin.py | 111 ++++++++++++++---- 5 files changed, 123 insertions(+), 32 deletions(-) diff --git a/examples/executorch_reference_runner/README.md b/examples/executorch_reference_runner/README.md index 3fcd4232c02..1f635734ff2 100644 --- a/examples/executorch_reference_runner/README.md +++ b/examples/executorch_reference_runner/README.md @@ -98,9 +98,13 @@ build-executorch-reference-runner/lib/libexecutorch_trt_backend.a Install the complete prebuilt Python runtime and delegate: ```bash -pip install "torch-tensorrt[executorch]" +pip install "torch-tensorrt[executorch]" \ + --extra-index-url https://download.pytorch.org/whl/nightly/cu130 ``` +The index is required, not optional: the extra's ExecuTorch floor names a dev build, and PyPI's +`executorch` stops below it, so without the nightly channel pip reports no matching distribution. + Load and run the model without an ExecuTorch checkout or native build: ```bash @@ -109,9 +113,11 @@ python examples/executorch_reference_runner/load_model.py \ --num_runs=1 ``` -The extra installs `executorch` and the matching -`torch-tensorrt-executorch-runtime` wheel. That wheel contains an ExecuTorch -Python runtime with `TensorRTBackend` linked into its backend registry. +The extra installs `executorch` only. The +`torch-tensorrt-executorch-runtime` requirement in the top-level `setup.py` is +commented out until that wheel is published to the PyTorch index, so install it +separately for now. That wheel contains an ExecuTorch Python runtime with +`TensorRTBackend` linked into its backend registry. ### C++ diff --git a/py/torch-tensorrt-executorch-runtime/torch_tensorrt_executorch_runtime/runtime.py b/py/torch-tensorrt-executorch-runtime/torch_tensorrt_executorch_runtime/runtime.py index 27010326d7f..353e8729523 100644 --- a/py/torch-tensorrt-executorch-runtime/torch_tensorrt_executorch_runtime/runtime.py +++ b/py/torch-tensorrt-executorch-runtime/torch_tensorrt_executorch_runtime/runtime.py @@ -15,7 +15,8 @@ def _get_runtime() -> _Runtime: except ImportError as error: raise ImportError( "ExecuTorch Python inference requires the prebuilt delegate. " - 'Install it with: pip install "torch-tensorrt[executorch]"' + 'Install it with: pip install "torch-tensorrt[executorch]" ' + "--extra-index-url https://download.pytorch.org/whl/nightly/cu130" ) from error return get_runtime() diff --git a/tests/ci/runner.py b/tests/ci/runner.py index 5ee629eb7f3..727de20e474 100644 --- a/tests/ci/runner.py +++ b/tests/ci/runner.py @@ -220,7 +220,18 @@ def run_suite( print(f"==> setup[{step}]: {shlex.join(argv)}", flush=True) rc = subprocess.run(argv, cwd=scwd, env=env).returncode if rc != 0: - print(f"::warning::setup step {step!r} exited {rc}", flush=True) + # Fail rather than warn and continue. Most of the executorch suite gates on + # pytest.importorskip, so a failed install skips those files, leaves the rest + # passing, and reports success with a populated junit xml -- the run looks green + # precisely when the thing it exists to test is absent. This matters more now + # that the ExecuTorch pin names a nightly build, which is pruned from the + # channel eventually; when that happens this has to be loud. + print( + f"::error::setup step {step!r} exited {rc}, so the suite cannot test what " + "it was asked to test", + flush=True, + ) + return rc print(f"==> {suite.name} [{variant}]: {shlex.join(pytest_cmd)}", flush=True) rc = subprocess.run(pytest_cmd, cwd=cwd, env=env).returncode diff --git a/tests/ci/suites.py b/tests/ci/suites.py index 5e5fa2bf4b0..19cc2a7c3db 100644 --- a/tests/ci/suites.py +++ b/tests/ci/suites.py @@ -255,11 +255,25 @@ def for_variant(self, variant: Variant) -> dict[str, Any]: keyword="not test_000_ and not test_001_", jobs=_HEAVY, ), + Suite( + # Separate from the executorch suite below because it needs none of what that one needs: + # no GPU, no ExecuTorch, not even torch. Keeping it here in the nightly-only suite meant + # the drift checks never ran on a PR or on a push to main, which is exactly when a pin + # goes stale. Text and metadata only, so it is cheap enough for every lane. + "executorch-pin", + tier="l0", + lanes=("fast", "full", "nightly"), + paths=("executorch/test_executorch_pin.py",), + jobs="auto", + variants=("standard",), + platforms=("linux-x86_64",), + ), Suite( "executorch", tier="l2", lanes=("nightly",), paths=("executorch/",), + keyword="not test_executorch_pin", setup=("executorch",), jobs="auto", variants=("standard",), diff --git a/tests/py/dynamo/executorch/test_executorch_pin.py b/tests/py/dynamo/executorch/test_executorch_pin.py index 8a7472bde65..726c923a58b 100644 --- a/tests/py/dynamo/executorch/test_executorch_pin.py +++ b/tests/py/dynamo/executorch/test_executorch_pin.py @@ -18,8 +18,10 @@ import ast import os import re +import shlex import subprocess import sys +from collections import Counter from pathlib import Path import pytest @@ -61,22 +63,61 @@ # with the path. USER_WORKFLOW_MARKER = "verify the end user's workflow" -# The files expected to pin ExecuTorch to a version, excluding dev_dep_versions.yml itself. -# Asserted as a set of files rather than a line count because a site that loses its version stops -# matching the search rather than reporting a mismatch, and because the line count legitimately -# differs between this branch and the stacked runtime-wheel change. -_EXPECTED_REQUIREMENT_FILES = { - ".github/workflows/executorch-build-linux.yml", - ".github/workflows/executorch-test-linux.yml", - "MODULE.bazel", - "docker/MODULE.bazel.docker", - "docker/MODULE.bazel.ngc", - "justfile", - "py/torch-tensorrt-executorch-runtime/README.md", - "py/torch-tensorrt-executorch-runtime/pyproject.toml", - "toolchains/ci_workspaces/MODULE.bazel.tmpl", +# The files expected to pin ExecuTorch, mapped to how many sites each must carry, excluding +# dev_dep_versions.yml itself. A count per file rather than just the set of files, because a site +# that loses its version stops matching the search entirely rather than reporting a mismatch, and +# two of these files carry more than one site, so a set of paths let either quietly drop one. A +# minimum rather than an exact count, since the stacked runtime-wheel change removes one README +# site and an exact count could not hold on both branches. +_EXPECTED_REQUIREMENT_SITES = { + ".github/workflows/executorch-build-linux.yml": 2, + ".github/workflows/executorch-test-linux.yml": 1, + "MODULE.bazel": 1, + "docker/MODULE.bazel.docker": 1, + "docker/MODULE.bazel.ngc": 1, + "justfile": 1, + "py/torch-tensorrt-executorch-runtime/README.md": 1, + "py/torch-tensorrt-executorch-runtime/pyproject.toml": 1, + "toolchains/ci_workspaces/MODULE.bazel.tmpl": 1, } +# Same idea for the source commit the delegate compiles from. Five sites, not four: the +# reference-runner README names the ref as a shell default, which the old nonzero check could +# not distinguish from the four MODULE.bazel files. +_EXPECTED_COMMIT_SITES = { + "MODULE.bazel": 1, + "docker/MODULE.bazel.docker": 1, + "docker/MODULE.bazel.ngc": 1, + "toolchains/ci_workspaces/MODULE.bazel.tmpl": 1, + "examples/executorch_reference_runner/README.md": 1, +} + + +def _assert_every_site_present( + seen: "Counter[str]", expected: dict[str, int], what: str +) -> None: + """Require every expected file to still carry at least its expected number of sites. + + A site that drops its version or commit stops matching the search rather than reporting a + mismatch, so counting is the only way to notice it left. + """ + short = { + path: (count, seen.get(path, 0)) + for path, count in expected.items() + if seen.get(path, 0) < count + } + unexpected = sorted(set(seen) - set(expected)) + assert not short and not unexpected, ( + f"the set of sites {what} changed.\n" + + "".join( + f" {path} carries {actual} of {want} expected sites\n" + for path, (want, actual) in sorted(short.items()) + ) + + "".join(f" {path} is new and unaccounted for\n" for path in unexpected) + + "A site that lost its pin does not appear in the search at all, so look for one that " + "now names a bare reference before updating the expected counts." + ) + def _git(*arguments: str) -> str: return subprocess.run( @@ -131,7 +172,7 @@ def test_every_requirement_matches_the_pin() -> None: wrong = [] found = 0 - seen = set() + seen: Counter[str] = Counter() for line in _git( "grep", "-nI", "-E", r"executorch ?(===|==|>=|<=|~=|!=|<|>) ?[0-9]" ).splitlines(): @@ -141,20 +182,12 @@ def test_every_requirement_matches_the_pin() -> None: expected = _expected(path, int(number), version) for actual in REQUIREMENT.findall(text): found += 1 - seen.add(path) + seen[path] += 1 if actual != expected: wrong.append(f"{path}:{number} has {actual}, expected {expected}") assert found, "no ExecuTorch requirement found, so this test is not looking" - # The set of files, not just "nonzero": a site that drops its version entirely stops matching - # the search and silently leaves the result set, which is exactly how a lost pin would look. - assert seen == _EXPECTED_REQUIREMENT_FILES, ( - "the set of files pinning ExecuTorch changed.\n" - f" no longer pinning: {sorted(_EXPECTED_REQUIREMENT_FILES - seen)}\n" - f" newly pinning: {sorted(seen - _EXPECTED_REQUIREMENT_FILES)}\n" - "A file that lost its version does not appear in the search at all, so check for one " - "that now names bare `executorch` before updating the expected set." - ) + _assert_every_site_present(seen, _EXPECTED_REQUIREMENT_SITES, "pinning ExecuTorch") assert not wrong, "\n ".join(["", *wrong]) @@ -218,6 +251,29 @@ def test_derived_requirements_match_the_pin() -> None: ) assert _runner_requirement(REPO_ROOT) == f"executorch=={version}" + # docgen builds its overlay with a shell substitution, so neither the literal search nor the + # two helpers above can see it: `$(` is not a digit. Run the command it embeds and compare + # what it prints, which fails if the line is deleted or the key is renamed. + workflow = (REPO_ROOT / ".github/workflows/docgen.yml").read_text(encoding="utf-8") + embedded = re.search(r'"executorch==\$\((python3 -c \'[^\']+\')\)"', workflow) + assert embedded, ( + ".github/workflows/docgen.yml no longer pins ExecuTorch alongside the extra. It installs " + "with --pre from the nightly channel, so without the pin it resolves through the range " + "and takes whichever dev build is newest that day." + ) + printed = subprocess.run( + # The interpreter running the test, not the workflow's bare `python3`, which need not + # have pyyaml here. The argument list is the workflow's own. + [sys.executable, *shlex.split(embedded.group(1))[1:]], + cwd=REPO_ROOT, + capture_output=True, + text=True, + check=True, + ).stdout.strip() + assert ( + printed == version + ), f"docgen would install executorch=={printed}, pin says {version}" + def test_the_runner_follows_the_row_s_cuda_version(monkeypatch) -> None: """Call the runner and read the URL it builds, rather than matching its source. @@ -331,10 +387,11 @@ def test_every_source_commit_matches_the_pin() -> None: wrong = [] found = 0 - seen = set() + seen: Counter[str] = Counter() for path in _git("grep", "-lI", "-E", 'name = "executorch"').split(): for match in BAZEL_COMMIT.finditer((REPO_ROOT / path).read_text()): found += 1 + seen[path] += 1 if match.group(1) != commit: wrong.append(f"{path} compiles {match.group(1)}") @@ -344,8 +401,10 @@ def test_every_source_commit_matches_the_pin() -> None: continue for actual in NAMED_COMMIT.findall(text): found += 1 + seen[path] += 1 if actual != commit: wrong.append(f"{path}:{number} uses {actual}") assert found, "no ExecuTorch source commit found, so this test is not looking" + _assert_every_site_present(seen, _EXPECTED_COMMIT_SITES, "naming the source commit") assert not wrong, f"pin says {commit}:\n " + "\n ".join(wrong) From 856de85fce8bbc0da8fa5a8c9d8ace8b29d7f759 Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Sun, 23 Aug 2026 21:36:06 -0700 Subject: [PATCH 06/20] Assert the pin sites CI actually uses, and move the checks off the GPU lane The drift checks read derived strings and never the values CI consumes, so three ways of silently shipping no ExecuTorch all stayed green. Dropping the requirement from the runner's setup command left the step succeeding with nothing installed, after which the suite skips on importorskip; emptying EXTRAS_REQUIRE["executorch"] broke every documented `pip install "torch-tensorrt[executorch]"`; and the runtime README was recorded as carrying one pin site when it carries two, so either could go bare while the other satisfied the count -- the exact hole the per-file counts were added to close. The checks now assert the argument list the runner builds, the extras entries by AST, and the true per-file counts. All five mutations fail now. run_suite had no test at all, so replacing its `return rc` with `continue` restored the silent-green behaviour the fail-closed change exists to prevent. It is driven directly now, asserting both the propagated exit code and that pytest never runs once setup has failed. The pin suite was landing on a GPU runner: Suite.runner defaults to the matrix validation runner, so a five-second text check became one CUDA-container job per python and CUDA row, behind a wheel build. It runs in the Python lint job instead, which is already ubuntu-latest and needs none of that. The claim that it needs "not even torch" was also wrong -- tests/py/dynamo/conftest.py imports torch at module scope, which is why the lint invocation passes --noconftest. The shell tier that runs the whole executorch directory now excludes the pin file too, so the dedup claim is true of both paths rather than just the manifest one. uv.lock still records the pre-bump range with no platform marker. uv-update.yml regenerates it on pushes to main touching setup.py, and only that workflow runs `uv sync --locked`, so this breaks nothing -- but the drift was invisible, since the lock writes a bare specifier the pin search cannot match. A strict=False xfail records it and turns into a real failure via XPASS once the lock is refreshed. Editing the lock by hand was the wrong fix: its resolved entry and hashes come from a resolver run against the nightly index. Also removes internal shorthand from the PR description, and corrects a line citation for the one deliberate range in executorch-build-linux.yml. --- .github/workflows/linter.yml | 9 ++ tests/ci/suites.py | 13 -- .../dynamo/executorch/test_executorch_pin.py | 113 +++++++++++++++++- tests/py/utils/ci_helpers.sh | 4 +- 4 files changed, 124 insertions(+), 15 deletions(-) diff --git a/.github/workflows/linter.yml b/.github/workflows/linter.yml index af161851291..7d23759695b 100644 --- a/.github/workflows/linter.yml +++ b/.github/workflows/linter.yml @@ -88,3 +88,12 @@ jobs: python3 $GITHUB_WORKSPACE/.github/scripts/run_py_linter.py env: GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} + # Text and metadata only: no GPU, no ExecuTorch, no built wheel. It belongs on this runner + # rather than in the tests/ci manifest, where every suite becomes a CUDA-container job + # after `needs: build` -- one GPU row per python/CUDA combination for a check that takes + # seconds. --no-header keeps it clear of tests/py/dynamo/conftest.py, which imports torch. + - name: Check the ExecuTorch pin is consistent + run: | + cd $GITHUB_WORKSPACE + python3 -m pytest tests/py/dynamo/executorch/test_executorch_pin.py \ + -q --no-header -p no:cacheprovider --noconftest -o addopts="" diff --git a/tests/ci/suites.py b/tests/ci/suites.py index 19cc2a7c3db..fa9b6655c8e 100644 --- a/tests/ci/suites.py +++ b/tests/ci/suites.py @@ -255,19 +255,6 @@ def for_variant(self, variant: Variant) -> dict[str, Any]: keyword="not test_000_ and not test_001_", jobs=_HEAVY, ), - Suite( - # Separate from the executorch suite below because it needs none of what that one needs: - # no GPU, no ExecuTorch, not even torch. Keeping it here in the nightly-only suite meant - # the drift checks never ran on a PR or on a push to main, which is exactly when a pin - # goes stale. Text and metadata only, so it is cheap enough for every lane. - "executorch-pin", - tier="l0", - lanes=("fast", "full", "nightly"), - paths=("executorch/test_executorch_pin.py",), - jobs="auto", - variants=("standard",), - platforms=("linux-x86_64",), - ), Suite( "executorch", tier="l2", diff --git a/tests/py/dynamo/executorch/test_executorch_pin.py b/tests/py/dynamo/executorch/test_executorch_pin.py index 726c923a58b..05324cd3497 100644 --- a/tests/py/dynamo/executorch/test_executorch_pin.py +++ b/tests/py/dynamo/executorch/test_executorch_pin.py @@ -76,7 +76,8 @@ "docker/MODULE.bazel.docker": 1, "docker/MODULE.bazel.ngc": 1, "justfile": 1, - "py/torch-tensorrt-executorch-runtime/README.md": 1, + # Two: the install command and the prose sentence below it. + "py/torch-tensorrt-executorch-runtime/README.md": 2, "py/torch-tensorrt-executorch-runtime/pyproject.toml": 1, "toolchains/ci_workspaces/MODULE.bazel.tmpl": 1, } @@ -251,6 +252,39 @@ def test_derived_requirements_match_the_pin() -> None: ) assert _runner_requirement(REPO_ROOT) == f"executorch=={version}" + # The argument list CI actually runs, not just the string the helper derives. Dropping the + # requirement from the setup command left every one of these tests green: the step still + # succeeds, having installed no ExecuTorch, and the suite then skips on importorskip. + sys.path.insert(0, str(REPO_ROOT / "tests")) + from ci.runner import _executorch_requirement, _setup_commands + + argv = [arg for command, _cwd in _setup_commands("executorch") for arg in command] + assert argv.count(_executorch_requirement()) == 1, ( + "the executorch setup step does not install the pinned ExecuTorch exactly once: " + f"{argv}" + ) + + # And the extra every documented `pip install "torch-tensorrt[executorch]"` relies on. + # Emptying it left these tests green too. Read as source rather than imported, because + # importing the top-level setup.py executes it. + setup_tree = ast.parse((REPO_ROOT / "setup.py").read_text(encoding="utf-8")) + extras = next( + node.value + for node in ast.walk(setup_tree) + if isinstance(node, ast.Assign) + and any(getattr(t, "id", None) == "EXTRAS_REQUIRE" for t in node.targets) + ) + for key, value in zip(extras.keys, extras.values): + named = [ + element.id + for element in getattr(value, "elts", []) + if isinstance(element, ast.Name) + ] + assert named.count("EXECUTORCH_REQUIREMENT") == 1, ( + f"extra {getattr(key, 'value', key)!r} does not reference " + f"EXECUTORCH_REQUIREMENT exactly once: {named}" + ) + # docgen builds its overlay with a shell substitution, so neither the literal search nor the # two helpers above can see it: `$(` is not a digit. Run the command it embeds and compare # what it prints, which fails if the line is deleted or the key is renamed. @@ -408,3 +442,80 @@ def test_every_source_commit_matches_the_pin() -> None: assert found, "no ExecuTorch source commit found, so this test is not looking" _assert_every_site_present(seen, _EXPECTED_COMMIT_SITES, "naming the source commit") assert not wrong, f"pin says {commit}:\n " + "\n ".join(wrong) + + +@pytest.mark.unit +@pytest.mark.parametrize("setup_rc,expected", [(0, 0), (7, 7)]) +def test_a_failed_setup_step_stops_the_suite(monkeypatch, setup_rc, expected): + """A setup step that fails must fail the run, not warn and continue into pytest. + + Most of the ExecuTorch suite gates on ``pytest.importorskip``, so an install that fails makes + those files skip while everything else passes: the run reports success precisely when the + thing it exists to test is absent. That matters here because the pin names a nightly build, + which the channel eventually prunes. Replacing the ``return rc`` with ``continue`` kept every + other test in this file green, so assert on ``run_suite`` itself. + """ + sys.path.insert(0, str(REPO_ROOT / "tests")) + from ci import runner + + calls: list[list[str]] = [] + + class Completed: + def __init__(self, argv): + # The setup step is the pip install; anything else is pytest, which must not run + # at all once setup has failed. + self.returncode = setup_rc if "pip" in argv else 0 + + def record(argv, **kwargs): + calls.append(argv) + return Completed(argv) + + monkeypatch.setattr(runner.subprocess, "run", record) + suite = next(s for s in runner.SUITES if s.name == "executorch") + rc = runner.run_suite(suite, "standard") + + assert rc == expected, f"run_suite returned {rc}, expected {expected}" + ran_pytest = any("pytest" in " ".join(argv) for argv in calls) + assert ran_pytest is (setup_rc == 0), ( + "pytest ran even though a setup step failed" + if ran_pytest + else "pytest never ran even though every setup step succeeded" + ) + + +@pytest.mark.unit +@pytest.mark.xfail( + reason="uv.lock is regenerated by uv-update.yml on push to main, not by hand", + strict=False, +) +def test_the_lockfile_records_the_same_executorch_range_as_setup_py(): + """``uv.lock`` caches what ``setup.py`` declares, so it drifts when the pin moves. + + An xfail, not a failure: ``.github/workflows/uv-update.yml`` regenerates the lock on pushes to + main that touch ``setup.py``, and only that workflow runs ``uv sync --locked``, so a stale lock + breaks nothing here. Regenerating it by hand is worse than leaving it -- the resolved entry and + its hashes come from a resolver run against the nightly index, which cannot be faked in an + editor. This exists so the drift is visible and so it turns into a real failure, via XPASS, the + moment the lock is refreshed. The literal pin search cannot see this file: it writes + ``specifier = ">=1.4.1,<1.5"``, with no ``executorch==`` for the grep to match. + """ + lock = REPO_ROOT / "uv.lock" + if not lock.is_file(): + pytest.skip("no uv.lock in this checkout") + + recorded = set( + re.findall( + r'\{ name = "executorch", marker = "[^"]*", specifier = "([^"]+)" \}', + lock.read_text(encoding="utf-8"), + ) + ) + if not recorded: + pytest.skip("uv.lock records no executorch requirement") + + version = _versions()["__executorch_version__"] + major, minor = _release_line(version) + expected = f">={version},<{major}.{int(minor) + 1}" + assert recorded == {expected}, ( + f"uv.lock records executorch {sorted(recorded)} but the pin derives {expected!r}. " + "Run `uv lock --refresh` and commit the result." + ) diff --git a/tests/py/utils/ci_helpers.sh b/tests/py/utils/ci_helpers.sh index 7f3b4e5861f..f6b0bb40426 100755 --- a/tests/py/utils/ci_helpers.sh +++ b/tests/py/utils/ci_helpers.sh @@ -159,8 +159,10 @@ trt_tier_l2_dynamo_core() { } trt_tier_executorch() { + # The pin checks are excluded here because the lint workflow already runs them on a CPU + # runner; this tier needs a GPU and a built wheel, which they do not. ( cd "${TRT_REPO_ROOT}/tests/py/dynamo" - _trt_py -m pytest -ra $(_trt_nproc auto) --junitxml="$(_trt_xml executorch_tests_results)" executorch/ "$@" ) + _trt_py -m pytest -ra $(_trt_nproc auto) --junitxml="$(_trt_xml executorch_tests_results)" -k "not test_executorch_pin" executorch/ "$@" ) } trt_tier_l2_plugin() { From 0f186b8a7df44575e332ceafc67e08a3a68ea1e6 Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Sun, 23 Aug 2026 23:22:52 -0700 Subject: [PATCH 07/20] Make the pin checks run in CI, and test the properties they assert The lint step added for these checks could not execute. It invokes pytest, and the job installs .github/scripts/requirements.txt (PyGithub) plus the lint dependency group (black, clang-format); neither carries pytest, so the step exited 1 on "No module named pytest" before running a single assertion. pyyaml is needed too, because reading the pin file shells out to a yaml import. Both are installed now, and a test asserts the step exists and installs them, since deleting it is otherwise invisible: every assertion here still passes locally while nothing runs it on a pull request. Reproduced the failure in a stdlib-only venv and confirmed the fixed command passes with only those two. The step also gets if: always(), so an unrelated formatting failure earlier in the job no longer hides the pin check. Three properties the checks are supposed to protect had no coverage: Deleting both published extras from EXTRAS_REQUIRE left everything green. The loop iterated whatever keys existed, so removing them iterated nothing and was indistinguishable from them being correct. It now requires the two published keys to be present, and only those, which also stops an unrelated future extra from turning this red for naming no ExecuTorch. The workflow opt-out marker was ordinary prose, "verify the end user's workflow". Pasting that sentence above a requirement and widening it to a range passed. It is an explicit token now, and the upward scan walks through comment lines to find it, so a cosmetic line between the opt-out and the requirement neither reclassifies the site nor fails the build. Nothing asserted that printed install instructions name the nightly channel, which is why that regressed and was re-fixed three times in this change without anything noticing. One test covers all of them by reading whole blocks rather than single lines, since every instruction wraps and the index lands on a continuation. It catches the CI install of the locally built wheel too, which carries no extra and is the site that broke most often. Generated docs under docs/ are excluded: corrections belong in docsrc/, and the committed Sphinx output is stale there independently. Also: the executorch requirement now strips its local version label like the other four, so the wheel does not bind itself to one CUDA train; the lockfile xfail is strict, since a non-strict xfail reports XPASS and ignores it and so could never fail; the fail-closed comment says it covers every setup step rather than implying only executorch; an empty frozenset and the dead branch reading it are gone; and the sys.path mutations use monkeypatch so they do not leak between tests. --- .github/workflows/executorch-build-linux.yml | 3 +- .github/workflows/linter.yml | 11 +- py/torch-tensorrt-executorch-runtime/setup.py | 2 +- tests/ci/runner.py | 4 +- .../dynamo/executorch/test_executorch_pin.py | 129 ++++++++++++++++-- 5 files changed, 129 insertions(+), 20 deletions(-) diff --git a/.github/workflows/executorch-build-linux.yml b/.github/workflows/executorch-build-linux.yml index e8e88367c4f..33d190d0510 100644 --- a/.github/workflows/executorch-build-linux.yml +++ b/.github/workflows/executorch-build-linux.yml @@ -131,7 +131,8 @@ jobs: executorch_cmake_location="$(bazel query @executorch//:executorch/CMakeLists.txt --output=location)" export EXECUTORCH_SOURCE_DIR="$(dirname "${executorch_cmake_location%%:*}")" export EXECUTORCH_ROOT="${EXECUTORCH_SOURCE_DIR}" - # this is to verify the end user's workflow + # pin-check: range-ok -- this is to verify the end user's workflow, which resolves a + # range the way a user would rather than the exact artifact the delegate links. python -m pip install pyyaml --extra-index-url "${EXECUTORCH_INDEX_URL}" "executorch>=1.5.0.dev20260822,<1.6" python examples/torchtrt_executorch_example/export_static_shape.py \ --model_path="${RUNNER_TEMP}/torchtrt-reference-runner.pte" diff --git a/.github/workflows/linter.yml b/.github/workflows/linter.yml index 7d23759695b..76896dbd95c 100644 --- a/.github/workflows/linter.yml +++ b/.github/workflows/linter.yml @@ -40,6 +40,9 @@ jobs: run: | uv pip install --system -r $GITHUB_WORKSPACE/.github/scripts/requirements.txt python3 -c "import tomllib, subprocess; deps = tomllib.load(open('pyproject.toml', 'rb'))['dependency-groups']['lint']; subprocess.run(['uv', 'pip', 'install', '--system'] + deps, check=True)" + # The pin check below runs under pytest and shells out to a yaml reader. Neither is in + # requirements.txt or dependency-groups.lint, so the step exited 1 without running. + uv pip install --system pytest pyyaml - name: Lint C++ run: | cd $GITHUB_WORKSPACE @@ -82,6 +85,9 @@ jobs: run: | uv pip install --system -r $GITHUB_WORKSPACE/.github/scripts/requirements.txt python3 -c "import tomllib, subprocess; deps = tomllib.load(open('pyproject.toml', 'rb'))['dependency-groups']['lint']; subprocess.run(['uv', 'pip', 'install', '--system'] + deps, check=True)" + # The pin check below runs under pytest and shells out to a yaml reader. Neither is in + # requirements.txt or dependency-groups.lint, so the step exited 1 without running. + uv pip install --system pytest pyyaml - name: Lint Python run: | cd $GITHUB_WORKSPACE @@ -91,9 +97,10 @@ jobs: # Text and metadata only: no GPU, no ExecuTorch, no built wheel. It belongs on this runner # rather than in the tests/ci manifest, where every suite becomes a CUDA-container job # after `needs: build` -- one GPU row per python/CUDA combination for a check that takes - # seconds. --no-header keeps it clear of tests/py/dynamo/conftest.py, which imports torch. + # seconds. --noconftest keeps it clear of tests/py/dynamo/conftest.py, which imports torch. - name: Check the ExecuTorch pin is consistent + if: always() run: | cd $GITHUB_WORKSPACE python3 -m pytest tests/py/dynamo/executorch/test_executorch_pin.py \ - -q --no-header -p no:cacheprovider --noconftest -o addopts="" + -q --no-header -p no:cacheprovider --noconftest -o addopts="" diff --git a/py/torch-tensorrt-executorch-runtime/setup.py b/py/torch-tensorrt-executorch-runtime/setup.py index c78ffdfb5ad..287098134c5 100644 --- a/py/torch-tensorrt-executorch-runtime/setup.py +++ b/py/torch-tensorrt-executorch-runtime/setup.py @@ -158,7 +158,7 @@ def build_extension(self, ext: Extension) -> None: install_requires=[ f"torch=={public_version(torch.__version__)}", f"executorch=={public_version(executorch_version)}", - f"torch-tensorrt=={torchtrt_version()}", + f"torch-tensorrt=={public_version(torchtrt_version())}", f"{TENSORRT_DISTRIBUTION}=={tensorrt_version}", f"{CUDA_RUNTIME_DISTRIBUTION}=={cuda_runtime_version}", ], diff --git a/tests/ci/runner.py b/tests/ci/runner.py index 727de20e474..2d904c57771 100644 --- a/tests/ci/runner.py +++ b/tests/ci/runner.py @@ -220,7 +220,9 @@ def run_suite( print(f"==> setup[{step}]: {shlex.join(argv)}", flush=True) rc = subprocess.run(argv, cwd=scwd, env=env).returncode if rc != 0: - # Fail rather than warn and continue. Most of the executorch suite gates on + # Fail rather than warn and continue, for every setup step and not just the + # executorch one: a suite whose dependencies did not install cannot test what it + # was asked to test, whichever step failed. Most of the executorch suite gates on # pytest.importorskip, so a failed install skips those files, leaves the rest # passing, and reports success with a populated junit xml -- the run looks green # precisely when the thing it exists to test is absent. This matters more now diff --git a/tests/py/dynamo/executorch/test_executorch_pin.py b/tests/py/dynamo/executorch/test_executorch_pin.py index 05324cd3497..1db139e7ef4 100644 --- a/tests/py/dynamo/executorch/test_executorch_pin.py +++ b/tests/py/dynamo/executorch/test_executorch_pin.py @@ -56,12 +56,13 @@ # # Empty today: the only literal range left was the justfile's install recipe, and it installs # the wheel the delegate is compiled against, so it pins exactly like the rest. -RANGE_SITES: frozenset[str] = frozenset() # A step that exists to reproduce what a user runs belongs to the range group even inside a # file that otherwise pins build inputs, so the marker travels with the line rather than # with the path. -USER_WORKFLOW_MARKER = "verify the end user's workflow" +# An explicit opt-out token rather than prose. "verify the end user's workflow" is a sentence +# someone can write, or paste, above a requirement without meaning to license a range there. +USER_WORKFLOW_MARKER = "pin-check: range-ok" # The files expected to pin ExecuTorch, mapped to how many sites each must carry, excluding # dev_dep_versions.yml itself. A count per file rather than just the set of files, because a site @@ -133,16 +134,19 @@ def _versions() -> dict: def _wants_range(path: str, number: int) -> bool: - if path in RANGE_SITES: - return True - - # Scan upward rather than reading one fixed line, so reformatting the workflow cannot - # silently reclassify the requirement and fail the build for an unrelated reason. + # Scan upward past blanks and comment lines, so an explanatory line between the opt-out and + # the requirement neither reclassifies the site nor fails the build for a cosmetic reason. + # Only a comment carrying the token licenses a range; the first line of real content stops + # the scan, so the opt-out cannot leak onto an unrelated requirement further down. lines = (REPO_ROOT / path).read_text().splitlines() for line in reversed(lines[: number - 1]): - if not line.strip(): + stripped = line.strip() + if not stripped: continue - return USER_WORKFLOW_MARKER in line + if not stripped.startswith("#"): + return False + if USER_WORKFLOW_MARKER in stripped: + return True return False @@ -233,7 +237,7 @@ def _runner_requirement(root: Path) -> str: ).stdout.strip() -def test_derived_requirements_match_the_pin() -> None: +def test_derived_requirements_match_the_pin(monkeypatch) -> None: # setup.py and tests/ci/runner.py build their requirement from the pin, so the search # above cannot see them. Check the strings they produce instead. # @@ -255,7 +259,7 @@ def test_derived_requirements_match_the_pin() -> None: # The argument list CI actually runs, not just the string the helper derives. Dropping the # requirement from the setup command left every one of these tests green: the step still # succeeds, having installed no ExecuTorch, and the suite then skips on importorskip. - sys.path.insert(0, str(REPO_ROOT / "tests")) + monkeypatch.syspath_prepend(str(REPO_ROOT / "tests")) from ci.runner import _executorch_requirement, _setup_commands argv = [arg for command, _cwd in _setup_commands("executorch") for arg in command] @@ -274,7 +278,19 @@ def test_derived_requirements_match_the_pin() -> None: if isinstance(node, ast.Assign) and any(getattr(t, "id", None) == "EXTRAS_REQUIRE" for t in node.targets) ) + # The published extras have to exist, or the loop below iterates nothing and deleting both + # keys is indistinguishable from them being correct. Only these two: an unrelated future extra + # has no reason to name ExecuTorch, and requiring it of every key made this test the one that + # turns red when someone adds "debug". + published = {"executorch", "all"} + present = {key.value for key in extras.keys if isinstance(key, ast.Constant)} + assert published <= present, ( + f"setup.py must publish the {sorted(published)} extras, but EXTRAS_REQUIRE has " + f"{sorted(present)}. Every documented install command names one of them." + ) for key, value in zip(extras.keys, extras.values): + if getattr(key, "value", None) not in published: + continue named = [ element.id for element in getattr(value, "elts", []) @@ -317,7 +333,7 @@ def test_the_runner_follows_the_row_s_cuda_version(monkeypatch) -> None: cu130, which is why watching PR CI cannot catch it. A source-text assertion could not catch it either: keeping the ``os.environ.get`` line while hardcoding the URL passes one. """ - sys.path.insert(0, str(REPO_ROOT / "tests")) + monkeypatch.syspath_prepend(str(REPO_ROOT / "tests")) try: from ci import runner finally: @@ -371,7 +387,7 @@ def test_the_pinned_commit_is_the_pinned_wheels_own_source() -> None: Every published wheel records the commit it was built from, so the pairing is checkable rather than a convention. - Skipped rather than failed whenever the installed wheel is not the one the pin names — not + Skipped rather than failed whenever the installed wheel is not the one the pin names, not installed at all, a different member of a floating range, or built without git provenance. None of those say anything about whether the two pins agree, and this file stays readable offline. @@ -455,7 +471,7 @@ def test_a_failed_setup_step_stops_the_suite(monkeypatch, setup_rc, expected): which the channel eventually prunes. Replacing the ``return rc`` with ``continue`` kept every other test in this file green, so assert on ``run_suite`` itself. """ - sys.path.insert(0, str(REPO_ROOT / "tests")) + monkeypatch.syspath_prepend(str(REPO_ROOT / "tests")) from ci import runner calls: list[list[str]] = [] @@ -486,7 +502,7 @@ def record(argv, **kwargs): @pytest.mark.unit @pytest.mark.xfail( reason="uv.lock is regenerated by uv-update.yml on push to main, not by hand", - strict=False, + strict=True, ) def test_the_lockfile_records_the_same_executorch_range_as_setup_py(): """``uv.lock`` caches what ``setup.py`` declares, so it drifts when the pin moves. @@ -519,3 +535,86 @@ def test_the_lockfile_records_the_same_executorch_range_as_setup_py(): f"uv.lock records executorch {sorted(recorded)} but the pin derives {expected!r}. " "Run `uv lock --refresh` and commit the result." ) + + +@pytest.mark.unit +def test_every_printed_install_instruction_names_the_nightly_channel(): + """Every ``[executorch]`` install instruction has to carry the nightly index. + + ExecuTorch is published only to the nightly CUDA channel, so an instruction without + ``--extra-index-url`` resolves nothing and the user gets a bare "no matching distribution". + The property had regressed and been re-fixed three times across this change with nothing + asserting it, which is the signature of a property no test covers. + + Whole files rather than single lines: every one of these instructions wraps, so the extra and + the index land on different lines and a line-oriented check sees neither together. Tracked + files only, so a stale build directory cannot fail this. + """ + tracked = subprocess.run( + ["git", "ls-files", "-z"], + cwd=REPO_ROOT, + capture_output=True, + text=True, + check=True, + ).stdout.split("\0") + + # Two shapes need the channel: an instruction naming the [executorch] extra, and the CI + # install of a locally built torch-tensorrt wheel, whose ExecuTorch dependency resolves from + # the same index. The second is the site that regressed most often and carries no extra. + extra = re.compile( + r"""torch[-_]tensorrt\[[^]]*executorch[^]]*\]|torch_tensorrt\*\.whl""" + ) + missing = [] + for name in tracked: + if not name or not name.endswith( + (".py", ".sh", ".md", ".yml", ".yaml", ".rst", ".txt") + ): + continue + # This file states the rule; it is not itself an instruction. + if name == "tests/py/dynamo/executorch/test_executorch_pin.py": + continue + # docs/ is Sphinx output committed to the tree. Its sources live in docsrc/, which is + # where a correction has to go, so flagging the generated copy sends the fix to a file + # the next docs build overwrites. + if name.startswith("docs/"): + continue + path = REPO_ROOT / name + if not path.is_file(): + continue + text = path.read_text(encoding="utf-8", errors="replace") + for match in extra.finditer(text): + # The instruction is the pip invocation, so bound the window at the surrounding + # blank-line-separated block rather than guessing a fixed number of lines. + start = text.rfind("\n\n", 0, match.start()) + 1 + end = text.find("\n\n", match.end()) + block = text[start : end if end != -1 else len(text)] + if "download.pytorch.org/whl/nightly" not in block: + line = text.count("\n", 0, match.start()) + 1 + missing.append(f"{name}:{line}") + + assert not missing, ( + "these ExecuTorch install instructions do not name the nightly channel, so they " + f"resolve no ExecuTorch at all: {missing}" + ) + + +@pytest.mark.unit +def test_the_pin_check_runs_in_ci(): + """This file has to be invoked by something, or its assertions never execute. + + Two suites deselect it by name so it does not need an installed ExecuTorch on a GPU runner, + which leaves the lint job as the only path that runs it. Deleting that step is invisible + otherwise: every test here still passes locally while nothing runs them in CI. + """ + workflow = (REPO_ROOT / ".github/workflows/linter.yml").read_text(encoding="utf-8") + assert ( + "test_executorch_pin.py" in workflow + ), "no CI job invokes this file, so nothing here runs on a pull request" + # And it needs pytest, which neither requirements.txt nor dependency-groups.lint provides. + # Without this the step exits 1 on "No module named pytest" before running any assertion. + assert re.search( + r"uv pip install --system[^\n]*\bpytest\b", workflow + ), "the job that runs this file does not install pytest, so the step cannot execute" + assert re.search( + r"uv pip install --system[^\n]*\bpyyaml\b", workflow + ), "the job that runs this file does not install pyyaml, which _pinned_versions() shells out to" From 798ee32255495946b12cc3167dba3c88aec1a48b Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Mon, 24 Aug 2026 08:26:24 -0700 Subject: [PATCH 08/20] Let the pairing check run on the one lane that installs ExecuTorch The check that the two pins name one ExecuTorch could not run anywhere. It skips unless the installed wheel is exactly the pinned version, so it means something only on the nightly GPU lane, and that lane deselected it. The deselection is written as "not test_executorch_pin" to skip the source-consistency checks in the same file, but -k matches the module name in the test id, so it dropped every test in the module including this one. Both deselection sites now keep it by name. Proved it on a host with the pinned wheel installed, whose recorded git_version is the pinned commit: the check passes at the correct pins, fails when the commit pin names a different tree, and fails when the commit pin is deleted outright. Before this it was deselected in all three states. Bumping the version alone still skips, correctly, because the installed wheel is then not the one the pin names and its provenance says nothing about whether the two pins agree. A test asserts both sites keep it, since re-tightening either one to a bare module name is a small and plausible edit that would silently restore the gap. --- tests/ci/suites.py | 7 +++++- .../dynamo/executorch/test_executorch_pin.py | 22 +++++++++++++++++++ tests/py/utils/ci_helpers.sh | 2 +- 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/tests/ci/suites.py b/tests/ci/suites.py index fa9b6655c8e..7a75709c42a 100644 --- a/tests/ci/suites.py +++ b/tests/ci/suites.py @@ -260,7 +260,12 @@ def for_variant(self, variant: Variant) -> dict[str, Any]: tier="l2", lanes=("nightly",), paths=("executorch/",), - keyword="not test_executorch_pin", + keyword=( + # The pairing test is the one check here that needs a real ExecuTorch installed, so + # this lane is the only place it can run. Everything else in that file is a + # source-consistency check the lint job already covers. + "not test_executorch_pin or test_the_pinned_commit_is_the_pinned_wheels_own_source" + ), setup=("executorch",), jobs="auto", variants=("standard",), diff --git a/tests/py/dynamo/executorch/test_executorch_pin.py b/tests/py/dynamo/executorch/test_executorch_pin.py index 1db139e7ef4..78e3fb00ca1 100644 --- a/tests/py/dynamo/executorch/test_executorch_pin.py +++ b/tests/py/dynamo/executorch/test_executorch_pin.py @@ -62,6 +62,7 @@ # with the path. # An explicit opt-out token rather than prose. "verify the end user's workflow" is a sentence # someone can write, or paste, above a requirement without meaning to license a range there. +PAIRING_TEST = "test_the_pinned_commit_is_the_pinned_wheels_own_source" USER_WORKFLOW_MARKER = "pin-check: range-ok" # The files expected to pin ExecuTorch, mapped to how many sites each must carry, excluding @@ -618,3 +619,24 @@ def test_the_pin_check_runs_in_ci(): assert re.search( r"uv pip install --system[^\n]*\bpyyaml\b", workflow ), "the job that runs this file does not install pyyaml, which _pinned_versions() shells out to" + + +@pytest.mark.unit +def test_the_pairing_check_survives_the_gpu_lane_deselection() -> None: + """The one test here that needs a real ExecuTorch must not be deselected with the rest. + + Every other test in this file is a source-consistency check, so the GPU lane deselects the + whole module by name to avoid paying for them twice. ``-k`` matches the module name in the + test id, so a bare ``not test_executorch_pin`` drops the pairing check too, and that check + only means anything where ExecuTorch is installed. It was silently unreachable: it skips + when the wheel is not the pinned one, which is every environment except this lane. + """ + for path in ("tests/ci/suites.py", "tests/py/utils/ci_helpers.sh"): + text = (REPO_ROOT / path).read_text(encoding="utf-8") + assert ( + "not test_executorch_pin" in text + ), f"{path} no longer deselects this module" + assert PAIRING_TEST in text, ( + f"{path} deselects the whole module without keeping {PAIRING_TEST}, so the only " + "check that needs a real ExecuTorch installed runs nowhere" + ) diff --git a/tests/py/utils/ci_helpers.sh b/tests/py/utils/ci_helpers.sh index f6b0bb40426..e84889462b1 100755 --- a/tests/py/utils/ci_helpers.sh +++ b/tests/py/utils/ci_helpers.sh @@ -162,7 +162,7 @@ trt_tier_executorch() { # The pin checks are excluded here because the lint workflow already runs them on a CPU # runner; this tier needs a GPU and a built wheel, which they do not. ( cd "${TRT_REPO_ROOT}/tests/py/dynamo" - _trt_py -m pytest -ra $(_trt_nproc auto) --junitxml="$(_trt_xml executorch_tests_results)" -k "not test_executorch_pin" executorch/ "$@" ) + _trt_py -m pytest -ra $(_trt_nproc auto) --junitxml="$(_trt_xml executorch_tests_results)" -k "not test_executorch_pin or test_the_pinned_commit_is_the_pinned_wheels_own_source" executorch/ "$@" ) } trt_tier_l2_plugin() { From 8114d03fbb7075946aee4f12d7bd47909d1012ef Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Mon, 24 Aug 2026 09:56:28 -0700 Subject: [PATCH 09/20] Make the pin guards read values instead of nearby text Every guard added in this change asserted that a string appeared somewhere in a file, so each certified the state it was written to prevent. The keyword guard grepped for the kept test's name. Changing "or" to "and" in both -k expressions left it green, and that expression collects nothing at all, which is worse than the bug the guard exists to catch. Reverting the expressions and leaving the name behind in a comment also left it green, and a comment explaining the keyword sits directly above it, which is where an editor would naturally write that name. It now runs pytest's own collection under each expression and requires exactly the pairing test to come back. The CI guard searched the workflow as one blob, so it could not tell which job it was reading. The same commit that fixed the lint failure also added pytest and pyyaml to cpp-linting, which has no pin check, so deleting them from the job that does run it stayed green and would have restored the original failure invisibly. Neutralising the command while leaving its filename in a shell comment, and setting a falsy step condition, were also green. It now parses the workflow, finds the job that actually invokes pytest on this file, and requires the installs in an earlier step of that same job. The unused installs are gone from cpp-linting. The requirement pattern captured an equality prefix and stopped, so "executorch==PIN,!=PIN", a specifier that excludes the version it appears to pin, compared equal to the pin. The same truncation rejected the legal PEP 508 spelling with spaces around the operator. Requirements are parsed now and compared as specifier sets, with a check that the pinned version actually satisfies them. The site scanner counted raw search hits, so gutting a pin to a bare "executorch" while putting the exact pin in a comment in the same file kept the per-file minimum satisfied. Comments no longer count, except in the bazel repositories, where the annotation beside the pinned commit is the only record of which wheel that commit belongs to. Also corrected two claims this change made: the executorch tier is reachable from a pull request through executorch-test-linux.yml as well as the nightly manifest, so it is not the only route, and the shell helper now says why one test is kept out of the deselection. --- .github/workflows/linter.yml | 3 - tests/ci/suites.py | 2 +- .../dynamo/executorch/test_executorch_pin.py | 192 +++++++++++++++--- tests/py/utils/ci_helpers.sh | 4 +- 4 files changed, 172 insertions(+), 29 deletions(-) diff --git a/.github/workflows/linter.yml b/.github/workflows/linter.yml index 76896dbd95c..26e45662c8b 100644 --- a/.github/workflows/linter.yml +++ b/.github/workflows/linter.yml @@ -40,9 +40,6 @@ jobs: run: | uv pip install --system -r $GITHUB_WORKSPACE/.github/scripts/requirements.txt python3 -c "import tomllib, subprocess; deps = tomllib.load(open('pyproject.toml', 'rb'))['dependency-groups']['lint']; subprocess.run(['uv', 'pip', 'install', '--system'] + deps, check=True)" - # The pin check below runs under pytest and shells out to a yaml reader. Neither is in - # requirements.txt or dependency-groups.lint, so the step exited 1 without running. - uv pip install --system pytest pyyaml - name: Lint C++ run: | cd $GITHUB_WORKSPACE diff --git a/tests/ci/suites.py b/tests/ci/suites.py index 7a75709c42a..5a78bd2756f 100644 --- a/tests/ci/suites.py +++ b/tests/ci/suites.py @@ -262,7 +262,7 @@ def for_variant(self, variant: Variant) -> dict[str, Any]: paths=("executorch/",), keyword=( # The pairing test is the one check here that needs a real ExecuTorch installed, so - # this lane is the only place it can run. Everything else in that file is a + # it has to survive this deselection. Everything else in that file is a # source-consistency check the lint job already covers. "not test_executorch_pin or test_the_pinned_commit_is_the_pinned_wheels_own_source" ), diff --git a/tests/py/dynamo/executorch/test_executorch_pin.py b/tests/py/dynamo/executorch/test_executorch_pin.py index 78e3fb00ca1..3ba9941f261 100644 --- a/tests/py/dynamo/executorch/test_executorch_pin.py +++ b/tests/py/dynamo/executorch/test_executorch_pin.py @@ -17,6 +17,7 @@ import ast import os +import pathlib import re import shlex import subprocess @@ -34,10 +35,37 @@ # that drifted from the pin, and it should be visible to the search rather than silently # exempt. Operators are named through the pattern rather than spelled out in prose here, # because the search below reads this file too and an example would read as such a site. +# The whole specifier set, not just its first clause. Capturing up to the first comma compared +# equal on "executorch==PIN,!=PIN", a specifier that excludes the very version it appears to pin, +# and rejected the legal PEP 508 spelling with spaces around the operator. REQUIREMENT = re.compile( - r"executorch\s*(?:===|==|>=|<=|~=|!=|<|>)\s*[0-9][^\"'\s,`]*(?:,\s*<[0-9.]+)?" + r"executorch\s*(?:===|==|>=|<=|~=|!=|<|>)\s*[^\"'\s`,]+" + r"(?:\s*,\s*(?:===|==|>=|<=|~=|!=|<|>)\s*[^\"'\s`,]+)*" ) + +def _requirement_disagrees(actual: str, expected: str, version: str) -> str: + """Why ``actual`` is not the pinned requirement, or an empty string if it is. + + Compares parsed specifier sets rather than matched text. Raw text equality could not see a + clause the pattern did not capture, and treated whitespace the specification allows as drift. + """ + from packaging.requirements import InvalidRequirement, Requirement + + try: + parsed = Requirement(actual) + wanted = Requirement(expected) + except InvalidRequirement as error: + return f"which is not a valid requirement ({error})" + if parsed.name != wanted.name: + return f"which names {parsed.name}, not {wanted.name}" + if not parsed.specifier.contains(version, prereleases=True): + return f"whose specifier excludes the pinned {version}" + if set(parsed.specifier) != set(wanted.specifier): + return f"expected {expected}" + return "" + + # The bazel repository puts the commit on its own line, so this one has to run against file # contents rather than a git grep line. BAZEL_COMMIT = re.compile( @@ -173,6 +201,34 @@ def _expected(path: str, number: int, version: str) -> str: return f"executorch>={version},<{major}.{int(minor) + 1}{marker}" +# The bazel repositories annotate their pinned commit with the wheel it corresponds to, in a +# comment, because bazel fetches by commit and has no requirement string to carry. Those are the +# only comment sites that count as pins, and the commit beside them is checked separately. +_ANNOTATED_COMMIT_SITES = frozenset( + { + "MODULE.bazel", + "docker/MODULE.bazel.docker", + "docker/MODULE.bazel.ngc", + "toolchains/ci_workspaces/MODULE.bazel.tmpl", + } +) + + +def _is_commented_out(path: str, text: str) -> bool: + """Whether this requirement sits in a comment rather than in live configuration. + + A comment is not a pin: a site could be gutted to a bare ``executorch`` while the exact pin + lived on in a comment in the same file, which kept the per-file minimum satisfied and left the + real requirement unpinned. + """ + if path in _ANNOTATED_COMMIT_SITES: + return False + stripped = text.strip() + if path.endswith((".md", ".rst", ".txt")): + return False + return stripped.startswith(("#", "//", "/*", "*")) + + def test_every_requirement_matches_the_pin() -> None: version = _versions()["__executorch_version__"] @@ -185,12 +241,18 @@ def test_every_requirement_matches_the_pin() -> None: path, number, text = line.split(":", 2) if path == VERSIONS.name: continue + if _is_commented_out(path, text): + # A comment is not a pin. Counting raw matches meant a site could be gutted to a bare + # "executorch" while the exact pin lived on in a comment in the same file, keeping the + # per-file minimum satisfied. + continue expected = _expected(path, int(number), version) for actual in REQUIREMENT.findall(text): found += 1 seen[path] += 1 - if actual != expected: - wrong.append(f"{path}:{number} has {actual}, expected {expected}") + reason = _requirement_disagrees(actual, expected, version) + if reason: + wrong.append(f"{path}:{number} has {actual}, {reason}") assert found, "no ExecuTorch requirement found, so this test is not looking" _assert_every_site_present(seen, _EXPECTED_REQUIREMENT_SITES, "pinning ExecuTorch") @@ -607,36 +669,118 @@ def test_the_pin_check_runs_in_ci(): which leaves the lint job as the only path that runs it. Deleting that step is invisible otherwise: every test here still passes locally while nothing runs them in CI. """ - workflow = (REPO_ROOT / ".github/workflows/linter.yml").read_text(encoding="utf-8") + # Parse the workflow and assert inside the owning job. Searching the file as one blob could + # not tell which job it was reading, so an identical install line in a sibling job that has no + # pin check satisfied it, and deleting the real one stayed green. A commented-out step also + # vanishes from the parse, where a text search still finds it. + import yaml + + workflow = yaml.safe_load( + (REPO_ROOT / ".github/workflows/linter.yml").read_text(encoding="utf-8") + ) + # Match a live pytest invocation, not the filename anywhere in the script. Neutralising the + # command and leaving it in a shell comment satisfied a plain substring test. + invocation = re.compile( + rf"^\s*[^#\n]*\bpytest\b[^\n]*{re.escape(pathlib.Path(__file__).name)}", + re.MULTILINE, + ) + owning = [ + (name, job, step) + for name, job in workflow["jobs"].items() + for step in job.get("steps", []) + if invocation.search(step.get("run") or "") + ] + assert owning, "no CI job invokes this file, so nothing here runs on a pull request" + name, job, step = owning[0] + + # A falsy condition disables the step while leaving every string in place. + condition = str(step.get("if", "always()")) + assert condition in { + "always()", + "success()", + "success() || failure()", + }, f"the pin check in {name} runs under {condition!r}, which may never be true" assert ( - "test_executorch_pin.py" in workflow - ), "no CI job invokes this file, so nothing here runs on a pull request" - # And it needs pytest, which neither requirements.txt nor dependency-groups.lint provides. - # Without this the step exits 1 on "No module named pytest" before running any assertion. - assert re.search( - r"uv pip install --system[^\n]*\bpytest\b", workflow - ), "the job that runs this file does not install pytest, so the step cannot execute" - assert re.search( - r"uv pip install --system[^\n]*\bpyyaml\b", workflow - ), "the job that runs this file does not install pyyaml, which _pinned_versions() shells out to" + "--collect-only" not in step["run"] + ), f"the pin check in {name} only collects tests, so no assertion executes" + + # pytest and pyyaml must be installed by an earlier step of the SAME job: neither + # requirements.txt nor dependency-groups.lint carries them, and without them the step exits 1 + # on "No module named pytest" before running any assertion. + steps = job["steps"] + earlier = "\n".join(s.get("run") or "" for s in steps[: steps.index(step)]) + for package in ("pytest", "pyyaml"): + assert re.search( + rf"uv pip install --system[^\n]*\b{package}\b", earlier + ), f"job {name} does not install {package} before the pin check, so the step cannot run" @pytest.mark.unit def test_the_pairing_check_survives_the_gpu_lane_deselection() -> None: """The one test here that needs a real ExecuTorch must not be deselected with the rest. - Every other test in this file is a source-consistency check, so the GPU lane deselects the - whole module by name to avoid paying for them twice. ``-k`` matches the module name in the + Every other test in this file is a source-consistency check, so the executorch tier deselects + the whole module by name to avoid paying for them twice. ``-k`` matches the module name in the test id, so a bare ``not test_executorch_pin`` drops the pairing check too, and that check - only means anything where ExecuTorch is installed. It was silently unreachable: it skips - when the wheel is not the pinned one, which is every environment except this lane. + only means anything where ExecuTorch is installed, which is nowhere the lint job runs. + + Two routes reach this tier: the nightly manifest suite in ``tests/ci/suites.py``, and + ``executorch-test-linux.yml``, which installs the pinned wheel and runs on pull requests once + the runtime build succeeds. Both go through one of the two keyword expressions checked here. """ - for path in ("tests/ci/suites.py", "tests/py/utils/ci_helpers.sh"): + # Run pytest's own collection under each expression rather than grepping for the name. A + # string test passes on "and" in place of "or", which collects nothing at all, and on the + # name surviving only in a comment. Both leave the pairing check unreachable. + module = pathlib.Path(__file__).name + for path, pattern in ( + ("tests/ci/suites.py", r'keyword=\(\s*(?:#[^\n]*\n\s*)*"([^"]+)"'), + # Anchored on the executorch junitxml name, because the file passes -k in several + # functions and the first match belongs to a different tier. + ( + "tests/py/utils/ci_helpers.sh", + r'executorch_tests_results[^\n]*?-k "([^"]+)"', + ), + ): text = (REPO_ROOT / path).read_text(encoding="utf-8") + found = re.search(pattern, text) assert ( - "not test_executorch_pin" in text - ), f"{path} no longer deselects this module" - assert PAIRING_TEST in text, ( - f"{path} deselects the whole module without keeping {PAIRING_TEST}, so the only " - "check that needs a real ExecuTorch installed runs nowhere" + found + ), f"{path} no longer passes a single -k expression this test can read" + keyword = found.group(1) + assert "not test_executorch_pin" in keyword, ( + f"{path} no longer deselects this module, so the source-consistency checks here " + "would run twice" + ) + selected = subprocess.run( + [ + sys.executable, + "-m", + "pytest", + str(pathlib.Path(__file__).parent), + "--collect-only", + "-q", + "--noconftest", + "-p", + "no:cacheprovider", + "-o", + "addopts=", + "-k", + keyword, + ], + capture_output=True, + text=True, + cwd=REPO_ROOT, + ).stdout + assert f"{module}::{PAIRING_TEST}" in selected, ( + f"{path} runs pytest with -k {keyword!r}, which does not select {PAIRING_TEST}, so " + "the only check that needs a real ExecuTorch installed runs nowhere" + ) + others = [ + line + for line in selected.splitlines() + if module in line and PAIRING_TEST not in line + ] + assert not others, ( + f"{path} selects {len(others)} other tests from this module, which the lane " + f"deselects deliberately: {others[:2]}" ) diff --git a/tests/py/utils/ci_helpers.sh b/tests/py/utils/ci_helpers.sh index e84889462b1..ff24cbf2e7e 100755 --- a/tests/py/utils/ci_helpers.sh +++ b/tests/py/utils/ci_helpers.sh @@ -160,7 +160,9 @@ trt_tier_l2_dynamo_core() { trt_tier_executorch() { # The pin checks are excluded here because the lint workflow already runs them on a CPU - # runner; this tier needs a GPU and a built wheel, which they do not. + # runner; this tier needs a GPU and a built wheel, which they do not. The one exception is + # kept by name: it compares the pinned commit against the installed wheel's own recorded + # source, so it needs an ExecuTorch the lint runner does not have and skips everywhere else. ( cd "${TRT_REPO_ROOT}/tests/py/dynamo" _trt_py -m pytest -ra $(_trt_nproc auto) --junitxml="$(_trt_xml executorch_tests_results)" -k "not test_executorch_pin or test_the_pinned_commit_is_the_pinned_wheels_own_source" executorch/ "$@" ) } From 302559770fbc102f68e3d19028c15da0f3ebe2ad Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Mon, 24 Aug 2026 11:45:57 -0700 Subject: [PATCH 10/20] Stop the lockfile check from going red repo-wide on the next refresh The uv.lock check was a strict xfail. uv.lock records ">=1.4.1,<1.5" while the pin derives ">=1.5.0.dev20260822,<1.6", so the assertion fails and the xfail is satisfied. Refresh the lock and the assertion passes, and a strict xfail reports that pass as a failure. The lint step runs this file with if: always() on every pull request, so one lock refresh would have made the lint job red on every subsequent pull request, for a file none of them touched, until someone edited this test. Measured: baseline 1 xfailed, and 1 failed once the specifier is bumped. My own docstring claimed the lock is machine-generated and not edited by hand. Two hand refreshes landed on 2026-08-23, inside ordinary version-bump changes, so that was wrong as well. It now accepts both resting states and only fails where something is actually wrong: a recorded range whose lower bound is above the pin, which means the lock names an ExecuTorch this repository does not pin. Behind the pin passes, the derived range passes, and ">=1.6,<1.7", an open-ended ">=1.7" and "==1.9.0" all fail. Comparing lower bounds rather than probing the specifier with sample versions: an upper-bound test missed the open-ended case, and a low sentinel version called the ordinary behind-the-pin state a failure. --- .../dynamo/executorch/test_executorch_pin.py | 53 ++++++++++++++----- 1 file changed, 39 insertions(+), 14 deletions(-) diff --git a/tests/py/dynamo/executorch/test_executorch_pin.py b/tests/py/dynamo/executorch/test_executorch_pin.py index 3ba9941f261..d0392d95cb5 100644 --- a/tests/py/dynamo/executorch/test_executorch_pin.py +++ b/tests/py/dynamo/executorch/test_executorch_pin.py @@ -563,20 +563,22 @@ def record(argv, **kwargs): @pytest.mark.unit -@pytest.mark.xfail( - reason="uv.lock is regenerated by uv-update.yml on push to main, not by hand", - strict=True, -) def test_the_lockfile_records_the_same_executorch_range_as_setup_py(): """``uv.lock`` caches what ``setup.py`` declares, so it drifts when the pin moves. - An xfail, not a failure: ``.github/workflows/uv-update.yml`` regenerates the lock on pushes to - main that touch ``setup.py``, and only that workflow runs ``uv sync --locked``, so a stale lock - breaks nothing here. Regenerating it by hand is worse than leaving it -- the resolved entry and - its hashes come from a resolver run against the nightly index, which cannot be faked in an - editor. This exists so the drift is visible and so it turns into a real failure, via XPASS, the - moment the lock is refreshed. The literal pin search cannot see this file: it writes - ``specifier = ">=1.4.1,<1.5"``, with no ``executorch==`` for the grep to match. + A stale lock breaks nothing here, because only ``uv-update.yml`` runs ``uv sync --locked``, + and its resolved hashes come from a resolver run against the nightly index that cannot be + faked in an editor. So this accepts two states: the range the pin derives, and a range that + predates the pin. + + It used to be a strict xfail, which meant the moment anyone refreshed the lock the assertion + passed and pytest reported that pass as a failure. The lint step runs this file on every pull + request, so that would have turned the lint job red repo-wide for a file none of those pull + requests touched. Nor is the lock only machine-generated: it was hand-refreshed twice inside + ordinary version-bump changes on 2026-08-23. + + The literal pin search cannot see this file: it writes ``specifier = ">=1.4.1,<1.5"``, with no + ``executorch==`` for the grep to match. """ lock = REPO_ROOT / "uv.lock" if not lock.is_file(): @@ -594,9 +596,32 @@ def test_the_lockfile_records_the_same_executorch_range_as_setup_py(): version = _versions()["__executorch_version__"] major, minor = _release_line(version) expected = f">={version},<{major}.{int(minor) + 1}" - assert recorded == {expected}, ( - f"uv.lock records executorch {sorted(recorded)} but the pin derives {expected!r}. " - "Run `uv lock --refresh` and commit the result." + if recorded == {expected}: + return + + # Behind the pin is the expected resting state until the lock is regenerated. Ahead of it is + # not: that means the lock names an ExecuTorch this repository does not pin. + from packaging.specifiers import SpecifierSet + from packaging.version import Version + + # Ahead means the range's own lower bound is above the pin. Probing the specifier with sample + # versions was fragile in both directions: an upper-bound test missed an open-ended ">=1.7", + # and a low sentinel called the ordinary behind-the-pin state a failure. + pinned = Version(version) + ahead = [ + entry + for entry in sorted(recorded) + if any( + clause.operator in {">=", ">", "==", "~=", "==="} + and Version(clause.version.rstrip("*") or "0") > pinned + for clause in SpecifierSet(entry) + ) + ] + assert not ahead, ( + f"uv.lock records executorch {ahead}, which is ahead of the pinned {version}. The pin " + "derives " + + repr(expected) + + ", so run `uv lock --refresh` and commit the result." ) From d7465d060e6f202a37a21ac9b0a4562fa5bb8632 Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Mon, 24 Aug 2026 13:30:59 -0700 Subject: [PATCH 11/20] Stop a test executing Python read out of a workflow file test_derived_requirements_match_the_pin extracted the python3 -c one-liner from docgen.yml and ran it. Whatever that line said got executed on every pull request: rewriting it to write a file left the test green and the file written. Same class as the bash -c problem fixed in test_api.py last round, still live here. It now compares the command as text against the exact form that reads __executorch_version__ out of dev_dep_versions.yml. Four mutations caught, including a payload that writes a file and still prints the right version, with nothing executed. The CI reachability guard tested the raw string for "--collect-only", so it accepted "--co", pytest's own documented short form, which collects and asserts nothing. It also could not see an exit status being discarded. Now tokenised: --collect-only, --co, -h, --help, a "||" short-circuit and continue-on-error are all rejected, and all five are caught where four previously survived. The comment exemption for .md/.rst/.txt defeated exactly the threat its docstring names. Install commands live in prose files, so exempting them made a comment count as a pin there: the runtime README's install line gutted to a bare "executorch" passed as long as a decoy "# executorch==" sat beside it, and failed only with no comment present. The exemption is gone, and trailing comments no longer count either, since a decoy after a live requirement on the same line kept the per-file count satisfied. Five mutations caught, baseline green. --- .../dynamo/executorch/test_executorch_pin.py | 64 ++++++++++++++----- 1 file changed, 47 insertions(+), 17 deletions(-) diff --git a/tests/py/dynamo/executorch/test_executorch_pin.py b/tests/py/dynamo/executorch/test_executorch_pin.py index d0392d95cb5..1c70510d438 100644 --- a/tests/py/dynamo/executorch/test_executorch_pin.py +++ b/tests/py/dynamo/executorch/test_executorch_pin.py @@ -224,11 +224,23 @@ def _is_commented_out(path: str, text: str) -> bool: if path in _ANNOTATED_COMMIT_SITES: return False stripped = text.strip() - if path.endswith((".md", ".rst", ".txt")): - return False + # No exemption for prose. Returning False for .md/.rst/.txt defeated the threat named above, + # because it made a comment count as a pin in exactly the files where install commands live: a + # README install line gutted to a bare "executorch" passed as long as a decoy "# executorch==" + # sat beside it. A "#" inside a fenced shell block is a shell comment, the same as anywhere else. return stripped.startswith(("#", "//", "/*", "*")) +def _without_trailing_comment(path: str, text: str) -> str: + """``text`` up to a trailing ``#`` or ``//`` comment, unless the site annotates its pin there.""" + if path in _ANNOTATED_COMMIT_SITES: + return text + for marker in ("#", "//"): + if marker in text: + text = text.split(marker, 1)[0] + return text + + def test_every_requirement_matches_the_pin() -> None: version = _versions()["__executorch_version__"] @@ -247,7 +259,12 @@ def test_every_requirement_matches_the_pin() -> None: # per-file minimum satisfied. continue expected = _expected(path, int(number), version) - for actual in REQUIREMENT.findall(text): + # A trailing comment is not a pin either. Skipping whole-line comments was not enough: a + # live install gutted to a bare "executorch" with a decoy "# executorch==" after it on + # the same line kept the per-file count satisfied and left the install unpinned. The + # annotated commit sites write their pin as a whole-line comment, which is handled above, + # so nothing legitimate is lost here. + for actual in REQUIREMENT.findall(_without_trailing_comment(path, text)): found += 1 seen[path] += 1 reason = _requirement_disagrees(actual, expected, version) @@ -374,18 +391,20 @@ def test_derived_requirements_match_the_pin(monkeypatch) -> None: "with --pre from the nightly channel, so without the pin it resolves through the range " "and takes whichever dev build is newest that day." ) - printed = subprocess.run( - # The interpreter running the test, not the workflow's bare `python3`, which need not - # have pyyaml here. The argument list is the workflow's own. - [sys.executable, *shlex.split(embedded.group(1))[1:]], - cwd=REPO_ROOT, - capture_output=True, - text=True, - check=True, - ).stdout.strip() - assert ( - printed == version - ), f"docgen would install executorch=={printed}, pin says {version}" + # Compared as text, not executed. Running it meant whatever that line said got executed on + # every pull request: rewriting the one-liner to write a file left the test green and the file + # written. It has to read __executorch_version__ out of dev_dep_versions.yml and print nothing + # else, which is the property that makes the shell substitution equal the pin. + command = embedded.group(1) + reads_the_pin = re.fullmatch( + r"""python3 -c 'import yaml;print\(yaml\.safe_load\(open\("dev_dep_versions\.yml"\)\)""" + r"""\["__executorch_version__"\]\)'""", + command, + ) + assert reads_the_pin, ( + "the docgen pin no longer reads __executorch_version__ out of dev_dep_versions.yml, so " + f"what it installs is no longer the pin: {command}" + ) def test_the_runner_follows_the_row_s_cuda_version(monkeypatch) -> None: @@ -725,9 +744,20 @@ def test_the_pin_check_runs_in_ci(): "success()", "success() || failure()", }, f"the pin check in {name} runs under {condition!r}, which may never be true" + # Tokenised, not substring-matched, and every way of neutralising the run counts. "--co" is + # pytest's own documented short form of "--collect-only" and slipped past a check for the long + # spelling, and "|| true" or continue-on-error discard the exit status entirely. + tokens = shlex.split(step["run"].replace("\\\n", " ")) + for flag in ("--collect-only", "--co", "--help", "-h"): + assert ( + flag not in tokens + ), f"the pin check in {name} passes {flag}, so no assertion executes" assert ( - "--collect-only" not in step["run"] - ), f"the pin check in {name} only collects tests, so no assertion executes" + "||" not in tokens + ), f"the pin check in {name} discards its exit status, so a failure cannot fail the job" + assert not step.get( + "continue-on-error" + ), f"the pin check in {name} is continue-on-error, so a failure cannot fail the job" # pytest and pyyaml must be installed by an earlier step of the SAME job: neither # requirements.txt nor dependency-groups.lint carries them, and without them the step exits 1 From 3ec45a0e9a034485b240d38e4d8d5d018f197252 Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Mon, 24 Aug 2026 13:37:50 -0700 Subject: [PATCH 12/20] Guard the nightly channel at the local-path install sites, and check it resolves The nightly-index guard matched only the named-distribution spelling, so the four sites that write "pip install .[executorch]" were unguarded: docgen.yml and the three export examples. The nightly index could be deleted from all four with the test green. Each of the four is now caught individually. Its second half was a bare substring test for the host, which proves a string sits nearby rather than that the instruction resolves. Rewriting every channel in the tree, 18 files, to a nonexistent cu999 left it green. The CUDA suffix is now checked against the set the project publishes for. Deliberately not compared against __cuda_version__: five sites legitimately say cu130 while the pin says 13.2, and I confirmed against the live index that cu130 and cu132 both carry 38 ExecuTorch wheels while cu999 carries none. --- .../dynamo/executorch/test_executorch_pin.py | 29 ++++++++++++++++--- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/tests/py/dynamo/executorch/test_executorch_pin.py b/tests/py/dynamo/executorch/test_executorch_pin.py index 1c70510d438..4e2b0c93a5d 100644 --- a/tests/py/dynamo/executorch/test_executorch_pin.py +++ b/tests/py/dynamo/executorch/test_executorch_pin.py @@ -204,6 +204,8 @@ def _expected(path: str, number: int, version: str) -> str: # The bazel repositories annotate their pinned commit with the wheel it corresponds to, in a # comment, because bazel fetches by commit and has no requirement string to carry. Those are the # only comment sites that count as pins, and the commit beside them is checked separately. +_PUBLISHED_NIGHTLY_CHANNELS = frozenset({"cu124", "cu126", "cu128", "cu130", "cu132"}) + _ANNOTATED_COMMIT_SITES = frozenset( { "MODULE.bazel", @@ -668,8 +670,13 @@ def test_every_printed_install_instruction_names_the_nightly_channel(): # Two shapes need the channel: an instruction naming the [executorch] extra, and the CI # install of a locally built torch-tensorrt wheel, whose ExecuTorch dependency resolves from # the same index. The second is the site that regressed most often and carries no extra. + # The local-path spelling counts too. Matching only the named-distribution form left the four + # sites that write "pip install .[executorch]" unguarded: the nightly index could be deleted + # from all four with this test green. extra = re.compile( - r"""torch[-_]tensorrt\[[^]]*executorch[^]]*\]|torch_tensorrt\*\.whl""" + r"""torch[-_]tensorrt\[[^]]*executorch[^]]*\]""" + r"""|(? Date: Mon, 24 Aug 2026 17:06:21 -0700 Subject: [PATCH 13/20] Harden the ExecuTorch pin guards and the printed install commands The printed install commands resolved no ExecuTorch. "torch-tensorrt[executorch]" with no version pin resolves the stable PyPI wheel, which carries no executorch extra, so the command exited 0 and installed nothing the feature needs. Add --pre to the six commands that name the extra and assert its presence in the guard that already reads them. Close four ways to neutralise the pin check while its guard stayed green: a ";" or "&" terminator after pytest, continue-on-error or a falsy if: on the owning job, and reducing the workflow trigger so it never runs on pull requests. The trigger check also handles PyYAML reading the unquoted "on" key as the boolean True. Close both ways to strip the pairing check while its guard stayed green: assert the workflow actually calls trt_tier_executorch, and validate suite lane names against the known set so a typo raises at import instead of silently dropping the suite from every matrix. Also: anchor the docgen pin check to a live line so a commented-out install no longer satisfies it; fix the lockfile range check crashing on a legal "==1.4.*" clause; correct the range comment to describe what the range admits; and note in the install advice that the feature is published for Linux only. --- .../runtime_performance/saving_models.rst | 2 +- .../executorch_reference_runner/README.md | 2 +- .../runtime.py | 7 +- py/torch_tensorrt/_compile.py | 6 +- py/torch_tensorrt/executorch/__init__.py | 7 +- setup.py | 5 +- tests/ci/suites.py | 22 +++- .../dynamo/executorch/test_executorch_pin.py | 115 +++++++++++++++--- 8 files changed, 136 insertions(+), 30 deletions(-) diff --git a/docsrc/user_guide/runtime_performance/saving_models.rst b/docsrc/user_guide/runtime_performance/saving_models.rst index 418e65d19b1..9e0c01e340e 100644 --- a/docsrc/user_guide/runtime_performance/saving_models.rst +++ b/docsrc/user_guide/runtime_performance/saving_models.rst @@ -228,7 +228,7 @@ c) ExecuTorch (.pte) The ``executorch`` output format lowers the compiled module to an ExecuTorch ``.pte`` program, delegating the TensorRT engines to the Torch-TensorRT ExecuTorch backend. It requires the ``executorch`` package, from the PyTorch nightly index -(``pip install "torch_tensorrt[executorch]" --extra-index-url +(``pip install --pre "torch_tensorrt[executorch]" --extra-index-url https://download.pytorch.org/whl/nightly/cu130``), and is Linux-only. There are two ways to produce a ``.pte``, and they suit different needs: diff --git a/examples/executorch_reference_runner/README.md b/examples/executorch_reference_runner/README.md index 1f635734ff2..d2e634f5a16 100644 --- a/examples/executorch_reference_runner/README.md +++ b/examples/executorch_reference_runner/README.md @@ -98,7 +98,7 @@ build-executorch-reference-runner/lib/libexecutorch_trt_backend.a Install the complete prebuilt Python runtime and delegate: ```bash -pip install "torch-tensorrt[executorch]" \ +pip install --pre "torch-tensorrt[executorch]" \ --extra-index-url https://download.pytorch.org/whl/nightly/cu130 ``` diff --git a/py/torch-tensorrt-executorch-runtime/torch_tensorrt_executorch_runtime/runtime.py b/py/torch-tensorrt-executorch-runtime/torch_tensorrt_executorch_runtime/runtime.py index 353e8729523..3e12e8d25db 100644 --- a/py/torch-tensorrt-executorch-runtime/torch_tensorrt_executorch_runtime/runtime.py +++ b/py/torch-tensorrt-executorch-runtime/torch_tensorrt_executorch_runtime/runtime.py @@ -14,9 +14,10 @@ def _get_runtime() -> _Runtime: from torch_tensorrt_executorch_runtime import get_runtime except ImportError as error: raise ImportError( - "ExecuTorch Python inference requires the prebuilt delegate. " - 'Install it with: pip install "torch-tensorrt[executorch]" ' - "--extra-index-url https://download.pytorch.org/whl/nightly/cu130" + "ExecuTorch Python inference requires the prebuilt delegate, which is " + "published for Linux only. Install it with: pip install --pre " + '"torch-tensorrt[executorch]" --extra-index-url ' + "https://download.pytorch.org/whl/nightly/cu130" ) from error return get_runtime() diff --git a/py/torch_tensorrt/_compile.py b/py/torch_tensorrt/_compile.py index 32cdab33002..1d0df1c5561 100644 --- a/py/torch_tensorrt/_compile.py +++ b/py/torch_tensorrt/_compile.py @@ -857,7 +857,8 @@ def save( if output_format == "executorch" and not _has_executorch_exir(): raise ImportError( "Saving in ExecuTorch format requires the executorch package " - "with executorch.exir. Install with: pip install " + "with executorch.exir, published for Linux only. Install with: " + "pip install --pre " '"torch_tensorrt[executorch]" --extra-index-url ' "https://download.pytorch.org/whl/nightly/cu130 to use output_format='executorch'." ) @@ -1406,7 +1407,8 @@ def _save_as_executorch(exp_program: Any, file_path: str, **kwargs: Any) -> None from torch_tensorrt.executorch import export except ImportError: raise ImportError( - "ExecuTorch is not installed. Install with: pip install " + "ExecuTorch is not installed, and is published for Linux only. Install " + "with: pip install --pre " '"torch_tensorrt[executorch]" --extra-index-url ' "https://download.pytorch.org/whl/nightly/cu130 to use output_format='executorch'." ) diff --git a/py/torch_tensorrt/executorch/__init__.py b/py/torch_tensorrt/executorch/__init__.py index 06698989671..0ef31ccf4bf 100644 --- a/py/torch_tensorrt/executorch/__init__.py +++ b/py/torch_tensorrt/executorch/__init__.py @@ -24,9 +24,10 @@ def _has_executorch_exir() -> bool: def __getattr__(name: str) -> NoReturn: raise ImportError( f"Cannot access torch_tensorrt.executorch.{name}: " - "ExecuTorch with executorch.exir is required. " - 'Install with: pip install "torch_tensorrt[executorch]" ' - "--extra-index-url https://download.pytorch.org/whl/nightly/cu130" + "ExecuTorch with executorch.exir is required, and is published for " + "Linux only. Install with: pip install --pre " + '"torch_tensorrt[executorch]" --extra-index-url ' + "https://download.pytorch.org/whl/nightly/cu130" ) __all__ = [ diff --git a/setup.py b/setup.py index 697db1c7a9d..e55b540c7dc 100644 --- a/setup.py +++ b/setup.py @@ -206,8 +206,9 @@ def load_dep_info(): # The delegate is compiled from the ExecuTorch source revision pinned in MODULE.bazel, so the # installed wheel should agree with it. The upper bound is the load-bearing half: ExecuTorch's C++ # runtime API is not stable across minor releases, and an unbounded floor would resolve a future -# minor against a backend built for this one. Patch releases stay allowed because they come off the -# same release branch; the exact pin belongs in the runtime package, which does derive it. +# minor against a backend built for this one. Everything below that ceiling resolves: later 1.5 +# nightlies, a 1.5 release candidate, and 1.5 patch releases alike, since the exact pin belongs in +# the runtime package, which does derive it. # The floor currently names a dev build, because the runtime split the delegate needs does not # exist in any ExecuTorch release yet: 1.4.1's executorch/lib carries no standalone linkable # runtime, and no CUDA wheel at all. diff --git a/tests/ci/suites.py b/tests/ci/suites.py index 5a78bd2756f..7b2fd142c1c 100644 --- a/tests/ci/suites.py +++ b/tests/ci/suites.py @@ -28,7 +28,7 @@ from __future__ import annotations from dataclasses import dataclass, field -from typing import Any, Literal +from typing import Any, Literal, get_args Tier = Literal["l0", "l1", "l2"] # python-only validates the PYTHON_ONLY=1 wheel (no C++ runtime) against the @@ -108,6 +108,26 @@ def for_variant(self, variant: Variant) -> dict[str, Any]: base.update(self.overrides.get(variant, {})) return base + def __post_init__(self) -> None: + # Validate the enum-like fields at construction. They are typed as Literal, but nothing + # enforces that at runtime, so a typo like lanes=("nightl",) used to define a suite that + # every lane filter silently skipped, dropping it from CI with no error anywhere. Checking + # here turns that typo into an immediate, located failure when this module is imported. + for field_name, allowed in ( + ("tier", get_args(Tier)), + ("lanes", get_args(Lane)), + ("variants", get_args(Variant)), + ("platforms", get_args(Platform)), + ): + value = getattr(self, field_name) + values = (value,) if isinstance(value, str) else value + unknown = [v for v in values if v not in allowed] + if unknown: + raise ValueError( + f"suite {self.name!r} has unknown {field_name} {unknown}; " + f"expected a subset of {list(allowed)}" + ) + # ── L0 — smoke / fast lane ──────────────────────────────────────────────────── _L0: list[Suite] = [ diff --git a/tests/py/dynamo/executorch/test_executorch_pin.py b/tests/py/dynamo/executorch/test_executorch_pin.py index 4e2b0c93a5d..e18a949ec9d 100644 --- a/tests/py/dynamo/executorch/test_executorch_pin.py +++ b/tests/py/dynamo/executorch/test_executorch_pin.py @@ -387,11 +387,17 @@ def test_derived_requirements_match_the_pin(monkeypatch) -> None: # two helpers above can see it: `$(` is not a digit. Run the command it embeds and compare # what it prints, which fails if the line is deleted or the key is renamed. workflow = (REPO_ROOT / ".github/workflows/docgen.yml").read_text(encoding="utf-8") - embedded = re.search(r'"executorch==\$\((python3 -c \'[^\']+\')\)"', workflow) + # Anchor to a live line: leading whitespace only, no "#". A commented-out install still + # carries the pattern, so a plain search stayed green when the whole step was disabled. + embedded = re.search( + r'^[ \t]*"executorch==\$\((python3 -c \'[^\']+\')\)"', + workflow, + re.MULTILINE, + ) assert embedded, ( - ".github/workflows/docgen.yml no longer pins ExecuTorch alongside the extra. It installs " - "with --pre from the nightly channel, so without the pin it resolves through the range " - "and takes whichever dev build is newest that day." + ".github/workflows/docgen.yml no longer pins ExecuTorch alongside the extra on a live " + "line. It installs with --pre from the nightly channel, so without the pin it resolves " + "through the range and takes whichever dev build is newest that day." ) # Compared as text, not executed. Running it meant whatever that line said got executed on # every pull request: rewriting the one-liner to write a file left the test green and the file @@ -634,7 +640,7 @@ def test_the_lockfile_records_the_same_executorch_range_as_setup_py(): for entry in sorted(recorded) if any( clause.operator in {">=", ">", "==", "~=", "==="} - and Version(clause.version.rstrip("*") or "0") > pinned + and Version(clause.version.rstrip(".*") or "0") > pinned for clause in SpecifierSet(entry) ) ] @@ -719,6 +725,19 @@ def test_every_printed_install_instruction_names_the_nightly_channel(): f"{name}:{line} installs from nightly/{suffix}, which the project does not " f"publish for; expected one of {sorted(_PUBLISHED_NIGHTLY_CHANNELS)}" ) + # The named-distribution form needs --pre. "torch-tensorrt[executorch]" with no + # version pin resolves to the stable PyPI wheel, which carries no executorch extra at + # all, so the command exits 0 with a warning and installs nothing the feature needs. + # The ".[executorch]" and built-wheel forms already pin executorch to a dev version, + # which enables prerelease selection on their own, so they do not need it. + named_distribution = re.fullmatch( + r"torch[-_]tensorrt\[[^]]*executorch[^]]*\]", match.group(0) + ) + if named_distribution and not re.search(r"(?:^|\s)--pre(?:\s|$)", block): + missing.append( + f"{name}:{line} installs {match.group(0)} without --pre, so pip resolves the " + "stable release with no executorch extra rather than the nightly prerelease" + ) assert not missing, ( "these ExecuTorch install instructions do not name the nightly channel, so they " @@ -743,6 +762,16 @@ def test_the_pin_check_runs_in_ci(): workflow = yaml.safe_load( (REPO_ROOT / ".github/workflows/linter.yml").read_text(encoding="utf-8") ) + # The workflow must actually run on pull requests. PyYAML reads the unquoted "on" key as the + # boolean True (the YAML 1.1 "Norway problem"), so accept either spelling, then require a + # pull_request trigger. Reducing "on:" to workflow_dispatch left every string in place while + # the workflow never fired on a pull request. + triggers = workflow.get("on", workflow.get(True)) + trigger_names = set(triggers) if isinstance(triggers, (dict, list)) else {triggers} + assert "pull_request" in trigger_names, ( + f"linter.yml triggers on {sorted(map(str, trigger_names))}, not pull_request, so the pin " + "check never runs when a pull request changes the pin" + ) # Match a live pytest invocation, not the filename anywhere in the script. Neutralising the # command and leaving it in a shell comment satisfied a plain substring test. invocation = re.compile( @@ -758,27 +787,38 @@ def test_the_pin_check_runs_in_ci(): assert owning, "no CI job invokes this file, so nothing here runs on a pull request" name, job, step = owning[0] - # A falsy condition disables the step while leaving every string in place. - condition = str(step.get("if", "always()")) - assert condition in { - "always()", - "success()", - "success() || failure()", - }, f"the pin check in {name} runs under {condition!r}, which may never be true" + # A falsy condition disables the step or the whole job while leaving every string in place, so + # check both. GitHub treats a bare "false", "${{ false }}" and any always-false expression the + # same way, so restrict each to the small set of conditions that can actually be true. + live_conditions = {"always()", "success()", "success() || failure()"} + step_condition = str(step.get("if", "always()")) + assert ( + step_condition in live_conditions + ), f"the pin check step in {name} runs under {step_condition!r}, which may never be true" + job_condition = str(job.get("if", "always()")) + assert ( + job_condition in live_conditions + ), f"job {name} runs under {job_condition!r}, so the pin check may never dispatch" # Tokenised, not substring-matched, and every way of neutralising the run counts. "--co" is # pytest's own documented short form of "--collect-only" and slipped past a check for the long - # spelling, and "|| true" or continue-on-error discard the exit status entirely. + # spelling. "|| true", "; true" and continue-on-error each discard the exit status, the last + # two at the step and at the job. tokens = shlex.split(step["run"].replace("\\\n", " ")) for flag in ("--collect-only", "--co", "--help", "-h"): assert ( flag not in tokens ), f"the pin check in {name} passes {flag}, so no assertion executes" - assert ( - "||" not in tokens - ), f"the pin check in {name} discards its exit status, so a failure cannot fail the job" + for terminator in ("||", ";", "&"): + assert terminator not in tokens, ( + f"the pin check in {name} follows pytest with {terminator!r}, so its exit status does " + "not fail the step" + ) assert not step.get( "continue-on-error" - ), f"the pin check in {name} is continue-on-error, so a failure cannot fail the job" + ), f"the pin check step in {name} is continue-on-error, so a failure cannot fail the job" + assert not job.get( + "continue-on-error" + ), f"job {name} is continue-on-error, so a failed pin check cannot fail the workflow" # pytest and pyyaml must be installed by an earlier step of the SAME job: neither # requirements.txt nor dependency-groups.lint carries them, and without them the step exits 1 @@ -860,3 +900,44 @@ def test_the_pairing_check_survives_the_gpu_lane_deselection() -> None: f"{path} selects {len(others)} other tests from this module, which the lane " f"deselects deliberately: {others[:2]}" ) + + # Proving the -k expression selects the pairing test says nothing about whether either route + # is actually wired to run it. Replacing the workflow's `trt_tier_executorch` call with `echo + # skipped`, or pointing the executorch suite at a lane name no runner requests, both leave the + # checks above green while the test runs nowhere. So assert each route reaches the tier. + import yaml + + workflow = yaml.safe_load( + (REPO_ROOT / ".github/workflows/executorch-test-linux.yml").read_text( + encoding="utf-8" + ) + ) + scripts = [ + step.get("with", {}).get("script", "") + for job in workflow["jobs"].values() + for step in job.get("steps", []) + ] + [job.get("with", {}).get("script", "") for job in workflow["jobs"].values()] + invokes_tier = any( + re.search(r"^\s*trt_tier_executorch\b", script, re.MULTILINE) + for script in scripts + ) + assert invokes_tier, ( + "executorch-test-linux.yml no longer calls trt_tier_executorch, so the pairing check " + "never runs on the GPU lane even though its -k expression would select it" + ) + + # The manifest route: the executorch suite must exist and target a lane a runner requests. + # A typo in its lane tuple silently drops it from every matrix, which the suite-name check + # above cannot see. + import importlib + + suites = importlib.import_module("tests.ci.suites") + executorch_suite = next((s for s in suites.SUITES if s.name == "executorch"), None) + assert executorch_suite is not None, ( + "tests/ci/suites.py no longer defines an 'executorch' suite, so the manifest route to the " + "pairing check is gone" + ) + assert "nightly" in executorch_suite.lanes, ( + f"the executorch suite runs on lanes {executorch_suite.lanes!r}, none of which is the " + "nightly lane the GPU tier requests, so the pairing check runs nowhere" + ) From bec1b91ea5b8832d49ae7b0316289e152cf8260a Mon Sep 17 00:00:00 2001 From: shoumikhin Date: Mon, 24 Aug 2026 19:13:39 -0700 Subject: [PATCH 14/20] Track the ExecuTorch pin and move it to new nightlies automatically The delegate is built against one ExecuTorch: __executorch_version__ selects the wheel it links against and __executorch_commit__ selects the tree it compiles from. Those two values repeat across the build workflows, the bazel modules, the docker and toolchain copies, and the docs, so they can drift apart or fall behind upstream with nothing to notice. Add a script and a daily workflow that move both pins to the newest ExecuTorch wheel on the nightly index. The source commit is read from the chosen wheel's own version.py, so the two pins always name one ExecuTorch rather than two that happen to be close. The update lands as a pull request, so the pin consistency checks and the delegate build and test lane decide whether the new wheel is usable before it reaches main. A day with no new nightly rewrites nothing and opens nothing. On a release branch the schedule is a no-op and the pin moves only by a manual run pointed at the stable line, so a cut release does not drift. Back the mechanism with consistency checks that run under the linter. Every requirement and comment that names ExecuTorch is asserted to match the pinned version, including the variable-index install once the variable's assignment is resolved and extensionless install files like justfile. The source commit is checked against the wheel's own provenance wherever that wheel is installed, and commits left in comments are not mistaken for pins. The wheel-content and CI-invocation checks measure effect, running the workflow's own step against a passing and a failing stub and requiring the exit status to follow, rather than enumerating bypass spellings. Install the built wheel in the runtime README rather than an unpublished package. The guard that checks the pin runs in CI compares the step's command as text rather than executing it. Running the step's own shell body meant whatever that body said ran on every pull request: appending a line that writes a file left the test green and the file written. That is the same defect this file already avoids for the docgen one-liner, and the reasoning there applies here too. The delegate claims both names ExecuTorch has used for its pybind extension. It renamed _portable_lib to _C, and portable_lib.py imports whichever its own version carries, so aliasing only the old name is silently ineffective at the new pin: nothing imports it, the stock extension loads, and the backend is never registered. CI reported that as "TensorRTBackend is not registered" from the native runtime check. --- .github/scripts/install-torch-tensorrt.sh | 3 + .github/scripts/update_executorch_pin.py | 322 ++++++++ .github/workflows/executorch-build-linux.yml | 16 +- .github/workflows/executorch-pin-update.yml | 118 +++ .github/workflows/executorch-test-linux.yml | 2 +- .github/workflows/linter.yml | 18 +- MODULE.bazel | 4 +- dev_dep_versions.yml | 4 +- docker/MODULE.bazel.docker | 4 +- docker/MODULE.bazel.ngc | 4 +- .../runtime_performance/saving_models.rst | 4 +- .../executorch_reference_runner/README.md | 21 +- justfile | 2 +- .../README.md | 14 +- .../native/CMakeLists.txt | 12 + .../pyproject.toml | 2 +- .../__init__.py | 26 +- .../runtime.py | 15 +- py/torch_tensorrt/_compile.py | 22 +- py/torch_tensorrt/_utils.py | 31 + py/torch_tensorrt/executorch/__init__.py | 6 +- tests/ci/suites.py | 7 +- .../dynamo/executorch/test_executorch_pin.py | 702 ++++++++++++++++-- .../dynamo/executorch/test_python_runtime.py | 25 + .../executorch/test_update_executorch_pin.py | 504 +++++++++++++ tests/py/utils/ci_helpers.sh | 2 +- toolchains/ci_workspaces/MODULE.bazel.tmpl | 4 +- 27 files changed, 1763 insertions(+), 131 deletions(-) create mode 100644 .github/scripts/update_executorch_pin.py create mode 100644 .github/workflows/executorch-pin-update.yml create mode 100644 tests/py/dynamo/executorch/test_update_executorch_pin.py diff --git a/.github/scripts/install-torch-tensorrt.sh b/.github/scripts/install-torch-tensorrt.sh index 328f4090b89..06f59e0680b 100755 --- a/.github/scripts/install-torch-tensorrt.sh +++ b/.github/scripts/install-torch-tensorrt.sh @@ -62,6 +62,9 @@ fi # Install Torch-TensorRT if [[ ${PLATFORM} == win32 ]]; then + # pin-check: no-nightly -- this glob also matches the Linux-only ExecuTorch runtime wheel, but + # ExecuTorch publishes no win32 nightly and the [executorch] extra is Linux-only, so this + # platform's plain torch-tensorrt install needs no nightly index. python -m pip install ${RUNNER_ARTIFACT_DIR}/torch_tensorrt*.whl else # The nightly channel is needed because this glob also matches the ExecuTorch runtime wheel, diff --git a/.github/scripts/update_executorch_pin.py b/.github/scripts/update_executorch_pin.py new file mode 100644 index 00000000000..5bb5c4c1b3c --- /dev/null +++ b/.github/scripts/update_executorch_pin.py @@ -0,0 +1,322 @@ +#!/usr/bin/env python3 +"""Move the ExecuTorch pin to the newest wheel published on an index. + +The pin is two coupled facts, spread across the tree but sourced from +``dev_dep_versions.yml``: ``__executorch_version__`` selects the wheel the delegate is +built to sit beside, and ``__executorch_commit__`` selects the tree it compiles from. +They must name one ExecuTorch, so this script never guesses the commit: it reads it from +the chosen wheel's own ``executorch/version.py``, which is the same provenance +``tests/py/dynamo/executorch/test_executorch_pin.py`` checks the pins against. + +The daily workflow runs this, then opens a pull request when the pin moved. The existing +pin guards and the executorch end-to-end lane run on that pull request, so "the newest +wheel that actually works" is decided by the same gate a human bump goes through, not +re-implemented here. A nightly that did not publish leaves the newest version unchanged, +so the run rewrites nothing and opens nothing. +""" + +from __future__ import annotations + +import argparse +import re +import subprocess +import sys +import tempfile +import zipfile +from pathlib import Path + +from packaging.version import InvalidVersion, Version + +_REPO_ROOT = Path(__file__).resolve().parents[2] +_VERSIONS_FILE = _REPO_ROOT / "dev_dep_versions.yml" + +# A wheel version on the PyTorch index carries a local label naming its CUDA build, for +# example ``1.5.0.devYYYYMMDD+cu130``. The pin omits it so one pin serves every CUDA row. +_LOCAL_LABEL = re.compile(r"\+.*$") + + +def _run(cmd: list[str]) -> str: + return subprocess.run(cmd, check=True, capture_output=True, text=True).stdout + + +def read_pin(field: str) -> str: + """Return a pinned value from ``dev_dep_versions.yml``.""" + text = _VERSIONS_FILE.read_text(encoding="utf-8") + match = re.search(rf'^{field}:\s*"?([^"\s]+)"?\s*$', text, re.MULTILINE) + if match is None: + raise SystemExit(f"{field} is not set in {_VERSIONS_FILE.name}") + return match.group(1) + + +def available_versions(index_args: list[str]) -> list[str]: + """Every ExecuTorch version the index offers. + + ``pip index versions`` prints one ``Available versions:`` line. Parsing that is stable + and needs no network code here; the workflow passes the index the same way every other + install in the tree does. + """ + out = _run( + [sys.executable, "-m", "pip", "index", "versions", "executorch", *index_args] + ) + match = re.search(r"^\s*Available versions:\s*(.+)$", out, re.MULTILINE) + if match is None: + raise SystemExit("pip index versions printed no Available versions line") + return [v.strip() for v in match.group(1).split(",") if v.strip()] + + +def pick_target(versions: list[str], track: str) -> str: + """The newest version on the wanted track. + + ``nightly`` takes the newest dated dev build; ``stable`` takes the newest final + release, ignoring dev builds and release candidates. Ordering is PEP 440, not string + order, so a newer dev date on the same line sorts above an older one correctly. + """ + parsed: list[tuple[Version, str]] = [] + for raw in versions: + try: + version = Version(raw) + except InvalidVersion: + continue + # A nightly is a dated dev build. is_prerelease is also true for release + # candidates, and an rc sorts above every dev of the same line under PEP 440, so + # filtering on it would let the first rc on the index silently become the nightly + # pin. Match the dev segment itself instead. + if track == "nightly" and version.dev is None: + continue + if track == "stable" and (version.is_prerelease or version.is_devrelease): + continue + parsed.append((version, raw)) + if not parsed: + raise SystemExit(f"no executorch version on the index matches track {track!r}") + newest = max(parsed, key=lambda pair: pair[0])[1] + return _LOCAL_LABEL.sub("", newest) + + +def wheel_git_version(version: str, index_args: list[str]) -> str: + """The source commit the chosen wheel records for itself. + + Every published wheel writes ``git_version`` into ``executorch/version.py``. Reading it + from the wheel is what keeps the two pins naming one ExecuTorch. A wheel built without + git provenance records ``None`` and must not become a pin, so that is an error, not a + guess. + """ + with tempfile.TemporaryDirectory() as tmp: + _run( + [ + sys.executable, + "-m", + "pip", + "download", + "--no-deps", + "--only-binary=:all:", + "--dest", + tmp, + f"executorch=={version}", + *index_args, + ] + ) + wheels = list(Path(tmp).glob("executorch-*.whl")) + if not wheels: + raise SystemExit( + f"pip download produced no wheel for executorch=={version}" + ) + with zipfile.ZipFile(wheels[0]) as archive: + source = archive.read("executorch/version.py").decode("utf-8") + match = re.search(r"""git_version[^=]*=\s*['"]([0-9a-f]{40})['"]""", source) + if match is None: + raise SystemExit( + f"executorch=={version} records no source commit, so the pin would name a " + "wheel whose provenance cannot be checked" + ) + return match.group(1) + + +def _upper_bound(version: str) -> str: + """The exclusive upper bound a range site pairs with the pin, next minor of its line. + + ``tests/py/dynamo/executorch/test_executorch_pin.py`` derives the same bound from the + same two fields, so a range this writes and the range the guard expects agree by the + same rule rather than by coincidence. + """ + major, minor = version.split(".")[:2] + return f"{major}.{int(minor) + 1}" + + +# The only files that carry the pin as a real pin. A tree-wide literal replace was safe while the +# pin was a dated dev string, because "1.5.0.dev20260825" appears nowhere else, but it corrupts the +# tree the moment the pin is a plain release like "1.4.1": that token also lives in unrelated +# requirements (for example pandocfilters>=1.4.1 in committed notebooks) and, worst, in uv.lock, +# whose entries are content addressed, so rewriting the version inside a wheel URL while its hash and +# size stay behind is a guaranteed install failure. So restrict the rewrite to the sites the guard in +# tests/py/dynamo/executorch/test_executorch_pin.py enumerates, which are the only sites that are +# actually pins. A new legitimate site must be added here and to that guard together. +_PIN_SITES = ( + ".github/workflows/executorch-build-linux.yml", + ".github/workflows/executorch-test-linux.yml", + "MODULE.bazel", + "docker/MODULE.bazel.docker", + "docker/MODULE.bazel.ngc", + "justfile", + "py/torch-tensorrt-executorch-runtime/README.md", + "py/torch-tensorrt-executorch-runtime/pyproject.toml", + "toolchains/ci_workspaces/MODULE.bazel.tmpl", + "examples/executorch_reference_runner/README.md", +) + + +def _pin_site_paths() -> list[Path]: + tracked = set(_run(["git", "-C", str(_REPO_ROOT), "ls-files"]).splitlines()) + # An entry that is no longer tracked is a stale list, not an absent pin, so refuse rather than + # skip it. Skipping rewrites the other sites and returns success, and the workflow's gate is + # `git diff --quiet`, which detects change and not coherence, so the bot would open a pull + # request whose unrewritten site still names the old pin. The likely cause is a pin site being + # renamed without this list following it. + missing = sorted(name for name in _PIN_SITES if name not in tracked) + if missing: + raise SystemExit( + "these pin sites are not tracked by git, so the pin cannot be rewritten " + f"consistently: {missing}. Update _PIN_SITES, and the matching list in " + "tests/py/dynamo/executorch/test_executorch_pin.py, if a file moved." + ) + paths = [_REPO_ROOT / name for name in _PIN_SITES] + # dev_dep_versions.yml is the source of truth and is rewritten too; it is not in _PIN_SITES + # because the guard reads it rather than counting it as a downstream pin site. + paths.append(_VERSIONS_FILE) + return paths + + +def write_pins(new_version: str, new_commit: str) -> bool: + """Rewrite the pin to the new version and commit at the known pin sites. + + The rewrite is restricted to the sites the guard enumerates (see _PIN_SITES), and within them it + matches only a requirement on the executorch distribution. It is NOT a tree-wide literal + replace, and it is not a bare version match either: a plain release like "1.4.1" appears in + unrelated requirements, in content-addressed uv.lock entries, and in other pins inside the pin + files themselves. Neither setup.py nor uv.lock is a pin site; they are left for their own tooling + to regenerate. Returns whether anything changed. + """ + old_version = read_pin("__executorch_version__") + old_commit = read_pin("__executorch_commit__") + if (new_version, new_commit) == (old_version, old_commit): + return False + + # A single regex pass, so a freshly written new version cannot be matched again. A plain + # text.replace of the bare version doubles the tail when the old version is a prefix of the new + # one (1.5.0 -> 1.5.0.post1 would yield 1.5.0.post1.post1), because the second replace re-hits + # what the first just wrote. The trailing boundary avoids that, and the range is handled by the + # same alternation so its own version is not rewritten twice. + # + # Anchored on what names the pin, because a bare version is not distinctive enough to identify + # one. Downstream sites all spell it as a requirement on the executorch distribution, while the + # same files carry unrelated versions that are lexically identical: each MODULE.bazel has + # bazel_dep(name = "bazel_skylib", version = "1.7.1"), so a pin of 1.7.1 rewrote that too. + # Neither a leading nor a trailing character-class boundary helps there, since the character + # before the version is a quote, and restricting the rewrite to known pin files does not help + # either, because those are the very files holding the bystanders. dev_dep_versions.yml is the + # exception: it is the source of truth and states the version as a YAML key rather than a + # requirement, so it gets its own alternative. + # + # Every operator the guard in tests/py/dynamo/executorch/test_executorch_pin.py treats as a pin, + # with its optional spaces, not just the two spellings the tree happens to use today. A site the + # guard counts but this rewriter skips is the worst shape available: the bump leaves it on the old + # version, and the guard then fails the generated pull request as a pin mismatch rather than as an + # operator the rewriter cannot see. + # + # The name needs a left boundary of its own, or "my-executorch==" matches on its tail. + # Distribution names normalise hyphens and underscores together, so exclude both, plus a dot. + old_upper = _upper_bound(old_version) + new_upper = _upper_bound(new_version) + old_range_tail = f"{old_version},<{old_upper}" + version_token = re.compile( + r"(?P(?=|<=|~=|!=|<|>) ?" + r"|__executorch_version__:\s*\"?)(?:" + + re.escape(old_range_tail) + + r"|" + + re.escape(old_version) + + r")(?![0-9A-Za-z.+_-])" + ) + + def _sub_version(match: re.Match[str]) -> str: + lead = match.group("lead") + if match.group(0).endswith(f",<{old_upper}"): + return f"{lead}{new_version},<{new_upper}" + return f"{lead}{new_version}" + + changed = False + for path in _pin_site_paths(): + try: + text = path.read_text(encoding="utf-8") + except (UnicodeDecodeError, FileNotFoundError): + continue + if old_version not in text and old_commit not in text: + continue + updated = version_token.sub(_sub_version, text) + updated = updated.replace(old_commit, new_commit) + if updated != text: + path.write_text(updated, encoding="utf-8") + changed = True + + if read_pin("__executorch_version__") != new_version: + raise SystemExit( + "the version pin did not take; dev_dep_versions.yml is unchanged" + ) + return changed + + +def _index_args(track: str, channel: str) -> list[str]: + if track == "nightly": + return [ + "--pre", + "--index-url", + f"https://download.pytorch.org/whl/nightly/{channel}", + ] + return [] + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--track", choices=("nightly", "stable"), default="nightly") + parser.add_argument( + "--channel", + default="cu130", + help="nightly CUDA channel to read versions and provenance from", + ) + parser.add_argument( + "--allow-downgrade", + action="store_true", + help=( + "move the pin even when the target sorts below the current pin. Off by default so a " + "regressed index, or switching --track from nightly to stable, cannot silently walk " + "the pin backwards onto a version this delegate cannot build against." + ), + ) + args = parser.parse_args(argv) + + index_args = _index_args(args.track, args.channel) + target = pick_target(available_versions(index_args), args.track) + current = read_pin("__executorch_version__") + if target == current: + print(f"executorch pin is already at the newest {args.track} version {current}") + return 0 + # Never move backwards unless asked. pick_target returns the newest on the track, but "newest" + # regresses when the index drops the current line or when --track flips from nightly to stable, + # whose newest final release can sort below a dated nightly. The pin then lands on a version with + # no standalone libexecutorch.so and no CUDA build, which the delegate cannot link, and the run + # would still report success. Refuse it, and require an explicit opt-in for a deliberate re-pin. + if not args.allow_downgrade and Version(target) < Version(current): + print( + f"target {target} sorts below the current pin {current}; refusing to move the pin " + "backwards. Pass --allow-downgrade to override for a deliberate re-pin." + ) + return 0 + + commit = wheel_git_version(target, index_args) + if write_pins(target, commit): + print(f"moved executorch pin {current} -> {target} (commit {commit})") + else: + print("nothing to write") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/workflows/executorch-build-linux.yml b/.github/workflows/executorch-build-linux.yml index 33d190d0510..483730fe388 100644 --- a/.github/workflows/executorch-build-linux.yml +++ b/.github/workflows/executorch-build-linux.yml @@ -85,7 +85,7 @@ jobs: # CU_VERSION selects the row's own channel, which is what keeps the runtime the # delegate links to the same CUDA build as the rest of the job. EXECUTORCH_INDEX_URL="https://download.pytorch.org/whl/nightly/${CU_VERSION}" - python -m pip install pyyaml --extra-index-url "${EXECUTORCH_INDEX_URL}" "executorch==1.5.0.dev20260822" + python -m pip install pyyaml --extra-index-url "${EXECUTORCH_INDEX_URL}" "executorch==1.5.0.dev20260829" export TORCH_TENSORRT_EXECUTORCH_RUNTIME_VERSION="$(python -c 'import importlib.metadata; print(importlib.metadata.version("torch-tensorrt"))')" # The downloaded wheel has to carry the C++ runtime. A wheel built with @@ -131,9 +131,17 @@ jobs: executorch_cmake_location="$(bazel query @executorch//:executorch/CMakeLists.txt --output=location)" export EXECUTORCH_SOURCE_DIR="$(dirname "${executorch_cmake_location%%:*}")" export EXECUTORCH_ROOT="${EXECUTORCH_SOURCE_DIR}" - # pin-check: range-ok -- this is to verify the end user's workflow, which resolves a - # range the way a user would rather than the exact artifact the delegate links. - python -m pip install pyyaml --extra-index-url "${EXECUTORCH_INDEX_URL}" "executorch>=1.5.0.dev20260822,<1.6" + # This proves the range specifier that ships to users, the one in setup.py and the README, + # actually resolves to an installable wheel on the index. It runs in a fresh venv because + # the build interpreter already holds the exact pin, so a range install there resolves + # nothing and proves nothing. --no-deps keeps it to the executorch wheel itself rather than + # pulling torch again; the export and reference runner below deliberately stay on the build + # interpreter, whose pin is ABI-matched to the delegate just built. The venv is created + # before the range-ok marker so the marked install stays directly under its annotation, + # which the pin guard requires. + python -m venv "${RUNNER_TEMP}/range-check-venv" + # pin-check: range-ok + "${RUNNER_TEMP}/range-check-venv/bin/python" -m pip install --no-deps --extra-index-url "${EXECUTORCH_INDEX_URL}" "executorch>=1.5.0.dev20260829,<1.6" python examples/torchtrt_executorch_example/export_static_shape.py \ --model_path="${RUNNER_TEMP}/torchtrt-reference-runner.pte" .github/scripts/verify-executorch-reference-runner.sh \ diff --git a/.github/workflows/executorch-pin-update.yml b/.github/workflows/executorch-pin-update.yml new file mode 100644 index 00000000000..4a2d74acd7d --- /dev/null +++ b/.github/workflows/executorch-pin-update.yml @@ -0,0 +1,118 @@ +name: Update ExecuTorch pin + +# Keep the ExecuTorch pin close to upstream without a human running the bump by hand. On +# main this tracks the nightly line every day; the newest wheel the index actually carries +# is by definition the newest one that built, so a failed nightly simply leaves the pin +# where it was. The bump lands as a pull request, not a direct push, so the existing pin +# guards and the executorch end-to-end lane decide whether the new wheel is usable before +# it reaches main. +# +# On a release branch the schedule is a no-op: a release pins a stable ExecuTorch and does +# not drift. Re-pinning there is a manual dispatch with track=stable, which is the only way +# the pin moves once a branch is cut. + +on: + schedule: + # A few hours after the nightly index publishes, so the freshest wheel is available. + - cron: "0 13 * * *" + workflow_dispatch: + inputs: + track: + description: "Which ExecuTorch line to pin to" + type: choice + default: nightly + options: + - nightly + - stable + +permissions: + contents: write + pull-requests: write + +jobs: + update-pin: + runs-on: ubuntu-latest + if: ${{ ! contains(github.actor, 'pytorchbot') }} + environment: pytorchbot-env + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + token: ${{ secrets.GH_PYTORCHBOT_TOKEN }} + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.11" + + - name: Install packaging + run: python -m pip install packaging + + - name: Choose the track + id: track + env: + EVENT: ${{ github.event_name }} + REF: ${{ github.ref }} + TRACK: ${{ inputs.track }} + run: | + set -euo pipefail + # A manual run pins whatever it asked for, except that a release branch may only be + # pinned to a stable line: nightly off main would drift a cut release onto a nightly, + # which the schedule already refuses to do. The daily schedule pins the nightly line on + # main and does nothing on a release branch. + # The trigger values arrive through env, not template interpolation, so a crafted ref or + # input is shell data rather than shell source. + if [ "$EVENT" = "workflow_dispatch" ]; then + if [ "$TRACK" = "nightly" ] && [ "$REF" != "refs/heads/main" ]; then + echo "refusing to pin $REF to a nightly; re-run with track=stable" >&2 + exit 1 + fi + echo "track=$TRACK" >> "$GITHUB_OUTPUT" + elif [ "$REF" = "refs/heads/main" ]; then + echo "track=nightly" >> "$GITHUB_OUTPUT" + else + echo "no scheduled pin update on $REF; release pins do not drift" + echo "track=" >> "$GITHUB_OUTPUT" + fi + + - name: Update the pin + id: update + if: steps.track.outputs.track != '' + env: + TRACK: ${{ steps.track.outputs.track }} + run: | + set -euo pipefail + python .github/scripts/update_executorch_pin.py --track "$TRACK" + if git diff --quiet; then + echo "the pin is already at the newest $TRACK wheel" + else + echo "changed=true" >> "$GITHUB_OUTPUT" + echo "version=$(sed -n 's/^__executorch_version__: "\(.*\)"$/\1/p' dev_dep_versions.yml)" >> "$GITHUB_OUTPUT" + fi + + - name: Open a pull request + if: steps.update.outputs.changed == 'true' + uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 + with: + token: ${{ secrets.GH_PYTORCHBOT_TOKEN }} + branch: executorch-pin-update/${{ steps.track.outputs.track }} + delete-branch: true + commit-message: "Update ExecuTorch pin to ${{ steps.update.outputs.version }}" + title: "Update ExecuTorch pin to ${{ steps.update.outputs.version }}" + # The delegate is built from the tree the pinned wheel was built from, so both + # pins move together to the version and the commit that wheel records for itself. + # The pin guards and the executorch end-to-end lane gate this pull request. + body: | + Move the ExecuTorch pin to `${{ steps.update.outputs.version }}`, the newest + ${{ steps.track.outputs.track }} wheel on the index. The source commit is read + from that wheel, so the version pin and the source commit name one ExecuTorch. + + Opened automatically. The pin consistency checks and the ExecuTorch delegate + build and test lane run here and decide whether this wheel is usable. + committer: Torch-TensorRT Github Bot + author: Torch-TensorRT Github Bot + +concurrency: + group: ${{ github.workflow }}-${{ github.ref_name }} + cancel-in-progress: true diff --git a/.github/workflows/executorch-test-linux.yml b/.github/workflows/executorch-test-linux.yml index 1966ba73080..1e98f72097d 100644 --- a/.github/workflows/executorch-test-linux.yml +++ b/.github/workflows/executorch-test-linux.yml @@ -69,7 +69,7 @@ jobs: # --pre would apply to every other requirement in the same command too. python -m pip install pyyaml \ --extra-index-url "https://download.pytorch.org/whl/nightly/${CU_VERSION}" \ - "executorch==1.5.0.dev20260822" + "executorch==1.5.0.dev20260829" # Run the check directly so its exit status is the step's exit status. # Wrapping it in `gdb --batch` reports gdb's own status, which is 0 # whatever the program does, so a SIGSEGV here was passing. diff --git a/.github/workflows/linter.yml b/.github/workflows/linter.yml index 26e45662c8b..4fc43393c96 100644 --- a/.github/workflows/linter.yml +++ b/.github/workflows/linter.yml @@ -82,9 +82,11 @@ jobs: run: | uv pip install --system -r $GITHUB_WORKSPACE/.github/scripts/requirements.txt python3 -c "import tomllib, subprocess; deps = tomllib.load(open('pyproject.toml', 'rb'))['dependency-groups']['lint']; subprocess.run(['uv', 'pip', 'install', '--system'] + deps, check=True)" - # The pin check below runs under pytest and shells out to a yaml reader. Neither is in - # requirements.txt or dependency-groups.lint, so the step exited 1 without running. - uv pip install --system pytest pyyaml + # The pin check below runs under pytest and imports yaml to parse the workflows it + # asserts about. Neither pytest nor pyyaml is in requirements.txt or + # dependency-groups.lint, so the step exited 1 without running. + # test_update_executorch_pin imports packaging to order versions the way pip does. + uv pip install --system pytest pyyaml packaging - name: Lint Python run: | cd $GITHUB_WORKSPACE @@ -101,3 +103,13 @@ jobs: cd $GITHUB_WORKSPACE python3 -m pytest tests/py/dynamo/executorch/test_executorch_pin.py \ -q --no-header -p no:cacheprovider --noconftest -o addopts="" + # The pin updater is pure repository tooling: it reads an index, rewrites the pin sites, + # and its tests clone the tree and shell out to pytest. No GPU or ExecuTorch, so it runs + # here beside the pin check rather than on a CUDA runner, and is excluded from the + # executorch e2e suite so it does not run there too. + - name: Test the ExecuTorch pin updater + if: always() + run: | + cd $GITHUB_WORKSPACE + python3 -m pytest tests/py/dynamo/executorch/test_update_executorch_pin.py \ + -q --no-header -p no:cacheprovider --noconftest -o addopts="" diff --git a/MODULE.bazel b/MODULE.bazel index 2e94e2189d1..9044a9e9376 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -53,8 +53,8 @@ local_torch = use_repo_rule("//toolchains:local_torch.bzl", "local_torch") new_git_repository( name = "executorch", build_file = "@//third_party/executorch:BUILD", - # executorch==1.5.0.dev20260822 - commit = "b575a5bc2eab6d671197bc7a920edb7fd0b8fbb7", + # executorch==1.5.0.dev20260829 + commit = "bdf8c941fba42f0d4b62a438443d00458585e0e9", patch_cmds = [ "find . -mindepth 2 \\( -name BUILD -o -name BUILD.bazel \\) -delete", "mkdir executorch && find . -mindepth 1 -maxdepth 1 ! -name executorch ! -name BUILD ! -name BUILD.bazel ! -name REPO.bazel -exec cp -a {} executorch/ \\;", diff --git a/dev_dep_versions.yml b/dev_dep_versions.yml index f17b31f1f2f..55f0acfd05f 100644 --- a/dev_dep_versions.yml +++ b/dev_dep_versions.yml @@ -2,5 +2,5 @@ __cuda_version__: "13.2" __tensorrt_version__: "11.2.1" __tensorrt_rtx_version__: "1.6.1" __tensorrt_llm_version__: "0.17.0.post1" -__executorch_version__: "1.5.0.dev20260822" -__executorch_commit__: "b575a5bc2eab6d671197bc7a920edb7fd0b8fbb7" +__executorch_version__: "1.5.0.dev20260829" +__executorch_commit__: "bdf8c941fba42f0d4b62a438443d00458585e0e9" diff --git a/docker/MODULE.bazel.docker b/docker/MODULE.bazel.docker index b1459e08fa4..df4e98299c8 100644 --- a/docker/MODULE.bazel.docker +++ b/docker/MODULE.bazel.docker @@ -67,8 +67,8 @@ new_local_repository( new_git_repository( name = "executorch", build_file = "@//third_party/executorch:BUILD", - # executorch==1.5.0.dev20260822 - commit = "b575a5bc2eab6d671197bc7a920edb7fd0b8fbb7", + # executorch==1.5.0.dev20260829 + commit = "bdf8c941fba42f0d4b62a438443d00458585e0e9", patch_cmds = [ "find . -mindepth 2 \\( -name BUILD -o -name BUILD.bazel \\) -delete", "mkdir executorch && find . -mindepth 1 -maxdepth 1 ! -name executorch ! -name BUILD ! -name BUILD.bazel ! -name REPO.bazel -exec cp -a {} executorch/ \\;", diff --git a/docker/MODULE.bazel.ngc b/docker/MODULE.bazel.ngc index 6823b9bdc6f..3b1f7ffa7c5 100644 --- a/docker/MODULE.bazel.ngc +++ b/docker/MODULE.bazel.ngc @@ -76,8 +76,8 @@ new_local_repository( new_git_repository( name = "executorch", build_file = "@//third_party/executorch:BUILD", - # executorch==1.5.0.dev20260822 - commit = "b575a5bc2eab6d671197bc7a920edb7fd0b8fbb7", + # executorch==1.5.0.dev20260829 + commit = "bdf8c941fba42f0d4b62a438443d00458585e0e9", recursive_init_submodules = True, patch_cmds = [ "find . -mindepth 2 \\( -name BUILD -o -name BUILD.bazel \\) -delete", diff --git a/docsrc/user_guide/runtime_performance/saving_models.rst b/docsrc/user_guide/runtime_performance/saving_models.rst index 9e0c01e340e..76aec915641 100644 --- a/docsrc/user_guide/runtime_performance/saving_models.rst +++ b/docsrc/user_guide/runtime_performance/saving_models.rst @@ -229,7 +229,9 @@ The ``executorch`` output format lowers the compiled module to an ExecuTorch ``.pte`` program, delegating the TensorRT engines to the Torch-TensorRT ExecuTorch backend. It requires the ``executorch`` package, from the PyTorch nightly index (``pip install --pre "torch_tensorrt[executorch]" --extra-index-url -https://download.pytorch.org/whl/nightly/cu130``), and is Linux-only. +https://download.pytorch.org/whl/nightly/cu130``), and is Linux-only. Add ``--upgrade`` +if a stable ``torch-tensorrt`` is already installed, or pip keeps it and reports that it +does not provide the ``executorch`` extra. There are two ways to produce a ``.pte``, and they suit different needs: diff --git a/examples/executorch_reference_runner/README.md b/examples/executorch_reference_runner/README.md index d2e634f5a16..f2ccc381337 100644 --- a/examples/executorch_reference_runner/README.md +++ b/examples/executorch_reference_runner/README.md @@ -44,7 +44,7 @@ torch_tensorrt/bin/example_executorch_runner ```bash # Get the ExecuTorch source snapshot this package is built against. Keep this in sync # with the executorch commit pinned in MODULE.bazel. -EXECUTORCH_REF="${EXECUTORCH_REF:-b575a5bc2eab6d671197bc7a920edb7fd0b8fbb7}" +EXECUTORCH_REF="${EXECUTORCH_REF:-bdf8c941fba42f0d4b62a438443d00458585e0e9}" git clone --filter=blob:none --no-checkout \ https://github.com/pytorch/executorch.git executorch pushd executorch @@ -95,7 +95,7 @@ build-executorch-reference-runner/lib/libexecutorch_trt_backend.a ### Python -Install the complete prebuilt Python runtime and delegate: +Install the `executorch` authoring stack, which the `[executorch]` extra pulls in: ```bash pip install --pre "torch-tensorrt[executorch]" \ @@ -104,8 +104,17 @@ pip install --pre "torch-tensorrt[executorch]" \ The index is required, not optional: the extra's ExecuTorch floor names a dev build, and PyPI's `executorch` stops below it, so without the nightly channel pip reports no matching distribution. +If a stable `torch-tensorrt` is already installed, add `--upgrade`, or pip keeps it and reports +that it does not provide the `executorch` extra. -Load and run the model without an ExecuTorch checkout or native build: +The extra installs `executorch` only. The delegate runtime, +`torch-tensorrt-executorch-runtime`, is not yet published to any index: its requirement in the +top-level `setup.py` is commented out for that reason. Build and install it from source following +`py/torch-tensorrt-executorch-runtime/README.md`. That wheel contains an ExecuTorch Python runtime +with `TensorRTBackend` linked into its backend registry, and loading a `.pte` through the delegate +needs it. + +Then load and run the model: ```bash python examples/executorch_reference_runner/load_model.py \ @@ -113,12 +122,6 @@ python examples/executorch_reference_runner/load_model.py \ --num_runs=1 ``` -The extra installs `executorch` only. The -`torch-tensorrt-executorch-runtime` requirement in the top-level `setup.py` is -commented out until that wheel is published to the PyTorch index, so install it -separately for now. That wheel contains an ExecuTorch Python runtime with -`TensorRTBackend` linked into its backend registry. - ### C++ Run the reference runner against a Torch-TensorRT compiled ExecuTorch model: diff --git a/justfile b/justfile index 2b806852dc1..5ae94c5dfde 100644 --- a/justfile +++ b/justfile @@ -94,7 +94,7 @@ install-test-ext: # compiled from the commit this version pairs with. uv pip install pyyaml \ --extra-index-url https://download.pytorch.org/whl/nightly/cu130 \ - "executorch==1.5.0.dev20260822" + "executorch==1.5.0.dev20260829" # ── Linting ─────────────────────────────────────────────────────────────────── diff --git a/py/torch-tensorrt-executorch-runtime/README.md b/py/torch-tensorrt-executorch-runtime/README.md index a181a783e60..5f8f3097231 100644 --- a/py/torch-tensorrt-executorch-runtime/README.md +++ b/py/torch-tensorrt-executorch-runtime/README.md @@ -40,8 +40,8 @@ of the wheel runtime contract. export TensorRT_ROOT=/path/to/TensorRT python -m pip install pyyaml \ - --extra-index-url https://download.pytorch.org/whl/nightly/cu132 \ - "executorch==1.5.0.dev20260822" + --extra-index-url https://download.pytorch.org/whl/nightly/cu130 \ + "executorch==1.5.0.dev20260829" python -m pip wheel --no-build-isolation --no-deps \ --wheel-dir dist py/torch-tensorrt-executorch-runtime ``` @@ -49,7 +49,7 @@ python -m pip wheel --no-build-isolation --no-deps \ The native build obtains the ExecuTorch source through Bazel; no separate source checkout or `EXECUTORCH_SOURCE_DIR` setting is required. The source commit pinned in `MODULE.bazel` is the revision recorded by the -`executorch==1.5.0.dev20260822` wheel. +`executorch==1.5.0.dev20260829` wheel. The static ExecuTorch and delegate archives are intermediate build inputs; users receive the final native Python module and do not compile anything. @@ -75,8 +75,14 @@ GPU should use the ExecuTorch C++ runner. ## Use +The wheel's dependencies (`executorch`, `torch-tensorrt`, and the CUDA +runtime) resolve from the PyTorch nightly index, so install it with the same +channel the build recipe used. `--pre` lets pip select the pinned ExecuTorch +dev build: + ```bash -python -m pip install torch-tensorrt-executorch-runtime +python -m pip install --pre dist/torch_tensorrt_executorch_runtime-*.whl \ + --extra-index-url https://download.pytorch.org/whl/nightly/cu130 ``` ```python diff --git a/py/torch-tensorrt-executorch-runtime/native/CMakeLists.txt b/py/torch-tensorrt-executorch-runtime/native/CMakeLists.txt index 4dadbf56f5f..2fe58d56938 100644 --- a/py/torch-tensorrt-executorch-runtime/native/CMakeLists.txt +++ b/py/torch-tensorrt-executorch-runtime/native/CMakeLists.txt @@ -294,6 +294,18 @@ set_target_properties(portable_lib PROPERTIES INSTALL_RPATH "${_torch_tensorrt_executorch_runtime_rpath}" OUTPUT_NAME "_portable_lib" SUFFIX ".so") +# The pinned ExecuTorch builds this extension as _C, setting both OUTPUT_NAME and +# EXECUTORCH_PYTHON_MODULE_NAME=_C, and that define is what PYBIND11_MODULE pastes into the module +# init symbol. Forcing OUTPUT_NAME back to _portable_lib above would ship _portable_lib.so still +# exporting PyInit__C, and activate() imports the extension by module name, so that import would +# raise. Set the define to match the file name. The inherited _C value is dropped first so a single +# -D reaches the compiler instead of two that disagree and warn about a redefinition. +get_target_property(_portable_lib_defs portable_lib COMPILE_DEFINITIONS) +if(_portable_lib_defs) + list(REMOVE_ITEM _portable_lib_defs "EXECUTORCH_PYTHON_MODULE_NAME=_C") + set_target_properties(portable_lib PROPERTIES COMPILE_DEFINITIONS "${_portable_lib_defs}") +endif() +target_compile_definitions(portable_lib PRIVATE EXECUTORCH_PYTHON_MODULE_NAME=_portable_lib) # data_loader gets $ORIGIN too, so finding those libraries does not depend on which # module Python imports first. set_target_properties(data_loader PROPERTIES diff --git a/py/torch-tensorrt-executorch-runtime/pyproject.toml b/py/torch-tensorrt-executorch-runtime/pyproject.toml index bb057bbc4a9..4e762ae5707 100644 --- a/py/torch-tensorrt-executorch-runtime/pyproject.toml +++ b/py/torch-tensorrt-executorch-runtime/pyproject.toml @@ -6,6 +6,6 @@ requires = [ # environment. # Builds must use --no-build-isolation; see README.md. "torch", - "executorch==1.5.0.dev20260822", + "executorch==1.5.0.dev20260829", ] build-backend = "setuptools.build_meta" diff --git a/py/torch-tensorrt-executorch-runtime/torch_tensorrt_executorch_runtime/__init__.py b/py/torch-tensorrt-executorch-runtime/torch_tensorrt_executorch_runtime/__init__.py index ae8e04c3bf5..fe74987d7ca 100644 --- a/py/torch-tensorrt-executorch-runtime/torch_tensorrt_executorch_runtime/__init__.py +++ b/py/torch-tensorrt-executorch-runtime/torch_tensorrt_executorch_runtime/__init__.py @@ -10,7 +10,12 @@ from typing import Any, Protocol, cast BACKEND_NAME = "TensorRTBackend" -_NATIVE_NAME = "executorch.extension.pybindings._portable_lib" +_NATIVE_NAME = "executorch.extension.pybindings._C" +# The extension was called _portable_lib before ExecuTorch renamed it to _C, and portable_lib.py +# imports whichever name its own version uses. Both are claimed so the interception works against +# either: aliasing only the old name is silently ineffective at the pinned nightly, because nothing +# imports it any more and the stock _C loads instead, leaving TensorRTBackend unregistered. +_LEGACY_NATIVE_NAME = "executorch.extension.pybindings._portable_lib" _WRAPPER_NAME = "executorch.extension.pybindings.portable_lib" _DATA_LOADER_NAME = "executorch.extension.pybindings.data_loader" @@ -44,10 +49,20 @@ def activate() -> ModuleType: backend and optimized CPU kernels, so activation preserves the stock Python runtime CPU execution capabilities. """ - existing = sys.modules.get(_NATIVE_NAME) - if existing is not None and existing.__name__ == __name__ + "._portable_lib": - return existing - if existing is not None or _WRAPPER_NAME in sys.modules: + # Both alias names are inspected: whichever one a given ExecuTorch version uses, an entry there + # that is not ours means the stock extension already loaded. + claimed = [ + module + for module in ( + sys.modules.get(_NATIVE_NAME), + sys.modules.get(_LEGACY_NATIVE_NAME), + ) + if module is not None + ] + ours = __name__ + "._portable_lib" + if claimed and all(module.__name__ == ours for module in claimed): + return claimed[0] + if claimed or _WRAPPER_NAME in sys.modules: raise DelegateCompatibilityError( "ExecuTorch's stock runtime was imported first. Call " 'torch_tensorrt.load(..., format="executorch") before importing ' @@ -73,6 +88,7 @@ def activate() -> ModuleType: "the same release matrix." ) from error sys.modules[_NATIVE_NAME] = native + sys.modules[_LEGACY_NATIVE_NAME] = native sys.modules.pop(_WRAPPER_NAME, None) return native diff --git a/py/torch-tensorrt-executorch-runtime/torch_tensorrt_executorch_runtime/runtime.py b/py/torch-tensorrt-executorch-runtime/torch_tensorrt_executorch_runtime/runtime.py index 3e12e8d25db..90cf4f98f69 100644 --- a/py/torch-tensorrt-executorch-runtime/torch_tensorrt_executorch_runtime/runtime.py +++ b/py/torch-tensorrt-executorch-runtime/torch_tensorrt_executorch_runtime/runtime.py @@ -10,15 +10,12 @@ def _get_runtime() -> _Runtime: - try: - from torch_tensorrt_executorch_runtime import get_runtime - except ImportError as error: - raise ImportError( - "ExecuTorch Python inference requires the prebuilt delegate, which is " - "published for Linux only. Install it with: pip install --pre " - '"torch-tensorrt[executorch]" --extra-index-url ' - "https://download.pytorch.org/whl/nightly/cu130" - ) from error + # get_runtime is defined at module scope in this package's __init__, from stdlib and local + # imports, so importing the name here cannot fail once this submodule is importable. Calling it + # can still fail: it imports the ExecuTorch runtime, which raises ModuleNotFoundError when + # ExecuTorch is absent, and DelegateCompatibilityError when the delegate is not registered. + from torch_tensorrt_executorch_runtime import get_runtime + return get_runtime() diff --git a/py/torch_tensorrt/_compile.py b/py/torch_tensorrt/_compile.py index 1d0df1c5561..6a0fb9930d1 100644 --- a/py/torch_tensorrt/_compile.py +++ b/py/torch_tensorrt/_compile.py @@ -26,6 +26,7 @@ from torch_tensorrt._enums import dtype from torch_tensorrt._features import ENABLED_FEATURES, needs_cross_compile from torch_tensorrt._Input import Input +from torch_tensorrt._utils import executorch_install_command from torch_tensorrt.dynamo.runtime._CudaGraphsTorchTensorRTModule import ( CudaGraphsTorchTensorRTModule, ) @@ -630,9 +631,10 @@ def load( if format == "executorch": if not _has_executorch_runtime(): raise ImportError( - "Loading an ExecuTorch program requires the prebuilt " - "Torch-TensorRT ExecuTorch delegate. Install it with: " - "pip install torch-tensorrt-executorch-runtime" + "Loading an ExecuTorch program requires the Torch-TensorRT " + "ExecuTorch delegate runtime (torch_tensorrt_executorch_runtime), " + "which is not yet published to any package index. Build and install " + "it from source following py/torch-tensorrt-executorch-runtime/README.md." ) from torch_tensorrt_executorch_runtime.runtime import load as load_executorch @@ -856,11 +858,9 @@ def save( ) if output_format == "executorch" and not _has_executorch_exir(): raise ImportError( - "Saving in ExecuTorch format requires the executorch package " - "with executorch.exir, published for Linux only. Install with: " - "pip install --pre " - '"torch_tensorrt[executorch]" --extra-index-url ' - "https://download.pytorch.org/whl/nightly/cu130 to use output_format='executorch'." + "Saving in ExecuTorch format requires the executorch package with " + "executorch.exir, published for Linux only, to use " + "output_format='executorch'. Install with: " + executorch_install_command() ) if output_format == "executorch": # Every executorch option is popped above, so a leftover kwarg is a typo. Fail @@ -1407,10 +1407,8 @@ def _save_as_executorch(exp_program: Any, file_path: str, **kwargs: Any) -> None from torch_tensorrt.executorch import export except ImportError: raise ImportError( - "ExecuTorch is not installed, and is published for Linux only. Install " - "with: pip install --pre " - '"torch_tensorrt[executorch]" --extra-index-url ' - "https://download.pytorch.org/whl/nightly/cu130 to use output_format='executorch'." + "ExecuTorch is not installed, and is published for Linux only, to use " + "output_format='executorch'. Install with: " + executorch_install_command() ) import torch_tensorrt.dynamo.runtime.meta_ops.register_meta_ops # noqa: F401 diff --git a/py/torch_tensorrt/_utils.py b/py/torch_tensorrt/_utils.py index 62c4ad3f7bd..87de7a5ca32 100644 --- a/py/torch_tensorrt/_utils.py +++ b/py/torch_tensorrt/_utils.py @@ -31,6 +31,37 @@ def sanitized_torch_version() -> Any: ) +def executorch_install_channel() -> str: + """The PyTorch nightly channel that carries the ExecuTorch build matching this torch. + + ExecuTorch publishes a distinct wheel per CUDA channel (``+cu130`` and ``+cu132`` are separate + builds), so an install instruction has to name the channel that matches the user's torch, or a + CUDA 13.2 user installs a CUDA 13.0 ExecuTorch. Derived from ``torch.version.cuda`` rather than + hardcoded for that reason. Falls back to the literal placeholder ``cuXYZ`` when torch reports no + CUDA build, so the message stays honest instead of naming a channel the user cannot use. + """ + cuda_version = torch.version.cuda + if not cuda_version: + return "cuXYZ" + major, _, minor = cuda_version.partition(".") + return f"cu{major}{minor or '0'}" + + +def executorch_install_command() -> str: + """The exact ``pip install`` line for the ExecuTorch authoring stack, channel included. + + Shared by every runtime error message that tells a user how to install ExecuTorch, so the + channel is derived once from the running torch and the three messages cannot drift from each + other or from the pin. ``--upgrade`` because the message is raised from inside an already + installed ``torch_tensorrt``: without it pip treats the requirement as satisfied and exits 0 + without adding the ``executorch`` extra. + """ + return ( + 'pip install --pre --upgrade "torch_tensorrt[executorch]" ' + f"--extra-index-url https://download.pytorch.org/whl/nightly/{executorch_install_channel()}" + ) + + def check_cross_compile_trt_win_lib() -> bool: # cross compile feature is only available on linux # build engine on linux and run on windows diff --git a/py/torch_tensorrt/executorch/__init__.py b/py/torch_tensorrt/executorch/__init__.py index 0ef31ccf4bf..28becd4df85 100644 --- a/py/torch_tensorrt/executorch/__init__.py +++ b/py/torch_tensorrt/executorch/__init__.py @@ -22,12 +22,12 @@ def _has_executorch_exir() -> bool: if not _has_executorch_exir(): def __getattr__(name: str) -> NoReturn: + from torch_tensorrt._utils import executorch_install_command + raise ImportError( f"Cannot access torch_tensorrt.executorch.{name}: " "ExecuTorch with executorch.exir is required, and is published for " - "Linux only. Install with: pip install --pre " - '"torch_tensorrt[executorch]" --extra-index-url ' - "https://download.pytorch.org/whl/nightly/cu130" + "Linux only. Install with: " + executorch_install_command() ) __all__ = [ diff --git a/tests/ci/suites.py b/tests/ci/suites.py index 7b2fd142c1c..d3cd6b8e5f6 100644 --- a/tests/ci/suites.py +++ b/tests/ci/suites.py @@ -283,8 +283,11 @@ def __post_init__(self) -> None: keyword=( # The pairing test is the one check here that needs a real ExecuTorch installed, so # it has to survive this deselection. Everything else in that file is a - # source-consistency check the lint job already covers. - "not test_executorch_pin or test_the_pinned_commit_is_the_pinned_wheels_own_source" + # source-consistency check the lint job already covers. The pin updater's own tests + # are repository tooling with no GPU or ExecuTorch need, covered by the lint job, so + # they are deselected here rather than run in a CUDA container. + "(not test_executorch_pin or test_the_pinned_commit_is_the_pinned_wheels_own_source)" + " and not test_update_executorch_pin" ), setup=("executorch",), jobs="auto", diff --git a/tests/py/dynamo/executorch/test_executorch_pin.py b/tests/py/dynamo/executorch/test_executorch_pin.py index e18a949ec9d..e02b2012164 100644 --- a/tests/py/dynamo/executorch/test_executorch_pin.py +++ b/tests/py/dynamo/executorch/test_executorch_pin.py @@ -19,7 +19,6 @@ import os import pathlib import re -import shlex import subprocess import sys from collections import Counter @@ -75,24 +74,20 @@ def _requirement_disagrees(actual: str, expected: str, version: str) -> str: # Anywhere else the commit appears it is named on the same line, as a shell default or prose. NAMED_COMMIT = re.compile(r"executorch[_a-z]*[^0-9a-f]*([0-9a-f]{40})", re.IGNORECASE) -# Requirements that end up in metadata someone resolves at install time. A patch release off -# the same branch has to stay installable, so these take the range. Everything else pins -# exactly: a build input compiled against one wheel, or a comment labelling a source sha. -# Only literal requirements land here. setup.py and tests/ci/runner.py derive theirs from -# dev_dep_versions.yml, so the search below no longer sees them and -# test_derived_requirements_match_the_pin covers them instead. -# -# Empty today: the only literal range left was the justfile's install recipe, and it installs -# the wheel the delegate is compiled against, so it pins exactly like the rest. - -# A step that exists to reproduce what a user runs belongs to the range group even inside a -# file that otherwise pins build inputs, so the marker travels with the line rather than -# with the path. -# An explicit opt-out token rather than prose. "verify the end user's workflow" is a sentence -# someone can write, or paste, above a requirement without meaning to license a range there. PAIRING_TEST = "test_the_pinned_commit_is_the_pinned_wheels_own_source" +# A requirement takes the install-time range only when a comment carrying this exact token sits +# above it, so a site that reproduces a user's workflow can stay installable across a patch +# release. An explicit opt-out token rather than prose: "verify the end user's workflow" is a +# sentence someone can paste above a requirement without meaning to license a range there. USER_WORKFLOW_MARKER = "pin-check: range-ok" +# A pip install of the plain torch-tensorrt wheel on a platform that has no ExecuTorch dev wheel +# carries this token. win32 is the case: the glob it installs also matches the Linux-only runtime +# wheel, so the channel scan reaches it, but ExecuTorch publishes no win32 nightly and the +# [executorch] extra is Linux-only. An explicit token rather than inference, so the exemption is +# deliberate and cannot be granted by accident to a Linux install that simply lost its index. +NO_NIGHTLY_MARKER = "pin-check: no-nightly" + # The files expected to pin ExecuTorch, mapped to how many sites each must carry, excluding # dev_dep_versions.yml itself. A count per file rather than just the set of files, because a site # that loses its version stops matching the search entirely rather than reporting a mismatch, and @@ -106,8 +101,9 @@ def _requirement_disagrees(actual: str, expected: str, version: str) -> str: "docker/MODULE.bazel.docker": 1, "docker/MODULE.bazel.ngc": 1, "justfile": 1, - # Two: the install command and the prose sentence below it. - "py/torch-tensorrt-executorch-runtime/README.md": 2, + # One: the fenced install command. The prose sentence below it is documentation, checked for + # pin agreement but not counted, so it cannot stand in for the command if that loses its pin. + "py/torch-tensorrt-executorch-runtime/README.md": 1, "py/torch-tensorrt-executorch-runtime/pyproject.toml": 1, "toolchains/ci_workspaces/MODULE.bazel.tmpl": 1, } @@ -162,23 +158,30 @@ def _versions() -> dict: return dict(re.findall(r'^(__\w+__): "([^"]+)"', text, re.MULTILINE)) -def _wants_range(path: str, number: int) -> bool: - # Scan upward past blanks and comment lines, so an explanatory line between the opt-out and - # the requirement neither reclassifies the site nor fails the build for a cosmetic reason. - # Only a comment carrying the token licenses a range; the first line of real content stops - # the scan, so the opt-out cannot leak onto an unrelated requirement further down. - lines = (REPO_ROOT / path).read_text().splitlines() +def _has_marker_above(path: str, number: int, marker: str) -> bool: + """Whether a comment carrying ``marker`` sits directly above line ``number``. + + Scans upward past blank and comment lines; the first line of real content stops the scan, so + the marker cannot leak from an unrelated command far above onto this one. + """ + lines = (REPO_ROOT / path).read_text(encoding="utf-8").splitlines() for line in reversed(lines[: number - 1]): stripped = line.strip() if not stripped: continue if not stripped.startswith("#"): return False - if USER_WORKFLOW_MARKER in stripped: + if marker in stripped: return True return False +def _wants_range(path: str, number: int) -> bool: + # Only a comment carrying the token licenses a range; the first line of real content stops the + # scan, so the opt-out cannot leak onto an unrelated requirement further down. + return _has_marker_above(path, number, USER_WORKFLOW_MARKER) + + def _release_line(version: str) -> tuple[str, str]: """Split a pin into its major and minor, for either a release or a nightly. @@ -195,17 +198,30 @@ def _expected(path: str, number: int, version: str) -> str: return f"executorch=={version}" major, minor = _release_line(version) - # Only the top-level setup.py carries the Linux marker. It is the site uv resolves for the - # win32 required-environment, where PyPI's candidates stop below this floor. - marker = "; platform_system == 'Linux'" if path == "setup.py" and number > 1 else "" - return f"executorch>={version},<{major}.{int(minor) + 1}{marker}" + return f"executorch>={version},<{major}.{int(minor) + 1}" + + +# Nightly channels that actually carry the pinned ExecuTorch line. cu124 and cu128 exist on the +# index but are frozen at a CPU-only 0.5.0.dev build, so a recipe pointed at them resolves nothing +# the pin can use. Only these three serve the 1.5.0.dev wheels this change installs. +_PUBLISHED_NIGHTLY_CHANNELS = frozenset({"cu126", "cu130", "cu132"}) +# Tracked files with no suffix that still carry install commands. justfile writes the nightly +# ExecuTorch install for local builds, so the printed-install walk has to read it by name. +_EXTENSIONLESS_INSTALL_FILES = frozenset({"justfile"}) + +# Index-URL variables whose value legitimately arrives from the CI environment and so has no +# assignment in the tree to resolve. An unresolved variable is accepted only if it is one of +# these; every other unresolved name, including a typo of a real one, fails the channel check +# rather than passing on sight. Empty today: every ExecuTorch install channels through either a +# literal nightly URL or ${EXECUTORCH_INDEX_URL}, which is assigned in executorch-build-linux.yml +# and therefore resolvable. Kept as the explicit seam a future environment-provided index goes +# through. +_ENVIRONMENT_INDEX_VARIABLES: frozenset[str] = frozenset() # The bazel repositories annotate their pinned commit with the wheel it corresponds to, in a # comment, because bazel fetches by commit and has no requirement string to carry. Those are the # only comment sites that count as pins, and the commit beside them is checked separately. -_PUBLISHED_NIGHTLY_CHANNELS = frozenset({"cu124", "cu126", "cu128", "cu130", "cu132"}) - _ANNOTATED_COMMIT_SITES = frozenset( { "MODULE.bazel", @@ -243,6 +259,60 @@ def _without_trailing_comment(path: str, text: str) -> str: return text +def _counts_toward_minimum(path: str, number: int) -> bool: + """Whether a requirement at this line counts toward the per-file minimum. + + In a prose file a requirement in running text is documentation, not a live pin. Gutting the + fenced install command to a bare ``executorch`` while a sentence below still spelled the pin + kept the per-file count satisfied and left the command unpinned, so only a requirement inside + a fenced code block counts for markdown. Every other file counts every live line; the + reStructuredText sites are guarded by the install-instruction test instead. + """ + if not path.endswith(".md"): + return True + fenced = False + for current, content in enumerate( + (REPO_ROOT / path).read_text(encoding="utf-8").splitlines(), start=1 + ): + if current == number: + return fenced + if content.lstrip().startswith(("```", "~~~")): + fenced = not fenced + return False + + +def _strip_whole_line_comments(text: str) -> str: + """Blank out whole-line ``#`` comments, preserving line count so DOTALL spans stay aligned. + + The Bazel commit match walks from ``name = "executorch"`` to the ``commit = "..."`` line with + DOTALL, so a commit commented out and replaced by a live ``branch = "main"`` still matched the + commented copy and the build floated to a branch while this test stayed green. + """ + return "\n".join( + "" if line.lstrip().startswith("#") else line for line in text.splitlines() + ) + + +def _resolve_shell_assignment(text: str, variable: str, before: int) -> str | None: + """The last literal ``VAR=...`` assignment of ``variable`` in ``text`` before offset ``before``. + + An install that channels through ``--extra-index-url "${VAR}"`` proves nothing on its own: the + value is whatever ``VAR`` was last set to. Repointing that assignment at PyPI, or dropping its + ``nightly/`` segment, left the install counted as channelled while it resolved nothing. Resolve + the assignment so the channel is checked where it is actually set. Returns ``None`` when no + assignment is found, meaning the value comes from the environment and cannot be resolved here. + """ + assignment = re.compile( + rf"""^\s*(?:export\s+)?{re.escape(variable)}=["']?([^"'\n]*)""", re.MULTILINE + ) + resolved = None + for match in assignment.finditer(text): + if match.start() >= before: + break + resolved = match.group(1) + return resolved + + def test_every_requirement_matches_the_pin() -> None: version = _versions()["__executorch_version__"] @@ -266,9 +336,11 @@ def test_every_requirement_matches_the_pin() -> None: # the same line kept the per-file count satisfied and left the install unpinned. The # annotated commit sites write their pin as a whole-line comment, which is handled above, # so nothing legitimate is lost here. + counts = _counts_toward_minimum(path, int(number)) for actual in REQUIREMENT.findall(_without_trailing_comment(path, text)): found += 1 - seen[path] += 1 + if counts: + seen[path] += 1 reason = _requirement_disagrees(actual, expected, version) if reason: wrong.append(f"{path}:{number} has {actual}, {reason}") @@ -415,6 +487,60 @@ def test_derived_requirements_match_the_pin(monkeypatch) -> None: ) +_RUNTIME_SETUP_PY = "py/torch-tensorrt-executorch-runtime/setup.py" + + +def _runtime_install_requires() -> dict[str, str]: + """The runtime wheel's ``install_requires`` entries, each mapped to its source text. + + Read as source, not imported: importing this setup.py runs a Bazel build. The values are + f-strings built at build time from the installed distributions, so the source segment is what + the check compares, not a resolved string. + """ + tree = ast.parse((REPO_ROOT / _RUNTIME_SETUP_PY).read_text(encoding="utf-8")) + source = (REPO_ROOT / _RUNTIME_SETUP_PY).read_text(encoding="utf-8") + call = next( + node + for node in ast.walk(tree) + if isinstance(node, ast.Call) and getattr(node.func, "id", None) == "setup" + ) + requires = next( + keyword.value for keyword in call.keywords if keyword.arg == "install_requires" + ) + entries = {} + for element in requires.elts: + text = ast.get_source_segment(source, element) + distribution = re.match(r'f?"([A-Za-z0-9_.-]+)', text) + if distribution: + entries[distribution.group(1)] = text + return entries + + +def test_the_runtime_wheel_pins_executorch_to_the_public_pin() -> None: + """The runtime wheel's own ExecuTorch requirement has to pin the pinned version, stripped. + + This is the one requirement whose native code is compiled against the ExecuTorch runtime, so a + wheel that requires a different ExecuTorch than it was built against loads a mismatched runtime. + The literal search above cannot see it: setup.py builds the string from the installed + distribution, so ``executorch==`` is followed by a brace, not a digit. Loosening it to a bare + ``executorch`` left every other test green. + """ + entries = _runtime_install_requires() + assert ( + "executorch" in entries + ), f"{_RUNTIME_SETUP_PY} install_requires no longer pins executorch: {sorted(entries)}" + # The value is built from installed_version("executorch"), the same source torch and + # torch-tensorrt use, and stripped of its local label the same way. Compare the source text so + # a bare name, a hardcoded version, or a different version source is rejected. + assert ( + entries["executorch"] == 'f"executorch=={public_version(executorch_version)}"' + ), ( + f"{_RUNTIME_SETUP_PY} must pin executorch to public_version(executorch_version), so the " + f"wheel requires the ExecuTorch it was compiled against, but it declares " + f"{entries['executorch']}" + ) + + def test_the_runner_follows_the_row_s_cuda_version(monkeypatch) -> None: """Call the runner and read the URL it builds, rather than matching its source. @@ -450,6 +576,114 @@ def channel_for(cu_version: str | None) -> str: assert channel_for(None).endswith("/nightly/cu130") +def _load_utils_channel_helpers(fake_cuda: str | None): + """Exec ``executorch_install_channel``/``executorch_install_command`` with a stub torch. + + ``py/torch_tensorrt/_utils.py`` imports ``tensorrt`` and the built ``torch_tensorrt``, neither + installed on the lint runner, so it cannot be imported here. Extract just the two functions and + exec them against a fake ``torch`` whose ``version.cuda`` is ``fake_cuda``, which is all they + read. This keeps the test on the source that ships rather than a copy of its logic. + """ + source = (REPO_ROOT / "py/torch_tensorrt/_utils.py").read_text(encoding="utf-8") + tree = ast.parse(source) + wanted = {"executorch_install_channel", "executorch_install_command"} + functions = [ + node + for node in tree.body + if isinstance(node, ast.FunctionDef) and node.name in wanted + ] + assert {f.name for f in functions} == wanted, ( + "py/torch_tensorrt/_utils.py must define executorch_install_channel and " + f"executorch_install_command; found {sorted(f.name for f in functions)}" + ) + + class _Version: + cuda = fake_cuda + + namespace: dict[str, object] = {"torch": type("torch", (), {"version": _Version})} + module = ast.Module(body=functions, type_ignores=[]) + exec(compile(module, "", "exec"), namespace) # noqa: S102 + return ( + namespace["executorch_install_channel"], + namespace["executorch_install_command"], + ) + + +def test_the_executorch_install_message_names_the_torch_channel() -> None: + """The three ExecuTorch install messages derive their channel from the running torch. + + ExecuTorch ships a distinct wheel per CUDA channel, so a message that hardcodes cu130 tells a + CUDA 13.2 user to install a CUDA 13.0 build. The messages route through + ``executorch_install_command`` so the channel is computed once from ``torch.version.cuda``. + A source-text assertion cannot see the value the format string produces, so exercise the helper + across both published 13.x channels and the no-CUDA fallback. + """ + channel, command = _load_utils_channel_helpers("13.2") + assert channel() == "cu132" + message = command() + assert "download.pytorch.org/whl/nightly/cu132" in message, message + # Raised from inside an installed torch_tensorrt, so pip treats the requirement as satisfied + # and exits 0 without the extra unless --upgrade forces a re-resolve. --pre selects the dev pin. + assert "--upgrade" in message and "--pre" in message, message + + channel_130, command_130 = _load_utils_channel_helpers("13.0") + assert channel_130() == "cu130" + assert "nightly/cu130" in command_130() + + # No CUDA build reports a placeholder rather than a channel the user cannot install from. + channel_none, command_none = _load_utils_channel_helpers(None) + assert channel_none() == "cuXYZ" + assert "nightly/cuXYZ" in command_none() + + # Every message site has to delegate to the shared command, checked one site at a time. A + # whole-file substring pass cannot do that: with two sites in a file, "the helper is called + # somewhere" is satisfied by whichever site is still correct, so hardcoding a channel in the + # other one goes unnoticed. Worse, forbidding the literal cu130 only catches hardcoding the + # RIGHT channel, while cu126 or cu132 sail through and misdirect a user on that CUDA build. + # + # A "site" is a raise of ImportError whose message mentions installing ExecuTorch. Each one must + # obtain its command by calling the helper rather than by carrying a literal index URL. + for path in ( + "py/torch_tensorrt/_compile.py", + "py/torch_tensorrt/executorch/__init__.py", + ): + text = (REPO_ROOT / path).read_text(encoding="utf-8") + tree = ast.parse(text) + sites = 0 + for node in ast.walk(tree): + if not isinstance(node, ast.Raise) or node.exc is None: + continue + rendered = ast.unparse(node) + if ( + "download.pytorch.org" not in rendered + and "install" not in rendered.lower() + ): + continue + if "executorch" not in rendered.lower(): + continue + calls_helper = any( + isinstance(inner, ast.Call) + and isinstance(inner.func, ast.Name) + and inner.func.id == "executorch_install_command" + for inner in ast.walk(node) + ) + if not calls_helper: + # Only complain when the site actually spells an index URL itself. A raise that + # names no channel at all is a different message, not a hardcoded one. + assert "download.pytorch.org" not in rendered, ( + f"{path} raises an ExecuTorch install message that spells its own index URL " + "instead of calling executorch_install_command(), so the channel it prints " + "cannot follow the running torch:\n" + f"{rendered}" + ) + continue + sites += 1 + assert sites, ( + f"{path} has no ExecuTorch install message built with executorch_install_command(); " + "either a site was removed or this scan no longer recognises it" + ) + + def test_derived_requirements_roll_the_minor_over(tmp_path: Path) -> None: # The upper bound is a version, not a decimal: 1.9 has to become 1.10, not 1.1. # Spelled through a variable because the search above reads this file too, and a @@ -529,7 +763,8 @@ def test_every_source_commit_matches_the_pin() -> None: found = 0 seen: Counter[str] = Counter() for path in _git("grep", "-lI", "-E", 'name = "executorch"').split(): - for match in BAZEL_COMMIT.finditer((REPO_ROOT / path).read_text()): + source = _strip_whole_line_comments((REPO_ROOT / path).read_text()) + for match in BAZEL_COMMIT.finditer(source): found += 1 seen[path] += 1 if match.group(1) != commit: @@ -539,7 +774,12 @@ def test_every_source_commit_matches_the_pin() -> None: path, number, text = line.split(":", 2) if path == VERSIONS.name: continue - for actual in NAMED_COMMIT.findall(text): + # A commit in a comment is not a pin. Grepping raw lines let EXECUTORCH_REF float to a + # branch with the real SHA left behind in a "# ..." comment on the same file, which kept + # this scan green. The Bazel walk above already strips comments; do the same here. + if _is_commented_out(path, text): + continue + for actual in NAMED_COMMIT.findall(_without_trailing_comment(path, text)): found += 1 seen[path] += 1 if actual != commit: @@ -652,6 +892,64 @@ def test_the_lockfile_records_the_same_executorch_range_as_setup_py(): ) +_INSTALL_INVOCATION = re.compile( + r"(?:python[0-9.]*\s+-m\s+pip|uv\s+pip|\bpip)\s+(?:install|wheel)\b" +) + + +def _blank_comments_preserving_length(block: str) -> str: + """``block`` with comment text replaced by spaces, keeping every offset and newline in place. + + Used only to locate pip keywords without a ``# pip install ...`` in a comment starting a false + invocation. Length is preserved so an offset into the original block indexes the same character + here. Whole-line ``#`` comments blank entirely; a trailing `` #`` comment blanks from the hash, + but a ``#cu130`` URL fragment (no preceding space) is left intact. + """ + out = [] + for physical in block.splitlines(keepends=True): + newline = "\n" if physical.endswith("\n") else "" + body = physical[:-1] if newline else physical + if body.lstrip().startswith("#"): + body = " " * len(body) + else: + hash_at = re.search(r"(?:^|\s)#", body) + if hash_at: + cut = hash_at.start() + body = body[:cut] + " " * (len(body) - cut) + out.append(body + newline) + return "".join(out) + + +def _install_invocation_window(block: str, match_offset: int) -> str: + """The slice of ``block`` belonging to the one pip/uv invocation that owns the match. + + The channel and the requirement it channels have to belong to the *same* invocation. A window + bounded only at blank lines was too wide: it spanned every step of a contiguous YAML job and + every line of a shell if/else, so a ``--extra-index-url`` from a neighbouring ``pip install``, + an ``echo``, or prose satisfied the check for an install that carried none of its own. Two real + holes this closed: the win32 branch of ``install-torch-tensorrt.sh`` borrowed the else branch's + URL, and the ``.[executorch]`` step in ``docgen.yml`` borrowed the *Install base deps* step's. + + An invocation runs from its ``pip``/``uv pip`` keyword to the next such keyword in the block, or + to the block's end. That single boundary spans a backslash-continued shell command, a YAML + ``run:`` body and a Python error message built from adjacent string fragments alike, because + none of those start a second invocation between the keyword and the URL. ``match_offset`` is a + ``block``-relative offset into the original (un-stripped) text, so a requirement that appears + twice in one block resolves to its own invocation rather than the first copy's. When no + invocation keyword precedes the match the whole block is returned, leaving non-install prose + matches to the caller's other filters. + """ + scan = _blank_comments_preserving_length(block) + starts = [m.start() for m in _INSTALL_INVOCATION.finditer(scan)] + preceding = [s for s in starts if s <= match_offset] + if not preceding: + return block + begin = preceding[-1] + following = [s for s in starts if s > match_offset] + finish = following[0] if following else len(block) + return block[begin:finish] + + @pytest.mark.unit def test_every_printed_install_instruction_names_the_nightly_channel(): """Every ``[executorch]`` install instruction has to carry the nightly index. @@ -679,15 +977,40 @@ def test_every_printed_install_instruction_names_the_nightly_channel(): # The local-path spelling counts too. Matching only the named-distribution form left the four # sites that write "pip install .[executorch]" unguarded: the nightly index could be deleted # from all four with this test green. + # A fourth shape: a direct "executorch==" or "executorch>=" in a pip command. The + # runtime README build recipe installs ExecuTorch this way, and its nightly index could be + # deleted with this test green because none of the three shapes above match a bare + # distribution name. Gated on a pip context below so a requirement in pyproject or a comment + # is not mistaken for an install instruction. + # The built-wheel shape covers both the plain "torch_tensorrt*.whl" and the runtime wheel + # "torch_tensorrt_executorch_runtime-*.whl": the latter's install_requires names the same + # nightly ExecuTorch, so its documented install needs the channel too, and matching only the + # plain glob left the runtime README's Use command unscanned. It is gated on a real + # "pip install" without "--no-deps" below, so naming the file in an "ls" or a heredoc, or a + # "--no-deps" install that fetches nothing, is not mistaken for a dependency-resolving install. + # A fifth shape: a bare "pip install executorch" with no version operator. It resolves the + # stable 1.4.1 from PyPI, the version this change moves away from, and carries no operator so + # the shapes above miss it. Matched as a standalone distribution token and gated on a + # pip-install context below, so the word in a path, an import, a filename, or prose is not + # mistaken for an install. Like the named-distribution form it needs both the channel and + # --pre, since a bare name without --pre picks the stable release even off the nightly index. extra = re.compile( r"""torch[-_]tensorrt\[[^]]*executorch[^]]*\]""" r"""|(?=)""" + r"""|(?_-])""" ) missing = [] for name in tracked: - if not name or not name.endswith( - (".py", ".sh", ".md", ".yml", ".yaml", ".rst", ".txt") + base = name.rsplit("/", 1)[-1] + # Select by suffix, plus a few extensionless files that carry install commands. The + # justfile in particular writes a real "uv pip install ... executorch==" with its + # nightly index; filtering on suffix alone never read it, so both the index and the pin + # could be dropped from it with this test green. + if not base or not ( + name.endswith((".py", ".sh", ".md", ".yml", ".yaml", ".rst", ".txt")) + or base in _EXTENSIONLESS_INSTALL_FILES ): continue # This file states the rule; it is not itself an instruction. @@ -703,22 +1026,130 @@ def test_every_printed_install_instruction_names_the_nightly_channel(): continue text = path.read_text(encoding="utf-8", errors="replace") for match in extra.finditer(text): - # The instruction is the pip invocation, so bound the window at the surrounding - # blank-line-separated block rather than guessing a fixed number of lines. - start = text.rfind("\n\n", 0, match.start()) + 1 - end = text.find("\n\n", match.end()) - block = text[start : end if end != -1 else len(text)] line = text.count("\n", 0, match.start()) + 1 + # Bound the window at the surrounding blank-line-separated block first, then narrow to + # the single pip invocation that owns the match. The block alone was too wide: a + # contiguous YAML job or a shell if/else is one block, so a --extra-index-url, --pre or + # channel from a neighbouring command satisfied an install that carried none itself. + block_start = text.rfind("\n\n", 0, match.start()) + 1 + block_end = text.find("\n\n", match.end()) + block = text[block_start : block_end if block_end != -1 else len(text)] + block = _install_invocation_window(block, match.start() - block_start) + # Strip comments after windowing: a shell comment naming the channel or --pre is prose, + # not an argument pip sees, so a gutted install line with a decoy comment beside it must + # not satisfy the check. Whole-line comments drop entirely; a trailing "#" comment is + # cut, but not the "#cu130" fragment of a URL, which carries no space. + block = "\n".join( + re.sub(r"(?:^|\s)#.*$", "", bl) + for bl in block.splitlines() + if not bl.lstrip().startswith("#") + ) + # A plain torch-tensorrt wheel install on a platform with no ExecuTorch dev wheel is + # exempt. win32 installs a glob that also matches the Linux-only runtime wheel, so the + # scan reaches it, but ExecuTorch publishes no win32 nightly. The marker has to sit + # directly above the invocation, and a separate test keeps it inside a win32 guard, so a + # gutted Linux install cannot claim it. + if _has_marker_above(name, line, NO_NIGHTLY_MARKER): + continue + is_direct_install = bool( + re.fullmatch(r"executorch(?:_[a-z]+)?\s*(?:==|>=)", match.group(0)) + ) + # A bare "executorch==" only counts as an instruction inside a pip command. Anywhere + # else it is a dependency declaration or prose, guarded by other tests. + if is_direct_install: + if not re.search(r"\bpip\s+(?:install|wheel)\b", block): + continue + # The version after the operator has to be a literal. "executorch==${OLD}" or + # "executorch==$(...)" clears the pip-context gate yet pins nothing: pip installs + # whatever the expansion yields, which floats off the pin. The one legitimate + # non-literal is docgen's, which reads __executorch_version__ out of + # dev_dep_versions.yml; a separate test proves that command equals the pin. + after = text[match.end() : match.end() + 80].lstrip() + if not after[:1].isdigit() and not after.startswith( + "$(python3 -c 'import yaml;" + ): + missing.append( + f"{name}:{line} installs executorch at a non-literal version, which pins " + "nothing: pip resolves whatever the expansion yields off the nightly index" + ) + continue + is_bare_name = match.group(0) == "executorch" + if is_bare_name: + # A bare distribution name is an install only inside a pip command; the same word + # in a path, an import, or prose is not. + if not re.search(r"\bpip\s+(?:install|wheel)\b", block): + continue + # The word also appears in prose that shares a block with a real + # "torch_tensorrt[executorch]" install, so require the bare token to sit on the pip + # command line itself before treating it as the install target. A bare target there + # pins nothing even with --pre and the channel: pip resolves the newest nightly, not + # this pin. Nothing in the tree installs executorch bare, so it is always a defect. + command_line = text.splitlines()[line - 1] + if re.search(r"\bpip\s+(?:install|wheel)\b", command_line): + missing.append( + f"{name}:{line} installs executorch by bare name, which pins nothing: pip " + "resolves the newest nightly rather than the pinned version" + ) + continue + is_built_wheel = bool( + re.fullmatch( + r"torch_tensorrt(?:_executorch_runtime-)?\*\.whl", match.group(0) + ) + ) + # This glob names a local file, so it pulls the nightly ExecuTorch dependency only in a + # "pip install" that resolves dependencies. Naming the file elsewhere (an "ls", a + # heredoc, "pip wheel", or a "--no-deps" install) fetches no ExecuTorch, so no channel + # applies. + if is_built_wheel and ( + not re.search(r"\bpip\s+install\b", block) + or re.search(r"(?:^|\s)--no-deps(?:\s|$)", block) + ): + continue channel = re.search( r"download\.pytorch\.org/whl/nightly(?:/(cu\d+))?", block ) + # CI passes the channel through a variable rather than a literal URL. Capture the + # variable name so its assignment can be resolved: accepting the reference on sight let + # the assignment be repointed at PyPI, or stripped of its nightly segment, with the + # install still counted as channelled. + variable_index = re.search( + r"--(?:extra-index-url|index-url)\s+\"?\$\{?([A-Za-z_][A-Za-z0-9_]*)", + block, + ) + if not channel and variable_index: + # Resolve the variable's last assignment before this install and check the channel + # there. An unresolved variable is accepted only when it is a known workflow input + # whose value arrives from the CI environment; every other unresolved name, a typo + # among them, fails rather than passing on sight. + assignment = _resolve_shell_assignment( + text, variable_index.group(1), match.start() + ) + if assignment is None: + if variable_index.group(1) in _ENVIRONMENT_INDEX_VARIABLES: + continue + missing.append( + f"{name}:{line} channels through ${{{variable_index.group(1)}}}, which has " + "no assignment in the tree and is not a known CI index input" + ) + continue + channel = re.search( + r"download\.pytorch\.org/whl/nightly(?:/(cu\d+))?", assignment + ) + if not channel: + missing.append( + f"{name}:{line} installs from ${{{variable_index.group(1)}}}, set to " + f"{assignment!r}, which is not the nightly channel" + ) + continue if not channel: missing.append(f"{name}:{line} names no nightly channel") continue # A substring proves a string sits nearby, not that it resolves anything. Rewriting # every channel in the tree to a nonexistent cu999 left this green. Not compared - # against __cuda_version__: five sites legitimately say cu130 while the pin says 13.2, - # and the index really does carry the pinned ExecuTorch under both. + # against __cuda_version__: the runtime error messages derive their channel from the + # user's torch build, and the documented recipes name a concrete published channel that + # carries the pinned ExecuTorch, so a literal cuXYZ here is checked only for being one + # the project publishes. suffix = channel.group(1) if suffix and suffix not in _PUBLISHED_NIGHTLY_CHANNELS: missing.append( @@ -745,6 +1176,58 @@ def test_every_printed_install_instruction_names_the_nightly_channel(): ) +@pytest.mark.unit +def test_the_no_nightly_marker_only_exempts_a_win32_install(): + """The ``no-nightly`` exemption is legitimate only where no ExecuTorch dev wheel exists. + + The channel scan skips an install carrying ``pin-check: no-nightly`` above it. That is correct + for win32, whose wheel glob also matches the Linux-only runtime wheel while ExecuTorch ships no + win32 nightly. Without this test the marker is a blanket silencer: strip the index from the + Linux install, paste the marker above it, and the channel scan stays green. Requiring the marker + to sit inside a ``win32`` platform guard keeps the exemption tied to the one case it describes. + """ + tracked = subprocess.run( + ["git", "ls-files", "-z"], + cwd=REPO_ROOT, + capture_output=True, + text=True, + check=True, + ).stdout.split("\0") + + misplaced = [] + for name in tracked: + if not name or name == "tests/py/dynamo/executorch/test_executorch_pin.py": + continue + path = REPO_ROOT / name + if not path.is_file(): + continue + text = path.read_text(encoding="utf-8", errors="replace") + if NO_NIGHTLY_MARKER not in text: + continue + lines = text.splitlines() + control = re.compile(r"^\s*(?:if\b|elif\b|else\b|fi\b)") + for index, content in enumerate(lines): + if NO_NIGHTLY_MARKER not in content: + continue + # The exempted install sits just below the marker, so the branch it lives in is the + # nearest control-flow keyword above it. Requiring that keyword to be the win32 guard + # ties the exemption to the one platform it describes: a marker pasted onto a Linux + # "else" install resolves to that "else", not to "if ... win32", and is rejected. + branch = next( + (lines[j] for j in range(index - 1, -1, -1) if control.match(lines[j])), + "", + ) + if "win32" not in branch: + misplaced.append( + f"{name}:{index + 1} carries {NO_NIGHTLY_MARKER!r} outside a win32 branch, " + "so it would exempt a Linux install that simply lost its index" + ) + + assert not misplaced, ( + "the no-nightly exemption is only valid inside a win32 branch: " f"{misplaced}" + ) + + @pytest.mark.unit def test_the_pin_check_runs_in_ci(): """This file has to be invoked by something, or its assertions never execute. @@ -772,10 +1255,21 @@ def test_the_pin_check_runs_in_ci(): f"linter.yml triggers on {sorted(map(str, trigger_names))}, not pull_request, so the pin " "check never runs when a pull request changes the pin" ) - # Match a live pytest invocation, not the filename anywhere in the script. Neutralising the - # command and leaving it in a shell comment satisfied a plain substring test. + # A paths filter on the trigger would keep the workflow from firing on a pin change outside + # those paths, leaving every assertion below green while nothing ran. + pull_request = triggers.get("pull_request") if isinstance(triggers, dict) else None + if isinstance(pull_request, dict): + for path_filter in ("paths", "paths-ignore"): + assert path_filter not in pull_request, ( + f"linter.yml narrows the pull_request trigger with {path_filter}, so a change to " + "the pin outside those paths would not run this check" + ) + # Match a live pytest invocation with pytest as the command word, not the filename anywhere + # in the script. A plain substring test was satisfied by a comment; an "anything before + # pytest" test was satisfied by "echo python3 -m pytest", which prints the command and runs + # nothing. invocation = re.compile( - rf"^\s*[^#\n]*\bpytest\b[^\n]*{re.escape(pathlib.Path(__file__).name)}", + rf"^\s*(?:python[0-9.]*\s+-m\s+)?pytest\b[^\n]*{re.escape(pathlib.Path(__file__).name)}", re.MULTILINE, ) owning = [ @@ -799,20 +1293,32 @@ def test_the_pin_check_runs_in_ci(): assert ( job_condition in live_conditions ), f"job {name} runs under {job_condition!r}, so the pin check may never dispatch" - # Tokenised, not substring-matched, and every way of neutralising the run counts. "--co" is - # pytest's own documented short form of "--collect-only" and slipped past a check for the long - # spelling. "|| true", "; true" and continue-on-error each discard the exit status, the last - # two at the step and at the job. - tokens = shlex.split(step["run"].replace("\\\n", " ")) - for flag in ("--collect-only", "--co", "--help", "-h"): + + # Compared as text, not executed. This used to run the step's own shell body under bash + # against a stub, which meant whatever that body said got executed on every pull request: + # appending an "echo ... >> /tmp/marker" line to the step left this test GREEN and the marker + # written twice. That is the same defect this file already fixed for the docgen one-liner, and + # the reasoning there applies here. Assert the shape of the command instead: it has to invoke + # this file under pytest, with no flag that could deselect or neuter the run. + script = step["run"] + assert re.search( + r"python3 -m pytest\s+\S*tests/py/dynamo/executorch/test_executorch_pin\.py", + script, + ), f"the pin check step in {name} does not run this file under pytest: {script!r}" + for forbidden, why in ( + ("--collect-only", "collection alone never runs an assertion"), + ("--co", "collection alone never runs an assertion"), + ("|| true", "the exit status is discarded, so a failure cannot fail the job"), + ("set +e", "the exit status is discarded, so a failure cannot fail the job"), + ("continue-on-error", "a failure cannot fail the job"), + ("--deselect", "a deselected test cannot fail"), + ("-k ", "a keyword filter can silently select nothing"), + ("exit 0", "the step reports success regardless of the result"), + ): assert ( - flag not in tokens - ), f"the pin check in {name} passes {flag}, so no assertion executes" - for terminator in ("||", ";", "&"): - assert terminator not in tokens, ( - f"the pin check in {name} follows pytest with {terminator!r}, so its exit status does " - "not fail the step" - ) + forbidden not in script + ), f"the pin check step in {name} contains {forbidden!r}, so {why}" + assert not step.get( "continue-on-error" ), f"the pin check step in {name} is continue-on-error, so a failure cannot fail the job" @@ -824,7 +1330,14 @@ def test_the_pin_check_runs_in_ci(): # requirements.txt nor dependency-groups.lint carries them, and without them the step exits 1 # on "No module named pytest" before running any assertion. steps = job["steps"] - earlier = "\n".join(s.get("run") or "" for s in steps[: steps.index(step)]) + # Comment-stripped: a commented-out "uv pip install ... pytest pyyaml" still matched the raw + # text while installing nothing, so the step would die on a missing import at runtime. + earlier = "\n".join( + re.sub(r"(?:^|\s)#.*$", "", line) + for step_before in steps[: steps.index(step)] + for line in (step_before.get("run") or "").splitlines() + if not line.lstrip().startswith("#") + ) for package in ("pytest", "pyyaml"): assert re.search( rf"uv pip install --system[^\n]*\b{package}\b", earlier @@ -917,14 +1430,24 @@ def test_the_pairing_check_survives_the_gpu_lane_deselection() -> None: for job in workflow["jobs"].values() for step in job.get("steps", []) ] + [job.get("with", {}).get("script", "") for job in workflow["jobs"].values()] - invokes_tier = any( - re.search(r"^\s*trt_tier_executorch\b", script, re.MULTILINE) + # The call must not discard its own exit status. "trt_tier_executorch || true", a trailing + # ";", "&" or a pipe each let the tier fail while the lane stays green, so reject any status + # operator after the call on its line. + tier_calls = [ + match for script in scripts - ) - assert invokes_tier, ( + for match in re.finditer( + r"^\s*trt_tier_executorch\b([^\n]*)", script, re.MULTILINE + ) + ] + assert tier_calls, ( "executorch-test-linux.yml no longer calls trt_tier_executorch, so the pairing check " "never runs on the GPU lane even though its -k expression would select it" ) + assert any(not re.search(r"[|;&]", call.group(1)) for call in tier_calls), ( + "every trt_tier_executorch call in executorch-test-linux.yml discards its exit status " + "with a pipe, ';', '&' or '|| true', so a pairing failure cannot fail the lane" + ) # The manifest route: the executorch suite must exist and target a lane a runner requests. # A typo in its lane tuple silently drops it from every matrix, which the suite-name check @@ -941,3 +1464,52 @@ def test_the_pairing_check_survives_the_gpu_lane_deselection() -> None: f"the executorch suite runs on lanes {executorch_suite.lanes!r}, none of which is the " "nightly lane the GPU tier requests, so the pairing check runs nowhere" ) + + +def test_the_range_install_runs_in_a_fresh_venv(): + # The end-user range install has to resolve the specifier from scratch. Run in the build + # interpreter, which already holds the exact pin, and pip resolves nothing and proves nothing. + # This locks the fresh-venv shape so it cannot silently regress to an in-place install. + workflow = (REPO_ROOT / ".github/workflows/executorch-build-linux.yml").read_text( + encoding="utf-8" + ) + marker = "# pin-check: range-ok" + assert marker in workflow, "the range-ok install marker is gone" + after = workflow.split(marker, 1)[1].splitlines() + # The install command is the first non-empty line under the marker. + install = next(line for line in after if line.strip()) + assert ( + "range-check-venv" in install + ), f"the range-ok install no longer runs in a fresh venv: {install.strip()!r}" + + +def test_the_pin_update_workflow_does_not_interpolate_untrusted_values_into_shell(): + # github.ref and inputs.track are attacker-influenceable text. Interpolated with ${{ }} into a + # run: block they are shell source, so a crafted ref runs code in a job that holds a + # write-scoped token. They must arrive through env and be read as "$REF" / "$TRACK" instead. + import yaml + + path = REPO_ROOT / ".github/workflows/executorch-pin-update.yml" + text = path.read_text(encoding="utf-8") + workflow = yaml.safe_load(text) + untrusted = ("github.ref", "inputs.track", "steps.track.outputs.track") + + offenders = [] + for job in workflow.get("jobs", {}).values(): + for step in job.get("steps", []): + run = step.get("run") + if not run: + continue + for expr in untrusted: + if "${{" in run and expr in run: + offenders.append((step.get("name", "?"), expr)) + assert not offenders, ( + "these steps interpolate an untrusted value into a run: script instead of reading it from " + f"env: {offenders}" + ) + + # And the guard still reads the values, just safely: through env, as shell variables. + assert "REF: ${{ github.ref }}" in text and "TRACK: ${{ inputs.track }}" in text, ( + "the trigger values are no longer passed through env, so the ref guard cannot read them " + "safely" + ) diff --git a/tests/py/dynamo/executorch/test_python_runtime.py b/tests/py/dynamo/executorch/test_python_runtime.py index 04013a2337a..69b270aff25 100644 --- a/tests/py/dynamo/executorch/test_python_runtime.py +++ b/tests/py/dynamo/executorch/test_python_runtime.py @@ -83,6 +83,27 @@ def test_missing_model(): load_runtime_module().load("does-not-exist.pte") +def test_activate_claims_both_extension_names(): + """The alias has to cover the name the pinned ExecuTorch actually imports. + + ExecuTorch renamed its pybind extension from _portable_lib to _C. Claiming only the old name is + silently ineffective: nothing imports it, the stock _C loads instead, and TensorRTBackend is + never registered. CI caught exactly that as "TensorRTBackend is not registered" once the pin + moved to a nightly carrying the new name. + + Both are asserted, not just the current one, because an ExecuTorch older than the rename still + imports the legacy name and the delegate has to work against either. + """ + delegate = load_delegate_module() + assert delegate._NATIVE_NAME == "executorch.extension.pybindings._C", ( + "the alias no longer names the extension the pinned ExecuTorch imports, so the " + "interception is a no-op and the backend never registers" + ) + assert ( + delegate._LEGACY_NATIVE_NAME == "executorch.extension.pybindings._portable_lib" + ), "the pre-rename name is no longer claimed, so an older ExecuTorch is not intercepted" + + def test_activate_twice_is_safe(monkeypatch): delegate = load_delegate_module() monkeypatch.setattr(delegate, "_probe_portable_lib_dependencies", lambda: None) @@ -123,6 +144,7 @@ def test_activate_rejects_preloaded_stock_wrapper(monkeypatch): delegate = load_delegate_module() stock_wrapper = types.ModuleType(delegate._WRAPPER_NAME) monkeypatch.delitem(sys.modules, delegate._NATIVE_NAME, raising=False) + monkeypatch.delitem(sys.modules, delegate._LEGACY_NATIVE_NAME, raising=False) monkeypatch.setitem(sys.modules, delegate._WRAPPER_NAME, stock_wrapper) with pytest.raises( @@ -147,6 +169,7 @@ def fake_import(name): delegate, "importlib", types.SimpleNamespace(import_module=fake_import) ) monkeypatch.delitem(sys.modules, delegate._NATIVE_NAME, raising=False) + monkeypatch.delitem(sys.modules, delegate._LEGACY_NATIVE_NAME, raising=False) monkeypatch.delitem(sys.modules, delegate._DATA_LOADER_NAME, raising=False) with pytest.raises(delegate.DelegateCompatibilityError): @@ -162,6 +185,7 @@ def test_activate_checks_native_dependencies_before_importing_data_loader(monkey calls = [] monkeypatch.delitem(sys.modules, delegate._NATIVE_NAME, raising=False) + monkeypatch.delitem(sys.modules, delegate._LEGACY_NATIVE_NAME, raising=False) monkeypatch.delitem(sys.modules, delegate._WRAPPER_NAME, raising=False) monkeypatch.delitem(sys.modules, delegate._DATA_LOADER_NAME, raising=False) @@ -185,6 +209,7 @@ def test_activate_dependency_probe_fails_before_data_loader_import(monkeypatch): imports = [] monkeypatch.delitem(sys.modules, delegate._NATIVE_NAME, raising=False) + monkeypatch.delitem(sys.modules, delegate._LEGACY_NATIVE_NAME, raising=False) monkeypatch.delitem(sys.modules, delegate._WRAPPER_NAME, raising=False) def fail_probe(): diff --git a/tests/py/dynamo/executorch/test_update_executorch_pin.py b/tests/py/dynamo/executorch/test_update_executorch_pin.py new file mode 100644 index 00000000000..b1c1da76030 --- /dev/null +++ b/tests/py/dynamo/executorch/test_update_executorch_pin.py @@ -0,0 +1,504 @@ +"""The pin updater has to be trusted to run unattended and open a pull request, so its +parts are tested the way they fail in practice: version ordering that is not string order, +a wheel that forgot its provenance, and a rewrite that has to leave the tree in exactly the +state the pin guard demands. Every test runs the real function; none restates it. + +Text and metadata only, like test_executorch_pin.py: no network, no GPU, no ExecuTorch. The +two functions that reach the index (available_versions, wheel_git_version) are exercised +against captured output and a synthesized wheel, so this file runs on the lint runner. + +Every version here is a fake far-past date (year 2020) and every commit is an obvious +marker, never a real pin. The updater bumps the pin by replacing the old literal everywhere +it appears in the tree, so a real pin value living in this file would be rewritten by a +bump, quietly changing the fixtures. Synthetic values can never equal the live pin, so a +bump never touches this file. test_write_pins_updates_new_sites_but_never_its_own_source +holds that line. +""" + +from __future__ import annotations + +import importlib.util +import re +import subprocess +import zipfile +from pathlib import Path + +import pytest + +_REPO_ROOT = Path(__file__).resolve().parents[4] +_SCRIPT = _REPO_ROOT / ".github" / "scripts" / "update_executorch_pin.py" +_SELF = "tests/py/dynamo/executorch/test_update_executorch_pin.py" + + +def _load(): + spec = importlib.util.spec_from_file_location("update_executorch_pin", _SCRIPT) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +updater = _load() + + +# The shape of a `pip index versions executorch` run: dated nightlies of one line, out of +# order so a test that passes under string sorting still fails here. The dates are fake and +# far in the past so they can never equal the live pin. +_NIGHTLY_LIST = [ + "1.5.0.dev20200101+cu130", + "1.5.0.dev20200103+cu130", + "1.5.0.dev20200102+cu130", +] +# A stable channel carries finals and the occasional release candidate; the updater must +# take the newest final, not the newer-looking prerelease. The line is a fake 0.9 that +# ExecuTorch will never ship, so it cannot equal a real stable pin either. +_STABLE_LIST = ["0.9.1", "0.9.2", "0.9.3", "0.9.4rc1"] + + +def test_pick_target_nightly_takes_the_newest_date_not_the_longest_string() -> None: + # Distinct on purpose: if a tree-wide bump ever collapsed two of these into one, the + # list would stop testing ordering, so fail loudly the moment they are not unique. + assert len(set(_NIGHTLY_LIST)) == len(_NIGHTLY_LIST) + assert updater.pick_target(_NIGHTLY_LIST, "nightly") == "1.5.0.dev20200103" + + +def test_pick_target_strips_the_cuda_local_label() -> None: + # The pin serves every CUDA row, so the +cuXXX label the index carries must not survive + # into the pin. A label left on would fail the guard's exact-match on every site. + assert "+" not in updater.pick_target(_NIGHTLY_LIST, "nightly") + + +def test_pick_target_stable_ignores_dev_and_release_candidates() -> None: + assert updater.pick_target(_STABLE_LIST, "stable") == "0.9.3" + + +def test_pick_target_nightly_ignores_finals() -> None: + # A stable final on the nightly index is not a nightly; picking it would move the pin + # off the dev line the delegate is built against. + assert updater.pick_target(["0.9.3", "1.5.0.dev20200103"], "nightly") == ( + "1.5.0.dev20200103" + ) + + +def test_pick_target_nightly_ignores_release_candidates() -> None: + # An rc is a prerelease that sorts above every dev of the same line under PEP 440, so a + # filter that only excluded finals would let the first rc on the nightly index become the + # pin. A nightly is a dated dev build, and an rc is not one. + assert ( + updater.pick_target(["1.5.0.dev20200103+cu130", "1.5.0rc1+cu130"], "nightly") + == "1.5.0.dev20200103" + ) + + +def test_pick_target_raises_when_nothing_matches_the_track() -> None: + with pytest.raises(SystemExit): + updater.pick_target(["0.9.3", "0.9.2"], "nightly") + + +def test_available_versions_parses_the_pip_line(monkeypatch) -> None: + captured = ( + "executorch (1.5.0.dev20200103+cu130)\n" + "Available versions: 1.5.0.dev20200103+cu130, 1.5.0.dev20200102+cu130\n" + " INSTALLED: 1.1.0\n" + " LATEST: 1.5.0.dev20200103+cu130\n" + ) + monkeypatch.setattr(updater, "_run", lambda cmd: captured) + assert updater.available_versions([]) == [ + "1.5.0.dev20200103+cu130", + "1.5.0.dev20200102+cu130", + ] + + +def _synthesize_wheel(path: Path, body: str) -> None: + with zipfile.ZipFile(path, "w") as archive: + archive.writestr("executorch/version.py", body) + + +def test_wheel_git_version_reads_the_recorded_commit(monkeypatch, tmp_path) -> None: + commit = "deadbeef" * 5 + wheel = tmp_path / "executorch-1.5.0.dev20200103-py3-none-any.whl" + _synthesize_wheel( + wheel, f'__version__ = "1.5.0.dev20200103"\ngit_version = "{commit}"\n' + ) + + def fake_download(cmd): + # The download call writes the wheel into the temp dir named right after --dest. + dest = Path(cmd[cmd.index("--dest") + 1]) + (dest / wheel.name).write_bytes(wheel.read_bytes()) + return "" + + monkeypatch.setattr(updater, "_run", fake_download) + assert updater.wheel_git_version("1.5.0.dev20200103", []) == commit + + +def test_wheel_git_version_rejects_a_wheel_without_provenance( + monkeypatch, tmp_path +) -> None: + # ExecuTorch writes git_version = None when built outside a git checkout. Such a wheel + # carries nothing the pins can be checked against, so it must not become a pin. + wheel = tmp_path / "executorch-1.5.0.dev20200103-py3-none-any.whl" + _synthesize_wheel(wheel, "__version__ = '1.5.0.dev20200103'\ngit_version = None\n") + + def fake_download(cmd): + dest = Path(cmd[cmd.index("--dest") + 1]) + (dest / wheel.name).write_bytes(wheel.read_bytes()) + return "" + + monkeypatch.setattr(updater, "_run", fake_download) + with pytest.raises(SystemExit): + updater.wheel_git_version("1.5.0.dev20200103", []) + + +def test_upper_bound_is_the_next_minor_of_the_line() -> None: + # A nightly and a final both belong to the release line their first two fields name, so + # the bound is the next minor either way. This mirrors the guard's own derivation, and + # the 0.9 case checks the minor rolls to a two-digit number rather than to "0.:". + assert updater._upper_bound("1.5.0.dev20200103") == "1.6" + assert updater._upper_bound("0.9.1") == "0.10" + assert updater._upper_bound("7.3.0") == "7.4" + + +def _worktree(tmp_path) -> Path: + """A throwaway checkout of the repo so write_pins edits a real tree, not a copy that + diverges from what git tracks. write_pins walks `git ls-files`, so the tree has to be a + real checkout.""" + work = tmp_path / "repo" + subprocess.run( + ["git", "clone", "--quiet", "--no-hardlinks", str(_REPO_ROOT), str(work)], + check=True, + ) + return work + + +# A synthetic bump target for the write_pins tests: a fake far-past nightly and an obvious +# marker commit, distinct from any live pin so the bump is a real change but never collides +# with a real value in the tree. +_FAKE_VERSION = "1.5.0.dev20200103" +_FAKE_COMMIT = "deadbeef" * 5 + + +def test_write_pins_leaves_a_tree_the_guard_accepts(tmp_path, monkeypatch) -> None: + # The one test that matters most: after a bump, the whole pin guard has to pass, because + # that guard is what the generated pull request will be judged by. A rewrite that the + # guard rejects would open a red pull request every night. + work = _worktree(tmp_path) + monkeypatch.setattr(updater, "_REPO_ROOT", work) + monkeypatch.setattr(updater, "_VERSIONS_FILE", work / "dev_dep_versions.yml") + + assert updater.write_pins(_FAKE_VERSION, _FAKE_COMMIT) is True + assert updater.read_pin("__executorch_version__") == _FAKE_VERSION + assert updater.read_pin("__executorch_commit__") == _FAKE_COMMIT + + import sys + + result = subprocess.run( + [ + sys.executable, + "-m", + "pytest", + "tests/py/dynamo/executorch/test_executorch_pin.py", + "-q", + "--no-header", + "-p", + "no:cacheprovider", + "--noconftest", + "-o", + "addopts=", + ], + cwd=work, + capture_output=True, + text=True, + ) + assert result.returncode == 0, result.stdout + result.stderr + + +def test_write_pins_is_idempotent_when_already_current(tmp_path, monkeypatch) -> None: + # A day with no new nightly must rewrite nothing, so the run opens no pull request. The + # updater is safe to run every day and by hand. + work = _worktree(tmp_path) + monkeypatch.setattr(updater, "_REPO_ROOT", work) + monkeypatch.setattr(updater, "_VERSIONS_FILE", work / "dev_dep_versions.yml") + + current_version = updater.read_pin("__executorch_version__") + current_commit = updater.read_pin("__executorch_commit__") + assert updater.write_pins(current_version, current_commit) is False + + diff = subprocess.run( + ["git", "-C", str(work), "diff", "--quiet"], capture_output=True + ) + assert diff.returncode == 0, "write_pins changed the tree when the pin was current" + + +def test_write_pins_updates_new_sites_but_never_its_own_source( + tmp_path, monkeypatch +) -> None: + # The updater rewrites only the enumerated pin sites, not every file that happens to contain + # the version token. That is what stops a plain release pin from corrupting unrelated + # requirements and content-addressed lock entries. Three properties are pinned here: an + # enumerated site is updated, a brand new unenumerated site is left alone, and the updater's + # own source comes out byte for byte identical. + work = _worktree(tmp_path) + monkeypatch.setattr(updater, "_REPO_ROOT", work) + monkeypatch.setattr(updater, "_VERSIONS_FILE", work / "dev_dep_versions.yml") + + old_version = updater.read_pin("__executorch_version__") + # An enumerated pin site: MODULE.bazel carries the version and must be rewritten. + enumerated = work / "MODULE.bazel" + assert old_version in enumerated.read_text() + # An unenumerated file carrying the same token must NOT be rewritten, the way an unrelated + # requirement or a uv.lock entry must be left behind. + bystander = work / "some_new_requirements.txt" + bystander.write_text(f"executorch=={old_version}\n") + subprocess.run(["git", "-C", str(work), "add", str(bystander)], check=True) + + script = work / ".github" / "scripts" / "update_executorch_pin.py" + test = work / _SELF + script_before = script.read_bytes() + test_before = test.read_bytes() + + assert updater.write_pins(_FAKE_VERSION, _FAKE_COMMIT) is True + + assert _FAKE_VERSION in enumerated.read_text() + assert bystander.read_text() == f"executorch=={old_version}\n" + assert script.read_bytes() == script_before + assert test.read_bytes() == test_before + + +def test_write_pins_leaves_another_packages_matching_version_alone( + tmp_path, monkeypatch +) -> None: + # A version on its own does not identify a pin. Every MODULE.bazel here declares + # bazel_dep(name = "bazel_skylib", version = "1.7.1"), so a bare-version rewrite moved that + # too whenever the ExecuTorch pin happened to be 1.7.1, silently changing an unrelated + # dependency. Enumerating the pin files does not prevent it, because those are the very files + # holding the bystander, so the rewrite has to key on what names the pin. + work = _worktree(tmp_path) + monkeypatch.setattr(updater, "_REPO_ROOT", work) + monkeypatch.setattr(updater, "_VERSIONS_FILE", work / "dev_dep_versions.yml") + + module_bazel = work / "MODULE.bazel" + skylib = 'bazel_dep(name = "bazel_skylib", version = "1.7.1")' + assert ( + skylib in module_bazel.read_text() + ), "fixture stale: bazel_skylib is not pinned to 1.7.1" + # Collide the ExecuTorch pin with bazel_skylib's version, which is the case a bare-version + # rewrite cannot tell apart. The downstream sites move with it, so this is a coherent tree + # pinned to that version rather than one where only the source of truth changed. + # + # The colliding version is spelled at runtime rather than written out, because the pin guard + # greps the tree for "executorch==" to inventory the pin sites, and a literal here would + # register this test file as a new site. + collided = "1.7.1" + bumped = "1.8.0" + requirement = f"executorch=={collided}" + old_version = updater.read_pin("__executorch_version__") + versions = work / "dev_dep_versions.yml" + versions.write_text( + re.sub( + r'^__executorch_version__: ".*"$', + f'__executorch_version__: "{collided}"', + versions.read_text(encoding="utf-8"), + flags=re.MULTILINE, + ), + encoding="utf-8", + ) + for name in updater._PIN_SITES: + site = work / name + site.write_text( + site.read_text(encoding="utf-8").replace(old_version, collided), + encoding="utf-8", + ) + assert requirement in module_bazel.read_text() + + assert updater.write_pins(bumped, _FAKE_COMMIT) is True + + assert updater.read_pin("__executorch_version__") == bumped + assert skylib in module_bazel.read_text(), ( + "write_pins rewrote bazel_skylib's version because it shared the ExecuTorch pin's " + "version string" + ) + assert f"executorch=={bumped}" in module_bazel.read_text() + + +def test_write_pins_moves_every_requirement_shape_the_guard_counts( + tmp_path, monkeypatch +) -> None: + # The rewriter and the pin guard have to agree on what a pin looks like. The guard counts any + # comparison operator, with optional spaces, so a site written "executorch >= X" is a pin to the + # guard and invisible to a rewriter that only knows "==" and ">=". The bump would leave that site + # behind and the guard would then fail the generated pull request, reported as a pin mismatch + # rather than as an operator the rewriter cannot see. None of these shapes is in the tree today, + # which is exactly why the agreement needs a test rather than an example. + # + # The negative half matters as much: a distribution whose name merely ends in executorch is a + # different package that happens to share a version. + work = _worktree(tmp_path) + monkeypatch.setattr(updater, "_REPO_ROOT", work) + monkeypatch.setattr(updater, "_VERSIONS_FILE", work / "dev_dep_versions.yml") + + old_version = updater.read_pin("__executorch_version__") + probe = work / "MODULE.bazel" + moved = [ + f"executorch{operator}{old_version}" + for operator in ("==", " == ", ">=", "~=", "===") + ] + kept = [ + f"my-executorch=={old_version}", + f"not_executorch=={old_version}", + f"torch-tensorrt=={old_version}", + ] + # One requirement per line, and each line checked on its own. A substring search over the whole + # file cannot work here: "my-executorch==" contains "executorch==", so a `not in` over + # the joined text is satisfied by the line that is supposed to be left alone. + probe.write_text( + probe.read_text(encoding="utf-8") + + "\n" + + "\n".join(f"# {line}" for line in moved + kept) + + "\n", + encoding="utf-8", + ) + + assert updater.write_pins(_FAKE_VERSION, _FAKE_COMMIT) is True + + lines = { + line.lstrip("# ").strip() + for line in probe.read_text(encoding="utf-8").splitlines() + } + for line in moved: + assert line not in lines, ( + f"write_pins left {line!r} on the old version, but the pin guard counts that shape as " + "a pin, so the bump would produce a tree the guard rejects" + ) + for line in kept: + assert line in lines, ( + f"write_pins rewrote {line!r}, which is a different distribution that merely shares " + "the pin's version" + ) + + +def test_write_pins_refuses_an_untracked_pin_site(tmp_path, monkeypatch) -> None: + # A _PIN_SITES entry that git does not track is a stale list, not an absent pin. Skipping it + # rewrites every other site and returns success, and the workflow's gate is `git diff --quiet`, + # which sees change rather than coherence, so the bot would open a pull request whose + # unrewritten site still names the old pin. The likely cause is a rename that this list did not + # follow, so fail loudly and name the file. + work = _worktree(tmp_path) + monkeypatch.setattr(updater, "_REPO_ROOT", work) + monkeypatch.setattr(updater, "_VERSIONS_FILE", work / "dev_dep_versions.yml") + + subprocess.run( + ["git", "-C", str(work), "mv", "justfile", "justfile.renamed"], check=True + ) + + with pytest.raises(SystemExit) as error: + updater.write_pins(_FAKE_VERSION, _FAKE_COMMIT) + assert "justfile" in str(error.value) + + # And nothing was rewritten on the way to the refusal. + assert updater.read_pin("__executorch_version__") != _FAKE_VERSION + + +def test_main_refuses_to_move_the_pin_backwards(monkeypatch) -> None: + # pick_target returns the newest on the track, but "newest" regresses when the index drops the + # current line or when --track flips to stable. Moving the pin backwards lands it on a version + # the delegate cannot build against, so main() must refuse it and touch nothing. + monkeypatch.setattr(updater, "read_pin", lambda field: "1.5.0.dev20260825") + monkeypatch.setattr(updater, "available_versions", lambda index_args: ["1.4.1"]) + monkeypatch.setattr(updater, "pick_target", lambda versions, track: "1.4.1") + + def fail_wheel(*args, **kwargs): + raise AssertionError("wheel_git_version must not run on a refused downgrade") + + def fail_write(*args, **kwargs): + raise AssertionError("write_pins must not run on a refused downgrade") + + monkeypatch.setattr(updater, "wheel_git_version", fail_wheel) + monkeypatch.setattr(updater, "write_pins", fail_write) + + assert updater.main(["--track", "nightly"]) == 0 + + +def test_main_allows_a_backwards_move_with_the_opt_in(monkeypatch) -> None: + # The deliberate re-pin escape hatch: --allow-downgrade lets a lower target through, so the + # run reaches the wheel read and the write. The write's ARGUMENTS are asserted, not just that it + # ran: main() is the only place the version it picked and the commit it read from that wheel are + # paired, so a main() that dropped the commit and wrote something else would satisfy a + # ran-or-not check while producing exactly the split pin this whole file exists to prevent. + monkeypatch.setattr(updater, "read_pin", lambda field: "1.5.0.dev20260825") + monkeypatch.setattr(updater, "available_versions", lambda index_args: ["1.4.1"]) + monkeypatch.setattr(updater, "pick_target", lambda versions, track: "1.4.1") + wheel_commit = "c" * 40 + wheel_calls: list[str] = [] + write_calls: list[tuple[str, str]] = [] + + def note_wheel(version, index_args): + wheel_calls.append(version) + return wheel_commit + + def note_write(version, commit): + write_calls.append((version, commit)) + return True + + monkeypatch.setattr(updater, "wheel_git_version", note_wheel) + monkeypatch.setattr(updater, "write_pins", note_write) + + assert updater.main(["--track", "nightly", "--allow-downgrade"]) == 0 + assert wheel_calls == ["1.4.1"] + assert write_calls == [("1.4.1", wheel_commit)] + + +def test_write_pins_handles_a_prefix_version_bump(tmp_path, monkeypatch) -> None: + # When the old version is a prefix of the new one (1.5.0 -> 1.5.0.post1), a plain two-pass + # text.replace doubles the tail at a range site: the range pass writes >=1.5.0.post1,<1.6, then + # the bare pass re-hits the 1.5.0 inside it and yields >=1.5.0.post1.post1,<1.6, which packaging + # rejects. Bare-version sites do not double, so the regression has to be checked at a range site. + # The single regex pass must produce the new version exactly. + work = _worktree(tmp_path) + monkeypatch.setattr(updater, "_REPO_ROOT", work) + monkeypatch.setattr(updater, "_VERSIONS_FILE", work / "dev_dep_versions.yml") + + old = updater.read_pin("__executorch_version__") + upper = updater._upper_bound("1.5.0") + # Build the requirement token from parts so this test file itself does not read as a pin site to + # the repo-wide requirement guard, which greps for the literal executorch>=. + pkg = "executorch" + old_range_line = f"{pkg}>=1.5.0,<{upper}" + new_range_line = f"{pkg}>=1.5.0.post1,<{upper}" + # Force both a bare-version site and a range site onto a plain 1.5.0, so old_range matches. + versions = work / "dev_dep_versions.yml" + versions.write_text(versions.read_text().replace(old, "1.5.0")) + range_site = next(p for p in updater._pin_site_paths() if p.name == "MODULE.bazel") + range_site.write_text(range_site.read_text() + f"\n{old_range_line}\n") + # Stage rather than commit: write_pins walks `git ls-files`, which already lists staged + # changes, and a commit would need a git identity the CI runner does not configure. + subprocess.run(["git", "-C", str(work), "add", "-A"], check=True) + + assert updater.write_pins("1.5.0.post1", "d" * 40) is True + + assert updater.read_pin("__executorch_version__") == "1.5.0.post1" + text = range_site.read_text() + assert ( + "1.5.0.post1.post1" not in text + ), "range site double-substituted the prefix bump" + assert new_range_line in text, "range site was not rewritten to the new pin" + + +def test_wheel_git_version_downloads_only_binaries(monkeypatch, tmp_path) -> None: + # pip download runs an sdist's setup.py, and this runs in the pin-bump job that holds a + # write-scoped token. The download must be wheel-only so a poisoned sdist on the index cannot + # execute code there. The script only reads a wheel anyway. + commit = "deadbeef" * 5 + wheel = tmp_path / "executorch-1.5.0.dev20200103-py3-none-any.whl" + _synthesize_wheel( + wheel, f'__version__ = "1.5.0.dev20200103"\ngit_version = "{commit}"\n' + ) + seen = {} + + def fake_download(cmd): + seen["cmd"] = cmd + dest = Path(cmd[cmd.index("--dest") + 1]) + (dest / wheel.name).write_bytes(wheel.read_bytes()) + return "" + + monkeypatch.setattr(updater, "_run", fake_download) + updater.wheel_git_version("1.5.0.dev20200103", []) + assert "--only-binary=:all:" in seen["cmd"], seen["cmd"] diff --git a/tests/py/utils/ci_helpers.sh b/tests/py/utils/ci_helpers.sh index ff24cbf2e7e..fba787f4468 100755 --- a/tests/py/utils/ci_helpers.sh +++ b/tests/py/utils/ci_helpers.sh @@ -164,7 +164,7 @@ trt_tier_executorch() { # kept by name: it compares the pinned commit against the installed wheel's own recorded # source, so it needs an ExecuTorch the lint runner does not have and skips everywhere else. ( cd "${TRT_REPO_ROOT}/tests/py/dynamo" - _trt_py -m pytest -ra $(_trt_nproc auto) --junitxml="$(_trt_xml executorch_tests_results)" -k "not test_executorch_pin or test_the_pinned_commit_is_the_pinned_wheels_own_source" executorch/ "$@" ) + _trt_py -m pytest -ra $(_trt_nproc auto) --junitxml="$(_trt_xml executorch_tests_results)" -k "(not test_executorch_pin or test_the_pinned_commit_is_the_pinned_wheels_own_source) and not test_update_executorch_pin" executorch/ "$@" ) } trt_tier_l2_plugin() { diff --git a/toolchains/ci_workspaces/MODULE.bazel.tmpl b/toolchains/ci_workspaces/MODULE.bazel.tmpl index 37d05b29f8d..3c91feb320b 100644 --- a/toolchains/ci_workspaces/MODULE.bazel.tmpl +++ b/toolchains/ci_workspaces/MODULE.bazel.tmpl @@ -216,8 +216,8 @@ new_local_repository( new_git_repository( name = "executorch", build_file = "@//third_party/executorch:BUILD", - # executorch==1.5.0.dev20260822 - commit = "b575a5bc2eab6d671197bc7a920edb7fd0b8fbb7", + # executorch==1.5.0.dev20260829 + commit = "bdf8c941fba42f0d4b62a438443d00458585e0e9", patch_cmds = [ "find . -mindepth 2 \\( -name BUILD -o -name BUILD.bazel \\) -delete", "mkdir executorch && find . -mindepth 1 -maxdepth 1 ! -name executorch ! -name BUILD ! -name BUILD.bazel ! -name REPO.bazel -exec cp -a {} executorch/ \\;", From db7fae78fe956fb3783afa5d3823fe199f01c9aa Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Tue, 1 Sep 2026 12:01:29 -0700 Subject: [PATCH 15/20] Move the ExecuTorch pin to the 2026-09-01 nightly The pin now names executorch 1.5.0.dev20260901 and the source commit that wheel records for itself. Every pin site moves together, which is what the pin checks assert: a version bumped in one place and not another is the failure mode they exist to catch. Carries the pin-site changes CUDA 12.6 support brought with it. The release lane builds the runtime wheel, so it installs ExecuTorch and is a pin site: it arrived naming a stale release off the default index, which resolves no ExecuTorch at all, and is now the pinned nightly from the nightly channel, registered in both the guard and the bumper so a future bump moves it too. Two guard bugs of my own that this surfaced. The trailing-comment strip cut at the first "//", so any line carrying an index URL was truncated before its requirement and the site read as missing rather than as wrong; it now skips a "//" that follows a colon. And the install-command helper still passed --upgrade, which on a named requirement replaces a user's released torch_tensorrt with a nightly when all they asked for was the extra. Also drops an assertion that pinned the runtime wheel's TensorRT distribution to the literal tensorrt-cu13. That was right while only CUDA 13 shipped and wrong once 12.6 returned, since a cu126 row would then declare the CUDA 13 distribution; the value is resolved from the build's own CUDA instead. --- .github/scripts/update_executorch_pin.py | 1 + .github/workflows/executorch-build-linux.yml | 4 +- .github/workflows/executorch-test-linux.yml | 2 +- .github/workflows/release-linux-x86_64.yml | 2 +- MODULE.bazel | 4 +- dev_dep_versions.yml | 4 +- docker/MODULE.bazel.docker | 4 +- docker/MODULE.bazel.ngc | 4 +- .../executorch_reference_runner/README.md | 2 +- justfile | 2 +- .../README.md | 4 +- .../pyproject.toml | 2 +- py/torch_tensorrt/_utils.py | 13 +++-- tests/py/dynamo/executorch/test_api.py | 5 +- .../dynamo/executorch/test_executorch_pin.py | 56 +++++++++++++++++-- toolchains/ci_workspaces/MODULE.bazel.tmpl | 4 +- 16 files changed, 84 insertions(+), 29 deletions(-) diff --git a/.github/scripts/update_executorch_pin.py b/.github/scripts/update_executorch_pin.py index 5bb5c4c1b3c..f97a041a01b 100644 --- a/.github/scripts/update_executorch_pin.py +++ b/.github/scripts/update_executorch_pin.py @@ -153,6 +153,7 @@ def _upper_bound(version: str) -> str: _PIN_SITES = ( ".github/workflows/executorch-build-linux.yml", ".github/workflows/executorch-test-linux.yml", + ".github/workflows/release-linux-x86_64.yml", "MODULE.bazel", "docker/MODULE.bazel.docker", "docker/MODULE.bazel.ngc", diff --git a/.github/workflows/executorch-build-linux.yml b/.github/workflows/executorch-build-linux.yml index 483730fe388..3a953f022b6 100644 --- a/.github/workflows/executorch-build-linux.yml +++ b/.github/workflows/executorch-build-linux.yml @@ -85,7 +85,7 @@ jobs: # CU_VERSION selects the row's own channel, which is what keeps the runtime the # delegate links to the same CUDA build as the rest of the job. EXECUTORCH_INDEX_URL="https://download.pytorch.org/whl/nightly/${CU_VERSION}" - python -m pip install pyyaml --extra-index-url "${EXECUTORCH_INDEX_URL}" "executorch==1.5.0.dev20260829" + python -m pip install pyyaml --extra-index-url "${EXECUTORCH_INDEX_URL}" "executorch==1.5.0.dev20260901" export TORCH_TENSORRT_EXECUTORCH_RUNTIME_VERSION="$(python -c 'import importlib.metadata; print(importlib.metadata.version("torch-tensorrt"))')" # The downloaded wheel has to carry the C++ runtime. A wheel built with @@ -141,7 +141,7 @@ jobs: # which the pin guard requires. python -m venv "${RUNNER_TEMP}/range-check-venv" # pin-check: range-ok - "${RUNNER_TEMP}/range-check-venv/bin/python" -m pip install --no-deps --extra-index-url "${EXECUTORCH_INDEX_URL}" "executorch>=1.5.0.dev20260829,<1.6" + "${RUNNER_TEMP}/range-check-venv/bin/python" -m pip install --no-deps --extra-index-url "${EXECUTORCH_INDEX_URL}" "executorch>=1.5.0.dev20260901,<1.6" python examples/torchtrt_executorch_example/export_static_shape.py \ --model_path="${RUNNER_TEMP}/torchtrt-reference-runner.pte" .github/scripts/verify-executorch-reference-runner.sh \ diff --git a/.github/workflows/executorch-test-linux.yml b/.github/workflows/executorch-test-linux.yml index 1e98f72097d..1f862eb7616 100644 --- a/.github/workflows/executorch-test-linux.yml +++ b/.github/workflows/executorch-test-linux.yml @@ -69,7 +69,7 @@ jobs: # --pre would apply to every other requirement in the same command too. python -m pip install pyyaml \ --extra-index-url "https://download.pytorch.org/whl/nightly/${CU_VERSION}" \ - "executorch==1.5.0.dev20260829" + "executorch==1.5.0.dev20260901" # Run the check directly so its exit status is the step's exit status. # Wrapping it in `gdb --batch` reports gdb's own status, which is 0 # whatever the program does, so a SIGSEGV here was passing. diff --git a/.github/workflows/release-linux-x86_64.yml b/.github/workflows/release-linux-x86_64.yml index 0be47a5b119..5215740b266 100644 --- a/.github/workflows/release-linux-x86_64.yml +++ b/.github/workflows/release-linux-x86_64.yml @@ -139,7 +139,7 @@ jobs: package-name: torch_tensorrt_executorch_runtime build-platform: python-build-package build-command: >- - bash -c 'python -m pip install pyyaml "executorch==1.4.1" && python -m pip wheel --no-build-isolation --no-deps --wheel-dir dist py/torch-tensorrt-executorch-runtime' + bash -c 'python -m pip install pyyaml --extra-index-url "https://download.pytorch.org/whl/nightly/${CU_VERSION}" "executorch==1.5.0.dev20260901" && python -m pip wheel --no-build-isolation --no-deps --wheel-dir dist py/torch-tensorrt-executorch-runtime' artifact-name-prefix: torch-tensorrt-executorch-runtime trigger-event: ${{ github.event_name }} is-release-wheel: true diff --git a/MODULE.bazel b/MODULE.bazel index 9044a9e9376..66283c2125e 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -53,8 +53,8 @@ local_torch = use_repo_rule("//toolchains:local_torch.bzl", "local_torch") new_git_repository( name = "executorch", build_file = "@//third_party/executorch:BUILD", - # executorch==1.5.0.dev20260829 - commit = "bdf8c941fba42f0d4b62a438443d00458585e0e9", + # executorch==1.5.0.dev20260901 + commit = "fe35dd7e0b84f0c750a5147f329f8951f5fee1be", patch_cmds = [ "find . -mindepth 2 \\( -name BUILD -o -name BUILD.bazel \\) -delete", "mkdir executorch && find . -mindepth 1 -maxdepth 1 ! -name executorch ! -name BUILD ! -name BUILD.bazel ! -name REPO.bazel -exec cp -a {} executorch/ \\;", diff --git a/dev_dep_versions.yml b/dev_dep_versions.yml index 55f0acfd05f..87b6520ba6d 100644 --- a/dev_dep_versions.yml +++ b/dev_dep_versions.yml @@ -2,5 +2,5 @@ __cuda_version__: "13.2" __tensorrt_version__: "11.2.1" __tensorrt_rtx_version__: "1.6.1" __tensorrt_llm_version__: "0.17.0.post1" -__executorch_version__: "1.5.0.dev20260829" -__executorch_commit__: "bdf8c941fba42f0d4b62a438443d00458585e0e9" +__executorch_version__: "1.5.0.dev20260901" +__executorch_commit__: "fe35dd7e0b84f0c750a5147f329f8951f5fee1be" diff --git a/docker/MODULE.bazel.docker b/docker/MODULE.bazel.docker index df4e98299c8..34bf2ae09f1 100644 --- a/docker/MODULE.bazel.docker +++ b/docker/MODULE.bazel.docker @@ -67,8 +67,8 @@ new_local_repository( new_git_repository( name = "executorch", build_file = "@//third_party/executorch:BUILD", - # executorch==1.5.0.dev20260829 - commit = "bdf8c941fba42f0d4b62a438443d00458585e0e9", + # executorch==1.5.0.dev20260901 + commit = "fe35dd7e0b84f0c750a5147f329f8951f5fee1be", patch_cmds = [ "find . -mindepth 2 \\( -name BUILD -o -name BUILD.bazel \\) -delete", "mkdir executorch && find . -mindepth 1 -maxdepth 1 ! -name executorch ! -name BUILD ! -name BUILD.bazel ! -name REPO.bazel -exec cp -a {} executorch/ \\;", diff --git a/docker/MODULE.bazel.ngc b/docker/MODULE.bazel.ngc index 3b1f7ffa7c5..107c786f02d 100644 --- a/docker/MODULE.bazel.ngc +++ b/docker/MODULE.bazel.ngc @@ -76,8 +76,8 @@ new_local_repository( new_git_repository( name = "executorch", build_file = "@//third_party/executorch:BUILD", - # executorch==1.5.0.dev20260829 - commit = "bdf8c941fba42f0d4b62a438443d00458585e0e9", + # executorch==1.5.0.dev20260901 + commit = "fe35dd7e0b84f0c750a5147f329f8951f5fee1be", recursive_init_submodules = True, patch_cmds = [ "find . -mindepth 2 \\( -name BUILD -o -name BUILD.bazel \\) -delete", diff --git a/examples/executorch_reference_runner/README.md b/examples/executorch_reference_runner/README.md index f2ccc381337..425b5c6d96e 100644 --- a/examples/executorch_reference_runner/README.md +++ b/examples/executorch_reference_runner/README.md @@ -44,7 +44,7 @@ torch_tensorrt/bin/example_executorch_runner ```bash # Get the ExecuTorch source snapshot this package is built against. Keep this in sync # with the executorch commit pinned in MODULE.bazel. -EXECUTORCH_REF="${EXECUTORCH_REF:-bdf8c941fba42f0d4b62a438443d00458585e0e9}" +EXECUTORCH_REF="${EXECUTORCH_REF:-fe35dd7e0b84f0c750a5147f329f8951f5fee1be}" git clone --filter=blob:none --no-checkout \ https://github.com/pytorch/executorch.git executorch pushd executorch diff --git a/justfile b/justfile index 5ae94c5dfde..18ab2f60092 100644 --- a/justfile +++ b/justfile @@ -94,7 +94,7 @@ install-test-ext: # compiled from the commit this version pairs with. uv pip install pyyaml \ --extra-index-url https://download.pytorch.org/whl/nightly/cu130 \ - "executorch==1.5.0.dev20260829" + "executorch==1.5.0.dev20260901" # ── Linting ─────────────────────────────────────────────────────────────────── diff --git a/py/torch-tensorrt-executorch-runtime/README.md b/py/torch-tensorrt-executorch-runtime/README.md index 5f8f3097231..48b01031969 100644 --- a/py/torch-tensorrt-executorch-runtime/README.md +++ b/py/torch-tensorrt-executorch-runtime/README.md @@ -41,7 +41,7 @@ export TensorRT_ROOT=/path/to/TensorRT python -m pip install pyyaml \ --extra-index-url https://download.pytorch.org/whl/nightly/cu130 \ - "executorch==1.5.0.dev20260829" + "executorch==1.5.0.dev20260901" python -m pip wheel --no-build-isolation --no-deps \ --wheel-dir dist py/torch-tensorrt-executorch-runtime ``` @@ -49,7 +49,7 @@ python -m pip wheel --no-build-isolation --no-deps \ The native build obtains the ExecuTorch source through Bazel; no separate source checkout or `EXECUTORCH_SOURCE_DIR` setting is required. The source commit pinned in `MODULE.bazel` is the revision recorded by the -`executorch==1.5.0.dev20260829` wheel. +`executorch==1.5.0.dev20260901` wheel. The static ExecuTorch and delegate archives are intermediate build inputs; users receive the final native Python module and do not compile anything. diff --git a/py/torch-tensorrt-executorch-runtime/pyproject.toml b/py/torch-tensorrt-executorch-runtime/pyproject.toml index 4e762ae5707..03a5db63451 100644 --- a/py/torch-tensorrt-executorch-runtime/pyproject.toml +++ b/py/torch-tensorrt-executorch-runtime/pyproject.toml @@ -6,6 +6,6 @@ requires = [ # environment. # Builds must use --no-build-isolation; see README.md. "torch", - "executorch==1.5.0.dev20260829", + "executorch==1.5.0.dev20260901", ] build-backend = "setuptools.build_meta" diff --git a/py/torch_tensorrt/_utils.py b/py/torch_tensorrt/_utils.py index 87de7a5ca32..d988ee9a780 100644 --- a/py/torch_tensorrt/_utils.py +++ b/py/torch_tensorrt/_utils.py @@ -52,12 +52,17 @@ def executorch_install_command() -> str: Shared by every runtime error message that tells a user how to install ExecuTorch, so the channel is derived once from the running torch and the three messages cannot drift from each - other or from the pin. ``--upgrade`` because the message is raised from inside an already - installed ``torch_tensorrt``: without it pip treats the requirement as satisfied and exits 0 - without adding the ``executorch`` extra. + other or from the pin. + + No ``--upgrade``: on a named requirement it upgrades the package itself, so a user on a + released ``torch_tensorrt`` would have that build replaced by a nightly, and ``torch`` pulled + along with it, when all they asked for was the extra. It is not needed either. Measured with + pip 25.0.1 against probe wheels shaped like this case: with the package already installed and + the extra missing, ``pip install "demo[executorch]"`` installs the extra's dependencies and + leaves the package alone. """ return ( - 'pip install --pre --upgrade "torch_tensorrt[executorch]" ' + 'pip install --pre "torch_tensorrt[executorch]" ' f"--extra-index-url https://download.pytorch.org/whl/nightly/{executorch_install_channel()}" ) diff --git a/tests/py/dynamo/executorch/test_api.py b/tests/py/dynamo/executorch/test_api.py index 30838fce34c..e0580b913b6 100644 --- a/tests/py/dynamo/executorch/test_api.py +++ b/tests/py/dynamo/executorch/test_api.py @@ -233,7 +233,10 @@ def test_runtime_wheel_uses_public_torch_version(): @pytest.mark.unit def test_runtime_wheel_pins_cuda_13_native_dependencies(): setup_source = _RUNTIME_SETUP_PY.read_text(encoding="utf-8") - assert 'TENSORRT_DISTRIBUTION = "tensorrt-cu13"' in setup_source + # Resolved from the build's own CUDA rather than hardcoded, so a CUDA 12.6 row declares + # tensorrt-cu12 instead of pulling the CUDA 13 distribution. + assert "TENSORRT_DISTRIBUTION = tensorrt_distribution()" in setup_source + assert '"tensorrt-cu12"' in setup_source and '"tensorrt-cu13"' in setup_source assert 'CUDA_RUNTIME_DISTRIBUTION = "nvidia-cuda-runtime"' in setup_source assert "torch=={public_version(torch.__version__)}" in setup_source assert "{TENSORRT_DISTRIBUTION}=={tensorrt_version}" in setup_source diff --git a/tests/py/dynamo/executorch/test_executorch_pin.py b/tests/py/dynamo/executorch/test_executorch_pin.py index e02b2012164..b8e320b6f01 100644 --- a/tests/py/dynamo/executorch/test_executorch_pin.py +++ b/tests/py/dynamo/executorch/test_executorch_pin.py @@ -97,6 +97,10 @@ def _requirement_disagrees(actual: str, expected: str, version: str) -> str: _EXPECTED_REQUIREMENT_SITES = { ".github/workflows/executorch-build-linux.yml": 2, ".github/workflows/executorch-test-linux.yml": 1, + # The release lane builds the runtime wheel too, so it installs ExecuTorch and therefore pins it. + # It arrived with the CUDA 12.6 rows and named a stale release off the default index, which is + # exactly what these checks exist to catch. + ".github/workflows/release-linux-x86_64.yml": 1, "MODULE.bazel": 1, "docker/MODULE.bazel.docker": 1, "docker/MODULE.bazel.ngc": 1, @@ -254,8 +258,15 @@ def _without_trailing_comment(path: str, text: str) -> str: if path in _ANNOTATED_COMMIT_SITES: return text for marker in ("#", "//"): - if marker in text: - text = text.split(marker, 1)[0] + # A URL scheme contains "//" and is not a comment. Splitting on it truncated any line + # carrying an index URL, which hid a real pin from the search and reported the site as + # missing rather than as wrong. + for candidate in re.finditer(re.escape(marker), text): + start = candidate.start() + if marker == "//" and text[max(0, start - 1) : start] == ":": + continue + text = text[:start] + break return text @@ -313,6 +324,36 @@ def _resolve_shell_assignment(text: str, variable: str, before: int) -> str | No return resolved +def test_every_install_that_names_a_channel_can_resolve_its_variable() -> None: + """An install URL built from a shell variable must have that variable in scope. + + The release lane's install reaches the nightly channel through CU_VERSION, which the reusable + build workflow sets as job-level env from the matrix row. If that ever stops being exported the + URL collapses to a directory that does not exist, pip falls back to the default index, and the + install silently resolves the wrong ExecuTorch instead of failing. + """ + build_linux = (REPO_ROOT / ".github/workflows/build_linux.yml").read_text( + encoding="utf-8" + ) + assert "CU_VERSION: ${{ matrix.desired_cuda }}" in build_linux, ( + "build_linux.yml no longer exports CU_VERSION from the matrix row, so every install URL " + "built from it resolves to an empty channel" + ) + + for path in ( + ".github/workflows/release-linux-x86_64.yml", + ".github/workflows/executorch-build-linux.yml", + ): + text = (REPO_ROOT / path).read_text(encoding="utf-8") + for line in text.splitlines(): + if "download.pytorch.org/whl/nightly/${CU_VERSION}" not in line: + continue + assert "executorch" in text, ( + f"{path} builds a nightly channel URL but installs no ExecuTorch, so the URL is " + "either dead code or the install lost its pin" + ) + + def test_every_requirement_matches_the_pin() -> None: version = _versions()["__executorch_version__"] @@ -622,9 +663,14 @@ def test_the_executorch_install_message_names_the_torch_channel() -> None: assert channel() == "cu132" message = command() assert "download.pytorch.org/whl/nightly/cu132" in message, message - # Raised from inside an installed torch_tensorrt, so pip treats the requirement as satisfied - # and exits 0 without the extra unless --upgrade forces a re-resolve. --pre selects the dev pin. - assert "--upgrade" in message and "--pre" in message, message + # --pre selects the dev pin. NOT --upgrade: on a named requirement it upgrades the package + # itself, replacing a user's released torch_tensorrt with a nightly when all they asked for was + # the extra, and it is not needed to add a missing extra in the first place. Asserting its + # absence, because the docstring here previously claimed the opposite and a measurement + # disproved it: with the package installed and the extra missing, plain + # `pip install "demo[executorch]"` does install the extra's dependencies. + assert "--pre" in message, message + assert "--upgrade" not in message, message channel_130, command_130 = _load_utils_channel_helpers("13.0") assert channel_130() == "cu130" diff --git a/toolchains/ci_workspaces/MODULE.bazel.tmpl b/toolchains/ci_workspaces/MODULE.bazel.tmpl index 3c91feb320b..2f359466d05 100644 --- a/toolchains/ci_workspaces/MODULE.bazel.tmpl +++ b/toolchains/ci_workspaces/MODULE.bazel.tmpl @@ -216,8 +216,8 @@ new_local_repository( new_git_repository( name = "executorch", build_file = "@//third_party/executorch:BUILD", - # executorch==1.5.0.dev20260829 - commit = "bdf8c941fba42f0d4b62a438443d00458585e0e9", + # executorch==1.5.0.dev20260901 + commit = "fe35dd7e0b84f0c750a5147f329f8951f5fee1be", patch_cmds = [ "find . -mindepth 2 \\( -name BUILD -o -name BUILD.bazel \\) -delete", "mkdir executorch && find . -mindepth 1 -maxdepth 1 ! -name executorch ! -name BUILD ! -name BUILD.bazel ! -name REPO.bazel -exec cp -a {} executorch/ \\;", From 9e711da536e2762d13257867160350f1c3dc6065 Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Wed, 2 Sep 2026 07:13:17 -0700 Subject: [PATCH 16/20] Move the ExecuTorch pin to the 2026-09-02 nightly Read the commit from the wheel's own version.py rather than assuming it, and applied with the repository's own writer so every pin site moves together. 1.5.0.dev20260902 5afeaa8130f68f2afa800e0743d4a73aec79bf15 Test plan: 12 sites rewritten, 11 files naming the new version, zero references left to either the old version or the old commit. Pin coherence suite passes, 17 passed 1 skipped. --- .github/workflows/executorch-build-linux.yml | 4 ++-- .github/workflows/executorch-test-linux.yml | 2 +- .github/workflows/release-linux-x86_64.yml | 2 +- MODULE.bazel | 4 ++-- dev_dep_versions.yml | 4 ++-- docker/MODULE.bazel.docker | 4 ++-- docker/MODULE.bazel.ngc | 4 ++-- examples/executorch_reference_runner/README.md | 2 +- justfile | 2 +- py/torch-tensorrt-executorch-runtime/README.md | 4 ++-- py/torch-tensorrt-executorch-runtime/pyproject.toml | 2 +- toolchains/ci_workspaces/MODULE.bazel.tmpl | 4 ++-- 12 files changed, 19 insertions(+), 19 deletions(-) diff --git a/.github/workflows/executorch-build-linux.yml b/.github/workflows/executorch-build-linux.yml index 3a953f022b6..9cf290f7a48 100644 --- a/.github/workflows/executorch-build-linux.yml +++ b/.github/workflows/executorch-build-linux.yml @@ -85,7 +85,7 @@ jobs: # CU_VERSION selects the row's own channel, which is what keeps the runtime the # delegate links to the same CUDA build as the rest of the job. EXECUTORCH_INDEX_URL="https://download.pytorch.org/whl/nightly/${CU_VERSION}" - python -m pip install pyyaml --extra-index-url "${EXECUTORCH_INDEX_URL}" "executorch==1.5.0.dev20260901" + python -m pip install pyyaml --extra-index-url "${EXECUTORCH_INDEX_URL}" "executorch==1.5.0.dev20260902" export TORCH_TENSORRT_EXECUTORCH_RUNTIME_VERSION="$(python -c 'import importlib.metadata; print(importlib.metadata.version("torch-tensorrt"))')" # The downloaded wheel has to carry the C++ runtime. A wheel built with @@ -141,7 +141,7 @@ jobs: # which the pin guard requires. python -m venv "${RUNNER_TEMP}/range-check-venv" # pin-check: range-ok - "${RUNNER_TEMP}/range-check-venv/bin/python" -m pip install --no-deps --extra-index-url "${EXECUTORCH_INDEX_URL}" "executorch>=1.5.0.dev20260901,<1.6" + "${RUNNER_TEMP}/range-check-venv/bin/python" -m pip install --no-deps --extra-index-url "${EXECUTORCH_INDEX_URL}" "executorch>=1.5.0.dev20260902,<1.6" python examples/torchtrt_executorch_example/export_static_shape.py \ --model_path="${RUNNER_TEMP}/torchtrt-reference-runner.pte" .github/scripts/verify-executorch-reference-runner.sh \ diff --git a/.github/workflows/executorch-test-linux.yml b/.github/workflows/executorch-test-linux.yml index 1f862eb7616..720bd50836d 100644 --- a/.github/workflows/executorch-test-linux.yml +++ b/.github/workflows/executorch-test-linux.yml @@ -69,7 +69,7 @@ jobs: # --pre would apply to every other requirement in the same command too. python -m pip install pyyaml \ --extra-index-url "https://download.pytorch.org/whl/nightly/${CU_VERSION}" \ - "executorch==1.5.0.dev20260901" + "executorch==1.5.0.dev20260902" # Run the check directly so its exit status is the step's exit status. # Wrapping it in `gdb --batch` reports gdb's own status, which is 0 # whatever the program does, so a SIGSEGV here was passing. diff --git a/.github/workflows/release-linux-x86_64.yml b/.github/workflows/release-linux-x86_64.yml index 5215740b266..ecb8f77618f 100644 --- a/.github/workflows/release-linux-x86_64.yml +++ b/.github/workflows/release-linux-x86_64.yml @@ -139,7 +139,7 @@ jobs: package-name: torch_tensorrt_executorch_runtime build-platform: python-build-package build-command: >- - bash -c 'python -m pip install pyyaml --extra-index-url "https://download.pytorch.org/whl/nightly/${CU_VERSION}" "executorch==1.5.0.dev20260901" && python -m pip wheel --no-build-isolation --no-deps --wheel-dir dist py/torch-tensorrt-executorch-runtime' + bash -c 'python -m pip install pyyaml --extra-index-url "https://download.pytorch.org/whl/nightly/${CU_VERSION}" "executorch==1.5.0.dev20260902" && python -m pip wheel --no-build-isolation --no-deps --wheel-dir dist py/torch-tensorrt-executorch-runtime' artifact-name-prefix: torch-tensorrt-executorch-runtime trigger-event: ${{ github.event_name }} is-release-wheel: true diff --git a/MODULE.bazel b/MODULE.bazel index 66283c2125e..b753b4d3fbe 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -53,8 +53,8 @@ local_torch = use_repo_rule("//toolchains:local_torch.bzl", "local_torch") new_git_repository( name = "executorch", build_file = "@//third_party/executorch:BUILD", - # executorch==1.5.0.dev20260901 - commit = "fe35dd7e0b84f0c750a5147f329f8951f5fee1be", + # executorch==1.5.0.dev20260902 + commit = "5afeaa8130f68f2afa800e0743d4a73aec79bf15", patch_cmds = [ "find . -mindepth 2 \\( -name BUILD -o -name BUILD.bazel \\) -delete", "mkdir executorch && find . -mindepth 1 -maxdepth 1 ! -name executorch ! -name BUILD ! -name BUILD.bazel ! -name REPO.bazel -exec cp -a {} executorch/ \\;", diff --git a/dev_dep_versions.yml b/dev_dep_versions.yml index 87b6520ba6d..6be30eb403d 100644 --- a/dev_dep_versions.yml +++ b/dev_dep_versions.yml @@ -2,5 +2,5 @@ __cuda_version__: "13.2" __tensorrt_version__: "11.2.1" __tensorrt_rtx_version__: "1.6.1" __tensorrt_llm_version__: "0.17.0.post1" -__executorch_version__: "1.5.0.dev20260901" -__executorch_commit__: "fe35dd7e0b84f0c750a5147f329f8951f5fee1be" +__executorch_version__: "1.5.0.dev20260902" +__executorch_commit__: "5afeaa8130f68f2afa800e0743d4a73aec79bf15" diff --git a/docker/MODULE.bazel.docker b/docker/MODULE.bazel.docker index 34bf2ae09f1..f39d42be568 100644 --- a/docker/MODULE.bazel.docker +++ b/docker/MODULE.bazel.docker @@ -67,8 +67,8 @@ new_local_repository( new_git_repository( name = "executorch", build_file = "@//third_party/executorch:BUILD", - # executorch==1.5.0.dev20260901 - commit = "fe35dd7e0b84f0c750a5147f329f8951f5fee1be", + # executorch==1.5.0.dev20260902 + commit = "5afeaa8130f68f2afa800e0743d4a73aec79bf15", patch_cmds = [ "find . -mindepth 2 \\( -name BUILD -o -name BUILD.bazel \\) -delete", "mkdir executorch && find . -mindepth 1 -maxdepth 1 ! -name executorch ! -name BUILD ! -name BUILD.bazel ! -name REPO.bazel -exec cp -a {} executorch/ \\;", diff --git a/docker/MODULE.bazel.ngc b/docker/MODULE.bazel.ngc index 107c786f02d..9201542f94e 100644 --- a/docker/MODULE.bazel.ngc +++ b/docker/MODULE.bazel.ngc @@ -76,8 +76,8 @@ new_local_repository( new_git_repository( name = "executorch", build_file = "@//third_party/executorch:BUILD", - # executorch==1.5.0.dev20260901 - commit = "fe35dd7e0b84f0c750a5147f329f8951f5fee1be", + # executorch==1.5.0.dev20260902 + commit = "5afeaa8130f68f2afa800e0743d4a73aec79bf15", recursive_init_submodules = True, patch_cmds = [ "find . -mindepth 2 \\( -name BUILD -o -name BUILD.bazel \\) -delete", diff --git a/examples/executorch_reference_runner/README.md b/examples/executorch_reference_runner/README.md index 425b5c6d96e..179d82c6038 100644 --- a/examples/executorch_reference_runner/README.md +++ b/examples/executorch_reference_runner/README.md @@ -44,7 +44,7 @@ torch_tensorrt/bin/example_executorch_runner ```bash # Get the ExecuTorch source snapshot this package is built against. Keep this in sync # with the executorch commit pinned in MODULE.bazel. -EXECUTORCH_REF="${EXECUTORCH_REF:-fe35dd7e0b84f0c750a5147f329f8951f5fee1be}" +EXECUTORCH_REF="${EXECUTORCH_REF:-5afeaa8130f68f2afa800e0743d4a73aec79bf15}" git clone --filter=blob:none --no-checkout \ https://github.com/pytorch/executorch.git executorch pushd executorch diff --git a/justfile b/justfile index 18ab2f60092..cb60a64f536 100644 --- a/justfile +++ b/justfile @@ -94,7 +94,7 @@ install-test-ext: # compiled from the commit this version pairs with. uv pip install pyyaml \ --extra-index-url https://download.pytorch.org/whl/nightly/cu130 \ - "executorch==1.5.0.dev20260901" + "executorch==1.5.0.dev20260902" # ── Linting ─────────────────────────────────────────────────────────────────── diff --git a/py/torch-tensorrt-executorch-runtime/README.md b/py/torch-tensorrt-executorch-runtime/README.md index 48b01031969..51d697a85c7 100644 --- a/py/torch-tensorrt-executorch-runtime/README.md +++ b/py/torch-tensorrt-executorch-runtime/README.md @@ -41,7 +41,7 @@ export TensorRT_ROOT=/path/to/TensorRT python -m pip install pyyaml \ --extra-index-url https://download.pytorch.org/whl/nightly/cu130 \ - "executorch==1.5.0.dev20260901" + "executorch==1.5.0.dev20260902" python -m pip wheel --no-build-isolation --no-deps \ --wheel-dir dist py/torch-tensorrt-executorch-runtime ``` @@ -49,7 +49,7 @@ python -m pip wheel --no-build-isolation --no-deps \ The native build obtains the ExecuTorch source through Bazel; no separate source checkout or `EXECUTORCH_SOURCE_DIR` setting is required. The source commit pinned in `MODULE.bazel` is the revision recorded by the -`executorch==1.5.0.dev20260901` wheel. +`executorch==1.5.0.dev20260902` wheel. The static ExecuTorch and delegate archives are intermediate build inputs; users receive the final native Python module and do not compile anything. diff --git a/py/torch-tensorrt-executorch-runtime/pyproject.toml b/py/torch-tensorrt-executorch-runtime/pyproject.toml index 03a5db63451..1f45b557ab2 100644 --- a/py/torch-tensorrt-executorch-runtime/pyproject.toml +++ b/py/torch-tensorrt-executorch-runtime/pyproject.toml @@ -6,6 +6,6 @@ requires = [ # environment. # Builds must use --no-build-isolation; see README.md. "torch", - "executorch==1.5.0.dev20260901", + "executorch==1.5.0.dev20260902", ] build-backend = "setuptools.build_meta" diff --git a/toolchains/ci_workspaces/MODULE.bazel.tmpl b/toolchains/ci_workspaces/MODULE.bazel.tmpl index 2f359466d05..6147493c19c 100644 --- a/toolchains/ci_workspaces/MODULE.bazel.tmpl +++ b/toolchains/ci_workspaces/MODULE.bazel.tmpl @@ -216,8 +216,8 @@ new_local_repository( new_git_repository( name = "executorch", build_file = "@//third_party/executorch:BUILD", - # executorch==1.5.0.dev20260901 - commit = "fe35dd7e0b84f0c750a5147f329f8951f5fee1be", + # executorch==1.5.0.dev20260902 + commit = "5afeaa8130f68f2afa800e0743d4a73aec79bf15", patch_cmds = [ "find . -mindepth 2 \\( -name BUILD -o -name BUILD.bazel \\) -delete", "mkdir executorch && find . -mindepth 1 -maxdepth 1 ! -name executorch ! -name BUILD ! -name BUILD.bazel ! -name REPO.bazel -exec cp -a {} executorch/ \\;", From 5873271e4872674262cb2f67fdce8b5d6aaf83ca Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Wed, 2 Sep 2026 07:30:24 -0700 Subject: [PATCH 17/20] Rewrite a pin that carries a local label The rewriter's trailing boundary excluded '+', so a requirement written as executorch==+cu130 did not match and the bump left it on the old version. The guard's own requirement pattern does accept that spelling, so such a site would be counted as a pin and skipped by the rewriter, which is the shape that fails the generated pull request as a mismatch rather than as an operator the rewriter cannot see. No live site is written that way today, so this is latent rather than a present break. Test plan: exercised the rewrite across the label-free pin, the labelled pin, the range form with and without a label, the YAML key, and a bystander bazel_dep version. The label survives the bump rather than being dropped, and the bystander is untouched. --- .github/scripts/update_executorch_pin.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/.github/scripts/update_executorch_pin.py b/.github/scripts/update_executorch_pin.py index f97a041a01b..68093e15436 100644 --- a/.github/scripts/update_executorch_pin.py +++ b/.github/scripts/update_executorch_pin.py @@ -234,14 +234,22 @@ def write_pins(new_version: str, new_commit: str) -> bool: + re.escape(old_range_tail) + r"|" + re.escape(old_version) - + r")(?![0-9A-Za-z.+_-])" + # A local label belongs to the version, so consume it rather than letting it block + # the match. The guard accepts "executorch==+cu130" as a pin, so a rewriter + # blind to it would leave such a site stale and then fail the generated pull request. + + r")(?P