Stop the debug name maps from pinning the model they snapshot - #8356
Conversation
module_names and param_names are module-level dicts, and dict keys are
strong references. engine.__init__ fills them at engine.py:402 and only
destroy() resets them at engine.py:965, so everything in the snapshotted
model stays reachable for the life of the process.
Anything that swaps a submodule out after that point cannot release it.
setattr unlinks the old module from the tree, but its parameters are
still live keys, so the refcount never reaches zero. Replacing 16 blocks
of a 64 MiB model leaves all 64 MiB resident:
before replaced 64.0 MiB, still resident 64.0 MiB (16/16 tensors)
after replaced 64.0 MiB, still resident 0.0 MiB (0/16 tensors)
weakref.WeakKeyDictionary is not usable here: its keys are weakref.ref
objects whose __eq__ forwards to the referents, and comparing two live
parameters returns a tensor rather than a bool, so a lookup raises
"Boolean value of Tensor with more than one value is ambiguous". Key on
id() and drop the entry from a finalizer instead, which keeps the
identity semantics the dicts already had. deepspeed/utils/pin_memory.py
tracks its allocations the same way.
Reported as problem 2 of deepspeedai#8353, with the diagnosis there. This does not
touch problem 1, the init-time transient in the AutoEP path.
Signed-off-by: alanhuangyoo <alanhuangyoo@gmail.com>
tohtana
left a comment
There was a problem hiding this comment.
This is a significant fix, thank you @alanhuangyoo!
The fix overall looks good to me. As test_debug_name_maps.py shows the old copyright, can you update it to DeepSpeed one? (# Copyright (c) DeepSpeed Team.).
Also, can you consider improving these in the tests?
- In
test_clear_and_re_extract,_Model()is passed as a temporary object. Since the name maps now hold only weak references, the model can be collected whendebug_extract_module_and_param_names(_Model())returns, removing its entries before the explicit clear call. The empty-map assertions can therefore pass even ifdebug_clear_module_and_param_names()does nothing. Keep the model in a local variable, assert that both maps are non-empty before clearing, then assert that both are empty while the model is still alive. test_a_recycled_id_is_not_a_stale_hitdeletes an old parameter and checks that a new parameter resolves to"unknown", but never checks whether their IDs are equal. If the new parameter has a different ID, the lookup returns"unknown"even if the old"ghost"entry was never removed. To claim coverage of ID reuse, the test must confirm that the new parameter actually received the old parameter's ID before checking the lookup. A more deterministic alternative is to assert that the old parameter is collected and its map entry is removed, and rename the test totest_collected_parameter_entry_is_removed.
Both cases could pass against an implementation that does nothing.
test_clear_and_re_extract passed the model as a temporary. The maps hold weak
references, so it was collectable as soon as extract returned, and the maps could
be empty before the clear ran. Neutering debug_clear_module_and_param_names shows
the difference:
before len(module_names) == 0, len(param_names) == 0 -> passes
after assert 9 == 0 -> fails
The model is now held in a local, both maps are asserted non-empty before the
clear, and the model is kept referenced past the emptiness assertions.
test_a_recycled_id_is_not_a_stale_hit built a new parameter and asserted it
resolved to "unknown", but never established that it had reused the collected
parameter's id. Without that the assertion holds whether or not the stale entry
was removed, and id reuse is not something a test can arrange. Renamed to
test_collected_parameter_entry_is_removed and asserts the removal directly.
Also updates the file's copyright line to the DeepSpeed one.
Signed-off-by: alanhuangyoo <alanhuangyoo@gmail.com>
|
Both points were right, and both tests were vacuous rather than merely weak. Fixed in 2d90ea1, along with the copyright line.
So the test asserted nothing about the function it was named for. The model is now held in a local, both maps are asserted non-empty before the clear, and the model is kept referenced past the emptiness assertions so it cannot be collected out from under them.
param_names[doomed.weight] = "ghost"
assert len(param_names) == 1
del doomed; gc.collect()
assert len(param_names) == 0The 4 passing, |
|
Note on the red check here, since a red X reads as "this PR broke something" and that is not what happened. The 137 is SIGKILL — the sandbox killed pytest mid-suite. There is not a single The same thing hit #8384 on the same day, and three other branches of mine that ran within the same hour (#8362, #8433, #8435) went green, so it is intermittent rather than a property of this tree. I cannot re-run it — that needs write access to the repo. Any maintainer re-running the failed job should be enough; happy to push an empty commit instead if that is easier. |
|
@tohtana — re-review request. Both of your points were right, both are fixed in 2d90ea1, and the PR has been sitting on the stale changes-requested since.
The recycled-id test. Also right — it only held if the new parameter reused the collected one's id, which a test cannot arrange. Renamed to Copyright header updated to The red CI is the 90-minute job timeout rather than a failure; I have merged current master in for #8404's raised limit and it is re-running. |
tohtana
left a comment
There was a problem hiding this comment.
Thank you for the update, @alanhuangyoo! Looks good to me.
Problem 2 of #8353 — the diagnosis there is @pengdurice's, this is the fix for that half. It does not touch problem 1 (the init-time transient in the AutoEP replacement path), which is a separate, larger change.
What retains the model
deepspeed/utils/debug.pykeeps two module-level dicts:Dict keys are strong references, and these are module-level globals, so they live as long as
deepspeed.utils.debugis imported. The call order seals it:Anything that swaps a submodule out between those two points cannot release it.
setattr(parent, name, replacement)unlinks the old module from the tree, but every one of its parameters is still a live key, so the refcount never reaches zero. Expert-parallel replacement is where #8353 hit it; kernel injection replaces modules the same way.Replacing all 16 blocks of a 64 MiB model:
Why not WeakKeyDictionary
It is the obvious fix and it does not work.
WeakKeyDictionarystoresweakref.refobjects as keys, andweakref.ref.__eq__forwards to the referents when both are alive. Comparing two live parameters runsTensor.__eq__, which returns a tensor:So the entries go in fine and every lookup raises.
Keying on
id()and dropping the entry from aweakref.finalizekeeps exactly the identity semantics the dicts already had — the previous code compared parameters byTensor.__hash__, which is id-based.deepspeed/utils/pin_memory.pyalready tracks its allocations this way, in the same package.The public surface is unchanged:
debug_module2name/debug_param2namestill dointhen[], and still return"unknown"for anything absent.Test
tests/unit/utils/test_debug_name_maps.py— lookups and the"unknown"fallback, release of a replaced submodule, clear/re-extract, and that a recycledid()is not a stale hit.On master:
With this PR:
The other three pass either way — they are there so the behaviour this preserves stays preserved.
The one failure is
test_pin_memory_tracker.py::test_checkpoint_emits_info(assert 2 == 1). It fails identically on master with this file reverted, so it is not from this change.