diff --git a/python/paddle/compat/api_dispatch.py b/python/paddle/compat/api_dispatch.py new file mode 100644 index 0000000000000..848506dadbdd4 --- /dev/null +++ b/python/paddle/compat/api_dispatch.py @@ -0,0 +1,189 @@ +# Copyright (c) 2026 PaddlePaddle Authors. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""``enable_compat(level=2)`` API dispatch: route ``paddle.*`` (and +``paddle.Tensor`` methods) to the torch-aligned ``paddle.compat.*`` for +external callers while paddle-internal callers keep native semantics. + +The enable/disable/level lifecycle lives in ``paddle.compat.proxy``; this +module only installs/removes the dispatchers and holds the dispatch state. +""" + +from __future__ import annotations + +import importlib +import inspect +import pkgutil +import sys +from functools import wraps +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + import types + from collections.abc import Generator + + +# (live module, attr name) -> original value for every patched ``paddle.*`` +# symbol, so ``disable_compat()`` can restore the paddle namespace. +_PADDLE_NAMESPACE_SAVED: dict[tuple[types.ModuleType, str], Any] = {} + + +def _caller_is_paddle_internal() -> bool: + """True when the caller of the dispatched ``paddle.*`` API is a ``paddle`` + module. Paddle's own internals call these APIs with native kwargs/defaults, so + they (and the compat impls) get native; only external callers see compat.""" + # _getframe(1) = our caller (the dispatch site); its f_back = who called paddle.X. + frame = sys._getframe(1).f_back + if frame is None: + return False + name = frame.f_globals.get("__name__") or "" + return name == "paddle" or name.startswith("paddle.") + + +def dispatch_function(compat_fn: Any) -> Any: + """Wrap a native ``paddle`` callable to route external callers to + ``compat_fn`` while compat is enabled; paddle-internal callers and the + disabled state get the native callable. Installed only under + ``enable_compat(level=2)``; ``disable_compat`` restores the originals, + so the default hot path is untouched.""" + + def decorator(native_fn: Any) -> Any: + @wraps(native_fn) + def dispatcher(*args: Any, **kwargs: Any) -> Any: + if ( + len(_PADDLE_NAMESPACE_SAVED) > 0 + and not _caller_is_paddle_internal() + ): + return compat_fn(*args, **kwargs) + return native_fn(*args, **kwargs) + + dispatcher.__compat_fn__ = compat_fn + dispatcher.__native_fn__ = native_fn + dispatcher.__signature__ = inspect.signature(compat_fn) + return dispatcher + + return decorator + + +def _iter_compat_modules() -> Generator[types.ModuleType, None, None]: + """Yield ``paddle.compat`` modules that declare ``__all__``. + + ``pkgutil.walk_packages`` skips the starting package, so the root + ``paddle.compat`` (holding the top-level functions) is yielded explicitly. + """ + import paddle.compat + + if hasattr(paddle.compat, "__all__"): + yield paddle.compat + for module_info in pkgutil.walk_packages( + paddle.compat.__path__, + paddle.compat.__name__ + ".", + ): + compat_module = importlib.import_module(module_info.name) + if not hasattr(compat_module, "__all__"): + continue + yield compat_module + + +def dispatch_class(native_cls: type, compat_cls: type) -> type: + """Create a class proxy that selects compat only for external callers.""" + + class _CompatAwareMeta(type(compat_cls)): + def __call__(cls, *args: Any, **kwargs: Any) -> Any: + if cls is proxy: + if ( + len(_PADDLE_NAMESPACE_SAVED) > 0 + and not _caller_is_paddle_internal() + ): + return compat_cls(*args, **kwargs) + return native_cls(*args, **kwargs) + return super().__call__(*args, **kwargs) + + proxy = _CompatAwareMeta( + native_cls.__name__, + (compat_cls,), + { + "__module__": native_cls.__module__, + "__compat_cls__": compat_cls, + "__native_cls__": native_cls, + }, + ) + proxy.__signature__ = inspect.signature(compat_cls) + return proxy + + +def _patch_tensor_methods() -> None: + """Route ``paddle.Tensor.`` to the compat function for the root compat APIs + that torch also exposes as Tensor methods (max/min/sort/split/unique/...), so + ``x.max(dim=1)`` works torch-style for external callers (native for internal). + The dispatcher is patched directly like any paddle Tensor method: the + descriptor protocol forwards the tensor as the first positional argument, + which is exactly the compat function's ``input`` parameter. + """ + import paddle + import paddle.compat as compat_root + + for attr_name in getattr(compat_root, "__all__", ()): + native_method = getattr(paddle.Tensor, attr_name, None) + if native_method is None: + continue + compat_fn = getattr(compat_root, attr_name) + _PADDLE_NAMESPACE_SAVED[(paddle.Tensor, attr_name)] = native_method + setattr( + paddle.Tensor, + attr_name, + dispatch_function(compat_fn)(native_method), + ) + + +def _apply_paddle_namespace_aliases() -> None: + """Install caller-aware dispatchers/proxies for every public ``paddle.compat.*`` + symbol that has a ``paddle.*`` counterpart, plus the Tensor methods.""" + if _PADDLE_NAMESPACE_SAVED: + return + + for compat_module in _iter_compat_modules(): + target_name = compat_module.__name__.replace( + "paddle.compat", "paddle", 1 + ) + try: + target_module = importlib.import_module(target_name) + except ModuleNotFoundError: + continue + for attr_name in compat_module.__all__: + compat_attr = getattr(compat_module, attr_name) + current = getattr(target_module, attr_name, None) + if current is None or current is compat_attr: + continue + _PADDLE_NAMESPACE_SAVED[(target_module, attr_name)] = current + if isinstance(compat_attr, type): + setattr( + target_module, + attr_name, + dispatch_class(current, compat_attr), + ) + else: + setattr( + target_module, + attr_name, + dispatch_function(compat_attr)(current), + ) + _patch_tensor_methods() + + +def _restore_paddle_namespace_aliases() -> None: + """Undo :func:`_apply_paddle_namespace_aliases`, restoring the paddle namespace.""" + for (target_module, attr_name), original in _PADDLE_NAMESPACE_SAVED.items(): + setattr(target_module, attr_name, original) + _PADDLE_NAMESPACE_SAVED.clear() diff --git a/python/paddle/compat/distributions/categorical.py b/python/paddle/compat/distributions/categorical.py index 7836595d1326d..910aba15f3134 100644 --- a/python/paddle/compat/distributions/categorical.py +++ b/python/paddle/compat/distributions/categorical.py @@ -20,8 +20,10 @@ from paddle.distribution import constraint, distribution from paddle.tensor import multinomial +from ..utils import _CompatClassMeta -class Categorical(distribution.Distribution): + +class Categorical(distribution.Distribution, metaclass=_CompatClassMeta): arg_constraints = { "probs": constraint.simplex, "logits": constraint.real_vector, @@ -61,7 +63,9 @@ def __init__( batch_shape = ( tuple(self._param.shape[:-1]) if self._param.dim() > 1 else () ) - super().__init__(batch_shape, validate_args=validate_args) + distribution.Distribution.__init__( + self, batch_shape, validate_args=validate_args + ) def expand(self, batch_shape, _instance=None): new = ( @@ -81,7 +85,9 @@ def expand(self, batch_shape, _instance=None): ) new._param = new._logits if new._logits is not None else new._probs new._num_events = self._num_events - super(Categorical, new).__init__(batch_shape, validate_args=False) + distribution.Distribution.__init__( + new, batch_shape, validate_args=False + ) new._validate_args_enabled = self._validate_args_enabled return new diff --git a/python/paddle/compat/nn/__init__.py b/python/paddle/compat/nn/__init__.py index 2b8e797e6dd3d..e401845d96c7b 100644 --- a/python/paddle/compat/nn/__init__.py +++ b/python/paddle/compat/nn/__init__.py @@ -23,6 +23,7 @@ from paddle.nn.modules.utils import _single from paddle.utils.decorator_utils import ForbidKeywordsDecorator +from ..utils import _CompatClassMeta from . import functional from .transformer import MultiheadAttention @@ -58,7 +59,7 @@ ] -class BatchNorm1D(nn.BatchNorm1D): +class BatchNorm1D(nn.BatchNorm1D, metaclass=_CompatClassMeta): def __init__( self, num_features: int, @@ -85,7 +86,7 @@ def __init__( self.momentum = momentum -class BatchNorm2D(nn.BatchNorm2D): +class BatchNorm2D(nn.BatchNorm2D, metaclass=_CompatClassMeta): def __init__( self, num_features: int, @@ -112,7 +113,7 @@ def __init__( self.momentum = momentum -class BatchNorm3D(nn.BatchNorm3D): +class BatchNorm3D(nn.BatchNorm3D, metaclass=_CompatClassMeta): def __init__( self, num_features: int, @@ -144,7 +145,7 @@ def __init__( BatchNorm3d = BatchNorm3D -class AvgPool1D(nn.Layer): +class AvgPool1D(nn.Layer, metaclass=_CompatClassMeta): r""" This operation applies a 1D average pooling over an input signal composed of several input planes, based on the input, output_size, return_mask parameters. @@ -227,7 +228,7 @@ def __init__( ceil_mode: bool = False, count_include_pad: bool = True, ) -> None: - super().__init__() + nn.Layer.__init__(self) self.kernel_size = _single(kernel_size) self.stride = _single(stride if stride is not None else kernel_size) self.padding = _single(padding) @@ -248,7 +249,7 @@ def extra_repr(self) -> str: return f"kernel_size={self.kernel_size}, stride={self.stride}, padding={self.padding}" -class AvgPool2D(nn.Layer): +class AvgPool2D(nn.Layer, metaclass=_CompatClassMeta): r""" This operation applies 2D average pooling over input features based on the input, and kernel_size, stride, padding parameters. Input(X) and Output(Out) are @@ -345,7 +346,7 @@ def __init__( count_include_pad: bool = True, divisor_override: int | None = None, ): - super().__init__() + nn.Layer.__init__(self) self.kernel_size = kernel_size self.stride = stride if (stride is not None) else kernel_size self.padding = padding @@ -368,7 +369,7 @@ def extra_repr(self) -> str: return f"kernel_size={self.kernel_size}, stride={self.stride}, padding={self.padding}" -class AvgPool3D(nn.Layer): +class AvgPool3D(nn.Layer, metaclass=_CompatClassMeta): """ This operation applies 3D max pooling over input features based on the input, @@ -452,7 +453,7 @@ def __init__( count_include_pad: bool = True, divisor_override: int | None = None, ) -> None: - super().__init__() + nn.Layer.__init__(self) self.kernel_size = kernel_size self.stride = stride if (stride is not None) else kernel_size self.padding = padding @@ -481,7 +482,7 @@ def __setstate__(self, state): self.__dict__.setdefault("count_include_pad", True) -class Unfold(nn.Unfold): +class Unfold(nn.Unfold, metaclass=_CompatClassMeta): """ A compatible version of paddle.nn.Unfold: @@ -547,7 +548,7 @@ def to_list_if_necessary(x): ) -class Linear(nn.Layer): +class Linear(nn.Layer, metaclass=_CompatClassMeta): r""" Python compatible fully-connected linear transformation layer. For each input :math:`X` , @@ -639,7 +640,7 @@ def __init__( device: PlaceLike | None = None, dtype: DTypeLike | None = None, ) -> None: - super().__init__() + nn.Layer.__init__(self) self._dtype = ( self._helper.get_default_dtype() if dtype is None else dtype ) @@ -687,7 +688,7 @@ def reset_parameters(self) -> None: nn.init.uniform_(self.bias, -bound, bound) -class Softmax(nn.Layer): +class Softmax(nn.Layer, metaclass=_CompatClassMeta): r""" Softmax Activation. @@ -814,7 +815,7 @@ class Softmax(nn.Layer): correct_name="paddle.nn.Softmax", ) def __init__(self, dim: int | None = None) -> None: - super().__init__() + nn.Layer.__init__(self) self._dim = dim self._dtype = None @@ -825,7 +826,7 @@ def extra_repr(self) -> str: return f"dim={self._dim}" -class SmoothL1Loss(nn.Layer): +class SmoothL1Loss(nn.Layer, metaclass=_CompatClassMeta): r""" PyTorch compatible version of :ref:`api_paddle_nn_SmoothL1Loss`, aligned with @@ -890,7 +891,7 @@ def __init__( reduction: str = 'mean', beta: float = 1.0, ) -> None: - super().__init__() + nn.Layer.__init__(self) if size_average is not None or reduce is not None: reduction = ( 'none' diff --git a/python/paddle/compat/nn/transformer.py b/python/paddle/compat/nn/transformer.py index 222a954e761eb..61576dc06fd9e 100644 --- a/python/paddle/compat/nn/transformer.py +++ b/python/paddle/compat/nn/transformer.py @@ -21,12 +21,18 @@ from paddle import nn from paddle.nn.initializer import XavierNormal, XavierUniform +from ..utils import _CompatClassMeta + if TYPE_CHECKING: from paddle import Tensor from paddle._typing import DTypeLike, PlaceLike -class MultiheadAttention(nn.Layer): +class MultiheadAttention( + nn.Layer, + metaclass=_CompatClassMeta, + native_cls=nn.MultiHeadAttention, +): r""" Allows the model to jointly attend to information from different representation subspaces. @@ -100,9 +106,9 @@ def __init__( dtype: DTypeLike | None = None, ) -> None: if dtype: - super().__init__(dtype=dtype) + nn.Layer.__init__(self, dtype=dtype) else: - super().__init__() + nn.Layer.__init__(self) self.embed_dim = embed_dim self.kdim = kdim if kdim is not None else embed_dim self.vdim = vdim if vdim is not None else embed_dim diff --git a/python/paddle/compat/proxy.py b/python/paddle/compat/proxy.py index c9f7f5488bd3c..22b54cc51ca47 100644 --- a/python/paddle/compat/proxy.py +++ b/python/paddle/compat/proxy.py @@ -18,7 +18,6 @@ import importlib.abc import importlib.util import inspect -import pkgutil import sys import types import warnings @@ -26,6 +25,12 @@ from functools import cache from typing import TYPE_CHECKING, Any, Literal +from .api_dispatch import ( + _apply_paddle_namespace_aliases, + _iter_compat_modules, + _restore_paddle_namespace_aliases, +) + if TYPE_CHECKING: from collections.abc import Generator, Iterable from typing import TypeAlias @@ -147,31 +152,17 @@ def _extend_torch_proxy_overrides( @cache def _register_compat_override(): - import paddle.compat - - PADDLE_PREFIX = "paddle.compat" - TORCH_PREFIX = "torch" - PUBLIC_ATTR_DECLARATION = "__all__" - compat_overrides = {} - for module_info in pkgutil.walk_packages( - paddle.compat.__path__, - paddle.compat.__name__ + ".", - ): - module = importlib.import_module(module_info.name) - if hasattr(module, PUBLIC_ATTR_DECLARATION): - public_attrs = getattr(module, PUBLIC_ATTR_DECLARATION) - torch_module_name = module_info.name.replace( - PADDLE_PREFIX, TORCH_PREFIX, 1 + for module in _iter_compat_modules(): + torch_module_name = module.__name__.replace("paddle.compat", "torch", 1) + for attr_name in module.__all__: + if attr_name.startswith("_"): + continue + paddle_attr = getattr(module, attr_name) + torch_attr_name = f"{torch_module_name}.{attr_name}" + compat_overrides[torch_attr_name] = RawOverriddenAttribute( + paddle_attr ) - for attr_name in public_attrs: - if attr_name.startswith("_"): - continue - paddle_attr = getattr(module, attr_name) - torch_attr_name = f"{torch_module_name}.{attr_name}" - compat_overrides[torch_attr_name] = RawOverriddenAttribute( - paddle_attr - ) _extend_torch_proxy_overrides(compat_overrides) @@ -479,6 +470,7 @@ def enable_compat( blocked_modules: _ScopeType = None, backend: Literal["torch"] = "torch", silent: bool = False, + level: int = 1, ) -> None: """ Enable the PyTorch compat by adding the TorchProxyMetaFinder to sys.meta_path. @@ -493,6 +485,9 @@ def enable_compat( "torch" is supported. Defaults to "torch". silent (bool, optional): If True, suppresses warnings about scope changes. Defaults to False. + level (int, optional): The compatibility level. ``1`` (default) preserves the + original ``torch -> paddle`` proxy behavior. ``2`` aliases the torch-aligned + ``paddle.compat.*`` APIs onto ``paddle.*`` and ``paddle.Tensor```. Defaults to 1. Example: .. code-block:: pycon @@ -517,6 +512,10 @@ def enable_compat( >>> paddle.disable_compat() # Disable torch compat """ assert backend == "torch", f"Unsupported backend: {backend}" + + if level not in {1, 2}: + raise ValueError(f"Unsupported level: {level}. It should be 1 or 2.") + blocked_modules = _parse_scope(blocked_modules) if blocked_modules is not None: extend_torch_proxy_blocked_modules(blocked_modules) @@ -526,6 +525,9 @@ def enable_compat( _modify_scope_of_torch_proxy(scope, silent=silent) sys.meta_path.insert(0, TORCH_PROXY_FINDER) + if level == 2: + _apply_paddle_namespace_aliases() + def disable_compat() -> None: """ @@ -547,6 +549,7 @@ def disable_compat() -> None: """ if TORCH_PROXY_FINDER in sys.meta_path: sys.meta_path.remove(TORCH_PROXY_FINDER) + _restore_paddle_namespace_aliases() _clear_torch_proxy_modules() _copy_torch_modules_from_cache() return diff --git a/python/paddle/compat/utils.py b/python/paddle/compat/utils.py index e909473a9ef54..2b68aeb789120 100644 --- a/python/paddle/compat/utils.py +++ b/python/paddle/compat/utils.py @@ -21,6 +21,40 @@ ) +class _CompatClassMeta(type): + """Keep compat classes recognizable as native classes across dispatch.""" + + def __new__(mcls, name, bases, namespace, *, native_cls=None, **kwargs): + if native_cls is None: + module_parts = namespace.get('__module__', '').split('.') + if ( + module_parts[:2] == ['paddle', 'compat'] + and len(module_parts) > 2 + ): + native_module = getattr(paddle, module_parts[2], None) + native_cls = getattr(native_module, name, None) + if isinstance(native_cls, type) and not any( + issubclass(base, native_cls) for base in bases + ): + bases = tuple( + native_cls if issubclass(native_cls, base) else base + for base in bases + ) + return super().__new__(mcls, name, bases, namespace, **kwargs) + + def __instancecheck__(cls, instance: object) -> bool: + native_cls = cls.__dict__.get('__native_cls__') + if native_cls is not None: + return isinstance(instance, native_cls) + return super().__instancecheck__(instance) + + def __subclasscheck__(cls, subclass: type) -> bool: + native_cls = cls.__dict__.get('__native_cls__') + if native_cls is not None: + return issubclass(subclass, native_cls) + return super().__subclasscheck__(subclass) + + def _check_out_status( out: Tensor | tuple[Tensor, Tensor] | list[Tensor], expect_multiple: bool = False, diff --git a/test/compat/fake_modules/torch_proxy_root_api_module.py b/test/compat/fake_modules/torch_proxy_root_api_module.py new file mode 100644 index 0000000000000..9ee874e019637 --- /dev/null +++ b/test/compat/fake_modules/torch_proxy_root_api_module.py @@ -0,0 +1,22 @@ +# Copyright (c) 2026 PaddlePaddle Authors. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +def capture_root_apis(): + import torch + + return {"sort": torch.sort, "min": torch.min, "unique": torch.unique} + + +captured = capture_root_apis() diff --git a/test/compat/test_compat_level2_internal_composites.py b/test/compat/test_compat_level2_internal_composites.py new file mode 100644 index 0000000000000..b847477b47c50 --- /dev/null +++ b/test/compat/test_compat_level2_internal_composites.py @@ -0,0 +1,135 @@ +# Copyright (c) 2026 PaddlePaddle Authors. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Verify ``enable_compat(level=2)`` keeps paddle-internal composites native. + +- vsplit / hsplit / dsplit / tensor_split / chunk -> split +- quantile -> paddle.sort(x, axis) +- nan_to_num -> paddle.equal -> paddle.where +- histogram_bin_edges -> paddle.min / paddle.max +- F.nll_loss (ignore_index, mean) -> paddle.equal +""" + +import unittest + +import numpy as np + +import paddle +import paddle.nn.functional as F + + +def setUpModule(): + paddle.enable_compat(level=2) + + +def tearDownModule(): + paddle.disable_compat() + + +class TestCompatIsActuallyOn(unittest.TestCase): + def test_external_surface_is_torch_style(self): + t = paddle.to_tensor([[3.0, 1.0, 2.0]]) + self.assertIsInstance(paddle.split(t, 1, dim=0), tuple) + self.assertTrue(hasattr(paddle.sort(t, dim=-1), "values")) + with self.assertRaises(TypeError): + paddle.max(t, axis=1) + + +class TestSplitFamilyStaysNative(unittest.TestCase): + def test_vsplit(self): + x = np.arange(48, dtype="float32").reshape([4, 4, 3]) + outs = paddle.vsplit(paddle.to_tensor(x), 2) + for o, r in zip(outs, np.array_split(x, 2, axis=0)): + np.testing.assert_array_equal(o.numpy(), r) + + def test_hsplit(self): + x = np.arange(24, dtype="float32").reshape([4, 6]) + outs = paddle.hsplit(paddle.to_tensor(x), 3) + for o, r in zip(outs, np.array_split(x, 3, axis=1)): + np.testing.assert_array_equal(o.numpy(), r) + + def test_dsplit(self): + x = np.arange(48, dtype="float32").reshape([2, 4, 6]) + outs = paddle.dsplit(paddle.to_tensor(x), 2) + for o, r in zip(outs, np.array_split(x, 2, axis=2)): + np.testing.assert_array_equal(o.numpy(), r) + + def test_tensor_split(self): + x = np.arange(7, dtype="float32") + outs = paddle.tensor_split(paddle.to_tensor(x), 3) + self.assertEqual(len(outs), 3) + for o, r in zip(outs, np.array_split(x, 3)): + np.testing.assert_array_equal(o.numpy(), r) + + def test_chunk(self): + x = np.arange(18, dtype="float32").reshape([6, 3]) + outs = paddle.chunk(paddle.to_tensor(x), 3, axis=0) + self.assertEqual(len(outs), 3) + for o, r in zip(outs, np.split(x, 3, axis=0)): + np.testing.assert_array_equal(o.numpy(), r) + + +class TestReduceAndCompareStayNative(unittest.TestCase): + def test_quantile_uses_native_sort(self): + x = np.array( + [ + [0.2, 0.7, 0.1, 0.4], + [1.0, 0.3, 0.8, 0.5], + [0.6, 0.9, 0.0, 0.25], + ], + dtype="float32", + ) + got = paddle.quantile(paddle.to_tensor(x), 0.35, axis=1) + np.testing.assert_allclose( + got.numpy(), np.quantile(x, 0.35, axis=1), rtol=1e-5 + ) + + def test_nan_to_num_uses_native_equal(self): + x = np.array([1.0, np.nan, np.inf, -np.inf, -2.5], dtype="float32") + got = paddle.nan_to_num(paddle.to_tensor(x), nan=0.5) + np.testing.assert_allclose(got.numpy(), np.nan_to_num(x, nan=0.5)) + + def test_histogram_bin_edges_uses_native_min_max(self): + x = np.array([0.0, 1.5, 3.0, 4.5, 6.0], dtype="float32") + got = paddle.histogram_bin_edges(paddle.to_tensor(x), bins=4) + np.testing.assert_allclose( + got.numpy(), np.histogram_bin_edges(x, bins=4), rtol=1e-6 + ) + + def test_nll_loss_uses_native_equal(self): + prob = np.array( + [ + [0.70, 0.10, 0.10, 0.10], + [0.20, 0.50, 0.20, 0.10], + [0.10, 0.20, 0.60, 0.10], + [0.25, 0.25, 0.25, 0.25], + [0.10, 0.20, 0.20, 0.50], + ], + dtype="float32", + ) + logp = np.log(prob) + label = np.array([0, 1, 2, 1, 3], dtype="int64") + got = F.nll_loss( + paddle.to_tensor(logp), + paddle.to_tensor(label), + ignore_index=1, + reduction="mean", + ) + keep = label != 1 + ref = -logp[np.arange(5)[keep], label[keep]].sum() / keep.sum() + np.testing.assert_allclose(got.item(), ref, rtol=1e-5) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/compat/test_compat_namespace_aliased.py b/test/compat/test_compat_namespace_aliased.py new file mode 100644 index 0000000000000..f60cc1cb86950 --- /dev/null +++ b/test/compat/test_compat_namespace_aliased.py @@ -0,0 +1,557 @@ +# Copyright (c) 2026 PaddlePaddle Authors. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import inspect +import pathlib +import sys +import unittest +from contextlib import contextmanager +from functools import wraps + +import paddle +from paddle.compat.api_dispatch import _PADDLE_NAMESPACE_SAVED +from paddle.compat.proxy import TORCH_PROXY_FINDER + +sys.path.append(str(pathlib.Path(__file__).parent / "fake_modules")) + + +@contextmanager +def level2_guard(): + paddle.enable_compat(level=2) + try: + yield + finally: + paddle.disable_compat() + + +def with_level2(func): + @wraps(func) + def wrapper(*args, **kwargs): + with level2_guard(): + return func(*args, **kwargs) + + return wrapper + + +class CompatNamespaceAliasBase(unittest.TestCase): + """Snapshot the relevant native symbols and the proxy-finder state so every + test reverts the global namespace/finder no matter how it ends.""" + + # (module, attr) pairs that should be perfectly restored after disable. + EXISTING = [ + (paddle, "sort"), + (paddle, "split"), + (paddle, "min"), + (paddle, "max"), + (paddle, "unique"), + (paddle, "median"), + (paddle, "nanmedian"), + (paddle, "allclose"), + (paddle, "equal"), + (paddle, "seed"), + ] + # Compat-only symbols must not be added to the paddle namespace. + COMPAT_ONLY = [ + (paddle, "slogdet"), + (paddle.nn, "AvgPool1d"), + (paddle.nn, "AvgPool2d"), + (paddle.nn, "AvgPool3d"), + (paddle.nn, "BatchNorm1d"), + (paddle.nn, "BatchNorm2d"), + (paddle.nn, "BatchNorm3d"), + (paddle.nn, "MultiheadAttention"), + ] + + def setUp(self): + # Dispatch is device-agnostic; run on CPU to dodge a DCU GPU-op hang + # (attention/sdpa). Original device restored in tearDown. + self._device = paddle.get_device() + paddle.set_device('cpu') + while TORCH_PROXY_FINDER in sys.meta_path: + paddle.disable_compat() + self._native = {(m, a): getattr(m, a) for (m, a) in self.EXISTING} + self._scope = set(TORCH_PROXY_FINDER._local_enabled_scope) + self._global = TORCH_PROXY_FINDER._globally_enabled + + def tearDown(self): + while TORCH_PROXY_FINDER in sys.meta_path: + paddle.disable_compat() + TORCH_PROXY_FINDER._local_enabled_scope = set(self._scope) + TORCH_PROXY_FINDER._globally_enabled = self._global + paddle.set_device(self._device) + + def assertNativeRestored(self): + for (m, a), native in self._native.items(): + self.assertIs( + getattr(m, a), native, f"{m.__name__}.{a} not restored" + ) + for m, a in self.COMPAT_ONLY: + self.assertFalse( + hasattr(m, a), f"{m.__name__}.{a} should not be added" + ) + + def assertAliased(self, paddle_attr, compat_attr): + """Check the dispatcher or class proxy points to the compat API.""" + target = getattr( + paddle_attr, + "__compat_fn__", + getattr(paddle_attr, "__compat_cls__", paddle_attr), + ) + self.assertIs(target, compat_attr) + + +class TestTopLevelAlias(CompatNamespaceAliasBase): + def test_public_level_parameter_is_minimal(self): + enable_parameters = list( + inspect.signature(paddle.enable_compat).parameters + ) + self.assertEqual(enable_parameters[-1], "level") + self.assertEqual( + inspect.signature(paddle.enable_compat).parameters["level"].default, + 1, + ) + self.assertNotIn( + "level", inspect.signature(paddle.use_compat_guard).parameters + ) + + def test_invalid_level_has_no_side_effect(self): + with self.assertRaisesRegex(ValueError, "Unsupported level: 3"): + paddle.enable_compat(level=3) + self.assertNotIn(TORCH_PROXY_FINDER, sys.meta_path) + self.assertNativeRestored() + + def test_top_level_symbols_aliased_on_enable(self): + paddle.enable_compat(level=2) + self.assertAliased(paddle.sort, paddle.compat.sort) + self.assertAliased(paddle.split, paddle.compat.split) + self.assertAliased(paddle.min, paddle.compat.min) + self.assertAliased(paddle.max, paddle.compat.max) + self.assertAliased(paddle.unique, paddle.compat.unique) + self.assertAliased(paddle.median, paddle.compat.median) + self.assertAliased(paddle.nanmedian, paddle.compat.nanmedian) + self.assertAliased(paddle.allclose, paddle.compat.allclose) + self.assertAliased(paddle.equal, paddle.compat.equal) + self.assertAliased(paddle.seed, paddle.compat.seed) + paddle.disable_compat() + self.assertNativeRestored() + + def test_level1_default_does_not_alias(self): + """level=1 (the default) installs only the torch proxy and must NOT alias + paddle.*; the namespace dispatch is opt-in via level=2.""" + paddle.enable_compat() # default level=1 + try: + self.assertIs(paddle.sort, self._native[(paddle, "sort")]) + self.assertFalse(hasattr(paddle.sort, "__compat_fn__")) + with self.assertRaises(TypeError): + paddle.sort( + paddle.to_tensor([3.0, 1.0]), dim=-1 + ) # native: no dim= + finally: + paddle.disable_compat() + self.assertNativeRestored() + + def test_class_type_compatibility_without_alias(self): + constructor_args = { + "Unfold": (1,), + "Linear": (2, 2), + "Softmax": (), + "AvgPool1D": (1,), + "AvgPool2D": (1,), + "AvgPool3D": (1,), + "BatchNorm1D": (2,), + "BatchNorm2D": (2,), + "BatchNorm3D": (2,), + "SmoothL1Loss": (), + "MultiheadAttention": (4, 1), + "Categorical": (paddle.to_tensor([0.5, 0.5]),), + } + native_classes = { + "Unfold": paddle.nn.Unfold, + "Linear": paddle.nn.Linear, + "Softmax": paddle.nn.Softmax, + "AvgPool1D": paddle.nn.AvgPool1D, + "AvgPool2D": paddle.nn.AvgPool2D, + "AvgPool3D": paddle.nn.AvgPool3D, + "BatchNorm1D": paddle.nn.BatchNorm1D, + "BatchNorm2D": paddle.nn.BatchNorm2D, + "BatchNorm3D": paddle.nn.BatchNorm3D, + "SmoothL1Loss": paddle.nn.SmoothL1Loss, + "MultiheadAttention": paddle.nn.MultiHeadAttention, + "Categorical": paddle.distributions.Categorical, + } + compat_modules = (paddle.compat.nn, paddle.compat.distributions) + compat_classes = { + getattr(module, name) + for module in compat_modules + for name in module.__all__ + if isinstance(getattr(module, name), type) + } + self.assertEqual( + {compat_cls.__name__ for compat_cls in compat_classes}, + set(native_classes), + ) + + for compat_cls in compat_classes: + name = compat_cls.__name__ + with self.subTest(name=name): + native_cls = native_classes[name] + compat = compat_cls(*constructor_args[name]) + self.assertIsInstance(compat, native_cls) + self.assertTrue(issubclass(compat_cls, native_cls)) + native = native_cls(*constructor_args[name]) + self.assertNotIsInstance(native, compat_cls) + + @with_level2 + def test_submodule_symbols_aliased(self): + self.assertAliased(paddle.nn.Linear, paddle.compat.nn.Linear) + self.assertAliased(paddle.nn.Softmax, paddle.compat.nn.Softmax) + self.assertAliased(paddle.nn.Unfold, paddle.compat.nn.Unfold) + self.assertAliased( + paddle.nn.functional.pad, paddle.compat.nn.functional.pad + ) + self.assertAliased( + paddle.nn.functional.linear, paddle.compat.nn.functional.linear + ) + + @with_level2 + def test_aliased_signatures_are_torch_style(self): + self.assertEqual( + inspect.signature(paddle.sort), + inspect.signature(paddle.compat.sort), + ) + self.assertEqual( + inspect.signature(paddle.nn.Linear), + inspect.signature(paddle.compat.nn.Linear), + ) + + def test_compat_only_symbols_are_not_added(self): + paddle.enable_compat(level=2) + try: + for module, name in self.COMPAT_ONLY: + self.assertFalse(hasattr(module, name)) + finally: + paddle.disable_compat() + + def test_all_avgpool_aliases(self): + paddle.enable_compat(level=2) + try: + for a in ("AvgPool1D", "AvgPool2D", "AvgPool3D"): + self.assertAliased( + getattr(paddle.nn, a), getattr(paddle.compat.nn, a) + ) + finally: + paddle.disable_compat() + + @with_level2 + def test_all_nn_functional_aliases(self): + """Every public compat nn.functional symbol overrides its paddle target, + including softmax/log_softmax/scaled_dot_product_attention (NOT no-ops).""" + F, cF = paddle.nn.functional, paddle.compat.nn.functional + for a in ( + "pad", + "softmax", + "log_softmax", + "linear", + "scaled_dot_product_attention", + "unfold", + ): + self.assertAliased(getattr(F, a), getattr(cF, a)) + + +class TestAliasBehavior(CompatNamespaceAliasBase): + def test_sort_returns_namedtuple(self): + t = paddle.to_tensor([[3.0, 1.0, 2.0], [9.0, 7.0, 8.0]]) + with level2_guard(): + out = paddle.sort(t, dim=-1) + self.assertTrue(hasattr(out, "values")) + self.assertTrue(hasattr(out, "indices")) + self.assertEqual(out.values[0].tolist(), [1.0, 2.0, 3.0]) + # native again: returns a plain Tensor, not the compat namedtuple + self.assertIs(paddle.sort, self._native[(paddle, "sort")]) + self.assertNotIsInstance(paddle.sort(t), tuple) + + @with_level2 + def test_min_max_no_recursion(self): + """Reduce-all min/max self-call the native impl; must not RecursionError.""" + t = paddle.to_tensor([3.0, 1.0, 2.0]) + self.assertEqual(paddle.min(t).item(), 1.0) + self.assertEqual(paddle.max(t).item(), 3.0) + # dim form returns a namedtuple (values, indices) + r = paddle.min(t, dim=0) + self.assertEqual(r.values.item(), 1.0) + self.assertEqual(r.indices.item(), 1) + + @with_level2 + def test_median_unique_allclose_no_recursion(self): + t = paddle.to_tensor([[1.0, 2.0, 3.0], [6.0, 5.0, 4.0]]) + med = paddle.median(t, dim=1) + self.assertTrue(hasattr(med, "values")) + u = paddle.unique(paddle.to_tensor([2, 3, 3, 1])) + self.assertEqual(u.tolist(), [1, 2, 3]) + # compat.allclose returns a python bool + res = paddle.allclose(t, t) + self.assertIsInstance(res, bool) + self.assertTrue(res) + + @with_level2 + def test_seed_no_recursion(self): + s = paddle.seed() # compat.seed takes no arg and returns an int + self.assertIsInstance(s, int) + + @with_level2 + def test_aliased_wrappers_execute(self): + """Broader behavioral coverage of the self-ref-fixed / aliased wrappers + that were previously only checked by identity.""" + t = paddle.to_tensor([[1.0, 2.0, 3.0], [6.0, 5.0, 4.0]]) + # nanmedian dim-path reaches the native paddle.nanmedian internally + nm = paddle.nanmedian( + paddle.to_tensor([[1.0, float("nan")], [3.0, 4.0]]), dim=0 + ) + self.assertTrue(hasattr(nm, "values")) + # split: torch semantics (per-chunk size), returns a tuple + parts = paddle.split(t, 1, dim=0) + self.assertIsInstance(parts, tuple) + self.assertEqual(len(parts), 2) + # equal returns a python bool (like allclose) + self.assertIsInstance(paddle.equal(t, t), bool) + # nn.functional: pad / linear / unfold + self.assertEqual(paddle.nn.functional.pad(t, [1, 1]).shape, [2, 5]) + y = paddle.nn.functional.linear( + t, paddle.ones([4, 3]), paddle.zeros([4]) + ) + self.assertEqual(y.shape, [2, 4]) + img = paddle.randn([1, 1, 4, 4]) + self.assertEqual(paddle.nn.functional.unfold(img, 2).shape[:2], [1, 4]) + # nn.Unfold layer forward routes nn.functional.unfold to native + self.assertEqual(paddle.nn.Unfold(kernel_size=2)(img).shape[0], 1) + + @with_level2 + def test_nn_functional_softmax_logsoftmax_sdpa_execute(self): + """Direct execution of the aliased softmax/log_softmax/sdpa (these are + genuinely aliased to torch-style compat APIs, not no-ops). After enable, + paddle.nn.functional.softmax is the torch-style compat API, so it takes + `dim=` and REJECTS the paddle-native `axis=` via ForbidKeywords.""" + x = paddle.randn([2, 4]) + self.assertEqual(paddle.nn.functional.softmax(x, dim=-1).shape, [2, 4]) + self.assertEqual( + paddle.nn.functional.log_softmax(x, dim=-1).shape, [2, 4] + ) + # paddle-native axis= is rejected once aliased (torch-style contract) + with self.assertRaises(TypeError): + paddle.nn.functional.softmax(x, axis=-1) + q = paddle.randn([2, 2, 5, 4]) + out = paddle.nn.functional.scaled_dot_product_attention(q, q, q) + self.assertEqual(out.shape, [2, 2, 5, 4]) + + def test_sdpa_numeric_correct_under_compat(self): + """Regression for caller-aware dispatch: at level=2 paddle.nn.functional.softmax + dispatches to the torch-style compat softmax for *external* code, but paddle's + own sdpa math backend (``_math_attention``) calls F.softmax internally and must + get the NATIVE softmax (last/key axis). fp32 forces the math backend, so a + wrong-axis softmax would diverge from this hand-computed softmax(QK^T/sqrt(d))V + reference.""" + import numpy as np + + rng = np.random.RandomState(0) + x = rng.rand(2, 4, 8, 16).astype("float32") + d = x.shape[-1] + s = (x @ np.swapaxes(x, -1, -2)) / np.sqrt(d) + s = s - s.max(-1, keepdims=True) + e = np.exp(s) + ref = (e / e.sum(-1, keepdims=True)) @ x + with level2_guard(): + q = paddle.to_tensor(x) + out = paddle.nn.functional.scaled_dot_product_attention(q, q, q) + np.testing.assert_allclose(out.numpy(), ref, rtol=1e-4, atol=1e-4) + + +class TestScopeAndLifecycle(CompatNamespaceAliasBase): + def test_scoped_level2_enable_aliases(self): + paddle.enable_compat(scope={"triton"}, level=2, silent=True) + try: + self.assertAliased(paddle.sort, paddle.compat.sort) + finally: + paddle.disable_compat() + self.assertNativeRestored() + + def test_level1_repeated_enable_preserves_legacy_finders(self): + paddle.enable_compat() + paddle.enable_compat(scope={"triton"}, silent=True) + self.assertEqual(sys.meta_path.count(TORCH_PROXY_FINDER), 2) + paddle.disable_compat() + self.assertEqual(sys.meta_path.count(TORCH_PROXY_FINDER), 1) + paddle.disable_compat() + self.assertNotIn(TORCH_PROXY_FINDER, sys.meta_path) + self.assertNativeRestored() + + def test_registry_empty_after_disable(self): + self.assertEqual(len(_PADDLE_NAMESPACE_SAVED), 0) + paddle.enable_compat(level=2) + self.assertGreater(len(_PADDLE_NAMESPACE_SAVED), 0) + paddle.disable_compat() + self.assertEqual(len(_PADDLE_NAMESPACE_SAVED), 0) + + def test_bare_guard_keeps_level2_alias(self): + t = paddle.to_tensor([[3.0, 1.0, 2.0]]) + paddle.enable_compat(level=2) + try: + with paddle.use_compat_guard(): + self.assertAliased(paddle.sort, paddle.compat.sort) + self.assertTrue(hasattr(paddle.sort(t, dim=-1), "values")) + # the level in effect survives the guard + self.assertAliased(paddle.sort, paddle.compat.sort) + self.assertTrue(hasattr(paddle.sort(t, dim=-1), "values")) + finally: + paddle.disable_compat() + self.assertNativeRestored() + + +class TestTorchSurfaceUnderCompat(CompatNamespaceAliasBase): + """torch.* reaches the public compat implementations at both levels.""" + + @staticmethod + def _drop_torch_modules(): + for name in [ + m + for m in list(sys.modules) + if m == "torch" or m.startswith("torch.") + ]: + del sys.modules[name] + + def test_level1_root_torch_apis_resolve_to_compat(self): + paddle.enable_compat() + try: + self._drop_torch_modules() + import torch + + self.assertIs(torch.sort, paddle.compat.sort) + self.assertIs(torch.min, paddle.compat.min) + self.assertIs(torch.unique, paddle.compat.unique) + finally: + self._drop_torch_modules() + paddle.disable_compat() + + def test_root_torch_apis_resolve_to_compat_at_level2(self): + paddle.enable_compat(level=2) + try: + self._drop_torch_modules() + import torch + + self.assertIs(torch.sort, paddle.compat.sort) + self.assertIs(torch.min, paddle.compat.min) + self.assertIs(torch.unique, paddle.compat.unique) + # nn.* overrides reach compat regardless of the alias. + self.assertIs(torch.nn.Linear, paddle.compat.nn.Linear) + finally: + self._drop_torch_modules() + paddle.disable_compat() + + def test_root_compat_only_api_is_registered_at_both_levels(self): + for level in (1, 2): + with self.subTest(level=level): + paddle.enable_compat(level=level) + try: + self._drop_torch_modules() + import torch + + self.assertFalse(hasattr(paddle, "slogdet")) + self.assertIs(torch.slogdet, paddle.compat.slogdet) + result = torch.slogdet(paddle.eye(2)) + self.assertEqual(result.sign.item(), 1.0) + self.assertEqual(result.logabsdet.item(), 0.0) + finally: + self._drop_torch_modules() + paddle.disable_compat() + + +class TestLevel2InternalCallersUseNative(CompatNamespaceAliasBase): + """level=2 redirects ``paddle.*`` to the torch-aligned compat APIs for the + USER's code, but paddle's own internals call these same APIs (F.softmax / + F.linear / paddle.max / ...) with native ``axis=``/``name=`` kwargs and native + defaults. Caller-aware dispatch must route those internal calls to native, or + composite native layers break under level=2.""" + + @with_level2 + def test_native_composite_layers_run_under_level2(self): + # Each of these calls F.linear / F.softmax / paddle.* internally with + # native kwargs; under level=2 they must still work (internal -> native). + x = paddle.randn([2, 5, 8]) + self.assertEqual( + paddle.nn.TransformerEncoderLayer(8, 2, 16)(x).shape, [2, 5, 8] + ) + mha = paddle.nn.MultiHeadAttention( + 8, 2 + ) # native (capital H), not compat + self.assertEqual(mha(x, x, x).shape, [2, 5, 8]) + self.assertEqual(paddle.nn.LayerNorm(8)(x).shape, [2, 5, 8]) + + @with_level2 + def test_external_caller_still_gets_compat(self): + # This test module is not a ``paddle.*`` module, so it still sees the + # torch-aligned compat API: torch-style ``dim=`` + namedtuple return, and + # the native ``axis=`` kwarg is rejected by the torch-style contract. + t = paddle.to_tensor([[3.0, 1.0, 2.0]]) + self.assertTrue(hasattr(paddle.sort(t, dim=-1), "values")) + with self.assertRaises(TypeError): + paddle.max(t, axis=1) + + def test_tensor_methods_caller_aware(self): + """torch exposes max/min/sort/split/... as Tensor methods too; under + level=2 ``x.max(dim=1)`` is torch-style for external callers and native + for paddle-internal ``x.max(axis=1)``; restored on disable.""" + native_max = paddle.Tensor.max + t = paddle.to_tensor([[3.0, 1.0, 2.0], [6.0, 5.0, 4.0]]) + with level2_guard(): + r = t.max(dim=1) # external -> compat namedtuple + self.assertTrue(hasattr(r, "values")) + self.assertEqual(r.values.tolist(), [3.0, 6.0]) + self.assertIsInstance(t.split(1, dim=0), tuple) + # paddle-internal native-style call (simulated) stays native + ns = {"__name__": "paddle.fake_internal", "t": t} + exec("internal = t.max(axis=1)", ns) # native accepts axis= + self.assertIs(paddle.Tensor.max, native_max) # restored on disable + + @with_level2 + def test_aliased_class_caller_aware(self): + """Existing classes (Linear/...) become caller-aware proxies: external + callers get the torch-aligned compat class, paddle-internal callers get + native.""" + # external (this module) -> compat class (torch-style) + self.assertIs(type(paddle.nn.Linear(2, 2)), paddle.compat.nn.Linear) + with self.assertRaises(TypeError): + paddle.nn.Linear(2, 2, weight_attr=False) # native kwarg rejected + # paddle-internal caller (simulated) -> native class, accepts native kwarg + ns = {"__name__": "paddle.fake_internal", "paddle": paddle} + exec("obj = paddle.nn.Linear(2, 2, weight_attr=False)", ns) + self.assertIsNot(type(ns["obj"]), paddle.compat.nn.Linear) + # isinstance / issubclass accept both forms + self.assertIsInstance(paddle.nn.Linear(2, 2), paddle.nn.Linear) + self.assertTrue(issubclass(paddle.compat.nn.Linear, paddle.nn.Linear)) + + @with_level2 + def test_aliased_class_subclassing_is_torch_style(self): + """A user subclass derived from the alias class under level=2 uses the + torch-style (compat) constructor, not a silent fallback to native.""" + + class MyLinear(paddle.nn.Linear): + pass + + m = MyLinear(3, 4, bias=False) # torch-style ``bias=`` must be accepted + self.assertIsInstance(m, MyLinear) + self.assertIsInstance(m, paddle.nn.Linear) + self.assertTrue(issubclass(MyLinear, paddle.nn.Linear)) + + +if __name__ == "__main__": + unittest.main()