项目文件夹

文件
2026-07-13 12:40:42 +08:00

701 行
24 KiB
Python

# 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()