Repository: modin-project/modin

Issue:
Cast query compilers between backends at API layer instead of at query compiler.
Currently `QueryCompilerCaster` casts between backends at the query compiler layer. This approach doesn't work when some of the backends use API-level extensions.

Discussion:

Patch:
diff --git a/modin/core/storage_formats/base/query_compiler_calculator.py b/modin/core/storage_formats/base/query_compiler_calculator.py
index 58ff5e1c..9acddcf3 100644
--- a/modin/core/storage_formats/base/query_compiler_calculator.py
+++ b/modin/core/storage_formats/base/query_compiler_calculator.py
@@ -19,8 +19,6 @@ between a set of different backends. It aggregates the cost across
 all query compilers to determine the best query compiler to use.
 """
 
-from typing import Union
-
 from modin.core.storage_formats.base.query_compiler import (
     BaseQueryCompiler,
     QCCoercionCost,
@@ -58,10 +56,9 @@ class BackendCostCalculator:
     def __init__(self):
         self._backend_data = {}
         self._qc_list = []
-        self._default_qc = None
         self._result_backend = None
 
-    def add_query_compiler(self, query_compiler):
+    def add_query_compiler(self, query_compiler: BaseQueryCompiler):
         """
         Add a query compiler to be considered for casting.
 
@@ -69,36 +66,24 @@ class BackendCostCalculator:
         ----------
         query_compiler : QueryCompiler
         """
-        if isinstance(query_compiler, type):
-            # class, no data
-            qc_type = query_compiler
-        else:
-            # instance
-            qc_type = type(query_compiler)
-            self._qc_list.append(query_compiler)
-            backend = query_compiler.get_backend()
-            backend_data = AggregatedBackendData(backend, query_compiler)
-            self._backend_data[backend] = backend_data
-        # TODO: Handle the case where we have a class method and an instance argument of different
-        # backends.
-        self._default_qc = qc_type
-
-    def calculate(self) -> Union[str, type[BaseQueryCompiler]]:
+        self._qc_list.append(query_compiler)
+        backend = query_compiler.get_backend()
+        backend_data = AggregatedBackendData(backend, query_compiler)
+        self._backend_data[backend] = backend_data
+
+    def calculate(self) -> str:
         """
         Calculate which query compiler we should cast to.
 
         Returns
         -------
-        Union[str, type[BaseQueryCompiler]]
-            A string representing a backend or, in the cases when we are executing
-            a class method, the QueryCompiler class which should be used for the operation.
+        str
+            A string representing a backend.
         """
         if self._result_backend is not None:
             return self._result_backend
-        if self._default_qc is None:
+        if len(self._qc_list) == 0:
             raise ValueError("No query compilers registered")
-        if len(self._backend_data) == 0:
-            return self._default_qc
 
         # instance selection
         for qc_from in self._qc_list:
diff --git a/modin/core/storage_formats/pandas/native_query_compiler.py b/modin/core/storage_formats/pandas/native_query_compiler.py
index 41978df8..d58b4ee4 100644
--- a/modin/core/storage_formats/pandas/native_query_compiler.py
+++ b/modin/core/storage_formats/pandas/native_query_compiler.py
@@ -27,10 +27,7 @@ from modin.core.dataframe.base.interchange.dataframe_protocol.dataframe import (
     ProtocolDataframe,
 )
 from modin.core.storage_formats.base.query_compiler import BaseQueryCompiler
-from modin.core.storage_formats.pandas.query_compiler_caster import QueryCompilerCaster
-from modin.utils import (
-    _inherit_docstrings,
-)
+from modin.utils import _inherit_docstrings
 
 _NO_REPARTITION_ON_NATIVE_EXECUTION_EXCEPTION_MESSAGE = (
     "Modin dataframes and series using native execution do not have partitions."
@@ -83,7 +80,7 @@ def _set_axis(axis):
 
 
 @_inherit_docstrings(BaseQueryCompiler)
-class NativeQueryCompiler(BaseQueryCompiler, QueryCompilerCaster):
+class NativeQueryCompiler(BaseQueryCompiler):
     """
     Query compiler for executing operations with native pandas.
 
diff --git a/modin/core/storage_formats/pandas/query_compiler.py b/modin/core/storage_formats/pandas/query_compiler.py
index b6f3c114..e4e18504 100644
--- a/modin/core/storage_formats/pandas/query_compiler.py
+++ b/modin/core/storage_formats/pandas/query_compiler.py
@@ -69,7 +69,6 @@ from modin.core.dataframe.pandas.metadata import (
     extract_dtype,
 )
 from modin.core.storage_formats import BaseQueryCompiler
