Gates → GPUs · Notes
Unit 4 · Lessons 16–19

Add parallelism

Unit 3 found independent instructions within one thread. Eventually, dependencies and limited hardware leave little more to find there. This unit looks for work in other threads and in the elements of an array. We will put threads on separate cores, let them share one core, and group several data elements into one instruction. Each step gives the hardware more work to choose from, but also gives us a new question about sharing or coordination.

You'll be able to
Tell apart multiple cores, multiple hardware contexts and multiple vector elements. Explain how cached copies of shared memory stay coherent, why that still isn't enough to order operations, and how software establishes the ordering it needs.
Carries over
Caches and lines (Lesson 10), store buffers and retirement (Lesson 15), software threads and saved contexts (Lesson 7), issue ports and ILP (Lesson 13).
A note on models
Each explorer isolates a few mechanisms. A coherence step is one completed transaction, an ordering step is one abstract event, a hardware multithreading step is one cycle, and a vector step is one group of elements. The assumptions beside each explorer define what its results tell us.
For each form of parallelism, identify the work that can proceed independently, then account for the time spent moving data and coordinating that work. Adding resources helps when it reduces the total execution time.
Lesson 16

Share memory across cores

LimitationOne core can find only so much independent work in one instruction stream. Increasing its width or frequency also tends to raise power consumption.
New mechanismSeveral cores, each running its own thread and accessing shared memory. A coherence protocol coordinates reads and writes to their private cached copies.
New stateA coherence state for each cached line, plus records of which caches hold a copy and which may write it.
CostCoherence messages, invalidations, cache-to-cache transfers, and contention on shared caches and memory bandwidth.

More cores, one memory

A multicore processor puts several cores in one package, sometimes on separate dies called chiplets (Unit 6 returns to them). Each core has its own PC, registers and pipeline, so it can run a different software thread. Caches nearest the cores are often private; farther out, the cores typically share a last-level cache, an interconnect, memory controllers and DRAM bandwidth.

When we say the cores share memory, we mean they can address the same bytes. It does not mean every access takes the same time. In the figure, each core reaches its nearby L1 before going through the shared interconnect. If two cores read the same address, both private caches may keep a copy and serve later reads locally. When one core writes, the caches need a way to prevent the other core from continuing to read its old copy.

interconnect: carries coherence requests and cache-to-cache data shared L3 cacheshared capacity: one core's data can crowd out another's memory controllersshared DRAM bandwidth
Private near the core, shared further out. The exact cache levels vary by design. Near-core caches are small, close and built for low latency. Private caches also keep other cores from competing for the same local capacity and access ports. Two cores can each keep a copy of the same line in L1 or L2, so later reads need not reach the shared L3.

The speedup limit: Amdahl's bound

To estimate how dividing work across cores affects execution time, keep the task fixed and separate its original time into serial and parallel portions. Suppose it takes 100 time units on one core: 10 units must run alone, while 90 can be divided evenly. Four cores reduce the parallel part to 90/4 = 22.5 units, but the serial 10 remain. Ideal time is therefore 32.5 units, for a speedup of 100/32.5 ≈ 3.08×.

This is Amdahl's bound. Let s be the serial fraction of the original execution time (not of source lines or operations), and P the number of equally fast cores. If the parallel fraction divides perfectly and adds no overhead, then

speedup ≤ 1 / (s + (1 − s) / P)

The serial part becomes the limit. Set s to 10% and increase P. The curve approaches 10× because the original 10 serial time units cannot shrink. Synchronization, uneven work and shared-bandwidth limits can lower real speedup further. A different algorithm may change s; the curve describes this division of one task, not a fixed property of the application.

Keeping cached copies consistent

Suppose cores A and B have both cached the word x = 0. A writes x = 1 into its own cache. Without coordination between the caches, B could keep reading 0 from its old copy even after A's write completes.

Cache coherence addresses this problem one location at a time. Writes to x have one agreed order. When A takes ownership to write x = 1, B's old cache line becomes invalid: B cannot use those old bits for its next load of x. That load misses and obtains the current value. An invalidation protocol enforces this by granting permission to read or write each line:

A line may have several readers or one writer. Before a core gains permission to write a line, every other cached copy must be invalidated. A later read that misses obtains the current data, which may come from the writer's cache if memory has not yet been updated.

Permission applies to a whole cache line, so writing x can also invalidate a cached copy of another variable in that line. This is the cause of false sharing, which we will trace below. Coherence does not make B execute another load, and it does not by itself order accesses to different addresses. Lesson 17 introduces the rules for that ordering.

Cache-line permissions: MSI

In the MSI protocol, each cached line carries one of three states: Modified, Shared or Invalid. The state describes whether this cache may read or write the line; it is separate from the data value stored there:

