Skip to content
MINH VO A working notebook
by an engineer in Vietnam
Foundation8 min read

Forward a result before it reaches the register file

Select the newest available producer for each operand, trace two writes to the same register, and distinguish forwarding from waiting for an unavailable result.

Illustrated circuit board resting on drafting paper with faint technical sketches

Forwarding supplies an operand from an older instruction’s in-flight result before that result reaches the register file. If you read compiler output, this explains how adjacent dependent additions can execute without waiting for writeback. Correct forwarding must choose the newest relevant producer, check that it really writes the register, and wait when that producer’s value is not available.

Assume a hypothetical in-order, single-issue pipeline with IF, ID, EX, MEM and WB stages. An integer addition computes its result during EX, captures it in the EX/MEM register at the cycle boundary, then carries it toward WB. The consumer can select an EX operand from the EX/MEM or MEM/WB register during its own EX cycle. These paths and timing rules are declared features of this example, not requirements of the RISC-V ISA.

Follow one dependent addition through time

Suppose x1, x2, x3 and x4 contain 3, 4, 5 and 6. The register file’s old x5 value is 99. Consider these illustrative RV32 instructions:

add x5, x1, x2
add x5, x5, x3
add x6, x5, x4
add x7, x6, x5

Their sequential results are x5 equals 7 after I1, x5 equals 12 after I2, x6 equals 18 after I3, and x7 equals 30 after I4. The second write to x5 is deliberate: it creates a case in which two possible bypass sources have the same destination number but different ages and values.

Without stalls, I1 performs EX in cycle three and I2 performs EX in cycle four. At the start of cycle four, EX/MEM holds I1’s completed sum, 7. I2 may have captured the old value 99 during ID, but its operand multiplexer selects 7 from the bypass path instead. It adds x3’s value 5 and produces 12 at the end of that cycle.

This does not require two additions to propagate through one EX stage in the same cycle. I1’s result was captured at the preceding boundary. The bypass selects an already available registered value, then I2’s own ALU computes from it during the next interval. The exact timing budget must still include that selection logic and wiring.

In cycles four and five, the younger instruction's EX operands select results held by earlier instructions in pipeline registers rather than stale decoded operands.View full-size image ↗

The Computation Structures lecture on pipelined execution explains bypassing and producer-age priority. Its Beta example forwards into the register-read stage, including a combinational ALU-output path. This article deliberately uses a different, explicitly registered path into EX. The underlying dependency rule is shared; the clock boundary and number of possible stalls must be derived for the selected datapath.

Choose the youngest matching writer

In cycle five, I3 reaches EX. EX/MEM contains I2’s result 12 for x5, while MEM/WB contains I1’s older result 7 for x5. Both destination numbers match I3’s source register. Choosing any match is insufficient: sequential execution requires the younger older instruction, I2, because it overwrites I1’s value before I3 reads x5.

The priority is EX/MEM before MEM/WB in this strictly in-order example. Selecting MEM/WB first would make I3 compute 7 plus 6, producing 13 instead of 18. A priority bug can remain invisible in instruction streams that never have two in-flight writes to the same destination. Include an intentional overwrite when testing the selector.

For I4 in cycle six, the two operands take different routes. Its x6 value 18 comes from I3 in EX/MEM, and its x5 value 12 comes from I2 in MEM/WB. There must be a selection decision for each consumed source. One shared “use forwarded operand” flag cannot describe both sources unless the hardware also carries the required independent choices.

Qualify the match before using a value

A destination field is meaningful only when the instruction actually writes that register. An inactive pipeline slot or an instruction with writeback disabled must not override the decoded operand merely because some stored bits happen to equal its number. The example represents that condition with a write-enable flag.

RISC-V x0 always reads as zero. A result nominally directed to x0 cannot become a forwarded nonzero operand. Check the zero-register rule explicitly rather than assuming every destination match represents architectural state. The RV32 register and integer-instruction definitions supply this semantic requirement; how the bypass circuit enforces it belongs to the implementation.

Availability is another condition. A matching newer producer can exist before its final result is ready, as with a load whose EX result is only an address. The selector must not bypass that address as loaded data, or silently fall through to an older value for the same register. The consumer needs the newer writer’s result, so it must wait or use another correctly specified mechanism.

from dataclasses import dataclass

MASK32 = (1 << 32) - 1

