Skip to content

Finish lib.memory documentation #1228

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 9 commits into from
Mar 22, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
669 changes: 370 additions & 299 deletions amaranth/lib/memory.py

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions docs/_static/custom.css
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,6 @@ a { text-decoration: underline; }

/* Many of our diagnostics are even longer */
.rst-content pre.literal-block, .rst-content div[class^="highlight"] pre, .rst-content .linenodiv pre { white-space: pre-wrap; }

/* Work around https://github.com/readthedocs/sphinx_rtd_theme/issues/1301 */
.py.property { display: block !important; }
6 changes: 5 additions & 1 deletion docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,11 @@
napoleon_use_ivar = True
napoleon_include_init_with_doc = True
napoleon_include_special_with_doc = True
napoleon_custom_sections = ["Platform overrides"]
napoleon_custom_sections = [
("Attributes", "params_style"), # by default displays as "Variables", which is confusing
("Members", "params_style"), # `lib.wiring` signature members
"Platform overrides"
]

html_theme = "sphinx_rtd_theme"
html_static_path = ["_static"]
Expand Down
41 changes: 41 additions & 0 deletions docs/stdlib/_images/memory/example_fifo.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
22 changes: 22 additions & 0 deletions docs/stdlib/_images/memory/example_hello.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
144 changes: 139 additions & 5 deletions docs/stdlib/memory.rst
Original file line number Diff line number Diff line change
@@ -1,10 +1,144 @@
Memories
--------
Memory arrays
-------------

.. py:module:: amaranth.lib.memory

The :mod:`amaranth.lib.memory` module provides a way to efficiently store data organized as an array of identically shaped rows, which may be addressed (read and/or written) one at a time.
The :mod:`amaranth.lib.memory` module provides a way to efficiently store data organized as an array of identically shaped rows, which may be addressed (read and/or written) one at a time. This organization is well suited for an efficient implementation in hardware.


Introduction
============

A memory :ref:`component <wiring>` is accessed through read and write *memory ports*, which are :ref:`interface objects <wiring>` with address, data, and enable ports. The address input selects the memory row to be accessed, the enable input determines whether an access will be made, and the data output (for read ports) or data input (for write ports) transfers data between the memory row and the rest of the design. Read ports can be synchronous (where the memory row access is triggered by the :ref:`active edge <lang-sync>` of a clock) or asynchronous (where the memory row is accessed continuously); write ports are always synchronous.

.. note::

Unfortunately, the terminology related to memories has an ambiguity: a "port" could refer to either an *interface port* (:class:`.Signal` objects created by the :mod:`amaranth.lib.wiring` module) or to a *memory port* (:class:`ReadPort` or :class:`WritePort` object created by :class:`amaranth.lib.memory.Memory`).

Amaranth documentation always uses the term "memory port" when referring to the latter.

To use a memory, first create a :class:`Memory` object, providing a shape, depth (the number of rows), and initial contents. Then, request as many memory ports as the number of concurrent accesses you need to perform by using the :meth:`Memory.read_port` and :meth:`Memory.write_port` methods.

.. warning::

While :class:`Memory` will provide virtually any memory configuration on request and all will simulate correctly, only a subset of configurations can implemented in hardware efficiently or `at all`. Exactly what any given hardware platform supports is specific to both the device and the toolchain used.

However, the following two configurations are well-supported on most platforms:

1. Zero or one write ports and any amount of read ports. Almost all devices include one or two read ports in a hardware memory block, but the toolchain will replicate memory blocks as needed to meet the requested amount of read ports, using more resources.
2. Two write ports and any amount of read ports whose address input always matches that of the either write port. Most devices include two combined read/write ports in a hardware memory block (known as "true dual-port", or "TDP", block RAM), and the toolchain will replicate memory blocks to meet the requested amount of read ports. However, some devices include one read-only and one write-only port in a hardware memory block (known as "simple dual-port", or "SDP", block RAM), making this configuration unavailable. Also, the combined (synchronous) read/write port of a TDP block RAM usually does not have independent read enable and write enable inputs; in this configuration, the read enable input should usually be left in the (default if not driven) asserted state.

Most devices include hardware primitives that can efficiently implement memories with asynchronous read ports (known as "LUT RAM", "distributed RAM", or "DRAM"; not to be confused with "dynamic RAM", also abbreviated as "DRAM"). On devices without these hardware primitives, memories with asynchronous read ports are implemented using logic resources, which are consumed at an extremely high rate. Synchronous read ports should be always preferred over asynchronous ones.

