Skip to content

Decode the video reader's batches with up to num_threads decode lanes - #6563

Draft
jantonguirao wants to merge 12 commits into
NVIDIA:mainfrom
jantonguirao:janton/dali-4935-lanes
Draft

jantonguirao wants to merge 12 commits into
NVIDIA:mainfrom
jantonguirao:janton/dali-4935-lanes

Conversation

@jantonguirao

@jantonguirao jantonguirao commented Oct 27, 2026 •

Copy link
Copy Markdown
Collaborator

Category:

New feature (non-breaking change which adds functionality)

Description:

fn.experimental.readers.video (CPU and GPU) decodes the samples of a batch in parallel, with up to num_threads decoders ("decode lanes"), and waits for all of them at the end of the batch.

Problem. Before this PR, the reader decoded every sample of a batch one after another, with one decoder, one CUDA stream and one thread. num_threads had no effect on decoding, and only one NVDEC session per reader was ever active. Video decoding is the bulk of the reader's work, so with large batches, or with samples spread over many files, the reader is limited by one decoder's throughput, while the CPU cores (CPU backend) or the NVDEC engines (GPU backend) that are available stay idle.

Solution. The loader still only describes the samples. The reader now plans each batch and decodes it on its lanes. Opening a video decoder is costly and a decoder decodes consecutive samples of a file without seeking, so the unit of work is a run, not a sample:

  1. Plan (PlanBatch). Samples of one file that a decoder reaches without seeking form a run: as SeekFrame decides, at most kMinimumSeekLeap frames ahead or in the same keyframe segment (CanReachWithoutSeek). A sample that pad_last_batch repeats in the batch is decoded once, and a sample that it repeats into the next batch is not decoded again (a planned flag on the sample). Otherwise two lanes could decode the same sample at once, or a repeat could overwrite the data of a batch that may still be in use.
  2. Assign (PlanLanes, new decode_lane_planner.{h,cc}).
    • Runs are costed in the pictures their decoder decodes, simulated with FramesDecoderBase::AddDecodeWork in the order the lane decodes them. That counts B-frames, pictures hidden by an edit list, where a seek really lands and decoder resets, plus a fixed cost (kOpenCost) per opening or reset of a decoder. Without an index, a run costs its frame range.
    • Runs go to lanes longest first, each to the lane that would finish it first, counting what a lane saves when its decoder already holds the file.
    • A lane decodes the runs of one file together, by first frame, starting with the file its decoder holds and the run that continues where the decoder is; the other files follow in batch order, each on a newly opened decoder. With more than one lane, a lane without runs releases its decoder, so that its frame buffers and NVDEC decoder are not kept while idle.
  3. Decode (DecodeBatch, DecodeLanePart). Every lane has its own decoder, CUDA stream (GPU), constant frame and scratch, and decodes its runs on a thread pool with one thread per lane. When only one lane has work in a batch (always with num_threads=1), it decodes on the calling thread, as before. The batch is done when every lane is. A batch's error is the one of its first failing sample in batch order, whatever the lanes' timing.

The assignment depends only on the samples and on where the lanes' decoders are, never on thread timing, so neither does the output. The new tests check that the output is the same with one and with several threads.

Number of lanes: min(num_threads, max_batch_size) (at least one) and, on the GPU, at most two per NVDEC engine of the device (new FramesDecoderGpu::NumEngines, from CUVIDDECODECAPS::nNumNVDECs). With num_threads=1 there is one lane: one decoder, decoding on the calling thread, as before. The one difference is the order within a batch: the samples are decoded grouped by file and by first frame, not in batch order, so the samples of a file share one decoder even when they are not consecutive in the batch.

Every batch waits for its slowest lane. Running the lanes on their own threads and decoding a batch ahead is done in #6564.

This PR is split out of #6545.

Depends on #6558, #6560 and #6562, and on what they build on. Their commits are also on this branch until they merge:

This PR's own change is the last commit only: "Decode the video reader's batches with up to num_threads decode lanes".

Additional information:

