Fused bindings

Rake's let name = expression gives an expression's value a name. A fused binding, | name <| expression, binds one stage of a fused vector calculation, and fusion lets the compiler combine those stages so they execute together. Read a stage from right to left: the expression flows into name. Consecutive stages line up on their leading bars, so the calculation reads down the page:

scratch advance(positions: f32s, velocities: f32s) -> f32s:
  | scaled: f32s <| velocities * <0.5>
  | moved: f32s  <| positions + scaled
  moved

The optional type annotation, as in scaled: f32s, needs to match the expression's type. A fused name is a value, like a let name: it's bound once, and it isn't a memory location, an evaluation point or a rounding boundary. scaled exists for the reader, and the two stages above are the same computation as | moved <| positions + velocities * <0.5>.

Permitted expressions

Every rack on the implemented profiles lives in one physical CPU register or one WebAssembly v128 value, with no source-visible spills. A fused binding's expression needs to be pure data flow, emitted as one contiguous run of vector instructions. The compiler proves this or rejects the binding, and never falls back to something weaker.

A fused expression may contain arithmetic, comparisons, mask logic, uniforms, if ... then ... else, fma, shuffles, and calls to these built-in operations:

sqrt abs floor ceil trunc nearest min max exp log log2 tanh select
dot narrow widen_low widen_high to_f32 to_i32 to_u32
bit_and bit_or bit_xor bit_andnot shift_bits_left shift_bits_right shift_bits_right_signed
relaxed_madd relaxed_nmadd relaxed_min relaxed_max

It can't contain a call to a scratch, rake or other function, a reduction, a scan, extract, insert, bitmask, indexing or another memory access, a conversion such as bitcast, or a record or array. The compiler doesn't infer a function's effects yet, so a user-defined call can't enter a fused region even when it's pure. A rejection gives the binding and the reason:

scratch spread(values: f32s) -> f32:
  | total: f32 <| sum(values)
  total

Rewrites and fused multiply-add

Inside a fused region, arithmetic describes data flow rather than instruction order or intermediate rounding. The language allows the compiler to substitute identifiers, reassociate, factor, distribute, share common subexpressions and form fused instructions, choosing the cheapest graph for the profile. Different profiles may therefore round one floating-point expression differently.

The compiler implements part of that. It substitutes fused identifiers, and on x86-avx2, x86-avx512 and aarch64-neon it contracts an f32 multiply feeding an add in the same fused region into one fused multiply-add, removing the multiply when nothing else uses it. advance above compiles to one vfmadd231ps on AVX2 and AVX-512, and one fmla on NEON. A multiply and an add outside a fused region, or in two different regions, stay separate. x86-sse2 and wasm-simd128 contract nothing, because neither instruction set has a fused multiply-add. Reassociation, factoring, distribution and the sharing of subexpressions aren't implemented. Integer multiply and add keep their wrapping semantics, so this floating-point rewrite never applies to them.

fma(a, b, c) computes a * b + c with a single rounding. On AVX2, AVX-512 and NEON it's one fused instruction, and --verify-native checks that the object's fused multiply-adds, both contracted and explicit, match the compiler's selection exactly. SSE2 and wasm-simd128 reject fma. Rake has no slower, stricter arithmetic mode, so a program that depends on an exact operation specifies it explicitly.

Reductions and scans have a defined lane order. That order is part of their result, and fusion never reassociates it, even when their input comes from a fused region.