Context

Main Idea

Let’s now look at another way we can speed up our processor— instruction level parallelism (ILP). If we can execute more than one instruction at a time, then we’ll get some massive performance gains!

Suppose we have the following 3 instructions, which we want to execute in parallel.

InstrCycle 1Cycle 2Cycle 3Cycle 4Cycle 5
R1 = R2 + R3FetchDecodeExecuteWrite Back
R4 = R1 - R5FetchDecodeExecuteWrite Back
R6 = R5 x R9FetchDecodeExecuteWrite Back

If we could execute all of these instructions at the same time, then we’d be able to save on a lot of time! However, this just isn’t possible, because instructions have data dependencies. If we execute and at the same time, we’ll get unexpected results because uses the output of !

Forwarding can’t help either, since we can’t forward in the same cycle!

Instruction Level Parallelism

Given a set of instructions, what instructions we can “legally” execute in parallel?

Typically, we’re looking to execute 3-6 instructions at a time. A CPU that can ideally run instructions per cycle is called N-way superscalar, where is called the issue width.

  • Scalar CPUs: Execute one instruction at a time
  • Vector CPUs: Execute one instruction at a time, but on vector data
  • Superscalar: Can execute more than one unrelated instructions at a time

Data Dependencies

One of the major bottlenecks for ILP is data dependencies. These prevent us from executing instructions in parallel, as we risk creating unexpected outputs.

To understand why these limits are happening, let’s first review the types of data dependencies.

  • Register Dependencies occur due to data dependency conflicts with register numbers
    • Read-After-Write (RAW; True Dependency): writes to a location, and reads from the same location.
    • Write-After-Read (WAR; Anti-Dependency): reads from a location, then writes to the location. If executes before has read its operand, then the operand will be lost.
    • Write-After-Write (WAW) writes to a location, then writes to the same location. Here, there is an output dependency, as the location’s value depends on what executes last.
  • Memory Dependencies occur due the data dependency conflicts with memory addresses

    Memory dependencies are hard to minimize, as because register names are known at decode, memory addresses are not known until execute!

A lot of ILP revolves around addressing these dependencies, so we can maximize parallel execution of instructions. More on addressing register dependencies later.

Register Renaming (WAR, WAW)

In terms of register dependencies, WAR and WAW are false dependencies; they only occur because we have a limited number of registers, so at some point we’re forced to re-use registers.

So, a simple solution is to just add more registers! If we have more registers, and our compiler uses them, we’ll have less false dependency issues.

However, this isn’t very scalable.

  • If you write a value to a register in a loop body, then that same register will be reused every iteration. This introduces many false dependencies!
  • If you make function calls, you could have similar register reuse!

Instead, we use hardware register renaming. The idea is to separate the physical registers from the registers in code:

  • Architecture Registers: Registers that the programmers and compilers use
  • Physical Registers: Actual registers that the processor uses

This abstracts the use of registers in the code we write from the physical registers. Then, if we dynamically map our architecture registers to the physical registers, we can avoid dependency issues!

Consider the following set of instructions.

I1: ADD R1, R2, R3
I2: SUB R2, R1, R5
I3: AND R5, R11, R7
I4: OR R8, R5, R2
I5: XOR R2, R4, R11

To reduce the number of false dependencies, lets replace our registers with a temporary “name” for the values they contain / produce. So, after any write to a register, we will use the “same name” for all subsequent reads until the next write!

I1: ADD R1, R2, R3
I2: SUB R2, R1, R5
I3: AND S, R11, R7
I4: OR R8, S, R2
I5: XOR R2, R4, R11

This tells us what instructions depend on one another! We can rewrite this temporary name with another register to avoid a dependency.

So, we will rename every instruction, and anytime we decode an instruction that will write to a register, we will change the name of the register. We store the mapping between architectural / physical registers in a register allocation table (RAT).

