-
-
Notifications
You must be signed in to change notification settings - Fork 6.8k
Expand file tree
/
Copy pathdiffusion_memory.py
More file actions
1256 lines (1102 loc) · 60.6 KB
/
Copy pathdiffusion_memory.py
File metadata and controls
1256 lines (1102 loc) · 60.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Measured-budget memory policy for the local diffusion backend.
From the resolved device target, a free-memory snapshot, and a coarse model footprint
estimate, this picks a CPU-offload policy and VAE slice/tile settings, then applies them to a
built diffusers pipeline. A model that won't fit resident is kept running by streaming weights
through the GPU one module at a time, which is lossless (offload / VAE slicing change placement
and decode chunking, not numerics).
The choice is coarse (sizes the model, not every activation), so ``auto`` is best-effort and
the explicit ``fast`` / ``balanced`` / ``low_vram`` modes are a hard override. torch / psutil
imported lazily.
"""
from __future__ import annotations
import os
from dataclasses import dataclass, replace
from typing import Any, Optional
# ── memory modes (operator intent) ───────────────────────────────────────────
MEMORY_MODE_AUTO = "auto"
MEMORY_MODE_FAST = "fast"
MEMORY_MODE_BALANCED = "balanced"
MEMORY_MODE_LOW_VRAM = "low_vram"
MEMORY_MODES = (
MEMORY_MODE_AUTO,
MEMORY_MODE_FAST,
MEMORY_MODE_BALANCED,
MEMORY_MODE_LOW_VRAM,
)
# none -- all weights resident (fastest; fits only with room).
# model -- enable_model_cpu_offload(): one top-level module on the GPU at a time.
# group -- apply_group_offloading() on the transformer: stream a few blocks at a time with a prefetch stream.
# streaming -- group-offload the transformer and leaf-offload text encoders that cannot fit whole.
# sequential -- enable_sequential_cpu_offload(): submodule-level (broken for GGUF through diffusers 0.39, kept as an escape hatch).
OFFLOAD_NONE = "none"
OFFLOAD_MODEL = "model"
OFFLOAD_GROUP = "group"
OFFLOAD_STREAMING = "streaming"
OFFLOAD_SEQUENTIAL = "sequential"
# Transformer blocks resident per group under group offloading: fewer = lower VRAM, more host-to-device traffic.
DEFAULT_GROUP_BLOCKS = 1
DEFAULT_IMAGE_WIDTH = 1024
DEFAULT_IMAGE_HEIGHT = 1024
# Flat allowance for fixed pipeline costs (scheduler, embeddings, CUDA context, fragmentation).
DEFAULT_BASE_OVERHEAD_MIB = 2048
def normalize_memory_mode(value: Optional[str]) -> Optional[str]:
"""Lower/strip a requested mode (accepting dashes); None passes through. Raises ValueError
for an unsupported mode so the route rejects it as a 4xx before any GPU work."""
if value is None:
return None
normalized = str(value).strip().lower().replace("-", "_")
if not normalized:
return None
if normalized not in MEMORY_MODES:
valid = ", ".join(MEMORY_MODES)
raise ValueError(f"Unsupported diffusion memory_mode '{value}'. Use one of: {valid}.")
return normalized
@dataclass(frozen = True)
class DeviceMemory:
"""Point-in-time view of the active device's memory, in MiB.
``memory_kind`` distinguishes discrete VRAM (CPU offload helps) from unified / system memory
(offload moves bytes within the same pool, so it does not)."""
backend: str
device: str
memory_kind: str # "discrete_vram" | "unified_memory" | "system_memory" | "unknown"
free_mib: Optional[int] = None
total_mib: Optional[int] = None
@property
def is_unified(self) -> bool:
return self.memory_kind in ("unified_memory", "system_memory")
def as_public_dict(self) -> dict[str, Any]:
return {
"backend": self.backend,
"device": self.device,
"memory_kind": self.memory_kind,
"free_mib": self.free_mib,
"total_mib": self.total_mib,
}
@dataclass(frozen = True)
class MemoryPlan:
"""The chosen runtime profile for one load."""
requested_mode: str
offload_policy: str
vae_tiling: bool
vae_slicing: bool
device_memory: DeviceMemory
estimates: dict[str, Optional[int]]
reasons: tuple[str, ...] = ()
# Under group offload, stream the TEXT ENCODERS alongside the transformer instead of keeping
# them resident. Defaulted so every existing construction is unchanged; set only where that
# is what makes group offload fit at all (see plan_diffusion_memory).
stream_text_encoders: bool = False
@property
def engages_offload(self) -> bool:
return self.offload_policy != OFFLOAD_NONE
def as_public_dict(self) -> dict[str, Any]:
return {
"requested_mode": self.requested_mode,
"offload_policy": self.offload_policy,
"vae_tiling": self.vae_tiling,
"vae_slicing": self.vae_slicing,
"device_memory": self.device_memory.as_public_dict(),
"estimates": dict(self.estimates),
"reasons": list(self.reasons),
"stream_text_encoders": self.stream_text_encoders,
}
# ── hardware snapshot ─────────────────────────────────────────────────────────
def snapshot_device_memory(target: Any) -> DeviceMemory:
"""Free / total memory for ``target``'s device. Never raises: a probe failure yields None
counts, which the planner treats as "budget unknown" (stay resident)."""
device = getattr(target, "device", "cpu")
backend = getattr(target, "backend", device)
if device == "cuda":
free, total, kind = _cuda_memory(backend)
return DeviceMemory(backend, device, kind, free, total)
if device == "xpu":
free, total = _xpu_memory()
return DeviceMemory(backend, device, "discrete_vram", free, total)
if device == "mps":
# Apple Silicon shares one CPU/GPU pool: system memory is the budget, offload pointless.
total, free = _system_memory_mib()
return DeviceMemory(backend, device, "unified_memory", free, total)
total, free = _system_memory_mib()
return DeviceMemory(backend, device, "system_memory", free, total)
def reclaimable_snapshot_device_memory(target: Any) -> DeviceMemory:
"""``snapshot_device_memory`` with the caching allocator's RECLAIMABLE bytes credited back
as free, without flushing it.
``torch.cuda.mem_get_info`` reports driver-level free memory, so every block the caching
allocator is holding for reuse counts as used even though the next allocation would take it
straight back. A generation that has already run therefore looks like it is on a much smaller
card than it is. ``settled_snapshot_device_memory`` fixes that with ``empty_cache()``, which is
right when it runs ONCE per load, but wrong on a per-generation path: releasing every cached
block forces the next forward to go back to ``cudaMalloc`` for all of its activations, which is
the exact cost the caching allocator exists to avoid.
``memory_reserved() - memory_allocated()`` is that same figure without the flush: bytes this
process holds and is not using. Adding it back is the honest reading of "how much could this
generation get". Deliberately an over-estimate at the margin -- fragmentation can stop some of
it being handed to one large tensor -- because this feeds a REFUSAL, and over-estimating free
memory can only make the guard quieter, never more trigger-happy.
Only the process's own allocator is credited. Host memory pinned by ``enable_model_cpu_offload``
lives outside it and is not counted here, which is correct: it is not device memory this
generation can allocate into.
Falls back to the plain snapshot on any failure or non-cuda device."""
if getattr(target, "device", "cpu") != "cuda":
return snapshot_device_memory(target)
snapshot = snapshot_device_memory(target)
if snapshot.free_mib is None:
return snapshot
try:
import torch
reclaimable = int(torch.cuda.memory_reserved()) - int(torch.cuda.memory_allocated())
except Exception: # noqa: BLE001 -- no allocator reading: the plain snapshot still stands
return snapshot
if reclaimable <= 0:
return snapshot
free = int(snapshot.free_mib) + reclaimable // (1024 * 1024)
if snapshot.total_mib is not None:
free = min(free, int(snapshot.total_mib)) # never claim more than the card has
return DeviceMemory(
snapshot.backend, snapshot.device, snapshot.memory_kind, free, snapshot.total_mib
)
def _settle_delay(delay_s: float) -> float:
"""How long to wait between the retried reads, honouring ``UNSLOTH_SETTLE_DELAY_S``.
What the retry loop is for is rejecting a TRANSIENT undercount, and the ``max`` over the
reads does that whatever the spacing: a real neighbouring tenant caps every read, a
transient caps only some. The spacing exists to give a real transient time to clear on a
live card, so production keeps the full second.
A test that reaches this through ``_plan_memory`` cannot pass ``delay_s`` and pays the
wait for nothing -- its snapshots are stubs whose answers do not change with time.
``test_diffusion_backend.py`` alone spent 142s of a 328s suite here, most of it in
tests sitting at exactly 4.00s. Callers that can pass ``delay_s = 0`` already do
(``test_diffusion_memory.py``); this is for the ones that cannot reach the argument.
"""
override = os.environ.get("UNSLOTH_SETTLE_DELAY_S")
if override is None:
return delay_s
try:
return max(0.0, float(override))
except (TypeError, ValueError):
return delay_s # a typo in the env must not change production behaviour
def settled_snapshot_device_memory(
target: Any,
attempts: int = 3,
delay_s: float = 1.0,
) -> DeviceMemory:
"""``snapshot_device_memory`` hardened against TRANSIENT free-VRAM undercounts on cuda.
``torch.cuda.mem_get_info`` is device-wide and instantaneous: a neighbouring process (or a
just-spawned subprocess context) briefly holding tens of GB at the wrong moment makes an
empty card look full, and the planner then silently declines the resident/quant fast path
(measured on B200: a cold FLUX.2-dev int8 load saw free < 74 GB on an idle 183 GB card and
fell back to offloaded GGUF; the identical retry saw >= 124 GB and went resident). Settle
the allocator (synchronize + empty_cache, best-effort) and take the MAX free over a few
spaced reads: a transient can only SHRINK free, so the max rejects transient undercounts
while a persistent tenant still caps every read. Non-cuda targets keep the single read.
On mps the budget is system memory, and torch's MPS caching allocator holds the previous
pipeline's freed buffers as reserved -- which reads as used system memory. Release them first,
or a swap is budgeted against a pool that only looks too small
(torch.mps.empty_cache: "Releases all unoccupied cached memory currently held by the caching
allocator so that those can be used in other GPU applications")."""
device = getattr(target, "device", "cpu")
if device == "mps":
try:
import torch
empty_cache = getattr(getattr(torch, "mps", None), "empty_cache", None)
if callable(empty_cache):
empty_cache()
except Exception: # noqa: BLE001 — settle is best-effort; the snapshot below still runs
pass
return snapshot_device_memory(target)
if device != "cuda":
return snapshot_device_memory(target)
try:
import torch
torch.cuda.synchronize()
torch.cuda.empty_cache()
except Exception: # noqa: BLE001 — settle is best-effort; the snapshot below still runs
pass
best = snapshot_device_memory(target)
delay_s = _settle_delay(delay_s)
for _ in range(max(0, attempts - 1)):
if best.free_mib is not None and best.total_mib is not None:
# Free already within the reserve of total: nothing transient to wait out.
if best.free_mib >= best.total_mib - max(2048, int(best.total_mib * 0.10)):
break
try:
import time
time.sleep(delay_s)
except Exception: # noqa: BLE001
break
nxt = snapshot_device_memory(target)
if nxt.free_mib is not None and (best.free_mib is None or nxt.free_mib > best.free_mib):
best = nxt
return best
def _cuda_memory(backend: str) -> tuple[Optional[int], Optional[int], str]:
try:
import torch
# Not torch.cuda.mem_get_info directly: on Windows ROCm its free half is an
# over-report that does not track residency, and this feeds the activation
# refusal that exists BECAUSE Windows WDDM spills to host RAM instead of
# raising (#8403). Imported lazily to keep this module free of backend
# imports at module scope.
from utils.hardware import trusted_mem_get_info
free, total = trusted_mem_get_info()
kind = "discrete_vram"
try:
# Query the CURRENT device (mem_get_info reports it); hardcoding 0 would inspect the wrong GPU and misclassify it.
props = torch.cuda.get_device_properties(torch.cuda.current_device())
if bool(getattr(props, "integrated", False) or getattr(props, "is_integrated", False)):
kind = "unified_memory" # e.g. Jetson / integrated SoC
except Exception:
pass
return int(free // (1024 * 1024)), int(total // (1024 * 1024)), kind
except Exception:
return None, None, "discrete_vram"
def _xpu_memory() -> tuple[Optional[int], Optional[int]]:
try:
import torch
mem_get_info = getattr(getattr(torch, "xpu", None), "mem_get_info", None)
if callable(mem_get_info):
free, total = mem_get_info()
return int(free // (1024 * 1024)), int(total // (1024 * 1024))
except Exception:
pass
return None, None
def _system_memory_mib() -> tuple[Optional[int], Optional[int]]:
"""(total, available) host RAM in MiB, via psutil then POSIX sysconf."""
try:
import psutil
vm = psutil.virtual_memory()
return int(vm.total // (1024 * 1024)), int(vm.available // (1024 * 1024))
except Exception:
pass
try:
page = os.sysconf("SC_PAGE_SIZE")
total = os.sysconf("SC_PHYS_PAGES") * page
avail = os.sysconf("SC_AVPHYS_PAGES") * page
return int(total // (1024 * 1024)), int(avail // (1024 * 1024))
except Exception:
return None, None
# ── size estimates ────────────────────────────────────────────────────────────
def file_size_mib(path: Any) -> Optional[int]:
"""On-disk size of ``path`` in MiB, or None if it can't be stat'd."""
try:
from pathlib import Path
return max(1, int(Path(path).expanduser().stat().st_size // (1024 * 1024)))
except Exception:
return None
def estimate_gguf_resident_mib(storage_mib: Optional[int]) -> Optional[int]:
"""Approximate the RESIDENT device size of a GGUF transformer under ``GGUFQuantizationConfig``.
Weights stay PACKED as quantised bytes; ``GGUFLinear.forward`` dequantises each transiently
for its matmul and frees it, so the persistent footprint is ~= on-disk size, not unpacked
bf16. Measured on Z-Image-Turbo: Q2_K 3.64 -> 3.68 GiB, Q8_0 7.22 -> 7.25 GiB resident. The
transient dequant is covered by the separate runtime headroom. (The prior per-quant expansion
assumed a full unpack that never happens, over-estimating Q2 ~7.6x and forcing needless offload.)"""
if storage_mib is None:
return None
return int(storage_mib * 1.05) # small margin for allocator + bf16 norms/biases
def estimate_safetensors_dense_mib(
storage_mib: Optional[int], *, fp8_upcast: bool = False
) -> Optional[int]:
"""Resident size of a safetensors checkpoint, in MiB.
Usually loads near on-disk size (None passes through). Exception: ``fp8_upcast`` -- an fp8
single-file transformer loads with no quantization_config, so diffusers upcasts to bf16 (~2x)."""
if storage_mib is None:
return None
if fp8_upcast:
return storage_mib * 2
return storage_mib
def estimate_image_runtime_mib(
*,
width: Optional[int],
height: Optional[int],
batch_size: int = 1,
family: Optional[str] = None,
) -> int:
"""Per-call activation / latent headroom for an image gen, scaled by pixel area and batch.
Distilled / turbo models (few steps, no CFG) need less."""
w = max(64, int(width or DEFAULT_IMAGE_WIDTH))
h = max(64, int(height or DEFAULT_IMAGE_HEIGHT))
batch = max(1, int(batch_size or 1))
pixel_scale = (w * h * batch) / float(DEFAULT_IMAGE_WIDTH * DEFAULT_IMAGE_HEIGHT)
fam = (family or "").lower()
multiplier = 1.0
if "edit" in fam:
multiplier *= 1.35
if "turbo" in fam or "distilled" in fam or "schnell" in fam:
multiplier *= 0.85
return max(1024, int(8192 * max(0.25, pixel_scale) * multiplier))
def estimate_video_runtime_mib(
*, width: Optional[int], height: Optional[int], num_frames: Optional[int]
) -> int:
"""Per-call activation / latent / decode headroom for a video generation.
The pixel-area image estimator undershoots video: the VAE DECODE is the peak -- the clip
materialises as num_frames full-res fp32 frames plus decoder intermediates. Scale by the
decoded-clip footprint (frames x H x W x 3 x 4 bytes) with a 3x factor for intermediates +
the export copy, on top of a fixed denoise-side base.
"""
w = max(64, int(width or 768))
h = max(64, int(height or 512))
frames = max(1, int(num_frames or 121))
decoded_mib = (frames * w * h * 3 * 4) / float(1024 * 1024)
return max(3072, int(4096 + 3.0 * decoded_mib))
def _reserve_mib(memory_kind: str, base: int) -> int:
if memory_kind == "unified_memory":
return max(2048, int(base * 0.20)) # OS + CPU share this pool
if memory_kind == "system_memory":
return max(1024, int(base * 0.10))
return max(2048, int(base * 0.10))
def _safe_device_budget_mib(memory: DeviceMemory) -> Optional[int]:
"""Free memory minus a headroom reserve (room for fragmentation + other tenants). None when
free memory is unknown."""
if memory.free_mib is None:
return None
base = memory.total_mib or memory.free_mib
return max(0, int(memory.free_mib) - _reserve_mib(memory.memory_kind, base))
def plan_fits_total_capacity(plan: Any) -> bool:
"""Whether ``plan``'s resident requirement fits TOTAL device capacity under the standard
reserve + the 0.85 resident margin -- i.e. an offload decision can only stem from the
instantaneous FREE reading (something else held VRAM at snapshot time), never from the
device being too small. Used to retry a declined resident/quant plan once with a fresh
settled snapshot instead of trusting a single transient undercount. False on any missing
input (unknown sizes keep today's behaviour)."""
try:
required = plan.estimates.get("resident_required_mib")
memory = plan.device_memory
total = memory.total_mib
kind = memory.memory_kind
except Exception: # noqa: BLE001 — malformed plan: no retry
return False
if required is None or total is None:
return False
return int(required) <= int((int(total) - _reserve_mib(kind, int(total))) * 0.85)
# Opt-in escape hatch for the unified-memory refusal below: the shortfall check is an
# estimate, so an operator who believes it is wrong can still attempt the load.
UNIFIED_OVERSIZE_ENV = "UNSLOTH_DIFFUSION_ALLOW_OVERSIZED_LOAD"
def _unified_oversize_override() -> bool:
return os.environ.get(UNIFIED_OVERSIZE_ENV, "").strip().lower() in ("1", "true", "yes", "on")
def unified_memory_shortfall_message(plan: Any, *, family: Optional[str] = None) -> Optional[str]:
"""On UNIFIED device memory, a user-facing refusal when the WEIGHTS alone cannot fit the
safe budget (else None).
Unified memory is the one placement where the planner has no fallback left. On discrete
VRAM an oversized model still loads: it degrades to group / whole-module CPU offload and
streams from host RAM. On Apple Silicon (and integrated CUDA) the CPU and GPU share one
pool, so offload moves bytes within that pool and frees nothing -- ``plan_diffusion_memory``
correctly returns ``none``, and the load then allocates past physical memory. There is no
torch OOM to catch, because ``_mps_or_cpu_target`` sets PYTORCH_MPS_HIGH_WATERMARK_RATIO=0.0
to disable the MPS allocator's hard limit, so the failure is the OS killing the process
with no Python exception. Refusing up front is the only way the user learns why.
Weights only (``model_dense_mib`` + the flat base overhead): the per-call runtime headroom
is the SOFT term -- it is a coarse activation / VAE-decode estimate, and the mps path
already turns on VAE tiling + slicing, which cuts the decode peak this estimate does not
model. Counting it would refuse marginal loads that would in fact complete. The weights are
the hard term: they are unavoidable resident bytes, sized from measured per-family
component tables or from on-disk checkpoint size, and if they alone do not fit then nothing
at generation time can rescue the load. Same reasoning as the llama.cpp APU guard, which
budgets weights and lets KV/context auto-reduce.
Fail-open on anything unknown (no budget, no size), matching the planner's own
"budget or model size unknown; staying resident"."""
if _unified_oversize_override():
return None
try:
memory = plan.device_memory
# ``system_memory`` (plain CPU) is deliberately excluded: it is an opt-in fringe path,
# it has swap, and it is not what gets Metal-killed. Only the accelerator-on-system-pool
# case is guarded.
if getattr(memory, "memory_kind", None) != "unified_memory":
return None
estimates = plan.estimates
budget = estimates.get("safe_device_budget_mib")
weights = estimates.get("model_dense_mib")
overhead = estimates.get("base_overhead_mib")
free = getattr(memory, "free_mib", None)
except Exception: # noqa: BLE001 — malformed plan: never block the load
return None
if budget is None or weights is None:
return None
required = int(weights) + int(overhead or 0)
if required <= int(budget):
return None
what = f"'{family}'" if family else "This model"
free_note = (
f"of the {int(free) / 1024:.0f} GB currently free, after reserving room for the "
"operating system"
if free is not None
else "after reserving room for the operating system"
)
return (
f"{what} needs about {required / 1024:.0f} GB of memory for its weights, but only "
f"about {int(budget) / 1024:.0f} GB is usable on this device ({free_note}). This device "
"has unified memory, so the CPU and GPU share one pool: offloading weights to the CPU "
"frees nothing, and the operating system stops an oversized load outright instead of "
"reporting an out-of-memory error. Use a smaller or more quantized model, free memory "
f"by closing other applications, or set {UNIFIED_OVERSIZE_ENV}=1 to attempt the load "
"anyway."
)
def raise_on_unified_memory_shortfall(
plan: Any,
*,
family: Optional[str] = None,
logger: Any = None,
) -> None:
"""Refuse a load whose weights cannot fit unified device memory. No-op on every other
placement, so the discrete-VRAM path is untouched.
Lives outside ``plan_diffusion_memory`` on purpose: the planner is a pure sizing function
that both loaders call SPECULATIVELY (the image loader re-plans candidate quantisations, and
both re-plan against a settled snapshot), and a planner that raised would turn those probes
into load failures instead of letting a smaller candidate win. Call this once, on the plan
the loader has committed to, after the previous pipeline has been evicted so the free
reading is the memory the load actually gets."""
message = unified_memory_shortfall_message(plan, family = family)
if message is None:
return
if logger is not None:
logger.error("diffusion.memory: refusing oversized unified-memory load: %s", message)
raise RuntimeError(message)
def _sum_required(*values: Optional[int]) -> Optional[int]:
total = 0
for value in values:
if value is None:
return None
total += int(value)
return total
# ── the planner ───────────────────────────────────────────────────────────────
def plan_diffusion_memory(
*,
target: Any,
device_memory: DeviceMemory,
model_dense_mib: Optional[int],
runtime_headroom_mib: int,
companion_dense_mib: Optional[int] = None,
text_encoder_dense_mib: Optional[int] = None,
base_overhead_mib: int = DEFAULT_BASE_OVERHEAD_MIB,
requested_mode: Optional[str] = None,
explicit_offload: bool = False,
) -> MemoryPlan:
"""Pick an offload policy + VAE memory savers for the current load.
``model_dense_mib`` is the resident size of all weights; ``companion_dense_mib`` is just the
companions, which stay resident under group offload while the transformer streams block by
block. ``text_encoder_dense_mib`` is the TEXT-ENCODER share of that companion total, which
unlocks a second group tier (below); None means "no split available" and reproduces the
pre-split decision exactly. ``explicit_offload`` is the back-compat ``cpu_offload=True``
request (forces model offload).
Policies by speed/VRAM tradeoff:
none - everything resident: fastest, highest VRAM.
group - stream the transformer, companions resident: near-resident speed, moderate cut.
group + streamed text encoders - as above, but the encoders stream too: they run ONCE,
before step 0, so this costs one extra host-to-device pass per call rather than a
per-step one, and it frees their bytes for every denoising step.
model - offload every component: lowest VRAM, slow.
streaming - stream transformer blocks and text-encoder leaves when one component cannot fit.
"""
mode = normalize_memory_mode(requested_mode) or MEMORY_MODE_AUTO
can_offload = bool(getattr(target, "supports_model_cpu_offload", False))
budget = _safe_device_budget_mib(device_memory)
required = _sum_required(model_dense_mib, runtime_headroom_mib, base_overhead_mib)
# The resident floor under group offload: companions stay, the transformer streams.
group_floor = _sum_required(companion_dense_mib, runtime_headroom_mib, base_overhead_mib)
# A SECOND floor, for the same tier with the text encoders streamed as well. The encoders are
# the largest companion on most families (Z-Image: 8.0 of 8.2 GB) and they are used exactly
# once, before step 0, so holding them resident for the whole denoise reserves their bytes for
# nothing. Streaming them leaves the VAE as the only resident companion. Computed only when
# BOTH terms are known: an unknown split must reproduce the previous decision, never guess a
# smaller floor. Clamped at 0 because the two terms can come from different sources.
group_floor_streamed_te = (
_sum_required(
max(0, int(companion_dense_mib) - int(text_encoder_dense_mib)),
runtime_headroom_mib,
base_overhead_mib,
)
if companion_dense_mib is not None and text_encoder_dense_mib is not None
else None
)
reasons: list[str] = []
stream_text_encoders = False
estimates: dict[str, Optional[int]] = {
"safe_device_budget_mib": budget,
"model_dense_mib": model_dense_mib,
"companion_dense_mib": companion_dense_mib,
"text_encoder_dense_mib": text_encoder_dense_mib,
"runtime_headroom_mib": runtime_headroom_mib,
"base_overhead_mib": base_overhead_mib,
"resident_required_mib": required,
"group_floor_mib": group_floor,
"group_floor_streamed_te_mib": group_floor_streamed_te,
}
def _group_fits() -> bool:
# Group offload only helps if the resident companions fit; a too-big text encoder needs whole-module offload.
return group_floor is not None and budget is not None and group_floor <= budget
def _group_fits_streamed_te() -> bool:
# The same tier once the text encoders stream too; only reachable with a known split.
return (
group_floor_streamed_te is not None
and budget is not None
and group_floor_streamed_te <= budget
)
# The best tier available when the weights do not fit resident, in speed order: plain group
# (companions resident) beats group with streamed encoders (one extra host-to-device pass per
# CALL) beats whole-module offload (every component paged per STEP -- the 48-minute case).
def _offload_tier() -> tuple[str, bool]:
if _group_fits():
return OFFLOAD_GROUP, False
if _group_fits_streamed_te():
return OFFLOAD_GROUP, True
return OFFLOAD_MODEL, False
_STREAMED_TE_REASON = (
"companions exceed budget, but they fit with the text encoders streamed too "
"(they run once, before step 0); streaming them beats paging every component per step"
)
if not can_offload or device_memory.is_unified:
# MPS / CPU cannot stream to a separate device; on unified memory offload just shuffles bytes within the same pool.
policy = OFFLOAD_NONE
if device_memory.is_unified:
reasons.append("unified/system memory: CPU offload frees no device memory")
else:
reasons.append(f"{device_memory.backend}: CPU offload unavailable; staying resident")
elif mode == MEMORY_MODE_FAST:
policy = OFFLOAD_NONE
if budget is not None and required is not None and required > budget:
# Doesn't fit resident: streamed transformer is the fastest offload.
policy, stream_text_encoders = _offload_tier()
reasons.append("fast requested but weights do not fit resident; offloading")
if stream_text_encoders:
reasons.append(_STREAMED_TE_REASON)
else:
reasons.append("fast requested; weights resident on device")
elif mode == MEMORY_MODE_BALANCED:
policy = OFFLOAD_GROUP
reasons.append("balanced requested; streamed block-level transformer offload")
elif mode == MEMORY_MODE_LOW_VRAM:
policy = OFFLOAD_MODEL
reasons.append("low_vram requested; whole-module offload of every component")
elif budget is None or required is None:
policy = OFFLOAD_NONE
reasons.append("device budget or model size unknown; staying resident")
elif required <= int(budget * 0.85):
policy = OFFLOAD_NONE
reasons.append("weights fit resident with headroom")
elif _group_fits():
policy = OFFLOAD_GROUP
reasons.append("tight fit; stream the transformer, companions resident")
elif _group_fits_streamed_te():
policy = OFFLOAD_GROUP
stream_text_encoders = True
reasons.append(_STREAMED_TE_REASON)
else:
policy = OFFLOAD_MODEL
reasons.append("companions exceed budget; whole-module offload of every component")
# The legacy cpu_offload flag applies only when no memory_mode was supplied, so an explicit `fast` stays resident.
if (
explicit_offload
and normalize_memory_mode(requested_mode) is None
and policy == OFFLOAD_NONE
and can_offload
and not device_memory.is_unified
):
policy = OFFLOAD_MODEL
reasons.append("explicit cpu_offload overrides resident placement")
# VAE savers cap the high-res decode spike. Slicing (one image at a time) is EXACT, so enable it on any offload tier. Tiling
# is only bit-identical for a single tile (<=1MP), so restrict it to the lowest tiers. Group offload keeps the VAE resident.
any_offload = policy != OFFLOAD_NONE or device_memory.backend in ("mps", "cpu")
tile = policy in (OFFLOAD_MODEL, OFFLOAD_SEQUENTIAL) or device_memory.backend in ("mps", "cpu")
return MemoryPlan(
requested_mode = mode,
offload_policy = policy,
vae_tiling = tile,
vae_slicing = any_offload,
device_memory = device_memory,
estimates = estimates,
reasons = tuple(reasons),
# Only ever meaningful under group offload; every other tier already places the encoders.
stream_text_encoders = stream_text_encoders and policy == OFFLOAD_GROUP,
)
# ── apply to a built pipeline ─────────────────────────────────────────────────
def _streamable_components(pipe: Any, torch: Any) -> dict[str, tuple[Any, str]]:
"""Component name -> (module, group-offload type) for what streaming keeps off the device.
Every denoiser streams block by block; every text encoder streams leaf by leaf. Anything else
(the VAE, an image encoder) has no granular hook here and stays resident, so this is also the
set ``refine_memory_plan_for_components`` is allowed to size the policy against."""
streamed: dict[str, tuple[Any, str]] = {}
for name in ("transformer", "transformer_2", "unconditional_transformer"):
module = getattr(pipe, name, None)
if isinstance(module, torch.nn.Module):
streamed[name] = (module, "block_level")
for name, module in getattr(pipe, "components", {}).items():
if str(name).startswith("text_encoder") and isinstance(module, torch.nn.Module):
streamed[str(name)] = (module, "leaf_level")
return streamed
def refine_memory_plan_for_components(pipe: Any, plan: MemoryPlan) -> MemoryPlan:
"""Replace whole-module offload when a loaded component cannot fit on the device.
The coarse planner runs before the pipeline exists. At this point the weights are still on
CPU, so their actual packed storage is a better signal than family or cache estimates. Keep
whole-module offload when every component can fit, preserving its faster execution. When one
cannot, use granular streaming so no forward needs to materialise that component in full.
Only a STREAMABLE component justifies the switch, and only if the components streaming cannot
hook still fit resident TOGETHER: whole-module offload onloads one at a time, streaming holds
all of them at once, so refining past either bound would trade one OOM for another.
"""
if plan.offload_policy != OFFLOAD_MODEL:
return plan
budget = plan.estimates.get("safe_device_budget_mib")
if budget is None or int(budget) <= 0:
return plan
try:
import torch
components = getattr(pipe, "components", {})
transformer = getattr(pipe, "transformer", None)
if not isinstance(components, dict) or not isinstance(transformer, torch.nn.Module):
return plan
streamable = _streamable_components(pipe, torch)
sizes: dict[str, int] = {}
mib = 1024 * 1024
for name, component in components.items():
if not isinstance(component, torch.nn.Module):
continue
seen: set[int] = set()
storage_bytes = 0
tensors = list(component.parameters(recurse = True)) + list(
component.buffers(recurse = True)
)
for tensor in tensors:
marker = id(tensor)
if marker in seen:
continue
seen.add(marker)
storage_bytes += int(tensor.numel()) * int(tensor.element_size())
sizes[str(name)] = (storage_bytes + mib - 1) // mib
except Exception: # noqa: BLE001 - runtime measurement is an optional refinement
return plan
streamed_sizes = {n: m for n, m in sizes.items() if n in streamable}
if not streamed_sizes:
return plan
largest_name, largest_mib = max(streamed_sizes.items(), key = lambda item: item[1])
if largest_mib <= int(budget):
return plan
# What streaming leaves resident, all at once. Over budget here means streaming OOMs too.
resident_mib = sum(m for n, m in sizes.items() if n not in streamable)
if resident_mib > int(budget):
return plan
estimates = dict(plan.estimates)
estimates["largest_component_mib"] = largest_mib
estimates["streaming_resident_mib"] = resident_mib
return replace(
plan,
offload_policy = OFFLOAD_STREAMING,
estimates = estimates,
reasons = plan.reasons
+ (
f"loaded {largest_name} is {largest_mib} MiB, above the {int(budget)} MiB "
"device budget; streaming transformer blocks and text-encoder layers",
),
)
def apply_memory_plan(
pipe: Any,
plan: MemoryPlan,
*,
device: str,
placement_device: Optional[str] = None,
logger: Any = None,
) -> tuple[str, bool]:
"""Apply ``plan`` to a built diffusers pipeline: enable the VAE savers then place / offload
the weights. Exactly one placement call runs (fully resident or wired for offload, never both).
Returns the ``(offload_policy, vae_tiling)`` ACTUALLY engaged, which can differ from the plan:
tiling is a no-op where there's no tiling control, and group / sequential offload fall back to
whole-module offload if unsupported (e.g. sequential is broken for GGUF through diffusers 0.39).
``placement_device`` is the INDEXED string when a card was selected ("cuda:1"), and is what
every diffusers handoff below receives. A bare "cuda" is not equivalent to the CPU-offload
APIs: ``enable_model_cpu_offload`` reads the index off the device and, finding none, falls
back to ``_offload_gpu_id = 0`` and onloads to cuda:0 (pipeline_utils.py, diffusers 0.39), so
the modules would page onto the very card the selection existed to avoid while generation ran
on another. ``device`` stays bare for anything reading it as a policy string."""
placement = placement_device or device
tiling_engaged = False
if plan.vae_tiling:
tiling_engaged = _enable_vae_saver(pipe, "enable_vae_tiling", "enable_tiling", logger)
if plan.vae_slicing:
_enable_vae_saver(pipe, "enable_vae_slicing", "enable_slicing", logger)
def _fallback_to_model_offload() -> None:
# The GROUP plan set vae_tiling=False (the VAE stays resident). Dropping to whole-module offload is the low-VRAM case where the decode spike can OOM, so turn tiling on now.
nonlocal tiling_engaged
pipe.enable_model_cpu_offload(device = placement)
if not tiling_engaged:
tiling_engaged = _enable_vae_saver(pipe, "enable_vae_tiling", "enable_tiling", logger)
policy = plan.offload_policy
if policy == OFFLOAD_MODEL:
pipe.enable_model_cpu_offload(device = placement)
elif policy == OFFLOAD_GROUP:
# getattr, not attribute access: manually built / duck-typed plans predate this field.
if not _apply_group_offload(
pipe,
placement,
logger,
stream_text_encoders = bool(getattr(plan, "stream_text_encoders", False)),
):
_fallback_to_model_offload()
policy = OFFLOAD_MODEL
elif policy == OFFLOAD_STREAMING:
_apply_streaming_offload(pipe, placement, logger)
elif policy == OFFLOAD_SEQUENTIAL:
try:
pipe.enable_sequential_cpu_offload(device = placement)
except Exception as exc: # noqa: BLE001 — keep the model loadable
if logger is not None:
logger.warning(
"diffusion.memory: sequential offload failed (%s); "
"falling back to whole-module offload",
exc,
)
_fallback_to_model_offload()
policy = OFFLOAD_MODEL
else:
pipe.to(placement)
return policy, tiling_engaged
def _enable_vae_saver(pipe: Any, pipe_method: str, vae_method: str, logger: Any) -> bool:
"""Turn on a VAE memory saver, trying the pipeline shortcut first then the VAE submodule
(some pipelines, e.g. Z-Image, only expose it on ``pipe.vae``). Returns whether it engaged."""
for owner, method in ((pipe, pipe_method), (getattr(pipe, "vae", None), vae_method)):
fn = getattr(owner, method, None)
if not callable(fn):
continue
try:
fn()
return True
except Exception as exc: # noqa: BLE001 — a VAE saver is an optimisation, never fatal
if logger is not None:
logger.warning("diffusion.memory: %s() failed: %s", method, exc)
return False
def _apply_group_offload(
pipe: Any,
device: str,
logger: Any,
*,
stream_text_encoders: bool = False,
) -> bool:
"""Stream the transformer a few blocks at a time via diffusers group offloading, keeping the
smaller components resident. Returns False (caller falls back to whole-module) on any failure.
``stream_text_encoders`` extends the streamed set to every ``text_encoder*`` module. Off by
default: keeping them resident is faster when there is room. The planner turns it on only
where it is the difference between group offload and whole-module offload."""
transformer = getattr(pipe, "transformer", None)
if transformer is None:
return False
installed = 0 # streamed modules that already carry group-offload hooks
try:
import inspect
import torch
from diffusers.hooks import apply_group_offloading
# A dual-DiT pipeline (Ideogram 4) carries a second denoiser as large as the first, so stream every DiT and keep only smaller companions resident.
streamed: dict[str, Any] = {"transformer": transformer}
for extra in ("transformer_2", "unconditional_transformer"):
module = getattr(pipe, extra, None)
if isinstance(module, torch.nn.Module):
streamed[extra] = module
# The text encoders are streamed SEPARATELY from the DiTs, and tolerantly (see the apply
# loop below). Kept in their own dict so the resident placement loop still skips them.
streamed_encoders: dict[str, Any] = {}
if stream_text_encoders:
# A text encoder runs ONCE, before step 0, so residency buys it nothing while it costs
# its bytes for every step of the denoise. Streaming it does two things: the resident
# loop below skips it (it is no longer placed with comp.to(onload)), and group hooks
# page it in for that single encode. Component names, not attributes, so a family with
# text_encoder / text_encoder_2 / text_encoder_3 is covered without a per-family list.
for name, comp in getattr(pipe, "components", {}).items():
if name.startswith("text_encoder") and isinstance(comp, torch.nn.Module):
streamed_encoders[name] = comp
onload = torch.device(device)
use_stream = onload.type == "cuda" # overlap H2D copies with compute
gkwargs: dict[str, Any] = {
"onload_device": onload,
"offload_device": torch.device("cpu"),
"offload_type": "block_level",
"num_blocks_per_group": DEFAULT_GROUP_BLOCKS,
"use_stream": use_stream,
}
# On the CUDA stream path, overlap each block's H2D copy with compute. Lossless, and gated on the signature so older diffusers still works.
_params = inspect.signature(apply_group_offloading).parameters
if use_stream:
if "non_blocking" in _params:
gkwargs["non_blocking"] = True
if "record_stream" in _params:
gkwargs["record_stream"] = True
if stream_text_encoders and "low_cpu_mem_usage" in _params:
# The streamed path PINS every offloaded parameter in host RAM when a copy stream is
# in use (diffusers group_offloading `_init_cpu_param_dict`), which is a fine trade
# when group offload was already the plan. It is not a fine trade here: this tier is
# only ever reached as a rescue from whole-module offload, which pins nothing, on a
# card small enough that the companions did not fit. Those hosts are not reliably
# RAM-rich either, and silently converting a device-memory shortfall into ten-plus GB
# of unswappable host RAM is how #8188's machine got into trouble in the first place.
# low_cpu_mem_usage trades a slower host-to-device copy for not pinning; the encoders
# this tier streams run ONCE per call, so that copy is paid once, not per step.
gkwargs["low_cpu_mem_usage"] = True
# Place the smaller components resident BEFORE attaching the transformer group-offload hooks: a companion .to() OOM then returns False with no hooks installed, and diffusers rejects enable_model_cpu_offload once group hooks exist.
for name, comp in getattr(pipe, "components", {}).items():
if name in streamed or name in streamed_encoders:
continue
if isinstance(comp, torch.nn.Module):
comp.to(onload)
for module in streamed.values():
apply_group_offloading(module, **gkwargs)
installed += 1
# The encoders come AFTER the DiTs and are applied one by one, each failure absorbed. A
# text encoder is a far less well-trodden target for block-level group offloading than a
# DiT (a family whose encoder exposes no recognisable block list can simply refuse), and
# this tier is a rescue: the alternative to streaming an encoder is keeping it resident,
# which is what happened before this tier existed. Letting one refusal join the all-or-
# nothing DiT loop would turn a slow-but-working load into a hard failure, because by then
# hooks are installed and whole-module offload can no longer be used as a fallback. So a
# refusal places that encoder resident instead: the plan's floor becomes optimistic by
# that encoder's bytes, and the load still runs.
# Leaf level, not the DiTs' block level: an encoder is not a stack of uniform blocks, so
# _streamable_components and _apply_streaming_offload already classify every text_encoder*
# that way. Reusing the transformer's kwargs here grouped the whole encoder as one unit,
# which is the residency the planner's floor was chosen to avoid -- the plan said leaf and
# the application said block. num_blocks_per_group goes with it: leaf level has no blocks.
ekwargs = {k: v for k, v in gkwargs.items() if k != "num_blocks_per_group"}
ekwargs["offload_type"] = "leaf_level"
for name, module in streamed_encoders.items():
try:
apply_group_offloading(module, **ekwargs)
installed += 1
except Exception as exc: # noqa: BLE001 -- degrade this encoder, never fail the load
if logger is not None:
logger.warning(
"diffusion.memory: group offload unavailable for %s (%s); "
"keeping it resident",
name,
exc,
)
module.to(onload)
return True
except Exception as exc: # noqa: BLE001 — fall back to whole-module offload
if installed:
# An earlier streamed module already has hooks but a later one failed: the pipe is in a PARTIAL group-offload state enable_model_cpu_offload rejects, so propagate the real failure instead of a misleading hook error.
if logger is not None:
logger.warning(