Skip to content

Latest commit

 

History

History
250 lines (174 loc) · 10.3 KB

File metadata and controls

250 lines (174 loc) · 10.3 KB

Inside the VOLT Pipeline

This document walks through every stage of the VOLT out-of-order pipeline, the branch predictors, the memory subsystem, and the IPC wormhole fix that made the numbers real.


Pipeline Overview

The VOLT pipeline is a 4-phase per-cycle loop, processing up to issue_width (default 6) instructions per phase:

   FETCH ──→ RENAME ──→ ISSUE ──→ EXECUTE ──→ COMMIT
   (Phase 3)  (Phase 3)  (Phase 2)  (Phase 2)   (Phase 1)

Each cycle executes these phases in reverse order (Commit → Execute → Rename/Fetch) so that results from one cycle are available to dependent instructions in the same cycle.


Phase 1: Commit

The commit phase retires up to issue_width instructions from the head of the ROB.

// Simplified from src/volt/cpu.rs
while committed < issue_width && !rob.is_empty() && rob.peek_head().ready {
    let entry = rob.pop_head();
    // Write result to architectural register file
    arch_regs[entry.arch_rd] = prf.read(entry.phys_rd);
    // Free old physical register back to freelist
    freelist.push(entry.old_phys);
    // If store: commit to L1D / DRAM
    if entry.is_store { l1d.write(entry.store_addr, entry.store_data); }
    // If ECALL: handle syscall (write-to-stdout, exit)
    // If branch mispredict: set pending_flush
    committed += 1;
}

Key behaviors:

  • Stores drain from the LSQ only at commit time (write-through to DRAM).
  • The architectural register file is updated when the instruction leaves the ROB.
  • A pending_exit flag defers halt until the end of the commit phase, so all earlier instructions retire first.
  • Branch mispredict flush is triggered from the execute phase, but only takes effect at the start of the next cycle.

Phase 2: Execute

Issue Queue — Select & Wakeup

The issue queue is a unified (non-split) queue shared across all functional unit types. Each entry tracks:

struct IqEntry {
    valid: bool,
    rob_idx: usize,           // which ROB entry owns this
    phys_rs1: usize,          // physical register for rs1
    phys_rs2: usize,          // physical register for rs2
    rs1_ready: bool,          // is rs1 value available?
    rs2_ready: bool,          // is rs2 value available?
}

Select: Iterates all valid entries with both operands ready, computes circular ROB age as (rob_idx - head) mod rob_size, and picks the oldest. This models an age-based matrix scheduler.

Wakeup: When an instruction completes and writes its result to physical register P, all IQ entries are scanned. Any entry waiting on P as phys_rs1 or phys_rs2 is marked ready. This is an explicit broadcast wakeup network — not idealized zero-delay.

// Simplified from src/volt/ooo.rs
fn wakeup(&mut self, phys_reg: usize) {
    for entry in &mut self.entries {
        if entry.phys_rs1 == phys_reg { entry.rs1_ready = true; }
        if entry.phys_rs2 == phys_reg { entry.rs2_ready = true; }
    }
}

Execution

Up to issue_width instructions are selected and executed per cycle:

  • ALU ops: ADD, SUB, XOR, shifts, compares — all single-cycle.
  • MUL/DIV: Modeled with extra latency (multi-cycle via the FP unit pipeline).
  • Loads: Check the LSQ for store-to-load forwarding first; miss → access L1D cache hierarchy.
  • Stores: Compute address and data, store in LSQ entry.
  • Branches: Compare actual direction vs prediction. Mispredict → set do_flush with correct target.
  • JAL/JALR: Always flush and redirect, since the BTB-predicted target may differ from the computed target.

Store-to-Load Forwarding

Before hitting the L1D, loads search the LSQ for the youngest older store with a matching address:

fn try_forward(&self, addr: Addr, size: u64) -> Option<u64> {
    self.entries.iter().rev().find(|e| e.valid && e.addr_known && e.addr == addr)
        .map(|e| e.data & mask(size))
}

If a store-to-forwarding candidate is found, the load completes immediately without accessing the cache. This is critical for performance — it avoids the store-to-load latency penalty that plagues many simple simulators.

Memory ordering: If any older store has an unknown address, the load stalls (has_older_store_unknown_addr). This is a conservative model of real OoO memory disambiguation.


Phase 3: Rename & Fetch

Rename

Each instruction in the fetch bundle goes through:

  1. RAT lookup: Read the current physical register mapping for rs1/rs2.
  2. PRF allocation: Pop a free physical register from the freelist for rd.
  3. RAT update: Map rd → new physical register.
  4. ROB allocation: Create a ROB entry, record arch_rd, phys_rd, old_phys.
  5. IQ insertion: Insert an issue queue entry with phys_rs1/phys_rs2 and ready status (ready if the physical register is ≤ 31, or if it's already been written).

Branch prediction happens during rename. If the decoded instruction is a branch and the predictor says "taken", the fetch PC is immediately redirected.

Fetch

Fetches up to fetch_width 32-bit instructions from L1I per cycle. On an L1I miss, the fetch stalls while the cache hierarchy fills the line (L2 → DRAM). On a flush (from branch mispredict), the PC is redirected to the correct target and the fetch pipeline restarts.


Branch Predictors

Gshare

The Gshare predictor XORs the PC with a 12-bit global history register to index a table of 2-bit saturating counters:

let index = ((pc >> 2) as usize ^ self.history as usize) & (self.pht.len() - 1);
let prediction = self.pht[index] >= 2; // weakly taken or taken

On update, the counter is incremented (taken) or decremented (not taken), and the global history shifts in the result.

TAGE (TAgged GEometric)

TAGE is the most sophisticated predictor — it's the current state of the art for conditional branch prediction in real CPUs.

VOLT's TAGE has 4 tagged tables with geometric history lengths:

Table History Length Entries Counter Bits
T1 4 bits 2048 3-bit
T2 8 bits 2048 3-bit
T3 12 bits 2048 3-bit
T4 16 bits 2048 3-bit

Each entry stores:

  • Tag: pc_hashed ^ folded_history (16 bits)
  • Counter: 3-bit saturating (threshold ≥ 4 = taken)
  • Useful: 2-bit (tracked via US/ALT update)

Prediction: Walk tables from longest history to shortest. The first table that hits (tag match) provides the prediction. If no table hits, fall back to a bimodal predictor.

Allocation: On misprediction, allocate an entry in the longest-matching table or one tier shorter (if that entry's useful counter is 0). This is a simplified but effective version of the TAGE allocation policy.

// Simplified from src/volt/pipeline.rs
fn predict(&self, pc: u64, hist: u64) -> bool {
    for (i, table) in self.tables.iter().enumerate().rev() {
        let idx = hash(pc, hist, table.hist_len);
        if table.entries[idx].tag == compute_tag(pc, hist, table.hist_len)
            && table.entries[idx].counter >= 4
        {
            return table.entries[idx].counter >= 4;
        }
    }
    self.bimodal.predict(pc) // fallback
}

Hybrid (Gshare + Bimodal)

The hybrid predictor runs both Gshare and Bimodal in parallel, with a 4096-entry meta-predictor table. When the two sub-predictors disagree, the meta-table chooses which one to trust. The meta-table updates by favoring whichever sub-predictor was correct.

Unified Dispatch

All four predictors are dispatched through a common BranchPredictor enum:

enum BranchPredictor {
    Bimodal(BimodalPredictor),
    Gshare(GsharePredictor),
    Tage(TagePredictor),
    Hybrid(HybridPredictor),
}

The GA selects which predictor type to use per configuration.


Memory Subsystem

Cache Hierarchy

Three-level hierarchy using a generic Cache struct (265 lines, src/volt/mem/cache.rs):

Cache Default Size Associativity Hit Latency Miss Latency
L1I 32 KB 8-way 1 cycle 10 cycles
L1D 32 KB 8-way 1 cycle 10 cycles
L2 512 KB 8-way 10 cycles 50 cycles

Miss handlers chain: L1 miss → L2 read, L2 miss → DRAM read (instant in the current model).

Load/Store Queue

The LSQ (src/volt/mem/lsq.rs, 178 lines) supports:

  • Separate load/store capacity partitions (configurable)
  • Store-to-load forwarding with address matching and size masking
  • Memory ordering: loads stall behind unknown-address older stores
  • Flush: on misprediction, all entries newer than the flush ROB index are invalidated

IPC Wormhole Fix

Early GA runs reported unrealistically high IPC (>10) for certain configurations. The root cause was a subtle interaction between the physical register file and the ROB.

The Bug

prf.is_ready(phys_rd) checked whether physical register phys_rd had been written by its producer. When a physical register was freed at commit and immediately reallocated to a new instruction, is_ready() returned true based on the old producer's completed write — even though the new producer hadn't executed yet.

This meant the issue queue would wake up the dependent instruction with a stale value, leading to IPC that was physically impossible.

The Fix

is_phys_available() in src/volt/cpu.rs replaces prf.is_ready():

fn is_phys_available(phys_rd: usize, rob: &ReorderBuffer) -> bool {
    match rob.find_producer(phys_rd) {
        Some(e) => e.ready,           // still in-flight: check if done
        None => true,                 // committed: value is in PRF
    }
}

It searches the ROB for the instruction that owns the physical register. If found in-flight, it checks e.ready; if not found (already committed), the value is resident in the PRF and available.

The select() function was also fixed to use proper circular age: age = (rob_idx - head) mod rob_size instead of a raw < comparison, which broke under circular ROB wrap-around.

After the fix, synthetic workloads report IPC=2.95 (high_perf, 2M instructions), and CoreMark reaches IPC=5.16 on the best GA-optimized config — realistic numbers for an 8-wide OoO core.