Affected modules and functionalities:

  • dali/operators/video/reader/video_reader_decoder_op.cc:
    • PlanBatch, AssignRuns, DecodeBatch, DecodeLanePart; DecodeSample is the former per-sample body of Prefetch, now on a lane's decoder, stream and constant frame;
    • the reader's single decoder, stream and constant frame are replaced by a vector of DecodeLanes and a thread pool (only with more than one lane);
    • VideoSample::planned, for samples repeated by pad_last_batch;
    • the first error of a batch is reported, after all lanes are done;
    • docstring: decoding in parallel by up to num_threads decoders, and memory growing with the number of decoders.
  • dali/operators/video/reader/decode_lane_planner.{h,cc} (new): SampleFrames, DecoderPosition, CanReachWithoutSeek, PlanLanes. They are separate from the operator so that they can be tested on their own.
  • dali/operators/video/frames_decoder_gpu.{h,cc}: FramesDecoderGpu::NumEngines(device_id).

fn.experimental.decoders.video is not affected.

Key points relevant for the review:

  • Plan prediction. The planner must predict where SeekFrame seeks (CanReachWithoutSeek) and where each sample leaves its decoder (DecoderPosition, AddDecodeWork). A wrong prediction only makes the plan worse, never the output wrong: each sample is still decoded by DecodeSample with the decoder's own seeking logic, as before.
  • Determinism. Which samples a lane decodes, and in which order, must not depend on thread timing (ties in PlanLanes go to the lowest lane). Otherwise a decoder's history, and with it the output, could change from run to run.
  • Errors. A lane that fails a sample resets its decoder (its destructor waits for its stream) and goes on with its other samples. The batch reports the error of its first failing sample in batch order. On the GPU, a lane synchronizes its stream at the end of its part, and an asynchronous error there is reported only if no sample failed.
  • GPU memory. Every lane has its own decoder and frame buffers, up to two per NVDEC engine. Lanes without work in a batch release their decoders.

