Parallel Map in Elixir
selectedintelligence.com
selectedintelligence.com
Another great thing about Elixir is the community -- it is helpful, friendly, oriented for learning and newcomers. Also Jose Valim is really an incredible guy.
It also helps that Elixir came about right around the time when folks were starting to realize that this old telecom language called "Erlang" might actually be precisely what we need for modern web development, hence why web frameworks ended up evolving pretty quickly relative to Erlang (which also saw a growth in available web frameworks at around the same time; Chicago Boss is a particularly-excellent example).
It feels noisier in general and as if I lose some of the homoiconic feeling I get from Erlang where matching on literal data types is essential and can lead to very concise code.
I know that a lot of people find Erlang's syntax to be a pain point (I don't, I actually like it), but as a unique language it helps to have a fitting syntax that's easy on the mind for context switching to and from. And I'd say it's quite perfectly optimized for a language whose backbone is terms, processes and pattern matching.
It's also far less learning than most other functional languages, quite frankly. It probably has a lot to do with its culture that sees the FP aspect as a tool that fits into the paradigm of solving engineering problems related to high availability distributed systems, rather than FP being the end itself, although we need both kinds of languages.
But hey, anything to get BEAM into the spotlight is probably a good thing. I'd just prefer if it was pure Erlang.
The type system is pretty recognizable to most people, unlike something like Haskell where it's a big part of learning the language.
parallel_map(Fun, List) ->
Last = lists:foldl(fun(Value, Parent) ->
spawn(fun() ->
MappedValue = Fun(Value),
receive
Rest -> Parent ! [MappedValue|Rest]
end
end)
end, self(), List),
Last ! [],
receive
Result -> Result
end.
It essentially sets up a chain of processes which pass their results to their parent when they finish their mapping operation.So instead of copying 2n elements over the processes, you'll be copying roughly 2nlogn (or is it n(n+1)/2 ?) elements around.
The solution to this problem is just to store messages as they are received inside of a map data structure, so that there is no overhead for receives. This requires indexing the list which makes the code a lot more inelegant.
Edit: Given n processors and an input of size n, I believe the time complexities are: Two map solution: O(n^2)+O(f) Fold solution: O(n^2)+O(f) Two map with map data structure: O(n)+O(f)
Where O(f) is the asymptotic upper bound of the function being mapped
Edit 2: This has me thinking about what will be the most efficient way to assign an element from the list to worker processes. In Erlang, it's clear that there isn't a way to avoid the O(n) overhead since the original process must reconstruct the list in order using cons.
In an imperative language this isn't necessary. Perhaps the original process can recursively assign indicies by spawning two children worker processes which are given a range of indicies to work on (who then create their own assignment processes). I believe the overhead is then only O(log n)...
The only O(n) operations are, yes, a completely degenerate case of when messages get sent back (which is -incredibly- unlikely; with Erlang's task scheduling you're likely only going to ever have a max message queue length of a few items, so it's more likely a constant factor. To get the degenerate case you would need it to finish in -reverse- list order, that is, would need to finish with the last one first, then the next to last one, etc), and when reversing the list(s) built up from the map at the end (as under the covers I'm pretty sure map is written to be tail recursive), which while technically O(n), is still incredibly fast.
Hitting the degenerate case depends on the function you're computing in question. It's quite possible that the tasks will complete in the given order. I think you're giving too much credit to Erlang's task scheduler.
Also I'm not sure how one would even implement a tail recursive map function on a singly linked list. The cons operation can only add elements to the front of the list. I looked up how the map operation is implemented in Erlang. It isn't tail recursive:
https://github.com/erlang/otp/blob/172e812c491680fbb175f56f7...
I'm interested to know how you'd implement a tail-recursive version of map (continuations aren't allowed).
I'm not saying the task scheduler is perfect, but I'd be really, really weirded out if it gave priority to the final process spawned, and worked its way backwards, which would be necessary for the degenerate case (that is, we spawned off items 1,2,3,4 in that order, but they completed 4,3,2,1. I would expect them to finish in close to 1,2,3,4 order, which would leave it at O(1) on each receive).
I'd implement a tail recursive map as -
map(F, L) -> map(F, L, []).
map(_, [], Acc) -> lists:reverse(Acc);
map(F, [H|T], Acc) -> map(F, T, [F(H) | Acc]). pmap f xs = map f xs `using` parList rdeepseq
The cool thing about this is that `map` is just the regular map function. However, as Haskell is lazy, `map f xs` returns a thunk and is not immediately evaluated. `using` allows you to evaluate the resultant thunk using any "evaluation strategy", and `parList rdeepseq` describes a strategy for evaluating lists in parallel!Also, like the Erlang VM, GHC has fast, lightweight "green threads".
Edit: Another way to write it would be
pmap _ [] = []
pmap f (x:xs) = runEval $ do
x' <- rpar (f x)
xs' <- rpar (pmap f xs)
return $ (x':xs') receive do { ^pid, result } -> result end
Without looking up what the "receive" procedure does, it suggests that the current ("main"?) process is going to block waiting for an incoming message that matches the pattern. Done in a loop as I presume the Enum.map function does, it looks like the main process might be blocked waiting for the matching message while some internal mailbox fills up. In the worst case I would expect this to have an O(n^2) running time unless there is something fancy going on when pattern matching against a full mailbox.Please correct me if I'm wrong. This looks elegant, but I would expect it to be considerably less performant than, say, the equivalent Go program which would issue a select on a channel of incoming messages, thus only blocking until any message is received, rather than a specific message in series.
From the horse's mouth:
"Each process has its own input queue for messages it receives. New messages received are put at the end of the queue. When a process executes a receive, the first message in the queue is matched against the first pattern in the receive, if this matches, the message is removed from the queue and the actions corresponding to the the pattern are executed.
However, if the first pattern does not match, the second pattern is tested, if this matches the message is removed from the queue and the actions corresponding to the second pattern are executed. If the second pattern does not match the third is tried and so on until there are no more pattern to test. If there are no more patterns to test, the first message is kept in the queue and we try the second message instead. If this matches any pattern, the appropriate actions are executed and the second message is removed from the queue (keeping the first message and any other messages in the queue). If the second message does not match we try the third message and so on until we reach the end of the queue. If we reach the end of the queue, the process blocks (stops execution) and waits until a new message is received and this procedure is repeated."
The Erlang VM accomplishes this by providing computation credits to each process, which are spent to perform actions ensuring the complete, non-blocking, parallel system that guzzles information at a startling rate all over the globe as we type this :)
This means you get results filling up the array in the order they come rather than the original order, but if order's important, you can always pass around an index and sort by that after the fact.
http://erlang.org/doc/man/rpc.html#pmap-3
So you could call that from Elixir if you wanted.
Re ugly: well, eye of the beholder I suppose.
def square(float x) float : x*x def main(array<float, 256> a) array<float, 256> : map(square, a)
sub pmap(Code $func, @items) {
return await @items.map: {
start { $func($_) }
}
}Edit: Geez, I even did a refresh check before clicking submit. I'm having the worst luck with that lately.