Posted on July 31, 2026

Bottom line up front: this long blog post describes the culmination of a years-long effort that means that we can now, modulo compiler bugs and a few unimportant edge cases, compile any Futhark program to parallel GPU code.

Introduction

One of the most interesting implementation challenges in Futhark is how to map nested parallelism, in which parallel operations can contain other parallel operations, to flat parallelism. This challenge arises because much interesting hardware (such as GPUs) does not efficiently support nested parallelism, and even on CPUs, arbitrary nesting may have significant run-time costs. Futhark has for a long time supported flattening only a limited (although important) subset of programs, called regular nested parallelism (as opposed to irregular), which meant that there were programs that we were unable to compile to GPU code. In 2022 I began an experimental implementation of “full flattening”, which as of this writing is the longest open pull request on the Futhark repository. I got far enough to build a proof of concept, but the sheer amount of effort involved led to the work stagnating - until now. Largely due to the efforts of students, we are now close to having a complete reimplementation of flattening in Futhark.

The main contributions have been by Cornelius Sevald-Krause who implemented function and match lifting in his BSc thesis, and particularly by Amirreza Hashemi whose MSc work has been a whirlwind of finishing everything else. This includes various crucial optimisations in order to reach functionality and performance parity with the old flattener. I cannot overstate how impressive it is to come up with a systematic and maintainable replacement for a ten-year-old compiler pass that is full of optimisation heuristics.

This post will introduce the basic problem of flattening, go through some of the various challenges, and explain how we have resolved them in the soon-to-be-merged work on the Futhark compiler. Flattening has a long academic pedigree, and I will try to link relevant other work, but this post is not a systematic survey of prior work. I will ignore most of the nontrivial bookkeeping that a compiler needs to do to actually implement all of this stuff, and instead focus on the intuition of how programs are transformed.

I should mention up front what the goal is of this work: the new flattening transformation should not reduce performance for programs that already worked with the old implementation, but we are less concerned with the performance of programs that did not work before. As we shall see, achieving truly good performance on arbitrarily irregular nested parallelism is a major research problem in itself, and one that we intend to address incrementally in the time to come.

Also, this is a long post. I have tried to make everything here relatively high-level, but you may want to skip to the reflections at the bottom if things drag on too much for your taste.

Basics of flattening

To motivate flattening, we’ll be assuming a slightly caricatured view of what a GPU can do. Expanding to full GPU functionality does not fundamentally change the story; it just means I would need more convoluted examples. Also, despite this post focusing on GPUs, you can mentally substitute “efficient but restricted processors” instead - these concerns crop up with any kind of vector processing.

In the language of functional programming, we’ll start by assuming that a GPU can efficiently execute a map where the function contains sequential code, and where all memory for intermediate results can be allocated in advance. To avoid writing zip/unzip all the time, we’ll also suppose that our maps accept any number of arrays. This, for example, is something we can efficiently execute on a GPU:

map (\x y -> x + y) xs ys

(Incidentally, when describing the transformations to follow, it would be much simpler if map accepted its arrays before the lambda, like we do in Futhark’s IR - but I didn’t want to go that far in a blog post.)

It is easy to imagine that this can be turned into a GPU program with one thread per iteration of the map. Apart from a single map with sequential code, we’ll also allow any nesting of maps. So this is also efficient:

map (\xs ys -> map (\x y -> x + y)
                   xs ys)
    xss yss

It is similarly easy to imagine that these maps can be collapsed, with one GPU thread for every iteration of the innermost map.

In addition to arbitrary sequential code, we will also allow the innermost operation to be a single parallel operation, such as a scan, a reduction, a transpose - the precise collection doesn’t really matter. Then this is also efficient:

map (\xs -> reduce (+) 0 xs)
    xs

Essentially, “efficient GPU code” means a tower of maps on top of some single parallel primitive operation (or sequential code). The reason these patterns are efficient is because we know how to generate efficient GPU kernels from these patterns. This has some relation to skeletons, but we don’t generally use that term, since we intend to use these patterns as a reasoning mechanism for the compiler, rather than a user-visible programming model. In the actual compiler, we of course have dedicated constructs to reliably express “a map with something specific inside of it”, but to minimise the syntax in this post, I will mostly stick with the towers-of-maps.

Of course, Futhark programmers might not write programs that look like that. Such bold and free-thinking people might instead write this:

map (\xs -> let y = reduce (+) 0 xs
            let zs = map (+y) xs
            in zs)
    xss

