Skip to content

Commit eee0a9b

Browse files
committed
Add live stream memory cleanup hook
1 parent 50a742c commit eee0a9b

7 files changed

Lines changed: 318 additions & 60 deletions

File tree

live/streamdiffusion/pipeline/pipeline.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
import logging
33
import asyncio
44
import base64
5+
import ctypes
6+
import gc
57
import re
68
from pathlib import Path
79
from typing import Dict, List, Optional, Any, cast
@@ -30,6 +32,10 @@
3032
ENGINES_DIR = Path("./engines")
3133
# this one is used only for realesrgan_trt which has ./models hardcoded
3234
LOCAL_MODELS_DIR = Path("./models")
35+
STREAM_CLEANUP_GC_ENV = "LIVE_PIPELINE_STREAM_CLEANUP_GC"
36+
STREAM_CLEANUP_MALLOC_TRIM_ENV = "LIVE_PIPELINE_STREAM_CLEANUP_MALLOC_TRIM"
37+
STREAM_CLEANUP_CUDA_CACHE_ENV = "LIVE_PIPELINE_STREAM_CLEANUP_CUDA_CACHE"
38+
STREAM_CLEANUP_STYLE_CACHE_ENV = "LIVE_PIPELINE_STREAM_CLEANUP_STYLE_CACHE"
3339

3440
class StreamDiffusion(Pipeline):
3541
def __init__(self):
@@ -280,13 +286,51 @@ async def stop(self):
280286
self.params = None
281287
self.frame_queue = asyncio.Queue()
282288

289+
async def cleanup_stream(self):
290+
async with self._pipeline_lock:
291+
_clear_asyncio_queue(self.frame_queue)
292+
if _env_enabled(STREAM_CLEANUP_STYLE_CACHE_ENV, default=False):
293+
self._cached_style_image_tensor = None
294+
self._cached_style_image_url = None
295+
296+
if _env_enabled(STREAM_CLEANUP_GC_ENV, default=True):
297+
await asyncio.to_thread(gc.collect)
298+
if _env_enabled(STREAM_CLEANUP_MALLOC_TRIM_ENV, default=True):
299+
await asyncio.to_thread(_malloc_trim)
300+
if _env_enabled(STREAM_CLEANUP_CUDA_CACHE_ENV, default=False) and torch.cuda.is_available():
301+
torch.cuda.empty_cache()
302+
logging.info("StreamDiffusion stream cleanup complete")
303+
283304
@classmethod
284305
def prepare_models(cls):
285306
from .prepare import prepare_streamdiffusion_models
286307

287308
prepare_streamdiffusion_models()
288309

289310

311+
def _env_enabled(name: str, *, default: bool) -> bool:
312+
raw = os.getenv(name)
313+
if raw is None:
314+
return default
315+
return raw.strip().lower() in {"1", "true", "yes", "on"}
316+
317+
318+
def _malloc_trim() -> None:
319+
try:
320+
libc = ctypes.CDLL("libc.so.6")
321+
libc.malloc_trim(0)
322+
except Exception:
323+
logging.debug("malloc_trim unavailable", exc_info=True)
324+
325+
326+
def _clear_asyncio_queue(queue: asyncio.Queue) -> None:
327+
while True:
328+
try:
329+
queue.get_nowait()
330+
except asyncio.QueueEmpty:
331+
return
332+
333+
290334
def _prepare_controlnet_configs(params: StreamDiffusionParams) -> Optional[List[Dict[str, Any]]]:
291335
"""Prepare ControlNet configurations for wrapper"""
292336
if not params.controlnets:

