diff --git a/.ci/scripts/wheel/cuda_arch_list.sh b/.ci/scripts/wheel/cuda_arch_list.sh new file mode 100644 index 00000000000..092e08c4e92 --- /dev/null +++ b/.ci/scripts/wheel/cuda_arch_list.sh @@ -0,0 +1,126 @@ +#!/usr/bin/env bash +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +# GPU architectures to compile device code for, chosen per release row rather than detected from +# the build machine. +# +# Without this the build compiles for whichever GPU the builder happens to have. The wheel then +# installs on every machine the row claims and fails when a model runs on a different generation, +# with an error that looks like a model problem rather than a packaging one. Detection is the right +# default for a local build and the wrong one for a published artifact. +# +# The value is published as TORCH_CUDA_ARCH_LIST rather than CMAKE_CUDA_ARCHITECTURES, because +# PyTorch's CMake rejects the latter and overrides it, so setting only that reduces the build to a +# single detected architecture. + +# The architectures each row serves. Two rules decide the list, and they pull in opposite directions. +# +# The upper end follows the published PyTorch build for that train, read from its own library rather than +# chosen by reasoning about which GPUs matter. A delegate is only useful where torch already runs, and an +# architecture torch supports but this wheel omits produces a wheel that installs and then fails at the +# first kernel launch. Two omissions found that way were the GPU on the runner that tests these wheels, +# and a common desktop card. +# +# The lower end does NOT follow torch. It stops at 8.0 even though torch reaches further down, because one +# source here compiles an integer matrix-multiply path only at 8.0 and above. Below that a user gets a +# delegate that loads, runs most models, and fails on one needing that operator, which is worse than a row +# that never claimed the device. So these lists are narrower than torch at the bottom on purpose. +_cuda_arch_x86_64_cu130="8.0 8.6 8.9 9.0 10.0 12.0" +_cuda_arch_x86_64_cu132="${_cuda_arch_x86_64_cu130}" + +# The architectures the published aarch64 PyTorch CUDA build covers, read from its own library on an ARM +# machine, for the same reason as the x86_64 rows above. Includes the ARM module whose train matches. +_cuda_arch_aarch64_cu130="8.0 9.0 10.0 11.0 12.0" +_cuda_arch_aarch64_cu132="${_cuda_arch_aarch64_cu130}" + +# The older CUDA train. +# +# The two architectures do not carry identical lists, because each covers what the published PyTorch +# build for that architecture covers, and those differ. Matching them to each other instead would mean +# advertising a GPU on one architecture that PyTorch cannot serve there. +# +# The smaller embedded modules are deliberately absent. An embedded-only architecture in a generic +# wheel would advertise a device the row cannot otherwise serve, since those devices also need the +# CUDA, TensorRT and PyTorch pinned by their own software release rather than the ones a generic +# wheel resolves. +# +# The floor is 8.0 rather than the oldest architecture PyTorch still carries. One of these sources compiles +# an integer matrix-multiply path only at 8.0 and newer, so an older architecture would get a delegate that +# loads, runs most models, and fails on one that needs that operator. Claiming hardware the delegate only +# partly serves is the same problem the embedded modules have, so the row leaves it out for the same reason. +_cuda_arch_x86_64_cu126="8.0 8.6 8.9 9.0" +_cuda_arch_aarch64_cu126="8.0 9.0" # what the aarch64 build of this train covers + +# A CUDA train with no architecture list would leave the build detecting the builder's GPU, which is +# the failure this file exists to prevent. Adding a train to the release matrix without adding its +# architectures should fail loudly rather than silently produce a single-GPU wheel. +_executorch_unknown_train() { + echo "cuda_arch_list.sh: no GPU architecture list for CUDA train '$1' on $(uname -m)." >&2 + echo "Add one before building this row, or the wheel ships device code for one GPU only." >&2 + return 64 +} + +# The architectures for the current row, space separated in the dotted form PyTorch expects. +executorch_cuda_arch_list() { + local machine + machine="$(uname -m)" + # The wheel build exports the row's CUDA train as CU_VERSION. DESIRED_CUDA is the name of the + # matrix field rather than of the variable, so reading only that leaves every row falling back to + # detecting the builder's GPU. + local train="${CU_VERSION:-${DESIRED_CUDA:-}}" + # A CPU row names no CUDA train and needs no architectures, so it is not an error. + # + # A CUDA row always names one, so an empty value there means the row lost it. Treating that as a CPU + # row let the build fall back to detecting the builder's GPU, which produces a wheel carrying device + # code for whatever machine happened to build it while every check still reports green. + case "${train}" in + "" | cpu | CPU | none | NONE) + if [ "${EXECUTORCH_BUILD_CUDA:-}" = "1" ]; then + echo "this is a CUDA build but the row's CUDA version is '${train}', which names no CUDA" >&2 + echo "train. Refusing to detect the builder GPU instead." >&2 + return 65 + fi + return 0 + ;; + esac + # The value arrives as cu130, while some callers pass 13.0 instead. + train="${train#cu}" + train="${train//./}" + + case "${machine}" in + aarch64 | arm64) + case "${train}" in + 126) printf '%s' "${_cuda_arch_aarch64_cu126}" ;; + 130) printf '%s' "${_cuda_arch_aarch64_cu130}" ;; + 132) printf '%s' "${_cuda_arch_aarch64_cu132}" ;; + *) _executorch_unknown_train "${train}" ;; + esac + ;; + x86_64) + case "${train}" in + 126) printf '%s' "${_cuda_arch_x86_64_cu126}" ;; + 130) printf '%s' "${_cuda_arch_x86_64_cu130}" ;; + 132) printf '%s' "${_cuda_arch_x86_64_cu132}" ;; + *) _executorch_unknown_train "${train}" ;; + esac + ;; + *) _executorch_unknown_train "${train}" ;; + esac +} + +# The same list with a portable form appended for the newest architecture, so a GPU newer than any +# in the row can still run the wheel by compiling that form at load time. Without it a newer GPU +# gets no usable code at all. +executorch_cuda_arch_list_with_ptx() { + local dotted top + # Propagate a failed lookup rather than reporting an empty list, since a caller cannot tell an + # unknown row from a CPU row and the unknown one must not pass silently. + dotted="$(executorch_cuda_arch_list)" || return $? + [ -n "${dotted}" ] || return 0 + top="${dotted##* }" + printf '%s %s+PTX' "${dotted}" "${top}" +} diff --git a/.ci/scripts/wheel/envvar_cuda_linux.sh b/.ci/scripts/wheel/envvar_cuda_linux.sh new file mode 100644 index 00000000000..d66ae3f2d22 --- /dev/null +++ b/.ci/scripts/wheel/envvar_cuda_linux.sh @@ -0,0 +1,42 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +# This file is sourced into the environment before building a pip wheel. It +# should typically only contain shell variable assignments. Be sure to export +# any variables so that subprocesses will see them. + +source "${GITHUB_WORKSPACE}/${REPOSITORY}/.ci/scripts/wheel/envvar_base.sh" + +# Ask for the CUDA delegate explicitly rather than letting the build detect a toolkit. A detected +# build is fine locally, but a release row states what it is producing, and a row that silently +# produced a CPU wheel because the toolkit was missing would publish under a CUDA name. +export EXECUTORCH_BUILD_CUDA=1 +export CMAKE_ARGS="${CMAKE_ARGS} -DEXECUTORCH_BUILD_CUDA=ON" + +# Fail the build if CUDA is not actually present. Without this the packaging step would look for +# CUDA libraries that were never built and report a confusing missing-file error several minutes +# after the real problem. +if [ ! -x "${CUDA_HOME:-/usr/local/cuda}/bin/nvcc" ]; then + echo "EXECUTORCH_BUILD_CUDA is set but no nvcc was found. This row cannot build a CUDA wheel." >&2 + exit 1 +fi + +# Compile device code for the GPUs this release row claims, rather than for whichever GPU the +# builder happens to have. A wheel built by detection alone installs on every machine the row covers +# and then fails when a model runs on a different generation. +source "${GITHUB_WORKSPACE}/${REPOSITORY}/.ci/scripts/wheel/cuda_arch_list.sh" +# The status is checked rather than only the output. An unrecognised row makes the lookup fail, and +# this file is sourced rather than run under a failing-command shell, so ignoring the status would +# leave the variable unset and let the build fall back to detecting the builder's own GPU. That is +# exactly the outcome this is meant to prevent, and it would ship quietly. +if ! _executorch_cuda_arch="$(executorch_cuda_arch_list_with_ptx)"; then + echo "could not resolve GPU architectures for CU_VERSION=${CU_VERSION:-unset}" >&2 + exit 1 +fi +if [ -n "${_executorch_cuda_arch}" ]; then + export TORCH_CUDA_ARCH_LIST="${_executorch_cuda_arch}" + echo "building device code for: ${TORCH_CUDA_ARCH_LIST}" +fi diff --git a/.ci/scripts/wheel/test_cuda_linux.py b/.ci/scripts/wheel/test_cuda_linux.py new file mode 100644 index 00000000000..671eba2d56c --- /dev/null +++ b/.ci/scripts/wheel/test_cuda_linux.py @@ -0,0 +1,260 @@ +#!/usr/bin/env python +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +"""Smoke test for a CUDA wheel row. + +Runs the checks a GPU wheel needs, then the packaging and C++ SDK checks a CPU wheel gets. +It does not repeat the CPU row's backend-specific checks, because a CUDA row is not built +with those backends. The extra checks exist because a GPU wheel can install cleanly, import +cleanly, and still be unusable: + + the CUDA libraries can be absent while the wheel is still named as a CUDA build + the runtime dependency can be undeclared, so a user has nothing to resolve it from + the loader path can point at the build machine's toolkit, which no user has + the device code can cover no GPU the row claims, which only appears when a model runs + +The build machines for these rows have no GPU, so this does not execute a model. It verifies +everything that can be checked from the artifact, and the release gate runs a model on real +hardware. +""" + +import os +import pathlib +import platform +import subprocess +import tempfile +from pathlib import Path + +import test_base +import test_cpp_sdk +import test_shared_libraries +from examples.models import Backend, Model + + +def _package_dir() -> Path: + import executorch + + return Path(executorch.__path__[0]) + + +def test_cuda_libraries_are_shipped() -> None: + """The row is named for CUDA, so the CUDA libraries have to be in it.""" + lib_dir = _package_dir() / "lib" + shipped = {path.name for path in lib_dir.iterdir()} if lib_dir.is_dir() else set() + expected = { + "libexecutorch_backend_cuda.so", + "libexecutorch_extension_cuda.so", + } + missing = sorted(expected - shipped) + assert not missing, ( + f"this is a CUDA row but {missing} are not in the wheel, so it would install as a " + f"CUDA build with no CUDA delegate. Shipped: {sorted(shipped)}" + ) + print(f"✓ the CUDA libraries ship ({len(expected)} of them)") + + +def test_cuda_runtime_is_declared() -> None: + """The wheel links the CUDA runtime without bundling it, so it must declare it. + + Without this a user installs the wheel and has nothing to resolve libcudart from, which + surfaces as a loader error at the first import rather than as a resolution failure at + install time. + """ + import importlib.metadata as metadata + + requirements = metadata.requires("executorch") or [] + cuda = [ + requirement + for requirement in requirements + if "nvidia" in requirement.lower() or "cuda" in requirement.lower() + ] + assert cuda, ( + "this is a CUDA row but the wheel declares no CUDA runtime dependency, so nothing " + "would install the libraries its delegate links" + ) + print(f"✓ the CUDA runtime is declared ({len(cuda)} requirements)") + + +def test_cuda_libraries_resolve_relatively() -> None: + """Each CUDA library must reach its runtime through a relative path. + + An absolute toolkit path names the machine that built the wheel. It resolves there and + nowhere else, so the wheel would work only on a builder. + + Every shipped library that links the CUDA runtime is inspected, wherever it lives. Naming + only the two in lib/ skipped libaoti_cuda_shims.so, which sits under backends/cuda/, links + cudart and curand, and carries the device code, so an absolute toolkit path on the library + that matters most shipped green. + """ + readelf = test_shared_libraries._tool("readelf") + assert readelf is not None, "readelf is required to inspect the wheel" + + package_dir = _package_dir() + libraries = sorted(test_shared_libraries._shipped_shared_objects(package_dir)) + # Without this the loop below finds nothing on a wheel that ships no CUDA library and + # reports a pass, which is the same as having no check at all. + assert libraries, f"no shared libraries found under {package_dir}" + + linked_to_cuda = [] + for library in libraries: + output = subprocess.run( + [readelf, "-d", str(library)], capture_output=True, text=True, check=True + ).stdout + if any("NEEDED" in line and "libcud" in line for line in output.splitlines()): + linked_to_cuda.append((library, output)) + + assert linked_to_cuda, ( + "no shipped library links the CUDA runtime, so this check inspected nothing. A CUDA " + "row must ship the libraries it is named for." + ) + for library, output in linked_to_cuda: + name = library.relative_to(package_dir) + entries: list[str] = [] + for line in output.splitlines(): + if "RPATH" in line or "RUNPATH" in line: + entries += line.split("[", 1)[1].rstrip("]").strip().split(":") + relative = [ + entry + for entry in entries + if entry.startswith("$ORIGIN") and "nvidia" in entry + ] + assert relative, ( + f"{name} links the CUDA runtime but has no relative path to the CUDA wheels " + f"installed beside it, so it can only resolve where the builder had a toolkit: " + f"{entries}" + ) + print(f"✓ {name} resolves the CUDA runtime relatively ({relative[0]})") + + +def _row_architectures() -> list[str]: + """The architectures this row claims, from the same script the build uses. + + A refusal from that script is a fault, not an absence. It returns non-zero when a CUDA row reaches it + with no version, which is precisely the case that would otherwise build device code for whatever GPU the + builder happens to have, so swallowing it here would hide the one failure this check exists to catch. + + EXECUTORCH_BUILD_CUDA is passed through because that is how the build invokes the script, and the + refusal is conditional on it. Without it the script returned an empty list on a CUDA row that had lost + its version, this check reported nothing to do, and the assertion below could never fire. + """ + script = pathlib.Path(__file__).parent / "cuda_arch_list.sh" + assert script.is_file(), f"the architecture script is missing at {script}" + result = subprocess.run( + ["bash", "-c", f"source {script}; executorch_cuda_arch_list"], + capture_output=True, + text=True, + check=False, + env={**os.environ, "EXECUTORCH_BUILD_CUDA": "1"}, + ) + assert result.returncode == 0, ( + f"the architecture script refused this row with exit {result.returncode}, so the build had no list " + f"to compile against: {result.stderr.strip()[:300]}" + ) + # "8.0 9.0" describes sm_80 and sm_90. + return ["sm_" + value.replace(".", "") for value in result.stdout.split()] + + +def test_device_code_covers_the_row() -> None: + """Every GPU the row claims must have device code in the shipped libraries. + + A row that promises a GPU it did not compile for produces a wheel that installs and then dies + at the first kernel launch, which is the worst failure to publish. + """ + expected = _row_architectures() + if not expected: + print("- this row claims no GPU architectures, nothing to check") + return + + cuobjdump = test_shared_libraries._tool("cuobjdump") + if cuobjdump is None: + raise AssertionError( + "cuobjdump is required to check device code, and this is a CUDA row. Without it a " + "wheel missing code for a claimed GPU would ship unnoticed." + ) + + # Searched across every shipped library rather than a named one. The kernels are compiled + # into their own library, not into the delegate, and which library holds them is an internal + # detail. What the row promises is that the wheel covers those GPUs. + present: set[str] = set() + inspected = [] + for library in sorted(_package_dir().rglob("*.so")): + listed = subprocess.run( + [cuobjdump, "--list-elf", str(library)], + capture_output=True, + text=True, + check=False, + ).stdout + found = { + token + for token in listed.replace(".", " ").split() + if token.startswith("sm_") + } + if found: + inspected.append(f"{library.name} ({', '.join(sorted(found))})") + present |= found + + assert inspected, ( + "no shipped library contains any GPU device code, so this wheel cannot run a model on any " + f"GPU, while the row claims {expected}" + ) + missing = sorted(set(expected) - present) + assert not missing, ( + f"the row claims {expected} but the wheel carries no device code for {missing}. " + f"Found: {inspected}. A user with one of those GPUs would install this wheel and fail at " + "the first kernel launch." + ) + print(f"✓ device code covers the row: {inspected}") + + +def test_the_delegate_registers() -> None: + """The delegate has to appear in the runtime's backend list, not merely be present as a file. + + Registration happens in a static initializer, which a normal link discards because nothing in the + program references it. Keeping it alive needs a linker option, and a wheel whose delegate ships but + does not register would load a delegated program and fail with an unregistered backend. That is the + failure this whole layout is most able to introduce, so it is worth asserting rather than assuming. + + Needs no GPU: registration is a link-time property, checked by importing. + """ + from executorch.extension.pybindings.portable_lib import ( + _get_registered_backend_names, + ) + + registered = _get_registered_backend_names() + assert "CudaBackend" in registered, ( + f"the wheel ships the CUDA delegate but CudaBackend is not registered: {registered}. " + "The library is present and its static initializer did not run, which means the option " + "that keeps it on the link line stopped working." + ) + print(f"✓ the delegate registers: CudaBackend among {len(registered)} backend(s)") + + +if __name__ == "__main__": + assert platform.system() == "Linux", "the CUDA rows are Linux only" + + test_cuda_libraries_are_shipped() + test_cuda_runtime_is_declared() + test_cuda_libraries_resolve_relatively() + test_device_code_covers_the_row() + test_the_delegate_registers() + + # The packaging and linking checks a CPU wheel is held to still apply: one owner per + # component, no build-tree paths, and a C++ application able to link what the wheel + # ships. The CPU row's backend checks do not, because a CUDA row is not built with them. + with tempfile.TemporaryDirectory() as work_dir: + test_shared_libraries.run_tests(Path(work_dir)) + with tempfile.TemporaryDirectory() as work_dir: + test_cpp_sdk.run_tests(Path(work_dir)) + + test_base.run_tests( + model_tests=[ + test_base.ModelTest( + model=Model.Mv3, + backend=Backend.XnnpackQuantizationDelegation, + ), + ] + ) diff --git a/.ci/scripts/wheel/test_shared_libraries.py b/.ci/scripts/wheel/test_shared_libraries.py index b618e93e175..e364da90e6b 100644 --- a/.ci/scripts/wheel/test_shared_libraries.py +++ b/.ci/scripts/wheel/test_shared_libraries.py @@ -1552,17 +1552,29 @@ def test_model_matches_eager_pytorch(work_dir: Path) -> None: def test_declared_dependencies_match_the_wheel_tag() -> None: - """A CPU wheel must not declare the CUDA runtime, and a CUDA wheel must declare it. + """A CPU wheel must not declare the CUDA runtime, and a CUDA wheel must declare its own train. The tag is what a user resolves against, so a mismatch is a promise the wheel cannot keep in either direction: a CPU wheel that pulls the CUDA packages costs a user hundreds of megabytes it never loads, and a CUDA wheel that declares nothing leaves the runtime unresolvable. + Declaring the wrong train is the quiet case, and the reason this checks the names rather than + only their presence. The CUDA 12 packages are published with a "-cu12" suffix and the CUDA 13 + ones without, so a cu130 wheel that asked for the cu12 packages would install a runtime its + libraries cannot load, while looking correctly specified. + This is metadata only, so no library check can see it. A CPU wheel that wrongly declared the CUDA runtime passed every other check in this file. """ requirements = importlib.metadata.requires("executorch") or [] - cuda = sorted(r.split()[0] for r in requirements if r.lower().startswith("nvidia")) + # Split off any environment marker first. The name and the marker are one token when no + # space separates them, so a nvidia dependency that is conditional on the platform would + # otherwise be compared against the marker text rather than against its own name. + cuda = sorted( + name + for name in (r.split(";")[0].split()[0] for r in requirements) + if name.lower().startswith("nvidia") + ) # The local version segment of the installed version states what the wheel was built for. version = importlib.metadata.version("executorch") @@ -1574,7 +1586,24 @@ def test_declared_dependencies_match_the_wheel_tag() -> None: f"version {version} says this is a CUDA wheel, but it declares no CUDA runtime " "packages, so nothing resolves the runtime it links" ) - print(f"✓ this CUDA wheel declares the runtime ({len(cuda)} packages)") + # "cu130" names CUDA 13. The packaging code keys its dependency names off that + # major version, so the tag decides which names are correct here: the CUDA 12 + # packages carry a "-cu12" suffix and the CUDA 13 ones carry none. + train = local[len("cu") : len("cu") + 2] + expected_suffix = f"-cu{train}" if train == "12" else "" + wrong = sorted( + name + for name in cuda + if not name.endswith(expected_suffix) + or (not expected_suffix and name.endswith(("-cu12", "-cu13"))) + ) + assert not wrong, ( + f"version {version} is a CUDA {train} wheel, but it declares {wrong}, which belong to " + f"another CUDA train. A user would install a runtime this wheel's libraries cannot load." + ) + print( + f"✓ this CUDA {train} wheel declares its own runtime ({len(cuda)} packages)" + ) else: assert not cuda, ( f"version {version} is not a CUDA wheel, yet it declares {cuda}. A user installing it " diff --git a/.github/scripts/filter_cuda_matrix.py b/.github/scripts/filter_cuda_matrix.py new file mode 100644 index 00000000000..28ba36ce0da --- /dev/null +++ b/.github/scripts/filter_cuda_matrix.py @@ -0,0 +1,214 @@ +#!/usr/bin/env python3 +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +"""Narrow the generated build matrix to the rows a GPU wheel can honestly support. + +The shared matrix generator emits every CUDA version and Python version it knows about. +Building all of them would publish wheels for combinations nothing can verify, and a GPU +wheel that installs and then cannot run is worse than one that does not exist: the failure +appears when a model runs, and it looks like a model problem rather than a packaging one. + +A row is kept only when both of these hold: + + a GPU exists that the row's device code covers + a PyTorch build is published for that CUDA version and architecture + +Running a real model before release is a separate gate, on hardware that has the matching +GPU, so a row can be published for a CUDA version no machine here can execute. + +The values below are the current answers to those questions. They are written out rather +than derived because each one is an external fact that can change independently. +""" + +import argparse +import json +import sys +from typing import Any, Dict, List + +# Python versions to skip. 3.14 is excluded because the current CPU wheel rows already fail +# on it for an unrelated reason in the example requirements, so a GPU row would inherit a +# known-broken build. The free-threaded builds are excluded because the CUDA dependencies +# are not published for them. +DISABLED_PYTHON_VERSIONS: List[str] = ["3.13t", "3.14", "3.14t", "3.15", "3.15t"] + +# CUDA versions to publish. +# +# Chosen so that every consumer row can find a matching wheel rather than by what is +# convenient to verify. A delegate built against one of these has to be able to depend on an +# ExecuTorch wheel for the same CUDA version, and a missing version means that consumer has +# nothing to depend on: +# +# cu126 the floor, and what Jetson devices are limited to +# cu130 the generator's stable choice, and the default for accelerator consumers +# cu132 the newest, which consumers building against a current TensorRT need +# +# cu132 is included even though no machine here can execute it, because omitting it would +# leave a published consumer row with no ExecuTorch wheel to pair with. The packaging +# properties are checked on every row; executing a model is a release-gate step on hardware +# that has the matching GPU. +SUPPORTED_CUDA_VERSIONS: List[str] = ["cu126", "cu130", "cu132"] + +# Python versions to publish, stated rather than derived for the same reason the CUDA +# versions are. Deriving them from the rows that survived the filter made the release +# guard below unable to notice a python that disappeared from every supported train: with +# nothing left to compare, a release quietly published nine wheels instead of twelve. +# Keep in step with the python-versions list in the CUDA wheel workflows. +SUPPORTED_PYTHON_VERSIONS: List[str] = ["3.10", "3.11", "3.12", "3.13"] + +# The single row built for a pull request. A full matrix on every push would cost hours for +# little signal, and this pair is the one with a machine that can run a model on it. +PR_PYTHON_VERSION: str = "3.12" +PR_CUDA_VERSION: str = "cu130" + +# Jetson devices are their own row: a JetPack image, one Python version, and one CUDA +# version. They cannot take a generic aarch64 wheel, because the generic builds carry no +# device code for their GPU architecture and no portable fallback either. +# +# Kept empty on purpose. Published PyTorch stopped shipping sm_87 device code after 2.8.0, +# so a Jetson row today would produce a wheel whose PyTorch dependency cannot execute on the +# device. Populate this when that changes. +JETPACK_PYTHON_VERSIONS: List[str] = [] +JETPACK_CUDA_VERSIONS: List[str] = [] +JETPACK_CONTAINER_IMAGE: str = "nvcr.io/nvidia/l4t-jetpack:r36.4.0" + + +def keep(item: Dict[str, Any], is_jetpack: bool) -> bool: + """Whether this row should be built, adjusting its container image where needed.""" + if item["python_version"] in DISABLED_PYTHON_VERSIONS: + return False + + if is_jetpack: + if ( + item["python_version"] in JETPACK_PYTHON_VERSIONS + and item["desired_cuda"] in JETPACK_CUDA_VERSIONS + ): + item["container_image"] = JETPACK_CONTAINER_IMAGE + return True + return False + + if item["desired_cuda"] not in SUPPORTED_CUDA_VERSIONS: + return False + + return True + + +def _version_rank(cuda: str) -> int: + """Where a CUDA version sits in the supported list, or -1 when it is not supported at all.""" + try: + return SUPPORTED_CUDA_VERSIONS.index(cuda) + except ValueError: + return -1 + + +def only_pull_request_row(items: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """One representative row, so a pull request does not build the whole matrix. + + Chosen by preference rather than by exact match. An exact request degrades quietly when the + generator does not offer that combination: asking for a python version it did not emit once left + a pull request building the OLDEST CUDA version instead of the newest, which still passed and + tested the wrong thing. + """ + if not items: + return [] + + # Looked up once, and tolerantly: a PR_CUDA_VERSION that falls off SUPPORTED_CUDA_VERSIONS used to + # raise here and break every pull request while releases kept working, which is the wrong way round + # for a constant that only chooses which single row to build. + wanted = _version_rank(PR_CUDA_VERSION) + + def rank(item: Dict[str, Any]) -> tuple: + # Closeness peaks at the requested version, then falls off, and it outranks the python match. + # Ranking python first picked a wheel for a CUDA version nothing on hand can execute whenever the + # generator skewed the two axes, and the point of building one row is to get signal from it. + offered = _version_rank(item["desired_cuda"]) + # Negative above the requested version, so a newer one never outranks an older one a machine here + # can actually run. + closeness = offered if offered <= wanted else wanted - offered + return (closeness, item["python_version"] == PR_PYTHON_VERSION) + + return [max(items, key=rank)] + + +def main(argv: List[str]) -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--matrix", required=True, help="the generated matrix, as JSON") + parser.add_argument( + "--jetpack", default="false", help="build the Jetson row instead" + ) + parser.add_argument("--limit-pr-builds", default="false", help="build one row only") + args = parser.parse_args(argv) + + try: + matrix = json.loads(args.matrix) + except json.JSONDecodeError as error: + print(f"could not parse the matrix: {error}", file=sys.stderr) + sys.exit(1) + + is_jetpack = args.jetpack.lower() == "true" + items = [item for item in matrix.get("include", []) if keep(item, is_jetpack)] + + if args.limit_pr_builds.lower() == "true" and items: + items = only_pull_request_row(items) + elif items and not is_jetpack: + # A release has to publish every combination this policy advertises. Comparing the result against + # what the generator offered cannot catch anything, because both sides apply the same conditions, so + # the difference is empty by construction and the check never fires. The policy's own list is the + # thing to compare against: a CUDA version the generator stopped offering otherwise disappears from + # the release silently, and a missing job is a green check for a wheel that was never built. + # + # The generic rows only. A JetPack release advertises the single pair its own lists name rather than + # every supported CUDA version, so checking it against this list would fail a correct release. + # + # Both axes come from this policy's own lists, not from the matrix. Reading the generator's python + # axis pulled in rows this policy never builds, and deriving it from the rows that survived went + # blind to a python that disappeared from every supported train. The generator lives in another + # repository and its axes move independently of what this policy promises to publish. + built = {(item["python_version"], item["desired_cuda"]) for item in items} + # A train that produced no row at all is missing for every python, so reporting it per python + # would read as a python problem. Named on its own instead, and first, because the per-pair + # report below would otherwise bury it. + absent_trains = sorted( + set(SUPPORTED_CUDA_VERSIONS) - {cuda for _, cuda in built} + ) + if absent_trains: + print( + f"this policy publishes {SUPPORTED_CUDA_VERSIONS}, but the generator offered no row " + f"this filter could keep for {absent_trains}, so a release would publish no wheel for " + "that CUDA version at all", + file=sys.stderr, + ) + sys.exit(1) + missing = sorted( + f"{python}/{cuda}" + for python in SUPPORTED_PYTHON_VERSIONS + for cuda in SUPPORTED_CUDA_VERSIONS + if (python, cuda) not in built + ) + if missing: + print( + f"this policy publishes {SUPPORTED_CUDA_VERSIONS} for each of " + f"{SUPPORTED_PYTHON_VERSIONS}, but {len(missing)} combination(s) produced no row, so a " + f"release would publish no wheel for them: {missing}", + file=sys.stderr, + ) + sys.exit(1) + + # Fail loudly on an empty result. A silently empty matrix produces a workflow with no + # build job, which shows up as a green check for a build that never happened. + if not items: + print( + "the filter produced no rows to build, so nothing would be verified. " + f"jetpack={is_jetpack}, supported CUDA={SUPPORTED_CUDA_VERSIONS}", + file=sys.stderr, + ) + sys.exit(1) + + print(json.dumps({"include": items})) + + +if __name__ == "__main__": + main(sys.argv[1:]) diff --git a/.github/workflows/build-wheels-cuda-aarch64-linux.yml b/.github/workflows/build-wheels-cuda-aarch64-linux.yml new file mode 100644 index 00000000000..f054035adc1 --- /dev/null +++ b/.github/workflows/build-wheels-cuda-aarch64-linux.yml @@ -0,0 +1,99 @@ +# From https://github.com/pytorch/test-infra/wiki/Using-Nova-Reusable-Build-Workflows +name: Build Aarch64 Linux CUDA Wheels + +on: + pull_request: + paths: + - .ci/**/* + - .github/scripts/filter_cuda_matrix.py + - .github/workflows/build-wheels-cuda-aarch64-linux.yml + - '**/CMakeLists.txt' + - backends/cuda/**/* + - examples/**/* + - extension/cuda/**/* + - install_requirements.py + - pyproject.toml + - setup.py + - tools/cmake/**/* + push: + branches: + - nightly + - release/* + tags: + # NOTE: Binary build pipelines should only get triggered on release candidate builds + # Release candidate tags look like: v1.11.0-rc1 + - v[0-9]+.[0-9]+.[0-9]+-rc[0-9]+ + - ciflow/binaries/* + workflow_dispatch: + +jobs: + generate-matrix: + uses: pytorch/test-infra/.github/workflows/generate_binary_build_matrix.yml@main + with: + package-type: wheel + os: linux-aarch64 + test-infra-repository: pytorch/test-infra + test-infra-ref: main + with-cuda: enable + with-cpu: disable + with-rocm: disable + python-versions: '["3.10", "3.11", "3.12", "3.13"]' + + # The generator emits every CUDA version it knows about. Publishing all of them would ship + # wheels for combinations nothing can verify, so this keeps only the rows with a GPU to run + # them on. The script fails rather than emitting an empty matrix, because a workflow with no + # build job reads as a pass. + filter-matrix: + needs: generate-matrix + runs-on: ubuntu-latest + outputs: + matrix: ${{ steps.filter.outputs.matrix }} + steps: + - uses: actions/setup-python@v6 + with: + python-version: '3.12' + - uses: actions/checkout@v4 + - name: Filter the matrix + id: filter + run: | + set -eou pipefail + MATRIX_BLOB=${{ toJSON(needs.generate-matrix.outputs.matrix) }} + LIMIT_PR=${{ github.event_name == 'pull_request' && 'true' || 'false' }} + MATRIX_BLOB="$(python3 .github/scripts/filter_cuda_matrix.py \ + --matrix "${MATRIX_BLOB}" --limit-pr-builds "${LIMIT_PR}")" + echo "${MATRIX_BLOB}" + echo "matrix=${MATRIX_BLOB}" >> "${GITHUB_OUTPUT}" + + build: + needs: filter-matrix + permissions: + id-token: write + contents: read + strategy: + fail-fast: false + matrix: + include: + - repository: pytorch/executorch + pre-script: .ci/scripts/wheel/pre_build_script.sh + post-script: .ci/scripts/wheel/post_build_script.sh + smoke-test-script: .ci/scripts/wheel/test_cuda_linux.py + package-name: executorch + name: ${{ matrix.repository }} + uses: pytorch/test-infra/.github/workflows/build_wheels_linux.yml@main + with: + repository: ${{ matrix.repository }} + ref: "" + test-infra-repository: pytorch/test-infra + test-infra-ref: main + build-matrix: ${{ needs.filter-matrix.outputs.matrix }} + submodules: recursive + env-var-script: .ci/scripts/wheel/envvar_cuda_linux.sh + pre-script: ${{ matrix.pre-script }} + post-script: ${{ matrix.post-script }} + package-name: ${{ matrix.package-name }} + smoke-test-script: ${{ matrix.smoke-test-script }} + trigger-event: ${{ github.event_name }} + # Required for aarch64. Without it the shared build workflow prepares an x86_64 job + # and skips the aarch64 conda install, so the first build step fails on a missing + # conda. + architecture: aarch64 diff --git a/.github/workflows/build-wheels-cuda-linux.yml b/.github/workflows/build-wheels-cuda-linux.yml new file mode 100644 index 00000000000..7d547617e38 --- /dev/null +++ b/.github/workflows/build-wheels-cuda-linux.yml @@ -0,0 +1,95 @@ +# From https://github.com/pytorch/test-infra/wiki/Using-Nova-Reusable-Build-Workflows +name: Build Linux CUDA Wheels + +on: + pull_request: + paths: + - .ci/**/* + - .github/scripts/filter_cuda_matrix.py + - .github/workflows/build-wheels-cuda-linux.yml + - '**/CMakeLists.txt' + - backends/cuda/**/* + - examples/**/* + - extension/cuda/**/* + - install_requirements.py + - pyproject.toml + - setup.py + - tools/cmake/**/* + push: + branches: + - nightly + - release/* + tags: + # NOTE: Binary build pipelines should only get triggered on release candidate builds + # Release candidate tags look like: v1.11.0-rc1 + - v[0-9]+.[0-9]+.[0-9]+-rc[0-9]+ + - ciflow/binaries/* + workflow_dispatch: + +jobs: + generate-matrix: + uses: pytorch/test-infra/.github/workflows/generate_binary_build_matrix.yml@main + with: + package-type: wheel + os: linux + test-infra-repository: pytorch/test-infra + test-infra-ref: main + with-cuda: enable + with-cpu: disable + with-rocm: disable + python-versions: '["3.10", "3.11", "3.12", "3.13"]' + + # The generator emits every CUDA version it knows about. Publishing all of them would ship + # wheels for combinations nothing can verify, so this keeps only the rows with a GPU to run + # them on. The script fails rather than emitting an empty matrix, because a workflow with no + # build job reads as a pass. + filter-matrix: + needs: generate-matrix + runs-on: ubuntu-latest + outputs: + matrix: ${{ steps.filter.outputs.matrix }} + steps: + - uses: actions/setup-python@v6 + with: + python-version: '3.12' + - uses: actions/checkout@v4 + - name: Filter the matrix + id: filter + run: | + set -eou pipefail + MATRIX_BLOB=${{ toJSON(needs.generate-matrix.outputs.matrix) }} + LIMIT_PR=${{ github.event_name == 'pull_request' && 'true' || 'false' }} + MATRIX_BLOB="$(python3 .github/scripts/filter_cuda_matrix.py \ + --matrix "${MATRIX_BLOB}" --limit-pr-builds "${LIMIT_PR}")" + echo "${MATRIX_BLOB}" + echo "matrix=${MATRIX_BLOB}" >> "${GITHUB_OUTPUT}" + + build: + needs: filter-matrix + permissions: + id-token: write + contents: read + strategy: + fail-fast: false + matrix: + include: + - repository: pytorch/executorch + pre-script: .ci/scripts/wheel/pre_build_script.sh + post-script: .ci/scripts/wheel/post_build_script.sh + smoke-test-script: .ci/scripts/wheel/test_cuda_linux.py + package-name: executorch + name: ${{ matrix.repository }} + uses: pytorch/test-infra/.github/workflows/build_wheels_linux.yml@main + with: + repository: ${{ matrix.repository }} + ref: "" + test-infra-repository: pytorch/test-infra + test-infra-ref: main + build-matrix: ${{ needs.filter-matrix.outputs.matrix }} + submodules: recursive + env-var-script: .ci/scripts/wheel/envvar_cuda_linux.sh + pre-script: ${{ matrix.pre-script }} + post-script: ${{ matrix.post-script }} + package-name: ${{ matrix.package-name }} + smoke-test-script: ${{ matrix.smoke-test-script }} + trigger-event: ${{ github.event_name }} diff --git a/backends/cuda/CMakeLists.txt b/backends/cuda/CMakeLists.txt index 05f238401a4..6e828be33f5 100644 --- a/backends/cuda/CMakeLists.txt +++ b/backends/cuda/CMakeLists.txt @@ -42,6 +42,74 @@ if(NOT CMAKE_CUDA_COMPILER) check_language(CUDA) endif() +# Take the architectures from the release row when it names them, before the +# language is enabled, since CMake fixes them at that point. Without this the +# build uses CMake's default, which on some devices is older than the intrinsics +# these sources use, and the compile fails with an undefined identifier that +# looks like a source problem. +# +# TORCH_CUDA_ARCH_LIST is the variable the surrounding build environment already +# sets, in PyTorch's dotted form. CMake wants bare integers, so "9.0" becomes +# 90. A "+PTX" suffix asks for the portable form in addition to the compiled +# one, which is what PyTorch means by it, so it adds the -virtual kind rather +# than replacing the -real one. +if(NOT DEFINED CMAKE_CUDA_ARCHITECTURES AND DEFINED ENV{TORCH_CUDA_ARCH_LIST}) + set(_cuda_arch_list "") + string(STRIP "$ENV{TORCH_CUDA_ARCH_LIST}" _cuda_arch_request) + string(TOLOWER "${_cuda_arch_request}" _cuda_arch_request_lower) + string(REPLACE " " ";" _cuda_arch_items "${_cuda_arch_request}") + # CMake understands these three itself, and they cannot be combined with a + # version list, so they pass straight through as the whole value. Dropping + # them left the variable unset and the compile fell back to CMake's own + # default, measured as 52 under CUDA 12.8 and 75 under 13.0, which is the + # outcome this block exists to prevent. + if(_cuda_arch_request_lower MATCHES "^(native|all|all-major)$") + set(CMAKE_CUDA_ARCHITECTURES ${_cuda_arch_request_lower}) + message( + STATUS + "CUDA architectures from TORCH_CUDA_ARCH_LIST: ${CMAKE_CUDA_ARCHITECTURES}" + ) + set(_cuda_arch_items "") + endif() + foreach(_cuda_arch_item IN LISTS _cuda_arch_items) + if(_cuda_arch_item STREQUAL "") + continue() + endif() + set(_cuda_arch_ptx OFF) + if(_cuda_arch_item MATCHES "\\+PTX$") + set(_cuda_arch_ptx ON) + string(REPLACE "+PTX" "" _cuda_arch_item "${_cuda_arch_item}") + endif() + string(REPLACE "." "" _cuda_arch_number "${_cuda_arch_item}") + # A trailing letter selects an architecture-specific feature set, as in + # "10.0a", which CMake accepts and this build passes through. + if(_cuda_arch_number MATCHES "^[0-9]+[a-z]?$") + list(APPEND _cuda_arch_list "${_cuda_arch_number}-real") + if(_cuda_arch_ptx) + list(APPEND _cuda_arch_list "${_cuda_arch_number}-virtual") + endif() + else() + # Said out loud, because dropping an entry silently is how the whole list + # ends up empty and the compile falls back to CMake's default, which is + # the failure this block exists to prevent. The named forms PyTorch also + # accepts, such as "native" or a GPU family, land here. + message( + WARNING + "Ignoring \"${_cuda_arch_item}\" from TORCH_CUDA_ARCH_LIST: expected a " + "version such as 9.0, 9.0+PTX or 10.0a." + ) + endif() + endforeach() + if(_cuda_arch_list) + list(REMOVE_DUPLICATES _cuda_arch_list) + set(CMAKE_CUDA_ARCHITECTURES ${_cuda_arch_list}) + message( + STATUS + "CUDA architectures from TORCH_CUDA_ARCH_LIST: ${CMAKE_CUDA_ARCHITECTURES}" + ) + endif() +endif() + if(CMAKE_CUDA_COMPILER) enable_language(CUDA) endif() diff --git a/install_requirements.py b/install_requirements.py index 1aedcf6f0f8..d967d69a1d2 100644 --- a/install_requirements.py +++ b/install_requirements.py @@ -45,7 +45,11 @@ def install_requirements(use_pytorch_nightly): # Determine the appropriate PyTorch URL based on CUDA delegate status torch_url = determine_torch_url(TORCH_URL_BASE) - torchao_url = determine_torch_url(TORCHAO_URL_BASE) + # torchao is deliberately NOT given the CUDA suffix. Its CUDA channel publishes x86_64 only, so + # asking for a CUDA build makes the pin unsatisfiable on other architectures, and the CUDA build + # is not needed: nothing in the wheel links or bundles torchao, which is a quantization-workflow + # dependency of the examples and tests. + torchao_url = TORCHAO_URL_BASE # pip packages needed by exir. TORCH_PACKAGE = [