Control flow

Rake has two kinds of choice: a uniform condition chooses the same branch for every lane, while a mask chooses independently in each lane. Vector code makes both choices by vector selection, whereas slow code can branch around statements. Tines, through blocks and sweeps are the named form of mask choice, defined in tines, through and sweeps. This page defines conditional expressions, loops and branches.

Conditional expressions

if c then a else b is a value. Its condition decides how it chooses.

A mask condition chooses lane by lane. Each branch is computed under its mask, the condition's lanes for a and the others for b, combined with any mask around the expression, and a lane-wise selection joins them. An inactive lane's computation doesn't happen, as in a through block, so targets with floating-point exceptions replace its operands with benign values. A mask conditional compiles on every production profile, and on WebAssembly the expression below is two subtractions, a comparison and one v128.bitselect:

scratch distance(v: f32s, w: f32s) -> f32s:
  if v > w then v - w else w - v

A uniform condition chooses one whole rack. It can be a direct comparison of uniform f32, i32 or u32 values, including a literal of the same type or an extracted f32 bound as a uniform. The physical profiles broadcast the operands, compare them as vectors and select the same branch in every participating lane, with benign operands in untaken work that could raise floating-point exceptions. A surrounding through or a stack run's tail also limits participation.

scratch pick(near: f32s, far: f32s, <distance: f32>, <limit: f32>) -> f32s:
  if <distance> <= <limit> then near else far

Float comparisons with a NaN are false, including !=, so an unordered condition chooses the else arm. Integer comparisons keep their declared signed or unsigned order, including values across bit 31.

WebAssembly keeps one scalar condition and a whole-rack selection, computing both pure branches before selecting. These integer conditions work on every production profile:

scratch pick_late(near: f32s, far: f32s, <late: i32>) -> f32s:
  if <late> > <0> then near else far

scratch pick_unsigned(near: u32s, far: u32s, <distance: u32>, <limit: u32>) -> u32s:
  if <distance> <= <limit> then near else far

A Boolean uniform chooses a whole rack on every production profile. On the physical profiles its value bit expands into an all-lane mask, so untaken floating-point work keeps the same operand protection as a comparison. The condition can come from a parameter, a marked Boolean literal such as <true>, or a mask reduction. Below, any makes every lane choose near when at least one lane is positive:

scratch pick_enabled(near: f32s, far: f32s, <enabled: bool>) -> f32s:
  if <enabled> then near else far

scratch pick_any_positive(near: f32s, far: f32s) -> f32s:
  let <enabled: bool> = any(near > <0.0>)
  if <enabled> then near else far

scratch pick_first(near: f32s, far: f32s) -> f32s:
  pick_enabled(near, far, <true>)

Native stack runs take i32, u32 and bool uniforms within their C register boundary.

In slow code, only the chosen branch is evaluated.

In vector code, a branch can't read memory. A load, gather or element read is bound with let before the conditional, so it visibly happens either way:

run choose(x: []f32, out: mut []f32, <late: i32>):
  out[<0>] <- if <late> > <0> then x[<0>] else x[<4>]

Fixed-count loops

repeat <i: i32> from <0> up to <4>: runs its body once for each index from the first bound up to, but not including, the second. Both bounds are constants, and the brackets mark the index as uniform. Inside the body the index is a constant, so it can index an array of rack locations and take part in address arithmetic.

In a scratch or rake, repeat unrolls completely, and the body may update mutable rack locations, so a scratch stays straight-line code. This compiles on every production profile:

scratch cube(a: f32s) -> f32s:
  power := a
  repeat <i: i32> from <0> up to <2>:
    power <- power * a
  power

In a run, repeat unrolls while its trips times its body's statements stay within 4096, and otherwise it stays a loop with the same meaning, keeping each rack location in one v128 local on WebAssembly. Native stack runs accept only unrolled repeats, including nested ones that update local rack locations or arrays of racks. Each rack of records starts with fresh locations, and earlier immutable bindings keep their values.

pack Samples {
  f32: value;
}

run cubes(samples: stack Samples) -> stack Samples:
  let value = samples.value
  power := value
  repeat <i: i32> from <0> up to <2>:
    power <- power * value
  samples with { value: power }

Loops and branches in runs

A run's counted loop, for <i: i32> from <a> up to <b> by <s>:, has a uniform index, bounds and step. The step is 1 unless given. A step of zero or less traps, and the last iteration stops at the bound instead of stepping past it. A statement if <c>:, with else if and else arms, takes a uniform condition and branches around whole statements:

run clip_rows(x: []f32, out: mut []f32, <n: i32>, <limit: i32>):
  for <i: i32> from <0> up to <n> by <4>:
    let row = x[<i>]
    if <i> < <limit>:
      out[<i>] <- row * <2.0>
    else:
      out[<i>] <- row

slow main() -> i32:
  values: [12]f32 := [1.0; 12]
  result: [12]f32 := [0.0; 12]
  clip_rows(values, result, <12>, <8>)
  total := 0.0
  i := 0
  while true:
    if i = 12:
      break
    total <- total + result[i]
    i <- i + 1
  return i32(total)

A run has no while, break, continue or return, so every vector loop runs a counted number of iterations. Mutable rack locations, such as total := <0.0>, and arrays of them, such as acc: [8]i32s := <0>, carry values across iterations. In a stack run's tail an assignment updates only the active lanes.

A stack run has no loop of its own: the compiler visits its stacks' records a rack at a time, as packs and runs define. Its body may contain repeat, counted loops and statement if.

Slow control flow

Slow code has if, else if and else, while, the counted for i from a up to b by s, break, continue and return, as the slow tier defines. It holds no racks, so a run called inside a slow loop does whole-rack work once each iteration.