Introduction
In our previous lessons, we've seen how #pragma omp for is a powerful tool for parallelizing loops, which represent "regular" workloads with predictable, independent iterations. We also covered the synchronization primitives like critical, atomic, and barrier needed to manage shared data and coordinate threads in such scenarios.
However, many problems in computing don't fit neatly into a for loop. Consider these cases:
- Traversing a linked list or a tree data structure.
- Executing a recursive algorithm, like a divide-and-conquer search.
- Processing items from a dynamic queue.
These are examples of irregular workloads, where the units of work are discovered dynamically and may not be balanced. Forcing these into a parallel for construct is often difficult or impossible.
This lesson introduces OpenMP's solution: task-based parallelism. Our learning outcome is to implement task-based parallelism using OpenMP tasks for irregular workloads. You will learn how to define independent units of work, or "tasks," that the OpenMP runtime can dynamically schedule across available threads, offering a flexible and powerful model for parallel programming.
1. The OpenMP Tasking Model
At its core, an OpenMP task is simply a block of code that can be executed independently. The typical model for tasking works as follows:
- A parallel region is started, creating a team of threads.
- One thread is designated to be the "producer." It encounters
#pragma omp taskdirectives and creates task packages (containing the code to run and the necessary data). These tasks are placed into a conceptual pool. - All threads in the team, including the producer thread once it finishes creating tasks, become "consumers." They pull tasks from the pool and execute them until the pool is empty.
To set up this model, we combine a few directives we've already seen with the new #pragma omp task. A common pattern is to use #pragma omp single to ensure only one thread generates the initial set of tasks.
#pragma omp parallel
{
// All threads in the team are here.
#pragma omp single
{
// Only one thread enters this block.
// This thread will create the tasks.
#pragma omp task
{ /* code for task 1 */ }
#pragma omp task
{ /* code for task 2 */ }
// ... more tasks
}
// All threads in the team will execute the tasks created above.
} // Implicit barrier here ensures all tasks are completed.
Let's begin by studying a short resource that formally defines what a task is and introduces this execution model.
[PDF] Advanced OpenMP Tutorial - LRZ-Doku
This presentation, 'Advanced OpenMP Tutorial' from LRZ, provides a clear introduction to the concept of tasking.
Please read slides 12 to 14 (PDF pages 13-15). The slide titles are 'What is a task in OpenMP?' and 'Tasking execution model'. Focus on understanding that a task is a deferred unit of work and how the 'single creator, multiple consumer' model works.
2. Irregular Workload Part 1: Linked List Traversal
A linked list traversal is a classic example of an irregular workload. It's structured as a while loop, not a for loop, so #pragma omp for cannot be used. Let's see how tasks solve this elegantly.
The strategy is to have a single thread walk the list, and for each node it finds, it creates a task to process that node's data.
// Assume 'Node* head' is the start of a linked list.
// void process(Node* p); is a function that does work on a node.
#pragma omp parallel
{
#pragma omp single
{
Node* p = head;
while (p != nullptr) {
#pragma omp task firstprivate(p)
{
process(p);
}
p = p->next;
}
}
}
Data Scoping is Crucial: Notice the firstprivate(p) clause. This is vital.
pis the loop variable that changes with each iteration in thesingleregion.- If
pwereshared(the default), all tasks would see the final value ofp(which isnullptr), not the value it had when the task was created. This is a common and subtle bug. firstprivate(p)ensures that each task gets its own private copy of the pointerp, initialized with the valuephad at the moment the#pragma omp taskdirective was encountered.
The following image provides an excellent visualization of this process. One thread iterates through the list (the serial part), while the other threads in the team pick up the process(p) tasks and execute them in parallel.

