-
Notifications
You must be signed in to change notification settings - Fork 1.1k
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] task_group_dynamic_dependencies #1469
base: master
Are you sure you want to change the base?
Changes from all commits
18ad843
5404632
e1b964a
416d638
66710d2
1b32c34
aceddbd
19ac8d8
ff7e366
11b26d6
5a27a6c
97ef660
83eec0f
cbacc32
9681b66
304faf1
746ef99
f69b18c
c373fbb
d2432ab
9b433be
bc78aca
91deaf2
eeb66ba
3884430
52bb751
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,378 @@ | ||
# Extend ``task_group`` for Dynamic Task Dependencies | ||
|
||
## Introduction | ||
|
||
In 2021, with the transition from TBB 2020 to the first release of oneTBB, | ||
the lowest-level tasking interface changed significantly and was no longer | ||
promoted as a user-facing feature. Instead, we encouraged | ||
using the `task_group` or the flow graph APIs to express patterns | ||
previously handled by the lowest-level tasking API. This approach has been | ||
sufficient for many cases. However, there is one use case which is not | ||
straightforward to express by the revised API: Dynamic task graphs which are | ||
not trees. This proposal expands `tbb::task_group` to support additional use cases. | ||
|
||
The class definition from section | ||
[scheduler.task_group](https://oneapi-spec.uxlfoundation.org/specifications/oneapi/v1.4-rev-1/elements/onetbb/source/task_scheduler/task_group/task_group_cls) | ||
of the oneAPI Threading Building Blocks (oneTBB) Specification 1.4 for | ||
`tbb::task_group` is shown below. Note the existing `defer` function, since this | ||
function and its return type, `task_handle`, are the foundation of the proposed extensions: | ||
|
||
class task_group { | ||
public: | ||
task_group(); | ||
task_group(task_group_context& context); | ||
|
||
~task_group(); | ||
|
||
template<typename Func> | ||
void run(Func&& f); | ||
|
||
template<typename Func> | ||
task_handle defer(Func&& f); | ||
vossmjp marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
void run(task_handle&& h); | ||
|
||
template<typename Func> | ||
task_group_status run_and_wait(const Func& f); | ||
|
||
task_group_status run_and_wait(task_handle&& h); | ||
|
||
task_group_status wait(); | ||
void cancel(); | ||
}; | ||
|
||
## Proposal | ||
|
||
The following list summarizes the three primary extensions that are under | ||
consideration. The sections that follow provide background and further | ||
clarification on the proposed extensions. | ||
|
||
1. **Extend semantics and useful lifetime of `task_handle`.** We propose `task_handle` | ||
to represent tasks for the purpose of adding dependencies. It requires extending its | ||
useful lifetime and semantics to include tasks that have been | ||
submitted, are currently executing, or have been completed. | ||
vossmjp marked this conversation as resolved.
Show resolved
Hide resolved
|
||
2. **Add functions to set task dependencies.** In the current `task_group`, tasks can | ||
only be waited on as a group, with no direct way to define before-after | ||
relationships between individual tasks. | ||
3. **Add a function to move successors from an executing task to a new task.** | ||
This functionality is necessary for recursively generated task graphs. It enables | ||
safe modifification of dependencies for an already submitted task. | ||
|
||
### Extend the Semantics and Useful Lifetime of task_handle | ||
|
||
Dynamic tasks graphs order the execution of tasks via dependencies on the | ||
completion of other tasks. They are considered dynamic because task creation, | ||
specification of dependencies, submission for scheduling, and task execution | ||
may happen concurrently and in various orders. Different | ||
use cases have different requirements on when tasks are created | ||
and when dependencies are specified. | ||
|
||
For the sake of discussion, let’s define four points in a task’s lifetime: | ||
|
||
1. **Created:** is allocated but is not yet known to the scheduling | ||
algorithm, and therefore cannot begin executing. | ||
2. **Submitted:** is known to the scheduling algorithm and may be | ||
scheduled for execution whenever its incoming dependencies (predecessor tasks) | ||
are complete. | ||
3. **Executing:** has started executing its body but is not yet complete. | ||
4. **Completed:** fully executed to completion. | ||
|
||
In the current `task_group` specification, the `task_group::defer` function | ||
already allows separate task creation and submission. | ||
`task_group::defer` returns a `tbb::task_handle` that represents a created | ||
task. A created task remains created until it is submitted through | ||
`task_group::run` or `task_group::run_and_wait`. The current | ||
`task_group` specification treats accessing a `task_handle` after it is submitted | ||
via one of the run functions as undefined behavior. Therefore, a | ||
Comment on lines
+83
to
+86
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Note that this is technically done by the run functions accepting a In order to "extend useful lifetime of a task handle`, the run functions should therefore treat the handle argument differently. This part is not covered by the proposal. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes, needs to be added. |
||
`task_handle` represents a created task only. Furthermore, since `task_group` | ||
does not support task dependencies, any task that is run can be immediately | ||
scheduled for execution without considering dependencies. | ||
|
||
The first extension is to expand the semantics and usable lifetime of | ||
`task_handle` so that it can used as a predecessor to other tasks even after it is | ||
passed to run. The handle will track the state of its associated task and may represent | ||
a task that is created, submitted, executing, or completed. Similarly, a submitted `task_handle` | ||
may represent a task that depends on predecessors that must complete before it can execute. | ||
In that case, passing a `task_handle` to `task_group::run` or `task_group::run_and_wait` only makes | ||
it available for dependency tracking but does not make it immediately eligible for execution. | ||
Comment on lines
+96
to
+97
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do not forget also about There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes, run and enqueue functions are not yet well covered. |
||
|
||
### Add Functions to Set Dependencies. | ||
|
||
The next logical extension is to add a mechanism for specifying dependencies | ||
between tasks. In the most conservative view, it should only be allowed to add | ||
predecessors (in-dependencies) to tasks in the created state. | ||
After a task starts, adding more predecessors is irrelevant, | ||
since it’s too late to delay the task execution. | ||
|
||
It might be reasonable to add new predecessors to a task that is | ||
currently executing if it is suspended until these additional dependencies complete. | ||
However, this proposal does not include support for the suspension model. | ||
|
||
For a task in the submitted state, there can be a race between | ||
adding a new predecessor and the scheduler deciding to execute the task once its | ||
current predecessors are complete. We will revisit the discussion of | ||
adding predecessors to submitted tasks in the next section when discussing | ||
recursively grown task graphs. | ||
|
||
After resolving the question about when to add predecessors, the next question is, | ||
what can be added as a predecessor task? The simplest answer is to have no limitations. | ||
It means, any valid `task_handle` can act as a predecessor. In many cases, you may | ||
only know what work must be completed before a task can start, but you may not know | ||
work's state. | ||
|
||
We therefore think predecessors may be in any state when they are added, | ||
vossmjp marked this conversation as resolved.
Show resolved
Hide resolved
|
||
as shown below: | ||
|
||
<img src="add_dependency.png" width=400> | ||
|
||
There are a number of possible options to spell a function for adding | ||
a single predecessor. Additionally, we may also want a function to allow | ||
adding multiple predecessors in a single call. | ||
|
||
Given two `task_handle` objects, `h1` and `h2`, some possible options | ||
for adding `h1` as a predecessor (in-dependence) of `h2` include: | ||
|
||
- `h2.add_predecessor(h1)` | ||
- `h2 = defer([]() { … }, h1)` | ||
- `make_edge(h1, h2)` | ||
|
||
The proposal is to include the first option. Similarly, there could be | ||
a version of this function that accepts multiple predecessors | ||
at once: | ||
|
||
- `h.add_predecessors(h1, ..., hn)` | ||
|
||
This initial proposal does not include this function, but it can be added later. | ||
|
||
In the general case, it is undefined behavior to add a new predecessor | ||
to a task in the submitted, executing, or completed states. | ||
|
||
### Add a Function for Recursively Grown Graphs | ||
|
||
A very common use case for oneTBB tasks is parallel recursive decomposition. | ||
An example of this is the implementation of `tbb::parallel_for` that | ||
performs a parallel recursive decomposition of a range. Currently, | ||
the oneTBB algorithms, such as tbb::parallel_for, are implemented using the non-public, | ||
low-level tasking API, rather than `tbb::task_group`. This low-level tasking API | ||
puts the responsibility for dependence tracking and memory | ||
management of tasks on developers. While it allows the oneTBB development team to build highly optimized | ||
algorithms, a simpler set of interfaces can be provided for the | ||
users. Recursive parallel algorithms are one of the primary cases that we want | ||
our task_group extension to cover. | ||
vossmjp marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
The key capability required for recursive decomposition is the ability to | ||
create work while executing a task and insert this newly created work before | ||
the (perhaps already submitted) successors of the currently executing task. | ||
A simple example is a merge sort. As shown in the figure | ||
below, the top-level algorithm breaks a collection into two pieces and | ||
creates three tasks: | ||
|
||
1. A task to sort the left half. | ||
2. A task to sort the right half. | ||
3. A task to merge the halves once they are sorted. | ||
|
||
In a recursive merge sort, each of the sort tasks recursively takes the same | ||
vossmjp marked this conversation as resolved.
Show resolved
Hide resolved
|
||
approach to sort its portions of the collection. The top-level task (and | ||
subsequent recursively generated tasks) must be able to create new tasks | ||
and then update the graph for their outer merge task to wait for the | ||
newly created subtasks to complete. | ||
|
||
<img src="merge_sort.png" width=800> | ||
|
||
A key point of this recursive parallel algorithm is the requirement to change | ||
the predecessors of the merge tasks. However, the merge tasks are already | ||
submitted when their predecessors are modified. As mentioned in the previous | ||
section, updating the predecessors of a submitted task can be | ||
risky due to the potential for a race condition. However, in this case, | ||
it is safe to add or change predecessors to the merge task. | ||
This is because the merge task cannot start execution until all of its current predecessors | ||
complete. Those predecessors are the tasks responsible for modifying the merge task dependencies. | ||
|
||
Therefore, we propose a limited extension that allows transferring | ||
all the successors of the currently executing task to become the successors | ||
of a different created task. This function can only access the successors | ||
of the currently executing task, and those tasks are prevented from executing | ||
by a dependence on the current task itself, so we can ensure that we can safely | ||
vossmjp marked this conversation as resolved.
Show resolved
Hide resolved
|
||
update the incoming dependencies for those tasks without worrying about | ||
potential race conditions. | ||
|
||
One possible spelling for this function would be `transfer_successors_to(h)`, | ||
vossmjp marked this conversation as resolved.
Show resolved
Hide resolved
|
||
Where `h` is a `task_handle` to a created task, and the | ||
`transfer_successors_to` function must be called from within a task. Calling | ||
this function from outside a task or passing anything other than a `task_handle` | ||
representing a task in the created state is undefined behavior. | ||
Comment on lines
+200
to
+203
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think we need to add information about possible limitations (or lack of limitations) for the task states represented by |
||
|
||
### Proposed Changes for `task_handle` and `task_group` | ||
|
||
namespace oneapi { | ||
namespace tbb { | ||
class task_handle { | ||
public: | ||
|
||
// existing functions | ||
task_handle(); | ||
task_handle(task_handle&& src); | ||
~task_handle(); | ||
task_handle& operator=(task_handle&& th); | ||
explicit operator bool() const noexcept; | ||
|
||
// proposed addition | ||
void add_predecessor(task_handle& th); | ||
}; | ||
|
||
class task_group { | ||
public: | ||
task_group(); | ||
task_group(task_group_context& context); | ||
|
||
~task_group(); | ||
|
||
template<typename Func> | ||
void run(Func&& f); | ||
|
||
template<typename Func> | ||
task_handle defer(Func&& f); | ||
|
||
void run(task_handle&& h); | ||
|
||
template<typename Func> | ||
task_group_status run_and_wait(const Func& f); | ||
|
||
task_group_status run_and_wait(task_handle&& h); | ||
|
||
task_group_status wait(); | ||
void cancel(); | ||
|
||
// proposed addition | ||
static void transfer_successors_to(task_handle& th); | ||
}; | ||
} | ||
} | ||
|
||
|
||
#### void task_handle::add_predecessor(task_handle& th); | ||
|
||
Adds `th` as a predecessor that must complete before the task represented by | ||
`*this` can start executing. | ||
|
||
#### void task_group::transfer_successors_to(task_handle& th); | ||
|
||
Transfers all of the successors from the currently executing task to the task | ||
represented by `th`. | ||
|
||
### Examples | ||
|
||
##### Simple Three-Nodes Task Graph | ||
|
||
The example below shows a simple graph with three nodes. | ||
`final_task` must wait for `first_task` and `second_task` | ||
to complete. | ||
|
||
tbb::task_group tg; | ||
|
||
tbb::task_handle first_task = tg.defer([&] { /* task body */ }); | ||
tbb::task_handle second_task = tg.defer([&] { /* task body */ }); | ||
tbb::task_handle final_task = tg.defer([&] { /* task body */ }); | ||
|
||
final_task.add_predecessor(first_task); | ||
final_task.add_predecessor(second_task); | ||
|
||
// order of submission is not important | ||
tg.run(std::move(final_task)); | ||
tg.run(std::move(first_task)); | ||
tg.run(std::move(second_task)); | ||
|
||
tg.wait(); | ||
|
||
The dependency graph for this example is: | ||
|
||
<img src="three_task_graph.png" width=400> | ||
|
||
#### Predecessors in Unknown States | ||
|
||
The example below shows a graph where the dependencies are determined | ||
dynamically. The state of the predecessors is unknown. The | ||
`users::find_predecessors` function returns, based on application | ||
logic, the tasks that must complete before the new work can start. | ||
|
||
void add_another_task(tbb::task_group& tg, int work_id) { | ||
tbb::task_handle new_task = tg.defer([=] { do_work(work_id); }); | ||
|
||
for (tbb::task_handle& p : users::find_predecessors(work_id)) { | ||
new_task.add_predecessor(p); | ||
} | ||
|
||
tg.run(std::move(new_task)); | ||
} | ||
|
||
While the graph, as shown below, is simple, the completion status of the predecessors | ||
is unknown. Therefore, for ease of use, `task_handle` should be usable as a dependency | ||
regardless of state of the task it represents. Any predecessor that is already | ||
completed when it is added as a predecessor will not delay the start of the dependent | ||
task. | ||
|
||
<img src="unknown_states.png" width=400> | ||
|
||
#### Recursive Decomposition | ||
|
||
This example is a version of merge-sort (with many of the details left out). | ||
vossmjp marked this conversation as resolved.
Show resolved
Hide resolved
|
||
Assume an initial task executes the function shown below as its body, and the | ||
function implements that task, and also serves as the body for the recursively | ||
decomposed pieces. The range of the sequence is defined by `b` (beginning) and | ||
`e` (end). Most of the implementation details of merge sort are abstracted | ||
into the following helper functions: `users::do_serial_sort`, `users::create_left_range`, | ||
`users::create_right_range`, and `users::do_merge`. | ||
|
||
template<typename T> | ||
void merge_sort(tbb::task_group& tg, T b, T e) { | ||
if (users::range_is_too_small(b, e)) { | ||
// base-case when range is small | ||
users::do_serial_sort(b, e); | ||
} else { | ||
// calculate left and right ranges | ||
T lb, le, rb, re; | ||
users::create_left_range(lb, le, b, e); | ||
users::create_right_range(rb, re, b, e); | ||
|
||
// create the three tasks | ||
tbb::task_handle sortleft = | ||
tg.defer([lb, le, &tg] { | ||
merge_sort(tg, lb, le); | ||
}); | ||
tbb::task_handle sortright = | ||
tg.defer([rb, re, &tg] { | ||
merge_sort(tg, rb, re); | ||
}); | ||
tbb::task_handle merge = | ||
tg.defer([lb, le, rb, re, &tg] { | ||
users::do_merge(tg, lb, le, rb, re); | ||
}); | ||
|
||
// add predecessors for new merge task | ||
merge.add_predecessor(sortleft); | ||
merge.add_predecessor(sortright); | ||
|
||
// insert new subgraph between currently executing | ||
// task and its successors | ||
tbb::task_group::transfer_successors_to(merge); | ||
|
||
tg.run(std::move(sortleft)); | ||
tg.run(std::move(sortright)); | ||
tg.run(std::move(merge)); | ||
} | ||
} | ||
|
||
This task tree matches the one shown earlier for merge-sort. | ||
|
||
## Open Questions in Design | ||
|
||
Some open questions that remain: | ||
vossmjp marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
- Are the suggested APIs sufficient? | ||
- Should we add a function to adds more than one predecessor as single call, such as `add_predecessors`? | ||
- Should we add functions that merge creation and definition of predecessor tasks, such as | ||
`template <typename Func> add_predecessor(Func&& f);`. | ||
- Are there additional use cases that should be considered that we missed in our analysis? | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I can suggest a few more potentially interesting cases:
These seem to be interesting examples of "dynamic task graphs that are not trees", as stated in the introduction. I do not suggest that we must support these patterns, but it is interesting to see if we could, and what would be needed for that. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes, more example would be good. In particular, we need to see if the very limited ways to add and modified predecessors is sufficient. And how lifetimes of task handles will be managed. |
||
- Are there other parts of the pre-oneTBB tasking API that developers have struggled to find a good alternative for? | ||
- What are the performance targets for this feature? | ||
- Assuming this will be targeted initially as an experimental feature, what are the exit criteria? |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I have noticed one thing that is not related to the dependencies between tasks, but relates into covering the migration from the old tasking API to another, so I decided to add it as a comment here.
Let's consider the recursive Fibonacci example rewritten using the API proposed in this RFC (the splitting stage):
|
The main difference between merge sort and this example is some data that is required for executing the task (
left
andright
- the placeholders for partial results of Fibonacci calculations on leaft).Since the lifetime of this data should be preserved until the sum task is executed, it cannot be placed on stack of current function and needs to be allocated dynamically.
Back to old TBB, this data was placed inside of the corresponding task that provides the required lifetime guarantees.
The question is do we need to extend the
task_handle
API somehow to allow putting the additional data to the task.