COMP 3150Operating Systems

01 Introduction

Until the mid-2000s, CPU clock speeds grew steadily and single-threaded programs grew faster for free. That ended. Modern CPUs gain performance by adding cores rather than by increasing clock speed, because pushing the clock higher runs into hard physical limits on power consumption and heat dissipation. For software to benefit from this generation of hardware, it must expose parallelism — and threads are the primary mechanism for doing so.

The kernel-thread model from Lesson 4.3 makes parallelism possible: the kernel can dispatch different threads of the same process onto different CPU cores simultaneously. This lesson examines whether it delivers what it promises. The short answer is that parallelism is real, but scaling is sublinear — a program that uses four threads on a four-core machine does not complete in a quarter of the time, and the reasons why are deeply informative about how modern hardware works.

The lesson covers five topics. First, the distinction between concurrency and true parallelism. Second, the hardware mechanisms that make parallelism possible and the costs they impose: caches, cache coherence, and false sharing. Third, the memory consistency models that define what reorderings are permitted and how languages expose them. Fourth, Amdahl's law, which formalises why speedup is sublinear. Fifth, the practical limits — lock contention, memory bandwidth, synchronization overhead, and NUMA effects — that shape the design of parallel programs. By the end, you will have both the theory that predicts scaling and the measurement techniques to verify it empirically.

Prerequisites

This lesson builds on Lesson 4.3 (the kernel-thread model, which makes parallelism possible), Lesson 3.5 (context switching and its costs), and Lesson 8.1 (the memory hierarchy and cache locality, which is the foundation of the cache-coherence discussion). If the concept of a cache line is not yet clear, review Lesson 8.1 before continuing.

02 Learning objectives

By the end of this lesson you will be able to

  • Explain true parallelism.
  • Describe cache coherence.
  • Analyse memory models.
  • Evaluate scaling limits.
  • Measure parallel speedup.

This lesson contributes to U4.3 and to course learning outcomes CLO2 and CLO8.

03 Required reading

  • OSTEP — Chapter 26, the section on hardware considerations in concurrency. OSTEP's treatment of cache coherence and memory barriers is concise and approachable.
  • Operating System Concepts — Chapter 4, Section 4.5, Multicore Programming. The classical presentation of the challenges of parallel programming.
  • Patterson & Hennessy — Computer Architecture: A Quantitative Approach, the chapter on memory hierarchy and multiprocessor systems. For the hardware side of cache coherence.
  • Amdahl, G. M. — Validity of the Single Processor Approach to Achieving Large-Scale Computing Capabilities. AFIPS, 1967. The original paper introducing the speedup law.
  • Linux man pages: man 1 taskset, man 1 lscpu, man 1 perf-stat, man 7 numa. These tools let you observe the multicore behaviour discussed in the lesson.

Reading strategy

Read the OSTEP section first for the practical framing of cache coherence and false sharing. Then read the Silberschatz section for the classical treatment of parallel-programming challenges. The Amdahl paper is short and worth reading in the original — his argument about the limits of parallel speedup was made decades before multicore hardware made it urgent.

04 Core content

4.1 Concurrency versus parallelism

The two terms are often used interchangeably, but they describe different things. Getting the distinction right is the foundation of everything that follows.

Concurrency
Multiple tasks are making progress during the same time period. On a single CPU core, concurrency is achieved by rapid context switching — the processor alternates between tasks so quickly that they appear to run at once, but only one is executing at any instant. Concurrency is about dealing with many things at once.
Parallelism
Multiple tasks are executing at the same instant, on different CPU cores. This requires hardware with more than one core. Parallelism is about doing many things at once.

A multithreaded program on a single-core machine is concurrent but not parallel: the threads interleave on one core. The same program on a multicore machine is both concurrent and parallel: the threads genuinely execute at the same time. The thread abstraction is the same in both cases; only the hardware changes what is achieved.

A useful way to remember the distinction: concurrency is a program property, parallelism is a hardware property. You can write concurrent code that runs on any machine. Whether it runs in parallel depends on how many cores the machine has.

4.2 The hardware: cores, caches, and coherence

