TensorPlay
latest (dev)
Copy
View Markdown

Latest development documentation · Updated 2026-10-15

Source code for tensorplay.autograd.function

import functools
import warnings
import weakref

import tensorplay
import tensorplay._C._autograd as _autograd


_BACKWARD_TWICE_MESSAGE = (
    "Trying to backward through the graph a second time (or directly access "
    "saved tensors after they have already been freed). Saved intermediate "
    "values of the graph are freed when you call .backward() or "
    "autograd.grad(). Specify retain_graph=True if you need to backward "
    "through the graph a second time or if you need to access saved tensors "
    "after calling backward."
)


def _current_saved_hooks_pair():
    """Active (pack, unpack) pair from an enclosing saved_tensors_hooks
    context, or None.  Late import avoids a graph<->function cycle."""
    from .graph import _hook_stack

    return _hook_stack[-1] if _hook_stack else None


def _native_saved_hooks_active() -> bool:
    return bool(getattr(
        _autograd, "_saved_variable_hooks_active", lambda: False)())


def _native_pack_saved_tensor(tensor):
    return _autograd._pack_saved_tensor(tensor)


def _native_unpack_saved_tensor(token):
    return _autograd._unpack_saved_tensor(token)


# build without them is loaded, the generic Python fallbacks run instead.
_FAST_GRAPH = hasattr(_autograd, "setup_custom_function_graph")
_FAST_ATTACH = hasattr(_autograd, "PyNode") and hasattr(
    getattr(_autograd, "PyNode", None), "attach_outputs"
)
_PyNode = _autograd.PyNode
_setup_graph = getattr(_autograd, "setup_custom_function_graph", None)
_APPLY_ALL = getattr(_autograd, "custom_function_apply", None)
_RUN_FWD = getattr(_autograd, "run_custom_function_forward", None)


def _fast_capable():
    return _FAST_GRAPH and _RUN_FWD is not None and _FAST_ATTACH


def _collect_edges(t):
    return _autograd.collect_next_edges(t)


def _materialize(ctx, grads):
    """Zero-fill missing output gradients using lazily captured outputs."""
    outputs = getattr(ctx, "_outputs", None)
    out = []
    metas = ctx._output_grad_metas
    for i, g in enumerate(grads):
        if g is not None:
            out.append(g)
            continue
        if i < len(metas):
            shape, dtype, device = metas[i]
            out.append(tensorplay.zeros(shape, dtype=dtype, device=device))
        elif outputs is not None and i < len(outputs) and outputs[i] is not None:
            o = outputs[i]
            meta = (tuple(o.shape), o.dtype, o.device)
            while len(metas) <= i:
                metas.append(None)
            metas[i] = meta
            out.append(tensorplay.zeros(shape=meta[0], dtype=meta[1],
                                        device=meta[2]))
        else:
            out.append(None)
    return out


def _make_backward(ctx, cls):
    """Resolve the generic backward entry for ``ctx``.

    All per-apply state (hooks, materialization flags, gradient arity)
    lives on the context, so the entry reads it at backward time instead
    of the forward pass building a closure per call.  Returns a bound
    method: the engine invokes ``ctx.backward(*grads)``.
    """
    return ctx._backward_entry


def _make_direct_backward(ctx, cls):
    """Bind a fixed custom backward without retaining its context cycle."""

    context_ref = weakref.ref(ctx)

    def backward(*grads):
        context = context_ref()
        if context is None:
            raise RuntimeError("autograd context was released before backward")
        return cls.backward(context, *grads)

    return backward


def _collect_needs(data, out: list) -> None:
    """Append ``requires_grad`` per input position, recursing into
    nested structures (single-pass variant of the old flat walk)."""
    if isinstance(data, tensorplay.Tensor):
        out.append(bool(data.requires_grad))
    elif isinstance(data, dict):
        for value in data.values():
            _collect_needs(value, out)
    elif isinstance(data, (list, tuple)):
        for item in data:
            _collect_needs(item, out)
    else:
        out.append(False)


