@@ -526,11 +526,22 @@ BetaCudaDeviceInterface::~BetaCudaDeviceInterface() {
526526 // What happens to those decode surfaces that haven't yet been mapped is
527527 // unclear.
528528 flush ();
529+ if (surface_read_done_ != nullptr ) {
530+ // Whoever picks this decoder up from the cache will map its output
531+ // surface and overwrite it. We block the host until the last consumer is
532+ // done reading.
533+ cudaEventSynchronize (surface_read_done_);
534+ }
529535 unmap_previous_frame ();
530536 NVDECCache::get_cache (device_).return_decoder (
531537 &video_format_, surface_format_, std::move (decoder_));
532538 }
533539
540+ if (surface_read_done_ != nullptr ) {
541+ cudaEventDestroy (surface_read_done_);
542+ surface_read_done_ = nullptr ;
543+ }
544+
534545 if (video_parser_) {
535546 cuvidDestroyVideoParser (video_parser_);
536547 video_parser_ = nullptr ;
@@ -792,12 +803,14 @@ int BetaCudaDeviceInterface::receive_frame(UniqueAVFrame& av_frame) {
792803 proc_params.progressive_frame = disp_info.progressive_frame ;
793804 proc_params.top_field_first = disp_info.top_field_first ;
794805 proc_params.unpaired_field = disp_info.repeat_first_field < 0 ;
795- // We set the NVDEC stream to the current stream. It will be waited upon
796- // by the color conversion stream before any color conversion.
806+ // We set the NVDEC stream to the current stream, and remember it: consumers
807+ // of the mapped surface run later and possibly on a different stream, so they
808+ // need to know which stream produces the surface's content in order to wait
809+ // on it.
797810 // Re types: we get a cudaStream_t from PyTorch but it's interchangeable with
798811 // CUstream
799- proc_params. output_stream =
800- reinterpret_cast <CUstream>(get_current_cuda_stream (device_. index ()) );
812+ nvdec_output_stream_ = get_current_cuda_stream (device_. index ());
813+ proc_params. output_stream = reinterpret_cast <CUstream>(nvdec_output_stream_ );
801814
802815 CUdeviceptr frame_ptr = 0 ;
803816 unsigned int pitch = 0 ;
@@ -812,13 +825,15 @@ int BetaCudaDeviceInterface::receive_frame(UniqueAVFrame& av_frame) {
812825 // immediately after mapping them: they do the color-conversion in-between,
813826 // which involves a copy of the data, so that works.
814827 // We, OTOH, will do the color-conversion later, outside of receive_frame().
815- // So we unmap here: just before mapping a new frame. At that point we know
816- // that the previously-mapped frame is no longer needed:
828+ // So we unmap here: just before mapping a new frame. At that point the
829+ // previously-mapped frame has been consumed, or at least its consumption has
830+ // been enqueued:
817831 // - With SingleStreamDecoder, that frame was either color-converted (with a
818832 // copy), or that's a frame that was discarded in SingleStreamDecoder.
819- // Either way, the underlying output surface can be safely re-used.
820833 // - With the "Blocks" APIs, the PacketDecoder forces a copy in
821834 // make_frame_standalone().
835+ // Those reads are asynchronous, which is what the call below accounts for.
836+ order_mapping_after_surface_read ();
822837 unmap_previous_frame ();
823838 CUresult result = cuvidMapVideoFrame (
824839 *decoder_.get (),
@@ -836,6 +851,41 @@ int BetaCudaDeviceInterface::receive_frame(UniqueAVFrame& av_frame) {
836851 return AVSUCCESS ;
837852}
838853
854+ void BetaCudaDeviceInterface::record_surface_read (cudaStream_t stream) {
855+ // Called by every consumer of the mapped surface, once its read has been
856+ // enqueued on `stream`.
857+ // This sets the surface_read_done_ event that must be waited upon before
858+ // mapping a new frame on the surface.
859+ if (surface_read_done_ == nullptr ) {
860+ cudaError_t err =
861+ cudaEventCreateWithFlags (&surface_read_done_, cudaEventDisableTiming);
862+ STD_TORCH_CHECK (
863+ err == cudaSuccess,
864+ " cudaEventCreateWithFlags failed: " ,
865+ cudaGetErrorString (err));
866+ }
867+ cudaError_t err = cudaEventRecord (surface_read_done_, stream);
868+ STD_TORCH_CHECK (
869+ err == cudaSuccess, " cudaEventRecord failed: " , cudaGetErrorString (err));
870+ surface_reader_stream_ = stream;
871+ }
872+
873+ void BetaCudaDeviceInterface::order_mapping_after_surface_read () {
874+ // The mapping we're about to do on the NVDEC stream writes the output
875+ // surface, which the previous frame's consumer may still be reading from
876+ // another stream: the NVDEC stream must also wait on that consumer.
877+ if (surface_read_done_ == nullptr ||
878+ surface_reader_stream_ == nvdec_output_stream_) {
879+ return ;
880+ }
881+ cudaError_t err =
882+ cudaStreamWaitEvent (nvdec_output_stream_, surface_read_done_, 0 );
883+ STD_TORCH_CHECK (
884+ err == cudaSuccess,
885+ " cudaStreamWaitEvent failed: " ,
886+ cudaGetErrorString (err));
887+ }
888+
839889void BetaCudaDeviceInterface::unmap_previous_frame () {
840890 if (previously_mapped_frame_ == 0 ) {
841891 return ;
@@ -950,9 +1000,9 @@ void BetaCudaDeviceInterface::make_frame_standalone(UniqueAVFrame& av_frame) {
9501000 // receive_frame() without losing the data.
9511001 // - CPU-fallback frames are uploaded here too, so that a PacketDecoder always
9521002 // hands out frames that live on its own device.
953- // The copy of GPU frames and the upload of CPU frames is async: we thus
954- // record the producer stream in the attached data so that the stream can be
955- // waited upon before running the color-conversion .
1003+ // The copy of GPU frames is async, so we record the stream it runs on in the
1004+ // attached data: a ColorConverter on another stream must wait on it before
1005+ // reading the frame .
9561006 STD_TORCH_CHECK (
9571007 mode () == Mode::DecoderOnly,
9581008 " make_frame_standalone() is only valid in decoder-only mode: standalone "
@@ -962,13 +1012,39 @@ void BetaCudaDeviceInterface::make_frame_standalone(UniqueAVFrame& av_frame) {
9621012
9631013 torch::stable::Tensor storage;
9641014 if (decoding_on_cpu_) {
965- auto uploaded = upload_cpu_frame_to_gpu_on_current_stream (*av_frame);
1015+ auto uploaded = upload_cpu_frame_to_gpu (*av_frame, current_stream );
9661016 av_frame = std::move (uploaded.av_frame );
9671017 storage = std::move (uploaded.storage );
9681018 } else {
9691019 storage = copy_nvdec_surface (av_frame, current_stream);
9701020 }
9711021
1022+ // TODO_API_BREAKDOWN CORRECTNESS P0: `storage` comes from the PyTorch
1023+ // caching allocator on `current_stream`, but a ColorConverter reads it from
1024+ // whatever stream it runs on. The allocator only tracks the allocating
1025+ // stream: once the frame is dropped, the block returns to `current_stream`'s
1026+ // pool with no synchronisation, and the next frame's copy - same size, same
1027+ // stream - lands right in it while the converter is still reading. Frames
1028+ // are then silently corrupted whenever the converter lags behind the
1029+ // decoder, which is the normal state of a two-stream pipeline. Measured on a
1030+ // 4K clip with the converter backlogged: 117 of 119 frames wrong, and clean
1031+ // again as soon as the frames are kept alive.
1032+ // The usual remedy is Tensor::record_stream() on the consumer side, but
1033+ // neither the stable ABI nor the AOTI shim exposes it, and StableIValue has
1034+ // no Stream conversion, so it isn't reachable through the dispatcher either.
1035+ // We don't actually need it though: the allocator recycles the block because
1036+ // *we* drop our reference too early, so it's enough to hold on to the
1037+ // storage until the consumer is done. Have the ColorConverter record an
1038+ // event on its own stream into the attached data, and make
1039+ // standalone_frame_free_callback() hand (storage, event) to a per-device
1040+ // pending-release list instead of dropping the tensor. Drain that list
1041+ // opportunistically with cudaEventQuery. No host stall, and a consumer that
1042+ // permanently lags shows up as a growing list, i.e. as backpressure rather
1043+ // than as silent corruption.
1044+ // Frames that were never converted have no event and can be released
1045+ // straight away. Raw planes handed to the user via materialize() keep the
1046+ // storage alive on their own, and once the user drops those, ordering their
1047+ // own kernels is their responsibility, same as for any other tensor.
9721048 auto attached_data = new StandAloneFrameAttachedData ();
9731049 attached_data->producer_stream = current_stream;
9741050 attached_data->storage = std::move (storage);
@@ -985,7 +1061,7 @@ void BetaCudaDeviceInterface::make_frame_standalone(UniqueAVFrame& av_frame) {
9851061
9861062torch::stable::Tensor BetaCudaDeviceInterface::copy_nvdec_surface (
9871063 UniqueAVFrame& av_frame,
988- cudaStream_t stream ) {
1064+ cudaStream_t current_stream ) {
9891065 // The amount of bytes an NV12 image takes is:
9901066 // num_bytes = len(Y) + len(UV)
9911067 // = num_pixels + num_pixels / 2
@@ -1006,34 +1082,28 @@ torch::stable::Tensor BetaCudaDeviceInterface::copy_nvdec_surface(
10061082 auto storage =
10071083 torch::stable::empty ({num_bytes}, kStableUInt8 , std::nullopt , device_);
10081084
1009- // TODO_API_BREAKDOWN CORRECTNESS P1: I suspect we don't need to wait on the
1010- // nvdec stream here, because we can only arrive here from a path where the
1011- // frame has already been mapped so its data is available - worth double
1012- // checking.
1085+ // The surface's content is produced by the mapping post-processing that
1086+ // receive_frame() enqueued on nvdec_output_stream_, which isn't necessarily
1087+ // the stream we're copying on.
1088+ if (current_stream != nvdec_output_stream_) {
1089+ sync_streams (
1090+ /* running_stream=*/ nvdec_output_stream_,
1091+ /* waiting_stream=*/ current_stream);
1092+ }
1093+
10131094 cudaError_t err = cudaMemcpyAsync (
10141095 storage.mutable_data_ptr (),
10151096 av_frame->data [0 ],
10161097 static_cast <size_t >(num_bytes),
10171098 cudaMemcpyDeviceToDevice,
1018- stream );
1099+ current_stream );
10191100 STD_TORCH_CHECK (
10201101 err == cudaSuccess,
10211102 " Failed to copy NVDEC surface: " ,
10221103 cudaGetErrorString (err));
10231104
1024- // TODO_API_BREAKDOWN CORRECTNESS P0: We might want to unmap here to clearly
1025- // state that the surface memory can be reused and that there's no leak (and
1026- // rename this into copy_and_unmap_nvdec_surface). However, regardless of
1027- // whether we unmap here or let receive_frame() unmap, I think we have a
1028- // problem: the copy is async, and nothing prevents a PacketDecoder from
1029- // decoding 2 consecutive frames on 2 separate streams. The following can
1030- // happen: with Stream():
1031- // packet_decoder.decode() -> receive_frame() -> copy_nvdec_surface() ->
1032- // cudaMemcpyAsync()
1033- // with Stream():
1034- // packet_decoder.decode() -> receive_frame() -> unmap_previous_frame()
1035- // where unmap_previous_frame() unmaps the surface before the cudaMemcpyAsync
1036- // is able to finish on the other stream.
1105+ // The copy is async, so the next mapping must be ordered after it.
1106+ record_surface_read (current_stream);
10371107
10381108 auto y_plane = static_cast <uint8_t *>(storage.mutable_data_ptr ());
10391109 int64_t plane_stride = pitch * num_luma_plane_rows;
@@ -1068,17 +1138,17 @@ void BetaCudaDeviceInterface::flush() {
10681138 send_seqhdr_packet ();
10691139}
10701140
1071- GpuFrameAndStorage
1072- BetaCudaDeviceInterface::upload_cpu_frame_to_gpu_on_current_stream (
1073- const AVFrame& cpu_frame ) {
1141+ GpuFrameAndStorage BetaCudaDeviceInterface::upload_cpu_frame_to_gpu (
1142+ const AVFrame& cpu_frame,
1143+ cudaStream_t stream ) {
10741144 // This is called in the context of the CPU fallback: the frame was decoded
10751145 // on the CPU, and in this function we convert that frame into a format we
10761146 // can color-convert on the GPU, and send it there.
10771147 // We do that in 2 steps:
10781148 // - First we convert the input CPU frame into an intermediate CPU frame in
10791149 // the target format using sws_scale.
1080- // - Then we allocate GPU memory and copy the CPU frame to the GPU
1081- // asynchronously on the current stream.
1150+ // - Then we allocate GPU memory and copy the CPU frame to the GPU on the
1151+ // given stream.
10821152 // We return the new AVFrame and its associated GPU storage so that the caller
10831153 // can handle the memory lifetime. The GPU storage is a torch
10841154 // Tensor because we want to rely on the torch CUDA allocator.
@@ -1208,17 +1278,18 @@ BetaCudaDeviceInterface::upload_cpu_frame_to_gpu_on_current_stream(
12081278 gpu_frame->data [p] = storage_ptr + plane_offsets[p];
12091279 gpu_frame->linesize [p] = row_bytes;
12101280
1211- // Note that we use cudaMemcpy2D here instead of cudaMemcpy because the
1212- // linesizes (strides) may be different than the widths for the input CPU
1213- // frame. That's precisely what cudaMemcpy2D is for.
1214- cudaError_t err = cudaMemcpy2D (
1281+ // Note that we use cudaMemcpy2DAsync here instead of cudaMemcpyAsync
1282+ // because the linesizes (strides) may be different than the widths for the
1283+ // input CPU frame. That's precisely what the 2D variants are for.
1284+ cudaError_t err = cudaMemcpy2DAsync (
12151285 gpu_frame->data [p],
12161286 gpu_frame->linesize [p],
12171287 intermediate_cpu_frame->data [p],
12181288 intermediate_cpu_frame->linesize [p],
12191289 row_bytes,
12201290 plane_heights[p],
1221- cudaMemcpyHostToDevice);
1291+ cudaMemcpyHostToDevice,
1292+ stream);
12221293 STD_TORCH_CHECK (
12231294 err == cudaSuccess,
12241295 " Failed to copy plane " ,
@@ -1227,6 +1298,17 @@ BetaCudaDeviceInterface::upload_cpu_frame_to_gpu_on_current_stream(
12271298 cudaGetErrorString (err));
12281299 }
12291300
1301+ // intermediate_cpu_frame is freed when this function returns, so we can't
1302+ // leave the copies in flight, we must wait for it to finish. Making the
1303+ // upload truly async would mean allocating the intermediate frame in pinned
1304+ // memory and keeping it alive until the copies complete. Probably not worth
1305+ // it for this CPU fallback path that is already slow by nature.
1306+ cudaError_t err = cudaStreamSynchronize (stream);
1307+ STD_TORCH_CHECK (
1308+ err == cudaSuccess,
1309+ " Failed to wait for the CPU-to-GPU upload: " ,
1310+ cudaGetErrorString (err));
1311+
12301312 ret = av_frame_copy_props (gpu_frame.get (), &cpu_frame);
12311313 STD_TORCH_CHECK (
12321314 ret >= 0 ,
@@ -1241,6 +1323,7 @@ void BetaCudaDeviceInterface::convert_av_frame_to_frame_output(
12411323 FrameOutput& frame_output,
12421324 std::optional<torch::stable::Tensor> pre_allocated_output_tensor) {
12431325 CudaContextGuard context_guard (device_.index ());
1326+ cudaStream_t current_stream = get_current_cuda_stream (device_.index ());
12441327
12451328 // We may need to upload a frame here in case of the CPU fallback. This is
12461329 // only needed in Both() mode i.e. with the SingleStreamDecoder. The reason we
@@ -1266,7 +1349,7 @@ void BetaCudaDeviceInterface::convert_av_frame_to_frame_output(
12661349 // the color conversion below.
12671350 GpuFrameAndStorage uploaded;
12681351 if (needs_upload) {
1269- uploaded = upload_cpu_frame_to_gpu_on_current_stream (av_frame);
1352+ uploaded = upload_cpu_frame_to_gpu (av_frame, current_stream );
12701353 }
12711354 const AVFrame& gpu_frame = needs_upload ? *uploaded.av_frame : av_frame;
12721355
@@ -1291,13 +1374,14 @@ void BetaCudaDeviceInterface::convert_av_frame_to_frame_output(
12911374 gpu_frame.opaque_ref ->data );
12921375 producer_stream = attached_data->producer_stream ;
12931376 } else {
1294- // In case of CPU fallback, the producer stream is indeed the current
1295- // stream.
1296- // TODO_API_BREAKDOWN CORRECTNESS P1: when we're not in CPU fallback, what
1297- // is the producer stream? It's the NVDEC stream isn't it? I think it works
1298- // because we know the data is valid since we mapped the frame, but we might
1299- // want to document this
1300- producer_stream = get_current_cuda_stream (device_.index ());
1377+ STD_TORCH_CHECK (
1378+ mode () == Mode::Both,
1379+ " Color conversion requires the interface to be initialized for color "
1380+ " conversion." );
1381+ // Either the frame was just uploaded on the current stream, or it's a
1382+ // mapped NVDEC surface, whose content is produced by the mapping
1383+ // post-processing that receive_frame() enqueued on nvdec_output_stream_.
1384+ producer_stream = needs_upload ? current_stream : nvdec_output_stream_;
13011385 }
13021386
13031387 auto convert_frame = [&](std::optional<torch::stable::Tensor> pre_alloc)
@@ -1325,6 +1409,17 @@ void BetaCudaDeviceInterface::convert_av_frame_to_frame_output(
13251409 frame_output.data = convert_frame (/* preAlloc=*/ std::nullopt );
13261410 apply_rotation (frame_output, pre_allocated_output_tensor);
13271411 }
1412+
1413+ if (mode () == Mode::Both && !needs_upload) {
1414+ // The conversion read the mapped surface directly, and did so
1415+ // asynchronously, so the next mapping must be ordered after it.
1416+ // This is only needed in Both() mode because in ColorConverterOnly() mode,
1417+ // the frame is already standalone and doesn't come from the mapped surface.
1418+ // It's also only needed in the non-fallback mode (needs_upload is false)
1419+ // because with the fallback, the GPU frame is a copy of the CPU frame, so
1420+ // the mapped surface isn't read at all.
1421+ record_surface_read (current_stream);
1422+ }
13281423}
13291424
13301425void BetaCudaDeviceInterface::apply_rotation (
0 commit comments