Every core in a modern CPU has its own private L1 cache (and often its own L2 cache), and the cores share a larger L3 cache and the main memory. This design is what makes parallelism fast: each core can service most of its memory accesses without contending with the others. But it also creates a fundamental problem, because the same memory location may be cached in the private caches of several cores at once.

Suppose core 0 writes to address X, and later core 1 reads address X. If core 1's copy of X is stale — because core 0's write did not propagate to it — core 1 will read the wrong value. The hardware must prevent this. The mechanism that ensures every core sees a consistent view of memory is called cache coherence.

Cache coherence is maintained by a hardware protocol, of which the most common family is MESI (and its variants). Under MESI, each cache line is in one of four states:

  • Modified (M). This core has a dirty copy — it has been written to and the write has not yet reached main memory. No other core has a valid copy.
  • Exclusive (E). This core has a clean copy matching main memory, and no other core has a copy. It can transition to Modified on a write without notifying anyone.
  • Shared (S). Multiple cores have clean copies matching main memory. Any core can read the line; a write requires coordination with the others.
  • Invalid (I). This core's copy is stale or absent. A read or write will miss and must fetch from another core or from main memory.

When a core wants to write to a line that is currently Shared, it must first invalidate the copies in the other caches — a process that involves sending messages on the coherence bus. When a core reads a line that another core holds in Modified state, the modified copy must be written back or forwarded before the read can proceed.

Coherence traffic is the fundamental cost of sharing mutable data across cores. When a variable is written frequently by several cores, each write forces the others to invalidate their copies, and each subsequent read requires a fetch. The line bounces between cores at cache-line granularity, and the effective cost of a write becomes the cost of the coherence protocol rather than the cost of a local cache update.

4.3 False sharing

The coherence protocol operates on cache lines — typically 64 bytes on x86-64 — not on individual variables. This leads to a subtle and surprisingly impactful performance problem.

Consider two threads running on two cores, each incrementing its own private counter. The counters are declared as adjacent elements of an array:

struct counters {
    long a;   /* thread 0 increments this */
    long b;   /* thread 1 increments this */
} c;

Logically, the two threads never touch the same variable. In a correct memory model, there is no reason for them to slow each other down. But a and b are adjacent in memory — 8 bytes each, so both live in the same 64-byte cache line. When thread 0 writes to a, the coherence protocol invalidates the line in thread 1's cache. When thread 1 writes to b, it invalidates the line in thread 0's cache. The line bounces between the two cores at every increment, and throughput collapses.

This is false sharing: sharing that exists at the cache-line level but not at the level of the program's logic. It is called "false" because the threads do not actually share the variables; they only share the cache line that happens to contain them.

False sharing and its solution Two panels. On the left, two threads on two cores each write their own variable, but the variables share a cache line; each write invalidates the line in the other cache, and the line bounces. On the right, the variables are padded so that each occupies its own cache line, and there is no coherence traffic. FALSE SHARING · THE PROBLEM AND THE FIX WITHOUT PADDING two variables, one cache line Core 0 Core 1 cache line copy of a,b cache line copy of a,b line bounces on every write memory layout long a long b both in the same 64-byte line Threads 0 and 1 never touch the same variable. Yet every write forces a coherence invalidation. Throughput collapses — often 10× or worse. WITH PADDING each variable on its own line Core 0 Core 1 cache line copy of a only cache line copy of b only no coherence traffic memory layout long a padding long b each variable in its own 64-byte line Cost: a few bytes of wasted memory per padded variable. Benefit: no false coherence invalidations. Throughput scales with the number of cores.
Figure 4.4 — False sharing and its solution. Left: two threads writing to adjacent variables share a cache line, and the line bounces between cores on every write. Right: the variables are padded to separate cache lines, and each core works on its own line with no coherence traffic.

False sharing is easy to demonstrate and easy to fix once you know about it. The fix is padding: ensure that variables written by different threads fall on different cache lines. The exact size of the padding must match the cache line size of the target architecture — 64 bytes on x86-64 and most ARM cores today, 128 bytes on some others — and cross-platform code often pads to a safe upper bound.

The classic way to express padding in C is with a union or a char array sized to force alignment:

#define CACHE_LINE_SIZE 64

