Skip to content

largerock/taskflow

 
 

Repository files navigation

Taskflow

Linux Build Status Windows Build status Wiki TFProf Cite

Taskflow helps you quickly write parallel and heterogeneous tasks programs in modern C++

Why Taskflow?

Taskflow is faster, more expressive, and easier for drop-in integration than many of existing task programming frameworks in handling complex parallel workloads.

Taskflow lets you quickly implement task decomposition strategies that incorporate both regular and irregular compute patterns, together with an efficient work-stealing scheduler to optimize your multithreaded performance.

Static Tasking Dynamic Tasking

Taskflow supports conditional tasking for you to make rapid control-flow decisions across dependent tasks to implement cycles and conditions that were otherwise difficult to do with existing tools.

Conditional Tasking

Taskflow is composable. You can create large parallel graphs through composition of modular and reusable blocks that are easier to optimize at an individual scope.

Taskflow Composition

Taskflow supports heterogeneous tasking for you to accelerate a wide range of scientific computing applications by harnessing the power of CPU-GPU collaborative computing.

Concurrent CPU-GPU Tasking

Taskflow provides visualization and tooling needed for profiling Taskflow programs.

Taskflow Profiler

We are committed to support trustworthy developments for both academic and industrial research projects in parallel computing. Check out Who is Using Taskflow and what our users say:

See a quick presentation and visit the documentation to learn more about Taskflow. Technical details can be referred to our IPDPS paper.

Table of Contents

Get Started with Taskflow

The following example simple.cpp shows the basic Taskflow API you need in most applications.

#include <taskflow/taskflow.hpp>  // Taskflow is header-only

int main(){
  
  tf::Executor executor;
  tf::Taskflow taskflow;

  auto [A, B, C, D] = taskflow.emplace(
    [] () { std::cout << "TaskA\n"; },               //  task dependency graph
    [] () { std::cout << "TaskB\n"; },               // 
    [] () { std::cout << "TaskC\n"; },               //          +---+          
    [] () { std::cout << "TaskD\n"; }                //    +---->| B |-----+   
  );                                                 //    |     +---+     |
                                                     //  +---+           +-v-+ 
  A.precede(B);  // A runs before B                  //  | A |           | D | 
  A.precede(C);  // A runs before C                  //  +---+           +-^-+ 
  B.precede(D);  // B runs before D                  //    |     +---+     |    
  C.precede(D);  // C runs before D                  //    +---->| C |-----+    
                                                     //          +---+          
  executor.run(taskflow).wait();

  return 0;
}

Compile and run the code with the following commands:

~$ g++ simple.cpp -I path/to/include/taskflow/ -std=c++17 -O2 -lpthread -o simple
~$ ./simple
TaskA
TaskC  <-- concurrent with TaskB
TaskB  <-- concurrent with TaskC
TaskD

Create a Taskflow Application

Taskflow defines a very expressive API to create task dependency graphs. Most applications are developed through the following three steps:

Step 1: Create a Taskflow

Create a taskflow object to build a task dependency graph:

tf::Taskflow taskflow;

A task is a callable object for which std::invoke is applicable. Use the method emplace to create a task:

tf::Task A = taskflow.emplace([](){ std::cout << "Task A\n"; });

Step 2: Define Task Dependencies

You can add dependency links between tasks to enforce one task to run before or after another.

A.precede(B);  // A runs before B.

Step 3: Execute a Taskflow

To execute a taskflow, you need to create an executor. An executor manages a set of worker threads to execute a taskflow through an efficient work-stealing algorithm.

tf::Executor executor;

The executor provides a rich set of methods to run a taskflow. You can run a taskflow multiple times, or until a stopping criteria is met. These methods are non-blocking with a std::future return to let you query the execution status. Executor is thread-safe.

executor.run(taskflow);       // runs the taskflow once
executor.run_n(taskflow, 4);  // runs the taskflow four times

// keeps running the taskflow until the predicate becomes true
executor.run_until(taskflow, [counter=4](){ return --counter == 0; } );

You can call wait_for_all to block the executor until all associated taskflows complete.

executor.wait_for_all();  // block until all associated tasks finish

Notice that the executor does not own any taskflow. It is your responsibility to keep a taskflow alive during its execution, or it can result in undefined behavior. In most applications, you need only one executor to run multiple taskflows each representing a specific part of your parallel decomposition.

Dynamic Tasking

Another powerful feature of Taskflow is dynamic tasking. Dynamic tasks are those tasks created during the execution of a taskflow. These tasks are spawned by a parent task and are grouped together to a subflow graph. To create a subflow for dynamic tasking, emplace a callable with one argument of type tf::Subflow.