After renaming, we won’t have any false dependencies! We can use these renamed registers with physical registers at runtime, and select free registers to avoid conflicts.

More on implementing this later.

Dynamic Instruction Scheduling

To actually create a processor that uses ILP, we need to use dynamic instruction scheduling.

Theoretical Gains

To calculate our maximum theoretical ILP gain, we want to look at

Where the longest path is determined by the number of true dependencies, we cannot remove them.

We can however, ignore false dependencies, as we’ve (ideally) can assume that we’ve resolved them with other techniques.

ILP is a property of the program and compiler. No matter what processor you run the program on, the true dependencies restrict our maximum parallelism!

Instructions Per Cycle (IPC), on the other hand, depends on the actual machine architecture. This is how much parallelism we can practically achieve!

ILP the upper bound on IPC that we can achieve!

To increase the number of instructions we run per cycle, we use scheduling. Scheduling finds instructions whose dependencies have been resolved, so that they can be executed!

If the scheduling is out of order, then the instructions can be executed in any order, so long as their dependencies have already been resolved.

How do we practically implement a scheduler?

Tomasulo’s Algorithm

Tomasulo’s Algorithm is a hardware algorithm that determines which instructions have inputs ready, and can be executed. The algorithm includes a form of register renaming to eliminate false dependencies.

To implement this algorithm, we need the following hardware components:

  1. Instruction Buffer / Queue: Stores the instructions that we have available to us and can examine for parallelism.
  2. Reservation Stations: Stores instructions that are pending execution, and which operands are ready. Every entry in the reservation tables has a unique identifier.
  3. Register Allocation Table (RAT): Maps register names to reservation station IDs. If the value is 0, then it means the value is in the register file. Otherwise, the entry tells us what pending instruction (in one of the reservation stations) is writing to this register.
  4. The Register File: The register file that stores the values in each register.

For the sake of example, let our tables store the following.

Instruction QueueRAT TableRegister File
3F1 = F2 + F3F10F13.141693
2F4 = F1 - F2F21F2-1.00
1F1 = F2 / F3F30F32.718282
F40F40.707107
Adder Reservation StationMul/Div Reservation Station
1F2 = F4 + F1 | 0.7071 | 0.354
25
3

This algorithm has 3 stages, which are ran each cycle. Together, they let us achieve instruction level parallelism.

Stage 1: Issue

The issue stage takes an instruction and places it in the reservation station, with data telling us what instructions it depends on.

Let’s walk through an example of what the issue stage does.

  1. Pull from the instruction queue to get the next instruction.

    • Here, we pull F1 = F2 / F3.
  2. Find a free spot in a reservation station that can handle the instruction.

    • We find spot (4) in the Mul/Div reservation station.
  3. Write this instruction to the free spot. Then, check the RAT table to see if the operands of the instruction are available or not.

    1. If the RAT table entry is 0, then the value is in the register file. We pull this value and store it in the reservation station.
    • For operand F3, the RAT table stores 0. So, pull the value, 2.718, from the register file, and store it in the reservation table.
    1. If the RAT table is not 0, then the value is the output of a reservation station. We store this reference for now.
    • For operand F2, the RAT table stores 1. So, just store the reservation station spot for the operand to signify that we’re waiting on this instruction.
  4. Look at the instructions output register, and update the RAT table so that its entry points to this instruction’s reservation station index. This tells subsequent instructions that the register output depends on this instruction.

    • Here, our output register is F1. We update the RAT entry for F1 to (4).

After the issue stage, our tables look as follows:

Instruction QueueRAT TableRegister File
3F1 = F2 + F3F14F13.141693
2F4 = F1 - F2F21F2-1.00
1F1 = F2 / F3F30F32.718282
F40F40.707107
Adder Reservation StationMul/Div Reservation Station
1F2 = F4 + F1 ; 0.7071 ; 0.354F1 = F2 / F3 ; (1) ; 2.718
25
3

Stage 2: Execute