This does fit one of the patterns we discussed: if we discard the parallelism of the inner map and reduce, then this is a map containing sequential code. This is an important choice when compiling a parallel language: you don’t have to exploit all the parallelism. Semantically, most parallel languages don’t require parallel execution in order to be correct; it’s just an option the implementation can take. Of course, we should not go overboard - a parallel language that does not exploit any parallelism might well be accused of violating the contract.

In this case, we can rewrite the program to be one that is semantically identical, but is expressed in terms of the efficient patterns. To make it clear what is going on, first let us add some type annotations, where we assume that the shape of xs is [n][m]:

map (\xs -> let y : i32 = reduce (+) 0 xs
            let zs : [m]i32 = map (+y) xs
            in zs)
    xss

Then we split apart the outer map into two separate maps, with the first one returning an array of intermediate results that are passed into the second map:

let ys =
  map (\xs -> reduce (+) 0 xs)
      xss
let zss =
  map (\xs y -> map (+y) xs)
      xss ys

These two maps now match the patterns that we know how to turn into efficient GPU code. The transformation we did here is often called loop distribution or fission, and is the counterpart to loop fusion. For a purely functional language, the following equation holds:

map f ∘ map g = map (f∘g)

(The rule is more convoluted once multiple input arrays are allowed, but let us ignore that.) Usually we apply the rule left-to-right in order to perform fusion and avoid having to store intermediate arrays in memory, but we can also apply it right-to-left, in which case it provides us a way to decompose complex maps into sequences of trivial maps that just perform one single operation (or a compound one - we can apply fission to the degree we want, which I will return to). This is the main technique we use for cutting apart an arbitrarily complex data parallel program into smaller manageable chunks. As far as I know, it was pioneered by Guy Blelloch in the early 90s as part of his work on NESL, although it was (and is) sometimes called “vectorization”, because its output, when fully applied, consists of maps and scans with trivial operations inside, which can be seen as “vector operations”. However, there are still two major problems we need to address before we have all the components of a working flattening algorithm.

Irregular intermediate arrays

Consider this program:

map (\m -> let xs : [m]i64 = iota m
           let y : i64 = reduce (+) 0 xs
           in y)
    ns

Recall that iota m produces an array [0, ..., m-1] of size m. This program has the same issue as above, in that the parallel operations are not perfectly nested. So let us try to fission (fise?) the map:

let xss : [n][???]i64 =
  map (\n -> iota n)
      ns
let ys =
  map (\xs -> reduce (+) 0 xs)
      xss

Notice the question marks in the type of the xss binding - what size should we put there? Intuitively, the operation map (\n -> iota n) ns produces an irregular array, where the rows have different sizes, depending on the values of the input array ns. One might expect that map (\n -> iota n) [1,2,3] produces the array [[0],[0,1],[0,1,2]], but unfortunately, Futhark does not allow irregular arrays; neither in the source language nor in our IR. The reasons are motivated by performance, as once you have irregular arrays, you either need to represent arrays as arrays-of-pointers, or lose random access indexing. Neither are palatable when you want to target vector processors. That means we don’t want to allow irregular arrays in the source language, nor in the IR.

This issue is the root of the limitation on our original flattening transformation, which is that it cannot handle irregular nested parallelism, meaning parallelism of a size that varies between iterations of outer parallel dimensions. In the example above, m is local to the outermost map, so the iota and reduce (both of which have a parallel width of m) are irregular. Our only choice would then be to sequentialise this code. Now, in the actual compiler, the iota would be fused into the reduce (using a representation I will not go into), but if we ignore that part, it seems like we are violating one of our constraints, which is that all memory for intermediate results must be preallocated - and here we need an array of size m, where m can be different for each thread. In this case, the problem can be solved by an inspector that extracts a slice of the program that computes all intermediate sizes (but not any other values), which is then used to compute memory requirements and organise intermediate results in memory. This can be done sometimes (roughly, when there is no tricky business with sequential loops), but it is a best-effort kind of thing, and it does not bring back parallelism.

Anyway, the ability to handle irregular arrays is clearly crucial to the general goal of handling irregular nested parallelism. The solution is to figure out a representation of irregular arrays as flat (one-dimensional arrays), which still allow the parallel execution of the operations we need. The central building block is an encoding of a two-dimensional irregular array as a data vector and a flag vector, both of the same size. The data vector is merely the rows of the array concatenated, while the flag vector indicates whether its corresponding element is the beginning of a new row. Example:

  • Nested irregular array: [[1], [2,3], [4,5,6]]
  • Value vector: [1,2,3,4,5,6]
  • Flag vector: [true, true, false, true, false, false]

