Skip to content

Commit 1412562

Browse files
committed
Merge branch 'main' of github.com:meta-pytorch/torchcodec into crop-dance-fix
2 parents b3201f8 + f9edbfb commit 1412562

7 files changed

Lines changed: 216 additions & 57 deletions

File tree

src/torchcodec/_core/BetaCudaDeviceInterface.cpp

Lines changed: 143 additions & 48 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;
@@ -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+
839889
void 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

9861062
torch::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

13301425
void BetaCudaDeviceInterface::apply_rotation(

src/torchcodec/_core/BetaCudaDeviceInterface.h

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -103,15 +103,27 @@ class BetaCudaDeviceInterface : public DeviceInterface {
103103
CUdeviceptr previously_mapped_frame_ = 0;
104104
void unmap_previous_frame();
105105

106+
cudaStream_t nvdec_output_stream_ = nullptr;
107+
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+
106117
UniqueAVFrame convert_cuda_frame_to_av_frame(
107118
CUdeviceptr frame_ptr,
108119
unsigned int pitch,
109120
const CUVIDPARSERDISPINFO& disp_info);
110121

111122
void make_frame_standalone(UniqueAVFrame& av_frame) override;
112123

113-
GpuFrameAndStorage upload_cpu_frame_to_gpu_on_current_stream(
114-
const AVFrame& cpu_frame);
124+
GpuFrameAndStorage upload_cpu_frame_to_gpu(
125+
const AVFrame& cpu_frame,
126+
cudaStream_t stream);
115127

116128
torch::stable::Tensor copy_nvdec_surface(
117129
UniqueAVFrame& av_frame,

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)