Skip to content

Commit 54a9141

Browse files
leszkoclaude
andcommitted
fix(vram): reclaim idle pool after session teardown + offload upload encoder
Session start/close cycles left the pod sitting on the session's transient allocation peak: measured driver-level, ~3.6-4.7 GB idle after a plain 60s session and ~6 GB after one that swapped to the 240s decoder profile, on a server whose true idle floor is ~1 GB. No objects leak — teardown frees its last tensors asynchronously (recv thread joined with timeout, polygraphy/TRT finalizer chains), AFTER every in-band empty_cache() has already run, so the freed blocks stay reserved in PyTorch's caching pool indefinitely. That pool is invisible to TensorRT's cudaMalloc workspaces, so it eats exactly the headroom the next session's engine loads and swap-time stem extraction need — the slow 'OOM over time' pattern on fleet pods. - ws_adapter: idle VRAM janitor — when no session is registered and the pool holds >512 MiB of freed blocks, gc.collect() + empty_cache(). Idle-only by construction; never competes with the realtime loop. Cycle-tested: idle returns to ~1 GB after every session (was 3.6-6 GB). - ws_adapter: final trim in handle_client after the body frame (the last session-ref holder) is gone. - ws_adapter: the upload-encoder Session now offloads to CPU (offload_to_cpu + offload_dit_to_cpu). It previously pinned a full eager DiT+VAE+text-encoder copy (~6 GB) on the GPU permanently from the first upload onward, next to the streaming TRT engines. prepare_source hops weights per call; uploads measured 14-22s and settle at ~1.4 GB. - Session: expose offload_dit_to_cpu (passthrough to ModelContext); without it offload_to_cpu lets the DiT go GPU-resident on first use. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 1200019 commit 54a9141

3 files changed

Lines changed: 125 additions & 9 deletions

File tree

