Repository navigation
[Diffusion] Add a permanent lifetime for layerwise resident layers - #40599
Conversation
`--layerwise-resident-layers` keeps a set of layers on the GPU while the component runs and releases it when the component finishes -- once per request, with a pinned host copy kept for the next request. That is the right shape when the resident sets of several components have to share one budget: only the running component holds its set, so the peak is the largest set rather than their sum. It is the wrong shape when the sum fits: the layers are transferred again on every request and pin host memory for a reload that could have been avoided. This adds the other lifetime. `--dit-layerwise-residency-lifetime permanent` (and the per-component `--layerwise-residency-lifetime transformer=permanent,...`) places the resident layers on the device at load, before any forward, keeps no host copy of them, and never releases them: one transfer for the life of the server. `forward`, the previous behaviour, stays the default, so nothing moves for an existing command. Manager: permanent layers are split out before the hosting plan, so they neither take a host store nor spend the pin budget; they are placed with a layout-keeping device copy (a transposed FP8 view has to stay transposed, as in the strided host store); residency is armed from construction, so the first streamed layer is the head of the stream; `release_layer` returns early for them regardless of `force`, which covers `release_all`, `enable_offload` after a `disable_offload` round trip, skip-gap and wraparound releases alike. `sync_layer_to_cpu` has nothing to sync and `iter_materialized_weights` reads them from the module, as it does any non-offloaded parameter. The readiness log names the lifetime. Server args: one group default plus one per-component map, mirroring the policy knob, validated in both the CLI (choices) and the Python API. The two resident-layer help strings now describe the default lifetime rather than the permanent one they used to promise, and point at the knob. Tests cover placement without a host store and with layout kept, every release path, the disable/enable round trip, prepare not touching the set, the pin plan excluding it, materialized-weight iteration, the no-op warning, rejection of unknown values, and the help text's claims. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The CLI reference's example puts `--layerwise-offload-components dit,...` next to `--layerwise-resident-layers transformer=4`, and a reader has no way to know that only the first accepts the group alias. `dit=4` in any of the per-component maps matches no component and is silently ignored; one sentence under the example says so. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
CI status note (from the author's triage, so reviewers need not re-derive it). Every red on this PR falls in one of three buckets, none of them from the diff:
|
`wan2_1_t2v_1.3b_encoder_permanent_residents`: the sibling 1.3B case with `--layerwise-resident-layers text_encoder=0.8 --layerwise-residency-lifetime text_encoder=permanent`. The encoder is already layerwise under the default component set, so the only difference from `wan2_1_t2v_1.3b` is that 80% of its layers are placed at load and never released. What the guard catches: the shape of TextEncodingStage. With the set on the device the stage is compute plus the remaining 20% of the transfer; if the layers ever go back to being transferred per request, the stage regains the whole ~9 GB and lands near the streamed sibling's 535 ms -- about 3x a permanent baseline, against a non_denoise_stage tolerance of 1.8x. load_peak_vram carries the placed set, so it also pins that the placement happens at load rather than on the first forward. The baseline is seeded by CI in the next run: a case without a scenario fails and logs the entry to add to perf_baselines/h100.json. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
Added a lightweight e2e guard for the permanent lifetime ( It is the existing The baseline is seeded the usual way: this run's |
The values CI printed for `wan2_1_t2v_1.3b_encoder_permanent_residents` on the H100 pool, unchanged except for the shape the sibling entries use (no `per_frame_generation: null`, no host-anon fields) and an `estimated_full_test_time_s` in the siblings' range for partitioning. What they say: TextEncodingStage 171.56 ms against the streamed sibling's 534.89 ms -- the resident set is on the device and not re-transferred; load_peak_vram 17792 MiB against the sibling's 7814 MiB -- it was placed at load. If the lifetime ever regresses to per-request transfer, the stage lands near 535 ms, 3.1x this baseline, against a non_denoise_stage tolerance of 1.8x. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
CI ran the new case with every performance metric on its seeded baseline (load_peak_vram 17792 MiB to the MiB, TextEncodingStage 171 ms) and failed it on the consistency check alone: there is no ground-truth image under the new case id. There should not need to be one. The lifetime moves weights between host and device; it does not change the math, so the output is the base case's, which `wan2_1_t2v_1.3b` already guards -- and which the PR's H200 runs showed bitwise identical across both lifetimes. Publishing a copy of that GT under a second name would turn a lightweight guard into a ci-data revision bump for the whole suite. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
CI note, B200 lane (
One rerun of that job once the run completes. The seeded guard case reproduced its baseline on the previous head ( |
Resolves the textual overlap with sgl-project#40593 (help strings, CLI reference, readiness log): the merged text keeps sgl-project#40593's user vocabulary and adds the lifetime axis on top, and the readiness log names the lifetime (`(forward)` / `(permanent)`) where sgl-project#40593 wrote `(per request)`. Brings in sgl-project#40590 and sgl-project#40592, which this PR composes with unchanged. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
sgl-project#40573 moved the RTX 5090 to the DiT-resident recipe on a measurement taken with the explicit form: --component-residency dit=resident text_encoder=layerwise-offload vae=resident --warmup-resolutions 1024x1024 and published the trimmed form, `--component-residency text_encoder=layerwise-offload`, which nothing had run. Under `--performance-mode manual` the auto-tuner does not fill anything in (`auto_tune.py`: `performance_mode != "manual"`), so whether the DiT and VAE stay resident without being named is a property of the defaults rather than of the tuner -- worth asking the card rather than reasoning about. Asked, on one RTX 5090 (1024px, 40 steps, seed 42, three interleaved rounds, both servers restarted each round, five measured requests after warmup): | | p50 per round | median | VRAM after load | |---|---|---|---| | explicit | 14.568 / 14.606 / 14.628 s | 14.606 s | 26566 MiB | | trimmed | 14.603 / 14.597 / 14.614 s | 14.603 s | 26564 MiB | Paired diffs +0.035 / -0.010 / -0.014 s disagree in sign, so there is no resolvable difference; both arms log the same placement (`resident=0/63`, encoder layerwise, DiT and VAE resident) and every request in both arms returns the same PNG, md5 b781b296. The trimmed form is the explicit one. So `unverified: true` comes off `rtx5090-1-offload`: the recipe the picker emits is the recipe that was measured. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The flag's own documentation explains the Sigma-versus-max trade in the abstract. This puts a number on it for the case a reader is most likely to try it in, because the number argues against reaching for it there. Qwen-Image-2.1 on one RTX 5090, 1024px / 40 steps / seed 42, three interleaved rounds of five measured requests, every server restarted, making the text encoder's resident set permanent: none (default) 13.53 s -- 25168 MiB 78% of the card 0.3 permanent 13.45 s -0.6% 29570 MiB 92% 0.5 permanent 13.41 s -0.8% 31480 MiB 98% 0.8 permanent 13.37 s -1.2% 32092 MiB 100% Every round reproduces to 0.1% and the ordering holds, so the win is real. It is also 0.6-1.2% in exchange for nearly all of the card's remaining headroom, on a machine that is also running a desktop. And it shrinks as the card gets faster: the same flag measured -2.8% on a slower box whose baseline was 14.14 s, because the saved per-request encoder transfer is a smaller share of a shorter request. Numerics stay in the usual floating-point reordering class: 0.5 is bit-identical to the default, 0.8 deviates by at most 1/255 with MAE 0.0111/255 and no pixel beyond one LSB -- the same class this repo already accepts for placement changes. So the cookbook now says where it earns its memory (one use, many passes, and the sum still fits) rather than leaving a reader to discover on their own card that 98% of load peak is where this lands them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Follows #40590 / #40592 / #40593 (all merged), which name and document the
lifetime the resident set has today; this one adds the other lifetime, which is
what the flag was designed to mean.
mainis merged in (a5e7f87), so the diffis exactly this change.
What the flag does today, and what it was meant to do
--layerwise-resident-layerskeeps a set of a component's layers on the GPUinstead of streaming them. Today that set lives for the component's run: it is
pinned by the layer-0 pre-hook when the component starts its forwards for a
request, released by
finish_use → release_all()when it finishes, and apinned host copy of every resident layer is kept for the next request.
finish_requestforcespreferred = Falsefor any module that holdsresidents, so nothing keeps the set past the request.
That is the right shape when several components' resident sets have to share
one budget: only the running component holds its set, so the peak is the
largest single set rather than their sum. It is the wrong shape when the sum
fits -- the layers are transferred again on every request, and host memory is
pinned for a reload that never had to happen. The original intent of the flag
was the second case: place the layers once, at load, keep no host copy, never
release them.
Both shapes are real, on adjacent consumer cards, for the same model and the
same flag value. Qwen-Image-2.1 with DiT+VAE resident (14.5 GiB) and the
text encoder's resident set at 0.8 (~9-11 GiB): the sum fits on an RTX 5090
(31.4 GiB) and keeping the set was measured faster (14.136 → 13.737 s); it does
not fit on an RTX 4090 (23.5 GiB, ~6.9 GiB free once DiT+VAE are resident), and
keeping it left the server unable to start. So the lifetime cannot be a
constant, and it is not the same knob as which layers are resident.
The change
A lifetime axis beside the existing policy axis, in the same two-flag shape:
forward(default, today's behaviour)permanentforwardstays the default, so no existing command changes behaviour. Whetherpermanentbecomes the default when the sum fits is a placement decision; itbelongs with the residency planner (#37917 already carries a
permanent_residencyfield onResidencyTargetand splits its option keysinto
permanent/stage), not in a constant here.Manager
layer_groupsbefore the hosting planruns, so they take no host store and spend none of the pin budget. They are
placed with a layout-keeping device copy -- a transposed FP8 view has to stay
transposed, the same reason the strided host store exists -- shared by the
CUDA and MPS load paths.
_head_of_streamstarts at thefirst streamed layer rather than layer 0.
release_layerreturns early for a permanent layer regardless offorce.That single check covers
release_all, thedisable_offload → enable_offloadround trip the compile-warmup and LoRA paths take, skip-gap and wraparound
releases alike.
sync_layer_to_cpuhas nothing to sync;iter_materialized_weightsreads them from the module as it does anynon-offloaded parameter.
prepare_for_next_reqno longer visits them.resident=53/66 (permanent), policy=leading, and placement logs what itput on the device:
placed 29 permanent resident layers (10.42 GiB) on the device at load; no host copy is kept.Server args and help
One group default plus one per-component map, validated in the CLI (
choices)and in the Python API (
ValueError), with the same "has no effect" warnings thepolicy knob gives. The two resident-layer help strings now describe the default
lifetime instead of the permanent one they used to promise, and point at the
new knob; the CLI reference and the deployment cookbook gain the lifetime and
the rule for choosing it (sum fits →
permanent; otherwiseforward).Wording is the user's, not the runtime's: "while the component runs for a
request", "released when it finishes", "for the life of the server". No
ComponentUsevocabulary on any user-facing surface.Validation
Unit tests, on an H200 devbox at this head (
2bb100d):test_layerwise_offload.py170 passed (158 onmain+ 12 new);test_server_args.py -k "lifetime or residency"21 passed + 13 subtests.The new tests pin: placement at load without a host store and with layout kept;
survival through
release_layer(force=True),release_all,sync_all_layers_to_cpu, and adisable_offload → enable_offloadround trip;prepare_for_next_reqnot touching the set (samedata_ptr, no prefetchissued); the hosting plan receiving only the streamed layers;
iter_materialized_weightsyielding every parameter exactly once; thezero-resident warning; rejection of unknown values in both the manager and
layerwise_tuning_for; the readiness log; and the help text's claims.End to end, Qwen-Image-2.1 on one H200, the 5090 recipe's shape
(
--component-residency dit=resident text_encoder=layerwise-offload vae=resident --layerwise-resident-layers text_encoder=0.8), only the lifetimeflag differing; 1024², 40 steps, seed 42,
generator_device=cpu; one warmrequest then five measured:
forwardpermanent[4.752 4.767 4.778 4.799 4.786][4.546 4.547 4.543 4.556 4.555]1db98827…1db98827…The −0.231 s is the per-request transfer it removes: 11.19 GiB at this card's
~48 GB/s host-to-device is ~0.23 s. The peak is unchanged because the DiT is
resident in both runs, so the text encoder's set sits on top of the same
footprint either way -- on a card where the sum fits,
permanentcosts nothingin peak.
nvidia-smicannot show theforwardrelease at all: the cachingallocator keeps the freed blocks reserved, which is why "VRAM after load" reads
the same in both modes (36437 vs 36473 MiB) and why host RSS, not
nvidia-smi,is the number that moves.
Run again in the opposite order (
permanentfirst, thenforward, each afresh server) to rule out order effects:
permanentp50 4.547 s[4.545 4.545 4.547 4.554 4.549],forwardp50 4.785 s[4.789 4.785 4.789 4.764 4.772], Δ −0.238 s (−5.0%); RSS 6.87 vs 18.12 GiB;the same md5 from all four servers. Across both orders every one of the ten
permanentsamples is below every one of the tenforwardsamples (max 4.556 svs min 4.752 s). The one thing order did move was the first request of the
first server on the box (6.9 s vs ~4.6-4.8 s everywhere else): a cold page cache
on a freshly downloaded checkpoint, not a lifetime effect, so it is not claimed
above.
CI guard
wan2_1_t2v_1.3b_encoder_permanent_residents(1 GPU): the existing 1.3B casewith
--layerwise-resident-layers text_encoder=0.8 --layerwise-residency-lifetime text_encoder=permanent. Baseline seeded by CI onthe H100 pool (
b2fefd5822):wan2_1_t2v_1.3bThe guard is the stage: with the set on the device the stage is compute plus the
streamed 20%; if the lifetime ever regresses to per-request transfer, the stage
lands near the sibling's 535 ms, 3.1× this baseline, against a
non_denoise_stagetolerance of 1.8×.load_peak_vramcarrying the set pinsthat placement happens at load. Removing the transfer from the measured stage
also makes this case less sensitive to runner disk speed than its
forwardsiblings.
CI's first run of the seeded case reproduced the baseline (
load_peak_vram17792 MiB to the MiB) and failed only on the consistency check: no ground truth
exists under the new case id. The case now sets
run_consistency_check=False(
bd8caa64d6): the lifetime moves weights, not math, the output is the base case's,wan2_1_t2v_1.3balready guards it, and the H200 runs above showed it bitwiseidentical across both lifetimes. A GT copy under a second name would turn this
into a ci-data revision bump for the whole suite.
Not in this PR
permanentwhen the sum fits ([diffusion] Plan component residency from calibrated warmup records (2/4) #37917 / [diffusion] Apply, validate and roll back calibrated residency after warmup (3/4) #37918).still pass through host memory once at load, as every layer does today.
_h2d_bytes_a_pin_would_saveranks components for the pin budget by wholemodule bytes; permanent layers never move, so it slightly over-ranks a
component that has them. A priority heuristic, not a correctness issue.
component=valuemap names a component the pipeline doesnot have. Today
--layerwise-resident-layers dit=4is silently ignored(the maps take
transformer, not the group alias); the CLI reference nowsays so, but a startup warning from the module-level configure, which sees
every component, would catch it at the source. Separate change.
Relationship to the merged PRs
#40590 (
release_after_use/release_allsplit) and #40592 (retain_resident_layers)change what
finish_usecalls; this PR puts its invariant one level lower, inrelease_layer, so it composes with both unchanged. #40593 rewrote the same twohelp strings and the readiness-log line; the merge keeps its user vocabulary and
adds the lifetime on top, and the log now names the lifetime (
(forward)/(permanent)) where #40593 wrote(per request).Churn: existing production files +220/−25 over 3 files (
layerwise_offload.py+122/−10,
server_args.py+91/−15,layerwise_offload_components.py+7); newproduction files 0; tests +308/−1; docs +10/−6.
🤖 Generated with Claude Code
CI States
Latest PR Test (Base): ✅ Run #35750453665
Latest PR Test (Extra): ✅ Run #35750453457
Latest PR Test (AMD ROCm 10): ❌ Run #35750453551