Repository navigation
fix(wan): keep system RAM within the model cache budget - #10192
Pfannkuchensack wants to merge 6 commits into
Conversation
Free the outgoing expert before the next one loads, so a swap no longer holds both. Stop fp32-kept tensors pinning whole fp32 shards in Diffusers folders. Read single files without mmap and reserve before the fold and cast.
| sd = load_file(str(model_path)) | ||
| # pread rather than mmap: with mmap every tensor is a slice of one file-wide mapping, so the | ||
| # source stays mapped through the fold and the cast until the last tensor is replaced. | ||
| sd = load_file(str(model_path), backend="pread") |
There was a problem hiding this comment.
[P1] Checkpoint loading now materializes the entire source before shrinking it.
load_file(..., backend="pread") eagerly allocates every tensor before FP32-to-BF16 conversion or removal of bundled VAE/text-encoder weights. On RAM-constrained Linux systems, previously reclaimable mapped source pages become anonymous allocations, so loading can exhaust RAM even when the resulting model fits. Cache eviction cannot solve an oversized source allocation. Filter keys before reading payloads, then read and convert tensors incrementally.
There was a problem hiding this comment.
Fixed in 5aa9b11. The single-file loader now settles everything from the header before it reads any payload: the prefix, the dropped VAE and text-encoder keys, the quantization checks, and the cache reservation at the size after the fold and the cast. Then it reads one tensor at a time through pread and folds or casts each one before reading the next. Dropped keys and scale metadata are never read. The int8 branch also reserves before its first read now.
On a 21 GB fp32 TI2V-5B single file with a bundled VAE (9.3 GB model), the peak working set is 10.3 GB, against 22.1 GB for the first version and 28.8 GB on main. Tests check that bundled keys are never read, that the reservation comes before the first read, and that each tensor is cast before the next one is read.
|
|
||
| # The weights are fp32 on disk and `_keep_in_fp32_modules` leaves some of them fp32, so those | ||
| # tensors would stay views into the mmap and each would pin its whole fp32 shard. | ||
| mmap_kwarg = {"disable_mmap": True} if issubclass(load_class, ModelMixin) else {} |
There was a problem hiding this comment.
[P2] Diffusers loading unnecessarily doubles whole-file allocations.
Blanket disable_mmap=True also affects supported unsharded BF16 folders, where the cited FP32 backing-storage problem does not apply. Diffusers holds the complete file bytes alongside deserialized tensors, while cache reservation covers only one file’s size. Previously working loads can now OOM or swap heavily. Use bounded reading or selectively detach problematic mapped tensors.
There was a problem hiding this comment.
Agreed, the blanket disable_mmap is gone (5aa9b11). Diffusers loads through mmap again, exactly as on main. Afterwards the loader copies only the tensors left on the mapping that are not bf16, which are the fp32 _keep_in_fp32_modules views that pinned the fp32 shards. A bf16 folder keeps its weights on the mapping: the unsharded 10 GB TI2V-5B folder peaks at 10.2 GB working set again, against 19.5 GB with disable_mmap. The fp32 5-shard folder now ends at 10.2 GB, where main stays at 28.4 GB.
One limit, also in the description: during from_pretrained the fp32 peak is still main's (28.7 GB here), because diffusers keeps every shard mapped until it returns and the copies run after that. Releasing them per shard would mean patching diffusers' loading loop, so I left that out of this PR.
# Conflicts: # invokeai/backend/model_manager/load/model_loaders/wan.py # tests/app/invocations/test_wan_expert_swapper.py # tests/backend/model_manager/load/test_wan_checkpoint_loader.py
Since invoke-ai#10185 a marked int8 file takes the int8 branch and stays int8, and an unmarked one is refused, so no int8 weight reaches the fold any more. The int8 branch reserves before its split and has its own reservation test.
… on mmap The single-file loader decides the key set, quantization and RAM reservation from the header, then reads one tensor at a time through pread and folds or casts it before the next, so bundled VAE and text-encoder weights are never read and the source is never held in full. The int8 branch reserves before its first read too. The Diffusers loader drops the blanket disable_mmap, so bf16 folders load as on main. After the load it copies only the tensors left on the mapping off the bf16 dtype, the fp32 _keep_in_fp32_modules views that pinned whole fp32 shards. Header reads now map F8_E8M0, so MXFP8 grids reach their refusal, and name any other dtype they cannot read.
…hides in-place writes The macOS runners keep the bytes a tensor was mapped with, so the probe cannot tell a view from a copy there and the fp32-folder check would pass without testing anything.
joshistoast
left a comment
There was a problem hiding this comment.
Overall, the expert-reference fix is small and sound, and the dense streaming approach and regression tests are strong. The documented fp32 Diffusers peak remains a limitation.
| # Everything up to the read -- the prefix, what is dropped, how the file is quantized, the reservation -- is | ||
| # decided on the header's `meta` tensors, so a bundled VAE or text encoder is never read and the cache makes | ||
| # room before the first payload arrives. | ||
| sd = safetensors_meta_state_dict(model_path) |
There was a problem hiding this comment.
[P2] Previously readable fp8 checkpoints now fail.
Unconditionally uses a header reader whose dtype table omits F8_E4M3FNUZ and F8_E5M2FNUZ. Both worked through the previous loader. Identification still accepts them through its fallback, so installation can succeed while generation fails. Real safetensors CPU probes reproduced this. Add the missing mappings and regression coverage.
There was a problem hiding this comment.
Fixed in ce8458f. SAFETENSORS_DTYPES now maps F8_E4M3FNUZ, F8_E5M2FNUZ and C64, which is everything safetensors.torch reads except F4. F4 is still refused by name, since its header shape counts packed elements. The new test test_every_dtype_safetensors_writes_reads_back_from_the_header writes every torch dtype save_file accepts and reads it back from the header. It cannot skip a dtype the table maps. Both FNUZ formats, plain and scaled, now load through the Wan single-file path bit-identical to load_file + cast/fold.
| reserve( | ||
| predict_int8_cast_size(sd, dtype, quantized, model=model, skip_patterns=skip_patterns) + extra_reserved_bytes | ||
| ) | ||
| if read is not None: |
There was a problem hiding this comment.
[P2] Mixed int8 checkpoints can exceed the RAM reservation.
Reads every tensor before casting, despite reserving only the final size. A real mixed fp32/int8 fixture reserved 4.21 MB but held 8.40 MB of payloads before conversion. Unlike the previous reclaimable mmap pages, these are anonymous allocations, increasing OOM risk. Cast dense tensors as they arrive; current int8 coverage uses float32 compute and misses this case.
There was a problem hiding this comment.
Fixed in ce8458f. The read loop now casts each dense tensor as it arrives; the marked layers' payload and scale stay as stored for the split. No local keeps the stored tensor alive past its cast either. On your kind of fixture (int8 + fp32 dense, bf16 compute) the peak during the read stays within the reservation. The new test test_a_header_read_never_holds_more_than_the_reservation runs at bf16 and also counts handed-out payloads still referenced outside the state dict. It fails on the old order (264 KB held vs 133 KB reserved). Through the real Wan loader the weights are bit-identical to before, with the same single make_room.
…ypes The int8 install read every header tensor before casting, so an fp32 file's dense tensors were held at twice the width the reservation charged. Each dense tensor is now cast as it arrives, and no local keeps the stored copy alive. The header dtype table lacked F8_E4M3FNUZ, F8_E5M2FNUZ and C64, which load_file reads, so such single files installed and then failed to load.
Summary
Wan 2.2 held far more system RAM than the model cache accounts for. Three separate causes, all in Wan code, each fixed here:
_ExpertSwapper.get()and by the denoise loop'stransformerlocal while the next expert loaded. The cache evicted it, but its tensors could not be freed, so both experts were resident during every swap. The default cache is about 1x VRAM, below two experts, so this hits default settings._keep_in_fp32_modulesleaves some tensors fp32. Those stay views of the mmap, and each pins its whole shard for the life of the model, while the cache only counts the bf16 size. Afterfrom_pretrained, the loader now copies only the tensors still on the mapping that are not bf16. bf16 tensors stay on the mapping as onmain, because they are the model itself, so bf16 folders load exactly as before.preadand folds or casts each one before the next. Dropped keys and scale metadata are never read, and no tensor stays a view of a file-wide mapping. ComfyUI int8 files reserve before their first read too.Peak and final RAM (working set), Windows, CPU-only loads, TI2V-5B:
mainChecksums match across all three versions in every case. The fp32 Diffusers peak equals
main's, see the known limits.From the first version, with the expert-swap fix (unchanged since):
max_cache_ram_gb: 16, real clipsRelated Issues / Discussions
Follow-up announced in #10185. Both PRs edit the cast block in
WanCheckpointModel._load_from_singlefile, so the second to merge needs a small rebase. The RAM figures inwan-2.2.mdxfrom #10185 describe the state before this PR and need updating once both are in.QA Instructions
Peak RAM above is process working set. Windows commit reads higher because VRAM counts toward it 1:1 (12 GB on the device added 12 GB of commit and no working set, with either CUDA allocator).
use_cache=false. All three seeds are bit-identical before and after (same SHA-256). 65 s per clip after, 68-72 s before.mainand the first version run fromgit archivetrees. The working set is read after touching every byte, as a device move would. The bundled-VAE single file was built locally from the TI2V-5B folder, because no real one was at hand.pread, reading from disk showed no penalty: 462 MiB/s against 431 MiB/s for mmap on a cold 19 GiB file.safe_openboundary:F8_E8M0gets its refusal by name.tests/backend/model_manager/load,tests/backend/model_manager/test_model_on_disk.py,tests/backend/model_manager/configs,tests/model_identificationandtests/backend/quantization: 3499 passed, 161 skipped. Wan loader and invocation tests: 411 passed, 1 skipped.uv lock --check,scripts/check_platform_locks.pyand Ruff are clean.Not run: Linux, macOS, ROCm, multi-GPU, the family E2E (Wan has no manifest yet). The 14B fp32 expert was not re-measured after the review round: its peak would not fit the free RAM of the test machine. No real scaled-fp8 Wan file was at hand, so the fold is covered by tests only.
Review
Fixed during review:
preadbefore it dropped or cast anything; it now works from the header and reads tensor by tensor.disable_mmapdoubled unsharded bf16 folders; it is gone, and only the tensors that pin a mapping are copied.F8_E8M0failed with a bareKeyErrorin the header read; it reaches its refusal by name again, and any other unreadable dtype is named.Known limits:
from_pretrainedreturns, so the fp32 copies are dropped only then. The peak ismain's: on Linux the extra pages are clean and reclaimable but count toward RSS and cgroup limits, and on Windows the mapping is charged to commit. For an A14B expert that is about 57 GB of shards plus about 28.5 GB of bf16, against a 57 GB reservation. Dropping them per shard would mean patching diffusers' loading loop.preadreads a tensor. A cold read stalls other threads for up to one tensor's read time: 1.9 s measured on a USB disk. This matters most with multiple GPUs.load_fileand cast, or use_keep_in_fp32_modules, are unchanged.Compatibility / Rollout
safetensors>=0.8.0is a new floor, becausebackend=does not exist before it.uv.lockwas already on 0.8.0, so only its metadata line changes. No migrations, no API change.Checklist
What's Newcopy (if doing a release after this PR)Open in [code]smith · Need help on this PR? Tag
@codesmith-botwith what you need.