Skip to content

[Fix] multimodal: Decode JPEGs on GPU with torchcodec and apply EXIF orientation - #46123

Open
NicolasHug wants to merge 1 commit into
sgl-project:mainfrom
NicolasHug:torchcodec-jpeg-decode
Open

NicolasHug wants to merge 1 commit into
sgl-project:mainfrom
NicolasHug:torchcodec-jpeg-decode

Conversation

@NicolasHug

@NicolasHug NicolasHug commented Oct 23, 2026 •

Copy link
Copy Markdown

Context: I'm the maintainer for both TorchCodec and TorchVision

Motivation

JPEGs are decoded on the GPU by default (gpu_image_decode = True) with torchvision.io.decode_jpeg(device="cuda"). That decoder has three known bugs in sglang:

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 with mode="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_image and 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_image decodes JPEGs on the GPU with torchcodec.decoders.decode_jpeg(image_bytes, mode="RGB", device="cuda").
    • If torchcodec is missing or fails, it warns once per distinct error and falls back to PIL.
    • PIL-decoded images and caller-supplied PIL images go through a new _apply_exif_orientation helper. 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_image takes apply_exif_orientation (default True). With False, images keep their stored orientation on every path; since torchcodec always applies EXIF, such JPEGs are decoded with PIL instead.
    • smart_to_rgb no longer applies EXIF orientation itself, since images reach it already upright.
  • Opt-out for Kimi-K3 and EmbeddingGemma2. Their GPU decoder (nvImageCodec, gpu_image_decode="nvjpeg_fancy") ignores EXIF orientation. They set apply_exif_orientation = False on their processors, and Kimi-K3 also sets it in its EncoderMediaProcessorConfig. As a result their PIL fallback and their non-JPEG inputs keep today's behavior too. The flag is passed through BaseMultimodalProcessor._load_single_item, MediaArtifactCacheMixin.decode_media_snapshot and 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 through load_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 (VideoDecoderWrapper and load_audio use 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:

ms / image
PIL decode + CHW tensor + .cuda() 16.39
torchvision decode_jpeg (CUDA) 3.77
torchcodec decode_jpeg (CUDA) 3.78
torchcodec, batch of 8 (CUDA) 3.20

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) and test/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:

Locally, those two files plus test_audio_container_decode.py, test_base_processor_bad_input.py and test_media_artifact_processor.py all pass, as does test_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

@NicolasHug

NicolasHug commented Oct 26, 2026 •

Copy link
Copy Markdown
Author

Hi @mickqian, pinging you here as your Slack profile asks. I'm Nicolas, on the PyTorch team at Meta. I maintain torchcodec and torchvision. I noticed a few areas for improvement in sglang's image/video decoding, and this is the first: it moves GPU JPEG decoding from torchvision to torchcodec, which fixes the stream race, grayscale and EXIF bugs (#43075, #44748, #33464).

Some other improvements I'd love to follow up on:

  • CPU image decoding with torchcodec to tensors, which skips the PIL-to-tensor conversion and is generally ~10% faster than PIL
  • Batching a request's JPEGs into one nvJPEG call: about 15% faster per image in my tests.

Happy to connect on Slack to chat more. Thanks!

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies Pull requests that update a dependency file

Projects

None yet

1 participant