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.