Using Generators in Fortress

In Fortress, generators play the role that iterators play in many other programming languages. If you're used to writing iterators in languages like C++ and Java you may find that writing a Fortress Generator is a good deal easier and simpler---but that you'll have to turn your old mindset inside out. The key thing to remember is this:

In Fortress, the Generator is in charge of how a loop is run.

A generator extends the type Generator[\E\], where E is the type of the elements the generator produces. Here are a few examples of generators:

ExpressionType GeneratesDescription
0#5 Range[\ZZ32\] ZZ32 5 integers starting with 0, ie 0,1,2,3,4
seq(0#5) SeqRange[\ZZ32\] ZZ32 The same, but guaranteed to be sequential
a = array1[\String,5\](fn (i) => "Howdy, " i) Array1[\String,0,5\] String The strings "Howdy, n" for n=0,1,2,3,4
a.indices() Indexed[\ZZ32,ZZ32\] ZZ32 The indices of a, ie 0,1,2,3,4 ; see also ArrayIndices
a.indexValuePairs() Indexed[\(ZZ32,String),ZZ32\] (ZZ32,String) The pairs (n,"Howdy, n") for n=0,1,2,3,4. For arrays with dimensionality > 1, the first element of the pair is a tuple of indices
l = <| "The", "quick", "brown", "fox" |> List[\String\] String The list elements
l.indices() Indexed[\ZZ32,ZZ32\] ZZ32 The indices of l, ie 0,1,2,3
(0#5).map(fn x => x x) MappedGenerator[\ZZ32\] ZZ32 The squares of 0#5, ie 0,1,4,9,16
a.map(fn x => x ", how are you?") Array1[\String,0,5\] String A fresh array holding "Howdy, n, how are you?"

For Loops

Fortress provides three forms of convenient syntax for using generators. The first and most obvious is the for loop:

for i <- 2:4, s <- a do println(i ": " s) end

One run of this program printed:

3: Howdy, 2
2: Howdy, 0
2: Howdy, 1
2: Howdy, 2
2: Howdy, 3
2: Howdy, 4
4: Howdy, 2
4: Howdy, 3
3: Howdy, 4
3: Howdy, 1
3: Howdy, 3
3: Howdy, 0
4: Howdy, 1
4: Howdy, 0
4: Howdy, 4

Note that the lines appear to have been printed in no particular order! This is one of the most important aspects of Generators:

A Generator can run computations in any order it wants, and usually runs them in parallel.

There's an important proviso here, though: sequential generators. We can wrap any generator with seq(...) and obtain a sequential version of the same generator. Every generator has a so-called natural order; this is the order in which its elements appear when we sequentialize it using seq. Let's try running the above sequentially:

for i <- seq(2:4), s <- seq(a) do println(i ": " s) end

Now we obtain:

2: Howdy, 0
2: Howdy, 1
2: Howdy, 2
2: Howdy, 3
2: Howdy, 4
3: Howdy, 0
3: Howdy, 1
3: Howdy, 2
3: Howdy, 3
3: Howdy, 4
4: Howdy, 0
4: Howdy, 1
4: Howdy, 2
4: Howdy, 3
4: Howdy, 4

This is more like the behavior we were expecting. As you might expect, the natural order of an array produces its contents from lowest to highest index. This also leads us to another handy rule of thumb:

When doing I/O, use sequential loops.

Below we'll look at ways that we can circumvent this particular problem.

Reductions and comprehensions

The other two convenient syntaxes for generators in Fortress are reductions:

SUM [i <- 1:10] i^2

And comprehensions:

<| i^2 | i <- 1:10 |>

At the time of writing these don't work yet. But note that even though body computations occur in parallel, the results are combined together in their natural order. Consider the following code:

s = BIG DOT [i <- 2:4, s <- a] (i ": " s "\n")
print(s)

This will print:

2: Howdy, 0
2: Howdy, 1
2: Howdy, 2
2: Howdy, 3
2: Howdy, 4
3: Howdy, 0
3: Howdy, 1
3: Howdy, 2
3: Howdy, 3
3: Howdy, 4
4: Howdy, 0
4: Howdy, 1
4: Howdy, 2
4: Howdy, 3
4: Howdy, 4

But it does so by first computing (in parallel) the individual lines of the string, and appending them together; the resulting string is then printed. This can be particularly useful if it requires effort to compute the individual strings in the first place.

The Generate Function

The real magic of the type Generator[\E\] is found in the generate method:

generate[\R\](r: Reduction[\R\], body: E->R): R

We can call the generate function explicitly in our code, as follows:

s = a.generate[\String\](StringReduction, fn (s) => s "\n")
print(s)

This prints:

Howdy, 0
Howdy, 1
Howdy, 2
Howdy, 3
Howdy, 4

Let's take a look at the arguments to generate. The second argument is the "body" of the reduction. Each time the generator produces an element, it calls body passing that element as an argument. The body is free to do any kind of computation it likes using that element, but ultimately it'll produce a result of type R. The first argument to generate is a reduction, an object that packages up an associative operation with its identity. This operation is used to combine together all those results of type R into a final answer. From FortressLibrary.fss:

trait Reduction[\ R \]
    empty(): R
    join(a: R, b: R): R
end

There are a number of pre-defined reductions in the library:

VoidReduction Reduction[\()\] Discards its inputs.
SumReduction[\N\]() Reduction[\N\] Sums objects of type N
AndReduction Reduction[\Boolean\] Boolean AND
OrReduction Reduction[\Boolean\] Boolean OR
StringReduction Reduction[\String\] String concatenation

The join operation takes two Rs and combines them together in order to produce an R. It must obey three rules:

  • join(a,join(b,c)) = join(join(a,b),c)(Associativity)
  • join(empty(),r) = r (Left identity)
  • join(r,empty()) = r (Right identity)

The first rule is the most important from the perspective of parallelism. It says that if we combine elements together according to the natural order of the generator, it doesn't matter exactly how we group the combining operation. Consider for example what happens if we concatenate the contents of l:

"The" "quick" "brown" "fox" => "Thequickbrownfox"

It doesn't matter how we do the concatenation:

("The" "quick") ("brown" "fox") = (("The" "quick") "brown") "fox" = "The" ("quick" ("brown" "fox"))

That's the key property of the join function, and it allows the generate method to be pretty flexible about how it combines together results.

By bending these rules a little bit, we can see what's going on under the covers of generate. Let's write a reduction and a body that log some of their output:

object LoggingReduction extends Reduction[\String\]
  join(a:String, b:String): String = do
      println("Joining \"" a "\" and \"" b "\"")
      a b
    end
  empty(): String = ""
end

run(args:String...) = do
    loggingBody(i:ZZ32): String = do
        println("Iteration " i)
        "i" i
      end
    r = (1:16).generate[\String\](LoggingReduction, loggingBody)
    println(r)
  end

One run of this program printed:

Iteration 1
Iteration 2
Joining "i1" and "i2"
Iteration 13
Iteration 14
Joining "i13" and "i14"
Iteration 15
Iteration 16
Joining "i15" and "i16"
Joining "i13i14" and "i15i16"
Iteration 11
Iteration 12
Joining "i11" and "i12"
Iteration 10
Iteration 9
Joining "i9" and "i10"
Joining "i9i10" and "i11i12"
Joining "i9i10i11i12" and "i13i14i15i16"
Iteration 3
Iteration 4
Joining "i3" and "i4"
Iteration 5
Iteration 6
Joining "i5" and "i6"
Iteration 7
Iteration 8
Joining "i7" and "i8"
Joining "i5i6" and "i7i8"
Joining "i1i2" and "i3i4"
Joining "i1i2i3i4" and "i5i6i7i8"
Joining "i1i2i3i4i5i6i7i8" and "i9i10i11i12i13i14i15i16"
i1i2i3i4i5i6i7i8i9i10i11i12i13i14i15i16

Individual iterations---and individual combining operations---are run in parallel. The logging output appears higgeldy-piggeldy; the only thing we can really rely upon is that we can't join together two iterations until they've happened! But the result we obtain and print on the last line will be the same every single time the program is run.


Defining Generators in Fortress