The Rake playground

Lesson 1 of 12

A program

Ctrl/⌘ + Enter runs the program.

Loading the compiler…

The playground teaches Rake to someone who has never written SIMD code, with small programs to edit and run in the browser. Each lesson introduces one idea and makes it visible in the result, the lane trace, the generated code or a compiler message. This page is the lessons' source: the documentation checker compiles every program here and verifies its stated result, so a language change that breaks a lesson fails the compiler's tests.

Tabs and targets

The editor has four output tabs:

TabShows
Resultwhat main returned, or the trap that stopped it, with its line
Laneseach computed rack as a row with one decimal value per lane, and each mask as a row of filled and empty cells
Codethe selected target's output: C with WebAssembly intrinsics, native kernel assembly, or native mixed C with opaque kernel assembly
Messagesthe compiler's errors, each also underlined in the editor at its line and column

The target menu offers WebAssembly, SSE2, AVX2, AVX-512 and NEON. Lessons with a whole program start on WebAssembly, which compiles stack runs and general runs over views. The native targets compile stack runs, while general runs over views and runtime inner loops give the compiler's work-in-progress diagnostic there. Results and lane traces come from Rake's interpreter at the selected profile's f32 rack width, and Code displays the generated C or assembly without executing it.

The browser compiler

rakec is written in OCaml, and js_of_ocaml compiles its front end, interpreter and emitters for the browser, so the playground runs the same code as rakec --interpret, rakec --emit-c and rakec --emit-asm. Compilation and execution happen in a worker, so a program that runs for more than three seconds is stopped without freezing the editor. Object verification needs clang and a disassembler, so it stays on the command line.

1. A program

A Rake file is a list of definitions. slow main() -> i32: defines the program's entry point, a function with no parameters that returns a 32-bit integer. A line ending in a colon opens the indented body below it, let binds a name to a value, and ~~ starts a comment.

~~ The program's result is what main returns.
slow main() -> i32:
  let answer = 6 * 7
  return answer

Result: main returned 42. Change 6 * 7 to 9 * 9 and run, and Result changes to main returned 81.

2. Changing values

A let is bound once. A name that changes is a location, made with := and changed with <-. for i from 0 up to 10: counts from 0 to 9, and % is the remainder.

slow main() -> i32:
  total := 0
  for i from 0 up to 10:
    if i % 2 = 0:
      total <- total + i
  return total

Result: main returned 20, the sum of 0, 2, 4, 6 and 8. Change total := 0 to let total = 0 and run. Messages says this location can't be assigned at the <-, because an immutable binding can't change.

3. A rack

f32s is a rack: four f32 values in one WebAssembly v128. A run is vector code that reads and writes memory. Its parameter x: []f32 is a view of floats, and out: mut []f32 is a writable one. x[<0>] loads the rack of four floats starting at element 0, out[<0>] <- ... stores one, and <2.0> is the number 2 in every lane.

run twice(x: []f32, out: mut []f32):
  out[<0>] <- x[<0>] * <2.0>

slow main() -> i32:
  values: [4]f32 := [1.0, 2.0, 3.0, 4.0]
  doubled: [4]f32 := [0.0; 4]
  twice(values, doubled)
  return i32(doubled[0] + doubled[1] + doubled[2] + doubled[3])

Result: main returned 20. Change <2.0> to <3.0> and run, and Result becomes main returned 30. In Lanes, the load fills a row of four cells, 1 2 3 4, and the multiply turns the whole row into 3 6 9 12 in one step, and Code shows it as one wasm_f32x4_mul.

4. Marking uniforms

A scratch is a function of racks. <factor: f32> declares a uniform, one scalar shared by every lane, and <factor> uses it. The brackets appear in both places so that every point where a scalar becomes a rack shows on the page.

scratch scale(values: f32s, <factor: f32>) -> f32s:
  values * <factor>

run scale_all(x: []f32, out: mut []f32, <factor: f32>):
  out[<0>] <- scale(x[<0>], <factor>)

slow main() -> i32:
  values: [4]f32 := [1.0, 2.0, 3.0, 4.0]
  scaled: [4]f32 := [0.0; 4]
  let factor = 10.0
  scale_all(values, scaled, <factor>)
  return i32(scaled[3])

Result: main returned 40. Slow code writes factor bare, because there it's just a number, and marks it <factor> at the call, where it crosses into vector code. Delete the brackets around factor inside the scratch and run:

scratch scale(values: f32s, <factor: f32>) -> f32s:
  values * factor

The message points at factor and says what to write. Broadcasting a scalar costs an instruction, and Rake never does it out of sight.

