Skip to content

[modular] improve auto offload - #14304

Open
yiyixuxu wants to merge 18 commits into
mainfrom
auto-offload-v2
Open

[modular] improve auto offload#14304
yiyixuxu wants to merge 18 commits into
mainfrom
auto-offload-v2

Conversation

@yiyixuxu

@yiyixuxu yiyixuxu commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator

What does this PR do?

this PR improves ComponentsManager's auto offloading, main features includes:

  • automatic OOM recovery
  • keep a record of every offloading decision,
  • clean up some unused method in ComponentsManager
  • a slow test suite for modular pipeline that include offloading behavior test for different GPUs
  • updated docs.

Offloading behavior

  • memory_reserve (renamed from memory_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.
  • OOM retry (retry_on_oom=True): memory_reserve is 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 autoencoder encode/decode entry points (apply_forward_hook now marks its wrappers so the hook machinery can discover them).
  • Non-disruptive add/remove: adding a component while offloading is enabled hooks it in without rebuilding existing hooks or moving resident models; removing detaches only that component.

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 (Available free memory and Peak allocated memory), plus a Reason column for moves an onload didn't cause (oom_retry:<model>, offloading_disabled):

# | Onload                                 | Offloaded                              | Available | Peak     | Reason
-------------------------------------------------------------------------------------------------------------------
1 | text_encoder_129824856400544 (7.49 GB) | -                                      | 18.02 GB  | 8.50 KB  |
2 | transformer_129824854851072 (11.46 GB) | text_encoder_129824856400544 (7.49 GB) | 10.38 GB  | 7.77 GB  |
3 | vae_129824905851968 (159.87 MB)        | -                                      | 6.36 GB   | 12.07 GB |

The docs walk through reading it: spotting thrashing, finding the run's memory bottleneck with the peak column, and diagnosing an under-sized memory_reserve from an oom_retry row.

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 to enable_model_cpu_offload for standard pipelines) and a policy example that keeps co-scheduled models resident together.

ComponentsManager cleanup

get_components_by_names and get_one's pattern matching are removed (get_one is exact-match), OffloadRecord.summary() and unused record fields are gone, display helpers moved to components_manager_utils.py, and device handling is unified in one normalize_execution_device rule. The zh doc is synced for the removals.

Tests

  • New simulate_accelerator_memory testing 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.py reorganized into focused classes (registry / strategy unit tests / util validation / real-accelerator behavior)
  • New slow 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 covers memory_reserve and 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

…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>
@github-actions github-actions Bot added size/L PR with diff > 200 LOC documentation Improvements or additions to documentation tests modular-pipelines labels Jul 27, 2026
@HuggingFaceDocBuilderDev

Copy link
Copy Markdown

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.

yiyixuxu and others added 2 commits July 28, 2026 00:17
…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>
@yiyixuxu
yiyixuxu marked this pull request as draft July 28, 2026 22:18
yiyi@huggingface.co and others added 6 commits July 29, 2026 00:16
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>
@github-actions github-actions Bot added the utils label Jul 29, 2026
yiyixuxu and others added 2 commits July 29, 2026 10:20
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>
yiyixuxu and others added 6 commits July 29, 2026 21:02
…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:

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 components
  • TestAutoOffloadStrategy: test the strategy itself
  • TestAutoOffload: unit test for the enable_auto_cpu_offload feature with dummy models and simulated memory

cm.disable_auto_cpu_offload()


class ModularPipelineOffloadTesterMixin:

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@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

@yiyixuxu
yiyixuxu marked this pull request as ready for review July 31, 2026 03:36
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@yiyixuxu
yiyixuxu requested a review from sayakpaul July 31, 2026 03:51

@stevhliu stevhliu left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thanks!

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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
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:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
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:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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()`.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was a bit long and dense, I think we can let the table show and explain instead of explicitly spelling out whats happening.

Suggested change
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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
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.

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

Labels

documentation Improvements or additions to documentation modular-pipelines size/L PR with diff > 200 LOC tests utils

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants