Skip to content

Commit 031201a

Browse files
authored
Fix silent frame corruption when a ColorConverter runs on its own CUDA stream (#1651)
1 parent 2c6f32c commit 031201a

9 files changed

Lines changed: 163 additions & 33 deletions

File tree

src/torchcodec/_core/BetaCudaDeviceInterface.cpp

Lines changed: 56 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1019,32 +1019,6 @@ void BetaCudaDeviceInterface::make_frame_standalone(UniqueAVFrame& av_frame) {
10191019
storage = copy_nvdec_surface(av_frame, current_stream);
10201020
}
10211021

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.
10481022
auto attached_data = new StandAloneFrameAttachedData();
10491023
attached_data->producer_stream = current_stream;
10501024
attached_data->storage = std::move(storage);
@@ -1059,6 +1033,62 @@ void BetaCudaDeviceInterface::make_frame_standalone(UniqueAVFrame& av_frame) {
10591033
"Failed to attach standalone frame data");
10601034
}
10611035

1036+
std::optional<torch::stable::Tensor> BetaCudaDeviceInterface::get_frame_storage(
1037+
const AVFrame& av_frame) const {
1038+
STD_TORCH_CHECK(
1039+
// Only decoder-only should reach here, and this should only be called on
1040+
// frames that went through make_frame_standalone(), which sets
1041+
// opaque_ref.
1042+
mode() == Mode::DecoderOnly && av_frame.opaque_ref != nullptr,
1043+
"Unexpected call to get_frame_storage(), please report a bug ");
1044+
1045+
// Note [Standalone Frame Storage and the need for record_stream]
1046+
//
1047+
// A PacketDecoder and a ColorConverter may run on different CUDA streams.
1048+
// Consider the following:
1049+
//
1050+
// ```
1051+
// with decoder_stream:
1052+
// frame = decoder.receive_frame()
1053+
// with color_converter_stream:
1054+
// color_converter.convert(frame)
1055+
//
1056+
// del frame
1057+
//
1058+
// with decoder_stream:
1059+
// frame = decoder.receive_frame()
1060+
// ```
1061+
//
1062+
// The call to convert(frame) is non-blocking and just enqueues the
1063+
// color-conversion kernel. The CPU moves on immediately to `del frame` while
1064+
// the kernel is still running (it may also not even have started depending on
1065+
// how color_converter_stream is congested).
1066+
//
1067+
// When the frame is deleted, the torch CUDA allocator reclaims its memory and
1068+
// it becomes available for reuse for any subsequent allocation on the
1069+
// decoder_stream. If the next decoder.receive_frame() happens before the
1070+
// color-conversion kernel has finished (specifically: the new storage
1071+
// allocation for that next frame in make_frame_standalone()), the memory is
1072+
// reused, overwritten, and the color-conversion kernel reads garbage (i.e.
1073+
// the next frame's samples!).
1074+
//
1075+
// We're hitting exactly what
1076+
// https://zdevito.github.io/2022/08/04/cuda-caching-allocator.html describes
1077+
// in the 'Streams and freeing memory' section, and the solution is to call
1078+
// record_stream() on the frame's storage within color_conversion_stream just
1079+
// after the kernel is enqueued: this tells the allocator that it must wait
1080+
// until this point (on the device side) before reclaiming the memory.
1081+
//
1082+
// We call record_stream(color_conversion_stream) on the frame storage in the
1083+
// ColorConverter, on behalf of the user. But we still must expose the storage
1084+
// for those users who would like to consume the frame with their own
1085+
// consumer, i.e. not using the ColorConverter: they need to call
1086+
// frame.storage.record_stream(color_conversion_stream) themselves.
1087+
return reinterpret_cast<StandAloneFrameAttachedData*>(
1088+
av_frame.opaque_ref->data)
1089+
->storage;
1090+
}
1091+
10621092
torch::stable::Tensor BetaCudaDeviceInterface::copy_nvdec_surface(
10631093
UniqueAVFrame& av_frame,
10641094
cudaStream_t current_stream) {

src/torchcodec/_core/BetaCudaDeviceInterface.h

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,9 @@ class BetaCudaDeviceInterface : public DeviceInterface {
121121

122122
void make_frame_standalone(UniqueAVFrame& av_frame) override;
123123

124+
std::optional<torch::stable::Tensor> get_frame_storage(
125+
const AVFrame& av_frame) const override;
126+
124127
GpuFrameAndStorage upload_cpu_frame_to_gpu(
125128
const AVFrame& cpu_frame,
126129
cudaStream_t stream);

src/torchcodec/_core/DeviceInterface.h

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,11 @@ class DeviceInterface {
160160
virtual void make_frame_standalone([[maybe_unused]] UniqueAVFrame& av_frame) {
161161
};
162162

163+
virtual std::optional<torch::stable::Tensor> get_frame_storage(
164+
[[maybe_unused]] const AVFrame& av_frame) const {
165+
return std::nullopt;
166+
}
167+
163168
// Flush remaining frames from decoder
164169
virtual void flush() {
165170
STD_TORCH_CHECK(

src/torchcodec/_core/PacketDecoder.h

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,11 @@ class FORCE_PUBLIC_VISIBILITY PacketDecoder {
4646
// if more input is needed, AVERROR_EOF at end, or a negative error code.
4747
int receive_frame(UniqueAVFrame& av_frame);
4848

49+
std::optional<torch::stable::Tensor> get_frame_storage(
50+
const AVFrame& av_frame) const {
51+
return device_interface_->get_frame_storage(av_frame);
52+
}
53+
4954
const StableDevice& device() const {
5055
return device_interface_->device();
5156
}

src/torchcodec/_core/custom_ops.cpp

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ STABLE_TORCH_LIBRARY_FRAGMENT(torchcodec_ns, m) {
8282
"_blocks_packet_decoder_send_packet(Tensor(a!) decoder, Tensor packet) -> int");
8383
m.def("_blocks_packet_decoder_send_eof(Tensor(a!) decoder) -> int");
8484
m.def(
85-
"_blocks_packet_decoder_receive_frame(Tensor(a!) decoder) -> (Tensor, int, float, float, str)");
85+
"_blocks_packet_decoder_receive_frame(Tensor(a!) decoder) -> (Tensor, int, float, float, str, Tensor)");
8686
m.def(
8787
"_blocks_create_color_converter(str device=\"cpu\", str output_dtype=\"uint8\") -> Tensor");
8888
m.def("_blocks_convert_frame(Tensor(a!) converter, Tensor frame) -> Tensor");
@@ -855,9 +855,14 @@ std::string device_to_string(const StableDevice& device) {
855855
return name;
856856
}
857857

858-
// (frame_handle, status, pts_seconds, duration_seconds, device).
859-
using OpsReceiveFrameOutput =
860-
std::tuple<torch::stable::Tensor, int64_t, double, double, std::string>;
858+
// (frame_handle, status, pts_seconds, duration_seconds, device, storage).
859+
using OpsReceiveFrameOutput = std::tuple<
860+
torch::stable::Tensor,
861+
int64_t,
862+
double,
863+
double,
864+
std::string,
865+
torch::stable::Tensor>;
861866

862867
OpsReceiveFrameOutput _blocks_packet_decoder_receive_frame(
863868
torch::stable::Tensor& decoder) {
@@ -871,7 +876,8 @@ OpsReceiveFrameOutput _blocks_packet_decoder_receive_frame(
871876
static_cast<int64_t>(status),
872877
0.0,
873878
0.0,
874-
std::string("cpu"));
879+
std::string("cpu"),
880+
torch::stable::empty({int64_t(0)}, kStableUInt8));
875881
}
876882
AVRational time_base = decoder_ptr->time_base();
877883
double pts_seconds = pts_to_seconds(get_pts_or_dts(*av_frame), time_base);
@@ -881,12 +887,16 @@ OpsReceiveFrameOutput _blocks_packet_decoder_receive_frame(
881887
// TODO_API_BREAKDOWN DESIGN P1: Not sure we need to return the device at all,
882888
// the device should always match the device parameter now.
883889
std::string device = device_to_string(decoder_ptr->device());
890+
torch::stable::Tensor storage =
891+
decoder_ptr->get_frame_storage(*av_frame).value_or(
892+
torch::stable::empty({int64_t(0)}, kStableUInt8));
884893
return std::make_tuple(
885894
wrap_pointer_to_tensor(std::move(av_frame)),
886895
static_cast<int64_t>(0),
887896
pts_seconds,
888897
duration_seconds,
889-
device);
898+
device,
899+
storage);
890900
}
891901

892902
torch::stable::Tensor _blocks_create_color_converter(

src/torchcodec/decoders/_blocks/_color_converter.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,9 @@ def __init__(
5252

5353
def convert(self, decoded_frame: DecodedFrame) -> Frame:
5454
data = _blocks_convert_frame(self._handle, decoded_frame._handle)
55+
if decoded_frame.storage is not None:
56+
# See [Standalone Frame Storage and the need for record_stream]
57+
decoded_frame.storage.record_stream(torch.cuda.current_stream())
5558
# The core op produces HWC; permute to CHW to match VideoDecoder (which
5659
# also returns a non-contiguous permuted view).
5760
data = data.permute(2, 0, 1)

src/torchcodec/decoders/_blocks/_frame.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ def __init__(self, handle: torch.Tensor):
3535
# and the ColorConverter honors it - but nothing surfaces it in Python, so
3636
# whoever color-converts the planes themselves has no way of knowing the frame
3737
# needs rotating. And should reported height and width be pre or post rotation?
38+
# Same for storage: where should we expose it?
3839
@dataclass
3940
class RawFrame:
4041
planes: tuple[torch.Tensor, ...]
@@ -81,9 +82,11 @@ def __init__(
8182
pts_seconds: float,
8283
duration_seconds: float,
8384
device: str = "cpu",
85+
storage: torch.Tensor | None = None,
8486
):
8587
self._handle = handle
8688
self._device = device
89+
self.storage = storage
8790
self.pts_seconds = pts_seconds
8891
self.duration_seconds = duration_seconds
8992

src/torchcodec/decoders/_blocks/_packet_decoder.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ def __init__(self, demuxer: Demuxer, device="cpu"):
4040
def _drain(self) -> list[DecodedFrame]:
4141
frames = []
4242
while True:
43-
handle, status, pts_seconds, duration_seconds, device = (
43+
handle, status, pts_seconds, duration_seconds, device, storage = (
4444
_blocks_packet_decoder_receive_frame(self._handle)
4545
)
4646
if status != 0: # EAGAIN (need more packets) or EOF: nothing ready
@@ -51,6 +51,7 @@ def _drain(self) -> list[DecodedFrame]:
5151
pts_seconds,
5252
duration_seconds,
5353
device=device,
54+
storage=storage if storage.numel() > 0 else None,
5455
)
5556
)
5657
return frames

test/test_decoders.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import concurrent.futures
88
import contextlib
99
import gc
10+
import itertools
1011
import math
1112
import queue
1213
import threading
@@ -3974,6 +3975,75 @@ def test_materialize_planes_are_not_rotated_but_color_conversion_rotates(
39743975
assert Y.shape == (width, height)
39753976
assert converter.convert(frame).data.shape == (3, height, width)
39763977

3978+
@pytest.mark.needs_cuda
3979+
@pytest.mark.parametrize("record_stream", (True, False))
3980+
def test_storage_record_stream(self, record_stream):
3981+
# Using PacketDecoder on one stream and consuming the frames on a
3982+
# different stream requires the user to call record_stream() on the
3983+
# frame storage.
3984+
# Without the record_stream() call the decoder's next frame may be
3985+
# handed the same buffer and overwrites it while the read is still
3986+
# queued.
3987+
# See [Standalone Frame Storage and the need for record_stream]
3988+
video = NASA_VIDEO.path
3989+
decode_stream = torch.cuda.Stream()
3990+
read_stream = torch.cuda.Stream()
3991+
3992+
def run(separate_stream):
3993+
demuxer, decoder, _ = self._make_blocks(video, "cuda")
3994+
reads = []
3995+
for packet in itertools.chain(demuxer, [None]):
3996+
with torch.cuda.stream(decode_stream):
3997+
decoded = (
3998+
decoder.flush() if packet is None else decoder.decode(packet)
3999+
)
4000+
with torch.cuda.stream(
4001+
read_stream if separate_stream else decode_stream
4002+
):
4003+
while decoded:
4004+
frame = decoded.pop(0)
4005+
torch.cuda._sleep(20_000_000) # ~10ms, fall behind
4006+
reads.append(frame.materialize().planes[0].clone())
4007+
if separate_stream and record_stream:
4008+
frame.storage.record_stream(read_stream)
4009+
torch.cuda.synchronize()
4010+
return reads
4011+
4012+
ref = run(separate_stream=False)
4013+
got = run(separate_stream=True)
4014+
wrong = sum(1 for a, b in zip(ref, got) if not torch.equal(a, b))
4015+
if record_stream:
4016+
assert wrong == 0
4017+
else:
4018+
# Guards the test itself: without the call this really does corrupt,
4019+
# so the assertion above is meaningful.
4020+
assert wrong > 0
4021+
4022+
@pytest.mark.needs_cuda
4023+
def test_backlogged_converter_on_separate_stream(self):
4024+
# Similar test to test_storage_record_stream(), but with the ColorConverter on a
4025+
# separate stream. In this case, *we* call record_stream() on behalf of
4026+
# the user.
4027+
# See [Standalone Frame Storage and the need for record_stream]
4028+
video = NASA_VIDEO.path
4029+
demuxer, decoder, converter = self._make_blocks(video, "cuda")
4030+
decode_stream = torch.cuda.Stream()
4031+
convert_stream = torch.cuda.Stream()
4032+
4033+
frames = []
4034+
for packet in itertools.chain(demuxer, [None]):
4035+
with torch.cuda.stream(decode_stream):
4036+
decoded = decoder.flush() if packet is None else decoder.decode(packet)
4037+
with torch.cuda.stream(convert_stream):
4038+
while decoded:
4039+
torch.cuda._sleep(20_000_000) # ~10ms
4040+
frames.append(converter.convert(decoded.pop(0)))
4041+
torch.cuda.synchronize()
4042+
4043+
got = self._to_frame_batch(frames)
4044+
ref = VideoDecoder(video, device="cuda").get_all_frames()
4045+
torch.testing.assert_close(got.data, ref.data, atol=0, rtol=0)
4046+
39774047
@pytest.mark.needs_cuda
39784048
@pytest.mark.parametrize("case", _MATERIALIZE_VIDEOS, ids=_materialize_ids)
39794049
def test_materialize_cuda_planes_match_cpu(self, case):

0 commit comments

Comments
 (0)