Skip to content

Add write skid buffer for same-register read-modify-write - #330

Merged
tancheng merged 6 commits into
masterfrom
register-bank-write-skid-buffer
Aug 13, 2026
Merged

Add write skid buffer for same-register read-modify-write#330
tancheng merged 6 commits into
masterfrom
register-bank-write-skid-buffer

Conversation

@tancheng

Copy link
Copy Markdown
Owner

Follow-up to #322 (based on its branch; retarget to master after #322 merges). Removes #322's known limitation and addresses the register half of #281/#286.

Problem

Under #322's token discipline, an operation that reads and writes the same register within one ctrl step deadlocked: the write was rejected while the operand's token was unconsumed, and the step could not complete until the write was delivered. This pattern is real — the mapper emits it (NOT $0 -> $0, SOUTH in the histogram kernel from #281), and it is the natural encoding of an accumulator.

Design

Each bank gains a one-entry write skid buffer (one write port ⇒ at most one blocked write, so one entry per bank suffices; the cluster/tile interfaces are unchanged).

  • Acceptance: outport_wr_rdy = ~skid_valid — a write is accepted whenever the skid is free. Pure registered state: the producer's rdy never combinationally depends on any consumer's readiness (deliberately not on skid_commit, which derives from ctrl_proceed and would close a loop through the FU's rdy chain), and a direct write can never collide with a commit on the single write port.
  • Routing: an accepted write lands directly if that cannot disturb anything (target token-free and not read by the current step), or at the reading step's boundary (write-through on the ctrl_proceed pulse — the new token atomically replaces the consumed one; set wins over clear). Otherwise it parks in the skid and commits once its target is no longer read by an open step.
  • Stability invariant: a register being read by an open step never changes value until the step completes. This matters beyond RMW: the previous behavior (direct write whenever token-free) allowed [P0] Simulator and rtl comparison (latency gap) debug #281's partial-multicast corruption — a speculative register write landing while another fan-out leg is backpressured, then re-execution on the changed operand. The tile-level test below caught exactly this during development.
  • Skid state is cleared on task switch along with the token bookkeeping.

With the write-through path, a same-register accumulator runs at full rate (one iteration per step, no bubble).

Tests

  • test_reg_cluster_write_skid_buffer: three back-to-back writes to one register with a stalled consumer flow through park→commit→park; all three delivered in order.
  • test_tile_same_register_accumulate: single-ctrl-word INC accumulator through one register that also fans out to a stalled tile outport — the [P0] Simulator and rtl comparison (latency gap) debug #281 shape end-to-end at tile level. Deadlocks at max-cycles against the Prevent overwriting unconsumed tokens in register banks #322 base (verified as a negative control); completes with the skid. The unarmed first read seeds the accumulator with the register default, so the outport observes 1, 2, 3.
  • Cluster test harnesses now always emulate the tile's per-step done-tracking (ctrl_proceed), since commit timing is tied to step completion; historical expectations are preserved, with stalled-sink tests observing one leading unarmed read.
  • Kernel regression sweep (everything that ever failed on Prevent overwriting unconsumed tokens in register banks #322's CI rounds: CgraRTL_test ×3, FIR terminate, streaming ×2, vector global reduce, migration ×3): 10/10 pass locally.

Relation to #286

The xbar half of #281 (send_rdy_vector ignoring send_accepted) is separate and remains open in #286 — this PR handles the register-file half (stability + RMW liveness).

Under token discipline, an operation that reads and writes the same
register within one ctrl step (an accumulator, or #281's
`NOT $0 -> $0, SOUTH` under backpressure) deadlocked: the write was
rejected while the operand token was unconsumed, and the step could
not complete until the write was delivered.

Each bank now has a one-entry write skid buffer (one write port ->
at most one blocked write). A write is accepted whenever the skid is
free (outport_wr_rdy = ~skid_valid, pure registered state, so the
producer's rdy never combinationally depends on any consumer's
readiness and never collides with a commit on the single write port):
- It lands directly if that cannot disturb an in-flight read (target
  token-free and not read by the current step), or at the boundary of
  the reading step (the write's token atomically replaces the consumed
  one; set wins over clear in the token update).
- Otherwise it parks in the skid and commits once its target is no
  longer read by an open step: at that step's completion pulse, or
  immediately if the target is not being read and holds no token.

The stability invariant — a register being read holds its value until
the step completes — also fixes the partial-multicast corruption from
issue #281 (a speculative register write landing while another fan-out
leg is backpressured, followed by re-execution on the changed value),
which the previous direct-write-if-token-free behavior still allowed.

Tests:
- test_reg_cluster_write_skid_buffer: three back-to-back writes with a
  stalled consumer flow through park/commit in order.
- test_tile_same_register_accumulate: single-ctrl-word INC accumulator
  through one register with a stalled tile outport (the #281 shape);
  deadlocks without the skid buffer (verified), completes with it.
- The cluster test harnesses now always emulate the tile's per-step
  done-tracking to drive inport_ctrl_proceed, since commit timing is
  tied to step completion.
@tancheng
tancheng force-pushed the register-bank-write-skid-buffer branch from 2c43ee9 to b672077 Compare July 17, 2026 04:19
@tancheng
tancheng changed the base branch from 321-register-bank-token-tracking to master July 29, 2026 19:01
Comment thread mem/register_cluster/RegisterBankRTL.py Outdated
Comment thread mem/register_cluster/RegisterBankRTL.py Outdated
Comment thread mem/register_cluster/RegisterBankRTL.py Outdated
Comment thread mem/register_cluster/RegisterBankRTL.py
Comment thread mem/register_cluster/RegisterBankRTL.py Outdated

@yyan7223 yyan7223 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The overall skid buffer control logic looks fine. But I need more elaboration to 4 signals below to help me better understand the details:

  • s.wr_target_token
  • s.skid_target_token
  • s.wr_target_read
  • s.skid_target_read

Per review: wr_target_token / skid_target_token read ambiguously (the
"target token" could be mistaken for the value being written), and
wr_target_read / skid_target_read look like read enables. Renamed to
wr_target_holds_token / skid_target_holds_token (the destination
register still holds its old, unconsumed token) and
wr_target_being_read / skid_target_being_read (hazard flags: the
current ctrl step is reading that destination), with the declaration
comments expanded accordingly and a short definition of "ctrl step"
added to the module docstring.
@tancheng

Copy link
Copy Markdown
Owner Author

@yyan7223 Elaboration on the 4 signals (they were renamed in a recent push, exactly to address this — mapping below):

Signal (as reviewed) Renamed to What it is Role in the control logic
s.wr_target_token s.wr_target_holds_token token_valid[write_reg_idx] — whether the register targeted by this cycle's incoming write still holds its old, unconsumed token. ("target" = the destination register; it never refers to the value being written.) Steers the accepted write: target token-free and not being read → lands directly in the register file; otherwise → parks in the skid buffer.
s.skid_target_token s.skid_target_holds_token token_valid[skid_idx] — the same question, asked for the register targeted by the write parked in the skid (skid_idx was latched at park time, possibly during an earlier ctrl step, so the two targets can differ). Times the parked entry's drain when its target is not being read: commit once that old token has been consumed.
s.wr_target_read s.wr_target_being_read Hazard flag: the current ctrl step is reading the incoming write's target register (read_reg_idx == write_reg_idx, with a read configured). A register being read must stay stable until the step completes, so this forces the write to park — or to land exactly on the ctrl_proceed pulse (write-through at the step boundary, where the new token atomically replaces the consumed one).
s.skid_target_read s.skid_target_being_read Hazard flag: the current ctrl step is reading the parked entry's target register (read_reg_idx == skid_idx). Gates the skid commit to the ctrl_proceed pulse, so the drain also lands exactly at the step boundary instead of disturbing the in-flight read.

Two clarifications that tie these together:

  • The token_valid[r] assignments in the for-loop are not enables — they are the loop-unrolled mux for indexing a vector with a signal (RTLIR-friendly form of token_valid[write_reg_idx]). The actual write enables are wr_en (direct) and skid_commit (drain).
  • Nothing ever reads from the skid: reads always come from the register file. If a register's next value is parked, readers see the old register value until the step completes — that is the stability invariant that fixes [P0] Simulator and rtl comparison (latency gap) debug #281's mid-step corruption.

Branch is also synced with master (post-#322 merge), so the current diff shows the new names.

The first version made outport_wr_rdy = ~skid_valid, i.e. it accepted
any write while the skid was free and parked whatever could not land.
That silently changed flow control for kernels that never do
same-register RMW: a write whose target still held an unconsumed token
used to backpressure its producer, but was now accepted into the skid,
letting the producer run ahead. Two FIR kernels
(CgraRTL_fir_2x2_loop_counter_test and CgraVerifAssert_test, both
2x2 fir *_return) computed wrong results as a result -- 0x413 instead
of 0x8a7 -- which CI caught after this branch was synced with master.
Verified by bisection: reverting only the acceptance condition (not the
read-hazard parking rule) restores them, and both tests pass on master.

outport_wr_rdy is now the pre-existing condition -- land directly when
the target holds no unconsumed token and is not being read this step --
OR-ed with the one case that condition cannot express: a write whose
target is being read by its own ctrl step, which deadlocks if
backpressured and corrupts the in-flight read if it lands mid-step.
Only that case uses the skid. Backpressure for every other flow is
bit-for-bit the pre-existing behavior.

All terms remain registered state or ctrl-word fields, so the
producer's rdy still never combinationally depends on a consumer's
readiness, and a direct write still cannot collide with a skid commit
on the single write port.
@tancheng

Copy link
Copy Markdown
Owner Author

Fixed the two CI failures (CgraRTL_fir_2x2_loop_counter_test and CgraVerifAssert_test, both 2x2_fir_*_return) in 912bdbf — they were a real regression from this PR, not pre-existing.

Root cause. The first version set outport_wr_rdy = ~skid_valid, i.e. it accepted any write while the skid was free and parked whatever could not land directly. That silently changed flow control for kernels that never do same-register RMW: a write whose target still held an unconsumed token used to backpressure its producer, but was now accepted into the skid, letting the producer run ahead. The two FIR kernels then computed 0x413 instead of 0x8a7.

How it was isolated.

  1. Baseline: reverting mem/register_cluster/ to master and re-running both tests → both pass, so the regression is mine.
  2. Bisection: removing only the read-hazard parking rule while keeping wr_rdy = ~skid_valid → still fails, so the acceptance condition was the culprit, not the stability rule.

Fix. The skid is now used only for the case that has no other solution — a write whose target is being read by its own ctrl step (backpressuring it deadlocks; landing it mid-step corrupts the in-flight read):

s.outport_wr_rdy @= (~s.wr_target_holds_token & ~s.wr_target_being_read) | \
                    (s.wr_target_being_read & ~s.skid_valid)

The first term is the pre-existing acceptance condition, so backpressure for every flow that already worked is bit-for-bit unchanged; only same-register RMW takes the skid path. All terms are still registered state or ctrl-word fields, so rdy still never combinationally depends on a consumer's readiness, and a direct write still cannot collide with a skid commit on the single write port. Docstring/comments updated to match the narrowed rule.

Verification: 23/23 locally — both previously-failing tests, the full FIR 2x2 set, CgraRTL_test ×3, streaming ×2, 4x4 FIR terminate, vector global reduce, the migration suite, and all register-cluster tests (the skid tests are RMW-shaped by construction, so they still exercise the skid path).

@tancheng
tancheng merged commit ac96a7a into master Aug 13, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants