Files
Warren Deng d602395970 [torch._native] Fall back when the Triton driver is unavailable (#197695)
# Summary

Outer-product `bmm` dispatches to Triton through `torch._native`. However, driver setup can fail when Python headers, a host compiler, or another prerequisite from Triton is unavailable even if `triton` is importable. In this case, the user hits failure instead of falling back to aten implementation. This is the failure reported in #196977.

# Change

Add an optional `try_initialize_runtime` hook to native override registration. The router calls it after the predicate matches and before invoking the implementation. A `False` result continues to the next override or the captured ATen kernel.

Conditional Triton overrides use a cached `_try_initialize_driver` hook backed by `triton.runtime.driver.active`. Unconditional overrides do not accept runtime initialization hooks, and graph construction does not run them. This keeps import and nonmatching operations lazy while leaving implementation, kernel-compilation, and launch errors visible.

# Test plan

```bash
python test/python_native/test_registry.py -k runtime_initialization_falls_through -v
python test/python_native/test_registry.py -k fake_tensor_shape_inference -v
python test/python_native/test_registry.py -k unconditional_override_cond_none -v
python test/python_native/test_registry.py -k unconditional_override_rejects_runtime_initialization -v
python test/python_native/test_registry.py -k override_node_dataclass -v
python test/python_native/test_registry.py -k register_op_override_basic -v
python test/python_native/test_native_dsl_ops.py -k triton_driver_initialization_is_cached -v
python test/python_native/test_native_dsl_ops.py -k version_skip_env_var_overrides -v
python test/python_native/test_decomp.py -k export_ -v
```

All focused tests passed. Fresh-cache missing-header and invalid-compiler runs used ATen; a healthy cold start used Triton. The cached hook measured 47.8 ns, and repeated fallback measured 30.3 μs versus 15.9 μs for direct ATen.

Authored with assistance from Codex

Fixes #196977

Pull Request resolved: https://github.com/pytorch/pytorch/pull/197695
Approved by: https://github.com/slayton58
2026-09-23 07:10:51 +00:00

288 lines
12 KiB
Python

# Owner(s): ["module: dsl-native-ops"]
"""
Tests for native_decomp_table and related compile/export decomposition
machinery exposed by torch._native.registry.
These tests isolate themselves from any overrides that got registered at
import time (e.g. bmm_outer_product) by saving/restoring all relevant
registry-module-level state in setUp / tearDown.
"""
import uuid
import torch
import torch._native.registry as registry_module
from torch._native.registry import native_decomp_table, register_op_override
from torch.testing._internal.common_utils import run_tests, skipIfTorchDynamo, TestCase
# Common predicates/impls used across several tests.
def _always_true(*a, **kw):
return True
def _always_false(*a, **kw):
return False
def _make_fill_impl(value):
"""Build an impl that returns a fresh tensor filled with `value`."""
def impl(a, *_args, **_kwargs):
return torch.empty_like(a).fill_(value)
return impl
@skipIfTorchDynamo("Decomp tests don't need dynamo compilation")
class TestNativeDecompTable(TestCase):
"""Tests for native_decomp_table() and register/deregister syncing."""
def setUp(self):
self.registry = registry_module
# Per-test unique DSL name so generated `_native::<node_id>` ops
# don't collide across tests. `_defined_native_ops` and the
# underlying torch dispatcher registration are process-wide and
# cannot be un-defined; instead, each test uses a fresh name.
self.dsl_name = f"test_{uuid.uuid4().hex[:8]}"
# Snapshot registry state that registrations / deregistrations
# touch. `_graphs` is the source of truth; `_override_libs`
# and `_libs` are derived (materialized dispatcher registrations)
# and get rebuilt from `_graphs` on tearDown rather than restored
# directly -- restoring destroyed Library objects would leave the
# dispatcher without live kernels. We deliberately do NOT touch
# `_defined_native_ops` or `_def_libs`; their contents mirror
# permanent dispatcher state.
self._snapshot = {
"graphs": dict(self.registry._graphs),
"decomp_overrides": dict(self.registry._native_decomp_overrides),
"dsl_name_to_lib_graph": {
k: list(v) for k, v in self.registry._dsl_name_to_lib_graph.items()
},
"dispatch_key_to_lib_graph": {
k: list(v) for k, v in self.registry._dispatch_key_to_lib_graph.items()
},
"op_symbol_to_lib_graph": {
k: list(v) for k, v in self.registry._op_symbol_to_lib_graph.items()
},
"filter_state": (
set(self.registry._filter_state._dsl_names),
set(self.registry._filter_state._op_symbols),
set(self.registry._filter_state._dispatch_keys),
),
}
# Destroy live overrides -- every namespace, not just the aten ones
# this class registers -- and _native IMPL libs, so the test's own
# registrations see pristine kernels. These are rebuilt from the
# snapshotted `_graphs` on tearDown.
self._destroy_live_libs()
# Clear dicts that won't corrupt process-wide state.
self.registry._graphs.clear()
self.registry._native_decomp_overrides.clear()
self.registry._dsl_name_to_lib_graph.clear()
self.registry._dispatch_key_to_lib_graph.clear()
self.registry._op_symbol_to_lib_graph.clear()
self.registry._filter_state._dsl_names.clear()
self.registry._filter_state._op_symbols.clear()
self.registry._filter_state._dispatch_keys.clear()
def _destroy_live_libs(self):
"""Tear down every live override + _native IMPL library and clear
their dicts. `_override_libs` is keyed by (namespace, op, dispatch
key), so this covers all namespaces. The C++ dispatcher has no
per-kernel removal, so Library._destroy() is the only way to
unregister."""
for lib in list(self.registry._override_libs.values()):
lib._destroy()
self.registry._override_libs.clear()
for lib in list(self.registry._libs.values()):
lib._destroy()
self.registry._libs.clear()
def tearDown(self):
if not hasattr(self, "registry"):
return
# Destroy whatever the test installed, then rebuild libs from the
# snapshotted graph state so other tests (and the rest of the
# process) see live dispatcher kernels matching the saved graphs.
self._destroy_live_libs()
self.registry._graphs.clear()
self.registry._graphs.update(self._snapshot["graphs"])
self.registry._native_decomp_overrides.clear()
self.registry._native_decomp_overrides.update(
self._snapshot["decomp_overrides"]
)
self.registry._dsl_name_to_lib_graph.clear()
for k, v in self._snapshot["dsl_name_to_lib_graph"].items():
self.registry._dsl_name_to_lib_graph[k] = list(v)
self.registry._dispatch_key_to_lib_graph.clear()
for k, v in self._snapshot["dispatch_key_to_lib_graph"].items():
self.registry._dispatch_key_to_lib_graph[k] = list(v)
self.registry._op_symbol_to_lib_graph.clear()
for k, v in self._snapshot["op_symbol_to_lib_graph"].items():
self.registry._op_symbol_to_lib_graph[k] = list(v)
fs = self._snapshot["filter_state"]
self.registry._filter_state._dsl_names.clear()
self.registry._filter_state._dsl_names.update(fs[0])
self.registry._filter_state._op_symbols.clear()
self.registry._filter_state._op_symbols.update(fs[1])
self.registry._filter_state._dispatch_keys.clear()
self.registry._filter_state._dispatch_keys.update(fs[2])
# Rebuild live libs from the restored graphs.
self.registry._register_all_overrides()
# ------------------------------------------------------------------
# Test helpers.
# ------------------------------------------------------------------
def _register(self, op_symbol, cond, impl, dispatch_key="CPU"):
"""Register an aten-namespace override and materialize it."""
register_op_override(self.dsl_name, "aten", op_symbol, dispatch_key, cond, impl)
self.registry._register_all_overrides()
def _export_add(self, cond, impl):
"""Export a module that calls `x + y`; decompose with the native table."""
self.registry.register_op_override(
self.dsl_name,
"aten",
"add.Tensor",
"CPU",
cond,
impl,
try_initialize_runtime=lambda: self.fail(
"runtime initialization ran during export"
),
)
self.registry._register_all_overrides()
class M(torch.nn.Module):
def forward(self, x, y):
return x + y
ep = torch.export.export(M(), (torch.randn(3), torch.randn(3)))
return ep.run_decompositions(native_decomp_table())
def _graph_targets(self, ep):
"""Return the str(target) of every call_function node in an ExportedProgram."""
return [
str(node.target)
for node in ep.graph_module.graph.nodes
if node.op == "call_function"
]
def assertGraphRoutedToNative(self, ep):
targets = self._graph_targets(ep)
self.assertTrue(
any("_native" in t for t in targets),
lambda msg: f"{msg}\nExpected a _native call in graph; got targets={targets}\n"
f"{ep.graph_module.code}",
)
def assertGraphNotRoutedToNative(self, ep):
targets = self._graph_targets(ep)
for t in targets:
self.assertNotIn(
"_native",
t,
lambda msg: f"{msg}\nUnexpected _native call; targets={targets}\n{ep.graph_module.code}",
)
# ------------------------------------------------------------------
# Dict-level tests: fast, no export / FakeTensor required.
# ------------------------------------------------------------------
def test_table_default_includes_core_aten_and_overrides(self):
"""Default table = core aten decomps + registered overrides."""
self._register("add.Tensor", _always_true, _make_fill_impl(1))
table = native_decomp_table()
self.assertIn(torch.ops.aten.add.Tensor, table)
# Also contains a lot of default aten decomps (exact count varies by
# PyTorch version, but it's in the hundreds).
self.assertGreater(len(table), 100)
def test_table_override_wins_on_conflict(self):
"""If an op has both a default decomp and a registered override,
the override wins (merged last)."""
self._register("add.Tensor", _always_true, _make_fill_impl(99))
# The stored override is a router closure that wraps our impl -- not
# our impl directly -- so we verify table identity against the
# closure we queued.
table = native_decomp_table()
self.assertIs(
table[torch.ops.aten.add.Tensor],
self.registry._native_decomp_overrides[torch.ops.aten.add.Tensor],
)
def test_table_overrides_only(self):
"""overrides_only=True returns just the registered overrides."""
self._register("add.Tensor", _always_true, _make_fill_impl(1))
self._register("mul.Tensor", _always_true, _make_fill_impl(2))
table = native_decomp_table(overrides_only=True)
self.assertEqual(len(table), 2)
self.assertIn(torch.ops.aten.add.Tensor, table)
self.assertIn(torch.ops.aten.mul.Tensor, table)
def test_deregister_removes_decomp_entry(self):
"""Deregistering an override drops it from the decomp table."""
self._register("add.Tensor", _always_true, _make_fill_impl(1))
self.assertIn(
torch.ops.aten.add.Tensor, native_decomp_table(overrides_only=True)
)
self.registry.deregister_op_overrides(disable_dsl_names=self.dsl_name)
self.assertNotIn(
torch.ops.aten.add.Tensor, native_decomp_table(overrides_only=True)
)
def test_reenable_restores_decomp_entry(self):
"""Reenabling a deregistered override puts it back in the table."""
self._register("add.Tensor", _always_true, _make_fill_impl(1))
self.registry.deregister_op_overrides(disable_dsl_names=self.dsl_name)
self.assertNotIn(
torch.ops.aten.add.Tensor, native_decomp_table(overrides_only=True)
)
self.registry.reenable_op_overrides(enable_dsl_names=self.dsl_name)
self.assertIn(
torch.ops.aten.add.Tensor, native_decomp_table(overrides_only=True)
)
# ------------------------------------------------------------------
# End-to-end tests that exercise compile_router's tracing behavior.
# ------------------------------------------------------------------
def test_export_with_matching_cond_routes_to_native(self):
"""A matching cond rewrites aten to _native without runtime initialization."""
ep = self._export_add(_always_true, _make_fill_impl(7))
self.assertGraphRoutedToNative(ep)
def test_export_with_non_matching_cond_preserves_aten(self):
"""When cond returns False, the decomp falls through -- graph keeps aten::<op>."""
ep = self._export_add(_always_false, _make_fill_impl(-1))
self.assertGraphNotRoutedToNative(ep)
def test_export_cond_exception_treated_as_non_match(self):
"""A cond that raises under FakeTensor tracing is swallowed (treated
as non-match), so the graph falls through to the default lowering."""
def raising_cond(*a, **kw):
raise RuntimeError("intentional failure under tracing")
ep = self._export_add(raising_cond, _make_fill_impl(-1))
self.assertGraphNotRoutedToNative(ep)
if __name__ == "__main__":
run_tests()