[modular] improve auto offload - #14304
Conversation
…tive add/remove - each offloading decision checks the memory actually available on the device (`mem_get_info` free plus the allocator's reusable cache) and keeps a tunable `memory_reserve` of it free; `memory_reserve_margin` is renamed to `memory_reserve` - if a forward still runs out of device memory, offload the smallest model on the device and retry, escalating one model at a time until it fits (`retry_on_oom=True` by default); when nothing else is resident, point at group offloading - adding or removing a component attaches/detaches a single hook instead of re-running `enable_auto_cpu_offload` and offloading the resident working set - add a `simulate_accelerator_memory` test util that makes a large accelerator behave like a smaller card, so offloading can be exercised with real models Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
The docs for this PR live here. All of your documentation changes will be reflected on that endpoint. The docs are available until 30 days after the last update. |
…ments `memory_budget` (and with it the budget/dynamic split) is not part of this PR, so there is only one strategy behavior left to describe. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`enable_auto_cpu_offload` makes its decisions silently, so there is no way to tell a well-tuned `memory_reserve` from one that thrashes, and tests have to monkeypatch internals to see the sequence. Add an always-on `OffloadRecord` on the manager: every onload, offload and OOM retry is appended as an `OffloadEvent` carrying the model, its size, the reason (`needed_by:<model>`, `oom_retry:<model>`, `component_added`, `offloading_disabled`), and what the strategy saw when it decided. Printing `manager.offload_record` gives a table plus a summary (bytes moved, OOM retries, peak co-residency); the event log is bounded and notes what it dropped. Add opt-in `measure_activations=True`, which brackets each forward with `reset_peak_memory_stats()` and reports `activation_peak` and `suggested_memory_reserve` — the number `memory_reserve` is supposed to cover, measured on the user's own hardware at their own settings, instead of a calibration run that assumes spare memory. `AutoOffloadStrategy.last_decision` is cleared at the top of `__call__` so an early-returning decision cannot inherit the previous call's readings. The two test spies that monkeypatched `UserCustomOffloadHook.offload` and `AutoOffloadStrategy.__call__` now read the record instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Each printed row is now one decision: the model that loaded, what was evicted to make room for it (folded from its needed_by offload events), and the free memory the decision saw. Offloads an onload did not cause (OOM retry, disabling offloading) keep their own rows, and column widths follow the content. The event log and summary() are unchanged. The doc example is replaced with real output from running Z-Image-Turbo under auto offload on a (simulated) 20GB card - the previous hand-written table showed sizes that contradicted the component listing above it and an Available value on an offload row, which never has one. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…measurement; cover encode/decode in the OOM retry - format_size, a shared format_table renderer, and summarize_dict_by_value_and_parts (simplified; its no-common-prefix branch wrote a stale loop variable) move to components_manager_utils.py. Both table reprs render through format_table, the components table Size column uses format_size, and get_model_info drops a deepcopy of the attention processors. Doc tables regenerated from a real run. - measure_activations is removed: the reserve can be deduced from torch's own peak counter plus the weights the record already shows, so the flag, the per-load peak-stat resets, and OffloadRecord.activations go away; the doc shows the deduction on a real Z-Image run (peak 14.15 GB - 11.6 GB resident weights -> ~2.5 GB decode headroom, covered by the default 3GB reserve). - The OOM retry now wraps encode/decode too: autoencoders enter through apply_forward_hook, which fires pre_forward but routes around forward, so a VAE decode OOM was previously never retried. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ry itself - OffloadEvent drops data nothing read: seconds, the constant reason="forward" on onloads, the OOM message, and memory_reserve (static config). The Reason column now only speaks on moves an onload did not cause (oom_retry:<model>, offloading_disabled); the doc points at set_verbosity_info() for watching moves live. - AutoOffloadStrategy.last_decision is gone: pre_forward reads the available memory itself just before the eviction decision, via a shared available_device_memory() helper (driver-free + reusable allocator cache) used by both the hook and the strategy. Every onload row now records a reading - including the first, which previously showed "-" - and custom strategies get the Available column for free. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
An event is now only ever a move - action "onload" or "offload" - with reason explaining why. The oom pseudo-action is gone (an OOM appears as the eviction it caused, reason oom_retry:<model>, which summary() now counts), and OffloadEvent drops the redundant offloaded/resident_before tuples: each eviction is its own event whose needed_by:<onloader> reason names its cause, and the printed table correlates them back into one row per decision. The dropped counter and truncation notice are removed (bounded deque truncates silently at 10k events). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The record is now just the bounded event deque plus the printed table - summary(), the repr's footer line, and __len__ are gone. Consumers read events directly; the tests replay peak co-residency from the moves with a small helper. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- apply_forward_hook stamps its wrapper with _is_forward_entry_point, so wrap_forward statically discovers which methods besides forward are device entry points (autoencoders' encode/decode) and wraps them all at attach for the OOM retry - no hardcoded name list, first calls included. The retry loop moves to a _with_oom_retry method. - Eviction reason renamed needed_by:<model> -> release_memory_for:<model>. - MAX_RECORDED_OFFLOAD_EVENTS becomes OffloadRecord.MAX_EVENTS. - pre_forward reads available memory unguarded (enable_auto_cpu_offload already rejects devices without mem_get_info); the cpu hook tests keep their fake mem_get_info alive with an autouse fixture instead. - resident_other_hooks() inlined into its only caller. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Checked every method against its real consumers (diffusers itself and the Mellon ModularDiffusers nodes): - search_components and its pattern-matching machinery (wildcards, !, |) are removed: nothing used them - Mellon fetches by component_id, the one in-repo caller was get_one's search path, which now goes through _lookup_ids (exact name/collection/load_id). - get_components_by_names is removed (no callers anywhere); the zh doc's workflow example now builds the dict with a get_one comprehension. - Single-caller helpers inlined: _attach_offload_hook into add(), _detach_offload_hook into remove(), get_ids into (the late) get_components_by_names. Kept with receipts: remove_from_collection (Mellon x3), _lookup_ids (Mellon x3 + 3 internal callers), get_components_by_ids (Mellon x8), get_one/get_model_info/enable+disable_auto_cpu_offload (Mellon + tests). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…arts - OffloadEvent recording moves to the site of each decision; offload() is a pure move, and every event (onload and offload) carries the free-memory reading taken just before its move. The repr's Available column shows the decision's first reading, so the table keeps its meaning. - add()'s offload(reason="component_added") was dead code: accelerate's add_hook_to_module already moves the model to CPU at attach, so the call never moved or recorded anything. Deleted. - One device rule: normalize_execution_device() (accelerators indexed, cpu not - the form tensors report) is shared by enable_auto_cpu_offload and CustomOffloadHook, and every resident check is now a plain ==. The hook defaults via get_device(); the PartialState import is gone. - Removed unused set_strategy; _offload_retry_on_oom is None outside the enabled window; remove() finds the hook by component id; pre_forward got a docstring; the strategy docstring no longer describes hook behavior; stale pre-PR comment deleted; "evict" replaced by "offload" throughout. - Tests: the four record tests that relied on cpu != cpu:0 to observe onloads moved to the accelerator section under simulated pressure, and disable's recorded final move is now asserted; the strategy-record test also checks the offload event's own reading. Verified with a real Z-Image-Turbo run on a simulated 20GB card: same 3-row table, peak 14.15 GB as documented. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Four focused test groups: registry (no hardware), strategy unit tests (cuda descriptor + scripted readings), simulate_accelerator_memory validation, and TestAutoOffload (real device moves), plus the pipeline offload mixin. House-style require_accelerate/require_accelerator decorators per test. - The OOM-retry tests now recover from real torch.OutOfMemoryErrors on a hard-capped simulated card (weights fit, the forward's output does not; one eviction is exactly what makes the retry fit) instead of scripted fake OOMs - the fake-OOM wrapper and its choreography tests are gone. - One patch helper (_patch_memory_stats) replaces the three reading fakes; _simulate_card_with_headroom sizes a simulated card relative to whatever the device currently holds. - Patterns adopted from the group-offloading tests: output equivalence (an OOM-survived run reproduces a plain run's output bit-for-bit), torch.no_grad around measured phases, hooks-installed guards, backend_* memory helpers, and a three-tier peak-memory test (baseline > partial offloading on a 160MB card > fully serialized on an 80MB card). - New coverage: enable rejects backends without mem_get_info; adding or removing models mid-run keeps residents in place and links hooks correctly; per-record asserts walk the offload record event by event. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…avior Every recorded offload event now carries the device's cumulative peak-memory reading (never reset by the offloader, so deltas between consecutive events attribute activation peaks to the interval between moves). New ModularPipelineIntegrationTesterMixin runs a modular pipeline against its real checkpoint: subclasses declare the expected offloading behavior per simulated card (offload counts, OOM-retry counts, final device per model) and test_auto_cpu_offload_on_cards verifies the run matches the spec and leaves the output untouched. The docstring carries the discovery recipe for transcribing a new pipeline's behavior from the offload record. TestZImageModularIntegration exercises Z-Image-Turbo on 32/24/16/10GB cards (no offloading / text encoder yields / transformer also yields / run-alone escape hatch) plus text-to-image and image-to-image slice checks. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Back to the version introduced in #13961, adapted only for this PR's renames (memory_reserve_margin -> memory_reserve, _patch_free_memory -> _patch_memory_stats). Also comment the Z-Image card specs with why each card behaves the way it does. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ng docs - ComponentsManager.set_offload_strategy() to swap the offload strategy on all managed models while auto offloading is enabled, with a fast test - offload record repr gains a Peak column (peak allocated memory at each decision) next to Available - docs: explain the record and the Reason column, custom strategy examples (sequential OffloadEverything + keeping co-scheduled models resident), finding the bottleneck with the peak column, and tuning memory_reserve from an OOM-retry record Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
|
||
|
|
||
| @require_accelerate | ||
| class ComponentsManagerTesterMixin: |
There was a problem hiding this comment.
I broke this up into a couple of tests and moved things around a bit, all the tests are still there, just got moved to different places
TestComponentsManagerRegistry: test stuff like add/remove componentsTestAutoOffloadStrategy: test the strategy itselfTestAutoOffload: unit test for theenable_auto_cpu_offloadfeature with dummy models and simulated memory
| cm.disable_auto_cpu_offload() | ||
|
|
||
|
|
||
| class ModularPipelineOffloadTesterMixin: |
There was a problem hiding this comment.
@sayakpaul I kept this but I think maybe we can remove this test now that we are going to have an integration test that tests real checkpoints. Let me know
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
| manager.enable_auto_cpu_offload(device="cuda", memory_reserve="1GB") | ||
| ``` | ||
|
|
||
| The reserve is an estimate, so a forward pass can still run out of memory. By default the manager recovers on its own: it offloads the smallest model on the device and retries the forward pass, escalating one model at a time until it fits — pass `retry_on_oom=False` to raise the error instead. A model that still doesn't fit once everything else is offloaded is too large for the device on its own; use [group offloading](../optimization/memory#group-offloading) for it. |
There was a problem hiding this comment.
| The reserve is an estimate, so a forward pass can still run out of memory. By default the manager recovers on its own: it offloads the smallest model on the device and retries the forward pass, escalating one model at a time until it fits — pass `retry_on_oom=False` to raise the error instead. A model that still doesn't fit once everything else is offloaded is too large for the device on its own; use [group offloading](../optimization/memory#group-offloading) for it. | |
| The reserve is an estimate, so a forward pass can still run out of memory. By default the manager recovers on its own: it offloads the smallest model on the device and retries the forward pass, escalating one model at a time until it fits. OOM retry is intended for inference only because each retry reruns the forward call from its original inputs. Set `retry_on_oom=False` during training or when the forward pass is not safe to repeat. A model that still doesn't fit once everything else is offloaded is too large for the device on its own; use [group offloading](../optimization/memory#group-offloading) for it. |
| return hooks # offload every other resident model before each load | ||
| ``` | ||
|
|
||
| This strategy offloads and onloads models in the sequence they are called (pretty much what [`~DiffusionPipeline.enable_model_cpu_offload`] does for standard pipelines). Use it like this: |
There was a problem hiding this comment.
| This strategy offloads and onloads models in the sequence they are called (pretty much what [`~DiffusionPipeline.enable_model_cpu_offload`] does for standard pipelines). Use it like this: | |
| This strategy offloads and onloads models in the sequence they are called (like [`~DiffusionPipeline.enable_model_cpu_offload`] for standard pipelines). Use it like this: |
|
|
||
| ### Finding the bottleneck with the peak column | ||
|
|
||
| `Peak` is the device's peak allocated memory as of that moment, so reading down the column tells you *when* the run's heaviest moments happened. In the first record above: nothing had run yet when the text encoder loaded (8.50 KB); by the transformer's turn the peak was the text encoder's weights plus its activations (7.77 GB); by the VAE's turn the transformer and its denoising steps had pushed it to 12.07 GB. The end of the run isn't a row (nothing loads after the VAE), so read the final peak directly — it's the same counter: |
There was a problem hiding this comment.
I think we can clarify how to interpret Peak and memory_reserve a bit more. For example, Peak is cumulative since process start but here it reads a bit like a per-run activation measurement (same for memory_reserve).
This may also work better as a sequential list instead of a paragraph :)
| ------------------------------------------------------------------------------------------------------------------- | ||
| ``` | ||
|
|
||
| Each row is one decision: the model that loaded, what was offloaded to make room for it, and the memory picture (`Available` and `Peak`, read just before the decision's first move) it was based on. Here the transformer found 10.38 GB free — not enough for its 11.46 GB of weights while keeping the 3GB `memory_reserve` — so the text encoder was offloaded first. The VAE then fit into the 6.36 GB left next to the transformer, so it loaded without pushing anything off. The `Reason` column stays empty for these planned moves; it is filled when a model is offloaded for any other reason: `oom_retry:<model>` when a forward pass ran out of memory and offloading was the rescue (covered below), or `offloading_disabled` when [`~ComponentsManager.disable_auto_cpu_offload`] moves everything back to CPU. To watch the moves live as they happen instead, enable info logging with `diffusers.logging.set_verbosity_info()`. |
There was a problem hiding this comment.
This was a bit long and dense, I think we can let the table show and explain instead of explicitly spelling out whats happening.
| Each row is one decision: the model that loaded, what was offloaded to make room for it, and the memory picture (`Available` and `Peak`, read just before the decision's first move) it was based on. Here the transformer found 10.38 GB free — not enough for its 11.46 GB of weights while keeping the 3GB `memory_reserve` — so the text encoder was offloaded first. The VAE then fit into the 6.36 GB left next to the transformer, so it loaded without pushing anything off. The `Reason` column stays empty for these planned moves; it is filled when a model is offloaded for any other reason: `oom_retry:<model>` when a forward pass ran out of memory and offloading was the rescue (covered below), or `offloading_disabled` when [`~ComponentsManager.disable_auto_cpu_offload`] moves everything back to CPU. To watch the moves live as they happen instead, enable info logging with `diffusers.logging.set_verbosity_info()`. | |
| Each row represents one offload decision. `Onload` names the model moved to the device, and `Offloaded` lists the models moved to CPU first. `Available` and `Peak` show the memory readings before the decision. Planned offloads leave `Reason` blank. The field is populated for OOM retries and for moves caused by disabling offloading. |
|
|
||
| Each row is one decision: the model that loaded, what was offloaded to make room for it, and the memory picture (`Available` and `Peak`, read just before the decision's first move) it was based on. Here the transformer found 10.38 GB free — not enough for its 11.46 GB of weights while keeping the 3GB `memory_reserve` — so the text encoder was offloaded first. The VAE then fit into the 6.36 GB left next to the transformer, so it loaded without pushing anything off. The `Reason` column stays empty for these planned moves; it is filled when a model is offloaded for any other reason: `oom_retry:<model>` when a forward pass ran out of memory and offloading was the rescue (covered below), or `offloading_disabled` when [`~ComponentsManager.disable_auto_cpu_offload`] moves everything back to CPU. To watch the moves live as they happen instead, enable info logging with `diffusers.logging.set_verbosity_info()`. | ||
|
|
||
| A model appearing repeatedly in this table is thrashing — it is being offloaded and re-loaded every step, which costs a PCIe transfer each way. That usually means `memory_reserve` is too large (models are pushed off that would have fit) or too small (each step ends in an OOM retry). It can also mean the default strategy is making the wrong call for your workload: a model that runs again inside the same denoise loop should stay resident even when evicting it looks fine on memory alone. That is a case for a custom strategy, which the next section covers. |
There was a problem hiding this comment.
| A model appearing repeatedly in this table is thrashing — it is being offloaded and re-loaded every step, which costs a PCIe transfer each way. That usually means `memory_reserve` is too large (models are pushed off that would have fit) or too small (each step ends in an OOM retry). It can also mean the default strategy is making the wrong call for your workload: a model that runs again inside the same denoise loop should stay resident even when evicting it looks fine on memory alone. That is a case for a custom strategy, which the next section covers. | |
| A model appearing repeatedly in this table is thrashing — it is being offloaded and reloaded every step, which costs a PCIe transfer each way. That usually means `memory_reserve` is too large (models are pushed off that would have fit) or too small (each step ends in an OOM retry). It can also mean the default strategy is making the wrong call for your workload: a model that runs again inside the same denoise loop should stay resident even when evicting it looks fine on memory alone. That is a case for a custom strategy, which the next section covers. |
What does this PR do?
this PR improves
ComponentsManager's auto offloading, main features includes:ComponentsManagerOffloading behavior
memory_reserve(renamed frommemory_reserve_margin, default"3GB", accepts size strings): each offloading decision keeps this much memory free as headroom for activations. The docs explain how to size it for a workload using the record's peak column.retry_on_oom=True):memory_reserveis an estimate, so a forward pass can still run out of memory. The manager now recovers on its own, when it runs into a OOM, it offloads the smallest resident model and reruns the forward pass, escalating one model at a time until it fits. If nothing is left to offload, it re-raises with advice to use group offloading. This also covers autoencoderencode/decodeentry points (apply_forward_hooknow marks its wrappers so the hook machinery can discover them).Offload record
Every move is now recorded.
print(manager.offload_record)shows one row per decision — what loaded, what was offloaded to make room, and the memory picture the decision was based on (Availablefree memory andPeakallocated memory), plus aReasoncolumn for moves an onload didn't cause (oom_retry:<model>,offloading_disabled):The docs walk through reading it: spotting thrashing, finding the run's memory bottleneck with the peak column, and diagnosing an under-sized
memory_reservefrom anoom_retryrow.Custom offload strategies
New
ComponentsManager.set_offload_strategy(): the decision-maker is any callable(hooks, model_id, model, execution_device) -> hooks to offload. The docs show a one-line sequential strategy (equivalent toenable_model_cpu_offloadfor standard pipelines) and a policy example that keeps co-scheduled models resident together.ComponentsManagercleanupget_components_by_namesandget_one's pattern matching are removed (get_oneis exact-match),OffloadRecord.summary()and unused record fields are gone, display helpers moved tocomponents_manager_utils.py, and device handling is unified in onenormalize_execution_devicerule. The zh doc is synced for the removals.Tests
simulate_accelerator_memorytesting util: makes the device behave like a card of a given size, this allows use to run integration tests use real checkpoints "on" different consumer-sized cards.test_components_manager.pyreorganized into focused classes (registry / strategy unit tests / util validation / real-accelerator behavior)ModularPipelineIntegrationTesterMixin: subclasses declare the expected offloading behavior per simulated card (offload counts, OOM counts, final devices) and the test verifies the record against the declaration plus output equivalence with a fully-resident baseline. First user: Z-Image (test_modular_pipeline_z_image.py), which also gains basic t2i/i2i slice tests. All expected values were captured on real hardware (H100).Docs
docs/source/en/modular_diffusers/components_manager.md: the offloading section now coversmemory_reserveand OOM retry, reading the record, writing and setting a custom strategy, finding the bottleneck with the peak column, and tuning the reserve from an OOM-retry record. All tables in the doc are genuine Z-Image-Turbo output on simulated cards.Follow up TODOs