runner/src/runner/live/pipelines/interface.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,15 @@ async def stop(self):
108108
"""
109109
pass
110110

111+
async def cleanup_stream(self):
112+
"""Release per-stream state while keeping the warmed pipeline alive.
113+
114+
Called after a stream goes offline. Implementations should clear
115+
frame queues, per-stream caches, and allocator state that does not need
116+
to survive into the next stream, without unloading model weights.
117+
"""
118+
pass
119+
111120
@classmethod
112121
@abstractmethod
113122
def prepare_models(cls):

runner/src/runner/live/process/process.py

Lines changed: 51 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@
2828

2929
from .loading_overlay import LoadingOverlayRenderer
3030

31+
STREAM_CLEANUP_CONTROL_KEY = "__stream_cleanup__"
32+
3133

3234
class PipelineProcess:
3335
@staticmethod
@@ -139,20 +141,28 @@ def _set_pipeline_ready(self, ready: bool):
139141
def update_params(self, params: dict):
140142
self.param_update_queue.put(params)
141143

142-
def reset_stream(self, request_id: str, manifest_id: str, stream_id: str):
144+
def reset_stream(
145+
self,
146+
request_id: str,
147+
manifest_id: str,
148+
stream_id: str,
149+
*,
150+
cleanup_stream: bool = False,
151+
):
143152
# Clear queues to avoid using frames from previous sessions
144153
clear_queue(self.input_queue)
145154
clear_queue(self.output_queue)
146155
clear_queue(self.param_update_queue)
147156
clear_queue(self.error_queue)
148157
clear_queue(self.log_queue)
149-
self.param_update_queue.put(
150-
{
151-
"request_id": request_id,
152-
"manifest_id": manifest_id,
153-
"stream_id": stream_id,
154-
}
155-
)
158+
message = {
159+
"request_id": request_id,
160+
"manifest_id": manifest_id,
161+
"stream_id": stream_id,
162+
}
163+
if cleanup_stream:
164+
message[STREAM_CLEANUP_CONTROL_KEY] = True
165+
self.param_update_queue.put(message)
156166

157167
# TODO: Once audio is implemented, combined send_input with input_loop
158168
# We don't need additional queueing as comfystream already maintains a queue
@@ -330,7 +340,7 @@ async def _param_update_loop(self, pipeline: Pipeline, overlay: LoadingOverlayRe
330340
while not self.is_done():
331341
reload_task = None
332342
try:
333-
params = await self._get_latest_params(timeout=0.1)
343+
params = await self._get_latest_params(timeout=0.1, pipeline=pipeline, overlay=overlay)
334344
if params is None:
335345
continue
336346

@@ -382,7 +392,12 @@ async def _param_update_loop(self, pipeline: Pipeline, overlay: LoadingOverlayRe
382392
except Exception:
383393
logging.warning("Failed to prewarm loading overlay caches", exc_info=True)
384394

385-
async def _get_latest_params(self, timeout: float) -> dict | None:
395+
async def _get_latest_params(
396+
self,
397+
timeout: float,
398+
pipeline: Pipeline,
399+
overlay: LoadingOverlayRenderer,
400+
) -> dict | None:
386401
"""
387402
Get the latest params from the param_update_queue, skipping stale entries before the latest. Already filters
388403
and processes params that are only logging updates. Waits for timeout seconds for a new params entry, or returns
@@ -396,20 +411,44 @@ async def _get_latest_params(self, timeout: float) -> dict | None:
396411
except queue.Empty:
397412
return None
398413

399-
if self._handle_logging_params(params):
414+
if await self._handle_process_message(params, pipeline, overlay):
400415
params = None
401416

402417
# Drain the params queue to get the latest params
403418
while not self.param_update_queue.empty():
404419
try:
405420
new_params = self.param_update_queue.get_nowait()
406-
if not self._handle_logging_params(new_params):
421+
if not await self._handle_process_message(new_params, pipeline, overlay):
407422
params = new_params
408423
except queue.Empty:
409424
break
410425

411426
return params
412427

428+
async def _handle_process_message(
429+
self,
430+
params: dict,
431+
pipeline: Pipeline,
432+
overlay: LoadingOverlayRenderer,
433+
) -> bool:
434+
consumed = self._handle_logging_params(params)
435+
if isinstance(params, dict) and params.get(STREAM_CLEANUP_CONTROL_KEY):
436+
await self._cleanup_stream_state(pipeline, overlay)
437+
consumed = True
438+
return consumed
439+
440+
async def _cleanup_stream_state(self, pipeline: Pipeline, overlay: LoadingOverlayRenderer):
441+
logging.info("PipelineProcess: stream cleanup requested")
442+
clear_queue(self.input_queue)
443+
clear_queue(self.output_queue)
444+
overlay.end_reload()
445+
try:
446+
await pipeline.cleanup_stream()
447+
except Exception as e:
448+
self._report_error("Error cleaning up pipeline stream state", e)
449+
else:
450+
logging.info("PipelineProcess: stream cleanup complete")
451+
413452
def _report_error(self, msg: str, error: Exception | None = None, silent=False):
414453
if not silent:
415454
logging.error(msg, exc_info=error)

runner/src/runner/live/process/process_guardian.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -301,8 +301,9 @@ async def _monitor_loop(self):
301301
continue
302302

303303
if state == PipelineState.OFFLINE:
304-
# Revert to initial params when the stream stops
305-
self.process.reset_stream("", "", "")
304+
# Revert to initial params and release stream-scoped state
305+
# while keeping the warmed pipeline alive.
306+
self.process.reset_stream("", "", "", cleanup_stream=True)
306307
self.process.update_params(self.pipeline_spec.initial_params)
307308

308309
if state != PipelineState.ERROR:

runner/src/runner/live/streamer/protocol/trickle.py

Lines changed: 56 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import asyncio
22
import logging
3+
import os
34
import queue
45
import json
56
from typing import AsyncGenerator, Optional
@@ -11,14 +12,49 @@
1112
from .protocol import StreamProtocol
1213
from .last_value_cache import LastValueCache
1314

