Skip to content

fix(bitnet): retire prefetch_done on entry to IDLE - #2340

Merged
gHashTag merged 1 commit into
masterfrom
fix/1985-prefetch-done-stale-flag
Aug 21, 2026
Merged

fix(bitnet): retire prefetch_done on entry to IDLE#2340
gHashTag merged 1 commit into
masterfrom
fix/1985-prefetch-done-stale-flag

Conversation

@gHashTag

Copy link
Copy Markdown
Owner

The defect was still live on master

#1985 reports itself as fixed. Its fixes landed on a branch that never opened a PR, so the
defect is on master verbatim. Found at bootstrap/src/bitnet_buffers.rs:181-182:

s.push_str("            IDLE: if (start_prefetch) begin\n");
s.push_str("                state <= FETCH; prefetch_active <= 1'b1; prefetch_done <= 1'b0;\n");

DONE_ST raises prefetch_done and returns to IDLE; the only clear outside reset sits
inside the start_prefetch guard.

Why that is one cycle too late. The clear is a non-blocking assignment, so it takes
effect the cycle after the request is sampled. The consumer, multilayer_sequencer, does:

PREFETCH: begin start_prefetch<=1'b1; state<=WAIT_PF; end
WAIT_PF: if(prefetch_done) state<=RUN;

WAIT_PF tests the flag in the same cycle it holds start_prefetch high — exactly the one
cycle where the controller is still in IDLE and the stale 1 is still there.

The fix

Emit the clear in the IDLE arm ahead of the guard, so the flag is retired on entry to
IDLE and is genuinely the one-cycle pulse the module doc-comment already claimed:

IDLE: begin
    prefetch_done <= 1'b0;
    if (start_prefetch) begin
        state <= FETCH; prefetch_active <= 1'b1;
        ...
    end
end

No other signal, state or port changed. Scope: bootstrap/src/bitnet_buffers.rs, its
integration test, and one docs/now/ entry.

Three bars

TRUE

rustc --test bootstrap/src/bitnet_buffers.rs — the module is dependency-free, so its real
unit tests run standalone:

test result: ok. 23 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

ALIVE — the guard is not vacuous, and the obvious version of it would have been

The reset block also contains prefetch_done <= 1'b0;. Measured against the defective
emitter:

unanchored  v.contains("prefetch_done <= 1'b0;") = true
anchored    IDLE-arm clear-before-guard          = false

An unanchored contains check passes on the bug. The new guard therefore slices the IDLE
case arm out of the emitted text first and asserts the clear precedes the guard.

BITING — planted mutant

Mutant: move the clear back inside the start_prefetch branch (i.e. restore master).

test tests::prefetch_done_retired_in_idle_before_start_guard ... FAILED

thread 'tests::prefetch_done_retired_in_idle_before_start_guard' panicked at:
prefetch_done must be cleared on entry to IDLE, before the `if (start_prefetch)` guard,
so a new requester never observes the previous transaction's completion (#1985). IDLE arm:

            IDLE: if (start_prefetch) begin
                state <= FETCH; prefetch_active <= 1'b1; prefetch_done <= 1'b0;
                axi_araddr <= src_addr;
                words_remaining <= num_words;
                bram_addr <= 12'd0;
            end

test result: FAILED. 21 passed; 2 failed

The message prints the offending IDLE arm, which is what proves the assertion read the
right region rather than passing on the reset line. Reverting the mutant: 23 passed; 0 failed.

RTL evidence (icarus, on the actually-emitted Verilog)

Two transactions, sampling prefetch_done in the cycle the second start_prefetch is
raised — the cycle WAIT_PF looks at it:

emitter sampled_done_t2 done_rises we_count
master (defective) 1 — stale 2 4
this PR 0 2 4

done_rises=2 and we_count=4 are the controls: completion is still signalled exactly once
per transaction and all 2×2 words still reach the BRAM.

End-to-end, real multilayer_sequencer + real weight_prefetch_ctrl, two layers
(re-run against the sequencer as changed by #2337):

emitter overlap_cycles layer_start_during_prefetch
master (defective) 1 1
this PR 0 0

Layer 1 was starting compute while its own weights were still being written into the weight
BRAM. That is the overlap in the issue title.

The defect shipped under a green check

Master's own 22 unit tests all pass on the defective emitter (22 passed; 0 failed).

And they are not run in CI at all. No workflow invokes cargo test -p t27c;
corpus-ratchet.yml records that the step was removed by #2292 after going red on master
(1602 passed / 13 failed / 2 ignored). So the guards added here are proved locally and are
not executed by any required check. That gap is pre-existing and repo-wide — recorded here
rather than papered over, and deliberately not "fixed" in this PR, since the same comment
warns that re-adding it as a plain gate lands red and gets disabled.

Not fixed here, filed separately

#1985 reports a second defect: multilayer_sequencer uses a level-triggered handshake and
cannot distinguish "done already" from "done still". That is a different module
(bootstrap/src/bitnet_pipeline.rs) and a different change, so it is not bundled here.

The end-to-end measurement above shows this fix alone closes the observable overlap for that
consumer, but the handshake remains edge-insensitive by construction and is worth hardening
on its own. Filed as a follow-up rather than folded into this diff.

Notes

  • Local pre-commit could not run: it calls scripts/tri check-now, which exits 1 with
    tri: t27c not found because no t27c binary is built in this worktree (the wrapper
    probes four paths, none present). That is a missing build artifact, not a policy
    violation. The gate's substantive assertion was verified instead by running the CI script
    itself, unmodified — scripts/ci/now-sync-gate-diff.sh — which needs no binary:
    NOW sync gate passed: docs/now/2026-08-21-prefetch-done-stale-flag.md (UTC window: 2026-08-20 .. 2026-08-22)

Closes #1985

weight_prefetch_ctrl raised prefetch_done in DONE_ST and returned to
IDLE, but cleared the flag only inside the `if (start_prefetch)` guard.
The clear is a non-blocking assignment, so it lands one cycle after the
request is sampled -- and multilayer_sequencer tests prefetch_done in
WAIT_PF, the same cycle it holds start_prefetch high. The next requester
therefore read the previous transaction's completion and ran its layer
against weights still being written into the weight BRAM.

Emit the clear in the IDLE arm ahead of the guard, so prefetch_done is
genuinely the one-cycle pulse the module doc-comment already described.
No other signal, state or port changed.

Guarded by prefetch_done_retired_in_idle_before_start_guard, which
slices the IDLE case arm out of the emitted text and requires the clear
to precede the guard. The assertion is anchored to that arm on purpose:
the reset block also contains `prefetch_done <= 1'b0;`, so an unanchored
contains check passes on the defective emitter.

Closes #1985
@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 15:48:14 UTC

Summary

Status Count
Total Open PRs 5
PRs with Failing Checks 1
PRs with All Checks Green 4
READY 1
FAILING 1
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 enabled auto-merge (squash) August 21, 2026 15:48
@gHashTag
gHashTag merged commit b178010 into master Aug 21, 2026
24 of 27 checks passed
gHashTag pushed a commit that referenced this pull request Aug 22, 2026
#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
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>
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 567 — weight-BRAM overlap closed: stale completion flag + missing req/ack handshake

2 participants