Double-buffered processing
Double-buffered (or ping-pong) processing sits between block and streaming on the overlap continuum. It overlaps at block granularity: while block n is loading, block n-1 is being computed and block n-2 is being stored.
load(n) ∥ process(n-1) ∥ store(n-2)
All three stages run at once on different blocks, so once the pipeline fills, the time per block
is set by the slowest stage rather than the sum of all three — roughly
t_block ≈ max(load, process, store).
Why this flow needs a different shape
You cannot model this overlap inside one sequential coroutine. In block and streaming, the
whole timing model fits in a single run_proc because the stages really are sequential there. But
in a double-buffered flow the stages must run concurrently, and the yield from on
read_array / write_array genuinely advances the clock and serializes — the moment you
yield from a load, that coroutine is blocked until the load finishes, so nothing else in it can
overlap. A single coroutine cannot be in a load and a store at the same simulated instant.
So this flow requires actual concurrency: three separate SimPy processes — a loader, a compute stage, and a storer — handing blocks off to each other through bounded buffers.
The pattern
import math
import simpy
buf_in = simpy.Store(env, capacity=2) # ping-pong: capacity 2 == double buffer
buf_out = simpy.Store(env, capacity=2)
def loader():
for blk in blocks:
x = yield from mem.read_array(Float32, n, blk.xaddr, word_bw=word_bw)
yield buf_in.put(x) # blocks if the loader gets 2 blocks ahead
def compute_proc():
for blk in blocks:
x = yield buf_in.get()
y = compute(x)
m = math.ceil(n / self.unroll_factor)
yield self.timeout((self.latency + self.proc_ii * (m - 1)) * self.clk.period)
yield buf_out.put(y)
def storer():
for blk in blocks:
y = yield buf_out.get()
yield from mem.write_array(y, Float32, blk.yaddr, word_bw=word_bw)
The three functions are started as concurrent processes (e.g. self.process(loader()),
self.process(compute_proc()), self.process(storer())) so SimPy interleaves them.
A timeline figure for this page (deferred) would show three staggered rows — load, compute, store — each one block behind the row above, with the steady-state block period set by the tallest bar.
Key points
- The overlap is emergent, not computed. You do not calculate
t_block ≈ max(load, process, store)anywhere — it falls out of SimPy scheduling. Each stage simply does its work and blocks on the buffer hand-off; the discrete-event runtime resolves who waits for whom, and the steady-state throughput is whatever the slowest stage allows. This is the payoff of modeling with real processes instead of arithmetic. capacity=2is what makes it “double” buffered. The depth-2Storebounds how far ahead a producer may run: the loader can be at most one block ahead of compute beforebuf_in.putblocks. That one block of look-ahead is exactly the second buffer.- The capacity is the knob across the whole continuum.
capacity=1removes the look-ahead and collapses this back to block processing (load and compute can no longer overlap).capacity=∞is unbounded buffering (the loader races ahead with no back-pressure).capacity=2is the canonical ping-pong middle ground.
Worked example: the matrix-LT FIR
The Rowwise FIR example walks this end-to-end (model, hook, cosim, calibration); this section is the summary.
examples/rowwise_fir (FIRAccel) is a shipped, cosim-
calibrated realization of this model — a per-matrix-row FIR. It keeps the three-process shape
(load / compute / store started in pre_sim, handing off through transaction_queues) but
sharpens three things over the bare simpy.Store pattern above:
- Per-direction channel resources, not one bus — owned by the interconnect. Bus contention,
occupancy timing, and duplex are properties of the contended memory port, so they live on the
interconnect/slave (
MMIFSlave.read_channel/write_channel, independent capacity-1 resources), reached by the element-coordinate slice calls through the interconnect (it decodes the address to the serving slave). An AXI bundle is full-duplex, so a read and a write never contend — that is the default. A slave that genuinely shares R/W bandwidth (a single-port BRAM, or a DDR model) declares itselfhalf_duplex=True, re-coupling the two channels onto one resource. The component never wires bus contention by hand and owns no channel — it supplies only the access pattern (num_trans,nwords) at the slice call. - Element-coordinate pipelined transfers. Load and store use
read_slice_pipelined/write_slice_pipelinedwithnum_trans = n_row(one burst per row) instead ofread_array/write_array; the per-burst span comes from the serving slave’s calibrated bus timing (MMIFSlave.bus_timing), configured once per platform at wire-up. - The store hides under compute. The Y-write is early-anchored and given
min_span = compute_body, so it occupies the write channel formax(write_occ, compute_body)— finishing under compute’s shadow when compute is the bottleneck. This is the double-bufferedmax(load, compute, store)made explicit on the store side (and it is the latency fix that lets the Y-write overlap the X-read).
The payoff of calibrating this model is that the whole-kernel latency decomposes into physical, near-fit-free terms:
whole = (n_col + T) + trips + n_row·row_depth(n_col) + fill_const
└ fill └ II=1 └ per-row pipeline └ one
(first row) compute (the one fitted curve) scalar
where trips = n_row·(n_col − T + 1). The channel occupancy is deterministic (one transfer beat
per word), the compute body is exactly II=1, and the only fitted term is row_depth(n_col) —
the per-row ping-pong refill depth, a saturating lookup. How those terms are measured from a cosim
sweep and fit is Fitting a timing model and the Calibration section; the
full end-to-end walkthrough is the rowwise_fir example.
Where to log critical events
To validate a double-buffered model against RTL you compare event timelines, so the model emits a
timestamped event at each stage boundary. FIRAccel logs seven per command (_log in
fir.py):
| event | logged at | bus-visible in RTL? |
|---|---|---|
cmd_arrive |
the loader pulls the command | no — the anchor (t = 0) for the comparison |
load_begin / load_end |
around the X-read | yes — the X-read burst span |
comp_begin |
compute starts (carries compute_body) |
no — sim-internal |
store_begin / store_end |
around the Y-write | yes — the Y-write burst span |
resp_sent |
the response burst after Y | yes |
The rule of thumb: log a begin/end pair around every transfer (the bus-visible spans), plus the
command arrival as the anchor. The bus-visible events are exactly what the VCD burst extractor
recovers from cosim, so you anchor both timelines at cmd_arrive and compare each later event’s
offset — the comparison that drives the calibration gates.
Synthesis mapping
This page teaches an LT timing model, not a synthesizable structure. The three internal SimPy processes are a sim-only device: their only job is to produce the
max(load, compute, store)timeline. They use nothing new — threeself.process(...)coroutines and twosimpy.Stores (ortransaction_queues) — so the model works today, with no added framework capability.
There are two synthesizable realizations of this overlap, and the FIR example takes the first:
-
A single hand-written
#pragma HLS DATAFLOWhook (shipped). One@synthesizablekernel whose body is the three sub-functions — load, compute, store — wired in a per-block DATAFLOW region over a partitioned-BRAM ping-pong and anhls::streamFIFO. This is exactly the Dataflow custom-hook pattern, and it is howrowwise_firsynthesizes today. It needs no new framework capability — the hook is the whole kernel. -
Hierarchical multi-
HwComponentcomposition (deferred). The same overlap expressed in Waveflow as a parent component owning three dataflow sub-components with shared top-level PIPO buffers andhls::streamhandshakes — codegen lowering the sub-graph to the DATAFLOW region. That is gated on hierarchical composition (the accelerator-anatomy convergence work) and is not in this docs pass; until then, hand-write the hook (path 1).
See also
- Block processing —
capacity=1collapses double-buffering back to this. - Streaming processing — finer-grained overlap (per element) when the source delivers a continuous stream rather than discrete blocks.
- Dataflow custom hook — the synthesizable side: the three sub-functions in a
#pragma HLS DATAFLOWregion (the FIR realization). - Fitting a timing model / Calibration — how the matrix-LT FIR’s terms (occupancy, compute,
row_depth) are measured from cosim and fit. - Hardware Components — declaring components and their ports; the home of the future hierarchical-composition milestone (synthesis path 2).
- Process generators — spawning concurrent SimPy processes, the mechanism this flow relies on.
examples/rowwise_fir— the worked, cosim-calibrated matrix-LT FIR.