class _Context:
    """
    Records information needed for computing gradients.
    """

    # Immutable per-apply defaults live on the class so the constructor
    # only allocates the mutable holders (hooks, gradient metas, sets);
    # instance assignment shadows these on first write.
    materialize_grads = True
    backward_fn = None
    _metadata = None
    requires_grad = False
    next_functions: tuple = ()
    _saved_tensors: tuple = ()
    _saved_anchors: tuple = ()
    _saved_released: bool = False
    _to_save_for_backward: tuple = ()
    _outputs: tuple = ()

    def __init__(self):
        self.dirty_tensors = set()
        self._non_differentiable = set()
        # Outputs captured lazily for gradient materialization; metas are
        # only computed if a None grad actually arrives in backward.
        self._output_grad_metas: list = []
        # Kept as real lists: the C++ PyNode register_hook bindings append
        # into them directly.
        self._hooks: list = []
        self._prehooks: list = []

    def __del__(self):
        # Adoption, not teardown: the saved tensors stay untouched because a
        # backward pass that has not run yet still reads them.  While saved
        # tensors exist they carry this context back (``_tp_saved_ctx``), so
        # reaching the finalizer means either the node is already gone or
        # the node is owned from elsewhere without holding any of this
        # context's tensors -- in both cases adopting (taking the node's
        # keep-alive) is safe and cannot close a cycle the collector cannot
        # see.
        node_id = getattr(self, "_node_id", None)
        if node_id is None:
            return
        try:
            _autograd._adopt_node_if_needed(node_id, self)
        except Exception:
            pass

    @property
    def metadata(self):
        if self._metadata is None:
            self._metadata = {}
        return self._metadata

    @property
    def non_differentiable(self):
        return self._non_differentiable

    def release_saved(self) -> None:
        """Let go of the tensors saved for backward.

        A saved tensor is held for the backward pass that reads it, and that
        pass is its last reader: once it has run, the value is the graph's
        memory for nothing.  The engine knows when a graph is being kept for
        another pass, so this is one call at the point where the engine stops
        needing them, and a function whose backward may run again does not
        make it.  The back-references each saved tensor carries to this
        context (see :meth:`save_for_backward`) go with them.
        """
        anchors = getattr(self, "_saved_anchors", None)
        if anchors:
            for t in anchors:
                if getattr(t, "_tp_saved_ctx", None) is self:
                    try:
                        del t._tp_saved_ctx
                    except AttributeError:
                        pass
        self._saved_anchors = ()
        self._saved_tensors = ()
        self._saved_released = True
        for name in ("_saved_versions", "_saved_native_tokens", "_saved_pack"):
            if isinstance(getattr(self, name, None), tuple):
                setattr(self, name, ())

    def maybe_clear_saved_tensors(self) -> None:
        """Release saved values when this backward does not retain its graph."""
        if not _autograd._get_current_graph_task_keep_graph():
            self.release_saved()

    @property
    def to_save(self):
        return self._saved_tensors

    @to_save.setter
    def to_save(self, tensors):
        if not isinstance(tensors, (tuple, list)):
            raise TypeError(
                "to_save attribute is expected to be a tuple but is "
                f"{type(tensors)}")
        self.save_for_backward(*tensors)

    def register_hook(self, hook):
        """
        ``(grad_inputs, grad_outputs)`` after :meth:`Function.backward`;
        may return a replacement for ``grad_inputs``."""
        self._hooks.append(hook)

    def register_prehook(self, hook):
        """
        ``(grad_outputs,)`` before :meth:`Function.backward` runs; may
        return replacement for ``grad_outputs``."""
        self._prehooks.append(hook)

    def _backward_entry(self, *grads):
        """Engine-invoked backward: complete/mask the gradient tuple, run
        prehooks, materialize missing grads, call the user formula, then
        posthooks.  Every piece of state is read off this context so the
        forward pass binds this method instead of building a closure."""
        # Complete missing trailing slots with None before anything else.
        # Output count is provided by the C++ fast path (which no longer
        # stores the output tensors on ctx); fall back to the stored tuple
        # for the slow path / materialize_grads=False case.
        n_out = getattr(self, "_n_outputs", None)
        if n_out is None:
            n_out = len(self._outputs)
        if n_out == 0:
            n_out = 1
        if len(grads) > n_out:
            grads = grads[:n_out]
        if len(grads) < n_out:
            grads = grads + (None,) * (n_out - len(grads))
        for ph in self._prehooks:
            replaced = ph((grads,))
            if replaced is not None:
                grads = tuple(replaced[0])
        engine_materializes = getattr(self, "_engine_materializes", False)
        if self.materialize_grads and not engine_materializes \
                and any(g is None for g in grads):
            grads = tuple(_materialize(self, grads))
        results = self.backward_fn(self, *grads)
        if not isinstance(results, tuple):
            results = (results,)
        n = len(results)
        n_in = len(self.needs_input_grad)
        if n != n_in and not (n > n_in and all(r is None for r in results[n_in:])):
            raise RuntimeError(
                f"function {getattr(self, '_node_name', 'Function')} returned "
                f"an incorrect number of gradients (expected {n_in}, got {n})")
        if n > n_in:
            results = results[:n_in]
        for hk in self._hooks:
            replaced = hk(results, grads)
            if replaced is not None:
                results = tuple(replaced)
        return results

    def save_for_backward(self, *tensors):
        r"""Saves given tensors to be accessed via ``ctx.saved_tensors`` in backward.

        When a ``saved_tensors_hooks`` context is active, each tensor is
        passed through the pack hook at save time (and through the unpack
        """
        for t in tensors:
            if t is not None and not isinstance(t, tensorplay.Tensor):
                raise TypeError(
                    "save_for_backward only accepts Tensors or None")
        # Each saved tensor carries this context back: the backward pass may
        # still have to run after the caller drops the context, and a tensor
        # that outlives it (a kept output, a live parameter) must keep the
        # context -- with the backward entry and the saved state -- reachable.
        # The reference is a plain instance attribute, so a context whose
        # saved tensors all became unreachable collects together with them
        # instead of pinning a dead graph.
        anchors = tuple(t for t in tensors if t is not None)
        for t in anchors:
            t._tp_saved_ctx = self
        self._saved_anchors = anchors
        if _native_saved_hooks_active():
            self._saved_native_tokens = tuple(
                None if t is None else _native_pack_saved_tensor(t)
                for t in tensors
            )
            self._saved_pack = None
            self._saved_unpack = None
            self._saved_tensors = tuple(None for _ in tensors)
        else:
            self._saved_native_tokens = None
            pair = _current_saved_hooks_pair()
            if pair is not None:
                pack_fn, unpack_fn = pair
                self._saved_pack = tuple(
                    None if t is None else pack_fn(t) for t in tensors)
                self._saved_unpack = unpack_fn
            else:
                self._saved_pack = None
                self._saved_unpack = None
            self._saved_tensors = tensors
        if not _native_saved_hooks_active():
            self._saved_versions = tuple(
                None if t is None else t._version for t in tensors)
        else:
            self._saved_versions = tuple(None for _ in tensors)

    @property
    def saved_tensors(self):
        r"""Returns saved tensors.

        Raises if any saved tensor was modified in-place since saving,
        """
        if self._saved_released:
            raise RuntimeError(_BACKWARD_TWICE_MESSAGE)
        native_tokens = getattr(self, "_saved_native_tokens", None)
        if native_tokens is not None:
            return tuple(
                None if token is None else _native_unpack_saved_tensor(token)
                for token in native_tokens
            )
        tensors = self._saved_tensors
        versions = getattr(self, "_saved_versions", ())
        for t, v in zip(tensors, versions):
            if t is None or v is None:
                continue
            if t._version != v:
                raise RuntimeError(
                    "one of the variables needed for gradient computation has "
                    "been modified by an inplace operation: "
                    f"[Tensor (version {t._version})] is at version "
                    f"{t._version}; expected version {v} instead."
                )
        unpack_fn = getattr(self, "_saved_unpack", None)
        packed = getattr(self, "_saved_pack", None)
        if unpack_fn is not None and packed is not None:
            return tuple(None if p is None else unpack_fn(p) for p in packed)
        return tuple(tensors)

    def save_for_forward(self, *tensors):
        r"""Saves given tensors for use in the ``vjp`` computation."""
        self._to_save_for_forward = tensors

    @property
    def saved_for_forward(self):
        r"""Returns tensors saved via :meth:`save_for_forward`."""
        return tuple(self._to_save_for_forward)

    def set_materialize_grads(self, value: bool):
        r"""Sets whether None output gradients are materialized into zero tensors."""
        self.materialize_grads = value

    def mark_dirty(self, *args):
        r"""Marks given tensors as modified in an in-place operation.

        immediately (``_mark_dirty`` in python_function.cpp), so later
        ``saved_tensors`` access and double-backward detect the mutation.
        """
        for arg in args:
            if not isinstance(arg, tensorplay.Tensor):
                raise RuntimeError("mark_dirty only accepts Tensor arguments")
            arg._bump_version()
            self.dirty_tensors.add(id(arg))

    def mark_non_differentiable(self, *args):
        r"""Marks outputs as non-differentiable."""
        for arg in args:
            if not isinstance(arg, tensorplay.Tensor):
                raise RuntimeError("mark_non_differentiable only accepts Tensors")
            self._non_differentiable.add(id(arg))