struct padded_counter {
    long value;
    char padding[CACHE_LINE_SIZE - sizeof(long)];
};

Modern C and C++ offer a portable alternative: alignas(64) or _Alignas(64) can be applied to a variable to force it onto a 64-byte boundary, and the compiler handles the layout. Either approach gives the same result: the two variables end up in different cache lines, and the coherence traffic disappears.

4.4 Memory consistency models

Cache coherence ensures that every core sees a consistent view of a single memory location. A related but distinct question is what order the operations on different memory locations appear to happen in. This is the domain of the memory consistency model.

The problem is that modern CPUs reorder memory operations for performance. When the programmer writes code that stores to A and then stores to B, the hardware may reorder these operations on the bus — because they are to different addresses, there is no hardware reason to keep them in order. If the program requires A to be visible before B, this reordering breaks the program's assumptions.

A memory consistency model is a formal specification of what reorderings the hardware is permitted to make. Three models matter in practice:

Sequential consistency
Every memory operation appears to happen in program order, and every thread's operations appear in a single global order that is consistent across all threads. This is the strongest model and the easiest to reason about — it matches the intuition most programmers have. It is also the most expensive to implement, because it requires the hardware to enforce the ordering on every operation. No modern general-purpose CPU provides it by default for all operations.
Total Store Order (TSO)
The x86 and x86-64 model. Stores may be reordered with subsequent loads to different addresses, but not with each other, and loads are not reordered with each other. The reordering that is permitted corresponds to the CPU's store buffer: a store may still be in the buffer when a later load executes, so the load does not see the store's effect. TSO is close to sequential consistency in practice, and x86 programmers can usually write correct concurrent code without explicit memory barriers — but not always.
Relaxed (or weakly ordered)
The ARM and POWER models. Almost no ordering is guaranteed: loads and stores may be reordered against each other, in essentially every combination. Correct code must insert explicit memory barriers, or use language-level primitives that do so. ARM has been the dominant mobile architecture for years and is now on the desktop through Apple Silicon, so even programmes that used to target x86 only must now be written to handle relaxed ordering.

Programming languages abstract over the hardware models. In C11 and C++11 and later, the <stdatomic.h> and <atomic> headers provide atomic operations with an explicit memory-ordering argument:

  • memory_order_relaxed — atomicity only, no ordering.
  • memory_order_acquire — no subsequent operations may be reordered before this one.
  • memory_order_release — no prior operations may be reordered after this one.
  • memory_order_acq_rel — both acquire and release.
  • memory_order_seq_cst — the strongest, matching sequential consistency. The default if unspecified.

The reason this matters for multicore programming is that correct synchronization depends on the ordering model. A lock that appears correct on x86 may fail on ARM if it uses only relaxed atomics, because the compiler and the CPU are free to reorder the operations that the lock depends on. Getting this right is subtle, and it is a good reason to use the standard synchronization primitives (mutexes, condition variables, C11 atomics with proper ordering) rather than hand-rolling your own.

4.5 Amdahl's law

Even when a program has no cache-coherence problems and no synchronization, its speedup from parallelism is bounded. The bound is expressed by Amdahl's law, which answers the question: if a fraction p of a program's work is parallelisable and the remaining fraction s = 1 − p is inherently serial, what speedup is possible with N processors?

speedup(N) = 1 / (s + p / N)

The law shows that the serial fraction dominates the result. As N grows large, the speedup approaches 1/s, which is the maximum speedup the program can ever achieve, no matter how many cores are available.

Maximum speedup by serial fraction
Serial fraction Speedup with 2 cores 4 cores 8 cores 16 cores ∞ cores
0%2.004.008.0016.00∞
5%1.903.485.939.1420.00
10%1.823.084.716.4010.00
25%1.602.292.913.374.00
50%1.331.601.781.882.00

The table is the practical heart of the lesson. Even a modest serial fraction — 10% — caps the speedup at 10× no matter how many cores the machine has. And with 25% serial, the ceiling is 4×, which is achieved at around sixteen cores: adding more cores beyond that buys almost nothing.