The execute stage executes instructions whose operands are ready, and broadcasts the results so they can be used in other instructions.

To update reservation stations, the stage uses a common data bus, and broadcasts the output of the instruction.

Let’s walk through an example of the execute stage.

  1. First, find the instructions whose operands are all populated. These instructions are ready to be executed.
    • We find that slot (1) is ready to be executed, standing for F2 = F4 + F1!
  2. Execute this instruction, using the compute units.
    • We execute F2 = F4 + F1, !
  3. Load results of the instruction so they can update the other hardware components.

If several instructions are ready for one functional unit, we could use whichever instruction we want, with one exception— load/store instructions. Load/store must be done in the correct order to avoid memory hazards.

More on addresing load/store hazards later.

Stage 3: Write

The write stage takes the result of an instruction, and updates the hardware components necessary for continuing the algorithm.

Let’s walk through an example of the execute stage.

  1. Given the output of an instruction, broadcast it on the common data bus. Any reservation station depending on this output will pull and store this output.

    • Recall that our instruction is F2 = F4 + F1, with result 3.8487, in reservation entry (1).
    • Reservation station entry (4) depends on (1). So, it will overwrite (1) with the result, 3.8487. Now, this instruction is ready to execute in the next cycle!
  2. Check the RAT table for the instruction’s output. If the table stores the current reservation station index, then update the RAT table (to 0) and RF file (to store theoutput).

    • The RAT table for register F2 is (1), which is our instruction! So, set this entry to 0, and update the RF file for F2 to be 3.8487.

    It’s important we only update if the RAT table entry matches. This implements register renaming, as the RAT table only stores the mapping for the last write to a register!

  3. Free the reservation station entry for the instruction.

    • Free reservation entry (1).

After the write stage, our components look like this:

Instruction QueueRAT TableRegister File
3F1 = F2 + F3F14F13.141693
2F4 = F1 - F2F20F23.8487
1F1 = F2 / F3F30F32.718282
F40F40.707107
Adder Reservation StationMul/Div Reservation Station
14F1 = F2 / F3 ; 3.8487 ; 2.718
25
3

The reservation stations would likely have fetched more instructions by now, but for the sake of example we’ve committed this.

Note how through the common data bus, the reservation stations take care of register dependencies!

Example: Deep Dive

The Reorder Buffer (ROB)

Tomasulo’s algorithm lets us execute instructions out of order to achieve instruction level parallelism! However, even if we reorder instructions, we still must process instructions exactly in program order!

This is mainly because of control flow— in the real world, we could have exceptions, or branch mispredictions. These can cause us to update registers in the incorrect order!

Example: Branch Misprediction

DIV R1 R3 R4
DEQ R1 R3 Label
ADD R3 R4 R5

Suppose here, we predict that a branch is not taken, so we execute the ADD and update R3.

Later, we realize that the branch should have been taken. By this time, we’ve already updated R3 with a new wrong value! This will cause incorrect program behaviors.

To address this, we need to deposit values to registers in order! We do this using a reorder buffer (ROB), which will:

  • Remember the program order
  • Keep the results of instructions until it is safe to write

It does this by storing entries, which contain

  • Type of instruction
  • Destination register of instruction
  • Result of instruction
  • A flag indicating if the instruction was finished

And a head, tail indicating what instructions have yet to be “committed” in order (more on that later).

TypeDest.ValueFinished
ROB1DIVR29Yes
ROB2 - HEADMULR112
ROB3ADDR33Yes
ROB4 - TAILMULR136Yes
ROB5

Now, the register file will always store the “official” register state, which will only be updated in program order.

The ROB will now be the one storing our register results in the correct order, and will ensure our register file is updated in the order the program gave.

