A Pipeline: Instruction-Level Checking
Check a pipeline through its retirement port: one symbolic register for "read what was written", the ISA function for "computed what the ISA says", and the helpers that make it a proof.
- Lesson
- F8.7
- Time
- 25 min
- Runs on
- SymbiYosys
- Check
- 4 questions
A three-stage pipeline forwarded results from writeback to issue. A later change added a bypass from execute with the priority the wrong way round. It broke one case: a register written by two instructions in a row and read by the third. The random instruction stream rarely made the pattern, and the wrong value looked like a data error.
Read the diagram as text
- issue: read rs1, rs2: execute bypass, else writeback bypass, else the register file
- execute: ADD, SUB, AND, ADDI
- writeback: writes rd in the four-entry register file
- retirement port: rvfi_valid, op, rs1/rs2 index and value used, rd index and value written
- issue → execute
- execute → writeback
- execute → issue: execute bypass
- writeback → issue: writeback bypass
- writeback → retirement port
The same idea as riscv-formal's RVFI, nine signals instead of dozens. Pipelining itself is R4.3. pipe3 has 147 flops.
The Reg Check: One Register Stands for All
Measured with smtbmc yices: FAIL at reported step 7, 0.6 s. Ops: 0 ADD, 1 SUB, 2 AND, 3 ADDI.
Pick a register index as a free constant, keep a shadow of its value updated at retirement, and assert that every retired read of that register used the shadow. One index, every register (F4.4). The trace marks the three instructions that matter. Two writes retire at steps 4 and 5; the read at step 6 took the older result.
The Insn Check, and Why Both
| Check | Correct design | BUG_FWD_PRIO | BUG_ALU (SUB off by one on a borrow) |
|---|---|---|---|
| reg: reads see the last write | BMC PASS to depth 20; abc pdr Prove PASS (abc pdr) | FAIL at reported step 7 | BMC PASS to depth 20 |
| insn: result is the ISA function of the operands | Prove PASS (k = 3) | BMC PASS to depth 20 | FAIL at reported step 6 |
Every run is bmc depth 20 unless noted, with smtbmc yices.
insn says each result is the ISA function of the operand values the port reports. It cannot see a wrong operand, because it trusts the reported one. reg cannot see a wrong result, because it only checks reads. Each is blind to the other's bug.
The riscv-formal suite runs more checks on the same port: pc_fwd and pc_bwd for the program counter, unique and causal for ordering, and a liveness check. This toy has no program counter, so two are enough.
Making It a Proof
| Proof run, correct design | Measured result |
|---|---|
| reg, prove k = 8 | UNKNOWN at k = 8 (base case passed, induction step failed), 0.6 s |
| reg plus helpers, prove k = 2 | Prove PASS (k = 2), 0.6 s |
reg, abc pdr | Prove PASS (abc pdr), 3.3 s |
| insn, prove k = 2 | UNKNOWN at k = 2 (base case passed, induction step failed), 0.5 s |
| insn, prove k = 3 | Prove PASS (k = 3), 0.5 s |
smtbmc yices unless noted.
A register can sit untouched for any number of cycles, so no k makes the reg check inductive alone. The helpers state what the pipeline keeps true: the register file matches the shadow, and every operand in flight matches what issue should have seen (F4.6).
The alternative is sequential equivalence against an unpipelined reference (F6.2). It needs a golden model and a mapping; the instruction checks need only the ISA. Use SEC when a reference exists, these checks when the ISA is the spec.
Asked in Interviews
| Question | Answer |
|---|---|
| How does riscv-formal check a core? | Through a retirement port, with per-instruction checks (insn, reg, pc_fwd, pc_bwd, unique, causal, liveness) that never look inside the pipeline. |
| SEC or property checking for a pipeline? | SEC when a reference core exists; instruction checks when the ISA is the spec. |
| Why both reg and insn? | Each is blind to the other's bug class: a wrong operand passes insn, a wrong result passes reg. |
Try It
[options]
mode bmc
depth 20
[engines]
smtbmc yices
[script]
read_verilog -formal -sv design.sv
prep -top pipe3
[files]
design.sv(* anyconst *) reg [1:0] f_rd;
assert property (@(posedge clk) disable iff (rst) rvfi_valid && rvfi_rs1_addr == f_rd |-> rvfi_rs1_rdata == f_shadow);
assert property (@(posedge clk) disable iff (rst) rvfi_valid && rvfi_op == ADD |-> rvfi_rd_wdata == rvfi_rs1_rdata + rvfi_rs2_rdata);
cover property (@(posedge clk) disable iff (rst) rvfi_valid [*3]); always @(posedge clk) begin
if ($initstate) begin assume(rst); end else begin assume(!rst); end
end
(* anyconst *) reg [1:0] f_rd; // the tracked register: the solver picks it
reg [7:0] f_shadow = 0; // its value as the retired instructions wrote it (registers reset to 0)
always @(posedge clk)
if (rst) f_shadow <= 0;
else if (rvfi_valid && rvfi_rd_addr == f_rd) f_shadow <= rvfi_rd_wdata;
always @(posedge clk) if (!rst && rvfi_valid) begin
if (rvfi_rs1_addr == f_rd) assert(rvfi_rs1_rdata == f_shadow); // reg: read what was written
if (rvfi_rs2_addr == f_rd && rvfi_op != 2'd3) assert(rvfi_rs2_rdata == f_shadow);
assert(rvfi_rd_wdata == (rvfi_op == 2'd0 ? rvfi_rs1_rdata + rvfi_rs2_rdata : rvfi_op == 2'd1 ? rvfi_rs1_rdata - rvfi_rs2_rdata
: rvfi_op == 2'd2 ? rvfi_rs1_rdata & rvfi_rs2_rdata : rvfi_rs1_rdata + rvfi_imm)); // insn: the ISA result
end
// helpers for k-induction: the register file and every operand in flight agree with the shadow
wire [7:0] f_now = rf[f_rd]; // includes the write now on the retirement port
wire [7:0] f_wb_alu = wb_op == 2'd0 ? wb_a + wb_b : wb_op == 2'd1 ? wb_a - wb_b : wb_op == 2'd2 ? wb_a & wb_b : wb_a + wb_imm;
always @(posedge clk) if (!rst) begin
assert(f_now == (rvfi_valid && rvfi_rd_addr == f_rd ? rvfi_rd_wdata : f_shadow));
if (wb_v) assert(wb_res == f_wb_alu);
if (wb_v && wb_rs1 == f_rd) assert(wb_a == f_now);
if (wb_v && wb_rs2 == f_rd && wb_op != 2'd3) assert(wb_b == f_now);
if (ex_v && ex_rs1 == f_rd) assert(ex_a == (wb_v && wb_rd == f_rd ? wb_res : f_now));
if (ex_v && ex_rs2 == f_rd && ex_op != 2'd3) assert(ex_b == (wb_v && wb_rd == f_rd ? wb_res : f_now));
end
reg [1:0] f_since = 3; // retirements since the last write to f_rd, saturating
reg [1:0] f_run = 0; // consecutive retirements, saturating
always @(posedge clk) begin
f_since <= rst ? 2'd3 : rvfi_valid && rvfi_rd_addr == f_rd ? 2'd0 : rvfi_valid && f_since != 2'd3 ? f_since + 2'd1 : f_since;
f_run <= rst || !rvfi_valid ? 2'd0 : f_run != 2'd3 ? f_run + 2'd1 : f_run;
end
always @(posedge clk) if (!rst) begin
c_dep: cover(rvfi_valid && rvfi_rs1_addr == f_rd && f_since == 2'd1); // reads f_rd right after the write
c_three: cover(rvfi_valid && f_run == 2'd2); // three retirements in a row
endCovers
A read of f_rd right after a write to it, and three retirements in a row, both at step 7.
The bug
Run as loaded: FAIL at reported step 7. Find the two writes and the read.
Fix and prove
Swap the bypass priority, then prove reg with the helpers at k = 2 and insn at k = 3.
Blind spots
Run insn on BUG_FWD_PRIO and on BUG_ALU. Which one passes, and why?
design.sv
props.sv
Use assert property (@(posedge clk) ...);. Leave blank for immediate assertions in the design.
Inputs are free unless constrained. Run to find a counterexample or check a proof.
Model: one positive-edge clock, binary data. Sampled history requires declaration-initialized variables; primary-input history is unsupported.
SBY summary and log
Check Yourself
Remember
- Write pipeline checks against a retirement port, never against internals.
reg: one free register index and a shadow catch every forwarding bug.insn: the result is the ISA function of the reported operands;regandinsnare blind to each other's bugs.- Helpers that tie the register file and the operands in flight to the shadow make reg inductive at k = 2.