MemMgr — allocation and addressing

MemMgr answers two questions and stores nothing:

  • Where does this go? — first-fit placement (place).
  • What word index is this address? — the addressing convention (addr_to_index / index_to_addr).

That is the whole class. It models the manager — an OS allocator, a linker script, a hand-rolled arena — as distinct from the memory being managed.

from waveflow.hw.memory import AddrUnit, MemMgr

mgr = MemMgr(word_size=32, nwords_tot=1024, addr_unit=AddrUnit.byte)
mgr.index_to_addr(4)      # 16  — four 32-bit words in
mgr.addr_to_index(16)     # 4
mgr.place(4, [(0, 8), (16, 32)])    # 8 — first-fit lands in the gap

Addressing: the byte-vs-word convention

AddrUnit selects what an address means, and this is the only place the convention is implemented:

addr_unit an address is… typical use
AddrUnit.byte a byte offset AXI4 / DDR / PYNQ interfaces
AddrUnit.word a word index local arrays, BRAM-like interfaces

Byte addressing divides by the word’s byte width and enforces alignment — a misaligned address raises rather than silently truncating:

MemMgr(word_size=64, addr_unit=AddrUnit.byte).addr_to_index(4)
# ValueError: Address 4 is not aligned to the word size of 8 bytes.

Word addressing is the identity. It models hardware that indexes words directly, which is what an HLS local array does.

A word size that is not a whole number of bytes is rejected for byte addressing, because the conversion is not defined — again, loudly rather than by rounding.

Placement: first-fit over ranges you supply

def place(self, nwords: int, occupied: list[tuple[int, int]]) -> int

occupied is a sorted list of half-open (start_index, end_index) ranges. place walks them and returns the first index where nwords fits, or raises MemoryError if nothing fits inside nwords_tot.

The signature is the design decision worth explaining: the manager is handed the occupancy rather than tracking it. A manager with its own allocation table would be a second source of truth about what is occupied, and the two could drift — a freed segment the manager still believes is live, or vice versa. Handing it the live ranges makes disagreement structurally impossible, at the cost of one argument.

The consequence is visible in behaviour: freeing a segment immediately reopens its gap to the next placement, because the next call sees the new occupancy.

mem = Memory(word_size=32, nwords_tot=64)
a = mem.alloc(4); mem.alloc(4)
mem.free(a)
mem.alloc(4) == a          # True — first-fit found the reopened gap

How Memory uses it

Memory keeps the bytes and delegates the policy. Its alloc is now two clearly separate steps — where (the manager) and what (the storage):

next_free = self.mgr.place(nwords, self._segment_bounds())
addr = self.mgr.index_to_addr(next_free)
self.segments[addr] = np.zeros(...)

Every existing alloc / free / read / write call site is unaffected; what changed is that the policy now has a name.

The C++ side

The name is shared on purpose. The generated Vitis testbench uses MemMgr<word_dwidth> from memmgr_tb.hpp, and kernels use memmgr::byte_addr_to_word_index<mem_dwidth> for the same byte→word conversion. Because both sides implement the same first-fit rule, addresses computed in Python are valid in the C++ testbench — which is what lets a Python model and an HLS kernel agree on a layout without either restating it. How an m_axi port itself is emitted is Endpoint interfaces.

What it is not

  • Not storage. It has no segments, no arrays, no bytes. If you want to hold data, you want MemoryMod (or the Memory it wraps).
  • Not timed. Allocation is elaboration-time bookkeeping, not a simulated transaction.
  • Not a hardware object. Nothing is emitted for it. It is the model of whoever decided the layout, which in a real system is software.