diff --git a/.github/scripts/install-torch-tensorrt.sh b/.github/scripts/install-torch-tensorrt.sh index 306a5c1768..06f59e0680 100755 --- a/.github/scripts/install-torch-tensorrt.sh +++ b/.github/scripts/install-torch-tensorrt.sh @@ -62,9 +62,24 @@ 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 - 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. + # 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. + # || 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}" || exit 1 fi echo -e "Running test script"; diff --git a/.github/scripts/update_executorch_pin.py b/.github/scripts/update_executorch_pin.py new file mode 100644 index 0000000000..ba82220f6c --- /dev/null +++ b/.github/scripts/update_executorch_pin.py @@ -0,0 +1,332 @@ +#!/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", + ".github/workflows/release-linux-x86_64.yml", + ".github/workflows/release-linux-aarch64.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) + # 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