In real programs, the "serial fraction" includes more than just the code that cannot be parallelised. It includes the initialization phase, the final reduction of partial results, the portion of the work performed while holding a lock, and the overhead of thread creation and synchronization. Because each of these grows as threads are added to the system, the effective serial fraction often increases with N, making scaling worse than Amdahl's law alone predicts. This observation is formalised in a related law — Gustafson's law — which considers a fixed problem size per core rather than a fixed total problem size, and gives a much more optimistic view. Amdahl's law is the right model when the goal is to finish a fixed job faster; Gustafson's law is the right model when the goal is to solve larger problems in the same time.

4.6 Practical scaling limits

Beyond Amdahl's law and cache coherence, four specific mechanisms limit the scaling of real parallel programs.

Lock contention

Every time a thread must acquire a lock, other threads that want the same lock must wait. The proportion of time spent waiting grows as more threads compete for the same lock, and beyond a certain point, adding threads makes the program slower rather than faster. This is often called lock convoy behaviour. The fix is to reduce the amount of work done while holding a lock, to use finer-grained locks, or to use lock-free data structures where possible.

Memory bandwidth

Every core shares the same memory controller and the same L3 cache. A program whose working set is much larger than the per-core caches becomes bandwidth-bound: adding more cores does not help because the bottleneck is the memory subsystem, not the compute. This is common in scientific computing and in data-intensive applications, and it is why hardware manufacturers have moved memory controllers onto the CPU die and added multi-channel memory.

Synchronization overhead

Every lock acquisition and release costs cycles. Every atomic operation on a shared variable is a coherence event. Every barrier forces all threads to wait for the slowest one. As the number of threads grows, the aggregate synchronization cost grows with it — often quadratically, because the cost of a barrier is proportional to the number of threads arriving at it. Reducing the number of synchronization points is a common optimization.

NUMA effects

On multi-socket systems, each socket has its own memory controller and its own bank of DRAM. Accessing memory on a remote socket is significantly slower than accessing local memory — often 1.5–2× the latency. A thread that happens to run on socket 0 but reads data stored on socket 1 pays this penalty on every access. Good NUMA policies place threads near their data, or migrate data toward the threads that use it, and the operating system's scheduler plays a central role.

4.7 Multicore scheduling in Linux

The kernel's scheduler plays a central role in determining how well a parallel program scales. Linux's multicore scheduler has three main concerns:

  • Load balancing. When a CPU core becomes idle and there are runnable tasks on other cores, the kernel migrates a task to the idle core. This is done by a per-core idle balancer that runs when a core has no work, and by periodic load-balancing passes.
  • Affinity and cache locality. Moving a task between cores destroys its cache locality — the L1 and L2 caches on the new core do not hold the task's working set. The kernel therefore tries to keep each task on the same core when possible, migrating only when the benefit of balancing outweighs the cost of the migration.
  • NUMA awareness. On multi-socket systems, the scheduler tries to keep threads on the socket where their memory is allocated, and to migrate threads between sockets only when necessary. On Linux, NUMA balancing is a configurable feature and interacts closely with the memory manager.

From user space, several tools let you influence or observe these decisions. The taskset command pins a process or thread to a specific set of cores. The numactl command controls memory placement on NUMA systems. Both are covered in the interactive activity below.

4.8 Measuring parallel speedup

The metrics that matter for a parallel program are the same ones used in Unit 3 for scheduling, adapted to the parallel setting:

  • Speedup. The ratio of the time to complete the task on one core to the time on N cores. S(N) = T(1) / T(N).
  • Efficiency. The speedup divided by the number of cores. E(N) = S(N) / N. An efficiency of 1.0 means perfect scaling; anything less indicates overhead or a serial fraction.
  • Scalability. How the speedup or efficiency changes as N grows. A program is scalable if the speedup continues to grow roughly linearly (or at least continues to grow usefully) as cores are added.

Measuring these values is a matter of running the same workload under different thread counts and recording the elapsed times. The worked example below walks through this in detail, and the interactive activity has you perform the measurement on your own VM.

4.9 What scalable parallel programs look like

