Primitive interfaces

A primitive interface is one that lowers to a real HLS construct. It is not built out of anything else in this section — it is the bottom of the stack, and everything in Derived interfaces is a transaction pattern layered on top of one of these.

Primitives divide by position, which is a column rather than a folder because the same reader needs both in one list:

  test pages
Boundary becomes a port on the generated kernel, and has a kind_of_endpoint kind Stream · MM · BRAM · Register map
Internal lowers to an HLS construct that exists only inside the kernel Stream-of-blocks · Crossbar

The distinction matters when reading about lowering and nowhere else: a StreamIFSlave at a boundary is an axis_in port, and the same endpoint on an internal edge is an hls::stream FIFO. See Interfaces for the tier table this table refines, and Interface lowering for the boundary-port emitter.

Pages

What an access method says

An endpoint’s access methods vary along two independent dimensions, and almost every question about “why is this method called that” is really a question about which dimension you are looking at.

What is transferred — three options, and no more:

  stream m_axi
raw words get() read(nwords, addr)
one schema instance get_schema(T) read_schema(T, addr)
an array of T get_array(T, count=N) read_array(T, N, addr)

The verb differs because the semantics do — a stream get is a destructive dequeue and an m_axi read is an addressed look, which is the distinction the access vocabulary draws. The suffix is the same on both sides, because what is transferred is the same question either way.

When it happens relative to other work — the access cases below: non-overlapping, overlapping, or in place.

The stream used to dispatch on arguments, and no longer does. One get returned three different types depending on how it was called — Words, an instance, or a DataArray — because the typed path was added to the existing method rather than given names of its own. That was the one place the vocabulary was inconsistent for no reason, and it cost more than tidiness: the codegen extractor matches methods structurally, by name, so get(T) and get(T, count=N) were indistinguishable to it and the array form silently lowered to a single-element read. The three names above are what fixed it. The same split has not reached AXIMMQueue.get, which still dispatches on its arguments; that is unfinished work rather than a considered difference.

The pipelined forms take no payload suffix, and that is the one exception to the rule above. get_pipelined(T, count=N) — and read_pipelined / write_pipelined on the other endpoints — are always array transfers, because the saving is proportional to the words moved. There is no schema-only form for a suffix to distinguish them from, which is why read_array_pipelined lost its _array_ infix rather than the others gaining one.

The access cases

An endpoint’s access vocabulary is not a menu of spellings. Every operation falls into one of three cases, and the case is decided by what physically happens and therefore what owns the time. All three are essential; collapsing any of them models a cost the hardware does not have.

  Non-overlapping transfer Overlapping (pipelined) transfer In place
StreamIF get / write get_pipelined / write_pipelined — no addressing
MMIF read/write_schema, read/write_array *_pipelined, *_anchored, *_spanned — every access is a bus transaction
BramIF not built read_pipelined / write_pipelined array_ref
HwState — already local not built

Non-overlapping transfer. Data physically moves into an internal structure, and the call elapses the whole of it — nothing downstream starts until it finishes. The model is block timing.

Overlapping (pipelined) transfer. Two transfers proceed at once — reading one endpoint while writing another — so the pair costs max(a, b) rather than a + b. The model is streaming timing. Pipelined ops are array operations: the saving is proportional to the words moved, so there is no schema-only form.

x, tstart = yield from self.s_in.get_pipelined(Float32, count=n)
y = <numpy over the whole array>              # no element loop anywhere
yield from self.buf_w.write_pipelined(y, addr, tstart)

⚠️ The overlap is yours to declare, and nothing checks it. tstart is the whole mechanism: a read hands back the cycle its first word arrived, and write_pipelined(data, t_start) treats the write as having begun then, shortening its wait if t_start is already past. Anchor it correctly and you get max(a, b); anchor it wrongly and you get a number that is confidently wrong, because no gate compares your anchor against anything. In bram_access, anchoring the memory write at the payload read’s tstart is the entire reason a firing costs max(stream, memory) — and adding that anchor moved the model’s own predictions by a real cycle per command. If you are unsure what to anchor to, read streaming timing before guessing.

In place. Unique to directly-addressable storage, and the reason is timing, not copies. A kernel computing against a BRAM transfers nothing — in C++ it is foo(&buf[addr], n), reading and writing the memory through its port. Modelling that as a read, a compute and a write invents two transfers that do not exist and charges the design for them. A stream has no addressing and every m_axi access is a bus transaction, so BramIF and HwState are the only two citizens.

x = self.buf.array_ref(addr, n)      # a LIVE view -- nothing moved, no simulated time passed

Nothing there elapses time on its own, and that is the point: the caller owns the timing, because the cost is the compute loop’s II x n rather than a transfer. The port publishes the rate to compute from, and enforces the two rules that keep a reference honest — see BramIF.

Vectorized Python, looped HLS, timing carried by the model. These cases are what make that work: a design body moves whole vectors and the interface supplies the cycles, while the generated C++ keeps its #pragma HLS PIPELINE II=1 loop. A per-element for in a pysim body is a defect rather than a fidelity feature — it opts the design out of the model. examples/stream_inband’s PolyAccel is the reference, and bram_access is the same shape over a memory.

