Skip to content

Commit d115fcc

Browse files
committed
Add Cuda Context guard
1 parent e04e070 commit d115fcc

9 files changed

Lines changed: 80 additions & 59 deletions

File tree

src/torchcodec/_core/BetaCudaDeviceInterface.cpp

Lines changed: 57 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,8 @@ std::optional<cudaVideoSurfaceFormat> get_nvdec_surface_format(
266266

267267
// Callback for freeing CUDA memory associated with AVFrame see where it's used
268268
// for more details.
269+
// TODO_API_BREAKDOWN P2: Should we align this with the other free callback
270+
// below? Why did we use cudaMalloc? Can we just allocate with torch??
269271
void cuda_buffer_free_callback(void* opaque, [[maybe_unused]] uint8_t* data) {
270272
cudaFree(opaque);
271273
}
@@ -276,6 +278,36 @@ void standalone_frame_free_callback(
276278
delete reinterpret_cast<StandAloneFrameAttachedData*>(data);
277279
}
278280

281+
class CudaContextGuard {
282+
// There's one CUDA context per process per device. But new threads aren't
283+
// bound to a context. The binding often happens automatically when calling
284+
// CUDA APIs (like cudaFree), but some APIs like the NVCUVID ones that we use
285+
// here aren't automatically binding.
286+
// So for a thread to be able to use NVCUVID APIs, it must have a context
287+
// bound to it, and we have to enforce that binding manually.
288+
// That's what this guard does: it calls cudaFree(nullptr), which is a common
289+
// near-free way to force the CUDA runtime to bind the context for the current
290+
// thread. And this call must happen within a device guard to make sure we're
291+
// binding the context of the device this interface is using.
292+
// We must call this guard in every public method of the interface that uses
293+
// NVCUVID APIs, because these methods can, in theory, be called from any
294+
// thread.
295+
// Note that none of this was an issue before when our only entry-point was
296+
// the SingleStreamDecoder: all the entry-points were called from the same
297+
// thread. Now that we have split the APIs in different blocks (PacketDecoder,
298+
// ColorConverter), each of these blocks can be on different threads - and
299+
// importantly, they can be created in the main thread (where the context is
300+
// bound by our call to initialize_cuda_context_with_pytorch()), but then used
301+
// in a different thread that doesn't have the context.
302+
public:
303+
explicit CudaContextGuard(int device_index) : device_guard_(device_index) {
304+
cudaFree(nullptr);
305+
}
306+
307+
private:
308+
StableDeviceGuard device_guard_;
309+
};
310+
279311
} // namespace
280312

281313
BetaCudaDeviceInterface::BetaCudaDeviceInterface(const StableDevice& device)
@@ -284,6 +316,9 @@ BetaCudaDeviceInterface::BetaCudaDeviceInterface(const StableDevice& device)
284316
STD_TORCH_CHECK(
285317
device_.type() == kStableCUDA, "Unsupported device: must be CUDA");
286318

319+
// Note: now that we have the CudaContextGuard, we might not need to do that
320+
// anymore. The comment says we need pytorch to create the context - maybe
321+
// that's true, but that's a very old comment now.
287322
initialize_cuda_context_with_pytorch(device_);
288323

