amaranth
amaranth copied to clipboard
Using `sim.add_clock(Period())` (`Period` without any arguments) leads to an empty VCD file
Version: 9167910
If I remove 1 MHz in the example from the documentation,
sim.add_clock(Period(MHz=1))
(from https://github.com/amaranth-lang/amaranth-lang.github.io/blob/0856ab9702c05600e159de2fc9c4ccbf2dcf3cf6/docs/amaranth/latest/_code/up_counter.py#L71)
then the VCD ends like this and does not contain any value changes:
...
$dumpvars
1!
0"
0#
b0 $
1%
$end
The minimum time interval is 1 fs. For a 1-0 cycle, the _femtoseconds must be modified to 2:
https://github.com/amaranth-lang/amaranth/blob/916791022cb9cf96a552756d0e00efe2a4b16aba/amaranth/hdl/_time.py#L26-L30
Motivation: In many designs, the absolute period is irrelevant. In this case, we can use a clock cycle of 2 fs.
If this makes sense, I can open a PR.
Period() without arguments gives you a zero period by design. The appropriate fix here would be for add_clock() to reject a zero period, since that's nonsensical.
I believe I could not express my improvement idea clearly: what if Period would have a default of 2 fs instead of 0? This would be useful for designs where an absolute period value is irrelevant.
In your fix we still have to provide a number. This has two disadvantages. More boilerplate code, and it could carry the information that the design relies on, e.g., 100 MHz.
I believe I could not express my improvement idea clearly: what if Period would have a default of 2 fs instead of 0?
That seems completely wrong to me. Period is a general purpose data type we provide for use in simulation, synthesis, and downstream software, not a mere accessory to add_clock().
I also think, like @zyp, that add_clock() should be strengthened to check for a minimum period.
It makes sense to initialize it with a neutral value if Period has a general purpose.
What about supplying to add_clock a default period of 2 fs? (additional to rejecting 0 fs) Because:
In your fix we still have to provide a number. This has two disadvantages. More boilerplate code, and it could carry the information that the design relies on, e.g., 100 MHz.
Is it really more boilerplate code? The line count is the exact same. I'm unconvinced that the added confusion (other people may not expect the behavior you propose) is worth saving 12 characters.
IMHO, in Amaranth, one has to write several lines to simulate a simple design. I would wish that a simple design could be clocked in a single line, like executing a function in Python. I see the implementation of a default behavior for 'add_clock()` as just a small step to my ideal 🙂.
I see our views are far apart. Maybe the reason is the difference between our backgrounds. I have been teaching programming for the last years, where explaining a programming concept involves (at least in Python) just firing up a REPL, writing some statements, and examining the variables, types, etc, directly using len, type, isinstance, etc. I can imagine that in daily engineering, boilerplate is not very important, as you don't begin a new project every minute.
With the power of REPL of Python in mind, I started using Amaranth, where I like the infrastructure to examine what happens with signals in REPL during simulation runtime (currently with breakpoint() to get the values during simulation).
Less boilerplate code (even 12 letters) makes it easier to interact with a design when working on a REPL. I hope I could explain where my nitpicking comes from 🙂.
I would wish that sim.add_clock() would connect all the existing clock domains in a design to the simplest usable Period possible. I thought about the confusions that could arise, but I cannot come up with any. What kind of other behaviors could be expected?
Thank you for the context, that does explain where you're coming from.
IMHO, in Amaranth, one has to write several lines to simulate a simple design.
Yes. The design of the simulator's interface is very intentional in that it avoids catering to anyone's ambient assumptions by making "obvious" shortcuts, but rather makes you write out the complete simulation conditions. It's a decision driven by years of experience with Amaranth's predecessor, Migen, which took too many of these shortcuts to its detriment. There are still some inherited decisions (around the clock domain system for example) where this design approach continues to be an issue.
Let's take a testbench I've written recently. The testbench here is 75 lines, and there are several more in the same file. The code that sets up the simulator is 6 lines, amortized over many testbenches that share it and some related setup code. From my perspective, the latter isn't worth being concerned over, especially given that every line of the boilerplate does something useful.
I would wish that
sim.add_clock()would connect all the existing clock domains in a design to the simplest usablePeriodpossible. I thought about the confusions that could arise, but I cannot come up with any. What kind of other behaviors could be expected?
There are three purposes to sim.add_clock():
- Specifying which clock domains in the design must be externally driven by a continuous 50% duty cycle waveform. a. Note that it is legal to have an undriven clock domain in the simulation. (You may be checking how your design behaves with one of the domains powered off.) b. More frequently than leaving a domain undriven, you may be driving the clock of a domain yourself from a testbench. This is the only way to have a domain driven by anything other than a continuous 50% duty cycle waveform. Such cases are very common when dealing with interfaces like SPI.
- Specifying the ratios of clock periods (as well as their phase offsets) of each of the domains driven as in point (1). You are right that in many cases the exact periods are unimportant. But their ratios are, and if you default to 2 fs, the choice of ratios that are still feasible is limited.
- Specifying the exact period. In some cases, the exact period is important. For example, if you are driving an UART at 115200 baud, you would reasonably expect one bit period to take 1/115200'th of a second. If your system clock period is 2 fs, you will need over 4 billion cycles to transmit 1 bit. This is not practical. a. Moreover, most people do not expect anything on a silicon ASIC to happen within a femtosecond, so it is intuitively surprising to see such small periods in a simulation to anyone with a modicum of EE experience.
For your specific use case of very simple simulations created in bulk, it might be useful to have this default. But you are not beholden to use only the functions provided built-in by Amaranth: you can write your own function that has all of the behavior you want:
def run_simulation(dut, tb):
sim = Simulator(dut)
sim.add_clock(Period(fs=2))
sim.add_testbench(tb)
with sim.write_vcd(...):
sim.run()
... this design approach continues to be an issue.
I understand that some components of Migen used to oversimplify some HDL constructs which led to less expressibility and ambiguity. This is always a trade-off, isn't it? You come up with a new language to simplify some expressions, but then they become detrimental later. I am not cognizant of Migen's architecture, so I cannot comment on this aspect. And I also don't expect you to explain me the mistakes in this thread 🙂.
... especially given that every line of the boilerplate does something useful
I agree. If you work on such a project, then these 6 lines are nothing compared to.
three purposes to sim.add_clock():
I will try to come up with how sim.add_clock() would be used in each case:
- a. leaving everything undriven: no invocation of
sim.add_clock()b. no continuous 50% duty cyclesim.add_clock(something_specific)(but not defaultsim.add_clock()) - different periods that must have a relation to each other:
sim.add_clock(something_specific);...` - specifying exact period required, because the design inherently requires an absolute notion of time, e.g., 115200 bps.
sim.add_clock(something_specific)
One problem that I now see is: How would a default clock look like if two clock domains are somehow connected to each other? This would probably require an analysis, which would be a showstopper for my idea.
Last but not least, I still think that specifying sim.add_clock(ns=2) can still carry the intent that the design must work at this frequency, even if the design could also work at other frequencies. I would wish that I just have to specify clock relations between different domains instead of absolute frequencies (like in UART). But even waveform viewers don't support this, as you have mentioned.
But you are not beholden to use ...
I mean software, modularization, functions enable creating your own world after all. But you would still prefer to have batteries included instead of importing your own world every time, don't you think? I just remembered that I had a similar discussion with Verilator's maintainer while searching for the minimal boilerplate for a SV simulation. As far as I remember, the C++ boilerplate code was required, partly because it gave the flexibility to simulate several designs. Somehow, after some time --binary option came and no C++ was required from the user, which simplified the simulation for some users. Advanced users can still use the C++ interface. I find this a good approach.
In summary, thank you for explaining these in detail, whitequark, and I will use something like run_simulation as you have suggested in my lectures 🤝.
So, what is the fix for this issue then? It is sensical to have undriven clocks according to our discussion, and Period() means no period = no clock. Idea: If we assume that Period is an immutable object, then we could indeed forbid adding a period with zero period. If the user wants unlocked domains, then they must not use any add_clock. What do you think?
- b. no continuous 50% duty cycle
sim.add_clock(something_specific)(but not defaultsim.add_clock())
No, you cannot generally use sim.add_clock() in this case at all. Consider a SoC that includes an SPI peripheral. The clock would only be running while there is an SPI transaction going on. This isn't representable with sim.add_clock() (and shouldn't be).
But you would still prefer to have batteries included instead of importing your own world every time, don't you think?
I'm actually completely fine with copying and pasting utility functions that I find useful but not useful enough (or not finished enough) to go to the standard library. We even recommend this right now in the official docs for the amaranth.lib.stream module (although those two specific functions are intended to become a part of the stdlib once we have an RFC for it).
Many languages have "batteries included" that railroad users into a limited way of completing a task, or are too opinionated, or too simplistic. I consider this a failure mode, although this, too, is a matter of taste. (Python is often cited as an example of a language with "batteries included" where the batteries are dead or leaky.)
Somehow, after some time
--binaryoption came and no C++ was required from the user, which simplified the simulation for some users.
That's not comparable to Amaranth. Looking over the documentation of the option, I see that it does not drive any inputs. This is possible in Verilog since Verilog has a non-synthesizable subset, which I am guessing Verilator now recognizes. Amaranth does not have a non-synthesizable subset (or equivalently, the non-synthesizable subset is the code that goes into async def testbench():).
Moreover, basically the entire point of the Amaranth simulator is that you can use Python code to introspect and change design state in your testbench. It also happens to be able to run a piece of code with nothing but the clock provided for however long you ask it, but that is not what it was designed for, it's more of a happy side effect. Most of the effort we put into it went towards making RTL<>testbench interaction work better.
So, what is the fix for this issue then? It is sensical to have undriven clocks according to our discussion, and
Period()means no period = no clock. Idea: If we assume thatPeriodis an immutable object, then we could indeed forbid adding a period with zero period. If the user wants unlocked domains, then they must not use anyadd_clock. What do you think?
Yes, the fix for this issue would be to prohibit clocks with too short of a period to simulate (less than 2 fs).
In summary, thank you for explaining these in detail, whitequark, and I will use something like
run_simulationas you have suggested in my lectures 🤝.
I would be quite interested to see your course materials, if you're able to share. One of the design goals for Amaranth is teachability, so it is very important for me to see how the language is taught in real life. We take this goal seriously: there have been breaking changes to the core language that we made because it wasn't easy enough to teach.
... SPI transaction going on. This isn't representable with sim.add_clock() (and shouldn't be).
Probably because sim.add_clock() cannot be stopped or changed during simulation. add_clock() can only be delayed with phase, as I understand. 👍
... is a matter of taste. (Python is often cited as an example of a language with "batteries included" where the batteries are dead or leaky.)
Actually, I was fascinated by the breadth of the standard library until now, probably because I did not carry out large projects with it. But thanks to you, I understand now why requests, loguru, or lxml are used instead of the standard library.💡 As I understand, a future-proof and rigorous standard library is not easy to design. I have the impression that the leaky and dead batteries are the exception in the rich set of batteries of Python, however.
That's not comparable to Amaranth. Looking over the documentation of the option, I see that it does not drive any inputs. This is possible in Verilog since Verilog has a non-synthesizable subset, which I am guessing Verilator now recognizes.
Verilator used to focus on the synthesizable-subset, but then #delay expressions were supported which paved the way to avoid C++ stimulation code. The clock still has to be generated in the HDL, in that regard it is not comparable to Amaranth, you are right. My intent was to give an example that lowers the entry for beginners and simple simulations (--binary is actually a shortcut to four different options), but will probably not be used by advanced users.
Moreover, basically the entire point of the Amaranth simulator is that you can use Python code to introspect and change design state in your testbench. It also happens to be able to run a piece of code with nothing but the clock provided for however long you ask it, but that is not what it was designed for, it's more of a happy side effect.
I see. At the same time I cannot imagine a testbench for a sequential circuit without a clock 🙂.
Regarding introspection of the design: I currently use ctx.get()s and breakpoint()s to introspect the design. I find ctx.get() bit long. I would wish that there would be a namespace where signal would automatically mean ctx.get(signal). Now I understand why waveform viewers are so useful. I noticed that this is off-topic, so I don't expect any answer.
I would be quite interested to see your course materials, if you're able to share.
I open source all my teaching materials, but I don't have right now something with Amaranth or other modern HDLs. My goal right now is to present the students a list of modern HDL languages along with some examples.
One of the design goals for Amaranth is teachability, so it is very important for me to see how the language is taught in real life. We take this goal seriously: there have been breaking changes to the core language that we made because it wasn't easy enough to teach.
♥️ . This motivates me to spend more time with Amaranth.
Yes, the fix for this issue would be to prohibit clocks with too short of a period to simulate (less than 2 fs).
👍
I have the impression that the leaky and dead batteries are the exception in the rich set of batteries of Python, however.
PEP 594 has identified, by my count, twenty two standard library packages as "dead batteries". (Some have since become maintained or were otherwise kept for various reasons). I would say that 22 is a pretty big number. It is a bit annoying to count the amount of modules in the Python standard library (it also depends how you count), but this is at least 10% of all modules shipped with Python 3.13 by the most conservative count.
I see. At the same time I cannot imagine a testbench for a sequential circuit without a clock 🙂.
What I mean is that the simulator is designed solely with the use case where you have one or more testbenches in mind. It also happens to run without any testbenches because there is no reason to prohibit it and it is useful in some limited circumstances like yours, but it was not something that was driving the design process.
There were some other design goals and non-goals that conflict with yours. For example...
Regarding introspection of the design: I currently use
ctx.get()s andbreakpoint()s to introspect the design. I findctx.get()bit long. I would wish that there would be a namespace wheresignalwould automatically meanctx.get(signal).
The simulator was explicitly designed to avoid having any global state anywhere. This is why it does not use a cocotb-style API where sig.get() returns the value with the simulator context implied.
Although if by "namespace" here you mean "an object obj such that obj.signal returns the value of a signal", this doesn't generally work either because signal names are not unique and signals aren't inherently tied to a hierarchy level. The relationship between Signal objects and the traces in the waveform viewer is one-to-many, and the names get rewritten to resolve duplicates, in a way that loses direct correspondence to the source code.
I realize that these limitations are frustrating but I cannot offer a better resolution than "wrap the Amaranth simulator in your own code". (You can write and submit an RFC changing the behavior but it is unlikely to get the necessary consensus for acceptance.)
Although the features you suggest would clearly make it easier to write code-in-the-small, like one-off snippets in the REPL and such, Amaranth intentionally prioritizes code-in-the-large by carefully considering how testbenches could be maintained long-term, reused between functional blocks, and reviewed in a PR over the terseness of the surface syntax.
You will notice this all over the place. For example, the m.d.sync syntax uses the .d. qualifier in the middle to make sure that you could use any clock domain name you want, even seemingly "unlikely" or "wrong" ones like If, without ever having to fear that additions to the language would break your code. Many Python projects that use eDSLs reserve some names when using __getattr__, but we chose not to do this in favor of writing two extra characters for every statement. (The m. prefix could be eliminated too by adding some global state.) This is a consequence of philosophy that emphasizes reliability over being quick to type: a conscious tradeoff.
♥️ . This motivates me to spend more time with Amaranth.
Happy to hear that!
Thanks for the elaboration! Now I understand bit better why it is not easy to embed a circuit description logic in a general-purpose language.
Although if by "namespace" here you mean "an object obj such that obj.signal returns the value of a signal", this doesn't generally work either because signal names are not unique and signals aren't inherently tied to a hierarchy level. The relationship between Signal objects and the traces in the waveform viewer is one-to-many, and the names get rewritten to resolve duplicates, in a way that loses direct correspondence to the source code.
I don't understand why it is a problem that signal names are not unique. For example:
self.x = Signal()
y = Signal()
self.z = y
Can't I assume that we have two unique signals?
Consider this case:
a1 = Signal(name="a")
a2 = Signal(name="a")