Two streams share one output port through a small round-robin arbiter, arb2. Release v1.4 went out last month. Today the system team writes (illustrative):
The block's regression is green on both releases:
$ for t in v1.3 v1.4; do git checkout -q $t; echo "$t: $(../perf-check.sh +NOBUDGET | grep PASS)"; done
v1.3: PASS: 500 words from each master in order
v1.4: PASS: 500 words from each master in orderBetween the two tags there are five commits, all to arb2.sv, and their messages say "rename", "comment", "lint" and one timing change. The block's tests check every word's value and order, and none of them checks how many cycles it took.
Stop here if you want to try it. The arbiter and its bench are below. What is the first number you would want, and how would you find the commit?
"Half" is a structural number: something now costs two cycles where it cost one. Make the test report cycles per transfer, then make it fail on them. A test that fails is all git bisect needs.
The arbiter's three versions are in one file. REG_GRANT=0 is v1.3's, where the grant is picked from this cycle's valids. REG_GRANT=1, FIX=0 is v1.4's: the grant became a register, so in_ready no longer depends on in_valid through the pick logic. FIX=1 is the fix we will reach. The bench is the old functional test plus three counters and a budget, the pattern from performance bugs: with both masters back to back, 1,000 transfers may take at most 1,004 cycles. +NOBUDGET turns the budget off, which is what the old test was.
`timescale 1ns/1ps
// arb2: two valid/ready masters share one output, round robin.
// REG_GRANT=0: the original. The grant is picked from this cycle's valids, so in_ready
// depends combinationally on in_valid (a long path, and a lint warning).
// REG_GRANT=1: the grant is a register, so in_ready comes from a flop.
// FIX=0: the register is loaded only while it is empty and cleared after each transfer,
// so every transfer is followed by an idle cycle.
// FIX=1: the register picks the next owner in the same cycle a transfer completes.
module arb2 #(parameter bit REG_GRANT = 1, parameter bit FIX = 1) (
input logic clk, rst_n,
input logic [1:0] in_valid,
output logic [1:0] in_ready,
input logic [1:0][7:0] in_data,
output logic out_valid,
input logic out_ready,
output logic [7:0] out_data,
output logic out_src
);
logic last; // the master served last
logic [1:0] gnt_q, gnt;
logic fire;
// round robin: with both valid, the master not served last wins
function automatic logic [1:0] pick(input logic [1:0] req, input logic prev);
if (req == 2'b11) return prev ? 2'b01 : 2'b10;
return req;
endfunction
assign gnt = REG_GRANT ? gnt_q : pick(in_valid, last);
assign out_valid = |(gnt & in_valid);
assign out_data = gnt[1] ? in_data[1] : in_data[0];
assign out_src = gnt[1];
assign in_ready = gnt & {2{out_ready}};
assign fire = out_valid && out_ready;
always_ff @(posedge clk or negedge rst_n)
if (!rst_n) begin gnt_q <= '0; last <= 1'b1; end
else begin
if (fire) last <= gnt[1];
if (FIX) begin
if (fire) gnt_q <= (in_valid & ~gnt) != 2'b00 ? (in_valid & ~gnt) : gnt; // hand over, or keep
else if (!(|(gnt_q & in_valid))) gnt_q <= pick(in_valid, last); // owner has nothing
end else begin
if (gnt_q == 2'b00) gnt_q <= pick(in_valid, last);
else if (fire) gnt_q <= 2'b00; // release, re-arbitrate next cycle
end
end
endmodule
Same bench, three builds. The counters are the ones from counters and performance probes: work (transfers), time (cycles), and waiting (cycles with a word ready, a ready sink, and no transfer).
$ iverilog -g2012 -Parb_tb.REG_GRANT=0 -o a0.vvp arb_tb.sv && vvp -n a0.vvp
PERF: 1000 transfers in 1000 cycles, 1.00 cycles per transfer, idle cycles with a word waiting=0
PASS: 500 words from each master in order (REG_GRANT=0 FIX=1)
...
$ iverilog -g2012 -Parb_tb.FIX=0 -o ab.vvp arb_tb.sv && vvp -n ab.vvp
PERF: 1000 transfers in 2000 cycles, 2.00 cycles per transfer, idle cycles with a word waiting=1000
FAIL: PERF: 2000 cycles for 1000 transfers, budget 1004
...v1.3's arbiter: 1.00 cycles per transfer, one word every cycle, no idle cycles. That is the rate the system team had.
v1.4's: 2.00, and exactly 1000 idle cycles for 1000 transfers. Not contention, which would give an untidy number, but one idle cycle per transfer, every time.
The budget line fails. The same bench that printed PASS in the report now fails, and that is the tool for the next step.
The old test passes at every commit, so it cannot drive a bisection:
$ for c in $(git rev-list --reverse main); do git checkout -q $c; echo "$(git log -1 --format=%h) $(../perf-check.sh +NOBUDGET | tail -1)"; done
f1bdad0 PASS: 500 words from each master in order
dffd540 PASS: 500 words from each master in order
d7855ea PASS: 500 words from each master in order
353a3b1 PASS: 500 words from each master in order
fbdb9d3 PASS: 500 words from each master in order
de28814 PASS: 500 words from each master in order
b957a81 PASS: 500 words from each master in order
3c7969f PASS: 500 words from each master in orderWith the budget on, it fails on v1.4 and passes on v1.3, so it can. The script builds whatever arb2.sv the commit has with the bench (kept outside the repository, so every commit is tested by the same bench) and returns git's codes: 0 good, 1 bad, 125 skip if it does not compile.
#!/bin/sh
# perf-check.sh: build arb2.sv from the current directory with the bench from the case study
# (saved next to this script) and run it. Exit 0 = pass, 1 = fail (functional or budget),
# 125 = does not compile (git bisect skips it).
here=$(cd "$(dirname "$0")" && pwd)
iverilog -g2012 -o "$here/arb_bisect.vvp" arb2.sv "$here/bench.sv" 2>/dev/null || exit 125
vvp -n "$here/arb_bisect.vvp" "$@" | grep -E "^(PERF|PASS|FAIL)"
vvp -n "$here/arb_bisect.vvp" "$@" | grep -q "^PASS" || exit 1$ git bisect start v1.4 v1.3
Bisecting: 2 revisions left to test after this (roughly 1 step)
[fbdb9d37c770c82bfe1eb17b64863cf8118dfdbb] arb2: register the grant to cut the valid-to-ready path
$ git bisect run ../perf-check.sh
running '../perf-check.sh'
PERF: 1000 transfers in 2000 cycles, 2.00 cycles per transfer, idle cycles with a word waiting=1000
FAIL: PERF: 2000 cycles for 1000 transfers, budget 1004
Bisecting: 0 revisions left to test after this (roughly 0 steps)
[353a3b1ae5892e213c79dabb6b86e427e00a6af8] arb2: comment the grant path
running '../perf-check.sh'
PERF: 1000 transfers in 1000 cycles, 1.00 cycles per transfer, idle cycles with a word waiting=0
PASS: 500 words from each master in order
fbdb9d37c770c82bfe1eb17b64863cf8118dfdbb is the first bad commit
commit fbdb9d37c770c82bfe1eb17b64863cf8118dfdbb
...
arb2: register the grant to cut the valid-to-ready path
...Two runs. The bisection itself is the one you already know; the only new part is a test that fails on time instead of on a value. The guilty commit is the timing change, which is no surprise in hindsight, and the reason it was not suspected is in its message: it was a fix. Its diff, trimmed to the logic:
$ git show fbdb9d3
...
- assign gnt = pick(in_valid, last); // valid -> pick -> ready: one long path
+ assign gnt = gnt_q; // registered: in_ready no longer depends on in_valid
...
- if (!rst_n) last <= 1'b1;
- else if (fire) last <= gnt[1];
+ if (!rst_n) begin gnt_q <= '0; last <= 1'b1; end
+ else begin
+ if (fire) last <= gnt[1];
+ if (gnt_q == 2'b00) gnt_q <= pick(in_valid, last);
+ else if (fire) gnt_q <= 2'b00;
+ endRead the last two added lines as a timeline. A cycle with a transfer clears the grant. The next cycle has no grant, so nothing moves, and the register loads a new pick. The cycle after that moves a word. One transfer, one idle cycle, forever.
Read the two halves as two copies of the same block fed the same traffic, and count words.
Slots 0, 2, 4, 6, 8, bad copy: grant_bad holds 1 or 2 and a word moves (00, 80, 01, 81, 02).
Slots 1, 3, 5, 7, 9, bad copy: grant_bad is 0, out_valid_bad is 0 and no word moves, although both masters are waiting. This is the release cycle from the diff.
Good copy, every slot: the grant alternates 1, 2, 1, 2 with no gap, and ten words move in ten slots: the round robin hands over in the same cycle a transfer completes.
Hypothesis: each transfer costs one extra cycle in which the grant register reloads, whatever the traffic. That predicts something the full-load run cannot show: at half load, where masters often have nothing to send, the idle cycles with a word waiting should still equal the number of transfers, exactly, while the original arbiter should have none. If the cost came from contention instead, half load would shrink it.
$ vvp -n a0.vvp +LOAD=50 | grep -E "^PERF" # v1.3's arbiter
PERF: 1000 transfers in 1179 cycles, 1.17 cycles per transfer, idle cycles with a word waiting=0
$ vvp -n ab.vvp +LOAD=50 | grep -E "^PERF" # v1.4's
PERF: 1000 transfers in 2095 cycles, 2.09 cycles per transfer, idle cycles with a word waiting=1000Exactly 1000 again. Each word now waits one cycle for the grant to load (or reload), at any load. The hypothesis survives a test it could have failed, and it says what the fix must do: decide the next owner in the cycle the transfer happens, not in the cycle after.
Reverting the commit would bring the long valid-to-ready path back, and that path was the reason for the change. The better fix keeps gnt_q as a register and loads it on the transfer itself: the other master if it is waiting, otherwise the same owner. Only when the owner has nothing does it pick afresh.
// snippet: the fix (FIX=1), arb2's grant register
if (fire) gnt_q <= (in_valid & ~gnt) != 2'b00 ? (in_valid & ~gnt) : gnt; // hand over, or keep
else if (!(|(gnt_q & in_valid))) gnt_q <= pick(in_valid, last); // owner has nothingin_ready is still gnt & out_ready with gnt straight from a flop, so the timing improvement stays.
$ iverilog -g2012 -o a.vvp arb_tb.sv && vvp -n a.vvp
PERF: 1000 transfers in 1001 cycles, 1.00 cycles per transfer, idle cycles with a word waiting=1
PASS: 500 words from each master in order (REG_GRANT=1 FIX=1)
...
$ vvp -n a.vvp +LOAD=50 | grep -E "^PERF"
PERF: 1000 transfers in 1369 cycles, 1.36 cycles per transfer, idle cycles with a word waiting=223Arbiter | Full load: cycles per transfer | Idle cycles | Half load: cycles per transfer | Idle cycles |
|---|---|---|---|---|
v1.3, grant picked each cycle | 1.00 | 0 | 1.17 | 0 |
v1.4, registered, released after each transfer | 2.00 | 1000 | 2.09 | 1000 |
fix, registered, handed over on the transfer | 1.00 | 1 (the first cycle after reset) | 1.36 | 223 |
At full load the fix is back to one word per cycle and passes the budget; Verilator gives the same lines. At half load it is not free: 223 times a master that held the grant ran dry while the other was waiting, and a registered grant needs a cycle to notice. That is the real price of the timing fix: 1.36 cycles per transfer against v1.3's 1.17 at this load, about 14% fewer words per cycle, far from half. Whether it is acceptable depends on the traffic the port must carry, so write it in the change's review, with the table.
The bug escaped because no test could fail on time. The cure is the cycle budget in the block's bench, which now fails v1.4 and passes the fix, and a pinned test, as in the regression test that pins the bug: full-load traffic with a budget of transfers plus a small fill allowance. Add one review question for every timing fix that adds a register to a control path: in the cycle after this register changes, what does the circuit do? Here the honest answer was "nothing".
Which step won this hunt? Localise. Once a test failed on cycles, the commit was two runs away and its diff explained itself. Test then turned "one idle cycle per transfer" from a reading of the diff into a number checked at a second load.
No, it trades the bubble for starvation. In v1.4's code the register reloads only when it is 0, so once it is set to master 0 after every transfer it is never 0 again, and master 1 is never granted. The round robin must choose the next owner on the transfer, from who is waiting now, which is what the fix does. Run the bench, with its scoreboard for both masters, before believing any variant.
A commit in the range might rename a port or break the build for a reason unrelated to the slowdown. Returning 125 tells git to skip it; returning 1 would mark it bad, and bisect could name a commit that never ran the test. A bisect script must separate the bug is here from I could not tell.
arb_tb.sv as shown, 2026-10-18, Icarus Verilog 13.0: iverilog -g2012 [-Parb_tb.REG_GRANT=0 | -Parb_tb.FIX=0] -o <x>.vvp arb_tb.sv, then vvp -n <x>.vvp and vvp -n <x>.vvp +LOAD=50. Original: 1000 transfers in 1000 cycles, 1179 at half load. Registered with the bug: 2000 cycles and the budget FAIL, 2095 at half load, 1000 idle cycles in both. Fixed: 1001 cycles and PASS, 1369 at half load with 223 idle cycles. Verilator 5.048 (verilator --binary --timing --assert -Wno-fatal [-G...] --top-module arb_tb) prints the same PERF, PASS and FAIL lines at full load.
History: a scratch repository of eight commits of arb2.sv (tags v1.3 and v1.4; only commit 5 changes behaviour) built with mkhist.py (fixed dates, so the hashes repeat). perf-check.sh (on this page) runs the bench with the arbiter's own defaults (bench.sv: arb_tb with the parameter overrides removed). +NOBUDGET passes at all eight commits; git bisect start v1.4 v1.3 and git bisect run ../perf-check.sh (git 2.54.0) found fbdb9d3 in 2 runs.
Waveform: COURSE=dbg node scripts/tools/gen-waves.mjs case-studies:the-throughput-regression on wave-src/the-throughput-regression/arb-bubble.sv (the two registered versions side by side, both masters always ready); it matched its expectations and a second run changed nothing. Limits: one small arbiter, simulated; the timing path the register cut is described, not measured.