Phase 1, week 5retrospective

scf.forall tiling

scf.forall expresses tile-level parallelism with induction variables and a shared destination; it cannot reduce across iterations.


A loop with a destination

scf.forall is the parallel tile loop. On tensors it carries the result through shared_outs, and the terminator says how each iteration writes into it:

func.func @matmul_forall(%A: tensor<4x4xf32>, %B: tensor<4x4xf32>, %C: tensor<4x4xf32>)
    -> tensor<4x4xf32> {
  %0 = scf.forall (%i, %j) = (0, 0) to (4, 4) step (2, 2)
       shared_outs(%c = %C) -> (tensor<4x4xf32>) {
    %sA = tensor.extract_slice %A[%i, 0] [2, 4] [1, 1]
        : tensor<4x4xf32> to tensor<2x4xf32>
    %sB = tensor.extract_slice %B[0, %j] [4, 2] [1, 1]
        : tensor<4x4xf32> to tensor<4x2xf32>
    %sC = tensor.extract_slice %c[%i, %j] [2, 2] [1, 1]
        : tensor<4x4xf32> to tensor<2x2xf32>
    %t = linalg.matmul ins(%sA, %sB : tensor<2x4xf32>, tensor<4x2xf32>)
                       outs(%sC : tensor<2x2xf32>) -> tensor<2x2xf32>
    scf.forall.in_parallel {
      tensor.parallel_insert_slice %t into %c[%i, %j] [2, 2] [1, 1]
          : tensor<2x2xf32> into tensor<4x4xf32>
    }
  }
  return %0 : tensor<4x4xf32>
}

With explicit bounds and steps, %i and %j are element offsets, which is why they can be used as slice offsets directly. The other spelling, in (2, 2), is normalised: lower bounds zero and steps one, so the induction variables are tile indices and every offset needs iv * tile. Mixing the two shifts every tile by one tile size.

shared_outs binds the destination tensor to a block argument, and the loop yields one result per shared out. It is a synchronisation point as well, so nothing after it observes a partial tile.

mapping records the processing units the op is meant for. It is an attribute, not a launch: a lowering interprets it or drops it.

Reductions are the hard case

Tiling M and N needs no communication: each iteration owns a distinct tile of C. Tiling K does, because every iteration wants the same accumulator. The shape that works is a wider shared out with one partial-sum slice per iteration, plus a combining op after the loop. A split-reduction transform builds exactly that.

Things that break

Writing the result into %C instead of the block argument %c is the common mistake, and it fails loudly because the parallel insert must target a shared-out argument. A missing scf.forall.in_parallel fails verification instead of silently dropping the result. And a forall whose iteration count does not match the induction-variable list is a verifier error, not a runtime truncation, so read the diagnostic rather than the IR.

Sources

A version records the documentation the claim was checked against, not a house pin. Confirm flags with --help on the release you installed.

Check yourself

What does `shared_outs(%c = %C)` actually bind?

You tile the `K` dimension with `scf.forall` so every iteration accumulates into the same tile of `C`. What do you get?

You attach `mapping = [#gpu.thread<x>, #gpu.thread<y>]` to the op and lower on CPU. What happens?

0 / 3 correct

← back to the map