Statements¶
Conditional (if/elif/else)¶
Pyrope uses a typical if, elif, else sequence found in most languages.
Before the if starts, there is an optional keyword unique that enforces that
a single condition is true in the if/elif chain. This is useful for synthesis
which allows a parallel mux. The unique is a cleaner way to write an
assume statement.
The if sequence can be used in expressions too.
a = unique if x1 == 1 {
300
}elif x2 == 2 {
400
}else{
500
}
mut x = nil
if a > 350 { x = 3 } else { x = 4 } // a condition is a Bool: `if a` is an error
The equivalent code with an explicit assume, but unlike the assume, the
unique will guarantee to generate the hotmux statement. EDA tools can also
optimize unique if to tri-state buffers when the conditions are mutually
exclusive, providing the same behavior as a hardware bus without needing a
separate bus construct.
assume(!(x1==1 and x2==2))
a = if x1 == 1 {
300
}elif x2 == 2 {
400
}else{
500
}
Like several modern programming languages, there can be a list of expressions
in the evaluation condition. A variable declared there becomes visible at its
declaration and remains visible through the rest of the if/elif/else
chain. An earlier arm cannot read a variable declared in a later elif
initializer, even if the earlier condition is constant. This also applies to
unique if and to reads inside a nested block or lambda.
mut tmp = x+1
if mut x1=x+1; x1 == tmp {
puts("x1:{x1} is the same as tmp:{tmp}")
}elif mut x2=x+2; x2 == tmp {
puts("x1:{x1} != x2:{x2} == tmp:{tmp}")
}
Unique parallel conditional (match)¶
The match statement is similar to a chain of unique if/elif, like the
unique if/elif sequence. The match statement is a replacement for the
common "unique parallel case" Verilog directive, and behaves like also
having an assume statement, which allows for more efficient code
generation than a sequence of if/else.
A match declares its arms mutually exclusive and exhaustive (it behaves
like an assume/unique-parallel-case), so there is always exactly one matching
branch. The else arm is optional: when the arms already cover the whole
key space — e.g. every value of a bounded U<N>/S<N> selector — it can be left
out. An omitted else behaves like an unreachable else { assert(false) }:
in hardware it lowers to a don't-care (the Hotmux "none-of" slot is never
selected), and at compile time a constant selector that somehow matches no arm
leaves the result undefined (nil), exactly as for an if/elif chain with
no else. Add an explicit else only when you need a real catch-all value or
a cassert(false).
// `sel:U2` lists all four values — no `else` needed.
res = match sel {
== 0 { a }
== 1 { b }
== 2 { c }
== 3 { d }
}
In addition to functionality, the syntax is different to avoid redundancy.
match joins the match expression with the beginning of the matching
entry to form a valid expression.
const x = 1
match x {
== 1 { puts("always true") }
in (2,3) { puts("never") }
else { cassert(false) }
}
// It is equivalent to:
unique if x == 1 { puts("always true") }
elif x in (2,3) { puts("never") }
else { cassert(false) }
Like the if, it can also be used as an expression.
mut hot = match x {
== 0sb001 { a }
== 0sb010 { b }
== 0sb100 { c }
else { cassert(false); 0 }
}
// Equivalent
assume(x==0sb001 or x==0sb010 or x==0sb100)
mut hot2 = __hotmux(p0=x#[0], p1=a, p2=x#[1], p3=b, p4=x#[2], p5=c)
assert(hot==hot2)
Like the if statement, a sequence of statements and declarations are possible in the match statement.
match const one=1 ; (one, 2) {
== (1,2) { puts("one:{one}") } // should always hit
else { cassert(false) }
}
Since the == is the most common condition in the match statement, it can be
omitted.
for x in 1..=5 {
const v1 = match x {
3 { "three" }
4 { "four" }
else { "neither"}
}
const v2 = match x {
== 3 { "three" }
== 4 { "four" }
else { "neither"}
}
cassert(v1 == v2)
}
Conditional statements¶
Conditional behavior is expressed with if/else blocks, if expressions,
or match chains. Runtime conditions synthesize muxes or enables. Comptime
conditions are folded during elaboration, but declarations inside an if
block still follow normal block scope.
comptime const DEBUG = true
mut a = 3
if false {
a += 1
}
cassert(a == 3)
if DEBUG {
assert(a == 3)
}
if not DEBUG {
return
}
A comptime condition is not just folded: the arms it rules out are not
elaborated at all. Nothing inside a comptime-false arm is type checked, no
cassert in it is evaluated, and a lambda called only from it is never
instantiated, so it contributes no gates and no module. The same holds for every
arm of an if/elif chain: only the first comptime-true arm — or the else,
when every condition is comptime-false — is elaborated. The untaken arm is still
parsed and its names must resolve (an unknown lambda or variable there is a
compile error), but everything after name resolution is skipped.
comb narrow_only(v) -> (r) {
cassert(v.[bits] <= 4)
r = v
}
comb widen<NARROW>(a:U8) -> (o:U8) {
if NARROW {
o = narrow_only(v=a) // never instantiated: the `cassert` does not run
}else{
wrap o = a + 1
}
}
This is what makes a generic parameter or an imported comptime const usable as
a configuration switch: the code under a disabled option may call lambdas or use
widths that are only legal when the option is enabled.
mut x = c // always declared
if cond { x = other } // runtime-gated assignment
result = if cond { other } else { c }
Code block¶
A code block is a sequence of statements delimited by { and }. The
functionality is the same as in other languages. Variables declared within
a code block are not visible outside the code block. In other words, code block
variables have scope from definition until the end of the code block.
Code blocks are different from lambdas. A lambda consists of a code block but
it has several differences. In lambdas, (1) visible comptime bindings from
upper scopes are available lexically, but runtime upper-scope variables are not
implicitly visible; (2) inputs and outputs could be constrained, and (3) the
return statement finishes a lambda not a code block.
The main features of code blocks:
-
Code blocks define a new scope. New variable declarations inside are not visible outside it.
-
Code blocks do not allow variable declaration shadowing.
-
Expressions can have multiple code blocks but they are not allowed to have side-effects for variables outside the code block. The evaluation order provides more details on expressions evaluation order.
-
A code block used as an expression evaluates to the value of its last expression. This is a property of code blocks, not lambdas — see lambdas for how lambda outputs work (declared by name, assigned in the body, no implicit return).
mut yy = 0
{
mut x=1
mut z=0
{
z = 10
mut x=2 // error: 'x' is a shadow variable
}
cassert(z == 10)
yy = x
}
const zz = x // error: `x` is out of scope
cassert(yy == 1)
mut yy2 = {const x=3 ; 33/3} + 1
cassert(yy2 == 12)
const xx = {yy=1 ; 33} // error: 'yy' has side effects
if {const a=1+yy2; 13<a} {
// a is not visible in this scope
some_code()
}
comb doit(f, a) -> (r) {
const x = f(a)
assert(x == 7)
r = 3
}
comb real_doit(a) -> (r) {
assert(a != 0)
r = 7
return // exit the current lambda; later statements skipped
r = 100 // never reached
}
const z3 = doit(f=real_doit, a=33)
cassert(z3 == 3)
Loop (for)¶
The for iterates over the first-level elements in a tuple or the values in a
range. In all the cases, the number of loop iterations must be known at
compile time. The loop exit condition can not be run-time data-dependent.
The loop can have an early exit when calling break and skip of the current
iteration with the continue keyword.
for i in 0..<100 {
some_code(i)
}
mut bund = (1,2,3,4)
for (index, i) in bund { // a pair binding IS the enumerate: index (position) first, value second
assert(bund[index] == i)
}
The loop binding controls what is exposed, with the index/position first (as in most languages):
for value in t— just the element value.for (index, value) in t—indexis the (const) position,valuethe element.for (index, value, key) in t—keyis the field name (empty''for every entry of an unnamed tuple).
A tuple is either all-named or all-unnamed (see Tuples). The
field order of a named tuple carries no meaning, so a for visits its fields
in field-name order (sorted by name, not declaration order), and index is the
iteration count in that order; it does not make the named tuple accessible by
position (b[0] is still an error).
The value is a copy; iterate over ref t (e.g. for (index, value) in ref t)
to write the element back into the tuple.
const b = (const c=5, const a=1, const b=3) // declaration order is irrelevant
cassert(b.keys() == ('a', 'b', 'c'))
for (index, i, key) in b {
cassert(i==1 implies (index==0 and key == 'a'))
cassert(i==3 implies (index==1 and key == 'b'))
cassert(i==5 implies (index==2 and key == 'c'))
}
const u = (7, 11)
for (index, i, key) in u {
cassert(i==7 implies (index==0 and key == '' ))
cassert(i==11 implies (index==1 and key == '' ))
}
To build a tuple/array from a loop, use an explicit for over a mut
accumulator and rebuild it with ... each iteration (d = (...d, i)).
Trailing-for comprehensions on expressions are not supported (they make
trailing tokens of every expression ambiguous to parse) — write the loop out
instead.
mut d:[] = nil
for i in 0..<5 {
d = (...d, i)
}
mut e:[] = nil
for i in 0..<5 {
if i != 0 {
e = (...e, i)
}
}
cassert((0,1,2,3,4) == d)
cassert(e == (1,2,3,4))
The iterating element is copied by value, if the intention is to iterate over a
vector or array to modify the contents, a ref must be used. Only the element
is mutable. When a ref is used, it must be a variable reference, not a
function call return (value). The mutable for can not be used in
comprehensions.
mut b = (1,2,3,4,5)
for x in ref b {
x += 1
}
cassert(b == (2,3,4,5,6))
Code block control¶
Code block control statements allow changing the control flow for lambdas and
loop statements (for, loop, and while). return is a terminator only
— it never carries a value. Whatever has been assigned to the lambda's
declared output names is what the caller sees.
-
returnis for early exits — it terminates the current lambda before reaching the end of the body. It takes no arguments;return Xis a syntax error. To return early with a specific value, assign the output first:r = X; return. For conditional early exits, put the terminator inside anifblock:if cond { return }. -
breakterminates the closest inner loop (for/while/loop). If none is found, a compile error is generated. -
continuelooks for the closest inner loop (for/while/loop) code block. Thecontinuewill perform the next loop iteration. If no inner loop is found, a compile error is generated.
mut total:[] = nil
for a in 1..=10 {
if a == 2 { continue }
total = (...total, a)
if a == 3 { break } // exit for scope
}
cassert(total == (1,3))
if true {
code(x)
continue // error: no upper loop scope
}
mut a = 3
mut total2:[] = nil
while a>0 {
total2 = (...total2, a)
if a == 2 { break } // exit if scope
a = a - 1
continue
assert(false) // never executed
}
cassert(total2 == (3,2))
mut total3:[] = nil
for i in 1..=9 {
if i<3 {
total3 = (...total3, i+10)
}
}
cassert(total3 == (11, 12))
while/loop¶
while cond { [stmts]+ } is a typical while loop found in most programming
languages. The only difference is that like with loops, the while must be fully
unrolled at compilation time. The loop { [stmts]+ } is equivalent to a while
true { [stmts]+ }.
Like if/match, the while condition can have a sequence of statements with
variable declarations visible only inside the while statements.
// a do while contruct does not exist, but a loop is quite clean/close
mut a = 0
loop {
puts("a:{a}")
a += 1
if a >= 10 { break }
} // do{ ... }while(a<10)
for, while, and loop are compile-time only and fully unrolled. For a
runtime, cycle-driven loop inside a test (run for N cycles or forever), use
tick.
Loops and wire¶
A loop body may read a
wire declared
outside it; the read needs no hoisting. It may not write (drive) a wire:
the body runs once per iteration, so a write there would add one driver per
iteration and break the single-driver rule. Drive the wire outside the loop.
wire w:U4 = a ^ b
mut ones:U3 = 0
for i in 0..<4 {
ones += w#[i] // OK: reads the wire
}
wire z:U4 = nil
for i in 0..<4 {
z#[i] = a#[i] // error: a loop body may not drive a wire
}
Semantically every loop unrolls. The compiler may still keep a simple for
compact (rolled) internally, which never changes behavior, but a loop whose
body reads a wire or writes a memory / reg array is always unrolled.
Cycle access¶
Cycle-based access to values is expressed through a small set of constructs:
- The first bare
variablereads before update hold the register's 'q' value:
reg counter:U32 = 0
const counter_q = counter // snapshot 'q' before any updates this cycle
if whatever {
wrap counter = counter + 1
}
-
past[N](variable)pipelines the value overNcycles.Nmust be a literal positive decimal; the compiler insertsNflops, so the hardware cost is explicit in the call, and the expression's landing cycle shifts byN. There is no barepast(x)in a design body. Do not confuse it with the verificationpast(x, n)history sample, which does not shift the cycle — see Temporal library. -
To escape program order within a cycle — read a value that is only produced by a later statement, e.g. to close a ring — declare it as a
wire(single-driver combinational net) and read it before its driver appears. See Wire. -
For pipeline timing, use
stage[N](declaration modifier that pipelines the whole RHS overNcycles;mod-only) andfoo@[N](pure timing type check, legal in bothmodandpipebodies — it asserts the inferred stage and never inserts flops). -
For debug-only sampling over time (inside
assert,test,formal, …), use the temporal library —past(x, n=2),rose(x),eventually(x, w=1..=N), etc. Every cycle argument is an ordinary named argument; there is no bracket form, and the whole library is still TBD. See Temporal library.
foo@[N] is a pure cycle-alignment type check, never a flop insertion, and
is legal inside both mod and pipe bodies. foo@[3] checks that foo is
3 pipeline stages ahead of the lambda inputs. To actually delay a value, use
stage[N] lhs = rhs (a mod-only construct) or past[N](x). There is no
future-cycle read in synthesizable code.
To feed a register's next-state into both the register and a same-cycle
consumer, name the value as a wire and read it in both places:
reg counter:U32 = 0
wire nx:U32 = nil // forward-declared net for the next-state value
wrap nx = counter + 1 // its single driver (U32: wraps like the register)
counter = nx // registered write
const also = nx + 1 // same-cycle consumer reads the same net
To connect ring calls in a loop, forward-declare the back edge as a wire:
wire f4 = nil
f1 = ring(x=a, prev=f4) // reads f4 before its driver appears
f2 = ring(x=b, prev=f1)
f3 = ring(x=c, prev=f2)
f4 = ring(x=d, prev=f3) // the single driver of f4
Testing (test)¶
A test block is a debug-only simulation entry point. It is named by a
dotted identifier (a selector path), not a string, so individual tests and
whole groups can be selected from the command line:
test add.basic {
assert(add(lhs=2, rhs=3) == 5)
}
lhd sim add.prp # run every test in add.prp
lhd sim add.prp add # run every test under the `add.` group (prefix match)
lhd sim add.prp add.basic # run one test
lhd sim <file.prp> [test.name] takes the source file as the first positional
and an optional dotted test selector as the second. With no selector every test
in the file runs.
The leading segments form the group and the final segment is the leaf. A
fully-qualified test name must be unique (a selector maps to one definition).
There is no test "string" form: a human-readable message is just a puts(...)
(or an assert message) inside the body. A test body still behaves like a
puts followed by a scope, and its statements can not have any effect outside.
Runtime parameters¶
A test may declare runtime parameters in a (...) list, exactly like a lambda
but without a -> (...) return (a test never returns a value). The
parameters are ordinary values usable inside the body — to drive a DUT input,
size a tick loop, or seed a cpp model — so a single test becomes a small
parametrized experiment:
test add.checked(lhs:S32=3, rhs:S32) {
assert(add(lhs, rhs) == lhs + rhs) // bare names match add's inputs `lhs` and `rhs`
}
A parameter (like a test local) may not reuse the name of a visible file-scope
const: the const is visible inside the test, so that is shadowing, a compile
error.
Each parameter is either optional or required:
lhs:S32=3has a default, so it is optional: the runner uses3unless it is overridden.rhs:S32has no default, so it is required: the runner MUST supply a value.rhs:S32=nilmeans exactly the same thing — an explicitnildefault and an omitted default both say "the runner must set this". Anilthat reaches the body is a runner error, never a silent0.
Values are passed with +name=value (repeatable). Supplying a required
argument is mandatory; running without it is an error, not a default-to-zero:
lhd sim add.prp add.checked +rhs=7 # lhs=3 (default), rhs=7
lhd sim add.prp add.checked +lhs=10 +rhs=-4 # both overridden
lhd sim add.prp add.checked # error: required `rhs` not set
A required parameter is the hook for external setup: the value can come from the command line as above, or from a harness that fills it in (a fuzzer, a constrained-random or directed-test generator, a CI matrix). The test declares what it needs; the runner decides how the value is produced.
Runtime parameters are debug-only simulation values (they drive the DUT's
inputs, size a tick/step, or feed a cpp model); they never reach
synthesizable logic. A value that must size hardware has to be comptime, so it
is never a (...) parameter: take it from a comptime const or bind it as a
generic of the lambda under test. A comptime test-parameter sweep (one test
instance per swept value) is reserved but not yet specified (see
Implementation status).
Many tests can run in parallel to increase throughput. A comptime for loop
multiplies the number of tests; each unrolled instance shares the leaf name and
the runner disambiguates them by index. (TBD: lhd sim runs neither form
below yet. It finds no test nested in a top-scope for, and it rejects a
for/while/loop statement inside a test body; use tick N for a cycle
loop. See Implementation status.)
comb add(a,b) -> (r) { r = a + b }
for i in 0..<10 { // 10 tests
const a = (-30..<100).rand
const b = (-30..<100).rand
test add.sweep {
assert(add(a,b) == (a+b))
}
}
comb add(a,b) -> (r) { r = a + b }
test add.batch {
for i in 0..<10 { // 10 checks
const a = (-30..<100).rand
const b = (-30..<100).rand
assert(add(a,b) == (a+b))
}
}
Test only statements¶
test code blocks are allowed to use special statements not available outside
testing blocks:
-
step [ncycles]advances the simulation one cycle (orncycles). The local variables preserve their value; the inputs may change value.stepis the explicit yield point of a test — the clock edge.One
stepis settle → commit → settle: the combinational cone is evaluated against the state and inputs as they now stand, that result is committed (the edge), and the cone is evaluated once more against the state just committed so every output holds a fresh value. The first settle is why an input driven immediately before astepaffects that edge; the second is why a read placed immediately after it sees post-edge values. -
regref(x)binds a writable reference to a register or memory word.xis a dotted path such asregref(acc.core0.count)or a string path such asregref("acc/core0.count"). Intermediate components name the variables that instantiate the child modules. Bind the reference once, outside atickloop; it remains valid throughout the run.
Read any signal with bare dotted access (acc.core0.count). A write through
regref changes the stored value immediately; the next step evaluates the
design from that value and commits its computed next state. It does not
hold the register across later edges. Persistent force/release is TBD.
To wait for a condition there is no separate primitive: step each cycle and
continue until it holds (the tick N bound is the timeout). See
Waiting on a condition.
test wait.one {
mut dut = top
tick 100 {
dut.a = 1
step
if not dut.ready { continue } // wait until ready, then proceed
break
}
assert(dut.ready, "ready did not assert within 100 cycles")
}
Running cycles (tick)¶
for, while, and loop all unroll at compile time, so they can not express
"run for a runtime number of cycles". That is what tick does — a
non-unrolling, cycle-driven loop usable only inside test:
tick N { stmts } // run up to N cycles; one `step` (clock edge) per iteration
Each tick iteration is one cycle. You declare the design under test (DUT)
once as an instance before the loop and interact with it by field access:
acc.x = v drives input x (pre-edge), acc.y reads an output or an internal
register (acc.total). Each such access is sugar for an anonymous
storage access whose binding is hoisted out of the
loop, so it costs nothing per iteration. The clock edge is the explicit
step in the body — statements above it drive this cycle's inputs,
statements below sample the results — and there is exactly one step per
iteration. Read the DUT below the step: a read placed above it observes what
the previous step settled, not the inputs driven by the statements just above
it, so for an output that depends combinationally on those inputs the two
placements differ by a cycle.
Each tick block gets a minted clock:Clock. In simulation a Clock is a cycle
counter, and this one counts the tick's cycles: it is 0 in the first
iteration and each step advances it. The DUT instances stepped inside the tick
auto-wire their unbound Clock input to it, like any instance whose caller has
a single Clock. A test is a debug context, so the numeric
view of clock is legal there: use it in puts, assert, and to drive inputs
such as a reset (acc.reset = clock < 2; a Bool expression binds to a Reset
without a cast). Outside a tick block, clock is undefined unless the test
declares it. A harness never feeds a constant to a Clock input (clk=1 is an
error); it passes a real Clock such as the tick's clock.
mod counter(enable:Bool) -> (value:U8@[0]) {
reg count:U8 = 0
value = count // combinational read of count.q -> @[0]
if enable { wrap count += 1 }
}
test counter.held_high {
mut acc = counter // one persistent instance, reset on declaration
mut v_final = nil
tick 20 { // run up to 20 cycles
acc.enable = true // drive this cycle's input (pre-edge)
step // the clock edge
v_final = acc.value // sample this cycle's output (post-edge)
}
assert(v_final == 20, "after 20 enabled cycles the count must be 20")
}
Test-local muts persist across iterations, so a golden value updated in
lockstep inside the same loop mirrors the design's next-state and makes the
final assert self-checking:
test counter.gated {
mut acc = counter
mut en = false
mut expected = 0
mut v_final = nil
tick 20 {
en = not en // this cycle's enable
if en { expected = expected + 1 } // golden mirror of count
acc.enable = en
step
v_final = acc.value
}
assert(v_final == expected, "gated counter disagrees with golden model")
assert(v_final == 10) // enable was high on 10 of the 20 cycles
}
A Reset input is Bool-like — drive it from the cycle index rather than a magic
window. counter has a register and declares no Clock or Reset input, so it
gets the minted clock:Clock and reset:Reset inputs (see
Implicit clock and reset). The
reset is active-high because counter does not set negreset=true.
Holding acc.reset for the first cycles keeps the registers at their reset
value until you release it:
test counter.with_reset {
mut acc = counter
tick 8 {
acc.enable = true
acc.reset = clock < 2 // cycles 0,1 held in reset; counting starts at cycle 2
step
}
assert(acc.value == 6) // counted only on cycles 2..7
}
A runtime parameter can drive the simulation itself.
Because tick takes a runtime count (unlike the unrolling for), the cycle
bound can be a test argument set from outside — the value comes from +name=value, or
from the runner when it is omitted:
test counter.run_for(cycles:U8=20) {
mut acc = counter
mut v_final = nil
tick cycles { // a runtime argument sets the loop length
acc.enable = true
step
v_final = acc.value
}
assert(v_final == cycles, "after {cycles} enabled cycles the count must be {cycles}")
}
lhd sim counter.prp counter.run_for # cycles=20 (default)
lhd sim counter.prp counter.run_for +cycles=50 # run 50 cycles instead
The N bound doubles as a watchdog: a break stops the loop early once a
runtime condition holds, while N guarantees the test can never spin forever.
test runner.until_done {
mut r = runner
mut done_final = false
tick 100 { // watchdog bound: never spin forever
r.start = clock == 0 // one-cycle start pulse on cycle 0
r.len = 5
step
done_final = r.done
if r.done { break }
}
assert(done_final, "runner never reached Done within 100 cycles")
}
tick and step follow the same name rule as every other reserved word:
backticks are required in every name position, for the exact spelling only
(Tick and STEP are ordinary names). This includes
fields and methods: x.`step` = 1, (const `tick` = 1), and
mod `tick`(ref self, ...). (regref is an ordinary built-in call,
not a reserved word.)
const tick = 1 // error: `tick` is a reserved word
mut step = 1 // error: `step` is a reserved word
const `tick` = 1 // OK: a backticked reserved word is an ordinary name
An unbounded tick { } (no count: run until a break) is TBD; the simulation
runner currently requires the N bound, which doubles as the watchdog/timeout.
Stimulus, waiting, and monitors all live inside the one bounded loop as
if-blocks — see Extended Verification.
"Wait until a condition" is step plus if/continue — there is no waitfor
primitive. The tick N bound is the timeout, and a post-loop assert turns a
timeout into a failure:
total = 3
tick 1000 {
step
if not a_cond { continue } // wait until a_cond is true
break
}
assert(total == 3 and a_cond, "a_cond did not hold within 1000 cycles")
The main reason for using the step is that the "equivalent" #>[1] is a more
structured construct. The step behaves more like a "yield" in that the next
call or cycle it will continue from there. The #>[1] directive adds a
pipeline structure which means that it can be started each cycle. Calling a
lambda that has called a step and still has not finished should result in a
simulation assertion failure.
- Bare dotted access reads any register, memory word, or module input/output.
regrefbinds a writable register or memory reference.peek/pokeandsigrefare removed.