Skip to content

Do not map, convert nor decode video pictures that are never read - #6562

Draft
jantonguirao wants to merge 5 commits into
NVIDIA:mainfrom
jantonguirao:janton/dali-4935-unread-pictures
Draft

jantonguirao wants to merge 5 commits into
NVIDIA:mainfrom
jantonguirao:janton/dali-4935-unread-pictures

Conversation

@jantonguirao

@jantonguirao jantonguirao commented Oct 27, 2026 •

Copy link
Copy Markdown
Collaborator

Category:

Refactoring (Redesign of existing code that doesn't affect functionality)

Description:

Split out of #6545 (decode lanes for fn.experimental.readers.video), which was too big to review as a whole. This PR covers one concern only: the GPU video decoder no longer maps, converts or keeps a frame buffer for pictures that are never read. It also skips decoding H.264 non-reference pictures that are never read.

Depends on: #6561, which itself builds on #6556, #6559 and #6540. Until those are merged, their commits are also on this branch. This PR's own change is only the last commit, "Do not map, convert nor decode video pictures that are never read". The other commits belong to other PRs:

The problem

FramesDecoderGpu has to decode every picture between a keyframe and the frames it returns, because later pictures may refer to them. Until now it also did everything else for each of those pictures. When a picture was not returned right away, it was mapped from the NVDEC output surface and color-converted into a frame buffer. That buffer was device memory the size of one output frame. Many of these pictures are never read with their data:

  • Pictures hidden by an edit list. These come before the first visible frame, and there can be hundreds of them. One of the Sintel test clips has 188, and before this change the decoder held more than 188 frame buffers to read its first frames.
  • Frames skipped on the way to the frames to read. Examples are the frames between those of a strided sample (stride > 1), and the frames between a keyframe and the target of a seek.
  • Frames shown before the next frame to read that are already buffered because of decoding order.

For each such picture, the decoder mapped an NVDEC surface and launched a color conversion into a frame buffer, and then threw the result away. This used GPU time and device memory. With an edit-list lead-in, the frame buffer pool also grew to one buffer per hidden picture. This affects the GPU backends of fn.experimental.readers.video, fn.experimental.decoders.video and fn.experimental.inputs.video, which all use FramesDecoderGpu. The effect is largest for strided sampling, for seeks into long GOPs, and for videos with an edit-list lead-in.

The change

  • The decoder now knows which frames it reads next (FramesDecoderBase::frames_to_read_):

    • DecodeFrames sets it to the frames of the sample it has yet to read.
    • SeekFrame sets it to the frame it seeks to, unless DecodeFrames has already set it.
    • It is cleared on scope exit.
  • FramesDecoderGpu::CanBeReadWithData(pts) decides whether a buffered frame can still be read. The index is sorted by timestamp (SetIndex now asserts this). A frame can be read only if:

    • its timestamp is in the index at or after the next frame, and
    • it is one of the frames to read next, or it comes after the last of them, or the frames to read next are not known. A frame after the last one is kept because a later DecodeFrames call on the same decoder may read it.

    Without an index, every frame can be read, as before.

  • In HandlePictureDisplay, a buffered frame that cannot be read keeps its slot in the buffer, but only its timestamp is kept (BufferedFrame::has_data_ == false). It is neither mapped nor converted.

  • Frame buffers are attached lazily (AttachFrameBuffer). A slot reuses the buffer of an empty slot or of a timestamp-only slot. Only if there is none is a new buffer allocated. Neither parser initialization nor growing the buffer (FindEmptySlot) allocates a frame buffer for every slot any more.

  • In ProcessPictureDecode, an H.264 non-reference frame picture (ref_pic_flag == 0, not a field picture) that is not read with its data (IsPictureReadWithData) is not decoded at all. No other picture refers to it, so only its timestamp is kept for display order. These pictures are counted by SkippedPictures(). FramesDecoderBase::ParsedPictures() (decoded plus skipped) is now the number that the AddDecodeWork estimate from Estimate a video decoder's work from the decoding order in its index #6559 matches. Other codecs, and reference or field pictures, are always decoded.

  • Reading a frame from the buffer that kept only its timestamp fails with an internal error, instead of returning a frame whose data was never written.

The frames returned do not change. The tests compare them against a decoder that maps every picture.

Additional information:

Affected modules and functionalities:

  • dali/operators/video/frames_decoder_base.{h,cc}:
    • frames_to_read_, set by DecodeFramesImpl and SeekFrame;
    • skipped_pictures_ and ParsedPictures();
    • an assertion in SetIndex that the index is sorted by timestamp.
  • dali/operators/video/frames_decoder_gpu.{h,cc}:
    • BufferedFrame::has_data_;
    • CanBeReadWithData, IsPictureReadWithData and AttachFrameBuffer;
    • lazy frame buffer allocation;
    • the H.264 non-reference skip in ProcessPictureDecode;
    • the internal-error check in ReadNextFrameWithIndex;
    • test and diagnostic accessors MappedPictures(), SkippedPictures(), NumFrameBuffers() and SetBufferedFrameDataForTesting.
  • dali/operators/video/frames_decoder_test.cc: new tests, and the decode-work checks now use ParsedPictures().

Users: the GPU backends of fn.experimental.readers.video, fn.experimental.decoders.video and fn.experimental.inputs.video. The CPU decoder is not changed.

Key points relevant for the review:

  • CanBeReadWithData must never say "no" to a frame that is read later. If it did, that frame would have no data. This PR turns that case into an internal error (ReadNextFrameWithIndex), not a garbage frame. Please check the reasoning in its comment:
    • Until a flush, buffered frames are only looked up for frames at or after next_frame_idx_.
    • Frames after the last one in frames_to_read_ are always kept.
  • frames_to_read_ lifetime. It is a span into a local SmallVector in DecodeFramesImpl, and is cleared by AtScopeExit. SeekFrame only sets it, and clears it, when DecodeFrames has not set it already.
  • The non-reference skip. The timestamp of the picture being decoded is taken as piped_pts_.front(), or current_pts_ when nothing is piped. This is the same timestamp HandlePictureDisplay will assign. IsPictureReadWithData makes the same decision HandlePictureDisplay makes: the picture is returned now (current_copy_to_output_), or buffered with its data. The skip is limited to H.264 frame pictures with ref_pic_flag == 0.
  • AttachFrameBuffer buffer reuse. It takes the buffer of an empty or timestamp-only slot. Any pending copy out of that buffer and the conversion into it both run on stream_, so they stay ordered.
  • AddDecodeWork (from Estimate a video decoder's work from the decoding order in its index #6559) still counts skipped non-reference pictures as work. This is exact in parsed pictures (ParsedPictures), but it over-estimates NVDEC work. Weighting them would need a reference flag per index entry, so it is left for a follow-up.

Tests:

  • Existing tests apply
  • New tests added
    • Python tests
    • GTests
    • Benchmark
    • Other
  • N/A

New GTests in frames_decoder_test.cc:

  • FramesDecoderGpuTest.PicturesNotReadAreNotMapped uses a clip with 188 pictures hidden by an edit list. It reads frames in a row, with stride 3, and from frame 30. It checks that:
    • the frames match a decoder that maps every picture;
    • no more than 10 frame buffers are held (versus more than 188 when every picture is mapped);
    • at least 80 hidden non-reference pictures are skipped;
    • at most a few pictures beyond the frames read are mapped.
  • FramesDecoderGpuTest.SameFramesAsMappingAll covers Sintel clips, VFR H.264 and (when supported) HEVC and VFR HEVC. It compares the frames, and the total pictures parsed, with a decoder that maps every picture, for:
  • FramesDecoderGpuTest.ReadingFrameNotMappedFails checks that reading a frame whose data was not kept raises the internal error and does not return a garbage frame.

Changed: the decode-work helpers used by FramesDecoderTest_CpuOnlyTests.DecodeWork, FramesDecoderGpuTest.DecodeWork and FramesDecoderGpuTest.DecodeWorkFromPosition now compare the estimate with ParsedPictures().

Existing tests that cover the change:

  • all FramesDecoderGpuTest.* tests, e.g. ConstantFrameRate*, VariableFrameRate*, InMemory*, *NoIndex, RawH264 and RawH265;
  • dali/test/python/test_video_reader.py, dali/test/python/decoder/test_video.py, dali/test/python/input/test_video.py and dali/test/python/test_video_pipeline.py.

Checklist

Documentation

  • Existing documentation applies
  • Documentation updated
    • Docstring
    • Doxygen
    • RST
    • Jupyter
    • Other
  • N/A

DALI team only

Requirements

  • Implements new requirements
  • Affects existing requirements
  • N/A

REQ IDs: N/A

JIRA TASK: DALI-4935

@copy-pr-bot

copy-pr-bot Bot commented Oct 27, 2026

Copy link
Copy Markdown

This pull request requires additional validation before any workflows can run on NVIDIA's runners.

Pull request vetters can view their responsibilities here.

Contributors can view more details about this message here.

Planning decode work across lanes needs an estimate of the pictures a decoder decodes for a
sample. A frame costs every picture up to its own in decoding order (B-frames), clips with an edit
list decode a hidden lead-in before their first frame, a seek can land a GOP before the keyframe,
and padding past the last frame reopens the file.

The frame index now records, per frame, the position of its packet in decoding order (counting
packets hidden by an edit list) and, for the frames SeekFrame seeks to, where a seek really starts
decoding (asking the demuxer only for streams with hidden packets). Both decoders count the
pictures they decode. FramesDecoderBase::AddDecodeWork mirrors SeekFrame (Reset,
kMinimumSeekLeap, same keyframe, backward seek) to estimate the pictures decoded and the
reopenings, PositionAt gives the position of a decoder from its next frame, and HandleBoundary is
static so that frames can be mapped without a decoder. Tests check the estimate against real
decoding on the CPU (exact) and GPU (within one picture). Nothing that affects the output changes.

Signed-off-by: Joaquin Anton Guirao <janton@nvidia.com>
AddDecodeWork ignores frames out of range (a constant frame from HandleBoundary is copied, not
decoded) instead of indexing past the index. PositionAt returns -1 after the last frame, as
NextFrameIdx does, and also counts the leading pictures of an open GOP, decoded after its
keyframe. BuildIndex does not ask the demuxer where seeks land in all-intra streams, where it
would read the whole file again. The docs say what a decoded picture is on each decoder.

Tests: exact estimates on hand-built indices (B-frames, a hidden lead-in, a seek landing before
its keyframe, an open GOP, out-of-range frames, no decoding order); the reopenings are checked
against a new Reopens() counter; the GPU slack only lets a decoder be ahead, not behind; the
seek landing of files without hidden packets is checked against the demuxer; "across a keyframe"
runs before the far jump, so that most files have a keyframe ahead.

Signed-off-by: Joaquin Anton Guirao <janton@nvidia.com>
…r names

Signed-off-by: Joaquin Anton Guirao <janton@nvidia.com>
FramesDecoderGpu synchronized its stream after the color space conversion of every picture before
it unmapped the NVDEC output surface, so the host sat idle while each conversion ran. The surface
now stays mapped after the conversion is launched, with an event recorded behind it; the wait and
the unmap move to ReleaseMappedFrame, which runs right before the next cuvidDecodePicture, in Flush,
and in the destructor (on the decoder's device, after its stream sync), before the decoder goes
back to the NVDEC cache. NVDEC is still used only after every earlier conversion is done, and at
most one surface is mapped at a time. A conversion that fails after the map no longer leaks the
mapping. The move constructor is deleted, as the parser calls back into the object.

As the decoder no longer waits for its GPU work, the users of the decoder wait for its stream after
each sample (decoders.video, readers.video) or at the end of each Run (inputs.video), so that
asynchronous errors are reported at the sample that caused them.

Tests hold up the decoder's stream so that conversions are pending when the next picture is
decoded, when the decoder seeks, flushes and is destroyed (also in another thread, and before
NVDEC goes back to the cache), and check that a failed conversion leaves no surface mapped.

Signed-off-by: Joaquin Anton Guirao <janton@nvidia.com>
The GPU video decoder mapped and converted every picture it buffered, and gave each one a frame
buffer, although many are never read with their data: pictures hidden by an edit list, frames only
skipped on the way to the frames to read, or frames shown before the next one to read. Decoding them
is still needed for the pictures that refer to them, but mapping and converting them is not.

The decoder now knows the frames it reads next (frames_to_read_, set by DecodeFrames and SeekFrame)
and keeps only the timestamp of a buffered frame that cannot be read with its data
(CanBeReadWithData, BufferedFrame::has_data_); frame buffers are attached lazily, reusing spare
ones. H.264 non-reference pictures that are not read with their data are not decoded at all
(IsPictureReadWithData); they are counted by SkippedPictures, and ParsedPictures (decoded plus
skipped) is what the decode-work estimate is checked against. Reading a frame without its data is
an internal error.

Tests compare the frames read with those of a decoder mapping every picture, and check that pictures
not read are neither mapped nor given a frame buffer.

Signed-off-by: Joaquin Anton Guirao <janton@nvidia.com>
@jantonguirao
jantonguirao force-pushed the janton/dali-4935-unread-pictures branch from bfe8c3e to b4683c9 Compare October 30, 2026 17:11
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