Skip to content

← Matmul and einsum step 5 of 6

Medium Primitives

Choose the order of matrix multiplications

Jump to the task

The brackets in a chain of matrix products determine which intermediate matrix is built first. Consider:

a: (n, r)    b: (r, n)    c: (n, r)    with n much larger than r

Both (a @ b) @ c and a @ (b @ c) produce shape (n, r), but their first products have very different sizes.

Expression First product Elements when n = 1000, r = 2
(a @ b) @ c (n, n) 1,000,000
a @ (b @ c) (r, r) 4

The final output has 2,000 elements in both cases. These are individual tensor sizes, not peak process memory: inputs, simultaneously live tensors, backend workspaces and gradient tracking can also contribute.

The arithmetic changes too

Both expressions call matrix multiplication twice, but the matrix sizes determine the amount of work. Using conventional dense multiplication, the left grouping needs about 2 * n * n * r scalar multiplications; the right grouping needs about 2 * n * r * r.

For these shapes, computing b @ c first saves both intermediate storage and arithmetic. For other chains, compare the candidate shapes and costs rather than applying a rule based only on the smallest dimension. Python groups a @ b @ c from the left, so explicit brackets are needed for the smaller intermediate here.

Mathematical equality and rounding

Matrix multiplication is associative in exact arithmetic. Floating-point rounding can make the two groupings differ, so do not expect bit-for-bit equality for arbitrary tensors. This exercise uses small integer-valued inputs whose products and sums are exactly representable in float32.

Where the idea applies

For a low-rank update B @ A, applying it to a column vector as B @ (A @ x) avoids constructing the full update matrix. LoRA can use this fact during training; the update can also be merged into the base weights for inference.

Reassociation also helps some linear-attention formulations. It cannot simply move products through the softmax in ordinary attention: softmax(Q @ K.T) @ V is not generally equivalent to Q @ (K.T @ V).

Your task

def chain(a: torch.Tensor, b: torch.Tensor, c: torch.Tensor) -> torch.Tensor

For shapes (n, r), (r, n) and (n, r), with 0 < r < n, return the chain product without constructing an (n, n) tensor.

The harness records the largest tensor returned by any dispatched operation during your function, including its final output. It must have fewer than n * n elements. This is a tensor-size check, not a peak-memory profiler.