5. Loops and reductions

In a run, for <i: i32> from <0> up to <n> by <4>: counts in steps of four, with a uniform index. sums := <0.0> is a location holding a rack of four running sums, sum(sums) adds a rack's lanes into one scalar, and let <whole: f32> = ... binds that scalar as a uniform.

run total(x: []f32, out: mut []f32, <n: i32>):
  sums := <0.0>
  for <i: i32> from <0> up to <n> by <4>:
    sums <- sums + x[<i>]
  let <whole: f32> = sum(sums)
  out[<0>] <- <whole>

slow main() -> i32:
  values: [12]f32 := [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 11.0, 12.0]
  result: [4]f32 := [0.0; 4]
  total(values, result, <12>)
  return i32(result[0])

Result: main returned 78, the sum of 1 to 12. Change the count passed to total from <12> to <8> and run, and Result becomes main returned 36. Lanes shows sums holding 6 8 10 12, one running sum in each lane, and sum folds them from lane 0 upwards into 36: vector code adds in columns, then reduces once at the end.

6. Fused stages

| step <| velocities * <dt> is one stage of a fused computation, read from right to left: the expression flows into step. Consecutive stages form one region of pure arithmetic, and their identifiers are for the reader, so the compiler sees positions + velocities * <dt>.

scratch advance(positions: f32s, velocities: f32s, <dt: f32>) -> f32s:
  | step  <| velocities * <dt>
  | moved <| positions + step
  moved

This lesson has no main. Select x86-avx2, run the compiler and open Code to find the two instructions that implement the scratch:

advance:
    vbroadcastss ymm2, xmm2
    vfmadd231ps ymm0, ymm1, ymm2
    ret

The multiply and the add became one fused multiply-add on eight lanes, after a broadcast of <dt>. On wasm-simd128, which has no fused multiply-add, the tab shows a splat, a multiply and an add.

7. Choosing by lane

A comparison of racks gives a mask, one true or false for each lane, and if v > w then v - w else w - v with a mask condition chooses lane by lane.

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

run distances(a: []f32, b: []f32, out: mut []f32):
  out[<0>] <- distance(a[<0>], b[<0>])

slow main() -> i32:
  a: [4]f32 := [1.0, 9.0, 4.0, 4.0]
  b: [4]f32 := [5.0, 2.0, 4.0, 1.0]
  result: [4]f32 := [0.0; 4]
  distances(a, b, result)
  return i32(result[0] * 1000.0 + result[1] * 100.0 + result[2] * 10.0 + result[3])

Result: main returned 4703, the four distances 4, 7, 0 and 3 as digits. Change the first value of a from 1.0 to 6.0 and run, and Result becomes main returned 1703. In Lanes, the first mask value changes and the select takes the other subtraction for that lane. Both subtractions still run on all four lanes, because there's only one instruction stream.

8. Tines and sweeps

The square root of a negative number is NaN:

scratch roots(values: f32s) -> f32s:
  sqrt(values)

run all_roots(x: []f32, out: mut []f32):
  out[<0>] <- roots(x[<0>])

slow main() -> i32:
  values: [4]f32 := [16.0, -4.0, 9.0, 1.0]
  rooted: [4]f32 := [0.0; 4]
  all_roots(values, rooted)
  return i32(rooted[0] + rooted[1] + rooted[2] + rooted[3])

Result: a trap at the last line, -nan does not fit i32, with 4 NaN 3 1 in Lanes. A rake guards the lanes instead. tine #valid(values: f32s) means values >= <0.0> is a reusable predicate defined outside the rake, and #valid(values) computes the mask of lanes that may take a root. through #valid(values) into rooted: binds roots in those lanes and leaves the others undefined, so the sweep reads rooted only where the tine holds, and the gaps arm supplies zero for every other lane.

tine #valid(values: f32s) means values >= <0.0>

rake safe_root(values: f32s) -> f32s:
  through #valid(values) into rooted:
    sqrt(values)

  sweep:
    | #valid(values) => rooted
    | #valid(values) gaps => <0.0>

run all_roots(x: []f32, out: mut []f32):
  out[<0>] <- safe_root(x[<0>])

slow main() -> i32:
  values: [4]f32 := [16.0, -4.0, 9.0, 1.0]
  rooted: [4]f32 := [0.0; 4]
  all_roots(values, rooted)
  return i32(rooted[0] + rooted[1] + rooted[2] + rooted[3])

Result: main returned 8, and the NaN has left Lanes: 4 0 3 1.

Change the gaps arm's value to <-1.0> and run. The result becomes main returned 7, with output lanes 4 -1 3 1. There's no intermediate zero to change, because rooted has a value only where #valid(values) holds.

