Hello! Welcome to the third lesson in our "Synthesis and Exam Preparation" module.
In our last lesson, we explored the high-level theoretical limits of parallel performance using Amdahl's and Gustafson's Laws. We learned that the "serial fraction" of a program is a critical bottleneck. Now, it's time to zoom in and understand what constitutes that serial fraction at the code level.
This lesson directly addresses the learning outcome: Analyze C++ code fragments to identify data dependencies, race conditions, and performance bottlenecks. We will move from abstract laws to concrete code analysis, equipping you with the skills to spot issues that limit parallelism and correctness. This is a crucial step before we begin writing our own parallel code with frameworks like OpenMP.
We will cover three core topics:
- Data Dependencies: The fundamental ordering constraints within your code.
- Race Conditions: A common and serious bug in parallel programs.
- Performance Bottlenecks: Subtle issues, like false sharing, that can cripple performance even in correctly synchronized code.
1. Data Dependencies: The Rules of Engagement
Before we can parallelize code, we must understand the rules that govern the correct order of operations. These rules are called data dependencies. They aren't errors; they are inherent properties of an algorithm that dictate which parts must execute before others. Ignoring them leads to incorrect results.
In our earlier lessons on CPU architecture, we discussed how instructions are executed in a pipeline. Data dependencies are a primary reason why a pipeline might have to stall. Let's formalize the three main types.
Data Dependencies - Georgia Tech - HPCA: Part 1
This video from Georgia Tech's High Performance Computer Architecture course provides an excellent, concise explanation of the three types of data dependencies using simple instruction examples. While the examples use assembly language, the concepts apply directly to the operations within your C++ code.
Please watch from the beginning to 03:55. Focus on understanding the relationship between the read and write operations for each type of dependency.
As the video explains, there are three key types of data dependencies:
-
Read-After-Write (RAW) / Flow Dependence: This is the most intuitive one. An instruction needs to read a value that a previous instruction writes. This is a "true" dependence because data is literally flowing from one instruction to another.
- C++ Example:
The calculation ofint x = a + b; // Write to x int y = x * 2; // Read from xydepends on the result ofx.
- C++ Example:
-
Write-After-Read (WAR) / Anti-Dependence: An instruction needs to write to a location, but a previous instruction must read the old value from that location first. Reordering these would cause the read to get the new, incorrect value.
- C++ Example:
The calculation ofint y = x * 2; // Read from x x = a + b; // Write to xymust happen beforexis overwritten.
- C++ Example:
-
Write-After-Write (WAW) / Output Dependence: Two instructions write to the same location. Their order must be preserved to ensure the final value in that location is the correct one.
- C++ Example:
If these were reordered, any subsequent code usingx = a + b; // First write to x x = c + d; // Second write to xxwould get the wrong value.
- C++ Example:
These dependencies become especially critical when analyzing loops, as a dependency might exist between different iterations. This is called a loop-carried dependency.
For example, consider this loop:
for (int i = 1; i < N; ++i) {
a[i] = a[i-1] * 2;
}
Here, iteration i cannot begin until iteration i-1 has completed because it needs the value of a[i-1]. This is a loop-carried RAW dependency, and it prevents the loop from being easily parallelized.
The following reading provides more examples of analyzing dependencies in loops.
[PDF] Parallel Programming with OpenMP SHARCNET Tutorial
This SHARCNET tutorial on OpenMP has a great section on pitfalls, including data dependencies. Let's focus on its examples for detecting dependencies in loops.
Please read the subsection titled 'Data Dependences', focusing on the 'Detection' part and the examples labeled 'Loops with or without data dependence'. You can find this starting on page 33. Notice how changing the loop structure (e.g., the stride) can sometimes remove a dependency.
Practice: Identify the Dependencies
Let's test your understanding. Look at the MIPS code in the image below. Can you fill out the table to identify the dependencies between the instructions? Try identifying at least one of each type (RAW, WAR, WAW) if they exist.

Hint & Example Solution
Let's look at the first two instructions:
add $t0, $s1, $s2sub $s4, $t0, $s3
- Instruction 1 writes to
$t0. - Instruction 2 reads from
$t0. - This is a classic Read-After-Write (RAW) dependency. The
subinstruction needs the result from theaddinstruction. In a pipeline, this would cause a data hazard that needs to be resolved, typically by forwarding the result from the ALU output back to its input.
Now try to find the others. For example, look for two instructions that write to the same register (WAW), or where a later instruction writes to a register that an earlier one reads (WAR).
2. Race Conditions: The Perils of Uncontrolled Access
While data dependencies are about the required order of operations, race conditions are about the uncontrolled and unpredictable order of operations.
A race condition occurs when multiple threads access a shared resource (like a variable or a file) concurrently, and at least one of them modifies it. The final outcome of the program depends on the non-deterministic order in which the threads are scheduled by the operating system. This often leads to corrupted data and incorrect results.

