fix(autobatching): detach states to stop UMA autograd-graph leak (swap loop + chunked init)#590
Conversation
…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.
|
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 However, I still think this is a valuable defence layer in torch-sim:
Happy to open the fairchem issue in parallel too. |
|
Opened the upstream issue: facebookresearch/fairchem#2072 |
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. |
Problem
Some models return graph-carrying outputs — notably UMA, whose
energykeepsrequires_grad=Trueeven though its forces are already detached. Any state that isretained after a forward pass therefore pins that pass's entire forward autograd graph.
torch_sim.optimizeretains such states in two places, and both leak GPU memory until thedevice 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.
InFlightAutoBatcherswap loopoptimizeaccumulates every converged system (viaInFlightAutoBatcher.next_batch→ thecaller'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 retainedcompleted state pins the full forward graph of the swap it finished in — one live graph per
finished system.
torch.cuda.memory_allocatedclimbs monotonically (tens of MB perstructure) until OOM.
Reproduced with
uma-s-1p1over a ~4300-structure molecular library (FIRE, D3 on): livememory 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_applyoptimizer-state init passBefore the swap loop,
optimizeinitializes the optimizer state over all systems via_chunked_apply, which runs the init fn per bin through aBinningAutoBatcherandaccumulates 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,
BinningAutoBatcherhas no runtime OOM recovery, so a bin that tips over the device isfatal — and this bites larger models before the swap loop is ever reached.
Reproduced with
uma-m-1p1over 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 intorch_sim/autobatching.pyand call it onstates as they are retained, at both sites:
InFlightAutoBatcher.next_batch, on completed states before they are accumulated;_chunked_apply(intorch_sim/runners.py), on each initialized state before it isaccumulated.
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_graphto a publictorch_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-1p1was fatal at every paddingwithout the
_chunked_applyfix.