diff --git a/.ci/scripts/wheel/test_cpp_sdk.py b/.ci/scripts/wheel/test_cpp_sdk.py index ee8c92fc08a..29b2021d398 100644 --- a/.ci/scripts/wheel/test_cpp_sdk.py +++ b/.ci/scripts/wheel/test_cpp_sdk.py @@ -33,6 +33,7 @@ _EXPORT_SCRIPT = """ import json import sys +from pathlib import Path import torch from executorch.exir import to_edge_transform_and_lower @@ -64,7 +65,15 @@ def forward(self, x, image): # variants of the quantized operators with torch. Without it the export fails with # "Missing out variants: quantized_decomposed::quantize_per_tensor", because the # lowering step has no out variant to select. - import executorch.kernels.quantized # noqa: F401 + # Loaded directly rather than through executorch.kernels.quantized, whose __init__ + # swallows every exception, so a load failure would otherwise appear much later as + # "Missing out variants" with no indication of why. + import executorch as _executorch + + _root = Path(list(_executorch.__path__)[0]) / "kernels" / "quantized" + _libs = sorted(_root.glob("*quantized_ops_aot_lib.*")) + assert len(_libs) == 1, f"expected one ahead-of-time library, found {_libs}" + torch.ops.load_library(str(_libs[0])) from executorch.backends.xnnpack.quantizer.xnnpack_quantizer import ( get_symmetric_quantization_config, XNNPACKQuantizer, @@ -261,6 +270,61 @@ def _consumer_cmake(components) -> str: """ +def _mach_o_runtime_paths(binary) -> list: + """The runtime search path entries a Mach-O binary records. + + Mach-O keeps one entry per LC_RPATH load command, where ELF keeps a single colon joined + string, so they are read rather than split. + """ + otool = _tool("otool") + listing = subprocess.run( + [otool, "-l", str(binary)], capture_output=True, text=True, check=True + ).stdout + entries = [] + lines = listing.splitlines() + for index, line in enumerate(lines): + if "LC_RPATH" not in line: + continue + for following in lines[index + 1 : index + 4]: + stripped = following.strip() + if stripped.startswith("path "): + entries.append(stripped.split(" (offset", 1)[0][len("path ") :]) + break + return entries + + +def _dynamic_lib_suffix() -> str: + """The loadable library suffix on this platform, including the dot.""" + return ".dylib" if sys.platform == "darwin" else ".so" + + +def _library_file_name(base_name: str) -> str: + """The file name a library has on this platform.""" + return f"{base_name}{_dynamic_lib_suffix()}" + + +def _recorded_dependencies(binary) -> str: + """What a built binary records about its dependencies and search paths. + + readelf prints the ELF dynamic section, otool -l the Mach-O load commands. Both + carry the same facts: a dependency entry and a runtime search path entry, named + NEEDED and RUNPATH on ELF, LC_LOAD_DYLIB and LC_RPATH on Mach-O. + """ + if sys.platform == "darwin": + tool, args = _tool("otool"), ["-l"] + needed = "otool" + else: + tool, args = _tool("readelf"), ["-d"] + needed = "readelf" + assert tool is not None, f"{needed} is needed to read the runtime search path" + return subprocess.run( + [tool, *args, str(binary)], + capture_output=True, + text=True, + check=True, + ).stdout + + def _tool(name: str) -> str: """Locate a build tool, including one pip installed beside this interpreter. @@ -269,6 +333,9 @@ def _tool(name: str) -> str: directly, so a tool installed into that environment is present on disk and invisible to a PATH search. """ + # _tool returns the bare name when a PATH search fails, so it never returns None and an + # `is not None` assert on its result can never fire. Callers check the subprocess result + # instead, which is what actually surfaces a missing tool. found = shutil.which(name) if found: return found @@ -276,6 +343,25 @@ def _tool(name: str) -> str: return str(beside) if beside.is_file() else name +def _loader_clean_environment() -> dict: + """The environment with every loader override removed. + + Making the shipped libraries findable is the package config's job, and a + search path or an injected library inherited from the environment does that + job instead, hiding a failure to do it. Both spellings go on both platforms: + the one that does not apply is absent rather than harmful, and naming only + the ELF variable is what left the macOS runs honouring DYLD_LIBRARY_PATH. + """ + overrides = ( + "LD_LIBRARY_PATH", + "LD_PRELOAD", + "DYLD_LIBRARY_PATH", + "DYLD_FALLBACK_LIBRARY_PATH", + "DYLD_INSERT_LIBRARIES", + ) + return {key: value for key, value in os.environ.items() if key not in overrides} + + def _installed_package_dir() -> Path: """Where the wheel installed itself, found without importing it. @@ -382,12 +468,7 @@ def _run_consumer( expected = work_dir / "expected.data" expected.write_text(" ".join(repr(v) for v in reference["expected"])) - # No LD_LIBRARY_PATH. Making the shipped libraries findable is the package - # config's job, and inheriting one from the environment would hide a failure to - # do it. - environment = { - key: value for key, value in os.environ.items() if key != "LD_LIBRARY_PATH" - } + environment = _loader_clean_environment() result = subprocess.run( [ str(consumer), @@ -432,9 +513,7 @@ def test_runtime_alone_links_but_cannot_compute(work_dir: Path) -> None: shape_b, data_b = _write_tensor(work_dir, "rb", inputs[1]) expected = work_dir / "r_expected.data" expected.write_text(" ".join(repr(v) for v in reference["expected"])) - environment = { - key: value for key, value in os.environ.items() if key != "LD_LIBRARY_PATH" - } + environment = _loader_clean_environment() result = subprocess.run( [ str(consumer), @@ -507,9 +586,7 @@ def test_delegated_model_needs_the_delegate_component(work_dir: Path) -> None: # delegate. Only the delegate is removed, so a failure can only be about the # missing backend rather than about absent operators. without = _build_consumer(work_dir, "no-delegate", ["runtime", "kernels_optimized"]) - environment = { - key: value for key, value in os.environ.items() if key != "LD_LIBRARY_PATH" - } + environment = _loader_clean_environment() inputs = reference["inputs"] shape_a, data_a = _write_tensor(work_dir, "na", inputs[0]) shape_b, data_b = _write_tensor(work_dir, "nb", inputs[1]) @@ -560,19 +637,14 @@ def test_consumer_is_relocatable(work_dir: Path) -> None: model, reference = _export(work_dir, "plain") consumer = _build_consumer(work_dir, "relocate", ["runtime", "kernels_optimized"]) - assert shutil.which("readelf") is not None, "readelf is needed to read the RUNPATH" - dynamic = subprocess.run( - [_tool("readelf"), "-d", str(consumer)], - capture_output=True, - text=True, - check=True, - ).stdout - assert "libexecutorch.so" in dynamic, ( + dynamic = _recorded_dependencies(consumer) + assert _library_file_name("libexecutorch") in dynamic, ( "the application records no dependency on the shipped runtime, so it is not " f"linking what the wheel ships:\n{dynamic}" ) - assert "$ORIGIN" in dynamic, ( - "the application has no $ORIGIN-relative runtime search path, so it cannot " + token = "@loader_path" if sys.platform == "darwin" else "$ORIGIN" + assert token in dynamic, ( + f"the application has no {token} relative runtime search path, so it cannot " f"work anywhere but where it was built:\n{dynamic}" ) # The newer tag specifically, not just any search path. DT_RPATH is searched @@ -580,12 +652,15 @@ def test_consumer_is_relocatable(work_dir: Path) -> None: # a consumer given DT_RPATH cannot point an instrumented or locally built # runtime at their application. Both tags satisfy the check above, so without # this the package could silently go back to the older one. - assert "(RUNPATH)" in dynamic, ( - "the application's runtime search path is recorded as DT_RPATH rather than " - "DT_RUNPATH. DT_RPATH outranks LD_LIBRARY_PATH and is inherited by " - "dependencies, so a consumer could not override a packaged library with " - f"their own build:\n{dynamic}" - ) + # ELF only. Mach-O records one LC_RPATH with no weaker older variant, so there is no + # equivalent preference to check there. + if sys.platform != "darwin": + assert "(RUNPATH)" in dynamic, ( + "the application's runtime search path is recorded as DT_RPATH rather than " + "DT_RUNPATH. DT_RPATH outranks LD_LIBRARY_PATH and is inherited by " + "dependencies, so a consumer could not override a packaged library with " + f"their own build:\n{dynamic}" + ) package_dir = _installed_package_dir() deployed = work_dir / "deployed" @@ -598,47 +673,72 @@ def test_consumer_is_relocatable(work_dir: Path) -> None: directory = package_dir / source if not directory.is_dir(): continue - for library in sorted(directory.glob("lib*.so*")): + for library in sorted(directory.glob(_library_file_name("lib*") + "*")): if library.is_file() and not library.is_symlink(): shutil.copy2(library, deployed / library.name) moved = deployed / "consumer" - # Strip the absolute entry the build left behind, so only $ORIGIN can resolve the - # libraries. Without this the application would find the original wheel and the - # check would pass for the wrong reason. - # Fatal, not a skip. Stripping the absolute entry is the whole point: without it the - # relocated application finds the original package and this check passes for the - # wrong reason. A skip here is indistinguishable from a pass in the log, which is - # the shape of failure this suite exists to avoid. - patchelf = _tool("patchelf") - if shutil.which("patchelf") is None and not Path(patchelf).is_file(): - print("- patchelf not present, installing it so this check can run") + # Strip the absolute entry the build left behind, so only the loader-relative token can + # resolve the libraries. Without this the application would find the original wheel and + # the check would pass for the wrong reason. + if sys.platform == "darwin": + entries = _mach_o_runtime_paths(moved) + else: + # Fatal, not a skip, and only on the ELF side, which is the only one that reads or + # rewrites the search path with patchelf. A skip here is indistinguishable from a pass + # in the log, which is the shape of failure this suite exists to avoid. + patchelf = _tool("patchelf") + if shutil.which("patchelf") is None and not Path(patchelf).is_file(): + print("- patchelf not present, installing it so this check can run") + subprocess.run( + [sys.executable, "-m", "pip", "install", "--quiet", "patchelf"], + capture_output=True, + text=True, + check=True, + ) + patchelf = _tool("patchelf") + assert shutil.which("patchelf") or Path(patchelf).is_file(), ( + "patchelf is required to prove the application is relocatable, and could not " + "be installed. Without it the relocated application resolves the original " + "package and the check would pass without testing anything." + ) + entries = [ + entry + for entry in subprocess.run( + [patchelf, "--print-rpath", str(moved)], + capture_output=True, + text=True, + check=True, + ) + .stdout.strip() + .split(":") + if entry + ] + kept = [entry for entry in entries if not entry.startswith(str(package_dir))] + + if sys.platform == "darwin": + # One entry per load command, so each unwanted one is deleted individually and the + # fallback is added only when stripping emptied the list. + install_name_tool = _tool("install_name_tool") + for entry in entries: + if entry in kept: + continue + subprocess.run( + [install_name_tool, "-delete_rpath", entry, str(moved)], + capture_output=True, + check=True, + ) + if not kept: + subprocess.run( + [install_name_tool, "-add_rpath", "@loader_path", str(moved)], + capture_output=True, + check=True, + ) + else: subprocess.run( - [sys.executable, "-m", "pip", "install", "--quiet", "patchelf"], - capture_output=True, - text=True, - check=False, + [patchelf, "--set-rpath", ":".join(kept) or "$ORIGIN", str(moved)], + check=True, ) - patchelf = _tool("patchelf") - assert shutil.which("patchelf") or Path(patchelf).is_file(), ( - "patchelf is required to prove the application is relocatable, and could not " - "be installed. Without it the relocated application resolves the original " - "package and the check would pass without testing anything." - ) - current = subprocess.run( - [patchelf, "--print-rpath", str(moved)], - capture_output=True, - text=True, - check=True, - ).stdout.strip() - kept = [ - entry - for entry in current.split(":") - if entry and not entry.startswith(str(package_dir)) - ] - subprocess.run( - [patchelf, "--set-rpath", ":".join(kept) or "$ORIGIN", str(moved)], check=True - ) output = _run_consumer(moved, model, reference, work_dir) print(f"✓ the application still runs deployed away from the wheel ({output})") @@ -798,7 +898,9 @@ def test_profiler_component_is_usable(work_dir: Path) -> None: # Globbed, not an exact name: the library carries a version suffix outside a wheel build, and an exact # match would silently skip this check there. The profiler is required elsewhere in this suite, so its # absence is a fault rather than a reason to skip. - shipped = sorted((package_dir / "lib").glob("libexecutorch_etdump.so*")) + shipped = sorted( + (package_dir / "lib").glob(_library_file_name("libexecutorch_etdump") + "*") + ) assert shipped, ( f"the wheel ships no profiler library under {package_dir / 'lib'}, so the etdump component it " "advertises cannot be linked" @@ -1033,7 +1135,7 @@ def test_shipped_headers_have_implementations(work_dir: Path) -> None: "executorch_kernels_optimized", "executorch_threadpool", ) - if (library_dir / f"lib{name}.so").is_file() + if (library_dir / (f"lib{name}" + _dynamic_lib_suffix())).is_file() ], f"-Wl,-rpath,{library_dir}", ], @@ -1253,10 +1355,8 @@ def test_pre_3_28_route_builds_a_consumer_through_variables(work_dir: Path) -> N _run_consumer(consumer, model, reference, work_dir) # The runtime and CPU kernels have to be on the link line, since the variables are # the only thing that carries them on this route. - dependencies = subprocess.run( - ["readelf", "-d", str(consumer)], capture_output=True, text=True, check=False - ).stdout - assert "libexecutorch.so" in dependencies, ( + dependencies = _recorded_dependencies(consumer) + assert _library_file_name("libexecutorch") in dependencies, ( "a consumer built through EXECUTORCH_LIBRARIES on pre-3.28 CMake does not " f"depend on the runtime:\n{dependencies}" ) @@ -1286,7 +1386,11 @@ def test_quantized_kernels_component_runs_a_model(work_dir: Path) -> None: package_dir = _installed_package_dir() # Globbed for the same reason the profiler check is: the library carries a version suffix outside a # wheel build, and an exact name would skip this silently there rather than running it. - shipped = sorted((package_dir / "lib").glob("libexecutorch_kernels_quantized.so*")) + shipped = sorted( + (package_dir / "lib").glob( + _library_file_name("libexecutorch_kernels_quantized") + "*" + ) + ) assert shipped, ( "the wheel ships no quantized kernels library. The preset that builds it enables " "them unconditionally, so this is a packaging or build regression rather than an " @@ -1335,7 +1439,9 @@ def test_aggregate_variable_excludes_the_quantized_kernels(work_dir: Path) -> No # always enables these kernels, so their absence is a regression rather than a # configuration to tolerate, and skipping would report this as coverage. assert sorted( - (package_dir / "lib").glob("libexecutorch_kernels_quantized.so*") + (package_dir / "lib").glob( + _library_file_name("libexecutorch_kernels_quantized") + "*" + ) ), "the wheel ships no quantized kernels library, so this check cannot run" source_dir = work_dir / "aggregate-only" @@ -1370,9 +1476,7 @@ def test_aggregate_variable_excludes_the_quantized_kernels(work_dir: Path) -> No ) consumer = build_dir / "consumer" - dependencies = subprocess.run( - ["readelf", "-d", str(consumer)], capture_output=True, text=True, check=False - ).stdout + dependencies = _recorded_dependencies(consumer) assert "libexecutorch_kernels_quantized" not in dependencies, ( "an application that linked only ${EXECUTORCH_LIBRARIES} depends on the " "quantized kernels. That library collides with the export-time plugin, so it " diff --git a/.ci/scripts/wheel/test_macos.py b/.ci/scripts/wheel/test_macos.py index 6cc2765e6e3..2defbfb2eb6 100644 --- a/.ci/scripts/wheel/test_macos.py +++ b/.ci/scripts/wheel/test_macos.py @@ -7,13 +7,30 @@ # LICENSE file in the root directory of this source tree. import sys +import tempfile +from pathlib import Path import test_base +import test_cpp_sdk +import test_shared_libraries from examples.models import Backend, Model if __name__ == "__main__": test_base.test_cmsis_nn_install() + # The wheel ships the runtime, the kernels, the delegate, the thread pool and + # the profiler as separate libraries here too, so check that each has exactly + # one owner and that all of them are loadable. + with tempfile.TemporaryDirectory() as work_dir: + test_shared_libraries.run_tests(Path(work_dir)) + + # And that a C++ application outside the wheel can actually use them. Nothing + # else covers this: the Python extension links those libraries itself, so it + # passes whether or not the package config names them or the shipped headers + # are complete. + with tempfile.TemporaryDirectory() as work_dir: + test_cpp_sdk.run_tests(Path(work_dir)) + model_tests = [ test_base.ModelTest( model=Model.Mv3, diff --git a/.ci/scripts/wheel/test_shared_libraries.py b/.ci/scripts/wheel/test_shared_libraries.py index b7f99208ea4..abd55842673 100644 --- a/.ci/scripts/wheel/test_shared_libraries.py +++ b/.ci/scripts/wheel/test_shared_libraries.py @@ -34,8 +34,12 @@ import subprocess import sys import tempfile +import zipfile from pathlib import Path +from packaging.requirements import Requirement +from packaging.utils import canonicalize_name + # Registry entry points. A second definer of any of these means a second # process-wide registry. _REGISTRY_SYMBOLS = ( @@ -129,7 +133,14 @@ # Checked separately from the wrapper symbols above because the wrappers can each # have exactly one owner while the bundled code underneath them does not. That is # the same failure the split exists to prevent, reached by a different route. -_BUNDLED_THREADPOOL_SYMBOLS = ("pthreadpool_create", "cpuinfo_initialize") +# pthreadpool is compiled with hidden visibility on Apple, deliberately, so that the +# copy inside libtorch_cpu cannot take precedence over the bundled one. Its symbols are +# present but not exported there, so only cpuinfo can serve as the sentinel on that +# platform. Both are checked elsewhere. +if sys.platform == "darwin": + _BUNDLED_THREADPOOL_SYMBOLS = ("cpuinfo_initialize",) +else: + _BUNDLED_THREADPOOL_SYMBOLS = ("pthreadpool_create", "cpuinfo_initialize") # The delegate's own entry points. A second definer means the delegate is compiled # into the Python extension as well, which would register it twice in one process. _OPENVINO_BACKEND_SYMBOLS = ("executorch::backends::openvino::OpenvinoBackend",) @@ -144,6 +155,19 @@ # " U " for an undefined reference. _DEFINED = re.compile(r"^[0-9a-fA-F]+\s+(?P[A-Za-z])\s+(?P.+)$") +# Thin and fat Mach-O headers, both byte orders, used to tell a file the Mach-O +# tools should have been able to read from one that merely carries their suffix. +_MACH_O_MAGIC = frozenset( + { + b"\xcf\xfa\xed\xfe", + b"\xce\xfa\xed\xfe", + b"\xfe\xed\xfa\xcf", + b"\xfe\xed\xfa\xce", + b"\xca\xfe\xba\xbe", + b"\xbe\xba\xfe\xca", + } +) + # Symbol kinds that mean the object owns the code or storage. _OWNING_KINDS = frozenset("TtBbDdGgSsRrWV") @@ -210,11 +234,6 @@ def _declared_requirements() -> set: return names -def _nm_defined_args(): - """The nm flags that list what a library defines.""" - return ["-DC"] - - def _installed_package_dir() -> Path: """The installed executorch package, never the source checkout. @@ -262,6 +281,332 @@ def _tool(name: str): return str(beside) if beside.is_file() else None +def _loader_clean_environment() -> dict: + """The environment with every loader override removed. + + A search path or an injected library inherited from the build environment + supplies what a shipped library failed to record, so a child process started + without stripping these can load a package that would not load anywhere else. + Both spellings go on both platforms: the one that does not apply is absent + rather than harmful, and naming only the ELF variable is what left the macOS + checks honouring DYLD_LIBRARY_PATH. + """ + overrides = ( + "LD_LIBRARY_PATH", + "LD_PRELOAD", + "DYLD_LIBRARY_PATH", + "DYLD_FALLBACK_LIBRARY_PATH", + "DYLD_INSERT_LIBRARIES", + ) + return {key: value for key, value in os.environ.items() if key not in overrides} + + +def _dynamic_section(library) -> str | None: + """What a library records about its dependencies and search paths. + + Returns None when no tool can read it, so a caller can tell "nothing recorded" + apart from "could not look", which are different verdicts. + + readelf prints the ELF dynamic section. otool -l prints the Mach-O load commands, + which carry the same facts under different names: LC_LOAD_DYLIB for a dependency + where ELF has NEEDED, and LC_RPATH where ELF has RUNPATH. + """ + if sys.platform == "darwin": + tool, args = _tool("otool"), ["-l"] + else: + tool, args = _tool("readelf"), ["-d"] + if tool is None: + return None + return subprocess.run( + [tool, *args, str(library)], + capture_output=True, + text=True, + check=False, + ).stdout + + +def _linked_libraries(library) -> str | None: + """The libraries this one resolves at load time, as text to search. + + ldd resolves an ELF library's dependencies transitively. otool -L lists a Mach-O + library's direct dependencies without resolving them, which is weaker, so a macOS + result says which names are recorded rather than whether each one was found. + """ + if sys.platform == "darwin": + tool, args = _tool("otool"), ["-L"] + else: + tool, args = _tool("ldd"), ["-r"] + if tool is None: + return None + result = subprocess.run( + [tool, *args, str(library)], + capture_output=True, + text=True, + check=False, + ) + return result.stdout + result.stderr + + +def _recorded_dependencies(library) -> set: + """The library names this one records a dependency on, without resolving them. + + ELF names them in NEEDED entries of the dynamic section, already as bare file + names. Mach-O names them in LC_LOAD_DYLIB commands, which otool -L prints as the + install name, a path, so only its last component is comparable with the ELF answer. + + The tool is required rather than optional, because a caller cannot tell an empty + result apart from one this could not read, and the second is a passing check that + examined nothing. + """ + if sys.platform == "darwin": + tool = _tool("otool") + assert tool is not None, "otool is required to inspect the wheel" + # The first line names the file itself, and each later line is one dependency + # followed by the version information otool appends in parentheses. + return { + Path(line.strip().split(" (", 1)[0]).name + for line in subprocess.run( + [tool, "-L", str(library)], capture_output=True, text=True, check=True + ).stdout.splitlines()[1:] + if line.strip() + } + tool = _tool("readelf") + assert tool is not None, "readelf is required to inspect the wheel" + return { + line.split("[", 1)[1].rstrip("]").strip() + for line in subprocess.run( + [tool, "-d", str(library)], capture_output=True, text=True, check=True + ).stdout.splitlines() + if "NEEDED" in line + } + + +def _raw_recorded_identity(library) -> str | None: + """The identity exactly as recorded, without reducing it to a basename. + + Separate from _recorded_identity because that function's basename reduction is correct for + comparing names but hides whether the recorded string is absolute, which is a different fault. + """ + section = _dynamic_section(library) + if section is None: + return None + # otool -l prints a name field for LC_LOAD_DYLIB as well as LC_ID_DYLIB, so the current load + # command has to be tracked: taking the first name line returned a dependency such as + # /usr/lib/libc++.1.dylib for a library that has no install name at all, which would report an + # absolute install name where the truth is that there is none. + in_id_command = False + for line in section.splitlines(): + stripped = line.strip() + if sys.platform == "darwin": + if stripped.startswith("cmd "): + # Containment, matching how every other otool -l parser in this file reads a load + # command. Exact equality would return None for every library if any otool spelled + # the line differently, and the absolute check would then inspect nothing and pass. + in_id_command = "LC_ID_DYLIB" in stripped + elif in_id_command and stripped.startswith("name "): + return stripped.split(" (offset", 1)[0][len("name ") :] + elif "SONAME" in stripped and "[" in stripped: + return stripped.split("[", 1)[1].rstrip("]") + return None + + +def _recorded_identity(library) -> str | None: + """The name this library tells a consumer to record when linking against it. + + ELF calls it the soname. Mach-O calls it the install name and spells it as a path, + usually relative to the consumer's runtime search path, so the two compare only by + the last component. + """ + if sys.platform == "darwin": + tool = _tool("otool") + assert tool is not None, "otool is required to inspect the wheel" + # otool -D prints the file name it was given, then the install name if the + # library has one. A library without one prints only the first line. + lines = [ + line.strip() + for line in subprocess.run( + [tool, "-D", str(library)], capture_output=True, text=True, check=True + ).stdout.splitlines()[1:] + if line.strip() + ] + return Path(lines[0]).name if lines else None + tool = _tool("readelf") + assert tool is not None, "readelf is required to inspect the wheel" + return next( + ( + line.split("[", 1)[1].rstrip("]").strip() + for line in subprocess.run( + [tool, "-d", str(library)], capture_output=True, text=True, check=False + ).stdout.splitlines() + if "SONAME" in line + ), + None, + ) + + +def _runtime_search_paths(library) -> list | None: + """The runtime search path entries recorded in a shipped library. + + Returns None when the file cannot be read, so a caller can tell "records nothing" + apart from "could not look", which are different verdicts. + + patchelf prints an ELF RPATH as one colon separated string. Mach-O keeps each entry + in its own LC_RPATH load command, so otool is parsed for those instead. patchelf + cannot read Mach-O at all, which is why it is not simply reused here. + """ + if sys.platform == "darwin": + tool = _tool("otool") + if tool is None: + return None + result = subprocess.run( + [tool, "-l", str(library)], capture_output=True, text=True, check=False + ) + if result.returncode != 0: + return None + entries = [] + lines = result.stdout.splitlines() + for index, line in enumerate(lines): + if "LC_RPATH" not in line: + continue + # The path sits a couple of lines below its command, followed by the + # offset otool appends, which is not part of the value. + for following in lines[index + 1 : index + 4]: + stripped = following.strip() + if stripped.startswith("path "): + entries.append(stripped.split(" (offset", 1)[0][len("path ") :]) + break + return entries + tool = _tool("patchelf") + if tool is None: + return None + result = subprocess.run( + [tool, "--print-rpath", str(library)], + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + return None + # A library with no search path prints nothing, which is not the same as a search path holding + # an empty entry. The fields are kept rather than filtered, because the loader reads an empty + # entry as the process working directory and the caller rejects exactly that. + recorded = result.stdout.strip() + return recorded.split(":") if recorded else [] + + +def _assert_mach_o_architecture_matches(wheel: Path) -> None: + """Fail if a macOS wheel's declared architecture is not what its binaries contain. + + auditwheel answers this on Linux by classifying against manylinux, which has no macOS + equivalent, so lipo is asked directly instead. It reports the architectures present in + a Mach-O file, and every shipped binary has to be one the tag promises. + + A universal binary lists several architectures, so containing the declared one is the + test rather than equalling it. + """ + lipo = _tool("lipo") + assert lipo is not None, ( + "lipo is required to check that a macOS wheel's contents match the architecture " + "it claims, and it was not found" + ) + claimed = wheel.name.split("-")[-1].removesuffix(".whl") + # macosx_14_0_arm64 and macosx_11_0_x86_64 both end in the architecture. + declared = claimed.split("_")[-1] + if declared == "64" and claimed.endswith("x86_64"): + declared = "x86_64" + + with tempfile.TemporaryDirectory() as unpacked: + with zipfile.ZipFile(wheel) as archive: + archive.extractall(unpacked) + root = Path(unpacked) + binaries = [ + path + for path in sorted(root.rglob("*")) + if path.is_file() + and not path.is_symlink() + and path.suffix in (".dylib", ".so") + ] + assert binaries, ( + f"the wheel {wheel.name} contains no binaries, so the architecture it claims " + "cannot be checked against anything" + ) + mismatched = [] + unreadable = [] + for binary in binaries: + result = subprocess.run( + [lipo, "-archs", str(binary)], + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + # Skipping every unreadable file would let a wheel whose binaries + # lipo cannot parse pass having inspected none of them. Something + # merely named .so that is not a Mach-O file is not this check's + # concern; the magic bytes tell the two apart without depending on + # how lipo words its refusal. + with binary.open("rb") as handle: + header = handle.read(4) + if header in _MACH_O_MAGIC: + unreadable.append( + f"{binary.relative_to(root)}: {result.stderr.strip()[:120]}" + ) + continue + present = result.stdout.split() + if declared not in present: + mismatched.append(f"{binary.relative_to(root)} is {' '.join(present)}") + assert not unreadable, ( + f"lipo could not read these Mach-O files in {wheel.name}, so the architecture " + f"check covered less than the wheel ships: {unreadable}" + ) + assert not mismatched, ( + f"the wheel claims architecture {declared} but these binaries are built for " + f"something else, so it would install where it cannot run: {mismatched}" + ) + print(f"\u2713 the wheel is tagged for the architecture it contains ({declared})") + + +def _shipped_object_patterns() -> list[str]: + """Filename patterns for every shipped loadable object. + + Both suffixes are listed because a Mach-O Python extension is a .so by convention, so a + dylib-only pattern silently drops the extension on macOS, which is the one artifact these + checks exist to verify. + """ + return ["*.dylib*", "*.so*"] + + +def _dynamic_lib_suffix() -> str: + """The loadable library suffix on this platform, including the dot.""" + return ".dylib" if sys.platform == "darwin" else ".so" + + +def _library_file_name(base_name: str) -> str: + """The file name a component's library has on this platform. + + The component table names libraries without a suffix so one table serves both + platforms. + """ + return f"{base_name}{_dynamic_lib_suffix()}" + + +def _nm_defined_args(): + """The nm flags that list what a library defines. + + GNU nm reads the dynamic symbol table with -D. Mach-O has no separate dynamic + symbol table, so that flag fails outright there and -gU, global and defined, is + the equivalent question. + """ + return ["-gU", "-C"] if sys.platform == "darwin" else ["-DC"] + + +def _nm_undefined_args(): + """The nm flags that list what a library needs from elsewhere.""" + if sys.platform == "darwin": + return ["-gu", "-C"] + return ["-DC", "--undefined-only"] + + def _shipped_shared_objects(package_dir: Path): """Every shared object the wheel installed. @@ -270,7 +615,11 @@ def _shipped_shared_objects(package_dir: Path): """ found = [ path - for path in sorted(package_dir.rglob("*.so*")) + for path in sorted( + item + for pattern in _shipped_object_patterns() + for item in package_dir.rglob(pattern) + ) if path.is_file() and not path.is_symlink() ] assert ( @@ -294,7 +643,7 @@ def _shipped_runtime_libraries(package_dir: Path): return [] return [ path - for path in sorted(lib_dir.glob("lib*.so*")) + for path in sorted(lib_dir.glob(f"lib*{_dynamic_lib_suffix()}*")) if path.is_file() and not path.is_symlink() ] @@ -314,7 +663,10 @@ def _defines_symbol(library: Path, symbol: str) -> bool: process, which counts what actually registered rather than what is visible. """ result = subprocess.run( - [_tool("nm"), "-DC", str(library)], capture_output=True, text=True, check=False + [_tool("nm"), *_nm_defined_args(), str(library)], + capture_output=True, + text=True, + check=False, ) if result.returncode != 0: # A file that is not an object file at all is not this check's concern: something whose @@ -329,14 +681,28 @@ def _defines_symbol(library: Path, symbol: str) -> bool: f"checks cannot be trusted: {result.stderr.strip()[:200]}" ) return False + # Mach-O prefixes a C symbol with an underscore, so nm prints _cpuinfo_initialize + # where ELF prints cpuinfo_initialize. A C++ name demangles to the same text on both, + # so accepting the prefix is enough and no per-symbol spelling is needed. + accepted = (symbol, f"_{symbol}") if sys.platform == "darwin" else (symbol,) + # Matched whole rather than by prefix. A prefix match also accepts a longer + # symbol that merely begins with this one, and reports a second definer of + # something no library defines twice. Three suffixes may follow a complete + # name: nm prints a demangled C++ definition as name(args), a symbol table + # carrying versions prints name@@version, and a sentinel that names a class + # appears only as one of its members, name::member, because a class has no + # symbol of its own. for line in result.stdout.splitlines(): if symbol not in line: continue match = _DEFINED.match(line) - if ( - match - and match.group("name").startswith(symbol) - and match.group("kind") in _OWNING_KINDS + if not match or match.group("kind") not in _OWNING_KINDS: + continue + name = match.group("name") + if any( + name == spelling + or name.startswith((f"{spelling}(", f"{spelling}@", f"{spelling}::")) + for spelling in accepted ): return True return False @@ -352,8 +718,8 @@ def _is_export_only(library: Path) -> bool: Excluded from the single-owner check for the one component that genuinely has two copies. Counting them there would report a duplicate for something that is not one, - and the alternative, making them resolve the kernels from the shipped library, would - mean an export-time library depending on a runtime layout it never uses. + and the plugin does resolve its registrar from the shipped library now, while the code + generator still compiles the kernel bodies into it for the dispatcher half. Recognised by linking torch, which is the property that makes a library export-side. Python extensions link torch too and are not export-side operator libraries, so they @@ -362,17 +728,12 @@ def _is_export_only(library: Path) -> bool: """ if ".cpython-" in library.name or library.name.endswith(".pyd"): return False - if library.name.endswith("_aot_lib.so"): + if library.name.endswith(f"_aot_lib{_dynamic_lib_suffix()}"): return True - if _tool("readelf") is None: + dynamic = _dynamic_section(library) + if dynamic is None: return False - dynamic = subprocess.run( - [_tool("readelf"), "-d", str(library)], - capture_output=True, - text=True, - check=False, - ).stdout - return "libtorch.so" in dynamic + return _library_file_name("libtorch") in dynamic def _assert_single_definer( @@ -390,14 +751,15 @@ def _assert_single_definer( has zero definers and is reported as such. What must never happen is two. `allow_export_copy` excuses the export-side libraries for one component only. The - quantized kernels genuinely exist twice, once in the runtime library and once in the - library torch loads at export time, because each side registers into a table the - other never reads. Loading both into one process does abort on the second - registration, so what this check enforces for that component is one owner among the - runtime libraries, not the absence of the export copy. Excusing every component - would disarm the check where duplication is a real fault: two of these libraries - defined the backend registry symbols in one released wheel and not in the release - before it, so the duplication this catches does happen. + export plugin no longer carries its own registrar, but the code generator still + compiles the kernels into it for the dispatcher half, so the symbols appear twice: + once in the runtime library and once in the library torch loads at export time, + because each side registers into a table the other never reads. Loading both used to + abort on the second registration, so what this check enforces for that component is + one owner among the runtime libraries, not the absence of the export copy. Excusing + every component would disarm the check where duplication is a real fault: two of + these libraries defined the backend registry symbols in one released wheel and not + in the release before it, so the duplication this catches does happen. """ assert _tool("nm") is not None, "nm is required to inspect the wheel" @@ -486,8 +848,8 @@ def _resolve_required(required): # runs a build when imported, and duplicated deliberately so a rename on the packaging # side has to be made here too rather than silently agreeing with itself. _EXPECTED_CUDA_PACKAGES = { - "12": ("nvidia-cuda-runtime-cu12",), - "13": ("nvidia-cuda-runtime",), + "12": ("nvidia-cuda-runtime-cu12>=12,<13",), + "13": ("nvidia-cuda-runtime>=13,<14",), } @@ -501,7 +863,7 @@ def _resolve_required(required): # one of them drift: it looked up its library with its own glob, which silently # stopped matching when the libraries were renamed while the others kept working. _OWNED_COMPONENTS = ( - ("backend registry", _REGISTRY_SYMBOLS, "libexecutorch.so", True), + ("backend registry", _REGISTRY_SYMBOLS, _library_file_name("libexecutorch"), True), # The platform layer, which two shipped libraries each carried their own copy of, so a # register_pal call through one did not reach the other. Listed here so the ownership check # that already exists catches a regression rather than a later reader discovering it. @@ -515,28 +877,38 @@ def _resolve_required(required): "executorch::runtime::register_pal", "executorch::runtime::get_pal_impl", ), - "libexecutorch.so", + _library_file_name("libexecutorch"), + True, + ), + ( + "operator registry", + _KERNEL_REGISTRY_SYMBOLS, + _library_file_name("libexecutorch"), + True, + ), + ( + "thread pool", + _THREADPOOL_SYMBOLS, + _library_file_name("libexecutorch_threadpool"), True, ), - ("operator registry", _KERNEL_REGISTRY_SYMBOLS, "libexecutorch.so", True), - ("thread pool", _THREADPOOL_SYMBOLS, "libexecutorch_threadpool.so", True), - ("profiler", _ETDUMP_SYMBOLS, "libexecutorch_etdump.so", True), + ("profiler", _ETDUMP_SYMBOLS, _library_file_name("libexecutorch_etdump"), True), ( "XNNPACK delegate", _XNNPACK_SYMBOLS, - "libexecutorch_backend_xnnpack.so", + _library_file_name("libexecutorch_backend_xnnpack"), True, ), ( "set of CPU kernels", _KERNEL_SYMBOLS, - "libexecutorch_kernels_optimized.so", + _library_file_name("libexecutorch_kernels_optimized"), False, ), ( "set of quantized kernels", _QUANTIZED_KERNEL_SYMBOLS, - "libexecutorch_kernels_quantized.so", + _library_file_name("libexecutorch_kernels_quantized"), True, ), # The CUDA components. Required exactly when the wheel says it is a CUDA wheel, @@ -547,19 +919,19 @@ def _resolve_required(required): ( "CUDA delegate", _CUDA_BACKEND_SYMBOLS, - "libexecutorch_backend_cuda.so", + _library_file_name("libexecutorch_backend_cuda"), _REQUIRED_ON_A_CUDA_WHEEL, ), ( "CUDA stream helper", _CUDA_STREAM_SYMBOLS, - "libexecutorch_extension_cuda.so", + _library_file_name("libexecutorch_extension_cuda"), _REQUIRED_ON_A_CUDA_WHEEL, ), ( "AOTI shim layer", _AOTI_SHIM_SYMBOLS, - "libaoti_cuda_shims.so", + _library_file_name("libaoti_cuda_shims"), _REQUIRED_ON_A_CUDA_WHEEL, ), # The third-party code these libraries bundle, checked separately from the @@ -576,13 +948,13 @@ def _resolve_required(required): ( "bundled thread pool implementation", _BUNDLED_THREADPOOL_SYMBOLS, - "libexecutorch_threadpool.so", + _library_file_name("libexecutorch_threadpool"), True, ), ( "bundled XNNPACK runtime", _BUNDLED_XNNPACK_SYMBOLS, - "libexecutorch_backend_xnnpack.so", + _library_file_name("libexecutorch_backend_xnnpack"), True, ), # Required on Linux, where packaging turns the backend on for every non-minimal @@ -592,17 +964,18 @@ def _resolve_required(required): ( "OpenVINO delegate", _OPENVINO_BACKEND_SYMBOLS, - "libexecutorch_backend_openvino.so", + _library_file_name("libexecutorch_backend_openvino"), _REQUIRED_ON_LINUX, ), ) -# The one component that legitimately exists twice. The quantized kernels are compiled into the runtime -# library and again into the library torch loads at export time, because each side registers into a -# table the other never reads, so a second definer there is expected rather than a fault. A process -# that loads both does abort on the second registration, which is why this is named per component and -# the check stays armed for every other component, where a second definer means two registries or two thread -# pools in one process. +# The one component that legitimately exists twice. The code generator compiles the quantized kernel +# bodies into the library torch loads at export time, for the dispatcher half, and they are also in the +# runtime library that owns the ExecuTorch registrar. Measured on a shipped wheel: both define +# quantize_per_tensor_out, neither carries an ExecuTorch registrar of its own, and loading both in one +# process exits cleanly. So the duplicate here is a build artifact of the two registration mechanisms, +# not two registries. Named per component rather than switched off globally, because for every other +# component a second definer does mean two registries or two thread pools in one process. _COMPONENTS_WITH_AN_EXPORT_COPY = frozenset({"set of quantized kernels"}) @@ -642,8 +1015,8 @@ def test_python_extensions_import() -> None: The symbol and dependency checks work on the files. This covers the other half: an extension can be packaged correctly and still fail to load because a runtime path does not reach one of its dependencies. Run in a subprocess with - `LD_LIBRARY_PATH` removed so a value from the build environment cannot supply - a path the shipped library is missing. + the loader overrides removed so a value from the build environment cannot + supply a path the shipped library is missing. The list is discovered from the installed package rather than written here, so an extension added later is covered without anyone remembering to add it. A @@ -667,9 +1040,7 @@ def test_python_extensions_import() -> None: if importlib.util.find_spec("torch") is None: print("- torch is not installed, skipping the extension import check") return - environment = { - key: value for key, value in os.environ.items() if key != "LD_LIBRARY_PATH" - } + environment = _loader_clean_environment() for module in modules: result = subprocess.run( [sys.executable, "-c", f"import {module}"], @@ -789,6 +1160,210 @@ def _is_torch_library(name: str) -> bool: return name.startswith(_TORCH_LIBRARY_PREFIXES) +def _dyld_load_failure(library: Path, *, with_torch: bool) -> str: + """Load `library` in a fresh interpreter and return dyld's message, or "". + + macOS has no ldd, and otool reports what a library asks for rather than + whether the loader can find it, so the load itself has to be the check. + RTLD_NOW binds every symbol, which is what makes this the counterpart of + `ldd -r` rather than of plain `ldd`. + + A separate process each time, because dyld satisfies a request by install + name from what the process has already loaded. Loading in this one would let + an earlier library stand in for a later library's dependency, and in the + relocated check it would hand back the original file instead of the copy + under test, which is the failure that check exists to find. + """ + prologue = "import torch;" if with_torch else "" + result = subprocess.run( + [ + sys.executable, + "-c", + f"{prologue}import ctypes,os,sys;ctypes.CDLL(sys.argv[1],mode=os.RTLD_NOW)", + str(library), + ], + capture_output=True, + text=True, + check=False, + # Any loader override in the build environment would paper over a runtime + # search path the shipped library is actually missing. + env=_loader_clean_environment(), + ) + if result.returncode == 0: + return "" + return (result.stdout + result.stderr).strip() + + +def _dyld_missing_names(message: str) -> list[str]: + """The dependency file names dyld reported it could not load.""" + return [ + Path(entry).name for entry in re.findall(r"Library not loaded: (\S+)", message) + ] + + +def _assert_shipped_libraries_load_with_dyld() -> None: + """The macOS half of the load check, split out only because the tool differs. + + The classification is the Linux one: a dependency the wheel ships must resolve + here, a dependency it does not ship is the environment's to provide, and an + unresolved symbol is a library that would fail at first use rather than at + load. + """ + package_dir = _installed_package_dir() + libraries = _shipped_shared_objects(package_dir) + shipped = {library.name for library in libraries} + + broken = {} + unreachable = {} + unresolved = {} + for library in libraries: + message = _dyld_load_failure(library, with_torch=True) + if not message: + continue + key = str(library.relative_to(package_dir)) + missing = _dyld_missing_names(message) + present_but_unreachable = [name for name in missing if name in shipped] + absent = [ + name + for name in missing + if name not in shipped and not _is_torch_library(name) + ] + # Interpreter symbols are excluded for the same reason as on Linux: a + # library Python loads resolves them from the running interpreter, and + # they say nothing about how the wheel is packaged. + symbols = [ + symbol + for symbol in re.findall(r"Symbol not found: (\S+)", message) + if not re.match(r"_?_?Py", symbol) + ] + if present_but_unreachable: + unreachable[key] = present_but_unreachable + if absent: + broken[key] = absent + if symbols: + unresolved[key] = symbols[:5] + # A refusal that names neither a library nor a symbol is still a refusal, + # and dropping it would turn this check off for whatever caused it. + if not (present_but_unreachable or absent or symbols): + broken[key] = [message[:200]] + + assert not broken, ( + "shipped libraries need dependencies that nothing provides, so they will " + f"fail to load: {broken}" + ) + assert not unreachable, ( + "shipped libraries need dependencies the wheel ships but the loader " + "cannot reach from them, which usually means a missing rpath entry: " + f"{unreachable}" + ) + assert not unresolved, ( + "shipped libraries reference symbols nothing provides, so they will fail " + f"at first use rather than at load: {unresolved}" + ) + print("✓ every shipped library loads in an environment with torch present") + + +def _macho_rpaths(library: Path) -> list[str]: + """The LC_RPATH entries a Mach-O file carries, in load command order.""" + listing = subprocess.run( + [_tool("otool"), "-l", str(library)], + capture_output=True, + text=True, + check=True, + ).stdout + paths = [] + in_rpath = False + for line in listing.splitlines(): + stripped = line.strip() + if stripped.startswith("cmd "): + in_rpath = stripped == "cmd LC_RPATH" + elif in_rpath and stripped.startswith("path "): + paths.append(stripped[len("path ") :].rsplit(" (offset", 1)[0]) + in_rpath = False + return paths + + +def _assert_shipped_libraries_relocate_with_dyld() -> None: + """The macOS half of the relocated load check. + + Same shape as the Linux one: mirror the layout somewhere else, take away + every absolute runtime search path, and see whether what is left still finds + the libraries the wheel ships. + """ + # Fatal, not a skip: both come with the developer tools on the only platform that + # reaches here, so going quiet would report success having examined nothing, which + # is the failure this check was ported to macOS to end. + assert _tool("otool") is not None and _tool("install_name_tool") is not None, ( + "otool and install_name_tool are required to relocate a Mach-O file and read " + "back its runtime search paths" + ) + + package_dir = _installed_package_dir() + libraries = _shipped_shared_objects(package_dir) + shipped = {library.name for library in libraries} + + with tempfile.TemporaryDirectory() as work_dir: + root = Path(work_dir) / package_dir.name + # Mirror the layout so a relative path such as @loader_path/../../lib + # still points where it would in a real install. + for library in libraries: + target = root / library.relative_to(package_dir) + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(library, target) + + broken = {} + for library in libraries: + name = str(library.relative_to(package_dir)) + target = root / library.relative_to(package_dir) + for entry in _macho_rpaths(target): + # @loader_path and @executable_path are the relative forms this + # check exists to prove sufficient. Anything else names a + # directory on the machine that built the wheel. + if entry.startswith("@"): + continue + subprocess.run( + [_tool("install_name_tool"), "-delete_rpath", entry, str(target)], + capture_output=True, + # A failure here would leave the original absolute build paths + # in place, and the load below would then pass by resolving + # through them, which is what this check exists to rule out. + check=True, + ) + remaining = [ + entry for entry in _macho_rpaths(target) if not entry.startswith("@") + ] + assert not remaining, ( + f"{name} still carries the absolute runtime search paths {remaining} " + "after they were deleted, so loading it here would prove nothing" + ) + + message = _dyld_load_failure(target, with_torch=True) + if not message: + continue + missing = _dyld_missing_names(message) + # Only wheel-provided dependencies are asserted on, because an external + # one is expected to come from the environment. They are still reported, + # since silently dropping them would hide a library that resolves only + # through an absolute build path. + external = [item for item in missing if item not in shipped] + if external: + print(f"- {name} also needs {external} from the environment") + inside = [item for item in missing if item in shipped] + if inside: + broken[name] = inside + elif not missing: + # A refusal naming no library at all is not about where files were + # found, so it belongs to the load check above rather than here. + print(f"- {name} did not load here either: {message[:160]}") + + assert not broken, ( + "shipped libraries only resolve their wheel-provided dependencies " + "through absolute build paths, so they would fail on any other " + f"machine: {broken}" + ) + print("✓ every shipped library resolves without the build tree") + + def test_shipped_libraries_load() -> None: """Every shipped library must depend only on things that exist. @@ -801,15 +1376,18 @@ def test_shipped_libraries_load() -> None: dependencies into the process, so they intentionally carry no path to them. Only a name nothing in the wheel provides is a real problem. """ - if _tool("ldd") is None: - print("- ldd not available, skipping the load check") - return # Torch has to be installed for this to mean anything: several shipped libraries # depend on it and resolve once it is imported. Without it every one of them looks # broken, which would report a packaging fault that does not exist. if importlib.util.find_spec("torch") is None: print("- torch is not installed, skipping the load check") return + if sys.platform == "darwin": + _assert_shipped_libraries_load_with_dyld() + return + if _tool("ldd") is None: + print("- ldd not available, skipping the load check") + return package_dir = _installed_package_dir() libraries = _shipped_shared_objects(package_dir) @@ -834,13 +1412,9 @@ def test_shipped_libraries_load() -> None: capture_output=True, text=True, check=False, - # Any LD_LIBRARY_PATH in the build environment would paper over a + # Any loader override in the build environment would paper over a # RUNPATH the shipped library is actually missing. - env={ - key: value - for key, value in os.environ.items() - if key != "LD_LIBRARY_PATH" - }, + env=_loader_clean_environment(), ) # ldd reports missing libraries on stdout but undefined symbols on stderr, # so both streams matter. @@ -921,15 +1495,16 @@ def test_shipped_libraries_resolve_without_build_tree() -> None: mirrors the wheel layout, drop every absolute runtime path, and check what is left is enough. """ + if sys.platform == "darwin": + _assert_shipped_libraries_relocate_with_dyld() + return if _tool("ldd") is None or _tool("patchelf") is None: print("- ldd or patchelf unavailable, skipping the relocated load check") return package_dir = _installed_package_dir() libraries = _shipped_shared_objects(package_dir) - environment = { - key: value for key, value in os.environ.items() if key != "LD_LIBRARY_PATH" - } + environment = _loader_clean_environment() with tempfile.TemporaryDirectory() as work_dir: root = Path(work_dir) / package_dir.name @@ -1059,7 +1634,7 @@ def test_custom_op_compiles(work_dir: Path) -> None: "a custom operator does not compile or link against the shipped extension: " f"{(compiled.stderr or compiled.stdout).strip()[-800:]}" ) - produced = list(build_dir.rglob("libcustom_op_check.so")) or list( + produced = list(build_dir.rglob(_library_file_name("libcustom_op_check"))) or list( build_dir.rglob("custom_op_check.dll") ) assert produced, "the custom operator library was not produced" @@ -1099,9 +1674,7 @@ def test_custom_op_compiles(work_dir: Path) -> None: capture_output=True, text=True, check=False, - env={ - key: value for key, value in os.environ.items() if key != "LD_LIBRARY_PATH" - }, + env=_loader_clean_environment(), ) assert loaded.returncode == 0, ( "a custom operator built against the shipped extension cannot be loaded, or it " @@ -1211,12 +1784,32 @@ def test_wheel_platform_tag() -> None: one installs on machines it cannot run on at all, and that is a mistake this can actually catch. """ + wheels = _find_wheel_files() + if not wheels: + # Failing rather than returning, because this is the only check that reads the declared + # platform tag at all, and a silent return reads as a pass in the job summary. A caller + # that genuinely has no wheel says so. + assert os.environ.get("EXECUTORCH_TEST_WITHOUT_WHEEL"), ( + "no built wheel was found to inspect, so the platform tag was never checked. Run this " + "from a tree where the wheel was built, or set EXECUTORCH_TEST_WITHOUT_WHEEL to state " + "that no wheel is expected" + ) + print("- no wheel file to inspect, skipping the platform tag check") + return + + # Before auditwheel is provisioned, because the Mach-O route reads the architecture with lipo + # and never imports it. Installing a tool this platform does not use is one more way for a + # correct wheel to fail the job. + if sys.platform == "darwin": + _assert_mach_o_architecture_matches(wheels[-1]) + return + if importlib.util.find_spec("auditwheel") is None: # Installed here rather than skipped, because auditwheel is not in any CI # image and a skip is indistinguishable from a pass in the summary. This - # check is the only thing that compares the wheel's declared tag against - # what its libraries actually need, and this change adds five libraries - # under that tag. + # check is the only thing that reads the architecture out of the wheel's + # contents rather than out of its file name, and this change adds five + # libraries under that tag. print("- auditwheel not present, installing it so this check can run") installed = subprocess.run( [sys.executable, "-m", "pip", "install", "--quiet", "auditwheel"], @@ -1228,24 +1821,11 @@ def test_wheel_platform_tag() -> None: raise AssertionError( "auditwheel is required to check the wheel's platform tag and could not " "be installed. Skipping instead would report a pass, and this is the only " - "check that compares the declared tag against what the shipped libraries " - f"actually need: {installed.stderr.strip()[-200:]}" + "check that reads the architecture out of the wheel's contents rather than " + f"out of its file name: {installed.stderr.strip()[-200:]}" ) importlib.invalidate_caches() - wheels = _find_wheel_files() - if not wheels: - # Failing rather than returning, because this is the only check that compares the declared - # platform tag against what the libraries need, and a silent return reads as a pass in the - # job summary. A caller that genuinely has no wheel says so. - assert os.environ.get("EXECUTORCH_TEST_WITHOUT_WHEEL"), ( - "no built wheel was found to inspect, so the platform tag was never checked. Run this " - "from a tree where the wheel was built, or set EXECUTORCH_TEST_WITHOUT_WHEEL to state " - "that no wheel is expected" - ) - print("- no wheel file to inspect, skipping the platform tag check") - return - result = subprocess.run( [sys.executable, "-m", "auditwheel", "show", str(wheels[-1])], capture_output=True, @@ -1332,7 +1912,9 @@ def test_no_absolute_runtime_paths() -> None: # guarantee patchelf on PATH, so this is the only place the guarantee can be # enforced. If both went quiet on the same missing tool, a wheel carrying the # build machine's directories would ship looking correct. - if _tool("patchelf") is None: + # Mach-O keeps its search path in load commands that otool reads, and otool comes + # with the developer tools, so only the ELF side needs an install step. + if sys.platform != "darwin" and _tool("patchelf") is None: print("- patchelf not present, installing it so this check can run") subprocess.run( [sys.executable, "-m", "pip", "install", "--quiet", "patchelf"], @@ -1340,10 +1922,10 @@ def test_no_absolute_runtime_paths() -> None: text=True, check=False, ) - patchelf = _tool("patchelf") - assert patchelf is not None, ( - "patchelf is required to check the shipped runtime paths and could not be " - "installed. Packaging uses it to strip build-tree directories, and without it " + reader = _tool("otool") if sys.platform == "darwin" else _tool("patchelf") + assert reader is not None, ( + "a runtime path reader is required to check the shipped runtime paths and could " + "not be found. Packaging strips build-tree directories, and without a reader " "here neither side would notice that they were left in place." ) @@ -1399,28 +1981,19 @@ def test_no_absolute_runtime_paths() -> None: offenders = {} inspected = 0 with_a_runtime_path = 0 - for library in sorted(package_dir.rglob("*.so*")): - if not library.is_file() or library.is_symlink(): - continue - result = subprocess.run( - [patchelf, "--print-rpath", str(library)], - capture_output=True, - text=True, - check=False, - ) - if result.returncode != 0: + for library in _shipped_shared_objects(package_dir): + entries = _runtime_search_paths(library) + if entries is None: continue inspected += 1 - # An absent RPATH and one containing a single empty entry both print as an - # empty string, so treat empty output as "no runtime path" rather than as an - # empty entry. A library with nothing to search is fine; the defect is - # searching somewhere unusable. - raw = result.stdout.strip() - if not raw: + # A library with nothing to search is fine; the defect is searching somewhere + # unusable. An absent search path and one holding a single empty entry are the + # same thing here, and the reader reports both as an empty list. + if not entries: continue with_a_runtime_path += 1 bad = [] - for entry in raw.split(":"): + for entry in entries: if not entry: bad.append("") elif ( @@ -1471,10 +2044,6 @@ def test_extension_contains_no_component() -> None: what the shipped libraries own, and records a dependency on each instead. """ assert _tool("nm") is not None, "nm is required to inspect the wheel" - # Required rather than skipped over. This is the one check the split exists to - # make, so a missing tool has to stop the run: returning early here reports - # success for a wheel nothing looked at. - assert _tool("readelf") is not None, "readelf is required to inspect the wheel" package_dir = _installed_package_dir() extensions = sorted( @@ -1524,16 +2093,7 @@ def test_extension_contains_no_component() -> None: # And it has to actually depend on each shipped library. Defining nothing while # depending on nothing would be an extension that cannot work at all. - needed = { - line.split("[", 1)[1].rstrip("]").strip() - for line in subprocess.run( - [_tool("readelf"), "-d", str(extension)], - capture_output=True, - text=True, - check=True, - ).stdout.splitlines() - if "NEEDED" in line - } + needed = _recorded_dependencies(extension) # Only the libraries whose code the extension used to contain. Those are the ones # this split moved out of it, so the extension must now resolve them from outside or # a retention option silently failed. @@ -1575,7 +2135,7 @@ def test_extension_contains_no_component() -> None: # UNDEFINED reference cannot be faked that way: it says the definition is not # here and has to come from a dependency. undefined = subprocess.run( - [_tool("nm"), "-DC", "--undefined-only", str(extension)], + [_tool("nm"), *_nm_undefined_args(), str(extension)], capture_output=True, text=True, check=False, @@ -1641,7 +2201,9 @@ def test_shipped_library_names_are_expected() -> None: # stale-artifact case this check is about, and a leftover from an earlier build is # a real file, so it is still caught. shipped = sorted( - p for p in lib_dir.glob("*.so*") if p.is_file() and not p.is_symlink() + p + for p in lib_dir.glob(f"*{_dynamic_lib_suffix()}*") + if p.is_file() and not p.is_symlink() ) assert shipped, f"the wheel ships a lib directory with no libraries: {lib_dir}" @@ -1669,9 +2231,10 @@ def test_shipped_library_names_are_expected() -> None: "libexecutorch_threadpool", "libexecutorch_etdump", ) - # A plain .so, because the wheel build does not version these. A trailing - # .so. would also be a name packaging did not produce here. - permitted = re.compile(rf"(?:{'|'.join(known)})\.so") + # Unversioned, because the wheel build does not version these. A trailing + # . would also be a name packaging did not produce here. These are + # libraries, so the suffix follows the platform and macOS spells them .dylib. + permitted = re.compile(rf"(?:{'|'.join(known)})\{_dynamic_lib_suffix()}") unknown = sorted(p.name for p in shipped if not permitted.fullmatch(p.name)) assert not unknown, ( f"the wheel ships {unknown} under lib/, which packaging does not produce. " @@ -1679,36 +2242,33 @@ def test_shipped_library_names_are_expected() -> None: "and it ships while looking correct to every other check." ) - # Required rather than skipped over. Skipping would leave the soname half of this - # test unrun while the check mark below still reports the test as done, and the - # wheel-build environment has the tool anyway. - assert _tool("readelf") is not None, "readelf is required to inspect the wheel" - - # The recorded soname has to match the file name, or a consumer records a + # The recorded identity has to match the file name, or a consumer records a # dependency on a name the wheel does not contain. mismatched = {} + absolute = {} for library in shipped: - dynamic = subprocess.run( - [_tool("readelf"), "-d", str(library)], - capture_output=True, - text=True, - check=False, - ).stdout - soname = next( - ( - line.split("[", 1)[1].rstrip("]").strip() - for line in dynamic.splitlines() - if "SONAME" in line - ), - None, - ) - if soname != library.name: - mismatched[library.name] = soname + identity = _recorded_identity(library) + if identity != library.name: + mismatched[library.name] = identity + # Checked separately from the name comparison above, which deliberately reduces to a + # basename so an @rpath/ prefix compares equal. That reduction also makes an absolute + # install name compare equal, and a consumer copies the recorded string verbatim into its + # own load command, so an absolute one only resolves on the machine that built the wheel. + raw = _raw_recorded_identity(library) + if raw is not None and raw.startswith("/"): + absolute[library.name] = raw + # Before the name comparison: this is the more specific condition of the two. On ELF the + # recorded soname is returned whole, so an absolute one populates both sets, and reporting the + # name mismatch first would claim the wheel does not ship a file it does ship. + assert not absolute, ( + "shipped libraries record an absolute install name, so a consumer copies a path that " + f"exists only on the build machine: {absolute}" + ) assert not mismatched, ( - "shipped libraries record a soname that is not their file name, so a " + "shipped libraries record an identity that is not their file name, so a " f"consumer would look for a file the wheel does not ship: {mismatched}" ) - print(f"✓ {len(shipped)} shipped libraries have expected names and sonames") + print(f"✓ {len(shipped)} shipped libraries have expected names and identities") _PARITY_MODEL = ''' @@ -1835,10 +2395,20 @@ def test_declared_dependencies_match_the_wheel_tag() -> None: def distribution_name(requirement: str) -> str: return re.split(r"[\s;\[<>=!~(]", requirement.strip(), maxsplit=1)[0] + # The specifier is part of what is compared, not just the name: a bound is only correct + # relative to the train the libraries were linked against, so a name-only comparison accepted + # nvidia-cuda-runtime<13 and ==14.0 on a cu130 wheel. Compared through packaging's own parser + # rather than as text, because it reorders the clauses it emits: a declared ">=13,<14" appears + # in METADATA as "<14,>=13". A raw string comparison therefore failed on a correctly built + # wheel, which is a self-inflicted gate rather than a defect in the wheel. + def normalized_requirement(requirement: str) -> str: + parsed = Requirement(requirement) + return f"{canonicalize_name(parsed.name)}{parsed.specifier}" + cuda = sorted( - name - for name in (distribution_name(r) for r in requirements) - if name.lower().startswith("nvidia") + normalized_requirement(r) + for r in requirements + if distribution_name(r).lower().startswith("nvidia") ) # The local version segment of the installed version states what the wheel was built for. @@ -1859,7 +2429,10 @@ def distribution_name(requirement: str) -> str: # declares one package and omits the others still cannot load, and one-direction only # would accept it. train = local[len("cu") : len("cu") + 2] - expected = set(_EXPECTED_CUDA_PACKAGES.get(train, ())) + expected = { + normalized_requirement(requirement) + for requirement in _EXPECTED_CUDA_PACKAGES.get(train, ()) + } assert expected, ( f"version {version} names CUDA train {train}, which this check has no expected " f"package list for. Add it beside the packaging list it mirrors." @@ -1867,6 +2440,22 @@ def distribution_name(requirement: str) -> str: actual = set(cuda) wrong = sorted(actual - expected) missing = sorted(expected - actual) + # Split by distribution name before the set difference is reported, so the message names the + # actual fault. Comparing whole requirement strings makes a correct package with a missing + # bound look identical to a package from the wrong train, and the second message would be + # false. Diagnostic precision is the reason this comparison is exact rather than a heuristic. + expected_by_name = {distribution_name(r): r for r in expected} + misbounded = sorted( + (declared, expected_by_name[distribution_name(declared)]) + for declared in wrong + if distribution_name(declared) in expected_by_name + ) + assert not misbounded, ( + f"version {version} is a CUDA {train} wheel and names the right runtime packages, but " + f"their version specifiers are not the ones packaging declares: " + + ", ".join(f"{d!r} should be {e!r}" for d, e in misbounded) + + ". A user could resolve a CUDA major whose libcudart this wheel's libraries cannot load." + ) assert not wrong, ( f"version {version} is a CUDA {train} wheel, but it declares {wrong}, which belong to " f"another CUDA train. Expected only {sorted(expected)}. A user would install a runtime " diff --git a/CMakeLists.txt b/CMakeLists.txt index 3632b3df581..85374a356d2 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -216,17 +216,16 @@ if(EXECUTORCH_BUILD_SHARED) "the wheel ships, or BUILD_SHARED_LIBS=ON on its own for CMake's per-target shared layout." ) endif() - # Linux only, and said here rather than left to fail somewhere downstream. The - # shared build names libraries with an ELF soname, records $ORIGIN runtime - # paths, and uses GNU linker options to keep a registration-only library on a - # link line. None of that applies on Apple, which is served by the Swift - # package distribution, or on Windows, where the runtime carries no export - # annotations for a DLL. Enabling it elsewhere failed much later and less - # clearly, when packaging looked for a .so the build never emitted. - if(NOT CMAKE_SYSTEM_NAME STREQUAL "Linux") + # Said here rather than left to fail somewhere downstream, where packaging + # looked for a library the build never emitted. Windows is still refused: + # there the runtime carries no export annotations, so a DLL would link against + # nothing, which is a missing capability rather than a different spelling of + # one. + if(NOT CMAKE_SYSTEM_NAME STREQUAL "Linux" AND NOT APPLE) message( - FATAL_ERROR "EXECUTORCH_BUILD_SHARED is supported on Linux only, not " - "${CMAKE_SYSTEM_NAME}." + FATAL_ERROR + "EXECUTORCH_BUILD_SHARED is supported on Linux and macOS only, not " + "${CMAKE_SYSTEM_NAME}." ) endif() set(CMAKE_POSITION_INDEPENDENT_CODE ON) @@ -1260,8 +1259,17 @@ if(EXECUTORCH_BUILD_PYBIND) # RPATH for _portable_lib.so. It sits in # /executorch/extension/pybindings, so torch is three levels up - # and the wheel's own lib/ directory is two. - set(_portable_lib_rpath "$ORIGIN/../../../torch/lib") + # and the wheel's own lib/ directory is two. Mach-O spells the loader relative + # token differently and takes a list rather than a colon joined string, so + # both differ here while the layout reasoning does not. + if(APPLE) + set(_portable_lib_origin "@loader_path") + set(_portable_lib_rpath_separator ";") + else() + set(_portable_lib_origin "$ORIGIN") + set(_portable_lib_rpath_separator ":") + endif() + set(_portable_lib_rpath "${_portable_lib_origin}/../../../torch/lib") # An editable install copies this extension into a package directory whose # subdirectories are symlinks to the checkout, and the loader resolves the # origin token against the real path, so the runtime sits two levels up and @@ -1316,12 +1324,20 @@ if(EXECUTORCH_BUILD_PYBIND) endif() if(EXECUTORCH_BUILD_CUDA OR EXECUTORCH_BUILD_ROCM) - string(APPEND _portable_lib_rpath ":$ORIGIN/../../backends/cuda") + string( + APPEND + _portable_lib_rpath + "${_portable_lib_rpath_separator}${_portable_lib_origin}/../../backends/cuda" + ) endif() if(EXECUTORCH_BUILD_QNN) list(APPEND _dep_libs qnn_executorch_backend) - string(APPEND _portable_lib_rpath ":$ORIGIN/../../backends/qualcomm") + string( + APPEND + _portable_lib_rpath + "${_portable_lib_rpath_separator}${_portable_lib_origin}/../../backends/qualcomm" + ) endif() if(EXECUTORCH_BUILD_ENN) @@ -1415,22 +1431,40 @@ if(EXECUTORCH_BUILD_PYBIND) endif() endforeach() - # Set RPATH to find PyTorch and backend libraries relative to the installation - # location. This goes from executorch/extension/pybindings up to - # site-packages, then to torch/lib. If QNN is enabled, also add - # backends/qualcomm/. Don't do this to APPLE, as it will error out on the - # following error: + # Where the extension finds PyTorch and the backend libraries, relative to + # where it is installed. # + # Mach-O gets the Torch hop only, because nothing else records it, while every + # other entry in this list arrives another way: + # executorch_target_shared_runtime_path below emits lib and + # src/executorch/lib, and setup.py's _SIBLING_LIBRARY_DIRECTORIES adds + # backends/cuda in the platform's own token at packaging time. + # backends/qualcomm is in neither, so it is dropped on Mach-O, which is + # correct because QNN is not a macOS backend. + # + # The Torch hop cannot be left out. Without it this extension records no + # relative route to Torch at all, and the absolute one packaging leaves behind + # is the build machine's own directory, so the module loads only when + # something has already brought libtorch_python into the process. Importing it + # directly, or dlopening it from a C++ consumer, finds nothing. The sibling + # _training_lib records @loader_path/../../../../torch/lib and loads on its + # own, which is the same construct from one directory deeper + # (extension/training/pybindings rather than extension/pybindings), and three + # more targets do it too: extension/llm/custom_ops, extension/llm/runner and + # kernels/quantized. + # + # The earlier "ld: duplicate LC_RPATH '@loader_path'" is about the bare token. + # A token with a subpath is a distinct entry, and this target already ships + # several. if(APPLE) - # Skip setting @loader_path for APPLE, since it causes error like ld: - # duplicate LC_RPATH '@loader_path' in '/torch/lib/ - # libtorch_cpu.dylib' + set(_portable_lib_torch_path "@loader_path/../../../torch/lib") else() - set_target_properties( - portable_lib PROPERTIES BUILD_RPATH "${_portable_lib_rpath}" - INSTALL_RPATH "${_portable_lib_rpath}" - ) + set(_portable_lib_torch_path "${_portable_lib_rpath}") endif() + set_target_properties( + portable_lib PROPERTIES BUILD_RPATH "${_portable_lib_torch_path}" + INSTALL_RPATH "${_portable_lib_torch_path}" + ) executorch_target_shared_runtime_path( portable_lib "extension/pybindings" "executorch/extension/pybindings" ) diff --git a/README-wheel.md b/README-wheel.md index f3a89f342ee..8ee58b56f2f 100644 --- a/README-wheel.md +++ b/README-wheel.md @@ -22,8 +22,8 @@ The prebuilt `executorch.runtime` module included in this package provides a way to run ExecuTorch `.pte` files, with some restrictions: * Only [core ATen operators](docs/source/ir-ops-set-definition.md) are linked into the prebuilt module * Only the [XNNPACK backend delegate](docs/source/backends/xnnpack/xnnpack-overview.md) is linked into the prebuilt module. -* \[macOS only] [Core ML](docs/source/backends/coreml/coreml-overview.md) and [MPS](docs/source/backends/mps/mps-overview.md) backend - are also linked into the prebuilt module. +* \[macOS only] [Core ML](docs/source/backends/coreml/coreml-overview.md) and MLX backends are + also linked into the prebuilt module. * \[Linux x86_64] [QNN](docs/source/backends-qualcomm.md) backend is linked into the prebuilt module. * \[Linux] [OpenVINO](docs/source/build-run-openvino.md) backend is also linked into the prebuilt module. OpenVINO requires the runtime to be installed separately: diff --git a/docs/source/using-executorch-cpp.md b/docs/source/using-executorch-cpp.md index 74690fb5c42..f5772cdb066 100644 --- a/docs/source/using-executorch-cpp.md +++ b/docs/source/using-executorch-cpp.md @@ -47,9 +47,9 @@ cover. ### Using the prebuilt libraries from the pip package -On Linux, `pip install executorch` ships the runtime as prebuilt shared libraries together with the -headers and a CMake package. So a C++ program can use ExecuTorch without building it from source, -and without knowing much CMake. +On Linux and macOS, `pip install executorch` ships the runtime as prebuilt shared libraries together +with the headers and a CMake package. So a C++ program can use ExecuTorch without building it from +source, and without knowing much CMake. #### Run your first model in four steps @@ -168,16 +168,16 @@ target_link_libraries(app PRIVATE executorch::runtime executorch::backend_xnnpack) ``` -These are the components the Linux package provides: +These are the components the package provides: | Component | What it gives you | Where | | --- | --- | --- | -| `runtime` | The engine. Always needed. | Linux | -| `kernels_optimized` | Fast CPU operators. The usual choice. | Linux | -| `backend_xnnpack` | The XNNPACK backend, for models exported with it. | Linux | -| `threadpool` | Multi-threaded execution. | Linux | -| `etdump` | Profiling, to record what ran and how long it took. | Linux | -| `kernels_quantized` | The quantized operator kernels | Linux | +| `runtime` | The engine. Always needed. | Linux, macOS | +| `kernels_optimized` | Fast CPU operators. The usual choice. | Linux, macOS | +| `backend_xnnpack` | The XNNPACK backend, for models exported with it. | Linux, macOS | +| `threadpool` | Multi-threaded execution. | Linux, macOS | +| `etdump` | Profiling, to record what ran and how long it took. | Linux, macOS | +| `kernels_quantized` | The quantized operator kernels | Linux, macOS | | `backend_cuda` | The CUDA delegate | Linux | | `extension_cuda` | The CUDA stream extension | Linux | | `backend_openvino` | The OpenVINO delegate | Linux | @@ -195,9 +195,14 @@ foreach(_component endforeach() ``` -The macOS wheel keeps everything inside the Python extension rather than shipping separate C++ -libraries, so there is nothing for a C++ application to link there; use it from Python, or build -from source if you need C++ on macOS. +On macOS the Core ML and MLX delegates are registered inside the Python extension rather than +shipped as separate C++ libraries, so a C++ application there cannot link them as components; use +them from Python, or build from source if you need them in C++. + +Profiling a Core ML model records `DELEGATE_CALL`, which tells you how long the delegate ran in +total. It does not record the individual operators inside the delegate, because that detail comes +from the Core ML developer tools sources, and the wheel does not build them. An XNNPACK model +records both. A backend is only needed if the model was exported for it. Linking XNNPACK does not make a plain model faster, and a model exported for XNNPACK will fail to load without it. If you are not sure diff --git a/extension/training/CMakeLists.txt b/extension/training/CMakeLists.txt index 04a880a4043..7b76723fb08 100644 --- a/extension/training/CMakeLists.txt +++ b/extension/training/CMakeLists.txt @@ -108,12 +108,17 @@ if(EXECUTORCH_BUILD_PYBIND) endif() executorch_target_link_shared_runtime(_training_lib) - if(EXECUTORCH_BUILD_SHARED AND NOT APPLE) + if(EXECUTORCH_BUILD_SHARED) # This module links Torch directly, and the only other entry reaching it is # the absolute build directory CMake adds, which does not exist anywhere # else, so the Torch path is recorded here rather than left implicit. + if(APPLE) + set(_training_torch_path "@loader_path/../../../../torch/lib") + else() + set(_training_torch_path "$ORIGIN/../../../../torch/lib") + endif() set_target_properties( - _training_lib PROPERTIES INSTALL_RPATH "$ORIGIN/../../../../torch/lib" + _training_lib PROPERTIES INSTALL_RPATH "${_training_torch_path}" ) executorch_target_shared_runtime_path( _training_lib "extension/training/pybindings" diff --git a/kernels/quantized/CMakeLists.txt b/kernels/quantized/CMakeLists.txt index ca24ccca035..cbb03b7ca18 100644 --- a/kernels/quantized/CMakeLists.txt +++ b/kernels/quantized/CMakeLists.txt @@ -171,8 +171,19 @@ if(NOT CMAKE_GENERATOR STREQUAL "Xcode" # route to the runtime, and overwriting the property here would drop it. get_target_property(_existing quantized_ops_aot_lib INSTALL_RPATH) if(_existing) - set(RPATH "${_existing}:${RPATH}") + # Mach-O keeps one entry per load command, so the two are a CMake list + # there. Joining them with a colon produced a single unusable path + # containing both, and the library then found neither the runtime nor + # the extension. + if(APPLE) + set(RPATH "${_existing};${RPATH}") + else() + set(RPATH "${_existing}:${RPATH}") + endif() endif() + # Quoted, because on Apple this is a list: unquoted it expands into + # separate arguments and the trailing entries are read as further property + # keywords, leaving the search path empty. set_target_properties( quantized_ops_aot_lib PROPERTIES BUILD_RPATH "${RPATH}" INSTALL_RPATH "${RPATH}" diff --git a/setup.py b/setup.py index 50a545d33a2..1577e8c273f 100644 --- a/setup.py +++ b/setup.py @@ -518,7 +518,18 @@ def _sibling_library_search_paths(depth: int = 1) -> List[str]: matching SONAME could satisfy the dependency first. """ up = "/".join([".."] * depth) - return [f"$ORIGIN/{up}/{directory}" for directory in _SIBLING_LIBRARY_DIRECTORIES] + token = _loader_relative_token() + return [f"{token}/{up}/{directory}" for directory in _SIBLING_LIBRARY_DIRECTORIES] + + +def _loader_relative_token() -> str: + """The token a runtime search path uses to mean "the directory this file is in". + + ELF spells it $ORIGIN and Mach-O spells it @loader_path. Both are literal text in the + recorded path, so the wrong one becomes a directory of that name and resolves to + nothing. + """ + return "@loader_path" if sys.platform == "darwin" else "$ORIGIN" def _cuda_runtime_search_paths(depth: int = 1) -> List[str]: @@ -532,7 +543,7 @@ def _cuda_runtime_search_paths(depth: int = 1) -> List[str]: train = _cuda_train() out = "/".join([".."] * (depth + 1)) return [ - f"$ORIGIN/{out}/{directory}" + f"{_loader_relative_token()}/{out}/{directory}" for directory in _CUDA_LIBRARY_DIRECTORIES.get(train, ()) ] @@ -1173,6 +1184,94 @@ def _append_relative_search_paths(entries: List[str], depth: int = 1) -> None: entries.append(search_path) +def _write_runtime_paths( + library: Path, + tool: str, + original: str, + found: List[str], + entries: List[str], + is_mach_o: bool, +) -> None: + """Record the filtered runtime search path back onto the library. + + ELF holds one value that is replaced outright. Mach-O holds one load command per entry + with nothing to overwrite, so the difference is applied as deletes and adds. + + Told which format this is rather than reading the suffix, because a macOS Python extension + is Mach-O while named .so, and deciding here produced patchelf syntax passed to otool. + """ + if not is_mach_o: + rewritten = ":".join(entries) + if rewritten == original: + return + subprocess.run( + [tool, "--set-rpath", rewritten, os.fspath(library)], + check=True, + ) + return + install_name_tool = shutil.which("install_name_tool") + if install_name_tool is None: + # The caller checks for this tool before deciding to clean a Mach-O library, so reaching + # here means the environment changed underneath. Leaving the paths in place would ship a + # library naming the build machine, so say so rather than continue quietly. + raise RuntimeError( + "install_name_tool disappeared while cleaning " + os.fspath(library) + ) + # Checked rather than best effort. A silent failure here leaves the entry the caller asked to + # remove, or omits the one it asked to add, and packaging then reports success on a library whose + # search paths are wrong. The result is a wheel that either carries the build machine's directories + # or cannot find torch, and neither is visible until something loads it. + for entry in found: + if entry not in entries: + _run_install_name_tool( + [install_name_tool, "-delete_rpath", entry, os.fspath(library)], library + ) + for entry in entries: + if entry not in found: + _run_install_name_tool( + [install_name_tool, "-add_rpath", entry, os.fspath(library)], library + ) + + +def _run_install_name_tool(command: List[str], library: Path) -> None: + """Run an install_name_tool rewrite, raising with its output if it fails. + + Separate from the call sites so both the delete and the add report the same way, and so a future + third rewrite cannot reintroduce the silent form. + """ + result = subprocess.run(command, capture_output=True, check=False) + if result.returncode != 0: + raise RuntimeError( + f"{' '.join(command[1:3])} failed on {library.name}, so its runtime search paths are not " + f"what this build intended: {(result.stderr or result.stdout).decode(errors='replace').strip()}" + ) + + +def _parse_runtime_paths(original: str, is_mach_o: bool) -> List[str]: + """The runtime search path entries a tool reported, as a list. + + The two formats differ in shape, not just spelling: ELF keeps one colon separated + string, while Mach-O keeps a separate load command per entry, so one cannot be + split the way the other is. + """ + if not is_mach_o: + return original.split(":") + found = [] + lines = original.splitlines() + for index, line in enumerate(lines): + if "LC_RPATH" not in line: + continue + for following in lines[index + 1 : index + 4]: + stripped = following.strip() + if stripped.startswith("path "): + found.append(stripped.split(" (offset", 1)[0][len("path ") :]) + break + # One entry per distinct path, order preserved. Mach-O can carry the same LC_RPATH in two load + # commands, and the caller issues one -delete_rpath per element: the second finds nothing left to + # delete and fails, which now aborts packaging rather than being silently absorbed. + return list(dict.fromkeys(found)) + + def _is_usable_runtime_path( entry: str, safe_to_drop_toolkit_paths: bool, @@ -1254,25 +1353,36 @@ def _strip_absolute_runtime_paths(library: Path, ships_cuda: bool) -> None: the release tests treats a missing patchelf as a failure rather than a skip: the wheel-build environment has it, and that is where the guarantee belongs. """ - if library.suffix != ".so" and ".so." not in library.name: + # Decided by the platform rather than the suffix, because a Python extension on macOS is + # Mach-O while being named .so: measured on a shipped macOS wheel, every .dylib came out + # clean and the extension still carried five build directories, because the suffix test sent + # it to patchelf, which does not exist there. + is_mach_o = sys.platform == "darwin" + if library.suffix not in (".dylib", ".so") and ".so." not in library.name: return - patchelf = shutil.which("patchelf") - if patchelf is None: + tool = shutil.which("otool") if is_mach_o else shutil.which("patchelf") + if tool is None or (is_mach_o and shutil.which("install_name_tool") is None): + needed = "otool and install_name_tool" if is_mach_o else "patchelf" if ships_cuda: raise RuntimeError( - "patchelf was not found on PATH, and this is a CUDA wheel. The relative " - "search path that lets the delegate reach the CUDA runtime beside it is " - "written here and nowhere else, so the wheel would install and then fail " - "to load. Install it and build again." + f"{needed} was not found on PATH, and this is a CUDA wheel. The relative " + f"search path that lets the delegate reach the CUDA runtime beside it is " + f"written here and nowhere else, so the wheel would install and then fail " + f"to load. Install it and build again." ) log.warn( - f"patchelf was not found on PATH, so {library.name} keeps the absolute search " - "paths the linker recorded. The wheel still works; the release check is what " - "enforces the guarantee." + f"{needed} was not found on PATH, so {library.name} keeps the absolute search " + "paths the linker recorded, including any that name this machine. " + "test_no_absolute_runtime_paths rejects those, so a wheel built without the tool " + "fails that check rather than shipping quietly." ) return result = subprocess.run( - [patchelf, "--print-rpath", os.fspath(library)], + ( + [tool, "-l", os.fspath(library)] + if is_mach_o + else [tool, "--print-rpath", os.fspath(library)] + ), capture_output=True, text=True, check=False, @@ -1299,13 +1409,15 @@ def _strip_absolute_runtime_paths(library: Path, ships_cuda: bool) -> None: _cuda_runtime_search_paths(_package_relative_depth(library)) ) + found = _parse_runtime_paths(original, is_mach_o) + # Whether the library can still reach torch without the absolute entry. has_relative_torch_route = any( not entry.startswith("/") and entry.rstrip("/").endswith("/torch/lib") - for entry in original.split(":") + for entry in found ) entries = [ entry - for entry in original.split(":") + for entry in found if _is_usable_runtime_path( entry, safe_to_drop_toolkit_paths, has_relative_torch_route ) @@ -1316,13 +1428,7 @@ def _strip_absolute_runtime_paths(library: Path, ships_cuda: bool) -> None: # which names the build machine and will not exist for a user who installed from an # index. Appended, so a path already present keeps its position. _append_relative_search_paths(entries, _package_relative_depth(library)) - rewritten = ":".join(entries) - if rewritten == original: - return - subprocess.run( - [patchelf, "--set-rpath", rewritten, os.fspath(library)], - check=True, - ) + _write_runtime_paths(library, tool, original, found, entries, is_mach_o) class CustomBuildPy(build_py): @@ -2054,8 +2160,8 @@ def run(self): # noqa C901 # what links it, which never happens inside a wheel. BuiltFile( src_dir="%CMAKE_CACHE_DIR%/", - src_name="libexecutorch.so", - dst="executorch/lib/libexecutorch.so", + src_name=get_dynamic_lib_name("executorch"), + dst="executorch/lib/" + get_dynamic_lib_name("executorch"), dependent_cmake_flags=["EXECUTORCH_BUILD_SHARED"], ), # Install the profiler next to it, as its own library rather than @@ -2063,8 +2169,8 @@ def run(self): # noqa C901 # it however many consumers load. BuiltFile( src_dir="%CMAKE_CACHE_DIR%/devtools/etdump/", - src_name="libexecutorch_etdump.so", - dst="executorch/lib/libexecutorch_etdump.so", + src_name=get_dynamic_lib_name("executorch_etdump"), + dst="executorch/lib/" + get_dynamic_lib_name("executorch_etdump"), # Not gated on EXECUTORCH_BUILD_DEVTOOLS. The shared build adds # the devtools subdirectory itself, so the library exists # whenever the shared build does. The Python extension carries a @@ -2077,8 +2183,9 @@ def run(self): # noqa C901 # component that uses it. BuiltFile( src_dir="%CMAKE_CACHE_DIR%/extension/threadpool/", - src_name="libexecutorch_threadpool.so", - dst="executorch/lib/libexecutorch_threadpool.so", + src_name=get_dynamic_lib_name("executorch_threadpool"), + dst="executorch/lib/" + + get_dynamic_lib_name("executorch_threadpool"), # The target only exists when both of its dependencies are # enabled, so packaging has to require them too or a shared # build with either turned off looks for a file that was @@ -2093,8 +2200,9 @@ def run(self): # noqa C901 # registered once per process rather than once per component. BuiltFile( src_dir="%CMAKE_CACHE_DIR%/configurations/", - src_name="libexecutorch_kernels_optimized.so", - dst="executorch/lib/libexecutorch_kernels_optimized.so", + src_name=get_dynamic_lib_name("executorch_kernels_optimized"), + dst="executorch/lib/" + + get_dynamic_lib_name("executorch_kernels_optimized"), # The target is only created when the optimized kernels are # enabled, so packaging has to require that too rather than # looking for a file a shared build may never have produced. @@ -2109,8 +2217,9 @@ def run(self): # noqa C901 # CPU-only build never produced. BuiltFile( src_dir="%CMAKE_CACHE_DIR%/backends/cuda/", - src_name="libexecutorch_backend_cuda.so", - dst="executorch/lib/libexecutorch_backend_cuda.so", + src_name=get_dynamic_lib_name("executorch_backend_cuda"), + dst="executorch/lib/" + + get_dynamic_lib_name("executorch_backend_cuda"), dependent_cmake_flags=[ "EXECUTORCH_BUILD_SHARED", "EXECUTORCH_BUILD_CUDA", @@ -2142,8 +2251,9 @@ def run(self): # noqa C901 # them before. BuiltFile( src_dir="%CMAKE_CACHE_DIR%/kernels/quantized/", - src_name="libexecutorch_kernels_quantized.so", - dst="executorch/lib/libexecutorch_kernels_quantized.so", + src_name=get_dynamic_lib_name("executorch_kernels_quantized"), + dst="executorch/lib/" + + get_dynamic_lib_name("executorch_kernels_quantized"), dependent_cmake_flags=[ "EXECUTORCH_BUILD_SHARED", "EXECUTORCH_BUILD_KERNELS_QUANTIZED", @@ -2165,8 +2275,9 @@ def run(self): # noqa C901 # copy of it instead of one per component that uses it. BuiltFile( src_dir="%CMAKE_CACHE_DIR%/backends/xnnpack/", - src_name="libexecutorch_backend_xnnpack.so", - dst="executorch/lib/libexecutorch_backend_xnnpack.so", + src_name=get_dynamic_lib_name("executorch_backend_xnnpack"), + dst="executorch/lib/" + + get_dynamic_lib_name("executorch_backend_xnnpack"), dependent_cmake_flags=[ "EXECUTORCH_BUILD_SHARED", "EXECUTORCH_BUILD_XNNPACK", diff --git a/tools/cmake/Utils.cmake b/tools/cmake/Utils.cmake index a6b5d1b5179..f9f04cda758 100644 --- a/tools/cmake/Utils.cmake +++ b/tools/cmake/Utils.cmake @@ -68,12 +68,22 @@ function(executorch_target_whole_archive target_name archive_target) # de-duplication problem above, and because this file's pre-existing # SHELL:LINKER: helpers already break on a path containing a space, which is # the more common case. Both fail loudly at link time rather than producing a - # binary whose registrations are quietly missing. - target_link_options( - ${target_name} - PRIVATE - "LINKER:--push-state,--whole-archive,$,--pop-state" - ) + # binary whose registrations are quietly missing. Mach-O has no bracketing + # pair: -force_load takes the archive directly, so the push and pop that scope + # --whole-archive have no counterpart and must not be emitted. ld rejects them + # outright rather than ignoring them. + if(APPLE) + target_link_options( + ${target_name} PRIVATE + "SHELL:LINKER:-force_load,$" + ) + else() + target_link_options( + ${target_name} + PRIVATE + "LINKER:--push-state,--whole-archive,$,--pop-state" + ) + endif() # Also link it the ordinary way. A link option naming a file is not a build # prerequisite, so on its own it lets the archive be rebuilt while the library # bundling it keeps the previous contents, which is a stale registration @@ -90,16 +100,26 @@ function(executorch_target_link_options_shared_lib target_name) # constructor. Export scoped --no-as-needed retention instead, which is what # actually keeps a registration-only shared library on the link line. get_target_property(_target_type ${target_name} TYPE) - if(_target_type STREQUAL "SHARED_LIBRARY" AND NOT (APPLE OR MSVC)) - target_link_options( - ${target_name} - INTERFACE - # One option with the library inside it, for two reasons. A SHELL: string - # would split on spaces and break a path containing one, and separate - # options repeat identical text that CMake de-duplicates, which silently - # leaves every library after the first outside any --no-as-needed scope. - "LINKER:--push-state,--no-as-needed,$,--pop-state" - ) + # A shared library is never an archive, so the archive handling below does not + # apply to one on any platform. On Apple it actively harms: -force_load on a + # shared library makes every consumer absorb a copy of its contents, which put + # a second operator registry inside the runtime library. + if(_target_type STREQUAL "SHARED_LIBRARY" AND NOT MSVC) + # Mach-O keeps a library named on the link line whether or not anything + # references it, so there is nothing to counter and ld rejects the GNU + # flags. + if(NOT APPLE) + target_link_options( + ${target_name} + INTERFACE + # One option with the library inside it, for two reasons. A SHELL: + # string would split on spaces and break a path containing one, and + # separate options repeat identical text that CMake de-duplicates, which + # silently leaves every library after the first outside any + # --no-as-needed scope. + "LINKER:--push-state,--no-as-needed,$,--pop-state" + ) + endif() # Retention is fully handled above, and applying whole-archive to a shared # library below would do nothing: that flag governs archive member # extraction, and this target is not an archive. @@ -335,12 +355,32 @@ function(executorch_target_retain_shared_library target_name library_target) # Scoped per library for the same reason as whole-archive above: unique option # text, so nothing is de-duplicated out of the retention scope. Without this a # registration-only library is dropped under the default --as-needed and its - # static initializer never runs. - target_link_options( - ${target_name} - PRIVATE - "LINKER:--push-state,--no-as-needed,$,--pop-state" - ) + # static initializer never runs. Mach-O records a library named on the link + # line whether or not anything references it, so there is nothing to counter + # and ld rejects the GNU flags. Named as a link option on Apple too, because + # CMake emits options before every ordered link library. That ordering is what + # makes the shared runtime resolve the registry symbols ahead of a static + # archive that also defines them, so no archive member is extracted and the + # process keeps one registry. Measured on the GNU side: the option sits at + # link slot 9 and the archive at 17, and the archive member is never + # extracted. Mach-O keeps a named library regardless, so the path alone is + # enough there and the --as-needed dance does not apply. + if(APPLE) + # Plain rather than SHELL:. This file already states the reason at the + # --no-as-needed helper above: a SHELL: string splits on spaces and breaks a + # path containing one. That helper needs SHELL: anyway because LINKER: joins + # its arguments with commas; there is no LINKER: prefix here, so SHELL: buys + # nothing and only takes on the space hazard. + target_link_options( + ${target_name} PRIVATE "$" + ) + else() + target_link_options( + ${target_name} + PRIVATE + "LINKER:--push-state,--no-as-needed,$,--pop-state" + ) + endif() target_link_libraries(${target_name} PRIVATE ${library_target}) endfunction() @@ -355,8 +395,32 @@ endfunction() # recorded directories are what resolves them there, and packaging strips them # so nothing absolute ships. function(executorch_target_shipped_runtime_path target_name) + # Mach-O spells the same idea @loader_path. + if(APPLE) + set(_origin "@loader_path") + else() + set(_origin "$ORIGIN") + endif() + # Appended rather than assigned, because a caller may have set INSTALL_RPATH + # to reach its own dependencies and replacing it silently drops them from the + # installed library. The origin entry goes first, since the shipped libraries + # sit beside this target, and anything the caller asked for follows as a + # fallback. + get_target_property( + _executorch_existing_install_rpath ${target_name} INSTALL_RPATH + ) + if(NOT _executorch_existing_install_rpath) + set(_executorch_existing_install_rpath "${CMAKE_INSTALL_RPATH}") + endif() + set(_executorch_install_rpath "${_origin}") + foreach(_entry IN LISTS _executorch_existing_install_rpath) + if(NOT _entry STREQUAL "${_origin}") + list(APPEND _executorch_install_rpath "${_entry}") + endif() + endforeach() set_target_properties( - ${target_name} PROPERTIES BUILD_RPATH "$ORIGIN" INSTALL_RPATH "$ORIGIN" + ${target_name} PROPERTIES BUILD_RPATH "${_origin}" + INSTALL_RPATH "${_executorch_install_rpath}" ) endfunction() @@ -378,19 +442,28 @@ endfunction() function(executorch_target_shared_runtime_path target_name wheel_subdir install_destination ) - if(NOT EXECUTORCH_BUILD_SHARED OR APPLE) + if(NOT EXECUTORCH_BUILD_SHARED) return() endif() + # Mach-O spells the token differently and takes a list rather than a colon + # joined string, so both differ here while the mechanism does not. + if(APPLE) + set(_origin "@loader_path") + set(_separator ";") + else() + set(_origin "$ORIGIN") + set(_separator ":") + endif() # Up out of the subdirectory, then into the package's lib/. string(REGEX REPLACE "[^/]+" ".." _up "${wheel_subdir}") - set(_paths "$ORIGIN/${_up}/lib") + set(_paths "${_origin}/${_up}/lib") # A checkout has neither the wheel directory nor the install prefix, and the # only place the runtime exists is the package directory, which is a symlink # farm. The loader resolves the origin token against the real path, so # reaching it takes the same hops plus src/executorch. Emitting it here keeps # a source build working without a post-link rewrite, which needs a tool the # install path does not provide. - string(APPEND _paths ":$ORIGIN/${_up}/src/executorch/lib") + string(APPEND _paths "${_separator}${_origin}/${_up}/src/executorch/lib") # Made absolute lexically, so a destination that is already absolute, as a # ${CMAKE_INSTALL_LIBDIR} based one becomes, is handled the same as a prefix # relative one. @@ -415,10 +488,10 @@ function(executorch_target_shared_runtime_path target_name wheel_subdir file(RELATIVE_PATH _to_libdir "${_installed_dir}" "${CMAKE_INSTALL_FULL_LIBDIR}" ) - string(APPEND _paths ":$ORIGIN/${_to_libdir}") + string(APPEND _paths "${_separator}${_origin}/${_to_libdir}") get_target_property(_existing ${target_name} INSTALL_RPATH) if(_existing) - set(_paths "${_existing}:${_paths}") + set(_paths "${_existing}${_separator}${_paths}") endif() set_target_properties( ${target_name} PROPERTIES BUILD_RPATH "${_paths}" INSTALL_RPATH "${_paths}" @@ -438,6 +511,14 @@ endfunction() # libexecutorch.so.1. A symlink is not an alternative, since a zip has no # portable symlink support. function(executorch_target_soname_policy target_name) + # Mach-O records the path a library expects to live at, and a consumer copies + # that path verbatim, so a library shipped somewhere other than where it was + # built is unfindable unless the recorded name is relative to whoever loads + # it. Set that before the wheel check, because the wheel is exactly the case + # that relocates. + if(APPLE) + set_target_properties(${target_name} PROPERTIES INSTALL_NAME_DIR "@rpath") + endif() if(EXECUTORCH_BUILD_WHEEL_DO_NOT_USE) return() endif() diff --git a/tools/cmake/executorch-wheel-config.cmake b/tools/cmake/executorch-wheel-config.cmake index c7e87480ae0..fb3b3eb9991 100644 --- a/tools/cmake/executorch-wheel-config.cmake +++ b/tools/cmake/executorch-wheel-config.cmake @@ -246,9 +246,18 @@ function(_executorch_find_library _output _base_name) "" PARENT_SCOPE ) - file(GLOB _matches "${_executorch_package_root}/lib/${_base_name}.so" - "${_executorch_package_root}/lib/${_base_name}.so.*" - ) + # Mach-O puts the version before the suffix, libfoo.1.dylib, where ELF puts it + # after, libfoo.so.1, so the versioned pattern differs and not just the + # suffix. + if(APPLE) + file(GLOB _matches "${_executorch_package_root}/lib/${_base_name}.dylib" + "${_executorch_package_root}/lib/${_base_name}.*.dylib" + ) + else() + file(GLOB _matches "${_executorch_package_root}/lib/${_base_name}.so" + "${_executorch_package_root}/lib/${_base_name}.so.*" + ) + endif() list(LENGTH _matches _count) if(_count EQUAL 0) return() @@ -431,6 +440,17 @@ elseif(_executorch_runtime_library) "LINKER:-rpath,$ORIGIN" "LINKER:-rpath,$ORIGIN/../lib" "LINKER:-rpath,${_executorch_package_root}/lib" ) + elseif(APPLE) + # Same purpose, in Mach-O spelling. The token differs, and there is no + # weaker older variant of the load command, so the tag selection flag has + # no counterpart here. + set_property( + TARGET executorch::runtime + APPEND + PROPERTY INTERFACE_LINK_OPTIONS "LINKER:-rpath,@loader_path" + "LINKER:-rpath,@loader_path/../lib" + "LINKER:-rpath,${_executorch_package_root}/lib" + ) endif() endif() endif() @@ -500,9 +520,11 @@ function(_executorch_define_component _suffix _library_name) PROPERTY INTERFACE_LINK_LIBRARIES executorch::runtime ) endif() - # Guarded on Linux because these are GNU linker options. A wheel only ships - # these components on Linux, so a consumer configured for another system is - # either cross-compiling from the wrong package or has nothing to retain. + # Spelled per platform because the options and the loader relative token + # differ. ELF takes $ORIGIN and needs a flag to choose the newer tag; Mach-O + # takes @loader_path and has no weaker older variant to choose against. A + # consumer configured for any other system is either cross-compiling from the + # wrong package or has nothing to retain. if(CMAKE_SYSTEM_NAME STREQUAL "Linux") set_property( TARGET ${_target} @@ -534,6 +556,17 @@ function(_executorch_define_component _suffix _library_name) # state, so the pop restores whatever the consumer had. "LINKER:--push-state,--no-as-needed,${_library},--pop-state" ) + elseif(APPLE) + # Mach-O spelling of the same two search paths. There is no --no-as-needed + # equivalent to bracket: the linker records a dependency on a dylib it was + # given, so nothing needs to be forced to stay. + set_property( + TARGET ${_target} + APPEND + PROPERTY INTERFACE_LINK_OPTIONS "LINKER:-rpath,@loader_path" + "LINKER:-rpath,@loader_path/../lib" + "LINKER:-rpath,${_executorch_package_root}/lib" + ) endif() if(NOT _component_OPT_IN) set(EXECUTORCH_LIBRARIES @@ -716,25 +749,21 @@ if(NOT _portable_lib_LIBRARY) endif() if(_portable_lib_LIBRARY) - set(EXECUTORCH_FOUND ON) message( - STATUS "ExecuTorch portable library is found at ${_portable_lib_LIBRARY}" + STATUS "ExecuTorch Python extension is found at ${_portable_lib_LIBRARY}" ) - # Only when nothing else is linkable, which is the fused layout: a macOS wheel - # ships this extension and no separate runtime library, so the appends above - # never ran and this is the only thing there is to offer. On a split wheel the - # runtime is already there and this is skipped, because the extension carries - # unresolved interpreter symbols and a plain C++ application that links it - # fails with a page of PyUnicode_InternFromString style errors. + # Defined so that a caller who specifically wants the extension, such as a + # custom operator project, can name the target, and deliberately kept out of + # EXECUTORCH_LIBRARIES and out of the found decision. The extension carries + # unresolved interpreter symbols, so a plain C++ application that links it + # fails with a page of PyUnicode_InternFromString style errors. Offering it as + # find_package's answer meant a wheel with no linkable library configured + # cleanly and failed at link instead of saying so, and it no longer stands in + # for a fused layout: every platform that ships a runtime now ships it as its + # own file. # - # Measured both layouts: split gives the runtime and its components, fused - # gives _portable_lib. Callers who specifically want the extension, such as a - # custom operator project, ask for the target by name rather than relying on - # this, and that target carries the C++20 requirement PyTorch's headers need - # while the runtime components require C++17. - if(NOT EXECUTORCH_LIBRARIES) - list(APPEND EXECUTORCH_LIBRARIES _portable_lib) - endif() + # The target carries the C++20 requirement PyTorch's headers need, while the + # runtime components require C++17. if(TARGET _portable_lib) # This file ran already in the same configure, because another subproject # called find_package too. No in-tree target uses this name, so it can only @@ -810,11 +839,21 @@ endif() # match the EXECUTORCH_FOUND spelling this file documents. Without this, a # REQUIRED find_package would succeed even when nothing usable was located. set(executorch_FOUND ${EXECUTORCH_FOUND}) -if(NOT executorch_FOUND AND executorch_FIND_REQUIRED) - message( - FATAL_ERROR - "Found the ExecuTorch package but neither the shared runtime nor the Python " - "extension could be located inside it." +if(NOT executorch_FOUND) + # The reason, not an error: the single REQUIRED gate at the bottom of this + # file raises. A component request that fails replaces this with a message + # naming the component, which is the more specific answer to what was asked + # for. + # + # One string rather than several arguments. Several make a list, and message() + # joins a list with semicolons, which lands separators mid sentence. + string( + CONCAT + executorch_NOT_FOUND_MESSAGE + "this ExecuTorch package ships the headers but no linkable library. The wheel " + "for this platform carries the Python extension only, which cannot stand in for " + "the runtime because it references the interpreter, so a C++ consumer has " + "nothing to link against" ) endif() diff --git a/tools/cmake/preset/pybind.cmake b/tools/cmake/preset/pybind.cmake index 51189a7bd45..e40507ee9c4 100644 --- a/tools/cmake/preset/pybind.cmake +++ b/tools/cmake/preset/pybind.cmake @@ -49,7 +49,11 @@ if(CMAKE_SYSTEM_NAME STREQUAL "Darwin") # interpreter on Windows, and a wheel that enables the hooks there hands that # crash to anyone who calls the profiling API. set_overridable_option(EXECUTORCH_ENABLE_EVENT_TRACER ON) - + # Same reason as on Linux: one shared runtime so a process has a single + # backend registry, and a C++ consumer can link the wheel instead of building + # from source. The Swift package remains the better fit for an application + # bundle. + set_overridable_option(EXECUTORCH_BUILD_SHARED ON) set_overridable_option(EXECUTORCH_BUILD_VGF ${_executorch_pybind_enable_vgf}) set_overridable_option(EXECUTORCH_BUILD_COREML ON) set_overridable_option(EXECUTORCH_BUILD_EXTENSION_TRAINING ON) @@ -115,9 +119,8 @@ elseif(CMAKE_SYSTEM_NAME STREQUAL "Linux") endif() set_overridable_option(EXECUTORCH_BUILD_OPENVINO OFF) # Ship one shared runtime that both the pybind extension and standalone C++ - # consumers link, so a process has a single backend registry. Linux only: - # macOS C++ consumers are served by the Swift package distribution, and the - # runtime has no export annotations for a Windows DLL. + # consumers link, so a process has a single backend registry. Not set on + # Windows, where the runtime has no export annotations for a DLL. set_overridable_option(EXECUTORCH_BUILD_SHARED ON) elseif(CMAKE_SYSTEM_NAME STREQUAL "Windows" OR CMAKE_SYSTEM_NAME STREQUAL "WIN32"