Skip to content

Remove dependency on pytorch#1495

Closed
NicolasHug wants to merge 27 commits into
meta-pytorch:mainfrom
NicolasHug:torch-optional-decoder
Closed

Remove dependency on pytorch#1495
NicolasHug wants to merge 27 commits into
meta-pytorch:mainfrom
NicolasHug:torch-optional-decoder

Conversation

@NicolasHug

@NicolasHug NicolasHug commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

Sooooo this makes TorchCodec torch-free: torch is optional and VideoDecoder returns numpy or cupy arrays when torch isn't there.

This PR is obviously never going to get merged, and there is no current plan to enable this, this is purely exploratory. Just wanted to explore the feasibility of this. (If you read this and you need TorchCodec to be torch-agnostic, let us know, this is useful signal).

Looks like it's doable, the main implication being that we now need a torch-agnostic data structure to store the frame outputs, and it needs all the operations that we used to rely on torch for (for CPU and CUDA): allocation, division, concatenation, etc. We can probably drastically reduce the surface of those if we're OK with not supporting all features in the non-torch paths, which is probably OK (I mean, decord has exactly the same constraint and they're not re-implementation all torch.tensor operations).

I'm still not entirely confident about the packaging story: what kind of wheels do we build, and where do we push them. Also unclear about the output type for the CUDA non-torch path. On CPU it should clearly be numpy.

Pasting below claude's design - it's a bit noisy. I particularly don't follow the justifications around the bindings, maybe we could simplify everything with nanobind - the first step would be simplify our current bindings (in main) since the situation is already messy.

Making PyTorch optional in TorchCodec — design & implementation

Branch: torch-optional-decoder (branched off main).

This document explains the goal, the architecture, the key decisions (with the
alternatives we rejected and why), the user experience, what's verified, and
what's intentionally left for later.

Update: torch-free decoding now covers GPU as well as CPU. With no
torch installed, CPU decode returns numpy and CUDA decode returns cupy
(both zero-copy via DLPack). See §4.11–§4.13 for the CUDA decisions and
§10 (Implications for future development) for what this means when you add
features going forward.


1. Goal

Make PyTorch an optional runtime dependency of TorchCodec's video decoder:

  • If torch is installed → VideoDecoder returns torch.Tensor (today's
    behavior, unchanged, including torch.compile support).
  • If torch is not installed → VideoDecoder still works and returns
    numpy.ndarray.
  • If torch is installed but the user wants numpy, they can opt in
    (torchcodec.set_bridge("numpy")).

Target end-state, in one sentence: one wheel that decodes to torch tensors when
torch is present and to numpy (CPU) / cupy (CUDA) arrays when it isn't.

Scope: the decoder, on CPU and CUDA. Torch-free CUDA decode returns cupy
(uint8). Out of scope for now (still require torch, raise a clear error
otherwise): audio decoding, encoders, samplers, transforms, and float32 output
— see §8 and §10.


2. User experience

Install

Neither torch nor numpy is a hard dependency (this mirrors the torch
ecosystem convention of not pinning torch). TorchCodec needs at least one of
them at runtime:

pip install torchcodec            # bring your own torch OR numpy
pip install torch torchcodec      # full features, torch.Tensor output
pip install numpy torchcodec      # torch-free, numpy output

The "bridge" (decord-inspired)

A global, thread-safe (ContextVar) setting selects the output array type. The
VideoDecoder API itself is unchanged.

import torchcodec
from torchcodec.decoders import VideoDecoder

torchcodec.get_bridge()             # "torch" if torch installed else "numpy"

d = VideoDecoder("video.mp4")
d[0]                                # torch.Tensor (torch present) or np.ndarray

torchcodec.set_bridge("numpy")      # even with torch installed -> numpy
d[0]                                # np.ndarray (zero-copy on CPU)

torchcodec.set_bridge("torch")      # raises clearly if torch isn't installed

Behavior matrix:

torch installed device bridge d[0] type
yes cpu/cuda "torch" (default) torch.Tensor
yes cpu "numpy" numpy.ndarray
no cpu "numpy" (default) numpy.ndarray
no cuda "numpy" (default) cupy.ndarray (GPU; see §4.13)
no any "torch" error

Torch-free CUDA decode requires cupy installed (the DLPack consumer); the
returned object is DLPack-capable, so jax/torch can also consume the frame.


3. The core problem and the architecture

TorchCodec's C++ core was built on torch::stable::Tensor for four distinct
things: (1) a tensor data structure, (2) allocation (CPU + CUDA caching
allocator), (3) ops (copy_, to, narrow, permute, cat, rot90, div,
contiguous, …), and (4) CUDA stream/context/device-guard. To make torch
optional we had to provide torch-free equivalents of #1#3 (#4 stays torch for
now — GPU is deferred).

DLPack does not solve this — see §4.1.

The architecture is three C++ libraries + a Python dispatch layer:

                     ┌───────────────────────────── Python ─────────────────────────────┐
                     │  VideoDecoder ── _bridge (output type) ── ops.py (dispatch)        │
                     │                                   │                                │
                     │            torch present?  ┌──────┴───────┐  torch absent?         │
                     │            _torch_ops.py   │              │  _numpy_ops.py         │
                     └────────────────│───────────┘              └──────────│─────────────┘
                                      │ torch.ops                           │ pybind
   libtorchcodec_custom_opsN.so  ◀────┘  (TORCH adapter: registers          │
     (links torch; the adapter)         allocator+CUDA backend hooks,       │
                                        converts tc<->torch::stable)        │
                                                  │                          │
   libtorchcodec_pybind_opsN.so  ◀─────────────────────────────────────────┘
     (torch-FREE pybind frontend; returns DLPack capsules -> numpy)
                                                  │
   libtorchcodec_coreN.so  ◀───────────── tc::Tensor (torch-FREE) ──────────
     (FFmpeg + the decode engine; CPU links NO torch)
  • core is now torch-symbol-free (it uses tc::Tensor, not
    torch::stable::Tensor) on both CPU and CUDA. A torch CUDA build still
    links torch (the allocator/compute hooks route to torch), but a torch-free
    CUDA build (ENABLE_CUDA=1 ENABLE_TORCH=0) links only CUDA::cudart and
    compiles TCCudaBackend.cu, which provides the allocator + GPU kernels with no
    torch at all. The CUDA stream/context/device-guard are reached through
    torch-free seams (CUDAStreamHook.h + CudaDeviceGuard); when torch is
    present the adapter registers torch's current-stream getter so behavior is
    unchanged.
  • custom_ops is the torch adapter: it keeps the torch.ops.torchcodec_ns.*
    custom-op registrations (so torch.compile works), and it registers the
    allocator/compute hooks that wire torch into the torch-free core.
  • pybind is the torch-free frontend: it exposes decode ops that return
    DLPack capsules (consumed as numpy/cupy/jax via from_dlpack).