289324
nvcuvid_available_ = load_nvcuvid_library();
@@ -295,11 +330,12 @@ void BetaCudaDeviceInterface::initialize_video(
295330
const VideoStreamOptions& video_stream_options,
296331
const std::vector<std::unique_ptr<Transform>>& transforms,
297332
const std::optional<FrameDims>& resized_output_dims) {
298-
// TODO_API_BREAKDOWN ewwwww
333+
// TODO_API_BREAKDOWN P0
299334
if (!av_stream) {
300335
return;
301336
}
302337
STD_TORCH_CHECK(av_stream != nullptr, "AVStream cannot be null");
338+
CudaContextGuard context_guard(device_.index());
303339
rotation_ = rotation_from_degrees(get_rotation_from_stream(av_stream));
304340
output_dtype_ = video_stream_options.output_dtype;
305341

@@ -403,6 +439,7 @@ void BetaCudaDeviceInterface::send_seqhdr_packet() {
403439
}
404440

405441
BetaCudaDeviceInterface::~BetaCudaDeviceInterface() {
442+
CudaContextGuard context_guard(device_.index());
406443
if (decoder_) {
407444
// DALI doesn't seem to do any particular cleanup of the decoder before
408445
// sending it to the cache, so we probably don't need to do anything either.
@@ -554,6 +591,7 @@ int BetaCudaDeviceInterface::stream_property_change(
554591
// Moral equivalent of avcodec_send_packet(). Here, we pass the AVPacket down to
555592
// the NVCUVID parser.
556593
int BetaCudaDeviceInterface::send_packet(ReferenceAVPacket& packet) {
594+
CudaContextGuard context_guard(device_.index());
557595
if (cpu_fallback_) {
558596
return cpu_fallback_->send_packet(packet);
559597
}
@@ -581,6 +619,7 @@ int BetaCudaDeviceInterface::send_packet(ReferenceAVPacket& packet) {
581619
}
582620

583621
int BetaCudaDeviceInterface::send_eof_packet() {
622+
CudaContextGuard context_guard(device_.index());
584623
if (cpu_fallback_) {
585624
return cpu_fallback_->send_eof_packet();
586625
}
@@ -656,6 +695,7 @@ int BetaCudaDeviceInterface::frame_ready_in_display_order(
656695

657696
// Moral equivalent of avcodec_receive_frame().
658697
int BetaCudaDeviceInterface::receive_frame(UniqueAVFrame& av_frame) {
698+
CudaContextGuard context_guard(device_.index());
659699
if (cpu_fallback_) {
660700
return cpu_fallback_->receive_frame(av_frame);
661701
}
@@ -698,8 +738,8 @@ int BetaCudaDeviceInterface::receive_frame(UniqueAVFrame& av_frame) {
698738
// color-converted (with a copy), or that's a frame that was discarded in
699739
// SingleStreamDecoder. Either way, the underlying output surface can be
700740
// safely re-used.
701-
// TODO_API_BREAKDOWN: We should update this comment slightly to now account
702-
// for the frame copy we do in make_frame_standalone()
741+
// TODO_API_BREAKDOWN P1: We should update this comment slightly to now
742+
// account for the frame copy we do in make_frame_standalone()
703743
unmap_previous_frame();
704744
CUresult result = cuvidMapVideoFrame(
705745
*decoder_.get(),
@@ -807,7 +847,7 @@ UniqueAVFrame BetaCudaDeviceInterface::convert_cuda_frame_to_av_frame(
807847
reinterpret_cast<uint8_t*>(frame_ptr + (pitch * even_height));
808848
av_frame->data[2] = nullptr;
809849
av_frame->data[3] = nullptr;
810-
// TODO_API_BREAKDOWN_CUDA: Check range before cast?
850+
// TODO_API_BREAKDOWN_CUDA P2: Check range before cast?
811851
av_frame->linesize[0] = static_cast<int>(pitch);
812852
av_frame->linesize[1] = static_cast<int>(pitch);
813853
av_frame->linesize[2] = 0;
@@ -816,10 +856,8 @@ UniqueAVFrame BetaCudaDeviceInterface::convert_cuda_frame_to_av_frame(
816856
return av_frame;
817857
}
818858

819-
// TODO_API_BREAKDOWN_CUDA: Does this even nede to be a method? Maybe it can be
820-
// a function that just lives in the PacketDecoder so we don't need to expose
821-
// another API to the DeviceInterface?
822859
void BetaCudaDeviceInterface::make_frame_standalone(UniqueAVFrame& av_frame) {
860+
CudaContextGuard context_guard(device_.index());
823861
if (!(av_frame->format == AV_PIX_FMT_P016LE ||
824862
av_frame->format == AV_PIX_FMT_NV12)) {
825863
// The CPU frames are already standalone, so we don't need to do anything.
@@ -832,7 +870,7 @@ void BetaCudaDeviceInterface::make_frame_standalone(UniqueAVFrame& av_frame) {
832870
// = num_pixels * 3 / 2
833871
//
834872
// To make it correct, we should use num_pixels = pitch * height, not
835-
// num_pixels = pitch * height. The pitch value also accounts for the data
873+
// num_pixels = width * height. The pitch value also accounts for the data
836874
// size (uint8 vs uint16) so this is also correct for P016.
837875
int64_t even_height =
838876
static_cast<int64_t>(round_up_to_even(av_frame->height));
@@ -842,7 +880,7 @@ void BetaCudaDeviceInterface::make_frame_standalone(UniqueAVFrame& av_frame) {
842880
auto storage =
843881
torch::stable::empty({num_bytes}, kStableUInt8, std::nullopt, device_);
844882

845-
// TODO_API_BREAKDOWN_CUDA: I suspect we don't need to wait on the nvdec
883+
// TODO_API_BREAKDOWN_CUDA P1: I suspect we don't need to wait on the nvdec
846884
// stream here, because we can only arrive here from a path where the frame
847885
// has already been mapped so its data is available - worth double checking.
848886
cudaStream_t current_stream = get_current_cuda_stream(device_.index());
@@ -857,7 +895,7 @@ void BetaCudaDeviceInterface::make_frame_standalone(UniqueAVFrame& av_frame) {
857895
"Failed to copy NVDEC surface: ",
858896
cudaGetErrorString(err));
859897

860-
// TODO_API_BREAKDOWN_CUDA: Should we unmap here? Or let the next
898+
// TODO_API_BREAKDOWN_CUDA P2: Should we unmap here? Or let the next
861899
// receive_frame() call do it?
862900
// unmap_previous_frame();
863901

@@ -867,7 +905,7 @@ void BetaCudaDeviceInterface::make_frame_standalone(UniqueAVFrame& av_frame) {
867905

868906
auto attached_data = new StandAloneFrameAttachedData();
869907
attached_data->producer_stream = current_stream;
870-
// TODO_API_BREAKDOWN_CUDA: We don't *really* need to std::move it I guess?
908+
// TODO_API_BREAKDOWN_CUDA P2: We don't *really* need to std::move it I guess?
871909
attached_data->storage = std::move(storage);
872910
av_frame->opaque_ref = av_buffer_create(
873911
reinterpret_cast<uint8_t*>(attached_data),
@@ -878,6 +916,7 @@ void BetaCudaDeviceInterface::make_frame_standalone(UniqueAVFrame& av_frame) {
878916
}
879917

880918
void BetaCudaDeviceInterface::flush() {
919+
CudaContextGuard context_guard(device_.index());
881920
if (cpu_fallback_) {
882921
cpu_fallback_->flush();
883922
return;
@@ -1044,9 +1083,10 @@ void BetaCudaDeviceInterface::convert_av_frame_to_frame_output(
10441083
const AVFrame& av_frame,
10451084
FrameOutput& frame_output,
10461085
std::optional<torch::stable::Tensor> pre_allocated_output_tensor) {
1047-
// TODO_API_BREAKDOWN_CUDA is that accurate and safe? Can there be a CPU NV12
1048-
// frame in our code? Should we create a helper used in the make_standalone
1049-
// function too?
1086+
CudaContextGuard context_guard(device_.index());
1087+
// TODO_API_BREAKDOWN_CUDA P0 is that accurate and safe? Can there be a CPU
1088+
// NV12 frame in our code? Should we create a helper used in the
1089+
// make_standalone function too?
10501090
bool cpu_fallback = av_frame.format != AV_PIX_FMT_NV12 &&
10511091
av_frame.format != AV_PIX_FMT_P016LE;
10521092

@@ -1062,7 +1102,7 @@ void BetaCudaDeviceInterface::convert_av_frame_to_frame_output(
10621102
av_pix_fmt_desc_get(static_cast<AVPixelFormat>(av_frame.format));
10631103
bool is444 = desc && desc->log2_chroma_w == 0 && desc->log2_chroma_h == 0;
10641104
if (is444) {
1065-
// TODO_API_BREAKDOWN we need to handle this
1105+
// TODO_API_BREAKDOWN P1: we need to handle this
10661106
FrameOutput cpu_frame_output;
10671107
cpu_fallback_->convert_av_frame_to_frame_output(
10681108
av_frame, cpu_frame_output);
@@ -1098,7 +1138,7 @@ void BetaCudaDeviceInterface::convert_av_frame_to_frame_output(
10981138
gpu_frame.format == AV_PIX_FMT_P016LE,
10991139
"Expected NV12 or P016LE format frame");
11001140

1101-
// TODO_API_BREAKDOWN: Cleanup how we get the attached data? Make it more
1141+
// TODO_API_BREAKDOWN P1: Cleanup how we get the attached data? Make it more
11021142
// robust? Should we couple it to a flag on the interface saying "I'm
11031143
// color-conversion only, I absolutely expect frames to be standalone"?
11041144
cudaStream_t producer_stream;
@@ -1111,7 +1151,7 @@ void BetaCudaDeviceInterface::convert_av_frame_to_frame_output(
11111151
producer_stream = get_current_cuda_stream(device_.index());
11121152
}
11131153

1114-
// TODO_API_BREAKDOWN: we don't suppor output_dtype so some of that is not
1154+
// TODO_API_BREAKDOWN P1: we don't suppor output_dtype so some of that is not
11151155
// execrcized.
11161156
auto convert_frame = [&](std::optional<torch::stable::Tensor> pre_alloc)
11171157
-> torch::stable::Tensor {

src/torchcodec/_core/ColorConverter.cpp

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,14 +25,14 @@ ColorConverter::ColorConverter(const StableDevice& device) {
2525
options.output_dtype = OutputDtype::UINT8; // dtype not exposed yet
2626
options.device = device;
2727

28-
// TODO_API_BREAKDOWN It seems unnatural that the color-converter needs its
28+
// TODO_API_BREAKDOWN P1 It seems unnatural that the color-converter needs its
2929
// own device_interface_, but at the same time the color-conversion *must* be
3030
// third-party aware, and the only way to achieve that for now is via the
3131
// interface. Should at the very least write a note about this design that now
3232
// the DeviceInterface has different modes: decode only, color-convert only,
3333
// and decode+color-convert (which used to be the only mode).
3434

35-
// TODO_API_BREAKDOWN: we shouldn't call initialize_video here, this is for
35+
// TODO_API_BREAKDOWN P0: we shouldn't call initialize_video here, this is for
3636
// the decoding+color-convert mode. We should do something cleaner e.g.
3737
// initialize_color_convertion_only()
3838
std::vector<std::unique_ptr<Transform>> no_transforms;

src/torchcodec/_core/CpuDeviceInterface.cpp

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ void CpuDeviceInterface::initialize_video(
8686
const VideoStreamOptions& video_stream_options,
8787
const std::vector<std::unique_ptr<Transform>>& transforms,
8888
const std::optional<FrameDims>& resized_output_dims) {
89-
// TODO_API_BREAKDOWN this used to be:
89+
// TODO_API_BREAKDOWN P0 this used to be:
9090
// STD_TORCH_CHECK(av_stream != nullptr, "avStream is null");
9191
// time_base_ = av_stream->time_base;
9292
// but now that avStrean can be null (to create a standalone color converter)

src/torchcodec/_core/Demuxer.cpp

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ Demuxer::Demuxer(
6969
}
7070

7171
UniqueAVPacket Demuxer::next_packet() {
72-
// TODO_API_BREAKDOWN: Not a fan of the ReferenceAVPacket / AutoAVPacket /
72+
// TODO_API_BREAKDOWN P2: Not a fan of the ReferenceAVPacket / AutoAVPacket /
7373
// UniqueAVPacket dance here. Can we simplify?
7474
ReferenceAVPacket packet(auto_packet_);
7575
int status =

src/torchcodec/_core/PacketDecoder.cpp

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
namespace facebook::torchcodec {
1010

11-
// TODO_API_BREAKDOWN: we should make sure the block APIs can dispatch to
11+
// TODO_API_BREAKDOWN P1: we should make sure the block APIs can dispatch to
1212
// third-party extensions - all of them.
1313

1414
SharedAVCodecContext create_and_open_codec_context(
@@ -75,7 +75,7 @@ PacketDecoder::PacketDecoder(
7575
options.output_dtype = OutputDtype::UINT8; // dtype not exposed yet
7676
options.device = device;
7777

78-
// TODO_API_BREAKDOWN: This isn't right, it's needed only for the NVDEC
78+
// TODO_API_BREAKDOWN P0: This isn't right, it's needed only for the NVDEC
7979
// interface. This should probably be initialize_video_only - there's a
8080
// sibling TODO in the ColorConverter code (about color-conversion only.)
8181
std::vector<std::unique_ptr<Transform>> no_transforms;

src/torchcodec/_core/custom_ops.cpp

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -827,7 +827,7 @@ int64_t _blocks_packet_decoder_send_packet(
827827
torch::stable::Tensor& decoder,
828828
torch::stable::Tensor& packet) {
829829
PacketDecoder* decoder_ptr = unwrap_tensor_to_pointer<PacketDecoder>(decoder);
830-
// TODO_API_BREAKDOWN: Do we really need this to be a raw AVPacket*?
830+
// TODO_API_BREAKDOWN P1: Do we really need this to be a raw AVPacket*?
831831
AVPacket* raw_packet = unwrap_tensor_to_pointer<AVPacket>(packet);
832832
return static_cast<int64_t>(decoder_ptr->send_packet(raw_packet));
833833
}

src/torchcodec/decoders/_blocks/_color_converter.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,10 @@
1111

1212
from ._frame import DecodedFrame
1313

14-
# TODO_API_BREAKDOWN support output_dtype?
15-
# TODO_API_BREAKDOWN Expose output_shape?
16-
17-
# TODO_API_BREAKDOWN We need to support rotation metadata!!
14+
# TODO_API_BREAKDOWN FEAT support output_dtype?
15+
# TODO_API_BREAKDOWN FEAT We need to support rotation metadata!!
16+
# TODO_API_BREAKDOWN FEAT Implement seeking?
17+
# TODO_API_BREAKDOWN FEAT Implement range-getting (start/end time) for decoding?
1818

1919

2020
class ColorConverter:
@@ -30,8 +30,8 @@ class ColorConverter:
3030
block is intentionally stream-agnostic.
3131
"""
3232

33-
# TODO_API_BREAKDOWN: device default should be None
34-
# TODO_API_BREAKDOWN: add checks for coupling between device param of
33+
# TODO_API_BREAKDOWN P1: device default should be None
34+
# TODO_API_BREAKDOWN P1: add checks for coupling between device param of
3535
# PacketDecoder and ColorConverter. What if one is CPU and the other is
3636
# CUDA? What if they're different CUDA devices? Maybe we should just error.
3737
def __init__(self, device="cpu"):

src/torchcodec/decoders/_blocks/_packet_decoder.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
from ._frame import DecodedFrame, Packet
1818

1919

20-
# TODO_API_BREAKDOWN revisit every single docstring / comments at some point.
20+
# TODO_API_BREAKDOWN P2 revisit every single docstring / comments at some point.
2121

2222

2323
class PacketDecoder:
@@ -31,7 +31,7 @@ class PacketDecoder:
3131
on your own threads.
3232
"""
3333

34-
# TODO_API_BREAKDOWN: device default should be None
34+
# TODO_API_BREAKDOWN P1: device default should be None, here and everywhere else
3535
def __init__(self, demuxer: Demuxer, device="cpu"):
3636
self._handle = _blocks_create_packet_decoder(
3737
demuxer._handle, num_threads=1, device=device
@@ -56,7 +56,7 @@ def decode(self, packet: Packet) -> list[DecodedFrame]:
5656
raise RuntimeError(f"Failed to send packet to decoder (status {status})")
5757
return self._drain()
5858

59-
# TODO_API_BREAKDOWN maybe this shouldn't be called flush, at least not
59+
# TODO_API_BREAKDOWN P2 maybe this shouldn't be called flush, at least not
6060
# as-is. It's not the same flush as the FFmpeg decoder buffer flush.
6161
def flush(self) -> list[DecodedFrame]:
6262
"""Signal end-of-stream and return all remaining buffered frames. Call

test/test_decoders.py

Lines changed: 7 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -3381,20 +3381,9 @@ def _decode_sequential(self, path, device):
33813381

33823382
def _decode_prefetch_frames(self, path, device):
33833383
# [demux + decode] on one thread || [color-convert] on another.
3384-
demuxer = Demuxer(path)
3385-
converter = ColorConverter(device=device)
3384+
demuxer, decoder, converter = self._make_blocks(path, device)
33863385

3387-
def demux_and_decode():
3388-
# Constructed here so the PacketDecoder (and thus the CUDA context it
3389-
# binds via its device interface) lives on the prefetch worker
3390-
# thread that actually runs the cuvid* calls.
3391-
# TODO_API_BREAKDOWN_CUDA: this is a temporary workaroun for a real
3392-
# issue - needs fixing. Things should work when the objects are
3393-
# constructed in a different thread than where they're consumed.
3394-
decoder = PacketDecoder(demuxer, device=device)
3395-
yield from self._decode(decoder, self._demux(demuxer))
3396-
3397-
frames = self.prefetch(demux_and_decode())
3386+
frames = self.prefetch(self._decode(decoder, self._demux(demuxer)))
33983387
return list(self._convert(converter, frames))
33993388

34003389
def _decode_prefetch_packets(self, path, device):
@@ -3405,19 +3394,9 @@ def _decode_prefetch_packets(self, path, device):
34053394

34063395
def _decode_prefetch_packets_and_frames(self, path, device):
34073396
# [demux] || [decode] || [color-convert], each on its own thread.
3408-
demuxer = Demuxer(path)
3409-
converter = ColorConverter(device=device)
3397+
demuxer, decoder, converter = self._make_blocks(path, device)
34103398
packets = self.prefetch(self._demux(demuxer))
3411-
3412-
def decode(packets):
3413-
# Constructed here so the PacketDecoder (and thus the CUDA context it
3414-
# binds via its device interface) lives on the prefetch worker
3415-
# thread that actually runs the cuvid* calls.
3416-
# TODO_API_BREAKDOWN_CUDA: same TODO as above.
3417-
decoder = PacketDecoder(demuxer, device=device)
3418-
yield from self._decode(decoder, packets)
3419-
3420-
frames = self.prefetch(decode(packets))
3399+
frames = self.prefetch(self._decode(decoder, packets))
34213400
return list(self._convert(converter, frames))
34223401

34233402
def _to_frame_batch(self, frames):
@@ -3431,6 +3410,8 @@ def _to_frame_batch(self, frames):
34313410
),
34323411
)
34333412

3413+
# TODO_API_BREAKDOWN P0: We need to test all assets. Generally we need more
3414+
# tests for all features / edge cases that were eventually fixed.
34343415
@pytest.mark.parametrize(
34353416
"video",
34363417
(
@@ -3499,7 +3480,7 @@ def test_color_converter_reused_across_videos(self, device):
34993480
def test_set_cuda_backend_is_a_noop(self, device):
35003481
# The blocks always use the NVDEC CUDA backend. Asking for the "ffmpeg"
35013482
# one changes nothing, rather than silently producing something else.
3502-
# TODO_API_BREAKDOWN: let's just error?
3483+
# TODO_API_BREAKDOWN P2: let's just error?
35033484
with set_cuda_backend("ffmpeg"):
35043485
got = self._to_frame_batch(self._decode_sequential(NASA_VIDEO.path, device))
35053486
ref = self._to_frame_batch(self._decode_sequential(NASA_VIDEO.path, device))

0 commit comments

Comments
 (0)