Files
pytorch/torch/_utils_internal.py
Klaus Zimmermann 7402bdbec6 Anchor torch's installed artifacts on the import system, not on __file__ (#195587)
## Summary

Follow-up to #195293, which #195295 closed by fixing the reported symptom. This fixes the path-resolution mechanism underneath it, including a hard `import torch` failure that nothing had reported and a defect that made #195295 itself correct only some of the time, and then applies the mechanism to three call sites that were waiting on it. Fixes #191752.

Redirect-mode editable installs (scikit-build-core) are the first layout where Python sources and CMake-installed artifacts live in different trees: `.py` in the checkout, `include/ lib/ bin/ share/` beside the installed distribution. Two lookups added in #180247 to cope with that ask a question a source checkout can answer wrongly.

**`_load_global_deps`** fell back to `importlib.metadata.distribution("torch")`, which returns the *first* torch distribution on `sys.path`, not necessarily the installed one. A checkout carrying a setuptools-era `torch.egg-info` supplies a nearer match whose `locate_file` resolves into the checkout, the `exists()` guard declines it, and `CDLL` proceeds with the path it already knew was missing. `import torch` fails, and since the `OSError` is routed through the CUDA-dependency retry it surfaces as an unrelated dlopen error naming the source tree.

**`_compute_torch_parent`** only consulted the distribution when `bin/` was absent beside the module. `tools/build_libtorch.py` installs into `<repo>/torch` by design (`docs/libtorch.rst` documents that flow), so a checkout can hold one while the artifacts that matter live elsewhere. The presence test then kept the fallback from firing and `get_file_path("torch")` silently returned the source tree, which made every caller conditionally wrong, including the already-merged #195295.

