PyTorch Internals: Tensor Storage, Views, and the Dispatch Mechanism
Summary: Why is
x.view(...)nearly free? How doestorch.matmulknow to call the CUDA kernel vs the CPU one? The answers live in three core concepts: the Tensor data model (storage/view/stride), the Dispatch mechanism, and the ATen operator library underneath. This post makes them clear with reproducible experiments—understanding these makes debugging memory leaks, performance bottlenecks, and custom ops an order of magnitude easier.
0. Experiment Goals
- Dissect the three-layer Tensor data model: storage (data) → view (view) → stride (stride)
- Verify "view is O(1)": changing metadata does not copy data
- Understand the Dispatch mechanism: how one op name routes to CPU/CUDA/custom backends
- Derive engineering practice from internals: memory layout, operator fusion, custom kernels
1. The Tensor Data Model: storage / view / stride
python
import torch
# A 4x4 tensor: data lives in storage; shape/strides are "view metadata"
x = torch.arange(16, dtype=torch.float32).reshape(4, 4)
print("storage:", x.untyped_storage().data_ptr()) # the single physical home of data
print("shape:", x.shape, "| strides:", x.stride()) # (4,1): row stride 4, col stride 1
print("numel (storage elements):", x.untyped_storage().nbytes() // 4)Step-by-step:
- Storage: the single physical location of tensor data (a contiguous memory block).
data_ptr()is its address in memory. - Shape + Stride: the "view" that interprets the storage.
stride=(4,1)means "jump 4 elements per row, 1 per column"—the addressing formulax[i][j] = storage[i*4 + j]. - Key insight:
reshape/viewusually only change shape/strides metadata, not data—that is why view ops are "nearly free".
2. Experiment: view Is O(1) (Transpose Does Not Copy)
python
# Transpose: reverse strides, data untouched
xt = x.t() # 4x4 → 4x4 (a view)
print("t strides:", xt.stride()) # (1,4) — only metadata changed
print("same storage:", xt.untyped_storage().data_ptr() == x.untyped_storage().data_ptr())
# Contiguity: non-contiguous views force many ops to .contiguous() first (real copy)
print("x contiguous:", x.is_contiguous(), "| xt contiguous:", xt.is_contiguous())
# .contiguous() is where the real copy happens
xtc = xt.contiguous()
print("different storage after copy:", xtc.untyped_storage().data_ptr() != x.untyped_storage().data_ptr())Results:
t strides: (1, 4)
same storage: True
x contiguous: True | xt contiguous: False
different storage after copy: TrueHow it works:
t()just swaps strides from(4,1)to(1,4)—an O(1) metadata operation, no data touched.- But non-contiguous tensors cannot be used directly by many ops (some BLAS, non-contiguous
view); they must.contiguous()—this is where the real copy happens. - Engineering meaning:
transposefollowed by a matmul makes PyTorch implicitly copy once. Avoid it by planning strides from the start (permute+ the right memory layout likechannels_last)—a real win when optimizing CNN/Transformer performance.
3. The Dispatch Mechanism: How One Op Picks a Kernel
python
# The same torch.add: CPU tensors take the CPU kernel, CUDA tensors take the CUDA kernel
import torch
cpu_x = torch.randn(3, 3)
cuda_x = cpu_x.cuda()
# Dispatch keys: dtype + device + requires_grad
# torch.add → ATen add() → dispatcher routes by key
# CPU tensor → add_cpu (CPU impl)
# CUDA tensor → add_cuda (CUDA impl)
# requires_grad=True → hits the autograd key first, then forwards to the backend implHow it works:
- The real implementation of PyTorch ops lives in the ATen operator library (C++).
torch.addat the Python layer is just an entry point; routing happens in the Dispatcher. - The Dispatcher uses a set of keys to pick the implementation. Common keys:
DispatchKey::CPU,DispatchKey::CUDA,DispatchKey::Autograd,DispatchKey::Meta(shape-only, no execution). - Keys are layered: with
requires_grad=True, theAutogradkey fires first (builds the backward graph), then drilling down to theCPU/CUDAcompute key. - This explains why custom ops/custom backends are easy to integrate: you only register an implementation for your key (e.g., register
addfor a custom device), the dispatcher routes automatically, and upper-layer code changes nothing.
4. Experiment: Register a Custom Op via torch.library (Through Dispatch)
python
from torch.library import Library, impl
# Register a custom op on Meta and CPU keys
m = Library("mylib", "DEF") # DEF: define a new op
m.define("triple(Tensor x) -> Tensor") # declare the op signature
@impl(m, "triple", "CPU")
def triple_cpu(x):
return x * 3
@impl(m, "triple", "Meta") # meta: shape-only (for compilation/shape inference)
def triple_meta(x):
return x.new_empty(x.shape)
x = torch.tensor([1.0, 2.0, 3.0])
print(torch.ops.mylib.triple(x)) # tensor([3., 6., 9.]) — routed to the CPU key
print(torch.ops.mylib.triple(torch.empty(2, 2, device="meta")).shape) # meta keyResults:
tensor([3., 6., 9.])
torch.Size([2, 2])Engineering notes:
- Custom ops = registering dispatch implementations: register on
CPU/CUDA/Metakeys and the op is recognized bytorch.compileand composes with autograd. - Kernel optimization (Triton/CUDA) plugs into the PyTorch ecosystem through this entry point—no upper-layer training code changes.
- Layered dispatch also structures debugging: performance issues → backend (
CPU/CUDA) implementations; gradient issues → theAutogradkey's graph construction.
5. Engineering Practice Derived from Internals
Memory layout:
- Contiguous vs non-contiguous drives hidden copies in every op.
torch.compile/compiler optimizations (see KB Compile Stack) fuse layout conversions automatically, but layout planning is your responsibility when writing kernels by hand. - Memory-leak triage: first look for hidden
.contiguous()copies (intorch.profilerthey appear as heavycopy_).
Operator fusion:
view/reshape/tare free—compilers can fuse them into neighboring ops without penalty. This is the confidence behindtorch.compile's bold graph rewriting.- Custom kernels should read/write once, compute many times (tiling), minimizing intermediate storage allocation.
Dispatch & custom backends:
- Adding new hardware (Ascend/Cambricon): just implement the op on your custom
DispatchKey; upper-layer PyTorch code stays compatible. - Quantized/sparse kernels also register through dispatch, decoupled from the framework.
6. Engineering Takeaways
- Tensor = storage + shape/strides: views are metadata ops, O(1), zero copy.
- Non-contiguity is a hidden copy source:
.contiguous()is the real copy; plan layout from the start. - Dispatch is the op router: CPU/CUDA/Autograd/Meta keys each do their job.
- requires_grad hits the Autograd key first: build the graph, then compute—this is autograd's entry.
- Custom ops = registering dispatch implementations: a few lines via
torch.library, upper layer unchanged. - Layered debugging: performance → backend keys; gradients → the autograd key.
- Hand-written kernels own their layout: the compiler can help, but storage allocation for custom kernels is engineering responsibility.