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" // String concatenation
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 don't-care/unknown, '_' is just a separator
mut unknown = 0b101? // Bit 0 is don't care/unknown
mut partial = 0b??10 // Multiple don't care/unknown bits
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.
const constant = 42 // Compile-time constant (immutable)
mut wire = 0 // Combinational (no persistence)
reg state = 0 // Register (persistent across cycles)
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
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
Combinational, Pipelines, or Flows¶
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
comb add_simple(a:u8, b:u8) {
a + b // Returns single-element tuple
}
Pipeline¶
A pipeline is a function where all the outputs are updated with the same time number of cycles with respect to the inputs.
pipe counter(enable:bool) -> (reg count:u8) {
count += 1 when enable
}
pipe 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 is a function that allows to connect combinational, pipeline, or flow functions but requires explicit time indication for each variable use. Each variable has a @cycle to indicate the expected cycle completion with respect to the flow inputs. The outputs do not need the explicit time annotation.
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@[2+1], in2_d@[2+1]) = delay[3] (mul(in1, in2), in2)
out_pipelined = delay[1] add(tmp@[2+1], in2_d@[2+1])
out_live =@[1] add(tmp@[2+1], in2@0) // =@[1] is the same as = delay[1]
}
flow accum_alu(in1, in2) -> (out) {
reg total:[init=0]
const tmp@[2+1] = delay[3] mul(in1, in2)
const sum_aligned = add(total@0, tmp@[2+1]) // explicit timing makes alignment clear
total@[1] = sum_aligned // @[1] defers write to end of cycle
out = total@0 // current register output
}
Inside flow blocks, the variables should have a time delay indication, but as usual they can also have
additional checks like type and attributes, but comptime attributes do not really care about the time delay.
const (tmp@0:u32, tmp2@[2]:u3:[something=true], x@0:i3:[comptime=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 Ruby-like unless at the end of the statement that removes the statement
unless/when the condition is satisfied.
return when enable
return unless !enable // Same
assert !enable
Compile-Time Loops¶
// For loops (must be compile-time bounded)
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 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 }
}
Enumerations¶
enum State = (Idle, Active, Done) // One-hot encoding: 1, 2, 4
// Simplified subset of full Pyrope enum features
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)
mut foo:u32:[comptime=true] = 42 // Set comptime attribute
reg counter:[reset_pin=rst] = 0 // Set reset pin attribute
// 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 foo::[comptime] == true // Check if compile-time constant
cassert z::[bits] < 32 // Check bit width constraint
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 attributes
const SIZE:[comptime=true] = 16 // Compile-time constant
mut array_size = SIZE // Uses compile-time value
// Hardware attributes
reg state:[reset_pin=my_reset] = 0 // Custom reset signal
reg clocked:[clock_pin=fast_clk] = 0 // Custom clock signal
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 (compile-time only due to cost)
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] += 1 // Defer write to end of cycle
assert counter == tmp
tmp += 1
assert counter != tmp
assert counter@[1] == tmp // Read deferred value (end of cycle)
// Timing syntax summary:
// counter@[0] - current value (same as just 'counter')
// counter@[1] - value at end of cycle (deferred/next)
// counter@[-1] - value from previous cycle
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 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" {
const rf = reg_file(we=true, ra=3, rb=1, wa=1, wd=42)
assert rf.rd_a == 0
assert rf.rd_b == 0 // no fwd
step
const rf2 = reg_file(we=false, ra=1, rb=0, wa=0, wd=0)
assert rf2.rd_a == 42
assert rf2.rd_b == 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.