Skip to content

Commit 0607909

Browse files
authored
Hand out CUDA frames from a CUDA PacketDecoder, always (#1636)
1 parent 4559cb7 commit 0607909

10 files changed

Lines changed: 351 additions & 305 deletions

File tree

src/torchcodec/_core/BetaCudaDeviceInterface.cpp

Lines changed: 265 additions & 251 deletions
Large diffs are not rendered by default.

src/torchcodec/_core/BetaCudaDeviceInterface.h

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -33,14 +33,16 @@
3333
#include "nvcuvid_include/nvcuvid.h"
3434

3535
namespace facebook::torchcodec {
36+
// TODO_API_BREAKDOWN P2: the name says "standalone", but this is really about
37+
// owning a GPU buffer. Find one that covers both.
3638
struct StandAloneFrameAttachedData {
3739
cudaStream_t producer_stream = nullptr;
3840
torch::stable::Tensor storage;
39-
// Whether the frame's samples are on the GPU. False for the CPU-fallback
40-
// frames a PacketDecoder hands out for streams NVDEC can't decode. The
41-
// pixel format can't tell the two apart: a 4:4:4 stream yields yuv444p
42-
// either way, natively from NVDEC or from the CPU fallback.
43-
bool is_device_frame = false;
41+
};
42+
43+
struct GpuFrameAndStorage {
44+
UniqueAVFrame av_frame;
45+
torch::stable::Tensor storage;
4446
};
4547

4648
class BetaCudaDeviceInterface : public DeviceInterface {
@@ -108,12 +110,12 @@ class BetaCudaDeviceInterface : public DeviceInterface {
108110

109111
void make_frame_standalone(UniqueAVFrame& av_frame) override;
110112

111-
bool is_device_frame(
112-
[[maybe_unused]] const UniqueAVFrame& av_frame) const override;
113+
GpuFrameAndStorage upload_cpu_frame_to_gpu_on_current_stream(
114+
const AVFrame& cpu_frame);
113115

114-
UniqueAVFrame transfer_cpu_frame_to_gpu(
115-
const AVFrame& cpu_frame,
116-
AVPixelFormat target_pix_fmt);
116+
torch::stable::Tensor copy_nvdec_surface(
117+
UniqueAVFrame& av_frame,
118+
cudaStream_t stream);
117119

118120
void apply_rotation(
119121
FrameOutput& frame_output,

src/torchcodec/_core/DeviceInterface.h

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

163-
virtual bool is_device_frame(
164-
[[maybe_unused]] const UniqueAVFrame& av_frame) const {
165-
return false;
166-
}
167-
168163
// Flush remaining frames from decoder
169164
virtual void flush() {
170165
STD_TORCH_CHECK(

src/torchcodec/_core/PacketDecoder.h

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

48-
bool is_device_frame(const UniqueAVFrame& av_frame) const {
49-
return device_interface_->is_device_frame(av_frame);
50-
}
51-
5248
const StableDevice& device() const {
5349
return device_interface_->device();
5450
}

src/torchcodec/_core/custom_ops.cpp

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -876,9 +876,11 @@ OpsReceiveFrameOutput _blocks_packet_decoder_receive_frame(
876876
AVRational time_base = decoder_ptr->time_base();
877877
double pts_seconds = pts_to_seconds(get_pts_or_dts(*av_frame), time_base);
878878
double duration_seconds = pts_to_seconds(get_duration(*av_frame), time_base);
879-
std::string device = decoder_ptr->is_device_frame(av_frame)
880-
? device_to_string(decoder_ptr->device())
881-
: std::string("cpu");
879+
// A decoder's frames always live on the decoder's own device, including the
880+
// ones a CUDA decoder had to decode on the CPU and upload.
881+
// TODO_API_BREAKDOWN_P1: Not sure we need to return the device at all, the
882+
// device should always match the device parameter now.
883+
std::string device = device_to_string(decoder_ptr->device());
882884
return std::make_tuple(
883885
wrap_pointer_to_tensor(std::move(av_frame)),
884886
static_cast<int64_t>(0),

src/torchcodec/decoders/_blocks/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
# This source code is licensed under the BSD-style license found in the
55
# LICENSE file in the root directory of this source tree.
66

7-
"""Private, experimental building-block decode API (CPU only, for now).
7+
"""Private, experimental building-block decode API.
88
99
Exposes the three decode stages -- :class:`Demuxer`, :class:`PacketDecoder`,
1010
:class:`ColorConverter` -- as passive, composable, GIL-releasing units, so a

src/torchcodec/decoders/_blocks/_frame.py

Lines changed: 16 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -33,22 +33,23 @@ def __init__(self, handle: torch.Tensor):
3333
@dataclass
3434
class RawFrame:
3535
planes: tuple[torch.Tensor, ...]
36-
pix_fmt: str # FFmpeg pixel-format name, e.g. "yuv420p"
36+
# FFmpeg pixel-format name. On CPU this is the source's own format, e.g.
37+
# "yuv420p". On CUDA it is always an NVDEC surface format: "nv12",
38+
# "p010le", "p012le", "p016le", "yuv444p" or "yuv444p16le".
39+
pix_fmt: str
3740
colorspace: str # e.g. "bt709"
3841
color_range: str # "tv" (limited) or "pc" (full)
39-
# The depth of pix_fmt (always).
40-
# This is also the source's bit depth, except for 12b-bit sources CUDA
41-
# frames: those are technically P012, but P012 was only introduced in FFmpeg
42-
# 6. So For FFmpeg < 6, we must report those as P016, and so the bit_depth
43-
# field here reports 16 (on CPU, it'd still be 12).
42+
# The depth of pix_fmt (always), which is the source's bit depth except
43+
# where a CUDA surface format's container is wider than the source samples:
44+
# a 10-bit 4:4:4 source is uploaded as yuv444p16le, and a 12-bit source is
45+
# tagged p016le on FFmpeg < 6 (which lacks p012le). Both report 16 here (on
46+
# CPU they'd report 10 and 12).
4447
# Everything downstream still reads right, because those samples are
45-
# msb-aligned and are therefore genuinely valid 16-bit ones, with 4 zeroed
48+
# msb-aligned and are therefore genuinely valid 16-bit ones, with zeroed
4649
# low bits.
4750
#
48-
# TODO_API_BREAKDOWN P2: We can't do anything about the P016 report, but
49-
# should this actually report the depth of the source instead of the depth
50-
# of the pixel format? Again the only discrepency arises for 12-bit sources
51-
# on CUDA for FFmpeg < 6.
51+
# TODO_API_BREAKDOWN P2: should this report the depth of the source instead
52+
# of the depth of the pixel format?
5253
bit_depth: int
5354

5455

@@ -63,9 +64,10 @@ class DecodedFrame:
6364
the decoder (which knows the stream time base) and carried here so the
6465
:class:`ColorConverter` need not be bound to any stream.
6566
66-
``device`` is where this frame's samples actually are, which is not
67-
necessarily the device its decoder was created with: a CUDA decoder falls
68-
back to CPU decoding for streams NVDEC can't handle.
67+
``device`` is where this frame's samples are, and it is always the device
68+
its decoder was created with. A CUDA decoder falls back to CPU decoding for
69+
streams NVDEC can't handle, but it uploads those frames before handing them
70+
out, so they are indistinguishable from NVDEC ones here.
6971
"""
7072

7173
def __init__(
57.5 KB
Binary file not shown.

test/test_decoders.py

Lines changed: 40 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,7 @@
134134
TESTSRC2_AV1_10BIT,
135135
TESTSRC2_ODD_HEIGHT_444,
136136
TESTSRC2_ODD_HEIGHT_AND_WIDTH_444,
137+
TESTSRC2_ODD_HEIGHT_AND_WIDTH_444_10BIT,
137138
TESTSRC2_ODD_HEIGHT_AND_WIDTH_VP9,
138139
TESTSRC2_ODD_HEIGHT_AND_WIDTH_VP9_10BIT,
139140
TESTSRC2_ODD_HEIGHT_VP9,
@@ -2300,7 +2301,9 @@ def test_nvdec_cpu_fallback_yuv444(self, tmp_path):
23002301
cpu_frames = cpu_decoder.get_frames_in_range(start=0, stop=num_frames).data
23012302
cuda_frames = cuda_decoder.get_frames_in_range(start=0, stop=num_frames).data
23022303

2303-
torch.testing.assert_close(cpu_frames, cuda_frames.cpu(), rtol=0, atol=0)
2304+
# The CUDA path uploads these as yuv444p and color-converts them with
2305+
# our kernel, which truncates where swscale rounds.
2306+
torch.testing.assert_close(cpu_frames, cuda_frames.cpu(), rtol=0, atol=1)
23042307

23052308
@needs_cuda
23062309
def test_nvdec_cuda_interface_error(self):
@@ -3500,6 +3503,7 @@ def pix_fmt(self, device):
35003503
TESTSRC2_ODD_WIDTH_VP9_10BIT,
35013504
TESTSRC2_ODD_HEIGHT_VP9_10BIT,
35023505
TESTSRC2_ODD_HEIGHT_AND_WIDTH_VP9_10BIT,
3506+
TESTSRC2_ODD_HEIGHT_AND_WIDTH_444_10BIT,
35033507
)
35043508

35053509

@@ -3509,9 +3513,12 @@ def pix_fmt(self, device):
35093513
_MATERIALIZE_VIDEOS = (
35103514
_MaterializeCase(NASA_VIDEO, 8, "yuv420p", "nv12"), # even dims
35113515
_MaterializeCase(TESTSRC2_ODD_HEIGHT_AND_WIDTH_VP9, 8, "yuv420p", "nv12"), # odd
3512-
# 4:4:4 (full-res chroma). NVDEC can't decode H264 4:4:4, so this one falls
3513-
# back to the CPU and keeps its native format.
3516+
# 4:4:4 (full-res chroma). NVDEC can't decode H264 4:4:4, so these fall back
3517+
# to the CPU and are uploaded as 4:4:4 rather than have their chroma halved.
35143518
_MaterializeCase(TESTSRC2_ODD_HEIGHT_AND_WIDTH_444, 8, "yuv444p", "yuv444p"),
3519+
_MaterializeCase(
3520+
TESTSRC2_ODD_HEIGHT_AND_WIDTH_444_10BIT, 10, "yuv444p10le", "yuv444p16le"
3521+
),
35153522
# HEVC 4:4:4, which NVDEC decodes natively into its YUV444 surfaces. The
35163523
# 16-bit one is the only 4:4:4 surface above 8 bits, so 10- and 12-bit
35173524
# sources both land in yuv444p16le.
@@ -3696,6 +3703,7 @@ def _assert_matches_video_decoder(got, ref, video):
36963703
TESTSRC2_ODD_WIDTH_444,
36973704
TESTSRC2_ODD_HEIGHT_444,
36983705
TESTSRC2_ODD_HEIGHT_AND_WIDTH_444,
3706+
TESTSRC2_ODD_HEIGHT_AND_WIDTH_444_10BIT,
36993707
# HEVC 4:4:4: NVDEC decodes these natively instead.
37003708
TESTSRC2_444_8BIT_HEVC,
37013709
TESTSRC2_444_10BIT_HEVC,
@@ -3867,6 +3875,15 @@ def test_materialize_structure(self, case, device):
38673875
expected_dtype = torch.uint16 if raw.bit_depth > 8 else torch.uint8
38683876
assert all(plane.dtype == expected_dtype for plane in planes)
38693877

3878+
if device == "cuda" and expected_dtype == torch.uint16:
3879+
# Whatever depth the format claims, a 16-bit CUDA surface holds the
3880+
# source's samples msb-aligned, with the unused low bits zeroed.
3881+
# That's what lets test_materialize_cuda_planes_match_cpu shift them
3882+
# back down by 16 - bit_depth and compare against the CPU planes.
3883+
unused_low_bits = (1 << (16 - case.bit_depth)) - 1
3884+
for plane in planes:
3885+
assert (plane.to(torch.int32) & unused_low_bits).count_nonzero() == 0
3886+
38703887
Y, U, V = planes
38713888
height, width = converter.convert(frame).data.shape[1:]
38723889
assert Y.shape == (height, width)
@@ -4005,23 +4022,29 @@ def test_materialize_neutral_chroma_is_grayscale(self, video, device):
40054022
torch.testing.assert_close(g, b, atol=1, rtol=0)
40064023

40074024
@pytest.mark.needs_cuda
4008-
@pytest.mark.parametrize("video", (H265_VIDEO, TESTSRC2_ODD_HEIGHT_AND_WIDTH_444))
4009-
def test_materialize_cpu_fallback_stays_on_cpu(self, video):
4010-
# TODO_NOW: This may not be what we want. We probalby want
4011-
# to output CUDA data. But how? Do we put the YUV420 on CUDA? Then we
4012-
# need a specialized kernel to color-convert? Or we put those frames we
4013-
# can have on NV12 - but for 444 it's a problem becaus ewe can't convert
4014-
# them to NV12, so we'd need a 444 color-conversion kernel anyway. So
4015-
# all frames would be NV12 except *some* (the 444 ones)?
4016-
# Unclear what to do here honestly. Maybe outputting CPU frames is
4017-
# actually justifiable?
4025+
@pytest.mark.parametrize(
4026+
"video, expected_pix_fmt",
4027+
(
4028+
# Too small for NVDEC.
4029+
(H265_VIDEO, "nv12"),
4030+
# H264 4:4:4, which NVDEC can't decode. Uploading it as NV12 would
4031+
# halve its chroma resolution, so it stays 4:4:4.
4032+
(TESTSRC2_ODD_HEIGHT_AND_WIDTH_444, "yuv444p"),
4033+
(TESTSRC2_ODD_HEIGHT_AND_WIDTH_444_10BIT, "yuv444p16le"),
4034+
),
4035+
)
4036+
def test_materialize_cpu_fallback_is_on_cuda(self, video, expected_pix_fmt):
4037+
# A CUDA PacketDecoder hands out CUDA frames even for the streams it has
4038+
# to decode on the CPU, and they're in an NVDEC surface format like any
4039+
# other CUDA frame.
4040+
assert VideoDecoder(video.path, device="cuda").cpu_fallback
4041+
40184042
frame, _ = self._first_frame(video.path, "cuda")
4019-
assert frame.device == "cpu"
4043+
assert frame.device == "cuda"
40204044

40214045
raw = frame.materialize()
4022-
planes, pix_fmt = raw.planes, raw.pix_fmt
4023-
assert pix_fmt != "nv12"
4024-
assert all(plane.device.type == "cpu" for plane in planes)
4046+
assert raw.pix_fmt == expected_pix_fmt
4047+
assert all(plane.device.type == "cuda" for plane in raw.planes)
40254048

40264049

40274050
# Small helpers to avoid having to always specify the same skip marks and decode_fn

test/utils.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1229,6 +1229,18 @@ def get_empty_chw_tensor(self, *, stream_index: int) -> torch.Tensor:
12291229
frames={0: {}},
12301230
)
12311231

1232+
# ffmpeg -f lavfi -i "testsrc2=size=321x241:rate=25:duration=1,format=rgb24" \
1233+
# -c:v libx264 -pix_fmt yuv444p10le -profile:v high444 \
1234+
# testsrc2_odd_height_and_width_444_10bit.mp4
1235+
TESTSRC2_ODD_HEIGHT_AND_WIDTH_444_10BIT = TestVideo(
1236+
filename="testsrc2_odd_height_and_width_444_10bit.mp4",
1237+
default_stream_index=0,
1238+
stream_infos={
1239+
0: TestVideoStreamInfo(width=321, height=241, num_color_channels=3),
1240+
},
1241+
frames={0: {}},
1242+
)
1243+
12321244
# HEVC 4:4:4, which NVDEC *can* decode natively (unlike H264 4:4:4 above), at
12331245
# 8, 10 and 12 bits. Odd dimensions, so they also cover the cropping NVDEC's
12341246
# even-aligned surfaces need. Encoded with, for DEPTH in 8/10/12:

0 commit comments

Comments
 (0)