Skip to content

fix: SG-43367: eliminate EXR+WAV playback stuttering - #1350

Draft
bernie-laberge wants to merge 2 commits into
AcademySoftwareFoundation:mainfrom
bernie-laberge:fix_playback_stuttering
Draft

fix: SG-43367: eliminate EXR+WAV playback stuttering#1350
bernie-laberge wants to merge 2 commits into
AcademySoftwareFoundation:mainfrom
bernie-laberge:fix_playback_stuttering

Conversation

@bernie-laberge

@bernie-laberge bernie-laberge commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

DO NOT REVIEW : Waiting for customer confirmation that this fix solves their issue first

fix: SG-43367: eliminate EXR+WAV playback stuttering

Linked issues

NA

Summarize your change.

Fixes stuttering during playback of high-resolution multi-part EXR sequences with WAV/MOV audio on Linux. The image was correct but playback dropped well below the target frame rate even with frames fully resident in cache. Root causes were in the GPU texture-upload path, the EXR decode scheduling, and an OpenEXR 3.3 I/O regression. Playback now holds 24 fps with ~100% of large frames on the DMA fast path and per-frame present stalls effectively gone.

GPU texture upload (primary fix) -- src/lib/ip/IPCore/ImageRenderer.{cpp,h}:

  • Make the staging PBO transient. It was allocated in initializeTexture() and pinned to the cached TextureDescription for the texture's entire cache life (~10+ frames), so with a large look-ahead cache the small fixed PBO pool was permanently occupied and most large frames fell back to the slow synchronous glTexImage2D-from-client-memory path (~72 ms, ~1.4 GB/s). The PBO is now acquired inside uploadPlane() for a single upload and released back to the pool immediately after the transfer is issued, so a handful of buffers serve an unbounded stream. Result: PBO fast-path use went ~64% -> 99.9% and CPU submit ~72 ms -> ~14 ms (~6.8 GB/s) for 4546x2864 frames.
  • Upload 3-channel RGB half/float frames via a native RGBA path: allocate the texture as GL_RGBA16F/RGBA32F and expand RGB->RGBA on the CPU during upload (GL_TEXTURE_RECTANGLE only). GL_RGB16F/RGB32F have no native DMA path; the driver was expanding per-pixel on upload, which is far slower.
  • Fix a texture cross-reuse corruption: an expanded 3ch->RGBA texture and a genuine 4ch RGBA texture produce identical destination geometry/format, so the getTexture() Compatible-reuse path could recycle one for the other and run the wrong upload path (wrong channel stride), causing image artifacts. compatible() now also compares expandRGBToRGBA so the two are never reused across each other.

RGB->RGBA expanders -- src/lib/image/TwkFB/FastMemcpy.{cpp,h}:

  • Add parallel (task-pool) expand_rgb_to_rgba_16bit/32bit(_MP) helpers used by the upload path.

EXR decode scheduling -- src/lib/ip/IPBaseNodes/FileSourceIPNode.cpp, src/lib/ip/IPCore/IPGraph.{cpp,h}, src/lib/ip/IPCore/IPCore/IPNode.h:

  • When a source mixes slow-random-access media (e.g. a MOV used only for audio) with fast EXR image media, no longer serialize the whole source onto a single caching thread. testEvaluate() now inspects only the media component actually supplying the displayed image and reports hasFastVideoSource, and the caching scheduler only forces single-threaded slow-media handling when the frame has no fast image source to parallelize. This restores concurrent EXR decoding for the common "EXR + MOV-for-audio" layout.

OpenEXR 3.3 I/O regression -- src/lib/image/IOexr/FileStreamIStream.{cpp,h}, src/lib/image/IOexr/IOMultiPartEXR.cpp:

  • OpenEXR 3.3.x is much slower for custom Imf::IStream subclasses that do not implement size()/stateless read(). RV's default memory-mapped I/O uses such a stream. Implement size(), isStatelessRead() and the stateless read() overload (guarded by IOEXR_HAS_STATELESS_ISTREAM) so 3.3+ takes the fast, concurrent read path from RV's mapped buffers.

Playback diagnostics (opt-in, env-gated) -- src/lib/base/TwkUtil/ PlaybackDiagnostics.{cpp,h} (+ CMakeLists), and instrumentation in Session.{cpp,h}, Application.cpp, GLView.cpp, FBCache.{cpp,h}, IPGraph.cpp, FileSourceIPNode.cpp, ImageRenderer.cpp, ALSASafeAudioModule/ALSASafeAudioRenderer.cpp (+ CMakeLists):

  • Add a thread-safe, RV_PLAYBACK_DIAG-gated CSV logger capturing background decode times/concurrency/compression, audio cache-miss/underrun events, buffering pauses and frame skips, display pacing (interval/dframe/refreshes), cache hit/miss and runway, per-plane GPU upload cost/PBO usage, and a stall anatomy split (present/composite/swap vs in-graph work). All timing is behind the env gate and has no cost when disabled.
  • tools/analyze_playback_diag.py: analyzer that summarizes the log and reports a bottleneck verdict (decode vs audio vs cache vs present).

Describe the reason for the change.

Playback of high-resolution multi-part EXR sequences with WAV/MOV audio on Linux could potentially stutter

Describe what you have tested and on which operating system.

Successfully tested on Rocky Linux 9

Add a list of changes, and note any that might need special attention during the review.

If possible, provide screenshots.

bernie-laberge and others added 2 commits July 15, 2026 22:47
… OpenEXR)

Fixes stuttering during playback of high-resolution multi-part EXR sequences
with WAV/MOV audio on Linux. The image was correct but playback dropped well
below the target frame rate even with frames fully resident in cache. Root
causes were in the GPU texture-upload path, the EXR decode scheduling, and an
OpenEXR 3.3 I/O regression. Playback now holds 24 fps with ~100% of large
frames on the DMA fast path and per-frame present stalls effectively gone.