// create three regular tasks
tf::Task A = tf.emplace([](){}).name("A");
tf::Task C = tf.emplace([](){}).name("C");
tf::Task D = tf.emplace([](){}).name("D");

// create a subflow graph (dynamic tasking)
tf::Task B = tf.emplace([] (tf::Subflow& subflow) {
  tf::Task B1 = subflow.emplace([](){}).name("B1");
  tf::Task B2 = subflow.emplace([](){}).name("B2");
  tf::Task B3 = subflow.emplace([](){}).name("B3");
  B1.precede(B3);
  B2.precede(B3);
}).name("B");
            
A.precede(B);  // B runs after A 
A.precede(C);  // C runs after A 
B.precede(D);  // D runs after B 
C.precede(D);  // D runs after C 

By default, a subflow graph joins its parent node. This ensures a subflow graph finishes before the successors of its parent task. You can disable this feature by calling subflow.detach(). For example, detaching the above subflow will result in the following execution flow:

// create a "detached" subflow graph (dynamic tasking)
tf::Task B = tf.emplace([] (tf::Subflow& subflow) {
  tf::Task B1 = subflow.emplace([](){}).name("B1");
  tf::Task B2 = subflow.emplace([](){}).name("B2");
  tf::Task B3 = subflow.emplace([](){}).name("B3");

  B1.precede(B3);
  B2.precede(B3);

  // detach the subflow to form a parallel execution line
  subflow.detach();
}).name("B");

A subflow can be nested or recursive. You can create another subflow from the execution of a subflow and so on.

Conditional Tasking

Taskflow supports conditional tasking for users to implement general control flow with cycles and conditionals. A condition task evalutes a set of instructions and returns an integer index of the next immediate successor to execute. The index is defined with respect to the order of its successor construction.

tf::Task init = tf.emplace([](){ }).name("init");
tf::Task stop = tf.emplace([](){ }).name("stop");

// creates a condition task that returns 0 or 1
tf::Task cond = tf.emplace([](){
  std::cout << "flipping a coin\n";
  return rand() % 2;
}).name("cond");

// creates a feedback loop
init.precede(cond);
cond.precede(cond, stop);  // cond--0-->cond, cond--1-->stop

executor.run(tf).wait();

Composable Tasking

A powerful feature of tf::Taskflow is composability. You can create multiple task graphs from different parts of your workload and use them to compose a large graph through the composed_of method.

tf::Taskflow f1, f2;

auto [f1A, f1B] = f1.emplace( 
  []() { std::cout << "Task f1A\n"; },
  []() { std::cout << "Task f1B\n"; }
);
auto [f2A, f2B, f2C] = f2.emplace( 
  []() { std::cout << "Task f2A\n"; },
  []() { std::cout << "Task f2B\n"; },
  []() { std::cout << "Task f2C\n"; }
);
auto f1_module_task = f2.composed_of(f1);

f1_module_task.succeed(f2A, f2B)
              .precede(f2C);

Similarly, composed_of returns a task handle and you can use precede to create dependencies. You can compose a taskflow from multiple taskflows and use the result to compose a larger taskflow and so on.

Concurrent CPU-GPU Tasking

Taskflow enables concurrent CPU-GPU tasking by leveraging Nvidia CUDA Toolkit. You can harness the power of CPU-GPU collaborative computing to implement heterogeneous decomposition algorithms.

Step 1: Create a cudaFlow

A tf::cudaFlow is a graph object created at runtime similar to dynamic tasking. It manages a task node in a taskflow and associates it with a CUDA Graph. To create a cudaFlow, emplace a callable with an argument of type tf::cudaFlow.

tf::Taskflow taskflow;
tf::Executor executor;

const unsigned N = 1<<20;                            // size of the vector
std::vector<float> hx(N, 1.0f), hy(N, 2.0f);         // x and y vectors at host
float *dx{nullptr}, *dy{nullptr};                    // x and y vectors at device

tf::Task allocate_x = taskflow.emplace([&](){ cudaMalloc(&dx, N*sizeof(float));});
tf::Task allocate_y = taskflow.emplace([&](){ cudaMalloc(&dy, N*sizeof(float));});
tf::Task cudaflow = taskflow.emplace([&](tf::cudaFlow& cf) {
  tf::cudaTask h2d_x = cf.copy(dx, hx.data(), N);    // host-to-device x data transfer
  tf::cudaTask h2d_y = cf.copy(dy, hy.data(), N);    // host-to-device y data transfer
  tf::cudaTask d2h_x = cf.copy(hx.data(), dx, N);    // device-to-host x data transfer
  tf::cudaTask d2h_y = cf.copy(hy.data(), dy, N);    // device-to-host y data transfer
  // launch saxpy<<<(N+255)/256, 256, 0>>>(N, 2.0f, dx, dy)
  tf::cudaTask kernel = cf.kernel((N+255)/256, 256, 0, saxpy, N, 2.0f, dx, dy);
  kernel.succeed(h2d_x, h2d_y)
        .precede(d2h_x, d2h_y);
});
cudaflow.succeed(allocate_x, allocate_y);            // overlap data allocations

