A new pipeline passes every functional test. Performance testing shows it delivers exactly half the expected rate. There are no stalls: the source always has data and the sink is always ready. Every waveform looks healthy, with valid and ready toggling neatly. That neat toggling is the bug.
How can a pipeline with no stalls lose half its throughput, and how do you see it on a waveform?
The obvious one-entry pipeline stage drives ready from "am I empty?": in_ready = !full. It never overflows, it is easy to reason about, and its ready comes from a register, which helps timing. But think through a cycle where the stage is full and its word is leaving. The stage is full, so in_ready is 0, so no new word enters in this cycle. Next cycle the stage is empty and accepts a word; the cycle after, it is full again and refuses. It alternates: one word every two cycles, forever.
A stage can accept a new word in the same cycle that its current word leaves, because the register is overwritten at the same clock edge the old value is taken. The correct condition is:
`in_ready = !full || out_ready`: accept if empty, or if the current word is leaving this cycle.
With that one extra term the stage passes a word every cycle when the sink is ready, and it still never overflows: when the sink stalls, out_ready is 0 and the stage behaves as before. The extra term is exactly what makes the forward register slice of lesson 3.8 a full-throughput stage. Its price is that in_ready now depends combinationally on out_ready, so a chain of such stages makes a long ready path, which is what skid buffers break.
The testbench drives two one-entry stages from sources that always have data into sinks that are always ready. The only difference is the ready condition. The checker counts words delivered over 20 cycles.
// Two one-entry pipeline stages fed by a source that always has data and drained by a
// sink that is always ready. Stage BAD accepts a word only when it is empty
// (in_rdy = !full), so it cannot take a new word in the cycle it hands one on: it
// alternates full and empty and passes one word every two cycles. Stage GOOD accepts
// when it is empty or being emptied (in_rdy = !full || out_rdy): one word per cycle.
module stage #(parameter bit GOOD = 1) (
input logic clk, rst_n,
input logic in_vld, output logic in_rdy, input logic [7:0] in_data,
output logic out_vld, input logic out_rdy, output logic [7:0] out_data
);
assign in_rdy = GOOD ? (!out_vld || out_rdy) : !out_vld;
always_ff @(posedge clk or negedge rst_n)
if (!rst_n) out_vld <= 0;
else begin
if (out_vld && out_rdy) out_vld <= 0; // word leaves
if (in_vld && in_rdy) begin out_vld <= 1; out_data <= in_data; end // word arrives
end
endmodule
module tb;
logic clk = 0;
always #5 clk = ~clk;
logic rst_n = 0;
initial begin
$dumpfile("wave.vcd"); $dumpvars(0, tb);
#12 rst_n = 1;
end
logic [7:0] w_bad = 0, w_good = 0;
wire rdy_bad, vld_bad, rdy_good, vld_good;
wire [7:0] d_bad, d_good;
stage #(.GOOD(0)) bad (.clk, .rst_n, .in_vld(1'b1), .in_rdy(rdy_bad), .in_data(w_bad), .out_vld(vld_bad), .out_rdy(1'b1), .out_data(d_bad));
stage #(.GOOD(1)) good (.clk, .rst_n, .in_vld(1'b1), .in_rdy(rdy_good), .in_data(w_good), .out_vld(vld_good), .out_rdy(1'b1), .out_data(d_good));
always @(posedge clk) if (rst_n) begin
if (rdy_bad) w_bad <= w_bad + 1;
if (rdy_good) w_good <= w_good + 1;
end
int n_bad = 0, n_good = 0, cyc = 0;
always @(posedge clk) if (rst_n) begin
cyc++;
if (cyc > 2 && cyc <= 22) begin n_bad += vld_bad; n_good += vld_good; end
end
initial begin
#260;
if (n_good == 20 && n_bad == 10)
$display("PASS: over 20 cycles the stage with in_rdy = !full passed %0d words, the stage with in_rdy = !full || out_rdy passed %0d", n_bad, n_good);
else $display("FAIL: bad %0d, good %0d", n_bad, n_good);
$finish;
end
endmoduleHow to read it, slot by slot:
The bad stage: rdy_bad and vld_bad toggle in opposite phase, every cycle. When the stage holds a word (vld_bad = 1) it refuses a new one (rdy_bad = 0); next cycle it is empty, accepts, and is full again. d_bad changes only every other slot: 0, 1, 2, 3, 4, 5 in eleven slots.
The good stage: rdy_good stays high, vld_good stays high from slot 1, and d_good changes every slot: 0 to 10 in eleven slots.
The signature to remember: valid and ready of one stage toggling in opposite phase, with no stall anywhere, is a half-throughput stage.
With illustrative numbers, a chain of stages is only as fast as its slowest stage:
A 10-stage pipeline at 500 MHz, one word per cycle, 64-bit words:
all stages correct: 500 M words/s x 8 B = 4.0 GB/s
one bad stage: 250 M words/s x 8 B = 2.0 GB/s (the whole chain runs at its rate)One missing term in one stage halves the bandwidth of everything behind it, without a single stall in any functional test.
Stage | Throughput | Ready path | Use |
|---|---|---|---|
in_ready = !full | Half | Short (from a register) | Only when half rate is enough and timing is very tight |
in_ready = !full || out_ready | Full | Combinational through the stage | The standard pipeline stage (forward slice) |
Skid buffer (two entries) | Full | Short (from a register) | When both full throughput and a cut ready path are needed |
Where | Why it matters |
|---|---|
Any valid/ready pipeline, including AXI and AXI-Stream stages | The handshake allows a transfer every cycle; a stage that refuses while emptying wastes that capacity silently. |
Interview questions on FIFOs and pipeline stages | A favourite: "your pipeline runs at half rate with no stalls, why?" |
A team measures 50% of the expected bandwidth on an otherwise idle system (illustrative). Hunting for stalls finds none, because there are none. The breakthrough comes from looking at one stage's valid and ready together: they toggle in opposite phase every cycle. The stage had in_ready = !full, copied from a FIFO template where it was correct for a deeper FIFO. Adding || out_ready doubled the bandwidth. The regression gained a throughput check: with an always-ready sink and an always-valid source, words out must equal cycles.
out_ready is 0, so in_ready = !full: the full stage refuses new words and holds its own, exactly as it should. Nothing overflows.
Its valid and ready toggle in opposite phase every cycle, and its data changes every other cycle, while no stage downstream is stalling.
Its ready comes straight from a register, giving the shortest ready path. That is acceptable only if half rate is enough; otherwise a skid buffer gives a registered ready at full rate.
A one-entry stage with in_ready = !full refuses a new word while its old one leaves, so it runs at half rate with no stalls.
The fix is in_ready = !full || out_ready, or a skid buffer when ready must stay registered.
Catch it with a throughput test: always-valid source, always-ready sink, one word out per cycle.