Renderer: dispose live frames before re-alloc on source switch - #704
Renderer: dispose live frames before re-alloc on source switch#704lrohrmann wants to merge 1 commit into
Conversation
Re-opening a source without a full Stop()/Renderer reset left the previous source's decoded frames alive. Frames that escape the VideoCache (switch/seek/screamer hand-off) were freed only by non-deterministic COM finalizers, keeping the D3D11 device referenced so the driver never reclaimed the native surface memory -> committed memory grew unbounded per switch (observed on Intel iGPU). Track every frame the renderer returns and dispose survivors deterministically in OpenInternal (before the new decoder allocates) and on teardown, then Flush so D3D11 destroys them. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
@lrohrmann Thanks for the PR. I will need to look deeper into this, however are you sure that is general or only for intel GPUs that they have also a bug with that? Additionally, the solution it should be to identity where the frame leaks and not to try to track everything and then dispose it independently. |
|
I'm not sure if it is related to Intel GPU, but I was able to reproduce it on different Intel platforms pretty easy by just playing a high resolution stream and switch between different streams. The committed memory is raising unlimited and never returned to OS. Maybe there are better ways to fix this, but that was what has helped at the end. The fastest way to see the issue is configuring some http URLs with pretty big static jpeg images and switch between those sources. I have used 30mb jpeg files and with every switch the ram was growing 100-150mb and never returned. But you need to check committed ram, the active ram is freed but committed not. |
Summary
Re-opening a source on a
Player(i.e. switching streams viaOpen/OpenAsyncwithout a full
Stop()/renderer reset) leaks native GPU memory. The process'scommitted memory grows on every switch and is only released when the device is
destroyed (Stop/dispose). On integrated Intel GPUs (where "VRAM" is system RAM) this
ratchets up until the machine runs out of commit charge.
Root cause
A
VideoFramecan leave theVideoCacheand never beDispose()d — e.g. the frameheld by the renderer/screamer at the moment of a source switch, seek, or hand-off.
VideoFramehas no finalizer, so the native objects it owns — the D3D11Texture/ShaderResourceView/VideoProcessorInputViewand the pinnedAVFrame—are released only when the COM wrappers are finalized by the GC.
Because the managed heap stays tiny during playback, Gen2 collections almost never
run, so those finalizers almost never run. Each switch therefore leaves the previous
source's frames (and their surfaces) committed while the new decoder allocates its
own → the concurrent peak is
old + new, and committed memory never recovers.A heap dump of a leaking process shows thousands of
VideoFrameandID3D11VideoProcessorInputViewinstances that are unrooted (no GC root) but whosenative resources were never freed, while only a handful of
ID3D11Texture2Dremain(HW frames share one texture array) — i.e. the managed wrappers are dead garbage and
the native memory leaked.
Reproduction (current
master)Player(e.g. theFlyleafPlayersample).Stop()between switches — e.g. alternate
player.OpenAsync(A)/player.OpenAsync(B)on a ~1s timer, or loop a playlist of a few items.
or VMMap "Private Committed"). Do not rely on the Working Set — it is trimmed
by the OS and hides the leak.
never drops while the player keeps running.
several megapixels) and on Intel integrated GPUs; hardware-decoded streams leak
more slowly but still ratchet up.
Stop()(full renderer reset) before eachOpenreleases the memory —confirming the leak is specific to the in-place re-open path.
Fix
Track every frame the renderer hands out through the single
FillPlaneschoke point,and dispose any survivors deterministically:
Player.OpenInternal, before the new decoder allocates (so the freedsurfaces are reused instead of accumulating), and
DisposeLocal).A
Flush()after disposal lets D3D11 actually destroy the released resources (thereis no
Presenton a paused/just-switched player to trigger deferred destruction).Changes
Renderer.Device.cs: per-renderer live-frame registry (TrackFrame/UntrackFrame/DisposeLiveFrames) +FlushContext();DisposeLiveFrames()is also called fromDisposeLocal.Renderer.VP.cs:FillPlanesbecomes a small wrapper around the (now private)fillPlanesdelegate that records each returned frame.VideoFrame: gains anOwnerback-reference and untracks itself onDispose().Player.Open.cs: dispose cached + escaped frames and flush beforedecoder.Open(...).No public API change (the affected members are
internal). The per-frame tracking isa
HashSetadd/remove guarded by a lock — negligible overhead on the frame path.Testing
Verified on an Intel integrated GPU (committed-memory growth is most pronounced there)
with a setup of several concurrent
Players, repeatedly switching one player's sourcebetween an H.264 RTSP stream (hardware-decoded) and large multi-megapixel MJPEG stills
(software-decoded).
Working Set): before the fix it rose by roughly one source's worth of surfaces on
every switch and never recovered (~tens of MB per switch with the large SW frames,
unbounded over ~30 switches). After the fix it stays bounded — it plateaus at roughly
a single source's peak instead of ratcheting up.
!dumpheap -stat -type VideoFrameand-type ID3D11VideoProcessorInputViewno longer show thousands ofaccumulating instances after repeated switches (they stay at a small steady count),
and
!gcrooton a sampled survivor confirms old frames are actually disposed ratherthan left as unrooted objects whose native resources were never released.
paths; the regression was reproducible and is resolved on both.
behave as before; no black frames or flicker introduced by the deterministic disposal