[docs]
def once_differentiable(fn):
    r"""Decorator to make a custom autograd Function's backward run once,
    with gradients detached and grad-mode disabled inside."""

    @functools.wraps(fn)
    def wrapper(ctx, *grad_inputs):
        prev = _autograd.is_grad_enabled()
        _autograd.set_grad_enabled(False)
        try:
            detached = tuple(
                g.detach() if isinstance(g, tensorplay.Tensor) else g
                for g in grad_inputs
            )
            return fn(ctx, *detached)
        finally:
            _autograd.set_grad_enabled(prev)

    return wrapper




[docs]
class FunctionMeta(type):
    """
    the ``name`` classproperty (``"<Cls>Backward"``, used for node naming)
    and a friendlier repr for subclasses."""

    def __new__(mcls, name, bases, namespace, **kwds):
        cls = super().__new__(mcls, name, bases, namespace, **kwds)
        cls._node_name = f"{name}Backward"
        return cls

    @property
    def name(cls):
        return cls._node_name



def _maybe_process_forward_ad(cls, ctx, args, output):
    """Forward-mode AD for custom Functions.

    When any input carries a tangent at the active forward level, the user's
    ``jvp`` computes the output tangents, which are attached to the outputs
    one-for-one.  Tangent reads stay disabled while ``jvp`` runs so the
    tangent arithmetic itself never re-enters forward propagation.
    """
    if cls.jvp is _BASE_JVP:
        return output

    grad_inputs = []
    any_fw = False

    def collect(a):
        nonlocal any_fw
        if isinstance(a, tensorplay.Tensor):
            g = tensorplay._C._fw_grad(a, 0)
            if g.defined():
                any_fw = True
                grad_inputs.append(g)
            else:
                grad_inputs.append(None)
        elif isinstance(a, (list, tuple)):
            for item in a:
                collect(item)
        elif isinstance(a, dict):
            for v in a.values():
                collect(v)
        else:
            grad_inputs.append(None)

    for a in args:
        collect(a)
    if not any_fw:
        return output

    from .forward_ad import _set_fwd_grad_enabled

    outs = output if isinstance(output, (tuple, list)) else (output,)
    with _set_fwd_grad_enabled(False):
        tangent_outs = cls.jvp(ctx, *grad_inputs)
    if not isinstance(tangent_outs, (tuple, list)):
        tangent_outs = (tangent_outs,)
    if len(tangent_outs) != len(outs):
        raise RuntimeError(
            f"jvp for {cls.__name__} returned {len(tangent_outs)} tangents "
            f"but the forward returned {len(outs)} outputs")
    for out, t in zip(outs, tangent_outs):
        if (isinstance(out, tensorplay.Tensor) and t is not None
                and out.defined() and t.defined()):
            tensorplay._C._set_fw_grad(out, t, 0, False)
    return output