Additionally, some memory configurations (that seem like they should be supported by the device and the toolchain) may fail to be recognized, or may use much more resources than they should. This can happen due to device and/or toolchain errata (including defects in Amaranth). Unfortunately, such issues can only be handled on a case-by-case basis; in general, simpler memory configurations are better and more widely supported.


Examples
========

.. testsetup::

from amaranth import *

First, import the :class:`Memory` class.

.. testcode::

from amaranth.lib.memory import Memory


Read-only memory
++++++++++++++++

In the following example, a read-only memory is used to output a fixed message in a loop:

.. testcode::
:hide:

m = Module()

.. testcode::

message = b"Hello world\n"
m.submodules.memory = memory = \
Memory(shape=unsigned(8), depth=len(message), init=message)

rd_port = memory.read_port(domain="comb")
with m.If(rd_port.addr == memory.depth - 1):
m.d.sync += rd_port.addr.eq(0)
with m.Else():
m.d.sync += rd_port.addr.eq(rd_port.addr + 1)

character = Signal(8)
m.d.comb += character.eq(rd_port.data)

In this example, the memory read port is asynchronous, and a change of the address input (labelled `a` on the diagram below) results in an immediate change of the data output (labelled `d`).

.. image:: _images/memory/example_hello.svg


First-in, first-out queue
+++++++++++++++++++++++++

In a more complex example, a power-of-2 sized writable memory is used to implement a first-in, first-out queue:

.. testcode::
:hide:

m = Module()

.. testcode::

push = Signal()
pop = Signal()

m.submodules.memory = memory = \
Memory(shape=unsigned(8), depth=16, init=[])

wr_port = memory.write_port()
m.d.comb += wr_port.en.eq(push)
with m.If(push):
m.d.sync += wr_port.addr.eq(wr_port.addr + 1)

rd_port = memory.read_port(transparent_for=(wr_port,))
m.d.comb += rd_port.en.eq(pop)
with m.If(pop):
m.d.sync += rd_port.addr.eq(rd_port.addr + 1)

# Data can be shifted in via `wr_port.data` and out via `rd_port.data`.
# This example assumes that empty queue will be never popped from.

In this example, the memory read and write ports are synchronous. A write operation (labelled `x`, `w`) updates the addressed row 0 on the next clock cycle, and a read operation (labelled `y`, `r`) retrieves the contents of the same addressed row 0 on the next clock cycle as well.

However, the memory read port is also configured to be *transparent* relative to the memory write port. This means that if a write and a read operation (labelled `t`, `u` respectively) access the same row with address 3, the new contents will be read out, reducing the minimum push-to-pop latency to one cycle, down from two cycles that would be required without the use of transparency.

.. image:: _images/memory/example_fifo.svg


Memories
========

..
attributes are not documented because they can be easily used to break soundness and we don't
document them for signals either; they are rarely necessary for interoperability

.. autoclass:: Memory(*, depth, shape, init, src_loc_at=0)
:no-members:

.. autoclass:: amaranth.lib.memory::Memory.Init(...)

.. automethod:: read_port

.. automethod:: write_port

.. autoproperty:: read_ports

.. autoproperty:: write_ports


Memory ports
============

.. todo::
.. autoclass:: ReadPort(...)

Write the rest of this document.
.. autoclass:: WritePort(...)
2 changes: 2 additions & 0 deletions docs/stdlib/wiring.rst
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
.. _wiring:

