Skip to content

Add sdxl to the residency contract, release NF4 scratch on deactivate - #62

Merged
TroyHernandez merged 3 commits into
mainfrom
feat/sdxl-resident
Aug 24, 2026
Merged

Add sdxl to the residency contract, release NF4 scratch on deactivate#62
TroyHernandez merged 3 commits into
mainfrom
feat/sdxl-resident

Conversation

@TroyHernandez

Copy link
Copy Markdown
Contributor

Makes sdxl the fifth resident family, and stops ltx leaking its NF4
dequantisation 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 check 0 errors / 0 warnings / 2 NOTEs, both of which are
the torch-fails-to-start artifact rather than code findings.

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 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 before
decode -- its "phase cleanup" is gc() plus cuda_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. 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() 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 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 .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.

The NF4 scratch leak

resident_deactivate() now releases the dequantisation buffers for ltx.
They live in a package-level environment rather than in the module, so
offloading the weights did not free them, and txt2vid_ltx2() deliberately
skips 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 on release, since a
caller passing release = FALSE to keep the pool warm for the next tenant
is exactly the one that must not be handed a budget short by this scratch.

Notes for review

  • resident_generate() returns list(image, metadata) for sdxl, unlike
    the 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_components is a general mechanism, not an SDXL special case: NULL
    means the whole set, and a roomier card could be given the decoder too
    without touching the placement logic.

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 TroyHernandez left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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 TroyHernandez left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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, flux2 and zimage, a video array for ltx, and for sdxl a list of image and metadata, because txt2img_sdxl has 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 return list(image, metadata); ltx returns 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.

@TroyHernandez
TroyHernandez merged commit 810f85c into main Aug 24, 2026
2 of 4 checks passed
@TroyHernandez
TroyHernandez deleted the feat/sdxl-resident branch August 24, 2026 16:15
TroyHernandez added a commit that referenced this pull request Aug 24, 2026
# Conflicts:
#	DESCRIPTION
#	NAMESPACE
#	NEWS.md
#	R/resident.R
#	inst/tinytest/test_resident_sdxl.R
#	man/resident_load.Rd
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant