diff --git a/amaranth/_utils.py b/amaranth/_utils.py index 35adcf9fc..938461527 100644 --- a/amaranth/_utils.py +++ b/amaranth/_utils.py @@ -76,16 +76,6 @@ def decorator_like(*args, **kwargs): return decorator_like -def extend(cls): - def decorator(f): - if isinstance(f, property): - name = f.fget.__name__ - else: - name = f.__name__ - setattr(cls, name, f) - return decorator - - def get_linter_options(filename): first_line = linecache.getline(filename, 1) if first_line: diff --git a/amaranth/lib/fifo.py b/amaranth/lib/fifo.py index 768b6c60d..5b8be9cae 100644 --- a/amaranth/lib/fifo.py +++ b/amaranth/lib/fifo.py @@ -6,6 +6,7 @@ from ..utils import ceil_log2 from .cdc import FFSynchronizer, AsyncFFSynchronizer from .memory import Memory +from . import stream __all__ = ["FIFOInterface", "SyncFIFO", "SyncFIFOBuffered", "AsyncFIFO", "AsyncFIFOBuffered"] @@ -93,6 +94,22 @@ def __init__(self, *, width, depth): self.r_en = Signal() self.r_level = Signal(range(depth + 1)) + @property + def w_stream(self): + w_stream = stream.Signature(self.width).flip().create() + w_stream.payload = self.w_data + w_stream.valid = self.w_en + w_stream.ready = self.w_rdy + return w_stream + + @property + def r_stream(self): + r_stream = stream.Signature(self.width).create() + r_stream.payload = self.r_data + r_stream.valid = self.r_rdy + r_stream.ready = self.r_en + return r_stream + def _incr(signal, modulo): if modulo == 2 ** len(signal): diff --git a/amaranth/lib/stream.py b/amaranth/lib/stream.py new file mode 100644 index 000000000..2cbf0d422 --- /dev/null +++ b/amaranth/lib/stream.py @@ -0,0 +1,122 @@ +from ..hdl import * +from .._utils import final +from . import wiring +from .wiring import In, Out + + +@final +class Signature(wiring.Signature): + """Signature of a unidirectional data stream. + + .. note:: + + "Minimal streams" as defined in `RFC 61`_ lack support for complex payloads, such as + multiple lanes or packetization, as well as introspection of the payload. This limitation + will be lifted in a later release. + + .. _RFC 61: https://amaranth-lang.org/rfcs/0061-minimal-streams.html + + Parameters + ---------- + payload_shape : :class:`~.hdl.ShapeLike` + Shape of the payload. + always_valid : :class:`bool` + Whether the stream has a payload available each cycle. + always_ready : :class:`bool` + Whether the stream has its payload accepted whenever it is available (i.e. whether it lacks + support for backpressure). + + Members + ------- + payload : :py:`Out(payload_shape)` + Payload. + valid : :py:`Out(1)` + Whether a payload is available. If the stream is :py:`always_valid`, :py:`Const(1)`. + ready : :py:`In(1)` + Whether a payload is accepted. If the stream is :py:`always_ready`, :py:`Const(1)`. + """ + def __init__(self, payload_shape: ShapeLike, *, always_valid=False, always_ready=False): + Shape.cast(payload_shape) + self._payload_shape = payload_shape + self._always_valid = bool(always_valid) + self._always_ready = bool(always_ready) + + super().__init__({ + "payload": Out(payload_shape), + "valid": Out(1), + "ready": In(1) + }) + + # payload_shape intentionally not introspectable (for now) + + @property + def always_valid(self): + return self._always_valid + + @property + def always_ready(self): + return self._always_ready + + def __eq__(self, other): + return (type(other) is type(self) and + other._payload_shape == self._payload_shape and + other.always_valid == self.always_valid and + other.always_ready == self.always_ready) + + def create(self, *, path=None, src_loc_at=0): + return Interface(self, path=path, src_loc_at=1 + src_loc_at) + + def __repr__(self): + always_valid_repr = "" if not self._always_valid else ", always_valid=True" + always_ready_repr = "" if not self._always_ready else ", always_ready=True" + return f"stream.Signature({self._payload_shape!r}{always_valid_repr}{always_ready_repr})" + + +@final +class Interface: + """A unidirectional data stream. + + Attributes + ---------- + signature : :class:`Signature` + Signature of this data stream. + """ + + payload: Signal + valid: 'Signal | Const' + ready: 'Signal | Const' + + def __init__(self, signature: Signature, *, path=None, src_loc_at=0): + if not isinstance(signature, Signature): + raise TypeError(f"Signature of stream.Interface must be a stream.Signature, not " + f"{signature!r}") + self._signature = signature + self.__dict__.update(signature.members.create(path=path, src_loc_at=1 + src_loc_at)) + if signature.always_valid: + self.valid = Const(1) + if signature.always_ready: + self.ready = Const(1) + + @property + def signature(self): + return self._signature + + @property + def p(self): + """Shortcut for :py:`self.payload`. + + This shortcut reduces repetition when manipulating the payload, for example: + + .. code:: + + m.d.comb += [ + self.o_stream.p.result.eq(self.i_stream.p.first + self.i_stream.p.second), + self.o_stream.valid.eq(self.i_stream.valid), + self.i_stream.ready.eq(self.o_stream.ready), + ] + """ + return self.payload + + def __repr__(self): + return (f"stream.Interface(payload={self.payload!r}, valid={self.valid!r}, " + f"ready={self.ready!r})") \ No newline at end of file diff --git a/docs/changes.rst b/docs/changes.rst index 88bd6b2c6..d6bcb4872 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -29,23 +29,23 @@ Migrating from version 0.4 Apply the following changes to code written against Amaranth 0.4 to migrate it to version 0.5: +* Update uses of :py:`reset=` keyword argument to :py:`init=`. +* Ensure all elaboratables are subclasses of :class:`Elaboratable`. * Replace uses of :py:`m.Case()` with no patterns with :py:`m.Default()`. * Replace uses of :py:`Value.matches()` with no patterns with :py:`Const(1)`. -* Update uses of :py:`amaranth.utils.log2_int(need_pow2=False)` to :func:`amaranth.utils.ceil_log2`. -* Update uses of :py:`amaranth.utils.log2_int(need_pow2=True)` to :func:`amaranth.utils.exact_log2`. -* Update uses of :py:`reset=` keyword argument to :py:`init=`. -* Convert uses of :py:`Simulator.add_sync_process` used as testbenches to :meth:`Simulator.add_testbench `. -* Convert other uses of :py:`Simulator.add_sync_process` to :meth:`Simulator.add_process `. -* Convert simulator processes and testbenches to use the new async API. -* Replace uses of :py:`amaranth.hdl.Memory` with :class:`amaranth.lib.memory.Memory`. +* Ensure clock domains aren't used outside the module that defines them, or its submodules; move clock domain definitions upwards in the hierarchy as necessary * Replace imports of :py:`amaranth.asserts.Assert`, :py:`Assume`, and :py:`Cover` with imports from :py:`amaranth.hdl`. * Remove uses of :py:`name=` keyword argument of :py:`Assert`, :py:`Assume`, and :py:`Cover`; a message can be used instead. -* Ensure all elaboratables are subclasses of :class:`Elaboratable`. -* Ensure clock domains aren't used outside the module that defines them, or its submodules; move clock domain definitions upwards in the hierarchy as necessary -* Remove uses of :py:`amaranth.lib.coding.*` by inlining or copying the implementation of the modules. +* Replace uses of :py:`amaranth.hdl.Memory` with :class:`amaranth.lib.memory.Memory`. * Update uses of :py:`platform.request` to pass :py:`dir="-"` and use :mod:`amaranth.lib.io` buffers. +* Remove uses of :py:`amaranth.lib.coding.*` by inlining or copying the implementation of the modules. +* Convert uses of :py:`Simulator.add_sync_process` used as testbenches to :meth:`Simulator.add_testbench `. +* Convert other uses of :py:`Simulator.add_sync_process` to :meth:`Simulator.add_process `. +* Convert simulator processes and testbenches to use the new async API. * Update uses of :meth:`Simulator.add_clock ` with explicit :py:`phase` to take into account simulator no longer adding implicit :py:`period / 2`. (Previously, :meth:`Simulator.add_clock ` was documented to first toggle the clock at the time :py:`phase`, but actually first toggled the clock at :py:`period / 2 + phase`.) * Update uses of :meth:`Simulator.run_until ` to remove the :py:`run_passive=True` argument. If the code uses :py:`run_passive=False`, ensure it still works with the new behavior. +* Update uses of :py:`amaranth.utils.log2_int(need_pow2=False)` to :func:`amaranth.utils.ceil_log2`. +* Update uses of :py:`amaranth.utils.log2_int(need_pow2=True)` to :func:`amaranth.utils.exact_log2`. Implemented RFCs @@ -55,6 +55,7 @@ Implemented RFCs .. _RFC 27: https://amaranth-lang.org/rfcs/0027-simulator-testbenches.html .. _RFC 30: https://amaranth-lang.org/rfcs/0030-component-metadata.html .. _RFC 36: https://amaranth-lang.org/rfcs/0036-async-testbench-functions.html +.. _RFC 42: https://amaranth-lang.org/rfcs/0042-const-from-shape-castable.html .. _RFC 39: https://amaranth-lang.org/rfcs/0039-empty-case.html .. _RFC 43: https://amaranth-lang.org/rfcs/0043-rename-reset-to-init.html .. _RFC 45: https://amaranth-lang.org/rfcs/0045-lib-memory.html @@ -65,6 +66,7 @@ Implemented RFCs .. _RFC 55: https://amaranth-lang.org/rfcs/0055-lib-io.html .. _RFC 58: https://amaranth-lang.org/rfcs/0058-valuecastable-format.html .. _RFC 59: https://amaranth-lang.org/rfcs/0059-no-domain-upwards-propagation.html +.. _RFC 61: https://amaranth-lang.org/rfcs/0061-minimal-streams.html .. _RFC 62: https://amaranth-lang.org/rfcs/0062-memory-data.html .. _RFC 63: https://amaranth-lang.org/rfcs/0063-remove-lib-coding.html .. _RFC 65: https://amaranth-lang.org/rfcs/0065-format-struct-enum.html @@ -74,6 +76,7 @@ Implemented RFCs * `RFC 30`_: Component metadata * `RFC 36`_: Async testbench functions * `RFC 39`_: Change semantics of no-argument ``m.Case()`` +* `RFC 42`_: ``Const`` from shape-castable * `RFC 43`_: Rename ``reset=`` to ``init=`` * `RFC 45`_: Move ``hdl.Memory`` to ``lib.Memory`` * `RFC 46`_: Change ``Shape.cast(range(1))`` to ``unsigned(0)`` @@ -83,6 +86,7 @@ Implemented RFCs * `RFC 55`_: New ``lib.io`` components * `RFC 58`_: Core support for ``ValueCastable`` formatting * `RFC 59`_: Get rid of upwards propagation of clock domains +* `RFC 61`_: Minimal streams * `RFC 62`_: The ``MemoryData`` class * `RFC 63`_: Remove ``amaranth.lib.coding`` * `RFC 65`_: Special formatting for structures and enums @@ -103,6 +107,7 @@ Language changes * Changed: :py:`Value.matches()` with no patterns is :py:`Const(0)` instead of :py:`Const(1)`. (`RFC 39`_) * Changed: :py:`Signal(range(stop), init=stop)` warning has been changed into a hard error and made to trigger on any out-of range value. * Changed: :py:`Signal(range(0))` is now valid without a warning. +* Changed: :py:`Const(value, shape)` now accepts shape-castable objects as :py:`shape`. (`RFC 42`_) * Changed: :py:`Shape.cast(range(1))` is now :py:`unsigned(0)`. (`RFC 46`_) * Changed: the :py:`reset=` argument of :class:`Signal`, :meth:`Signal.like`, :class:`amaranth.lib.wiring.Member`, :class:`amaranth.lib.cdc.FFSynchronizer`, and :py:`m.FSM()` has been renamed to :py:`init=`. (`RFC 43`_) * Changed: :class:`Shape` has been made immutable and hashable. @@ -130,6 +135,7 @@ Standard library changes * Added: :class:`amaranth.lib.io.SingleEndedPort`, :class:`amaranth.lib.io.DifferentialPort`. (`RFC 55`_) * Added: :class:`amaranth.lib.io.Buffer`, :class:`amaranth.lib.io.FFBuffer`, :class:`amaranth.lib.io.DDRBuffer`. (`RFC 55`_) * Added: :mod:`amaranth.lib.meta`, :class:`amaranth.lib.wiring.ComponentMetadata`. (`RFC 30`_) +* Added: :mod:`amaranth.lib.stream`. (`RFC 61`_) * Deprecated: :mod:`amaranth.lib.coding`. (`RFC 63`_) * Removed: (deprecated in 0.4) :mod:`amaranth.lib.scheduler`. (`RFC 19`_) * Removed: (deprecated in 0.4) :class:`amaranth.lib.fifo.FIFOInterface` with :py:`fwft=False`. (`RFC 20`_) diff --git a/docs/stdlib.rst b/docs/stdlib.rst index 4fbc51068..01e203459 100644 --- a/docs/stdlib.rst +++ b/docs/stdlib.rst @@ -3,8 +3,8 @@ Standard library The :mod:`amaranth.lib` module, also known as the standard library, provides modules that falls into one of the three categories: -1. Modules that will used by essentially all idiomatic Amaranth code, or which are necessary for interoperability. This includes :mod:`amaranth.lib.enum` (enumerations), :mod:`amaranth.lib.data` (data structures), :mod:`amaranth.lib.wiring` (interfaces and components), and :mod:`amaranth.lib.meta` (interface metadata). -2. Modules that abstract common functionality whose implementation differs between hardware platforms. This includes :mod:`amaranth.lib.cdc`, :mod:`amaranth.lib.memory`. +1. Modules that will used by essentially all idiomatic Amaranth code, or which are necessary for interoperability. This includes :mod:`amaranth.lib.enum` (enumerations), :mod:`amaranth.lib.data` (data structures), :mod:`amaranth.lib.wiring` (interfaces and components), :mod:`amaranth.lib.meta` (interface metadata), and :mod:`amaranth.lib.stream` (data streams). +2. Modules that abstract common functionality whose implementation differs between hardware platforms. This includes :mod:`amaranth.lib.memory` and :mod:`amaranth.lib.cdc`. 3. Modules that have essentially one correct implementation and are of broad utility in digital designs. This includes :mod:`amaranth.lib.coding`, :mod:`amaranth.lib.fifo`, and :mod:`amaranth.lib.crc`. As part of the Amaranth backwards compatibility guarantee, any behaviors described in these documents will not change from a version to another without at least one version including a warning about the impending change. Any nontrivial change to these behaviors must also go through the public review as a part of the `Amaranth Request for Comments process `_. @@ -18,6 +18,7 @@ The Amaranth standard library is separate from the Amaranth language: everything stdlib/data stdlib/wiring stdlib/meta + stdlib/stream stdlib/memory stdlib/io stdlib/cdc diff --git a/docs/stdlib/_images/stream_pipeline.png b/docs/stdlib/_images/stream_pipeline.png new file mode 100644 index 000000000..89d90ef1a Binary files /dev/null and b/docs/stdlib/_images/stream_pipeline.png differ diff --git a/docs/stdlib/stream.rst b/docs/stdlib/stream.rst new file mode 100644 index 000000000..acdcf9a48 --- /dev/null +++ b/docs/stdlib/stream.rst @@ -0,0 +1,416 @@ +Data streams +------------ + +.. py:module:: amaranth.lib.stream + +The :mod:`amaranth.lib.stream` module provides a mechanism for unidirectional exchange of arbitrary data between modules. + + +Introduction +============ + +One of the most common flow control mechanisms is *ready/valid handshaking*, where a *producer* pushes data to a *consumer* whenever it becomes available, and the consumer signals to the producer whether it can accept more data. In Amaranth, this mechanism is implemented using an :ref:`interface ` with three members: + +- :py:`payload` (driven by the producer), containing the data; +- :py:`valid` (driven by the producer), indicating that data is currently available in :py:`payload`; +- :py:`ready` (driven by the consumer), indicating that data is accepted if available. + +This module provides such an interface, :class:`stream.Interface `, and defines the exact rules governing the flow of data through it. + + +.. _stream-rules: + +Data transfer rules +=================== + +The producer and the consumer must be synchronized: they must belong to the same :ref:`clock domain `, and any :ref:`control flow modifiers ` must be applied to both, in the same order. + +Data flows through a stream according to the following four rules: + +1. On each cycle where both :py:`valid` and :py:`ready` are asserted, a transfer is performed: the contents of ``payload`` are conveyed from the producer to the consumer. +2. Once the producer asserts :py:`valid`, it must not deassert :py:`valid` or change the contents of ``payload`` until a transfer is performed. +3. The producer must not wait for :py:`ready` to be asserted before asserting :py:`valid`: any form of feedback from :py:`ready` that causes :py:`valid` to become asserted is prohibited. +4. The consumer may assert or deassert :py:`ready` at any time, including via combinational feedback from :py:`valid`. + +Some producers and consumers may be designed without support for backpressure. Such producers must tie :py:`ready` to :py:`Const(1)` by specifying :py:`always_ready=True` when constructing a stream, and consumers may (but are not required to) do the same. Similarly, some producers and consumers may be designed such that a payload is provided or must be provided on each cycle. Such consumers must tie :py:`valid` to :py:`Const(1)` by specifying :py:`always_valid=True` when constructing a stream, and producers may (but are not required to) do the same. + +If these control signals are tied to :py:`Const(1)`, then the :func:`wiring.connect <.lib.wiring.connect>` function ensures that only compatible streams are connected together. For example, if the producer does not support backpressure (:py:`ready` tied to :py:`Const(1)`), it can only be connected to consumers that do not require backpressure. However, consumers that do not require backpressure can be connected to producers with or without support for backpressure. The :py:`valid` control signal is treated similarly. + +These rules ensure that producers and consumers that are developed independently can be safely used together, without unduly restricting the application-specific conditions that determine assertion of :py:`valid` and :py:`ready`. + + +Examples +======== + +The following examples demonstrate the use of streams for a data processing pipeline that receives serial data input from an external device, transforms it by negating the 2's complement value, and transmits it to another external device whenever requested. Similar pipelines, albeit more complex, are widely used in :abbr:`DSP (digital signal processing)` applications. + +The use of a unified data transfer mechanism enables uniform testing of individual units, and makes it possible to add a queue to the pipeline using only two additional connections. + +.. testsetup:: + + from amaranth import * + +.. testcode:: + + from amaranth.lib import stream, wiring + from amaranth.lib.wiring import In, Out + +The pipeline is tested using the :doc:`built-in simulator ` and the two helper functions defined below: + +.. testcode:: + + from amaranth.sim import Simulator + + async def stream_get(ctx, stream): + ctx.set(stream.ready, 1) + payload, = await ctx.tick().sample(stream.payload).until(stream.valid) + ctx.set(stream.ready, 0) + return payload + + async def stream_put(ctx, stream, payload): + ctx.set(stream.valid, 1) + ctx.set(stream.payload, payload) + await ctx.tick().until(stream.ready) + ctx.set(stream.valid, 0) + + +.. note:: + + "Minimal streams" as defined in `RFC 61`_ do not provide built-in helper functions for testing pending further work on the clock domain system. They will be provided in a later release. For the time being, you can copy the helper functions above to test your designs that use streams. + + +Serial receiver ++++++++++++++++ + +The serial receiver captures the serial output of an external device and converts it to a stream of words. While the ``ssel`` signal is high, each low-to-high transition on the ``sclk`` input captures the value of the ``sdat`` signal; eight consecutive captured bits are assembled into a word (:abbr:`MSB (most significant bit)` first) and pushed into the pipeline for processing. If the ``ssel`` signal is low, no data transmission occurs and the transmitter and the receiver are instead synchronized with each other. + +In this example, the external device does not provide a way to pause data transmission. If the pipeline isn't ready to accept the next payload, it is necessary to discard data at some point; here, it is done in the serial receiver. + +.. testcode:: + + class SerialReceiver(wiring.Component): + ssel: In(1) + sclk: In(1) + sdat: In(1) + + stream: Out(stream.Signature(signed(8))) + + def elaborate(self, platform): + m = Module() + + # Detect edges on the `sclk` input: + sclk_reg = Signal() + sclk_edge = ~sclk_reg & self.sclk + m.d.sync += sclk_reg.eq(self.sclk) + + # Capture `sdat` and bits into payloads: + count = Signal(range(8)) + data = Signal(8) + done = Signal() + with m.If(~self.ssel): + m.d.sync += count.eq(0) + with m.Elif(sclk_edge): + m.d.sync += count.eq(count + 1) + m.d.sync += data.eq(Cat(self.sdat, data)) + m.d.sync += done.eq(count == 7) + + # Push assembled payloads into the pipeline: + with m.If(done & (~self.stream.valid | self.stream.ready)): + m.d.sync += self.stream.payload.eq(data) + m.d.sync += self.stream.valid.eq(1) + m.d.sync += done.eq(0) + with m.Elif(self.stream.ready): + m.d.sync += self.stream.valid.eq(0) + # Payload is discarded if `done & self.stream.valid & ~self.stream.ready`. + + return m + +.. testcode:: + + def test_serial_receiver(): + dut = SerialReceiver() + + async def testbench_input(ctx): + await ctx.tick() + ctx.set(dut.ssel, 1) + await ctx.tick() + for bit in [1, 0, 1, 0, 0, 1, 1, 1]: + ctx.set(dut.sdat, bit) + ctx.set(dut.sclk, 0) + await ctx.tick() + ctx.set(dut.sclk, 1) + await ctx.tick() + ctx.set(dut.ssel, 0) + await ctx.tick() + + async def testbench_output(ctx): + expected_word = 0b10100111 + payload = await stream_get(ctx, dut.stream) + assert (payload & 0xff) == (expected_word & 0xff), \ + f"{payload & 0xff:08b} != {expected_word & 0xff:08b} (expected)" + + sim = Simulator(dut) + sim.add_clock(1e-6) + sim.add_testbench(testbench_input) + sim.add_testbench(testbench_output) + with sim.write_vcd("stream_serial_receiver.vcd"): + sim.run() + +.. testcode:: + :hide: + + test_serial_receiver() + +The serial protocol recognized by the receiver is illustrated with the following diagram (corresponding to ``stream_serial_receiver.vcd``): + +.. wavedrom:: stream/serial_receiver + + { + signal: [ + { name: "clk", wave: "lpppppppppppppppppppp" }, + {}, + [ + "serial", + { name: "ssel", wave: "01................0.." }, + { name: "sclk", wave: "0..101010101010101..." }, + { name: "sdat", wave: "0.=.=.=.=.=.=.=.=....", data: ["1", "0", "1", "0", "0", "0", "0", "1"] }, + ], + {}, + [ + "stream", + { name: "payload", wave: "=..................=.", data: ["00", "A7"] }, + { name: "valid", wave: "0..................10" }, + { name: "ready", wave: "1...................0" }, + ] + ] + } + + +Serial transmitter +++++++++++++++++++ + +The serial transmitter accepts a stream of words and provides it to the serial input of an external device whenever requested. Its serial interface is the same as that of the serial receiver, with the exception that the ``sclk`` and ``sdat`` signals are outputs. The ``ssel`` signal remains an input; the external device uses it for flow control. + +.. testcode:: + + class SerialTransmitter(wiring.Component): + ssel: In(1) + sclk: Out(1) + sdat: Out(1) + + stream: In(stream.Signature(signed(8))) + + def elaborate(self, platform): + m = Module() + + count = Signal(range(9)) + data = Signal(8) + + with m.If(~self.ssel): + m.d.sync += count.eq(0) + m.d.sync += self.sclk.eq(1) + with m.Elif(count != 0): + m.d.comb += self.stream.ready.eq(0) + m.d.sync += self.sclk.eq(~self.sclk) + with m.If(self.sclk): + m.d.sync += data.eq(Cat(0, data)) + m.d.sync += self.sdat.eq(data[-1]) + with m.Else(): + m.d.sync += count.eq(count - 1) + with m.Else(): + m.d.comb += self.stream.ready.eq(1) + with m.If(self.stream.valid): + m.d.sync += count.eq(8) + m.d.sync += data.eq(self.stream.payload) + + return m + +.. testcode:: + + def test_serial_transmitter(): + dut = SerialTransmitter() + + async def testbench_input(ctx): + await stream_put(ctx, dut.stream, 0b10100111) + + async def testbench_output(ctx): + await ctx.tick() + ctx.set(dut.ssel, 1) + for index, expected_bit in enumerate([1, 0, 1, 0, 0, 1, 1, 1]): + _, sdat = await ctx.posedge(dut.sclk).sample(dut.sdat) + assert sdat == expected_bit, \ + f"bit {index}: {sdat} != {expected_bit} (expected)" + ctx.set(dut.ssel, 0) + await ctx.tick() + + sim = Simulator(dut) + sim.add_clock(1e-6) + sim.add_testbench(testbench_input) + sim.add_testbench(testbench_output) + with sim.write_vcd("stream_serial_transmitter.vcd"): + sim.run() + +.. testcode:: + :hide: + + test_serial_transmitter() + + +Value negator ++++++++++++++ + +The value negator accepts a stream of words, negates the 2's complement value of these words, and provides the result as a stream of words again. In a practical :abbr:`DSP` application, this unit could be replaced with, for example, a :abbr:`FIR (finite impulse response)` filter. + +.. testcode:: + + class ValueNegator(wiring.Component): + i_stream: In(stream.Signature(signed(8))) + o_stream: Out(stream.Signature(signed(8))) + + def elaborate(self, platform): + m = Module() + + with m.If(self.i_stream.valid & (~self.o_stream.valid | self.o_stream.ready)): + m.d.comb += self.i_stream.ready.eq(1) + m.d.sync += self.o_stream.payload.eq(-self.i_stream.payload) + m.d.sync += self.o_stream.valid.eq(1) + with m.Elif(self.o_stream.ready): + m.d.sync += self.o_stream.valid.eq(0) + + return m + +.. testcode:: + + def test_value_negator(): + dut = ValueNegator() + + async def testbench_input(ctx): + await stream_put(ctx, dut.i_stream, 1) + await stream_put(ctx, dut.i_stream, 17) + + async def testbench_output(ctx): + assert await stream_get(ctx, dut.o_stream) == -1 + assert await stream_get(ctx, dut.o_stream) == -17 + + sim = Simulator(dut) + sim.add_clock(1e-6) + sim.add_testbench(testbench_input) + sim.add_testbench(testbench_output) + with sim.write_vcd("stream_value_negator.vcd"): + sim.run() + +.. testcode:: + :hide: + + test_value_negator() + + +Complete pipeline ++++++++++++++++++ + +The complete pipeline consists of a serial receiver, a value negator, a FIFO queue, and a serial transmitter connected in series. Without queueing, any momentary mismatch between the rate at which the serial data is produced and consumed would result in data loss. A FIFO queue from the :mod:`.lib.fifo` standard library module is used to avoid this problem. + +.. testcode:: + + from amaranth.lib.fifo import SyncFIFOBuffered + + class ExamplePipeline(wiring.Component): + i_ssel: In(1) + i_sclk: In(1) + i_sdat: In(1) + + o_ssel: In(1) + o_sclk: Out(1) + o_sdat: Out(1) + + def elaborate(self, platform): + m = Module() + + # Create and connect serial receiver: + m.submodules.receiver = receiver = SerialReceiver() + m.d.comb += [ + receiver.ssel.eq(self.i_ssel), + receiver.sclk.eq(self.i_sclk), + receiver.sdat.eq(self.i_sdat), + ] + + # Create and connect value negator: + m.submodules.negator = negator = ValueNegator() + wiring.connect(m, receiver=receiver.stream, negator=negator.i_stream) + + # Create and connect FIFO queue: + m.submodules.queue = queue = SyncFIFOBuffered(width=8, depth=16) + wiring.connect(m, negator=negator.o_stream, queue=queue.w_stream) + + # Create and connect serial transmitter: + m.submodules.transmitter = transmitter = SerialTransmitter() + wiring.connect(m, queue=queue.r_stream, transmitter=transmitter.stream) + + # Connect outputs: + m.d.comb += [ + transmitter.ssel.eq(self.o_ssel), + self.o_sclk.eq(transmitter.sclk), + self.o_sdat.eq(transmitter.sdat), + ] + + return m + +.. testcode:: + + def test_example_pipeline(): + dut = ExamplePipeline() + + async def testbench_input(ctx): + for value in [1, 17]: + ctx.set(dut.i_ssel, 1) + for bit in reversed(range(8)): + ctx.set(dut.i_sclk, 0) + ctx.set(dut.i_sdat, bool(value & (1 << bit))) + await ctx.tick() + ctx.set(dut.i_sclk, 1) + await ctx.tick() + await ctx.tick() + ctx.set(dut.i_ssel, 0) + ctx.set(dut.i_sclk, 0) + await ctx.tick() + + async def testbench_output(ctx): + await ctx.tick() + ctx.set(dut.o_ssel, 1) + for index, expected_value in enumerate([-1, -17]): + value = 0 + for _ in range(8): + _, sdat = await ctx.posedge(dut.o_sclk).sample(dut.o_sdat) + value = (value << 1) | sdat + assert value == (expected_value & 0xff), \ + f"word {index}: {value:08b} != {expected_value & 0xff:08b} (expected)" + await ctx.tick() + ctx.set(dut.o_ssel, 0) + + sim = Simulator(dut) + sim.add_clock(1e-6) + sim.add_testbench(testbench_input) + sim.add_testbench(testbench_output) + with sim.write_vcd("stream_example_pipeline.vcd"): + sim.run() + +.. testcode:: + :hide: + + test_example_pipeline() + +This data processing pipeline overlaps reception and transmission of serial data, with only a few cycles of latency between the completion of reception and the beginning of transmission of the processed data: + +.. image:: _images/stream_pipeline.png + +Implementing such an efficient pipeline can be difficult without the use of appropriate abstractions. The use of streams allows the designer to focus on the data processing and simplifies testing by ensuring that the interaction of the individual units is standard and well-defined. + + +Reference +========= + +Components that communicate using streams must not only use a :class:`stream.Interface `, but also follow the :ref:`data transfer rules `. + +.. autoclass:: Signature + +.. autoclass:: Interface diff --git a/pyproject.toml b/pyproject.toml index 080c3cc77..1d74683c6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -76,7 +76,7 @@ test = [ docs = [ "sphinx~=7.1", "sphinxcontrib-platformpicker~=1.3", - "sphinxcontrib-yowasp-wavedrom==1.6", # exact version to avoid changes in rendering + "sphinxcontrib-yowasp-wavedrom==1.7", # exact version to avoid changes in rendering "sphinx-rtd-theme~=2.0", "sphinx-autobuild", ] diff --git a/tests/test_lib_fifo.py b/tests/test_lib_fifo.py index 939fe8a7e..d7afd0e43 100644 --- a/tests/test_lib_fifo.py +++ b/tests/test_lib_fifo.py @@ -1,12 +1,11 @@ # amaranth: UnusedElaboratable=no -import warnings - from amaranth.hdl import * from amaranth.asserts import Initial, AnyConst from amaranth.sim import * from amaranth.lib.fifo import * from amaranth.lib.memory import * +from amaranth.lib import stream from .utils import * from amaranth._utils import _ignore_deprecated @@ -64,6 +63,20 @@ def test_async_buffered_depth_wrong(self): r"requested exact depth 16 is not$")): AsyncFIFOBuffered(width=8, depth=16, exact_depth=True) + def test_w_stream(self): + fifo = SyncFIFOBuffered(width=8, depth=16) + self.assertEqual(fifo.w_stream.signature, stream.Signature(8).flip()) + self.assertIs(fifo.w_stream.payload, fifo.w_data) + self.assertIs(fifo.w_stream.valid, fifo.w_en) + self.assertIs(fifo.w_stream.ready, fifo.w_rdy) + + def test_r_stream(self): + fifo = SyncFIFOBuffered(width=8, depth=16) + self.assertEqual(fifo.r_stream.signature, stream.Signature(8)) + self.assertIs(fifo.r_stream.payload, fifo.r_data) + self.assertIs(fifo.r_stream.valid, fifo.r_rdy) + self.assertIs(fifo.r_stream.ready, fifo.r_en) + class FIFOModel(Elaboratable, FIFOInterface): """ diff --git a/tests/test_lib_stream.py b/tests/test_lib_stream.py new file mode 100644 index 000000000..17ce59684 --- /dev/null +++ b/tests/test_lib_stream.py @@ -0,0 +1,135 @@ +from amaranth.hdl import * +from amaranth.lib import stream, wiring, fifo +from amaranth.lib.wiring import In, Out + +from .utils import * + + +class StreamTestCase(FHDLTestCase): + def test_nav_nar(self): + sig = stream.Signature(2) + self.assertRepr(sig, f"stream.Signature(2)") + self.assertEqual(sig.always_valid, False) + self.assertEqual(sig.always_ready, False) + self.assertEqual(sig.members, wiring.SignatureMembers({ + "payload": Out(2), + "valid": Out(1), + "ready": In(1) + })) + intf = sig.create() + self.assertRepr(intf, + f"stream.Interface(payload=(sig intf__payload), valid=(sig intf__valid), " + f"ready=(sig intf__ready))") + self.assertIs(intf.signature, sig) + self.assertIsInstance(intf.payload, Signal) + self.assertIs(intf.p, intf.payload) + self.assertIsInstance(intf.valid, Signal) + self.assertIsInstance(intf.ready, Signal) + + def test_av_nar(self): + sig = stream.Signature(2, always_valid=True) + self.assertRepr(sig, f"stream.Signature(2, always_valid=True)") + self.assertEqual(sig.always_valid, True) + self.assertEqual(sig.always_ready, False) + self.assertEqual(sig.members, wiring.SignatureMembers({ + "payload": Out(2), + "valid": Out(1), + "ready": In(1) + })) + intf = sig.create() + self.assertRepr(intf, + f"stream.Interface(payload=(sig intf__payload), valid=(const 1'd1), " + f"ready=(sig intf__ready))") + self.assertIs(intf.signature, sig) + self.assertIsInstance(intf.payload, Signal) + self.assertIs(intf.p, intf.payload) + self.assertIsInstance(intf.valid, Const) + self.assertEqual(intf.valid.value, 1) + self.assertIsInstance(intf.ready, Signal) + + def test_nav_ar(self): + sig = stream.Signature(2, always_ready=True) + self.assertRepr(sig, f"stream.Signature(2, always_ready=True)") + self.assertEqual(sig.always_valid, False) + self.assertEqual(sig.always_ready, True) + self.assertEqual(sig.members, wiring.SignatureMembers({ + "payload": Out(2), + "valid": Out(1), + "ready": In(1) + })) + intf = sig.create() + self.assertRepr(intf, + f"stream.Interface(payload=(sig intf__payload), valid=(sig intf__valid), " + f"ready=(const 1'd1))") + self.assertIs(intf.signature, sig) + self.assertIsInstance(intf.payload, Signal) + self.assertIs(intf.p, intf.payload) + self.assertIsInstance(intf.valid, Signal) + self.assertIsInstance(intf.ready, Const) + self.assertEqual(intf.ready.value, 1) + + def test_av_ar(self): + sig = stream.Signature(2, always_valid=True, always_ready=True) + self.assertRepr(sig, f"stream.Signature(2, always_valid=True, always_ready=True)") + self.assertEqual(sig.always_valid, True) + self.assertEqual(sig.always_ready, True) + self.assertEqual(sig.members, wiring.SignatureMembers({ + "payload": Out(2), + "valid": Out(1), + "ready": In(1) + })) + intf = sig.create() + self.assertRepr(intf, + f"stream.Interface(payload=(sig intf__payload), valid=(const 1'd1), " + f"ready=(const 1'd1))") + self.assertIs(intf.signature, sig) + self.assertIsInstance(intf.payload, Signal) + self.assertIs(intf.p, intf.payload) + self.assertIsInstance(intf.valid, Const) + self.assertEqual(intf.valid.value, 1) + self.assertIsInstance(intf.ready, Const) + self.assertEqual(intf.ready.value, 1) + + def test_eq(self): + sig_nav_nar = stream.Signature(2) + sig_av_nar = stream.Signature(2, always_valid=True) + sig_nav_ar = stream.Signature(2, always_ready=True) + sig_av_ar = stream.Signature(2, always_valid=True, always_ready=True) + sig_av_ar2 = stream.Signature(3, always_valid=True, always_ready=True) + self.assertNotEqual(sig_nav_nar, None) + self.assertEqual(sig_nav_nar, sig_nav_nar) + self.assertEqual(sig_av_nar, sig_av_nar) + self.assertEqual(sig_nav_ar, sig_nav_ar) + self.assertEqual(sig_av_ar, sig_av_ar) + self.assertEqual(sig_av_ar2, sig_av_ar2) + self.assertNotEqual(sig_nav_nar, sig_av_nar) + self.assertNotEqual(sig_av_nar, sig_nav_ar) + self.assertNotEqual(sig_nav_ar, sig_av_ar) + self.assertNotEqual(sig_av_ar, sig_nav_nar) + self.assertNotEqual(sig_av_ar, sig_av_ar2) + + def test_interface_create_bad(self): + with self.assertRaisesRegex(TypeError, + r"^Signature of stream\.Interface must be a stream\.Signature, not " + r"Signature\(\{\}\)$"): + stream.Interface(wiring.Signature({})) + + +class FIFOStreamCompatTestCase(FHDLTestCase): + def test_r_stream(self): + queue = fifo.SyncFIFOBuffered(width=4, depth=16) + r = queue.r_stream + self.assertFalse(r.signature.always_valid) + self.assertFalse(r.signature.always_ready) + self.assertIs(r.payload, queue.r_data) + self.assertIs(r.valid, queue.r_rdy) + self.assertIs(r.ready, queue.r_en) + + def test_w_stream(self): + queue = fifo.SyncFIFOBuffered(width=4, depth=16) + w = queue.w_stream + self.assertFalse(w.signature.always_valid) + self.assertFalse(w.signature.always_ready) + self.assertIs(w.payload, queue.w_data) + self.assertIs(w.valid, queue.w_en) + self.assertIs(w.ready, queue.w_rdy)