(The restriction to two-dimensional arrays is without loss of generality: we can always collapse extra inner dimensions and have auxiliary arrays telling us how to reconstruct them. For simplicity, we will not go into further detail.)

We call this a segmented array, and the “irregular rows” are called segments. The array above has three segments, of size 1, 2, and 3 respectively. In practice, we often use certain additional auxiliary arrays, for tracking the size of each segment (the shape vector, in this example [1,2,3]), the beginning offset of each segment in the data vector (the offset vector, [0,1,3] here), and arrays associating each element of the data array to its segment index ([0,1,1,2,2,2]) and in-segment index ([0,0,1,0,1,2]). These can all be efficiently computed from the flag vector, however.

The fundamental operation on this representation is the segmented scan, which can be defined in terms of the standard scan as follows:

def segscan 't [n]
            (op: t -> t -> t)
            (ne: t)
            (flags: [n]bool)
            (vals: [n]t) : [n]t =
  let pairs =
    scan (\(v1, f1) (v2, f2) ->
            let f = f1 || f2
            let v = if f2 then v2 else op v1 v2
            in (v, f))
         (ne, false)
         (zip vals flags)
  let (res, _) = unzip pairs
  in res

We can apply it to the representation of the irregular array above to obtain the result of (inclusively) scanning each segment, concatenated into a single array:

> segscan (+) 0 [true, true, false, true, false, false]
                       [1,2,3,4,5,6]
[1, 2, 5, 4, 9, 15]

This is the value array for a segmented array representing [1,2,5]++[4,9]++[15], using the original flag array unchanged. Since scans are such an important building block in parallel algorithms, it is no surprise that once we can scan these segmented arrays, we can do all kinds of other things as well. For example, we can do a segmented reduction by first performing a segmented scan, then extracting the final element of each scanned segment:

def segreduce 't [n]
              (op: t -> t -> t)
              (ne: t)
              (flags: [n]bool)
              (vals: [n]t) : ?[m].[m]t =
  segscan op ne flags vals
  |> zip (rotate 1 flags)
  |> filter (.0)
  |> map (.1)
> segreduce (+) 0 [true, true, false, true, false, false]
                         [1,2,3,4,5,6]
[1, 5, 15]

Similarly, we can construct a segmented iota, which takes as input a shape vector of type []i64, and morally maps iota over this array. The construction is based on the observation that a normal iota can be written as an exclusive prefix sum over an array comprising n copies of 1:

def iota n = expresum (replicate n 1)

A segmented iota is then just a segmented scan over a segmented array where the data vector is all 1, and the flag vector has a true when a new iota begins, which can be determined by an exclusive prefix sum of the shape array. Since our segmented scan is inclusive rather than exclusive, we have to subtract from each element at the end:

def segiota [k] (ns: [k]i64) : ?[m].[m]i64 =
  let m = i64.sum ns
  let offsets = expresum ns
  let flags = spread m false offsets (replicate k true)
  in segscan (+) 0 flags (replicate m 1)
     |> map (\x -> x - 1)

Incidentally, the segmented iota of a shape vector gives the in-segment index for the segmented representation.

Essentially, whenever we talk of segmented foo, we mean an operation that intuitively performs the job of maping an operation foo, either consuming or producing a segmented array. As long as these segmented operations follow our rules for what we can turn into efficient (or just executable) GPU code, then we are fine - and you may note that all the segmented operations described so far are fully parallel and completely flat, in the sense that they do not contain any nested parallelism, despite in some sense operating on nested structures. In other fields, a segmented operation is sometimes called a batched operation, although sometimes this implies that the segment sizes are all the same.

This is then the core of how Futhark’s new flattening transformation works: use fission to split maps into maps of very simple operations, which temporarily introduces a notion of irregular arrays, then rewrite each of those maps to their corresponding segmented form, which consume and produce arrays in the segmented representation. This means that as long as we have a flattening rule telling us how to generate segmented code for map foo for any possible foo, then we can flatten any program. This is a nice compositional property that means we can treat each language construct in isolation.

The next problem, of course, is that some of those foos are mighty tricky.

Flattening control flow

The trickiest situation occurs when maping a conditional. Futhark’s intermediate representation has some complicated forms of control flow (multiway-branching and loops), but to illustrate the problem and solution, we’ll just deal with a plain if. The problem occurs when we have code like this:

map (\b x -> if b then foo x
                  else bar x)
    bs xs

Here foo x means some arbitrary code that uses x, not an actual function foo (we’ll get to that problem below). Fission won’t help us further, as the if is not a composition of smaller functions. What we need is a flattening rule for if.

