Incremental propagation patch - #34
Open
Kipa5577 wants to merge 5 commits into
Open
Conversation
Added SubBorrowIn class to implement borrow logic in arithmetic operations.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Three related fixes to the cycle-based simulation core (
Simulator.propagateAll()/_clk_cycle()andWire.prepare()), moving it from unconditional full re-evaluation on every clock cycle to real event-driven simulation. Found and fixed while profiling a ~4000-leaf structural design (a gate-level-decomposed CPU) whose instruction-set test suite originally took ~1244s for 87 tests; these three commits account for the majority of that time.Each commit is independently useful and independently verified, but they build on each other — see "Commit-by-commit" below for the exact dependency.
Net effect on the test suite this was built against: 87 tests, identical (bit-for-bit)
final_result/test_case/cycle-count values before and after, ~19x overall wall-clock speedup.14c33ba— Incremental propagation (event-driven "dirty wire" simulation)The problem
Simulator.clk(1)calls a full propagate pass over every propagatable leaf in the design twice per invocation — once explicitly at the top ofclk(), once again inline at the tail of_clk_cycle()— unconditionally, regardless of whether anything actually changed since the last call:For a ~4000-leaf design that's 8000
propagate()calls perclk(1), every single time, even when most of the design is frozen (a reset pulse holding a whole subsystem static, or simply because most of the design isn't the part "doing something" on a given cycle — the normal case for most designs most of the time). Benchmarked on a synthetic 4000-leaf design with 2000 idle cycles (nothing changing after the first): 2.86s → 0.004s (~700x) after this fix.What changed
Standard event-driven simulation:
Wire.put()/Wire.settle()now record a wire into a newWire.dirtyset whenever the value they're about to commit actually differs from the wire's current value.Simulator.propagateAll()now only re-.propagate()s leaves transitively downstream of a dirty wire (discovered via each wire's.sinks, already populated by py4hw), walked inself.propagatables' existing topological order — one forward pass suffices, since a newly-dirtied wire always belongs to a leaf later in that same order. The very first call still does one full pass (to establish correct initial values, matching the original behavior for that call).Simulator._clk_cycle()'s previously-duplicated unconditional loop is replaced with a call toself.propagateAll(), so both call sites get the same benefit instead of one silently bypassing the other.The correctness subtlety (read this before assuming it's "just" a perf patch)
This whole scheme assumes every
propagate()is a pure function of its current input wire values — true for ordinary combinational gates, but not for a component that keeps state acrosspropagate()calls (e.g. for edge detection), whose output can depend on its own history rather than just its current inputs. Skipping such a component just because its wires didn't change is wrong.This isn't hypothetical — an earlier version of this fix, without accounting for it, stalled a real CPU test permanently mid-instruction, because one real component's
propagate()kept aself.lastinsfield for edge detection and silently depended on "re-evaluate everything every cycle" as an implicit one-tick delay.The fix: rather than hand-maintaining a list of exempt classes (fragile against future additions to any codebase built on this),
propagateAll()inspects each class'spropagate()source once viaast(cached per class) and treats any class containing aself.<name> = ...assignment as "impure" — those instances are always fully re-evaluated regardless of dirty-wire state, exactly matching the original behavior for them, while every genuinely pure leaf gets the dirty-wire skip.Verified directly with a small synthetic component whose
propagate()doesself._n += 1(state kept across calls): confirmed it keeps advancing every cycle even though its only input wire never changes after the first cycle — i.e., the impure-class detection is actually catching this case, not just working by luck on the one real component that surfaced it originally.b542bc8— O(1) duplicate-prepare check inWire.prepare()The problem
Every
clock()-based (sequential) component — registers, FSM state, memory-write outputs — commits its next value viawire.prepare(val), which has always guarded against double-preparing the same wire in one cycle with:Wire.preparedis a class-level list — every wire prepared so far in the entire design this cycle, not per-wire — so theincheck is a linear scan re-run on every singleprepare()call: O(k) per call, O(k²) over a full cycle's k prepares. Independent of the propagation-side cost above — this isprepare()'s own overhead, scaling with how many sequential elements a design has. Benchmarked with 800 registers over 300 cycles: 0.93s → 0.078s (~12x).What changed
A new
Wire._prepared_lookupset, kept in lockstep with the existingWire.preparedlist (populated alongside it inprepare(), cleared alongside it insettleAll()— andBidirWire.settleAll(), for symmetry). The duplicate check becomes an O(1) set lookup instead of the O(n) list scan. Same warning text, same externally-observable behavior, no API or type change toWire.prepareditself or anything that reads it elsewhere — purely a change to the check mechanism.Independent of the incremental-propagation commit above: different methods in the same file (
prepare()/settleAll()vs.put()/settle()/propagateAll()), no overlap, safe to apply in either order relative to it.ed32270— Heap-walkedpropagateAll()(only touch what actually needs evaluation)The problem
Depends on
14c33baalready being applied — this is specifically what's left over after that fix, not a replacement for it.After incremental propagation,
propagateAll()correctly computes aneeds_evalset of only the objects transitively downstream of a dirty wire — but the loop that actually calls.propagate()on them still walks every propagatable leaf in the whole design, once per call, just to skip most of them:For a 4000-leaf design where
needs_evalis typically a few dozen to a few hundred objects on any given cycle, this is a 4000-element scan to find a handful of items — confirmed the single hottest function bycProfiletottimeon a representative test after the other two fixes were already in place (called ~10800 times, each call still doing the full scan). Benchmarked with 4000 total leaves, only 1 dirty per cycle: 0.45s → 0.015s (~30x).What changed
Replaces the "scan everything, skip most" loop with a min-heap walk that processes only the objects actually in
needs_eval, in the same topological order as before (so call order and final state are identical — only the walk mechanism changes):self._topo_index: a{object: position}map giving each propagatable's index inself.propagatables' existing topological order.(topo_index[obj], obj)for each object inneeds_eval, then repeatedly pops the lowest-index (earliest-in-topological-order) entry,.propagate()s it, and pushes any newly-dirtied downstream object not yet visited.O(k log k) for k objects needing evaluation, instead of O(n) for n total leaves — the two converge when k≈n (a cycle where most of the design is active), but diverge sharply whenever k≪n, which is the common case for any design with meaningfully localized per-cycle activity.
Verification
All three benchmarked independently (synthetic designs built purely from stock py4hw primitives —
Buf,Reg— no dependency on the original project this was found in), before/after, with the numbers above. All three re-verified together, cumulatively, against the original 87-test instruction-set simulation suite: 87/87 passing, with identicalfinal_result/test_case/cycle-count values to the unpatched baseline for every test — not just "still passes," bit-for-bit identical simulated behavior, which is the bar that actually matters for a performance-only change to a simulator.