Programs that scale well tend to share a set of design characteristics. If you are writing parallel code, these are the principles to keep in mind.

  • Partition the work, not the data structure. Split the computation into independent pieces that touch disjoint regions of memory. Each thread works on its own slice, and no coordination is needed while the work is being done.
  • Reduce shared mutable state. The less shared mutable state a program has, the less synchronization it needs and the fewer coherence events it generates. Prefer immutable data, thread-local accumulators, and per-thread copies that are combined at the end.
  • Combine partial results at the end. Each thread produces its own partial result, and a single reduction step at the end combines them. This is the canonical "map-reduce" pattern and it eliminates almost all synchronization from the hot path.
  • Watch the cache. Design data structures for cache locality. If a thread reads a batch of nearby items rather than scattered items, it uses its cache more efficiently and the coherence traffic goes down.
  • Pad shared structures. Any struct that has fields written by different threads should be padded to avoid false sharing.
  • Measure, do not assume. The interaction between the workload, the hardware, and the scheduler is complex enough that predictions are unreliable. Measure the speedup at several thread counts and observe where it stops improving.

05 Worked example

To make the concepts concrete, measure the speedup of a simple parallel workload at increasing thread counts, and then demonstrate the effect of false sharing by measuring two versions of the same computation.

Part 1 — Parallel speedup on a matrix multiply

The task is to multiply two 1000×1000 matrices of doubles. The computation is O(N³) — about a billion floating-point operations for this size — so it dominates any thread-management overhead, and it partitions naturally by rows.

#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <time.h>

#define N 1000

static double A[N][N];
static double B[N][N];
static double C[N][N];

typedef struct { int row_start, row_end; } chunk_t;

static void *worker(void *arg) {
    chunk_t *c = arg;
    for (int i = c->row_start; i < c->row_end; i++) {
        for (int j = 0; j < N; j++) {
            double s = 0.0;
            for (int k = 0; k < N; k++) s += A[i][k] * B[k][j];
            C[i][j] = s;
        }
    }
    return NULL;
}

int main(int argc, char **argv) {
    int nthreads = (argc > 1) ? atoi(argv[1]) : 1;

    /* Initialise A and B */
    for (int i = 0; i < N; i++)
        for (int j = 0; j < N; j++) { A[i][j] = i + j; B[i][j] = i - j; }

    struct timespec t0, t1;
    clock_gettime(CLOCK_MONOTONIC, &t0);

    pthread_t t[nthreads];
    chunk_t   c[nthreads];
    int per = N / nthreads;
    for (int w = 0; w < nthreads; w++) {
        c[w].row_start = w * per;
        c[w].row_end   = (w == nthreads - 1) ? N : (w + 1) * per;
        pthread_create(&t[w], NULL, worker, &c[w]);
    }
    for (int w = 0; w < nthreads; w++) pthread_join(t[w], NULL);

    clock_gettime(CLOCK_MONOTONIC, &t1);
    double elapsed = (t1.tv_sec - t0.tv_sec)
                   + (t1.tv_nsec - t0.tv_nsec) / 1e9;
    printf("threads=%d  time=%.3f s\n", nthreads, elapsed);
    return 0;
}

Build and run at increasing thread counts on a four-core VM:

gcc -Wall -Wextra -g -O2 -pthread -o matmul matmul.c
for n in 1 2 4; do ./matmul $n; done

Typical output:

threads=1  time=8.10 s
threads=2  time=4.15 s
threads=4  time=2.30 s

The speedup and efficiency:

Measured speedup and efficiency on a 4-core VM
Threads Time (s) Speedup Efficiency
18.101.001.00
24.151.950.98
42.303.520.88

Speedup is nearly ideal at two threads (efficiency 98%) and good at four threads (efficiency 88%). The 12% loss at four threads comes from the serial portions of the program (initialisation, thread creation, reduction) and from memory bandwidth contention — the four threads together exceed what the memory subsystem can supply.

Try eight threads on the same four-core VM:

threads=8  time=1.95 s
speedup:  4.15×
efficiency: 0.52

Speedup improves only slightly — from 3.52× to 4.15× — even though the thread count doubled. This is the practical ceiling of the machine: the four physical cores are already saturated, and the extra four threads only add context-switching overhead and cache pressure. This is what "scaling limit" looks like in a measurement.

Part 2 — False sharing in action