The approach we use was invented by Blelloch (as so many things in this field), and is called branch packing. The idea is to partition the iterations of the map into those where the condition is true, and those where the condition is false, execute each of those separately, and combine the results. The price is that we execute both branches separately and spend time partitioning and recombining data. Handwaving a little bit, we flatten code like the above into this:

let (xs_true, xs_false) = partition_inputs bs xs
let xs_true' = map (\x -> foo x) xs_true
let xs_false' = map (\x -> bar x) xs_false
let result = combine_inputs bs xs_true' xs_false'

In practice, partition_inputs also has to return some auxiliary data that combine_inputs uses to recombine the results in the original order. It’s not hard, just fiddly. The two maps may also need further flattening of course, as foo and bar can be arbitrary code.

Loops are handled similar in spirit - keep an array of which iterations of the map have yet to finish, and keep going until they are all done. The shape of the flattened code is an outer loop whose body is parallel. It takes a lot of data movement to repeatedly filter out the ones that are done, so it is not particularly efficient - we’ll look at some more efficient special cases below.

Flattening functions

But what if foo x really does mean applying the function foo to x? Unsurprisingly, for a functional language, function applications do occur. Although Futhark’s current inlining strategy means we could perhaps ignore them, one of the goals of improving flattening is to eventually be less crude with regards to inlining.

The flattening rule for functions is conceptually quite simple: an expression map f xs is flattened to a call to the lifted function f' xs, where f' is generated from f in a systematic manner. The nomenclature of “lifting” is because we “lift” a function of type a -> b to be a function of type []a -> []b, that is, the function f' is “segmented f”. Because we want to deal with segmented arrays, and functions of more than a single argument, the true transformation at the type level is more complicated: each scalar parameter is turned into a (normal) one-dimensional array, and each array parameter is turned into its segmented representation (flag and value vector), and similarly for the return type. The body of the function is then flattened as if it was inside a map nest.

It is a very convenient (and to me a little surprising) property that flattening composes so nicely, and it even turns out that recursive functions don’t need any special handling - it just works. This is due to the very nice property that lifting an already lifted function is the lifted function itself, meaning we do not need to generate more than one lifted function, no matter how deeply nested it may occur.

At this point in the post, and to the best of my knowledge, we have reproduced Blelloch’s flattening as it existed in NESL in the mid 90s, and which was also implemented for such languages as cuNESL, which compiled a subset of NESL to GPUs years before work began on Futhark. While flattening is (almost) preserving of the time asymptotics of the source program (there are some edge cases for conditionals I won’t get into), the performance on real hardware is not good when flattening is applied naively. The reasons are basically due to locality: flattening tends to increase array sizes asymptotically, and produce a lot of very small parallel kernels, each of which manipulate large arrays in memory. Let us now try to address some of these issues.

Cheaper replication

Looking under the rug just a bit, we see that one thing I swept under it was how to handle free variables in the functions we map with. I mentioned that as long as we had a flattening rule for map foo for every foo, then we could flatten arbitrary programs, but in the general case foo is a lambda that can access arbitrary variables in scope, like for example this matrix multiplication:

let res =
  map (\xs -> map (\ys -> let zs = map (*) xs ys
                          in reduce (+) 0 zs)
                  yss)
      xss

Applying fission, we are left with this:

let zsss = map (\xs -> map (\ys -> map (*) xs ys) yss) xss
let res =
  map (\zss -> map (\zs -> reduce (+) 0 zs)
                   zss)
      zsss

You should note the big three-dimensional zsss array, despite matrix multiplication only involving two-dimensional arrays. This overhead, where the three-dimensional code nesting of the original program results in three-dimensional data nesting in the intermediate results is a common downside of flattening. I usually describe it as flattening turning time into space. It does not affect the asymptotics, as matrix multiplication is already cubic time, but it obviously affects real performance tremendously.

In actual Futhark, this issue does not occur, because we do not treat the map and reduce as separate constructs - they are fused into a combined form in the IR, and treated atomically. But as we shall see, that does not solve the entire problem.

For simplicity, we do not want our flattening rules to be concerned with free variables. In order to establish the property that a lambda has no free variables, we replicate each free variable and instead pass it as an additional input to each map. Writing rep as a shorthand for replicating the necessary amount, we get:

let zsss = map (\xs yss' -> map (\ys xs' -> map (*) xs ys) yss' (rep xs))
               xss (rep yss)