GPU texture upload (primary fix) -- src/lib/ip/IPCore/ImageRenderer.{cpp,h}:
- Make the staging PBO transient. It was allocated in initializeTexture() and
  pinned to the cached TextureDescription for the texture's entire cache life
  (~10+ frames), so with a large look-ahead cache the small fixed PBO pool was
  permanently occupied and most large frames fell back to the slow synchronous
  glTexImage2D-from-client-memory path (~72 ms, ~1.4 GB/s). The PBO is now
  acquired inside uploadPlane() for a single upload and released back to the
  pool immediately after the transfer is issued, so a handful of buffers serve
  an unbounded stream. Result: PBO fast-path use went ~64% -> 99.9% and CPU
  submit ~72 ms -> ~14 ms (~6.8 GB/s) for 4546x2864 frames.
- Upload 3-channel RGB half/float frames via a native RGBA path: allocate the
  texture as GL_RGBA16F/RGBA32F and expand RGB->RGBA on the CPU during upload
  (GL_TEXTURE_RECTANGLE only). GL_RGB16F/RGB32F have no native DMA path; the
  driver was expanding per-pixel on upload, which is far slower.
- Fix a texture cross-reuse corruption: an expanded 3ch->RGBA texture and a
  genuine 4ch RGBA texture produce identical destination geometry/format, so
  the getTexture() Compatible-reuse path could recycle one for the other and
  run the wrong upload path (wrong channel stride), causing image artifacts.
  compatible() now also compares expandRGBToRGBA so the two are never reused
  across each other.

RGB->RGBA expanders -- src/lib/image/TwkFB/FastMemcpy.{cpp,h}:
- Add parallel (task-pool) expand_rgb_to_rgba_16bit/32bit(_MP) helpers used by
  the upload path.

EXR decode scheduling -- src/lib/ip/IPBaseNodes/FileSourceIPNode.cpp,
src/lib/ip/IPCore/IPGraph.{cpp,h}, src/lib/ip/IPCore/IPCore/IPNode.h:
- When a source mixes slow-random-access media (e.g. a MOV used only for audio)
  with fast EXR image media, no longer serialize the whole source onto a single
  caching thread. testEvaluate() now inspects only the media component actually
  supplying the displayed image and reports hasFastVideoSource, and the caching
  scheduler only forces single-threaded slow-media handling when the frame has
  no fast image source to parallelize. This restores concurrent EXR decoding
  for the common "EXR + MOV-for-audio" layout.

OpenEXR 3.3 I/O regression -- src/lib/image/IOexr/FileStreamIStream.{cpp,h},
src/lib/image/IOexr/IOMultiPartEXR.cpp:
- OpenEXR 3.3.x is much slower for custom Imf::IStream subclasses that do not
  implement size()/stateless read(). RV's default memory-mapped I/O uses such
  a stream. Implement size(), isStatelessRead() and the stateless read()
  overload (guarded by IOEXR_HAS_STATELESS_ISTREAM) so 3.3+ takes the fast,
  concurrent read path from RV's mapped buffers.

Playback diagnostics (opt-in, env-gated) -- src/lib/base/TwkUtil/
PlaybackDiagnostics.{cpp,h} (+ CMakeLists), and instrumentation in
Session.{cpp,h}, Application.cpp, GLView.cpp, FBCache.{cpp,h},
IPGraph.cpp, FileSourceIPNode.cpp, ImageRenderer.cpp,
ALSASafeAudioModule/ALSASafeAudioRenderer.cpp (+ CMakeLists):
- Add a thread-safe, RV_PLAYBACK_DIAG-gated CSV logger capturing background
  decode times/concurrency/compression, audio cache-miss/underrun events,
  buffering pauses and frame skips, display pacing (interval/dframe/refreshes),
  cache hit/miss and runway, per-plane GPU upload cost/PBO usage, and a stall
  anatomy split (present/composite/swap vs in-graph work). All timing is behind
  the env gate and has no cost when disabled.
- tools/analyze_playback_diag.py: analyzer that summarizes the log and reports
  a bottleneck verdict (decode vs audio vs cache vs present).

Co-authored-by: Cursor <cursoragent@cursor.com>
When the OpenEXR "Automatic Threads" preference is on (exrcpus == 0), RV set
the shared OpenEXR global thread pool to numCPUs-1 (e.g. 63 on a 64-core box).
Because every EXR decode draws from that single global pool, on high-core
machines the decode threads starve RV's playback/UI, audio and caching threads
and cause dropped frames -- worst with DWA/DWAB-compressed EXRs.

Add Rv::automaticExrThreadCount() and use it wherever the Automatic value is
applied:
- threads = (logicalCores > 16) ? logicalCores/2 : (logicalCores > 1 ? logicalCores-1 : 1)
- On hyper-threaded systems logicalCores/2 is roughly the physical core count,
  leaving headroom for the main/UI, audio and caching threads.
- Overridable at runtime via the RV_EXR_AUTO_MAX_THREADS environment variable.
- Behavior on <=16-core machines is unchanged.

Call sites updated: src/bin/apps/rv/main.cpp, src/bin/nsapps/RV/main.cpp, and
RvPreferences::exrNumThreadsFinished()/exrAutoThreads(). Manual thread counts
(exrcpus > 0) and rvio (batch, no interactive UI to starve) are unchanged.

Docs: document the Automatic heuristic and RV_EXR_AUTO_MAX_THREADS in the RV
user manual (EXR decoding threads section) and refresh the -exrcpus entries in
the RV and RVIO command-line reference tables.

Co-authored-by: Cursor <cursoragent@cursor.com>
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