-
-
Notifications
You must be signed in to change notification settings - Fork 5.5k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
RFC: Experimental API for may-happen in parallel parallelism #39773
base: master
Are you sure you want to change the base?
Conversation
Co-authored-by: Valentin Churavy <vchuravy@mit.edu>
Can IO be used inside of a Tapir task? That seems like something that requires a few more assumptions because the buffer is shared? |
I think I/O like reading or writing files from "normal" file system should be fine, unless you are doing something tricky. It's equivalent to the first example in "Composability with existing task system" section where something outside the Tapir tasks eventually guarantees the forward progress. But, it can introduce a deadlock if you do something tricky like using pipe to communicate between Tapir tasks. |
I haven't reviewed this in detail, but I'm wondering whether the frontend (i.e. whatever macro is used) should generate OpaqueClosures directly. Generating them late definitely needs to be supported, but being closer to the frontend is definitely easier. Is there something here that would prevent that from working? |
Ah, that's an interesting idea that I've never thought of! Yeah, I think it might make it easy to figure out input and output variables for tasks. One thing I'd worry about is the quality of type inference. I guess there is a lot of possible improvements over normal closure but I wonder what about the code without opaque closure? That is to say, what's the effect of replacing $code with f = @opaque () -> $code
f() in the inference, in particular, when Also, I wonder if allowing opaque closure to capture slots during inference and optimization helps this. (These slots are replaced with Ref-like objects just before LLVM passes, to avoid capturing any stack pointers.) Edit: I think we still need to inline all opaque closures, at the beginning of the optimizations, to enable all the optimizers (unless the quality of optimization across opaque closure boundaries becomes indistinguishable from the code without opaque closures). But maybe some analysis before the optimization is easier this way. |
@tkf and others I think Base.Experimental.Tapir is a good name. If not maybe |
Since optimized code is cached and used for inlining, we need to lower Tapir after optimized code is cached, to enable Tapir for IPO (i.e., we want to cache the optimized code that still has `detach` etc.). This patch moves Tapir lowering out of the standard `run_passes` optimization phase, to avoid caching the code that includes lowered code. Instead, lowering happens inside of `jl_emit_code` just before emitting LLVM IR.
After experimenting with this patch, I found that pure-Julia Tapir has an interesting interaction with the inlining pass. Since Julia caches optimized code and use it for inlining, lowering ("targetting") Tapir to the concrete calls of the parallel runtime in the optimizer negates the benefits of Tapir for IPO. Ideally, Julia compiler can see Tapir instructions in the inlined code. I tried to address this in commit 0cff766 by moving the call to Tapir lowering to |
Discussed in multithreading BoF. (1) Managing task outputs by assigning to local variables is hard to use. It would be better to have a future-like object that can be fetched/dereferenced after sync. (2) We can merge normal |
This patch is for preparing for the relaxed priority assignment patch which complicates `@sync` macro a bit and breaks this test.
The relaxed priority assignment patch broke the optimizer test with `TaskGroupOptimizations.nested_syncs`. It is presumably due to the new `finally` block in `@sync` to wrap `@sync_end`.
Co-authored-by: Shuhei Kadowaki <aviatesk@gmail.com>
Co-authored-by: Shuhei Kadowaki <aviatesk@gmail.com>
Co-authored-by: Shuhei Kadowaki <aviatesk@gmail.com>
tl;dr How about adding optimizable task parallel API to Julia?
Introduction
How to teach parallelism to the Julia compiler
(If you've already seen @vchuravy's PR #31086, maybe this part is redundant.)
How we currently implement the task parallel API in Julia introduces a couple of obstacles for supporting high-performance parallel programs. In particular, the compiler cannot analyze and optimize the child tasks in the context of the surrounding code. This limits the benefit parallel programs can obtain from existing analysis and optimizations like type inference and constant propagations. Furthermore, the notion of tasks in Julia supports complex concurrent communication mechanisms that imposes a hard limitation for the scheduler to implement an efficient scheduling strategy.
Tapir (Schardl et al., 2019) is a parallel IR that can be added to existing IR in the SSA form. They demonstrated that Tapir can be added to LLVM (aka Tapir/LLVM; a part of OpenCilk) relatively "easily" and existing programs written in Cilk can benefit from pre-existing optimizations such as loop invariant code motion (LICM), common sub-expression elimination (CSE), and others that were already written for serial programs. In principle, a similar strategy should work for any existing compiler with SSA IR developed for serial programs, including the Julia compiler. That is to say, with Tapir in the Julia IR, we should be able to unlock the optimizations in Julia for parallel programs.
Tapir enables the parallelism in the compiler by limiting its focus to parallel programs with the serial-projection property (@vchuravy and I call this type of parallelism the may-happen in parallel parallelism for clarity). Although this type of parallel programs cannot use unconstrained concurrency communication primitives (e.g.,
take!(channel)
), it can be used for a vast majority of the parallel programs that are compute-intensive; i.e., the type of programs for which Julia is already optimized/targeting. Furthermore, having a natural way to express this type of computation can be beneficial not only for the compiler but also for the scheduler.A strategy for optimizable task parallel API
This PR implements Tapir in the Julia compiler, in Julia. I've been working with @vchuravy and TB Schardl (@neboat) to extend and complete @vchuravy's PR #31086 that uses OpenCilk (which includes a fork of LLVM and clang) for supporting Tapir at LLVM level (Tapir/LLVM). This project still has to solve many obstacles since extracting out Julia tasks at a late stage of LLVM pass is very hard (you can see my VERY work-in-progress fork at https://github.com/cesmix-mit/julia). We observed that many benefits of Tapir can actually be realized at the level of Julia's compilation passes (see below). For example, type inference and constant propagation implemented in the Julia compiler can penetrate through
@spawn
code blocks with Tapir. So, I implemented Tapir in pure Julia and made it work without the dependency on OpenCilk. Since this can be done without taking on a dependency on a non-standard extension to the LLVM compilation pipeline, we think this is a good sweet spot for start adding parallelism support to Julia in a way fully integrated into the compiler.Although there are more works to be done to turn this into a mergeable/releasable (but experimental) state, I think it'd be beneficial to open a PR at this stage because to start discussing:
I'm very interested in receiving feedback!
Proposed API
Here is an example usage of the Tapir API implemented in this PR for the moment. Following the tradition, it computes the Fibonacci number in parallel:
Tapir.@sync begin ... end
denotes the syncregion in which Tapir tasks can be spawned.Tapir.@spawn ...
denotes the code block that may run in parallel with respect to other tasks (including the parent task; e.g.,fib(N - 2)
in the example above).local
. In the example above,x1
andx2
are the task output. Declaring withlocal
is required because, just like the standard@sync
,Tapir.@sync
creates a scope (i.e., it is alet
block). Task output variables can also be initialized beforeTapir.@sync
.Tapir
is expected to have the serial projection property (see below for the motivation). In particular, the code must be valid after removingTapir
-related syntax (i.e., replacingTapir.@sync
andTapir.@spawn
withlet
blocks).@goto
into or out of the tasks is not allowedOne of the important consideration is to keep this API very minimal to let us add more optimizations like OpenCilk-based lowering at a later stage of LLVM. I've designed this API first with my experimental branch of OpenCilk-based Tapir support. My OpenCilk-based implementation was not perfect but I think this is reasonably constrained to fully implement it.
Loop-based API: Although OpenCilk and @vchuravy's original PR support parallel
for
loop, I propose to not include it for the initial support inBase
. On one hand, it is straight forward to implement a simple parallel loop framework from just this API. On the other hand, there are a lot of consideration to be made in the design space if we want to have extensible data parallelism and I feel it's beyond the scope of this PR.Questions
More explicit task output declaration?
Current handling of task output variables may be "too automatic" and makes reasoning about the code harder for Julia programmers. For example, consider following code that has no race for now:
After sometimes, it may be re-written as
This code now has a race since
x
is updated by the child and parent tasks. Although this particular example can be rejected by the compiler, it is impossible to do so in general. So, it may be worth considering adding a declaration of task outputs. Maybe something likeor
The compiler can then compare and verify that its understanding of task output variables and the variables specified by the programmer.
It would be even better if we can support a unified syntax like this for all types of tasks (
@async
andThreads.@spawn
).Bikeshedding the name
On one hand, the name of the module
Base.Experimental.Tapir
is not entirely appropriate since Tapir is the concept for IR and not the user-facing API. On the other hand, we cannot come up with a more appropriate name for this. It is mainly because there is no crisp terminology for this type of parallelism (even though Cilk has been showing the importance of this approach with multiple aspects). Another name could beBase.Experimental.Parallel
. But "parallel" can also mean distributed or GPU-based parallelism. Providing@psync
and@pspawn
macros directly fromExperimental
is another approach.Performance improvements
Demo 1: type inference
The return type of the example
fib
above and the simple threaded mapreduce examplemapfold
implemented intest/tapir.jl
can be inferred even though they containTapir.@spawn
:As we all know, the improvement in type inference drastically change the performance when there is a tight loop following the parallel code (without a function boundary).
Demo 2: constant propagation
Here is another very minimal example for demonstrating performance benefit we observed. It (naively) computes the average on the sliding window in parallel:
For comparison, here are the same code using the current task system (
Threads.@spawn
) and the sequential version.:We can then run the benchmarks with
With
julia
started with a single thread 2, we geti.e., Tapir and sequential programs have identical performance while the performance of the code written with
Threads.@spawn
(current) is much worse. This is because Julia can propagate the constant across task boundaries with Tapir. Subsequently, since LLVM can see that the innermost loop has a fixed loop count, the loop can be unrolled and vectorized.It can be observed by introspecting the generated code:
i.e.,
N = 32
is successfully propagated. On the other hand, in the current task system:i.e.,
N = 32
(%32
) is captured as anInt64
.Indeed, the performance of the sequential parts of the code is crucial for observing the speedup. With
julia -t2
, we see:I think an important aspect of this example is that even a "little bit" of compiler optimizations enabled on the Julia side can be enough for triggering optimizations on the LLVM side yielding a substantial effect.
Demo 3: dead code elimination
The optimizations that can be done with forward analysis such as type inference and constant propagation are probably implementable for the current threading system in Julia with reasonable amount of effort. However, optimizations such as dead code elimination (DCE) that require backward analysis may be significantly more challenging given unconstrained concurrency of Julia's
Task
. In contrast, enabling Tapir at Julia IR level automatically triggers Julia's DCE (which, in turn, can trigger LLVM's DCE):In single thread
julia
, this takes 30 ms while an equivalent code with current threading system (i.e., replaceTapir.
withThreads.
) takes 250 ms.Note that Julia only has to eliminate
b1
and not the code insideeliminatable_computation
. The rest of DCE can happen inside LLVM (which does not have to understand Julia's parallelism).Motivation behind the restricted task semantics
As explained in the proposed API, the serial projection property restrict the set of programs expressible with
Tapir
. Although we have some degree of interoperability (see below), reasoning and guaranteeing forward progress are easy only when the programmer stick with the existing patterns. In particular, it means that we will not be able to dropThreads.@spawn
or@async
for expressing programs with unconstrained concurrency. However, this restricted semantics is still enough for expressing compute-oriented programs and allows more optimizations in the compiler and the scheduler.As shown above, enabling Tapir at Julia level already unlocks some appealing set of optimizations for parallel programs. The serial projection property is useful for supporting optimizations that require backward analysis such as DCE in a straightforward manner. Once we manage to implement a proper OpenCilk integration, we expect to see the improvements from much richer set of existing optimizations at the LLVM level. This would have multiplicative effects when more LLVM-side optimizations are enabled (e.g., #36832). Furthermore, there is ongoing research on more cleverly using the parallel IR for going beyond unlocking pre-existing optimizations. For example, fusing arbitrary multiple
Task
s to be executed in a singleTask
can introduce deadlock. However, this is not the case in Tapir tasks thanks to the serial projection property. This, in turn, let us implement more optimizations inside the compiler such as a task coalescing pass that is aware of a downstream vecotrizer pass. In addition to optimizing user programs, this type of task improves productivity tools such as race detector (ref productivity tools provided by OpenCilk).In addition to the performance improvements on the side of the compiler, may-happen in parallel parallelism can help the parallel task runtime to handle the task scheduling cleverly. For example, having parallel IR makes it easy to implement continuation-stealing as used in Cilk (although it may not be compatible with the depth-first approach in Julia). Another possibility is to use a version of
schedule
that may fail to reduce contention when there are a lot of small tasks (as I discussed here). This is possible because we don't allow concurrency primitives in Tapir tasks and the task is not guaranteed to be executed in a dedicatedTask
. Since Tapir makes the call/task tree analyzable by the compiler, it may also help improving the depth-first scheduler.Composability with existing task system
If we are going to have two notions of parallel tasks, it is important that the two systems can work together seamlessly. Of course, since the Tapir tasks cannot use arbitrary concurrency APIs, we can't support some task APIs inside
Tapir.@spawn
(e.g.,take!(channel)
). However, Tapir only requires the forward progress guarantee to be independent of other tasks within the same syncregion but not with respect to the code outside. Simplify put, we can invoke concurrency API as long as it does not communicate with other Tapir tasks in the same syncregion. For example, following code is valid:as long as
f()
does not use thebounded_channel
. That is to say, the author of this code guarantees the forward progress of Thunk 1 and Thunk 2 independent of each other but not with respect to Thunk 3. Therefore, the example above is a valid program.Another class of useful composable idiom is the use of concurrency API that unconditionally guarantees forward progress. For example,
put!(unbounded_channel, item)
can make forward progress independent of other tasks (but this is not true fortake!
). This is also true forschedule(::Task)
. Thus, invokingThreads.@spawn
insideTapir.@sync
is valid if (bot not only if 3) we do not invokewait(task)
in theTapir.@sync
. For example, the following code is validHowever, it is invalid if
wait(t1)
andwait(t2)
are uncommented.However, this is not an example of the idiom of using API that unconditionally guarantees forward progress.
Implementation strategy
Outlining
At the end of Julia's optimization passes (in
run_passes
) the child tasks are outlined into opaque closures and wrapped into aTask
(seelower_tapir!(ir::IRCode)
function). (Aside: @Keno's opaque closure was very useful for implementing outlining at a late stage! Actually, @vchuravy has been suggesting opaque closure would be the enabler for this type of transformation since the beginning of the project. But it's nice to see how things fit together in a real code.) The outlined tasks are spawned and synced using the helper functions defined inBase.Tapir
.Questions
jl_new_code_info_uninit
andjl_make_opaque_closure_method
for this. Are they allowed to be invoked in this part of the compiler?Core.Box
when combined with opaque closure?Acknowledgment
Many thanks to @vchuravy for pre-reviewing the PR!
TODOs
To reviewers: please feel free to add new ones or move the things in wishlist to here
Wishlist
This is a list of nice-to-have things that are maybe not strictly required.
@goto
into a task)manual/multi-threading.md
)Footnotes
I think we can make it an error by analyzing this in the front end, in principle. ↩
Since this PR is mainly about compiler optimization and not about the scheduler, single-thread performance compared with sequential program is more informative than multi-thread performance. ↩
Note that invoking
wait
in a Tapir task can be valid in some cases. For example, if it is known that the set of the tasks spawned byThreads.@spawn
eventually terminate independent of any forward progress in other Tapir task in the same syncregion, it is valid towait
on these tasks. For example, it is valid: ↩