Pure blocks

9 December 2011

I’ve been thinking a lot about “parallel blocks” recently and I am beginning to think they can be made to work very simply. The main thing that is needed is a type qualifier const that means “read-only”. This would be a type prefix with very low precedence, just like immutable and shared in D. The type const T would refer to an instance of T that cannot be modified. This is a deep property, so, given some record types defined like:

type R1 = {...};
type R2 = { f1: @R1 };

If I had a variable r2 of type const @R2, then the expression r2.f1 has type const @R1.

Given this tool, the basic idea is that a parallel block (written { par || }) can access its surrounding environment, but all of the types of the variables become const. This means that a block can read the heap via its environment but it cannot make any changes to it. This also means that any number of parallel blocks can execute in parallel without affecting each other.

However, this does not mean that parallel blocks cannot create and mutate data at all! They are free to allocate new structures in the task-local heap and mutate those, for example. They can even return those newly created structures to their caller. If regions come in, they could use their own stack frame as well. Finally, as described below, we can use unique pointers to give parallel blocks access to some data which they are free to modify if desired.

Also: here I am assuming fixed-length arrays, not the “always unique” vectors we have today, but everything still applies.

The critical invariant

In all the examples that follow, the critical invariant which is maintained is that only parallel blocks are executed in parallel: the “master thread” is effectively suspended while the parallel execution occurs. This ensures that there are no races between the parallel child threads and the common parent.

Parallel map etc using parallel blocks

We can define a parallel map function over arrays (this is clearly pseudocode):

fn par_map<S,T>(v: [S], b: par block(const S) -> T) -> [T] unsafe {
    let result = create uninitialized vector with length vec::len(v);
    spawn parallel tasks for each i = 0..vec::len(v) {
        result[i] = b(v[i]);
    }
    ret result;
}

This would be used like:

fn do_something(v: [T]) {
    par_map(v) { par |e: const T|
        let ctx = @{...};
        do_some_complex_computation(ctx, e)
    };
}

Note that the parallel block is allowed to create a (mutable!) variable ctx that is (presumably) used during the complex computation. But all data that the parent could reach, which was inherited either through upvars or through the parameter, is const and hence (temporarily) immutable. The execution of the parallel blocks themselves would be done using work-stealing or similar techniques.

It’s not hard to imagine a number of other similar primitives that implement more complex patterns than par_map. The work by Michael McCool on Structured Parallel Programming would be a good starting point. In fact, probably the next thing to do is to look at those patterns and see how well they can be fit into this framework (not sure if there is a more recent publication).

In-place map using parallel blocks

But what if wanted to map over an array in place? To keep things simple, let’s imagine that we want to overwrite the input array. That can be done in basically the same way with a function whose signature is:

fn par_map_in_place<S:send>(-v: ~[S], b: par block(&x: S)) -> ~[S] {
    spawn parallel tasks for each i = 0..vec::len(v) {
        b(v[i]);
    }
    ret v;
}

The difference from par_map() above is that this version does not create a new array but rather modifies the existing one in place. There are two subtle points:

  • The type variable S is declared as sendable. This means that it does not access shared state (i.e., no contained pointers except for unique pointers). This ensures that it can be safely modified in place.
  • This function both takes and returns the same vector. This is kind of a hack. What would be needed is a way to say a borrowed unique vector that the block can’t access. This doesn’t seem too hard but it’s not something that’s in the type system today (well, maybe using a mode like && in combination with a type ~[S] would achieve this, I’m not sure). Anyway this is a minor point.

Merging par and pure

A recent e-mail to the Rust mailing list got me thinking that perhaps the right way to think about parallel blocks is to call them pure blocks. They are kind of a pure function, after all, from their upvars and arguments to their output. The only thing that I don’t quite like is that a so-called pure block, if given a non-const argument, would be free to modify that argument in place. This is not very pure, but things like par_map_in_place() rely on it.