Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 40

Locking Granularity and Performance Trade-offs

Recommended resources

Introduction

Hello! In our previous lessons, we've explored different ways to create parallel work in OpenMP, from the structured parallelism of #pragma omp for to the flexible, dynamic approach of #pragma omp task. We've seen that managing shared data with constructs like critical and atomic is essential for correctness. We also touched upon the idea of "granularity" when using tasks, where creating too many small tasks can be inefficient due to overhead.

This lesson applies that same thinking to synchronization. When we protect shared data, we must decide how much data to protect with a single lock. This decision introduces a fundamental performance trade-off in parallel programming. Our learning outcome is to describe the performance trade-offs of fine-grained versus coarse-grained locking. You'll learn how this choice impacts parallelism, code complexity, and the risk of bugs like deadlock.


1. What is Locking Granularity?

Locking granularity refers to the amount of data protected by a single lock. At one extreme, you could use a single lock for all shared data in your program. At the other, you could have a separate lock for every single element in a large data structure.

  • Coarse-Grained Locking: Using one lock to protect a large amount of data (e.g., a single lock for an entire hash table or a whole array of bank accounts).
  • Fine-Grained Locking: Using many locks to protect small, independent pieces of data (e.g., a lock for each bucket in a hash table, or a lock for each individual bank account).

The image below illustrates this concept. A lock could be applied at the database level (very coarse), at the level of a functional unit, or down to the level of an individual record (very fine).

Multiple Granularity Level Locking Hierarchy
This diagram shows a data hierarchy. A lock can be requested at any level. Locking at a high level (like `f1`) is coarse-grained, protecting all data underneath it. Locking at a low level (like `r111`) is fine-grained, protecting only a single item.

Choosing the right granularity is a crucial design decision that balances simplicity against performance.


2. The Core Trade-Off: Contention vs. Complexity

The choice between coarse- and fine-grained locking boils down to a trade-off between two competing concerns:

  1. Contention: This occurs when multiple threads are blocked, waiting to acquire the same lock. High contention serializes your code, destroying parallelism and performance.
  2. Complexity: This relates to the difficulty of implementing and debugging the locking strategy. Managing many locks is harder than managing one and introduces risks like deadlock.

Let's explore this trade-off in detail by reading a key section from a university guide on concurrency.

[PDF] Introduction to Shared-Memory Parallelism and Concurrency

This section from a guide by Dan Grossman at the University of Washington provides a concise and clear explanation of the trade-offs between coarse-grained and fine-grained locking.

Please read the subsection titled 'Guideline #2: Start with coarse-grained locking and move to finer-grained locking only if contention is hurting performance' under section 8.2. Focus on the definitions of coarse- and fine-grained locking and the discussion of contention versus implementation difficulty.

As the reading suggests, a common and wise strategy is to start with a simple, coarse-grained lock. If and only if profiling reveals that lock contention is a performance bottleneck, you should then invest the effort to refactor your code to use a more complex, fine-grained approach.


3. Granularity in Practice with OpenMP

This theoretical trade-off has direct, practical consequences when using OpenMP. Let's look at two examples.

3.1 atomic vs. critical

You've already used both atomic and critical. You can think of this as a choice of granularity:

  • #pragma omp atomic: This is a highly specialized, very fine-grained construct. It is restricted to protecting a single memory location during a simple update (like x++ or sum += val). Because its scope is so limited, hardware can often implement it with extremely efficient, low-overhead instructions.
  • #pragma omp critical: This is a more general-purpose, coarser-grained construct. It protects an entire block of code, which could modify multiple variables. An unnamed critical section uses a single global lock, making it very coarse. Named critical sections (critical(my_name)) are finer, but still typically have higher overhead than an atomic operation.

Using critical when atomic would suffice is a common performance mistake.

[PDF] Common Mistakes in OpenMP and How To Avoid Them

This paper on common OpenMP mistakes highlights the performance implications of choosing the wrong synchronization construct.

First, read item 11, 'Use of critical when atomic would be sufficient' in the list on page 2. Then, read the first and last points in the 'Synchronisation' checklist at the end of the paper (page 5). This reinforces the performance difference between atomic and critical.

3.2 Minimizing Time in Critical Sections

The main drawback of coarse-grained locking is that threads hold the lock for longer than necessary, blocking other threads. A key optimization principle is to hold locks for the minimum time required for correctness.

This often means refactoring code to pull as much work as possible out of the critical section.

