Skip to content

fix(bitnet): pair the DMA local write address with its own beat (Closes #2003) - #2345

Merged
gHashTag merged 1 commit into
masterfrom
fix/dma-write-address-pairing-2003
Aug 21, 2026
Merged

fix(bitnet): pair the DMA local write address with its own beat (Closes #2003)#2345
gHashTag merged 1 commit into
masterfrom
fix/dma-write-address-pairing-2003

Conversation

@gHashTag

Copy link
Copy Markdown
Owner

Closes #2003

The defect is live on master verbatim

#2003 reports itself as fixed. Its fixes landed on a branch that never merged (no PR, in any state), so the defect is live. Confirmed against freshly-fetched origin/master (cb1f0d4eb9805d7e3b947b13cb53b299d50c969c) — bootstrap/src/bitnet_dma.rs:195-196:

    s.push_str("                local_we        <= 1'b1;\n");
    s.push_str("                local_addr      <= local_addr + 12'd1;\n");

Both are non-blocking, so both land on the same edge: the cycle the strobe goes high, the address has already advanced. Beat 0 is captured while local_addr is 0, but the memory sees we=1 with addr=1. Every beat is written one slot high and address 0 is never written at all.

The change

Add a beat_index counter. READ_DATA drives local_addr <= beat_index; and advances beat_index <= beat_index + 12'd1;, so address, data and enable are all registered from the same stage and describe the same beat. beat_index is cleared at reset and re-armed in the IDLE start branch.

WRITE_DATA is deliberately untouched — there local_addr is a read pointer and its post-increment is correct.

Differential simulation — old and new emitter in one testbench

4-beat read, identical well-behaved AXI slave driving both DUTs:

--- OLD emitter (origin/master) ---        --- NEW emitter (fixed) ---
  local writes = 4                          local writes = 4
    write[0] addr=1 data=d0d0000000000000     write[0] addr=0 data=d0d0000000000000
    write[1] addr=2 data=d0d0000000000001     write[1] addr=1 data=d0d0000000000001
    write[2] addr=3 data=d0d0000000000002     write[2] addr=2 data=d0d0000000000002
    write[3] addr=4 data=d0d0000000000003     write[3] addr=3 data=d0d0000000000003
  mem[0] = ffffffffffffffff                 mem[0] = d0d0000000000000
=== CONTROL: WRITE transfer (direction=1), unchanged path ===
  old AXI w-beats = 3   new AXI w-beats = 3
RESULT: PASS

Slot 0 goes from never-written to correctly written; the control (AXI write-channel beat count on the untouched direction=1 path) is unchanged in both.

Mutant evidence

Guard 1 — read_data_pairs_address_with_data_and_enable. Mutant: revert the READ_DATA arm to master's shape. Fails with:

READ_DATA must not post-increment `local_addr` in the same non-blocking group that raises
`local_we`: the increment lands in the same cycle as the strobe, so beat 0's data is
written to address 1 and slot 0 is never written. READ_DATA arm was:
READ_DATA: if (m_axi_rvalid) begin
                local_wdata     <= m_axi_rdata;
                local_we        <= 1'b1;
                local_addr      <= local_addr + 12'd1;
                bytes_remaining <= bytes_remaining - 32'd8;
                if (m_axi_rlast || bytes_remaining <= 32'd8) state <= DONE_ST;
            end else local_we <= 1'b0;
test result: FAILED. 18 passed; 1 failed

The message prints the offending arm — that is what shows the assertion read the right text.

Here an unanchored guard would have been WRONG, not merely vacuous — measured. WRITE_DATA legitimately keeps its own local_addr <= local_addr + 12'd1;. A naive !v.contains("local_addr + 12'd1") therefore fails on the CORRECT emitter:

test tests::unanchored_guard_ON_THE_CORRECT_EMITTER ... FAILED
unanchored: WRITE_DATA legitimately keeps its own increment

The guard slices the READ_DATA arm out first, so it can be satisfied — or broken — only by the state it is about.

Guard 2 — beat_index_rearmed_in_idle_not_only_at_reset, planted separately. One mutant per guard, not one per file. Mutant: delete only the IDLE re-arm. Fails with:

`beat_index` must be cleared in the IDLE start branch alongside `local_addr`, not only in
the reset block: without it the second transfer after power-on starts writing at a stale
index. IDLE arm was:
IDLE: if (start) begin
                busy            <= 1'b1;
                ...
                local_addr      <= 12'd0;

Guard 1 still passes under this mutant, so neither guard masks the other. Note the reset block still contains beat_index <= 12'd0; under this mutant — so the guard is provably not satisfied by the reset line, which is the trap that had to be repaired hours after landing elsewhere in this campaign.

Reverting every mutant: 19 passed, 0 failed. The emitted Verilog parses under iverilog -g2005.

Pre-existing gap, recorded not papered over

These tests are not executed by CI. No workflow runs cargo test -p t27c; corpus-ratchet.yml records that the step was removed by #2292 after going red on master. Run with rustc --test on the module, which is self-contained.

Adjacent problems seen and deliberately NOT fixed here

  • Wave Loop 557 — dma_controller abandoned bursts and advanced on ready-without-valid #1970arlen/awlen still hardwired to 8'hFF; READ_ADDR still advances on if (m_axi_arready) with no arvalid; READ_DATA still exits on m_axi_rlast || bytes_remaining <= 32'd8. Deriving the burst length requires a multi-burst FSM (the local memory is 12-bit addressed = 4096 beats, i.e. up to 16 maximal bursts), which is a redesign, not this diff.
  • The module has no wstrb, so a length that is not a multiple of 8 over-reads to the next 8-byte boundary.
  • DONE_ST is entered without ever waiting on m_axi_bvalid on the write path (assign m_axi_bready = 1'b1; and bvalid is never sampled), so done can rise before the slave has accepted the write response.

Not fixed here, and not filed as fixed. Nothing was weakened: no || true, no continue-on-error, no existing assertion relaxed, no case dropped.

#2003)

READ_DATA raised local_we and post-incremented local_addr in the same
non-blocking group, so both landed on the same edge: beat 0's data was written
to address 1 and slot 0 was never written at all.

Drive local_addr from a new beat_index counter so address, data and enable are
registered from one stage and describe the same beat. beat_index is cleared at
reset and re-armed in the IDLE start branch. WRITE_DATA is untouched -- there
local_addr is a read pointer and its post-increment is correct.

Differential icarus run, both emitters in one testbench: defective writes to
addresses 1,2,3,4 leaving mem[0] unwritten; fixed writes to 0,1,2,3. Control
(AXI write-channel beat count) unchanged at 3 in both.
@gHashTag
gHashTag enabled auto-merge (squash) August 21, 2026 16:13
@github-actions

Copy link
Copy Markdown
Contributor

📓 NotebookLM Notebook linked to this PR

This notebook contains session context, decisions, and artifacts for this work.

@github-actions

Copy link
Copy Markdown
Contributor

PR Dashboard

Generated at: 2026-08-21 16:13:31 UTC

Summary

Status Count
Total Open PRs 4
PRs with Failing Checks 2
PRs with All Checks Green 2
READY 1
FAILING 2
PENDING 0

Seal Status

  • ⚠️ STALE -- sha256(compiler.rs)=65f033d04125 != manifest seal=87e5cbd3ad94.
    The committed NMSE numbers were certified against an older compiler.rs.
    Run scripts/reseal-check.sh locally for the two-step reseal command (advisory; not a merge gate).

@gHashTag
gHashTag merged commit 6b19d8b into master Aug 21, 2026
24 of 27 checks passed
gHashTag added a commit that referenced this pull request Aug 22, 2026
…ss fix

PR #2345 cited a differential simulation as its primary evidence and did not
commit the testbench, so the claim was true but unreproducible from the tree.

sim/tb_bitnet_dma_write_address.v elaborates the pre-fix and post-fix renderings
of dma_controller in one simulation and drives them from identical stimulus,
following the sim/tb_bitnet_request_overflow.v precedent from #2351. The
published numbers reproduce: old writes 1..4 and never writes address 0, new
writes 0..3.

Reporting only -- vvp exits 0 on PASS and on FAIL, and no workflow runs it.
Wiring sim/ into CI is out of scope here and unchanged.

Refs #2348
Refs #2003
gHashTag added a commit that referenced this pull request Aug 22, 2026
…ss fix (#2379)

PR #2345 cited a differential simulation as its primary evidence and did not
commit the testbench, so the claim was true but unreproducible from the tree.

sim/tb_bitnet_dma_write_address.v elaborates the pre-fix and post-fix renderings
of dma_controller in one simulation and drives them from identical stimulus,
following the sim/tb_bitnet_request_overflow.v precedent from #2351. The
published numbers reproduce: old writes 1..4 and never writes address 0, new
writes 0..3.

Reporting only -- vvp exits 0 on PASS and on FAIL, and no workflow runs it.
Wiring sim/ into CI is out of scope here and unchanged.

Refs #2348
Refs #2003
gHashTag pushed a commit that referenced this pull request Aug 22, 2026
)

#2006 defaults `local_we` low ahead of the case. The pre-fix and post-fix
renderings are observationally IDENTICAL: every reachable path already drove
the strobe, and the states that never mention it are never entered with it
high, because READ_DATA's only exit is DONE_ST.

A harness that passes by finding no difference proves nothing, so this one is
three-way: A = pre-#2006 (PR #2344 base), B = #2006 (PR #2344 head), C =
#2006 + #2003 (PR #2345 head). B and C are consecutive revisions -- #2344's
head rendering is byte-identical to #2345's base -- so one comparator sees all
three. A vs B must be identical; B vs C must differ. Same comparator, same
stimulus, same run, same 293-bit vector of every output port. Putting B in the
C slot makes the run fail rather than certify its null result.

Measured: A vs B 0 mismatching cycles, B vs C 266, over 373 cycles and seven
phases. Deleting READ_DATA's `end else local_we <= 1'b0;` from both renderings
makes A emit 22 local writes against B's 18 -- latent today, a real backstop
the moment an arm stops clearing the strobe.

Reporting, not a gate: `vvp` exits 0 on FAIL as well as PASS.

Refs #2348
gHashTag added a commit that referenced this pull request Aug 22, 2026
) (#2381)

* test(sim): differential harness for the zero-count layer_sequencer hang (Refs #1977)

`layer_sequencer` never left RUN when asked for zero work: both terminators
are `index == count-1` against an unsigned port, and the bare literal 1 widens
each subtraction to 32 bits, so a zero count borrows to 32'hFFFFFFFF while the
index zero-extends.

Elaborates the PR #2337 pre-fix and post-fix renderings in one simulation.
Reproduces the published numbers: 200,000 cycles with no `done` and
`neuron_id` reaching exactly 50,000, plus six non-zero controls that are
identical on every output every cycle.

Reporting, not a gate: `vvp` exits 0 on FAIL as well as PASS.

Refs #2348

* test(sim): differential harness for the stale prefetch_done level (Refs #1985)

`weight_prefetch_ctrl` documented `prefetch_done` as a one-cycle pulse but
cleared it only inside the start guard, leaving it asserted for the whole idle
gap. A requester sampling it in the cycle it raises `start_prefetch` reads the
previous transaction's completion.

Elaborates the PR #2340 pre-fix and post-fix renderings in one simulation.
Reproduces the published numbers: t2 sampled_done OLD=1 / NEW=0, done_rises
2/2, we_count 4/4. A second case varies the idle gap to separate level from
pulse: old's high-time grows with the gap, new's does not.

Reporting, not a gate: `vvp` exits 0 on FAIL as well as PASS.

Refs #2348

* test(sim): three-way harness for the latent local_we default (Refs #2006)

#2006 defaults `local_we` low ahead of the case. The pre-fix and post-fix
renderings are observationally IDENTICAL: every reachable path already drove
the strobe, and the states that never mention it are never entered with it
high, because READ_DATA's only exit is DONE_ST.

A harness that passes by finding no difference proves nothing, so this one is
three-way: A = pre-#2006 (PR #2344 base), B = #2006 (PR #2344 head), C =
#2006 + #2003 (PR #2345 head). B and C are consecutive revisions -- #2344's
head rendering is byte-identical to #2345's base -- so one comparator sees all
three. A vs B must be identical; B vs C must differ. Same comparator, same
stimulus, same run, same 293-bit vector of every output port. Putting B in the
C slot makes the run fail rather than certify its null result.

Measured: A vs B 0 mismatching cycles, B vs C 266, over 373 cycles and seven
phases. Deleting READ_DATA's `end else local_we <= 1'b0;` from both renderings
makes A emit 22 local writes against B's 18 -- latent today, a real backstop
the moment an arm stops clearing the strobe.

Reporting, not a gate: `vvp` exits 0 on FAIL as well as PASS.

Refs #2348

* docs(sim): document the three new harnesses and the standalone emit recipe (Refs #2348)

Adds a README section per harness in the style #2379 established, plus the
shared emit recipe: every BitNet emitter compiles standalone under `rustc`
with a four-line driver, no cargo and no target directory, because the only
`use` in any of them is `use super::*` inside `#[cfg(test)]`.

Records the base/head shas each harness was rendered from, and the three
instrument faults found while reproducing the published claims -- a
posedge-sampled observer trailing the design by a cycle, an observer racing
the stimulus that drove `start_prefetch`, and `first_chunk`/`last_chunk`
having no reset in either rendering. Each was a fault in the instrument; every
published number reproduced once the instrument was corrected.

Refs #1977, #1985, #2006

---------

Co-authored-by: Claude <claude@anthropic.com>
gHashTag added a commit that referenced this pull request Aug 22, 2026
…teral count (#2385)

dma_local_addr_autoincrement_both_paths counted one literal across the whole
module and required two. #2345 replaced the read path's post-increment with
local_addr <= beat_index -- the #2003 fix -- so the count fell to one and the
test has been red on master since. The emitter is correct; the test was stale.

A global count cannot say which path a match came from, which is what let the
read path lose its advance while the assertion still saw one match. Lowering
the threshold to >= 1 would go green and re-open the same blind spot.

Now sliced per state arm. Invisible until now because cargo test stops at the
first failing target and this is the 42nd of 73 (#2382).

Closes #2384
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.

Wave Loop 579 — the write-pairing shape, enumerated across every port

2 participants