Skip to content

Commit ba8b9f6

Browse files
authored
Fix odd dimension in CUDA decoder for fallback frames (#1649)
1 parent f9edbfb commit ba8b9f6

5 files changed

Lines changed: 132 additions & 41 deletions

File tree

src/torchcodec/_core/BetaCudaDeviceInterface.cpp

Lines changed: 50 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -967,13 +967,15 @@ UniqueAVFrame BetaCudaDeviceInterface::convert_cuda_frame_to_av_frame(
967967
? AVCOL_RANGE_JPEG
968968
: AVCOL_RANGE_MPEG;
969969

970-
// NVDEC lays the chroma planes out after the Y plane, all with the same
971-
// pitch. The Y plane has an even number of rows (NVDEC rounds up internally),
972-
// so the offsets must use the rounded-up height.
973-
unsigned int even_height = round_up_to_even(height);
970+
// NVDEC stacks the planes in a single allocation, all with the same pitch,
971+
// and it rounds the Y plane's row count up to even. So consecutive planes
972+
// start plane_stride bytes apart, which is more than pitch * height for an
973+
// odd-height frame. NVIDIA's own NvDecoder addresses the chroma plane the
974+
// same way: dpSrcFrame + srcPitch * ((surface_height + 1) & ~1).
975+
unsigned int num_luma_plane_rows = round_up_to_even(height);
976+
unsigned int plane_stride = pitch * num_luma_plane_rows;
974977
auto plane = [&](unsigned int index) {
975-
return reinterpret_cast<uint8_t*>(
976-
frame_ptr + (pitch * even_height * index));
978+
return reinterpret_cast<uint8_t*>(frame_ptr + (plane_stride * index));
977979
};
978980
bool is_444 = is_444_surface_format(surface_format_);
979981

@@ -1065,16 +1067,17 @@ torch::stable::Tensor BetaCudaDeviceInterface::copy_nvdec_surface(
10651067
// = num_pixels + num_pixels / 2
10661068
// = num_pixels * 3 / 2
10671069
//
1068-
// where num_pixels = pitch * height, not num_pixels = width * height. The
1069-
// pitch value also accounts for the data size (uint8 vs uint16) so this is
1070-
// also correct for P016. A 4:4:4 surface has two full-size chroma planes
1071-
// instead of one half-height one, so it's num_pixels * 3.
1072-
int64_t even_height =
1070+
// where num_pixels = pitch * num_luma_plane_rows, not width * height: the
1071+
// pitch accounts for both the row padding and the data size (uint8 vs
1072+
// uint16), and NVDEC rounds the Y plane's row count up to even. A 4:4:4
1073+
// surface has two full-size chroma planes instead of one half-height one, so
1074+
// it's num_pixels * 3.
1075+
int64_t num_luma_plane_rows =
10731076
static_cast<int64_t>(round_up_to_even(av_frame->height));
10741077
int64_t pitch = static_cast<int64_t>(av_frame->linesize[0]);
10751078
bool is_444 = is_444_surface_format(surface_format_);
1076-
int64_t num_bytes =
1077-
is_444 ? pitch * even_height * 3 : pitch * even_height * 3 / 2;
1079+
int64_t num_bytes = is_444 ? pitch * num_luma_plane_rows * 3
1080+
: pitch * num_luma_plane_rows * 3 / 2;
10781081

10791082
auto storage =
10801083
torch::stable::empty({num_bytes}, kStableUInt8, std::nullopt, device_);
@@ -1103,10 +1106,11 @@ torch::stable::Tensor BetaCudaDeviceInterface::copy_nvdec_surface(
11031106
record_surface_read(current_stream);
11041107

11051108
auto y_plane = static_cast<uint8_t*>(storage.mutable_data_ptr());
1109+
int64_t plane_stride = pitch * num_luma_plane_rows;
11061110
av_frame->data[0] = y_plane;
1107-
av_frame->data[1] = y_plane + (pitch * even_height);
1111+
av_frame->data[1] = y_plane + plane_stride;
11081112
if (is_444) {
1109-
av_frame->data[2] = y_plane + (2 * pitch * even_height);
1113+
av_frame->data[2] = y_plane + (2 * plane_stride);
11101114
}
11111115

11121116
return storage;
@@ -1183,40 +1187,44 @@ GpuFrameAndStorage BetaCudaDeviceInterface::upload_cpu_frame_to_gpu(
11831187
int num_planes = semi_planar_420 ? 2 : 3;
11841188
int bytes_per_sample = want_16bit ? 2 : 1;
11851189

1186-
// The 4:2:0 kernel works on 2x2 blocks and skips any trailing odd row or
1187-
// column, so for those targets we round the frame up to even dimensions and
1188-
// let the color conversion crop the result back. Nothing about the pixel
1189-
// format itself requires this: FFmpeg is happy with odd-sized 4:2:0 frames.
1190-
// The 4:4:4 kernel is per-pixel, so those are uploaded at their exact size.
1191-
// TODO_API_BREAKDOW P1: Wait errrr does that mean we don't need this crop
1192-
// dance anymore?? Should check!!!
1190+
// The 4:2:0 kernel works on 2x2 blocks and never writes a trailing odd row or
1191+
// column, so its input planes must be even-sized. We allocate the buffer with
1192+
// even dimensions but keep the frame's real width and height, exactly like
1193+
// the even-sized surfaces NVDEC hands us for odd-sized videos: the pad
1194+
// row/column is only read as part of a boundary block, and the color
1195+
// conversion crops it away. Nothing about the pixel format itself requires
1196+
// this: FFmpeg is happy with odd-sized 4:2:0 frames. The 4:4:4 kernel is
1197+
// per-pixel, so those need no padding.
11931198
int width = cpu_frame.width;
11941199
int height = cpu_frame.height;
1195-
int target_width = semi_planar_420 ? round_up_to_even(width) : width;
1196-
int target_height = semi_planar_420 ? round_up_to_even(height) : height;
1200+
int padded_width = semi_planar_420 ? round_up_to_even(width) : width;
1201+
int padded_height = semi_planar_420 ? round_up_to_even(height) : height;
11971202

11981203
UniqueAVFrame intermediate_cpu_frame(av_frame_alloc());
11991204
STD_TORCH_CHECK(
12001205
intermediate_cpu_frame != nullptr,
12011206
"Failed to allocate intermediate CPU frame");
12021207

12031208
intermediate_cpu_frame->format = target_pix_fmt;
1204-
intermediate_cpu_frame->width = target_width;
1205-
intermediate_cpu_frame->height = target_height;
1209+
intermediate_cpu_frame->width = padded_width;
1210+
intermediate_cpu_frame->height = padded_height;
12061211

12071212
int ret = av_frame_get_buffer(intermediate_cpu_frame.get(), 0);
12081213
STD_TORCH_CHECK(
12091214
ret >= 0,
12101215
"Failed to allocate intermediate CPU frame buffer: ",
12111216
get_ffmpeg_error_string_from_error_code(ret));
12121217

1218+
// Source and destination dimensions are the same: this is a pixel format
1219+
// conversion, not a rescale. sws_scale() writes into the even-sized buffer
1220+
// allocated above but only fills the real width and height.
12131221
SwsConfig sws_config(
12141222
width,
12151223
height,
12161224
static_cast<AVPixelFormat>(cpu_frame.format),
12171225
cpu_frame.colorspace,
1218-
target_width,
1219-
target_height,
1226+
width,
1227+
height,
12201228
target_pix_fmt);
12211229

12221230
if (!sws_context_ || prev_sws_config_ != sws_config) {
@@ -1233,16 +1241,16 @@ GpuFrameAndStorage BetaCudaDeviceInterface::upload_cpu_frame_to_gpu(
12331241
intermediate_cpu_frame->data,
12341242
intermediate_cpu_frame->linesize);
12351243
STD_TORCH_CHECK(
1236-
converted_height == target_height,
1244+
converted_height == height,
12371245
"sws_scale failed for the CPU-fallback upload conversion");
12381246

12391247
// The chroma plane of a semi-planar 4:2:0 frame carries interleaved UV pairs,
12401248
// so it's as wide as the luma plane but half as tall.
1241-
int row_bytes = target_width * bytes_per_sample;
1249+
int row_bytes = padded_width * bytes_per_sample;
12421250
int plane_heights[3] = {
1243-
target_height,
1244-
semi_planar_420 ? target_height / 2 : target_height,
1245-
target_height};
1251+
padded_height,
1252+
semi_planar_420 ? padded_height / 2 : padded_height,
1253+
padded_height};
12461254

12471255
int64_t plane_offsets[3] = {0, 0, 0};
12481256
int64_t total_bytes = 0;
@@ -1260,8 +1268,8 @@ GpuFrameAndStorage BetaCudaDeviceInterface::upload_cpu_frame_to_gpu(
12601268
STD_TORCH_CHECK(gpu_frame != nullptr, "Failed to allocate GPU AVFrame");
12611269

12621270
gpu_frame->format = target_pix_fmt;
1263-
gpu_frame->width = target_width;
1264-
gpu_frame->height = target_height;
1271+
gpu_frame->width = width;
1272+
gpu_frame->height = height;
12651273

12661274
// One copy per plane: av_frame_get_buffer() allocates each plane with its own
12671275
// alignment padding, so they are neither contiguous with each other nor
@@ -1317,10 +1325,6 @@ void BetaCudaDeviceInterface::convert_av_frame_to_frame_output(
13171325
CudaContextGuard context_guard(device_.index());
13181326
cudaStream_t current_stream = get_current_cuda_stream(device_.index());
13191327

1320-
// Capture original dimensions before upload_cpu_frame_to_gpu() may round them
1321-
// up to even.
1322-
FrameDims original_dims(av_frame.height, av_frame.width);
1323-
13241328
// We may need to upload a frame here in case of the CPU fallback. This is
13251329
// only needed in Both() mode i.e. with the SingleStreamDecoder. The reason we
13261330
// do it here and not just after decoding is because the `decode_av_frame()`
@@ -1349,6 +1353,11 @@ void BetaCudaDeviceInterface::convert_av_frame_to_frame_output(
13491353
}
13501354
const AVFrame& gpu_frame = needs_upload ? *uploaded.av_frame : av_frame;
13511355

1356+
// Both NVDEC surfaces and uploaded CPU frames may be backed by even-sized
1357+
// buffers while describing an odd-sized frame; the color conversion crops the
1358+
// padding away.
1359+
FrameDims output_dims(gpu_frame.height, gpu_frame.width);
1360+
13521361
auto gpu_pix_fmt = static_cast<AVPixelFormat>(gpu_frame.format);
13531362
STD_TORCH_CHECK(
13541363
is_expected_pix_fmt_from_nvdec(gpu_pix_fmt),
@@ -1382,14 +1391,14 @@ void BetaCudaDeviceInterface::convert_av_frame_to_frame_output(
13821391
device_,
13831392
producer_stream,
13841393
pre_alloc,
1385-
original_dims,
1394+
output_dims,
13861395
static_cast<AVPixelFormat>(gpu_frame.format),
13871396
cached_color_matrix_);
13881397
};
13891398

13901399
if (rotation_ == Rotation::NONE) {
13911400
validate_pre_allocated_tensor_shape(
1392-
pre_allocated_output_tensor, original_dims);
1401+
pre_allocated_output_tensor, output_dims);
13931402
frame_output.data = convert_frame(pre_allocated_output_tensor);
13941403
} else {
13951404
// preAllocatedOutputTensor has post-rotation dimensions, but the
24.5 KB
Binary file not shown.
24.5 KB
Binary file not shown.

test/test_decoders.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,11 +135,13 @@
135135
TESTSRC2_ODD_HEIGHT_444,
136136
TESTSRC2_ODD_HEIGHT_AND_WIDTH_444,
137137
TESTSRC2_ODD_HEIGHT_AND_WIDTH_444_10BIT,
138+
TESTSRC2_ODD_HEIGHT_AND_WIDTH_MPEG2,
138139
TESTSRC2_ODD_HEIGHT_AND_WIDTH_VP9,
139140
TESTSRC2_ODD_HEIGHT_AND_WIDTH_VP9_10BIT,
140141
TESTSRC2_ODD_HEIGHT_VP9,
141142
TESTSRC2_ODD_HEIGHT_VP9_10BIT,
142143
TESTSRC2_ODD_WIDTH_444,
144+
TESTSRC2_ODD_WIDTH_MPEG2,
143145
TESTSRC2_ODD_WIDTH_VP9,
144146
TESTSRC2_ODD_WIDTH_VP9_10BIT,
145147
TRANSPARENT_GIF,
@@ -1788,6 +1790,51 @@ def test_odd_sized_videos_vp9(self, asset, output_dtype):
17881790
assert gpu_frames.shape == cpu_frames.shape
17891791
assert_tensor_close_on_at_least(gpu_frames, cpu_frames, percentage=89, atol=3)
17901792

1793+
@needs_cuda
1794+
@pytest.mark.parametrize(
1795+
"asset", (TESTSRC2_ODD_WIDTH_MPEG2, TESTSRC2_ODD_HEIGHT_AND_WIDTH_MPEG2)
1796+
)
1797+
@pytest.mark.parametrize("output_dtype", (torch.uint8, torch.float32))
1798+
def test_odd_sized_video_420_cpu_fallback(self, asset, output_dtype):
1799+
# MPEG-2 isn't decoded by NVDEC, so these yuv420p videos go through the
1800+
# CPU fallback: the frame is decoded on the CPU, then uploaded as NV12
1801+
# (or P016) for the GPU color conversion. Their odd dimensions mean the
1802+
# upload has to pad the frame to even ones, which the color conversion
1803+
# then crops away - if it padded by rescaling instead, or forgot to
1804+
# crop, the frames below would be shifted or too large.
1805+
decoder_gpu, _ = make_video_decoder(
1806+
asset.path, device="cuda", output_dtype=output_dtype
1807+
)
1808+
assert decoder_gpu.cpu_fallback
1809+
decoder_cpu = VideoDecoder(asset.path, device="cpu", output_dtype=output_dtype)
1810+
1811+
gpu_frames = decoder_gpu.get_frames_at([0, 1, 2]).data.cpu()
1812+
cpu_frames = decoder_cpu.get_frames_at([0, 1, 2]).data
1813+
expected_shape = (3, 3, asset.height, asset.width)
1814+
assert gpu_frames.shape == expected_shape
1815+
assert cpu_frames.shape == expected_shape
1816+
assert gpu_frames.dtype == output_dtype
1817+
1818+
if asset is TESTSRC2_ODD_HEIGHT_AND_WIDTH_MPEG2:
1819+
# An odd height stops swscale from using its fast unscaled
1820+
# yuv420p -> rgb converter, which pairs each chroma row with exactly
1821+
# two luma rows. It falls back to the general path and *resizes* the
1822+
# chroma plane's ceil(height / 2) rows onto `height` rows - a ratio
1823+
# just under 2, interpolated. We replicate chroma exactly 2x, like
1824+
# NVDEC does, so the two disagree along every colour edge in the
1825+
# frame. Only ~79% of samples land within 5, hence the loose bound;
1826+
# the pixels themselves are checked exactly by
1827+
# TestBlocks::test_matches_video_decoder, where both sides are ours.
1828+
percentage, atol = 75, 5
1829+
else:
1830+
percentage, atol = 98, 3
1831+
assert_tensor_close_on_at_least(
1832+
gpu_frames,
1833+
cpu_frames,
1834+
percentage=percentage,
1835+
atol=atol if output_dtype == torch.uint8 else atol / 255,
1836+
)
1837+
17911838
@needs_cuda
17921839
def test_10bit_gpu_fallsback_to_cpu(self):
17931840
# Test for 10-bit videos that aren't supported by NVDEC: we decode and
@@ -3713,6 +3760,13 @@ def _assert_matches_video_decoder(got, ref, video):
37133760
TESTSRC2_ODD_HEIGHT_444,
37143761
TESTSRC2_ODD_HEIGHT_AND_WIDTH_444,
37153762
TESTSRC2_ODD_HEIGHT_AND_WIDTH_444_10BIT,
3763+
# Odd dimensions with 4:2:0 chroma, taking the CPU-fallback path
3764+
# (MPEG-2 isn't decoded by NVDEC): the frame is uploaded padded to
3765+
# even dimensions, and the converter must crop it back. Both sides
3766+
# of this comparison use our own kernels, so unlike the
3767+
# CPU-reference tests the odd-height one can be compared exactly.
3768+
TESTSRC2_ODD_WIDTH_MPEG2,
3769+
TESTSRC2_ODD_HEIGHT_AND_WIDTH_MPEG2,
37163770
# HEVC 4:4:4: NVDEC decodes these natively instead.
37173771
TESTSRC2_444_8BIT_HEVC,
37183772
TESTSRC2_444_10BIT_HEVC,

test/utils.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1340,6 +1340,34 @@ def get_empty_chw_tensor(self, *, stream_index: int) -> torch.Tensor:
13401340
frames={0: {}},
13411341
)
13421342

1343+
# Odd dimensions with 4:2:0 chroma, in a codec NVDEC doesn't decode. That's the
1344+
# only combination that reaches the CPU fallback with a frame our 4:2:0 CUDA
1345+
# kernel can't consume as-is: it has to be padded to even dimensions before
1346+
# color conversion, and cropped back afterwards.
1347+
# ffmpeg -f lavfi -i "testsrc2=rate=25:duration=0.4:size=121x80,format=rgb24" \
1348+
# -c:v mpeg2video -pix_fmt yuv420p testsrc2_odd_width_mpeg2.mp4
1349+
TESTSRC2_ODD_WIDTH_MPEG2 = TestVideo(
1350+
filename="testsrc2_odd_width_mpeg2.mp4",
1351+
default_stream_index=0,
1352+
stream_infos={
1353+
0: TestVideoStreamInfo(width=121, height=80, num_color_channels=3),
1354+
},
1355+
frames={0: {}},
1356+
)
1357+
1358+
# Also odd in height, which additionally exercises the chroma plane's own
1359+
# rounding: an odd-height 4:2:0 frame has ceil(height / 2) chroma rows.
1360+
# ffmpeg -f lavfi -i "testsrc2=rate=25:duration=0.4:size=121x81,format=rgb24" \
1361+
# -c:v mpeg2video -pix_fmt yuv420p testsrc2_odd_height_and_width_mpeg2.mp4
1362+
TESTSRC2_ODD_HEIGHT_AND_WIDTH_MPEG2 = TestVideo(
1363+
filename="testsrc2_odd_height_and_width_mpeg2.mp4",
1364+
default_stream_index=0,
1365+
stream_infos={
1366+
0: TestVideoStreamInfo(width=121, height=81, num_color_channels=3),
1367+
},
1368+
frames={0: {}},
1369+
)
1370+
13431371

13441372
def supports_approximate_mode(asset: TestVideo) -> bool:
13451373
# Those are missing the `duration` field so they fail in approximate mode (on all devices).

0 commit comments

Comments
 (0)