Delete the gaps arm and run, and the compiler reports Sweep does not provably cover every lane. Restore it, then change its value to rooted. That's refused too, because it would read the root in the negative lane, where none was computed. To define that lane, add else <999.0> before into rooted in the through header, and run with rooted still in both sweep arms: the result is now 1007. Reset the lesson to restore its original behaviour.

9. Columns

A pack describes one particle, and a stack stores many particles as a column for each field, with a count of records. In a run, particles.position is the entire position column, and the compiler visits it a rack at a time, including a final partial rack. The byte column age stays one byte per record. widen extends it to unsigned 32-bit lanes, and to_f32 converts those to floats for the calculation.

pack Particles {
  f32: position, velocity;
  u8: age;
}

run advance(particles: stack Particles, <dt: f32>) -> stack Particles:
  let age = to_f32(widen(particles.age))
  particles with { position: particles.position + particles.velocity * <dt> / (age + <1.0>) }

slow main() -> i32:
  positions: [6]f32 := [0.0, 10.0, 20.0, 30.0, 40.0, 50.0]
  velocities: [6]f32 := [4.0, 4.0, 4.0, 4.0, 4.0, 4.0]
  ages: [6]u8 := [0, 1, 3, 0, 1, 3]
  let n = 6
  particles := stack Particles { position: slice(positions, 0, n), velocity: slice(velocities, 0, n), age: slice(ages, 0, n) }
  particles <- advance(particles, <0.5>)
  total := 0.0
  for i from 0 up to i32(count(particles)):
    total <- total + particles.position[i]
  return i32(total * 10.0)

Result: main returned 1570. Calling advance moves the stack in and returns it with position replaced, so the program assigns the result back to particles. Change let n = 6 to let n = 5 and run. The second rack in Lanes now has one live lane and three empty ones, because the tail loads and stores only the record that exists.

Now keep only the young particles. Add tine #young means age < <2.0> after the let in advance, and change its last line to compact particles with { position: ... } where #young, keeping the same position expression. With n = 6 the result is main returned 860: four particles remain, moved to the front of each column, and count(particles) is 4.

The lesson starts on WebAssembly, with four records per rack, as on SSE2 and NEON. Select AVX2, with eight, or AVX-512, with sixteen, and both counts above fit in one partial rack.

10. A whole program

Slow code holds a program's records, state and control flow. A record groups fields, state games: i32 := 0 keeps a value for the life of the program, and a mut parameter lets the function write to it. Inside a run, slow { ... } enters scalar mode, the performance boundary corresponding to Rust's unsafe { ... } safety boundary, and at } the run resumes vector mode.

record Score {
  f32: best;
  i32: rounds;
}

state games: i32 := 0

run highest(x: []f32, out: mut []f32):
  let <top: f32> = maximum(x[<0>])
  slow { games <- games + 1; }
  out[<0>] <- <top>

slow play(score: mut Score, values: []f32):
  top: [4]f32 := [0.0; 4]
  highest(values, top)
  if top[0] > score.best:
    score.best <- top[0]
  score.rounds <- score.rounds + 1

slow main() -> i32:
  score: Score := Score { best: 0.0, rounds: 0 }
  first: [4]f32 := [3.0, 8.0, 1.0, 5.0]
  second: [4]f32 := [9.0, 2.0, 4.0, 7.0]
  play(score, first)
  play(score, second)
  return i32(score.best) * 100 + score.rounds * 10 + games

Result: main returned 922: the best score 9, two rounds and two games. Add play(score, first) after the two existing calls and run. Result becomes main returned 933, because the record and module state survive each call.