Now consider a different program: four threads, each incrementing its own private counter one hundred million times. Two versions of the same struct:

/* Version A — no padding, all counters share a cache line */
struct counters_a {
    long value[4];
} ca;

/* Version B — padded, each counter on its own cache line */
struct counters_b {
    long value;
    char padding[64 - sizeof(long)];
} cb[4];

Both versions perform the same logical work: each of four threads increments its own counter. The only difference is the memory layout. Time each version:

Version A (no padding):
    time:        8.42 s

Version B (padded):
    time:        0.31 s

speedup:         27×

The padded version is twenty-seven times faster than the unpadded version, despite performing identical computation. The only difference is that the unpadded version has the four counters in the same cache line, so every increment by one thread invalidates the line in the other three cores' caches. The padded version has each counter on its own cache line, so there is no coherence traffic at all.

This is the most striking example in the course of a performance problem that is invisible in the source code. The program contains no locks, no atomics, and no explicit synchronization — the counters are truly independent. Yet the running time differs by a factor of twenty-seven based only on the arrangement of the fields in memory.

Part 3 — Observing the coherence traffic

Linux exposes the relevant counters through perf. For a false-sharing workload, the cache-miss and coherence counters spike dramatically:

perf stat -e cache-misses,cache-references,L1-dcache-load-misses ./program

Compare the numbers between the padded and unpadded versions. The unpadded version will show several times more cache misses and coherence events, because the line is bouncing between cores on every increment. The perf output makes the invisible traffic visible.

06 Interactive activity

Activity · measure multicore scaling on your VM

Work through the following in your course VM. The goal is to see both the theory and the practice of parallel scaling in your own measurements.

  1. Establish the baseline.
    lscpu | head -20
    nproc
    How many physical cores does your VM have? How many logical processors? Note the CPU model and the cache sizes reported by lscpu. These will be the reference points for the rest of the activity.
  2. Build the matrix multiply. Copy the matmul.c program from the worked example. Build with the course flags and run it with one, two, and four threads:
    gcc -Wall -Wextra -g -O2 -pthread -o matmul matmul.c
    for n in 1 2 4; do ./matmul $n; done
    Record the elapsed times. Compute the speedup and efficiency at each thread count.
  3. Push past the core count. Run with eight threads even if you only have four cores:
    ./matmul 8
    Is the speedup better or worse than at four threads? By how much? What does the result tell you about the ceiling of the machine?
  4. Demonstrate false sharing. Write the two counter programs from the worked example — one with no padding, one with padding. Build both and time each with four threads. Record both times. Compute the ratio.
  5. Observe the coherence traffic. If perf is available in your VM, run both versions under it:
    perf stat -e cache-misses,cache-references ./nopad
    perf stat -e cache-misses,cache-references ./pad
    Compare the counts. The padded version should show far fewer cache misses and coherence events.
  6. Pin threads to specific cores. Use taskset to constrain the parallel matrix multiply to two cores and compare against the unpinned version:
    taskset -c 0,1 ./matmul 2
    taskset -c 0,1 ./matmul 4
    With four threads pinned to two cores, the extra threads are pure overhead. Is the runtime longer than the two-thread case? Explain.
  7. Check the CPU frequency. Run a CPU-intensive workload and observe the frequency scaling:
    watch -n 1 'grep "cpu MHz" /proc/cpuinfo'
    Does the frequency change as more cores become busy? This is the thermal and power-limited behaviour that makes multicore scaling sublinear on modern laptops and mobile devices.

Reflect: which measurement best illustrated the gap between theoretical and achieved speedup? Which best illustrated the effect of a hardware mechanism that the program itself does not control? If you were asked to write a parallel program that scales to sixteen cores, what are the three most important design decisions you would make differently from a single-threaded program?

07 Knowledge check

Check your understanding

Question 1

Distinguish concurrency from parallelism, and give one example of a program that is concurrent but not parallel.

Show answer
Concurrency means multiple tasks are making progress during the same time period — they interleave on one CPU core through context switching. Parallelism means multiple tasks are executing at the same instant on different CPU cores. A multithreaded program on a single-core machine is concurrent but not parallel: the threads take turns on the one core. The distinction matters because the performance benefit of threads depends on parallelism, which requires multicore hardware.