let res =
  map (\zss -> map (\zs -> reduce (+) 0 zs)
                   zss)
      zsss

The new parameters have been marked with a prime symbol, and are in some sense only administrative - yss' really is just ys. The reason I don’t just write replicate here is that the arrays to be replicated may in some cases be irregular themselves due to fission, in which case the replication is a segmented replicate - a parallel operation, but a somewhat complicated one.

One property of our implementation is that we have not extended the IR in any way. This means that when we generate (say) a segmented iota, we generate a function that contains IR code corresponding to the scans, scatter, etc. that we need. The consequence is that the compiler does not have a high level understanding of the code that is produced by flattening, and it does not understand that fancy segmented replicates can in many cases be simplified away (e.g., indexing a segmented replicate is equivalent to an appropriate indexing of the original array). Perhaps that will change in the future, but for now we have no way to clean up the mess, so perhaps it is better to avoid making a mess in the first place.

In our flattening algorithm, we therefore track some arrays as “replicated” using a symbolic representation at compile-time, and handle them specially whenever they are passed as inputs to inner maps. Whenever we would be forced by unfortunate circumstance to actually return these to outside code, we could always manifest them, but this turns out to be needed very rarely. Also, this issue occurs only for variables that are free in a lambda, but bound inside an enclosing map. Variables that are free in the entire nest, such as global variables, need not be replicated.

I should note that a different approach to tackling the space blowup issue for flattening is to replace maping of arrays with streaming of sequences, which allows for incrementally producing flattened data, rather than all-at-a-time. This was investigated by Frederik M. Madsen in his PhD thesis. This requires a rather different language (and solving different research problems), so it is not a direction we took.

An aside on Data Parallel Haskell

Another solution comes from Data Parallel Haskell (DPH) and is discussed in the paper Work efficient higher-order vectorisation, and is based on a different representation of segmented arrays that distinguishes “physical” from “virtual” segments. This differs from Futhark’s implementation in that it also exists at run-time (and is therefore more flexible). As an aside, I will note that DPH is probably the most thorough treatment of flattening since Blelloch’s original work. A lot of the DPH work was highly inspirational to our own efforts, although DPH spent a lot of energy on tackling a richer source language than we are interested in (in particular, they can handle recursive data types).

Ultimately, DPH did not perform all that well in practice, and it was ultimately abandoned due to lack of funding. My impression is that DPH struggled because it relied on sophisticated fusion after applying flattening in order to avoid excessive intermediate results, and this fusion optimisation never panned out. Futhark does things slightly differently, in that we aggressively perform fusion before flattening, and then try hard not to fission too much during flattening, such that the flattened code does not need any fancy optimisation (although we still perform low-level memory optimisations, of course).

DPH had some very important offshoots however, such as the array libraries Repa and vector, and the embedded language Accelerate. Accelerate in particular is important prior work for Futhark as a whole, as it demonstrated just how nice (and fast) functional array programming can be. Accelerate did not (and does not) support nested parallelism, perhaps because of its authors’ experience with DPH, and this is one reason why support for nested parallelism was such a mark of distinction for Futhark when we first released it.

Vectorization avoidance

DPH did make one very important observation that we pretty much copied wholesale for Futhark’s flattening. Vectorization avoidance is the idea that sometimes it is more efficient to not flatten something fully. As a slightly contrived demonstration, consider this program:

map (\x y -> x * 2 + y * 3 - 1)
    xs ys

This is not what the program actually looks like to Futhark, as the compiler IR uses administrative normal form, a 3AC-like design where arguments to functions (or builtin operations) must be variables or constants. The input to flattening looks more like this:

map (\x y -> let a = x * 2
             let b = y * 3
             let c = a + b
             let d = c - 1
             in d)
    xs ys

We call such let-bindings “statements”. If we aggressively fission this map in order to flatten it, we are left with this:

let as = map (\x y -> x * 2) xs ys
let bs = map (\y -> y * 3) ys
let cs = map (\a b -> a + b) as bs
let ds = map (\c -> c - 1) cs

If we turn every map into a GPU kernel that reads and writes large arrays in global memory, then we have increased overall memory traffic significantly. Blelloch’s original flattening algorithm did this - partially because it is very principled, and partially because it is amenable to machines where parallel operations can only do a single thing - in practice, NESL compiled to a portable bytecode called VCODE, which had interpreters for various vector machines of the time. (This reminds me that it would be fun to write a VCODE interpreter in Futhark.)

However, the issue occurs even without excessive fission. Consider this program:

map (\b x -> if b then x + 2 else x - 2) bs xs

