Put a plain registered slice between a source that always has data and a sink that is always ready, and it moves 0.50 words per cycle. A skid buffer in the same place moves 1.00. Both drive their upstream ready from a flop, so both are safe for timing, but only one keeps the link full.
This lesson measures three single-stage styles side by side in one bench: the pass-through register from Valid/Ready Flow Control, a plain registered slice, and the skid buffer from Skid Buffer and Pipeline Stages.
Read each row from the sink back to the source along the dashed line. That line is ready, and where it starts decides timing.
Pass-through: in_ready = !out_valid || out_ready. The sink's ready goes through one OR gate to the source in the same cycle.
Plain slice: in_ready = !out_valid. The sink's ready only empties the slice; the source sees a flop.
Skid buffer: s_ready_o = ready_q, a flop that is low only when both data registers are full.
The pass-through stage and the skid buffer are the modules from their own lessons, unchanged. The plain slice is the obvious way to register ready: take a word when empty, hand it on, and only then take the next.
// Style 2: plain registered slice. in_ready comes straight from a flop, so the slice
// takes a new word only when it is empty: never in the cycle its word leaves.
module reg_slice_naive #(parameter int W = 8) (
input logic clk, rst_n,
input logic in_valid, output logic in_ready, input logic [W-1:0] in_data,
output logic out_valid, input logic out_ready, output logic [W-1:0] out_data
);
assign in_ready = !out_valid; // registered: only the slice's own flop
always_ff @(posedge clk or negedge rst_n)
if (!rst_n) out_valid <= 1'b0;
else if (in_valid && in_ready) out_valid <= 1'b1; // fill when empty
else if (out_ready) out_valid <= 1'b0; // drain; no refill on this edge
always_ff @(posedge clk)
if (in_valid && in_ready) out_data <= in_data;
endmoduleThe else if is the whole story. On the edge where the word leaves, the slice cannot also take a new one, because in_ready was low all cycle: the slice was full. Every word costs one fill cycle and at least one drain cycle.
One bench drives all three stages in parallel. Each has its own source that offers a new word as soon as the last one is taken, and all three share the same sink ready. Three sink patterns run for 1,200 cycles each: always ready, pseudo-random ready (591 of 1,200 cycles), and a burst stall of 8 ready cycles followed by 4 stalled ones. A scoreboard per stage checks every word for order, loss and duplication.
Words per cycle: words the stage accepted in the 1,200-cycle window, divided by 1,200.
Latency: cycles from the edge the stage accepts a word to the edge the sink takes it. 1 is the minimum for any registered stage.
Combinational ready: between clock edges the bench flips the sink's ready and back. If the stage's upstream ready moves with it, there is a combinational path. A structural check in Yosys agrees: the combinational output cone of the sink ready reaches in_ready of the pass-through stage and nothing in the other two.
Flops: Yosys stat after synth on each module, with 8-bit data.
style pattern words/cycle avg latency max latency comb ready
pass-through always ready 1.00 1.00 1 yes
naive slice always ready 0.50 1.00 1 no
skid buffer always ready 1.00 1.00 1 no
(sink ready in 1200 of 1200 cycles)
pass-through random 50% 0.49 2.03 12 yes
naive slice random 50% 0.33 2.00 12 no
skid buffer random 50% 0.49 3.05 13 no
(sink ready in 591 of 1200 cycles)
pass-through burst stall 0.67 1.50 5 yes
naive slice burst stall 0.33 1.99 5 no
skid buffer burst stall 0.67 2.00 5 no
(sink ready in 800 of 1200 cycles)
PASS: 2595 + 1404 + 2597 words scoreboarded in order, none lost or duplicatedThe same numbers as a table. Each cell is words per cycle, then the average latency in cycles in brackets.
Style | Always ready | Random 50% | Burst stall | Combinational ready | Flops |
|---|---|---|---|---|---|
Pass-through | 1.00 (1.00) | 0.49 (2.03) | 0.67 (1.50) | Yes | 9 |
Plain registered slice | 0.50 (1.00) | 0.33 (2.00) | 0.33 (1.99) | No | 9 |
Skid buffer | 1.00 (1.00) | 0.49 (3.05) | 0.67 (2.00) | No | 19 |
Read the table one column at a time.
Always ready: the pass-through stage and the skid buffer keep the link full. The plain slice is stuck at 0.50 with a sink that never stalls: that is its ceiling, not a back-pressure effect.
Random 50%: the sink is the limit, and both full-rate styles reach it at 0.49. The plain slice falls to 0.33: after each word leaves it spends a cycle refilling, then waits about two cycles for the next ready.
Burst stall: 8 ready cycles in 12 allow 0.67, and both full-rate styles reach it. The plain slice moves 4 words in each 8-cycle burst, so 4 in 12 is 0.33.
Cost: the skid buffer pays 10 more flops (a second data register and the ready flop) for full rate with a registered ready.
Each slot is one clock cycle, and a word leaves at the edge that ends a slot where its xfer lane is high. The n_ lanes are the plain slice and the k_ lanes the skid buffer; both see the same m_ready.
Slots 0 to 5: n_ready alternates 1 and 0, so the plain slice delivers only in slots 1, 3 and 5. k_ready stays 1 and the skid buffer delivers in every slot from 1.
Slot 6: the sink stalls. k_ready is still 1, so the skid buffer takes one more word into its skid register.
Slots 7 and 8: both skid registers are full and k_ready is 0. Delivery resumes in slot 8 and k_ready returns in slot 9.
Count: in 11 slots the plain slice delivers 5 words and the skid buffer 8.
Under random back-pressure the skid buffer's average latency is 3.05 cycles against 2.03 for the pass-through stage, at the same throughput. That is not a defect. Average occupancy equals throughput times latency (Little's law): 0.49 x 3.05 is about 1.5 words held, against about 1.0 for the pass-through stage. The skid buffer accepts a word while the sink is stalled and parks it in the second register, where it waits behind the first. The storage that hides the late ready is the same storage that adds the wait.
With an always-ready sink every style has latency 1, because nothing ever waits.
Pass-through register: inside a block, a stage or two deep, where the ready chain is short and the clock is easy. The cheapest full-rate stage.
Plain registered slice: only where half rate is acceptable and flops are scarce, such as a slow configuration or control stream that never needs a word every cycle.
Skid buffer: at block and timing-partition boundaries, every few pass-through stages in a long pipeline, and wherever ready crosses a long wire. Full rate and a registered ready, for twice the data flops.
// snippet: N pass-through stages in a row, synthesised for the longest-path run (pipe_stage is the module from the bench)
module pipe_chain #(parameter int N = 4, parameter int W = 8) (
input logic clk, rst_n,
input logic in_valid, output logic in_ready, input logic [W-1:0] in_data,
output logic out_valid, input logic out_ready, output logic [W-1:0] out_data
);
logic [N:0] v, r;
logic [W-1:0] d [N+1];
assign v[0] = in_valid; assign in_ready = r[0]; assign d[0] = in_data;
assign out_valid = v[N]; assign r[N] = out_ready; assign out_data = d[N];
for (genvar i = 0; i < N; i++) begin : st
pipe_stage #(W) s (.clk, .rst_n, .in_valid(v[i]), .in_ready(r[i]), .in_data(d[i]),
.out_valid(v[i+1]), .out_ready(r[i+1]), .out_data(d[i+1]));
end
endmoduleLongest topological path in $paramod\pipe_chain\N=s32'00000000000000000000000000000100 (length=5):
0: \st[3].s.out_valid
1: \st[2].s.out_ready (via $abc$200$auto$blifparse.cc:397:parse_blif$204)
2: \st[1].s.out_ready (via $abc$200$auto$blifparse.cc:397:parse_blif$205)
3: \st[0].s.out_ready (via $abc$200$auto$blifparse.cc:397:parse_blif$206)
4: \st[0].s.in_ready (via $abc$200$auto$blifparse.cc:397:parse_blif$210)
...The path for N = 4 starts at the last stage's out_valid flop and walks back through every stage's out_ready to the first in_ready. Insert a skid buffer every few stages to cut it.
0.50 words per cycle. Letting it accept on that edge means in_ready = !out_valid || out_ready, which is the pass-through stage: full rate, but ready is combinational again. Full rate with a registered ready needs a second data register, which is the skid buffer.
From the second register. The skid buffer holds about 1.5 words on average against 1.0, and a word in the skid register waits behind the one in the output register. Same throughput, more occupancy, so more latency.
This bench produced the log above. A harness wraps each style with a saturating source and a scoreboard; the top module runs the three sink patterns, probes the ready path between edges, prints the numbers and fails on any lost, duplicated or reordered word.
// Style 1: pass-through register (from Valid/Ready Flow Control). Data is registered,
// but in_ready is a wire through the stage: it depends on out_ready in the same cycle.
module pipe_stage #(parameter int W = 8) (
input logic clk, rst_n,
input logic in_valid, output logic in_ready, input logic [W-1:0] in_data,
output logic out_valid, input logic out_ready, output logic [W-1:0] out_data
);
assign in_ready = !out_valid || out_ready; // combinational ready path
always_ff @(posedge clk or negedge rst_n)
if (!rst_n) out_valid <= 1'b0;
else if (in_ready) out_valid <= in_valid;
always_ff @(posedge clk)
if (in_ready && in_valid) out_data <= in_data;
endmodule
// Style 2: plain registered slice. in_ready comes straight from a flop (no path from
// out_ready), so the slice takes a new word only when it is empty: never in the cycle
// its word leaves.
module reg_slice_naive #(parameter int W = 8) (
input logic clk, rst_n,
input logic in_valid, output logic in_ready, input logic [W-1:0] in_data,
output logic out_valid, input logic out_ready, output logic [W-1:0] out_data
);
assign in_ready = !out_valid; // registered: only the slice's own flop
always_ff @(posedge clk or negedge rst_n)
if (!rst_n) out_valid <= 1'b0;
else if (in_valid && in_ready) out_valid <= 1'b1; // fill when empty
else if (out_ready) out_valid <= 1'b0; // drain; no refill on this edge
always_ff @(posedge clk)
if (in_valid && in_ready) out_data <= in_data;
endmodule
// Style 3: two-entry skid buffer (from Skid Buffer and Pipeline Stages, unchanged).
module skid_buffer #(
parameter int DW = 8
) (
input logic clk,
input logic rst_n,
input logic s_valid_i,
output logic s_ready_o,
input logic [DW-1:0] s_data_i,
output logic m_valid_o,
input logic m_ready_i,
output logic [DW-1:0] m_data_o
);
logic [DW-1:0] out_data, skid_data;
logic out_valid, skid_valid, ready_q;
logic next_out_valid, next_skid_valid;
logic [DW-1:0] next_out_data, next_skid_data;
wire push, pop;
assign m_valid_o = out_valid;
assign m_data_o = out_data;
assign s_ready_o = ready_q; // registered: no combinational m_ready_i to s_ready_o path
assign push = s_valid_i && ready_q;
assign pop = out_valid && m_ready_i;
always_comb begin
next_out_valid = out_valid;
next_skid_valid = skid_valid;
next_out_data = out_data;
next_skid_data = skid_data;
if (pop) begin
if (skid_valid) begin
next_out_data = skid_data;
if (push) next_skid_data = s_data_i; // unreachable with registered ready, kept for safety
else next_skid_valid = 1'b0;
end else if (push) begin
next_out_data = s_data_i;
next_out_valid = 1'b1;
end else begin
next_out_valid = 1'b0;
end
end else if (push) begin
if (!out_valid) begin
next_out_data = s_data_i;
next_out_valid = 1'b1;
end else begin
next_skid_data = s_data_i;
next_skid_valid = 1'b1;
end
end
end
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
out_valid <= 1'b0;
skid_valid <= 1'b0;
ready_q <= 1'b1;
out_data <= '0;
skid_data <= '0;
end else begin
out_valid <= next_out_valid;
skid_valid <= next_skid_valid;
out_data <= next_out_data;
skid_data <= next_skid_data;
ready_q <= !(next_out_valid && next_skid_valid);
end
end
endmodule
// One stage of the chosen style between a saturating source and the shared sink ready.
// Keeps a scoreboard (order, loss, duplication) and the throughput and latency counts.
module style_harness #(parameter int STYLE = 0) (
input logic clk, rst_n, run, m_ready
);
logic s_valid = 0, s_ready, m_valid;
logic [7:0] s_data = 0, m_data;
int cyc = 0, accepted = 0, delivered = 0, lat_sum = 0, lat_max = 0, errors = 0;
int q_data [$];
int q_time [$];
if (STYLE == 0) begin : g
pipe_stage #(8) dut (.clk, .rst_n, .in_valid(s_valid), .in_ready(s_ready), .in_data(s_data),
.out_valid(m_valid), .out_ready(m_ready), .out_data(m_data));
end else if (STYLE == 1) begin : g
reg_slice_naive #(8) dut (.clk, .rst_n, .in_valid(s_valid), .in_ready(s_ready), .in_data(s_data),
.out_valid(m_valid), .out_ready(m_ready), .out_data(m_data));
end else begin : g
skid_buffer #(8) dut (.clk, .rst_n, .s_valid_i(s_valid), .s_ready_o(s_ready), .s_data_i(s_data),
.m_valid_o(m_valid), .m_ready_i(m_ready), .m_data_o(m_data));
end
always @(posedge clk) if (rst_n) begin // pre-edge values: what this edge transfers
cyc++;
if (s_valid && s_ready) begin // input transfer
q_data.push_back(s_data);
q_time.push_back(cyc);
accepted++;
end
if (m_valid && m_ready) begin // output transfer: check order, record latency
if (q_data.size() == 0) errors++;
else begin
if (m_data !== 8'(q_data[0])) errors++;
if (cyc - q_time[0] > lat_max) lat_max = cyc - q_time[0];
lat_sum += cyc - q_time[0];
void'(q_data.pop_front());
void'(q_time.pop_front());
end
delivered++;
end
// Saturating source: offer a new word as soon as the last one is taken, hold it otherwise.
if (s_valid && s_ready) begin s_data <= s_data + 1; s_valid <= run; end
else if (!s_valid) s_valid <= run;
end
endmodule
Tool: Icarus Verilog 13.0, run on 2026-10-17. Commands: iverilog -g2012 -o s.vvp styles.sv; vvp -n s.vvp (styles.sv is the bench on the page). Result: the printed table on the page, then PASS: 2595 + 1404 + 2597 words scoreboarded in order, none lost or duplicated. The bench also runs under node scripts/tools/run-rtl-testbenches.mjs rtl/memory-buses/valid-ready (PASS).
Tool: Yosys 0.69+post (git sha1 143eb14f9), run on 2026-10-17. Commands, for each of pipe_stage, reg_slice_naive and skid_buffer: yosys -p "read_verilog -sv stages_rtl.sv; synth -top <module>; stat" giving 9, 9 and 19 flip-flops (1 $_DFFE_PN0P_ + 8 $_DFFE_PP_; the same; 16 $_DFFE_PN0P_ + 2 $_DFF_PN0_ + 1 $_DFF_PN1_). Ready path: yosys -p "...; synth -top <module>; select -list i:<sink ready> %coe* w:<upstream ready> %i" lists pipe_stage/in_ready and nothing for the other two.
Chain: yosys -p "read_verilog -sv stages_rtl.sv chain.sv; chparam -set N <n> pipe_chain; synth -flatten -top pipe_chain; ltp -noff" for N = 1, 4, 8 gave length 2, 5, 9; the same with skid_buffer stages gave length 5 for N = 1 and 8. The waveform comes from wave-src/valid-ready/stage-styles.sv via node scripts/tools/gen-waves.mjs memory-buses:valid-ready (no mismatch).
Scope and limits: one deterministic pseudo-random seed and fixed patterns, 1,200 cycles each, with a saturating source; throughput figures are for that traffic. Flop counts are Yosys generic cells for 8-bit data; ltp counts generic cells on a path, not delay in a library.