Repository navigation
[Fix] multimodal: Decode JPEGs on GPU with torchcodec and apply EXIF orientation - #46123
Open
NicolasHug wants to merge 1 commit into
Open
NicolasHug wants to merge 1 commit into
NicolasHug wants to merge 1 commit into
Conversation
NicolasHug
requested review from
Fridge003,
HaiShaw,
JustinTong0323,
ShangmingCai,
ispobock,
liusy58,
merrymercy,
mickqian,
yctseng0211,
yhyang201 and
yuan-luo
as code owners
October 23, 2026 16:55
Author
|
Hi @mickqian, pinging you here as your Slack profile asks. I'm Nicolas, on the PyTorch team at Meta. I maintain Some other improvements I'd love to follow up on:
Happy to connect on Slack to chat more. Thanks! |
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Context: I'm the maintainer for both TorchCodec and TorchVision
Motivation
JPEGs are decoded on the GPU by default (
gpu_image_decode = True) withtorchvision.io.decode_jpeg(device="cuda"). That decoder has three known bugs in sglang:pixel_valuesbecame NaN and the request generated garbage.[1, H, W], which then fails RGB normalization ortorch.stackin the HF processors ([Bug] Grayscale JPEG's processing failes on normalization step #44748, [Bug] Gemma-4 mm: single non-RGB image crashes vision tower and kills scheduler (mat1 256 vs 768) #26751). Fixes proposed in [Fix] Normalize grayscale JPEGs in GPU image decoding #35101, [Fix] multimodal: decode JPEG to RGB on GPU path to fix grayscale crash #28828 and [Fix] multimodal: expand single-channel GPU JPEG tensors to RGB on load #44781 were never merged.smart_rgb_conversion.This PR switches the GPU path to torchcodec's
decode_jpeg, which is already a dependency (pinned at 0.17.0). It decodes on the caller's current stream and synchronizes with work already queued there (meta-pytorch/torchcodec#1634), outputs RGB withmode="RGB", and applies EXIF orientation. The PIL path now applies EXIF orientation too, so the GPU and CPU paths agree.This is a behavior change for images whose EXIF Orientation tag is 2–8 (in practice, most portrait phone photos): the model now sees them upright, as viewers display them. HF transformers'
load_imageand vLLM do the same. Images without such a tag are unaffected.Fixes #43075
Fixes #33464
Fixes the GPU-decode case of #44748, the reported 1-channel tensor.
Addresses the grayscale-JPEG trigger of #26751.
Supersedes #43076, #33449 and #35101.
Modifications
srt/utils/common.py:_load_imagedecodes JPEGs on the GPU withtorchcodec.decoders.decode_jpeg(image_bytes, mode="RGB", device="cuda")._apply_exif_orientationhelper. It returns the image itself (no copy) when there's nothing to undo, doesn't rotate the caller's object in place, and treats malformed EXIF as no rotation.load_imagetakesapply_exif_orientation(defaultTrue). WithFalse, images keep their stored orientation on every path; since torchcodec always applies EXIF, such JPEGs are decoded with PIL instead.smart_to_rgbno longer applies EXIF orientation itself, since images reach it already upright.gpu_image_decode="nvjpeg_fancy") ignores EXIF orientation. They setapply_exif_orientation = Falseon their processors, and Kimi-K3 also sets it in itsEncoderMediaProcessorConfig. As a result their PIL fallback and their non-JPEG inputs keep today's behavior too. The flag is passed throughBaseMultimodalProcessor._load_single_item,MediaArtifactCacheMixin.decode_media_snapshotand the encoder-disaggregation preprocessor. It is also part of the preprocess-cache fingerprint, so artifacts cached before this change aren't reused.srt/multimodal/media_artifacts/base.py: PIL inputs on the preprocess-cache path go throughload_image, so they get the same loading and orientation as everywhere else.python/pyproject.toml: torchcodec is now installed unconditionally. It used to be excluded on Linux ARM because no wheels were available there. torchcodec has shipped Linux aarch64 wheels since 0.11, and 0.17.0 has CUDA-enabled aarch64 wheels for every supported CPython. So GB200/GH200 keep GPU JPEG decoding. As a consequence, video and audio decoding on Linux ARM also use torchcodec, as they already do on x86 (VideoDecoderWrapperandload_audiouse torchcodec whenever it imports). decord2 and PyAV stay installed there as fallbacks. The Docker images already install FFmpeg on every architecture.Accuracy Tests
For JPEGs that are already RGB and have no EXIF rotation, the GPU path's output is unchanged. I compared torchcodec 0.17.0 against torchvision 0.29.1, both decoding on CUDA (RTX 4080, torch 2.14.1+cu130). On every RGB JPEG in torchcodec's test set and doc thumbnails (gradient, 15 real photos), the two outputs were bit-identical (
torch.equal). Both differ from PIL by the same amount, since both use nvJPEG's default chroma upsampling. So I didn't run an end-to-end eval.Model inputs change only in the cases this PR fixes: grayscale JPEGs (now 3-channel instead of an error) and photos with an EXIF orientation (now upright).
Speed Tests and Profiling
Median of 50 runs on a 1920x1280 JPEG (q=90, 4:2:0), RTX 4080:
.cuda()decode_jpeg(CUDA)decode_jpeg(CUDA)Per-image speed is unchanged. Batching a request's JPEGs into one nvJPEG call would save about 15%; that's left for a follow-up. The first-use lazy import costs about 930 ms for torchcodec against about 760 ms for torchvision, once per tokenizer process.
Tests
New tests in
test/registered/unit/multimodal/test_base_processor_image_decode.py(CPU) andtest/registered/unit/utils/test_common.py(1-GPU CUDA CI). Each non-regression test names its issue in its docstring. All of them fail on main and pass with this PR:(3, H, W).exif_transpose. The mean abs difference is about 1, against about 128 for the opposite rotation.torch.cuda._sleepstill read it. On main this corrupted those kernels' results in 7 of 20 runs on an RTX 4080; with torchcodec, 0 of 20.apply_exif_orientation = Falsegets the stored orientation, and torchcodec is never called for it.Locally, those two files plus
test_audio_container_decode.py,test_base_processor_bad_input.pyandtest_media_artifact_processor.pyall pass, as doestest_encode_server.py: 87 tests and 59 subtests. pre-commit is clean.Checklist
CI States
Latest PR Test (Base): ❌ Run #39990804200
Latest PR Test (Extra): ❌ Run #39990804122
Latest PR Test (AMD ROCm 10): ❌ Run #39990804063