With the ROB, we modify our stages as follows. Now, the RAT table and reservation stations will store references to ROB entries, instead of reservation station entries:

  • Issue:

    1. Read the instruction from the buffer.
    2. Check if there is a RS entry and a ROB entry available.
      • Stall if there are no open entries.
    3. Read the RAT table, read available sources, and update RAT to point to the ROB entry.
    4. Write to the RS and ROB.
  • Execute: No change

  • Write:

    1. Broadcast result on the data bus, for the reservation stations to grab.
    2. Write the result back to the ROB entry, and mark the entry as finished execution.
    3. Now that the ROB stores the results, free the reservation station.

    Some architectures free the instruction’s reservation station in execute, as the data will be in the ROB table after anyways.

We also add a new stage, commit.

  • Commit:
    1. For the oldest instruction in the ROB, check if instruction has been executed.
    2. If it has, write the result to the register file, or memory, depending on what the instruction is. We always update the register file, no matter what the RAT file says. However, if the ROB is:
      • Not the value in RAT, don’t change the RAT entry.
      • The value in RAT, clear the RAT entry to 0, indicating the value is in the register file.
    3. Advance the ROB-head to point to the next instruction.

Commit MUST be in order. An instruction cannot commit until all prior instructions have committed.

With the ROB in place, we can now use it to recover from branch mispredictions and exceptions by flushing all data after the instruction where the misprediction / exception occurred. If the exception occurred on instruction I, then we:

  • Flush the ROB for all entries after instruction I
  • Point all RAT entries to their corresponding RFs
  • Clear all of the RS entries and anything in the execution units

Then, resume execution with the correct instructions!

The Load-Store Queue (LSQ)

So, we used the ROB to fix exceptions and branch mispredictions.

What about memory dependencies? Memory instructions such as store only write to memory at commit. If this is the case, how do out-of-order loads know what their data is?

This is the purpose of the Load-Store Queue (LSQ). This queue stores the load, store instructions in order, so that loads can be populated with the results of stores before a commit takes place.

LD will denote a load instruction, and ST will denote a store instruction.

For example, an LSQ could look like the following:

L/SADDRVAL
LD104
ST20415done
LD20415

For every LD we add to the queue, we check to see if its address match any ST addresses.

  • If there is a matching ST, we do not go to memory, and can pull the value from the LSQ.

    Here, LD on 204 can pull value 15 from ST on 204.

  • If there is not a matching ST, we have a few options:

    1. We do not allow a LD to execute until all previous instructions have been completed.
    2. We do not allow a LD to execute until all previous ST’s have been completed, to match addresses
    3. Let the LD fetch from memory!

Modern processors do option 3. If we later realize that the LD loaded the wrong value, we recover!

We incorporate the LSQ into Tomasulo’s algorithm by adding onto the following stages as so:

  • Issue: Allocate an LSQ entry for each LD/ST, in addition to the ROB entry and RS.
  • Execute: Generate an address for the LD/ST instruction, and update the LSQ / look-up the LSQ for previous values.
  • Commit: If ST, write value to memory. Free the LSQ entry in addition to the ROB entry.

What the LSQ essentially does is track addresses for values.

… TODO

Improving Tomasulo’s Algorithm

Here, we discuss various ways we could potentially improve Tomasulo’s algorithm (though they each come with their own tradeoffs).

Unified Reservation Stations

In the issue stage, we’re forced to stall if there is no open RS. However, there could still be open RS slots for other compute units!

Figuring out the right number of RS per compute unit is difficult, as different programs have different distributions of instructions.

One way we could resolve this is by using a unified reservation station! Instead of different RS for different compute units, we just have 1 for every compute unit!

This does mean things get a bit more complex though!

Superscalar Processing

The current implementation of Tomasulo’s algorithm give us one instruction per cycle, but cannot give us more! We need a lot of the components to be superscalar in order to achieve higher than 1 IPC.

  • We need a superscalar fetch and decode
  • We need more than one CDB to write the results of instructions
  • We need a mechanism to dual-rename registers at the same time

