Chapter 3
Why Scaly Can Scale
Two Rounds at Once
Two rounds of a loop may run at the same time when neither can see what the other does: neither writes anything the other reads, and neither does something whose order the world outside the program can tell — printing a line, writing a file, answering a request. A compiler that can prove this for a loop can hand its rounds to every core of the machine, and the program cannot tell the difference except by the clock.
Most languages give the compiler no way to prove it. Any call may
write through any pointer it was handed, change a global, or print; so
parallel code is written by hand, with threads, locks and a library,
and getting it right is the programmer's business. Scaly gives the
compiler three facts, and checks every one of them in every compile:
what a routine may write is part of its declaration, a
function writes nothing and does no input or output, and
memory belongs to regions rather than to one heap that every core
shares. A plain for loop is all a program writes; whether
its rounds run in parallel is the compiler's decision.
This part of the book shows what that gives a program, a chapter for each way to scale: loops over every core, recursion that forks, the lanes of one core, many connections at once, and more than one machine. Each chapter is built around a program you can run. This chapter is about the three facts.
Functions Read, Procedures Write
A function reads its arguments and returns a value.
That is not a convention but a rule the compiler enforces: a function
writes nothing its parameters reach, reads no global that can change,
and performs no input or output — not even through the routines it
calls. A function that prints is refused:
function shout(word: String) returns String
{
print word ; error: function shout performs I/O
word
}So a call of a function is a question with an answer and nothing else, and two such calls can run side by side. Here the rounds of the first loop each compute one square and put it into a slot of their own; the second loop adds them up:
function square(x: int) returns int
x * x
let n 1000
var squares Vector[int](n as size_t)
for i in n
squares.put(i as size_t, square(i))
var total 0
for s in squares
total := total + s
print "sum of squares below `n`: `total`"$ scaly run squares.scaly
sum of squares below 1000: 332833500
$ scalyc --task-plan squares.scaly
task-plan: 2 for-loops, 2 parallelizable
task-plan: squares.scaly:6:1: parallel
task-plan: squares.scaly:10:1: parallel
$ SCALY_WORKERS=1 scaly run squares.scaly
sum of squares below 1000: 332833500--task-plan shows the compiler's decision for each
for of the file: both loops run in parallel. The first
writes to squares, which the loop did not make — but each
round only to the slot its own i names, so no two rounds
write the same place. The second is a sum, which the compiler knows how
to split and put together again; Loops on All Cores
says which loops it takes and which it leaves alone. The last command runs
the program on one core; The Same Program on One Core says
why it prints the same.
A Procedure Says What It Writes
A procedure may change things, and its declaration
says which: each parameter it writes is marked mutable,
the receiver this included, and each global it writes is
named after its signature. The compiler checks the marks against the
body, so they cannot lie (Mutation via Procedures).
A loop that hands its own state to a procedure that writes it keeps its
order — here every round adds to the one log:
procedure record(mutable log: Array[int], value: int)
log.add(value)
var log Array[int]()
for i in 5
record(log, i * i)
print "`log.length` entries, the last one `log[4]`"$ scaly run record.scaly
5 entries, the last one 16
$ scalyc --task-plan record.scaly
task-plan: 1 for-loops, 0 parallelizable
task-plan: record.scaly:5:1: blocked: transitively impure call recordA loop that prints keeps its order for the same reason:
print is a procedure that reaches the world outside the
program, and the lines come out as the rounds were written.
for i in 3
print "round `i`"round 0
round 1
round 2None of this is an error. A loop the compiler cannot prove
independent runs one round after the other, as written, and the program
means the same either way; --task-plan is the only
place where the difference shows.
Memory That Is Not Shared
The third fact is about memory. Scaly has no heap that every part of a program allocates from and a collector cleans up; memory belongs to regions — pages that belong to a call, or to an owner that lives longer — and a region is released as a whole. The compiler works out which region each value belongs to, and an escape checker refuses a program in which a value would outlive its region (Lifetimes and Memory Regions; Memory tells where the idea comes from).
For a loop on many cores that means there is nothing to take turns at. A worker allocates from pages of its own, so the rounds do not queue at one allocator, and there is no collector that must stop every core to look at all of memory. What a round builds and drops is gone with its page; what a round builds and the loop keeps (Values a Round Keeps) is handed to the region of the code around the loop.
The Same Program on One Core
Because a loop runs in parallel only where its rounds cannot see
each other, a program prints the same whether it runs on one core or
on all of them. The environment variable SCALY_WORKERS
caps how many cores a program uses; with
SCALY_WORKERS=1 it runs every loop on one, as
the last command of squares.scaly did — which is
also how you measure what the other cores bring.
Every program of this part is checked that way: what it prints with every core and with one is the output printed beside it.