Interfaces and connections
##########################

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ test = [
docs = [
"sphinx~=7.1",
"sphinxcontrib-platformpicker~=1.3",
"sphinx-rtd-theme~=1.2",
"sphinx-rtd-theme~=2.0",
"sphinx-autobuild",
]
examples = [
Expand Down
59 changes: 30 additions & 29 deletions tests/test_lib_memory.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,28 +58,29 @@ def test_signature(self):

def test_signature_wrong(self):
with self.assertRaisesRegex(TypeError,
"^`addr_width` must be a non-negative int, not -2$"):
r"^Address width must be a non-negative integer, not -2$"):
memory.WritePort.Signature(addr_width=-2, shape=8)
with self.assertRaisesRegex(TypeError,
"^Granularity must be a non-negative int or None, not -2$"):
r"^Granularity must be a non-negative integer or None, not -2$"):
memory.WritePort.Signature(addr_width=4, shape=8, granularity=-2)
with self.assertRaisesRegex(ValueError,
"^Granularity cannot be specified with signed shape$"):
r"^Granularity cannot be specified for a memory with a signed shape$"):
memory.WritePort.Signature(addr_width=2, shape=signed(8), granularity=2)
with self.assertRaisesRegex(TypeError,
"^Granularity can only be specified for plain unsigned `Shape` or `ArrayLayout`$"):
r"^Granularity can only be specified for memories whose shape is unsigned or "
r"data.ArrayLayout$"):
memory.WritePort.Signature(addr_width=2, shape=MyStruct, granularity=2)
with self.assertRaisesRegex(ValueError,
"^Granularity must be positive$"):
r"^Granularity must be positive$"):
memory.WritePort.Signature(addr_width=2, shape=8, granularity=0)
with self.assertRaisesRegex(ValueError,
"^Granularity must be positive$"):
r"^Granularity must be positive$"):
memory.WritePort.Signature(addr_width=2, shape=data.ArrayLayout(8, 8), granularity=0)
with self.assertRaisesRegex(ValueError,
"^Granularity must divide data width$"):
r"^Granularity must evenly divide data width$"):
memory.WritePort.Signature(addr_width=2, shape=8, granularity=3)
with self.assertRaisesRegex(ValueError,
"^Granularity must divide data array length$"):
r"^Granularity must evenly divide data array length$"):
memory.WritePort.Signature(addr_width=2, shape=data.ArrayLayout(8, 8), granularity=3)

def test_signature_eq(self):
Expand Down Expand Up @@ -120,7 +121,7 @@ def test_constructor(self):
m = memory.Memory(depth=16, shape=8, init=[])
port = memory.WritePort(signature, memory=m, domain="sync")
self.assertIs(port.memory, m)
self.assertEqual(m.w_ports, (port,))
self.assertEqual(m.write_ports, (port,))

signature = memory.WritePort.Signature(shape=MyStruct, addr_width=4)
port = signature.create()
Expand All @@ -134,17 +135,17 @@ def test_constructor(self):
def test_constructor_wrong(self):
signature = memory.ReadPort.Signature(shape=8, addr_width=4)
with self.assertRaisesRegex(TypeError,
r"^Expected `WritePort.Signature`, not ReadPort.Signature\(.*\)$"):
r"^Expected signature to be WritePort.Signature, not ReadPort.Signature\(.*\)$"):
memory.WritePort(signature, memory=None, domain="sync")
signature = memory.WritePort.Signature(shape=8, addr_width=4, granularity=2)
with self.assertRaisesRegex(TypeError,
r"^Domain has to be a string, not None$"):
r"^Domain must be a string, not None$"):
memory.WritePort(signature, memory=None, domain=None)
with self.assertRaisesRegex(TypeError,
r"^Expected `Memory` or `None`, not 'a'$"):
r"^Expected memory to be Memory or None, not 'a'$"):
memory.WritePort(signature, memory="a", domain="sync")
with self.assertRaisesRegex(ValueError,
r"^Write port domain cannot be \"comb\"$"):
r"^Write ports cannot be asynchronous$"):
memory.WritePort(signature, memory=None, domain="comb")
signature = memory.WritePort.Signature(shape=8, addr_width=4)
m = memory.Memory(depth=8, shape=8, init=[])
Expand Down Expand Up @@ -186,7 +187,7 @@ def test_signature(self):

def test_signature_wrong(self):
with self.assertRaisesRegex(TypeError,
"^`addr_width` must be a non-negative int, not -2$"):
"^Address width must be a non-negative integer, not -2$"):
memory.ReadPort.Signature(addr_width=-2, shape=8)

