Skip to content

Incremental propagation patch - #34

Open
Kipa5577 wants to merge 5 commits into
davidcastells:mainfrom
Kipa5577:patch3
Open

Incremental propagation patch#34
Kipa5577 wants to merge 5 commits into
davidcastells:mainfrom
Kipa5577:patch3

Conversation

@Kipa5577

@Kipa5577 Kipa5577 commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Summary

Three related fixes to the cycle-based simulation core (Simulator.propagateAll()/_clk_cycle() and Wire.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 of clk(), once again inline at the tail of _clk_cycle() — unconditionally, regardless of whether anything actually changed since the last call:

def propagateAll(self):
    for obj in self.propagatables:
        obj.propagate()

For a ~4000-leaf design that's 8000 propagate() calls per clk(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:

  1. Wire.put()/Wire.settle() now record a wire into a new Wire.dirty set whenever the value they're about to commit actually differs from the wire's current value.
  2. 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 in self.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).
  3. Simulator._clk_cycle()'s previously-duplicated unconditional loop is replaced with a call to self.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 across propagate() 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 a self.lastins field 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's propagate() source once via ast (cached per class) and treats any class containing a self.<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() does self._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 in Wire.prepare()

The problem

Every clock()-based (sequential) component — registers, FSM state, memory-write outputs — commits its next value via wire.prepare(val), which has always guarded against double-preparing the same wire in one cycle with:

def prepare(self, val: int):
    if self in Wire.prepared:      # O(n) linear scan
        print('WARNING! wire {} already prepared'.format(self.getFullPath()))
    ...
    Wire.prepared.append(self)

Wire.prepared is a class-level list — every wire prepared so far in the entire design this cycle, not per-wire — so the in check is a linear scan re-run on every single prepare() call: O(k) per call, O(k²) over a full cycle's k prepares. Independent of the propagation-side cost above — this is prepare()'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_lookup set, kept in lockstep with the existing Wire.prepared list (populated alongside it in prepare(), cleared alongside it in settleAll() — and BidirWire.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 to Wire.prepared itself 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-walked propagateAll() (only touch what actually needs evaluation)

The problem

Depends on 14c33ba already being applied — this is specifically what's left over after that fix, not a replacement for it.

After incremental propagation, propagateAll() correctly computes a needs_eval set 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 obj in self.propagatables:      # every leaf in the WHOLE design, every call
    if obj not in needs_eval:       # O(1) check, but still runs n times
        continue
    obj.propagate()

For a 4000-leaf design where needs_eval is 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 by cProfile tottime on 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):

  • On the first full-pass call, also caches self._topo_index: a {object: position} map giving each propagatable's index in self.propagatables' existing topological order.
  • On every later call, seeds a min-heap with (topo_index[obj], obj) for each object in needs_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 identical final_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.

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.

1 participant