Small Pyrope - Minimal Hardware Description Language¶
A minimal subset of Pyrope that can express any hardware design while being implementation-friendly. Small pyrope is designed to be the subset of Pyrope that allows easier implementation of a first Pyrope compiler while being compatible with full Pyrope.
Core Principles¶
Small Pyrope maintains Pyrope's expressiveness while reducing complexity:
-
Everything is a tuple (fundamental data structure)
-
Structural typing only
-
Compile-time elaboration for all control flow
-
Simple timing model with explicit cycles
Types and Variables¶
Basic Types¶
Small Pyrope supports integers (u8, i16, int), bool, and string. Type annotations use : and are optional when they can be inferred.
Number literals may include _ separators with no meaning (12_34__ == 1234). Binary literals may include ? bits (don't care/unknown). The ? value also serves as the default/uninitialized value.
Attributes are set at declaration with :[...] and are independent of the type: name:Type:[attr=value]. Use ::[attr] to read attribute values (see Attributes section).
// Integers (signed/unsigned with bit constraints)
mut a:u8 = 100 // 8-bit unsigned
mut b:i16 = -50 // 16-bit signed
mut c:int = 1000 // Unlimited precision (compile-time only)
// Boolean
mut flag:bool = true
// String (basic operations)
mut text:string = "hello"
mut combined = text ++ " world" // Tuple concatenation (strings are tuples of characters)
puts "Debug: value is ", combined // Print for debugging
// Default initialization
mut x = ? // Type default (0 for int, false for bool, "" for string)
mut y = 0 // Explicit value
// '?' bits are unknown (valid but unobserved, like Verilog x)
// Arithmetic works: 0sb? + 1 = 0sb??, 0sb? | 1 = 1
mut unknown = 0b101? // Bit 0 is unknown
mut partial = 0b??10 // Multiple unknown bits
// 'nil' is invalid (NOT unknown) — any use is an assertion error
// The compiler must prove all nil uses are eliminated or compile error
mut z = nil // invalid, can only be copied until assigned a real value
Variable Storage Classes¶
Semicolons have the same behavior as a newline: they are optional, but can be used to put multiple statements on one line.
comptime SIZE = 16 // Compile-time constant (shorthand for comptime const)
comptime mut counter = 0 // Mutable at compile time (updated during elaboration)
const constant = 42 // Immutable after assignment (NOT compile-time)
mut wire = 0 // Combinational (no persistence, can be reassigned)
reg state = 0 // Register (persistent across cycles)
Variables have two orthogonal properties: mutability (const vs mut) and
timing (comptime vs runtime). const is immutable after assignment but its
value can differ on each function call. mut can be reassigned. The comptime
prefix modifier means the value must be resolvable at compile/elaboration time.
reg persists across cycles. comptime alone is shorthand for comptime const.
Variable Scope (Simplified)¶
// Code block scope
mut a = 3
{
assert a == 3 // Visible from outer scope
mut b = 4 // Local to this block
// const a = 33 // Error: no shadowing allowed
}
// assert b == 4 // Error: 'b' not visible outside block
// Functions have their own scope (Small Pyrope does not support capture variables)
comb example() {
mut local = 5 // Function-local variable
local + 1
}
Tuples (Core Data Structure)¶
mut point = (x=10, y=20) // Named tuple
mut array = (1, 2, 3, 4) // Indexed tuple
mut mixed = (x=1, 2, y=3) // Mixed named/indexed
// Access
assert point.x == 10
assert array[2] == 3 // Array-style access
// Concatenation (++ is always tuple concatenation — strings, lambdas, tuples)
mut combined = point ++ (z=30) // (x=10, y=20, z=30)
Ranges¶
mut range1 = 1..=5 // Inclusive range: 1,2,3,4,5
mut range2 = 0..<4 // Exclusive range: 0,1,2,3
mut range3 = 2..+3 // Size-based range: 2,3,4
// Range operations
assert (1..=3) == (1,2,3) // Range to tuple conversion
assert int(1..=3) == 0b1110 // Range to one-hot encoding
Arrays and Memories¶
mut buffer:[16]u8 = ? // Array (no persistence)
reg memory:[256]u32 = 0 // Memory (persistent)
memory[addr] = data // Write
mut read_data = memory[addr] // Read
// Range-based access
mut slice = buffer[1..=4] // Extract elements 1-4
// Memory with synthesis attributes
reg ram:[1024]u32:[
latency=1, // 1-cycle read latency
fwd=true, // Write-to-read forwarding
wensize=4, // 4-bit write enable (byte enables)
rdport=(0,1), wrport=(2,3) // Port assignment
] = 0
// Dual-port access (simple Pyrope requires explicit port attribute for multiport)
ram[addr1]:[wrport=2] = data1 // Write port 2
ram[addr2]:[wrport=3] = data2 // Write port 3
mut out1 = ram.port[0][addr3]:[rdport=0] // Read port 0
mut out2 = ram.port[1][addr4]:[rdport=1] // Read port 1
Lambda Types: comb, pipe, flow, mod¶
Small Pyrope functions do not support capture variables (e.g. comb f[a] { ... } is not supported). Pass values explicitly as arguments.
Combinational or Pure Functions (comb)¶
In Pyrope, a combinational or pure function is a stateless function without memory or registers. As such, it can not have side-effects.
comb add(a:u8, b:u8) -> (result:u8) {
result = a + b
}
// Implicit return: last expression is the return value
comb add_simple(a:u8, b:u8) {
a + b // Returns single-element tuple
}
// 'return' is only needed for early exits
comb clamp(x:i16) -> (result:u8) {
return 0 when x < 0 // Early exit
return 255 when x > 255 // Early exit
result = x // Normal path, no return needed
}
Pipeline¶
A pipeline is a Moore machine — outputs always go through flops. The pipe
declares its latency (e.g., pipe[3]), and the tool may retime logic for
performance, but the behavior is equivalent to a comb with N flops appended
at the outputs. Pipelines can use reg for internal storage, but besides
storage, they behave like a comb with pipelined outputs.
pipe[1] counter(enable:bool) -> (reg count:u8) {
count += 1 when enable
}
mod fifo(push:bool, pop:bool, data_in:u18) -> (data_out:u18, full:bool, empty:bool) {
reg buffer:[16]u18 = _
reg head:u4 = 0
reg tail:u4 = 0
reg count:u5 = 0
if push and !full {
buffer[head] = data_in
head = (head + 1) & 0xF
count += 1
}
if pop and !empty {
data_out = buffer[tail]
tail = (tail + 1) & 0xF
count -= 1
}
full = (count == 16)
empty = (count == 0)
}
Flow (Connecting Blocks)¶
A flow connects combinational, pipeline, or other flow blocks with explicit
timing control. Flows can also use reg for persistent state across cycles.
There are three complementary timing mechanisms inside flow blocks:
delay[N]on operations: specifies that an operation takes N cycles.var@[N]on uses (RHS): specifies which cycle to read the value at. The compiler inserts alignment delays if needed.:@[N]on declarations (LHS): optional timing type check. The compiler verifies the computed cycle matches N.
pipe mul(a, b) -> (c) { c = a * b }
pipe add(a, b) -> (c) { c = a + b }
flow alu(in1, in2) -> (out_pipelined, out_live) {
const (tmp, in2_d) = delay[3] (mul(in1@[0], in2@[0]), in2)
out_pipelined:@[4] = delay[1] add(tmp@[3], in2_d@[3])
out_live = delay[1] add(tmp@[3], in2@[0])
}
flow accum_alu(in1, in2) -> (out) {
reg total:[init=0]
const tmp = delay[3] mul(in1@[0], in2@[0])
const sum_aligned = add(total@[0], tmp@[3]) // explicit timing makes alignment clear
total@[] = sum_aligned // @[] defers write to end of cycle
out = total@[0] // current register output
}
Inside flow blocks, variable uses on the right-hand side should have a @[N]
time annotation for the compiler to check alignment. The left-hand side can
optionally use :@[N] as a timing type check. As usual, variables can also
have type and attribute checks.
const (tmp:u32, tmp2:u3:[something=true]) = some_flow_call(a@[0], b@[3]:u32, c@[2]:[xxx_should_be_set=true])
Control Flow¶
Conditionals¶
if condition {
result = a
} else {
result = b
}
Pyrope also has when/unless trailing modifiers for single-statement
conditionals. when cond executes the statement only if cond is true;
unless cond executes only if cond is false. Unlike if blocks, these do
not create a new scope — the statement stays in the current scope. They can be
applied to assignments, function calls, assertions, and control statements
(return, break, continue).
return when enable
return unless !enable // Same
assert !enable
Compile-Time Loops¶
Loop bounds must be comptime (known at compile time) so that loops can be
unrolled. The loop body can contain runtime logic — only the bounds are
compile-time.
// For loops (bounds must be comptime, body is runtime hardware)
for i in 0..=7 {
memory[i] = init_value
}
// Range-based loops
for val in 1..<10 step 2 { // 1,3,5,7,9
process(val)
}
Match (Pattern Matching)¶
match is always unique (mutually exclusive branches, like unique if). It
supports any comparison operator, not just equality.
match state {
== 0 { next_state = 1 }
== 1 { next_state = 2 }
== 2 { next_state = 0 }
else { next_state = 0 }
}
// `case` is an alias for `==` in match statements
match state {
case 0 { next_state = 1 }
case 1 { next_state = 2 }
case 2 { next_state = 0 }
else { next_state = 0 }
}
// Other comparison operators are allowed
match value {
< 0 { result = -1 }
== 0 { result = 0 }
> 0 { result = 1 }
}
Enumerations¶
enum uses one-hot encoding (each value maps to a single bit), which is ideal
for FSMs in hardware. variant is a tagged union that shares bits between
entries for more compact representation. Both allow only one entry to be active
at a time.
enum State = (Idle, Active, Done) // One-hot encoding: 1, 2, 4
reg current_state:State = State.Idle
match current_state {
case State.Idle {
current_state = State.Active when start
}
case State.Active {
current_state = State.Done when complete
}
case State.Done {
current_state = State.Idle
}
}
Attributes¶
Attributes provide compile-time metadata and constraints for variables, enabling hardware-specific optimizations and Verilog compatibility.
Attribute Syntax¶
Attributes are set only at declaration using :[attr=value]. The ::[] syntax is only for reading attribute values.
// Set attribute (only at declaration)
reg counter:[reset_pin=ref rst] = 0 // Set reset pin (ref connects the wire)
// Read attribute value
const num_bits = counter::[bits] // Read number of bits
// Check attribute (read and compare)
cassert counter::[bits] == 8 // Check bit width
cassert z::[bits] < 32 // Check bit width constraint
// Compile-time uses the 'comptime' prefix modifier (not an attribute)
comptime SIZE = 16 // shorthand for comptime const
comptime mut elaboration_cnt = 0 // mutable at compile time
cassert SIZE::[comptime] == true // Can still query comptime status
Common Attributes¶
Attributes are immutable after declaration. To change attributes, create a new variable.
// Bitwidth constraints
mut data:u32:[max=1000, min=0] = 0
// Overflow behavior (set at declaration - applies to all operations)
mut counter_wrap:u8:[wrap=true] = 0 // Always wraps on overflow
mut counter_sat:u8:[saturate=true] = 0 // Always saturates on overflow
// One-off overflow behavior (typecast with attributes)
mut result = (a + b):u8:[wrap=true] // This operation wraps to u8
mut clamped = (x + y):u8:[saturate=true] // This operation saturates to u8
// Typecast without attributes
mut truncated = (large_val):u8 // Explicit typecast to u8
// Compile-time uses the 'comptime' prefix modifier
comptime SIZE = 16 // Known at elaboration time (shorthand for comptime const)
mut array_size = SIZE // Uses compile-time value
// Hardware attributes
reg state:[reset_pin=ref my_reset] = 0 // Custom reset signal (ref = wire connection)
reg clocked:[clock_pin=ref fast_clk] = 0 // Custom clock signal
reg no_reset:[reset_pin=false] = 0 // Tied low (comptime value, no ref needed)
reg async_reg:[async=true] = 0 // Asynchronous reset
reg pipeline:[retime=true] = 0 // Allow synthesis retiming
// Debug attributes
mut debug_val:[debug=true] = counter // Debug-only variable
// To "change" attributes, create a new variable
mut new_data:[wrap=true] = data // new_data has wrap, data unchanged
Memory Attributes¶
// Single-port memory with basic attributes
reg memory:[256]u32:[latency=1, fwd=true] = 0
// Multi-port memory configuration
reg dual_port:[1024]u16:[
rdport=(0,1), // Ports 0,1 are read ports
wrport=(2), // Port 2 is write port
latency=1, // Read latency
fwd=false, // No forwarding
wensize=4 // 4-bit write enable mask
] = 0
// Memory with custom clocking
reg async_mem:[64]u8:[
clock=(clk1, clk2), // Different clocks per port
reset=mem_rst, // Custom reset signal
posclk=false // Negative edge triggered
] = 0
Operators¶
Arithmetic¶
mut sum = a + b; mut diff = a - b; mut prod = a * b; mut div = a / b // Basic arithmetic
mut left_shift = a << n; mut right_shift = a >> n // Shifts
const remainder = a % b // Modulo (debug-only: too expensive for single-cycle hardware)
Bitwise¶
mut and_result = a & b; mut or_result = a | b; mut xor_result = a ^ b // AND, OR, XOR
mut not_result = ~a // NOT
Logical¶
mut logical_and = a and b; mut logical_or = a or b // Logical (no short-circuit)
mut logical_not = !a // Logical NOT
Comparison¶
mut equal = a == b; mut not_equal = a != b // Equality
mut less = a < b; mut less_eq = a <= b; mut greater = a > b; mut greater_eq = a >= b // Comparison
Bit Selection and Reduction¶
mut value = 0b1010_1100
mut bits = value#[3..=6] // Extract bits 3-6
value#[3] = 0 // Set 3rd bit to 0
// Reduction operators
mut or_reduce = value#|[..] // OR-reduce all bits
mut and_reduce = value#&[..] // AND-reduce all bits
mut xor_reduce = value#^[..] // XOR-reduce (parity)
mut pop_count = value#+[..] // Population count
// Sign/zero extension
mut extended = value#sext[0..=3] // Sign extend bits 0-3 (3 is sign)
mut zero_ext = value#zext[1..=5] // Zero extend bits 1-5 (no sign)
// Non-contiguous bit selection is a short-cut for bit selection and tuple typecast
// Careful to avoid endian confusion (think about tuple order)
mut sparse1 = (value#[0], value#[3], value#[7])#[..]
mut sparse2 = value#[0,3,7] // Select bits 0, 3, and 7
mut rparse1 = (value#[7], value#[3], value#[0])#[..]
mut rparse2 = value#[7,3,0] // Select bits 7, 3, and 0
assert value == 0b1010_1100
assert sparse2== 0b1____1__0
assert rparse2== 0b011 // reverse order of bits (LSB-first packing)
Operator Precedence¶
Small Pyrope follows the same precedence rules as full Pyrope for compatibility:
| Priority | Category | Operators |
|---|---|---|
| 1 | Unary | !, not, ~, - |
| 2 | Multiply/Divide | *, / |
| 3 | Other Binary | +, -, ++, <<, >>, &, \|, ^, ..=, ..<, ..+ |
| 4 | Comparators | <, <=, ==, !=, >=, > |
| 5 | Logical | and, or, implies |
// Explicit parentheses required for mixed precedence
mut result = (a * b) + (c & d) // Clear precedence
// mut mixed = a * b + c & d // Error: use parentheses
// Chained comparisons allowed
assert a <= b <= c // Same as: a <= b and b <= c
Testing and Verification¶
Assertions¶
assert condition // Runtime assertion
cassert compile_time_expr // Compile-time assertion
test "counter test" {
const cnt = counter(true)
puts "Counter value: ", cnt // Debug output
step // Advance one cycle
assert cnt == 1
cassert SIZE == 16 // Compile-time constant check
}
Debug Output¶
// Basic puts for debugging
puts "Hello World" // Simple string output
puts "Value: ", variable // Print variable
puts "Count: ", count, " Max: ", max_val // Multiple values
Hardware Semantics¶
Register Updates¶
reg counter:u8 = 0
mut tmp:u8 = counter
counter += 1 // Immediate update
tmp += 1
assert counter == tmp
counter@[] += 1 // Defer write to end of cycle
assert counter == tmp
tmp += 1
assert counter != tmp
assert counter@[] == tmp // Read deferred value (end of cycle)
assert counter@[1] == tmp // OK in assert (debug): @[1] == @[] for registers
// Timing syntax summary:
// counter@[0] - current value (same as just 'counter')
// counter@[] - deferred value (end of current cycle)
// counter@[-1] - value from previous cycle
// counter@[1] - next cycle value (compile error unless debug context)
Reset Behavior¶
reg counter:u8 = 100 // Reset value is 100
Module System¶
Import (Basic)¶
// Import functions from other files
const math_ops = import("math/basic")
const result = math_ops.add(a, b)
// Import specific function
const multiply = import("math/basic/multiply")
const product = multiply(x, y)
// Import from local file
const utils = import("utils")
utils.debug_print("Hello")
Complete Example¶
// Import required modules
const test_utils = import("test/helpers")
// Simple CPU register file
pipe[1] reg_file(
clk:bool,
we:bool,
ra:u5,
rb:u5,
wa:u5,
wd:u32
) -> (
rd_a:u32,
rd_b:u32
) {
reg registers:[32]u32 = 0
// Read ports (1st read, no forwarding)
rd_a = if ra == 0 { 0 } else { registers[ra] }
rd_b = if rb == 0 { 0 } else { registers[rb] }
// Write port
if we {
registers[wa] = wd when (wa != 0) // Register 0 is always 0
}
}
test "register file" {
// Cycle 0: write 42 to register 1, read regs 3 and 1
const rf = reg_file(we=true, ra=3, rb=1, wa=1, wd=42)
// pipe[1] outputs are registered — these reflect the initial state (all zeros)
assert rf.rd_a == 0 // reg[3] = 0 (initial), delayed 1 cycle
assert rf.rd_b == 0 // reg[1] = 0 (initial), delayed 1 cycle
step
// Cycle 1: no write, read reg 1 (was written last cycle)
const rf2 = reg_file(we=false, ra=1, rb=0, wa=0, wd=0)
// Output still reflects cycle 0 reads due to pipe[1] delay
assert rf2.rd_a == 0 // reg[3] still 0
step
// Cycle 2: pipe[1] output now reflects cycle 1 reads
const rf3 = reg_file(we=false, ra=1, rb=0, wa=0, wd=0)
assert rf3.rd_a == 42 // reg[1] = 42 (written in cycle 0, read in cycle 1, output in cycle 2)
assert rf3.rd_b == 0 // reg[0] always 0
}
TODO: Features to Add After Small Pyrope Implementation¶
This section has moved. See new_syntax_doc/01c-small_pyrope_todo.md for examples of planned features beyond Small Pyrope.