Skip to content
DenseChip
Learn
PracticeCode labInterview

DenseChip

Navigate

Menu

LearnPracticeCode labInterview

Tools

All toolsSV editorCode repoResume
DenseChip

Practice, courses, and chip-design tools in one focused workspace.

LearnPracticeCode labInterviewToolsLearning paths

© 2026 DenseChip

AboutContactPrivacyTerms
Skip to lesson

Course contents

  • Part 0 · What Debugging Is6
  • Part 1 · Reproduce8
  • Part 2 · Observe9
  • Part 3 · Localise10
  • Part 4 · Hypothesise and Test6
  • Part 5 · Bug Classes16
  • Part 6 · Debug by Stage9
  • Part 7 · Hangs and Deadlocks7
  • Part 8 · Intermittent Failures7
  • Part 9 · Fix, Prove, Prevent7
  • Part 10 · Case Studies12
    • The Off-by-One That Fails at Wrap
    • The Reset Value That Changed
    • The X That Hid for Thousands of Cycles
    • The Race Two Simulators Disagree On
    • The Bus Value That Was Never Sent
    • The Response to the Wrong Master
    • The Deadlock Under Back-Pressure
    • The Credit Leak That Hangs After an Hour
    • The 2x Throughput Regression
    • The Missing Isolation
    • The Test-Mode Leak
    • The Testbench That Was Wrong
  • Part 11 · Debug as a Team5
  • Part 12 · Interview Prep5
Loading lesson

‹Part 10 · Case Studies
  1. Topics
  2. The Art of Hardware Debug
  3. Part 10 · Case Studies
  4. The 2x Throughput Regression

Part 10 · Case Studies · Senior (S)

The 2x Throughput Regression

A release that passes every functional test and moves half as many words, told as a mystery: the cycles-per-transfer measurement, git bisect with a script that fails on cycles, the idle cycle in a waveform, a prediction checked at half load, and a fix that keeps the timing improvement and the full rate.

