Add sdxl to the residency contract, release NF4 scratch on deactivate - #62
Conversation
sdxl becomes the fifth resident family, and ltx stops leaking its NF4 dequantisation scratch when a handle deactivates. The staging layer needed nothing: R/staging.R walks any nn_module's $parameters/$buffers, so SDXL's four components pin, onload and offload through the same path the other families use. Verified end to end on an RTX 5060 Ti -- load 40.7 s (8.013 GB pinned), activate 2.51 s, a real 1024x1024 image, and the same seed reproducing bit-for-bit after a full GPU round trip. Four things did need doing: * Only the UNet goes to the card. Bulk-onloading all four components fits the 8.0 GB of weights and then OOMs in the VAE decode, which runs 1024x1024 in float32 while the UNet is still resident: measured 14.38 GiB of a 15.47 GiB card, dying on a further 512 MiB request. txt2img_sdxl cannot evict the UNet before decode (its "phase cleanup" is gc plus empty_cache, which cannot move a live module), so the pipeline declares gpu_components = "unet" and the text encode and decode run on the host from the same pinned copies. The fit check is charged for what actually travels, not for the whole pinned set. * The UNet dtype is fixed at load. sdxl_pipeline_from_safetensors derives it from the component device, so loading to CPU for pinning would page-lock a float32 UNet and then render in float32. A resident handle has to decide from where it will compute, not from where the weights are parked. * resident_generate states the placement. txt2img_sdxl ignores the pipeline's actual devices and, with its default devices = "auto", calls auto_devices() afresh; on a 12 GB card that answers "unet cuda, encoders cpu" and the text encoder call then dies on a device mismatch. An explicit devices= from the caller still wins. * A bulk activation pre-warms the allocator. A cold onload grows the pool one cudaMalloc per tensor and the syscalls dominate: 24.16 s against 0.32 s warm, a 74x ratio. Same technique the NF4 LTX loader already uses. It is not only wall clock -- a broker with a startup deadline reads 24 s inside a first activate as a wedged worker. Two fixes fell out of making that work, both of which affected callers beyond residency: * setup_dtype() rejected an ordinal-qualified device. resident_load binds an explicit "cuda:N" so transitions cannot drift, and "cuda:0" matched neither branch and hit "Invalid device". * txt2img_sdxl demanded TorchScript .pt files it never opens. models2devices ends by verifying them, which is right when it is about to load them and wrong when the caller already holds a native safetensors pipeline; download_models = FALSE does not avoid the check. Callers with a pipeline now take .devices_for_pipeline() and skip it. Separately, resident_deactivate now releases the NF4 dequantisation buffers for ltx. Those live in a package-level environment rather than in the module, so offloading the weights does not free them, and txt2vid_ltx2 deliberately skips its own release while the transformer is resident -- correct within a render, and it leaves the scratch on the card once the render ends. Nothing else could reclaim it: the environment still holds a reference, so gc() and cuda_empty_cache() cannot. Unconditional on `release`, since a broker passing release = FALSE to keep the pool warm for the next tenant is exactly the caller that must not be handed a budget short by this scratch.
TroyHernandez
left a comment
There was a problem hiding this comment.
Read this as the consumer of the residency contract. The design holds up and the two findings buried in it are worth more than the feature: that a family's on-card subset is not its pinned set, and that a cold bulk onload is dominated by allocator syscalls rather than by PCIe.
Four things, one of which I'd like changed before merge.
1. .resident_gen_args restates a shape that lives elsewhere
args$devices <- list(unet = place("unet"), decoder = place("decoder"),
text_encoder = place("text_encoder"),
text_encoder2 = place("text_encoder2"))Those four names also appear in sdxl_load_pipeline's devices argument, and the authoritative list is names(res$staging) — which .resident_gpu_set two functions up already derives correctly. If the pipeline ever gains or renames a component, .resident_gpu_set follows it and this does not, and the result is a devices list silently missing a key rather than an error.
Deriving costs nothing and cannot drift:
nms <- names(res$staging)
args$devices <- stats::setNames(lapply(nms, place), nms)The rest of the function is right, including preferring an explicit caller devices — filling a gap rather than overriding is the correct precedence.
2. The pre-warm participates in the number a caller budgets against
.resident_prewarm reserves bytes * 1.05 in one block before the transfers. That block is in the pool when the onload happens, so it lands in reserved.peak — and where activations are modest it will set it. So a peak measured on this code path is max(1.05 * onloaded, onloaded + activations), and for the family this was written for the first term may well win.
That is the conservative direction and I am not asking for a change. But it means the figure describes the implementation as much as the model, and anyone deriving a budget from cuda_memory_stats() should know the 5% is by construction rather than measured demand. Worth a sentence in the roxygen next to the existing 74x note.
3. Does the one-block pre-warm still help on a repeat activation?
Cold, the argument is clear. Warm, the pool already holds the previous activation's blocks in per-tensor sizes, and a single 1.05 * need request may not be satisfiable from that cache even though the total is. If it triggers a fresh cudaMalloc, a repeat activation grows reserved beyond what it needs, which is the opposite of the intent.
Your 0.32 s second-activation figure predates the pre-warm, so it does not answer this. Two consecutive activate/deactivate cycles with release = FALSE, reporting reserved after each, would.
4. Host-side decode: what did it cost?
gpu_components = "unet" moves the encode and the fp32 1024x1024 decode to the host. Activation is reported at 2.51 s, but generation is not. A caller with a per-request deadline cares about the total, and a host-side fp32 VAE decode is not obviously cheap. If you have the number from the run that produced the verified image, it belongs in the NEWS entry.
Endorsed as written
The unconditional scratch release. release = FALSE exists for a caller keeping the pool warm for the next tenant, and that is exactly the caller who must not be handed a budget short by a package-level environment nothing else can reach. Gating it on release would have made the flag mean two unrelated things.
Documenting the list(image, metadata) return rather than normalising it. Changing a long-standing return shape to make one new consumer tidier is the wrong trade, and a consumer wanting one shape can unwrap in its own wrapper. Your note that a permissive output assertion would accept either shape is the useful half — that is a check that cannot fail, and I have tightened mine.
TroyHernandez
left a comment
There was a problem hiding this comment.
Addendum, and this one is a factual correction to the new roxygen rather than a preference.
resident_generate's updated @return says:
an image array for
flux1,flux2andzimage, a video array forltx, and forsdxla list ofimageandmetadata, becausetxt2img_sdxlhas always returned that pair
The three image families already return that pair. From their own roxygen on main:
txt2img_flux #' @return Invisibly, list(image, metadata) where image is an [H, W, 3] array in [0, 1].
txt2img_flux2 #' @return Invisibly, list(image, metadata) where image is an [H, W, 3] array in [0, 1].
txt2img_zimage #' @return Invisibly, list(image, metadata) where image is an [H, W, 3] array in [0, 1].
Confirmed at runtime too — a flux2 generate through resident_generate returns a list whose names are image metadata, with image at 1024x1024x3.
So sdxl is not the odd one out; it agrees with all three. The decision not to normalise is still right, but the reason inverts: there is nothing to normalise, rather than a divergence being tolerated for back-compat.
That is worth fixing rather than leaving, because the doc as written tells a consumer to write a special case for sdxl and treat the other three as bare arrays — which is precisely backwards and fails on first contact with flux2. Suggested:
The image families (
flux1,flux2,zimage,sdxl) all returnlist(image, metadata);ltxreturns a video array. Callers wanting a bare array unwrap$image.
ltx I have not verified against this claim; its @return documents latent geometry among other things and its actual return carries latents, audio_latents, latent_shape, sample_rate, video and audio, which is not a bare video array either. Worth a second look before that half of the sentence ships.
Unrelated, and thank you for the flag: confirmed on this machine that safetensors 0.2.1 in the user library shadows 0.3.0 in site-library, and 0.2.1 is what packageVersion() resolves. Anyone measuring large-checkpoint behaviour here is measuring the old one.
# Conflicts: # DESCRIPTION # NAMESPACE # NEWS.md # R/resident.R # inst/tinytest/test_resident_sdxl.R # man/resident_load.Rd
Makes
sdxlthe fifth resident family, and stopsltxleaking its NF4dequantisation scratch when a handle deactivates.
Verified end to end on an RTX 5060 Ti: load 40.7 s (8.013 GB pinned),
activate 2.51 s, a real 1024x1024 image, and the same seed reproducing
bit-for-bit after a full GPU round trip. Full suite 1171 assertions, 0
failures;
R CMD check0 errors / 0 warnings / 2 NOTEs, both of which arethe torch-fails-to-start artifact rather than code findings.
The staging layer needed nothing
R/staging.Rwalks anynn_module's$parameters/$buffers, so SDXL'sfour components pin, onload and offload through the same path the other
families already use. Proven rather than assumed: 8/8 structural checks in
test_resident_sdxl.R, plus the round trip above.Four things did need doing
Only the UNet goes to the card. Onloading all four fits the 8.0 GB of
weights and then OOMs in the VAE decode, which runs 1024x1024 in float32
while the UNet is still resident: measured 14.38 GiB of a 15.47 GiB card,
dying on a further 512 MiB.
txt2img_sdxl()cannot evict the UNet beforedecode -- its "phase cleanup" is
gc()pluscuda_empty_cache(), whichcannot move a live module -- so the pipeline declares
gpu_components = "unet"and the text encode and decode run on the hostfrom the same pinned copies. The fit check is charged for what actually
travels, not for the whole pinned set. On the 12 GB cards this wrapper
exists for, only the UNet was ever going to fit.
The UNet dtype is fixed at load.
sdxl_pipeline_from_safetensors()derives it from the component device, so loading to CPU for pinning would
page-lock a float32 UNet and then render in float32. A resident handle has
to decide from where it will compute, not from where the weights are
parked.
resident_generate()states the placement.txt2img_sdxl()ignoresthe pipeline's actual devices and, with its default
devices = "auto",calls
auto_devices()afresh; on a 12 GB card that answers "unet cuda,encoders cpu" and the text encoder call then dies on a device mismatch. An
explicit
devices=from the caller still wins.A bulk activation pre-warms the allocator. A cold onload grows the pool
one
cudaMallocper tensor and the syscalls dominate: 24.16 s against0.32 s warm, a 74x ratio on an idle card. Same technique the NF4 LTX loader
already uses. It is not only wall clock -- a broker with a startup deadline
reads 24 s inside a first activate as a wedged worker.
Two fixes that fell out, affecting callers beyond residency
setup_dtype()rejected an ordinal-qualified device.resident_load()binds an explicit
"cuda:N"so transitions cannot drift, and"cuda:0"matched neither branch and hit "Invalid device".
txt2img_sdxl()demanded TorchScript.ptfiles it never opens.models2devices()ends by verifying them, which is right when it isabout to load them and wrong when the caller already holds a native
safetensors pipeline;
download_models = FALSEdoes not avoid the check.Callers with a pipeline now take
.devices_for_pipeline()and skip it.The NF4 scratch leak
resident_deactivate()now releases the dequantisation buffers forltx.They live in a package-level environment rather than in the module, so
offloading the weights did not free them, and
txt2vid_ltx2()deliberatelyskips its own release while the transformer is resident -- correct within a
render, and it left the scratch on the card once the render ended. Nothing
else could reclaim it: the environment still held a reference, so
gc()and
cuda_empty_cache()could not. Unconditional onrelease, since acaller passing
release = FALSEto keep the pool warm for the next tenantis exactly the one that must not be handed a budget short by this scratch.
Notes for review
resident_generate()returnslist(image, metadata)forsdxl, unlikethe bare array the other image families return, because
txt2img_sdxl()has always returned that pair and changing it would break every existing
caller. Documented rather than normalised.
gpu_componentsis a general mechanism, not an SDXL special case:NULLmeans the whole set, and a roomier card could be given the decoder too
without touching the placement logic.