@dataclass(frozen=True)
class Producer:
    rd: int
    value: int | None
    writes: bool = True
    ready: bool = True

def operand(rs, decoded_value, ex_mem=None, mem_wb=None):
    if rs == 0:
        return 0, "zero"
    for name, producer in (("EX/MEM", ex_mem), ("MEM/WB", mem_wb)):
        if producer is None or not producer.writes or producer.rd != rs:
            continue
        if not producer.ready:
            raise RuntimeError("newest matching result is not available")
        if producer.value is None:
            raise ValueError("ready producer must contain its result")
        return producer.value & MASK32, name
    return decoded_value & MASK32, "decoded"

# I1 EX in cycle 3; its result is registered for cycle 4.
i1 = Producer(5, 3 + 4)
a, route = operand(5, 99, ex_mem=i1)
assert (a, route) == (7, "EX/MEM")
i2 = Producer(5, (a + 5) & MASK32)

# I3 EX in cycle 5: both producers match x5; I2 is newer.
a, route = operand(5, 99, ex_mem=i2, mem_wb=i1)
assert (a, route) == (12, "EX/MEM")
i3 = Producer(6, (a + 6) & MASK32)
assert i3.value == 18 and (i1.value + 6) == 13

# I4 EX in cycle 6: its operands use different bypass registers.
a, route_a = operand(6, 0, ex_mem=i3, mem_wb=i2)
b, route_b = operand(5, 99, ex_mem=i3, mem_wb=i2)
assert (a + b, route_a, route_b) == (30, "EX/MEM", "MEM/WB")
assert operand(0, 99, Producer(0, 123)) == (0, "zero")
assert operand(5, 99, Producer(5, 123, writes=False)) == (99, "decoded")
assert operand(4, 6, i2, i1) == (6, "decoded")
assert operand(5, 0, Producer(5, 1 << 32)) == (0, "EX/MEM")

try:
    operand(5, 99, Producer(5, None, ready=False), i1)
except RuntimeError:
    pass
else:
    raise AssertionError("older x5 incorrectly replaced an unavailable newer result")
print("four dependent additions, source priority, x0 and unavailable-result cases checked")

The Python model selects from a declared snapshot of the two pipeline registers and the value previously captured during ID. It is not a complete pipeline simulator and does not claim to track register-file timing, branch recovery or memory responses. Its useful output is the selected value and route, which makes a priority mistake visible without hiding it inside a final arithmetic assertion.

The operand selector first honors x0, then the newer eligible producer, then the older producer, and otherwise the captured operand; an unavailable newer match requires waiting.View full-size image ↗

Account for the cost of the bypass

Forwarding removes a wait only when the producer’s value exists early enough and the path to the consumer meets timing. Comparators detect register matches; multiplexers choose operands; wires carry values between stages. Those components have delay and area. A design can reduce stalls while lengthening the clock period, so instruction count or cycles alone do not settle the performance tradeoff.

An implementation can choose fewer bypass paths and stall in the cases it does not cover. That may be a sensible cost decision for a small core, but the unsupported cases need deliberate control behavior. Producing an answer from stale register state is not a cheaper implementation of the same program.

Forwarding also does not remove the logical dependency chain. I2 still needs I1’s result, and I3 still needs I2’s. More independent execution resources cannot compute a true dependent result before its inputs exist. Instruction scheduling can interleave unrelated work, while a different algorithm can sometimes shorten the dependency chain. Neither change follows automatically from adding a bypass multiplexer.

Test the selection boundary rather than only the final sum

For an exercise, change the newest producer’s destination from x5 to x8. The source x5 should then select the older MEM/WB result. Next disable both producers’ write enables and check that the captured operand is selected. Finally restore the newer x5 match but mark its result unavailable: the older x5 must no longer be accepted as a substitute.

Those three cases distinguish no match, a disabled match and a required result that is not ready. They are different reasons for the selector to behave differently. When reviewing RTL or a simulator, record the consumer source, producer destination, age, write enable, availability and chosen route at the relevant boundary. That evidence is more diagnostic than a generic observation that the program sometimes computes the wrong number.

Sources & further reading

  1. Computation Structures L15: Pipelining the Beta, written bypass and hazard discussion
  2. RISC-V unprivileged ISA: RV32I registers and integer operations
← Back to the journal
All notes

Illustration

100%