Question 2

What is false sharing, and how do you avoid it?

Show answer
False sharing occurs when two threads write to distinct variables that happen to occupy the same cache line. Each write invalidates the line in the other core's cache, forcing the line to bounce between cores and destroying throughput. The threads do not share any variable — the sharing is at the cache-line level only, hence "false". The fix is to pad the variables so that each occupies its own cache line, typically by adding a char array of the appropriate size (64 bytes on x86-64 and most ARM cores) or by using the C11 alignas(64) specifier. The cost is a few bytes of wasted memory per padded variable; the benefit is that the two cores no longer interfere with each other's caches.

Question 3

A program is 90% parallelisable. What is the maximum possible speedup, and at what number of cores is it nearly reached?

Show answer
Using Amdahl's law with s = 0.10: the maximum speedup is 1 / 0.10 = 10×. Reaching that limit requires an enormous number of cores — with 16 cores the speedup is 6.4×, with 64 cores it is 8.8×, and only with hundreds of cores does it approach 10×. The practical conclusion is that a 10% serial fraction limits the benefit of adding cores fairly quickly: after a few cores, the marginal speedup per core becomes small. Real programs usually have serial fractions higher than 10%, and overheads grow with the thread count, so scaling in practice is often worse than the law's prediction.

Question 4

Why does x86 not guarantee sequential consistency, and what does it guarantee instead?

Show answer
Sequential consistency would require the CPU to execute and make visible every memory operation in program order, which conflicts with the store buffer — a small hardware buffer that absorbs stores so the CPU can continue executing without waiting for each store to reach the cache. The store buffer means a store may not yet be visible when a subsequent load of a different address executes, so the load may appear to happen "before" the store. x86 provides Total Store Order (TSO), which permits this specific reordering (stores vs. subsequent loads to different addresses) but not others. In practice TSO is close to sequential consistency, and x86 programmers rarely need explicit memory barriers — but the guarantees are weaker than they appear, and code that relies on them may break when ported to ARM or POWER, which use much more relaxed models.

Question 5

Name three practical limits on parallel scaling beyond cache coherence, and explain how each one manifests in a real parallel program.

Show answer
Any three of: Lock contention — threads queue up waiting for a shared lock, and beyond a certain point adding threads increases the queue length and makes the program slower. Memory bandwidth — the shared memory controller and L3 cache become the bottleneck for large working sets, so adding cores does not help even when the cores themselves are idle. Synchronization overhead — every lock operation, atomic, and barrier has a cost that grows with the number of threads; barriers in particular cost more as more threads must arrive. NUMA effects — on multi-socket systems, threads accessing remote memory pay a latency penalty that can dominate the benefit of parallel execution. Each of these limits scaling either by adding work that does not contribute to the computation (contention, synchronization) or by making the parallel work slower than the theory predicts (bandwidth, NUMA).

08 Unit discussion

Discussion prompt

The industry's move to multicore was driven by the physical limits of clock frequency — CPUs could not get faster without exceeding power and thermal budgets, so manufacturers added cores instead. Some observers have argued that this transition has been a failure: software did not become proportionally faster because most programs are not parallelisable to any useful degree, and the ones that are often hit coherence, contention, and bandwidth limits before they reach the core count of the machine.