In practice, modern processors do this! However, it comes at a cost of area, logic, and power.

  • Exceptions / Branch Mispredictions — Recovering from Control Dependencies
  • Out of Order Memory Dependencies

Compiler ILP Techniques

Previously, we’ve seen ways we can achieve ILP on the hardware. However, it’s not easy to do this, and it’s costly to implement all of these optimizations in the hardware!

To help with this, we can also use the compiler! The compiler can make changes in how the code is generated so that the hardware has an easier time achieving ILP.

  • Shortening dependency chains
  • Moving dependent instructions further apart
  • Improving the opportunity for parallelism

Below, we’ll discuss some of the various ways our compiler can optimize our code.

Optimization: Tree Height Reduction

Using the principle of associativity, it may be possible to reduce critical paths. For example, suppose we have code

R8 = R2 + R3 + R4 + R5

If we evaluate this left to right, we’ll generate code that’ll yield a dependency graph with a length 3 path!

R8 = ((R2 + R3) + R4) + R5
 
I1: ADD R6, R2, R3
I2: ADD R7, R6, R4
I3: ADD R8, R7, R5

However, we don’t have to do the addition in the given order because it’s associative! We can actually reduce this so that our longest dependency path is only length 2.

R8 = (R2 + R3) + (R4 + R5)
 
I1: ADD R6, R2, R3
I2: ADD R7, R4, R5
I3: ADD R8, R7, R6

Instruction Scheduling

Outside of Tomasulo’s Algorithm, it’s possible for the compiler to reduce dependency chains. It can do this by reordering our instructions!

Instruction Reordering

Suppose we have the following loop.

for (i = 1000; i > 0; i--)
    x[i] = x[i] + s;

Directly translating this loop, we’d have assembly code

Loop: 
    LD F0, 0(R1)
    ADD F0, F0, F2
    ST F0, 0(R1)
    ADD R1, R1, #-8
    
    BNE R1, R2, LOOP

Now suppose we have a single-issue processor, where LD takes 2 cycles, ADD takes 3 cycles, and other instructions take 1 cycle. Factoring in these stalls, we would execute these instructions as follows:

Loop: 
    LD F0, 0(R1)
    stall
    ADD F0, F0, F2
    stall
    stall
    ST F0, 0(R1)
    ADD R1, R1, #-8
    stall
    stall
    
    BNE R1, R2, LOOP

The compiler can optimize this to reduce the number of stalls! In fact, there’s no reason to decrement the pointer at the end— what if we do it earlier to spread out the dependencies?

Loop: 
    LD F0, 0(R1)
    ADD R1, R1, #-8
    ADD F0, F0, F2
    stall
    stall
    ST F0, 8(R1) # Note the change in offset
    BNE R1, R2, LOOP

Loop Unrolling

When possible, the compiler can reduce the number of branches in the code to make scheduling easier! One notable example of this is loop unrolling.

Using loop unrolling, a compiler can transform an M iteration loop into a loop with M / N iterations.

In this case, we say the loop has been unrolled times.

Suppose we have the following loop. We can unroll 4 times as follows:

// Original
for (i = 1000 ; i > 0 ; i--)
    x[i] = x[i] + s;
    
// Unrolled 1 Time
for (i = 1000; i > 0 ; i -= 2) {
    x[i] = x[i] + s;
    x[i - 1] = x[i - 1] + s;
}
 
// Unrolled 4 Times
for (i = 1000; i > 0 ; i -= 4) {
    x[i] = x[i] + s;
    x[i - 1] = x[i - 1] + s;
    x[i - 2] = x[i - 2] + s;
    x[i - 3] = x[i - 3] + s;
}

Let’s see the various reasons why loop unrolling helps.


Less Loop Overhead: Loop unrolling can reduce the number of total instructions, by reducing the number of times we need to update the index. Looking at the assembly of our loop,

