Memory Modeling

Storage in Waveflow is not one class, and the axis that sorts it is not the obvious one:

The scope of sharing determines the category — not the size, and not the lifetime.

A four-word FIFO and a megabyte of DDR are different categories because of who can reach them, not because of how big they are. A tap array that persists for the life of the design and a temporary that lives for one firing are the same category if only one module can see them.

# category mechanism who picks the storage class
1 local temporaries, one module plain Python / plain C++ Vitis, from the body
2 persistent, one module HwState Vitis + directives
3 between modules, inside the top BramIF + a memory module the designer — it is hand-written RTL
4 outside the top AXI-MM (MemoryMod, MemMgr) the platform
5 channel storage StreamIF.depth Vitis, from the pragma
6 block handoff stream_of_blocks Vitis — implicitly, if you share an array between tasks

Categories 5 and 6 are the ones a reader is most likely to be surprised to find here, and both earned their place the hard way.

A FIFO is memory, and it is the storage most designs have most of. StreamIF.depth lives under Interfaces because a channel is how you use it — which is precisely why nobody noticed for a long time that a boundary port’s declared depth is silently discarded (Vitis gives a top-level argument the default depth of 2 whatever you write). If you think of depth as memory, you ask where it is; if you think of it as a channel attribute, you do not.

Category 6 is created whether or not you asked for it. Share a local array between two hls::task bodies and Vitis builds a ping-pong buffer with a synchronizing handshake — and the handshake stalls the writer:

INFO: [HLS 200-741] Implementing PIPO rx_buf_r_RAM_T2P_BRAM_1R1W using a single memory

That is a storage decision the tool made for you, in silence, and you should meet it here rather than in a netlist. It is also the reason category 3 exists at all — see BRAM: memory between modules.

Category 1 needs one sentence, or you will get it wrong

The Python model is not a storage spec. A numpy array in run_iter need not correspond to anything in the RTL; only functional behaviour is contracted. RfSampIngress is the worked example: it takes a whole burst per firing in pysim and relays one word per firing in hardware, and both are correct because the words that come out are the words that went in.

So do not read a local array in a Python body as “this design has a buffer”. If storage is part of what you mean, say so with one of the other five categories.

Choosing between them

The decision is almost always about which boundary the storage sits inside.

Does one module own it, and does it have to survive between firings? HwState — filter taps, an accumulator, a line buffer. It becomes a static array inside the generated kernel. No protocol, no transactions: a hook receives it and indexes it.

Do two modules need to reach the same words, concurrently? That is category 3, and it is the one with a sharp edge: it cannot live inside a Vitis kernel at all. The memory becomes hand-written Verilog beside the kernel, joined by a generated wrapper. The designer owns the correctness argument — the tool will not arbitrate for you, and it will not warn you.

Is the storage on the far side of a bus? MemoryMod, reached over m_axi — transactions, latency, contention with other masters. In a real system this is DDR; in simulation a MemoryMod; in a generated XSI testbench the arena the AXI-MM slave models serve out of.

Are you deciding where things go rather than storing them? MemMgr — the allocator. It answers “where does this fit” and “what word index is this address”, and it holds no bytes at all.

One neighbour worth naming so you do not reach for it by mistake: a regmap field is what the host writes over AXI-Lite. Neither HwState nor MemoryMod is host-visible; a regmap is the control-plane story, not the storage story.

Region is not a seventh category. It is a cross-cutting access view — element coordinates over word storage — and it applies to several of the above.

What each backend actually models

Neither backend is uniformly better, and knowing which is which stops you trusting the wrong one:

category pysim XSI (RTL)
5 · FIFO depth honours the declaration everywhere real only for internal channels; a boundary port’s depth is discarded
4 · AXI-MM BusCalib timing and crossbar contention an un-arbitrated FlatMemory — no contention modelled
3 · BRAM faithful (untimed: deterministic access) faithful (the memory’s published read latency)

pysim is the better memory-system model; XSI is the better fabric model. The AXI-MM row is the one that bites: a design whose masters fight for bandwidth looks clean under XSI and honest under pysim, while a design whose boundary FIFO is deeper than 2 looks fine in pysim and is not.

What csynth counts

category counted by csynth of the kernel?
1 · local temporaries yes
2 · HwState yes
3 · memory beside the kernel no — it is outside the kernel entirely
4 · AXI-MM no — it is off-chip
5 · channel storage yes
6 · block handoff yes

Two of the six are invisible to the report, and category 3’s absence is total rather than approximate: a design with a 1024×16 buffer beside its kernel reports no BRAM at all. That is why a structural block declares its own footprint (depth × width maps to a primitive count by geometry), and why the wrapper is the design scope a resource estimate can be defined against. Logic blocks cannot declare theirs and need a run — the same line as who owns the correctness argument, one level up.

One rule worth knowing up front

MemMgr is handed the occupied ranges rather than tracking them. The byte store stays the single source of truth about what is occupied, so the manager and the storage can never disagree — a parallel allocation table would be a drift bug waiting to happen. The visible consequence is that freeing a region reopens its gap to the very next allocation.

The name is shared with the C++ side on purpose: the generated testbench uses MemMgr<word_dwidth> (memmgr_tb.hpp) and kernels use the waveflow::memmgr namespace for the same conversions.

Pages

  • HwState — category 2: storage inside a module, what it emits, partitioning.
  • MemMgr — allocation and addressing, including the byte-vs-word convention.
  • MemoryMod — category 4: the transactional, timed memory, its latency model, and the Memory store underneath it.
  • Streaming Memory KernelsMemRStream / MemWStream / MemCopy.

Category 3’s own page is not written yet; until it is, BRAM — memory between modules is the reference, with A module realized as Verilog for how the memory is declared.

Underneath categories 3 and 4 sits Memory, the sparse byte container. It is a plain Python object, not a SimObj — the same category as DataSchema or Region. That is deliberate: in Waveflow SimObj means “participates in the discrete-event simulation”, and a bag of bytes does not. MemoryMod is what makes a Memory a participant.

Two neighbouring topics live outside this section on purpose, because they are not about storage: how an endpoint becomes an HLS port (m_axi, axis, s_axilite) is Endpoint interfaces, and packing typed arrays into words is Array serialization.

Runnable toys for HwState, MemMgr and MemoryMod live in examples/memory/ and are executed by tests/examples/test_memory_demos.py, so the code on these pages cannot silently rot.


Table of contents

  • HwState — storage inside a module - HwState is storage a hardware module owns: codegen emits it as a static array inside the kernel, so it persists across firings and is synthesizable. It has no transactional interface — a hook receives it and indexes it directly. Declaring it with add_state is also what tells the extractor that a self.X read is deliberate persistent storage rather than an accidental capture.
  • MemMgr — allocation and addressing - MemMgr is the allocator and the address arithmetic, and it owns no bytes at all — it models what an OS, a linker script, or a hand-rolled arena does. It converts between caller addresses and word indices (byte or word units, with alignment checking) and runs first-fit placement over the occupied ranges it is handed. It is handed those ranges rather than tracking them, so it can never disagree with the storage.
  • MemoryMod — storage across a bus - MemoryMod is a SimObj that wraps a Memory and exposes AXI-MM endpoints, so storage on the far side of a bus becomes a real simulation participant with modelled access latency. Access latency (the memory) and bus latency (the interconnect) compose rather than being double-counted. In a generated XSI testbench it maps to the FlatMemory arena the AXI-MM slave models serve out of.
  • Streaming Memory Kernels