At runtime the loader (_internally_replaced_utils.py) loads custom_ops via
torch.ops.load_library when torch is importable, otherwise loads core via
ctypes + the pybind module. The same installed files serve both worlds
custom_ops is simply never loaded when torch is absent.


4. Key decisions (and rejected alternatives)

4.1 Our own tc::Tensor, not "just DLPack"

Decision: introduce a small torch-free tensor type, tc::Tensor
(TCTensor.{h,cpp}) — owns {data_ptr, shape, strides, dtype, device, deleter}, implements the op subset the core needs, and exports DLPack.

Rejected: "just use DLPack as the tensor type." DLPack is purely an
exchange format — a C struct + capsule protocol. It has no allocation, no
ops, no memory management
. The core needs to compute (copy, cast, permute,
cat, normalize), so we needed a real (if minimal) tensor type. DLPack is used
only at the edges (handing buffers to numpy/torch/cupy).

4.2 torch interop via from_blob+deleter, not DLPack

Phase 0 finding: PyTorch's stable ABI does not expose DLPack
(DLManagedTensor appears nowhere under torch/csrc/; ATen/dlpack.h is
guarded off when TORCH_TARGET_VERSION is defined, which the core sets). But
torch::stable::from_blob with a custom deleter exists (≥ 2.11).

Decision: the torch adapter converts tc::Tensortorch::stable::Tensor
zero-copy via from_blob + a deleter that holds a reference to the source
(TCStableConvert.h). DLPack is used only on the torch-free pybind frontend.

4.3 Allocator is a pluggable hook → torch's caching allocator when present

Decision: tc::Tensor never calls cudaMalloc itself. Allocation goes
through a process-wide hook (tc::setAllocator). The torch adapter registers an
allocator that uses torch::stable::empty — i.e. torch's CUDA caching
allocator
— when torch is present.

Why / rejected alternatives: we deliberately do not reimplement a CUDA
caching allocator (the "slippery slope"). We checked two reference libraries:

  • decord: device allocator is plain cudaMalloc/cudaFree; its
    NDArrayPool recycling free-list is constructed with capacity 0, i.e.
    effectively per-frame allocation. No caching allocator.
  • PyNvVideoCodec: a grow-once, reuse-forever std::vector of output buffers
    (steady-state zero alloc) + a manual lock/unlock free-list. No cuMemPool.

Conclusion: nobody ships a fancy GPU allocator for a video decoder, and a video
decoder's output frames are uniform-shape so a trivial reuse ring suffices.
When torch is present, torch's caching allocator is strictly better than
either
, so we use it. For the future torch-free GPU path, plain per-frame
cudaMalloc (decord-proven) or a ~10-line reuse ring is enough — no allocator
rabbit hole.

4.4 A pluggable compute backend for non-CPU ops

Decision: tc::Tensor implements all ops natively for CPU. For non-CPU
devices each data-touching op dispatches to a registered backend
(tc::registerDeviceBackend). The torch adapter registers a CUDA backend that
runs each op via torch. Metadata-only views (narrow/select/permute) need no
backend (pure stride math, device-agnostic).

This is why the CUDA path "just works" while torch is present without the core
containing GPU kernels: the hooks route GPU allocation + compute to torch.

4.5 The bridge selects the output type, not the execution backend

This was the subtlest decision. Two models were considered:

  • (A, chosen) bridge = output type. Execution backend is chosen by torch
    availability (torch present → torch path; absent → pybind path). The bridge
    only decides how the result is wrapped: "torch" → tensor as-is; "numpy"
    tensor.numpy() (zero-copy on CPU).
  • (B, rejected) bridge = execution backend. "torch" → torch path,
    "numpy" → pybind path, even with torch installed.

Why B was rejected: the decoder handle types differ between backends — the
torch path's handle is a torch.Tensor (a pointer laundered into a tensor,
which is exactly what lets it flow through torch.compile graphs), while the
pybind path's handle is a plain int. A decoder is therefore bound to one
backend for its whole life
(handle, ops, and metadata calls). Model B would
require both backends live simultaneously, the bridge locked at decoder-creation
time, threading "which backend" through every op and metadata call, and
torch.compile only on the torch bridge anyway. Model A is less code: one
execution path per environment, one handle type, metadata untouched, and the
"go through torch then convert" is a single zero-copy .numpy(). This also
matches what decord actually does (one decode engine, bridge converts at output).

4.6 Keep torch.compile → keep shipping custom_ops

Decision: the CPU wheel still contains custom_ops (the torch.ops
adapter), so torch users keep native tensors and torch.compile.

Rejected: a "pure DLPack" wheel that always decodes via pybind and wraps
from_dlpack → torch in Python. It's the simplest possible binary, but it
drops torch.compile (which needs the custom-op registration) — a
regression for existing torch users.

4.7 One wheel, not two — the CPU core must not link torch

