RTL Design Patterns · Chapter 8 · Pipelining
Pipelined Datapath Worked Example
This capstone assembles everything Chapter 8 taught into one working block: a pipelined multiply-accumulate that takes a new triple of operands every cycle and streams a times b plus c results. Done as one long combinational path the multiply-then-add is too slow to close timing, so you split it into two balanced stages, a multiply stage and an add stage. Two cycles of latency buys one result per cycle of throughput. Registering the datapath brings two obligations: a valid bit must march alongside the data, and every operand that meets at a stage must be delayed by the same number of stages, so c is registered one stage to line up with its own product. This lesson builds the block incrementally, adds stall and flush, and proves it against a latency-delayed reference in SystemVerilog, Verilog, and VHDL.
Intermediate16 min readRTL Design PatternsPipeliningMultiply-AccumulateValid BitOperand AlignmentStall and Flush
Chapter 8 · Section 8.6 · Pipelining
1. The Engineering Problem
You are building a small compute engine that lands everywhere in signal processing and datapath accelerators: a multiply-accumulate. Every cycle a producer hands you a fresh triple — a, b, c — and every cycle you must emit a*b + c. A FIR filter tap does this; a dot-product does this; a running weighted sum does this. There is nothing exotic about the arithmetic — one multiply and one add — and the naive way to write it is exactly one line: result = a*b + c, a combinational multiply feeding a combinational add.
That one line is the problem. A multiplier is not a cheap gate; it is a deep tree of partial-product adders whose delay grows with the operand width. Bolt an adder on its output and you have chained the two longest arithmetic operations you own into a single combinational path from the input registers to the output register. At any real clock speed that path does not close timing — the multiply alone eats most of the period, and the trailing add pushes it over. You cannot ship a datapath whose critical path is a*b + c computed in one hop, and slowing the clock until it fits throws away exactly the throughput the rest of the chip was designed around (this is the timing pressure of 8.1).
The escape is the whole of Chapter 8. You pipeline: cut that long path in two with a register in the middle, so the multiply gets its own cycle and the add gets its own cycle. Now each stage's path is roughly half as long and the clock can run fast again. But cutting the path is only the first move — a registered datapath streams many operations at once, and that raises three obligations the naive one-liner never had: the consumer must be told which outputs are real (the pipe starts empty), the operations must be kept from colliding under backpressure (stall and flush), and — the trap this page turns on — the c you add must belong to the same operation as the product it is added to. This page builds that block, end to end, and the interface it presents is small.
// INPUTS (a fresh operation offered every cycle)
input wire clk;
input wire rst; // synchronous, active-high -> pipe empty, valids clear
input wire in_valid; // this cycle's a,b,c are a REAL operation (else a bubble)
input wire [WIDTH-1:0] a, b, c; // operands: compute a*b + c for this triple
input wire stall; // backpressure: FREEZE the whole pipe this cycle
input wire flush; // drop in-flight work: clear the valid bits
// OUTPUTS (the result of the operation offered LATENCY cycles ago)
output wire [2*WIDTH-1:0] result; // a*b + c for the matching operation, LSBs aligned
output wire out_valid; // this result is REAL (not a start-up / flushed bubble)
// Timing: multiply in stage 1, add in stage 2 -> LATENCY = 2 cycles.
// result[t] corresponds to the triple offered at t-2 (when in_valid was high).
// Throughput = 1 result/cycle once the pipe is full (8.2).2. Mental Model
3. Pattern Anatomy
The block is two compute stages, a valid chain that shadows them, one operand that must be delayed to stay aligned, and two controls that act on the whole region. Take them in turn, then see the two views that matter: the compute-stage datapath (who feeds whom) and the control overlay (how valid marches and a stall freezes).
The two compute stages — the split path. Stage 1 is the multiplier from 5.x: prod_q <= a * b, a 2*WIDTH-bit product captured in a pipeline register. Stage 2 is the adder from 5.5 / adders-subtractors: result <= prod_q + c_q, the product plus the (aligned) addend, captured in the output register. The register between them — prod_q — is the cut that breaks the long a*b + c path into two shorter ones. Before the cut, the critical path was multiply-then-add; after it, stage 1 is just the multiply and stage 2 is just the add, each about half the delay, which is why the clock can run fast (8.1).
The valid chain — the work order that shadows the data. A one-bit register per stage — v1_q, v2_q — carries in_valid down the pipe in lockstep with the data: v1_q <= in_valid, then v2_q <= v1_q, and out_valid = v2_q. Because the valid bit lives in the same number of registers as the data it labels, it arrives at the output on the exact cycle the matching result does. This is the valid-bit discipline of 8.3: the data path carries the value, the valid path carries the fact that the value is real, and they must move together.
The aligned operand — c delayed one stage. Here is the crux. The product prod_q is a*b from one cycle ago (it spent a cycle in stage 1's register). The adder in stage 2 must add that operation's c — which was presented one cycle ago, together with its a and b. So c cannot go straight to the adder; it must be registered one stage (c_q <= c) so it arrives at the adder on the same cycle its product does. Every operand that converges at a pipeline stage has to be delayed by the same number of stages as everything else it meets there — c meets a one-stage-old product, so c must be one stage old too.
Stall — freeze the whole region. Backpressure (a downstream consumer that cannot take a result this cycle) must not drop or duplicate in-flight work. The clean answer is a clock-enable: when stall is high, none of the pipeline registers update — prod_q, c_q, v1_q, v2_q, and result all hold. Freeze the whole stage region as a unit, and the operations already in flight simply pause where they are and resume intact when the stall lifts. Freezing only part of the region is a bug (§6).
Flush — clear the valids. When in-flight work must be abandoned (a mispredict, a mode change, a reset of the stream), you do not need to clear the data registers — garbage data that is marked not valid is harmless. You only clear the valid chain: flush forces v1_q and v2_q to 0, so whatever is in the data registers stops being reported as real. Flush is cheap precisely because valid, not data, is what makes an output count.
Compute-stage datapath — two balanced stages, and c delayed one stage to meet its product
data flowThe second view is the control overlay — the valid bit marching alongside the data, and a stall freezing the whole region.
Control overlay — valid marches with the data, stall freezes the region, flush clears the valids
data flowOne reasoning thread runs under both views: the data path, the valid path, and every meeting operand advance together, one register per stage, and stall/flush act on all of them at once. On a normal cycle each stage's register captures the previous stage's output, the valid bit steps forward one register, and c_q steps forward the one stage it needs to meet its product. On a stall the whole region holds — data and valid alike — so nothing is lost or duplicated. On a flush the valid chain zeroes so the in-flight garbage stops counting. The only subtlety worth losing sleep over is alignment: every value that arrives at the stage-2 adder must have travelled the same number of registers to get there, and the one that is easy to forget is c (§7).
4. Real RTL Implementation
This is the core of the page. We build the pipelined MAC incrementally — (i) the plain 2-stage compute pipeline, (ii) add the per-stage valid bit, (iii) add stall and flush — then present the integrated module in SystemVerilog, Verilog, and VHDL, each with a self-checking clocked testbench that streams random a, b, c (with bubbles, a stall, and a flush) and checks result against a latency-delayed a*b + c reference and out_valid alignment. Then §4d isolates the signature bug — the operand-misalignment of an un-delayed c — as buggy-vs-fixed RTL in all three languages.
Three conventions run through every block, and each is a Chapter-8 lesson made concrete:
- Reset is synchronous, active-high (
rst), emptying the pipe by clearing the valid chain (the data registers may hold anything — cleared valids make them harmless). After resetout_validis low until real operations have propagated through. - A pipeline register sits between the stages, and every operand that meets at a stage is delayed the same number of stages.
prod_qcuts the path;c_qdelayscone stage so it reaches the adder aligned with its product (the alignment discipline). - The valid bit shadows the data one register per stage, and stall is a clock-enable on the whole region while flush clears only the valid chain — the 8.3 valid-bit discipline plus backpressure handling.
4a. Incremental build — the plain 2-stage compute pipeline
Start with just the arithmetic, cut in two. Stage 1 registers the product; stage 2 registers the sum. Even here the alignment rule already applies: c is registered once (c_q) so stage 2 adds the c that belongs to the product it has.
module mac_pipe_v1 #(
parameter int WIDTH = 8 // operand width; product is 2*WIDTH
)(
input logic clk,
input logic rst, // synchronous, active-high
input logic [WIDTH-1:0] a, b, c, // a fresh triple each cycle
output logic [2*WIDTH-1:0] result // a*b + c, LATENCY = 2 cycles
);
logic [2*WIDTH-1:0] prod_q; // STAGE-1 register: the product a*b
logic [WIDTH-1:0] c_q; // c delayed ONE stage to align with prod_q
always_ff @(posedge clk) begin
if (rst) begin
prod_q <= '0;
c_q <= '0;
result <= '0;
end else begin
// STAGE 1: multiply, and delay c by the SAME one stage so they meet aligned.
prod_q <= a * b; // 5.x multiplier, registered
c_q <= c; // c_q holds THIS operation's addend
// STAGE 2: add the product to its OWN (one-stage-old) addend.
result <= prod_q + c_q; // 5.5 adder, registered
end
end
endmoduleThe two right-hand sides read the current register values and the new inputs, and all assignments are non-blocking, so on each edge stage 2 consumes the prod_q/c_q that stage 1 produced last cycle while stage 1 loads the new product and addend — exactly the assembly-line hand-off. result is a*b + c for the triple offered two cycles earlier. Nothing here yet tells the consumer which outputs are real; that is step (ii).
4b. Incremental build — add the per-stage valid bit
Now attach the work order. A one-bit register per stage carries in_valid down the pipe in lockstep with the data, and out_valid reports the stage-2 valid. This is the whole of 8.3, and it is one register wide.
module mac_pipe_v2 #(
parameter int WIDTH = 8
)(
input logic clk,
input logic rst,
input logic in_valid, // this triple is a real operation
input logic [WIDTH-1:0] a, b, c,
output logic [2*WIDTH-1:0] result,
output logic out_valid // result is a real a*b + c this cycle
);
logic [2*WIDTH-1:0] prod_q;
logic [WIDTH-1:0] c_q;
logic v1_q, v2_q; // valid, one bit per stage
always_ff @(posedge clk) begin
if (rst) begin
v1_q <= 1'b0; v2_q <= 1'b0; // empty the pipe: clear the valid chain
prod_q <= '0; c_q <= '0; result <= '0;
end else begin
// DATA path (as 4a) AND valid path advance TOGETHER, register for register.
prod_q <= a * b; v1_q <= in_valid; // stage 1: product + its valid
c_q <= c; // c aligned one stage
result <= prod_q + c_q; v2_q <= v1_q; // stage 2: sum + its valid
end
end
assign out_valid = v2_q; // high exactly on real results
endmodulev1_q rides with prod_q and c_q; v2_q rides with result. Because valid lives in the same number of registers as the data it labels, out_valid is high precisely on the cycles result is a genuine a*b + c — and low for the two-cycle fill after reset. If you advanced v2_q <= in_valid (skipping a stage) the label would arrive a cycle early and mark a fill bubble as real; the valid chain must be as deep as the data (§6).
4c. Incremental build — add stall (clock-enable freeze) and flush
Finally, backpressure. stall is a clock-enable that freezes every register in the region; flush clears the valid chain so in-flight work stops counting. The freeze covers data and valid alike, so a paused operation resumes intact.
module mac_pipe_v3 #(
parameter int WIDTH = 8
)(
input logic clk, rst,
input logic in_valid,
input logic [WIDTH-1:0] a, b, c,
input logic stall, // freeze the whole pipe this cycle
input logic flush, // drop in-flight work (clear valids)
output logic [2*WIDTH-1:0] result,
output logic out_valid
);
logic [2*WIDTH-1:0] prod_q;
logic [WIDTH-1:0] c_q;
logic v1_q, v2_q;
always_ff @(posedge clk) begin
if (rst) begin
v1_q <= 1'b0; v2_q <= 1'b0;
prod_q <= '0; c_q <= '0; result <= '0;
end else if (!stall) begin // CLOCK-ENABLE: update only when NOT stalled
prod_q <= a * b;
c_q <= c;
result <= prod_q + c_q;
// flush clears the valid chain; otherwise valid advances with the data.
v1_q <= flush ? 1'b0 : in_valid;
v2_q <= flush ? 1'b0 : v1_q;
end
// when stall is high: NO register updates -> the whole region holds intact.
end
assign out_valid = v2_q;
endmoduleTwo decisions carry the semantics. The if (!stall) gates all the register updates as one unit, so a stalled cycle freezes prod_q, c_q, v1_q, v2_q, and result together — no operation is dropped or duplicated. The flush ? 1'b0 : ... clears only the valid chain, leaving the data registers alone: garbage marked not-valid is harmless, so flush is cheap. This mac_pipe_v3 is the integrated block; the testbench below drives it with bubbles, a stall, and a flush.
module mac_pipe_tb;
localparam int WIDTH = 8;
localparam int LAT = 2; // pipeline latency (stages)
logic clk = 1'b0, rst, in_valid, stall, flush, out_valid;
logic [WIDTH-1:0] a, b, c;
logic [2*WIDTH-1:0] result;
int errors = 0;
mac_pipe_v3 #(.WIDTH(WIDTH)) dut (.clk(clk), .rst(rst), .in_valid(in_valid),
.a(a), .b(b), .c(c), .stall(stall), .flush(flush),
.result(result), .out_valid(out_valid));
always #5 clk = ~clk;
// Reference model: a shift-register of expected {value, valid} delayed by LAT stages,
// advanced ONLY on non-stall cycles so it tracks the DUT's clock-enable exactly.
logic [2*WIDTH-1:0] ref_val [LAT+1];
logic ref_vld [LAT+1];
task automatic step(input logic iv, st, fl, input [WIDTH-1:0] xa, xb, xc);
in_valid = iv; stall = st; flush = fl; a = xa; b = xb; c = xc;
@(posedge clk); #1;
if (!st) begin // model advances only when not stalled
for (int i = LAT; i > 0; i--) begin
ref_val[i] = ref_val[i-1];
ref_vld[i] = fl ? 1'b0 : ref_vld[i-1]; // flush clears in-flight valids
end
ref_val[0] = xa * xb + xc; // the operation entering this cycle
ref_vld[0] = fl ? 1'b0 : iv;
end
// Check the DUT output against the model at stage LAT.
assert (out_valid === ref_vld[LAT])
else begin $error("valid mismatch: out_valid=%b exp=%b", out_valid, ref_vld[LAT]); errors++; end
if (ref_vld[LAT]) // only REAL results must match
assert (result === ref_val[LAT])
else begin $error("result mismatch: got=%h exp=%h", result, ref_val[LAT]); errors++; end
endtask
initial begin
rst = 1'b1; in_valid = 0; stall = 0; flush = 0; a = 0; b = 0; c = 0;
foreach (ref_vld[i]) ref_vld[i] = 1'b0;
@(posedge clk); #1; rst = 1'b0;
// Stream real ops with a bubble, a stall, and a flush interleaved. A VARYING c
// stream is what would expose the misalignment bug of 4d.
step(1,0,0, 8'd2, 8'd4, 8'd1 ); // a*b+c = 9
step(1,0,0, 8'd3, 8'd6, 8'd10 ); // 28
step(0,0,0, 8'd0, 8'd0, 8'd0 ); // bubble (in_valid = 0)
step(1,1,0, 8'd5, 8'd7, 8'd100); // STALL: pipe freezes, offer held
step(1,0,0, 8'd5, 8'd7, 8'd100); // 135 (accepted after stall lifts)
step(1,0,1, 8'd9, 8'd9, 8'd9 ); // FLUSH: in-flight valids cleared
step(0,0,0, 8'd0, 8'd0, 8'd0 ); // drain
step(0,0,0, 8'd0, 8'd0, 8'd0 );
step(0,0,0, 8'd0, 8'd0, 8'd0 );
if (errors == 0) $display("PASS: stream matches latency-delayed a*b+c; valid aligned through bubble/stall/flush");
else $display("FAIL: %0d mismatches", errors);
$finish;
end
endmodule4b (Verilog). The integrated block in Verilog
The Verilog form is the same three-stage-register machine with reg/wire typing; the incremental steps collapse into the one integrated module since the reasoning is identical. prod_q, c_q, and the valid chain advance together, gated by !stall, cleared by flush.
module mac_pipe #(
parameter WIDTH = 8
)(
input wire clk, rst,
input wire in_valid,
input wire [WIDTH-1:0] a, b, c,
input wire stall, flush,
output reg [2*WIDTH-1:0] result,
output wire out_valid
);
reg [2*WIDTH-1:0] prod_q; // stage-1 product register
reg [WIDTH-1:0] c_q; // c delayed one stage to align with prod_q
reg v1_q, v2_q; // valid, one bit per stage
always @(posedge clk) begin
if (rst) begin
prod_q <= {(2*WIDTH){1'b0}};
c_q <= {WIDTH{1'b0}};
result <= {(2*WIDTH){1'b0}};
v1_q <= 1'b0;
v2_q <= 1'b0;
end else if (!stall) begin // clock-enable: freeze whole region when stalled
prod_q <= a * b; // STAGE 1
c_q <= c; // c aligned
result <= prod_q + c_q; // STAGE 2: product + its OWN one-stage-old addend
v1_q <= flush ? 1'b0 : in_valid;
v2_q <= flush ? 1'b0 : v1_q;
end
end
assign out_valid = v2_q;
endmodule module mac_pipe_tb;
parameter WIDTH = 8;
parameter LAT = 2;
reg clk, rst, in_valid, stall, flush;
reg [WIDTH-1:0] a, b, c;
wire [2*WIDTH-1:0] result;
wire out_valid;
integer i, errors;
// Reference model: LAT-deep shift registers of expected value and valid.
reg [2*WIDTH-1:0] ref_val [0:LAT];
reg ref_vld [0:LAT];
mac_pipe #(.WIDTH(WIDTH)) dut (.clk(clk), .rst(rst), .in_valid(in_valid),
.a(a), .b(b), .c(c), .stall(stall), .flush(flush),
.result(result), .out_valid(out_valid));
initial clk = 1'b0;
always #5 clk = ~clk;
task step;
input iv, st, fl;
input [WIDTH-1:0] xa, xb, xc;
begin
in_valid = iv; stall = st; flush = fl; a = xa; b = xb; c = xc;
@(posedge clk); #1;
if (!st) begin // model advances only when not stalled
for (i = LAT; i > 0; i = i - 1) begin
ref_val[i] = ref_val[i-1];
ref_vld[i] = fl ? 1'b0 : ref_vld[i-1];
end
ref_val[0] = xa * xb + xc;
ref_vld[0] = fl ? 1'b0 : iv;
end
if (out_valid !== ref_vld[LAT]) begin
$display("FAIL: valid out=%b exp=%b", out_valid, ref_vld[LAT]);
errors = errors + 1;
end
if (ref_vld[LAT] && result !== ref_val[LAT]) begin
$display("FAIL: result got=%h exp=%h", result, ref_val[LAT]);
errors = errors + 1;
end
end
endtask
initial begin
errors = 0;
rst = 1'b1; in_valid = 0; stall = 0; flush = 0; a = 0; b = 0; c = 0;
for (i = 0; i <= LAT; i = i + 1) ref_vld[i] = 1'b0;
@(posedge clk); #1; rst = 1'b0;
step(1,0,0, 8'd2, 8'd4, 8'd1 ); // 9
step(1,0,0, 8'd3, 8'd6, 8'd10 ); // 28 (varying c exposes misalignment)
step(0,0,0, 8'd0, 8'd0, 8'd0 ); // bubble
step(1,1,0, 8'd5, 8'd7, 8'd100); // stall
step(1,0,0, 8'd5, 8'd7, 8'd100); // 135
step(1,0,1, 8'd9, 8'd9, 8'd9 ); // flush
step(0,0,0, 8'd0, 8'd0, 8'd0 );
step(0,0,0, 8'd0, 8'd0, 8'd0 );
step(0,0,0, 8'd0, 8'd0, 8'd0 );
if (errors == 0) $display("PASS: stream matches a*b+c reference; valid aligned through bubble/stall/flush");
else $display("FAIL: %0d mismatches", errors);
$finish;
end
endmodule4c (VHDL). The integrated block in VHDL
VHDL states the same pipeline with numeric_std for the arithmetic and rising_edge(clk) for the clocked process. prod_q and c_q are unsigned; the valid chain is two std_logic registers. The clock-enable is elsif stall = '0'; flush clears the valids.
library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;
entity mac_pipe is
generic ( WIDTH : positive := 8 ); -- product is 2*WIDTH
port (
clk : in std_logic;
rst : in std_logic; -- synchronous, active-high
in_valid : in std_logic;
a, b, c : in std_logic_vector(WIDTH-1 downto 0);
stall : in std_logic; -- freeze whole pipe
flush : in std_logic; -- clear in-flight valids
result : out std_logic_vector(2*WIDTH-1 downto 0);
out_valid : out std_logic
);
end entity;
architecture rtl of mac_pipe is
signal prod_q : unsigned(2*WIDTH-1 downto 0); -- stage-1 product register
signal c_q : unsigned(WIDTH-1 downto 0); -- c delayed one stage to align
signal res_q : unsigned(2*WIDTH-1 downto 0); -- stage-2 sum register
signal v1_q, v2_q : std_logic; -- valid, one bit per stage
begin
pipe : process (clk)
begin
if rising_edge(clk) then
if rst = '1' then
prod_q <= (others => '0');
c_q <= (others => '0');
res_q <= (others => '0');
v1_q <= '0';
v2_q <= '0';
elsif stall = '0' then -- clock-enable: hold when stalled
-- STAGE 1: product, and c aligned by the SAME one stage.
prod_q <= unsigned(a) * unsigned(b);
c_q <= unsigned(c);
-- STAGE 2: product + its OWN one-stage-old addend (zero-extend c_q).
res_q <= prod_q + resize(c_q, 2*WIDTH);
-- valid advances with the data; flush clears the chain.
if flush = '1' then
v1_q <= '0'; v2_q <= '0';
else
v1_q <= in_valid; v2_q <= v1_q;
end if;
end if;
end if;
end process;
result <= std_logic_vector(res_q);
out_valid <= v2_q;
end architecture; library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;
entity mac_pipe_tb is
end entity;
architecture sim of mac_pipe_tb is
constant WIDTH : positive := 8;
constant LAT : positive := 2;
signal clk : std_logic := '0';
signal rst : std_logic;
signal in_valid : std_logic := '0';
signal a, b, c : std_logic_vector(WIDTH-1 downto 0) := (others => '0');
signal stall : std_logic := '0';
signal flush : std_logic := '0';
signal result : std_logic_vector(2*WIDTH-1 downto 0);
signal out_valid : std_logic;
type val_arr is array (0 to LAT) of unsigned(2*WIDTH-1 downto 0);
type vld_arr is array (0 to LAT) of std_logic;
begin
dut : entity work.mac_pipe
generic map (WIDTH => WIDTH)
port map (clk => clk, rst => rst, in_valid => in_valid, a => a, b => b, c => c,
stall => stall, flush => flush, result => result, out_valid => out_valid);
clk <= not clk after 5 ns;
stim : process
variable ref_val : val_arr := (others => (others => '0'));
variable ref_vld : vld_arr := (others => '0');
variable errors : integer := 0;
-- One driven cycle: apply stimulus, advance the reference model on non-stall, check.
procedure step (iv, st, fl : std_logic; xa, xb, xc : integer) is
variable prod : unsigned(2*WIDTH-1 downto 0);
begin
in_valid <= iv; stall <= st; flush <= fl;
a <= std_logic_vector(to_unsigned(xa, WIDTH));
b <= std_logic_vector(to_unsigned(xb, WIDTH));
c <= std_logic_vector(to_unsigned(xc, WIDTH));
wait until rising_edge(clk); wait for 1 ns;
if st = '0' then -- model advances only when not stalled
for i in LAT downto 1 loop
ref_val(i) := ref_val(i-1);
if fl = '1' then ref_vld(i) := '0';
else ref_vld(i) := ref_vld(i-1); end if;
end loop;
prod := to_unsigned(xa * xb + xc, 2*WIDTH);
ref_val(0) := prod;
if fl = '1' then ref_vld(0) := '0'; else ref_vld(0) := iv; end if;
end if;
assert out_valid = ref_vld(LAT)
report "valid mismatch" severity error;
if ref_vld(LAT) = '1' then
assert unsigned(result) = ref_val(LAT)
report "result mismatch vs a*b+c reference" severity error;
end if;
end procedure;
begin
rst <= '1'; wait until rising_edge(clk); wait for 1 ns; rst <= '0';
step('1','0','0', 2, 4, 1); -- 9
step('1','0','0', 3, 6, 10); -- 28 (varying c exposes misalignment)
step('0','0','0', 0, 0, 0); -- bubble
step('1','1','0', 5, 7, 100); -- stall
step('1','0','0', 5, 7, 100); -- 135
step('1','0','1', 9, 9, 9); -- flush
step('0','0','0', 0, 0, 0);
step('0','0','0', 0, 0, 0);
step('0','0','0', 0, 0, 0);
report "mac_pipe self-check complete (stream matched a*b+c; valid aligned)" severity note;
wait;
end process;
end architecture;4d. The signature bug — operand misalignment (c un-delayed vs registered one stage)
Every failure of a pipelined datapath lives in the alignment of operands that meet at a stage, and the sharpest example is c. The buggy version feeds c straight to the adder instead of registering it one stage; the fix registers c (c_q) so it reaches the adder aligned with its own product. The bug is dangerous because it looks right: the pipeline still streams, out_valid is still perfect, and a steady c hides it completely — only a varying c stream exposes that each result mixes a product from one operation with the addend from the next.
// BUGGY: c goes STRAIGHT to the stage-2 adder, un-delayed. prod_q is a*b from the
// PREVIOUS cycle, but c is THIS cycle's c -> result = a[t-1]*b[t-1] + c[t]:
// the product of one operation added to the addend of the NEXT. A steady c hides
// it; a varying c stream corrupts every result by one operation of skew.
module mac_align_bad #(parameter int WIDTH = 8)(
input logic clk, rst,
input logic [WIDTH-1:0] a, b, c,
output logic [2*WIDTH-1:0] result
);
logic [2*WIDTH-1:0] prod_q;
always_ff @(posedge clk) begin
if (rst) begin prod_q <= '0; result <= '0; end
else begin
prod_q <= a * b; // stage-1 product (one cycle old at use)
result <= prod_q + c; // BUG: c is NOT delayed -> misaligned
end
end
endmodule
// FIXED: register c one stage (c_q) so it reaches the adder aligned with its product.
module mac_align_good #(parameter int WIDTH = 8)(
input logic clk, rst,
input logic [WIDTH-1:0] a, b, c,
output logic [2*WIDTH-1:0] result
);
logic [2*WIDTH-1:0] prod_q;
logic [WIDTH-1:0] c_q; // the missing one-stage delay
always_ff @(posedge clk) begin
if (rst) begin prod_q <= '0; c_q <= '0; result <= '0; end
else begin
prod_q <= a * b;
c_q <= c; // FIX: c travels the SAME one stage as prod
result <= prod_q + c_q; // now product and addend are the same op
end
end
endmodule module mac_align_bug_tb;
localparam int WIDTH = 8, LAT = 2;
logic clk = 1'b0, rst;
logic [WIDTH-1:0] a, b, c;
logic [2*WIDTH-1:0] result;
logic [2*WIDTH-1:0] ref_val [LAT+1];
int errors = 0;
// Bind to mac_align_good to PASS; to mac_align_bad to see the varying-c skew FAIL.
mac_align_good #(.WIDTH(WIDTH)) dut (.clk(clk), .rst(rst), .a(a), .b(b), .c(c), .result(result));
always #5 clk = ~clk;
task automatic step(input [WIDTH-1:0] xa, xb, xc);
a = xa; b = xb; c = xc;
@(posedge clk); #1;
for (int i = LAT; i > 0; i--) ref_val[i] = ref_val[i-1];
ref_val[0] = xa * xb + xc; // each result is THIS op's a*b + THIS op's c
if (result !== ref_val[LAT])
begin $error("skew: got=%h exp=%h (a*b from one op + c from another?)", result, ref_val[LAT]); errors++; end
endtask
initial begin
rst = 1'b1; a = 0; b = 0; c = 0; @(posedge clk); #1; rst = 1'b0;
@(posedge clk); #1; // let the pipe fill before checking
step(8'd2, 8'd4, 8'd1 ); // VARYING c: 1, 10, 100, 3 ...
step(8'd3, 8'd6, 8'd10 ); // the bug adds the WRONG cycle's c here
step(8'd5, 8'd7, 8'd100);
step(8'd9, 8'd2, 8'd3 );
step(8'd1, 8'd1, 8'd1 );
if (errors == 0) $display("PASS: c aligned with its product on every op");
else $display("FAIL: %0d mismatches (c misaligned by one operation)", errors);
$finish;
end
endmoduleThe Verilog and VHDL buggy/fixed pairs tell the identical story — the only difference between broken and correct is whether c passes through a one-stage register (c_q) before the adder.
// BUGGY: result = prod_q + c, with c un-delayed -> a[t-1]*b[t-1] + c[t] (misaligned).
module mac_align_bad #(parameter WIDTH = 8)(
input wire clk, rst,
input wire [WIDTH-1:0] a, b, c,
output reg [2*WIDTH-1:0] result
);
reg [2*WIDTH-1:0] prod_q;
always @(posedge clk) begin
if (rst) begin prod_q <= 0; result <= 0; end
else begin
prod_q <= a * b;
result <= prod_q + c; // BUG: c not delayed
end
end
endmodule
// FIXED: register c one stage so it aligns with its product.
module mac_align_good #(parameter WIDTH = 8)(
input wire clk, rst,
input wire [WIDTH-1:0] a, b, c,
output reg [2*WIDTH-1:0] result
);
reg [2*WIDTH-1:0] prod_q;
reg [WIDTH-1:0] c_q;
always @(posedge clk) begin
if (rst) begin prod_q <= 0; c_q <= 0; result <= 0; end
else begin
prod_q <= a * b;
c_q <= c; // FIX: the one-stage delay
result <= prod_q + c_q; // product and addend now same operation
end
end
endmodule module mac_align_bug_tb;
parameter WIDTH = 8, LAT = 2;
reg clk, rst;
reg [WIDTH-1:0] a, b, c;
wire [2*WIDTH-1:0] result;
reg [2*WIDTH-1:0] ref_val [0:LAT];
integer i, errors;
// Bind to mac_align_good to PASS; to mac_align_bad to observe the skew.
mac_align_good #(.WIDTH(WIDTH)) dut (.clk(clk), .rst(rst), .a(a), .b(b), .c(c), .result(result));
initial clk = 1'b0;
always #5 clk = ~clk;
task step;
input [WIDTH-1:0] xa, xb, xc;
begin
a = xa; b = xb; c = xc;
@(posedge clk); #1;
for (i = LAT; i > 0; i = i - 1) ref_val[i] = ref_val[i-1];
ref_val[0] = xa * xb + xc;
if (result !== ref_val[LAT]) begin
$display("FAIL: skew got=%h exp=%h", result, ref_val[LAT]);
errors = errors + 1;
end
end
endtask
initial begin
errors = 0;
rst = 1'b1; a = 0; b = 0; c = 0; @(posedge clk); #1; rst = 1'b0;
@(posedge clk); #1; // fill the pipe
step(8'd2, 8'd4, 8'd1 ); // varying c: exposes the wrong-cycle add
step(8'd3, 8'd6, 8'd10 );
step(8'd5, 8'd7, 8'd100);
step(8'd9, 8'd2, 8'd3 );
step(8'd1, 8'd1, 8'd1 );
if (errors == 0) $display("PASS: c aligned with its product on every op");
else $display("FAIL: %0d mismatches (c misaligned)", errors);
$finish;
end
endmodule library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;
-- BUGGY: c added un-delayed -> product from one op + addend from the next.
entity mac_align_bad is
generic ( WIDTH : positive := 8 );
port ( clk, rst : in std_logic;
a, b, c : in std_logic_vector(WIDTH-1 downto 0);
result : out std_logic_vector(2*WIDTH-1 downto 0) );
end entity;
architecture rtl of mac_align_bad is
signal prod_q : unsigned(2*WIDTH-1 downto 0);
signal res_q : unsigned(2*WIDTH-1 downto 0);
begin
process (clk) begin
if rising_edge(clk) then
if rst = '1' then prod_q <= (others => '0'); res_q <= (others => '0');
else
prod_q <= unsigned(a) * unsigned(b);
res_q <= prod_q + resize(unsigned(c), 2*WIDTH); -- BUG: c not delayed
end if;
end if;
end process;
result <= std_logic_vector(res_q);
end architecture;
-- FIXED: register c one stage (c_q) so it aligns with its product.
entity mac_align_good is
generic ( WIDTH : positive := 8 );
port ( clk, rst : in std_logic;
a, b, c : in std_logic_vector(WIDTH-1 downto 0);
result : out std_logic_vector(2*WIDTH-1 downto 0) );
end entity;
architecture rtl of mac_align_good is
signal prod_q : unsigned(2*WIDTH-1 downto 0);
signal c_q : unsigned(WIDTH-1 downto 0);
signal res_q : unsigned(2*WIDTH-1 downto 0);
begin
process (clk) begin
if rising_edge(clk) then
if rst = '1' then
prod_q <= (others => '0'); c_q <= (others => '0'); res_q <= (others => '0');
else
prod_q <= unsigned(a) * unsigned(b);
c_q <= unsigned(c); -- FIX: one-stage delay
res_q <= prod_q + resize(c_q, 2*WIDTH); -- aligned add
end if;
end if;
end process;
result <= std_logic_vector(res_q);
end architecture;Across all three languages the lesson is one sentence: every operand that meets at a pipeline stage must be delayed by the same number of stages as everything else it meets there — alignment is a property of the whole datapath, and a steady operand will hide a misalignment that a varying stream exposes.
5. Verification Strategy
A pipelined datapath's correctness is not "did one output look right" — it is "does the whole stream match a reference a*b + c delayed by the pipeline latency, with out_valid marking exactly the real cycles, through bubbles, a stall, and a flush." That is the one invariant, and it is what the §4 testbenches enforce.
Drive a stream of triples with
in_valid, bubbles, a stall, and a flush; maintain a reference shift-register that delays each expecteda*b + c(and its valid) by the pipeline latency and advances only on non-stall cycles; and assert that on every cycleout_validequals the reference valid and, when valid,resultequals the reference value.
Everything below is that invariant made checkable, and it maps directly onto the clocked testbenches in §4.
- Latency-delayed reference self-check (the core test). Model the pipe as a
LAT-deep shift register of{expected_value, expected_valid}. Each non-stall cycle, shift it and insert the new operation'sa*b + candin_validat the head; on a stall cycle, do not advance the model (mirroring the DUT's clock-enable); on a flush cycle, clear the in-flight valids in the model. Then assertout_valid === ref_vld[LAT]every cycle andresult === ref_val[LAT]whenever the reference valid is set. Because the whole stream must match, a one-operation operand skew (the §7 bug) or a mis-advanced valid fails the check. The three testbenches do exactly this (SVassert (result === ref_val[LAT]), Verilogif (result !== ref_val[LAT]) $display("FAIL…"), VHDLassert unsigned(result) = ref_val(LAT) report … severity error). - The result==reference invariant. Stated as a property that holds for every operation: the result emitted
LATvalid-cycles after an operation entered equals that operation'sa*b + c— its owna, its ownb, and its ownc. This is the alignment property the whole page is about; if it ever fails, an operand is misaligned. Drive a varyingcstream (not a constant), because a steadycmakes a misalignedcindistinguishable from an aligned one — the §7 bug is invisible under constantcand obvious under a changing one. out_validalignment invariant. Assertout_validis low for the firstLATcycles after reset (the pipe is filling — no real results yet), high exactly on the cycles a real operation reaches the output, and low again during bubbles and after a flush. Avalidthat leads or lags the data it labels is the valid-bit-misalignment failure of 8.3; checkingout_validagainst the reference valid every cycle catches it.- Stall correctness. Assert a stall freezes the pipe: during a stalled cycle no result advances, and after the stall lifts the exact same stream resumes with nothing dropped or duplicated. In the testbench, hold
stallhigh for one or more cycles mid-stream and confirm the post-stall results are precisely the pre-stall stream shifted in time. A partial freeze (some registers advance, others hold) shows up as a result that skipped or repeated an operation. - Flush correctness. Assert a flush drops in-flight work: after
flush, the operations that were mid-pipe must not emerge as valid results (their valids were cleared), while operations entered after the flush proceed normally. Check thatout_validgoes low for the flushed operations and that the data registers, though possibly still holding stale products, never surface because their valid is 0. - Corner cases and expected waveform. Exercise a full pipe (back-to-back valids), a fully bubbled pipe (all
in_validlow), a stall coincident with a flush, and reset mid-stream (the pipe must empty — valids clear — and refill cleanly). On a correct run the waveform showsresulttracking the input stream delayed by two cycles,out_validhigh two cycles after eachin_valid(minus flushed ones, minus stall-frozen time), and both frozen flat across any stalled cycle. Aresultthat changes during a stall, or anout_validthat fires during the fill, is the visual signature of a stall or valid bug.
6. Common Mistakes
A stage's valid not advancing with its data. The signature valid bug. If the valid chain is shallower than the data path (for example driving out_valid from v1_q instead of v2_q, or from in_valid directly), the valid label arrives a cycle early or late and marks a fill bubble as a real result — or discards a real one. The valid chain must be exactly as deep as the data it labels: one valid register per data register, advancing on the same enable. (This is the 8.3 discipline; the DebugLab's cousin.)
An operand not registered to the same stage — misalignment. The §7 bug, and the deepest one. The adder in stage 2 sees a product that is one cycle old, so the c it adds must be one cycle old too. Feed c straight to the adder and you compute a[t-1]*b[t-1] + c[t] — a product from one operation plus an addend from the next. It passes every test that uses a constant c and fails the moment c varies. Every operand that converges at a stage must be delayed by the same number of stages as everything else arriving there (the 8.1 lesson: register operands so they meet aligned).
Partial stall — freezing some registers but not others. Stall must freeze the whole region as one unit. If the clock-enable gates the data registers but not the valid chain (or vice versa), a stalled cycle lets one part advance while the other holds, so an operation is dropped or duplicated and the stream desynchronizes. Gate every pipeline register — data and valid — with the same !stall (or clock-enable) condition, so the region pauses and resumes atomically.
Flushing the data instead of the valids (or forgetting to flush the deeper stages). Flush should clear the valid chain, not the data — clearing data is wasted logic, and garbage marked not-valid is already harmless. The real trap is flushing only the first valid register: an operation already in the second stage still emerges as valid. Flush must clear every valid register in the pipe on the flush cycle, or in-flight work at deeper stages leaks through.
Unbalanced stages — the add still on the long path. Pipelining only helps if the stages are balanced. If stage 1 is the full multiply and stage 2 is a trivial add, the multiply stage is still the critical path and you have paid a cycle of latency for little timing gain — while if a stage does too much (multiply and part of the add), the cut did not actually shorten the worst path. Place the register where it splits the delay most evenly; an unbalanced pipeline closes timing no better than the longest stage.
Under-sizing the product width. The product of two WIDTH-bit operands is 2*WIDTH bits, and a*b + c needs at least 2*WIDTH bits (with c zero- or sign-extended) to avoid overflow. Declaring result as WIDTH bits silently truncates the high half of every product — a width bug that looks like corruption. State the product width deliberately (2*WIDTH) and extend c to match before the add.
7. DebugLab
The MAC whose single-value tests all pass and whose real stream is all wrong — an un-delayed c operand
The engineering lesson: when you pipeline a datapath, correctness is not just cutting the long path — it is keeping every operand that meets at a stage aligned in time, and the operand that is easy to forget is the one that does not go through the pipelined block. The tell here is a design whose constant-operand tests all pass and whose varying-operand stream is wrong: that split is the fingerprint of an alignment error, not a broken adder or multiplier. Two habits make pipelined datapaths trustworthy, and they are the same across SystemVerilog, Verilog, and VHDL: delay every operand by the same number of stages as everything it meets (register c alongside the product it is added to), and verify with a varying-operand stream against a latency-delayed reference — never a constant-operand spot check that a misalignment slips right past.
8. Interview Q&A
9. Exercises
Design and reasoning exercises — no syntax drills. Each rehearses building and aligning a pipelined datapath.
Exercise 1 — Add a third stage
Split stage 2 into two: an add of the low half and a carry-propagate into the high half, making the pipeline three stages (latency 3). State (i) how many valid registers you now need and why, (ii) how many stages c must now be delayed to reach the adder aligned, and (iii) what stays the same about throughput. Name which Chapter-8 idea each change draws on.
Exercise 2 — Predict the misalignment
An engineer registers a and b for one stage before the multiplier (to meet input timing) but leaves c going straight into the stage-2 adder, and the multiply itself is registered. Trace, cycle by cycle, which operation's a, b, and c meet at the adder. State whether the result is correct, and if not, by how many operations c is skewed and in which direction — then give the one-line fix and the varying-operand test that distinguishes this from the §7 bug.
Exercise 3 — Make the stall correct
Given the integrated mac_pipe, an engineer gates only prod_q and result with !stall but forgets to gate the valid chain (v1_q, v2_q update every cycle). Describe precisely what happens to a stream when stall is held high for two cycles — which operations are dropped or duplicated, and what the consumer sees on out_valid. Then state the fix and the invariant a testbench should assert to catch a partial stall.
Exercise 4 — Size and balance the pipe
You must MAC a stream of 16-bit operands (a*b + c) and static timing shows the multiply is 70% of the period and the add is 20%. Give (i) the correct width of result and how you extend c before the add, (ii) whether the two-stage cut (multiply | add) is well balanced and, if not, where a better register placement or an extra stage would go, and (iii) the latency and throughput of your chosen pipeline. (Hint: a stage no shorter than the longest sub-path sets the clock.)
10. Related Tutorials
The Chapter-8 ideas this worked example composes (the lessons you are putting together here):
- Why Pipeline? — Chapter 8.1; cutting the long combinational
a*b + cpath with a register so each stage closes timing, and why operands must be registered to meet aligned. - Latency vs Throughput — Chapter 8.2; the 2-cycle latency versus one-result-per-cycle throughput reading this block demonstrates.
- Pipeline Valid-Bit Discipline — Chapter 8.3; the valid bit that marches alongside the data so the consumer knows which outputs are real — applied here as the
v1_q/v2_qchain.
The datapath pieces this block is built from:
- ALU Construction — Chapter 5.5; the adder that forms stage 2 of the pipeline.
- Multipliers — Chapter 5; the multiply tree that forms stage 1 and is the reason the combinational path is too long.
- Adders & Subtractors — Chapter 5; the add primitive at stage 2 and the width reasoning for
a*b + c. - Datapath + Control Separation — Chapter 5.6; the discipline of a clean datapath, here streamed rather than sequenced.
- FSM + Datapath — Chapter 4.6; the control/status seam a stalling consumer would drive this pipeline through.
Backward / method:
- The RTL Design Mindset — Chapter 0.2; the Interface → State → Datapath → Control → Verification lenses this worked example walks end to end.
- What Is an RTL Design Pattern? — Chapter 0.1; why composing patterns — not just knowing them — is the design skill this worked example rehearses.
Forward (unlock as they ship):
- Pipeline Stalls & Flush (
/rtl-design-patterns/pipeline-stalls-flush) — Chapter 8; the full treatment of the clock-enable freeze and valid-clear this page uses. - Pipeline Hazards (
/rtl-design-patterns/pipeline-hazards) — Chapter 8; data/structural hazards that arise when pipelined operations depend on each other. - valid/ready Handshake (
/rtl-design-patterns/valid-ready-handshake) — Chapter 9.1; the standard backpressure interface that would drive this pipeline's stall. - Streaming Block Case Study (
/rtl-design-patterns/case-study-streaming-fifo) — Chapter 13; a full streaming datapath with FIFOs and backpressure that composes a pipe like this one.
Verilog v1 prerequisites (the grammar this datapath transcribes into):
- Blocking and Non-Blocking Assignments — the
<=in every pipeline register that makes the stage-to-stage hand-off correct. - Arithmetic Operators — the
*and+of the multiply and add stages, and their result widths. - Physical Data Types — the
reg/wireand vector widths (2*WIDTHfor the product) the datapath declares. - Case Statements — the construct behind a controller that would drive this pipeline's stall/flush in a larger design.
11. Summary
- A pipelined datapath is patterns wired together, not a new pattern. The streaming MAC is a multiply stage and an add stage (5.x / 5.5) cut apart by a pipeline register (8.1), carrying a valid bit register-for-register (8.3), with stall (a clock-enable freeze) and flush (a valid-chain clear) for backpressure. Each piece is a lesson you already had; the worked example is composing them.
- Latency and throughput are different numbers. Two stages means latency 2 — any one result appears two cycles after its operands — but throughput one per cycle once the pipe is full, because the stages overlap different operations (8.2). You pay latency and register area to shorten the critical path and stream at full rate.
- The valid bit shadows the data. A one-bit
validregister per stage advances in lockstep with the data, soout_validis high exactly on the cyclesresultis a genuinea*b + cand low while the pipe fills — the 8.3 discipline. A valid chain shallower or deeper than the data mislabels results. - Every operand meeting at a stage must be delayed the same number of stages. The adder sees a one-stage-old product, so
cmust be registered one stage (c_q) to meet its own product. Feedcstraight in and each result isa[t-1]*b[t-1] + c[t]— a product from one operation plus an addend from the next. A constantchides it; a varyingcexposes it (§7). - Stall freezes the whole region; flush clears the valids; verify the whole stream. Stall is an all-or-nothing clock-enable so in-flight work pauses intact; flush zeroes every valid register so abandoned work stops counting. Prove it by streaming triples with bubbles, a stall, and a flush against a latency-delayed
a*b + creference and assertingresultandout_validevery cycle — self-checking clocked testbenches shown in SystemVerilog, Verilog, and VHDL.