CodeRigor

FREE LESSON

PyTorch tensors and autograd

Autograd the way splatting and NeRF code uses it: the graph, requires_grad and leaves, gradient accumulation, no_grad and detach, in-place pitfalls, a custom torch.autograd.Function with a hand-written backward checked by gradcheck, exp/sigmoid/normalize parameter activations, and the Adam loop with parameter groups.

A 3D Gaussian splatting trainer (train.py in graphdeco-inria/gaussian-splatting) is, at heart, eight lines repeated 30 000 times:

Python
for iteration in range(1, opt.iterations + 1):
    render_pkg = render(viewpoint_cam, gaussians, pipe, background)
    image = render_pkg["render"]
    Ll1 = l1_loss(image, gt_image)
    loss = (1.0 - opt.lambda_dssim) * Ll1 + opt.lambda_dssim * (1.0 - ssim(image, gt_image))
    loss.backward()
    with torch.no_grad():
        ...  # densify, prune, reset opacity
        gaussians.optimizer.step()
        gaussians.optimizer.zero_grad(set_to_none=True)

Everything interesting is hidden in three places: how loss.backward() finds the gradient of a scalar with respect to millions of raw parameters, how the rasterizer — a CUDA kernel autograd can't see into — takes part in that, and how the parameters are shaped so that an unconstrained optimiser can't push them into nonsense. This lesson covers all three in plain PyTorch. The C++ side (gfx-differentiable-rendering) derived the same gradients by hand; here you'll see how research code wires them into autograd.

1. Tensors and the graph they record

A torch.Tensor is an n-dimensional array like a NumPy ndarray — same broadcasting rules, same ... indexing — with three differences that matter here:

  • the default floating dtype is float32 (NumPy's is float64); torch.from_numpy(a) keeps the array's dtype and shares its memory;
  • it can live on a GPU (.to("cuda")); the sandbox here is CPU-only, and research code is written so that the same lines run on both;
  • when any input has requires_grad=True, every operation records itself in a graph, so that the gradient can be computed later.

Tensors you create with requires_grad=True are leaves: the parameters. Every result computed from them has a grad_fn — the node that knows how to send a gradient back to its inputs.

Python
import torch

logit = torch.tensor([0.0, 2.0], requires_grad=True)   # a raw parameter: a leaf
color = torch.tensor([1.0, 0.5])                        # data: no gradient needed
opacity = torch.sigmoid(logit)
weight = opacity * 0.5
loss = (weight * color).sum()

for name, t in [("logit", logit), ("opacity", opacity), ("weight", weight), ("loss", loss)]:
    print(f"{name:8} leaf={t.is_leaf!s:5} requires_grad={t.requires_grad!s:5} grad_fn={type(t.grad_fn).__name__}")
print("loss.grad_fn.next_functions:", [type(f).__name__ for f, _ in loss.grad_fn.next_functions])
The graph autograd records
start / 2
Forward: each op records a grad_fn node pointing at its inputs' nodes. backward() walks the graph from the loss in reverse, multiplying by each local derivative (a vector-Jacobian product), and adds the result into the leaves' .grad.
The graph autograd records
Forward: each op records a grad_fn node pointing at its inputs' nodes. backward() walks the graph from the loss in reverse, multiplying by each local derivative (a vector-Jacobian product), and adds the result into the leaves' .grad.

2. backward() and gradient accumulation

loss.backward() computes ∂loss/∂leaf for every leaf that requires grad and adds it into leaf.grad. It never overwrites: a second backward adds a second gradient. That's deliberate — gradient accumulation over micro-batches, or one loss term per camera, needs no extra code — and it's why every training step clears the gradients first:

  • optimizer.zero_grad(set_to_none=True) sets every .grad to None (the default since PyTorch 2.0; cheaper than filling with zeros);
  • the graph is freed after backward() (its saved tensors are released), so calling it twice raises unless the first call passed retain_graph=True;
  • backward() needs a scalar; for a tensor output y, y.backward(v) computes the vector-Jacobian product vᵀ ∂y/∂x — which is all reverse mode ever computes, and why one backward costs about as much as one forward whatever the number of parameters.
Python
import torch

x = torch.tensor([1.0, 2.0, 3.0], requires_grad=True)

for step in range(2):
    loss = (x ** 2).sum()           # d loss / dx = 2x
    loss.backward()
    print(f"after backward {step}: x.grad = {x.grad.tolist()}")
x.grad = None                       # what zero_grad(set_to_none=True) does

y = x * torch.tensor([10.0, 20.0, 30.0])
y.backward(torch.tensor([1.0, 0.0, 1.0]))   # v^T J for a vector output
print("vector-Jacobian product:", x.grad.tolist())

# torch.autograd.grad returns gradients instead of accumulating them.
(g,) = torch.autograd.grad((x ** 3).sum(), x)
print("autograd.grad:", g.tolist(), "| x.grad untouched:", x.grad.tolist())

3. Switching recording off: no_grad, detach, item

Recording costs memory (every saved tensor stays alive until backward), and some computations must not be differentiated at all:

tool effect typical use
with torch.no_grad(): nothing inside is recorded the optimiser step, densification, opacity resets, evaluation renders
torch.inference_mode() stricter and faster no_grad; results can never enter a graph later pure inference/serving
t.detach() same storage, cut from the graph, a new leaf a target or a statistic you treat as constant (e.g. the max screen radius 3DGS tracks)
t.item(), t.tolist() a Python number, no graph logging losses (losses.append(loss) keeps every graph alive: a leak)

model.eval() is unrelated: it switches modules like dropout and batch norm to inference behaviour and records just as much. A hand-written optimiser shows the first row in action — the update itself must not be recorded, and the leaf must be modified in place so it stays the same leaf:

Python
import torch

torch.manual_seed(0)
target = torch.tensor([0.25, -1.0])
w = torch.zeros(2, requires_grad=True)

for step in range(50):
    loss = ((w - target) ** 2).sum()
    loss.backward()
    with torch.no_grad():          # an SGD step: not part of any graph
        w -= 0.1 * w.grad
    w.grad = None
print("w:", [round(v, 4) for v in w.tolist()], "| still a leaf:", w.is_leaf)

d = w.detach()
print("detached: requires_grad =", d.requires_grad, "| same storage as w:", d.data_ptr() == w.data_ptr())

detach() never copies: write into d in place and you have changed w. Use w.detach().clone() for an independent copy — the idiom for "make trainable copies of these tensors".

4. In-place operations and the version counter

Methods with a trailing underscore (add_, mul_, sigmoid_, clamp_) and indexed assignment (t[mask] = 0) modify a tensor in place. Autograd allows it, with two rules:

  1. A leaf that requires grad can't be modified in place outside no_grad — its value is the parameter, and its .grad refers to it.
  2. A tensor saved for backward can't be modified afterwards. Every tensor has a version counter that in-place ops bump; saved tensors remember the version, and backward raises if it changed. sigmoid and exp save their output (σ' = σ(1 − σ), exp' = exp); x * y saves both inputs; x * 3 saves nothing of x.
Python
import torch

leaf = torch.zeros(3, requires_grad=True)
try:
    leaf.add_(1)
except RuntimeError as e:
    print("leaf:", str(e).split(".")[0])

s = torch.sigmoid(leaf)
s.mul_(2)                                # overwrites what sigmoid saved
try:
    s.sum().backward()
except RuntimeError as e:
    print("saved tensor:", str(e).split(":")[0])

e = torch.exp(leaf)
e = e * 2                                # out of place: a new tensor
e.sum().backward()
print("out of place is fine:", leaf.grad.tolist())

A third trap is aliasing. expand, view, slicing and transpose return views that share memory. expand in particular gives stride-0 dimensions: writing into an expanded tensor writes the same element many times (PyTorch refuses with "unsupported operation: more than one element of the written-to tensor refers to a single memory location"). That's why 3DGS's SSIM window is built as _2D_window.expand(channel, 1, ws, ws).contiguous() — the .contiguous() makes a real copy before anyone writes to it.

5. Parameter activations: optimise free numbers

Adam treats every parameter as an unconstrained real number. Physical quantities aren't: a scale is positive, an opacity lies in (0, 1), a rotation quaternion has unit length. Research code stores a raw parameter and applies an activation in every forward pass — 3DGS's GaussianModel:

quantity stored activation (get_*) inverse (initialisation, resets)
scale _scaling (log-scale) torch.exp torch.log (of a k-NN distance at init)
opacity _opacity (logit) torch.sigmoid inverse_sigmoid(y) = log(y / (1 − y))
rotation _rotation (any 4-vector) torch.nn.functional.normalize — (init (1, 0, 0, 0))
colour _features_dc, _features_rest (SH) none (SH → RGB is 0.5 + C₀·f_dc, clamped at 0) RGB2SH

The activation shapes the optimisation, not just the range. With scale = exp(s), ∂scale/∂s = scale: an Adam step of roughly constant size in s is a constant factor on the scale, right for sizes spanning millimetres to metres. With opacity = σ(ℓ), ∂opacity/∂ℓ = σ(1 − σ), which vanishes as the opacity saturates: a Gaussian whose logit drifted to −10 is effectively frozen — one reason 3DGS prunes Gaussians below opacity 0.005 and resets all opacities to 0.01 every 3000 iterations.

σ(ℓ) and its gradient σ(1 − σ)
The opacity's gradient peaks at 0.25 for ℓ = 0 and is below 0.01 once |ℓ| > 4.6: saturated Gaussians stop learning.
σ(ℓ) and its gradient σ(1 − σ)
The opacity's gradient peaks at 0.25 for ℓ = 0 and is below 0.01 once |ℓ| > 4.6: saturated Gaussians stop learning.

The normalisation is part of the function too, and its gradient has a geometric meaning: q / ‖q‖ doesn't change when q is scaled, so the gradient on the raw quaternion is orthogonal to q — (I − q̂q̂ᵀ) g / ‖q‖. Detach the norm (a tempting "it's just a constant") and you lose that projection.

Python
import torch

torch.manual_seed(0)
raw = torch.randn(4, 4, requires_grad=True)             # four raw quaternions
q = raw / raw.norm(dim=-1, keepdim=True)                 # keepdim: (4, 4) / (4, 1)
(q * torch.randn(4, 4)).sum().backward()
print("‖q‖:", [round(v, 6) for v in q.norm(dim=-1).tolist()])
print("grad · raw per quaternion:", [round(v, 6) + 0.0 for v in (raw.grad * raw).sum(-1).tolist()])

raw.grad = None
q_bad = raw / raw.norm(dim=-1, keepdim=True).detach()
(q_bad * torch.randn(4, 4)).sum().backward()
print("with a detached norm:     ", [round(v, 3) for v in (raw.grad * raw).sum(-1).tolist()])

6. A custom torch.autograd.Function

Autograd through the rasterizer's per-pixel loop would record a node for every multiply of every Gaussian at every pixel — gigabytes of saved tensors. So 3DGS (diff_gaussian_rasterization._RasterizeGaussians) and gsplat (_RasterizeToPixels) are custom Functions: forward launches a CUDA kernel and returns tensors; backward launches a hand-derived kernel. Inside forward nothing is recorded — the whole call is one node — and you decide what to keep with ctx.save_for_backward.

The core of that backward is front-to-back alpha compositing. For one pixel with samples sorted front to back,

Text
rgb = Σ_k w_k c_k,   w_k = T_k α_k,   T_k = Π_{j<k} (1 − α_j)

T_k is the exclusive product — the light that reaches sample k, before k absorbs any. torch.cumprod is inclusive, so research code shifts it: nerf-pytorch writes torch.cumprod(torch.cat([torch.ones((N, 1)), 1. - alpha + 1e-10], -1), -1)[:, :-1].

Exclusive transmittance for α = (0.5, 0.5, 0.5, 0.5)
start / 3
cumprod(1 − α) gives the light left after each sample; shifting it right by one with a leading 1 gives the light that reaches each sample.
Exclusive transmittance for α = (0.5, 0.5, 0.5, 0.5)
cumprod(1 − α) gives the light left after each sample; shifting it right by one with a leading 1 gives the light that reaches each sample.

The gradient has two parts. c_k appears only in its own term: ∂rgb/∂c_k = w_k. But α_k appears in w_k and in every later T_m, through the factor (1 − α_k):

Text
∂rgb/∂α_k = T_k c_k − Σ_{m>k} w_m c_m / (1 − α_k)

The second term is how a front Gaussian learns that becoming more opaque hides what's behind it. Contracted with the incoming gradient g = ∂L/∂rgb, the suffix sum over m > k is a reversed cumulative sum:

Python
import torch


class Composite(torch.autograd.Function):
    @staticmethod
    def forward(ctx, alpha, color):                  # (..., K), (..., K, C)
        trans = torch.cumprod(1 - alpha, dim=-1)
        trans = torch.cat([torch.ones_like(trans[..., :1]), trans[..., :-1]], dim=-1)
        ctx.save_for_backward(alpha, color, trans)
        return ((trans * alpha).unsqueeze(-1) * color).sum(-2)

    @staticmethod
    def backward(ctx, g):                            # g: (..., C)
        alpha, color, trans = ctx.saved_tensors
        w = trans * alpha
        gc = (color * g.unsqueeze(-2)).sum(-1)       # g · c_k
        behind = (w * gc).flip(-1).cumsum(-1).flip(-1) - w * gc
        return trans * gc - behind / (1 - alpha), w.unsqueeze(-1) * g.unsqueeze(-2)


torch.manual_seed(0)
alpha = (torch.rand(3, 5, dtype=torch.float64) * 0.99).requires_grad_(True)
color = torch.rand(3, 5, 3, dtype=torch.float64, requires_grad=True)
rgb = Composite.apply(alpha, color)
print("rgb shape:", tuple(rgb.shape), "| grad_fn:", type(rgb.grad_fn).__name__)
print("gradcheck:", torch.autograd.gradcheck(Composite.apply, (alpha, color)))
What gradcheck compares
gradcheck perturbs each input element by ±eps (default 1e-6) and compares the finite-difference Jacobian with the one your backward implies. Run it in float64 on small inputs: in float32 the finite differences are rounding noise.
What gradcheck compares
gradcheck perturbs each input element by ±eps (default 1e-6) and compares the finite-difference Jacobian with the one your backward implies. Run it in float64 on small inputs: in float32 the finite differences are rounding noise.

The CUDA rasterizer computes the same thing per pixel, walking its sorted list back to front and accumulating the colour behind — and instead of saving every T_k it recomputes them by dividing the final transmittance by (1 − α), trading a division for memory. That division is one reason 3DGS clamps α to 0.99:

C++
// diff-gaussian-rasterization, renderCUDA backward (simplified)
float T = T_final;
for (int j = last; j >= 0; --j) {
    T = T / (1.f - alpha[j]);                       // T_j, recovered
    float dchannel_dcolor = alpha[j] * T;           // ∂rgb/∂c_j = w_j
    float dL_dalpha = 0.f;
    for (int ch = 0; ch < C; ++ch) {
        dL_dalpha += (color[j][ch] - accum_rec[ch]) * dL_dpixel[ch];
        accum_rec[ch] = alpha[j] * color[j][ch] + (1.f - alpha[j]) * accum_rec[ch];
    }
    dL_dalpha *= T;                                 // T_j (c_j − colour behind) · g
}

accum_rec there is Σ_{m>k} w_m c_m / T_{k+1} — the colour behind, renormalised — so T_k (c_k − accum_rec) is exactly the formula above. A few more rules for Functions: call them with .apply (never instantiate); return one gradient per forward argument, shaped like it, or None for non-tensors and inputs that need none (ctx.needs_input_grad[i] says which); use ctx.mark_dirty if forward modifies an input in place.

7. The optimiser loop: Adam and parameter groups

Adam (Kingma & Ba 2015) keeps per-element running means of the gradient m and of its square v, and steps by lr · m̂ / (√v̂ + ε) — about lr in magnitude whatever the gradient's scale. That's what makes the activations above behave: step sizes are set by the learning rate, in the raw parameter's units. Because positions, log-scales, logits, quaternions and SH coefficients live on very different scales, 3DGS's training_setup gives each its own parameter group with its own rate (positions 1.6e-4 × scene extent with an exponential decay, scales 5e-3, rotations 1e-3, SH DC 2.5e-3, the higher SH bands 20× less):

Python
l = [
    {"params": [self._xyz], "lr": training_args.position_lr_init * self.spatial_lr_scale, "name": "xyz"},
    {"params": [self._features_dc], "lr": training_args.feature_lr, "name": "f_dc"},
    {"params": [self._opacity], "lr": training_args.opacity_lr, "name": "opacity"},
    {"params": [self._scaling], "lr": training_args.scaling_lr, "name": "scaling"},
    {"params": [self._rotation], "lr": training_args.rotation_lr, "name": "rotation"},
]
self.optimizer = torch.optim.Adam(l, lr=0.0, eps=1e-15)

(The name keys let densification find each group's tensor and its Adam state when it concatenates new Gaussians or prunes old ones.)

One training step
The order matters: gradients accumulate unless cleared, the forward builds the graph that backward consumes, and the step runs on the leaves without being recorded.
One training step
The order matters: gradients accumulate unless cleared, the forward builds the graph that backward consumes, and the step runs on the leaves without being recorded.

Put together — fit two semi-transparent layers so that, composited, they show a target colour; every raw parameter is unconstrained, and copies keep the caller's tensors intact:

Python
import torch

torch.manual_seed(0)
target = torch.tensor([0.8, 0.4, 0.2])
init = {"logit": torch.zeros(2), "color": torch.zeros(2, 3)}
p = {k: v.detach().clone().requires_grad_(True) for k, v in init.items()}
opt = torch.optim.Adam([{"params": [p["logit"]], "lr": 0.05}, {"params": [p["color"]], "lr": 0.1}])

for step in range(301):
    opt.zero_grad(set_to_none=True)
    alpha = torch.sigmoid(p["logit"]) * 0.99
    trans = torch.cat([torch.ones(1), torch.cumprod(1 - alpha, 0)[:-1]])
    rgb = ((trans * alpha)[:, None] * torch.sigmoid(p["color"])).sum(0)
    loss = ((rgb - target) ** 2).sum()
    loss.backward()
    opt.step()
    if step % 100 == 0:
        print(f"step {step:3}: loss {loss.item():.2e}  rgb {[round(v, 3) for v in rgb.tolist()]}")
print("init untouched:", bool((init["logit"] == 0).all()))

In an interview

Expect "what does backward() compute and why is it cheap?" (a vector-Jacobian product per node, reverse order: one backward ≈ one forward, whatever the number of parameters), "why zero_grad?" (accumulation), "detach vs no_grad?" (one tensor vs a region), and the in-place error messages. For a splatting or NeRF role: "how would you make a CUDA kernel differentiable?" — a torch.autograd.Function, saved tensors, a hand-derived backward, gradcheck in float64 against a slow reference — and "why are opacities stored as logits and scales as logs?" Be ready to derive the compositing gradient on a whiteboard: the term most people forget is the occlusion term − Σ_{m>k} w_m c_m / (1 − α_k).

Watch & read

Free lectures, talks and readings that cover this skill from another angle. All resources →

Builds a scalar autograd engine from scratch: the graph of operations, topological order, and why gradients accumulate with +=. The first hour is everything this lesson assumes about backward().