Table of Contents
Understanding Parallel Efficiency
Parallel efficiency describes how effectively a parallel program uses the computing resources it requests. If $T_1$ is the runtime of the best sequential version, and $T_p$ is the runtime with $p$ processors or processes, then the speedup $S_p$ and efficiency $E_p$ are defined as
$$S_p = \frac{T_1}{T_p}, \quad E_p = \frac{S_p}{p} = \frac{T_1}{p \, T_p}.$$
Rule: High speedup is not enough. What matters in HPC is high efficiency $E_p$ at the scale you care about, which means that added resources provide proportional benefit.
In practice, you may not have a perfect single core reference or may define efficiency relative to a baseline parallel run, for example
$$E_{p,\text{rel}} = \frac{T_{p_\text{ref}} \, p_\text{ref}}{T_p \, p},$$
where $p_\text{ref}$ is a smaller core count that you treat as the reference. The strategies in this chapter aim to keep $E_p$ as close to 1 as possible as you increase $p$.
Sources of Parallel Inefficiency
Parallel inefficiency rarely comes from a single cause. It is usually a combination of algorithmic limitations and low-level overheads. At a high level, the main contributors are:
- Serial or poorly parallelized regions that limit speedup.
- Load imbalance, where some workers are idle while others are busy.
- Communication overhead and synchronization costs.
- Contention for shared resources such as memory bandwidth, caches, and filesystems.
- Over-subscription and poor mapping of work to hardware.
The parent chapter introduced measurement and profiling concepts. Here we concentrate on interpreting those measurements specifically from the point of view of parallel efficiency, then connecting them to concrete tuning strategies.
Distinguishing Strong and Weak Scaling Issues
When improving parallel efficiency, it is crucial to separate strong and weak scaling problems.
In strong scaling, you keep the total problem size fixed and increase the number of processors. Efficiency typically deteriorates as $p$ grows because overheads become a larger fraction of the runtime. Problems such as serial fractions, synchronization, and communication latency dominate. Many of the techniques in this chapter are targeted at strong scaling.
In weak scaling, you increase the problem size in proportion to the number of processors, ideally keeping $T_p$ approximately constant. Inefficiencies here are often related to communication patterns that grow faster than computation, for example surface area versus volume effects in domain decompositions, or to metadata or global communication costs that do not scale linearly with problem size.
When you examine performance results, always ask whether a problem is inherently strong-scaling limited or whether the parallel algorithm and implementation can be restructured to behave more like a weak-scaling friendly method.
Using Profiling Data to Target Parallel Inefficiencies
Improving parallel efficiency begins from measurement, not guesswork. The steps below assume you already know how to use basic profiling and tracing tools.
First, identify the parallel region of interest. Measure wall-clock time and breakdowns such as:
Total time spent in computation, per thread or process.
Total time spent in communication routines, for example MPI collectives, point-to-point calls, or GPU host-device transfers.
Time waiting at synchronization points, such as barriers, critical sections, locks, and implicit synchronizations in OpenMP.
Load imbalance indicators, for example maximum versus average per-rank compute time, or timelines where some ranks are idle.
A useful pattern is to measure:
- Serial fraction estimate, using Amdahl-style analysis from observed speedup.
- Per-rank or per-thread computation time and their variability.
- Communication time and message statistics, including number and size of messages.
- Synchronization counts and time lost to waits.
Key practice: Always break total runtime into at least compute, communication, and idle/wait components. Parallel efficiency improvements come from reducing idle and overhead, not just from speeding up computation.
Reducing Serial and Poorly Parallelized Regions
The portion of your code that runs serially or is poorly parallelized directly limits efficiency as processor count grows.
First, locate serial hot spots. Use a profiler on a small number of cores to find functions or loops that remain single threaded or run on only one rank while others are idle. Typical culprits include:
Initialization or setup phases.
Global input and output.
Serial solvers, global reductions, or bottleneck algorithms.
Legacy libraries without parallel support.
Once identified, there are several strategies.
Restructure algorithms to increase concurrency. Replace inherently sequential methods with parallel-friendly alternatives. Examples include using parallel prefix-sum algorithms instead of serial scans, or multigrid methods instead of purely sequential relaxation steps. The mathematical details belong to other areas, but as an HPC practitioner you should ask whether the algorithm admits more concurrency.
Move work from serial to parallel regions. Sometimes work is unnecessarily placed outside parallel constructs in OpenMP or executed by a single MPI rank. Pull as much computation as possible into parallel regions and avoid repeatedly starting and stopping them. For multithreading, it is often beneficial to use fewer, larger parallel regions rather than many short ones.
Exploit hybrid parallelism when appropriate. If MPI-based parallelization leads to a few ranks performing heavy work while others idle during serial phases, it may be advantageous to use threads inside each rank and reduce global synchronization. Hybrid design is covered elsewhere, but from the perspective of efficiency this is about reducing the visible serial fraction to each set of workers.
Finally, consider algorithmic reformulation. Amdahl’s Law shows that even a small serial fraction sharply limits maximum speedup. Replacing a method that requires global information at every step with one that uses more local operations and fewer global synchronizations directly improves parallel efficiency.
Tackling Load Imbalance
Load imbalance is one of the most common and most severe causes of poor parallel efficiency. Even if the total work is large, parallel efficiency will be low if some workers finish early and sit idle while others are still processing.
Profiling will show imbalance as large variability in per-thread or per-rank compute times, or as tall bars for some workers and very short bars for others in timeline views. To improve balance, your goal is to distribute work so that all workers finish their share at approximately the same time.
There are several general strategies.
Improve static partitioning. If you divide the problem domain geometrically, such as partitioning a grid, try to match the partition to the distribution of work instead of simply assigning equal numbers of grid points. If some regions have more costly physics or higher resolution, give them narrower subdomains. Aim to equalize computational cost, not just element count.
Use dynamic scheduling where work varies unpredictably. For shared-memory loops, this may mean using OpenMP schedule(dynamic) or schedule(guided) for irregular workloads. For message passing, it can mean a master-worker or work-stealing scheme where idle ranks request extra tasks when they finish early. The trade-off is that dynamic schemes add overhead and may hurt data locality, so they are most beneficial when workload variability is high.
Rebalance regularly in time-dependent problems. For simulations where the distribution of work changes over time, for example adaptive mesh refinement or evolving particle distributions, a partitioning that was balanced at start may become very skewed later. Periodic repartitioning using graph or hypergraph partitioners can restore balance. The cost of repartitioning should be weighted against the runtime savings.
Aggregate small tasks. If the basic units of work are very small and you treat each as a separate task, overhead and idle times can dominate. Group fine-grained tasks into larger chunks that amortize task management costs and allow more predictable scheduling.
Rule: For good parallel efficiency, aim for roughly equal total work per worker, not just equal data size, and use profiling to guide how you define and assign "work".
Reducing Communication Overhead
Communication overhead, particularly in distributed-memory systems or between host and accelerator, is a major contributor to reduced parallel efficiency. There are two primary concerns: the cost of individual messages and the cost of collective communication.
First, reduce the volume of data communicated. Avoid sending large elements that are not needed on the other side. Use more compact representations if possible, such as single precision instead of double precision when acceptable, or compressed formats when beneficial. Eliminate redundant transfers by caching reusable data locally instead of repeatedly requesting it.
Second, reduce the number of messages. Many small messages are typically more expensive than fewer large ones due to latency. Combine small messages into larger buffers when possible, for example pack multiple small halo regions into a single contiguous buffer. In MPI, using derived datatypes can help you communicate non-contiguous data efficiently, so you do not need many small sends. If algorithms involve frequent handshakes, see if you can switch to one-way or aggregated communication.
Third, hide communication behind computation. Nonblocking communication or asynchronous transfers allow you to initiate a data transfer, perform independent computation, and only wait for the data when actually needed. Effective overlap requires that you restructure code so that useful work exists during the transfer period. As a practice, inspect timelines to see whether communications are mostly overlapped or cause visible gaps where processors are idle.
Collective operations need special attention. Global reductions, broadcasts, and gathers often limit scalability at large process counts. To improve efficiency:
Reduce the frequency of collective calls. Accumulate partial results locally for several steps before performing a global reduction if the algorithm tolerates delayed global updates.
Change numerical formulations. Some methods rely on a global norm at every iteration. In some cases, algorithms can be reformulated to use pipelined or communication-avoiding schemes that perform fewer reductions or overlap them with computation.
Use efficient collective implementations. HPC MPI libraries offer optimized collectives that may exploit topology, tree-based algorithms, or hardware accelerations. Ensure you are calling the right collective operations and not re-implementing them with point-to-point patterns that scale poorly.
Minimizing Synchronization and Contention
Synchronization constructs such as barriers, locks, critical sections, and implicit synchronizations are necessary for correctness, but they directly reduce parallel efficiency. Contention occurs when multiple workers compete for a shared resource, for example a lock-protected data structure or a shared memory location, and spend time waiting rather than computing.
To improve efficiency, start by identifying where synchronization is actually needed. Many codes use global barriers where only partial synchronization would suffice, or overuse critical sections around operations that could be made independent.
Reduce the frequency of barriers. Avoid global barriers inside tight loops if possible. Use point-to-point synchronization and local dependencies where appropriate, such as producer-consumer patterns. In shared-memory frameworks, some constructs contain implicit barriers at the end of parallel regions or work-sharing constructs. Often these can be disabled with clauses if you know they are unnecessary.
Shorten critical sections. Only protect the minimal required code and data. Instead of assembling a large data structure under a single lock, consider a design where each thread produces a private partial result, then you combine these results in a separate reduction phase. This pattern reduces lock contention and is often easier to optimize.
Use more scalable synchronization primitives. Atomic operations can be less costly than locks for simple updates such as counters, provided you understand the potential for hotspots. For more complex patterns, lock-free or wait-free algorithms might give better scaling, but they require careful design. From an efficiency perspective, you should aim to replace high-contention global locks with finer grained or hierarchical schemes.
Avoid unnecessary serialization in hybrid codes. A common problem is a single thread performing work while others idle. Ensure that only those sections that truly must be serialized are inside critical regions. Watch for hidden serializations, for example I/O calls inside parallel regions that implicitly serialize on the filesystem or on a runtime lock.
Key principle: Parallel efficiency increases when you avoid global synchronization where local coordination is enough, and when you replace shared updates with private work plus reduction whenever possible.
Improving Data and Task Decomposition
The way you decompose data and tasks across cores and nodes strongly affects both load balance and communication patterns, which in turn determine parallel efficiency.
Effective data decomposition aims to partition the computational domain so that each worker handles a portion that:
Contains mostly local data, which reduces communication.
Has similar computational cost to other portions, which improves load balance.
Aligns with the underlying hardware topology, for example NUMA nodes or network dimensions, to reduce remote accesses.
In regular structured problems, common decompositions include one-dimensional, two-dimensional, or three-dimensional domain splits. While a one-dimensional split is easy to implement, it can lead to higher surface-to-volume ratios and more communication per computation. Higher-dimensional decompositions often give a better ratio and can yield improved efficiency, especially at large process counts.
In irregular problems, graph-based decompositions are widely used. A graph partitions the computational units as vertices and communication or interactions as edges. Partitioning tools attempt to minimize edge cuts while balancing vertex weights, which represent computational work. This approach attempts to simultaneously achieve load balance and communication minimization.
Task decomposition is about how you partition the work units themselves. If tasks are too large, you may not be able to distribute work evenly, particularly when some tasks are inherently more expensive than others. If tasks are too small, scheduling overhead and loss of data locality can degrade efficiency. A useful strategy is to select task sizes that are large enough to be efficient on a single core or node, but still numerous enough for flexible scheduling and redistribution.
Topology-aware mapping is an additional dimension. Many modern systems have hierarchical structures such as sockets, NUMA domains, and multi-level network fabrics. Mapping nearby data and communicating tasks to physically close resources reduces latency and network contention. MPI implementations often provide tools or environment variables to control rank placement, and hybrid codes can control thread affinity to cores. Aligning your logical decomposition with the hardware topology improves both communication and memory performance, and therefore parallel efficiency.
Managing Resource Contention and Over-Subscription
Parallel efficiency is harmed not only by your own application structure, but also by how it interacts with the shared resources of the node and cluster.
On a single node, major shared resources include memory bandwidth, cache capacity, and the floating-point units shared among cores. If you run too many threads per core or too many heavy processes per node, they compete for these resources. This competition can lead to memory stalls, cache thrashing, and decreased effective throughput per worker. The actual optimal number of threads per core or cores per socket must be discovered experimentally, with guidance from hardware performance counters and profiling tools.
On the cluster level, over-subscription of the network and parallel filesystem can degrade performance for your application and others. Excessively frequent or large communications, or many simultaneous large I/O operations, can saturate these systems. From the perspective of parallel efficiency, this means your code may spend more time waiting on communication and I/O, reducing $E_p$ even if your computational scaling looks good.
Use job scheduler settings that match the intended parallelization scheme. If you are using MPI with one process per core, avoid additional threading inside those processes unless you carefully control thread affinity and count. Conversely, if you rely heavily on threads, request fewer MPI ranks per node and more cores per rank.
In hybrid and accelerator codes, be careful not to oversubscribe GPU resources or memory. Launching more streams or kernels than the device can handle does not always yield speedups. It can reduce efficiency by causing queuing delays and increased contention. Always monitor actual device utilization and concurrency and adjust host-level parallelism accordingly.
Rule: High parallel efficiency often requires using fewer, well-matched resources rather than the maximum allowed. Oversubscription and contention can easily destroy scaling.
Balancing Accuracy, Granularity, and Efficiency
Sometimes improving parallel efficiency is not only a matter of changing code structure, but also of making informed choices about algorithmic parameters and problem configuration.
Granularity refers to the amount of work per parallel unit. Coarser granularity, where each task does more work, usually improves efficiency by reducing overhead in communication and scheduling. However, too coarse a granularity can limit flexibility for load balancing, particularly in heterogeneous or irregular environments.
Increasing problem size can improve strong-scaling efficiency. If you have fixed hardware and want to raise efficiency, sometimes the most practical method is to increase the work per core by solving a larger problem or by tightening convergence criteria. This does not change the strong-scaling behavior mathematically, but can shift the regime where your runs operate into a more favorable efficiency region by making overheads a smaller fraction of total runtime.
Accuracy and numerical settings also matter. Algorithms that require many global synchronizations for strict accuracy may be reformulated into approximate or communication-avoiding versions that deliver acceptable accuracy with better scaling. In some cases, looser tolerances, fewer synchronization points, or multi-level solvers can dramatically change the profile of communication versus computation.
The key is to treat numerical and scientific choices as part of performance tuning. Parallel efficiency is more useful when considered together with the scientific goals of the computation, not in isolation.
Systematic Workflow for Improving Parallel Efficiency
Improving parallel efficiency is an iterative process and should follow a disciplined workflow.
First, establish a baseline. Choose representative problem sizes and a range of processor counts. Measure overall runtime, speedup, and efficiency, and gather a coarse-grained breakdown into computation, communication, and idle time.
Second, identify the dominant source of inefficiency at your current scale. Is it imbalance, excessive communication, synchronization, serial sections, or contention? Use the profiling and tracing tools introduced previously to confirm.
Third, apply targeted changes. If imbalance dominates, adjust decomposition and scheduling. If communication dominates, reduce data volume, improve message aggregation, and introduce overlap. If synchronization controls the timeline, refine your locking and barrier strategy. If serial regions stand out, parallelize or restructure them. Always change one major factor at a time to understand its effect.
Fourth, re-measure and compare. After each significant modification, re-run the scaling tests and compare efficiency curves. Look not only at absolute times, but also at how $E_p$ behaves across different $p$. A successful improvement usually flattens the decline in efficiency and moves the onset of poor scaling to larger processor counts.
Finally, revisit your target scale. There is always a point where increasing $p$ will produce diminishing returns due to fundamental algorithmic limits. For production use, aim to run in a regime where your measured efficiency is acceptable given the cost and availability of resources. For many practical problems, using a moderate core count with high efficiency is more cost-effective than using maximum cores with very low efficiency.
Summary principle: Use a measure, analyze, modify, re-measure cycle. Parallel efficiency should be tuned empirically and guided by data, not by assumptions about how the code "should" scale.
By consistently applying these strategies, you can transform raw parallelism into effective parallel performance that uses HPC resources responsibly while delivering scientific or industrial results at scale.