Now remove slow { and its closing brace, leaving games <- games + 1 in the run. Run it and read the error: vector code can't access module state. Restore the block and run again, then find the scalar helper call in Code, between the vector reduction before it and the broadcast store after it.

A block can return a scalar to a marked uniform binding, but it can't capture a rack: move the maximum into it as let top = maximum(x[<0>]), run, and read the error. Then restore the reduction outside the block. A reduction or extraction is the explicit boundary from a rack to a scalar.

Slow code can't hold a rack at all, so this separate example is rejected:

slow total(values: f32s) -> f32:
  return 0.0

slow main() -> i32:
  return 0

11. Proved or refused

The target menu chooses a profile, and each profile has its own rules for compiled programs. Physical profiles keep every rack in one register without calls or spills, while WebAssembly keeps every rack as one v128 and uses only the profile's listed virtual instructions. Rake has no vector sine yet:

scratch wave(values: f32s) -> f32s:
  sin(values)

Run the program and read the unsupported-operation message, then replace sin(values) with abs(values) and run again. The supported operation compiles, and Code shows its vector instruction. A C compiler given a sine of every lane may call the scalar sinf once per lane and carry on, slower, whereas Rake stops and says which operation it can't compile.

12. Bits

Racks hold integers too. A u8s rack holds sixteen bytes, and here each byte is one row of an 8 by 8 board, with its bits as the row's cells. shift_bits_left and shift_bits_right move every row's bits one cell east or west at once, and repeat runs its body a fixed number of times.

~~ One step of a flood fill on an 8 by 8 board, a row to each byte lane:
~~ every reached cell spreads to its east and west neighbours that are open.
scratch spread(reached: u8s, open: u8s) -> u8s:
  let east = shift_bits_left(reached, 1)
  let west = shift_bits_right(reached, 1)
  bit_and(bit_or(reached, bit_or(east, west)), open)

run flood(rows: []u8, walls: []u8, out: mut []u8):
  reached := rows[<0>]
  repeat <step: i32> from <0> up to <7>:
    reached <- spread(reached, walls[<0>])
  out[<0>] <- reached

slow main() -> i32:
  start: [16]u8 := [8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
  open: [16]u8 := [239, 255, 255, 255, 255, 255, 255, 255, 0, 0, 0, 0, 0, 0, 0, 0]
  filled: [16]u8 := [0; 16]
  flood(start, open, filled)
  return i32(filled[0])

Result: main returned 15. The fill starts at bit 3 of the first row, and bit 4 is a wall, so it spreads west to bits 0 to 3. Change the first byte in open from 239 to 255 and run, and Result becomes main returned 255 because the fill reaches the whole row. Lanes shows sixteen decimal byte values, one per lane, and each shift updates all sixteen at once.

Reading Rake

Rake takes much of its look from functional languages such as OCaml, Haskell, Elm and Erlang, and its layout from Python.

Layout

A line ending in : opens the lines indented below it, as in Python, for function and control-flow bodies, and tabs aren't allowed. Braces enclose pack and record fields, stack constructors and slow { ... } blocks. In a slow block, multiline statements are indented and inline statements use semicolons, and the last expression is the block's value unless a semicolon discards it.

Types after identifiers

values: f32s reads "values, of type f32s", in the order of Python's type hints, TypeScript, Rust and the ML family, where C writes float values. The result type follows ->, as in slow main() -> i32:, and the plural spelling is a rack: f32 is one float and f32s a register of them.

Bindings and locations

let binds a name to a value once, as in OCaml and Haskell, or const in JavaScript. A location is created with := and changed with <-, which come from Pascal, ALGOL and OCaml. Keeping both apart from = means = is always a comparison, so if i % 2 = 0: tests equality where C would write ==.

Conditional expressions

if v > w then v - w else w - v is an expression with a value, like C's v > w ? v - w : w - v or OCaml's and Haskell's if ... then ... else. A statement if with a colon and a body is the ordinary kind.

The marks

MarkRead it asFamiliar from
<x>, <x: f32>"the uniform x", the same in every laneRake's own: angle brackets around a scalar
#valid"the tine valid", a mask of lanesRake's own: the # is drawn like a mask with open lanes
| moved <| e"e flows into moved"<| is backward application in Elm and F#, the mirror of |>
| #valid => rooted"where #valid, take rooted"the cases of OCaml's and Haskell's match and case, and the arms of Rust's match
_"every other lane"the wildcard pattern of OCaml, Haskell, Rust and Erlang
~~"a comment to the end of the line"Rake's own, like // in C. (* ... *) is a block comment, as in OCaml and Pascal

Brackets

[4]f32 is an array of four floats, written before the element type as in Go, and []f32 is a view of any number of them. [1.0, 2.0] is an array literal, and [0.0; 4] is four copies of 0.0, as in Rust. x[<i>] with a uniform index loads a rack of consecutive elements, and x[i] in slow code reads one.

Ligatures

A programming font with ligatures, such as this site's JetBrains Mono, Fira Code or Cascadia Code, draws operators such as ->, <-, =>, <| and ~~ as single symbols, while the file still holds the plain characters.

A negative uniform literal starts with the same two characters as assignment, so all three fonts draw <-1.0> as an arrow followed by 1.0>. The playground highlights <-1.0> as a uniform and turns ligatures off inside uniforms, so the literal keeps its characters while total <- total still gets its arrow. In an editor without that rule, < -1.0> with a space means the same and draws plainly. The Ligatures switch beside the target menu shows the plain characters, for copying code into an editor that draws them differently.