Do we really want to apply the if flattening rule to this, which involves relatively expensive filtering and moving stuff around in memory? No, that would go insufficiently fast.

Inspired by DPP, our flattening transformation categorises each statement by whether it is “scalar”, meaning it exhibits no meaningful parallelism, and any arrays it uses are uniform. Uniformity here (and in the next section) means that the size of an array is invariant to the map nest in which it occurs. This is important because it means that fissioning that statement does not result in irregular intermediate arrays, and that if we want to preallocate memory for the statement, we will know its size before executing its map nest.

It may be a bit surprising that the rule is not just that something is “scalar” if it involves only primitive (non-array) types, but Futhark programs often contain sequential loops that do sequential things via in-place updates, and it makes no sense to flatten these. We even have some slightly fuzzy heuristics, because it also makes no sense to flatten something like a replicate (even though it is in principle parallel) if it is only used to construct a scratch array that is then operated on completely sequentially. This heuristic may require tweaking in the future - we basically just hammered on it until it behaved reasonably for all of the patterns and benchmarks we could think of.

Once statements have been classified, we then group the statements of a lambda by whether they are scalar, and treat sequences of scalar statements as atomic “super-statements” that are not individually fissioned off. This means we can efficiently handle a map that contains both stretches of scalar code, as well as important parallel constructs (like further nested maps) that really need to be fissioned and flattened. All this comes from the nice property that we are free to choose how much to decompose a function into constituent functions, as long as we are willing to live with the consequences of our choice.

Exploiting uniformity

So far we have mostly looked at how Futhark has replicated known approaches to flattening, with the main tweak being aggressive fusion prior to flattening, and the context of targeting GPU execution, which only cuNESL did previously. But Futhark has one very important advantage that no previous language did: size types, which are present in both the source language and the IR, and which allow program transformations to reason about the symbolic shapes of arrays.

In the case of flattening, shape information allows us to distinguish nesting from guaranteed regular to possibly irregular. As a reminder, regular nested parallelism is when the parallel dimensions are uniform in the map nest, meaning invariant to the outer parallel dimensions. When this is the case, we do not need to represent arrays using the segmented representation, but can use normal multi-dimensional arrays. Apart from saving the cost of constructing and the metadata arrays such as the flag vector, this also results in code that is much easier for subsequent compiler passes to analyse. While we have little hope for analysing the result of full flattening (which tends to just be a soup of segmented scans), the Futhark compiler has passes that are able to perform optimisations such as loop tiling on regularly flattened code, but only if it can figure out the looping structure.

The uniform case also allows for much simpler flattening rules. The general flattening rule for for-loops is very nasty, in fact so nasty that I could not bring myself to add it to this post, but the flattening rule for a uniform for-loop (where the trip count is also uniform) is quite straightforward. The program fragment