Both now ask the import system where torch is installed, through one helper, `_installed_torch_dir`, in the order RFC-0060 (pytorch/rfcs#109) prescribes:

1. the editable loader's `paths` (scikit-build/scikit-build-core#1567, in review): the entry that is not the checkout and holds `lib/`. Identity, not `lib/`, is what excludes the checkout, since `torch/lib` is a tracked source directory; the order of `paths` is only fixed by scikit-build/scikit-build-core#1566, hence the probe rather than `paths[0]`;
2. `importlib.util.find_spec("torch._C").origin`, the directory `_C` will actually be imported from in every layout, and the anchor `torch/__init__.py` already uses for the Windows DLL directory. This is the path every released scikit-build-core hits today;
3. `__file__`, only when there is no spec at all (`BUILD_LIBTORCH_WHL`, a frozen interpreter).

Instead of reasoning around leftovers, the helper detects them: in a checkout whose `_C` resolves elsewhere, an in-tree `_C`, `lib/*.so`, `bin/`, `include/`, `share/` or `torch.egg-info` produces a warning listing the paths and pointing at `spin clean`. A warning rather than an error, because the editable finder maps `_C` and every other CMake-installed module by name to the install tree, so the stale copies are never imported; they only mislead tooling that walks metadata or `__file__`.

`_load_global_deps` resolves through `get_file_path` like every other consumer and raises `OSError` when the library is missing, which is what `ctypes.CDLL` raised and what `torch.utils.collect_env` catches.

With `get_file_path` sound, three further commits apply it to call sites that derived an asset path from `__file__`. **`torch.utils.cmake_prefix_path`** computed `share/cmake` with no fallback at all, so `find_package(Torch)` failed against every editable install; it is a public API, documented in `docs/cpp/source/installing.md`, and gets a regression test that asserts `Torch/TorchConfig.cmake` is actually present. **`torch/_inductor/codegen/mps.py`** embedded `c10/metal/*.h` from `Path(__file__).parent.parent.parent / "include"` on the cpp_wrapper path, the twin of the site #195295 fixed. **`torch_openreg`'s `get_pytorch_dir()`** passed `dirname(torch.__file__)` as `-DPYTORCH_INSTALL_DIR`. The last two are taken from #192559 by @guptaishaan.

**Revision history.** The first revision of this PR resolved the installed tree through `importlib.metadata.distributions()` with a METADATA-versus-PKG-INFO filter, and was reviewed in that form on 2026-09-04. A self-review found the indirection leaking (a built checkout shadowing a wheel could pair the wheel's `libtorch_global_deps` with the checkout's `_C`), and the review's `bin/`-probe finding pointed the same way; the import-system anchor replaces it. The reply below the review maps each of its points to the current revision.

**Still not fixed here.** More `__file__`-derived lookups remain, in inductor, in distributed, and in the testing helpers. All are `exists()`-guarded, so they degrade quietly rather than failing, and each wants its own decision. They are inventoried in #195887, together with the sites that were checked and deliberately excluded.

This PR was authored with the assistance of an AI coding assistant.

## Test Plan

**Linux, redirect-mode editable install (scikit-build-core 1.0.0), checkout holding a stale `_C`, 13 in-tree libraries and a `torch.egg-info`**, patch applied then reverted:

```
python test/test_utils.py -k TestTorchPathResolution -k test_cmake_prefix_path -v
```

5 passed against the pushed head. The warning listed all leftovers; `torch_parent` and `cmake_prefix_path` resolve to site-packages. scikit-build-core 1.0.0 has no `__loader__.paths`, so this exercises the `_C`-spec step end to end; the loader-paths step is pinned by `test_installed_torch_dir_prefers_the_editable_loader_paths` with a fake loader, including the case where the checkout appears in `paths` under a symlinked spelling.

**Windows, redirect-mode editable install (MSVC, Ninja, scikit-build-core 1.0.0), clean checkout**, run against the `_C`-spec revision:

```
python test\test_utils.py -k TestTorchPathResolution -k test_cmake_prefix_path -v
```

3 passed, 1 skipped (the global-deps test is a no-op on Windows); no warning; `cmake_prefix_path` resolves to site-packages with `TorchConfig.cmake` present.

**`cmake_prefix_path`** was also checked on a macOS arm64 editable install (scikit-build-core 1.0.3) by configuring a real project, since printing the path proves nothing when the stale value is a well-formed string:

```cmake
cmake_minimum_required(VERSION 3.27)
project(probe LANGUAGES CXX)
find_package(Torch REQUIRED)
message(STATUS "PROBE_OK TORCH_LIBRARIES=${TORCH_LIBRARIES}")
```

configured with `-DCMAKE_PREFIX_PATH=$(python -c 'import torch.utils; print(torch.utils.cmake_prefix_path)')`:

| tree | result |
| --- | --- |
| before | exit 1, "Could not find a package configuration file provided by Torch" |
| after | exit 0, `PROBE_OK`, `TORCH_LIBRARIES` resolved into site-packages |
| reverted | exit 1 again |

The inductor MPS path needs a Metal device and was not exercised; it is the same one-line change as the merged #195295. Building torch_openreg needs a full extension build and was not run; the change substitutes one directory computation for another, and both resolve to `<site-packages>/torch` in a wheel install.

Lint:

```
spin lint -- torch/_utils_internal.py torch/__init__.py torch/utils/__init__.py torch/_inductor/codegen/mps.py test/test_utils.py test/cpp_extensions/open_registration_extension/torch_openreg/setup.py
```

Pull Request resolved: https://github.com/pytorch/pytorch/pull/195587
Approved by: https://github.com/atalman
2026-10-14 07:37:49 +00:00

450 lines
15 KiB
Python

# mypy: allow-untyped-defs
import functools
import importlib.util
import logging
import os
import sys
import tempfile
import typing_extensions
import warnings
from collections.abc import Callable
from typing import Any, TypeVar
from typing_extensions import ParamSpec
import torch
from torch._strobelight.compile_time_profiler import StrobelightCompileTimeProfiler
_T = TypeVar("_T")
_P = ParamSpec("_P")
log = logging.getLogger(__name__)
if os.environ.get("TORCH_COMPILE_STROBELIGHT", False):
import shutil
if not shutil.which("strobeclient"):
log.info(
"TORCH_COMPILE_STROBELIGHT is true, but seems like you are not on a FB machine."
)
else:
log.info("Strobelight profiler is enabled via environment variable")
StrobelightCompileTimeProfiler.enable()
# this arbitrary-looking assortment of functionality is provided here
# to have a central place for overridable behavior. The motivating
# use is the FB build environment, where this source file is replaced
# by an equivalent.
def _stale_checkout_artifacts(checkout_torch_dir: str) -> list[str]:
"""Build outputs left inside a source checkout's torch/ by an in-tree build
or an old setup.py install, which coexist badly with an editable install."""
found = []
for name in sorted(os.listdir(checkout_torch_dir)):
if name.startswith("_C.") and name.endswith((".so", ".pyd")):
found.append(os.path.join(checkout_torch_dir, name))
for sub in ("bin", "include", "share"):
path = os.path.join(checkout_torch_dir, sub)
if os.path.isdir(path):
found.append(path)
lib = os.path.join(checkout_torch_dir, "lib")
if os.path.isdir(lib):
for name in sorted(os.listdir(lib)):
if name.endswith((".so", ".dylib", ".dll")) or ".so." in name:
found.append(os.path.join(lib, name))
egg_info = os.path.join(os.path.dirname(checkout_torch_dir), "torch.egg-info")
if os.path.isdir(egg_info):
found.append(egg_info)
return found
def _installed_torch_dir(torch_dir: str) -> str | None:
"""The torch/ directory holding the compiled extension and the CMake-installed
lib/, include/, share/ and bin/. Under a redirect-mode editable install that is
beside the installed distribution, not torch_dir (the source checkout); for a
wheel or an in-tree build both are the same directory. None means no importable
extension, i.e. a libtorch wheel build (BUILD_LIBTORCH_WHL) or a frozen
interpreter."""
# scikit-build-core exposes a package's search locations as __loader__.paths
# (scikit-build/scikit-build-core#1567); the attribute is absent on older
# releases and in wheel installs. Their order is not guaranteed before
# scikit-build/scikit-build-core#1566, so the checkout is excluded by
# identity. lib/ cannot tell the trees apart (torch/lib is tracked); it only
# rejects an entry that is neither.
checkout = os.path.normcase(os.path.realpath(torch_dir))
for path in getattr(torch.__loader__, "paths", None) or []:
if os.path.normcase(os.path.realpath(path)) == checkout:
continue
if os.path.isdir(os.path.join(path, "lib")):
return os.path.abspath(path)
# A third-party meta path finder may raise here; this runs during
# `import torch`, so degrade to __file__ rather than fail the import.
try:
spec = importlib.util.find_spec("torch._C")
except Exception:
return None
if spec is None or not spec.origin:
return None
return os.path.dirname(spec.origin)
def _compute_torch_parent() -> str:
torch_dir = os.path.dirname(os.path.abspath(__file__))
if os.path.basename(torch_dir) == "shared":
return os.path.dirname(os.path.dirname(torch_dir))
installed_dir = _installed_torch_dir(torch_dir)
if installed_dir is None:
return os.path.dirname(torch_dir)
is_checkout = os.path.isfile(os.path.join(torch_dir, "CMakeLists.txt"))
if installed_dir != torch_dir and is_checkout:
stale = _stale_checkout_artifacts(torch_dir)
if stale:
listing = "\n ".join(stale)
warnings.warn(
f"torch is installed at {installed_dir}, but the source checkout "
f"at {torch_dir} still holds build artifacts that tooling may "
f"pick up instead:\n {listing}\nRemove them with `spin clean`.",
stacklevel=2,
)
return os.path.dirname(installed_dir)
torch_parent = _compute_torch_parent()
def get_file_path(*path_components: str) -> str:
return os.path.join(torch_parent, *path_components)
def get_file_path_2(*path_components: str) -> str:
return os.path.join(*path_components)
def get_writable_path(path: str) -> str:
if os.access(path, os.W_OK):
return path
return tempfile.mkdtemp(suffix=os.path.basename(path))
def prepare_multiprocessing_environment(path: str) -> None:
pass
def resolve_library_path(path: str) -> str:
return os.path.realpath(path)
def throw_abstract_impl_not_imported_error(opname, module, context):
if module in sys.modules:
raise NotImplementedError(
f"{opname}: We could not find the fake impl for this operator. "
)
else:
raise NotImplementedError(
f"{opname}: We could not find the fake impl for this operator. "
f"The operator specified that you may need to import the '{module}' "
f"Python module to load the fake impl. {context}"
)
# NB! This treats "skip" kwarg specially!!
def compile_time_strobelight_meta(
phase_name: str,
) -> Callable[[Callable[_P, _T]], Callable[_P, _T]]:
def compile_time_strobelight_meta_inner(
function: Callable[_P, _T],
) -> Callable[_P, _T]:
@functools.wraps(function)
def wrapper_function(*args: _P.args, **kwargs: _P.kwargs) -> _T:
if "skip" in kwargs and isinstance(
skip := kwargs["skip"],
int,
):
kwargs["skip"] = skip + 1
# This is not needed but we have it here to avoid having profile_compile_time
# in stack traces when profiling is not enabled.
if not StrobelightCompileTimeProfiler.enabled:
return function(*args, **kwargs)
return StrobelightCompileTimeProfiler.profile_compile_time(
function, phase_name, *args, **kwargs
)
return wrapper_function
return compile_time_strobelight_meta_inner
# Meta only, see
# https://www.internalfb.com/intern/wiki/ML_Workflow_Observability/User_Guides/Adding_instrumentation_to_your_code/
#
# This will cause an event to get logged to Scuba via the signposts API. You
# can view samples on the API at https://fburl.com/scuba/workflow_signpost/zh9wmpqs
# we log to subsystem "torch", and the category and name you provide here.
# Each of the arguments translate into a Scuba column. We're still figuring
# out local conventions in PyTorch, but category should be something like
# "dynamo" or "inductor", and name should be a specific string describing what
# kind of event happened.
#
# Killswitch is at
# https://www.internalfb.com/intern/justknobs/?name=pytorch%2Fsignpost#event
def signpost_event(category: str, name: str, parameters: dict[str, Any]):
log.info("%s %s: %r", category, name, parameters)
def add_mlhub_insight(category: str, insight: str, insight_description: str):
pass
def log_compilation_event(metrics):
log.info("%s", metrics)
def upload_graph(graph):
pass
def set_pytorch_distributed_envs_from_justknobs():
pass
def log_export_usage(**kwargs):
pass
def log_draft_export_usage(**kwargs):
pass
def log_trace_structured_event(*args, **kwargs) -> None:
pass
def log_cache_bypass(*args, **kwargs) -> None:
pass
def log_torchscript_usage(api: str, **kwargs):
_ = api
return
def check_if_torch_exportable():
return False
def export_training_ir_rollout_check() -> bool:
return True
def full_aoti_runtime_assert() -> bool:
return True
def log_torch_jit_trace_exportability(
api: str,
type_of_export: str,
export_outcome: str,
result: str,
):
_, _, _, _ = api, type_of_export, export_outcome, result
return
DISABLE_JUSTKNOBS = True
def justknobs_check(name: str, default: bool = True) -> bool:
"""
This function can be used to killswitch functionality in FB prod,
where you can toggle this value to False in JK without having to
do a code push. In OSS, we always have everything turned on all
the time, because downstream users can simply choose to not update
PyTorch. (If more fine-grained enable/disable is needed, we could
potentially have a map we lookup name in to toggle behavior. But
the point is that it's all tied to source code in OSS, since there's
no live server to query.)
This is the bare minimum functionality I needed to do some killswitches.
We have a more detailed plan at
https://docs.google.com/document/d/1Ukerh9_42SeGh89J-tGtecpHBPwGlkQ043pddkKb3PU/edit
In particular, in some circumstances it may be necessary to read in
a knob once at process start, and then use it consistently for the
rest of the process. Future functionality will codify these patterns
into a better high level API.
WARNING: Do NOT call this function at module import time, JK is not
fork safe and you will break anyone who forks the process and then
hits JK again.
"""
return default
def justknobs_getval_int(name: str) -> int:
"""
Read warning on justknobs_check
"""
return 0
def is_fb_unit_test() -> bool:
return False
@functools.cache
def max_clock_rate(device: int | None = None):
"""
unit: MHz
"""
if not torch.version.hip:
if device is not None:
return torch.cuda.get_device_properties(device).clock_rate / 1000
from triton.testing import nvsmi
try:
return nvsmi(["clocks.max.sm"])[0]
except FileNotFoundError:
import pynvml # type: ignore[import]
handle = torch.cuda._get_pynvml_handler()
try:
return pynvml.nvmlDeviceGetMaxClockInfo(handle, pynvml.NVML_CLOCK_SM)
finally:
pynvml.nvmlShutdown()
else:
device = torch.cuda.current_device() if device is None else device
return torch.cuda.get_device_properties(device).clock_rate // 1000
def get_mast_job_name_version() -> tuple[str, int] | None:
return None
TEST_MASTER_ADDR = "127.0.0.1"
TEST_MASTER_PORT = 29500
# USE_GLOBAL_DEPS controls whether __init__.py tries to load
# libtorch_global_deps, see Note [Global dependencies]
USE_GLOBAL_DEPS = True
# USE_RTLD_GLOBAL_WITH_LIBTORCH controls whether __init__.py tries to load
# _C.so with RTLD_GLOBAL during the call to dlopen.
USE_RTLD_GLOBAL_WITH_LIBTORCH = False
# If an op was defined in C++ and extended from Python using the
# torch.library.register_fake, returns if we require that there be a
# m.set_python_module("mylib.ops") call from C++ that associates
# the C++ op with a python module.
REQUIRES_SET_PYTHON_MODULE = False
def maybe_upload_prof_stats_to_manifold(profile_path: str) -> str | None:
print("Uploading profile stats (fb-only otherwise no-op)")
return None
def log_chromium_event_internal(
event: dict[str, Any],
stack: list[str],
logger_uuid: str,
start_time_ns: int,
):
return None
def record_chromium_event_internal(
event: dict[str, Any],
):
return None
def profiler_allow_cudagraph_cupti_lazy_reinit_cuda12():
return True
def deprecated():
"""
When we deprecate a function that might still be in use, we make it internal
by adding a leading underscore. This decorator is used with a private function,
and creates a public alias without the leading underscore, but has a deprecation
warning. This tells users "THIS FUNCTION IS DEPRECATED, please use something else"
without breaking them, however, if they still really really want to use the
deprecated function without the warning, they can do so by using the internal
function name.
"""
def decorator(func: Callable[_P, _T]) -> Callable[_P, _T]:
# Validate naming convention - single leading underscore, not dunder
if not (func.__name__.startswith("_")):
raise ValueError(
"@deprecate must decorate a function whose name "
"starts with a single leading underscore (e.g. '_foo') as the api should be considered internal for deprecation."
)
public_name = func.__name__[1:] # drop exactly one leading underscore
module = sys.modules[func.__module__]
# Don't clobber an existing symbol accidentally.
if hasattr(module, public_name):
raise RuntimeError(
f"Cannot create alias '{public_name}' -> symbol already exists in {module.__name__}. \
Please rename it or consult a pytorch developer on what to do"
)
warning_msg = f"{func.__name__[1:]} is DEPRECATED, please consider using an alternative API(s). "
# public deprecated alias
alias = typing_extensions.deprecated(
# pyrefly: ignore [bad-argument-type]
warning_msg,
category=UserWarning,
stacklevel=1,
)(func)
alias.__name__ = public_name
# Adjust qualname if nested inside a class or another function
if "." in func.__qualname__:
alias.__qualname__ = func.__qualname__.rsplit(".", 1)[0] + "." + public_name
else:
alias.__qualname__ = public_name
setattr(module, public_name, alias)
return func
return decorator
def get_default_numa_options():
"""
When using elastic agent, if no numa options are provided, we will use these
as the default.
For external use cases, we return None, i.e. no numa binding. If you would like
to use torch's automatic numa binding capabilities, you should provide
NumaOptions to your launch config directly or use the numa binding option
available in torchrun.
Must return None or NumaOptions, but not specifying to avoid circular import.
"""
return None
def log_triton_builds(fail: str | None):
pass
def find_compile_subproc_binary() -> str | None:
"""
Allows overriding the binary used for subprocesses
"""
return None
def get_torch_source_version() -> str:
"""Return the source commit hash for the current PyTorch build."""
import torch.version as torch_version
return getattr(torch_version, "git_version", "")