Remove dependency on pytorch#1495
Closed
NicolasHug wants to merge 27 commits into
Closed
Conversation
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.
🔗 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 FailuresAs of commit 7d68779 with merge base eeb61c7 ( This comment was automatically generated by Dr. CI and updates every 15 minutes. |
…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.
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.
Sooooo this makes TorchCodec torch-free:
torchis optional andVideoDecoderreturns 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 offmain).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.
1. Goal
Make PyTorch an optional runtime dependency of TorchCodec's video decoder:
torchis installed →VideoDecoderreturnstorch.Tensor(today'sbehavior, unchanged, including
torch.compilesupport).torchis not installed →VideoDecoderstill works and returnsnumpy.ndarray.torchis 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
float32output— see §8 and §10.
2. User experience
Install
Neither
torchnornumpyis a hard dependency (this mirrors the torchecosystem convention of not pinning torch). TorchCodec needs at least one of
them at runtime:
The "bridge" (decord-inspired)
A global, thread-safe (
ContextVar) setting selects the output array type. TheVideoDecoderAPI itself is unchanged.Behavior matrix:
d[0]type"torch"(default)torch.Tensor"numpy"numpy.ndarray"numpy"(default)numpy.ndarray"numpy"(default)cupy.ndarray(GPU; see §4.13)"torch"Torch-free CUDA decode requires
cupyinstalled (the DLPack consumer); thereturned 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::Tensorfor four distinctthings: (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 torchoptional 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:
tc::Tensor, nottorch::stable::Tensor) on both CPU and CUDA. A torch CUDA build stilllinks torch (the allocator/compute hooks route to torch), but a torch-free
CUDA build (
ENABLE_CUDA=1 ENABLE_TORCH=0) links onlyCUDA::cudartandcompiles
TCCudaBackend.cu, which provides the allocator + GPU kernels with notorch at all. The CUDA stream/context/device-guard are reached through
torch-free seams (
CUDAStreamHook.h+CudaDeviceGuard); when torch ispresent the adapter registers torch's current-stream getter so behavior is
unchanged.
torch.ops.torchcodec_ns.*custom-op registrations (so
torch.compileworks), and it registers theallocator/compute hooks that wire torch into the torch-free core.
DLPack capsules (consumed as numpy/cupy/jax via
from_dlpack).At runtime the loader (
_internally_replaced_utils.py) loadscustom_opsviatorch.ops.load_librarywhen torch is importable, otherwise loadscoreviactypes+ the pybind module. The same installed files serve both worlds —custom_opsis 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 DLPackPhase 0 finding: PyTorch's stable ABI does not expose DLPack
(
DLManagedTensorappears nowhere undertorch/csrc/;ATen/dlpack.hisguarded off when
TORCH_TARGET_VERSIONis defined, which the core sets). Buttorch::stable::from_blobwith a custom deleter exists (≥ 2.11).Decision: the torch adapter converts
tc::Tensor↔torch::stable::Tensorzero-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::Tensornever callscudaMallocitself. Allocation goesthrough a process-wide hook (
tc::setAllocator). The torch adapter registers anallocator that uses
torch::stable::empty— i.e. torch's CUDA cachingallocator — when torch is present.
Why / rejected alternatives: we deliberately do not reimplement a CUDA
caching allocator (the "slippery slope"). We checked two reference libraries:
cudaMalloc/cudaFree; itsNDArrayPoolrecycling free-list is constructed with capacity 0, i.e.effectively per-frame allocation. No caching allocator.
std::vectorof 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 allocatorrabbit hole.
4.4 A pluggable compute backend for non-CPU ops
Decision:
tc::Tensorimplements all ops natively for CPU. For non-CPUdevices each data-touching op dispatches to a registered backend
(
tc::registerDeviceBackend). The torch adapter registers a CUDA backend thatruns each op via torch. Metadata-only views (
narrow/select/permute) need nobackend (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:
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)."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.compilegraphs), while thepybind path's handle is a plain
int. A decoder is therefore bound to onebackend 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.compileonly on the torch bridge anyway. Model A is less code: oneexecution path per environment, one handle type, metadata untouched, and the
"go through torch then convert" is a single zero-copy
.numpy(). This alsomatches what decord actually does (one decode engine, bridge converts at output).
4.6 Keep
torch.compile→ keep shippingcustom_opsDecision: the CPU wheel still contains
custom_ops(thetorch.opsadapter), 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 itdrops
torch.compile(which needs the custom-op registration) — aregression 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 asingle 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.tomldeclares neither torch nor numpy; numpy importsare guarded (
_bridge.py). A torch-only user doesn't pull numpy; a numpy-onlyuser doesn't pull torch. Requested-but-missing → clear error.
Rejected: declaring
numpyindependencies. It would force numpy ontorch-only users and didn't match the "leave it to the user" convention.
4.9 Module split for the Python dispatch
Decision:
ops.pybecame a thin loader+dispatcher that re-exports either_torch_ops.py(the original torch body, moved verbatim → byte-identicaltorch path) or
_numpy_ops.py(pybind → numpy). This keeps the torch pathunchanged and isolates the torch-free implementation.
4.10
TC_CHECKand moving metadata JSONSTD_TORCH_CHECK(torch) was replaced by a torch-freeTC_CHECK/TC_CHECK_INDEX(TCError.h) across the CPU core, mapping toRuntimeError/IndexErrorexactly as before.custom_ops.cpp(torch side); it wasmoved to a torch-free
MetadataJson.{h,cpp}so the pybind frontend can readmetadata (needed by
VideoDecoder.__init__).fmt::to_string→std::to_chars(shortest round-trip, preserves precision).
_video_decoder.py/_core/_decoder_utils.pyusefrom __future__ import annotationsso torch-typed annotations don't need torch at import, and theoutput_dtype=torch.uint8default 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), contextinit, and a device guard. Decision: replace each with a torch-free seam
rather than forking the CUDA code:
CUDAStreamHook.h, avoid*-typedstd::function): the torch adapter registers torch's current-stream getter sothe torch build is byte-for-byte unchanged; torch-free uses the default stream.
initializeCudaContextalready allocated through the allocator hook, so it istorch-agnostic for free.
CudaDeviceGuard— acudaSetDeviceRAII guard replacing torch'sDeviceGuard.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/cudaFreeallocator (the decord/PyNvVideoCodec approach from §4.3) and a CUDA compute
backend —
copy_(cudaMemcpy),zero_(cudaMemset), andcontiguous(asmall 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. Theop 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_arraypasses GPU arrays through unchanged underthe
"numpy"bridge (numpy is the host-only member of its family; on GPU thenatural 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 beadded later if needed.
5. Phase-by-phase (commit map)
from_blob+deleter is the interop pathtc::Tensor+DLPack, allocator hook, Device API, compute-backend hook, torch wiring, fulltorch::stable→tcswap,TC_CHECKops.pysplit, frame/loader/metadata torch-optional,import torchcodec+VideoDecodertorch-freeTC_CHECK);TCCudaBackend.cu(alloc + kernels);ENABLE_CUDA && !ENABLE_TORCHbuild; cupy output; smoke + GPU CI; third-party-interface + Windows<array>fixes6. 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 torchgenuinely fails),FFmpeg 8, numpy. The torch-free CPU path.
codecfreecuda— Python 3.12, no torch, condacuda-nvcc+cuda-cudart-dev+cupy, FFmpeg 7. The torch-free CUDA path.Results:
plus 787 samplers/transforms/encoders earlier; CPU + CUDA decode work.
tensors + numpy bridge, 124 cpu/bridge tests pass.
codecfree):import torchcodec; VideoDecoder(path)returns numpy for
[]/get_frame_at/get_frames_in_range/get_frames_at/get_frames_played_at/metadata/len, withtorchnever imported; built libslink no torch (
ldd).codecfreecuda):VideoDecoder(path, device="cuda")returns cupy uint8 (single + batch, both the NVDECdefaultandffmpegbackends),torchnever 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.
TCTensorTest,TCTorchBackendTest): pass. (TCTorchBackendTestincludes a real GPU CPU→CUDA→
div→CPU round-trip through torch's allocator.)pre-commit run --all-filespasses (ufmt + clang-format + flake8).Tests/CI added:
test/smoke_test_no_torch.py— loads the pybind module directly, decodes tonumpy, asserts torch never imported.
test/smoke_test_no_torch_package.py— fullimport torchcodec+VideoDecodertorch-free.
test/test_bridge.py— bridge behavior in the torch env..github/workflows/no_torch_smoke.yaml— (job 1) torch-free build + ldd + bothsmokes; (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
GetAudioMetadataC++ gtests fail due to the installed FFmpeg's header-reportedaudio 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-freeTC_CHECK/TC_CHECK_INDEX+ visibility macros.src/torchcodec/_core/TCStableConvert.h— zero-copytc::Tensor↔torch::stable::Tensor.src/torchcodec/_core/TCTorchBackend.cpp— registers torch allocator + CUDA backend (incustom_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.py—set_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.h—void*-typed stream-provider seam.src/torchcodec/_core/TCCudaBackend.cu— torch-free CUDA allocator + computebackend (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 buildingcustom_ops+ torchhooks.
ENABLE_CUDAgates the CUDA device interfaces; it no longer requiresENABLE_TORCH. The build matrix:ENABLE_TORCH=1 ENABLE_CUDA=0— CPU wheel (core torch-free,custom_opstorch); runs with or without torch at runtime.
ENABLE_TORCH=1 ENABLE_CUDA=1— torch CUDA build (core links torch; GPU computevia torch hooks).
ENABLE_TORCH=0 ENABLE_CUDA=0— torch-free CPU.ENABLE_TORCH=0 ENABLE_CUDA=1— torch-free CUDA (core linksCUDA::cudart,builds
TCCudaBackend.cu).8. What's missing / future work
Done since the original CPU scope:
no torch.
ENABLE_CUDAno longer requiresENABLE_TORCH.Deferred by design (still require torch; raise a clear error torch-free):
output_dtype="float32"— needs a uint8→float32 cast + divide. On CPU tcalready implements both natively (it's gated off at the Python layer only); on
CUDA the
toDtype/divbackend ops are stubbed (need two small kernels).torchcodec.transformsmoduleimports torch (transform-spec building), and GPU
rot90is stubbed.torch-free frontend (audio is CPU-only; nothing torch-fundamental).
torch.compileonly applies on the torch bridge (numpy/cupy users don't compile).Packaging caveats to verify in the real wheel build (not locally testable):
custom_opslinks libtorch, so auditwheel/delocate must EXCLUDE libtorchfrom bundling (as the existing torch wheels already do). Otherwise the wheel
would ship its own libtorch and defeat torch-optionality.
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:
"cupy"bridge target (trivial once torch-free GPU lands).with torchcodec.bridge("numpy"):).numpy
ModuleNotFoundErrorat import).Testing limitation encountered: the two validation envs use different Python
versions (3.10 vs 3.12), so a single built
.soset can't be shared across them(pybind ABI). The CI
single-wheel-runtime-optionaljob is the canonicalone-environment proof (build with torch → uninstall torch → still works).
9. How to build / use
Runtime:
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 optionalbackend. 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 coreThe CPU core is torch-symbol-free and the torch-free CUDA core links no torch at
all.
#include <torch/...>ortorch::stable::...inlibtorchcodec_coreis aregression (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. UseTC_CHECK/TC_CHECK_INDEX, notSTD_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, andnon-CPU work goes through the
DeviceBackendhook (TCTensor.h). Adding opfootouches up to four places — but most of that is not new work:
TCTensor.cpp— NEW. Plain C++.DeviceBackendfield forfoo— trivial (one struct field).TCTorchBackend.cpp— basically free. It's aone-line call to the torch op you would have called before the migration
anyway. Pre-migration the core already did GPU
fooby calling torch; thatcall just moved into the adapter.
TCCudaBackend.cu— NEW (only iffoomustwork 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):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 largelysidesteps the CPU-impl-plus-kernel burden:
NDArrayis a DLPack container, not a compute tensor. Its onlymethods are
Empty,CopyFrom/CopyTo(incl. device transfer), andCreateView/CreateOffsetView(shape/stride views). There is nodiv, nopermute, nocat, norot90, noastypein decord's core.optional /255 normalization — is fused into a single CUDA kernel
(
src/improc/improc.cu, thenormalized ? 1.f : 255.ffactor) plus CUDAtexture 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.
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,
float32normalization, decode-time transforms (resize/crop/rotate →rot90), and batch assembly (cat) — to hand back ready-to-use tensors. Thatdesign choice is what creates the
tc::Tensorop surface (div,permute,cat,rot90,contiguous,to) and hence the per-op CPU+kernel cost. If weever 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
.cukernel + FFmpeg on CPU). Most of our currentops are metadata-only views anyway (
permute/narrow/selectcost nothing onany 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, whichdispatches to one of two modules. Adding a Python-visible op means:
_torch_ops.py(thetorch.ops.torchcodec_ns.*schema + impl,and a
@register_fakeif it must work undertorch.compile), and_numpy_ops.py(calling the pybind frontend), which meansexposing it in
pybind_ops.cpptoo — or make it a_requires_torchstub there if it's torch-only.
Forgetting the
_numpy_ops/pybind side means the feature silently doesn't existwithout torch; forgetting
register_fakebreakstorch.compile.10.4 Never allocate or pick a stream directly
tc::empty/tc::full/the allocator hook — nevernew/cudaMallocin the core. This is what lets torch's caching allocator beused when present and
cudaMallocwhen not.getCurrentCudaStream()(theCUDAStreamHookseam) andCudaDeviceGuard— neveraoti_torch_get_current_cuda_streamor torch'sDeviceGuardin the core.10.5 Interop is
from_blob+deleter on the torch side, DLPack on the edgeThere is no DLPack in torch's stable ABI (§4.2). To hand a core buffer to torch,
use the
TCStableConvert.hzero-copy path. DLPack is only for the torch-freefrontend'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 letthe bridge convert.
10.6 Output type is device- and bridge-dependent — don't assume
torch.TensorA decoded frame may be a
torch.Tensor,numpy.ndarray, orcupy.ndarray.Code in
_video_decoder.pyand friends must not assume torch (no.cuda(),.cpu(),torch.*on decoder output without a guard). Route outputs throughto_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::DeviceTypeis{CPU, CUDA, XPU, PrivateUse1}. Supporting a genuinely newaccelerator means extending that enum and every switch over it
(
deviceTypeName), the DLPack mapping (toDLPack/fromDLPack), the stableconversion (
TCStableConvert.h), and providing aDeviceBackend. Out-of-treeplugins register a
DeviceInterfaceunderPrivateUse1(seetest/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/.sogotcha: editable installs copy libs into
src/torchcodec/, so building in oneenv clobbers another's libs (different Python ABI / FFmpeg version → import
errors);
rm -f src/torchcodec/*.soand rebuild when switching envs, and giveeach 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,_coreops) must respect §10.1–10.6.