Graded and Staged Tensor Programs

This chapter covers three modules that grew out of one question: can tensor programs be written element-by-element — the way the math is written — without giving up whole-tensor execution?

Why Tensor is not a Monad

A Monad instance must keep the type constructor fixed while the element type changes. Tensor computations do the opposite: binding a '[Batch 2] computation to a per-element continuation that produces '[RGB] values yields a '[Batch 2, RGB] tensor — the shape changes, and the element type (fixed by the dtype) does not. The structure this actually is, is a monad graded by the monoid of shapes under concatenation:

-- Torch.Typed.Graded
greturn :: Grade m -> m '[]
gbind   :: m s -> (Grade m -> m t) -> m (s ++ t)

'[] is the unit of ++ and ++ is associative, so the graded monad laws hold — and because they hold definitionally at the type level, GHC accepts both sides of the associativity law at the same type without any coercions. QualifiedDo gives back do-notation:

{-# LANGUAGE QualifiedDo #-}
import qualified Torch.Typed.Graded as G

rgbOf :: TensorMonad '( 'D.CPU, 0) 'D.Float '[Batch 2, RGB]
rgbOf = G.do
  x <- batch          -- '[Batch 2]
  channelwise x       -- each element expands to '[RGB]

All three laws are verified against real tensors in the test suite. gbind evaluates its continuation once per element, which makes it the reference semantics — correct, slow, and the oracle that the fast path below is tested against.

Staged element-wise code

The fast path is Coyoneda-shaped: write the element function once, polymorphically, and reinterpret it.

-- Torch.Typed.Staged
f :: (Floating a, Cond a) => a -> a
f x = whereE (gtE x 0) (sin x * 10) 0

The same f runs under two instantiations:

  • a = Float — per-element reference semantics;
  • a = Tensor — every operation is a whole-tensor ATen call, so the body executes once regardless of tensor size, no per-element FFI happens, and autograd sees every step.

This works because the untyped Tensor has Num, Fractional and Floating instances, and because the rank-2 type means the only operations available inside f are class methods — parametricity guarantees the function is a pointwise expression, so reinterpreting it is sound. An already-monomorphic Float -> Float cannot be vectorized this way; the polymorphic type is what keeps the code inspectable.

Value-dependent control flow cannot use if (there is no Bool to inspect at a = Tensor), so it goes through the Cond class: whereE compiles to torch.where, comparisons return 0/1 masks, and maxE/minE are overridden with native ATen calls in the Tensor instance.

The classes live in the untyped layer — Torch.Elementwise — so this style is not tied to typed tensors: emap relu' t works on a plain Tensor, and the typed module re-exports the same Cond. The typed entry points add shape tracking on top:

emap     :: (forall a. (Floating a, Cond a) => a -> a)      -> NamedTensor d t s -> NamedTensor d t s
ezipWith :: (forall a. (Floating a, Cond a) => a -> a -> a) -> ...
gbindV   :: TensorMonad d dt s
         -> (forall a. (Floating a, Cond a) => a -> HList (ToFinites t) -> a)
         -> TensorMonad d dt (s ++ t)

gbindV satisfies gbindV m k = gbind m (fromNamed . tabulate . k) — an equation the test suite checks with gbind as the oracle. The two instantiations of a formula built from +, *, comparisons and whereE agree bit-for-bit (IEEE basic operations are deterministic); only transcendental functions differ within float tolerance.

The element of an emap need not be a scalar: emapS and ezipWithS read the trailing dimensions of a tensor as the inside of a compound element — a batch of triangles '[Batch n, Vector 3, RGB] becomes a batch of Vector 3 (RGB a) values, and a polymorphic function on that structure runs once, vectorized over the batch. The Moving Dimensions chapter demonstrates this where it compares transparency with vmap.

Case study: non-maximum suppression

Torch.Typed.Vision implements NMS in this style. The whole algorithmic content of the pairwise step is one scalar formula:

iou :: (Fractional a, Cond a) => Box a -> Box a -> a
iou a b = inter / (area a + area b - inter)
  where
    iw = maxE 0 (minE (x2 a) (x2 b) - maxE (x1 a) (x1 b))
    ih = maxE 0 (minE (y2 a) (y2 b) - maxE (y1 a) (y1 b))
    inter = iw * ih
    area v = (x2 v - x1 v) * (y2 v - y1 v)

boxIou instantiates it at a = Tensor with fields shaped [n,1] and [1,n], so broadcasting produces the whole matrix from one evaluation; the tests instantiate the same code at a = Float as the reference. The greedy suppression itself is three lines of list recursion — the sequential part of the algorithm is where plain Haskell is already the clearest notation, and no tensor machinery is forced onto it. nms computes IoU rows lazily, only for boxes that are actually kept, so memory stays O(n).

Indicative CPU timings (FHD-scale boxes, n = pre-NMS top-k): n=1000 ≈ 8 ms, n=3000 ≈ 22 ms, n=6000 ≈ 48 ms. A native C++ kernel is still a few times faster at the top end — the remaining gap is eager per-op dispatch, discussed in TorchScript and the JIT.