map (\xs -> loop xs' = xs for i < k do
              f xs')
    xss

flattens via loop interchange to

loop xss' = xss for i < k do
  map f xss'

but only if f is size-preserving and xss is not irregular.

The uniform flattening rule for loops is one of my favourites, because it shows that flattening can to a large extent be seen as a transformation that “pushes the maps” to the innermost level (and vectorization avoidance as a heuristic that makes it not go all the way).

Flattening guided by size information is a big win, and we were fortunate that the Futhark compiler already tracked all this information precisely. I remember hearing about other implementations of flattening that got stuck on having to come up with all this size analysis on their own, on an IR that was not designed for it. As of this writing, all Futhark benchmark programs exhibit only regular nested parallelism (because that’s all that used to work!), so handling this efficiently was critical, and I am very pleased that Amirreza (who implemented this part) managed to get it to work as well as I had hoped for. It’s quite the experience to have a speculative plan go this well.

All the other stuff from a decade of flattening programs

While our old flattener was limited, it was hardly unsophisticated. It was the core of Futhark’s compilation process, and Futhark really is already quite fast in many cases. A new implementation of flattening would have to implement enough of its tricks in order to reach the same performance on real programs.

In addition to many heuristics and optimisations, it supported a crucial feature that we call incremental flattening. The idea is that instead of always trying to exploit all parallelism, which may come with overhead, we turn the program into multiple versions, such that each level of each map nest is present in both a sequentialised and flattened version, and at run-time we pick the version that is parallel enough to saturate the hardware, but no more. This is based on the idea that parallelism beyond what the machine can exploit is simply overhead - for example, applying the costly irregular if flattening rule is often not worth the gain of parallelism. Further, sequential code is often subject to locality optimisations, such as loop tiling. Which version is “best” depends on both the program, the workload, and the hardware, and in practice must be determined through an autotuning technique discussed in this paper.

Amirreza managed to re-implement incremental flattening using the same technique (and to a degree, the same code) as in our previous work, and it generates all the same versions as before - plus a few more, as we now also treat irregular nesting as parallel (this also causes trouble, more on that below).

Incremental flattening not only discriminates based on how much parallelism is exploited, but also on where in the hardware hierarchy it is mapped to. This functionality is core to most instances where Futhark produces truly efficient code, similar to what a human expert would write. It is also part of the new flattening pass, but if you are interested read the pertinent parts of the old blog post as the mechanism is the same.

I have hopes for doing optimised flattening inside GPU thread blocks, where the impact of flattening’s less-than-ideal locality is less pronounced, but both the code and the idea will have to wait for a future blog post.

The weird performance regressions

The goal for the new flattening transformation was to be performance-neutral relative to the old one. In the future we hope to do better, but for now the goal was to do more without anything getting worse. Of course, given that we were replacing a relatively unsystematic transformation with a more principled one, I realised it was impossible for the change to have zero impact, but overall the vast majority of our benchmarks see no change in performance.

Initially we did encounter regressions in many programs. The source of this turned out to be irregular nested parallelism that the old flattener would silently treat as sequential, and where treating it as sequential really is the optimal strategy. As an example, consider the following function from the benchmark program hand.fut:

def angle_axis_to_rotation_matrix (angle_axis: [3]f64) : [3][3]f64 =
  let n = f64.sqrt (angle_axis[0] ** 2 + angle_axis[1] ** 2 + angle_axis[2] ** 2)
  in if n < 0.0001
     then identity 3
     else let x = angle_axis[0] / n
          let y = angle_axis[1] / n
          let z = angle_axis[2] / n
          let s = f64.sin n
          let c = f64.cos n
          in [ [ x * x + (1 - x * x) * c
               , x * y * (1 - c) - z * s
               , x * z * (1 - c) + y * s
               ]
             , [ x * y * (1 - c) + z * s
               , y * y + (1 - y * y) * c
               , y * z * (1 - c) - x * s
               ]
             , [ x * z * (1 - c) - y * s
               , z * y * (1 - c) + x * s
               , z * z + (1 - z * z) * c
               ]
             ]

This function is used inside of a map, and since the conditional n < 0.0001 can vary between different iterations of the map, this is a nonuniform if due to control flow, although the size of the array it produces is a uniform [3][3]. The old flattener could not handle nonuniform if in any special way and just turned this entire thing into sequential code, while the new flattener will apply fission and the if flattening rule in order to exploit the parallelism of the identity 3 function call - which constructs a 3x3 identity matrix. The amount of parallelism here is completely irrelevant compared to the bookkeeping cost of flattening if, so this caused a substantial performance regression. Incremental flattening did not save us here, because the function is used in a context where sibling code contains important parallelism, and incremental flattening is all-or-nothing at each level of the map nest.

The solution is to add a #[sequential] attribute to the if that indicates to the compiler that it should just be sequentialised. We already use this attribute in other programs to guide incremental flattening along the right paths, or just to cut down on compile time by not generating unnecessary versions. Hence, while using an attribute like this is hardly elegant, it means the new flattener does not need an entirely new workaround when it does the wrong thing - the old workarounds can still be used.

Sadly, the attribute solution only works for code the user can see. Futhark also contains a sophisticated implementation of automatic differentiation (real post yet unwritten, but see the documentation and this post). When applying automatic differentiation, the compiler generates substantial and quite complicated code, including various parallel operations, but since it is code that is not written by a human, there is no way for a human to insert attributes. This means we do have one program (hand.fut again, actually) that in its Jacobian computation has up to 2x slowdown on some (not all) workloads, because the adjoint code is overly parallelised. On the other hand, the related benchmark gmm.fut sees a 4x speedup on some Jacobian computations, due to the additional parallelisation of its adjoint code! I played a little bit with our heuristics to see if I could come up with a simple fix, but ultimately decided to leave it until we have a better idea of what the right solution is. This means that there are some programs that will change in performance once the new flattener is merged.

How it actually looks

I figured some might be curious about what the IR will actually look like post-flattening, so I will provide some examples of flattening here. These are real IR examples, so they are necessarily quite verbose. I have however removed safety checks and source provenance to make it slightly terser. Feel free to skip this section if you dislike reading long reams of tedious machine-generated code (there’ll be enough of that in the future, it seems).

Consider this (source) function definition:

def f [m] (ns: [m]i64) =
  map (\n -> i64.sum (iota n)) ns

This function contains irregular nested parallelism, as the size n is nonuniform in the map nest. After flattening it will look like this:

fun
  f_7412 (m_7572 : i64,
          ns_7573 : [m_7572]i64)
  : {*[m_7572]i64} = {
  let {m_7777 : i64,
       segiota_flags_7778 : [m_7777]bool,
       segiota_offsets_7779 : [m_7572]i64,
       segiota_elems_7780 : [m_7777]i64} =
    apply builtin/segiota(m_7572, ns_7573)
    : {i64, *[?0]bool, *[m_7572]i64, *[?0]i64}
  let {redomap_7790 : [m_7777]i64} =
    segscan(thread; ; )
    (gtid_7791 < m_7777) (~phys_tid_7792)
    : {bool, i64} {
      let {flag_7793 : bool} =
        segiota_flags_7778[gtid_7791]
      let {x_7794 : i64} =
        segiota_elems_7780[gtid_7791]
      return {returns flag_7793,
              returns x_7794}
    }
    ({false, 0i64},
    ,
    \ {x_flag_7795 : bool,
       eta_p_7796 : i64,
       y_flag_7797 : bool,
       eta_p_7798 : i64}
      : {bool,
         i64} ->
      let {x_7799 : bool} =
        logor(x_flag_7795, y_flag_7797)
      let {x_7800 : i64} =
        if y_flag_7797
        then {eta_p_7798} else {
          let {+_res_7801 : i64} =
            add64(eta_p_7796, eta_p_7798)
          in {+_res_7801}
        }
        : {i64}
      in {x_7799, x_7800})
    \ {seg_flag_7802 : bool,
       x_7803 : i64}
      : {i64} ->
      {x_7803}
  let {segred_7808 : [m_7572]i64} =
    segmap(thread; virtualise; )
    (gtid_7809 < m_7572) (~phys_tid_7810) : {i64} {
      let {n_7811 : i64} =
        ns_7573[gtid_7809]
      let {cond_7813 : bool} =
        eq_i64(n_7811, 0i64)
      let {segment_res_7814 : i64} =
        if cond_7813
        then {0i64} else {
          let {offset_7812 : i64} =
            segiota_offsets_7779[gtid_7809]
          let {binop_x_7816 : i64} =
            add_nw64(n_7811, offset_7812)
          let {i_7817 : i64} =
            sub_nw64(binop_x_7816, 1i64)
          let {x_7818 : i64} =
            redomap_7790[i_7817]
          in {x_7818}
        }
        : {i64}
      return {returns segment_res_7814}
    }
  in {segred_7808}
}

Instead of using map and scan, the IR has dedicated constructs such as segscan and segmap, that can represent multidimensional regular segmented operations. Since this program is irregular, all of these are one-dimensional ((gtid_7809 < m_7572)), and encode the structure using flag arrays. The program first does a segmented iota through a call to a builtin function builtin/segiota, then performs a segmented scan, and finally extracts the last element of each segment. Note that builtin/segiota returns not just the data and flag vectors of the segmented representation, but also an offset vector that denotes the start offset of each segment. This is used in the final segmap. Generally the builtin irregular segmented operations return more than the minimum auxiliary information, because the additional arrays often turn out to be necessary, and usually have to be computed internally anyway.

The future

The new flattener is not merged yet, but it will be soon. The only work that remains to be done is general simplification and addressing various TODO comments - the performance fluctuations discussed above will be left for the future.

In NESL (and DPH), flattening was used to support irregular arrays as a programming construct in the source language. In Futhark, our goals are more modest: we want to support irregular nested parallelism, but we have no plans for adding irregular arrays to the source language or to the IR. Flattening handles irregular intermediate arrays using a bespoke representation that is internal to the flattening pass itself. I am not opposed to irregular arrays in principle, if they can be handled efficiently, but many important Futhark features such as size types and our FFI might get a lot more complicated if we added them. I prefer solving small problems well than big problems poorly, and it has already taken years to solve the problems that arise from Futhark’s fairly small design - and we’re not done yet. I’m not very eager to go looking for even larger problems.

That said, I do think we can expose flattening as a programming construct in some way, which will allow for new ways of writing data parallel programs, in particular if we also allow recursion. The main reason for not allowing recursion so far was that we did not have a compilation strategy for recursive functions on GPUs, but the flattening transformation will now move the recursion to the CPU, so this argument does not hold anymore. That, however, is a topic for a future post - this one got long enough.