Is the multicore transition a failure of hardware manufacturers to deliver value, or of software developers to exploit it? Pick a concrete workload — a web server, a database, a compiler, a game, a scientific simulation — and argue one side. Use the concepts from this lesson (Amdahl's law, false sharing, cache coherence, memory bandwidth) to support your position.

Post your response to the Unit 4 discussion forum and reply to at least two classmates. Supports CLO2, CLO3, and CLO8.

09 Practice exercise

This ungraded exercise consolidates the lesson and prepares you for Lab 3 and the thread portion of Assignment 1. Aim to spend 60–90 minutes on it.

  1. Write a parallel sum. Write a program that sums an array of one hundred million integers using one, two, four, and eight threads. Measure the elapsed time at each thread count. Compute speedup and efficiency. Plot (or tabulate) the results.
  2. Compare partitioned vs. shared accumulator. Rewrite the program so all threads increment a single shared accumulator protected by a mutex. Time both versions at four threads. Explain the difference in terms of lock contention.
  3. Demonstrate false sharing. Write two versions of a program with four threads, each incrementing its own counter one hundred million times — one version with the counters adjacent in memory, one with padding to separate cache lines. Measure both. Compute the ratio.
  4. Measure the cache line size. Write a small program that empirically determines the cache line size of your machine. (One approach: allocate an array of "counter" structs, have two threads increment adjacent ones, and vary the stride between them until performance jumps.) Report your measured value. Does it match the 64 bytes reported by lscpu?
  5. Predict the speedup. Choose a program that has an inherently serial portion — for example, a program that reads data from disk, computes in parallel, then writes results serially. Measure the serial fraction by timing the program single-threaded and comparing the parallel portion to the total. Predict the maximum speedup with Amdahl's law. Verify your prediction by running with an increasing number of threads.
  6. Consider NUMA. If your VM has more than one socket (rare on a typical VM, common on servers), use numactl --hardware to inspect the NUMA topology. Run the parallel sum under numactl --interleave=all and under numactl --cpunodebind=0 --membind=0. Compare the timings. Even on a single-socket VM, note what the difference would be if the machine had two sockets.
  7. Reflect. In your own words, describe one concrete example from your measurements of a performance problem that would not have been predictable from the source code alone. Explain what you would have had to know about the hardware to predict it.

10 Summary

  • Concurrency means tasks make progress during the same time period; parallelism means they execute at the same instant on different cores. Concurrency is a program property; parallelism is a hardware property.
  • Multicore hardware has private per-core caches and shared main memory. Cache coherence protocols such as MESI ensure every core sees a consistent view of memory, at the cost of coherence traffic when mutable data is shared.
  • False sharing occurs when two threads write distinct variables that happen to occupy the same cache line. The line bounces between cores on every write, often destroying performance. The fix is padding to separate cache lines.
  • Memory consistency models — sequential consistency, TSO, relaxed — specify what reorderings the hardware permits. Portable concurrent code uses language-level atomic operations with explicit memory-ordering arguments rather than relying on any hardware model.
  • Amdahl's law bounds the speedup of a parallel program by its serial fraction. A 10% serial fraction caps speedup at 10×, regardless of how many cores are available.
  • Other practical limits on scaling: lock contention, memory bandwidth, synchronization overhead, and NUMA effects. Each one bounds speedup independently of the core count.
  • Scalable parallel programs partition work rather than data structures, minimise shared mutable state, combine partial results at the end, watch cache locality, pad shared structures, and measure rather than assume.
  • Linux's scheduler balances load across cores, preserves cache and NUMA locality where possible, and exposes controls (taskset, numactl) for pinning threads to specific cores.

Looking ahead

Lesson 4.5 closes Unit 4 with the POSIX threads API — the concrete interface through which everything in this lesson is expressed in C. The concepts from this lesson — coherence, false sharing, memory models, scaling — become concrete when you write and measure pthread code in Lab 3.

11 References

  • Arpaci-Dusseau, R. H., & Arpaci-Dusseau, A. C. Operating Systems: Three Easy Pieces, Chapter 26. Available online at ostep.org.
  • Silberschatz, A., Galvin, P. B., & Gagne, G. Operating System Concepts, 10th ed., Chapter 4, Section 4.5.
  • Amdahl, G. M. Validity of the Single Processor Approach to Achieving Large-Scale Computing Capabilities. AFIPS, 1967.
  • Patterson, D. A., & Hennessy, J. L. Computer Architecture: A Quantitative Approach, 6th ed., Appendix B (memory hierarchy) and Chapter 5 (thread-level parallelism). Morgan Kaufmann, 2017.
  • McKenney, P. E. Is Parallel Programming Hard, And, If So, What Can You Do About It? Available online at kernel.org/pub/linux/kernel/people/paulmck/perfbook/.
  • Drepper, U. What Every Programmer Should Know About Memory. Red Hat, 2007.
  • Linux man-pages: man 1 lscpu, man 1 taskset, man 1 numactl, man 1 perf-stat.
👤