fix(API): resolve torch_musa._initialized dynamically so checkpoint preserves device RNGFix/initialized stale reexport - #151
Open
yinjiew wants to merge 6 commits into
Conversation
torch_musa/__init__.py re-exported _initialized from core/_lazy_init with `from ... import`, which binds a snapshot of False taken at import time. The rebinding that _lazy_init() performs inside core/_lazy_init can never reach that copy, so torch_musa._initialized -- and therefore torch.musa._initialized, since torch.musa *is* this module -- stayed False forever. is_initialized() is a function and re-reads the live global, which is why every internal caller behaved correctly and only code reading the raw attribute was affected. Upstream PyTorch reads exactly that attribute. torch.utils.checkpoint gates the device RNG save/restore on `getattr(device_module, "_initialized", False)` in both the reentrant and the non-reentrant path, so on MUSA preserve_rng_state=True silently degraded to CPU-RNG-only: any stochastic op inside a checkpointed region redrew different noise during the backward recompute and produced a wrong gradient with no error and no warning. Measured on 2x MTT S5000 with torch_musa 2.7.1, the max abs gradient difference against the uncheckpointed reference reached 2.6 for a stochastic activation and 139.7 with a learnable branch. Drop the re-export and forward the attribute through a module-level __getattr__ (PEP 562) so core/_lazy_init stays the single source of truth. That is correct for every path automatically, including re-initialization after fork, because there is only ever one binding. Deleting the re-export is required, not cosmetic: __getattr__ is consulted only when normal lookup fails, and a stale entry in torch_musa.__dict__ would keep shadowing it. Side effect: the upstream guard that rejects a device initialized for the first time *inside* a checkpointed forward becomes live again, so that case now raises RuntimeError as it does on CUDA instead of passing silently. Fixes MooreThreads#150
_lazy_init() declared `global _initialized, _queue_calls`, but no _queue_calls exists anywhere in the repository. The module-level list is _queued_calls, and it is only ever mutated with .append() and never rebound, so it needs no global declaration at all. Upstream torch/cuda/__init__.py has `global _initialized, _queued_calls`, so the typo came in with the port. pylint cannot see it here because W0602 and undefined-variable are both suppressed for this file. No behaviour change. Kept as a separate commit so it can be dropped without touching the fix. Refs MooreThreads#150
Neither of the two places that could have caught the stale _initialized re-export does. tests/unittest/amp/test_amp_checkpoint.py checkpoints a fully deterministic nn.Linear and asserts only that dtypes propagate, and the `assert torch.musa._initialized` in tests/unittest/miscs/test_musa_converter.py lives inside the python_dst string literal -- it is the expected *output* of the CUDA-to-MUSA source converter, compared as text and never executed. Add the flag assertion to tests/unittest/standalone/test_lazy_init.py, where run_unittest.sh gives every file its own process, and a new tests/unittest/core/test_checkpoint.py asserting the contract that actually matters: the backward recompute must replay the forward's random draws bitwise, for both use_reentrant values, and a stochastic checkpointed region must produce the same gradients as running it eagerly -- checked with randn_like and with nn.Dropout. The new file is picked up by the existing `pytest ... tests/unittest/core` line in scripts/run_unittest.sh, so no CI change is needed. The flag assertion fails without the first commit of this branch by construction, and the checkpoint contract was observed failing on hardware on torch_musa 2.7.1 (reproduction in MooreThreads#150). I no longer have access to an MTT S5000, so these tests have not been executed against 2.11.0.post1 -- please run them in CI. Refs MooreThreads#150
lijing-mt
reviewed
Aug 18, 2026
lijing-mt
reviewed
Aug 18, 2026
lijing-mt
reviewed
Aug 18, 2026
Per review: the previous commit dropped `_queue_calls` from the `global` statement because no such name exists anywhere in the repository. On reflection, matching upstream torch/cuda/__init__.py's `global _initialized, _queued_calls` is preferable for readability, even though the declaration is a no-op for _queued_calls -- it is only ever mutated with .append() inside _lazy_init(), never rebound, so Python does not require `global` for it either way. No behaviour change. Refs MooreThreads#150
Two changes, both from review feedback on the previous commit: - Merge test_initialized_attr_is_live into test_lazy_init_flag. Both triggered lazy init with a fresh musa tensor and asserted on the result; there was no reason to pay for that twice or to depend on the two methods running in a fixed order within the same process. - Drop test_checkpoint_gradient_matches_eager. Because d(x * n)/dx == n, its assertion reduces to the same "gradient equals a noise tensor" check that test_checkpoint_preserves_device_rng already makes more directly, by comparing the forward and recompute draws bitwise across both use_reentrant values. The eager-comparison technique earns its keep on test_checkpoint_dropout_gradient_matches_eager instead: an nn.Dropout mask cannot be read out of the module the way the draws list reads it out of the hand-rolled noisy_mul, so comparing against an independently seeded eager run is the only way to check RNG replay for a real, opaque nn.Module. No coverage lost: the mechanism (both use_reentrant paths, bitwise) and the black-box contract (a real nn.Module, gradient-level) are each still checked by exactly one test. Refs MooreThreads#150
lijing-mt
reviewed
Aug 19, 2026
Per review: torch_musa/__init__.py mixed two ways of pulling names out of core/_lazy_init -- a `from ... import (...)` block for most of them, plus a separate `_lazy_init_mod` handle used only to forward `_initialized`. Use `_lazy_init_mod` for all of them instead, so there is exactly one way this file reaches into core/_lazy_init. _lazy_init, _lazy_call, _is_in_bad_fork, is_initialized and musart are still assigned as top-level names here -- torch.musa.is_initialized() and friends have to keep working -- but now as explicit attribute reads off _lazy_init_mod rather than an import statement. That is safe for all five: each is a function object, and rebinding a name to a function never goes stale, unlike _initialized, which is a plain mutable value and is the reason the module-level __getattr__ forwarder exists at all. That distinction is now spelled out in the comment above __getattr__ instead of in a comment attached to the import block that no longer exists. No behaviour change. Refs MooreThreads#150
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #150.
Problem
torch_musa/__init__.py:120re-exports_initializedfromtorch_musa.core._lazy_initwith
from ... import. That binds a snapshot of the valueFalse, not the name, so the_initialized = Truethat_lazy_init()performs atcore/_lazy_init.py:137can neverreach
torch_musa.__dict__["_initialized"].torch.musais this module (torch.__setattr__("musa", sys.modules[__name__]),__init__.py:57), andtorch.utils.checkpointdecides whether to save and restore thedevice RNG state with
getattr(device_module, "_initialized", False)— in bothCheckpointFunction.forwardand_checkpoint_without_reentrant_generator.So on MUSA,
torch.utils.checkpoint(..., preserve_rng_state=True)silently degrades toCPU-RNG-only. Every stochastic op inside a checkpointed region (
nn.Dropout, stochasticdepth, Gumbel-softmax, any custom noisy activation) draws different random numbers in the
backward recompute than it did in the forward pass, producing a wrong gradient with no
error, no exception and no warning. Measured on 2x MTT S5000 with
torch_musa2.7.1 atResNet-18 scale, max abs gradient difference against the uncheckpointed reference: 2.6
for a stochastic activation, 139.7 with a learnable branch. Training still converges to
something, which is why this can go unnoticed indefinitely.
is_initialized()is a function, so it re-reads the live global and returns the correctanswer. That asymmetry is why every internal consumer behaves correctly and only upstream
PyTorch, reading the raw attribute, is affected.
torch.cudadoes not have this bug becauseits
_initializedand_lazy_init()live in the same module.Affected in every release I could inspect — 2.7.1, 2.9.1.post1 and 2.11.0.post1 (this
branch).
core/_lazy_init.pyis byte-identical across all three, and PyTorch v2.7.1, v2.9.1and v2.11.0 all gate on the same attribute. Nothing in
torch_patches/touchestorch/utils/checkpoint.py.Fix
Stop re-exporting the flag; forward it with a module-level
__getattr__(PEP 562) socore/_lazy_initremains the single source of truth. That is correct automatically forevery path, including re-initialization after fork, because there is only ever one binding.
Deleting the re-export is required, not cosmetic:
__getattr__is consulted only whennormal attribute lookup fails, so a leftover stale entry in
torch_musa.__dict__would keepshadowing the forwarder.
The other five names this file took from
core/_lazy_init(_lazy_init,_lazy_call,_is_in_bad_fork,is_initialized,musart) are now read off the same_lazy_init_modhandle, so there is one consistent way this file reaches into that module. Binding those
five statically is safe — each is a callable assigned once at import time and never
rebound, and rebinding a name to a function object cannot go stale.
_initializedisprecisely the exception, being a plain mutable
False/True, which is why it alone needsthe forwarder.
The alternative — writing
torch_musa._initialized = Truefrom inside_lazy_init()— is asmaller diff, but it creates two bindings that must then be kept in sync at every site that
touches the flag, including bad-fork reset and any future teardown path. Happy to switch if
you prefer it.
torch_musa/core/mudnn.pyalready uses the sibling technique (aPropModulereplacingsys.modules[__name__]) so thattorch.backends.mudnn.allow_tf32reads through to C++instead of returning a stale Python copy — same problem, same shape of solution.
Commits
Six commits, in three pairs — the original change and then the follow-up that addresses
your review of it. Happy to squash any or all of them if you would rather have a flatter
history.
eb51bc3+d452548_initializedre-export, add the__getattr__forwarder, and route the remaining five names through the same_lazy_init_modhandlea8dceb8+7c78f53_queue_callstypo in_lazy_init()'sglobalstatement to_queued_calls, matching upstreamtorch/cuda/__init__.py. The declaration is a no-op for that name either way — it is only ever mutated with.append(), never rebound — so this is readability only, no behaviour changed54b0c0+ecb6793Tests
Neither existing test could have caught this:
tests/unittest/amp/test_amp_checkpoint.pycheckpoints a fully deterministicnn.Linearand asserts only
inputs.dtype == output.dtype;test_amp_checkpoint_fp16has noassertion at all.
tests/unittest/miscs/test_musa_converter.py:70does containassert torch.musa._initialized, but inside thepython_dststring literal — it is theexpected output of the CUDA-to-MUSA source converter, compared as text and never
executed.
Added:
tests/unittest/standalone/test_lazy_init.py::TestLazyInit::test_lazy_init_flag— theexisting test, extended to also assert
torch_musa._initializedandtorch.musa._initializedonce initialization has happened. It lives in the directorywhere
scripts/run_unittest.shgives each file its own process, which is what makes anassertion about lazy-init state meaningful.
tests/unittest/core/test_checkpoint.py(new), two tests:test_checkpoint_preserves_device_rng, parametrized over bothuse_reentrantvalues —records what each pass drew from inside the checkpointed function and asserts the
backward recompute replayed the forward's draws bitwise. This is the direct probe of
the defect.
test_checkpoint_dropout_gradient_matches_eager— the black-box counterpart. Annn.Dropoutmask cannot be read out of the module the way thedrawslist reads itout of a hand-rolled function, so gradient equality against an independently seeded
eager run is the only way to verify replay for a real
nn.Module.Picked up by the existing
pytest ... tests/unittest/coreline inscripts/run_unittest.sh, so no CI change is needed.What I could and could not verify
I no longer have access to the MTT S5000 machine, so I have not executed these tests
against 2.11.0.post1 — please run them in CI. To be explicit about where every claim in
this PR comes from:
checkpointon MUSAtorch_musa2.7.1+5ee0a64 (reproduction and output in #150)torch_musa._initializedstale whileis_initialized()is correct__init__.py:120is unchanged from 2.7.1upstream/mainblack24.2.0, the version pinned in.pre-commit-config.yaml, reports all four files unchanged. Re-checked after each round of review changestorch/torch_musaimportable). I checked the new code againsttools/lint/pylintrcby hand:function-rgx/variable-rgx/argument-rgx/method-rgx,good-names,docstring-min-length, and thedocparamsaccept-no-*-docdefaultsNothing above is extrapolated; every claim is labelled with how it was obtained.
Behaviour change worth a release note
torch/utils/checkpoint.pyalso raisesRuntimeError("PyTorch's device state was initialized in the forward pass of a Checkpoint, which is not allowed.")when_initializedis truthy but the forward did not stash device state. With the stale flag thatguard could never fire; it now can. This matches CUDA and is upstream's intended behaviour,
and it is hard to reach in practice — the checkpointed region would have to be the first
thing in the process to touch a MUSA tensor — but it is a deliberate difference rather than a
pure bug fix.
Backports
The same re-export exists in the 2.9.1.post1 and 2.7.1 trees and this change applies there
too, but the repository has no release branches, so I can only target
main. Happy to openfollow-up PRs if you ever cut them.
Environment