3. Irregular Workload Part 2: Recursive Algorithms and taskwait
Recursion is another powerful programming pattern that represents an irregular workload. Tasking is a natural fit for parallelizing recursive "divide-and-conquer" algorithms. A classic example is calculating a Fibonacci number.
The sequential recursive implementation is:
int fib(int n) {
if (n < 2) return n;
int x = fib(n - 1);
int y = fib(n - 2);
return x + y;
}
The two recursive calls, fib(n-1) and fib(n-2), are independent and can be computed in parallel. We can create a task for each. However, we must wait for both tasks to complete before we can sum their results x + y.
This is where #pragma omp taskwait comes in. It's a directive that waits for the completion of all direct child tasks generated by the current task.
Here is the parallelized Fibonacci function:
int fib(int n) {
if (n < 2) return n;
int x, y;
#pragma omp task shared(x)
{
x = fib(n - 1);
}
#pragma omp task shared(y)
{
y = fib(n - 2);
}
#pragma omp taskwait
// At this point, we are guaranteed that the two tasks above have finished.
return x + y;
}
// To run this, you'd call it from within a parallel region:
// #pragma omp parallel
// {
// #pragma omp single
// {
// result = fib(N);
// }
// }
Let's look at a resource that illustrates this process.
[PDF] Advanced OpenMP Tutorial - LRZ-Doku
The 'Advanced OpenMP Tutorial' provides a great step-by-step visualization of how tasks are generated and executed for the Fibonacci algorithm.
Review slides 18 to 20 (PDF pages 41-43), titled 'Fibonacci illustrated'. Pay close attention to how the task queue is populated by the recursive calls and how multiple threads pull from this queue.
4. Controlling Task Granularity for Performance
While tasks are powerful, they are not free. The OpenMP runtime incurs some overhead for creating, scheduling, and managing each task. If a task does very little work (e.g., fib(2)), the overhead can be greater than the computational savings. This is a problem of task granularity.
To avoid creating an excessive number of fine-grained (small) tasks, you can use the if clause on the task directive. If the expression in the if clause evaluates to false, the task is not added to the pool. Instead, it is executed immediately by the encountering thread as if it were a regular function call. This is called an undeferred or included task.
Let's refine our Fibonacci example. We can set a threshold below which we stop creating tasks and just compute the result sequentially.
// Define a threshold. For n below this, the work is too small to be a task.
const int FIB_CUTOFF = 20;
int fib(int n) {
if (n < 2) return n;
int x, y;
// Only create a task if n is large enough.
#pragma omp task shared(x) if(n > FIB_CUTOFF)
{
x = fib(n - 1);
}
// Same for the second recursive call.
#pragma omp task shared(y) if(n > FIB_CUTOFF)
{
y = fib(n - 2);
}
// If the 'if' conditions were false, the calls above executed synchronously.
// If they were true, tasks were created, and we must wait.
// The taskwait is still necessary for the cases where tasks are generated.
#pragma omp taskwait
return x + y;
}
This optimization is crucial for performance in many recursive algorithms. It prunes the tree of tasks, focusing parallelization on the larger chunks of work at the top of the recursion tree.
[PDF] Advanced OpenMP Tutorial - LRZ-Doku
Let's read about these 'cutoff' strategies to manage task creation overhead.
Read slides 39 and 41 (PDF pages 71 and 73 in this resource), titled 'Performance Analysis' and 'The if clause'. This explains the motivation for controlling task generation and how the if clause works.
Challenge: Recursive Matrix Multiplication
Given your background in mathematics and algorithms, you might appreciate a more complex example. A standard matrix multiplication algorithm can be formulated recursively. This structure is a perfect candidate for task-based parallelism.
I encourage you to look at this challenge problem. It involves a divide-and-conquer approach to matrix multiplication where sub-problems are spawned as tasks.
[PDF] OpenMP* and the fundamental design patterns of parallel ...
The following slides from Tim Mattson's OpenMP tutorial outline a recursive matrix multiplication algorithm and provide the parallel solution using tasks.
First, review slides 254-257 (PDF pages 262-265), which explain the recursive algorithm. Then, examine the solution on slide 268 (PDF page 276). Note the use of #pragma omp task, firstprivate on the index variables, and #pragma omp taskwait to synchronize the sub-problems. You don't need to implement this now, but try to understand the pattern.
Conclusion
In this lesson, you've learned how to break free from the structured for loop and tackle irregular workloads using OpenMP's flexible tasking model.
Key Takeaways:
- Tasks for Irregular Workloads: Use
#pragma omp taskfor problems that don't fit into simple loops, such as traversing linked lists, trees, or implementing recursive algorithms. - Execution Model: A common pattern is for a single thread (
#pragma omp single) to generate tasks that are then executed by the entire team of threads. - Task Synchronization: Use
#pragma omp taskwaitto wait for the completion of child tasks generated by the current thread, which is essential for gathering results in recursive patterns. - Data Scoping: Be extremely careful with data scoping.
firstprivateis often necessary to ensure each task captures the correct value of loop variables or parameters at the time of its creation. - Task Granularity: Avoid creating tasks that do too little work. Use the
ifclause to conditionally create tasks and switch to sequential execution for small sub-problems, reducing overhead.
Preview of the Next Lesson
Whether you are using loop-based or task-based parallelism, performance can be mysteriously degraded by a subtle hardware issue known as false sharing. This occurs when threads, operating on completely independent data, inadvertently interfere with each other's access to the CPU cache.
In our next lesson, we will dive into this topic. You will learn how to diagnose and mitigate false sharing to improve cache performance in OpenMP applications, a critical skill for writing truly high-performance parallel code.