[PDF] Common Mistakes in OpenMP and How To Avoid Them

Let's examine a concrete example of this optimization. The following resource shows how to restructure a loop to dramatically reduce the cost of synchronization.

Read section 3.7, 'Put too much Work inside Critical Region'. Pay close attention to the three code examples that find the maximum value in an array. Understand how each version reduces the amount of work and the frequency of entering the critical region.

The final, most optimized version in that reading should look familiar. Each thread finds a local maximum (priv_max) in its own chunk of the array, and only then enters a single critical section at the very end to update the global maximum. This is essentially a manual implementation of what the OpenMP reduction clause does for you automatically! It's a powerful pattern: perform work locally in parallel, then synchronize only when you need to combine the final results.


4. The Danger of Fine-Grained Locking: Deadlock

While fine-grained locking reduces contention, it increases complexity and introduces a severe risk: deadlock. A deadlock occurs when two or more threads are stuck in a circular wait, where each thread is waiting for a lock held by the next thread in the circle. The program grinds to a halt.

This risk arises when an operation needs to acquire multiple locks. For example, transferring money between two bank accounts might require locking both accounts to ensure the transaction is atomic. If Thread A locks account X and tries to lock Y, while Thread B locks account Y and tries to lock X, they will deadlock.

A classic and robust solution to this problem is lock ordering. If all threads are required to acquire locks in the same global order (e.g., by their memory address or a unique ID), circular waits become impossible.

16. Nondeterministic Parallel Programming

The following video segment from an MIT course uses the classic 'Dining Philosophers' problem to explain deadlock and then demonstrates the elegant lock-ordering solution.

Watch the segment from 59:08 to 1:08:30. The first part (59:08 - 1:05:54) introduces deadlock with a simple two-lock example and the 'Dining Philosophers' problem. The second part (1:05:54 - 1:08:30) presents the lock-ordering solution and provides a simple proof for why it works.


5. A Deeper Look at Lock Performance

Finally, it's useful to understand that the "cost" of a lock isn't just about the time spent waiting. The lock/unlock operations themselves consume CPU cycles. When a thread tries to acquire a lock that is already held, it has two options:

  1. Spinning: Enter a tight loop, repeatedly checking if the lock is free. This consumes CPU cycles but can acquire the lock very quickly once it's released. It's efficient if the wait time is expected to be very short.
  2. Yielding (or Blocking): Tell the OS to put the thread to sleep and wake it up when the lock is available. This frees up the CPU for other threads but incurs the high cost of a context switch (typically millions of CPU cycles). It's efficient if the wait time is expected to be long.

Modern systems often use a competitive strategy: spin for a short time, and if the lock is still not free, then yield. This provides a balance that performs well in most cases.

16. Nondeterministic Parallel Programming

To round out your understanding, this segment explores the low-level implementation choices for locks. It details the spinning vs. yielding trade-off and connects it to hardware costs like context switching. This gives you a much better intuition for why lock contention can be so damaging to performance.

Watch the segment from 34:04 to 59:08. The first part (34:04 - 46:46) explains spinning vs. yielding mutexes and how cache behavior influences their implementation. The second part (46:46 - 59:08) introduces 'competitive' mutexes, which try to get the best of both worlds, using the 'ski rental problem' as an analogy that might appeal to your math interests.


Conclusion

You've now explored the critical trade-offs involved in choosing a locking strategy. This is less about finding a single "right" answer and more about making an informed engineering decision based on the specific needs of your application.

Key Takeaways:

  • Coarse-Grained Locking: One lock for many data items. It's simple to implement and less prone to deadlocks but can cause high contention, limiting parallelism.
  • Fine-Grained Locking: Many locks for individual data items. It allows for high parallelism but is more complex to implement and carries a significant risk of deadlock.
  • The Strategy: Start with coarse-grained locking. Use a profiler to measure contention. Only move to a finer-grained strategy if contention is a proven performance bottleneck.
  • Optimization: Always aim to minimize the time spent holding a lock. Refactor code to perform expensive computations outside of critical sections.
  • Deadlock Prevention: When using multiple locks, prevent deadlock by enforcing a strict global lock acquisition order.

Preview of the Next Lesson

In this lesson, we saw how threads can compete for locks. But what if threads, operating on completely separate and independent data, still slow each other down? This can and does happen due to a subtle interaction with the CPU cache system known as false sharing. In our next lesson, you will learn to diagnose and mitigate false sharing to improve cache performance in OpenMP applications.