On this page

    Why this matters

    A throughput regression passes every test that checks values, so it is found weeks later by someone else, with many commits in between. A test that fails on cycles turns it into an ordinary regression: bisect it, read the diff, fix it in an hour.

    After this lesson you can: measure cycles per transfer before and after a change, bisect a performance regression with a script that fails on cycles, read the bubble in a waveform, and fix a registered arbiter without losing the timing gain.

    Note

    Level: Senior (S) · Debug-loop step: The whole loop (won at Localise: bisecting on cycles)

    Before this: Performance Bugs, Bisection in History

    The report

    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):

    Note

    System test. With both streams running flat out, the shared port moves half the words it moved on v1.3. Nothing is dropped or reordered. Nobody on our side changed anything.

    The block's regression is green on both releases:

    Terminal
    $ 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 order

    Between 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?

    Check yourself Want a hint before you read the hunt?

    "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 hunt

    This hunt is decided at Localise (bisecting on cycles), with Observe giving the number and the waveform, and Test confirming the shape at a second load.

    The arbiter and a bench that counts

    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.

    arb2.sv
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    `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
    

    Observe: the number

    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).

    Terminal
    $ 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.

    Localise: bisect on cycles

    The old test passes at every commit, so it cannot drive a bisection:

    Terminal
    $ 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 order

    With 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.

    terminal
    1
    2
    3
    4
    5
    6
    7
    8
    #!/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
    Terminal
    $ 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:

    Terminal
    $ 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;
    +    end

    Read 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.

    Observe again: the bubble in a waveform

    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.

    Hypothesise and test: one idle cycle per transfer, at any load

    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.

    Terminal
    $ 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=1000

    Exactly 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.

    The fix and the lesson

    Fix: hand over in the transfer cycle, and keep the register

    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.

    design.sv
    // 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 nothing

    in_ready is still gnt & out_ready with gnt straight from a flop, so the timing improvement stays.

    Prove: full rate, and the honest number at half load

    Terminal
    $ 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=223

    Arbiter

    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.

    Prevent: make the regression count cycles

    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.

    Common pitfall

    Updating the baseline. When a performance check fails after a change that "only fixed timing", the easy move is to raise the budget. Do that only with the number in the review and a reason. Here, raising it would have shipped half the bandwidth.

    Common pitfall

    Measuring at one load. The full-load run says the fix is perfect; the half-load run shows its real cost. A throughput claim names its traffic; measure at the loads the system will see, not only at the one that is easiest to budget.

    Why this matters

    Senior lens. Timing fixes and performance are the same conversation. Cutting a combinational path with a register is right, but every register on a control loop adds a cycle of latency to the decision, and in a loop (grant, then transfer, then next grant) that latency becomes a bubble unless the next decision is made in advance. Ask for the cycles-per-transfer number in the same review as the slack number.

    Name performance tests after what they protect ("two masters at full load move one word per cycle") and keep them in the per-commit regression, not a weekly run. A regression found the next morning has one suspect commit; one found by the system team a month later has many.

    Interview

    Asked: "After a release, a block's throughput halved, but every test passes. How do you find the cause?"

    Weak answer: I would compare waveforms of the old and new versions to see where they differ, and ask the designers what they changed.

    Strong answer: First I measure: run one directed full-load test on both versions and print transfers, cycles and idle cycles with work waiting. A clean 2x with idle cycles equal to transfers says one bubble per transfer, a structural cause. Then I make the test fail on cycles, with a budget, because a test that fails is what git bisect needs, and bisect between the releases with a script that returns 0, 1 or 125. The guilty diff usually explains itself; here a grant register cleared after each transfer. I would confirm with a prediction at a different load, fix it so the next owner is chosen in the transfer cycle, and keep the budget in the regression so the next one fails on the night it lands.

    Follow-ups: (1) How would you bisect if the regression appeared across a parameter change instead of a commit? (2) When is a registered arbiter's extra cycle acceptable?

    Check yourself Someone proposes a different fix: keep v1.4's code but start the grant register at master 0 instead of 0 after each transfer, so there is always an owner. Does it restore full rate?

    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.

    Check yourself The bisect script returns 125 when the bench does not compile. Why does that matter here, and what would go wrong if it returned 1?

    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.

    Remember

    • A clean factor such as 2x points at a structure, often one idle cycle per transfer. Count the idle cycles; if they equal the transfers, you know the shape.

    • Bisection needs a test that fails. For a performance bug, write one that fails on cycles, then git bisect works as it does for a wrong value.

    • Registering a control signal moves its decision one cycle later. Ask what the circuit does in that cycle; often it is idle.

    Rule of thumb: every timing fix that adds a register gets a cycles-per-transfer check before it merges.

    How this was checked

    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.

    Keep going

    • Practice: Pipelined arbiter for timing closure

    • Practice: Round-robin arbiter

    • Practice: Pipeline performance instrumentation: stall counters

    • Practice: Round-robin arbiter for AXI masters

    • Code lab: Round-robin arbiter

    • Code lab: Performance counter

    • Interview: run a mock interview on this topic

    Related topics

    • Part 10 · Case Studies

    • Part 5: performance bugs

    • Counters and performance probes

    • Bisection in history

    • Bisection across configurations

    • RTL: bus arbitration

    • Case study: the deadlock under back-pressure

    Previous

    94 / 107

    Next: The Missing Isolation

    Up next

    Up nextThe Missing Isolation

    Sign in to save progress

    Practice this lessonReport an error

    See also

    • The Off-by-One That Fails at Wrap
    • The Reset Value That Changed
    • The X That Hid for Thousands of Cycles
    grant_bad1020102010out_valid_badword_bad0x80x1x81x2xgrant_good1212121212out_valid_goodword_good080181282383484
    The registered arbiter, both versions side by side, both masters always ready and the sink never stalling; grant 1 is master 0, 2 is master 1, and word is x when nothing moves