-from modin.core.storage_formats.pandas.query_compiler_caster import QueryCompilerCaster
 from modin.error_message import ErrorMessage
 from modin.logging import get_logger
 from modin.utils import (
@@ -277,7 +276,7 @@ def _series_logical_binop(func):
 
 
 @_inherit_docstrings(BaseQueryCompiler)
-class PandasQueryCompiler(BaseQueryCompiler, QueryCompilerCaster):
+class PandasQueryCompiler(BaseQueryCompiler):
     """
     Query compiler for the pandas storage format.
 
diff --git a/modin/core/storage_formats/pandas/query_compiler_caster.py b/modin/core/storage_formats/pandas/query_compiler_caster.py
index 2f456bdd..9f5ef864 100644
--- a/modin/core/storage_formats/pandas/query_compiler_caster.py
+++ b/modin/core/storage_formats/pandas/query_compiler_caster.py
@@ -21,20 +21,47 @@ This ensures compatibility between different query compiler classes.
 
 import functools
 import inspect
+from abc import ABC, abstractmethod
+from collections import namedtuple
 from types import FunctionType, MethodType
-from typing import Any, Dict, Tuple, TypeVar
+from typing import Any, Callable, Dict, Optional, Tuple, TypeVar, Union, ValuesView
 
 from pandas.core.indexes.frozen import FrozenList
+from typing_extensions import Self
 
 from modin.core.storage_formats.base.query_compiler import BaseQueryCompiler
 from modin.core.storage_formats.base.query_compiler_calculator import (
     BackendCostCalculator,
 )
+from modin.error_message import ErrorMessage
 
 Fn = TypeVar("Fn", bound=Any)
 
 
-class QueryCompilerCaster:
+_NON_EXTENDABLE_ATTRIBUTES = {
+    # we use these attributes to implement casting and backend dispatching, so
+    # we can't allow extensions to override them.
+    "__getattribute__",
+    "__setattr__",
+    "__delattr__",
+    "__getattr__",
+    "_getattribute__from_extension_impl",
+    "_getattr__from_extension_impl",
+    "get_backend",
+    "set_backend",
+    "_get_extension",
+    "_query_compiler",
+    "_get_query_compiler",
+    "_copy_into",
+    # TODO(https://github.com/modin-project/modin/issues/7475): Choose the
+    # correct __init__ implementation from extensions. We have to handle
+    # __init__ differently because the object passed to __init__ does not yet
+    # have a query compiler.
+    "__init__",
+}
+
+
+class QueryCompilerCaster(ABC):
     """Cast all query compiler arguments of the member function to current query compiler."""
 
     @classmethod
@@ -55,7 +82,55 @@ class QueryCompilerCaster:
         **kwargs : Additional keyword arguments
         """
         super().__init_subclass__(**kwargs)
-        apply_argument_cast(cls)
+        apply_argument_cast_to_class(cls)
+
+    @abstractmethod
+    def _get_query_compiler(self) -> BaseQueryCompiler:
+        """
+        Get the query compiler storing data for this object.
+
+        Returns
+        -------
+        BaseQueryCompiler
+            The query compiler storing data for this object.
+        """
+        pass
+
+    @abstractmethod
+    def get_backend(self) -> str:
+        """
+        Get the backend of this object.
+
+        Returns
+        -------
+        str
+            The backend of this object. The backend name must be title-cased.
+        """
+        pass
+
+    @abstractmethod
+    def set_backend(self, backend: str) -> Self:
+        """
+        Set the backend of this object.
+
+        Parameters
+        ----------
+        backend : str
+            The new backend.
+        """
+        pass
+
+    @abstractmethod
+    def _copy_into(self, other: Self) -> None:
+        """
+        Copy the data from this object into another object of the same type.
+
+        Parameters
+        ----------
+        other : Self
+            The object to copy data into.
+        """
+        pass
 
 
 def visit_nested_args(arguments, fn: callable):
@@ -72,68 +147,158 @@ def visit_nested_args(arguments, fn: callable):
     tuple or dict
         Returns args and kwargs with all query compilers casted to current_qc.
     """
-    imutable_types = (FrozenList, tuple)
-    if isinstance(arguments, imutable_types):
+    immutable_types = (FrozenList, tuple, ValuesView)
+    if isinstance(arguments, immutable_types):
         args_type = type(arguments)
-        arguments = list(arguments)
-        arguments = visit_nested_args(arguments, fn)
-
-        return args_type(arguments)
-    if isinstance(arguments, list):
+        return (
+            # ValuesView, which we might get from dict.values(), is immutable,
+            # but not constructable, so we convert it to a tuple. Otherwise,
+            # we return an object of the same type as the input.
+            tuple
+            if issubclass(args_type, ValuesView)
+            else args_type
+        )(visit_nested_args(list(arguments), fn))
+    types_to_recursively_visit = (list, dict, *immutable_types)
+    if isinstance(
+        arguments,
+        list,
+    ):
         for i in range(len(arguments)):
-            if isinstance(arguments[i], (list, dict)):
+            if isinstance(arguments[i], types_to_recursively_visit):
                 visit_nested_args(arguments[i], fn)
             else:
                 arguments[i] = fn(arguments[i])
     elif isinstance(arguments, dict):
         for key in arguments:
-            if isinstance(arguments[key], (list, dict)):
+            if isinstance(arguments[key], types_to_recursively_visit):
                 visit_nested_args(arguments[key], fn)
             else:
                 arguments[key] = fn(arguments[key])
     return arguments
 
 
-def apply_argument_cast(obj: Fn) -> Fn:
+def apply_argument_cast_to_class(klass: type) -> type:
     """
-    Cast all arguments that are query compilers to the current query compiler.
+    Apply argument casting to all functions in a class.
 
     Parameters
     ----------
-    obj : function
+    klass : type
+        The class to apply argument casting to.
 
     Returns
     -------
-    function
-        Returns decorated function which does argument casting.
+    type
+        The class with argument casting applied to all functions.
     """
-    if isinstance(obj, type):
-        all_attrs = dict(inspect.getmembers(obj))
-
-        # This is required because inspect converts class methods to member functions
-        current_class_attrs = vars(obj)
-        for key in current_class_attrs:
-            all_attrs[key] = current_class_attrs[key]
-        all_attrs.pop("__abstractmethods__")
-        all_attrs.pop("get_backend")
-        all_attrs.pop("__init__")
-        all_attrs.pop("qc_engine_switch_cost")
-        all_attrs.pop("qc_engine_switch_max_cost")
-        all_attrs.pop("from_pandas")
-        for attr_name, attr_value in all_attrs.items():
-            if isinstance(
-                attr_value, (FunctionType, MethodType, classmethod, staticmethod)
-            ):
-                wrapped = apply_argument_cast(attr_value)
-                setattr(obj, attr_name, wrapped)
-        return obj  # type: ignore [return-value]
-    elif isinstance(obj, classmethod):
-        return classmethod(apply_argument_cast(obj.__func__))  # type: ignore [return-value, arg-type]
-    elif isinstance(obj, staticmethod):
-        return staticmethod(apply_argument_cast(obj.__func__))
-
-    @functools.wraps(obj)
-    def cast_args(*args: Tuple, **kwargs: Dict) -> Any:
+    all_attrs = dict(inspect.getmembers(klass))
+    # This is required because inspect converts class methods to member functions
+    current_class_attrs = vars(klass)
+    for key in current_class_attrs:
+        all_attrs[key] = current_class_attrs[key]
+
+    # We want to avoid wrapping synonyms like __add__() and add() with
+    # different wrappers, so keep a dict mapping methods we've wrapped
+    # to their wrapped versions.
+    already_seen_to_wrapped: dict[Callable, Callable] = {}
+
+    for attr_name, attr_value in all_attrs.items():
+        if isinstance(attr_value, (FunctionType, classmethod, staticmethod)):
+            implementation_function = (
+                attr_value.__func__
+                if isinstance(attr_value, (classmethod, staticmethod))
+                else attr_value
+            )
+
+            if attr_name not in klass._extensions[None]:
+                # Register the original implementation as the default
+                # extension. We fall back to this implementation if the
+                # object's backend does not have an implementation for this
+                # method.
+                klass._extensions[None][attr_name] = implementation_function
+
+            if attr_value in already_seen_to_wrapped:
+                setattr(
+                    klass,
+                    attr_name,
+                    already_seen_to_wrapped[attr_value],
+                )
+            elif attr_name not in _NON_EXTENDABLE_ATTRIBUTES:
+                casting_implementation = wrap_function_in_argument_caster(
+                    f=implementation_function,
+                    wrapping_function_type=(
+                        classmethod
+                        if isinstance(attr_value, classmethod)
+                        else (
+                            staticmethod
+                            if isinstance(attr_value, staticmethod)
+                            else MethodType
+                        )
+                    ),
+                    cls=klass,
+                    name=attr_name,
+                )
+                wrapped = (
+                    classmethod(casting_implementation)
+                    if isinstance(attr_value, classmethod)
+                    else (
+                        staticmethod(casting_implementation)
+                        if isinstance(attr_value, staticmethod)
+                        else casting_implementation
+                    )
+                )
+                if attr_name not in klass.__dict__:
+                    # If this class's method comes from a superclass (i.e.
+                    # it's not in klass.__dict__), mark it so that
+                    # modin.utils._inherit_docstrings knows that the method
+                    # must get its docstrings from its superclass.
+                    wrapped._wrapped_superclass_method = attr_value
+                setattr(klass, attr_name, wrapped)
+                already_seen_to_wrapped[attr_value] = wrapped
+
+    return klass
+
+
+def wrap_function_in_argument_caster(
+    f: callable,
+    name: str,
+    wrapping_function_type: Optional[
+        Union[type[classmethod], type[staticmethod], type[MethodType]]
+    ],
+    cls: Optional[type],
+) -> callable:
+    """
+    Wrap a function so that it casts all castable arguments to a consistent query compiler, and uses the correct extension implementation for methods.
+
+    Parameters
+    ----------
+    f : callable
+        The function to wrap.
+    name : str
+        The name of the function.
+    wrapping_function_type : Optional[Union[type[classmethod], type[staticmethod], type[MethodType]]
+        The type of the original function that `f` implements.
+        - `None` means we are wrapping a free function, e.g. pd.concat()
+        - `classmethod` means we are wrapping a classmethod.
+        - `staticmethod` means we are wrapping a staticmethod.
+        - `MethodType` means we are wrapping a regular method of a class.
+    cls : Optional[type]
+        The class of the function we are wrapping. This should be None if
+        and only if `wrapping_function_type` is None.
+
+    Returns
+    -------
+    callable
+        The wrapped function.
+    """
+    ErrorMessage.catch_bugs_and_request_email(
+        (cls is None and wrapping_function_type is not None)
+        or (cls is not None and wrapping_function_type is None),
+        extra_log="`cls` should be None if and only if `wrapping_function_type` is None",
+    )
+
+    @functools.wraps(f)
+    def f_with_argument_casting(*args: Tuple, **kwargs: Dict) -> Any:
         """
         Add casting for query compiler arguments.
 
@@ -151,82 +316,111 @@ def apply_argument_cast(obj: Fn) -> Fn:
         if len(args) == 0 and len(kwargs) == 0:
             return
 
-        current_qc = args[0]
-        calculator = BackendCostCalculator()
-        calculator.add_query_compiler(current_qc)
-
-        def arg_needs_casting(arg):
-            current_qc_type = type(current_qc)
-            return isinstance(arg, BaseQueryCompiler) and not isinstance(
-                arg, current_qc_type
-            )
+        if wrapping_function_type in (classmethod, staticmethod):
+            # TODO: currently we don't support any kind of casting or extension
+            # for classmethod or staticmethod.
+            return f(*args, **kwargs)
+
+        # f() may make in-place updates to some of its arguments. If we cast
+        # an argument and then f() updates it in place, the updates will not
+        # be reflected in the original object. As a fix, we keep track of all
+        # the in-place updates that f() makes, and once f() is finished, we
+        # copy the updates back into the original objects. The query compiler
+        # interface is mostly immutable (the only exceptions being the mutable
+        # index and column properties), so to check for an in-place update, we
+        # check whether an input's query compiler has changed its identity.
+        InplaceUpdateTracker = namedtuple(
+            "InplaceUpdateTracker",
+            ["input_castable", "original_query_compiler", "new_castable"],
+        )
+        inplace_update_trackers: list[InplaceUpdateTracker] = []
+        calculator: BackendCostCalculator = BackendCostCalculator()
 
         def register_query_compilers(arg):
-            if not arg_needs_casting(arg):
-                return arg
-            calculator.add_query_compiler(arg)
+            if isinstance(arg, QueryCompilerCaster):
+                calculator.add_query_compiler(arg._get_query_compiler())
             return arg
 
-        def cast_to_qc(arg):
-            if not arg_needs_casting(arg):
-                return arg
-            qc_type = calculator.calculate()
-            if qc_type is None or qc_type is type(arg):
-                return arg
-
-            from modin.core.execution.dispatching.factories.dispatcher import (
-                FactoryDispatcher,
-            )
-
-            return FactoryDispatcher.from_pandas(
-                arg.to_pandas(), calculator.calculate()
-            )
-
-        if isinstance(current_qc, BaseQueryCompiler):
-            visit_nested_args(kwargs, register_query_compilers)
-            visit_nested_args(args, register_query_compilers)
+        visit_nested_args(args, register_query_compilers)
+        visit_nested_args(kwargs, register_query_compilers)
+
+        # Sometimes the function takes no query compilers as inputs,
+        # e.g. pd.to_datetime(0)
+        if len(calculator._qc_list) > 0:
+            result_backend = calculator.calculate()
+
+            def cast_to_qc(arg):
+                if not (
+                    isinstance(arg, QueryCompilerCaster)
+                    and arg.get_backend() != result_backend
+                ):
+                    return arg
+                cast = arg.set_backend(result_backend)
+                inplace_update_trackers.append(
+                    InplaceUpdateTracker(
+                        input_castable=arg,
+                        original_query_compiler=arg._get_query_compiler(),
+                        new_castable=cast,
+                    )
+                )
+                return cast
 
             args = visit_nested_args(args, cast_to_qc)
             kwargs = visit_nested_args(kwargs, cast_to_qc)
 
-        result_backend = calculator.calculate()
-        obj_or_cls = args[0]
-        if isinstance(obj_or_cls, type):
-            # Currently we are executing on a class method
-            if isinstance(result_backend, str):
-                raise TypeError(
-                    "Result backend is a string, but we expected a class"
-                )  # pragma: no cover
-            # The preferred class has not changed;
-            if obj_or_cls == result_backend:
-                return obj(*args, **kwargs)
-            # The preferred class is different; get the replacement function
-            # There are no instances where this occurs, but if we add more class
-            # methods on DataFrame we can use the following lines to get the new
-            # function:
-            # > obj_new = getattr(obj_or_cls, obj.__name__)
-            # > return obj_new(*args, **kwargs)
-            raise NotImplementedError("Class methods on objects not supported")
+        if wrapping_function_type is None:
+            # TODO(https://github.com/modin-project/modin/issues/7474): dispatch
+            # free functions like pd.concat() to the correct extension.
+            return f(*args, **kwargs)
+
+        # Now we should be dealing with a regular method of a class.
+        ErrorMessage.catch_bugs_and_request_email(
+            wrapping_function_type is not MethodType
+        )
+
+        # When python invokes a method on an object, it passes the object as
+        # the first positional argument.
+        self = args[0]
+        self_backend = self.get_backend()
+        if name in cls._extensions[self_backend]:
+            f_to_apply = cls._extensions[self_backend][name]
         else:
-            if isinstance(result_backend, type):
-                raise TypeError(
-                    "Result backend is a class, but we expected a string"
-                )  # pragma: no cover
-
-            current_backend = obj_or_cls.get_backend()
-
-            if result_backend == current_backend:
-                return obj(*args, **kwargs)
-
-            # Import moved inside the function to avoid cyclic import
-            from modin.core.execution.dispatching.factories.dispatcher import (
-                FactoryDispatcher,
-            )
+            if name not in cls._extensions[None]:
+                raise AttributeError(
+                    f"{type(self).__name__} object has no attribute {name}"
+                )
+            f_to_apply = cls._extensions[None][name]
+        method_result = f_to_apply(*args, **kwargs)
+        for (
+            original_castable,
+            original_qc,
+            new_castable,
+        ) in inplace_update_trackers:
+            new_qc = new_castable._get_query_compiler()
+            if original_qc is not new_qc:
+                new_castable._copy_into(original_castable)
+        return method_result
+
+    return f_with_argument_casting
+
+
+def wrap_free_function_in_argument_caster(name: str) -> callable:
+    """
+    Get a wrapper for a free function that casts all castable arguments to a consistent query compiler.
 
-            new_qc = FactoryDispatcher.from_pandas(
-                current_qc.to_pandas(), result_backend
-            )
-            obj_new = getattr(new_qc, obj.__name__)
-            return obj_new(*args[1:], **kwargs)
+    Parameters
+    ----------
+    name : str
+        The name of the function.
 
-    return cast_args
+    Returns
+    -------
+    callable
+        A wrapper for a free function that casts all castable arguments to a consistent query compiler.
+    """
+    return functools.partial(
+        wrap_function_in_argument_caster,
+        wrapping_function_type=None,
+        cls=None,
+        name=name,
+    )
diff --git a/modin/pandas/api/extensions/extensions.py b/modin/pandas/api/extensions/extensions.py
index b6548084..7a0b43f3 100644
--- a/modin/pandas/api/extensions/extensions.py
+++ b/modin/pandas/api/extensions/extensions.py
@@ -11,13 +11,16 @@
 # ANY KIND, either express or implied. See the License for the specific language
 # governing permissions and limitations under the License.
 
-import inspect
 from collections import defaultdict
-from functools import wraps
-from typing import Any, Callable, Optional
+from types import MethodType
+from typing import Any, Optional
 
 import modin.pandas as pd
 from modin.config import Backend
+from modin.core.storage_formats.pandas.query_compiler_caster import (
+    _NON_EXTENDABLE_ATTRIBUTES,
+    wrap_function_in_argument_caster,
+)
 
 # This type describes a defaultdict that maps backend name (or `None` for
 # method implementation and not bound to any one extension) to the dictionary of
@@ -27,21 +30,6 @@ EXTENSION_DICT_TYPE = defaultdict[Optional[str], dict[str, Any]]
 
 _attrs_to_delete_on_test = defaultdict(list)
 
-_NON_EXTENDABLE_ATTRIBUTES = (
-    # we use these attributes to implement the extension system, so we can't
-    # allow extensions to override them.
-    "__getattribute__",
-    "__setattr__",
-    "__delattr__",
-    "get_backend",
-    "set_backend",
-    "__getattr__",
-    "_get_extension",
-    "_getattribute__from_extension_impl",
-    "_getattr__from_extension_impl",
-    "_query_compiler",
-)
-
 
 def _set_attribute_on_obj(
     name: str, extensions: dict, backend: Optional[str], obj: type
@@ -92,7 +80,12 @@ def _set_attribute_on_obj(
             setattr(
                 obj,
                 name,
-                wrap_method_in_backend_dispatcher(name, new_attr, extensions),
+                wrap_function_in_argument_caster(
+                    f=new_attr,
+                    wrapping_function_type=MethodType,
+                    cls=obj,
+                    name=name,
+                ),
             )
             _attrs_to_delete_on_test[obj].append(name)
         return new_attr
@@ -134,7 +127,7 @@ def register_dataframe_accessor(name: str, *, backend: Optional[str] = None):
         will become the default for all backends.
     """
     return _set_attribute_on_obj(
-        name, pd.dataframe._DATAFRAME_EXTENSIONS_, backend, pd.dataframe.DataFrame
+        name, pd.dataframe.DataFrame._extensions, backend, pd.dataframe.DataFrame
     )
 
 
@@ -172,7 +165,7 @@ def register_series_accessor(name: str, *, backend: Optional[str] = None):
         Returns the decorator function.
     """
     return _set_attribute_on_obj(
-        name, pd.series._SERIES_EXTENSIONS_, backend=backend, obj=pd.series.Series
+        name, pd.series.Series._extensions, backend=backend, obj=pd.series.Series
     )
 
 
@@ -209,13 +202,13 @@ def register_base_accessor(name: str, *, backend: Optional[str] = None):
     decorator
         Returns the decorator function.
     """
-    import modin.pandas.base
+    from modin.pandas.base import BasePandasDataset
 
     return _set_attribute_on_obj(
         name,
-        modin.pandas.base._BASE_EXTENSIONS,
+        BasePandasDataset._extensions,
         backend=backend,
-        obj=modin.pandas.base.BasePandasDataset,
+        obj=BasePandasDataset,
     )
 
 
@@ -272,108 +265,3 @@ def register_pd_accessor(name: str):
         return new_attr
 
     return decorator
-
-
-def wrap_method_in_backend_dispatcher(
-    name: str, method: Callable, extensions: EXTENSION_DICT_TYPE
-) -> Callable:
-    """
-    Wraps a method to dispatch to the correct backend implementation.
-
-    This function is a wrapper that is used to dispatch to the correct backend
-    implementation of a method.
-
-    Parameters
-    ----------
-    name : str
-        The name of the method being wrapped.
-    method : Callable
-        The method being wrapped.
-    extensions : EXTENSION_DICT_TYPE
-        The extensions dictionary for the class this method is defined on.
-
-    Returns
-    -------
-    Callable
-        Returns the wrapped function.
-    """
-
-    @wraps(method)
-    def method_dispatcher(*args, **kwargs):
-        if len(args) == 0 and len(kwargs) == 0:
-            # Handle some cases like __init__()
-            return method(*args, **kwargs)
-        # TODO(https://github.com/modin-project/modin/issues/7470): this
-        # method may take dataframes and series backed by different backends
-        # as input, e.g. if we are here because of a call like
-        # `pd.DataFrame().set_backend('python_test').merge(pd.DataFrame().set_backend('pandas'))`.
-        # In that case, we should determine which backend to cast to, cast all
-        # arguments to that backend, and then choose the appropriate extension
-        # method, if it exists.
-
-        # Assume that `self` is the first argument.
-        self = args[0]
-        remaining_args = args[1:]
-        if (
-            hasattr(self, "_query_compiler")
-            and self.get_backend() in extensions
-            and name in extensions[self.get_backend()]
-        ):
-            # If `self` is using a query compiler whose backend has an
-            # extension for this method, use that extension.
-            return extensions[self.get_backend()][name](self, *remaining_args, **kwargs)
-        else:
-            # Otherwise, use the default implementation.
-            if name not in extensions[None]:
-                raise AttributeError(
-                    f"{type(self).__name__} object has no attribute {name}"
-                )
-            return extensions[None][name](self, *remaining_args, **kwargs)
-
-    return method_dispatcher
-
-
-def wrap_class_methods_in_backend_dispatcher(
-    extensions: EXTENSION_DICT_TYPE,
-) -> Callable:
-    """
-    Get a function that can wrap a class's instance methods so that they dispatch to the correct backend.
-
-    Parameters
-    ----------
-    extensions : EXTENSION_DICT_TYPE
-        The extension dictionary for the class.
-
-    Returns
-    -------
-    Callable
-        The class wrapper.
-    """
-
-    def wrap_methods(cls: type):
-        # We want to avoid wrapping synonyms like __add__() and add() with
-        # different wrappers, so keep a dict mapping methods we've wrapped
-        # to their wrapped versions.
-        already_seen_to_wrapped: dict[Callable, Callable] = {}
-        for method_name, method_value in inspect.getmembers(
-            cls, predicate=inspect.isfunction
-        ):
-            if method_value in already_seen_to_wrapped:
-                setattr(cls, method_name, already_seen_to_wrapped[method_value])
-                continue
-            elif method_name not in _NON_EXTENDABLE_ATTRIBUTES:
-                extensions[None][method_name] = method_value
-                wrapped = wrap_method_in_backend_dispatcher(
-                    method_name, method_value, extensions
-                )
-                if method_name not in cls.__dict__:
-                    # If this class's method comes from a superclass (i.e.
-                    # it's not in cls.__dict__), mark it so that
-                    # modin.utils._inherit_docstrings knows that the method
-                    # must get its docstrings from its superclass.
-                    wrapped._wrapped_superclass_method = method_value
-                setattr(cls, method_name, wrapped)
-                already_seen_to_wrapped[method_value] = getattr(cls, method_name)
-        return cls
-
-    return wrap_methods
diff --git a/modin/pandas/base.py b/modin/pandas/base.py
index f5353a94..37a07b60 100644
--- a/modin/pandas/base.py
+++ b/modin/pandas/base.py
@@ -76,15 +76,18 @@ from pandas.util._validators import (
 
 from modin import pandas as pd
 from modin.config import Backend
+from modin.core.storage_formats.pandas.query_compiler_caster import QueryCompilerCaster
 from modin.error_message import ErrorMessage
 from modin.logging import ClassLogger, disable_logging
 from modin.pandas.accessor import CachedAccessor, ModinAPI
-from modin.pandas.api.extensions.extensions import (
-    EXTENSION_DICT_TYPE,
-    wrap_class_methods_in_backend_dispatcher,
-)
+from modin.pandas.api.extensions.extensions import EXTENSION_DICT_TYPE
 from modin.pandas.utils import GET_BACKEND_DOC, SET_BACKEND_DOC, is_scalar
-from modin.utils import _inherit_docstrings, expanduser_path_arg, try_cast_to_pandas
+from modin.utils import (
+    _inherit_docstrings,
+    expanduser_path_arg,
+    sentinel,
+    try_cast_to_pandas,
+)
 
 from .utils import _doc_binary_op, is_full_grab_slice
 
@@ -99,9 +102,6 @@ if TYPE_CHECKING:
     from .series import Series
     from .window import Expanding, Rolling, Window
 
-# Similar to pandas, sentinel value to use as kwarg in place of None when None has
-# special meaning and needs to be distinguished from a user explicitly passing None.
-sentinel = object()
 
 # Do not look up these attributes when searching for extensions. We use them
 # to implement the extension lookup itself.
@@ -111,6 +111,8 @@ _EXTENSION_NO_LOOKUP = {
     "get_backend",
     "_getattribute__from_extension_impl",
     "_getattr__from_extension_impl",
+    "_get_query_compiler",
+    "set_backend",
 }
 
 # Do not lookup certain attributes in columns or index, as they're used for some
@@ -212,12 +214,8 @@ def _get_repr_axis_label_indexer(labels, num_for_repr):
     )
 
 
-_BASE_EXTENSIONS: EXTENSION_DICT_TYPE = EXTENSION_DICT_TYPE(dict)
-
-
 @_inherit_docstrings(pandas.DataFrame, apilink=["pandas.DataFrame", "pandas.Series"])
-@wrap_class_methods_in_backend_dispatcher(extensions=_BASE_EXTENSIONS)
-class BasePandasDataset(ClassLogger):
+class BasePandasDataset(QueryCompilerCaster, ClassLogger):
     """
     Implement most of the common code that exists in DataFrame/Series.
 
@@ -232,6 +230,8 @@ class BasePandasDataset(ClassLogger):
     _query_compiler: BaseQueryCompiler
     _siblings: list[BasePandasDataset]
 
+    _extensions: EXTENSION_DICT_TYPE = EXTENSION_DICT_TYPE(dict)
+
     @cached_property
     def _is_dataframe(self) -> bool:
         """
@@ -4352,7 +4352,7 @@ class BasePandasDataset(ClassLogger):
 
         if item not in _EXTENSION_NO_LOOKUP:
             extensions_result = self._getattribute__from_extension_impl(
-                item, _BASE_EXTENSIONS
+                item, __class__._extensions
             )
             if extensions_result is not sentinel:
                 return extensions_result
@@ -4388,7 +4388,7 @@ class BasePandasDataset(ClassLogger):
         # NOTE that to get an attribute, python calls __getattribute__() first and
         # then falls back to __getattr__() if the former raises an AttributeError.
         if item not in _EXTENSION_NO_LOOKUP:
-            extension = self._getattr__from_extension_impl(item, _BASE_EXTENSIONS)
+            extension = self._getattr__from_extension_impl(item, __class__._extensions)
             if extension is not sentinel:
                 return extension
         return object.__getattribute__(self, item)
@@ -4534,7 +4534,7 @@ class BasePandasDataset(ClassLogger):
         None
         """
         # An extension property is only accessible if the backend supports it.
-        extension = self._get_extension(key, _BASE_EXTENSIONS)
+        extension = self._get_extension(key, __class__._extensions)
         if extension is not sentinel and hasattr(extension, "__set__"):
             return extension.__set__(self, value)
         return super().__setattr__(key, value)
@@ -4554,7 +4554,7 @@ class BasePandasDataset(ClassLogger):
         None
         """
         # An extension property is only accessible if the backend supports it.
-        extension = self._get_extension(name, _BASE_EXTENSIONS)
+        extension = self._get_extension(name, __class__._extensions)
         if extension is not sentinel and hasattr(extension, "__delete__"):
             return extension.__delete__(self)
         return super().__delattr__(name)
@@ -4629,3 +4629,8 @@ class BasePandasDataset(ClassLogger):
             assert not callable(extension)
             return extension
         return sentinel
+
+    @disable_logging
+    @_inherit_docstrings(QueryCompilerCaster._copy_into)
+    def _get_query_compiler(self):
+        return self._query_compiler
diff --git a/modin/pandas/dataframe.py b/modin/pandas/dataframe.py
index b1157db6..a7fad2e7 100644
--- a/modin/pandas/dataframe.py
+++ b/modin/pandas/dataframe.py
@@ -62,10 +62,7 @@ from modin.config import PersistentPickle
 from modin.error_message import ErrorMessage
 from modin.logging import disable_logging
 from modin.pandas import Categorical
-from modin.pandas.api.extensions.extensions import (
-    EXTENSION_DICT_TYPE,
-    wrap_class_methods_in_backend_dispatcher,
-)
+from modin.pandas.api.extensions.extensions import EXTENSION_DICT_TYPE
 from modin.pandas.io import from_non_pandas, from_pandas, to_pandas
 from modin.utils import (
     MODIN_UNNAMED_SERIES_LABEL,
@@ -73,11 +70,12 @@ from modin.utils import (
     expanduser_path_arg,
     hashable,
     import_optional_dependency,
+    sentinel,
     try_cast_to_pandas,
 )
 
 from .accessor import CachedAccessor, SparseFrameAccessor
-from .base import _ATTRS_NO_LOOKUP, _EXTENSION_NO_LOOKUP, BasePandasDataset, sentinel
+from .base import _ATTRS_NO_LOOKUP, _EXTENSION_NO_LOOKUP, BasePandasDataset
 from .groupby import DataFrameGroupBy
 from .iterator import PartitionIterator
 from .series import Series
@@ -95,14 +93,9 @@ if TYPE_CHECKING:
     from modin.core.storage_formats import BaseQueryCompiler
 
 
-# Dictionary of extensions assigned to this class
-_DATAFRAME_EXTENSIONS_: EXTENSION_DICT_TYPE = EXTENSION_DICT_TYPE(dict)
-
-
 @_inherit_docstrings(
     pandas.DataFrame, excluded=[pandas.DataFrame.__init__], apilink="pandas.DataFrame"
 )
-@wrap_class_methods_in_backend_dispatcher(extensions=_DATAFRAME_EXTENSIONS_)
 class DataFrame(BasePandasDataset):
     """
     Modin distributed representation of ``pandas.DataFrame``.
@@ -147,6 +140,7 @@ class DataFrame(BasePandasDataset):
     """
 
     _pandas_class = pandas.DataFrame
+    _extensions: EXTENSION_DICT_TYPE = EXTENSION_DICT_TYPE(dict)
 
     def __init__(
         self,
@@ -2623,7 +2617,7 @@ class DataFrame(BasePandasDataset):
 
         if item not in _EXTENSION_NO_LOOKUP:
             extensions_result = self._getattribute__from_extension_impl(
-                item, _DATAFRAME_EXTENSIONS_
+                item, __class__._extensions
             )
             if extensions_result is not sentinel:
                 return extensions_result
@@ -2652,7 +2646,7 @@ class DataFrame(BasePandasDataset):
         # then falls back to __getattr__() if the former raises an AttributeError.
 
         if key not in _EXTENSION_NO_LOOKUP:
-            extension = self._getattr__from_extension_impl(key, _DATAFRAME_EXTENSIONS_)
+            extension = self._getattr__from_extension_impl(key, __class__._extensions)
             if extension is not sentinel:
                 return extension
         try:
@@ -2691,8 +2685,8 @@ class DataFrame(BasePandasDataset):
         #   before it appears in __dict__.
         if key in ("_query_compiler", "_siblings") or key in self.__dict__:
             pass
-        elif self._get_extension(key, _DATAFRAME_EXTENSIONS_) is not sentinel:
-            return self._get_extension(key, _DATAFRAME_EXTENSIONS_).__set__(self, value)
+        elif self._get_extension(key, __class__._extensions) is not sentinel:
+            return self._get_extension(key, __class__._extensions).__set__(self, value)
         # we have to check for the key in `dir(self)` first in order not to trigger columns computation
         elif key not in dir(self) and key in self:
             self.__setitem__(key, value)
@@ -3390,7 +3384,14 @@ class DataFrame(BasePandasDataset):
         -------
         None
         """
-        extension = self._get_extension(name, _DATAFRAME_EXTENSIONS_)
+        extension = self._get_extension(name, __class__._extensions)
         if extension is not sentinel:
             return extension.__delete__(self)
         return super().__delattr__(name)
+
+    @disable_logging
+    @_inherit_docstrings(BasePandasDataset._copy_into)
+    def _copy_into(self, other: DataFrame) -> None:
+        other._query_compiler = self._query_compiler
+        other._siblings = self._siblings
+        return None
diff --git a/modin/pandas/general.py b/modin/pandas/general.py
index 92aa195e..7ea943b1 100644
--- a/modin/pandas/general.py
+++ b/modin/pandas/general.py
@@ -25,6 +25,9 @@ from pandas._typing import ArrayLike, DtypeBackend, Scalar, npt
 from pandas.core.dtypes.common import is_list_like
 
 from modin.core.storage_formats import BaseQueryCompiler
+from modin.core.storage_formats.pandas.query_compiler_caster import (
+    wrap_free_function_in_argument_caster,
+)
 from modin.error_message import ErrorMessage
 from modin.logging import enable_logging
 from modin.pandas.io import to_pandas
@@ -37,6 +40,7 @@ from .series import Series
 
 @_inherit_docstrings(pandas.isna, apilink="pandas.isna")
 @enable_logging
+@wrap_free_function_in_argument_caster("isna")
 def isna(
     obj,
 ) -> bool | npt.NDArray[np.bool_] | Series | DataFrame:  # noqa: PR01, RT01, D200
@@ -54,6 +58,7 @@ isnull = isna
 
 @_inherit_docstrings(pandas.notna, apilink="pandas.notna")
 @enable_logging
+@wrap_free_function_in_argument_caster("notna")
 def notna(
     obj,
 ) -> bool | npt.NDArray[np.bool_] | Series | DataFrame:  # noqa: PR01, RT01, D200
@@ -71,6 +76,7 @@ notnull = notna
 
 @_inherit_docstrings(pandas.merge, apilink="pandas.merge")
 @enable_logging
+@wrap_free_function_in_argument_caster("merge")
 def merge(
     left,
     right,
@@ -118,6 +124,7 @@ def merge(
 
 @_inherit_docstrings(pandas.merge_ordered, apilink="pandas.merge_ordered")
 @enable_logging
+@wrap_free_function_in_argument_caster("merge_ordered")
 def merge_ordered(
     left,
     right,
@@ -156,6 +163,7 @@ def merge_ordered(
 
 @_inherit_docstrings(pandas.merge_asof, apilink="pandas.merge_asof")
 @enable_logging
+@wrap_free_function_in_argument_caster("merge_asof")
 def merge_asof(
     left,
     right,
@@ -226,6 +234,7 @@ def merge_asof(
 
 @_inherit_docstrings(pandas.pivot_table, apilink="pandas.pivot_table")
 @enable_logging
+@wrap_free_function_in_argument_caster("pivot_table")
 def pivot_table(
     data,
     values=None,
@@ -260,6 +269,7 @@ def pivot_table(
 
 @_inherit_docstrings(pandas.pivot, apilink="pandas.pivot")
 @enable_logging
+@wrap_free_function_in_argument_caster("pivot")
 def pivot(
     data, *, columns, index=no_default, values=no_default
 ) -> DataFrame:  # noqa: PR01, RT01, D200
@@ -273,6 +283,7 @@ def pivot(
 
 @_inherit_docstrings(pandas.to_numeric, apilink="pandas.to_numeric")
 @enable_logging
+@wrap_free_function_in_argument_caster("to_numeric")
 def to_numeric(
     arg,
     errors="raise",
@@ -293,6 +304,7 @@ def to_numeric(
 
 @_inherit_docstrings(pandas.qcut, apilink="pandas.qcut")
 @enable_logging
+@wrap_free_function_in_argument_caster("qcut")
 def qcut(
     x, q, labels=None, retbins=False, precision=3, duplicates="raise"
 ):  # noqa: PR01, RT01, D200
@@ -312,6 +324,7 @@ def qcut(
 
 @_inherit_docstrings(pandas.cut, apilink="pandas.cut")
 @enable_logging
+@wrap_free_function_in_argument_caster("cut")
 def cut(
     x,
     bins,
@@ -366,6 +379,7 @@ def cut(
 
 @_inherit_docstrings(pandas.unique, apilink="pandas.unique")
 @enable_logging
+@wrap_free_function_in_argument_caster("unique")
 def unique(values) -> ArrayLike:  # noqa: PR01, RT01, D200
     """
     Return unique values based on a hash table.
@@ -375,6 +389,7 @@ def unique(values) -> ArrayLike:  # noqa: PR01, RT01, D200
 
 # Adding docstring since pandas docs don't have web section for this function.
 @enable_logging
+@wrap_free_function_in_argument_caster("value_counts")
 def value_counts(
     values, sort=True, ascending=False, normalize=False, bins=None, dropna=True
 ) -> Series:
@@ -417,6 +432,7 @@ def value_counts(
 
 @_inherit_docstrings(pandas.concat, apilink="pandas.concat")
 @enable_logging
+@wrap_free_function_in_argument_caster(name="concat")
 def concat(
     objs: "Iterable[DataFrame | Series] | Mapping[Hashable, DataFrame | Series]",
     *,
@@ -566,6 +582,7 @@ def concat(
 
 @_inherit_docstrings(pandas.to_datetime, apilink="pandas.to_datetime")
 @enable_logging
+@wrap_free_function_in_argument_caster("to_datetime")
 def to_datetime(
     arg,
     errors="raise",
@@ -612,6 +629,7 @@ def to_datetime(
 
 @_inherit_docstrings(pandas.get_dummies, apilink="pandas.get_dummies")
 @enable_logging
+@wrap_free_function_in_argument_caster("get_dummies")
 def get_dummies(
     data,
     prefix=None,
@@ -661,6 +679,7 @@ def get_dummies(
 
 @_inherit_docstrings(pandas.melt, apilink="pandas.melt")
 @enable_logging
+@wrap_free_function_in_argument_caster("melt")
 def melt(
     frame,
     id_vars=None,
@@ -685,6 +704,7 @@ def melt(
 
 @_inherit_docstrings(pandas.crosstab, apilink="pandas.crosstab")
 @enable_logging
+@wrap_free_function_in_argument_caster("crosstab")
 def crosstab(
     index,
     columns,
@@ -718,6 +738,7 @@ def crosstab(
 
 # Adding docstring since pandas docs don't have web section for this function.
 @enable_logging
+@wrap_free_function_in_argument_caster("lreshape")
 def lreshape(data: DataFrame, groups, dropna=True) -> DataFrame:
     """
     Reshape wide-format data to long. Generalized inverse of ``DataFrame.pivot``.
@@ -747,6 +768,7 @@ def lreshape(data: DataFrame, groups, dropna=True) -> DataFrame:
 
 
 @_inherit_docstrings(pandas.wide_to_long, apilink="pandas.wide_to_long")
+@wrap_free_function_in_argument_caster("wide_to_long")
 @enable_logging
 def wide_to_long(
     df: DataFrame, stubnames, i, j, sep: str = "", suffix: str = r"\d+"
@@ -769,6 +791,7 @@ def wide_to_long(
     )
 
 
+@wrap_free_function_in_argument_caster("_determine_name")
 def _determine_name(objs: Iterable[BaseQueryCompiler], axis: Union[int, str]):
     """
     Determine names of index after concatenation along passed axis.
@@ -800,6 +823,7 @@ def _determine_name(objs: Iterable[BaseQueryCompiler], axis: Union[int, str]):
         return None
 
 
+@wrap_free_function_in_argument_caster("to_timedelta")
 @_inherit_docstrings(pandas.to_datetime, apilink="pandas.to_timedelta")
 @enable_logging
 def to_timedelta(
diff --git a/modin/pandas/indexing.py b/modin/pandas/indexing.py
index eb1808f0..10913515 100644
--- a/modin/pandas/indexing.py
+++ b/modin/pandas/indexing.py
@@ -40,8 +40,11 @@ from pandas.api.types import is_bool, is_list_like
 from pandas.core.dtypes.common import is_bool_dtype, is_integer, is_integer_dtype
 from pandas.core.indexing import IndexingError
 
+from modin.core.storage_formats.pandas.query_compiler_caster import QueryCompilerCaster
 from modin.error_message import ErrorMessage
-from modin.logging import ClassLogger
+from modin.logging import ClassLogger, disable_logging
+from modin.pandas.api.extensions.extensions import EXTENSION_DICT_TYPE
+from modin.utils import _inherit_docstrings
 
 from .dataframe import DataFrame
 from .series import Series
@@ -273,7 +276,7 @@ def _compute_ndim(row_loc, col_loc):
     return ndim
 
 
-class _LocationIndexerBase(ClassLogger):
+class _LocationIndexerBase(QueryCompilerCaster, ClassLogger):
     """
     Base class for location indexer like loc and iloc.
 
@@ -285,6 +288,29 @@ class _LocationIndexerBase(ClassLogger):
 
     df: Union[DataFrame, Series]
     qc: BaseQueryCompiler
+    _extensions: EXTENSION_DICT_TYPE = EXTENSION_DICT_TYPE(dict)
+
+    @disable_logging
+    @_inherit_docstrings(QueryCompilerCaster.set_backend)
+    def set_backend(self, backend):
+        return type(self)(self.df.set_backend(backend))
+
+    @disable_logging
+    @_inherit_docstrings(QueryCompilerCaster._get_query_compiler)
+    def _get_query_compiler(self):
+        return self.qc
+
+    @disable_logging
+    @_inherit_docstrings(QueryCompilerCaster.get_backend)
+    def get_backend(self):
+        return self.qc.get_backend()
+
+    @disable_logging
+    @_inherit_docstrings(QueryCompilerCaster._copy_into)
+    def _copy_into(self, other: Series):
+        other.qc = self.df._query_compiler
+        other.df._update_inplace(new_query_compiler=self.df._query_compiler)
+        return None
 
     def __init__(self, modin_df: Union[DataFrame, Series]):
         self.df = modin_df
@@ -625,6 +651,8 @@ class _LocIndexer(_LocationIndexerBase):
         DataFrame to operate on.
     """
 
+    _extensions: EXTENSION_DICT_TYPE = EXTENSION_DICT_TYPE(dict)
+
     def __getitem__(self, key):
         """
         Retrieve dataset according to `key`.
@@ -980,6 +1008,8 @@ class _iLocIndexer(_LocationIndexerBase):
         DataFrame to operate on.
     """
 
+    _extensions: EXTENSION_DICT_TYPE = EXTENSION_DICT_TYPE(dict)
+
     def __getitem__(self, key):
         """
         Retrieve dataset according to `key`.
diff --git a/modin/pandas/series.py b/modin/pandas/series.py
index 854d7381..71dea966 100644
--- a/modin/pandas/series.py
+++ b/modin/pandas/series.py
@@ -34,19 +34,17 @@ from pandas.util._validators import validate_bool_kwarg
 
 from modin.config import PersistentPickle
 from modin.logging import disable_logging
-from modin.pandas.api.extensions.extensions import (
-    EXTENSION_DICT_TYPE,
-    wrap_class_methods_in_backend_dispatcher,
-)
+from modin.pandas.api.extensions.extensions import EXTENSION_DICT_TYPE
 from modin.pandas.io import from_pandas, to_pandas
 from modin.utils import (
     MODIN_UNNAMED_SERIES_LABEL,
     _inherit_docstrings,
     import_optional_dependency,
+    sentinel,
 )
 
 from .accessor import CachedAccessor, SparseAccessor
-from .base import _ATTRS_NO_LOOKUP, _EXTENSION_NO_LOOKUP, BasePandasDataset, sentinel
+from .base import _ATTRS_NO_LOOKUP, _EXTENSION_NO_LOOKUP, BasePandasDataset
 from .iterator import PartitionIterator
 from .series_utils import (
     CategoryMethods,
@@ -71,14 +69,10 @@ if TYPE_CHECKING:
 
     from .dataframe import DataFrame
 
-# Dictionary of extensions assigned to this class
-_SERIES_EXTENSIONS_: EXTENSION_DICT_TYPE = EXTENSION_DICT_TYPE(dict)
-
 
 @_inherit_docstrings(
     pandas.Series, excluded=[pandas.Series.__init__], apilink="pandas.Series"
 )
-@wrap_class_methods_in_backend_dispatcher(extensions=_SERIES_EXTENSIONS_)
 class Series(BasePandasDataset):
     """
     Modin distributed representation of `pandas.Series`.
@@ -112,6 +106,8 @@ class Series(BasePandasDataset):
     _pandas_class = pandas.Series
     __array_priority__ = pandas.Series.__array_priority__
 
+    _extensions: EXTENSION_DICT_TYPE = EXTENSION_DICT_TYPE(dict)
+
     def __init__(
         self,
         data=None,
@@ -363,7 +359,7 @@ class Series(BasePandasDataset):
         # then falls back to __getattr__() if the former raises an AttributeError.
         if key not in _EXTENSION_NO_LOOKUP:
             extensions_result = self._getattribute__from_extension_impl(
-                key, _SERIES_EXTENSIONS_
+                key, __class__._extensions
             )
             if extensions_result is not sentinel:
                 return extensions_result
@@ -392,7 +388,7 @@ class Series(BasePandasDataset):
         # NOTE that to get an attribute, python calls __getattribute__() first and
         # then falls back to __getattr__() if the former raises an AttributeError.
         if key not in _EXTENSION_NO_LOOKUP:
-            extension = self._getattr__from_extension_impl(key, _SERIES_EXTENSIONS_)
+            extension = self._getattr__from_extension_impl(key, __class__._extensions)
             if extension is not sentinel:
                 return extension
         try:
@@ -562,7 +558,7 @@ class Series(BasePandasDataset):
         None
         """
         # An extension property is only accessible if the backend supports it.
-        extension = self._get_extension(name, _SERIES_EXTENSIONS_)
+        extension = self._get_extension(name, __class__._extensions)
         if extension is not sentinel and hasattr(extension, "__set__"):
             return extension.__set__(self, value)
         return super().__setattr__(name, value)
@@ -582,7 +578,7 @@ class Series(BasePandasDataset):
         None
         """
         # An extension property is only accessible if the backend supports it.
-        extension = self._get_extension(name, _SERIES_EXTENSIONS_)
+        extension = self._get_extension(name, __class__._extensions)
         if extension is not sentinel and hasattr(extension, "__delete__"):
             return extension.__delete__(self)
         return super().__delattr__(name)
@@ -2885,3 +2881,10 @@ class Series(BasePandasDataset):
     @disable_logging
     def get_backend(self) -> str:
         return super().get_backend()
+
+    @disable_logging
+    @_inherit_docstrings(BasePandasDataset._copy_into)
+    def _copy_into(self, other: Series):
+        other._query_compiler = self._query_compiler
+        other._siblings = self._siblings
+        return None
diff --git a/modin/utils.py b/modin/utils.py
index 81d5d2db..c4aaa527 100644
--- a/modin/utils.py
+++ b/modin/utils.py
@@ -51,6 +51,10 @@ from pandas.util._print_versions import (  # type: ignore[attr-defined]
 from modin._version import get_versions
 from modin.config import DocModule, Engine, StorageFormat
 
+# Similar to pandas, sentinel value to use as kwarg in place of None when None has
+# special meaning and needs to be distinguished from a user explicitly passing None.
+sentinel = object()
+
 T = TypeVar("T")
 """Generic type parameter"""
 


Test patch:
diff --git a/modin/tests/pandas/extensions/conftest.py b/modin/tests/pandas/extensions/conftest.py
index 54ac1c6d..0573caaa 100644
--- a/modin/tests/pandas/extensions/conftest.py
+++ b/modin/tests/pandas/extensions/conftest.py
@@ -20,6 +20,7 @@ from modin.config import Backend, Engine, Execution, StorageFormat
 from modin.core.execution.dispatching.factories import factories
 from modin.core.execution.dispatching.factories.factories import BaseFactory, NativeIO
 from modin.core.storage_formats.pandas.native_query_compiler import NativeQueryCompiler
+from modin.pandas.api.extensions.extensions import _NON_EXTENDABLE_ATTRIBUTES
 
 
 class Test1QueryCompiler(NativeQueryCompiler):
@@ -41,16 +42,16 @@ class Test1Factory(BaseFactory):
 @pytest.fixture(autouse=True)
 def clean_up_extensions():
 
-    original_dataframe_extensions = copy.deepcopy(pd.dataframe._DATAFRAME_EXTENSIONS_)
-    original_series_extensions = copy.deepcopy(pd.series._SERIES_EXTENSIONS_)
-    original_base_extensions = copy.deepcopy(pd.base._BASE_EXTENSIONS)
+    original_dataframe_extensions = copy.deepcopy(pd.dataframe.DataFrame._extensions)
+    original_series_extensions = copy.deepcopy(pd.Series._extensions)
+    original_base_extensions = copy.deepcopy(pd.base.BasePandasDataset._extensions)
     yield
-    pd.dataframe._DATAFRAME_EXTENSIONS_.clear()
-    pd.dataframe._DATAFRAME_EXTENSIONS_.update(original_dataframe_extensions)
-    pd.series._SERIES_EXTENSIONS_.clear()
-    pd.series._SERIES_EXTENSIONS_.update(original_series_extensions)
-    pd.base._BASE_EXTENSIONS.clear()
-    pd.base._BASE_EXTENSIONS.update(original_base_extensions)
+    pd.dataframe.DataFrame._extensions.clear()
+    pd.dataframe.DataFrame._extensions.update(original_dataframe_extensions)
+    pd.Series._extensions.clear()
+    pd.Series._extensions.update(original_series_extensions)
+    pd.base.BasePandasDataset._extensions.clear()
+    pd.base.BasePandasDataset._extensions.update(original_base_extensions)
 
     from modin.pandas.api.extensions.extensions import _attrs_to_delete_on_test
 
@@ -71,3 +72,12 @@ def Backend1():
             Execution(storage_format="Test1_Storage_Format", engine="Test1_Engine"),
         )
     return "Backend1"
+
+
+@pytest.fixture(
+    # sort the set of non-extendable attributes to make the sequence of test
+    # cases deterministic for pytest-xdist.
+    params=sorted(_NON_EXTENDABLE_ATTRIBUTES),
+)
+def non_extendable_attribute_name(request) -> str:
+    return request.param
diff --git a/modin/tests/pandas/extensions/test_base_extensions.py b/modin/tests/pandas/extensions/test_base_extensions.py
index 93e9fa3c..e45438c8 100644
--- a/modin/tests/pandas/extensions/test_base_extensions.py
+++ b/modin/tests/pandas/extensions/test_base_extensions.py
@@ -17,7 +17,6 @@ import pytest
 
 import modin.pandas as pd
 from modin.pandas.api.extensions import register_base_accessor
-from modin.pandas.api.extensions.extensions import _NON_EXTENDABLE_ATTRIBUTES
 from modin.tests.pandas.utils import df_equals
 
 
@@ -269,10 +268,13 @@ def test_deleting_extension_that_is_not_property_raises_attribute_error(
         delattr(modin_object, method_name)
 
 
-@pytest.mark.parametrize("name", _NON_EXTENDABLE_ATTRIBUTES)
-def test_disallowed_extensions(Backend1, name):
+def test_disallowed_extensions(Backend1, non_extendable_attribute_name):
     with pytest.raises(
         ValueError,
-        match=re.escape(f"Cannot register an extension with the reserved name {name}."),
+        match=re.escape(
+            f"Cannot register an extension with the reserved name {non_extendable_attribute_name}."
+        ),
     ):
-        register_base_accessor(name=name, backend=Backend1)("unused_value")
+        register_base_accessor(name=non_extendable_attribute_name, backend=Backend1)(
+            "unused_value"
+        )
diff --git a/modin/tests/pandas/extensions/test_dataframe_extensions.py b/modin/tests/pandas/extensions/test_dataframe_extensions.py
index eeff733d..7d811b2f 100644
--- a/modin/tests/pandas/extensions/test_dataframe_extensions.py
+++ b/modin/tests/pandas/extensions/test_dataframe_extensions.py
@@ -18,7 +18,6 @@ import pytest
 
 import modin.pandas as pd
 from modin.pandas.api.extensions import register_dataframe_accessor
-from modin.pandas.api.extensions.extensions import _NON_EXTENDABLE_ATTRIBUTES
 
 
 def test_dataframe_extension_simple_method(Backend1):
@@ -222,10 +221,13 @@ def test_deleting_extension_that_is_not_property_raises_attribute_error(Backend1
         delattr(df, method_name)
 
 
-@pytest.mark.parametrize("name", _NON_EXTENDABLE_ATTRIBUTES)
-def test_disallowed_extensions(Backend1, name):
+def test_disallowed_extensions(Backend1, non_extendable_attribute_name):
     with pytest.raises(
         ValueError,
-        match=re.escape(f"Cannot register an extension with the reserved name {name}."),
+        match=re.escape(
+            f"Cannot register an extension with the reserved name {non_extendable_attribute_name}."
+        ),
     ):
-        register_dataframe_accessor(name=name, backend=Backend1)("unused_value")
+        register_dataframe_accessor(
+            name=non_extendable_attribute_name, backend=Backend1
+        )("unused_value")
diff --git a/modin/tests/pandas/extensions/test_series_extensions.py b/modin/tests/pandas/extensions/test_series_extensions.py
index 920683ac..5fbea58f 100644
--- a/modin/tests/pandas/extensions/test_series_extensions.py
+++ b/modin/tests/pandas/extensions/test_series_extensions.py
@@ -17,7 +17,6 @@ import pytest
 
 import modin.pandas as pd
 from modin.pandas.api.extensions import register_series_accessor
-from modin.pandas.api.extensions.extensions import _NON_EXTENDABLE_ATTRIBUTES
 
 
 def test_series_extension_simple_method(Backend1):
@@ -198,10 +197,13 @@ def test_deleting_extension_that_is_not_property_raises_attribute_error(Backend1
         delattr(series, method_name)
 
 
-@pytest.mark.parametrize("name", _NON_EXTENDABLE_ATTRIBUTES)
-def test_disallowed_extensions(Backend1, name):
+def test_disallowed_extensions(Backend1, non_extendable_attribute_name):
     with pytest.raises(
         ValueError,
-        match=re.escape(f"Cannot register an extension with the reserved name {name}."),
+        match=re.escape(
+            f"Cannot register an extension with the reserved name {non_extendable_attribute_name}."
+        ),
     ):
-        register_series_accessor(name=name, backend=Backend1)("unused_value")
+        register_series_accessor(name=non_extendable_attribute_name, backend=Backend1)(
+            "unused_value"
+        )
diff --git a/modin/tests/pandas/native_df_interoperability/conftest.py b/modin/tests/pandas/native_df_interoperability/conftest.py
index 31486c4b..64ff5ee5 100644
--- a/modin/tests/pandas/native_df_interoperability/conftest.py
+++ b/modin/tests/pandas/native_df_interoperability/conftest.py
@@ -16,6 +16,13 @@ import itertools
 import pytest
 
 
-@pytest.fixture(params=list(itertools.product([True, False], repeat=2)))
+def _get_native_bool_descriptor(v: bool) -> str:
+    return "native" if v else "default"
+
+
+@pytest.fixture(
+    params=list(itertools.product([True, False], repeat=2)),
+    ids=lambda param: "_".join(_get_native_bool_descriptor(v) for v in param),
+)
 def df_mode_pair(request):
     return request.param
diff --git a/modin/tests/pandas/native_df_interoperability/test_compiler_caster.py b/modin/tests/pandas/native_df_interoperability/test_compiler_caster.py
index c2751a96..3db1a055 100644
--- a/modin/tests/pandas/native_df_interoperability/test_compiler_caster.py
+++ b/modin/tests/pandas/native_df_interoperability/test_compiler_caster.py
@@ -24,13 +24,14 @@ from modin.core.storage_formats.base.query_compiler_calculator import (
     BackendCostCalculator,
 )
 from modin.core.storage_formats.pandas.native_query_compiler import NativeQueryCompiler
+from modin.tests.pandas.utils import df_equals
 
 
 class CloudQC(NativeQueryCompiler):
     "Represents a cloud-hosted query compiler"
 
     def get_backend(self):
-        return "cloud"
+        return "Cloud"
 
     def qc_engine_switch_max_cost(self):
         return QCCoercionCost.COST_IMPOSSIBLE
@@ -49,7 +50,7 @@ class ClusterQC(NativeQueryCompiler):
     "Represents a local network cluster query compiler"
 
     def get_backend(self):
-        return "cluster"
+        return "Cluster"
 
     def qc_engine_switch_max_cost(self):
         return QCCoercionCost.COST_HIGH
@@ -68,7 +69,7 @@ class LocalMachineQC(NativeQueryCompiler):
     "Represents a local machine query compiler"
 
     def get_backend(self):
-        return "local_machine"
+        return "Local_machine"
 
     def qc_engine_switch_max_cost(self):
         return QCCoercionCost.COST_MEDIUM
@@ -86,7 +87,7 @@ class PicoQC(NativeQueryCompiler):
     "Represents a query compiler with very few resources"
 
     def get_backend(self):
-        return "pico"
+        return "Pico"
 
     def qc_engine_switch_max_cost(self):
         return QCCoercionCost.COST_LOW
@@ -104,7 +105,7 @@ class AdversarialQC(NativeQueryCompiler):
     "Represents a query compiler which returns non-sensical costs"
 
     def get_backend(self):
-        return "adversarial"
+        return "Adversarial"
 
     def qc_engine_switch_cost(self, other_qc_cls):
         return {
@@ -118,14 +119,14 @@ class DefaultQC(NativeQueryCompiler):
     "Represents a query compiler with no costing information"
 
     def get_backend(self):
-        return "test_casting_default"
+        return "Test_casting_default"
 
 
 class DefaultQC2(NativeQueryCompiler):
     "Represents a query compiler with no costing information, but different."
 
     def get_backend(self):
-        return "test_casting_default_2"
+        return "Test_casting_default_2"
 
 
 def register_backend(name, qc):
@@ -144,158 +145,159 @@ def register_backend(name, qc):
     Backend.register_backend(name, Execution(name, "Native"))
 
 
-register_backend("pico", PicoQC)
-register_backend("cluster", ClusterQC)
-register_backend("cloud", CloudQC)
-register_backend("local_machine", LocalMachineQC)
-register_backend("adversarial", AdversarialQC)
-register_backend("test_casting_default", DefaultQC)
-register_backend("test_casting_default_2", DefaultQC2)
+register_backend("Pico", PicoQC)
+register_backend("Cluster", ClusterQC)
+register_backend("Cloud", CloudQC)
+register_backend("Local_machine", LocalMachineQC)
+register_backend("Adversarial", AdversarialQC)
+register_backend("Test_casting_default", DefaultQC)
+register_backend("Test_casting_default_2", DefaultQC2)
 
 
 @pytest.fixture()
 def cloud_df():
-    return CloudQC(pandas.DataFrame([0, 1, 2]))
+    return pd.DataFrame(query_compiler=CloudQC(pandas.DataFrame([0, 1, 2])))
 
 
 @pytest.fixture()
 def cluster_df():
-    return ClusterQC(pandas.DataFrame([0, 1, 2]))
+    return pd.DataFrame(query_compiler=ClusterQC(pandas.DataFrame([0, 1, 2])))
 
 
 @pytest.fixture()
 def local_df():
-    return LocalMachineQC(pandas.DataFrame([0, 1, 2]))
+    return pd.DataFrame(query_compiler=LocalMachineQC(pandas.DataFrame([0, 1, 2])))
 
 
 @pytest.fixture()
 def pico_df():
-    return PicoQC(pandas.DataFrame([0, 1, 2]))
+    return pd.DataFrame(query_compiler=PicoQC(pandas.DataFrame([0, 1, 2])))
 
 
 @pytest.fixture()
 def adversarial_df():
-    return AdversarialQC(pandas.DataFrame([0, 1, 2]))
+    return pd.DataFrame(query_compiler=AdversarialQC(pandas.DataFrame([0, 1, 2])))
 
 
 @pytest.fixture()
 def default_df():
-    return DefaultQC(pandas.DataFrame([0, 1, 2]))
+    return pd.DataFrame(query_compiler=DefaultQC(pandas.DataFrame([0, 1, 2])))
 
 
 @pytest.fixture()
 def default2_df():
-    return DefaultQC2(pandas.DataFrame([0, 1, 2]))
+    return pd.DataFrame(query_compiler=DefaultQC2(pandas.DataFrame([0, 1, 2])))
 
 
-def test_two_same_qc_types_noop(pico_df):
-    df3 = pico_df.concat(axis=1, other=pico_df)
-    assert type(df3) is type(pico_df)
+def test_two_same_backend(pico_df):
+    df3 = pd.concat([pico_df, pico_df], axis=1)
+    assert pico_df.get_backend() == "Pico"
+    assert df3.get_backend() == "Pico"
 
 
-def test_two_qc_types_rhs(pico_df, cluster_df):
-    df3 = pico_df.concat(axis=1, other=cluster_df)
-    assert type(df3) is type(cluster_df)  # should move to cluster
+def test_cast_to_second_backend(pico_df, cluster_df):
+    df3 = pd.concat([pico_df, cluster_df], axis=1)
+    assert pico_df.get_backend() == "Pico"
+    assert cluster_df.get_backend() == "Cluster"
+    assert df3.get_backend() == "Cluster"  # result should be on cluster
 
 
-def test_two_qc_types_lhs(pico_df, cluster_df):
-    df3 = cluster_df.concat(axis=1, other=pico_df)
-    assert type(df3) is type(cluster_df)  # should move to cluster
+def test_cast_to_first_backend(pico_df, cluster_df):
+    df3 = pd.concat([cluster_df, pico_df], axis=1)
+    assert pico_df.get_backend() == "Pico"
+    assert cluster_df.get_backend() == "Cluster"
+    assert df3.get_backend() == cluster_df.get_backend()  # result should be on cluster
 
 
 def test_no_solution(pico_df, local_df, cluster_df, cloud_df):
-    with pytest.raises(ValueError, match=r"pico,local_machine,cluster,cloud"):
-        pico_df.concat(axis=1, other=[local_df, cluster_df, cloud_df])
+    with pytest.raises(ValueError, match=r"Pico,Local_machine,Cluster,Cloud"):
+        pd.concat(axis=1, objs=[pico_df, local_df, cluster_df, cloud_df])
 
 
 @pytest.mark.parametrize(
-    "df1, df2, df3, df4, result_type",
+    "df1, df2, df3, df4, expected_result_backend",
     [
         # no-op
-        ("cloud_df", "cloud_df", "cloud_df", "cloud_df", CloudQC),
+        ("cloud_df", "cloud_df", "cloud_df", "cloud_df", "Cloud"),
         # moving all dfs to cloud is 1250, moving to cluster is 1000
         # regardless of how they are ordered
         ("pico_df", "local_df", "cluster_df", "cloud_df", None),
         ("cloud_df", "local_df", "cluster_df", "pico_df", None),
         ("cloud_df", "cluster_df", "local_df", "pico_df", None),
-        ("cloud_df", "cloud_df", "local_df", "pico_df", CloudQC),
+        ("cloud_df", "cloud_df", "local_df", "pico_df", "Cloud"),
         # Still move everything to cloud
-        ("pico_df", "pico_df", "pico_df", "cloud_df", CloudQC),
-        ("pico_df", "pico_df", "local_df", "cloud_df", CloudQC),
+        ("pico_df", "pico_df", "pico_df", "cloud_df", "Cloud"),
+        ("pico_df", "pico_df", "local_df", "cloud_df", "Cloud"),
     ],
 )
-def test_mixed_dfs(df1, df2, df3, df4, result_type, request):
+def test_mixed_dfs(df1, df2, df3, df4, expected_result_backend, request):
     df1 = request.getfixturevalue(df1)
     df2 = request.getfixturevalue(df2)
     df3 = request.getfixturevalue(df3)
     df4 = request.getfixturevalue(df4)
-    if result_type is None:
+    if expected_result_backend is None:
         with pytest.raises(ValueError):
-            df1.concat(axis=1, other=[df2, df3, df4])
-        return
-    result = df1.concat(axis=1, other=[df2, df3, df4])
-    assert type(result) is result_type
-
-
-def test_call_on_non_qc(pico_df, cloud_df):
-    pico_df1 = pd.DataFrame(query_compiler=pico_df)
-    cloud_df1 = pd.DataFrame(query_compiler=cloud_df)
-
-    df1 = pd.concat([pico_df1, cloud_df1])
-    assert type(df1._query_compiler) is CloudQC
+            pd.concat(axis=1, objs=[df1, df2, df3, df4])
+    else:
+        result = pd.concat(axis=1, objs=[df1, df2, df3, df4])
+        assert result.get_backend() == expected_result_backend
 
 
 def test_adversarial_high(adversarial_df, cluster_df):
     with pytest.raises(ValueError):
-        adversarial_df.concat(axis=1, other=cluster_df)
+        pd.concat([adversarial_df, cluster_df], axis=1)
 
 
 def test_adversarial_low(adversarial_df, cloud_df):
     with pytest.raises(ValueError):
-        adversarial_df.concat(axis=1, other=cloud_df)
+        pd.concat([adversarial_df, cloud_df], axis=1)
 
 
 def test_two_two_qc_types_default_rhs(default_df, cluster_df):
     # none of the query compilers know about each other here
     # so we default to the caller
-    df3 = default_df.concat(axis=1, other=cluster_df)
-    assert type(df3) is type(default_df)  # should move to default
+    df3 = pd.concat([default_df, cluster_df], axis=1)
+    assert default_df.get_backend() == "Test_casting_default"
+    assert cluster_df.get_backend() == "Cluster"
+    assert df3.get_backend() == default_df.get_backend()  # should move to default
 
 
 def test_two_two_qc_types_default_lhs(default_df, cluster_df):
     # none of the query compilers know about each other here
     # so we default to the caller
-    df3 = cluster_df.concat(axis=1, other=default_df)
-    assert type(df3) is type(cluster_df)  # should move to cluster
+    df3 = pd.concat([cluster_df, default_df], axis=1)
+    assert default_df.get_backend() == "Test_casting_default"
+    assert cluster_df.get_backend() == "Cluster"
+    assert df3.get_backend() == cluster_df.get_backend()  # should move to cluster
 
 
 def test_two_two_qc_types_default_2_rhs(default_df, cloud_df):
     # cloud knows a bit about costing; so we prefer moving to there
-    df3 = default_df.concat(axis=1, other=cloud_df)
-    assert type(df3) is type(cloud_df)  # should move to cloud
+    df3 = pd.concat([default_df, cloud_df], axis=1)
+    assert default_df.get_backend() == "Test_casting_default"
+    assert cloud_df.get_backend() == "Cloud"
+    assert df3.get_backend() == cloud_df.get_backend()  # should move to cloud
 
 
 def test_two_two_qc_types_default_2_lhs(default_df, cloud_df):
     # cloud knows a bit about costing; so we prefer moving to there
-    df3 = cloud_df.concat(axis=1, other=default_df)
-    assert type(df3) is type(cloud_df)  # should move to cloud
+    df3 = pd.concat([cloud_df, default_df], axis=1)
+    assert default_df.get_backend() == "Test_casting_default"
+    assert cloud_df.get_backend() == "Cloud"
+    assert df3.get_backend() == cloud_df.get_backend()  # should move to cloud
 
 
 def test_default_to_caller(default_df, default2_df):
     # No qc knows anything; default to caller
-    df3 = default_df.concat(axis=1, other=default2_df)
-    assert type(df3) is type(default_df)  # should stay on caller
-    df3 = default2_df.concat(axis=1, other=default_df)
-    assert type(df3) is type(default2_df)  # should stay on caller
-    df3 = default_df.concat(axis=1, other=default_df)
-    assert type(df3) is type(default_df)  # no change
 
+    df3 = pd.concat([default_df, default2_df], axis=1)
+    assert df3.get_backend() == default_df.get_backend()  # should stay on caller
 
-def test_no_qc_data_to_calculate():
-    calculator = BackendCostCalculator()
-    calculator.add_query_compiler(ClusterQC)
-    result = calculator.calculate()
-    assert result is ClusterQC
+    df3 = pd.concat([default2_df, default_df], axis=1)
+    assert df3.get_backend() == default2_df.get_backend()  # should stay on caller
+
+    df3 = pd.concat([default_df, default_df], axis=1)
+    assert df3.get_backend() == default_df.get_backend()  # no change
 
 
 def test_no_qc_to_calculate():
@@ -305,15 +307,23 @@ def test_no_qc_to_calculate():
 
 
 def test_qc_default_self_cost(default_df, default2_df):
-    assert default_df.qc_engine_switch_cost(type(default2_df)) is None
     assert (
-        default_df.qc_engine_switch_cost(type(default_df)) is QCCoercionCost.COST_ZERO
+        default_df._query_compiler.qc_engine_switch_cost(
+            type(default2_df._query_compiler)
+        )
+        is None
+    )
+    assert (
+        default_df._query_compiler.qc_engine_switch_cost(
+            type(default_df._query_compiler)
+        )
+        is QCCoercionCost.COST_ZERO
     )
 
 
 def test_qc_casting_changed_operation(pico_df, cloud_df):
-    pico_df1 = pd.DataFrame(query_compiler=pico_df)
-    cloud_df1 = pd.DataFrame(query_compiler=cloud_df)
+    pico_df1 = pico_df
+    cloud_df1 = cloud_df
     native_cdf2 = cloud_df1._to_pandas()
     native_pdf2 = pico_df1._to_pandas()
     expected = native_cdf2 + native_pdf2
@@ -325,8 +335,26 @@ def test_qc_casting_changed_operation(pico_df, cloud_df):
 
 
 def test_qc_mixed_loc(pico_df, cloud_df):
-    pico_df1 = pd.DataFrame(query_compiler=pico_df)
-    cloud_df1 = pd.DataFrame(query_compiler=cloud_df)
+    pico_df1 = pico_df
+    cloud_df1 = cloud_df
     assert pico_df1[pico_df1[0][0]][cloud_df1[0][1]] == 1
     assert pico_df1[cloud_df1[0][0]][pico_df1[0][1]] == 1
     assert cloud_df1[pico_df1[0][0]][pico_df1[0][1]] == 1
+
+
+def test_setitem_in_place_with_self_switching_backend(cloud_df, local_df):
+    local_df.iloc[1, 0] = cloud_df.iloc[1, 0] + local_df.iloc[1, 0]
+    # compute happens in cloud, but we have to make sure that we propagate the
+    # in-place update to the local_df
+    df_equals(
+        local_df,
+        pandas.DataFrame(
+            [
+                0,
+                2,
+                2,
+            ]
+        ),
+    )
+    assert local_df.get_backend() == "Local_machine"
+    assert cloud_df.get_backend() == "Cloud"
diff --git a/modin/tests/pandas/native_df_interoperability/test_general.py b/modin/tests/pandas/native_df_interoperability/test_general.py
new file mode 100644
index 00000000..8d91695a
--- /dev/null
+++ b/modin/tests/pandas/native_df_interoperability/test_general.py
@@ -0,0 +1,102 @@
+# Licensed to Modin Development Team under one or more contributor license agreements.
+# See the NOTICE file distributed with this work for additional information regarding
+# copyright ownership.  The Modin Development Team licenses this file to you 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 pandas
+import pytest
+
+import modin.pandas as pd
+from modin.tests.pandas.native_df_interoperability.utils import (
+    create_test_df_in_defined_mode,
+    create_test_series_in_defined_mode,
+)
+from modin.tests.pandas.utils import default_to_pandas_ignore_string, df_equals
+
+# Our configuration in pytest.ini requires that we explicitly catch all
+# instances of defaulting to pandas, but some test modules, like this one,
+# have too many such instances.
+pytestmark = pytest.mark.filterwarnings(default_to_pandas_ignore_string)
+
+
+def test_cut(df_mode_pair):
+    modin_x, pandas_x = create_test_series_in_defined_mode(
+        [1, 3], native=df_mode_pair[0]
+    )
+    modin_bins, pandas_bins = create_test_series_in_defined_mode(
+        [0, 2], native=df_mode_pair[1]
+    )
+
+    def operation(*, lib, x, bins):
+        return lib.cut(x, bins)
+
+    df_equals(
+        operation(lib=pd, x=modin_x, bins=modin_bins),
+        operation(lib=pandas, x=pandas_x, bins=pandas_bins),
+    )
+
+
+def test_qcut(df_mode_pair):
+    modin_x, pandas_x = create_test_series_in_defined_mode(
+        [1, 2, 3, 4], native=df_mode_pair[0]
+    )
+    modin_quantiles, pandas_quantiles = create_test_series_in_defined_mode(
+        [0, 0.5, 1], native=df_mode_pair[1]
+    )
+
+    def operation(*, lib, x, quantiles):
+        return lib.qcut(x, quantiles)
+
+    df_equals(
+        operation(lib=pd, x=modin_x, quantiles=modin_quantiles),
+        operation(lib=pandas, x=pandas_x, quantiles=pandas_quantiles),
+    )
+
+
+def test_merge_ordered(df_mode_pair):
+    modin_left, pandas_left = create_test_df_in_defined_mode(
+        {
+            "key": ["a", "c", "e", "a", "c", "e"],
+            "lvalue": [1, 2, 3, 1, 2, 3],
+            "group": ["a", "a", "a", "b", "b", "b"],
+        },
+        native=df_mode_pair[0],
+    )
+    modin_right, pandas_right = create_test_df_in_defined_mode(
+        {"key": ["b", "c", "d"], "rvalue": [1, 2, 3]},
+        native=df_mode_pair[1],
+    )
+
+    def operation(*, lib, left, right):
+        return lib.merge_ordered(left, right, fill_method="ffill", left_by="group")
+
+    df_equals(
+        operation(lib=pd, left=modin_left, right=modin_right),
+        operation(lib=pandas, left=pandas_left, right=pandas_right),
+    )
+
+
+def test_merge_asof(df_mode_pair):
+    modin_left, pandas_left = create_test_df_in_defined_mode(
+        {"a": [1, 5, 10], "left_val": ["a", "b", "c"]}, native=df_mode_pair[0]
+    )
+    modin_right, pandas_right = create_test_df_in_defined_mode(
+        {"a": [1, 2, 3, 6, 7], "right_val": [1, 2, 3, 6, 7]},
+        native=df_mode_pair[1],
+    )
+
+    def operation(*, lib, left, right):
+        return lib.merge_asof(left, right, on="a")
+
+    df_equals(
+        operation(lib=pd, left=modin_left, right=modin_right),
+        operation(lib=pandas, left=pandas_left, right=pandas_right),
+    )
diff --git a/modin/tests/pandas/native_df_interoperability/test_join_sort.py b/modin/tests/pandas/native_df_interoperability/test_join_sort.py
index 5d74e76e..9ad11cd5 100644
--- a/modin/tests/pandas/native_df_interoperability/test_join_sort.py
+++ b/modin/tests/pandas/native_df_interoperability/test_join_sort.py
@@ -15,6 +15,7 @@ import matplotlib
 import numpy as np
 import pandas
 import pytest
+from pytest import param
 
 import modin.pandas as pd
 from modin.config import NPartitions
@@ -205,7 +206,40 @@ def test_join_cross_6786(df_mode_pair):
         ),
     ],
 )
-def test_merge(test_data, test_data2, df_mode_pair):
+@pytest.mark.parametrize(
+    "merge_with_on, merge_with_left_on_right_on",
+    [
+        param(
+            lambda df1, df2, *, lib, how, sort, on=None: df1.merge(
+                df2, how=how, on=on, sort=sort
+            ),
+            lambda df1, df2, *, lib, how, sort: df1.merge(
+                df2, how=how, left_on="key", right_on="key", sort=sort
+            ),
+            id="merge_with_dataframe_method",
+        ),
+        param(
+            lambda df1, df2, *, lib, how, sort, on=None: lib.merge(
+                df1,
+                df2,
+                how=how,
+                on=on,
+                sort=sort,
+            ),
+            lambda df1, df2, *, lib, how, sort: lib.merge(
+                df1, df2, how=how, left_on="key", right_on="key", sort=sort
+            ),
+            id="merge_with_general_function",
+        ),
+    ],
+)
+def test_merge(
+    test_data,
+    test_data2,
+    df_mode_pair,
+    merge_with_on,
+    merge_with_left_on_right_on,
+):
     modin_df, pandas_df = create_test_df_in_defined_mode(
         test_data,
         columns=["col{}".format(i) for i in range(test_data.shape[1])],
@@ -224,28 +258,20 @@ def test_merge(test_data, test_data2, df_mode_pair):
     assert len(ons) == len(sorts), "the loop below is designed for this condition"
     for i in range(len(hows)):
         for j in range(len(ons)):
-            modin_result = modin_df.merge(
-                modin_df2, how=hows[i], on=ons[j], sort=sorts[j]
+            modin_result = merge_with_on(
+                modin_df, modin_df2, how=hows[i], on=ons[j], sort=sorts[j], lib=pd
             )
-            pandas_result = pandas_df.merge(
-                pandas_df2, how=hows[i], on=ons[j], sort=sorts[j]
+            pandas_result = merge_with_on(
+                pandas_df, pandas_df2, how=hows[i], on=ons[j], sort=sorts[j], lib=pandas
             )
             # FIXME: https://github.com/modin-project/modin/issues/2246
             df_equals_and_sort(modin_result, pandas_result)
 
-            modin_result = modin_df.merge(
-                modin_df2,
-                how=hows[i],
-                left_on="key",
-                right_on="key",
-                sort=sorts[j],
+            modin_result = merge_with_left_on_right_on(
+                modin_df, modin_df2, how=hows[i], sort=sorts[j], lib=pd
             )
-            pandas_result = pandas_df.merge(
-                pandas_df2,
-                how=hows[i],
-                left_on="key",
-                right_on="key",
-                sort=sorts[j],
+            pandas_result = merge_with_left_on_right_on(
+                pandas_df, pandas_df2, how=hows[i], sort=sorts[j], lib=pandas
             )
             # FIXME: https://github.com/modin-project/modin/issues/2246
             df_equals_and_sort(modin_result, pandas_result)