Composable multi-threaded parallelism in Julia
julialang.org
julialang.org
The only systems which have supported compute-oriented composable parallelism like this are Cilk and TBB, which are both really excellent and successful—and provided major inspiration for this work. However, they both seem to have fallen short of widespread adoption partly due to being non-free (until recently) and also, I suspect, because they are non-standard C/C++ language/compiler extensions, which seem to never catch on that broadly.
This is a brave new world for high-performance technical computing, not just for Julia.
fib :: Int -> Int
fib n
| n <= 1 = 1
| otherwise = x ‘par‘ (y ‘pseq‘ x + y + 1)
where
x = fib (n-1)
y = fib (n-2)
[0] https://stackoverflow.com/questions/958449/what-is-a-spark-i...[1] http://simonmar.github.io/bib/papers/strategies.pdf
[2] http://hackage.haskell.org/package/parallel-3.2.2.0/docs/Con...
sub fib(Int $n) {
return $n if $n < 2;
my $t = start { fib($n - 2) }
fib($n - 1) + await($t);
}If a programming language is created in 2019 they can certainly instantly incorporate insights from existing technology in their design an plans. However, that doesn't mean that any programming language/compiler project started today will instantly have all of those features. Things take time in each new framework.
Haskell didn’t have it originally. Ok. But what do you think that says about whether it’s a new idea or not? What’s the connection? Why mention it in this thread about whether it's a new idea or not?
> The only systems which have supported compute-oriented composable parallelism like this are Cilk and TBB
Because it isn’t true. Haskell is an example of why it isn’t true.
Parallelism is always for CPU throughput, precisely why you don't run any more OS threads than the number of cores.
Concurrency primitives (whether in Go, Erlang or any other programming language) are meant for I/O-bound tasks. Let's not conflate concurrency primitives enabling I/O-bound tasks and parallelism primitives enabling CPU-bound tasks.
It’s often worth running more than your number of cores, in order to keep execution units busy during memory fetches for example, or integer units busy while float units are a bottleneck otherwise. (Did a PhD partially on this kind of thing.)
Hyperthreading and out of order execution are meant to accomplish keeping the execution units busy.
Now NodeJS or Python Asio style concurrency _are_ truly limited by their VMs and can’t do native parallel computations in a single process.
F# also has facilities for this, depending on what you're after. The _MailboxProcessor_ type, though not as "for real" as the green threading in Go, allows for some pretty high throughput with a relatively sane message-passing abstraction.
And .NET itself offers many means to do parallel programming (which can get quite optimized), of which F# has some nice abstractions for.
So I don't think it's quite fair to say that only Go and Erlang have good support for parallelism. I also don't think they use their abstractions typically for parallelism (but I good be wrong).
Related work in Common Lisp:
https://github.com/pcostanza/claws/
https://european-lisp-symposium.org/static/2014/costanza.pdf
Large swathes of the physics community have been migrating their code to Python over the past few years but it really seems now that Julia is getting so many amazing features and packages that it can't be ignored.
Threading is something of a prerequisite for acceptance as a serious language among many folks. So great to not just check that box, but to stick the pen right through it.
The devil is always in the details, but from the doc the interface looks pretty nice (given threaded code generally looks like it was written by Cthulhu).
Whereas in Julia, we use Cthulhu to read code ;) - https://github.com/JuliaDebug/Cthulhu.jl.
OpenMP, however, depends on not just compiler extensions, but pragmas, so it tends to not find traction outside of the HPC world.
I think it has been highly influential though.
What? Lots of parallelism doesn't use message passing!
Hence, in order for that up-front cost to be worth it, the algorithms run on the GPU need to re-use that data many times. If the algorithms running on the GPU don't make extensive reuse of the data sent to it, it would be faster to just do the calculation on the host CPU.
It's gone through several design patterns from the original APM (async programming model) to EAP (event based pattern) to TPL (task parallel library) to the all purpose Task/ValueTask embedded into the language. It's very fast and efficient, and you can build your own scheduler too. Combined with hardware intrinsics in the latest .NET Core, it's very good for computational and server work.
I'm surprised Julia considers this such a big update but it does seem to be an overstatement to say this changes technical computing.
What is 100% new and does not exist in any other system is that the task scheduling is depth first (rather than breadth first), which is a much better order for doing compute-heavy workloads. This ability is new and was the culmination of an Intel research project, applied to Julia.
Proof of optimality was in the early cilk papers in the '90s.
I'm sure the same algorithms made into Julia via Intel (which bought cilk in the early '00), but it is hardly new except in the 'everything old is new again'.
Also IIRC OpenMP supports (or at least allows) work stealing although at least GCC does not implement it yet.
https://www.cs.cmu.edu/%7Echensm/papers/ConstructiveCacheSha...
See slide 8 here: https://www.microsoft.com/en-us/research/wp-content/uploads/...
Later versions of Blelloch's language are referred to in other parallel Haskell papers.
FWIW I think this is nonetheless fantastic news for Julia! It's certainly poised to do great work in areas that I doubt will get any Haskell traction anytime soon (if ever) for good reasons.
EDIT: Interesting, I think Julia is taking a coarser grained approach here that breaks down a bit for irregularly balanced execution trees, but relies less on fusion (i.e. doesn't use fusion at all) which is nice. I'm curious to see how this particular implementation of the "depth first" idea goes!
EDIT EDIT: Actually I'm not sure how much fusion is necessary for GHC's approach... Time to investigate more closely. Also call out to anyone more familiar with Data Parallel Haskell to chime in. I'm getting out of my depth.
> "async/await in C#, which is concurrent but not parallel"
C# is multithreaded. Things can only be parallel if you have 2 separate paths of execution that don't depend on each other or any serial order, but you can also kick off millions of individual tasks in a single function and await them all too. These tasks are all scheduled onto threads in a managed threadpool that also share work and run at the same time. I fail to see how this is any different or less capable than the others you mentioned.
https://stackoverflow.com/questions/37419572/if-async-await-...
https://blog.stephencleary.com/2013/11/there-is-no-thread.ht...
https://blogs.msdn.microsoft.com/benwilli/2015/09/10/tasks-a...
I said that C# also uses Tasks which are scheduled onto these threads, which is what you said Julia does, so it's the same. C# can also start new threads just as easily, or use channels like Go with the TPL/Dataflow, System.Threading.Channels, actor frameworks, Reactive Extensions, or just chaining tasks together. Other than the depth-first algorithm, it seems like C# has the same capabilities as Julia and the others, if not more, and is much more "mainstream".
Can you explain how blocking functions are automatically yielded without keywords though? I cant find any documentation on this that doesnt mention the @async macro.
Tasks are really meant for responsive computing, not proactive computing (yes, even TPL and Dataflow), and thus I don't think they are well suited to parallelized computation in areas like e.g. simulation.
C# code that uses Tasks and C# programs built on Dataflow are very different. The latter benefits you most when you commit entirely to that paradigm.
If you want to get more low-level, low-overhead (for i.e. simulations), the same thing applies to things like Threading Building Blocks[1] in C++. I'd even argue that TBB is one of the best applications of this theory, going so far as to ship with dedicated tooling for the whole lifecycle of designing to implementing to profiling task- and graph-parallel programs [2]. Furthermore, it works well with other HPC libraries. Here [3] is an example of mixing it with OpenMP.
In summary, both Dataflow and TBB (et al) can readily be used to create CPU-intensive, proactive computing pipelines today. Even though they're "just" fancy Task schedulers, which are in turn "just" fancy thread multiplexers.
[1] - https://www.threadingbuildingblocks.org/tutorial-intel-tbb-f...
This trait of tasks makes sense when writing things like web servers because it prevents developers from shooting themselves in the foot by writing something that isn't stateless.
By proactive computing, I mean some program that always has control of the entire execution graph. For example, a simulation may initialize a bunch of datastructures, separate some of them into threads, run the threads, wait for all of them to complete x ticks, do some synchronization or measurement of the total system, then continue y times, and exit.
Fair or not, Julia does seem to have the potential to be the first really interesting shift in this area in ages. Doesn’t mean it will get there, but there is interest.
What do you mean by impact/shift exactly? I guess I'm not understanding what's suddenly going to change now since, as you say, these ideas are old and implemented several times before.
Julia, for good or ill, seems to have some potential to be.
Anyway, if by data parallelism you mean simd, then that is already available and widely used in Julia. Multithreading comes on top of that.
This however offers a lot more configurability than Cilk (or at least more accessible configuration). Whether that’s a curse or blessing... I guess we’ll find out! :)
Also, have you looked into implementing Cilk-style reducers at all? [0]
[0]: https://www.cilkplus.org/docs/doxygen/include-dir/group___re...
It is easy to forget to wait on a task, or even lose reference to it. The current scheduler will typically execute this task quickly, even if nobody ever waits on it; until load changes and you have races everywhere. There are currently no warnings comparable to Python's "RuntimeWarning: coroutine foo was never awaited".
Rust, while perhaps not as mainstream and not necessarily targeting scientific computing users, has data-bound parallelization like this via the rayon library.
I think there are a few more of those languages. AFAIK Clojure and Rust should be added to the list.
Concurrency? Go is not good at parallelism, compared to, say, Haskell.
We are using this in production for now over 1 year, and it helps tremendously. We are currently only supporting a ruby client, but other languages should be more or less easy to bring into the mix. A wider release is planned for later this year - feel free to ask me anything via email!
The only type of multithreading in Matlab is the type that is built into the core language, and you don't get any control over that.
Finally, the claim to originality here isn't just the introduction of multithreading (which isn't even new in Julia!), but the particular threading model and scheduler, which lends itself particularly well to composable multithreading.
> The model is portable and free from low-level details. You don’t need to explicitly start and stop threads, and you don’t even need to know how many processors or threads there are (though you can find out if you want).
> The model is nestable and composable: you can start parallel tasks that call library functions that themselves start parallel tasks, and everything works. Your CPUs will not be over-subscribed with threads.
It means that you can put threaded parallelism inside libraries without worrying. It means that Julia itself will start making its builtins threaded. It means that Julia itself is much more threadsafe by default. It means that packages can implement threaded algorithms — and you can use multiple threaded packages together at the same time without worrying. And it means that you can put threaded parallelism in your application code and all these things will work together beautifully.
julia> xs = [1]
1-element Array{Int64,1}:
1
julia> for j in 1:1024
d = @spawn xs[1] += 1
end
julia> xs
1-element Array{Int64,1}:
686They aren't as big as Python because Python has been around for nearly 30 years. Julia is just a baby compared to that. As it grows it will increase it's share. It may not ever surpass Python in terms of raw users, because Julia is focused on numerical computing, but Python is more general purpose.
That isn't the case. Julia's primary focus is scientific/numeric, but it's also general purpose. Take a look at Julia Robotics, for instance:
You could call any Turing-complete language with I/O a general purpose language, but some are clearly designed for certain tasks.
Same as J
> Tech is full of inertia, paradoxically.
I don't think it's paradoxical. Novelty is essential in academia, but that is not the case in industry.
2. Over concentration of the main contributors within the same startup that has a questionable business model and long-term prospects
3. Language issues that limit the desirability of Julia for use in larger projects
Depending on what you're doing, Python is usually great at performance where the time it saves the user writing code is more valuable than the performance itself (a lot of exploratory scientific research).
I can tell you that programmer productivity can plummet drastically when you need to get everything into vectorized form.
Also, custom classes and types do not vectorize well at all.
- as a substitute to something as low-level as numpy it is superb
- for anything dealing with differential equations, the ecosystem is unsurpassed
- plotting is a bit chaotic (and the community has not settled on a "one true way to plot")
- a good dataframe implementation already exists
- serialization is a bit chaotic (plenty of ways to do it but no "one true way" yet)
- automatic differentiation is on the cusp of being unsurpassed, but a lot of the tools are still experimental (and the community still has not settled on the best way forward)
- the Juno IDE looks amazing
- the profiling toolkit is amazing
- it is ridiculously easy to use other languages from within Julia
On the otherhand our expectations are huge - I remember early Java and the level of support that you get with Julia now really took a very long time to appear for Java, and that was backed by SUN.
If I were an Intel exec I'd be putting money into this though; I can see it gradually crushing the life out of nVidia and AMD, although I think that there will be a few smiles at ARM towers when they look at this too! I wonder what people could do with 2000 ARM cores in a rack?
- Good integrated test system (and test culture in the community)
- Super good package/environment manager
- Great integrated documentation system
- Good version control practices in the community
- Awesome community
At any stage you can call @code_{warntype, lowered,...} and inspect exactly what code has been produced by the compiler to find bottlenecks.
I’ve not written much R before but in my experience I’ve never had to go to those lengths to debug Julia code! (Although the error messages can sometimes be a bit of a mouthful, I think because it uses LLVM)
https://juliaobserver.com/packages
https://pkg.julialang.org/docs/
As a personal answer, I really like unitful for keeping track of physical units and libraries like MLStyle.jl or Match.jl which add stuff like pattern matching to the language (though they are more general helper libraries compared to stuff like Turing.jl and DifferentialEquations.jl):
https://painterqubits.github.io/Unitful.jl/stable/highlights...
I found out myself: Julia can mutate a single temp buffer across all threads, and implementing Sedgewicks nocopy sort (with ping-pong merge) is faster, particularly when passing down preallocated buffer memory to the single threaded sorting Routine once N <= BIG_THRESHOLD. The following are measurements run with JULIA_NUM_THREADS=4 on a Intel(R) Core(TM) i5-4300M CPU @ 2.60GHz, 2594 MHz, 2 physical cores, 4 logical cores, replication code follows. Note that not only runtime of nocopy mergesort ist better, it also causes less garbage collection time. Conclusion: it would make sense for Julia's sorting interface to allow passing in preallocated buffer memory and to use nocopy mergesort behind the curtain. Of course true parallel sorting also requires a parallel merge.
Best
julia> # 4 threads, mergesort, dynamic allocation
julia> @time for i=1:100 c = copy(a); psort!(c); end;
166.270792 seconds (756.58 k allocations: 89.470 GiB, 15.34% gc time)
julia> # 4 threads, mergesort, preallocation per thread, re-allocation in single threaded subroutine
julia> @time for i=1:100 c = copy(a); tsort!(c); end;
143.973485 seconds (658.00 k allocations: 36.972 GiB, 5.89% gc time)
julia> # 4 threads, nocopy mergesort, one preallocation, re-allocation in single threaded subroutine
julia> @time for i=1:100 c = copy(a); ksort!(c); end;
129.715467 seconds (654.51 k allocations: 37.312 GiB, 6.43% gc time)
julia> # 4 threads, nocopy mergesort, one preallocation reused in single threaded subroutine
julia> @time for i=1:100 c = copy(a); jsort!(c); end;
119.139551 seconds (449.91 k allocations: 29.846 GiB, 5.14% gc time)
import Base.Threads.@spawn
const SMALL_THRESHOLD = 2^6
const BIG_THRESHOLD = 2^16
# insertionsort
function isort!(v, lo::Int=1, hi::Int=length(v))
@inbounds for i = lo+1:hi
j = i
x = v[i]
while j > lo
if x < v[j-1]
v[j] = v[j-1]
j -= 1
continue
end
break
end
v[j] = x
end
return v
end
# single threaded merge (from t to v)
function nmerge!(v, lo::Int, mid::Int, hi::Int, t)
i, k, j = lo, lo, mid+1
@inbounds while i <= mid && j <= hi
if t[j] < t[i]
v[k] = t[j]
j += 1
else
v[k] = t[i]
i += 1
end
k += 1
end
@inbounds while i <= mid
v[k] = t[i]
k += 1
i += 1
end
@inbounds while j <= hi
v[k] = t[j]
k += 1
j += 1
end
return v
end
# single threaded nocopy mergesort, one preallocation
function nsort!(v, lo::Int, hi::Int, t)
@inbounds if hi-lo <= SMALL_THRESHOLD
return isort!(v, lo, hi)
end
mid = (lo+hi)>>>1
nsort!(t, lo, mid, v)
nsort!(t, mid+1, hi, v)
nmerge!(v, lo, mid, hi, t)
return v
end
function nsort!(v, lo::Int=1, hi::Int=length(v))
t = copy(v)
nsort!(v,lo,hi,t)
return v
end
# multithreaded mergesort, dynamic allocation
function psort!(v, lo::Int=1, hi::Int=length(v))
@inbounds if hi-lo <= BIG_THRESHOLD
sort!(view(v, lo:hi), alg = MergeSort)
return v
end
mid = (lo+hi)>>>1 # find the midpoint
half = @spawn psort!(v, lo, mid) # task to sort the lower half; will run
psort!(v, mid+1, hi) # in parallel with the current call sorting
# the upper half
wait(half) # wait for the lower half to finish
temp = v[lo:mid] # workspace for merging
i, k, j = 1, lo, mid+1 # merge the two sorted sub-arrays
@inbounds while k < j <= hi
if v[j] < temp[i]
v[k] = v[j]
j += 1
else
v[k] = temp[i]
i += 1
end
k += 1
end
@inbounds while k < j
v[k] = temp[i]
k += 1
i += 1
end
return v
end
# mergesort, preallocation per thread, re-allocation in single threaded subroutine
function tsort!(v, lo::Int=1, hi::Int=length(v), temps=[similar(v, 0) for i = 1:Threads.nthreads()])
if hi - lo < BIG_THRESHOLD # below some cutoff, run in serial
sort!(view(v, lo:hi), alg = MergeSort)
return v
end
mid = (lo+hi)>>>1 # find the midpoint
half = @spawn tsort!(v, lo, mid, temps) # task to sort the lower half; will run
tsort!(v, mid+1, hi, temps) # in parallel with the current call sorting
# the upper half
wait(half) # wait for the lower half to finish
temp = temps[Threads.threadid()] # workspace for merging
length(temp) < mid-lo+1 && resize!(temp, mid-lo+1)
copyto!(temp, 1, v, lo, mid-lo+1)
i, k, j = 1, lo, mid+1 # merge the two sorted sub-arrays
@inbounds while k < j <= hi
if v[j] < temp[i]
v[k] = v[j]
j += 1
else
v[k] = temp[i]
i += 1
end
k += 1
end
@inbounds while k < j
v[k] = temp[i]
k += 1
i += 1
end
return v
end
# multithreaded nocopy mergesort, one preallocation, re-allocation in single threaded subroutine
function ksort!(v, lo::Int, hi::Int, t)
if hi - lo < BIG_THRESHOLD # below some cutoff, run in serial
sort!(view(v, lo:hi), alg = MergeSort)
return v
end
mid = (lo+hi)>>>1 # find the midpoint
half = @spawn ksort!(t, lo, mid, v) # task to sort the lower half; will run
ksort!(t, mid+1, hi, v) # in parallel with the current call sorting
wait(half) # wait for the lower half to finish
nmerge!(v, lo, mid, hi, t)
return v
end
function ksort!(v, lo::Int=1, hi::Int=length(v))
t = copy(v)
ksort!(v,lo,hi,t)
return v
end
# multithreaded nocopy mergesort, one preallocation reused in single threaded subroutine
function jsort!(v, lo::Int, hi::Int, t)
if hi - lo < BIG_THRESHOLD # below some cutoff, run in serial
return nsort!(v, lo, hi, t)
end
mid = (lo+hi)>>>1 # find the midpoint
half = @spawn jsort!(t, lo, mid, v) # task to sort the lower half; will run
jsort!(t, mid+1, hi, v) # in parallel with the current call sorting
wait(half) # wait for the lower half to finish
nmerge!(v, lo, mid, hi, t)
return v
end
function jsort!(v, lo::Int=1, hi::Int=length(v))
t = copy(v)
jsort!(v,lo,hi,t)
return v
end
a = rand(2000);
b = copy(a); @time sort!(b, alg = MergeSort); # reference
c = copy(a); @time isort!(c); # insertionsort
all(b==c) # regression test
a = rand(20000000);
b = copy(a); @time sort!(b, alg = MergeSort); # single-threaded system mergesort used by psort and tsort
c = copy(a); @time nsort!(c); # single threaded nocopy mergesort, one preallocation
all(b==c) # regression test
c = copy(a); @time psort!(c); # 4 threads, mergesort, dynamic allocation
all(b==c) # regression test
c = copy(a); @time tsort!(c); # 4 threads, mergesort, preallocation per thread, re-allocation in single threaded subroutine
all(b==c) # regression test
c = copy(a); @time ksort!(c); # 4 threads, nocopy mergesort, one preallocation, re-allocation in single threaded subroutine
all(b==c) # regression test
c = copy(a); @time jsort!(c); # 4 threads, nocopy mergesort, one preallocation reused in single threaded subroutine
all(b==c) # regression test
# 4 threads, mergesort, dynamic allocation
@time for i=1:100 c = copy(a); psort!(c); end;
# 4 threads, mergesort, preallocation per thread, re-allocation in single threaded subroutine
@time for i=1:100 c = copy(a); tsort!(c); end;
# 4 threads, nocopy mergesort, one preallocation, re-allocation in single threaded subroutine
@time for i=1:100 c = copy(a); ksort!(c); end;
# 4 threads, nocopy mergesort, one preallocation reused in single threaded subroutine
@time for i=1:100 c = copy(a); jsort!(c); end;There's probably going to be a lot of development of parallelized code going forward. Maybe you could help? :)
[0] https://julialang.org/downloads/ [1] https://julialang.org/downloads/nightlies.html