StateMeaning in this cacheMay read?May write?
I InvalidNo usable copy. Old bits may physically remain, but must not be used.No: missNo
S SharedA clean, readable copy. Other caches may have one too.YesOnly after requesting ownership
M ModifiedThe only valid copy, possibly newer than memory.YesYes
Follow one cache's permission. Teal arrows are this core's reads and writes; copper arrows are requests from another core. A read can move I → S. A write needs M, so a writer with S must first make other copies invalid. The diagram shows stable states after requests complete; real protocols also track requests in flight.
Two cores with private write-back caches. One 16-byte line holds two 4-byte words, x and y (in the "separate lines" layout, y moves to its own line). Each transaction completes before the next begins: no evictions, transient states or simultaneous requests. A read that misses enters S, even if no one else has the line (that's MSI, not MESI). If the other cache holds the line in M, a read makes it supply the data and update memory, and both end in S. A write requires exclusive ownership. A writer with an S copy already has the data and needs only permission; a writer facing another cache's M copy must obtain the current data from that cache. Memory may stay stale after the write.

Try it: two caches, one line

Trace the textbook preset using a pair of states, (A, B). Both begin (I, I). A reads x and gets a copy: (S, I). B reads x too: (S, S). Two readers are allowed.

Now A writes x = 1. It requests ownership, invalidates B, and ends at (M, I). Memory may still say 0; A's M copy is the current one. When B reads again, it misses. In this model A supplies 1 and writes it back, so the pair returns to (S, S) and memory becomes 1. Finally B writes x = 2, leaving (I, M) while memory still says 1. Throughout the trace, the state says who may use a copy; the data value says what a read returns.

Play the preset below, then try your own reads and writes. Watch where the current value lives after each step, as well as the state letters.

Layout:
Presets:

The latest value may be in a cache. After "A writes x", memory still holds 0. A's M state identifies its copy as the current one. In a snooping design, caches watch coherence requests on a shared interconnect and respond when they hold a relevant copy. A directory records which caches hold each line, so requests can be directed to the relevant caches instead of broadcast to all of them. This reduces broadcast traffic as the number of cores grows.

False sharing: different variables, same line

Run the false sharing preset with x and y in one line. A only ever touches x and B only ever touches y. They never access the same variable, but each write requires ownership of the whole line. The first write acquires it; each subsequent alternating write transfers ownership to the other core: four ownership requests and three invalidations for four writes. Switch to separate lines and run it again. Each core takes its own line once and then hits in M: two requests and no invalidations.

Each alternating write transfers ownership. Per-thread counters stored next to each other in an array can have this problem. Placing each thread's frequently written data on a separate line, through padding and suitable alignment, prevents this ownership transfer. Changing which data each thread writes can also help. Padding uses memory and can hurt locality, so use the actual machine's line size (often 64 bytes; our 16 is a teaching choice).

Additional states in MESI and MOESI. Our MSI model sends an ownership request when a core reads a line and later writes it, even if no other core has a copy. MESI adds Exclusive (E) for a clean line known to be held by only one cache. That core can change E → M on a local write without a message. A read followed by a write to an unshared line can therefore avoid the ownership request required by MSI.

MOESI adds Owned (O) for a dirty line shared with readers: memory may not yet reflect the cached writes. Its designated owner supplies the data and remains responsible for writing it back, so memory need not be updated when a modified line becomes shared. The MSI explorer takes the simpler route: its M → S read updates memory.

PredictTrack permission, then data.
  1. In the textbook trace, after "A writes x = 1": what are A's and B's states, what does memory hold, and where is the current value?
    Show answer

    A = M, B = I, memory x = 0 (stale). The current value 1 lives only in A's cache. Its M state ensures that another core's read obtains that value even while memory is out of date.

  2. Next, B reads x. What value does B get, and what happens to both states and to memory?
    Show answer

    B misses, and A (in M) supplies 1 and writes it back. Both end in S, and memory now holds 1.

  3. Four alternating writes (A→x, B→y, A→x, B→y). Count ownership requests and invalidations with x, y in one line versus separate lines.
    Show answer

    One line: 4 requests, 3 invalidations. Separate lines: 2 requests, 0 invalidations, because after the first write each core keeps its own line in M.

  4. With 20% serial work and 4 ideal cores, what does Amdahl predict? What's the limit with unlimited cores?
    Show answer

    1 / (0.2 + 0.8/4) = 1 / 0.4 = 2.5×. With unlimited cores the limit is 1/0.2 = 5×.

  5. x is perfectly coherent. Why is that not enough to guarantee another thread sees a write to data before it sees ready = 1?
    Show answer

    Coherence talks about one location at a time: every core agrees on the order of writes to x. It says nothing about the order in which writes to two different locations (data, ready) become visible. That's a separate rule, the memory-consistency model, covered next.

For any trace, identify who has permission to read or write each line, where the current data lives, and whether writes to different variables require ownership transfers for the same line.

Lesson 17

Order memory accesses across threads

LimitationCoherence orders writes to each address separately. It does not fully specify how threads observe accesses to different addresses.
New mechanismA memory consistency model defines permitted outcomes. Synchronization operations, such as release/acquire atomics, locks and barriers, establish ordering relationships.
New stateStore buffers; a modification order for each atomic variable; lock ownership, waiting queues and barrier counts.
CostOrdering constraints can make some operations wait, with costs that depend on the operation and architecture. Contended atomics can repeatedly transfer writable ownership of a cache line between cores; locks and barriers can make threads wait.

Store buffers let later loads proceed

Lesson 16 established a shared order of writes to x. Ordering operations on x relative to operations on y requires additional rules. Recall Lesson 15: a core can retire a store while its value waits in a store buffer on the way to the coherent cache. The core can continue and load a different address before that store becomes visible to other cores. If two cores both do this, each may read the old value at the other's address, even though each issued its own store first.

A retired store can still be buffered. The store has retired from the core, but its value still waits in the buffer. A later load to another address can proceed before that value becomes visible to other cores. A load of the same address can read the buffered value, so the thread can still read its own recent write.

Sequential consistency: one order for all accesses

A memory consistency model specifies the permitted results of shared-memory operations. Under sequential consistency (SC), every thread's memory operations must fit in one global list. Each thread's operations keep their own program order in that list, and each load reads the latest write to its address earlier in the list. An outcome that cannot be described by such a list is impossible under SC.

Real CPUs and programming languages often permit additional outcomes to avoid unnecessary waiting; their exact rules differ. Here SC is an abstract baseline for the explorer. C++'s memory_order_seq_cst is a related, but separately defined, language guarantee.

Try it: can both threads read zero?

Start with x = 0 and y = 0. Thread A writes x = 1, then reads y into rA. Thread B writes y = 1, then reads x into rB. Each thread's load asks whether it can see the other's write. Step the two threads in any order. In the store-buffer model, also choose when each buffer drains, making its store visible. The explorer records each final (rA, rB) pair.

This explorer is an abstract hardware execution, not C++. Written as plain C++ variables, these accesses would be a data race, and the program's behaviour would be undefined. It is also not a complete x86 model: it shows only the store buffer, which is enough for this test.

Outcomes reached (rA, rB)

(0, 0) is impossible under SC but allowed with store buffers. Under SC you can reach (0, 1), (1, 0) and (1, 1), but never (0, 0). With store buffers, both stores can wait while both loads read the old zeros from memory. This preserves coherence: each address still has one order of writes, from 0 to 1.

To produce (0, 0), first let A buffer x = 1 and B buffer y = 1. Neither store has reached coherent memory. A then reads y = 0; B reads x = 0. Let the buffers drain afterward. Each thread issued its store before its load, but neither load saw the other thread's buffered store. The explorer permits this execution. The next figure shows why SC excludes it.

The well-defined C++ counterpart uses atomic variables, whose individual loads and stores are indivisible with respect to other atomic accesses to the same object. All modifications to one atomic variable have a modification order: a single order for its writes and other updates. This per-variable order does not by itself order operations on x relative to operations on y. With relaxed ordering, (0, 0) is a defined, permitted outcome:

std::atomic<int> x{0}, y{0};
// thread A                                  // thread B
x.store(1, std::memory_order_relaxed);       y.store(1, std::memory_order_relaxed);
int rA = y.load(std::memory_order_relaxed);  int rB = x.load(std::memory_order_relaxed);
// rA == 0 && rB == 0 is allowed. Making all four operations memory_order_seq_cst forbids it.

Follow the "must come before" arrows for (0, 0):

The four constraints form a cycle. Under SC, program order puts each store before its own thread's load. For each load to read 0, it must precede the other thread's store in the global list. Following those constraints returns to the starting store, requiring it to precede itself. No global list can satisfy them.

Publishing data safely: release and acquire

A producer-consumer handoff requires a specific relationship: the producer's data write must be ordered before the consumer's data read. Suppose a producer writes data = 42, then signals that the data is ready. A consumer waits for that signal before reading the data. This is a one-shot publication: both objects are initialized before the threads start, ready starts false, one producer sets it true, and no one changes data afterward. In C++:

int data = 0;                       // ordinary, non-atomic
std::atomic<bool> ready{false};

// producer                          // consumer
data = 42;                           while (!ready.load(std::memory_order_acquire)) { }
ready.store(true,                    int v = data;   // guaranteed to see 42
            std::memory_order_release);

The required event is the consumer's acquire load reading the value written by the producer's release store. In C++, this creates a synchronizes-with relationship between those two operations. Combined with the order of statements in each thread, it orders the earlier data write before the later data read:

Release/acquire orders the data write before the data read. In program order, the producer writes data before its release store to ready, and the consumer reads data after its acquire load. The acquire load reads the value written by that release store, connecting the two threads. Together these establish C++'s happens-before relationship from the write of 42 to the read of data. If both flag operations are relaxed, that cross-thread relationship is absent. The ordinary accesses to data then form a data race, so the program's behaviour is undefined.

An acquire load that reads false has not observed the release store, so it establishes no handoff. That is why the consumer loops. Even the correct release/acquire pair makes no promise about when the producer will run or whether the wait will end.

The compiler enforces the language rule using the instructions the target CPU needs. For this pattern on x86, no extra fence instruction is often needed, though the operations still constrain compiler reordering. ARM and RISC-V may need special instructions or fences. The program states the required relationship; the implementation supplies the mechanism.

Separate atomic accesses can lose an increment

Two threads each increment a shared counter using an atomic load followed by a separate atomic store, as in c.store(c.load() + 1). These accesses create no data race: each load and each store is indivisible. The pair remains separate operations, however, so another thread can access the counter between the load and the store:

Both loads read 0, so both stores write 1. One increment is lost. An atomic read-modify-write, such as fetch_add, instead makes the read, add and write one indivisible operation. Starting from 0, two increments then produce 2. For this isolated counting job, relaxed ordering is sufficient; it does not establish a publication relationship for other data.
ToolWhat it guaranteesWhat it doesn't
Atomic read-modify-write (fetch_add, compare-and-swap)One variable is updated indivisibly. That describes the visible result, not the cost: it isn't one cycle, and under contention it can wait or retry.Consistency across several variables
Mutex / lockOnly one thread runs a critical section at a time, and unlock → lock also synchronizes memoryThat threads are fairly scheduled. Contention serializes them.
Memory fenceConstrains the order of this thread's memory operations, as part of a correct scheme built on atomic operationsRepairing racy code: a fence placed around ordinary conflicting accesses still leaves a data race. Nor does it make other threads reach any particular point
Thread barrier (e.g. std::barrier, used as arrive_and_wait())Everyone reaches the phase boundary before anyone continues (with arrive_and_wait(); the interface also allows arrive() without waiting), and a synchronizing barrier like std::barrier makes earlier work visible after itProgress if some participants never arrive (deadlock)
sleep, volatileNothing about inter-thread ordering in C/C++Not a substitute for any of the above

Atomicity of one variable does not protect a relationship between several variables. Making every field of a structure individually atomic, for example, does not by itself preserve a rule such as "balance equals the sum of entries". A lock can protect the whole update, provided every access that relies on the rule uses the same lock. Choose the tool for the relationship the algorithm needs, then measure its cost under contention.

PredictName the relationship before the hardware.
  1. With store buffers, why can rA and rB both be 0? Did any cache violate coherence?
    Show answer

    Both stores are still waiting in their buffers when both loads read memory, which still holds zeros. Coherence is preserved: each address has one order of writes, from 0 to 1. It does not require either store to become visible before the other thread's load.

  2. State the argument that rules out (0, 0) under SC.
    Show answer

    A's store → A's load (program order). A's load reads y = 0 → it precedes B's store. B's store → B's load (program order). B's load reads x = 0 → it precedes A's store. That's a cycle, so no single global order satisfies all four constraints.

  3. In the C++ version, make both stores memory_order_release and both loads memory_order_acquire. Is (0, 0) now forbidden?
    Show answer

    No. In this example, an acquire load synchronizes with a release store when it reads the value written by that store. If both loads read the initial zeros, neither reads the other thread's store, so neither creates that relationship. The (0, 0) outcome remains permitted. In the publication example, the consumer's load of true reads the producer's release store and establishes the required relationship. Making all four operations memory_order_seq_cst forbids (0, 0).

  4. In the publication example, which event makes the ordinary read of data safe? Does an acquire load that reads false help?
    Show answer

    The acquire load reading the value written by the release store creates the synchronizes-with relationship. Combined with the program order in each thread, it makes the producer's write of 42 happen before the consumer's ordinary read. A load that reads false does not observe that release store, so it establishes no such relationship with the producer's data write.

  5. Why can two atomic loads and stores still lose an increment? What does fetch_add change?
    Show answer

    Each load and each store is atomic, but another thread's load can slip between them, so both threads read the same old value. fetch_add makes the read-add-write one indivisible operation, so the increments are serialized and none is lost.

  6. Match each need to a tool: "one shared counter", "update three fields together", "all workers finish phase 1 before phase 2", "keep my stores in order relative to a later load". Does volatile solve any of them in C++?
    Show answer

    Counter → atomic RMW. Three fields → mutex. Phases → barrier. Store-load order → seq_cst atomics, or fences used together with atomic operations (a fence alone doesn't make ordinary conflicting accesses race-free). volatile solves none of them in C++: it neither makes operations atomic nor orders them between threads.

To explain why a shared-memory program is correct, identify the language-level synchronization relationship: which acquire load reads which release store, or which lock acquisition follows which unlock. Then use the hardware model to explain how the implementation enforces it.

Lesson 18

One core, several hardware threads

LimitationOne instruction stream often leaves a wide core's issue slots empty, whether waiting on a dependency or a cache miss.
New mechanismHardware multithreading: keep several architectural contexts resident in the core and choose ready instructions from them.
New stateEach context's PC, architectural register values, and control/exception state.
CostExtra context state and selection logic. Threads compete for ports, queues, caches and bandwidth, so each may run slower.

Three ways to share a core

A core may have room to issue several instructions in a cycle while its thread has only one ready instruction, or none while it waits for a cache miss. An operating-system context switch, as in Unit 2, saves one thread's state and restores another's; that is too much work to fill a single empty cycle. Hardware multithreading keeps several thread contexts resident in the core. The core can choose their ready instructions without an OS save-and-restore operation, using extra state and selection logic. The diagrams compare three ways to choose instructions from these contexts:

Issue slots over time (4 slots per cycle, 8 cycles). Coarse-grained multithreading runs one thread until a long stall, such as a cache miss, then switches. Fine-grained alternates between threads each cycle, issuing from one thread per cycle. Simultaneous multithreading (SMT) can issue instructions from several threads in the same cycle. In each design, the OS schedules ordinary software threads onto the hardware contexts.

Separate register state and shared execution resources

Each resident thread needs its own place in the program and its own register values. Otherwise, two threads that both use the name x5 would overwrite each other's state. Their instructions can execute using the same ALUs and other execution units:

Register state for two hardware threads. The two paths each need a PC, architectural register values and exception state. A core that uses register renaming may keep both threads' values in one physical register file. Separate rename maps connect each thread's architectural register names to its physical registers. Queues, ports, caches, predictors and bandwidth may be shared, partitioned or duplicated, depending on the design.

The operating system sees each hardware context as a separate logical processor and schedules software threads onto them. A chip with 4 cores × 2 contexts shows the OS 8 logical processors. Each pair shares the execution resources of one core. The OS can schedule many more software threads by taking turns, saving and restoring their state through memory as in Unit 2.

Try it: two dependency chains, one core

Thread A runs x5 = x5 + 1 four times starting from 0. Thread B runs x5 = x5 + 10 four times starting from 100. Each is a chain: every add needs the previous result, and in this model a result is usable the next cycle. Both threads use the name x5; it refers to each thread's own architectural register.

Two independent chains can use both ports. With two ports, either chain has only one ready add per cycle. One-thread-per-cycle selection leaves the other port empty. SMT can issue one add from A and one from B in the same cycle. With one port, only one add can issue per cycle, so both policies take eight cycles.

Count both threads finished separately from A finished. On two ports, A alone finishes its four adds in cycle 4. Alternating one thread per cycle makes A finish in cycle 7 and B in cycle 8. Ideal SMT finishes both in cycle 4. The eight-to-four comparison is a gain in combined throughput; A itself still needs four dependent steps.

Model assumptions: the two ALU ports are interchangeable; every add is ready except for its own thread's one-cycle dependency; and the model ignores the front end, retirement, cache misses and any cost of selecting a context. It illustrates issue slots, not a general SMT speedup prediction.

Combined throughput and each thread's completion time

In the model, A completes one dependent add per cycle and B uses the other port, increasing the total work completed per cycle. Real threads also compete for caches, queues and bandwidth. Adding another thread on the same core, an SMT sibling, may slow the original thread even while total work per second rises. If the workload already saturates memory bandwidth, an additional sibling may offer little benefit or reduce throughput through contention. Whether SMT helps depends on the workload, the latency goal and the core. Shared resources can also leak timing information between siblings, which matters for security isolation.

PredictCount ports and ready operations.
  1. Two ALU ports: how many cycles do the two chains take with one-thread-per-cycle selection, and with SMT? What are the final x5 values?
    Show answer

    One thread per cycle: 8 cycles (A finishes in cycle 7, B in 8). SMT: 4 cycles. Either way A's x5 = 4 and B's x5 = 140. The results are unchanged because each chain uses only its own thread's register state.

  2. With a single ALU issue slot per cycle, why do both policies take 8 cycles? What's the limit?
    Show answer

    There are 8 adds and only one can start per cycle. The ALU issue capacity limits both policies to one add per cycle, so they both need 8 cycles.

  3. 4 physical cores with 2 hardware contexts each: how many logical processors does the OS see? Why isn't that 8 cores?
    Show answer

    8 logical processors on 4 physical cores. The two logical processors on each core share that core's execution resources.

  4. What must stay distinct between resident hardware threads, and how does switching between them differ from an OS context switch?
    Show answer

    Each context needs a distinct PC, architectural register values and control/exception state. Ports, queues, caches, predictors and bandwidth may be shared. An OS context switch saves and restores state through memory with software. Resident hardware contexts already hold that state, so the core can choose instructions from them without saving one context to memory and restoring another.

  5. Can adding an SMT sibling raise total throughput while making the original thread slower?
    Show answer

    Yes. A sibling can use previously idle slots and increase total work per second. It may also take cache space, queue entries and bandwidth the original thread was using. The original thread can then take longer to finish even though the two threads together complete more work per second.

Draw two register contexts connected to one shared set of execution resources. Then draw two physical cores, and explain what the second drawing has that the first doesn't.

Lesson 19

One instruction, many data elements

LimitationIn a scalar array loop, each element needs separate instructions even when all elements need the same operation.
New mechanismSIMD means single instruction, multiple data: one instruction applies an operation to a group of elements. Vector instructions can limit which positions participate.
New stateVector registers hold groups of values. Instruction sets may also provide masks to select active positions and a vector-length setting to select how many elements to process.
CostWider datapaths need more register bandwidth and power. Partial groups and sums across elements need extra handling; the memory system must supply enough data.

Scalar versus vector

Suppose we compute c[i] = a[i] + b[i] for a long array. A scalar add instruction adds one pair of values. Scalar code repeats two loads, an add, a store and loop bookkeeping for each element. A vector add instruction can instead name four elementwise additions at once. The arithmetic still happens four times, but grouping it under fewer instructions can reduce the work of fetching, decoding and scheduling the loop. This groups independent array operations within one thread.

Four adds, one instruction. On the left, each element needs its own scalar add instruction. On the right, one vector add names four elementwise additions, reducing instruction overhead.

The figure counts only the add instructions. A complete loop still loads both inputs and stores the outputs; the vector version groups those memory operations too. The time needed for one vector instruction depends on how the hardware processes its elements.

Register capacity, active elements and physical lanes

A register's bit width is its storage capacity. Dividing it by the element width, the number of bits per value, tells us how many elements fit. An instruction may use only some of those positions. The hardware performs the element operations in datapaths called physical lanes; for a given operation and element width, the lanes determine how many elements it can process side by side.

Four elements on two physical lanes. This 128-bit register holds four 32-bit elements. The two lanes process two elements per round, taking two rounds, called beats here. The diagram counts element-processing rounds, not the full instruction's latency in clock cycles. Activating fewer elements does not change the register's size.

Fixed-width SIMD, such as x86 SSE/AVX and Arm NEON, specifies register widths in the instruction set. Scalable vector instruction sets, such as Arm's Scalable Vector Extension (SVE) and RISC-V's V extension, allow a loop to adapt to implementations with different register lengths. A RISC-V V loop asks vsetvl how many elements to process in its next group, then uses the returned count:

remaining = N
while remaining > 0:
  vl = vsetvl(remaining)schematic: hardware returns how many it will do (≤ VLMAX), with element width and grouping already chosen
  process vl elementsvector loads, add, store
  advance pointers by vl; remaining −= vluse the returned vl, never an assumed width

In RISC-V V, VLEN is the number of bits in one vector register, SEW is the selected element width in bits, and LMUL is the vector register group multiplier. LMUL determines the register capacity used by an operand: one register, a group of registers, or a fraction of one. The maximum element count for that configuration is VLMAX = VLEN × LMUL / SEW. For VLEN = 128 bits, LMUL = 1 and SEW = 32 bits, VLMAX = 4. This is a capacity limit; physical lanes and execution time depend on the implementation.

The returned vl selects the element count for the current iteration. The tail is the final partial group when the number of remaining elements is smaller than the group's capacity. A RISC-V loop handles it by using a smaller count. Often vl equals min(remaining, VLMAX), but the specification permits other values in some cases; advancing by the returned count keeps the loop correct.

A mask, also called a predicate, selects active positions individually with on/off bits. Arm SVE commonly uses predicates to handle a tail: an instruction such as whilelt marks positions whose indices are less than N, and the loop can advance by the implementation's vector length. The explorer below models this masked approach.

Handle the last partial group

With ten elements and groups of four, the first two groups cover positions 0–3 and 4–7. The last group reaches positions 8–11, but only 8 and 9 exist. A mask turns off the final two positions. In this model, masked-off positions make no memory access; they neither read past the array nor write output. Reading past the end and discarding the extra values afterward would still touch memory the program may not own.

vector width:

Groups of four or eight elements. The width control sets the number of element positions per group. Here a[i] = i + 1 and b[i] = i + 100, so c[i] = 2i + 101. At width 4, three groups provide 12 positions but only 10 are active (83%). At width 8, two groups provide 16 positions and still only 10 are active (62.5%).

Lower position utilization does not by itself mean a slower loop: width 8 uses fewer group instructions. It also does not reduce the useful data needed by the computation. Every element still needs two 4-byte inputs and one 4-byte output, or 12 bytes of logical array traffic.

Dependencies, reductions and data layout

The elementwise addition above is a candidate for vectorization, replacing scalar loop operations with vector operations, because each c[i] can be computed independently, provided the output does not overlap inputs in a way that creates a dependency. A loop-carried dependency occurs when one iteration uses a result from another. A reduction, such as summing an array into one value, combines results from different positions. The figure shows both cases:

Left: a dependency chain. In a[i] = a[i−1] + 1, iteration i needs iteration i−1, so the four pictured iterations cannot run together without transforming the computation. Right: a reduction tree. A sum can combine eight values in three addition levels, using partial sums across the lanes.

A compiler must preserve the program's result when it changes the order of iterations. With exact integer arithmetic, the recurrence on the left can be rewritten as a[i] = a[0] + i, making each result independent. Applying that rewrite to a program also requires checking its arithmetic rules: overflow or floating-point rounding can make the expressions behave differently. More generally, a compiler needs to prove that a vector schedule preserves the required dependencies, or use a runtime check to select a safe version. Array overlap matters when it creates such a dependency: a[i] = a[i] + b[i] works if a and b name the same array, whereas an offset overlap can make one iteration rely on a neighbor's write.

Reductions pose a different issue. A tree combines values efficiently, but it changes the grouping of additions. With floating-point numbers, that can change rounding, so a vector sum may not be bit-identical to a sequential left-to-right sum.

Data layout affects the loads that feed these operations. Suppose records are stored as [{x, y}, {x, y}, …], an array of structures. A loop that uses only x reads values separated by each record's y. In a structure of arrays, x[] and y[] are separate, so successive x values are adjacent. Contiguous values usually suit vector loads better when a loop uses many elements of one field. A gather instruction can load elements from separate addresses; how many cache lines those addresses touch still affects the cost. If vector instructions increase the rate of arithmetic enough, memory bandwidth can limit further speedup.

PredictUse the explorer to check.
  1. Ten elements, width 4: how many groups, what's the last group's mask, and which outputs does it write?
    Show answer

    3 groups. The last covers positions 8–11 with mask 1 1 0 0, and it writes only c[8] = 117 and c[9] = 119. Positions 10–11 do no memory access.

  2. Compare position utilization at widths 4 and 8 for 10 elements. Is the lower one necessarily slower?
    Show answer

    Width 4: 10/12 = 83.3%. Width 8: 10/16 = 62.5%. Width 8 needs 2 vector groups instead of 3, so its lower utilization can still accompany a faster loop. Each group here uses two loads, one add and one store. Ignoring loop control and predicate setup, width 8 uses 8 vector instructions (4 loads, 2 adds, 2 stores); width 4 uses 12 (6 loads, 3 adds, 3 stores). These are instruction counts in this model. Elapsed time depends on the hardware's execution rate, mask handling and memory behaviour.

  3. How many useful bytes cross the array boundary for 10 elementwise 32-bit additions? Why might actual memory traffic differ?
    Show answer

    12 bytes per element × 10 = 120 bytes. Actual traffic moves whole cache lines, can include bytes you don't use, may read the output line before writing it (write-allocate), and depends on what's already cached.

  4. VLEN = 128, SEW = 32, LMUL = 1: what's VLMAX? Does it tell you the number of physical lanes or the instruction latency?
    Show answer

    VLMAX = 4. It does not tell you lanes or latency. The hardware could process the 4 elements on 1, 2 or 4 lanes over several cycles.

  5. Why is a sum different from an elementwise add? Give a reason a compiler can't vectorize every loop by packing adjacent iterations.
    Show answer

    A sum combines values from different lanes, so it needs cross-lane (horizontal) communication or partial sums. A compiler can't simply pack iterations that depend on each other (a[i] uses a[i−1]), or inputs and outputs that might overlap at an offset that creates a loop-carried dependence, without proving the schedule safe or checking at runtime. Regrouping a floating-point sum can also change the result unless the program allows it.

Distinguish a software thread, a hardware context, a vector element and a physical lane. Then handle a tail safely with a mask or a returned vector length.

Unit 4 · Summary

Putting it together

Consider two software threads running on different physical cores. Each adds a 10-element slice of two arrays using width-4 vectors, then increments its own "done" counter. Their output elements do not overlap, but their counters occupy the same cache line. Before opening the answer, count the vector groups and useful bytes, then identify cache lines that both cores write.

Work it out, then open

Count work and data. Each 10-element slice needs 3 width-4 groups, giving 6 groups across both threads. Each of the 20 elements uses two 4-byte inputs and one 4-byte output: 12 × 20 = 240 logical array bytes. Cache-line transfers and existing cached data can make actual memory traffic different.

Check the counters' cache line. Each core must obtain write permission for the line before incrementing its counter. If the cores alternate, ownership transfers between them even though they update different variables: false sharing. Repeated increments by one core can hit its M copy, so the cost depends on the schedule. Placing the counters on separate lines removes this particular conflict.

Check the output boundary too. Adjacent 10-element slices of 4-byte values meet at byte 40. With 64-byte cache lines starting at the array, both threads write part of the first line. In the 16-byte teaching model, they both write the line covering bytes 32–47. No element is written by both threads, so the writes are not a data race, but the line still changes owners. Separate, suitably aligned lines avoid that traffic.

Establish completion. The coordinator can join both threads, waiting for each to finish before reading their ordinary counters and outputs. In C++, a successful join() also synchronizes with that thread's completion. To observe completion without joining, each worker can release-store an atomic done flag, and the coordinator can acquire-load each flag until it sees that worker's store. Reading ordinary counters concurrently without synchronization would be a C++ data race. Coherence alone does not establish the needed ordering.

Now put both threads on SMT siblings. Their register contexts remain separate, but they compete for one core's vector units, front end and cache bandwidth. The same six groups of work no longer have two cores' worth of resources available.

Form of parallelismWhat is independent?What stays shared or limiting?
Instruction-level (Unit 3)Operations within one threadDependencies, ports, front end, queues
Multicore thread-level (Lesson 16)Instruction streams on different coresMemory bandwidth, shared caches, coherence, synchronization
Hardware multithreading / SMT (Lesson 18)Resident contexts on one coreExecution ports, queues, caches, front end
SIMD / vector data-level (Lesson 19)Element operations within one instructionVector throughput, masks, reductions, data supply
Which hardware helps?For each case, say whether more cores, SMT or wider vectors would help, and why the others wouldn't.
  1. A long dependency chain: each step needs the previous result.
    Show answer

    The dependency chain has only one ready operation at a time. Extra cores and vector positions have no independent steps of this chain to execute. SMT can use idle slots if another thread supplies work, raising combined throughput while leaving the chain's dependent steps unchanged. Shortening each operation's latency, or changing the algorithm to expose independent work, can reduce the chain's execution time.

  2. Many independent tasks, each branchy scalar code.
    Show answer

    More cores can run independent tasks in parallel. SMT can also help if each core has otherwise idle issue slots for a second thread to use; it cannot add execution ports or bandwidth. Vectors help little when the tasks follow different branches, because doing the same operation across many elements becomes difficult.

  3. A contiguous elementwise loop over arrays that fit in cache.
    Show answer

    Vector instructions can group these independent element operations. A wider vector reduces the number of groups, but its speed benefit depends on the vector units and cache bandwidth. More cores can process separate slices if the loop is long enough to justify splitting the work. SMT may help when one thread leaves execution resources unused; it adds no vector units.

  4. A stream that already saturates memory bandwidth.
    Show answer

    Memory bandwidth already limits the stream. More cores, SMT or wider vectors cannot increase that bandwidth, so issuing more requests does not raise the sustained processing rate. Improving that rate requires more available bandwidth or fewer transferred bytes, for example through reuse or smaller data types where the computation permits them.

A program can combine all four forms of parallelism. The gain still depends on how much independent work exists and which resource limits it. For a side-by-side view of hardware organizations and programming models, see A map of parallel machines and programs. Unit 5 takes the same questions to GPUs: many more logical threads, groups executing together, and a memory system that must keep them supplied.

Model assumptions and further reading

Every explorer on this page is a teaching model: coherence steps are whole transactions, the ordering explorer models only store buffers, the SMT explorer models only issue ports, and the vector explorer models only groups and masks. For the precise rules, go to the primary sources: