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.
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.
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_exitflag 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.
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; }
}
}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_flushwith correct target. - JAL/JALR: Always flush and redirect, since the BTB-predicted target may differ from the computed target.
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.
Each instruction in the fetch bundle goes through:
- RAT lookup: Read the current physical register mapping for
rs1/rs2. - PRF allocation: Pop a free physical register from the freelist for
rd. - RAT update: Map
rd→ new physical register. - ROB allocation: Create a ROB entry, record
arch_rd,phys_rd,old_phys. - IQ insertion: Insert an issue queue entry with
phys_rs1/phys_rs2and 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.
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.
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 takenOn update, the counter is incremented (taken) or decremented (not taken), and the global history shifts in the result.
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
}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.
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.
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).
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
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.
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.
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.