Skip to content

fix(autobatching): detach states to stop UMA autograd-graph leak (swap loop + chunked init)#590

Open
niklashoelter wants to merge 4 commits into
TorchSim:mainfrom
niklashoelter:fix/uma-inflight-energy-graph-leak
Open

fix(autobatching): detach states to stop UMA autograd-graph leak (swap loop + chunked init)#590
niklashoelter wants to merge 4 commits into
TorchSim:mainfrom
niklashoelter:fix/uma-inflight-energy-graph-leak

Conversation

@niklashoelter

@niklashoelter niklashoelter commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

Problem

Some models return graph-carrying outputs — notably UMA, whose energy keeps
requires_grad=True even though its forces are already detached. Any state that is
retained after a forward pass therefore pins that pass's entire forward autograd graph.
torch_sim.optimize retains such states in two places, and both leak GPU memory until the
device fills. Because the leak is allocated memory (not cached), torch.cuda.empty_cache()
cannot reclaim it, and cache-clearing / allocator-config workarounds don't help.

1. InFlightAutoBatcher swap loop

optimize accumulates every converged system (via InFlightAutoBatcher.next_batch → the
caller's completed-states list) for the entire run so it can restore the original order at
the end. A state popped out of the running batch preserves grad_fn, so each retained
completed state pins the full forward graph of the swap it finished in — one live graph per
finished system. torch.cuda.memory_allocated climbs monotonically (tens of MB per
structure) until OOM.

Reproduced with uma-s-1p1 over a ~4300-structure molecular library (FIRE, D3 on): live
memory climbs from ~13 GB to 24 GB over ~356 swaps and OOMs; with this fix it stays flat
at ~13 GB across the entire run
(validated to full completion, twice).

2. _chunked_apply optimizer-state init pass

Before the swap loop, optimize initializes the optimizer state over all systems via
_chunked_apply, which runs the init fn per bin through a BinningAutoBatcher and
accumulates every result in a GPU-resident list. The same grad-carrying-energy states are
held here too, so live memory grows by ~one graph per bin. Unlike the swap loop,
BinningAutoBatcher has no runtime OOM recovery, so a bin that tips over the device is
fatal — and this bites larger models before the swap loop is ever reached.

Reproduced with uma-m-1p1 over the same ~4300-structure library (FIRE, D3 on): OOMs
~halfway through the init pass (bin 73/154), completely unaffected by max_memory_padding
(0.95 → bin 73, 0.90 → bin 78 — padding only shifts the crash a few bins, as expected for
an accumulation leak). With this fix the init pass turns from a monotonic climb into a
bounded sawtooth and the full run completes.

This is distinct from the LBFGS/BFGS history-buffer accumulation fixed in #559/#568 — it
affects the plain optimization loop (e.g. FIRE) via the model's own energy tensor.

Fix

Add a small detach_state_graph() helper in torch_sim/autobatching.py and call it on
states as they are retained, at both sites:

  • InFlightAutoBatcher.next_batch, on completed states before they are accumulated;
  • _chunked_apply (in torch_sim/runners.py), on each initialized state before it is
    accumulated.

Retained states are only ever read for their values (never differentiated), so dropping the
graph is safe and changes no numerical results. Because the helper is now used across
modules, it is promoted from a private _detach_state_graph to a public
torch_sim.detach_state_graph (exported in __all__).

Tests / validation

tests/test_autobatching.py: test_detach_state_graph_drops_grad_but_keeps_values
asserts grad-carrying tensors are detached in place, non-grad tensors are left untouched,
and values are unchanged. Full autobatching suite green; ruff + ty clean.

Full 4272-structure benchmark now completes for all fire configs with the default
max_memory_padding=0.95: uma-s-1p1, uma-s-1p2, uma-m-1p1, orb-v3-direct,
orb-v3-conservative — all 4272/4272, no regression. uma-m-1p1 was fatal at every padding
without the _chunked_apply fix.

…memory leak

InFlightAutoBatcher accumulates every converged system in the caller's
completed-states list for the whole run. A popped state preserves grad_fn,
and some models return graph-carrying outputs - UMA's energy keeps
requires_grad=True while its forces are already detached - so each completed
state pins its swap's entire forward autograd graph. Across hundreds of
in-flight swaps that is one retained graph per finished system (~tens of MB
each), growing live (allocated, not cached) GPU memory monotonically until
the device OOMs deep into a long optimization. empty_cache cannot reclaim it
because the memory is allocated, not cached, which is why it surfaces as a
late 'reserved but unallocated' fragmentation OOM.

Detach any grad-carrying tensors on completed states as they leave
next_batch, before they are accumulated. Completed states are only read for
their values, never differentiated, so dropping the graph is safe. Live
memory then stays flat across the whole run.
The same graph-carrying-energy leak fixed for InFlightAutoBatcher also occurs
in _chunked_apply, which optimize() uses to initialize optimizer state over all
systems before the swap loop. It runs the init fn per bin through a
BinningAutoBatcher and accumulates every result in a GPU-resident list. Models
such as UMA return an energy that keeps requires_grad=True, so each accumulated
state pins its bin's full forward autograd graph and live memory grows by ~one
graph per bin until the device fills mid-pass. Unlike the swap loop,
BinningAutoBatcher has no runtime OOM recovery, so this is fatal for larger
models (reproduced with uma-m-1p1 on a 4272-structure run: OOM ~halfway through
the init pass, unaffected by max_memory_padding).

Detach each initialized state before it is accumulated, mirroring the
InFlightAutoBatcher fix. The initialized states are only read for their values
downstream, so dropping the graph is safe. Verified: full 4272-structure
benchmark now completes for uma-m-1p1/uma-s/orb fire runs with no regression.
detach_state_graph is now used across modules (autobatching's InFlightAutoBatcher
and runners' _chunked_apply) and is a small, reusable utility for dropping
retained autograd graphs on completed states. Drop the leading underscore and
export it from the top-level torch_sim namespace so the cross-module use is a
supported import rather than reaching into a private helper.
@niklashoelter niklashoelter changed the title fix(autobatching): detach completed states to stop InFlightAutoBatcher memory leak (UMA) fix(autobatching): detach states to stop UMA autograd-graph leak (swap loop + chunked init) Jul 5, 2026
@niklashoelter niklashoelter marked this pull request as ready for review July 5, 2026 09:32
@CompRhys

CompRhys commented Jul 5, 2026

Copy link
Copy Markdown
Member

This should be fixed upstream in fairchem rather than here?

@niklashoelter

niklashoelter commented Jul 5, 2026

Copy link
Copy Markdown
Contributor Author

This should be fixed upstream in fairchem rather than here?

Fair point, and worth raising upstream — though FWIW it's currently unfiled and unfixed in fairchem main: conservative UMA returns a graph-carrying energy (forces are detached, energy isn't).

However, I still think this is a valuable defence layer in torch-sim:

  • The leak comes from retaining states across the run — torch-sim's own pattern. ModelInterface doesn't forbid grad-carrying outputs, so any such model leaks here, not just UMA.
  • These sites are exactly where a state is finished and kept only for its values — so detaching is safe and near-free.
  • Notably, fairchem's own torch-sim wrapper already detaches at the boundary (torchsim_interface.py does {k: v.detach() for k, v in results.items()}) — so fairchem itself treats this as torch-sim's side to handle. This just extends the same guard to the native adapter path, which bypasses that wrapper.

Happy to open the fairchem issue in parallel too.

@niklashoelter

Copy link
Copy Markdown
Contributor Author

Opened the upstream issue: facebookresearch/fairchem#2072

@CompRhys

CompRhys commented Jul 7, 2026

Copy link
Copy Markdown
Member
  • The leak comes from retaining states across the run — torch-sim's own pattern. ModelInterface doesn't forbid grad-carrying outputs, so any such model leaks here, not just UMA.

I did toy around with trying to add the 'retain_graph' arg to models to try and have an opinion on whether the graph should be detatched or not but didn't think that the pattern was clean enough to insist it's part of the interface, mostly because the use cases for fully differentiable simulation appear to be niche.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants