Polling Overhead (poll_until)
A master that polls a memory word — a queue consumer watching a ring tail, a core spinning on a doorbell, a driver waiting on a status flag — is not free. It imposes two distinct costs on the shared bus:
- Bandwidth steal. Every poll read consumes bus beats. A master polling every N cycles steals a
1/Nfraction of the bus’s throughput from everyone else on it, whether or not the watched event has happened yet. - Discovery latency. After the watched event becomes true, the poller does not see it until its next poll. On average that is half a poll interval of delay between the event and its discovery.
Waveflow models both costs with MMIFMaster.poll_until, in O(transactions) — it never actually loops the simulation every poll cycle. This page describes the model; the full design rationale and decision record is in plans/poll_until_lt_model.md.
This is the loosely-timed (LT) twin of a real hardware poll loop (the C++ ring-poll
while (head == tail) tail = gmem[...]).poll_untilis also@synthesizable: the same call lowers to that C++ poll loop — see The synthesizable twin below. Most of this page documents the simulation model; the timing parameters (poll_interval,poll_beat_cost,discovery) are loosely-timed concerns and have no hardware meaning.
The primitive
from waveflow.hw.memif import Eq, Ne
value = yield from m_mem.poll_until(addr, cond, poll_interval) # poll_interval in CYCLES
poll_until blocks until cond holds on a poll of addr, then returns the satisfying word value. Conceptually the master reads addr every poll_interval cycles and checks cond; the simulation does not issue a timed read every interval — it applies the aggregate model below.
| Parameter | Meaning |
|---|---|
addr |
Global byte address of the watched word. |
cond |
A restricted PollCond — Eq(rhs) or Ne(rhs). |
poll_interval |
Poll period in cycles. |
poll_beat_cost |
(keyword, default 1) bus beats charged per poll read. |
discovery |
(keyword, default "mean") discovery-delay model; only "mean" in v1. |
The condition (PollCond)
cond is a small declarative value, not an arbitrary Python lambda. It is restricted to equality against a single word — which is exactly what real polls are (tail != head, status == DONE):
Eq(1) # read(addr) == 1
Ne(head) # read(addr) != head (head is a value read earlier)
The rhs may be a literal or a value captured from a prior runtime read. The restriction is deliberate: the same PollCond object is the single source of truth that a future extractor will lower to the C++ poll loop, so the sim and the hardware poll can never drift apart (design decision D4). cond.eval(value) is the simulation form.
The model
1. Bandwidth steal — the per-bus occupancy ov
For the duration of a poll_until wait, the master registers as an active poller on the bus (the slave) it polls. Each active poller i contributes a fraction of the bus’s beats:
ov = Σ poll_beat_cost[i] / poll_interval[i] (summed per shared slave/bus)
Real (non-poll) transactions on that bus have their per-word occupancy stretched by 1/(1-ov) — the effective bandwidth left for everyone else is 1-ov. Crucially, only the per-word term is derated: the fixed init / address / return-wire latency is untouched. Concretely, in AXIMMCrossBarIF a FULL read costs
latency_init + slave access + latency_read_return + nwords / (1 - ov)
└─ not derated ──────────────────────────────────────┘ └─ derated ─┘
ov is per-bus and time-varying: it is summed per slave (so independent ports never derate each other) and evaluated at each transaction’s time, because the active-poller set changes as poll_until calls begin and end. The crossbar owns this registry because it has the global, per-bus topology view the derating needs. DirectMMIF supports pollers (so a master wired point-to-point can still poll_until) but does not derate — there is no contention on a point-to-point link.
2. Discovery latency — the event-to-next-poll gap
Once cond becomes true, poll_until adds the average gap between the event and the next poll:
discovery delay = (poll_interval - 1) / 2 cycles (the deterministic mean)
This is deterministic by default (decision D1). Waveflow’s verification spine — byte-identical sim_timeline.json baselines, the “metrics hold” gate, the calibration regression — depends on reproducible timelines, so the default is the mean, never bare randomness. A seeded-stochastic mode (U[0, poll_interval-1], seeded from the Simulation) is reserved but not exposed in v1.
Saturation: ov ≥ 1 is clamped and warned
When polling alone would consume the whole bus, 1/(1-ov) diverges. A 1-cycle poll is already ov = 1. Rather than producing garbage timing, the model clamps ov to 0.99 (a visible ~100× per-word stretch that unmistakably reads as “polling-bound”) and emits one loud warning per bus per saturation onset (decision D2):
UserWarning: AXIMMCrossBarIF '...': bus 'slave_0' is polling-bound (ov=1.000 >= 1);
clamping to 0.99 (per-word occupancy stretched ~100x). A poll interval this aggressive
saturates the bus merely checking the condition.
This is a feature: the model flags the exact 1-cycle-poll mistake that a coarse fixed poll period would silently hide.
Making a slave pollable
poll_until needs an untimed snapshot of the watched word while it waits — its bus cost is already charged aggregately through ov, so the peek itself must take zero sim time. A slave advertises this by setting peek_read:
self.slave_ep = MMIFSlave(
sim=self.sim, bitwidth=32,
rx_write_proc=self.rx_write,
rx_read_proc=self.rx_read,
peek_read=self.peek, # (nwords, local_addr) -> Words, untimed snapshot
)
MemComponent populates peek_read automatically. A slave without one cannot be polled (poll_until raises). The blocked poller does not spin: it waits on a per-slave write-notify the interconnect fires after every write, then re-peeks — so there is no lost wakeup and no per-cycle stepping.
Worked example
examples/interface/poll_demo.py isolates each cost in its own scenario over one shared FULL slave:
Producer (master_0) ──┐
Poller (master_1) ──┼── AXIMMCrossBarIF ──── Ram (slave_0, FULL)
Burster (master_2) ──┘
A — discovery latency. The poller poll_until(flag, Eq(1), poll_interval=8)s a flag the producer sets later. The flag is observed exactly (8-1)/2 = 3.5 cycles after the producer’s write completes — the deterministic mean:
producer set flag=1 at t=13.143
poller observed it at t=16.643 (value=1)
discovery delay = 3.500 cycles (expected (poll_interval-1)/2 = 3.500)
(The producer’s own write lands a little late, too — the poller’s ov derates the very write it is waiting on, since both touch the same slave.)
B — bandwidth steal. A 64-word burst read is timed with and without a concurrent active poller (poll_interval=4, so ov=0.25):
burst (64 words) alone: 69.000 cycles
burst + poller (interval=4, ov=0.25): 90.333 cycles
The per-word term stretches by 1/(1-0.25) = 1.333× (64 → 85.33 words of time); the total grows from 69 to 90.3 — only 1.31×, because the init/access latency is not derated.
C — saturation. A 1-cycle poll (ov = 1) trips the clamp-and-warn.
Run it:
python examples/interface/poll_demo.py
VMAC integration
AXIMMQueue.get’s empty-ring wait is built on poll_until: the consumer polls the ring tail with poll_until(tail_addr, Ne(head), poll_interval). This retired the old coarse poll_cycles=64 band-aid — a poll interval now means something. An aggressive consumer poll shows up as derated bus throughput (and, if too aggressive, the saturation warning), not merely shifted dequeue times, so the cycle-model calibration can reflect real polling cost.
The synthesizable twin
poll_until is a legitimate hardware primitive too — a master spinning on a memory word. The same poll_until call that runs the LT model in simulation is @synthesizable: inside a synthesizable run_proc, the extractor lowers it to a C++ poll loop over the m_axi pointer, the hardware twin of the sim form. (The AXI-MM command-queue example shows the same lowering for the ring-dequeue hook it shares this primitive with.)
# inside a synthesizable run_proc — the consumer waits for the ring to fill
head = yield from self.cmd_queue.get(self.Cmd) # a prior runtime read
tail = yield from self.m_mem.poll_until(tail_addr, Ne(head), poll_interval)
lowers to a call into a small reusable C++ primitive (waveflow/build/poll_until_impl.tpp):
// poll word `tail_addr` of gmem until (value != head); return the satisfying value
ap_uint<MEM_BW> tail = poll_until_impl::poll_until_ne<MEM_BW>(
m_mem, memmgr::byte_addr_to_word_index<MEM_BW>(tail_addr), (ap_uint<MEM_BW>)head);
What lowers and what does not:
- The
PollCondis the lowerable subset.Eq(x)/Ne(x)map to the==/!=C++ poll; therhsmay be a constant (Eq(1)) or a runtime value already in scope (Ne(head), whereheadis a prior read). Supporting a runtime-variable rhs is the condition-IR extension this step added — the same capability now also lets a synthesizableif a != b:compare two runtime locals. - The timing parameters do not lower.
poll_interval,poll_beat_cost, anddiscoveryare AT-model (loosely-timed) concerns — a hardware poll just spins on the word — so they never appear in the generated C++.
This is one poll loop, not two: AXIMMQueue.get’s ring-dequeue hook (aximm_queue_impl::queue_get) expresses its own while (head == tail) non-empty wait as poll_until_ne(tail_word, head), so the standalone poll_until and the queue dequeue share the same primitive.
See also
- MM Interfaces — the
MMIFMaster/AXIMMCrossBarIFendpoints and the FULL/LITE latency modelpoll_untilplugs into. - Overview — the
Wordstype and the SimPy transaction lifecycle. - AXI-MM Command Queue example — the ring-dequeue hook (
queue_get) this poll primitive is shared with, and its cosim calibration. plans/poll_until_lt_model.md— the design record (decisions D1–D4) and the synthesizable twin.