def test_signature_eq(self):
Expand Down Expand Up @@ -227,7 +228,7 @@ def test_constructor(self):
m = memory.Memory(depth=16, shape=8, init=[])
port = memory.ReadPort(signature, memory=m, domain="sync")
self.assertIs(port.memory, m)
self.assertEqual(m.r_ports, (port,))
self.assertEqual(m.read_ports, (port,))
write_port = m.write_port()
port = memory.ReadPort(signature, memory=m, domain="sync", transparent_for=[write_port])
self.assertIs(port.memory, m)
Expand All @@ -245,14 +246,14 @@ def test_constructor(self):
def test_constructor_wrong(self):
signature = memory.WritePort.Signature(shape=8, addr_width=4)
with self.assertRaisesRegex(TypeError,
r"^Expected `ReadPort.Signature`, not WritePort.Signature\(.*\)$"):
r"^Expected signature to be ReadPort.Signature, not WritePort.Signature\(.*\)$"):
memory.ReadPort(signature, memory=None, domain="sync")
signature = memory.ReadPort.Signature(shape=8, addr_width=4)
with self.assertRaisesRegex(TypeError,
r"^Domain has to be a string, not None$"):
r"^Domain must be a string, not None$"):
memory.ReadPort(signature, memory=None, domain=None)
with self.assertRaisesRegex(TypeError,
r"^Expected `Memory` or `None`, not 'a'$"):
r"^Expected memory to be Memory or None, not 'a'$"):
memory.ReadPort(signature, memory="a", domain="sync")
signature = memory.ReadPort.Signature(shape=8, addr_width=4)
m = memory.Memory(depth=8, shape=8, init=[])
Expand All @@ -266,15 +267,15 @@ def test_constructor_wrong(self):
m = memory.Memory(depth=16, shape=8, init=[])
port = m.read_port()
with self.assertRaisesRegex(TypeError,
r"^`transparent_for` must contain only `WritePort` instances$"):
r"^Transparency set must contain only WritePort instances$"):
memory.ReadPort(signature, memory=m, domain="sync", transparent_for=[port])
write_port = m.write_port()
m2 = memory.Memory(depth=16, shape=8, init=[])
with self.assertRaisesRegex(ValueError,
r"^Transparent write ports must belong to the same memory$"):
r"^Ports in transparency set must belong to the same memory$"):
memory.ReadPort(signature, memory=m2, domain="sync", transparent_for=[write_port])
with self.assertRaisesRegex(ValueError,
r"^Transparent write ports must belong to the same domain$"):
r"^Ports in transparency set must belong to the same domain$"):
memory.ReadPort(signature, memory=m, domain="other", transparent_for=[write_port])


Expand All @@ -284,14 +285,14 @@ def test_constructor(self):
self.assertEqual(m.shape, 8)
self.assertEqual(m.depth, 4)
self.assertEqual(m.init.shape, 8)
self.assertEqual(m.init.depth, 4)
self.assertEqual(len(m.init), 4)
self.assertEqual(m.attrs, {})
self.assertIsInstance(m.init, memory.Memory.Init)
self.assertEqual(list(m.init), [1, 2, 3, 0])
self.assertEqual(m.init._raw, [1, 2, 3, 0])
self.assertRepr(m.init, "Memory.Init([1, 2, 3, 0])")
self.assertEqual(m.r_ports, ())
self.assertEqual(m.w_ports, ())
self.assertRepr(m.init, "Memory.Init([1, 2, 3, 0], shape=8, depth=4)")
self.assertEqual(m.read_ports, ())
self.assertEqual(m.write_ports, ())

def test_constructor_shapecastable(self):
init = [
Expand Down Expand Up @@ -353,10 +354,10 @@ def test_init_set_slice_wrong(self):
r"^Changing length of Memory.init is not allowed$"):
m.init[1:] = [1, 2]
with self.assertRaisesRegex(TypeError,
r"^Deleting items from Memory.init is not allowed$"):
r"^Deleting elements from Memory.init is not allowed$"):
del m.init[1:2]
with self.assertRaisesRegex(TypeError,
r"^Inserting items into Memory.init is not allowed$"):
r"^Inserting elements into Memory.init is not allowed$"):
m.init.insert(1, 3)

def test_port(self):
Expand All @@ -374,8 +375,8 @@ def test_port(self):
wp = m.write_port()
self.assertEqual(wp.signature.addr_width, addr_width)
self.assertEqual(wp.signature.shape, 8)
self.assertEqual(m.r_ports, (rp,))
self.assertEqual(m.w_ports, (wp,))
self.assertEqual(m.read_ports, (rp,))
self.assertEqual(m.write_ports, (wp,))

def test_elaborate(self):
m = memory.Memory(shape=MyStruct, depth=4, init=[{"a": 1, "b": 2}])
Expand Down