RTL Design Patterns · Chapter 8 · Pipelining
Why Pipeline
Your datapath can be functionally correct and still miss timing. A long chain of logic, like a multiply followed by an add, is one unbroken path from inputs to output, and because the clock cannot tick faster than its slowest path, that one operation pins the whole chip's clock speed. You cannot make the logic much faster, but you can stop asking it to finish in one cycle. Pipelining inserts registers into the long path, cutting it into shorter stages so each settles inside a much shorter period, and once the pipe is full a finished result falls out every cycle. You trade latency for throughput and Fmax. This lesson treats pipelining as cutting the critical path with registers, and drills the discipline of delaying every side signal by the same number of stages. It is coded in SystemVerilog, Verilog, and VHDL.
Intermediate14 min readRTL Design PatternsPipeliningCritical PathFmaxThroughputLatencyDatapath
Chapter 8 · Section 8.1 · Pipelining
1. The Engineering Problem
You have built the datapath from Chapter 5: a multiply-accumulate stage that computes result = a*b + c, all 32-bit, one result per input set. Functionally it is perfect — the testbench passes, the arithmetic is exact. Then you run static timing on it and the tool reports a 6 ns critical path: the signal enters a and b, propagates through the entire 32-bit multiplier, then through the 32-bit adder, and only then is result stable. Six nanoseconds from input to settled output, as one unbroken chain of combinational gates.
Here is why that single number is a crisis. A synchronous chip has one rule that governs its speed: the clock period must be at least as long as the slowest combinational path between two registers — the critical path. Formally, Fmax = 1 / t_critical. A 6 ns path means the clock can tick no faster than once every 6 ns, so Fmax = 166 MHz — for the whole clock domain, not just this block. Every other register in the domain, however fast its own logic, is now shackled to the clock this one long multiply-add sets. One slow datapath has capped the entire chip.
You cannot just wire the multiplier's output straight into the adder and call the clock faster — that is what you already have, and it is the 6 ns path. Tying more logic onto one combinational chain only makes the path longer and Fmax lower. What you need is a structure that lets each part of the work happen in its own clock cycle: let the multiply settle in one cycle, capture it, and let the add settle in the next. That structure is the pipeline, and the reason it is Chapter 8 is that it is how real datapaths hit real frequencies — almost every high-throughput block on a chip is pipelined.
// Chapter 5 datapath: multiply THEN add, as one combinational chain.
wire [63:0] product = a * b; // 32x32 multiply — deep logic
assign result = product + c; // then a 32-bit add on top
// Static timing: input a/b -> multiplier -> adder -> result settles in ~6 ns.
// That single path is the CRITICAL PATH of the clock domain.
// Fmax = 1 / 6ns = 166 MHz -> the WHOLE chip clock is capped here.
// WRONG fix — pile more logic on the same chain (path gets LONGER, Fmax LOWER):
// assign result = (a * b + c) * d; // now ~8 ns, ~125 MHz. Worse.
// RIGHT fix — a PIPELINE: register the product, do the add next cycle, so no
// single cycle contains both the multiply AND the add. (this page builds it)2. Mental Model
3. Pattern Anatomy
A pipeline is built from one idea — a register placed inside a combinational path — plus the accounting that keeps everything aligned.
The critical path and Fmax. In synchronous logic, data launches from a source register on one clock edge and must arrive, settled, at a destination register before the next edge. The longest such launch-to-capture combinational delay in the whole domain is the critical path, t_critical, and it sets the minimum clock period: Fmax = 1 / t_critical (ignoring setup/skew, which only tighten it). A wide multiply-add is a classic critical path because a 32×32 multiplier is deep logic and the adder stacks on top of it. To raise Fmax you must shorten the longest path between two registers — and the way to do that without changing the arithmetic is to put a register in the middle.
Inserting pipeline registers between logic stages. Take the combinational a*b + c. It has a natural seam: the multiply produces a product, then the add consumes it. Drop a register on that seam. Now stage 1 is just the multiply (stage1 <= a*b, captured at the edge) and stage 2 is just the add (result <= stage1 + c_delayed). Neither stage contains both operations, so the longest path in either stage is much shorter than the original 6 ns — and that shorter path is the new critical path that sets Fmax.
The canonical example — combinational vs 2-stage. The combinational form computes the whole a*b + c in one cycle (one 6 ns path, low Fmax, latency 1). The 2-stage pipeline registers the product between the multiply and the add: cycle 1 computes and stores the product, cycle 2 adds c. Same arithmetic, same answer — but the clock can now run roughly twice as fast, a result comes out every cycle once full, and the answer for a given input appears 2 cycles later instead of 1.
Balancing stage delays. A pipeline's Fmax is set by its slowest stage, so cutting the path helps only if the cut is balanced. If you register the path such that stage 1 is 5 ns and stage 2 is 1 ns, the clock is still stuck at 5 ns — you added a register and latency but barely moved Fmax. The engineering work is to place the register(s) so each stage carries a roughly equal share of the delay. Sometimes that means splitting the multiply itself across stages, not just separating multiply from add.
The latency cost, previewed. Registering the datapath does not come free: the result for inputs presented in cycle t now emerges in cycle t + N. For a stream of independent operations this is invisible after the pipe fills (one result per cycle), but for anything that needs the result this cycle — or a feedback loop that reads its own output — the N-cycle latency is real and must be designed around (that is 8.2, and stalls/flush are 8.4).
Side/control signals must be delayed to stay aligned. The data is delayed by N cycles through the pipeline registers. Any signal that must remain associated with a particular operation's data — a routing tag, a memory address, a valid bit, a sign flag — has to travel through the same N pipeline stages, or it will arrive at the output aligned with the wrong data. A companion signal that skips the pipeline (passed straight through) or that is delayed by the wrong number of stages is the signature pipeline bug.
Combinational a*b + c vs a 2-stage pipeline — where the register goes
data flowStage count and alignment close the anatomy. An N-stage pipeline delays the data by N cycles and improves Fmax only if the N cuts are balanced; every companion signal must be delayed by that same N through a matched shift register of pipeline flops. Get the balance wrong and Fmax does not improve; get the alignment wrong and the results are labelled — or routed — for the wrong operation, intermittently, only when the pipeline is full and operations overlap (§7).
4. Real RTL Implementation
This is the core of the page. We build three things — (a) the combinational multi-op datapath (a*b + c, the long path), (b) the WIDTH-generic 2-stage pipelined version (register the product, add in stage 2), and (c) the unaligned-side-signal bug (companion tag delayed by the wrong vs the right number of stages), buggy vs fixed — and each is shown in SystemVerilog, Verilog, and VHDL, with both an RTL block and a self-checking clocked testbench. The idea is identical in all three; the syntax differs, and seeing them side by side is what makes the pattern language-independent in your head.
Two disciplines run through every block. First, the pipeline register is the D-FF of 2.1 — a clocked always @(posedge clk) / process(clk) with an explicit synchronous reset, using non-blocking assignment so a whole rank of pipeline flops samples the old values simultaneously. Second, the reference model is the combinational answer, delayed by N cycles — the pipelined result in cycle t must equal a*b + c computed from the inputs of cycle t − N, and any companion tag must be that same operation's tag.
4a. The combinational multi-op datapath — the long path, three ways
Start with the datapath that has the problem: a*b + c, computed combinationally in one cycle. It is correct and it is the 6 ns critical path from §1. This is the reference behaviour the pipeline must match, just N cycles later.
module macc_comb #(
parameter int WIDTH = 32 // datapath width
)(
input logic [WIDTH-1:0] a, b, c, // all valid the same cycle
output logic [2*WIDTH-1:0] result // a*b is 2*WIDTH wide; +c fits
);
// ONE unbroken combinational chain: multiply THEN add. This is the whole
// critical path — input a/b -> multiplier -> adder -> result. No clock,
// no register in the middle, so the clock period must swallow all of it.
assign result = (a * b) + c; // 2*WIDTH product, +c sign-safe
endmoduleThe combinational testbench needs no clock — it drives inputs, waits for the logic to settle, and checks result == a*b + c directly. This is the model the pipeline is measured against.
module macc_comb_tb;
localparam int WIDTH = 32;
logic [WIDTH-1:0] a, b, c;
logic [2*WIDTH-1:0] result, exp;
int errors = 0;
macc_comb #(.WIDTH(WIDTH)) dut (.a(a), .b(b), .c(c), .result(result));
initial begin
for (int t = 0; t < 20; t++) begin
a = $urandom; b = $urandom; c = $urandom;
#1; // let the combinational path settle
exp = a * b + c; // reference model, same cycle
assert (result === exp)
else begin $error("a=%h b=%h c=%h result=%h exp=%h", a, b, c, result, exp); errors++; end
end
if (errors == 0) $display("PASS: macc_comb result == a*b + c");
else $display("FAIL: %0d mismatches", errors);
$finish;
end
endmoduleThe Verilog form is identical in intent — wire/reg typing and $random instead of $urandom. The assign is the same one long path.
module macc_comb #(
parameter WIDTH = 32
)(
input wire [WIDTH-1:0] a, b, c,
output wire [2*WIDTH-1:0] result
);
// Multiply then add, one combinational chain — the critical path.
assign result = (a * b) + c;
endmodule module macc_comb_tb;
parameter WIDTH = 32;
reg [WIDTH-1:0] a, b, c;
wire [2*WIDTH-1:0] result;
reg [2*WIDTH-1:0] exp;
integer t, errors;
macc_comb #(.WIDTH(WIDTH)) dut (.a(a), .b(b), .c(c), .result(result));
initial begin
errors = 0;
for (t = 0; t < 20; t = t + 1) begin
a = $random; b = $random; c = $random;
#1; // settle
exp = a * b + c; // reference model
if (result !== exp) begin
$display("FAIL: a=%h b=%h c=%h result=%h exp=%h", a, b, c, result, exp);
errors = errors + 1;
end
end
if (errors == 0) $display("PASS: macc_comb matches a*b + c");
else $display("FAIL: %0d mismatches", errors);
$finish;
end
endmoduleIn VHDL the same datapath is a concurrent assignment over unsigned, with widths made explicit through numeric_std. The product is 2*WIDTH wide; c is resized to that width before the add so no bits are lost.
library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;
entity macc_comb is
generic ( WIDTH : positive := 32 );
port (
a, b, c : in std_logic_vector(WIDTH-1 downto 0); -- all valid same cycle
result : out std_logic_vector(2*WIDTH-1 downto 0) -- product width
);
end entity;
architecture rtl of macc_comb is
begin
-- One combinational chain: unsigned multiply (2*WIDTH product) then add c,
-- resized to the product width so nothing truncates. This is the long path.
result <= std_logic_vector(
unsigned(a) * unsigned(b)
+ resize(unsigned(c), 2*WIDTH));
end architecture; library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;
entity macc_comb_tb is
end entity;
architecture sim of macc_comb_tb is
constant WIDTH : positive := 32;
signal a, b, c : std_logic_vector(WIDTH-1 downto 0);
signal result : std_logic_vector(2*WIDTH-1 downto 0);
begin
dut : entity work.macc_comb generic map (WIDTH => WIDTH)
port map (a => a, b => b, c => c, result => result);
stim : process
variable exp : unsigned(2*WIDTH-1 downto 0);
begin
for t in 0 to 19 loop
a <= std_logic_vector(to_unsigned((t*7) mod 2**WIDTH, WIDTH));
b <= std_logic_vector(to_unsigned((t*13) mod 2**WIDTH, WIDTH));
c <= std_logic_vector(to_unsigned((t*5) mod 2**WIDTH, WIDTH));
wait for 1 ns; -- settle
exp := unsigned(a) * unsigned(b) + resize(unsigned(c), 2*WIDTH);
assert unsigned(result) = exp
report "macc_comb mismatch: result /= a*b + c" severity error;
end loop;
report "macc_comb self-check complete" severity note;
wait;
end process;
end architecture;4b. The 2-stage pipelined datapath — register the product, add in stage 2
Now cut the path. Stage 1 captures the product a*b into a pipeline register; stage 2 reads that register and adds c. Because c is consumed a cycle after a and b, it must be delayed one stage too (c_d1) so it lines up with the product it belongs to — the first appearance of the alignment discipline, applied to real data. The pipeline registers are the D-FF of 2.1: clocked, synchronously reset, non-blocking.
module macc_pipe #(
parameter int WIDTH = 32,
localparam int PW = 2*WIDTH // product / result width
)(
input logic clk,
input logic rst, // synchronous, active-high
input logic [WIDTH-1:0] a, b, c,
output logic [PW-1:0] result // valid N=2 cycles after a,b,c
);
logic [PW-1:0] stage1; // registered product (stage 1)
logic [WIDTH-1:0] c_d1; // c delayed 1 stage to meet it
always_ff @(posedge clk) begin
if (rst) begin
stage1 <= '0; c_d1 <= '0; result <= '0;
end else begin
// STAGE 1: multiply only -> register the product. Also delay c by
// ONE stage so it arrives with the product it must be added to.
stage1 <= a * b;
c_d1 <= c;
// STAGE 2: add only -> shorter path than multiply+add in one cycle.
result <= stage1 + c_d1; // result = (a*b) + c, 2 cycles later
end
end
endmoduleThe pipelined testbench is clocked and self-checking against a delayed reference: it pushes a stream of (a, b, c) on every cycle and, N=2 cycles later, checks result against a*b + c computed from the inputs of 2 cycles ago. A small shift-register of expected values in the testbench is that delayed reference — the same matched-delay idea the DUT uses on c.
module macc_pipe_tb;
localparam int WIDTH = 32;
localparam int PW = 2*WIDTH;
localparam int LAT = 2; // pipeline latency (stages)
logic clk = 0, rst = 1;
logic [WIDTH-1:0] a, b, c;
logic [PW-1:0] result;
int errors = 0;
macc_pipe #(.WIDTH(WIDTH)) dut (.clk(clk), .rst(rst), .a(a), .b(b), .c(c), .result(result));
always #5 clk = ~clk; // 100 MHz test clock
// Reference: a queue of expected results, LAT deep = the delayed model.
logic [PW-1:0] exp_q [$:LAT];
initial begin
repeat (2) @(posedge clk); rst <= 0; // release reset
for (int t = 0; t < 24; t++) begin
a = $urandom; b = $urandom; c = $urandom;
exp_q.push_back(a * b + c); // enqueue this op's answer
@(posedge clk);
if (t >= LAT) begin // once the pipe is full
automatic logic [PW-1:0] exp = exp_q.pop_front();
assert (result === exp) // result now == op from t-LAT
else begin $error("t=%0d result=%h exp=%h", t, result, exp); errors++; end
end
end
if (errors == 0) $display("PASS: macc_pipe == a*b+c delayed by %0d cycles", LAT);
else $display("FAIL: %0d mismatches", errors);
$finish;
end
endmoduleThe Verilog pipeline is the same two clocked stages with reg state and a manual delay-line queue for the reference (Verilog has no dynamic queue, so the reference is an explicit shift register of expected values).
module macc_pipe #(
parameter WIDTH = 32,
parameter PW = 64 // = 2*WIDTH
)(
input wire clk,
input wire rst, // synchronous, active-high
input wire [WIDTH-1:0] a, b, c,
output reg [PW-1:0] result
);
reg [PW-1:0] stage1; // registered product
reg [WIDTH-1:0] c_d1; // c delayed one stage
always @(posedge clk) begin
if (rst) begin
stage1 <= {PW{1'b0}};
c_d1 <= {WIDTH{1'b0}};
result <= {PW{1'b0}};
end else begin
stage1 <= a * b; // STAGE 1: multiply only
c_d1 <= c; // keep c aligned with product
result <= stage1 + c_d1; // STAGE 2: add only
end
end
endmodule module macc_pipe_tb;
parameter WIDTH = 32;
parameter PW = 64;
parameter LAT = 2;
reg clk, rst;
reg [WIDTH-1:0] a, b, c;
wire [PW-1:0] result;
reg [PW-1:0] exp0, exp1; // 2-deep reference delay line
integer t, errors;
macc_pipe #(.WIDTH(WIDTH), .PW(PW)) dut
(.clk(clk), .rst(rst), .a(a), .b(b), .c(c), .result(result));
initial clk = 0;
always #5 clk = ~clk;
initial begin
errors = 0; rst = 1; exp0 = 0; exp1 = 0;
@(posedge clk); @(posedge clk); rst = 0;
for (t = 0; t < 24; t = t + 1) begin
a = $random; b = $random; c = $random;
@(posedge clk);
// Shift the reference: exp1 is the answer for the op LAT cycles ago.
exp1 = exp0;
exp0 = a * b + c;
if (t >= LAT) begin
if (result !== exp1) begin
$display("FAIL: t=%0d result=%h exp=%h", t, result, exp1);
errors = errors + 1;
end
end
end
if (errors == 0) $display("PASS: macc_pipe == a*b+c delayed by %0d", LAT);
else $display("FAIL: %0d mismatches", errors);
$finish;
end
endmoduleIn VHDL the two stages are a clocked process(clk) with numeric_std arithmetic; the reference model in the testbench is a two-element array that shifts each cycle — VHDL's matched delay line.
library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;
entity macc_pipe is
generic ( WIDTH : positive := 32 );
port (
clk, rst : in std_logic; -- rst: sync, active-high
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 macc_pipe is
signal stage1 : unsigned(2*WIDTH-1 downto 0); -- registered product
signal c_d1 : unsigned(WIDTH-1 downto 0); -- c delayed one stage
begin
process (clk)
begin
if rising_edge(clk) then
if rst = '1' then
stage1 <= (others => '0');
c_d1 <= (others => '0');
result <= (others => '0');
else
stage1 <= unsigned(a) * unsigned(b); -- STAGE 1: multiply
c_d1 <= unsigned(c); -- keep c aligned
result <= std_logic_vector(stage1 + resize(c_d1, 2*WIDTH)); -- STAGE 2
end if;
end if;
end process;
end architecture; library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;
entity macc_pipe_tb is
end entity;
architecture sim of macc_pipe_tb is
constant WIDTH : positive := 32;
constant LAT : positive := 2;
signal clk : std_logic := '0';
signal rst : std_logic := '1';
signal a, b, c : std_logic_vector(WIDTH-1 downto 0);
signal result : std_logic_vector(2*WIDTH-1 downto 0);
begin
dut : entity work.macc_pipe generic map (WIDTH => WIDTH)
port map (clk => clk, rst => rst, a => a, b => b, c => c, result => result);
clk <= not clk after 5 ns; -- 100 MHz test clock
stim : process
type ref_arr is array (0 to LAT-1) of unsigned(2*WIDTH-1 downto 0);
variable expd : ref_arr := (others => (others => '0')); -- delay line
begin
wait until rising_edge(clk); wait until rising_edge(clk);
rst <= '0';
for t in 0 to 23 loop
a <= std_logic_vector(to_unsigned((t*7 + 1) mod 2**WIDTH, WIDTH));
b <= std_logic_vector(to_unsigned((t*13 + 1) mod 2**WIDTH, WIDTH));
c <= std_logic_vector(to_unsigned((t*5 + 1) mod 2**WIDTH, WIDTH));
wait until rising_edge(clk);
-- shift the reference; expd(LAT-1) is the answer LAT cycles old
expd(1) := expd(0);
expd(0) := unsigned(a) * unsigned(b) + resize(unsigned(c), 2*WIDTH);
if t >= LAT then
assert unsigned(result) = expd(1)
report "macc_pipe mismatch: result /= a*b+c delayed" severity error;
end if;
end loop;
report "macc_pipe self-check complete" severity note;
wait;
end process;
end architecture;4c. The unaligned-side-signal bug — buggy vs fixed, in all three HDLs
Now the signature pipeline failure. The datapath carries a companion tag_in — a routing id or transaction number that must exit with the result it belongs to. The data is delayed 2 stages by the pipeline; the tag must be delayed 2 stages too. The BUGGY version passes the tag straight through (or delays it by only one stage), so at the output the tag belongs to a different operation than the data beside it. The FIXED version pushes the tag through a matched 2-deep shift register of flops — the same N as the data.
// BUGGY: data is pipelined 2 stages; tag is passed straight through combinationally.
// At the output, tag_out is THIS cycle's input tag, but result belongs to
// the operation from 2 cycles ago -> tag labels the WRONG result.
module macc_tag_bad #(parameter int WIDTH = 32, localparam int PW = 2*WIDTH) (
input logic clk, rst,
input logic [WIDTH-1:0] a, b, c,
input logic [7:0] tag_in,
output logic [PW-1:0] result,
output logic [7:0] tag_out
);
logic [PW-1:0] stage1; logic [WIDTH-1:0] c_d1;
always_ff @(posedge clk) begin
if (rst) begin stage1<='0; c_d1<='0; result<='0; end
else begin stage1<=a*b; c_d1<=c; result<=stage1+c_d1; end
end
assign tag_out = tag_in; // BUG: 0-stage tag vs 2-stage data
endmodule
// FIXED: delay the tag by the SAME 2 stages as the data — a matched shift register.
module macc_tag_good #(parameter int WIDTH = 32, localparam int PW = 2*WIDTH) (
input logic clk, rst,
input logic [WIDTH-1:0] a, b, c,
input logic [7:0] tag_in,
output logic [PW-1:0] result,
output logic [7:0] tag_out
);
logic [PW-1:0] stage1; logic [WIDTH-1:0] c_d1;
logic [7:0] tag_d1, tag_d2; // 2-deep tag delay = data depth
always_ff @(posedge clk) begin
if (rst) begin
stage1<='0; c_d1<='0; result<='0; tag_d1<='0; tag_d2<='0;
end else begin
stage1 <= a * b; c_d1 <= c; result <= stage1 + c_d1;
tag_d1 <= tag_in; tag_d2 <= tag_d1; // tag travels with the data
end
end
assign tag_out = tag_d2; // now aligned: tag matches result
endmodule module macc_tag_tb;
localparam int WIDTH = 32, PW = 2*WIDTH, LAT = 2;
logic clk = 0, rst = 1;
logic [WIDTH-1:0] a, b, c;
logic [7:0] tag_in, tag_out;
logic [PW-1:0] result;
int errors = 0;
// Bind to macc_tag_good to PASS; to macc_tag_bad to see the misaligned tag.
macc_tag_good #(.WIDTH(WIDTH)) dut (.clk(clk), .rst(rst), .a(a), .b(b), .c(c),
.tag_in(tag_in), .result(result), .tag_out(tag_out));
always #5 clk = ~clk;
logic [PW-1:0] res_q [$:LAT]; // delayed reference: result
logic [7:0] tag_q [$:LAT]; // delayed reference: tag
initial begin
repeat (2) @(posedge clk); rst <= 0;
for (int t = 0; t < 24; t++) begin
a = $urandom; b = $urandom; c = $urandom; tag_in = t[7:0]; // unique tag per op
res_q.push_back(a * b + c);
tag_q.push_back(tag_in); // this op's tag travels with its data
@(posedge clk);
if (t >= LAT) begin
automatic logic [PW-1:0] er = res_q.pop_front();
automatic logic [7:0] et = tag_q.pop_front();
assert (result === er) else begin $error("t=%0d result mismatch", t); errors++; end
assert (tag_out === et) else begin $error("t=%0d TAG MISALIGNED out=%0d exp=%0d", t, tag_out, et); errors++; end
end
end
if (errors == 0) $display("PASS: result AND tag both aligned to op t-%0d", LAT);
else $display("FAIL: %0d mismatches (tag not delayed to match data)", errors);
$finish;
end
endmoduleThe Verilog buggy/fixed pair is the same story with reg state; the fix is a 2-deep tag shift register matching the data depth. The testbench streams a unique tag per operation and checks both result and tag_out against 2-cycle-delayed references.
// BUGGY: tag passed straight through; data is 2 stages behind -> tag_out labels
// the wrong result whenever the pipeline holds overlapping operations.
module macc_tag_bad #(parameter WIDTH = 32, parameter PW = 64) (
input wire clk, rst,
input wire [WIDTH-1:0] a, b, c,
input wire [7:0] tag_in,
output reg [PW-1:0] result,
output wire [7:0] tag_out
);
reg [PW-1:0] stage1; reg [WIDTH-1:0] c_d1;
always @(posedge clk) begin
if (rst) begin stage1<=0; c_d1<=0; result<=0; end
else begin stage1<=a*b; c_d1<=c; result<=stage1+c_d1; end
end
assign tag_out = tag_in; // BUG: no delay on the tag
endmodule
// FIXED: matched 2-deep tag delay line = the data's pipeline depth.
module macc_tag_good #(parameter WIDTH = 32, parameter PW = 64) (
input wire clk, rst,
input wire [WIDTH-1:0] a, b, c,
input wire [7:0] tag_in,
output reg [PW-1:0] result,
output reg [7:0] tag_out
);
reg [PW-1:0] stage1; reg [WIDTH-1:0] c_d1; reg [7:0] tag_d1;
always @(posedge clk) begin
if (rst) begin stage1<=0; c_d1<=0; result<=0; tag_d1<=0; tag_out<=0; end
else begin
stage1 <= a*b; c_d1 <= c; result <= stage1 + c_d1;
tag_d1 <= tag_in; tag_out <= tag_d1; // 2 flops = 2 data stages
end
end
endmodule module macc_tag_tb;
parameter WIDTH = 32, PW = 64, LAT = 2;
reg clk, rst;
reg [WIDTH-1:0] a, b, c;
reg [7:0] tag_in;
wire [PW-1:0] result;
wire [7:0] tag_out;
reg [PW-1:0] r0, r1; // result reference delay line
reg [7:0] g0, g1; // tag reference delay line
integer t, errors;
// Bind to _good to PASS; to _bad to observe the misaligned tag.
macc_tag_good #(.WIDTH(WIDTH), .PW(PW)) dut
(.clk(clk), .rst(rst), .a(a), .b(b), .c(c),
.tag_in(tag_in), .result(result), .tag_out(tag_out));
initial clk = 0;
always #5 clk = ~clk;
initial begin
errors=0; rst=1; r0=0; r1=0; g0=0; g1=0;
@(posedge clk); @(posedge clk); rst=0;
for (t = 0; t < 24; t = t + 1) begin
a=$random; b=$random; c=$random; tag_in=t[7:0];
@(posedge clk);
r1=r0; r0=a*b+c; // shift result reference
g1=g0; g0=tag_in; // shift tag reference (same depth)
if (t >= LAT) begin
if (result !== r1) begin $display("FAIL: t=%0d result mismatch", t); errors=errors+1; end
if (tag_out !== g1) begin $display("FAIL: t=%0d TAG MISALIGNED out=%0d exp=%0d", t, tag_out, g1); errors=errors+1; end
end
end
if (errors == 0) $display("PASS: result and tag both aligned");
else $display("FAIL: %0d mismatches", errors);
$finish;
end
endmoduleIn VHDL the buggy version drives tag_out combinationally from tag_in; the fixed version clocks the tag through two flops (tag_d1, tag_out) so it is delayed by the same two stages as the data.
library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;
-- BUGGY: tag_out is combinational from tag_in; data is 2 stages delayed, so the
-- tag at the output belongs to a different operation than the result.
entity macc_tag_bad is
generic ( WIDTH : positive := 32 );
port (
clk, rst : in std_logic;
a, b, c : in std_logic_vector(WIDTH-1 downto 0);
tag_in : in std_logic_vector(7 downto 0);
result : out std_logic_vector(2*WIDTH-1 downto 0);
tag_out : out std_logic_vector(7 downto 0)
);
end entity;
architecture rtl of macc_tag_bad is
signal stage1 : unsigned(2*WIDTH-1 downto 0);
signal c_d1 : unsigned(WIDTH-1 downto 0);
begin
process (clk) begin
if rising_edge(clk) then
if rst = '1' then stage1<=(others=>'0'); c_d1<=(others=>'0'); result<=(others=>'0');
else
stage1 <= unsigned(a) * unsigned(b);
c_d1 <= unsigned(c);
result <= std_logic_vector(stage1 + resize(c_d1, 2*WIDTH));
end if;
end if;
end process;
tag_out <= tag_in; -- BUG: no delay on the tag
end architecture;
-- FIXED: clock the tag through 2 stages to match the data's pipeline depth.
entity macc_tag_good is
generic ( WIDTH : positive := 32 );
port (
clk, rst : in std_logic;
a, b, c : in std_logic_vector(WIDTH-1 downto 0);
tag_in : in std_logic_vector(7 downto 0);
result : out std_logic_vector(2*WIDTH-1 downto 0);
tag_out : out std_logic_vector(7 downto 0)
);
end entity;
architecture rtl of macc_tag_good is
signal stage1 : unsigned(2*WIDTH-1 downto 0);
signal c_d1 : unsigned(WIDTH-1 downto 0);
signal tag_d1 : std_logic_vector(7 downto 0);
begin
process (clk) begin
if rising_edge(clk) then
if rst = '1' then
stage1<=(others=>'0'); c_d1<=(others=>'0'); result<=(others=>'0');
tag_d1<=(others=>'0'); tag_out<=(others=>'0');
else
stage1 <= unsigned(a) * unsigned(b);
c_d1 <= unsigned(c);
result <= std_logic_vector(stage1 + resize(c_d1, 2*WIDTH));
tag_d1 <= tag_in;
tag_out <= tag_d1; -- 2 flops = 2 data stages
end if;
end if;
end process;
end architecture; library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;
entity macc_tag_tb is
end entity;
architecture sim of macc_tag_tb is
constant WIDTH : positive := 32;
constant LAT : positive := 2;
signal clk : std_logic := '0';
signal rst : std_logic := '1';
signal a, b, c : std_logic_vector(WIDTH-1 downto 0);
signal tag_in, tag_out : std_logic_vector(7 downto 0);
signal result : std_logic_vector(2*WIDTH-1 downto 0);
begin
-- Bind to macc_tag_good to PASS; to macc_tag_bad to observe the misaligned tag.
dut : entity work.macc_tag_good generic map (WIDTH => WIDTH)
port map (clk=>clk, rst=>rst, a=>a, b=>b, c=>c,
tag_in=>tag_in, result=>result, tag_out=>tag_out);
clk <= not clk after 5 ns;
stim : process
type res_arr is array (0 to LAT-1) of unsigned(2*WIDTH-1 downto 0);
type tag_arr is array (0 to LAT-1) of std_logic_vector(7 downto 0);
variable rref : res_arr := (others => (others => '0'));
variable gref : tag_arr := (others => (others => '0'));
begin
wait until rising_edge(clk); wait until rising_edge(clk);
rst <= '0';
for t in 0 to 23 loop
a <= std_logic_vector(to_unsigned((t*7 + 1) mod 2**WIDTH, WIDTH));
b <= std_logic_vector(to_unsigned((t*13+ 1) mod 2**WIDTH, WIDTH));
c <= std_logic_vector(to_unsigned((t*5 + 1) mod 2**WIDTH, WIDTH));
tag_in <= std_logic_vector(to_unsigned(t mod 256, 8)); -- unique per op
wait until rising_edge(clk);
rref(1) := rref(0); rref(0) := unsigned(a)*unsigned(b) + resize(unsigned(c), 2*WIDTH);
gref(1) := gref(0); gref(0) := tag_in; -- tag ref, same depth
if t >= LAT then
assert unsigned(result) = rref(1)
report "macc_tag mismatch: result wrong" severity error;
assert tag_out = gref(1)
report "macc_tag TAG MISALIGNED: tag_out labels the wrong result" severity error;
end if;
end loop;
report "macc_tag self-check complete" severity note;
wait;
end process;
end architecture;Across all three languages the lesson is one sentence: pipelining delays the data, so every companion signal — tag, address, control, valid — must be delayed by the exact same number of stages, or it arrives describing the wrong operation.
5. Verification Strategy
A pipeline is sequential, so its correctness is not a single truth table — it is a timing relationship: the output stream must equal the combinational answer, delayed by exactly N cycles, and every companion signal must be delayed by that same N. The single invariant that captures a correct pipeline is:
The pipelined datapath produces the SAME result as the combinational one, just N cycles later — and every side signal stays aligned with the data it belongs to.
Everything below is that one invariant, made checkable, and it maps directly onto the clocked testbenches in §4.
- A delayed reference model (self-checking). Build the golden model as the combinational function (
exp = a*b + c) and push each answer into an N-deep queue/shift-register. On each cycle, once the pipe is full (t >= LAT), compareresultagainst the answer that was enqueued N cycles ago. That queue is the pipeline's delay, mirrored in the testbench — the SVexp_q, the Verilogexp0/exp1shift, and the VHDLexpdarray in §4b all do exactly this. A pass means the pipeline computed the right value and emitted it at the right cycle. - Feed a stream and check every result. Do not test one operation at a time — a single-op test leaves the pipeline empty between operations and hides every alignment bug. Drive a new independent
(a, b, c[, tag])on every cycle so the pipeline is continuously full and operations overlap in the stages. Then check each emerging result. Overlapping operations are the only condition under which a misaligned side signal (§6, §7) shows itself. - Check every side signal stays aligned. For each companion signal, keep a second N-deep reference and assert it too. The §4c testbenches give each operation a unique
tag_in = tand asserttag_outequals the tag enqueued N cycles ago — so a tag that is off by even one stage is caught immediately. State it as an invariant: the tag emerging with a result must be the tag that entered with that result's inputs. - Parameter-generic verification. A
WIDTH-generic (and N-generic) pipeline must be verified generic, not assumed generic. Re-run the streamed, delayed-reference check forWIDTH = 8, 16, 32and, where the pipeline depth is a parameter, forN = 2, 3. A pipeline that passes atWIDTH=32can still be wrong at another width if the product width (2*WIDTH) or a resize was hardcoded — sweep it. - Corner cases and expected waveform. Exercise reset during operation (assert the pipe flushes to a defined state and refills cleanly), the fill and drain boundaries (the first N cycles produce no valid result; the last N drain after inputs stop), and back-to-back dense streams. On a waveform, "correct" is visible as a fixed N-cycle skew:
resulttracks the inputs shifted right by N cycles, and eachtag_outsteps in lockstep beside itsresult. Atag_outthat changes a cycle before or after itsresultis the visual signature of the §7 misalignment bug.
6. Common Mistakes
A side/control signal not pipelined to match the data. The signature pipeline bug. You register the datapath into N stages but pass a companion signal — a tag, an address, a valid, a sign flag — straight through combinationally, or delay it by the wrong number of stages. At the output the companion arrives a cycle (or more) early or late, so it describes a different operation than the data beside it: results get labelled, routed, or validated wrong. It passes single-op tests and fails only when the pipeline is full and operations overlap. The fix is mechanical: delay every signal that must stay with the data by the exact same number of stages the data is delayed — a matched shift register of flops (§4c, §7).
Expecting the result the same cycle (forgetting the added latency). After pipelining, the answer for inputs presented in cycle t no longer appears in cycle t; it appears in cycle t + N. Downstream logic that samples result "now" gets stale or garbage data during fill, and a reference model that compares without the N-cycle delay reports false failures. The whole point of §5's delayed reference is to build that latency into the check. Latency is not a bug — but forgetting it is.
Unbalanced stages — one stage still holds the long path. Pipelining raises Fmax only if the register is placed so the stages carry roughly equal delay. Cut a*b + c badly — put both the multiply and part of the add in stage 1 and almost nothing in stage 2 — and stage 1 is still nearly the original path, so Fmax barely moves while you have paid latency and area for the register. The slowest stage sets the clock. Balancing (sometimes splitting the multiply itself across stages) is the actual engineering, not just "add a register somewhere."
Pipelining a feedback loop. A pipeline is a feed-forward delay line: data flows in one direction, front to back. If the datapath has a feedback path — the output feeds back into an earlier stage the same cycle (an accumulator, an IIR filter, a state that reads its own next value) — inserting a pipeline register on the forward path breaks the timing of the loop: the fed-back value is now N cycles stale relative to what the loop expects. Pipelining a loop is a separate, harder problem (retiming, loop unrolling, C-slowing) that belongs to 8.2 and beyond — you cannot naively register a combinational feedback loop and keep it correct.
7. DebugLab
The transaction that got the wrong answer's tag — an unpipelined side signal
The engineering lesson: a pipeline delays the data, and anything that must stay aligned with that data has to be delayed by the exact same number of stages — no more, no fewer. The tell is a bug that appears only at full throughput and depends on request ordering ("result 5 came out with tag 7's label"): throughput-dependent misalignment in something you pipelined means a companion signal is on a different number of stages than the data. Two habits make it impossible, and they are identical across SystemVerilog, Verilog, and VHDL: push every side signal through a matched shift register of the same depth as the data path, and verify with the pipeline full — stream a unique tag per operation and assert each result exits with its own tag. This matched-delay discipline, applied specifically to a valid bit, is the foundation of the valid-bit discipline in 8.3.
8. Interview Q&A
9. Exercises
Design and reasoning exercises — no syntax drills. Each rehearses the "cut the path, keep everything aligned" habit.
Exercise 1 — Compute the Fmax gain
A combinational a*b + c datapath has a 6 ns critical path: the 32×32 multiplier accounts for ~4 ns and the adder for ~2 ns. (a) State the combinational Fmax. (b) You register the seam between multiply and add. Give the delay of each stage and the new Fmax, ignoring register overhead. (c) Now account for a realistic 0.4 ns register setup+clk-to-Q overhead per stage — recompute the new Fmax. (d) In one line, explain why the answer to (b) is not simply "double the combinational Fmax."
Exercise 2 — Balance the stages
The same 6 ns path (4 ns multiply, 2 ns add) is pipelined, but an engineer places the register so that stage 1 = full multiply (4 ns) and stage 2 = add (2 ns). (a) What is the resulting Fmax, and why is it worse than an ideal balanced 3 ns / 3 ns cut? (b) The multiply can itself be split into two ~2 ns halves. Propose a 3-stage pipeline that is well balanced, give each stage's delay and the new Fmax, and state the latency in cycles. (c) Name the two costs you paid to go from 2 stages to 3.
Exercise 3 — Align every companion signal
A pipelined datapath is N=3 stages deep and carries, alongside the data, three companion signals: a valid bit, a 6-bit dest_addr, and a sign flag. (a) For each companion, state exactly how many pipeline flops it needs and why. (b) An engineer delays valid by 3 stages but dest_addr by only 2. Describe precisely what goes wrong at the output and under what traffic condition it shows up. (c) Write, in one sentence, the general rule that makes all three correct, and name the 8.3 pattern that applies this rule to the valid bit specifically.
Exercise 4 — Decide when not to pipeline
For each scenario, say whether pipelining the datapath is the right call and why in one line: (a) a streaming FIR filter processing a continuous sample stream at maximum sample rate; (b) a single-shot CRC computed once per packet where the result is needed before the next packet header is parsed; (c) an accumulator acc <= acc + x inside a tight feedback loop; (d) an address-generation unit on the critical path of a design that is missing timing by 1.5 ns. (Hint: think throughput vs latency, and feed-forward vs feedback.)
10. Related Tutorials
Continue in RTL Design Patterns (forward links unlock as they ship):
- Latency vs Throughput — Chapter 8.2; the trade this page introduced, made its whole subject — how deep to pipeline and what the N-cycle latency really costs.
- Pipeline Valid-Bit Discipline — Chapter 8.3; the matched-delay rule of §6/§7 applied to the
validbit — the flagship pattern that keeps a pipeline's control aligned with its data. - Pipeline Stalls & Flush — Chapter 8.4; what to do when the pipeline must pause or discard in-flight work — enable-gating every stage together.
- Pipeline Hazards — Chapter 8; the correctness problems (data/control hazards) that arise once operations overlap in stages.
- Pipelined Datapath (worked example) — Chapter 8; a full multi-stage datapath composing everything in this chapter.
Backward / dependencies (the pieces this pattern composes):
- The Register Pattern (D-FF) — Chapter 2.1; a pipeline register is this D flip-flop, placed inside the datapath — the atom pipelining is built from.
- Adders & Subtractors — Chapter 5.1; the adder stage of
a*b + cand the source of the wide combinational path pipelining cuts. - Multipliers — Chapter 5; the deep multiply that dominates the critical path here and is the first candidate to split across stages.
- ALU Construction — Chapter 5; the wide combinational block whose delay pipelining is most often used to break.
- Shift Registers — Chapter 3; the matched delay line used to keep a companion tag/valid aligned with pipelined data.
- FSM + Datapath — Chapter 4; where pipelined datapaths meet the control that drives them.
- Datapath / Control Split — Chapter 4; the separation that makes it clear which signals are data (delayed by the pipe) and which are control (delayed to match).
Backward / method:
- The RTL Design Mindset — Chapter 0.2; the Interface → State → Datapath → Control → Verification lenses this page applied to a pipeline (registers turn pure datapath into staged, timed datapath).
- What Is an RTL Design Pattern? — Chapter 0.1; why pipelining earns the name "pattern" — a recurring problem (a path too long for the clock), a reusable form (registers on the seams), a synthesis reality (higher Fmax), and a signature failure (misaligned side signals).
Verilog v1 prerequisites (the grammar this pattern transcribes into):
- Blocking and Non-Blocking Assignments — why every pipeline register uses non-blocking (
<=) so a whole rank of stages samples the old values simultaneously. - Arithmetic Operators — the
*and+behinda*b + c, and the width rules that make the2*WIDTHproduct deliberate. - Physical Data Types —
reg/wire/vector widths and the intentional-width discipline the pipelined datapath depends on. - Case Statements — the construct behind the staged control that a pipelined datapath is usually driven by.
11. Summary
- The critical path sets Fmax for the whole domain.
Fmax = 1 / t_critical: the longest combinational path between two registers caps the clock for every register in the clock domain. A wide multiply-add (a*b + c) is a classic long path, and one such path can pin the entire chip's frequency. - Pipelining cuts the path with registers. Insert the D flip-flop of 2.1 into the datapath on the seam between logic chunks — register
a*b, then addcthe next cycle — so no single clock period contains the whole computation. Each shorter stage becomes the new, faster critical path. - Throughput and Fmax up, latency up. Once the pipeline is full a result emerges every cycle (throughput), and the shorter stages let the clock run faster (Fmax) — but a given result now appears N cycles after its inputs (latency). You trade latency for throughput and Fmax; that trade is 8.2's whole subject.
- Balance the stages or the gain evaporates. Fmax is set by the slowest stage, so a register placed to leave one stage still holding the long path barely helps — you pay latency and area for almost no Fmax. Balancing (sometimes splitting the multiply itself) is the real engineering.
- Everything that must stay with the data must be delayed with it — in every HDL. Pipelining delays the data by N cycles, so every companion signal (tag, address, control,
valid) must be pushed through the same N matched flops, or it labels the wrong result — the throughput-dependent bug in §7. Verify with a delayed reference model, the pipeline full, a unique tag per operation, and an assertion that each result exits with its own tag — self-checking clocked testbenches shown here in SystemVerilog, Verilog, and VHDL. This matched-delay discipline, applied to thevalidbit, is 8.3.