Let's see how a race condition manifests in C++ and how we can fix it.
C++ Threading #3: Data Race and Mutex
This video by Bo Qian provides an excellent, practical demonstration of race conditions in C++. It starts with a simple, obvious race condition and then introduces a much more subtle one, driving home the importance of careful design.
Please watch the video in these segments: 00:00 - 01:26: Understand what a race condition is from the std::cout example. 01:26 - 04:59: See how std::mutex and std::lock_guard are used to protect a shared resource. The use of lock_guard is a key C++ RAII pattern for safety. 08:43 - 13:40: Pay close attention to this part. It shows why simply locking individual operations isn't enough. The interface itself can be flawed, breaking the logical atomicity of an operation. 13:40 - 14:13: A quick summary of the takeaways.
Key Takeaways from the Video:
- Critical Section: The block of code that accesses the shared resource is called the critical section.
- Mutual Exclusion: To prevent a race condition, we must ensure mutual exclusion—only one thread can be in the critical section at any given time. This is typically achieved with a mutex (short for mutual exclusion).
- RAII and
std::lock_guard: Manually callinglock()andunlock()on a mutex is risky; an exception or an early return could leave the mutex locked forever (a deadlock). The C++ RAII (Resource Acquisition Is Initialization) pattern, implemented bystd::lock_guard, automatically unlocks the mutex when the guard object goes out of scope, making your code much safer. - Atomicity: As the
stack.top()andstack.pop()example showed, you must protect the entire logical operation. The sequence of "get the top element" and then "pop the top element" must be atomic—it must appear to happen as a single, indivisible step from the perspective of other threads. Separately lockingtop()andlockingpop()` does not achieve this and still allows for a race condition.
3. Performance Bottlenecks: The Case of False Sharing
You've identified your dependencies, and you've protected all your shared data with mutexes. Your program is now correct, but it might still be slow—sometimes even slower than the single-threaded version! Why?
Often, the cause is a subtle performance bottleneck arising from the interaction between your code and the hardware. One of the most infamous examples is false sharing.
Since you are new to computer architecture, here's a quick primer:
- Your CPU doesn't load data from main memory byte by byte. It fetches data in fixed-size chunks called cache lines (typically 64 bytes).
- When a CPU core writes to a memory location, the cache coherence protocol ensures that any other core holding a copy of that cache line has its copy invalidated (marked as stale). This ensures other cores don't read old data.
False sharing happens when:
- Two threads on different cores are working with completely separate and independent variables.
- However, these variables happen to be located next to each other in memory and fall on the same cache line.
- When Thread 1 writes to its variable, it invalidates the cache line.
- When Thread 2 then tries to access its own variable, it gets a cache miss, because the line was invalidated. It has to fetch the line from memory again.
- When Thread 2 writes to its variable, it invalidates the line for Thread 1, and the cycle continues.
The threads aren't logically sharing data, but they are "falsely" sharing a cache line, leading to a performance-killing ping-pong match of cache invalidations.
The following article gives a fantastic C++-centric explanation of this problem and its solution.
Optimizations for C++ multi-threaded programming - Medium
This article provides a clear walkthrough of false sharing, including how to diagnose it and how to fix it in C++.
Please read the sections 'II. Understanding your hardware', 'III. False Sharing and its performance implications', and the solution that follows (up to the end of Figure 7). Focus on how the memory layout of the variables leads to the problem and how padding solves it.
The solution, as the article demonstrates, is often to add padding. By inserting unused bytes between your variables, you can force them onto different cache lines, eliminating the false sharing. This is a classic trade-off: you use a bit more memory to gain a lot more performance.
Conclusion
Today, we've built a foundational toolkit for analyzing code in a parallel context. You are now equipped to look at a C++ code fragment and ask the right questions to assess its suitability for parallel execution.
Key Takeaways:
- Data Dependencies (RAW, WAR, WAW) are inherent constraints in an algorithm that dictate execution order. Loop-carried dependencies are a primary obstacle to parallelizing loops.
- Race Conditions are bugs caused by unsynchronized access to shared data. They are fixed by enforcing mutual exclusion on critical sections, typically with a
std::mutexandstd::lock_guard. - Atomicity is crucial. You must protect entire logical operations, not just individual statements, to prevent subtle race conditions.
- Performance Bottlenecks like false sharing can arise from hardware interactions. Understanding memory layout and cache behavior is essential for writing high-performance parallel code.
In our next lesson, we will put this knowledge to practical use. We will begin our exploration of OpenMP, a widely-used framework for shared-memory parallelism, and learn to trace the execution of OpenMP code to predict final variable values and identify parallel regions.