Decision: the CPU core does not link torch even when built with
ENABLE_TORCH=ON (it's torch-symbol-free; only the CUDA core links torch). So a
single CPU wheel, built with torch, ships core (torch-free) + custom_ops
(torch) + pybind, and loads in a torch-free environment.

Rejected: (a) a separate torch-free wheel — more packaging/UX surface;
(b) a "fat" wheel bundling two full core builds. Neither is needed once the core
doesn't link torch.

4.8 numpy is not a hard dependency

Decision: pyproject.toml declares neither torch nor numpy; numpy imports
are guarded (_bridge.py). A torch-only user doesn't pull numpy; a numpy-only
user doesn't pull torch. Requested-but-missing → clear error.

Rejected: declaring numpy in dependencies. It would force numpy on
torch-only users and didn't match the "leave it to the user" convention.

4.9 Module split for the Python dispatch

Decision: ops.py became a thin loader+dispatcher that re-exports either
_torch_ops.py (the original torch body, moved verbatim → byte-identical
torch path) or _numpy_ops.py (pybind → numpy). This keeps the torch path
unchanged and isolates the torch-free implementation.

4.10 TC_CHECK and moving metadata JSON

  • STD_TORCH_CHECK (torch) was replaced by a torch-free TC_CHECK/
    TC_CHECK_INDEX (TCError.h) across the CPU core, mapping to
    RuntimeError/IndexError exactly as before.
  • The metadata-JSON serialization lived in custom_ops.cpp (torch side); it was
    moved to a torch-free MetadataJson.{h,cpp} so the pybind frontend can read
    metadata (needed by VideoDecoder.__init__). fmt::to_stringstd::to_chars
    (shortest round-trip, preserves precision).
  • _video_decoder.py/_core/_decoder_utils.py use from __future__ import annotations so torch-typed annotations don't need torch at import, and the
    output_dtype=torch.uint8 default param value became a module constant.

4.11 CUDA decoupled from torch via three small seams (not a rewrite)

The CUDA device interfaces touched torch in only three places, all in
CUDACommon: the current stream (aoti_torch_get_current_cuda_stream), context
init, and a device guard. Decision: replace each with a torch-free seam
rather than forking the CUDA code:

  • a stream-provider hook (CUDAStreamHook.h, a void*-typed
    std::function): the torch adapter registers torch's current-stream getter so
    the torch build is byte-for-byte unchanged; torch-free uses the default stream.
  • initializeCudaContext already allocated through the allocator hook, so it is
    torch-agnostic for free.
  • CudaDeviceGuard — a cudaSetDevice RAII guard replacing torch's DeviceGuard.

This is the CUDA analog of what §4.4 did for compute: keep one set of CUDA code,
route the torch-specific bits through hooks.

4.12 Torch-free GPU compute = cudaMalloc + a few kernels (TCCudaBackend.cu)

Decision: for a torch-free CUDA build, register tc's allocator and compute
backend with a torch-free implementation: a per-frame cudaMalloc/cudaFree
allocator (the decord/PyNvVideoCodec approach from §4.3) and a CUDA compute
backend — copy_ (cudaMemcpy), zero_ (cudaMemset), and contiguous (a
small strided-copy kernel, needed because the decoded frame is a non-contiguous
CHW view). Ops only needed for float32 / transforms / audio
(toDtype/div/rot90/cat) are stubbed to raise a clear error.

Rejected: writing the full GPU op set now. The default uint8 decode path
only needs alloc + copy + zero + contiguous; the rest can be added when float32
/ transforms are made torch-free (§10).

4.13 Torch-free GPU output is cupy (the bridge passes GPU arrays through)

Decision: a torch-free CUDA frame comes back as a cupy.ndarray. The
op layer converts the decoded DLPack frame device-aware: CPU → numpy, CUDA →
cupy. The pybind frontend returns a self-describing _DLPackFrame
(correct __dlpack_device__), so the conversion (and any other DLPack consumer)
sees the real device. to_bridge_array passes GPU arrays through unchanged under
the "numpy" bridge (numpy is the host-only member of its family; on GPU the
natural output is the GPU array).

Rejected: (a) returning a raw DLPack capsule and making the user pick a
framework — worse ergonomics; cupy is the de-facto CUDA-DLPack array and the
object is still DLPack-capable for jax/torch. (b) adding a dedicated "cupy"
bridge value — GPU output is implicitly cupy regardless of bridge, since numpy
can't hold it and torch is absent. A "cupy" (or generic device) bridge can be
added later if needed.


5. Phase-by-phase (commit map)

Phase What Commits
0 DLPack spike: stable ABI has no DLPack; from_blob+deleter is the interop path (finding)
A torch-free core: tc::Tensor+DLPack, allocator hook, Device API, compute-backend hook, torch wiring, full torch::stabletc swap, TC_CHECK 8335362, e49ad54, 17b837a, f81405e, eab9ff2, 68b390f, e2e1889, 09a8552
B torch adapter converts at the op boundary (folded into e2e1889)
C torch-free Python frontend: pybind decode ops, metadata move, ops.py split, frame/loader/metadata torch-optional, import torchcodec+VideoDecoder torch-free a90652e, 7ae8afa, e57bf2c, a888cf1, d8b59b3, b2f9505, 97f0a48
D bridge, single runtime-optional wheel, numpy-optional, CI, formatting 8f7ca31, cce7485, d72bba7, f0af9d5
E torch-free CUDA: decouple CUDA from torch (stream hook, device guard, TC_CHECK); TCCudaBackend.cu (alloc + kernels); ENABLE_CUDA && !ENABLE_TORCH build; cupy output; smoke + GPU CI; third-party-interface + Windows <array> fixes bf3621e, 53a31a6, 5e10f45, …, 50aed53

6. Verification

Three real conda environments were used:

  • codeccuda — Python 3.10, torch (CUDA 12.6), FFmpeg 8. The torch path
    (CPU + CUDA).
  • codecfree — Python 3.12, no torch (import torch genuinely fails),
    FFmpeg 8, numpy. The torch-free CPU path.
  • codecfreecuda — Python 3.12, no torch, conda cuda-nvcc +
    cuda-cudart-dev + cupy, FFmpeg 7. The torch-free CUDA path.

Results:

  • Torch path unchanged: ~983 tests pass (decoders/ops/metadata/frame/bridge),
    plus 787 samplers/transforms/encoders earlier; CPU + CUDA decode work.
  • Torch path on the torch-free core (the CPU single-wheel config): torch
    tensors + numpy bridge, 124 cpu/bridge tests pass.
  • Genuinely torch-free (codecfree): import torchcodec; VideoDecoder(path)
    returns numpy for []/get_frame_at/get_frames_in_range/get_frames_at/
    get_frames_played_at/metadata/len, with torch never imported; built libs
    link no torch (ldd).
  • Genuinely torch-free CUDA (codecfreecuda): VideoDecoder(path, device="cuda") returns cupy uint8 (single + batch, both the NVDEC
    default and ffmpeg backends), torch never imported, core links no torch.
    GPU frame sums match the torch CUDA decode bit-for-bit (e.g. frame 10 sum =
    4213828). Third-party-interface: 3 tests pass.
  • C++ gtests (TCTensorTest, TCTorchBackendTest): pass. (TCTorchBackendTest
    includes a real GPU CPU→CUDA→div→CPU round-trip through torch's allocator.)
  • Lint: pre-commit run --all-files passes (ufmt + clang-format + flake8).

Tests/CI added:

  • test/smoke_test_no_torch.py — loads the pybind module directly, decodes to
    numpy, asserts torch never imported.
  • test/smoke_test_no_torch_package.py — full import torchcodec + VideoDecoder
    torch-free.
  • test/test_bridge.py — bridge behavior in the torch env.
  • .github/workflows/no_torch_smoke.yaml — (job 1) torch-free build + ldd + both
    smokes; (job 2) single-wheel-runtime-optional: build CPU wheel with torch,
    verify torch+numpy-bridge output, uninstall torch, verify same install
    still decodes numpy.

Pre-existing failures (NOT caused by this work, confirmed on base): 2
GetAudioMetadata C++ gtests fail due to the installed FFmpeg's header-reported
audio duration (13.056 vs 13.25); FFmpeg-version dependent.


7. Key files

New (torch-free core/frontend):

  • src/torchcodec/_core/dlpack.h — vendored, trimmed DLPack header.
  • src/torchcodec/_core/TCTensor.{h,cpp}tc::Tensor, ops, allocator +
    compute-backend hooks, DLPack export/import.
  • src/torchcodec/_core/TCError.h — torch-free TC_CHECK/TC_CHECK_INDEX + visibility macros.
  • src/torchcodec/_core/TCStableConvert.h — zero-copy tc::Tensortorch::stable::Tensor.
  • src/torchcodec/_core/TCTorchBackend.cpp — registers torch allocator + CUDA backend (in custom_ops).
  • src/torchcodec/_core/MetadataJson.{h,cpp} — torch-free metadata JSON.
  • src/torchcodec/_core/_torch_ops.py / _numpy_ops.py — the two backends.
  • src/torchcodec/_bridge.pyset_bridge/get_bridge + DLPack-based conversion.

Modified (made torch-optional / swapped to tc::Tensor): all CPU-core .cpp/.h
(Frame, DeviceInterface, SingleStreamDecoder, CpuDeviceInterface,
Encoder, AVIOTensorContext, SwScale, WavDecoder, Metadata,
FFMPEGCommon, FilterGraph, Transform, ValidationUtils, StreamOptions,
…); custom_ops.cpp, pybind_ops.cpp; ops.py, _internally_replaced_utils.py,
_metadata.py, _frame.py, _core/_decoder_utils.py, decoders/_video_decoder.py,
decoders/__init__.py, __init__.py; CMakeLists.txt (both), setup.py,
pyproject.toml.

New (torch-free CUDA):

  • src/torchcodec/_core/CUDAStreamHook.hvoid*-typed stream-provider seam.
  • src/torchcodec/_core/TCCudaBackend.cu — torch-free CUDA allocator + compute
    backend (kernels), compiled into core only for ENABLE_CUDA && !ENABLE_TORCH.
  • test/smoke_test_no_torch_cuda.py — public-API GPU decode → cupy, no torch.

Build flags: ENABLE_TORCH (default ON) gates building custom_ops + torch
hooks. ENABLE_CUDA gates the CUDA device interfaces; it no longer requires
ENABLE_TORCH. The build matrix:

  • ENABLE_TORCH=1 ENABLE_CUDA=0 — CPU wheel (core torch-free, custom_ops
    torch); runs with or without torch at runtime.
  • ENABLE_TORCH=1 ENABLE_CUDA=1 — torch CUDA build (core links torch; GPU compute
    via torch hooks).
  • ENABLE_TORCH=0 ENABLE_CUDA=0 — torch-free CPU.
  • ENABLE_TORCH=0 ENABLE_CUDA=1 — torch-free CUDA (core links CUDA::cudart,
    builds TCCudaBackend.cu).

8. What's missing / future work

Done since the original CPU scope:

  • GPU decoding torch-free (§4.11–4.13): uint8 video on CUDA returns cupy with
    no torch. ENABLE_CUDA no longer requires ENABLE_TORCH.

Deferred by design (still require torch; raise a clear error torch-free):

  • output_dtype="float32" — needs a uint8→float32 cast + divide. On CPU tc
    already implements both natively (it's gated off at the Python layer only); on
    CUDA the toDtype/div backend ops are stubbed (need two small kernels).
  • transforms (resize/crop/rotate) — the torchcodec.transforms module
    imports torch (transform-spec building), and GPU rot90 is stubbed.
  • audio decoding/WAV, encoders, samplers — not ported to the
    torch-free frontend (audio is CPU-only; nothing torch-fundamental).
  • file-like/bytes/tensor sources in the torch-free path (file paths work).
  • torch.compile only applies on the torch bridge (numpy/cupy users don't compile).

Packaging caveats to verify in the real wheel build (not locally testable):

  • custom_ops links libtorch, so auditwheel/delocate must EXCLUDE libtorch
    from bundling
    (as the existing torch wheels already do). Otherwise the wheel
    would ship its own libtorch and defeat torch-optionality.
  • A torch-free CUDA wheel must source libcudart (and FFmpeg's CUDA support)
    without torch — in the validation env these come from the conda CUDA toolkit.
    This is the "CUDA deps currently ride in via torch" problem; a real torch-free
    GPU wheel needs to depend on / bundle the CUDA runtime itself.

Nice-to-haves:

  • A "cupy" bridge target (trivial once torch-free GPU lands).
  • A context-manager form of the bridge (with torchcodec.bridge("numpy"):).
  • Clearer error if neither torch nor numpy is installed (today: a plain
    numpy ModuleNotFoundError at import).
  • mypy not run locally; lazy annotations should keep it green but verify in CI.

Testing limitation encountered: the two validation envs use different Python
versions (3.10 vs 3.12), so a single built .so set can't be shared across them
(pybind ABI). The CI single-wheel-runtime-optional job is the canonical
one-environment proof (build with torch → uninstall torch → still works).


9. How to build / use

# Full (torch) build — unchanged from before:
pip install -e ".[dev]" --no-build-isolation

# Torch-free source build (CPU only; needs ffmpeg, pybind11, numpy, no torch):
ENABLE_TORCH=0 ENABLE_CUDA=0 pip install -e . --no-build-isolation

# The published CPU wheel is built WITH torch (ENABLE_TORCH=1, ENABLE_CUDA=0):
# its core links no torch, so it also runs in torch-free environments.

Runtime:

import torchcodec
from torchcodec.decoders import VideoDecoder
frame = VideoDecoder("video.mp4")[0]        # torch.Tensor or np.ndarray
torchcodec.set_bridge("numpy")              # force numpy (torch optional)

10. Implications for future development (read this before adding features)

The big mental shift: the decoder core is no longer "a torch program." It is
a torch-free C++ engine (tc::Tensor) with torch wired in as one optional
backend
. If you add to the decoder path, you can't just reach for a torch op and
move on. Concretely:

10.1 You cannot use torch:: in the core

The CPU core is torch-symbol-free and the torch-free CUDA core links no torch at
all. #include <torch/...> or torch::stable::... in libtorchcodec_core is a
regression (it would re-break the torch-free build and the single-wheel
property). Torch may only appear in the torch adapter (custom_ops.cpp,
TCTorchBackend.cpp) and _torch_ops.py. Use TC_CHECK/TC_CHECK_INDEX, not
STD_TORCH_CHECK, in the core.

10.2 A new data-touching op: what's genuinely new vs. what was always there

Any operation that reads/writes tensor data (a normalization, a new layout, a
crop, …) is no longer "just call torch." The op lives on tc::Tensor, and
non-CPU work goes through the DeviceBackend hook (TCTensor.h). Adding op foo
touches up to four places — but most of that is not new work:

  1. CPU native impl in TCTensor.cppNEW. Plain C++.
  2. DeviceBackend field for foo — trivial (one struct field).
  3. torch CUDA backend in TCTorchBackend.cppbasically free. It's a
    one-line call to the torch op you would have called before the migration
    anyway
    . Pre-migration the core already did GPU foo by calling torch; that
    call just moved into the adapter.
  4. torch-free CUDA kernel in TCCudaBackend.cuNEW (only if foo must
    work on GPU without torch; otherwise stub it to raise).

So the honest cost of a new GPU-capable op is a CPU C++ implementation + a
CUDA kernel
. Steps 2–3 are nearly free (a field + the torch call we always
made). And if the op is only needed for a torch-only feature, step 4 is a stub.

Worked example — division (div, used by float32 normalization /255):

// 1. CPU native — NEW (TCTensor.cpp): a plain loop.
Tensor div(const Tensor& self, double other) {
  if (self.device().type() != DeviceType::CPU)
    return requireBackend(self.device(), "div").div(self, other);   // -> backend
  Tensor out = empty(self.sizes(), self.scalar_type(), self.device());
  forEachIndex(self.sizes(), [&](const auto& idx) {
    storeFromDouble(elemPtr(out, idx), dt,
                    loadAsDouble(elemPtr(self, idx), dt) / other);
  });
  return out;
}

// 3. torch CUDA — ~FREE (TCTorchBackend.cpp): the torch call we'd have made anyway.
backend.div = [](const tc::Tensor& self, double other) {
  return fromStable(stableDiv(toStable(self), other));   // torch does the work
};

// 4. torch-free CUDA — NEW, and currently STUBBED (div is float32-only, deferred):
backend.div = [unsupported](const tc::Tensor&, double) -> tc::Tensor {
  unsupported("div");   // would be a ~10-line elementwise divide kernel
};

The default uint8 decode path must keep working in all four build modes;
torch-only extras (like float32 div) may stub step 4 and gate in _numpy_ops.py.

10.2a How decord avoids almost all of this (and why we don't)

We checked decord. Its core needs far fewer ops than we do, so it largely
sidesteps the CPU-impl-plus-kernel burden:

  • decord's NDArray is a DLPack container, not a compute tensor. Its only
    methods are Empty, CopyFrom/CopyTo (incl. device transfer), and
    CreateView/CreateOffsetView (shape/stride views). There is no div, no
    permute, no cat, no rot90, no astype
    in decord's core.
  • The one piece of real compute it needs — color conversion + resize +
    optional /255 normalization — is fused
    into a single CUDA kernel
    (src/improc/improc.cu, the normalized ? 1.f : 255.f factor) plus CUDA
    texture hardware for scaling; on CPU it's FFmpeg swscale/filtergraph, not
    hand-written loops. So decord has exactly one CPU path (FFmpeg) and one GPU
    kernel for its whole pipeline.
  • Everything else — layout (it returns HWC), dtype, batching, further math —
    is deferred to the consuming framework
    via the bridge. The user does
    .permute(...), .float()/255, stacking, etc. in torch/numpy/cupy.

Why torchcodec carries more ops: we do more in the decoder — permute to
NCHW, float32 normalization, decode-time transforms (resize/crop/rotate
rot90), and batch assembly (cat) — to hand back ready-to-use tensors. That
design choice is what creates the tc::Tensor op surface (div, permute,
cat, rot90, contiguous, to) and hence the per-op CPU+kernel cost. If we
ever wanted to shrink that cost, the decord lever is the same: push layout /
dtype / normalization out to the bridge
and keep only color-conversion in core
(which is already a torch-free .cu kernel + FFmpeg on CPU). Most of our current
ops are metadata-only views anyway (permute/narrow/select cost nothing on
any device); the ones needing real CPU+kernel work are div/to(dtype) (float32)
and rot90/cat (rotate transform / batching).

10.3 Anything exposed to Python needs both op backends + the pybind op

A core op that the decoder calls from Python flows through ops.py, which
dispatches to one of two modules. Adding a Python-visible op means:

  • add it to _torch_ops.py (the torch.ops.torchcodec_ns.* schema + impl,
    and a @register_fake if it must work under torch.compile), and
  • add it to _numpy_ops.py (calling the pybind frontend), which means
    exposing it in pybind_ops.cpp too — or make it a _requires_torch
    stub there if it's torch-only.

Forgetting the _numpy_ops/pybind side means the feature silently doesn't exist
without torch; forgetting register_fake breaks torch.compile.

10.4 Never allocate or pick a stream directly

  • Allocation goes through tc::empty/tc::full/the allocator hook — never
    new/cudaMalloc in the core. This is what lets torch's caching allocator be
    used when present and cudaMalloc when not.
  • CUDA stream/context/guard go through getCurrentCudaStream() (the
    CUDAStreamHook seam) and CudaDeviceGuard — never
    aoti_torch_get_current_cuda_stream or torch's DeviceGuard in the core.

10.5 Interop is from_blob+deleter on the torch side, DLPack on the edge

There is no DLPack in torch's stable ABI (§4.2). To hand a core buffer to torch,
use the TCStableConvert.h zero-copy path. DLPack is only for the torch-free
frontend's numpy/cupy/jax consumers. If you return a new array type to Python,
return it as a self-describing _DLPackFrame (so the device is correct) and let
the bridge convert.

10.6 Output type is device- and bridge-dependent — don't assume torch.Tensor

A decoded frame may be a torch.Tensor, numpy.ndarray, or cupy.ndarray.
Code in _video_decoder.py and friends must not assume torch (no .cuda(),
.cpu(), torch.* on decoder output without a guard). Route outputs through
to_bridge_array. New public surfaces should be tested under all of: torch-CPU,
torch-CUDA, torch-free-CPU (numpy), torch-free-CUDA (cupy).

10.7 Device types are a closed enum

tc::DeviceType is {CPU, CUDA, XPU, PrivateUse1}. Supporting a genuinely new
accelerator means extending that enum and every switch over it
(deviceTypeName), the DLPack mapping (toDLPack/fromDLPack), the stable
conversion (TCStableConvert.h), and providing a DeviceBackend. Out-of-tree
plugins register a DeviceInterface under PrivateUse1 (see
test/third-party-interface) — keep that extension point working.

10.8 Be explicit with includes

The core no longer transitively pulls torch's giant headers, which used to mask
missing #includes. MSVC is strict: a missing <array>/<algorithm>/<cstring>
that "worked" via a torch transitive include will now fail the Windows build.
Include what you use.

10.9 The testing/build matrix grew

A decoder change should be exercised in the four build modes (§9) using the three
envs (codeccuda, codecfree, codecfreecuda). Note the shared-src/.so
gotcha
: editable installs copy libs into src/torchcodec/, so building in one
env clobbers another's libs (different Python ABI / FFmpeg version → import
errors); rm -f src/torchcodec/*.so and rebuild when switching envs, and give
each env its own TORCHCODEC_CMAKE_BUILD_DIR.

10.10 What is NOT affected

Samplers, transforms, encoders, audio, and WAV still legitimately require torch.
Extending those carries no torch-free obligation today — but the decoder
does, and anything those share with the decoder path (e.g. FrameBatch,
_core ops) must respect §10.1–10.6.

Introduce the keystone for making the PyTorch dependency optional: a
lightweight, torch-free tensor type (tc::Tensor) that owns a refcounted
buffer plus shape/strides/dtype/device and implements the small op surface
the core needs (empty/full/from_blob/copy_/zero_/to/narrow/select/permute/
contiguous/cat/rot90/div/accessors). It exports to DLPack so torch, numpy,
cupy and jax can all consume decoded frames zero-copy.

- dlpack.h: vendored, trimmed DLPack header (torch's ATen/dlpack.h is guarded
  off when TORCH_TARGET_VERSION is defined, which the core sets).
- TCTensor.{h,cpp}: CPU implementation of the type + ops + DLPack export/import.
  CUDA paths throw for now (wired up in the CUDA part of Phase A).
- TCTensorTest.cpp: gtest covering metadata, views, casts, cat, rot90
  (numpy-parity), and zero-copy DLPack roundtrip + storage lifetime.

Phase 0 finding: torch's stable ABI does not expose DLPack, but
torch::stable::from_blob (>=2.11) with a deleter is the zero-copy torch-side
interop path; DLPack is used only on the torch-free frontend.
TCStableConvert.h provides toStable/fromStable (plus dtype/device mappers) used
at the PyTorch-facing boundaries during migration: the custom-ops adapter and
the CUDA device interfaces that still use torch internally. Both directions are
zero-copy via from_blob + a deleter that holds a reference to the source so the
shared buffer outlives every view.

Verified to compile in the real torch context by including it in custom_ops.cpp
(built under -Werror). Not yet wired into behavior.
tc::Tensor must never reimplement a CUDA caching allocator (the slippery slope).
Reference libs confirm a simple approach is standard: decord effectively does
per-frame cudaMalloc (its NDArrayPool is built with capacity 0), and
PyNvVideoCodec uses a trivial grow-and-reuse vector, not a pool.

So allocation is delegated to a process-wide hook:
- torch present  -> adapter will register torch's caching allocator
- torch absent + CUDA -> a plain cudaMalloc/cudaFree allocator
- torch absent + CPU  -> built-in malloc fallback (no hook)

empty() now routes through the hook; non-CPU allocation without a hook is a
clear error. gtest covers the CPU default, the throw on CUDA-without-hook, and
that a registered allocator receives the right bytes/device and is used.
Give tc::Device type()/index() accessors mirroring torch::stable::Device so the
upcoming core swap (StableDevice -> tc::Device) needs minimal call-site churn.
Add the XPU device type (for the out-of-tree XPU plugin) and map it in the
tc<->stable conversion. Verified: editable build + 12 gtests green.
tc::Tensor implements all ops natively for CPU but must not reimplement GPU
kernels. Each data-touching op (copy_, to(dtype), to(device), div, contiguous,
cat, rot90) now dispatches to a per-device-type backend registered at module
init when the tensor is non-CPU:
- torch present -> adapter registers a CUDA backend running each op via torch
- torch-free GPU (later) -> a CUDA-kernel-backed backend
Metadata-only views (narrow/select/permute) need no backend and work on any
device. Missing backend -> clear error. gtest covers dispatch + the no-backend
error path. Editable build + 13 gtests green.
TCTorchBackend.cpp (compiled into the torch-linked custom-ops library)
registers, at load time:
- the storage allocator -> torch (torch's caching allocator on CUDA), so when
  torch is present GPU/CPU memory comes from torch's pool.
- the CUDA compute backend -> runs each non-CPU tc op via torch (toStable ->
  torch op -> fromStable), zero-copy at the boundary.

The torch-free pybind frontend won't load this, so a torch-free process keeps
the hooks unset (CPU malloc; GPU unavailable) by design.

Also fixes toStableDevice: CPU must be constructed without an explicit index
(torch CPU device is index -1; from_blob rejects a 'cpu:0' that doesn't match
the data's 'cpu'). Caught by the new GPU round-trip test.

TCTorchBackendTest links custom_ops and verifies the hooks are registered and a
real CPU->CUDA->div->CPU round-trip runs through torch (allocator + backend) on
GPU. All C++ gtests + editable build + import green.
…undary

The decoder/encoder core now uses tc::Tensor / tc::Device throughout instead of
torch::stable::Tensor / StableDevice. CPU ops run natively in tc; non-CPU ops
and allocation dispatch to the torch-backed hooks (allocator = torch's caching
allocator; CUDA compute backend = torch). The custom_ops adapter keeps its
torch::stable op surface (incl. torch.compile annotations) and converts
tc<->stable at each boundary via toStable/fromStable.

Swapped files: Frame, DeviceInterface, StreamOptions, SingleStreamDecoder,
CpuDeviceInterface, Encoder, AVIOTensorContext, SwScale, WavDecoder,
color_conversion, CUDACommon, Cuda/BetaCudaDeviceInterface, NVDECCache, Cache.
CUDA stream/context/device-guard plumbing stays on torch (CUDACommon).

tc::Tensor gained: typed-data_ptr dtype check (torch-compatible 'Long but found
Float' message), Device(string) parser, transpose, accessors, scalar-type
constants, operator<< for messages, zero_ backend dispatch. Conversions handle
empty tensors and canonicalize CPU device index (torch -1 vs tc 0).

STD_TORCH_CHECK retained for now (TC_CHECK is a later increment).

Verified: all libs build under -Werror; CPU+CUDA decode end-to-end; Python
suite green (test_decoders/ops 979 passed, encoders/samplers/transforms/
metadata/frame/policy 807 passed); C++ gtests 40/42 (the 2 GetAudioMetadata
failures are pre-existing — they fail identically on the base commit due to the
installed FFmpeg's header duration).
Introduce TCError.h with torch-free TC_CHECK / TC_CHECK_INDEX (std::runtime_error
-> RuntimeError, std::out_of_range -> IndexError) plus the symbol-visibility
macros. Replace STD_TORCH_CHECK / STABLE_CHECK_INDEX across the CPU core and drop
the StableABICompat.h (torch) include from those files; add tc::intArrayRefToString
for shape error messages and the cstring/algorithm includes that torch headers
had previously provided transitively.

After this, the CPU core files (Frame, DeviceInterface, SingleStreamDecoder,
CpuDeviceInterface, Encoder, AVIOTensorContext, SwScale, WavDecoder, Metadata,
FFMPEGCommon, FilterGraph, Transform, ValidationUtils, NVDECCacheConfig, ...)
contain zero torch symbols. The CUDA device interfaces and the custom-ops
adapter still use torch (StableABICompat.h) for stream/context and the op
boundary, as intended.

Verified: build green under -Werror; test_decoders+test_ops 979 passed (incl.
all error-message/type tests, confirming TC_CHECK maps to the same Python
exceptions); C++ gtests 40/42 (2 pre-existing GetAudioMetadata failures).

Next: a CMake torch-free build mode so the core can actually be compiled/linked
without torch (prereq for the torch-free frontend).
ENABLE_TORCH CMake option (default ON). When OFF: build a CPU-only core +
pybind frontend that link NO torch, skipping the custom-ops adapter and torch
hooks. setup.py honors ENABLE_TORCH=0 (doesn't import torch, omits Torch_DIR).
A guard makes ENABLE_CUDA require ENABLE_TORCH (CUDA still uses torch for
stream/context).

pybind_ops.cpp gains torch-free decode ops (create_decoder, add_video_stream,
get_next_frame, destroy_decoder) returning frames as DLPack capsules; numpy/
cupy/jax consume them zero-copy via from_dlpack. AVIOFileLikeContext switched to
TC_CHECK so the pybind lib is torch-free too.

test/smoke_test_no_torch.py loads the torch-free pybind module directly
(bypassing the torch-importing torchcodec package), decodes a frame, and checks
the numpy array; it asserts torch is never imported.

.github/workflows/no_torch_smoke.yaml builds torch-free in a torch-less conda
env, asserts the libs link no torch (ldd), and runs the smoke.

Verified locally: torch-free cmake build links zero torch libs (ldd) and the
smoke passes; the default torch build (pip) still works unchanged.
pybind frontend gains scan_all_streams, get_frame_at_index, get_frame_played_at
(returning data capsule + pts/duration floats) and get_frames_in_range
(returning data + 1-D pts/duration capsules). The no-torch smoke now exercises
sequential, indexed, time-based, and batched range decoding, each consumed as a
numpy array via DLPack with torch never imported. Torch build unaffected.
The decoder metadata JSON builders (get_video/container/stream JsonMetadata)
were inline in custom_ops.cpp (the torch adapter). Extracted them to a torch-free
MetadataJson.{h,cpp} operating on a SingleStreamDecoder* (fmt::to_string ->
std::to_chars shortest round-trip to preserve precision; STABLE_CHECK_INDEX ->
TC_CHECK_INDEX). custom_ops now delegates (unwrap decoder -> core fn); the pybind
frontend exposes get_json_metadata / get_container_json_metadata /
get_stream_json_metadata. This unblocks a torch-free VideoDecoder.__init__,
which parses this metadata.

Verified: torch metadata unchanged (test_metadata 9 passed; num_frames=390,
fps=29.97); torch-free smoke reads identical metadata via pybind with torch
never imported.
Additive torch-optionality (torch path unchanged):
- _frame.py: Frame/FrameBatch/AudioSamples data annotated as numpy.ndarray when
  torch is absent (the dataclasses only use .ndim/.shape/indexing/iteration,
  which numpy supports).
- _internally_replaced_utils.py: load_torchcodec_shared_libraries works without
  torch — loads the core via ctypes(RTLD_GLOBAL) + the pybind module via
  importlib (no custom-ops adapter exists in a torch-free build).
- _metadata.py: torch-optional import + decoder annotation.

Verified: torch path unchanged (import/decode/metadata; test_metadata +
test_frame_dataclasses 24 passed). These are the layers around ops.py; the
ops.py torch.ops-vs-pybind dispatch remains the gating change for a fully
torch-free 'import torchcodec'.
ops.py is now a thin loader+dispatcher: it loads the shared libs once, then
re-exports either _torch_ops (torch present) or _numpy_ops (torch-free). The
torch implementation is moved verbatim into _torch_ops.py, so the torch path is
byte-identical. _numpy_ops implements the video read path via the pybind
frontend returning numpy (DLPack), and raises a clear 'requires torch' for
audio/encode/wav/nvdec/file-like/compile.

pybind frontend gained the remaining read ops VideoDecoder needs:
get_frames_at_indices, get_frames_by_pts, get_frames_by_pts_in_range,
get_key_frame_indices, and add_video_stream now takes stream_index/num_threads.

Verified: torch path unchanged (test_decoders + test_ops 979 passed; re-exports
intact). This makes ops.py importable without torch; the remaining gate for a
torch-free 'import torchcodec' is guarding the package/decoders/_video_decoder
imports.
…put)

The package now imports and decodes video without PyTorch installed:
- torchcodec/__init__ + decoders/__init__: guard encoders/samplers/transforms
  and audio/wav decoder imports behind torch availability (they require torch).
- _video_decoder.py: lazy annotations (from __future__ import annotations) so
  torch-typed signatures don't need torch; guard the torch imports; output_dtype
  default is a module constant (was torch.uint8, a torch object used as a default
  param value); guard _log_api_usage_once, dtype mapping, device resolution, and
  custom_frame_mappings. The decode methods were already array-agnostic.
- _core/_decoder_utils.py: lazy annotations; guard torch/transforms imports with
  a torch-free _make_transform_specs (raises only if transforms are passed).
- _numpy_ops.py: add benign _get_log_level/_set_cpp_log_level stubs (imported by
  _logging at import time).

Verified end-to-end in a real torch-free conda env (no torch installed):
'import torchcodec; VideoDecoder(path)' returns numpy uint8 for [], get_frame_at,
get_frames_in_range, get_frames_at, get_frames_played_at, metadata, len — torch
never imported. Torch path unchanged: 994 passed (decoders/ops/metadata/frame).
test/smoke_test_no_torch_package.py imports torchcodec and decodes via the
public VideoDecoder API, asserting numpy output and that torch is never
imported. The no_torch CI now also does 'ENABLE_TORCH=0 pip install -e .' (the
real user path) and runs this package smoke, in addition to the cmake-build +
ldd-no-torch-linkage + pybind-direct checks.

Verified locally in a genuine torch-free conda env: import torchcodec +
VideoDecoder return numpy with torch never imported. Torch suite unchanged
(decoders/ops/metadata/frame 994 passed; samplers/transforms/encoders 787).
A global, thread-safe (ContextVar) bridge selects the array type VideoDecoder
returns, without changing its API:
- default 'torch' when torch is installed, else 'numpy'
- set_bridge('numpy') returns numpy even when torch is installed (zero-copy)
- set_bridge('torch') without torch raises a clear error
- the bridge is the OUTPUT type only; execution still uses the torch path when
  torch is present (so torch.compile/native tensors are preserved) and the
  pybind path otherwise. Conversion is DLPack/buffer-based and device-aware
  (numpy bridge on a CUDA frame errors clearly rather than silently copying),
  which generalizes to cupy/jax + GPU later.

VideoDecoder routes data/pts/duration through the bridge at the Frame/FrameBatch
boundary; default is a no-op in both envs.

Verified: torch env default -> torch (979 tests + 4 bridge tests pass),
set_bridge('numpy') -> numpy matching torch values; torch-free env default ->
numpy, set_bridge('torch') raises, torch never imported.
…hanges

No functional change: ufmt (black/usort) on the new/edited Python and
clang-format on the new/edited C++. Verified build + bridge tests + decode still
pass.
CMake: the CPU core no longer links torch even with ENABLE_TORCH=ON (it is
torch-symbol-free); it links torch only for CUDA builds. custom_ops (the torch
adapter) now links torch directly. Net effect: a single CPU wheel built WITH
torch ships core (torch-free) + custom_ops (torch) + pybind, and at runtime the
loader uses the torch path when torch is importable, else core+pybind -> numpy.
Verified: CPU config -> core has no torch linkage (ldd) and the torch path still
works on the torch-free core (124 cpu/bridge tests pass; torch tensor + numpy
bridge); CUDA config unchanged (core links torch, cuda decode works, 983 tests).

pyproject: neither torch nor numpy is a hard dependency (mirrors the torch
ecosystem convention) -- torchcodec needs at least one at runtime.

_bridge.py: numpy import is now optional/guarded, so a torch-only user does not
need numpy (and a numpy-only user does not need torch). set_bridge('numpy')
without numpy, or ('torch') without torch, raises a clear error.
New single-wheel-runtime-optional job: builds the CPU wheel WITH torch
(ENABLE_TORCH=1 ENABLE_CUDA=0), asserts the core links no torch, verifies torch
output + numpy bridge with torch present, then UNINSTALLS torch and verifies the
same install still decodes to numpy. This is the end-to-end proof that one wheel
serves both worlds.
@pytorch-bot

pytorch-bot Bot commented Jun 26, 2026

Copy link
Copy Markdown

🔗 Helpful Links

🧪 See artifacts and rendered test results at hud.pytorch.org/pr/meta-pytorch/torchcodec/1495

Note: Links to docs will display an error until the docs builds have been completed.

✅ No Failures

As of commit 7d68779 with merge base eeb61c7 (image):
💚 Looks good so far! There are no failures yet. 💚

This comment was automatically generated by Dr. CI and updates every 15 minutes.

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Jun 26, 2026
…as-needed

Two CI-only bugs surfaced by the GitHub run (not real regressions):

1. The 'no torch linkage' ldd checks used grep 'libtorch' which also matches
   torchcodec's own libtorchcodec_core*.so (pybind depends on it), giving a false
   'torch is linked' failure. Tightened to 'libtorch[._]|libc10' so it matches
   real torch libs (libtorch.so/libtorch_cpu.so/libc10.so) but not libtorchcodec*.

2. TCTorchBackendTest.HooksAreRegistered failed under the conda toolchain's
   default -Wl,--as-needed: the test references no custom_ops symbol directly, so
   the linker dropped the custom_ops dependency and its static initializer (which
   registers the tc hooks) never ran. Link custom_ops with --no-as-needed so it's
   retained. (The real Python path dlopens custom_ops explicitly and is
   unaffected.) Verified locally: test passes, custom_ops stays in NEEDED.
The CUDA device interfaces used torch for exactly three primitives, all in
CUDACommon: the current stream (aoti_torch_get_current_cuda_stream), context
init, and a device guard. Replace these with torch-free seams so the CUDA core
no longer references torch symbols (mirrors what Phase A did for the CPU core):

- CUDAStreamHook.h: a void*-based stream-provider hook. getCurrentCudaStream()
  uses the provider when installed (torch present) else the default stream.
- The torch adapter (TCTorchBackend.cpp) registers torch's current-stream getter
  under USE_CUDA, so torch builds keep the exact prior behavior (decoded GPU
  frames stay on the user's torch stream).
- initializeCudaContext() (renamed from ...WithPytorch): already routed through
  tc's allocator hook, so it is torch-agnostic; comment updated.
- CudaDeviceGuard: torch-free RAII replacement for torch's DeviceGuard.
- STD_TORCH_CHECK -> TC_CHECK and drop StableABICompat.h across the CUDA files.

Torch+CUDA build verified green: both CUDA backends decode identically; 368
CUDA decoder tests pass. This is the prerequisite for an ENABLE_CUDA && !ENABLE_TORCH build.
GPU video now decodes with NO PyTorch installed, returning cupy arrays via
zero-copy CUDA DLPack. The default NVCUVID ('beta') and 'ffmpeg' CUDA backends
both work; sums match the torch CUDA decode bit-for-bit.

- TCCudaBackend.cu: torch-free implementation of tc's hooks for a CUDA build
  without torch -- a cudaMalloc/cudaFree allocator (per-frame, as decord and
  PyNvVideoCodec do; no caching allocator reimplemented) plus a CUDA compute
  backend (copy_ via cudaMemcpy, zero_ via cudaMemset, contiguous via a small
  strided-copy kernel). Ops only needed for float32/transforms/audio
  (toDtype/div/rot90/cat) raise a clear 'requires torch' error.
- CMakeLists: drop the 'ENABLE_CUDA requires ENABLE_TORCH' guard; for a
  torch-free CUDA build link CUDA::cudart directly (cuvid is dlopen'd) and
  compile TCCudaBackend.cu into the core. Hoist enable_language(CUDA) to
  top-level scope so plain cmake (no torch to set it up) gets
  CMAKE_CUDA_COMPILE_* in the parent scope.
- pybind_ops: decode ops return a self-describing _DLPackFrame (correct
  __dlpack_device__ for CPU vs CUDA), and add_video_stream gained device /
  device_variant params.
- _numpy_ops: device-aware conversion (CPU->numpy, CUDA->cupy) and device
  pass-through; _bridge passes GPU (cupy) arrays through the numpy bridge.

Verified in a real torch-free CUDA env (codecfreecuda: cuda-toolkit + cupy, NO
torch): import torchcodec + VideoDecoder(device='cuda')[i] returns cupy uint8,
single + batch, both backends. Torch+CUDA build unchanged (368 CUDA tests pass);
torch-free CPU smokes still pass.
smoke_test_no_torch_cuda.py decodes on the GPU via the public VideoDecoder API
with no torch installed and asserts cupy uint8 output (single + batch, time- and
index-based). A new no-torch-cuda-smoke CI job builds ENABLE_CUDA=1
ENABLE_TORCH=0 in a torch-free conda env (conda nvcc + cupy) on the
linux.g5.4xlarge.nvidia.gpu runner, asserts no torch linkage, and runs it.
… JIT

The decode itself worked in CI; the job failed on cupy's .sum(), which
JIT-compiles a reduction kernel via NVRTC -- cupy's build toolchain isn't
present in the CI env (DynamicLibNotFoundError: nvrtc). Use cp.asnumpy (a plain
device->host copy, no kernel compilation) for the value checks so the smoke
tests torchcodec's decode + DLPack interop rather than cupy's JIT.
cupy-cuda12x does not bundle the NVRTC runtime; it expects it from the CUDA
install. Our decoded frames are non-contiguous CHW views, so even copying them to
host (cp.asnumpy) makes cupy JIT-compile an ascontiguousarray kernel via NVRTC,
which failed with 'Failure finding libnvrtc.so'. Add conda cuda-nvrtc and put
$CONDA_PREFIX/lib on LD_LIBRARY_PATH so cupy's pathfinder finds it.
Two fallout fixes from the torch-free migration:

- third-party-interface: the test implemented a DeviceInterface against the old
  torch::stable API (StableDevice / torch::stable::Tensor / StableDeviceType::
  PrivateUse1), which Phase A replaced with tc:: types. Restore a generic
  custom-device slot tc::DeviceType::PrivateUse1 (mirrors torch's PrivateUse1 --
  the closed {CPU,CUDA,XPU} enum left out-of-tree DeviceInterface plugins with
  nowhere to register) and update the test to the tc:: API. 3 tests pass.

- Windows build: WavDecoder.cpp uses std::array but relied on a transitive
  include that dropping StableABICompat.h removed; MSVC is stricter and failed
  with C2079 'std::array uses undefined class'. Add #include <array> (also to
  StableABICompat.h defensively).

Verified: third-party-interface 3 passed (torch); torch-free CUDA smoke still
passes; CUDA build green.
@NicolasHug NicolasHug closed this Jun 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Meta Open Source bot.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant