Skip to content

Commit f9edbfb

Browse files
authored
Record consumers of mapped surfaces so unmap can be properly ordered (#1648)
1 parent 249270d commit f9edbfb

3 files changed

Lines changed: 106 additions & 16 deletions

File tree

src/torchcodec/_core/BetaCudaDeviceInterface.cpp

Lines changed: 90 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -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;
@@ -814,13 +825,15 @@ int BetaCudaDeviceInterface::receive_frame(UniqueAVFrame& av_frame) {
814825
// immediately after mapping them: they do the color-conversion in-between,
815826
// which involves a copy of the data, so that works.
816827
// We, OTOH, will do the color-conversion later, outside of receive_frame().
817-
// So we unmap here: just before mapping a new frame. At that point we know
818-
// 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:
819831
// - With SingleStreamDecoder, that frame was either color-converted (with a
820832
// copy), or that's a frame that was discarded in SingleStreamDecoder.
821-
// Either way, the underlying output surface can be safely re-used.
822833
// - With the "Blocks" APIs, the PacketDecoder forces a copy in
823834
// make_frame_standalone().
835+
// Those reads are asynchronous, which is what the call below accounts for.
836+
order_mapping_after_surface_read();
824837
unmap_previous_frame();
825838
CUresult result = cuvidMapVideoFrame(
826839
*decoder_.get(),
@@ -838,6 +851,41 @@ int BetaCudaDeviceInterface::receive_frame(UniqueAVFrame& av_frame) {
838851
return AVSUCCESS;
839852
}
840853

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+
841889
void BetaCudaDeviceInterface::unmap_previous_frame() {
842890
if (previously_mapped_frame_ == 0) {
843891
return;
@@ -969,6 +1017,32 @@ void BetaCudaDeviceInterface::make_frame_standalone(UniqueAVFrame& av_frame) {
9691017
storage = copy_nvdec_surface(av_frame, current_stream);
9701018
}
9711019

1020+
// TODO_API_BREAKDOWN CORRECTNESS P0: `storage` comes from the PyTorch
1021+
// caching allocator on `current_stream`, but a ColorConverter reads it from
1022+
// whatever stream it runs on. The allocator only tracks the allocating
1023+
// stream: once the frame is dropped, the block returns to `current_stream`'s
1024+
// pool with no synchronisation, and the next frame's copy - same size, same
1025+
// stream - lands right in it while the converter is still reading. Frames
1026+
// are then silently corrupted whenever the converter lags behind the
1027+
// decoder, which is the normal state of a two-stream pipeline. Measured on a
1028+
// 4K clip with the converter backlogged: 117 of 119 frames wrong, and clean
1029+
// again as soon as the frames are kept alive.
1030+
// The usual remedy is Tensor::record_stream() on the consumer side, but
1031+
// neither the stable ABI nor the AOTI shim exposes it, and StableIValue has
1032+
// no Stream conversion, so it isn't reachable through the dispatcher either.
1033+
// We don't actually need it though: the allocator recycles the block because
1034+
// *we* drop our reference too early, so it's enough to hold on to the
1035+
// storage until the consumer is done. Have the ColorConverter record an
1036+
// event on its own stream into the attached data, and make
1037+
// standalone_frame_free_callback() hand (storage, event) to a per-device
1038+
// pending-release list instead of dropping the tensor. Drain that list
1039+
// opportunistically with cudaEventQuery. No host stall, and a consumer that
1040+
// permanently lags shows up as a growing list, i.e. as backpressure rather
1041+
// than as silent corruption.
1042+
// Frames that were never converted have no event and can be released
1043+
// straight away. Raw planes handed to the user via materialize() keep the
1044+
// storage alive on their own, and once the user drops those, ordering their
1045+
// own kernels is their responsibility, same as for any other tensor.
9721046
auto attached_data = new StandAloneFrameAttachedData();
9731047
attached_data->producer_stream = current_stream;
9741048
attached_data->storage = std::move(storage);
@@ -1025,19 +1099,8 @@ torch::stable::Tensor BetaCudaDeviceInterface::copy_nvdec_surface(
10251099
"Failed to copy NVDEC surface: ",
10261100
cudaGetErrorString(err));
10271101

1028-
// TODO_API_BREAKDOWN CORRECTNESS P0: We might want to unmap here to clearly
1029-
// state that the surface memory can be reused and that there's no leak (and
1030-
// rename this into copy_and_unmap_nvdec_surface). However, regardless of
1031-
// whether we unmap here or let receive_frame() unmap, I think we have a
1032-
// problem: the copy is async, and nothing prevents a PacketDecoder from
1033-
// decoding 2 consecutive frames on 2 separate streams. The following can
1034-
// happen: with Stream():
1035-
// packet_decoder.decode() -> receive_frame() -> copy_nvdec_surface() ->
1036-
// cudaMemcpyAsync()
1037-
// with Stream():
1038-
// packet_decoder.decode() -> receive_frame() -> unmap_previous_frame()
1039-
// where unmap_previous_frame() unmaps the surface before the cudaMemcpyAsync
1040-
// is able to finish on the other stream.
1102+
// The copy is async, so the next mapping must be ordered after it.
1103+
record_surface_read(current_stream);
10411104

10421105
auto y_plane = static_cast<uint8_t*>(storage.mutable_data_ptr());
10431106
av_frame->data[0] = y_plane;
@@ -1337,6 +1400,17 @@ void BetaCudaDeviceInterface::convert_av_frame_to_frame_output(
13371400
frame_output.data = convert_frame(/*preAlloc=*/std::nullopt);
13381401
apply_rotation(frame_output, pre_allocated_output_tensor);
13391402
}
1403+
1404+
if (mode() == Mode::Both && !needs_upload) {
1405+
// The conversion read the mapped surface directly, and did so
1406+
// asynchronously, so the next mapping must be ordered after it.
1407+
// This is only needed in Both() mode because in ColorConverterOnly() mode,
1408+
// the frame is already standalone and doesn't come from the mapped surface.
1409+
// It's also only needed in the non-fallback mode (needs_upload is false)
1410+
// because with the fallback, the GPU frame is a copy of the CPU frame, so
1411+
// the mapped surface isn't read at all.
1412+
record_surface_read(current_stream);
1413+
}
13401414
}
13411415

13421416
void BetaCudaDeviceInterface::apply_rotation(

src/torchcodec/_core/BetaCudaDeviceInterface.h

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,15 @@ class BetaCudaDeviceInterface : public DeviceInterface {
105105

106106
cudaStream_t nvdec_output_stream_ = nullptr;
107107

108+
// NVDEC gives us a single output surface, so every mapped frame lives at the
109+
// same address and a new mapping overwrites whatever the previous frame's
110+
// consumer is reading. These track that read so the next mapping can be
111+
// ordered after it. See order_mapping_after_surface_read().
112+
cudaEvent_t surface_read_done_ = nullptr;
113+
cudaStream_t surface_reader_stream_ = nullptr;
114+
void record_surface_read(cudaStream_t stream);
115+
void order_mapping_after_surface_read();
116+
108117
UniqueAVFrame convert_cuda_frame_to_av_frame(
109118
CUdeviceptr frame_ptr,
110119
unsigned int pitch,

src/torchcodec/_core/CUDACommon.cpp

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,13 @@ namespace facebook::torchcodec {
1313

1414
// Make waitingStream wait until all work currently enqueued on runningStream
1515
// has completed.
16+
// TODO_API_BREAKDOWN PERF P2: this creates and destroys a cudaEvent_t on every
17+
// call, and a timing-enabled one at that, which is the more expensive flavour.
18+
// It sits on the per-frame path: convert_yuv_frame_to_rgb() calls it for every
19+
// single frame, including when both streams are the same and the whole thing
20+
// is a no-op. Two easy wins: return early when the two streams are equal, and
21+
// take a caller-owned event created with cudaEventDisableTiming instead of
22+
// allocating one here (see record_surface_read() in BetaCudaDeviceInterface).
1623
void sync_streams(cudaStream_t running_stream, cudaStream_t waiting_stream) {
1724
cudaEvent_t event;
1825
cudaError_t err = cudaEventCreate(&event);

0 commit comments

Comments
 (0)