Tests:

  • Existing tests apply
    • decoder/test_video.py, checkpointing/test_dali_checkpointing.py: test_experimental_video_reader*, test_video_pipeline.py
  • New tests added
    • Python tests
      • decoder/test_video.py:
        • test_reader_output_independent_of_num_threads, test_reader_batch_size_1_output_independent_of_num_threads, test_reader_multi_run_lanes_output_independent_of_num_threads (fewer lanes than samples), CPU and GPU, and test_reader_lead_in_output_independent_of_num_threads (GPU only: H.264 clip with a long edit-list lead-in). Each compares the output with 1 and with N threads, and checks the frames against PyAV.
        • test_reader_pad_last_batch_repeats_last_sample: a shard's last sample repeated in its batch and as the whole next batch.
        • test_reader_reports_first_error_of_batch: samples fail in two lanes and in the next batch; the error is the batch's first one.
    • GTests
      • decode_lane_planner_test.cc (new): DecodeLanePlannerTest.SampleFrames, CanReachWithoutSeek, LongestFirstToEarliestFinish, HeldFileContinues, NoIndex, HeldFileGoesFirst, RunsOfAFileInAscendingOrder, LeadIn, ResetAfterTheLastFrame, LaneOrder, PlannedPicturesAreDecoded.
      • frame_index_cache_test.cc: FrameIndexCacheTest.HandBuiltIndexIsInDecodingOrder, FrameIndexCacheTest.KeepsDecodingOrder (an index without decoding order falls back to frame order; a cached index, and a decoder's copy of it, keep the decoding order that the planner uses).
    • Benchmark
    • Other
  • N/A

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

The NVDEC decoder cache looked up decoders by the coded size of the stream
(CUVIDEOFORMAT::coded_height/width), but stored the display area size
(ulTargetHeight/Width) of the decoder it created. The two differ whenever the
stream is cropped, e.g. 1080p H.264 is coded as 1920x1088 and displayed as
1920x1080, so for such videos no cached decoder ever matched. As the
cuvidReconfigureDecoder fallback is compiled out (ENABLE_NVDEC_RECONFIGURE is
never defined), every video opened by FramesDecoderGpu got a freshly created
decoder, and unused ones piled up in the cache.

In an nsys profile of experimental.readers.video on 1080p H.264 clips, each file
switch stalled the reader for ~70 ms before the first kernel, ~57 ms of which
were ioctls inside libnvcuvid, consistent with creating a new decoder.

Store both the coded size and the display area in DecInstance and require both
to match, so a decoder is reused only for streams it is really suited for.

Add NVDECCacheSize() and a test that decodes the same 800x600 (coded 800x608)
H.264 file with two consecutive decoders and checks that the second one reuses
the cached decoder.

Signed-off-by: Joaquin Anton Guirao <janton@nvidia.com>
Comparing the cache size before and after the second decode does not detect a
cache miss when the cache is full: the miss inserts a new decoder and evicts an
idle one, so the size stays the same.

Replace NVDECCacheSize() with NVDECDecodersCreated(), a cumulative count of
decoders created by the cache, and check that the second decoder does not
increase it.

Signed-off-by: Joaquin Anton Guirao <janton@nvidia.com>
Move the check whether a cached decoder can be reused into NVDECCache::IsReusable and document each criterion, in particular why both the coded size and the display area have to match. No functional change.

Signed-off-by: Joaquin Anton Guirao <janton@nvidia.com>
FramesDecoderGpu's frame_buffer_ is returned to the memory pool without stream ordering when the
decoder is destroyed (the reader replaces its decoder on every file switch), while an asynchronous
copy out of it may still be pending on the decoder's stream. Another stream could then be given
that memory. The destructor now synchronizes the stream first.

Signed-off-by: Joaquin Anton Guirao <janton@nvidia.com>
@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.

…_DTOR_CALL

As the other destructors that synchronize a stream do: only the error of an unloading CUDA
runtime is ignored.

Signed-off-by: Joaquin Anton Guirao <janton@nvidia.com>
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>
…anges below build on

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>
FrameIndexCache::find returned an iterator after releasing its lock and callers copied the index
through it without the lock, while insert() could overwrite the same entry (two readers indexing
the same file) or rehash the map.

The cache moves to its own files. GetOrBuild builds every index once per file, outside the lock
(a shared_mutex, so lookups run in parallel), and returns an immutable shared_ptr; Find never
builds nor waits. Indices are kept for the lifetime of the process, as before, and an index is
built again when the file's size or modification time changed, taken before the file is opened.
instance() is defined in a .cc file, so that there is one cache per process.

Each reader holds the indices of its files and decodes with those, without cache lookups after
setup, so an index cannot change under a reader.

Signed-off-by: Joaquin Anton Guirao <janton@nvidia.com>
The decoder capabilities do not change, but cuvidGetDecoderCaps is serialized in the driver and was
queried for every file, which gets slow when many decoders open files at once. Cache the result per
device, codec, chroma format and bit depth, and hold the lock during the query so that decoders that
start at once query each configuration only once. Used by NVDECCache::GetDecoder and
SelectVideoStream.

Signed-off-by: Joaquin Anton Guirao <janton@nvidia.com>
The video reader decoded every sample of a batch, one after another, with a single decoder. Now
it groups the samples of a batch into runs (samples of one file that one decoder decodes in a row
without seeking) and has up to num_threads decode lanes decode them in parallel, each with its own
decoder, stream and scratch, waiting for all of them at the end of the batch.

The runs are assigned to the lanes by PlanLanes (decode_lane_planner.h): longest first, each to
the lane that would finish it first, costed in the pictures its decoder decodes (simulated with
AddDecodeWork in the order the lane decodes them), plus a cost per opening of a decoder. Which
samples a lane decodes depends only on the samples and on where the lanes' decoders are, so the
output never depends on thread timing. A lane without runs releases its decoder.

There are at most max_batch_size lanes and, on the GPU, two per NVDEC engine (NumEngines). A
sample repeated by pad_last_batch is decoded once, also when it is repeated in the next batch.
An error is reported for the first failing sample of the batch, whatever the lanes' timing.

Signed-off-by: Joaquin Anton Guirao <janton@nvidia.com>
@jantonguirao
jantonguirao force-pushed the janton/dali-4935-lanes branch from 400e7a0 to 377d0c7 Compare October 27, 2026 15:57
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