diff --git a/.github/workflows/_test-windows.yml b/.github/workflows/_test-windows.yml index a3e6ba821b..f08921b2fc 100644 --- a/.github/workflows/_test-windows.yml +++ b/.github/workflows/_test-windows.yml @@ -158,9 +158,11 @@ jobs: script: | set -euo pipefail if [[ "${{ matrix.suite }}" == "executorch-windows" ]]; then + # A development ExecuTorch pin is on the nightly channel, a release pin on the test channel. # Windows uses torchao's Python-only nightly wheel from the CPU index. python -m pip install --pre pyyaml \ --extra-index-url "https://download.pytorch.org/whl/nightly/${CU_VERSION}" \ + --extra-index-url "https://download.pytorch.org/whl/test/${CU_VERSION}" \ --extra-index-url "https://download.pytorch.org/whl/nightly/cpu" \ -r packaging/executorch_pin.txt runtime_wheels=("${RUNNER_TEMP}"/artifacts/torch_tensorrt_executorch_runtime-*.whl) diff --git a/.github/workflows/build_linux.yml b/.github/workflows/build_linux.yml index c7a1297629..4e65fc330f 100644 --- a/.github/workflows/build_linux.yml +++ b/.github/workflows/build_linux.yml @@ -337,7 +337,7 @@ jobs: key: trt-bazel-${{ inputs.architecture }}-${{ matrix.desired_cuda }}-rtx${{ inputs.use-rtx }}-${{ hashFiles('**/MODULE.bazel', '**/WORKSPACE', '**/.bazelrc', '**/dev_dep_versions.yml') }} restore-keys: | trt-bazel-${{ inputs.architecture }}-${{ matrix.desired_cuda }}-rtx${{ inputs.use-rtx }}- - # Named before either wheel is built: the main wheel's executorch extra requires exactly this + # Named before either wheel is built: the main wheel's executorch extra can require exactly this # companion, and the companion build below takes its version from the same variable. - name: Name the ExecuTorch runtime companion if: ${{ inputs.build-executorch-runtime }} @@ -348,6 +348,13 @@ jobs: source "${BUILD_ENV_FILE}" version="$(${CONDA_RUN} python packaging/executorch_runtime_version.py ${{ inputs.is-release-wheel }})" echo "TORCH_TENSORRT_EXECUTORCH_RUNTIME_VERSION=${version}" >> "${GITHUB_ENV}" + # A release wheel has no CUDA label, the form PyPI needs; its cu132 build goes to PyPI. Any + # other build goes to this run's PyTorch channel. + if [[ ${{ inputs.is-release-wheel }} == true ]]; then + echo "TORCH_TENSORRT_EXECUTORCH_RUNTIME_CHANNEL=pypi" >> "${GITHUB_ENV}" + else + echo "TORCH_TENSORRT_EXECUTORCH_RUNTIME_CHANNEL=${CHANNEL}" >> "${GITHUB_ENV}" + fi - name: Build the wheel (python-build-package) if: ${{ inputs.build-platform == 'python-build-package' }} working-directory: ${{ inputs.repository }} @@ -447,17 +454,19 @@ jobs: sys.exit("The main wheel declares no CUDA 13 TensorRT dependencies") subprocess.check_call([sys.executable, "-m", "pip", "install", *tensorrt]) PY - # The nightly channel is named directly rather than through the channel variable, on release - # rows too, because the pin is a dated development build that only that channel carries. - # The default index stays in the set for the other requirements, which means a pin naming a - # version the default index also publishes could resolve to the processor-only build from - # there. What refuses that is the delegate's own build, which reads the installed + # Both channels that carry a pinned ExecuTorch are named directly rather than through the + # channel variable: a dated development build is only on the nightly channel, and a release + # is on the test channel. The default index stays in the set for the other requirements, + # which means a pin naming a version the default index also publishes could resolve to the + # processor-only build from there. pip prefers the labelled CUDA build of the same version, + # and what refuses the other is the delegate's own build, which reads the installed # ExecuTorch's local label and stops unless it names a CUDA build, so the failure is a clear # message rather than a configure error about a missing extension. # The version itself comes from the requirement file, so moving the pin to a newer # nightly changes no file in this directory. ${CONDA_RUN} python -m pip install pyyaml patchelf "wheel>=0.40" \ --extra-index-url "https://download.pytorch.org/whl/nightly/${CU_VERSION}" \ + --extra-index-url "https://download.pytorch.org/whl/test/${CU_VERSION}" \ -r packaging/executorch_pin.txt echo "TORCH_TENSORRT_EXECUTORCH_RUNTIME_VERSION=${TORCH_TENSORRT_EXECUTORCH_RUNTIME_VERSION:?}" ${CONDA_RUN} python -m pip wheel --no-build-isolation --no-deps --wheel-dir dist \ diff --git a/.github/workflows/build_windows.yml b/.github/workflows/build_windows.yml index 982873092b..5883c8655d 100644 --- a/.github/workflows/build_windows.yml +++ b/.github/workflows/build_windows.yml @@ -429,7 +429,7 @@ jobs: else export PYTORCH_VERSION="$(${CONDA_RUN} pip show torch | grep ^Version: | sed 's/Version: *//')" fi - # Named before either wheel is built: the main wheel's executorch extra requires exactly this + # Named before either wheel is built: the main wheel's executorch extra can require exactly this # companion, and the companion build below takes its version from the same variable. - name: Name the ExecuTorch runtime companion if: ${{ inputs.build-executorch-runtime && inputs.architecture == 'x64' && !inputs.cross-compile && !inputs.python-only && !inputs.use-rtx && !inputs.is-release-tarball }} @@ -439,6 +439,12 @@ jobs: source "${BUILD_ENV_FILE}" version="$(${CONDA_RUN} python packaging/executorch_runtime_version.py ${{ inputs.is-release-wheel }})" echo "TORCH_TENSORRT_EXECUTORCH_RUNTIME_VERSION=${version}" >> "${GITHUB_ENV}" + # A release wheel has the form PyPI needs, and PyPI's Windows ExecuTorch is the CPU build the + # delegate cannot load, so it records no channel and its extra leaves the delegate out. Any + # other build goes to this run's PyTorch channel. + if [[ ${{ inputs.is-release-wheel }} != true ]]; then + echo "TORCH_TENSORRT_EXECUTORCH_RUNTIME_CHANNEL=${CHANNEL}" >> "${GITHUB_ENV}" + fi - name: Build the wheel (python-build-package) X64 if: ${{ inputs.build-platform == 'python-build-package' && inputs.architecture == 'x64' && inputs.cross-compile == false }} working-directory: ${{ inputs.repository }} @@ -490,17 +496,22 @@ jobs: else ${CONDA_RUN} ${ENV_SCRIPT} python setup.py bdist_wheel ${BUILD_PARAMS} fi + # A release wheel has the form PyPI needs, and the delegate cannot load PyPI's Windows + # ExecuTorch, so its job builds no delegate. The version is still recorded above for the install + # advice. - name: Build the ExecuTorch runtime wheel - if: ${{ env.TORCH_TENSORRT_EXECUTORCH_RUNTIME_VERSION != '' }} + if: ${{ env.TORCH_TENSORRT_EXECUTORCH_RUNTIME_VERSION != '' && !inputs.is-release-wheel }} working-directory: ${{ inputs.repository }} run: | set -euxo pipefail source "${BUILD_ENV_FILE}" case "${CU_VERSION}" in cu132|cu134) ;; *) echo "Unsupported ExecuTorch CUDA row: ${CU_VERSION}" >&2; exit 1 ;; esac ${CONDA_RUN} python -m pip install --no-deps dist/torch_tensorrt-*.whl + # A development ExecuTorch pin is on the nightly channel, a release pin on the test channel. # Windows uses torchao's Python-only nightly wheel from the CPU index. ${CONDA_RUN} python -m pip install --pre "cmake>=3.28" ninja pyyaml "wheel>=0.40" \ --extra-index-url "https://download.pytorch.org/whl/nightly/${CU_VERSION}" \ + --extra-index-url "https://download.pytorch.org/whl/test/${CU_VERSION}" \ --extra-index-url "https://download.pytorch.org/whl/nightly/cpu" \ -r packaging/executorch_pin.txt # The core build has fetched the TensorRT SDK containing headers and import libraries. diff --git a/.github/workflows/executorch-test-linux.yml b/.github/workflows/executorch-test-linux.yml index ca60e6d09f..2dd2aaab4c 100644 --- a/.github/workflows/executorch-test-linux.yml +++ b/.github/workflows/executorch-test-linux.yml @@ -63,12 +63,14 @@ jobs: chmod +x "${RUNNER_TEMP}/bin/bazel" export PATH="${RUNNER_TEMP}/bin:${PATH}" - # 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. The version - # comes from the requirement file so a bump changes no file in this directory. + # ExecuTorch's CUDA wheels live only on the PyTorch indexes: a development build on the + # nightly one, a release on the test one. No --pre: a specifier naming a prerelease admits + # prereleases by itself, and --pre would apply to every other requirement in the same + # command too. The version comes from the requirement file so a bump changes no file in + # this directory. python -m pip install pyyaml "wheel>=0.40" \ --extra-index-url "https://download.pytorch.org/whl/nightly/${CU_VERSION}" \ + --extra-index-url "https://download.pytorch.org/whl/test/${CU_VERSION}" \ -r packaging/executorch_pin.txt # The shared artifact carries the matching core and companion wheels. @@ -108,18 +110,27 @@ jobs: # Resolve the user-facing range without reusing the build's installed pin. 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 "https://download.pytorch.org/whl/nightly/${CU_VERSION}" -r packaging/executorch_range.txt + "${RUNNER_TEMP}/range-check-venv/bin/python" -m pip install --no-deps --extra-index-url "https://download.pytorch.org/whl/nightly/${CU_VERSION}" --extra-index-url "https://download.pytorch.org/whl/test/${CU_VERSION}" -r packaging/executorch_range.txt # pip does not fail when an extra cannot be satisfied; it quietly picks an older - # torch-tensorrt. So check here that the main wheel's executorch extra requires exactly the - # companion built beside it, and that pip resolves that extra for the version just built. + # torch-tensorrt. So check here that the executorch extra requires exactly the companion + # built beside it when this run's channel also carries the ExecuTorch build that companion + # pins (nightly for a development build, test for a release), that it requires none + # otherwise, and that pip resolves that extra for the version just built. python - <<'PY' import importlib.metadata as metadata + import os import sys from packaging.requirements import Requirement companion = metadata.version("torch-tensorrt-executorch-runtime") + (executorch,) = [ + r + for r in map(Requirement, metadata.requires("torch-tensorrt-executorch-runtime") or []) + if r.name == "executorch" + ] + executorch_channel = "nightly" if ".dev" in str(executorch.specifier) else "test" pins = [ r for r in map(Requirement, metadata.requires("torch-tensorrt") or []) @@ -127,12 +138,14 @@ jobs: and r.marker is not None and r.marker.evaluate({"extra": "executorch"}) ] - if [str(r.specifier) for r in pins] != [f"=={companion}"]: - sys.exit(f"the executorch extra requires {pins}, not the companion built beside it ({companion})") + expected = [f"=={companion}"] if os.environ["CHANNEL"] == executorch_channel else [] + if [str(r.specifier) for r in pins] != expected: + sys.exit(f"the executorch extra requires {pins}, not {expected} (companion {companion}, channel {os.environ['CHANNEL']}, {executorch})") PY built="$(python -c 'import importlib.metadata as m; print(m.version("torch-tensorrt"))')" python -m pip install --pre --dry-run --find-links /opt/torch-tensorrt-builds \ --extra-index-url "https://download.pytorch.org/whl/nightly/${CU_VERSION}" \ + --extra-index-url "https://download.pytorch.org/whl/test/${CU_VERSION}" \ --extra-index-url https://pypi.nvidia.com \ "torch-tensorrt[executorch]==${built}" diff --git a/docsrc/RELEASE_CHECKLIST.md b/docsrc/RELEASE_CHECKLIST.md index 7321eba86a..b675408693 100644 --- a/docsrc/RELEASE_CHECKLIST.md +++ b/docsrc/RELEASE_CHECKLIST.md @@ -68,3 +68,10 @@ will result in a minor version bump and significant bug fixes will result in a p - TODO: Build a manylinux container for aarch64 - `docker run -it -v$(pwd)/..:/workspace/Torch-TensorRT build_torch_tensorrt_wheel /bin/bash /workspace/Torch-TensorRT/py/build_whl.sh` generates all wheels - To build container `docker build -t build_torch_tensorrt_wheel .` + +7. Publish ExecuTorch and the ExecuTorch delegate before torch-tensorrt + - Before building the release wheels, move the ExecuTorch pin to an ExecuTorch release that has CUDA wheels, in every pin site that `.github/scripts/update_executorch_pin.py` lists (`dev_dep_versions.yml`, `MODULE.bazel`, `packaging/executorch_pin.txt` and the rest). While the pin is a development build, a release wheel's `executorch` extra leaves out `torch-tensorrt-executorch-runtime`. + - When a release pins an ExecuTorch release, its `executorch` extra requires `torch-tensorrt-executorch-runtime`. If pip cannot find that package, it does not fail. It quietly installs an older torch-tensorrt, which brings no delegate. + - Stable index: in pytorch/test-infra, set `TORCH_TENSORRT_EXECUTORCH_RUNTIME_VERSION` in `release/release_versions.sh` with the other release versions. Then promote `executorch`, then `torch_tensorrt_executorch_runtime`, then `torch_tensorrt`. + - PyPI: the release workflows build every wheel for cu132 and cu134. Release wheels carry no CUDA label, so the cu132 and cu134 wheels share file names and only one set can go to PyPI. Upload the cu132 set, because PyTorch's and ExecuTorch's Linux wheels on PyPI are CUDA 13.2 builds. First check that `executorch` is on PyPI and that its Linux wheels are its CUDA 13.2 build. Then upload the cu132 Linux `torch-tensorrt-executorch-runtime` wheels, then the cu132 `torch-tensorrt` wheels. Do not upload the Windows delegate: PyPI's Windows ExecuTorch is the CPU build, which the delegate cannot load. + - Then check on Linux, in a fresh environment, that `pip install --dry-run "torch_tensorrt[executorch]"` lists `torch-tensorrt-executorch-runtime`. diff --git a/docsrc/user_guide/runtime_performance/executorch.rst b/docsrc/user_guide/runtime_performance/executorch.rst index 649e08d609..ecdd7765bb 100644 --- a/docsrc/user_guide/runtime_performance/executorch.rst +++ b/docsrc/user_guide/runtime_performance/executorch.rst @@ -29,32 +29,18 @@ program, so the whole model stays on the GPU. Installation ------------- -Install Torch-TensorRT with the ``executorch`` extra from the PyTorch channel for your CUDA -version. Swap ``cu132`` for ``cu134`` on CUDA 13.4: +One command installs everything: PyTorch, Torch-TensorRT, TensorRT, the CUDA build of +ExecuTorch, and the TensorRT delegate. Use a fresh virtual environment. -.. code-block:: bash - - pip install "torch-tensorrt[executorch]" \ - --index-url https://download.pytorch.org/whl/cu132 \ - --extra-index-url https://pypi.org/simple - -The PyTorch channel is needed because the CUDA build of ExecuTorch is published only there. - -What the command installs -^^^^^^^^^^^^^^^^^^^^^^^^^^ +Releases +^^^^^^^^^ -* ExecuTorch, the CUDA build, from the ``executorch`` extra. That is all you need to export. -* ``torch-tensorrt-executorch-runtime``, one shared library holding the TensorRT delegate, - which you need to run a ``.pte``. The extra installs the one built together with your - Torch-TensorRT wheel, for the same CUDA version. It requires the exact ExecuTorch build - it was compiled against, so you get that one rather than the newest. +.. code-block:: bash -This works for the standard Linux (x86-64 and aarch64) and Windows x86-64 wheels. On aarch64 -it needs glibc 2.35 or newer, the same as TensorRT itself. The TensorRT-RTX and Python-only -builds have no delegate. + pip install "torch_tensorrt[executorch]" -Use a fresh virtual environment. To export a coalesced program you also need a CUDA toolkit, -because ExecuTorch's CUDA backend compiles the leftover operators with ``nvcc``. +This installs the CUDA 13.2 builds. On Windows, PyPI has only the CPU builds of PyTorch and +ExecuTorch, so add ``--extra-index-url https://download.pytorch.org/whl/cu132``. Nightly builds ^^^^^^^^^^^^^^^ @@ -63,15 +49,28 @@ To pick up a change before it ships, or to report a problem against ``main``: .. code-block:: bash - pip install --pre --upgrade "torch-tensorrt[executorch]" \ - --index-url https://download.pytorch.org/whl/nightly/cu132 \ - --extra-index-url https://pypi.org/simple + pip install --pre "torch_tensorrt[executorch]" --index-url https://download.pytorch.org/whl/nightly/cu132 + +Swap ``cu132`` for ``cu134`` on CUDA 13.4. -* ``--pre`` is required. Without it pip takes the stable release instead. -* ``--upgrade`` is required if a nightly is already installed, or pip keeps the old one and - tells you nothing. -* PyPI stays in the list because TensorRT is not published on the nightly channel. -* Keep PyTorch, ExecuTorch and Torch-TensorRT on the same channel. +What these commands install +^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +* PyTorch and TensorRT, which Torch-TensorRT depends on. +* ExecuTorch, the CUDA build. That is all you need to export. +* ``torch-tensorrt-executorch-runtime``, one shared library holding the TensorRT delegate, + which you need to run a ``.pte``. It is the one built together with your Torch-TensorRT wheel, + for the same CUDA version. It requires the exact ExecuTorch build it was compiled against, so + you get that one rather than the newest. + +A wheel you build yourself does not bring the delegate. Build it as well, as the runtime wheel +README shows. + +The delegate is built for the standard Linux wheels (x86-64 and aarch64), and for Windows x86-64. +On aarch64 it needs glibc 2.35 or newer, the same as TensorRT itself. +The TensorRT-RTX and Python-only builds have no delegate. To export a coalesced program you also +need a CUDA toolkit, because ExecuTorch's CUDA backend compiles the leftover operators with +``nvcc``. ---- diff --git a/py/torch-tensorrt-executorch-runtime/README.md b/py/torch-tensorrt-executorch-runtime/README.md index 83901a8591..216155361c 100644 --- a/py/torch-tensorrt-executorch-runtime/README.md +++ b/py/torch-tensorrt-executorch-runtime/README.md @@ -112,50 +112,37 @@ consumer references a symbol the delegate defines, and the default would drop it and leave the backend unregistered. The wheel must use the same Python, PyTorch, ExecuTorch, CUDA, TensorRT, and -C++ ABI as its matching Torch-TensorRT wheel. This delegate requires CUDA 13; -the build matrix currently covers `cu132` and `cu134`, on both architectures. Ordinary Torch-TensorRT -release and JetPack builds retain their separate CUDA 12 support. +C++ ABI as its matching Torch-TensorRT wheel. This delegate requires CUDA 13. Nightly builds, and +release builds on the PyTorch indexes, cover `cu132` and `cu134`. The PyPI release wheels are the +`cu132` builds, the CUDA version of PyTorch's PyPI wheel. +Ordinary Torch-TensorRT release and JetPack builds keep their separate CUDA 12 support. ## Install -The `executorch` extra of a published Torch-TensorRT wheel brings this wheel (the one -built together with it) and the CUDA build of ExecuTorch it links. Torch-TensorRT brings -PyTorch. Swap `cu132` for `cu134` if you run CUDA 13.4: +The +[user guide](https://docs.pytorch.org/TensorRT/user_guide/runtime_performance/executorch.html#installation) +has the install commands. Installed that way, the `executorch` extra of a Torch-TensorRT wheel +brings this wheel (the one built together with it) and the exact CUDA build of ExecuTorch it links. +This is the nightly command. Swap `cu132` for `cu134` if you run CUDA 13.4: ```bash python -m pip install --pre "torch-tensorrt[executorch]" \ - --index-url https://download.pytorch.org/whl/nightly/cu132 \ - --extra-index-url https://pypi.org/simple \ - --extra-index-url https://pypi.nvidia.com + --index-url https://download.pytorch.org/whl/nightly/cu132 ``` -All three indexes are needed, and so is `--pre`. Without `--pre` pip takes the stable -Torch-TensorRT from the public index, which is far older. Without NVIDIA's index the -inference library resolves to a source distribution, and pip spends around twenty minutes -trying to build it before failing. -PyTorch you never name: it arrives as a dependency of Torch-TensorRT. This wheel asks for it by -name with no version, because ExecuTorch imports it and leaves the choice open, so installing this +`--pre` lets pip choose the nightly builds, which are prereleases. +You never name PyTorch: it arrives as a dependency of Torch-TensorRT. This wheel asks +for it by name with no version, because ExecuTorch imports it and leaves the choice open, so installing this wheel on its own lets the resolver pick a PyTorch older than the ExecuTorch it pins. That combination installs, imports and loads a program, then fails at the first run with a message about a tensor rather than about versions. Install it the way this section shows and the question does not arise. Building this wheel by hand is only needed to work on it, and those instructions are below. -On a fresh environment that command was measured installing: - -| package | version | -| --- | --- | -| torch-tensorrt | 2.15.0.dev20260919+cu132 | -| torch-tensorrt-executorch-runtime | 0.2.0.dev20260919+cu132 | -| executorch | 1.6.0.dev20260915+cu132 | -| torch | 2.15.0.dev20260919+cu132 | -| tensorrt-cu13-libs | 11.3.0.99 | -| nvidia-cuda-runtime | 13.4.92 | - -ExecuTorch lands on an older date than the rest because this wheel pins the exact build it -was compiled against. That is deliberate, and the next section says why. +ExecuTorch can be an older nightly than the rest, because this wheel pins the exact build it +was compiled against. That is deliberate, and the next paragraph says why. A CUDA build of ExecuTorch is required, not only to build against. The delegate needs a library that only ExecuTorch's CUDA wheels carry, so a processor-only build installs and -then fails on import. This wheel pins the exact ExecuTorch build it was compiled against, -label and all, which is what stops that happening quietly. +then fails on import. On the PyTorch indexes this wheel pins the exact ExecuTorch build it was +compiled against, label and all, which is what stops that happening quietly. ## Use it from Python Import the package once, anywhere before you load a program. Importing is what @@ -258,6 +245,7 @@ worse off than when you started. ```bash python -m pip install pyyaml patchelf tensorrt-cu13 \ --extra-index-url "https://download.pytorch.org/whl/nightly/${CU_VERSION}" \ + --extra-index-url "https://download.pytorch.org/whl/test/${CU_VERSION}" \ --extra-index-url https://pypi.nvidia.com/ \ "executorch==1.6.0.dev20261021" export TORCH_TENSORRT_EXECUTORCH_RUNTIME_VERSION="0.3.0.dev0+${CU_VERSION}" @@ -265,18 +253,24 @@ python -m pip wheel --no-build-isolation --no-deps \ --wheel-dir dist py/torch-tensorrt-executorch-runtime ``` -`TORCH_TENSORRT_EXECUTORCH_RUNTIME_VERSION` -sets only the companion's own version. -base version plus a development suffix and Git revision. The shared build keeps -the main wheel's date and CUDA suffix while retaining the companion's independent +`TORCH_TENSORRT_EXECUTORCH_RUNTIME_VERSION` sets the companion's own version. Without it, the +version is the companion's base version plus a development suffix and Git revision. The shared +build keeps the main wheel's date and CUDA suffix while retaining the companion's independent base version. Its CMake version also describes the companion, not the main wheel. +A release build of the main wheel (`RELEASE=1`) reads the same variable: + +- It records the version, so its error messages can name this wheel. +- If `TORCH_TENSORRT_EXECUTORCH_RUNTIME_CHANNEL` names a place that carries the pinned + ExecuTorch build (`nightly` for a development build, `test` or `pypi` for a release), its + `executorch` extra requires exactly that version. Otherwise the extra leaves this wheel out. + Windows release wheels for PyPI set no channel. + `tensorrt-cu13` is needed at build time so the wheel can record an exact `tensorrt-cu13==` requirement: that version is read from the installed -distribution, which the Bazel-provided libraries alone do not carry. It needs -`--extra-index-url https://pypi.nvidia.com/` above, because the PyPI -`tensorrt-cu13` sdist is a stub that downloads the real wheel from NVIDIA's index -and fails metadata generation without it. +distribution, which the Bazel-provided libraries alone do not carry. NVIDIA's +index above lets pip take its wheel directly; without it, the PyPI `tensorrt-cu13` +source package downloads that wheel while it builds. The delegate compiles and links entirely against the installed ExecuTorch wheel, which ships the headers, the prebuilt runtime, and a CMake package. A @@ -375,13 +369,18 @@ itself. A **CUDA** build of `executorch` is required at runtime, not just to build. The delegate carries a `DT_NEEDED` on `libexecutorch_extension_cuda.so`, which only ExecuTorch's CUDA wheels ship, and -those live on the PyTorch nightly index. `install_requires` names the version including its local +those live on PyTorch's indexes: a development build on the nightly one, a release on the test and +stable ones. `install_requires` names the version including its local label, so only the CUDA build the delegate linked satisfies it. A label-free specifier admits any label, which let a `+cpu` wheel resolve and then fail to load at import. Carrying the label rules that out, because PEP 440 only ignores labels when the specifier omits them. It does bind the wheel -to one CUDA train, which is correct: the delegate links that train's runtime. +to one CUDA train, which is correct: the delegate links that train's runtime. A wheel for PyPI +cannot carry a label, so it names the version alone. On Linux, PyPI's ExecuTorch is the CUDA build; +on Windows it is the CPU build, so the extra of a Windows release wheel on PyPI leaves this wheel +out. -To install the wheel built above, use the same CUDA index as the build. Build it for the +To install the wheel built above, use the same CUDA indexes as the build (nightly for a +development ExecuTorch pin, test for a release). Build it for the CUDA train you run on: the requirement names that train, because the delegate links its runtime. Its exact development-version requirements already permit the required prereleases. CUDA and TensorRT packages may resolve from PyPI or NVIDIA's index; the extra @@ -389,7 +388,8 @@ index applies to the whole dependency solve. ```bash python -m pip install dist/torch_tensorrt_executorch_runtime-*.whl \ - --extra-index-url https://download.pytorch.org/whl/nightly/cu132 + --extra-index-url https://download.pytorch.org/whl/nightly/cu132 \ + --extra-index-url https://download.pytorch.org/whl/test/cu132 ``` ```python diff --git a/py/torch_tensorrt/_utils.py b/py/torch_tensorrt/_utils.py index 1f0b584b7b..291a87184f 100644 --- a/py/torch_tensorrt/_utils.py +++ b/py/torch_tensorrt/_utils.py @@ -38,7 +38,7 @@ def sanitized_torch_version() -> Any: def executorch_install_channel() -> str | None: - """Return the nightly channel matching the active PyTorch CUDA build, if supported. + """Return the CUDA channel, such as cu132, matching the active PyTorch CUDA build, if supported. Names the channel matching the installed torch. It does not promise that channel exists, because PyTorch publishes a new CUDA minor before ExecuTorch fills it. @@ -57,11 +57,49 @@ def is_platform_supported_for_executorch() -> bool: ) +def _build_record(name: str) -> str: + """Return a value the build wrote into _version.py, or an empty string if it wrote none.""" + from torch_tensorrt import _version + + return getattr(_version, name, "") + + +def _executorch_index_options(cuda: str) -> str: + """Return the pip options naming the indexes with this wheel's delegate and pinned ExecuTorch. + + A development ExecuTorch build is only on the nightly index, a release candidate on the test + index, and a release on the stable index. + """ + # A version file older than this record was written by a source build pinning a development + # ExecuTorch. + executorch = _build_record("__executorch_channel__") or "nightly" + delegate = executorch + if _build_record("__executorch_runtime_version__"): + # A PyPI wheel records pypi, or no channel on Windows or before that record existed. PyPI has + # one delegate per platform at most, while the stable index keeps the release's delegate, and + # the ExecuTorch release it pins, for every CUDA version. + delegate = _build_record("__executorch_runtime_channel__") + if delegate in ("", "pypi"): + delegate = "" + if executorch == "test": + executorch = "" + indexes = [ + f"{channel}/{cuda}" if channel else cuda + for channel in dict.fromkeys((delegate, executorch)) + ] + return " ".join( + f"--extra-index-url https://download.pytorch.org/whl/{index}" + for index in indexes + ) + + def executorch_install_command() -> str: """Return an install command, or guidance for an unsupported CUDA build. - Adding this package's missing extra does not need a blanket --upgrade, but pip - may still change dependencies to satisfy it. --pre permits prerelease candidates. + Adding this package's missing extra does not need a blanket --upgrade. Naming the installed + version keeps pip from swapping in a different torch_tensorrt release to satisfy the extra. + Naming the recorded delegate keeps the ExecuTorch build it pins where the extra leaves the + delegate out. --pre permits prerelease candidates. """ channel = executorch_install_channel() if channel is None or not is_platform_supported_for_executorch(): @@ -70,34 +108,36 @@ def executorch_install_command() -> str: f"{EXECUTORCH_CUDA_MAJOR} build. Use matching PyTorch, ExecuTorch and " "Torch-TensorRT wheels in a fresh environment." ) - return ( - 'pip install --pre "torch_tensorrt[executorch]" ' - f"--extra-index-url https://download.pytorch.org/whl/nightly/{channel}" - ) + delegate = _build_record("__executorch_runtime_version__") + # The extra may require the delegate built for this wheel's CUDA channel, not torch's. + cuda = delegate.partition("+")[2] or channel + requirements = f'"torch_tensorrt[executorch]=={_build_record("__version__")}" ' + if delegate: + requirements += f'"torch-tensorrt-executorch-runtime=={delegate}" ' + return f"pip install --pre {requirements}" + _executorch_index_options(cuda) def executorch_runtime_install_command() -> str: """Return the install command for the delegate built with this wheel, or why there is none. - The build records the companion it was published with, which is the exact version this - wheel's executorch extra requires, so a hand install can name the same one. + The build records the companion built beside this wheel. Where the executorch extra can find + it, the extra requires exactly that version; otherwise a hand install has to name it. """ - from torch_tensorrt import _version - - version = getattr(_version, "__executorch_runtime_version__", "") + version = _build_record("__executorch_runtime_version__") if not version: return ( - "This Torch-TensorRT build has no matching delegate. It ships only with the " - "standard Linux and Windows x86-64 CUDA 13 wheels; TensorRT-RTX, Python-only, " - "JetPack and source builds do not have one." + "This Torch-TensorRT build has no matching delegate. The standard CUDA " + f"{EXECUTORCH_CUDA_MAJOR} wheels for Linux (x86-64 and aarch64) and Windows x86-64 " + "have one; TensorRT-RTX, Python-only and JetPack builds do not. To build it from " + "source, see py/torch-tensorrt-executorch-runtime/README.md in the Torch-TensorRT " + "repository." ) - channel = version.partition("+")[2] or executorch_install_channel() - if channel is None: + cuda = version.partition("+")[2] or executorch_install_channel() + if cuda is None: return executorch_install_command() - index = f"nightly/{channel}" if ".dev" in version else channel return ( f'pip install "torch-tensorrt-executorch-runtime=={version}" ' - f"--extra-index-url https://download.pytorch.org/whl/{index}" + + _executorch_index_options(cuda) ) diff --git a/setup.py b/setup.py index 40af2337fd..5b3ddd6f7c 100644 --- a/setup.py +++ b/setup.py @@ -209,14 +209,15 @@ 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. 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. +# minor against a backend built for this one. Everything below that ceiling resolves: later +# nightlies of the pinned minor, its release candidate, and its patch releases alike, since the +# exact pin belongs in the runtime package, which does derive it. Where a wheel's extras also +# require that package, they admit only the one ExecuTorch build it pins. # 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. -# 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. +# That also makes this range prefer a release as soon as one exists, since a release sorts above +# every development build of the same version, so nothing here changes on the day it ships. _executorch_major, _executorch_minor = __executorch_version__.split(".")[:2] # ExecuTorch CUDA wheels support Linux and Windows x86-64. Windows ARM64 is # excluded until ExecuTorch publishes a matching runtime wheel. @@ -230,13 +231,28 @@ def load_dep_info(): "executorch": [EXECUTORCH_REQUIREMENT], "all": [EXECUTORCH_REQUIREMENT], } -# A published wheel's extras also require the exact companion its build job produced. The workflow -# names it in this variable before building either wheel, and the companion build reads the same -# value. Source builds leave it out, so the lock keeps following the source pins. +# A published wheel's extras also require the exact companion its build job produced, but only +# where the wheel's own index carries the ExecuTorch build that companion pins: pip does not fail on +# an extra it cannot meet, it quietly installs another torch-tensorrt. A development build is only +# on the nightly index. A release is on the test index, and on Linux PyPI carries its CUDA build for +# the CUDA version of PyTorch's PyPI wheel, the one the PyPI companion is built for. A Windows PyPI +# wheel records no channel, because PyPI's Windows ExecuTorch is the CPU build. After promotion, +# the extra stays installable only if ExecuTorch and the companion reach the stable index before +# torch-tensorrt does. The workflow names the version and the channel before building either +# wheel, and the companion build reads the same version. Source builds leave both out, so the lock +# keeps following the source pins. +EXECUTORCH_CHANNEL = "nightly" if ".dev" in __executorch_version__ else "test" EXECUTORCH_RUNTIME_VERSION = ( os.environ.get("TORCH_TENSORRT_EXECUTORCH_RUNTIME_VERSION", "") if RELEASE else "" ) -if EXECUTORCH_RUNTIME_VERSION: +EXECUTORCH_RUNTIME_CHANNEL = ( + os.environ.get("TORCH_TENSORRT_EXECUTORCH_RUNTIME_CHANNEL", "") + if EXECUTORCH_RUNTIME_VERSION + else "" +) +if EXECUTORCH_RUNTIME_CHANNEL == EXECUTORCH_CHANNEL or ( + EXECUTORCH_RUNTIME_CHANNEL == "pypi" and EXECUTORCH_CHANNEL == "test" +): for _extra in ("executorch", "all"): EXTRAS_REQUIRE[_extra].append( f"torch-tensorrt-executorch-runtime=={EXECUTORCH_RUNTIME_VERSION}; " @@ -403,6 +419,10 @@ def gen_version_file(): f.write( '__executorch_runtime_version__ = "' + EXECUTORCH_RUNTIME_VERSION + '"\n' ) + f.write( + '__executorch_runtime_channel__ = "' + EXECUTORCH_RUNTIME_CHANNEL + '"\n' + ) + f.write('__executorch_channel__ = "' + EXECUTORCH_CHANNEL + '"\n') def copy_libtorchtrt(multilinux=False, rt_only=False): diff --git a/tests/py/dynamo/executorch/test_api.py b/tests/py/dynamo/executorch/test_api.py index 6b8a1f06cb..d42921bfce 100644 --- a/tests/py/dynamo/executorch/test_api.py +++ b/tests/py/dynamo/executorch/test_api.py @@ -2465,8 +2465,8 @@ def test_packaging_declares_executorch_extra(): assert extra_name in extras_by_name requirements = extras_by_name[extra_name] assert isinstance(requirements, ast.List) - # Authoring follows the source pin. The companion is installed explicitly - # because its published ABI pins can lag a new source dependency version. + # Authoring follows the source pin. A published build adds its companion after + # this literal, where its own index also carries the ExecuTorch the companion pins. named = [e.id for e in requirements.elts if isinstance(e, ast.Name)] assert named == ["EXECUTORCH_REQUIREMENT"], named diff --git a/tests/py/dynamo/executorch/test_executorch_pin.py b/tests/py/dynamo/executorch/test_executorch_pin.py index d3c5f4eae8..62f67b96db 100644 --- a/tests/py/dynamo/executorch/test_executorch_pin.py +++ b/tests/py/dynamo/executorch/test_executorch_pin.py @@ -7,12 +7,14 @@ """ import ast +import builtins import importlib.abc import json import os import re import runpy import shlex +import shutil import subprocess import sys import types @@ -538,14 +540,10 @@ def test_derived_requirements_match_the_pin(monkeypatch) -> None: 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}. The authoring extra " - "must follow the source pin without waiting for a companion wheel." - ) - assert "EXECUTORCH_RUNTIME_REQUIREMENT" not in named, ( - f"extra {getattr(key, 'value', key)!r} depends on the native companion: " - f"{named}. Install its matching build separately." + assert named == ["EXECUTORCH_REQUIREMENT"], ( + f"extra {getattr(key, 'value', key)!r} should name only EXECUTORCH_REQUIREMENT, " + f"found {named}. Source builds follow the source pin; a published build adds " + "its companion after this literal." ) # The doc build reads the pin through shell substitution, outside the literal scan. @@ -574,24 +572,39 @@ def _companion_version(build_version: str, cuda: str, release_wheel: bool) -> st return script["companion_version"](build_version, cuda, release_wheel) -def _published_extras(monkeypatch, companion, release: bool = True) -> dict: - """Run setup.py's companion requirement block with the companion the workflow named.""" +def _published_extras( + monkeypatch, + companion, + release: bool = True, + channel: str | None = "nightly", + executorch_version: str | None = None, +) -> dict: + """Run setup.py's companion requirement block with what the workflow named. + + ``executorch_version`` stands in for the repository's ExecuTorch pin. + """ tree = ast.parse((REPO_ROOT / "setup.py").read_text(encoding="utf-8")) blocks = [ node for node in tree.body if isinstance(node, (ast.Assign, ast.If)) - and "EXECUTORCH_RUNTIME_VERSION" - in {n.id for n in ast.walk(node) if isinstance(n, ast.Name)} + and {"EXECUTORCH_RUNTIME_VERSION", "EXECUTORCH_CHANNEL"} + & {n.id for n in ast.walk(node) if isinstance(n, ast.Name)} ] - assert len(blocks) == 2, "setup.py no longer names the companion in one place" - if companion is None: - monkeypatch.delenv("TORCH_TENSORRT_EXECUTORCH_RUNTIME_VERSION", raising=False) - else: - monkeypatch.setenv("TORCH_TENSORRT_EXECUTORCH_RUNTIME_VERSION", companion) + assert len(blocks) == 4, "setup.py's companion requirement blocks changed shape" + for variable, value in ( + ("TORCH_TENSORRT_EXECUTORCH_RUNTIME_VERSION", companion), + ("TORCH_TENSORRT_EXECUTORCH_RUNTIME_CHANNEL", channel), + ): + if value is None: + monkeypatch.delenv(variable, raising=False) + else: + monkeypatch.setenv(variable, value) requirement = _setup_py_requirement(_versions()["__executorch_version__"]) namespace: dict = { "os": os, + "__executorch_version__": executorch_version + or _versions()["__executorch_version__"], "RELEASE": release, "EXECUTORCH_REQUIREMENT": requirement, "EXTRAS_REQUIRE": {"executorch": [requirement], "all": [requirement]}, @@ -609,46 +622,89 @@ def _companion_requirements(extras: dict) -> list: ] +_DEVELOPMENT_PIN = "1.6.0.dev20261009" +_RELEASE_PIN = "1.6.0" + + @pytest.mark.unit @pytest.mark.parametrize( - "build_version, cuda, release_wheel, expected", + "build_version, cuda, release_wheel, channel, pin, required", [ - ("2.16.0.dev20261019+cu132", "cu132", False, ".dev20261019+cu132"), - ("2.16.0.dev20261019+cu134", "cu134", False, ".dev20261019+cu134"), - ("2.16.0+cu132", "cu132", False, "+cu132"), - ("2.16.0+cu132", "cu132", True, ""), + # Nightly wheels pinning a development build: the nightly index carries all three. + ("2.16.0.dev20261019+cu132", "cu132", False, "nightly", _DEVELOPMENT_PIN, True), + ("2.16.0.dev20261019+cu134", "cu134", False, "nightly", _DEVELOPMENT_PIN, True), + # A release candidate pinning an ExecuTorch release: the test index carries all three. + ("2.16.0+cu132", "cu132", False, "test", _RELEASE_PIN, True), + ("2.16.0+cu134", "cu134", False, "test", _RELEASE_PIN, True), + # The pinned ExecuTorch is not on the wheel's index, so the companion cannot install. + ("2.16.0+cu132", "cu132", False, "test", _DEVELOPMENT_PIN, False), + ("2.16.0.dev20261019+cu132", "cu132", False, "nightly", _RELEASE_PIN, False), + # PyPI carries an ExecuTorch release, as the CUDA build for PyTorch's PyPI CUDA version. + ("2.16.0+cu132", "cu132", True, "pypi", _RELEASE_PIN, True), + ("2.16.0+cu132", "cu132", True, "pypi", _DEVELOPMENT_PIN, False), + # A Windows PyPI wheel records no channel: PyPI's Windows ExecuTorch is the CPU build. + ("2.16.0+cu132", "cu132", True, None, _RELEASE_PIN, False), ], ) def test_published_extras_require_the_companion_built_beside_them( - monkeypatch, build_version, cuda, release_wheel, expected + monkeypatch, build_version, cuda, release_wheel, channel, pin, required ): - """A published wheel requires exactly the companion its own job built, CUDA label included. + """A wheel requires exactly the companion its own job built, CUDA label included, where it can. - Without the label a cu134 wheel accepts a cu132 companion, so upgrading from the cu132 index - to the cu134 one keeps the old companion and the ExecuTorch and CUDA runtime it pins. + It can where the wheel's own index also carries the ExecuTorch build the companion pins. pip + answers an extra it cannot meet by quietly installing another torch-tensorrt, so everywhere + else the extra must not name the companion. Without the label a cu134 wheel accepts a cu132 + companion, so upgrading from the cu132 index to the cu134 one keeps the old companion and the + ExecuTorch and CUDA runtime it pins. """ main = (REPO_ROOT / "version.txt").read_text().strip().removesuffix("a0") build_version = build_version.replace("2.16.0", main) - base = ( - (REPO_ROOT / "py/torch-tensorrt-executorch-runtime/version.txt") - .read_text() - .strip() - ) companion = _companion_version(build_version, cuda, release_wheel) - assert companion == f"{base}{expected}" marker = Requirement( _setup_py_requirement(_versions()["__executorch_version__"]) ).marker - requirements = _companion_requirements(_published_extras(monkeypatch, companion)) - assert len(requirements) == 2, requirements + requirements = _companion_requirements( + _published_extras( + monkeypatch, companion, channel=channel, executorch_version=pin + ) + ) + assert len(requirements) == (2 if required else 0), requirements for requirement in requirements: assert str(requirement.specifier) == f"=={companion}" assert str(requirement.marker) == str(marker) - if not release_wheel: - other = "cu134" if cuda == "cu132" else "cu132" - assert not requirement.specifier.contains( - companion.replace(cuda, other), prereleases=True - ), f"{requirement} accepts the {other} companion" + # PyPI holds one companion, so only a labelled one has another CUDA build to refuse. + if release_wheel: + continue + other = "cu134" if cuda == "cu132" else "cu132" + assert not requirement.specifier.contains( + companion.replace(cuda, other), prereleases=True + ), f"{requirement} accepts the {other} companion" + + +@pytest.mark.unit +@pytest.mark.parametrize( + "build_version, cuda, release_wheel, expected", + [ + ("2.16.0.dev20261019+cu132", "cu132", False, ".dev20261019+cu132"), + ("2.16.0.dev20261019+cu134", "cu134", False, ".dev20261019+cu134"), + ("2.16.0+cu132", "cu132", False, "+cu132"), + ("2.16.0+cu132", "cu132", True, ""), + ], +) +def test_the_companion_takes_the_wheel_s_suffix_and_label( + build_version, cuda, release_wheel, expected +): + """The companion keeps its own base version and takes the wheel's suffix and CUDA label.""" + main = (REPO_ROOT / "version.txt").read_text().strip().removesuffix("a0") + base = ( + (REPO_ROOT / "py/torch-tensorrt-executorch-runtime/version.txt") + .read_text() + .strip() + ) + companion = _companion_version( + build_version.replace("2.16.0", main), cuda, release_wheel + ) + assert companion == f"{base}{expected}" @pytest.mark.unit @@ -666,6 +722,26 @@ def test_an_unexpected_build_version_is_refused(): _companion_version("9.9.9.dev20261019+cu132", "cu132", False) +_NAMING_GATES = { + "build_linux.yml": "${{ inputs.build-executorch-runtime }}", + # Windows builds no companion for ARM64, cross-compiled, Python-only, RTX or tarball jobs. + "build_windows.yml": ( + "${{ inputs.build-executorch-runtime && inputs.architecture == 'x64' && " + "!inputs.cross-compile && !inputs.python-only && !inputs.use-rtx && " + "!inputs.is-release-tarball }}" + ), +} + + +_BUILD_GATES = { + "build_linux.yml": "${{ env.TORCH_TENSORRT_EXECUTORCH_RUNTIME_VERSION != '' }}", + # A Windows release wheel goes to PyPI, where the delegate cannot load. + "build_windows.yml": ( + "${{ env.TORCH_TENSORRT_EXECUTORCH_RUNTIME_VERSION != '' && !inputs.is-release-wheel }}" + ), +} + + @pytest.mark.unit @pytest.mark.parametrize( "workflow, main_build", @@ -675,12 +751,13 @@ def test_an_unexpected_build_version_is_refused(): ], ) def test_the_companion_and_the_requirement_on_it_share_one_version( - workflow, main_build + tmp_path, workflow, main_build ): """Each build workflow names the companion once, before the main wheel is built. The main wheel's requirement and the companion build both read that one variable, so they - cannot name different versions, and only a job that names a companion builds one. + cannot name different versions, and only a job that names a companion builds one. The step + is run, not read, so a disabled export fails here. """ steps = yaml.safe_load((REPO_ROOT / ".github/workflows" / workflow).read_text())[ "jobs" @@ -692,19 +769,77 @@ def test_the_companion_and_the_requirement_on_it_share_one_version( main_build ) assert names.index(main_build) < names.index("Build the ExecuTorch runtime wheel") - assert ( - "python packaging/executorch_runtime_version.py ${{ inputs.is-release-wheel }}" - in naming["run"] - ) - assert ( - 'TORCH_TENSORRT_EXECUTORCH_RUNTIME_VERSION=${version}" >> "${GITHUB_ENV}"' - in (naming["run"]) - ) - assert build["if"] == "${{ env.TORCH_TENSORRT_EXECUTORCH_RUNTIME_VERSION != '' }}" + # The gate decides whether the companion is named and built; setup.py decides whether the + # extra then requires it. + assert naming["if"] == _NAMING_GATES[workflow] + main = (REPO_ROOT / "version.txt").read_text().strip().removesuffix("a0") + for release_wheel, channel, build_version in ( + (False, "nightly", f"{main}.dev20261019+cu132"), + (False, "test", f"{main}+cu132"), + (True, "test", main), + ): + recorded = _run_naming_step( + tmp_path / f"{release_wheel}-{channel}", + naming["run"], + release_wheel, + {"BUILD_VERSION": build_version, "CU_VERSION": "cu132", "CHANNEL": channel}, + ) + expected = { + "TORCH_TENSORRT_EXECUTORCH_RUNTIME_VERSION": _companion_version( + build_version, "cu132", release_wheel + ) + } + # A release wheel goes to PyPI, whatever PyTorch channel the run is on. PyPI's Windows + # ExecuTorch is the CPU build, so a Windows release wheel records no channel. + if not release_wheel: + expected["TORCH_TENSORRT_EXECUTORCH_RUNTIME_CHANNEL"] = channel + elif workflow == "build_linux.yml": + expected["TORCH_TENSORRT_EXECUTORCH_RUNTIME_CHANNEL"] = "pypi" + assert recorded == expected, (workflow, release_wheel, channel) + assert build["if"] == _BUILD_GATES[workflow] assert "export TORCH_TENSORRT_EXECUTORCH_RUNTIME_VERSION" not in build["run"] assert "version.txt" not in build["run"] +def _run_naming_step(root: Path, script: str, release_wheel: bool, env: dict) -> dict: + """Run a workflow's naming step in bash and return what it wrote to GITHUB_ENV.""" + packaging = root / "packaging" + packaging.mkdir(parents=True) + shutil.copy2(REPO_ROOT / "packaging/executorch_runtime_version.py", packaging) + shutil.copy2(REPO_ROOT / "version.txt", root) + runtime = root / "py/torch-tensorrt-executorch-runtime" + runtime.mkdir(parents=True) + shutil.copy2( + REPO_ROOT / "py/torch-tensorrt-executorch-runtime/version.txt", runtime + ) + (root / "build-env").write_text("export CONDA_RUN=''\n") + (root / "bin").mkdir() + (root / "bin/python").symlink_to(sys.executable) + result = subprocess.run( + [ + shutil.which("bash"), + "-c", + script.replace( + "${{ inputs.is-release-wheel }}", str(release_wheel).lower() + ), + ], + cwd=root, + env={ + "PATH": f"{root / 'bin'}{os.pathsep}{os.environ['PATH']}", + "BUILD_ENV_FILE": str(root / "build-env"), + "GITHUB_ENV": str(root / "github-env"), + **env, + }, + text=True, + capture_output=True, + timeout=30, + ) + assert result.returncode == 0, result.stdout + result.stderr + return dict( + line.split("=", 1) for line in (root / "github-env").read_text().splitlines() + ) + + _RUNTIME_SETUP_PY = "py/torch-tensorrt-executorch-runtime/setup.py" @@ -1010,15 +1145,27 @@ def test_runner_channel_check_detects_removed_validator(monkeypatch): ) -def _load_utils_channel_helpers( - fake_cuda: str | None, platform: str = "linux", machine: str = "x86_64" -): - """Execute the real helpers without importing their torch and TensorRT dependencies.""" +_INSTALLED_VERSION = "2.16.0.dev20261019+cu132" + + +def _utils_namespace( + fake_cuda: str | None, + platform: str = "linux", + machine: str = "x86_64", + recorded: dict | None = None, +) -> dict: + """Execute the real helpers without importing their torch and TensorRT dependencies. + + ``recorded`` stands in for what the build wrote into _version.py. + """ source = (REPO_ROOT / "py/torch_tensorrt/_utils.py").read_text(encoding="utf-8") tree = ast.parse(source) wanted = { + "_build_record", + "_executorch_index_options", "executorch_install_channel", "executorch_install_command", + "executorch_runtime_install_command", "is_platform_supported_for_executorch", } functions = [ @@ -1027,9 +1174,17 @@ def _load_utils_channel_helpers( 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)}" - ) + f"py/torch_tensorrt/_utils.py must define {sorted(wanted)}; " + f"found {sorted(f.name for f in functions)}" + ) + # Every build records the ExecuTorch channel; nightly is what a development pin records. A + # field given as None is left out, as in a version file written before it existed. + record = { + "__version__": _INSTALLED_VERSION, + "__executorch_channel__": "nightly", + **(recorded or {}), + } + record = {name: value for name, value in record.items() if value is not None} class _Version: cuda = fake_cuda @@ -1055,8 +1210,25 @@ class _Version: ), **constants, } + # The real _build_record imports torch_tensorrt._version. Answer that import with a module + # holding only the recorded fields, so a missing field exercises the helper's own default. + version_module = types.SimpleNamespace(**record) + + def _import(name, *args, **kwargs): + if name == "torch_tensorrt": + return types.SimpleNamespace(_version=version_module) + return builtins.__import__(name, *args, **kwargs) + + namespace["__builtins__"] = {**vars(builtins), "__import__": _import} module = ast.Module(body=functions, type_ignores=[]) exec(compile(module, "", "exec"), namespace) + return namespace + + +def _load_utils_channel_helpers( + fake_cuda: str | None, platform: str = "linux", machine: str = "x86_64" +): + namespace = _utils_namespace(fake_cuda, platform, machine) return ( namespace["executorch_install_channel"], namespace["executorch_install_command"], @@ -1120,69 +1292,137 @@ def test_the_executorch_install_message_names_the_torch_channel() -> None: ) -def _load_runtime_install_command(monkeypatch, recorded, cuda="13.2"): - """Run the real helper against a build that recorded the given companion version.""" - tree = ast.parse((REPO_ROOT / "py/torch_tensorrt/_utils.py").read_text("utf-8")) - function = next( - node - for node in tree.body - if isinstance(node, ast.FunctionDef) - and node.name == "executorch_runtime_install_command" +@pytest.mark.unit +@pytest.mark.parametrize("executorch", ["nightly", "test"]) +def test_the_executorch_install_message_keeps_the_installed_wheel(executorch): + """The extra is requested for the installed version, from the index with its ExecuTorch. + + Without the version pip may answer the extra with a different torch-tensorrt from that + index, for example a nightly in place of a release candidate. + """ + message = _utils_namespace("13.2", recorded={"__executorch_channel__": executorch})[ + "executorch_install_command" + ]() + assert message == ( + f'pip install --pre "torch_tensorrt[executorch]=={_INSTALLED_VERSION}" ' + f"--extra-index-url https://download.pytorch.org/whl/{executorch}/cu132" ) - channel, command = _load_utils_channel_helpers(cuda) - version = types.ModuleType("torch_tensorrt._version") - if recorded is not None: - version.__executorch_runtime_version__ = recorded - package = types.ModuleType("torch_tensorrt") - package._version = version - monkeypatch.setitem(sys.modules, "torch_tensorrt", package) - monkeypatch.setitem(sys.modules, "torch_tensorrt._version", version) - namespace = { - "executorch_install_channel": channel, - "executorch_install_command": command, + + +@pytest.mark.unit +def test_the_extra_advice_follows_the_wheel_s_cuda_channel(): + """The extra may require the delegate built for the wheel's CUDA channel, not torch's.""" + message = _utils_namespace( + "13.4", + recorded={ + "__executorch_runtime_version__": "0.3.0.dev20261019+cu132", + "__executorch_runtime_channel__": "nightly", + }, + )["executorch_install_command"]() + assert message.endswith( + "--extra-index-url https://download.pytorch.org/whl/nightly/cu132" + ), message + + +@pytest.mark.unit +@pytest.mark.parametrize("channel", ["", "pypi"]) +def test_the_executorch_install_message_names_the_recorded_delegate(channel): + """A PyPI wheel pinning a development ExecuTorch leaves the delegate out of its extra. + + So the extra alone takes the newest ExecuTorch. Naming the recorded delegate keeps the exact + ExecuTorch build it pins. A Linux PyPI wheel records pypi, a Windows one no channel. + """ + message = _utils_namespace( + "13.2", + recorded={ + "__executorch_runtime_version__": "0.3.0", + "__executorch_runtime_channel__": channel, + }, + )["executorch_install_command"]() + assert message == ( + f'pip install --pre "torch_tensorrt[executorch]=={_INSTALLED_VERSION}" ' + '"torch-tensorrt-executorch-runtime==0.3.0" ' + + _indexes("cu132", "nightly/cu132") + ) + + +@pytest.mark.unit +def test_an_older_version_file_still_names_the_nightly_index(): + """A source build's version file from before the ExecuTorch record came from a development pin. + + Without a default the advice names a malformed index URL. + """ + message = _utils_namespace("13.2", recorded={"__executorch_channel__": None})[ + "executorch_install_command" + ]() + assert message.endswith( + "--extra-index-url https://download.pytorch.org/whl/nightly/cu132" + ), message + + +def _load_runtime_install_command( + recorded, channel="", executorch="nightly", cuda="13.2" +): + """Run the real helper against a build that recorded the given companion and channels.""" + record = { + "__executorch_runtime_channel__": channel, + "__executorch_channel__": executorch, } - exec(compile(ast.Module([function], []), "", "exec"), namespace) + if recorded is not None: + record["__executorch_runtime_version__"] = recorded + namespace = _utils_namespace(cuda, recorded=record) return namespace["executorch_runtime_install_command"]() +def _indexes(*names: str) -> str: + return " ".join( + f"--extra-index-url https://download.pytorch.org/whl/{name}" for name in names + ) + + @pytest.mark.unit @pytest.mark.parametrize( - "recorded, expected", + "recorded, channel, executorch, indexes", [ - ( - "0.3.0.dev20261019+cu132", - 'pip install "torch-tensorrt-executorch-runtime==0.3.0.dev20261019+cu132" ' - "--extra-index-url https://download.pytorch.org/whl/nightly/cu132", - ), - ( - "0.3.0+cu134", - 'pip install "torch-tensorrt-executorch-runtime==0.3.0+cu134" ' - "--extra-index-url https://download.pytorch.org/whl/cu134", - ), - ( - "0.3.0", - 'pip install "torch-tensorrt-executorch-runtime==0.3.0" ' - "--extra-index-url https://download.pytorch.org/whl/cu132", - ), + # A nightly delegate and the development ExecuTorch it pins share the nightly index. + ("0.3.0.dev20261019+cu132", "nightly", "nightly", _indexes("nightly/cu132")), + # A release candidate's version looks final, but only the test index carries it. A + # development ExecuTorch it pins is only on the nightly index; a release, on the test one. + # The delegate's own label picks the CUDA row. + ("0.3.0+cu134", "test", "nightly", _indexes("test/cu134", "nightly/cu134")), + ("0.3.0+cu134", "test", "test", _indexes("test/cu134")), + # A PyPI release records no label, and on Windows no channel. PyPI holds one build for one + # CUDA version, while the stable index keeps this release's delegate, and the ExecuTorch + # release it pins, for every CUDA version. + ("0.3.0", "", "nightly", _indexes("cu132", "nightly/cu132")), + ("0.3.0", "", "test", _indexes("cu132")), ], ) -def test_a_missing_delegate_names_the_exact_companion(monkeypatch, recorded, expected): - """The advice names the companion this wheel was built with and the index carrying it.""" - assert _load_runtime_install_command(monkeypatch, recorded) == expected +def test_a_missing_delegate_names_the_exact_companion( + recorded, channel, executorch, indexes +): + """The advice names the companion this wheel was built with and the indexes carrying it.""" + assert _load_runtime_install_command(recorded, channel, executorch) == ( + f'pip install "torch-tensorrt-executorch-runtime=={recorded}" {indexes}' + ) @pytest.mark.unit @pytest.mark.parametrize("recorded", ["", None]) -def test_a_build_without_a_companion_says_it_has_none(monkeypatch, recorded): +def test_a_build_without_a_companion_says_it_has_none(recorded): """RTX, Python-only, JetPack and source builds, and a version file from before the field.""" - message = _load_runtime_install_command(monkeypatch, recorded) + message = _load_runtime_install_command(recorded) assert "has no matching delegate" in message, message assert "pip install" not in message, message + # Read from the constant, so a new CUDA major cannot leave this text behind. + major = _utils_namespace("13.2")["EXECUTORCH_CUDA_MAJOR"] + assert f"CUDA {major} wheels for Linux (x86-64 and aarch64)" in message, message + assert "py/torch-tensorrt-executorch-runtime/README.md" in message, message @pytest.mark.unit def test_the_build_records_the_companion_it_requires(tmp_path) -> None: - """The version file carries the same companion the extras require, for the error above.""" + """The version file carries the companion and both channels, for the errors above.""" tree = ast.parse((REPO_ROOT / "setup.py").read_text(encoding="utf-8")) function = next( node @@ -1199,12 +1439,17 @@ def test_the_build_records_the_companion_it_requires(tmp_path) -> None: "__tensorrt_version__": "11.4.0", "__tensorrt_rtx_version__": "1.7.1", "__tensorrt_llm_version__": "0.17.0", - "EXECUTORCH_RUNTIME_VERSION": "0.3.0.dev20261019+cu132", + "EXECUTORCH_RUNTIME_VERSION": "0.3.0+cu132", + # A release candidate pinning a development build: the two channels differ. + "EXECUTORCH_RUNTIME_CHANNEL": "test", + "EXECUTORCH_CHANNEL": "nightly", } exec(compile(ast.Module([function], []), "setup.py", "exec"), namespace) namespace["gen_version_file"]() recorded = runpy.run_path(str(tmp_path / "torch_tensorrt/_version.py")) - assert recorded["__executorch_runtime_version__"] == "0.3.0.dev20261019+cu132" + assert recorded["__executorch_runtime_version__"] == "0.3.0+cu132" + assert recorded["__executorch_runtime_channel__"] == "test" + assert recorded["__executorch_channel__"] == "nightly" @pytest.mark.parametrize("cuda", ["13.0", "13.2", None]) @@ -2991,8 +3236,9 @@ def test_the_install_message_does_not_hand_a_no_op_command_to_other_platforms( @pytest.mark.unit def test_the_install_message_supports_windows_x64(machine: str) -> None: _, command = _load_utils_channel_helpers("13.2", platform="win32", machine=machine) - assert 'pip install --pre "torch_tensorrt[executorch]"' in command() + assert 'pip install --pre "torch_tensorrt[executorch]==' in command() assert "nightly/cu132" in command() + assert "nightly/cpu" not in command() @pytest.mark.parametrize("agree", [True, False]) @@ -3065,14 +3311,36 @@ def test_the_install_script_leaves_the_companion_out(tmp_path, branch): @pytest.mark.unit -def test_every_requirement_file_install_names_the_nightly_channel(): +def test_the_readme_pin_install_names_both_executorch_channels(): + """The README's build-time install names the exact pin, so it needs the index the pin lives on. + + A development build is only on the nightly index and a release only on the test index. Without + the test index, a release pin resolves to PyPI's processor-only build and the delegate build + stops. + """ + path = "py/torch-tensorrt-executorch-runtime/README.md" + pin = f'"executorch=={_versions()["__executorch_version__"]}"' + lines = (REPO_ROOT / path).read_text().splitlines() + numbers = [n for n, line in enumerate(lines, 1) if pin in line] + assert numbers, f"{path} no longer installs {pin}, so this test is not looking" + for number in numbers: + command = _pip_command_around(path, number) + for channel in ("nightly", "test"): + assert ( + f"download.pytorch.org/whl/{channel}/${{CU_VERSION}}" in command + ), f"{path}:{number} installs the pin without the {channel} channel" + + +@pytest.mark.unit +def test_every_requirement_file_install_names_both_executorch_channels(): """A ``-r`` install carries no requirement literal, so the channel scan cannot see it. That scan walks requirement literals inside pip commands, which is how it checks the index. - Passing the pin through a file removes the literal from the command, so without this the - nightly channel could be dropped or pointed at another index and every other guard here - would still pass. ExecuTorch's CUDA wheels exist only on the nightly index, so an install - that names another one resolves to a processor-only build. + Passing the pin through a file removes the literal from the command, so without this a + channel could be dropped or pointed at another index and every other guard here would still + pass. ExecuTorch's CUDA wheels exist only on PyTorch's indexes: a development build on the + nightly one, a release on the test one. An install missing the channel of the current pin + resolves to a processor-only build, and one missing the other breaks when the pin moves. """ wrong = [] for line in _git("grep", "-nI", "-e", "-r packaging/executorch_").splitlines(): @@ -3080,12 +3348,14 @@ def test_every_requirement_file_install_names_the_nightly_channel(): if _is_source_test(path): continue command = _pip_command_around(path, int(number)) - if not re.search( - r"download\.pytorch\.org/whl/nightly/\$\{CU_VERSION\}", command - ): - wrong.append( - f"{path}:{number} installs a pinned requirement file without the nightly channel" - ) + for channel in ("nightly", "test"): + if not re.search( + rf"download\.pytorch\.org/whl/{channel}/\$\{{CU_VERSION\}}", command + ): + wrong.append( + f"{path}:{number} installs a pinned requirement file without the " + f"{channel} channel" + ) assert not wrong, wrong assert _git( "grep", "-lI", "-e", "-r packaging/executorch_" diff --git a/tests/py/dynamo/executorch/test_shared_runtime_workflow.py b/tests/py/dynamo/executorch/test_shared_runtime_workflow.py index d1aae1efa7..8852e0972f 100644 --- a/tests/py/dynamo/executorch/test_shared_runtime_workflow.py +++ b/tests/py/dynamo/executorch/test_shared_runtime_workflow.py @@ -269,6 +269,7 @@ def test_shared_build_provisions_tensorrt_metadata(tmp_path, arch, cuda, release "BUILD_ENV_FILE": str(tmp_path / "build-env"), "BUILD_VERSION": version if release else f"{version}.dev20260911", "CU_VERSION": cuda, + "CHANNEL": "nightly", "ARCH": arch, "EVENTS": str(tmp_path / "events"), "BUILT_VERSION": str(tmp_path / "built-version"), @@ -369,6 +370,74 @@ def test_missing_tensorrt_provisioning_is_detected(tmp_path, monkeypatch, arch): test_shared_build_provisions_tensorrt_metadata(tmp_path, arch, "cu132", True) +def _device_extra_check() -> str: + script = _workflow("executorch-test-linux.yml")["jobs"]["test"]["with"]["script"] + start = script.index("python - <<'PY'\n") + len("python - <<'PY'\n") + return script[start : script.index("\nPY\n", start)] + + +@pytest.mark.parametrize( + "channel, executorch, pin, passes", + [ + # A development ExecuTorch build is only on the nightly index. + ("nightly", "1.6.0.dev20261014", "built", True), + ("nightly", "1.6.0.dev20261014", "other", False), + ("nightly", "1.6.0.dev20261014", None, False), + ("test", "1.6.0.dev20261014", None, True), + ("test", "1.6.0.dev20261014", "built", False), + # An ExecuTorch release is on the test index. + ("test", "1.6.0", "built", True), + ("test", "1.6.0", "other", False), + ("test", "1.6.0", None, False), + ("nightly", "1.6.0", None, True), + ("nightly", "1.6.0", "built", False), + ], +) +def test_the_device_job_checks_the_published_extra( + tmp_path, channel, executorch, pin, passes +): + """Run the device job's metadata check against installed wheels with known extras. + + A wheel must require exactly the companion built beside it when its own index also carries + the ExecuTorch build that companion pins. Anywhere else pip could not meet that requirement, + and would quietly pick another wheel, so the wheel must not name it. + """ + suffix = ".dev20261019" if channel == "nightly" else "" + companion = f"0.3.0{suffix}+cu132" + pins = {"built": companion, "other": f"0.3.0{suffix}+cu134"} + requires = [f"executorch>={executorch},<1.7; extra == 'executorch'"] + if pin: + requires.append( + f"torch-tensorrt-executorch-runtime=={pins[pin]}; extra == 'executorch'" + ) + for name, version, deps in ( + ("torch_tensorrt", f"2.16.0{suffix}+cu132", requires), + ( + "torch_tensorrt_executorch_runtime", + companion, + ["torch", f"executorch=={executorch}+cu132"], + ), + ): + info = tmp_path / f"{name}-{version}.dist-info" + info.mkdir() + (info / "METADATA").write_text( + f"Metadata-Version: 2.1\nName: {name.replace('_', '-')}\nVersion: {version}\n" + + "".join(f"Requires-Dist: {r}\n" for r in deps) + ) + result = subprocess.run( + [sys.executable, "-"], + input=_device_extra_check(), + env={**os.environ, "PYTHONPATH": str(tmp_path), "CHANNEL": channel}, + # "python -" puts the working directory first on sys.path, where a built checkout's own + # egg-info would shadow the fake metadata. + cwd=tmp_path, + text=True, + capture_output=True, + timeout=30, + ) + assert (result.returncode == 0) == passes, result.stderr + + _DEVICE_EXPORT = 'python examples/torchtrt_executorch_example/export_device_resident.py \\\n --model_path="${RUNNER_TEMP}/torchtrt-device-resident.pte"\n' _DEVICE_RUN = 'python examples/executorch_reference_runner/load_model_device_resident.py \\\n --model_path="${RUNNER_TEMP}/torchtrt-device-resident.pte" --num_runs=2\n' @@ -479,6 +548,12 @@ def _assert_device_commands(tmp_path, workflow, failure=""): resolve = [e for e in events if "--dry-run" in e] assert len(resolve) == 1, resolve assert resolve[0][-1] == f"torch-tensorrt[executorch]=={runner}", resolve + # The companion pins a development ExecuTorch from the nightly index or a release from the + # test one, so the resolve needs both to meet the extra on either kind of branch. + for channel in ("nightly", "test"): + assert ( + f"https://download.pytorch.org/whl/{channel}/cu132" in resolve[0] + ), resolve examples = [ event[1:] for event in events