# Original
Loop: 
    LD R2, 0[R1]
    ADD R2, R2, R3
    ST R2, 0[R1]
    ADD R1, R1, -4
    BNE R1, R5, LOOP
 
# Unrolled 1 Time
Loop:
    LD R2, 0[R1]
    ADD R2, R2, R3
    ST R2, 0[R1]
    LD R2, -4[R1]
    ADD R2, R2, R3
    ST R2, -4[R1]
    ADD R1, R1, -8
    BNE R1, R5, LOOP

While our assembly is longer, our unrolled loop actually executes less total instructions! Our original loop executes instructions, whereas our unrolled loop executes instructions.


Better Scheduling: Spreading out our loop lets us schedule our instructions better.

  1. We are able to execute instructions for the next iteration of the loop earlier (as there are less overall dependencies).
  2. Lets us hide load latencies by adding more instructions per iteration.

Loop unrolling can even eliminate small loops, letting us remove the branch in the loop entirely!

The less branches, the easier it is to schedule things.

Function Inlining

This idea can even be applied to functions! Compilers can “inline” functions, by “copying” the function code directly into where it is called! This lets us avoid the function call overhead.


Loop unrolling is not without consequences. Some issues related to unrolling include:

  • Larger code size leads to increased register pressure
  • Less readable code
  • Difficulties in unrolling— what if N is not known at compile time?

Software Pipelining

Recall instruction pipelining, where we perform a different operation on a different operation. We can also apply this general idea in the compiler as well!

Software Pipelining is a technique that lets us write code in a way that makes it easier for our hardware to schedule instructions. Let’s see an example of this below.

Consider the following loop, with stages Load, Multiply, and Add.

for (i = 0 ; i < 100 ; i++)
    sum += a[i] * b[i];

First, let’s break up our instruction into these different stages, so we have independent operations.

graph LR
subgraph Load Stage
0["Load a[i]"]; 1["Load b[i]"];
end

subgraph Mul Stage 
2[x];
end

subgraph Add Stage
3[+=];
end

0 & 1 -.-> 2 -.-> 3;

Now, let’s update our code to separate these different stages.

for (i = 0; i < 100; i++) {
    // Load Stage
    ai = a[i];
    bi = b[i];
    
    // Mul Staage
    prod = ai * bi;
    
    // Add Stage
    sum += prod;
}

So for any “instruction” (body of the loop), we need to run LOAD, MUL, then ADD on it. Now, for a pipeline, we want to execute our instructions so that at each iteration (analogous to cycle), each stage is executing a different “instruction”! So, we want something like:

  1. In Iteration 2 (“Cycle 2”), run LOAD on a[2], b[2], run MUL for a[1] * b[1], and run ADD for sum = a[0] + b[0].
  2. In Iteration 3 (“Cycle 3”), run LOAD on a[3], b[3], run MUL for a[2] * b[2], and run ADD for sum = a[1] + b[1].
  3. …

This separates our “stages” so that there is less overlap, giving our processor an easier time scheduling our instructions!

Like in the instruction pipeline, we need some way to connect these stages together! We can do this with memory.

  • Between LOAD/MUL, we need memory to store the ai, bi values fetched from load.
  • Between MUL/ADD, we need memory to store the result of the product ai * bi.

So, our code will look as follows:

// --- Prologue ---
// Memory for MUL/ADD
prod = a[0] * b[0];
// Memory for LOAD/MUL
ai = a[1]; bi = b[1];
// ---
 
// --- Pipeline ---
// BE CAREFUL ABOUT INDICES
for (i = 2; i < 100; i++) {
    // Add Stage: Adds Product of Index i - 2
    sum += prod; 
    
    // Mul Stage: Multiplies Load of Index  i - 1
    prod = ai * bi;
    
    // Load Stage: Loads Index i
    ai = a[i];
    bi = b[i];
}
// ---
 
// --- Epilogue ---
// Finish by adding the results of indices 98 and 99
sum += prod;
sum += ai * bi;
// ---