[docs]
class Function(metaclass=FunctionMeta):
    r"""Records operation history and defines formulas for differentiating ops.


    1. Legacy style: ``forward(ctx, ...)`` / ``backward(ctx, ...)``
       (forward receives a context object).
    2. Combined-forward style: define ``forward(*args, **kwargs)``,
       ``setup_context(ctx, inputs, output)`` and use
       ``save_for_backward``/``save_for_forward`` inside ``setup_context``
       instead of receiving a ``ctx`` argument in ``forward``.
    """

    generate_vmap_rule = False

    auto_setup_ctx = False


[docs]
    @staticmethod
    def forward(ctx, *args, **kwargs):
        r"""Performs the operation.

        This function is to be overridden by all subclasses. There are two ways
        to define forward:

        Usage 1 (Combined forward and ctx)::

            @staticmethod
            def forward(ctx, input1, input2):
                ...
                return output

        Usage 2 (Separated forward and ctx)::

            @staticmethod
            def forward(input1, input2):
                ...
                return output

            @staticmethod
            def setup_context(ctx, inputs, output):
                ...
        """
        raise NotImplementedError(
            "You must implement the forward function for your custom autograd Function."
        )



[docs]
    @staticmethod
    def setup_context(ctx, inputs, output):
        r"""Sets up the context object (Usage 2 above).

        Arguments:
            ctx (_Context): context object to modify in-place
            inputs (tuple): inputs to :meth:`forward`
            output (Any): output of :meth:`forward`
        """
        raise NotImplementedError(
            "You must implement the setup_context function for your custom "
            "autograd Function if you define forward without a ctx argument."
        )



[docs]
    @staticmethod
    def backward(ctx, *grad_outputs):
        r"""Defines a formula for differentiating the operation."""
        raise NotImplementedError(
            "You must implement either the backward or vjp method "
            "for your custom autograd Function to use it with autograd."
        )



[docs]
    @staticmethod
    def jvp(ctx, *grad_inputs):
        r"""Defines a formula for computing the jacobian-vector product.

        Called by forward-mode AD when at least one input of this Function
        carries a tangent: ``grad_inputs`` holds the tangent of each input
        (``None`` for inputs without one), and the returned tangents are
        attached to the outputs one-for-one.
        """
        raise NotImplementedError(
            "You must implement the jvp method for your custom autograd "
            "Function to use it with forward-mode AD."
        )



[docs]
    @staticmethod
    def vmap(info, in_dims, *args):
        r"""Defines a formula for vectorizing the operation.

        Not yet supported by this engine; provided for API compatibility.
        """
        raise RuntimeError(
            "You tried to vmap over a custom Function that does not have "
            "vmap support. Please override and implement the vmap "
            "staticmethod or set generate_vmap_rule=True."
        )



[docs]
    @classmethod
    def apply(cls, *args, **kwargs):
        r"""Runs the operation and attaches gradient bookkeeping to outputs.

        flat arguments computes ``needs_input_grad`` and wires next-edges
        BEFORE forward; outputs are marked and attached AFTER
        ``setup_context``.  When the fused C++ apply is present the hot
        path makes a single pybind crossing that also wires the backward
        entry; otherwise a generic Python fallback runs.
        """
        uses_setup_context = cls.setup_context is not _BASE_SETUP_CONTEXT
        grad_enabled = _autograd.is_grad_enabled()

        # Containers need the flattening fallback below; the fused helper
        # records one needs-bit per top-level slot only.
        flat = True
        for a in args:
            if isinstance(a, (list, tuple, dict)):
                flat = False
                break

        # ---- C++ boundary: ONE crossing ----
        if _APPLY_ALL is not None and flat and not kwargs and grad_enabled:
            output, ctx, needs, executable, fn = _APPLY_ALL(
                _Context,
                cls.forward,
                cls.setup_context if uses_setup_context else None,
                args,
                cls.backward,
                cls._node_name)
            if executable and getattr(
                    cls, "_tensorplay_direct_backward", False):
                ctx.backward = _make_direct_backward(ctx, cls)
            if cls.jvp is _BASE_JVP:
                return output
            return _maybe_process_forward_ad(cls, ctx, args, output)

        fast = (
            _FAST_GRAPH and _RUN_FWD is not None and _FAST_ATTACH
            and flat and not kwargs and grad_enabled
        )

        ctx = _Context()

        # ---- unpack_input path: needs bits + next_edges pre-forward ----
        if fast:
            fn = _PyNode(ctx)
            needs, any_rg = _setup_graph(fn, args)
            needs = tuple(needs)
        else:
            needs_l: list[bool] = []
            _collect_needs(args, needs_l)
            needs = tuple(needs_l)
            any_rg = any(needs)
            fn = _PyNode(ctx) if any_rg else None
        ctx.needs_input_grad = needs

        executable = grad_enabled and any_rg
        if not grad_enabled and any_rg:
            warnings.warn(
                "An output of the user-provided Function seems to not "
                "require grad while at least one input requires grad. "
                "The autograd engine will not track this op.",
                stacklevel=2,
            )

        if executable:
            ctx.requires_grad = True
            ctx.backward_fn = cls.backward
            ctx._node_name = f"{cls.__name__}Backward"

        # Run forward with grad disabled (engine semantics).  The fused
        # Crossing the C++ autograd boundary disables gradient recording for
        # the forward call, then restores the previous state.
        if fast:
            output = _RUN_FWD(
                ctx, cls.forward,
                cls.setup_context if uses_setup_context else None,
                args,
            )
        else:
            if grad_enabled:
                _autograd.set_grad_enabled(False)
            try:
                if uses_setup_context:
                    output = cls.forward(*args, **kwargs)
                else:
                    output = cls.forward(ctx, *args, **kwargs)
            finally:
                if grad_enabled:
                    _autograd.set_grad_enabled(True)

            if uses_setup_context:
                cls.setup_context(ctx, args, output)

        if not executable:
            return _maybe_process_forward_ad(cls, ctx, args, output)

        # choice (possibly set inside setup_context) to the ENGINE, so
        # zero-filling of missing gradient slots happens in C++.
        if fn is not None and hasattr(fn, "set_materialize_grads"):
            fn.set_materialize_grads(bool(ctx.materialize_grads))
            ctx._engine_materializes = bool(ctx.materialize_grads)

        # ---- _wrap_outputs path: mark + attach in one pass ----
        if isinstance(output, tuple):
            n_out = len(output)
        elif isinstance(output, list):
            n_out = len(output)
        else:
            n_out = 1
        ctx._n_outputs = n_out
        # The fused attach assumes edges were already wired by the fused
        # setup above; never mix fast-attach with slow wiring (or vice
        # versa) or the node reaches the engine with a wrong input arity.
        if fast:
            fn.attach_outputs(output)
        else:
            next_fns: list = []

            def connect(arg):
                if isinstance(arg, tensorplay.Tensor):
                    if arg.requires_grad:
                        edges = _collect_edges(arg)
                        if edges:
                            for e in edges:
                                fn.add_next_edge(e[0], e[1])
                                next_fns.append(e)
                        else:
                            fn.add_next_edge(None)
                            next_fns.append(None)
                    else:
                        fn.add_next_edge(None)
                        next_fns.append(None)
                elif isinstance(arg, dict):
                    for v in arg.values():
                        connect(v)
                elif isinstance(arg, (list, tuple)):
                    for item in arg:
                        connect(item)
                else:
                    fn.add_next_edge(None)
                    next_fns.append(None)

            for arg in args:
                connect(arg)
            ctx.next_functions = tuple(next_fns)

            idx = 0

            def attach_all(obj):
                nonlocal idx
                if isinstance(obj, tensorplay.Tensor):
                    if id(obj) not in ctx._non_differentiable:
                        obj.requires_grad = True
                        obj._set_grad_fn(fn, idx)
                    idx += 1
                elif isinstance(obj, (list, tuple)):
                    for o in obj:
                        attach_all(o)

            attach_all(output)

        ctx.backward = (
            _make_direct_backward(ctx, cls)
            if getattr(cls, "_tensorplay_direct_backward", False)
            else _make_backward(ctx, cls)
        )
        return _maybe_process_forward_ad(cls, ctx, args, output)




# Base-class markers for the per-call style checks in apply; module-level
# so the hot path compares against a plain global instead of walking the
# base class each time.
_BASE_SETUP_CONTEXT = Function.setup_context
_BASE_JVP = Function.jvp



[docs]
class InplaceFunction(Function):
    """

    In-place operations must call ``ctx.mark_dirty`` on the mutated inputs
    inside ``forward``; this subclass exists only so historical code that
    subclasses it keeps working.
    """




[docs]
class NestedIOFunction(Function):
    """

    Kept only for import compatibility; the modern contract is to define
    ``forward`` + ``backward`` on :class:`Function` directly.
    """

    def _nested_io(self, *inputs):
        raise RuntimeError("NestedIOFunction is legacy and unsupported")

    forward = _nested_io
    backward = _nested_io
Ask DeepWiki