15+
DEFAULT_PUBLISH_QUEUE_MAXSIZE = 1
16+
17+
18+
def _publish_queue_maxsize() -> int:
19+
raw = os.environ.get("LIVE_TRICKLE_PUBLISH_QUEUE_MAXSIZE")
20+
if raw is None or raw.strip() == "":
21+
return DEFAULT_PUBLISH_QUEUE_MAXSIZE
22+
try:
23+
value = int(raw)
24+
except ValueError:
25+
logging.warning(
26+
"Invalid LIVE_TRICKLE_PUBLISH_QUEUE_MAXSIZE=%r, using %s",
27+
raw,
28+
DEFAULT_PUBLISH_QUEUE_MAXSIZE,
29+
)
30+
return DEFAULT_PUBLISH_QUEUE_MAXSIZE
31+
return max(1, value)
32+
33+
34+
def _put_drop_oldest(_queue: queue.Queue, item) -> int:
35+
dropped = 0
36+
while True:
37+
try:
38+
_queue.put_nowait(item)
39+
return dropped
40+
except queue.Full:
41+
try:
42+
old = _queue.get_nowait()
43+
except queue.Empty:
44+
continue
45+
dropped += 1
46+
del old
47+
48+
1449
class TrickleProtocol(StreamProtocol):
1550
def __init__(self, subscribe_url: str, publish_url: str, control_url: Optional[str] = None, events_url: Optional[str] = None, input_width: Optional[int] = DEFAULT_WIDTH, input_height: Optional[int] = DEFAULT_HEIGHT, output_width: Optional[int] = None, output_height: Optional[int] = None):
1651
self.subscribe_url = subscribe_url
1752
self.publish_url = publish_url
1853
self.control_url = control_url
1954
self.events_url = events_url
2055
self.subscribe_queue = queue.Queue[InputFrame]()
21-
self.publish_queue = queue.Queue[OutputFrame]()
56+
self.publish_queue = queue.Queue[OutputFrame](maxsize=_publish_queue_maxsize())
57+
self.publish_queue_dropped = 0
2258
self.control_subscriber = None
2359
self.events_publisher = None
2460
self.subscribe_task = None
@@ -30,7 +66,8 @@ def __init__(self, subscribe_url: str, publish_url: str, control_url: Optional[s
3066

3167
async def start(self):
3268
self.subscribe_queue = queue.Queue[InputFrame]()
33-
self.publish_queue = queue.Queue[OutputFrame]()
69+
self.publish_queue = queue.Queue[OutputFrame](maxsize=_publish_queue_maxsize())
70+
self.publish_queue_dropped = 0
3471
metadata_cache = LastValueCache[dict]() # to pass video metadata from decoder to encoder
3572
self.subscribe_task = asyncio.create_task(
3673
media.run_subscribe(self.subscribe_url, self.subscribe_queue.put, metadata_cache.put, self.emit_monitoring_event, self.input_width, self.input_height)
@@ -49,7 +86,7 @@ async def stop(self):
4986

5087
# send sentinel None values to stop the trickle tasks gracefully
5188
self.subscribe_queue.put(None)
52-
self.publish_queue.put(None)
89+
_put_drop_oldest(self.publish_queue, None)
5390

5491
if self.control_subscriber:
5592
await self.control_subscriber.close()
@@ -86,17 +123,30 @@ def dequeue_frame():
86123
# TEMP: Put audio immediately into the publish queue
87124
# TODO: Remove once there is ComfyUI audio support
88125
if isinstance(image, AudioFrame):
89-
publish_queue.put(AudioOutput([image]))
126+
self.publish_queue_dropped += _put_drop_oldest(
127+
publish_queue,
128+
AudioOutput([image]),
129+
)
90130
continue
91131
yield image
92132

93133
async def egress_loop(self, output_frames: AsyncGenerator[OutputFrame, None]):
94134
publish_queue = self.publish_queue
95135
def enqueue_bytes(frame: OutputFrame):
96-
publish_queue.put(frame)
136+
return _put_drop_oldest(publish_queue, frame)
97137

98138
async for frame in output_frames:
99-
await asyncio.to_thread(enqueue_bytes, frame)
139+
try:
140+
dropped = await asyncio.to_thread(enqueue_bytes, frame)
141+
self.publish_queue_dropped += dropped
142+
if dropped and self.publish_queue_dropped % 100 == 1:
143+
logging.warning(
144+
"Dropped stale publish frames dropped_total=%s queue_maxsize=%s",
145+
self.publish_queue_dropped,
146+
publish_queue.maxsize,
147+
)
148+
finally:
149+
del frame
100150

101151
async def emit_monitoring_event(self, event: dict, queue_event_type: str = "ai_stream_events"):
102152
if not self.events_publisher:
@@ -134,4 +184,3 @@ async def control_loop(self, done: asyncio.Event) -> AsyncGenerator[dict, None]:
134184
except Exception:
135185
logging.error(f"Error in control loop", exc_info=True)
136186
continue
137-

0 commit comments

Comments
 (0)