Files
2026-07-13 12:40:42 +08:00

701 lines
24 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Copyright (c) 2025 PaddlePaddle Authors. All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Test PyLayer tensor_hold_helper for _clear_dataptr protection.
Pipeline-parallel pattern:
1. outputs = Layer.apply(inputs) # forward: data is valid
2. loss = f(outputs) # build loss graph BEFORE clearing
3. outputs._clear_dataptr() # free activation memory
4. loss.backward() # backward via tensor_hold_helper recovery
tensor_hold_helper is a vector<shared_ptr<DenseTensor>> on PyLayerObject that
holds strong references to every DenseTensor impl saved via save_for_backward.
It is born with set_container (save_for_backward) and destroyed with the
PyLayerObject itself, preventing _clear_dataptr from freeing the underlying
allocation before backward runs.
"""
import gc
import unittest
import numpy as np
import paddle
from paddle.autograd import PyLayer
def _clear(tensors):
"""Call _clear_dataptr on a single tensor or iterable of tensors."""
if isinstance(tensors, (list, tuple)):
for t in tensors:
if hasattr(t, '_clear_dataptr'):
t._clear_dataptr()
elif hasattr(tensors, '_clear_dataptr'):
tensors._clear_dataptr()
class TestPyLayerClearDataptr(unittest.TestCase):
"""Core tests: _clear_dataptr on outputs does not break backward."""
def test_basic_clear_dataptr(self):
"""Single output, single saved tensor."""
class TanhLayer(PyLayer):
@staticmethod
def forward(ctx, x):
y = paddle.tanh(x)
ctx.save_for_backward(y)
return y
@staticmethod
def backward(ctx, dy):
(y,) = ctx.saved_tensor()
return dy * (1 - paddle.square(y))
x = paddle.randn([2, 3]).astype('float64')
x.stop_gradient = False
out = TanhLayer.apply(x)
loss = out.mean() # build graph first
_clear(out) # then free activation
loss.backward()
self.assertIsNotNone(x.grad)
def test_multiple_saved_tensors(self):
"""Multiple tensors passed to save_for_backward."""
class AddLayer(PyLayer):
@staticmethod
def forward(ctx, x, y):
ctx.save_for_backward(x, y)
return x + y
@staticmethod
def backward(ctx, dy):
x, y = ctx.saved_tensor()
return dy, dy
x = paddle.randn([2, 3]).astype('float64')
y = paddle.randn([2, 3]).astype('float64')
x.stop_gradient = False
y.stop_gradient = False
out = AddLayer.apply(x, y)
loss = out.mean()
_clear(out)
loss.backward()
self.assertIsNotNone(x.grad)
self.assertIsNotNone(y.grad)
def test_multiple_outputs(self):
"""Tuple output: both outputs are cleared."""
class MultiOutLayer(PyLayer):
@staticmethod
def forward(ctx, x):
y1 = paddle.tanh(x)
y2 = paddle.sin(x)
ctx.save_for_backward(y1, y2)
return y1, y2
@staticmethod
def backward(ctx, dy1, dy2):
y1, y2 = ctx.saved_tensor()
return dy1 * (1 - paddle.square(y1)) + dy2 * paddle.cos(y2)
x = paddle.randn([2, 3]).astype('float64')
x.stop_gradient = False
y1, y2 = MultiOutLayer.apply(x)
loss = (y1 + y2).mean() # build graph while data is valid
_clear([y1, y2])
loss.backward()
self.assertIsNotNone(x.grad)
def test_chained_computation(self):
"""Final output of a chain is cleared; intermediate kept for input."""
class TanhLayer(PyLayer):
@staticmethod
def forward(ctx, x):
y = paddle.tanh(x)
ctx.save_for_backward(y)
return y
@staticmethod
def backward(ctx, dy):
(y,) = ctx.saved_tensor()
return dy * (1 - paddle.square(y))
x = paddle.randn([2, 3]).astype('float64')
x.stop_gradient = False
y = TanhLayer.apply(x) # intermediate not cleared
z = TanhLayer.apply(y) # final output
loss = z.mean()
_clear(z) # only clear final activation
loss.backward()
self.assertIsNotNone(x.grad)
def test_different_dtypes(self):
"""float32 / float64 (and float16 on GPU) all work after _clear_dataptr."""
class TanhLayer(PyLayer):
@staticmethod
def forward(ctx, x):
y = paddle.tanh(x)
ctx.save_for_backward(y)
return y
@staticmethod
def backward(ctx, dy):
(y,) = ctx.saved_tensor()
return dy * (1 - paddle.square(y))
dtypes = ['float32', 'float64']
if paddle.is_compiled_with_cuda():
dtypes.append('float16')
for dtype in dtypes:
x = paddle.randn([2, 3]).astype(dtype)
x.stop_gradient = False
out = TanhLayer.apply(x)
loss = out.mean()
_clear(out)
loss.backward()
self.assertIsNotNone(x.grad)
def test_memory_cleanup(self):
"""Multiple iterations: per-iteration objects are collectible."""
import weakref
class TanhLayer(PyLayer):
@staticmethod
def forward(ctx, x):
y = paddle.tanh(x)
ctx.save_for_backward(y)
return y
@staticmethod
def backward(ctx, dy):
(y,) = ctx.saved_tensor()
return dy * (1 - paddle.square(y))
# Track the first iteration's `out` via weakref; after the loop ends
# and gc runs, it must be collected. Catches holder leaks where
# tensor_hold_helper accidentally retains a strong reference across
# ctx lifetimes.
first_out_ref = None
for i in range(10):
x = paddle.randn([64, 64]).astype('float32')
x.stop_gradient = False
out = TanhLayer.apply(x)
if i == 0:
first_out_ref = weakref.ref(out)
loss = out.mean()
_clear(out)
loss.backward()
del x, out, loss
gc.collect()
self.assertIsNone(first_out_ref())
class TestCtxDirect(unittest.TestCase):
"""Unit tests for ctx API without going through PyLayer.apply().
These tests create a ctx object directly via cls._backward_function() and
exercise save_for_backward / saved_tensor / pop_saved_impl in isolation,
independently of the forward/backward dispatch machinery.
Key design: cls._backward_function is a subclass of PyLayerBackward which
inherits core.eager.PyLayer (C++ PyLayerObject). Instantiating it calls
PyLayerNew, giving a fully-initialized ctx with an empty tensor_hold_helper.
"""
def _make_ctx(self):
"""Create a bare ctx (PyLayerObject) without running forward."""
class _Stub(PyLayer):
@staticmethod
def forward(ctx, x):
return x
@staticmethod
def backward(ctx, dy):
return dy
return _Stub._backward_function()
# ------------------------------------------------------------------
# Basic save / recover
# ------------------------------------------------------------------
def test_direct_single_tensor_recover(self):
"""save_for_backward + _clear_dataptr + saved_tensor, no apply."""
ctx = self._make_ctx()
t = paddle.randn([2, 3]).astype('float64')
ctx.save_for_backward(t)
_clear(t)
(recovered,) = ctx.saved_tensor()
self.assertIsNotNone(recovered)
self.assertEqual(list(recovered.shape), [2, 3])
def test_direct_multiple_tensors_recover(self):
"""All tensors are recovered after _clear_dataptr, no apply."""
ctx = self._make_ctx()
a = paddle.randn([3]).astype('float32')
b = paddle.ones([4, 2]).astype('float64')
ctx.save_for_backward(a, b)
_clear(a)
_clear(b)
recovered = ctx.saved_tensor()
self.assertEqual(len(recovered), 2)
self.assertEqual(list(recovered[0].shape), [3])
self.assertEqual(list(recovered[1].shape), [4, 2])
def test_direct_no_clear(self):
"""saved_tensor returns correct values when _clear_dataptr was not called."""
ctx = self._make_ctx()
t = paddle.randn([2, 3]).astype('float32')
expected = t.numpy().copy()
ctx.save_for_backward(t)
(recovered,) = ctx.saved_tensor()
np.testing.assert_allclose(recovered.numpy(), expected, rtol=1e-6)
# ------------------------------------------------------------------
# pop_saved_impl
# ------------------------------------------------------------------
def test_pop_saved_impl_single(self):
"""pop_saved_impl removes the holder entry; recovered tensor stays valid."""
ctx = self._make_ctx()
t = paddle.randn([2, 3]).astype('float32')
orig = t.numpy().copy()
ctx.save_for_backward(t)
_clear(t)
(recovered,) = ctx.saved_tensor()
# Verify the recovered tensor carries the correct data (not just non-None).
np.testing.assert_allclose(recovered.numpy(), orig, rtol=1e-6)
# Pop removes the holder entry; recovered's own shared_ptr keeps data alive.
ctx._pop_saved_impl(recovered)
self.assertEqual(list(recovered.shape), [2, 3])
def test_pop_saved_impl_partial(self):
"""Pop both saved tensors one by one; proves each entry is stored independently."""
ctx = self._make_ctx()
a = paddle.randn([2]).astype('float32')
b = paddle.randn([3]).astype('float32')
ctx.save_for_backward(a, b)
_clear(a)
_clear(b)
recovered = ctx.saved_tensor()
self.assertEqual(len(recovered), 2)
# Pop the first entry; if holder only had one entry this would erase it
# and the second pop below would be a no-op instead of finding b's entry.
ctx._pop_saved_impl(recovered[0])
# Pop the second entry; succeeds only if b's entry is still in holder
# (i.e. the two entries are stored independently).
ctx._pop_saved_impl(recovered[1])
# Both recovered handles remain valid via their own shared_ptr copies.
self.assertEqual(list(recovered[0].shape), [2])
self.assertEqual(list(recovered[1].shape), [3])
def test_pop_saved_impl_no_clear(self):
"""pop_saved_impl does not crash when tensor was never cleared.
Also verifies the pop targets a specific entry: after popping t's
holder entry, a subsequent saved_tensor() call still succeeds and
returns t with its original data (pop did not corrupt container).
"""
ctx = self._make_ctx()
t = paddle.randn([5]).astype('float32')
orig = t.numpy().copy()
ctx.save_for_backward(t)
# No _clear_dataptr; pop should still succeed silently
ctx._pop_saved_impl(t)
# saved_tensor() must still return the tensor correctly.
(recovered,) = ctx.saved_tensor()
np.testing.assert_allclose(recovered.numpy(), orig, rtol=1e-6)
# ------------------------------------------------------------------
# Deep-traversal via nested list in container
# ------------------------------------------------------------------
def test_nested_list_holder_populated(self):
"""Container with a nested list: CollectDenseTensors populates holder for all tensors.
save_for_backward packs args as a tuple, so the container at the top
level is always a tuple. But tuple *elements* may themselves be lists
(e.g. when a list is passed as one argument). CollectDenseTensors
recurses into them; verify via pop_saved_impl that both were collected.
"""
ctx = self._make_ctx()
t1 = paddle.randn([2]).astype('float32')
t2 = paddle.randn([3]).astype('float32')
# Directly assign a tuple whose sole element is a list of tensors.
# This bypasses save_for_backward's *args flattening so we can test
# the deep-traversal branch.
ctx.container = ([t1, t2],)
# Each pop finds and removes its entry; if CollectDenseTensors missed
# an entry, the corresponding pop is a silent no-op — so we follow
# each pair of pops with a redundant third pop that must also not crash,
# confirming the erase path is robust against missing entries.
ctx._pop_saved_impl(t1)
ctx._pop_saved_impl(t2)
ctx._pop_saved_impl(t1) # already removed — must be a silent no-op
def test_nested_tuple_holder_populated(self):
"""Container with a nested tuple: all inner tensors are held."""
ctx = self._make_ctx()
t1 = paddle.randn([2]).astype('float32')
t2 = paddle.randn([3]).astype('float32')
ctx.container = ((t1, t2),)
ctx._pop_saved_impl(t1)
ctx._pop_saved_impl(t2)
ctx._pop_saved_impl(t1) # already removed — must be a silent no-op
class TestCtxHoldRestore(unittest.TestCase):
"""Direct-ctx tests for _hold_tensors / _restore_held_tensors.
These cover the C++ WalkDenseTensors recursion (Tensor / tuple / list /
dict), the SavedTensorsHooks short-circuit in pylayer_hold_tensors, and
the ``impl() != nullptr`` early-return in pylayer_restore_held_tensors.
"""
def _make_ctx(self):
class _Stub(PyLayer):
@staticmethod
def forward(ctx, x):
return x
@staticmethod
def backward(ctx, dy):
return dy
return _Stub._backward_function()
def test_hold_restore_basic(self):
"""hold(tensor) + _clear_dataptr + restore re-installs impl_."""
ctx = self._make_ctx()
t = paddle.randn([2, 3]).astype('float32')
orig = t.numpy().copy()
ctx._hold_tensors(t)
_clear(t)
self.assertFalse(t._is_initialized())
ctx._restore_held_tensors()
self.assertTrue(t._is_initialized())
np.testing.assert_allclose(t.numpy(), orig, rtol=1e-6)
def test_hold_nested_containers(self):
"""tuple / list / dict values are all deep-traversed."""
ctx = self._make_ctx()
t_tuple = paddle.randn([2]).astype('float32')
t_list = paddle.randn([3]).astype('float32')
t_dict = paddle.randn([4]).astype('float32')
originals = [t.numpy().copy() for t in (t_tuple, t_list, t_dict)]
# One call with a container mixing all three Python collection types.
ctx._hold_tensors(((t_tuple,), [t_list], {'k': t_dict}))
_clear([t_tuple, t_list, t_dict])
ctx._restore_held_tensors()
for got, orig in zip((t_tuple, t_list, t_dict), originals):
self.assertTrue(got._is_initialized())
np.testing.assert_allclose(got.numpy(), orig, rtol=1e-6)
def test_hold_none_is_noop(self):
"""_hold_tensors(None) collects nothing; restore is a no-op."""
ctx = self._make_ctx()
ctx._hold_tensors(None)
ctx._restore_held_tensors() # must not crash
def test_hold_scalar_top_level_noop(self):
"""_hold_tensors on a bare non-container scalar collects nothing."""
ctx = self._make_ctx()
for val in (42, 3.14, "str", b"bytes"):
ctx._hold_tensors(val)
ctx._restore_held_tensors() # must not crash
def test_restore_skips_valid_impl(self):
"""Restore leaves tensors whose impl is still valid untouched."""
ctx = self._make_ctx()
t_cleared = paddle.randn([2]).astype('float32')
t_kept = paddle.randn([3]).astype('float32')
orig_cleared = t_cleared.numpy().copy()
orig_kept = t_kept.numpy().copy()
ctx._hold_tensors([t_cleared, t_kept])
_clear(t_cleared) # only one is cleared
ctx._restore_held_tensors()
# cleared tensor resurrected
np.testing.assert_allclose(t_cleared.numpy(), orig_cleared, rtol=1e-6)
# kept tensor's impl untouched — covers the ``if (!tensor.impl())``
# false branch in pylayer_restore_held_tensors.
self.assertTrue(t_kept._is_initialized())
np.testing.assert_allclose(t_kept.numpy(), orig_kept, rtol=1e-6)
def test_hold_non_tensor_leaves_ignored(self):
"""Non-Tensor leaves (int/float/str/None/bytes) are silently skipped."""
ctx = self._make_ctx()
t1 = paddle.randn([2]).astype('float32')
t2 = paddle.randn([3]).astype('float32')
orig1 = t1.numpy().copy()
orig2 = t2.numpy().copy()
# Container mixes Tensors with int / float / str / None / bytes /
# a dict whose values are non-Tensor; WalkDenseTensors must descend
# into the containers, collect t1 / t2, and ignore everything else.
mixed = (
t1,
42,
"hello",
None,
[3.14, t2, b"bytes"],
{'tag': 'x', 'n': 7, 'nested': (None, 'str')},
)
ctx._hold_tensors(mixed)
_clear([t1, t2])
ctx._restore_held_tensors()
np.testing.assert_allclose(t1.numpy(), orig1, rtol=1e-6)
np.testing.assert_allclose(t2.numpy(), orig2, rtol=1e-6)
def test_hold_skipped_under_saved_tensors_hooks(self):
"""When saved_tensors_hooks is enabled _hold_tensors collects nothing."""
ctx = self._make_ctx()
t = paddle.randn([2, 3]).astype('float32')
with paddle.autograd.saved_tensors_hooks(lambda x: x, lambda x: x):
ctx._hold_tensors(t)
_clear(t)
ctx._restore_held_tensors()
# holder was not populated, so impl stays empty after _clear_dataptr.
self.assertFalse(t._is_initialized())
class TestRecomputeClosureHold(unittest.TestCase):
"""End-to-end recompute coverage of the Python-side closure helper.
Covers ``_closure_cell_values`` (plain fn / nn.Layer / no-closure) and the
``_has_held_tensors`` True/False branches in RecomputeFunction.
"""
def setUp(self):
np.random.seed(1234)
paddle.seed(1234)
@staticmethod
def _clone_leaf(t):
out = paddle.to_tensor(t.numpy(), dtype=t.dtype)
out.stop_gradient = False
return out
def test_closure_cell_values_empty_cell(self):
"""Empty cell triggers ValueError branch; valid cells still collected."""
from paddle.distributed.fleet.recompute.recompute import (
_closure_cell_values,
)
def outer():
x = 1 # will be deleted → empty cell
y = paddle.randn([2])
def inner(a):
return a + x + y # noqa: F821
del x
return inner, y
fn, y = outer()
vals = _closure_cell_values(fn)
# Empty cell dropped by the ValueError branch; only y remains.
self.assertEqual(vals, (y,))
def test_recompute_no_closure(self):
"""run_fn has no __closure__: _has_held_tensors=False, restore skipped."""
from paddle.distributed.fleet.utils import recompute
def run_fn(a, b):
return (a * b + a).sum()
a = paddle.randn([4, 4])
a.stop_gradient = False
b = paddle.randn([4, 4])
b.stop_gradient = False
a_ref = self._clone_leaf(a)
b_ref = self._clone_leaf(b)
loss = recompute(run_fn, a, b)
_clear([a, b])
# Sanity: _clear actually nulled impls — otherwise "restore succeeded"
# would be trivially true and mask regressions.
self.assertFalse(a._is_initialized())
self.assertFalse(b._is_initialized())
loss.backward()
run_fn(a_ref, b_ref).backward()
np.testing.assert_allclose(
a.grad.numpy(), a_ref.grad.numpy(), rtol=1e-4
)
np.testing.assert_allclose(
b.grad.numpy(), b_ref.grad.numpy(), rtol=1e-4
)
def test_recompute_closure_tensors(self):
"""Closure captures Tensor / tuple / list / dict: all restored."""
from paddle.distributed.fleet.utils import recompute
w_s = paddle.randn([4, 4])
w_s.stop_gradient = False
w_a = paddle.randn([4, 4])
w_a.stop_gradient = False
w_b = paddle.randn([4, 4])
w_b.stop_gradient = False
w_d = paddle.randn([4, 4])
w_d.stop_gradient = False
refs = [self._clone_leaf(t) for t in (w_s, w_a, w_b, w_d)]
def make_fn(s, pair, mapping):
def fn(x):
a, b = pair
return (x @ s + a * x + b * x + mapping['k'] * x).sum()
return fn
x = paddle.randn([4, 4])
x.stop_gradient = False
x_ref = self._clone_leaf(x)
run_fn = make_fn(w_s, (w_a, w_b), {'k': w_d})
ref_fn = make_fn(refs[0], (refs[1], refs[2]), {'k': refs[3]})
loss = recompute(run_fn, x)
_clear([x, w_s, w_a, w_b, w_d])
for t in (x, w_s, w_a, w_b, w_d):
self.assertFalse(t._is_initialized())
loss.backward()
ref_fn(x_ref).backward()
for got, expect in zip((x, w_s, w_a, w_b, w_d), (x_ref, *refs)):
self.assertIsNotNone(got.grad)
np.testing.assert_allclose(
got.grad.numpy(), expect.grad.numpy(), rtol=1e-4
)
def test_recompute_all_grad_from_closure(self):
"""Trainable tensors captured via closure must receive grads.
Real-world pattern: trainable weights are closure-captured while the
PyLayer arg is a regular activation. Verifies that closure-captured
``w1`` / ``w2`` tensors are held across ``_clear_dataptr()`` and their
grads are computed correctly during the recomputed backward.
"""
from paddle.distributed.fleet.utils import recompute
w1 = paddle.randn([4, 4])
w1.stop_gradient = False
w2 = paddle.randn([4, 4])
w2.stop_gradient = False
w1_ref = self._clone_leaf(w1)
w2_ref = self._clone_leaf(w2)
def make_fn(a, b):
def fn(inp):
return (inp * a * b).sum()
return fn
run_fn = make_fn(w1, w2)
ref_fn = make_fn(w1_ref, w2_ref)
inp = paddle.ones([4, 4])
inp.stop_gradient = False
inp_ref = paddle.ones([4, 4])
inp_ref.stop_gradient = False
loss = recompute(run_fn, inp)
_clear([inp, w1, w2])
for t in (inp, w1, w2):
self.assertFalse(t._is_initialized())
loss.backward()
ref_fn(inp_ref).backward()
np.testing.assert_allclose(
w1.grad.numpy(), w1_ref.grad.numpy(), rtol=1e-4
)
np.testing.assert_allclose(
w2.grad.numpy(), w2_ref.grad.numpy(), rtol=1e-4
)
def test_recompute_layer_forward_closure(self):
"""paddle.nn.Layer branch of _closure_cell_values."""
from paddle.distributed.fleet.utils import recompute
bias = paddle.randn([4, 4])
bias.stop_gradient = False
bias_ref = self._clone_leaf(bias)
class MyLayer(paddle.nn.Layer):
def __init__(self, captured):
super().__init__()
def forward(x):
return (x + captured).sum()
self.forward = forward
def forward(self, x): # pragma: no cover
raise RuntimeError
layer = MyLayer(bias)
layer_ref = MyLayer(bias_ref)
x = paddle.randn([4, 4])
x.stop_gradient = False
x_ref = self._clone_leaf(x)
loss = recompute(layer, x)
_clear([x, bias])
self.assertFalse(x._is_initialized())
self.assertFalse(bias._is_initialized())
loss.backward()
layer_ref(x_ref).backward()
np.testing.assert_allclose(
x.grad.numpy(), x_ref.grad.numpy(), rtol=1e-4
)
np.testing.assert_allclose(
bias.grad.numpy(), bias_ref.grad.numpy(), rtol=1e-4
)
if __name__ == '__main__':
unittest.main()