acestep/engine/session.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,7 @@ def __init__(
160160
vae_backend: str = "eager",
161161
use_flash_attention: bool = True,
162162
offload_to_cpu: bool = False,
163+
offload_dit_to_cpu: bool = False,
163164
offload_text_encoder: bool = False,
164165
quantization: Optional[str] = None,
165166
trt_engines: Optional[dict[str, str]] = None,
@@ -181,6 +182,13 @@ def __init__(
181182
trt_engines: Engine paths, used iff a *_backend is "tensorrt".
182183
Keys: "decoder", "vae_encode", "vae_decode". Use
183184
acestep.paths.default_trt_engines() for the canonical paths.
185+
offload_to_cpu: Build every model on CPU and hop weights to
186+
the GPU per call. NOTE: without ``offload_dit_to_cpu``
187+
the DiT becomes GPU-resident on its FIRST use and stays
188+
(see ``ModelContext._load_model_context``); set both for
189+
a session whose steady-state VRAM must stay ~0.
190+
offload_dit_to_cpu: Move the DiT back to CPU after each use
191+
instead of letting it go resident on first touch.
184192
offload_text_encoder: Override text encoder placement policy.
185193
Defaults to ``False`` so prompt edits do not pay CPU/GPU
186194
transfer cost. Set ``True`` for lower steady VRAM usage.
@@ -249,6 +257,7 @@ def _discover_loras_safely():
249257
device=device,
250258
use_flash_attention=use_flash_attention,
251259
offload_to_cpu=offload_to_cpu,
260+
offload_dit_to_cpu=offload_dit_to_cpu,
252261
offload_text_encoder=offload_text_encoder,
253262
quantization=quantization,
254263
**ctx_flags,

demos/realtime_motion_graph_web/server.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -590,7 +590,13 @@ def main():
590590
# Defer the heavy import until we know we need it. Pulling this in
591591
# loads torch + acestep + TRT machinery; in --no-backend we never
592592
# touch any of it.
593-
from .ws_adapter import handle_client
593+
from .ws_adapter import handle_client, start_idle_vram_janitor
594+
595+
# Session teardown frees its last GPU tensors asynchronously;
596+
# the janitor returns the caching allocator's freed pool to the
597+
# driver whenever the pod sits idle (see its docstring for the
598+
# measured numbers).
599+
start_idle_vram_janitor()
594600

595601
def ws_handler(ws):
596602
handle_client(

demos/realtime_motion_graph_web/ws_adapter.py

Lines changed: 109 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,77 @@
9898
from .protocol import COMMAND_NAMES, SAMPLE_RATE, coerce_command_payload
9999

100100

101+
# ---------------------------------------------------------------------------
102+
# Idle VRAM janitor
103+
# ---------------------------------------------------------------------------
104+
105+
_JANITOR_STARTED = False
106+
_JANITOR_LOCK = threading.Lock()
107+
108+
109+
def start_idle_vram_janitor(
110+
*,
111+
interval_s: float = 5.0,
112+
min_trim_bytes: int = 512 * 2**20,
113+
) -> None:
114+
"""Trim the CUDA caching allocator while no session is live.
115+
116+
Session teardown frees its last tensors asynchronously: the recv
117+
thread is joined with a timeout, and polygraphy/TRT finalizer
118+
chains can release buffers seconds after the connection handler
119+
returned — after every in-band ``empty_cache()`` call has already
120+
run. Those late frees land in PyTorch's caching pool and stay
121+
reserved against the driver for as long as the pod idles
122+
(measured: ~3 GB after a plain 60 s session, ~5 GB after one that
123+
swapped to the 240 s decoder profile). That reserved-but-unused
124+
pool is invisible to TensorRT, whose workspace comes from
125+
cudaMalloc, so it directly eats the headroom the next session's
126+
engine loads and stem extraction need.
127+
128+
A point-in-time trim can't win that race, so this janitor owns it:
129+
every ``interval_s`` it checks that no session is registered and,
130+
when the pool holds more than ``min_trim_bytes`` of freed blocks,
131+
runs ``gc.collect()`` + ``torch.cuda.empty_cache()``. Idle-only by
132+
construction — it never competes with a live session's allocator.
133+
"""
134+
global _JANITOR_STARTED
135+
with _JANITOR_LOCK:
136+
if _JANITOR_STARTED:
137+
return
138+
_JANITOR_STARTED = True
139+
140+
def _loop() -> None:
141+
import gc
142+
143+
while True:
144+
time.sleep(interval_s)
145+
try:
146+
if session_registry.list_handles():
147+
continue
148+
if not torch.cuda.is_available():
149+
continue
150+
reserved = torch.cuda.memory_reserved()
151+
allocated = torch.cuda.memory_allocated()
152+
if reserved - allocated < min_trim_bytes:
153+
continue
154+
gc.collect()
155+
torch.cuda.empty_cache()
156+
freed = reserved - torch.cuda.memory_reserved()
157+
logger.info(
158+
"idle_vram_trim freed_mib={:.0f} reserved_mib={:.0f} "
159+
"allocated_mib={:.0f}",
160+
freed / 2**20,
161+
torch.cuda.memory_reserved() / 2**20,
162+
torch.cuda.memory_allocated() / 2**20,
163+
)
164+
except Exception as exc: # never kill the janitor
165+
logger.warning("idle_vram_trim_failed error={}", exc)
166+
167+
threading.Thread(
168+
target=_loop, name="idle-vram-janitor", daemon=True,
169+
).start()
170+
171+
101172
# ---------------------------------------------------------------------------
102173
# Canonical user-upload packet processing
103174
# ---------------------------------------------------------------------------
@@ -122,11 +193,22 @@ def _upload_encoder_session(checkpoint: str) -> Session:
122193
session = _UPLOAD_ENCODERS.get(checkpoint)
123194
if session is None:
124195
logger.info("upload_encoder_load_start checkpoint={}", checkpoint)
196+
# offload_to_cpu: this Session lives for the process and only
197+
# serves prepare_source (VAE encode + semantic extract) for
198+
# uploads — both paths hop weights to the GPU per call via
199+
# _load_model_context and hop them back after. Keeping the
200+
# weights CPU-resident turns what used to be a PERMANENT
201+
# ~7 GB GPU copy (full eager DiT + VAE + text encoder, pinned
202+
# from the first upload onward, next to the streaming
203+
# session's TRT engines) into a few seconds of H2D/D2H per
204+
# upload.
125205
session = Session(
126206
project_root=str(checkpoints_dir()),
127207
config_path=checkpoint,
128208
decoder_backend="eager",
129209
vae_backend="eager",
210+
offload_to_cpu=True,
211+
offload_dit_to_cpu=True,
130212
)
131213
_UPLOAD_ENCODERS[checkpoint] = session
132214
logger.info("upload_encoder_loaded checkpoint={}", checkpoint)
@@ -278,14 +360,33 @@ def handle_client(
278360
this wrapper exists only to own a single ``ExitStack`` so the
279361
contextvar tokens bound for session / track unwind in reverse
280362
order on every exit path."""
281-
with contextlib.ExitStack() as ctx_stack:
282-
_handle_client_body(
283-
ws, ctx_stack,
284-
decoder_backend=decoder_backend,
285-
vae_backend=vae_backend,
286-
checkpoint=checkpoint,
287-
offload_text_encoder=offload_text_encoder,
288-
)
363+
try:
364+
with contextlib.ExitStack() as ctx_stack:
365+
_handle_client_body(
366+
ws, ctx_stack,
367+
decoder_backend=decoder_backend,
368+
vae_backend=vae_backend,
369+
checkpoint=checkpoint,
370+
offload_text_encoder=offload_text_encoder,
371+
)
372+
finally:
373+
# Final allocator trim, AFTER the body frame (the last holder of
374+
# the session, its state, codec, and recv-thread closures) is
375+
# gone. ``Session.close()`` runs its own gc + empty_cache, but at
376+
# that point this connection still references those objects, so
377+
# their pool blocks are live and the trim can't return them.
378+
# Once the body returns they are garbage — without this trim the
379+
# caching allocator keeps the session's transient peak reserved
380+
# (~3 GB after a 60s session, ~6 GB after a 240s-profile one,
381+
# measured driver-level) for as long as the pod idles, which is
382+
# exactly the headroom the next session's engine loads and stem
383+
# extraction need. Reserved-but-unallocated VRAM also can't be
384+
# used by TensorRT, whose workspace comes from cudaMalloc.
385+
import gc
386+
387+
gc.collect()
388+
if torch.cuda.is_available():
389+
torch.cuda.empty_cache()
289390

290391

291392
def _handle_client_body(

0 commit comments

Comments
 (0)