Opening: overlap, instead of forcing every instruction to the slowest one's pace

Lesson 3 ended on a thorny pitfall: a single-cycle datapath forces ALL instructions — even a simple ADD — to run at the pace of the SLOWEST one (LW). The fix is not "slow ADD down to be fair" but the opposite: split each instruction into 5 steps (stages) and let several instructions OVERLAP — the next instruction starts step 1 the moment the previous one finishes step 1. This is pipelining, and the price of overlapping is that instructions "running in parallel" sometimes need each other's DATA — a data hazard.


📚 Prerequisites
Read Lesson 3 (the single-cycle datapath and RV32I encoding — this lesson's pipeline overlaps EXACTLY the stages introduced there).

1. How a 5-stage pipeline works

Every RV32I instruction passes through exactly 5 steps: IF (Instruction Fetch — read the instruction), ID (Instruction Decode — decode and read registers), EX (Execute — compute through the ALU), MEM (Memory access — only LW/SW really use it), WB (Write Back — write the result into a register). A pipeline gives each stage its OWN hardware block, joined by pipeline registers — so while instruction A is in EX, instruction B (fetched exactly 1 cycle after A) can already be in ID, and instruction C can be in IF — all three instructions are "alive" inside the CPU AT THE SAME TIME.

pipeline_5stage.txt (the 5 stages & how they overlap across cycles)
Cycle:       1    2    3    4    5    6    7
Instr A:     IF   ID   EX   MEM  WB
Instr B:          IF   ID   EX   MEM  WB
Instr C:               IF   ID   EX   MEM  WB

# At cycle 3: A is in EX, B is in ID, C is in IF - ALL THREE instructions
# are "alive" inside the CPU at once, each in a DIFFERENT stage.
⚠️ Pitfall: assuming a pipeline makes one instruction faster
A pipeline optimises THROUGHPUT (instructions completed per second when running MANY instructions back to back), NOT the LATENCY of a single instruction — one instruction on its own still has to travel all 5 stages (5 cycles), no faster than before (and in fact with a little extra overhead from the pipeline registers between stages). The benefit appears ONLY when many instructions overlap — exactly like an assembly line: one single car does not leave the factory any sooner, but thousands of cars per hour do.

2. Computing pipeline performance

This section leans on two words constantly, so let us define both. First, a stall (also called a bubble) is a cycle in which the pipeline gets NO useful work done: the hardware has to wait, usually because a later instruction needs data an earlier one has not produced yet. It is an empty slot travelling down the assembly line — still costing a clock tick, but yielding no product.

Second, CPI stands for Cycles Per Instruction — the average number of cycles the CPU spends on ONE instruction. It is the central measure of this whole lesson, so it helps to see both ends of it:

  • CPI = 1 is the ideal for a 5-stage pipeline: exactly one instruction completes every cycle, with no stalls. It cannot go lower — there is only one WB port per cycle to write a result through.
  • CPI = 1.2 means each instruction costs 1.2 cycles on average — i.e. every 5 instructions lose one extra cycle to stalls. Higher is worse; $CPI = 2$ means the pipeline is running at half its capacity.

The time to run $N$ instructions on an $S$-stage pipeline with clock period $t_{clk}$ and $stallCycles$ extra stall cycles caused by hazards:

$$T = (N + S - 1 + stallCycles) \times t_{clk}$$

The $(S-1)$ term is the "fill" latency at start-up — the first 4 cycles (with $S=5$) do not yet have all 5 instructions overlapping. Verified with real numbers: 1 million instructions, a 5-stage pipeline, a 2GHz clock ($t_{clk}=0.5$ns) — with NO stalls it takes exactly 500,002 ns; the SAME program with 200,000 stall cycles (from hazards, say) takes 600,002 ns — a difference of exactly $200{,}000 \times 0.5\text{ns} = 100{,}000\text{ns}$.

pipeline_timing.js (excerpt from the shared cpu-core.js engine)
function pipelineTime(numInstructions, numStages, stallCycles, clockPeriodNs) {
  return (numInstructions + numStages - 1 + stallCycles) * clockPeriodNs;
}
function pipelineCPI(numInstructions, stallCycles) {
  return (numInstructions + stallCycles) / numInstructions; // ideal CPI is 1
}
// Verified: pipelineTime(1_000_000, 5, 0, 0.5) === 500002 (ns)
// Verified: pipelineTime(1_000_000, 5, 200_000, 0.5) === 600002 (ns)
// Verified: pipelineCPI(1_000_000, 200_000) === 1.2 (ideal CPI would be 1)
⚠️ Do not drop the pipeline "fill" cycles when $N$ is small
With $N=1$ million instructions the $(S-1)=4$ term is utterly negligible (4 parts per million). But for a snippet of only 5 instructions, $(S-1)=4$ accounts for nearly 44% of all cycles ($T$ in cycles $= 5+4=9$, against an ideal of $N=5$) — ignoring this term when estimating the performance of a SHORT snippet is a significant error, even though it barely matters for long programs.

3. Data hazards

When a later instruction OVERLAPS an earlier one, it may need to read or write a register the earlier one is also reading or writing — three classic conflicts:

Kind Meaning Does it happen in a basic pipeline?
RAW (Read-After-Write) A later instruction READS a register the earlier one has not finished WRITING Yes — a REAL conflict, needing forwarding or a stall
WAR (Write-After-Read) A later instruction WRITES a register the earlier one has not finished READING No — a basic pipeline reads at ID (early) and writes at WB (late), so the natural order is already correct
WAW (Write-After-Write) Two instructions write the same register in the wrong order No — a basic pipeline completes WB in program order (in-order)

Forwarding (also called operand bypassing): instead of WAITING for the earlier instruction to write its register and then reading it back, the ALU result takes a "shortcut" straight from the earlier instruction's EX output into the later instruction's EX input — verified: the real 3-instruction RV32I chain ADDI x1,x0,20 / ADD x2,x1,x1 / SUB x3,x2,x1 (two back-to-back RAW hazards) gives exactly 0 stalls WITH forwarding — not a single cycle lost; with forwarding OFF, each hazard needs exactly 2 stalls (waiting until WB completes), for a total of 4 stalls.

detect_hazards.js (excerpt from the shared cpu-core.js engine)
function detectHazards(instrs, forwardingEnabled) {
  let totalStalls = 0;
  const hazards = [];
  for (let i = 1; i < instrs.length; i++) {
    const prev = instrs[i - 1], curr = instrs[i];
    const prevWritesReg =
      (prev.type === 'R' || prev.type === 'I' || prev.type === 'ILOAD') && prev.rd !== 0;
    if (!prevWritesReg) continue;
    const reads = curr.rs1 === prev.rd || curr.rs2 === prev.rd;
    if (!reads) continue;
    if (prev.type === 'ILOAD') { totalStalls += 1; /* load-use: ALWAYS 1 stall */ }
    else if (!forwardingEnabled) { totalStalls += 2; /* plain RAW, no forwarding */ }
    // forwarding on AND not load-use -> 0 stalls, nothing to add
  }
  return { totalStalls, hazards };
}
// Verified on a real RAW chain: forwarding ON -> 0 stalls; OFF -> 4 stalls.
⚠️ The main pitfall: a load-use hazard, which forwarding CANNOT rescue
Forwarding moves a result from EX to the next EX, but LW only has its data READY at the MEM stage (exactly one stage later than EX). If the instruction IMMEDIATELY AFTER uses that result directly, forwarding still cannot arrive in time — one stall cycle must be inserted, whether forwarding is on or off. Verified for real: LW x1, 0(x2) followed by ADD x3, x1, x1 (using $x_1$ immediately) — with forwarding ON it still gives exactly 1 stall, and cannot reach 0 the way an ordinary RAW hazard does.

4. Working a real example & counting stalls

The pipeline clock grid for the load-use example — the 1 stall verified above appears as the ADD's EX stage being delayed by exactly one cycle, waiting for data from the LW's MEM stage:

Instruction C1 C2 C3 C4 C5 C6 C7
LW x1,0(x2) IF ID EX MEM WB
ADD x3,x1,x1 IF ID bubble EX MEM WB

Straight from the formula: 2 instructions, 5 stages, 1 stall → $T = (2+5-1+1) = 7$ cycles — matching the last column in the table above (the ADD's WB lands in cycle 7). The actual CPI of this 2-instruction snippet: $CPI = (2+1)/2 = 1.5$ — well above the ideal $CPI=1$, because the stall's share of a short snippet is very large (exactly the pitfall raised in Section 2).

load_use_cpi.js (CPI for the load-use example, using the same engine)
const seq = [
  assembleRV32I('LW',  { rd: 1, rs1: 2, imm: 0 }),
  assembleRV32I('ADD', { rd: 3, rs1: 1, rs2: 1 }),
].map(decodeRV32I);
const { totalStalls } = detectHazards(seq, true); // forwarding ON, still 1 stall

// Passing 1 as the clock period makes the result a CYCLE COUNT. Pass the real
// t_clk (e.g. 0.5) instead and the same call returns nanoseconds: 7 * 0.5 = 3.5.
const cycles = pipelineTime(2, 5, totalStalls, 1);   // = 7 cycles
const nanoseconds = pipelineTime(2, 5, totalStalls, 0.5); // = 3.5 ns
const cpi = pipelineCPI(2, totalStalls);             // = 1.5
// Verified: totalStalls=1, cycles=(2+5-1+1)=7, nanoseconds=3.5, CPI=(2+1)/2=1.5

5. Hands-on: an interactive RISC-V 5-stage pipeline simulation

The time/CPI calculator below uses EXACTLY the verified formula — change the instruction count, stall count or clock period to watch $T$ and CPI move directly. The hazard simulator lets you switch forwarding on and off across 2 real RV32I sequences (RAW and load-use) so you can watch the stall count change exactly as Section 3 described:

🧮 Time/CPI calculator & hazard simulator

T & CPI calculator

Hazard simulator

🛠️ A standalone simulator
To watch EVERY pipeline cycle actually run (not just count stalls) — especially for more complex code you write yourself — try the RISC-V Pipeline & L1 Cache Simulator, the separate visual tool reused throughout this series (already linked from the roadmap page). It shows each instruction overlapping through the 5 stages in real time, with a forwarding toggle so you can see hazards directly.

Summary

  • ✅ A pipeline overlaps many instructions across 5 stages (IF-ID-EX-MEM-WB) to raise THROUGHPUT; it does not shorten the latency of one single instruction.
  • ✅ Verified: $T=(N+S-1+stall) \times t_{clk}$ — 1 million instructions with no stalls = 500,002 ns, with 200,000 stalls = 600,002 ns at 2GHz.
  • ✅ RAW is the only REAL hazard in a basic pipeline (WAR/WAW are automatically ordered correctly by reading early at ID, writing late at WB, and in-order commit).
  • ✅ Verified: forwarding fully solves ALU-to-ALU RAW hazards (0 stalls); without forwarding each hazard costs 2 stalls.
  • ✅ Pitfall: a load-use hazard (LW then immediate use) CANNOT be rescued by forwarding — verified to always need exactly 1 stall.

Review quiz

Question 1

What does a 5-stage pipeline improve, compared with a single-cycle datapath?

Question 2

Verified: 1 million instructions with no stalls take 500,002 ns, and with 200,000 stalls take 600,002 ns (at 2GHz). Where does the 100,000 ns difference come from?

Question 3

Why do WAR and WAW NOT occur in a basic pipeline, even though RAW still does?

Question 4

Verified: LW x1,0(x2) then ADD x3,x1,x1 (using x1 immediately) still needs exactly 1 stall with forwarding on. Why does forwarding not solve this case?

Download the practice code for this lesson

The CPUJS JavaScript file — a mini computer-architecture library used across all 12 lessons. Lesson 4 adds pipelineTime(), pipelineCPI() and detectHazards() — the timing/CPI formula with stalls plus a RAW/load-use hazard detector, with a self-test that checks every number quoted in this lesson (run node cpu-core.js; nothing to install):

Download cpu-core.js

📖 References

Related lessons in this series

Lesson 3: RISC-V Assembly & the Datapath Lesson 5: Branch Prediction & the Spectre Vulnerability Back to the Computer Architecture roadmap

Comments