executor.run(taskflow).wait();

Assume our kernel implements the canonical saxpy operation (single-precision A·X Plus Y) using the CUDA syntax.

// saxpy (single-precision A·X Plus Y) kernel
__global__ void saxpy(
  int n, float a, float *x, float *y
) {
  // get the thread index
  int i = blockIdx.x*blockDim.x + threadIdx.x;

  if (i < n) {
    y[i] = a*x[i] + y[i];
  }
}

Step 2: Compile and Execute a cudaFlow

Name you source with the extension .cu, let's say saxpy.cu, and compile it through nvcc:

~$ nvcc saxpy.cu -I path/to/include/taskflow -O2 -o saxpy
~$ ./saxpy

Our source autonomously enables cudaFlow for compilers that support CUDA.

Visualize a Taskflow Graph

You can dump a taskflow through a std::ostream in GraphViz format using the method dump. There are a number of free GraphViz tools you could find online to visualize your Taskflow graph.

tf::Taskflow taskflow;
tf::Task A = taskflow.emplace([] () {}).name("A");
tf::Task B = taskflow.emplace([] () {}).name("B");
tf::Task C = taskflow.emplace([] () {}).name("C");
tf::Task D = taskflow.emplace([] () {}).name("D");
tf::Task E = taskflow.emplace([] () {}).name("E");
A.precede(B, C, E); 
C.precede(D);
B.precede(D, E); 

taskflow.dump(std::cout);  // dump the graph in DOT to std::cout

When you have tasks that are created at runtime (e.g., subflow, cudaFlow), you need to execute the graph first to spawn these tasks and dump the entire graph.

tf::Executor executor;
tf::Taskflow taskflow;

tf::Task A = taskflow.emplace([](){}).name("A");

// create a subflow of two tasks B1->B2
tf::Task B = taskflow.emplace([] (tf::Subflow& subflow) {
  tf::Task B1 = subflow.emplace([](){}).name("B1");
  tf::Task B2 = subflow.emplace([](){}).name("B2");
  B1.precede(B2);
}).name("B");

A.precede(B);

executor.run(tf).wait();  // run the taskflow to spawn subflows
tf.dump(std::cout);       // dump the graph including dynamic tasks

System Requirements

To use the latest Taskflow, you only need a C++17 compiler.

  • GNU C++ Compiler at least v7.0 with -std=c++17
  • Clang C++ Compiler at least v6.0 with -std=c++17
  • Microsoft Visual Studio at least v15.7 (MSVC++ 19.14); see vcpkg guide
  • AppleClang Xode Version at least v8
  • Intel C++ Compiler at least v19.0.1
  • Nvidia CUDA Toolkit and Compiler (nvcc) at least v11.1 with -std=c++17

Taskflow works on Linux, Windows, and Mac OS X. See the C++ compiler support status.

Who is Using Taskflow?

Taskflow is being used in both industry and academic projects to scale up existing workloads that incorporate complex task dependencies.

  • OpenTimer: A High-performance Timing Analysis Tool for Very Large Scale Integration (VLSI) Systems
  • NovusCore: An emulating project for World of Warcraft (Wrath of the Lich King 3.3.5a 12340 client build)
  • SA-PCB: Annealing-based Printed Circuit Board (PCB) Placement Tool
  • LPMP: A C++ framework for developing scalable Lagrangian decomposition solvers for discrete optimization problems
  • OpenPhySyn: A plugin-based physical synthesis optimization kit as part of the OpenRoad flow
  • OSSIA: Open-source Software System for Interactive Applications
  • deal.II: A C++ software library to support the creation of finite element code
  • PyRepScan: A Git Repository Leaks Scanner Python Library written in C++
  • MyDataModels: An online platform for self-service machine learning fro small data
  • revealtech.ai: Mobile application that provides focused, intelligent analytics on the edge

More...

Contributors

Taskflow is being actively developed and contributed by the these people. Meanwhile, we appreciate the support from many organizations for our developments.

License

Taskflow is licensed under the MIT License.


About

Parallel and Heterogeneous Task Programming in Modern C++

Resources

License

Stars

Watchers

Forks

Packages

No packages published

Languages

  • C++ 58.1%
  • JavaScript 19.5%
  • Cuda 9.9%
  • C 8.1%
  • CMake 2.7%
  • CSS 0.6%
  • Other 1.1%