Cells marked not built are filled as each case ships; see plans/typed_transfer_codec.md. (BramIF’s non-overlapping transfer has no caller yet, which is why it is deliberately last.)

All three cases in one design: A memory reached three ways is the worked example. WRITE is a non-overlapping transfer into the memory, COMPUTE is in place over it, READ is an overlapping transfer out of it — and because WRITE and COMPUTE share one port on one task, the difference between moving a word and computing on it in place is a measurement in one waveform rather than an argument.

The access vocabulary: three verbs, three meanings

The three cases above say what physically happens. This says what the verb is called, and the point of the table is that the differences are deliberate. A reader meeting get on a stream beside read on an m_axi port naturally assumes one of them is a leftover; neither is.

Verb Means Where What it costs the source
get a destructive dequeue — the item is gone from the channel StreamIFSlave, CreditStreamSlaveIF the item; nobody else can read it
read an addressed look, non-destructive — read the same address twice and get the same answer MMIFMaster, BramIFMaster nothing; the storage is unchanged
acquire a lease, with a matching release SobIFMaster (acquire_write / commit_write), SobIFSlave (acquire_read / release_read) exclusive use of the block until it is released

So get is not an older spelling of read. A queue has no addresses to re-read and a memory has nothing to consume, and a lease is neither: it hands out a region for a while and takes it back. Rename any one of them to the others and the page stops being able to say which of the three a call does.

The same distinction is why the pipelined forms are spelled the way they are: StreamIFSlave.get_pipelined beside BramIFMaster.read_pipelined and MMIFMaster.read_pipelined — one convergent _pipelined suffix, and the verb in front of it still carries the meaning above.

_nb is the non-blocking suffix, and offer is the deliberate exemption

A transfer that returns “nothing available” or “no room” instead of blocking carries _nb: get_nb, read_nb, write_nb, write_resp_nb, read_frame_nb.

Two suffixes stack, payload first and semantics lastget_schema_nb, get_array_nb. The order is not arbitrary: the payload suffix says what comes back, so it belongs beside the verb it qualifies, and _nb says when, which is a property of the call rather than of the data.

StreamIFMaster.offer does the same thing and keeps its own name, because the two exist for opposite reasons and the asymmetry is real:

  who declines to wait what a refusal means
get_nb a consumer that must not wait — one polling a progress channel, where empty means “no news”, not “stop” try again later; nothing was lost
offer a producer that physically cannot wait — a data converter presents a beat whether or not the fabric is ready the words that did not fit are gone, and StreamIF.dropped counts them

_nb says the caller chose not to wait, so a short answer is that caller’s business to retry. offer says the producer had no choice, so there is no retry and the loss is a fact about the run rather than a return value. Filing both under one suffix would hide that.

Two things that look like exceptions and are not. can_write_frame is a predicate, not a transfer — a predicate never blocks, so the suffix would carry no information; what it gates (write_frame) does block, and is correspondingly not _nb. And poll_credit, offer_credit, harvest and send_status on the reverse channels are all non-blocking but named for what they do, because “non-blocking” is already implied by the channel they run on.


Table of contents

  • Stream Interfaces - The point-to-point StreamIF and the four ways to move data over one — a raw word, n raw words, a typed array, or a schema — each with the in-kernel HLS call it corresponds to. Then the cases those four do not cover: a producer that cannot be back-pressured, pipelined get/write timing, and a runnable producer→consumer toy.
  • MM Interfaces - Memory-mapped interfaces in the SimPy model — MMIFMaster/MMIFSlave endpoints, the AXIMMCrossBarIF (FULL/LITE, address routing) and DirectMMIF, and read/write/read_schema/read_array, with a runnable two-SimObj DirectMMIF toy.
  • BRAM — memory between modules - BramIF connects a kernel task to an on-chip memory that lives OUTSIDE the kernel, as hand-written Verilog joined by a generated wrapper. Explains why a memory shared between two tasks cannot live inside a Vitis kernel — with the PIPO and dataflow-check evidence — and why a BramIF is registered with add_rtl_if rather than add_if, which is what keeps the accessor's port a boundary port.
  • Register Maps - The AXI-Lite register map as an interface — RegField / RegAccess (R, W, RW, W1C, W1S), the auto-assigned offset table, the RegMapMMIFSlave read/write dispatch, composite and bit-packed fields, and the per-transaction hook contract. axilite_slave is its own kind_of_endpoint boundary kind. The launch lifecycle layered on top of it — VitisRegMap's ap_ctrl_hs and the BoundRegMap host surface — is a separate page.
  • Stream-of-Blocks Interface - What Stream-of-Blocks (SOB) is in isolation: a block-granular handoff (DataArray[T, N]) with acquire/commit/release semantics over a depth-2 ping-pong buffer. Unlike a FIFO, it has two control paths — a block-ready channel forward and a buffer-free channel backward — which is why it needs four calls, not put/get.
  • Crossbar Interfaces - The n-input x m-output switching fabric — CrossBarIF, its routing function, and a runnable 2x2 example. Split out of the stream page, which is about the point-to-point StreamIF and the four ways to move data over one.