Python simulation
Python simulation is fast and easy to debug, so we run the histogram in SimPy
before ever invoking Vitis. This page shows the simulation harness — the host
controller that drives one transaction, the MemComponent that stands in for
shared DRAM, and how the kernel-produced counts are checked against the numpy
golden. The harness is run_sim() in
examples/shared_mem/hist.py.
What a SimPy run needs
Unlike the regmap example, where the host talks to the kernel over a single AXI-Lite link, the histogram has two participants on three interfaces: a host/controller that issues the command and stages the data, the accelerator, and a shared memory both reach. The harness instantiates all three and wires them together:
# examples/shared_mem/hist.py — run_sim
def run_sim(data, bin_edges, nbins, *, clk_freq=1e9, tx_id=7, addr_misalign=0):
sim = Simulation()
clk = Clock(freq=clk_freq)
mem = MemComponent(name="mem", sim=sim, inline=False, clk=clk)
accel = HistAccel(name="hist_accel", sim=sim, clk=clk)
ctrl = HistController(name="hist_ctrl", sim=sim, mem=mem,
data=data, bin_edges=bin_edges, nbins=nbins, tx_id=tx_id,
addr_misalign=addr_misalign)
connect(sim, ctrl, accel, mem, clk)
sim.run_sim()
expected = golden_counts(data, bin_edges, nbins)
return HistResult(counts=ctrl.counts, expected=expected, status=ctrl.resp.status)
Three SimObjs share one Simulation and one Clock:
MemComponentis the shared memory — a SimPy model of a byte-addressed word memory behind an AXI-MM slave endpoint. It is where thedata,bin_edges, andcountsbuffers live; both the controller (to stage inputs and read results) and the accelerator (to do the work) reach it overm_axi.inline=Falsemakes it its own scheduled process rather than a passive object, so its accesses take simulated time on the clock.HistAccelis the accelerator under test.HistControlleris the host stand-in — the SimObj that plays the CPU driver.
The controller — one transaction, end to end
HistController.run_proc is the host-side sequence the concept page
laid out: allocate the three regions, stage the inputs, issue the command, await
the response, read the counts back.
# examples/shared_mem/hist.py — HistController.run_proc
# Allocate the three regions, in order (data, edges, counts).
data_nwords = get_nwords(Float32, word_bw=self.mem.word_size, shape=ndata)
edge_nwords = get_nwords(Float32, word_bw=self.mem.word_size, shape=max(nedges, 1))
count_nwords = get_nwords(Uint32Field, word_bw=self.mem.word_size, shape=nbins)
self.data_addr = self.mem.alloc(data_nwords)
self.edge_addr = self.mem.alloc(edge_nwords)
self.count_addr = self.mem.alloc(count_nwords)
# Populate the input buffers (TB-side memory access).
yield from self.mem.m_mm.write_array(self.data, Float32, self.data_addr, word_bw=bw)
if nedges > 0:
yield from self.mem.m_mm.write_array(self.bin_edges, Float32, self.edge_addr, word_bw=bw)
# Issue the command and await the response.
cmd = HistCmd(tx_id=self.tx_id,
data_addr=self.data_addr + self.addr_misalign,
bin_edges_addr=self.edge_addr, ndata=ndata, nbins=nbins,
cnt_addr=self.count_addr)
yield from self.m_cmd.write(cmd)
resp_words = yield from self.s_resp.get()
self.resp = HistResp().deserialize(resp_words, word_bw=bw)
# Read the kernel-produced counts back.
out = yield from self.mem.m_mm.read_array(Uint32Field, nbins, self.count_addr, word_bw=bw)
self.counts = np.asarray(out, dtype=np.uint32)
The details that matter:
- Allocation order is the contract. The controller allocs
data, thenedges, thencounts— the same order the testbench will, so the three regions land at the same byte addresses on both sides.MemComponenthands out non-overlapping regions; the controller stamps the returned addresses into the command. - The edges alloc is clamped to
max(nedges, 1). Whennbins == 1there are zero edges, but a memory region still needs a valid base address — so the controller reserves one word it never writes. (The generated testbench does the same clamp; seeCODEGEN_NOTES.md.) addr_misalignis a test hook. Adding a byte offset todata_addrlets a test drive the kernel’sADDRESS_ERRORpath without any other change — the command now points one byte off a word boundary.
Wiring it together
connect() builds the three links — two StreamIFs for the command/response
control and one DirectMMIF for the shared memory — and binds each port to its
peer:
# examples/shared_mem/hist.py — connect
def connect(sim, ctrl, accel, mem, clk):
in_stream = StreamIF(sim=sim, clk=clk)
out_stream = StreamIF(sim=sim, clk=clk)
mem_link = DirectMMIF(sim=sim, clk=clk, byte_addressable=True)
in_stream.bind( "master", ctrl.m_cmd); in_stream.bind( "slave", accel.s_in)
out_stream.bind("master", accel.m_out); out_stream.bind("slave", ctrl.s_resp)
mem_link.bind( "master", accel.m_mem); mem_link.bind( "slave", mem.s_mm)
DirectMMIF(byte_addressable=True) is the in-process AXI-MM link: it carries the
accelerator’s read_array / write_array bursts to the memory’s slave endpoint,
converting the command’s byte addresses to word indices exactly as real
hardware does. The controller reaches the same memory through its own mem.m_mm
master — so in the model, host and kernel genuinely share one memory, addressed
identically.
Parity against the golden
The point of the SimPy run is to confirm the accelerator’s logic before
synthesis. run_sim returns a HistResult carrying both the observed counts and
the numpy golden, and passed is the conjunction of status is NO_ERROR and
counts match:
# examples/shared_mem/hist.py
@dataclass
class HistResult:
counts: npt.NDArray[np.uint32]
expected: npt.NDArray[np.uint32]
status: HistError
@property
def passed(self) -> bool:
return (self.status == HistError.NO_ERROR
and np.array_equal(self.counts, self.expected))
The golden is golden_counts — bin = #{edges <= sample}, then count per bin —
the same routine the compute hook uses, so the model is checked against a
second, independent numpy implementation of the binning rule.
The parity tests in
tests/examples/test_hist_sim.py
sweep several (ndata, nbins) vectors and the three failure modes:
res = run_sim(ht.data, ht.bin_edges, nbins=nbins, tx_id=seed)
assert res.status == HistError.NO_ERROR
np.testing.assert_array_equal(res.counts, ht.counts) # vs the HistogramAccel golden
Plus one test each for INVALID_NDATA (ndata out of range), INVALID_NBINS
(nbins out of range), and ADDRESS_ERROR (driven by addr_misalign=1) — so the
validation→status path is exercised in simulation, not just asserted in prose.
Timing in the SimPy run
The SimPy simulation is discrete-event on the clock: every stream transfer and every memory burst advances simulated time, so a run is not just functional — it produces a transaction timeline (command in → reads → compute → write → response out). That timeline is a first, cheap estimate of how long a transaction takes.
The simulation is cycle-approximate, though, not cycle-exact: the real burst
latencies, the m_axi handshake overhead, and the pipeline fill only show up once
the design is synthesized to RTL. The next page hands the generated kernel to
Vitis to get those measured numbers, and the timing page renders the
RTL waveform and the multi-buffer burst layout that the SimPy model is
approximating.
Next
- Code generation — lowering
HistAccel(and the testbench) to Vitis HLS C++. - C and RTL simulation — running the Vitis flows against the generated artifacts.