From 365d3ac3577c7cfb25aa4926bfff614494826077 Mon Sep 17 00:00:00 2001 From: Orual Date: Sat, 28 Feb 2026 22:44:35 -0500 Subject: [PATCH] docs: add dfasm macros and function calls design plan Macro system with IR-level expansion, static function call syntax with @ret markers and auto-inserted free_ctx, trailing-colon location directives, dot-notation scope resolution, and built-in macro library. 8 implementation phases. --- .exo/config.toml | 2 + .gitignore | 3 + design-notes/OR-1 Design.md | 32 +- design-notes/alu-and-output-design.md | 48 +- design-notes/architecture-overview.md | 6 +- design-notes/assembler-architecture.md | 298 ++--- design-notes/dfasm-primer.md | 298 ++--- design-notes/iram-and-function-calls.md | 658 +++++++++++ .../loop-patterns-and-flow-control.md | 1031 +++++++++++++++++ design-notes/sm-design.md | 24 +- docs/design-plans/2026-02-28-dfasm-macros.md | 410 +++++++ 11 files changed, 2363 insertions(+), 447 deletions(-) create mode 100644 .exo/config.toml create mode 100644 design-notes/iram-and-function-calls.md create mode 100644 design-notes/loop-patterns-and-flow-control.md create mode 100644 docs/design-plans/2026-02-28-dfasm-macros.md diff --git a/.exo/config.toml b/.exo/config.toml new file mode 100644 index 0000000..4ccf2a9 --- /dev/null +++ b/.exo/config.toml @@ -0,0 +1,2 @@ +default_role = "tl" +zellij_session = "or1-design" diff --git a/.gitignore b/.gitignore index 09b6314..f917ef0 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,6 @@ nix-profile* # dfgraph frontend dfgraph/frontend/node_modules/ dfgraph/frontend/dist/ +# ExoMonad - track config, ignore runtime artifacts +.exo/* +!.exo/config.toml diff --git a/design-notes/OR-1 Design.md b/design-notes/OR-1 Design.md index aae7e1a..b1093ec 100644 --- a/design-notes/OR-1 Design.md +++ b/design-notes/OR-1 Design.md @@ -14,11 +14,11 @@ Why? There's two reasons. One makes me sound like a crank, the other makes me sound like an asshole. I'll start with the latter. I've wanted to build a breadboard CPU for a bit now, but I didn't want to just follow in someone else's footsteps. James Sharman has made perhaps the most polished and usable system, even writing some decent games for his JAM-1, and achieved high subscalar performance. Fabian Schuiki is targeting a superscalar system with out-of-order execution, backporting ideas from the late 90s and 2000s. I think I can do something way more out of distribution than either, and get competitive results. -The other reason is that I do genuinely think we might have missed something when we dropped dataflow machines because the PC and thus the x86 CPU (and now the ARM CPU) ate the world. There are a bunch of concepts that, even within dataflow designs, got abandoned in the 90s favour of moving closer to a multicore Turing machine, just with hardware message passing and semaphores. +The other reason is that I do genuinely think we might have missed something when we dropped dataflow machines because the PC and thus the x86 CPU (and now the ARM CPU) ate the world. There are a bunch of concepts that, even within dataflow designs, got abandoned in the 90s favour of moving closer to a multi-core Turing machine, just with hardware message passing and semaphores. ## Project Goals -- Dynamic dataflow CPU achievable with discrete logic (74-series TTL + SRAM) +- Dynamic dataflow CPU achievable with discrete logic (74-series TTL or CMOS + SRAM) - Multi-PE design targeting superscalar-equivalent IPC - "Period-plausible" transistor budget: ~25-35K logic transistors + SRAM chips - Comparable to a 68000 or a couple of Z80s in logic complexity @@ -38,6 +38,30 @@ The other reason is that I do genuinely think we might have missed something whe ## Things the OR-1 does differently - **Very** small instruction and operand storage in the CM (think register file or L1 cache, not RAM) at least relative to other dataflow computers -- SM acts like L2 cache or RAM. - This means that instructions must be fetched while running. -- There is still no program counter or similar, loads are explicit. The compiler/assembler inserts loads as best it can. \ No newline at end of file +- There is still no program counter or similar, loads are explicit. The compiler/assembler inserts loads as best it can. + - The 'exec' SM instruction offers a straightforward way to load a coherent block of code into the instruction cache at runtime and optionally trigger its execution. +- SM is a hybrid of owned I-structure-esque memory and a standard shared address space with more typical guarantees. + - ROM and memory-mapped IO devices which do not need I-structure guarantees are generally mapped into the shared address space. + - Stronger guarantees over a block of raw memory space can be obtained in the typical way using synchronization primitives located in I-structure memory + +### Boot process + +One of the challenges in making a dataflow CPU without a dedicated (and more conventional) control unit is figuring out how to bootstrap the system. Instructions must be loaded into the control memory elements and the first seed tokens must be emitted. The OR-1 solves this by giving one structure memory element responsibility for bootstrapping the system. It reuses the SM 'exec' instruction circuitry with a hardwired address to clock tokens stored in ROM onto the bus until it reaches a stop signal. + +1. Bootstrap SM (SM00) activates on reset +2. Reset signal latches reset vector address into SM00's address register and triggers 'exec' instruction circuit +3. SM00 loads contents at reset vector into counter register, adds address +4. SM00 loads next address, and pushes direct to interconnect, increments address +5. Repeat until address == counter +6. Bootstrap SM input FIFO output now enabled + +The contents of the reset vector, after the length, contain the raw tokens required to load each PE's initial instructions and data, plus any seed tokens to begin execution. This can be a simple bootstrap program to enable loading of other code, or it can be the primary program itself. Valid programs must not send commands to the bootstrap SM during this process. The input FIFO's output is disabled during exec instructions, but putting any traffic intended for SM00 onto the bus risks interfering with other bus traffic, as SM00 makes no guarantees about the behaviour of its input FIFO during the boot process. + +### Dynamic scheduling + +The OR-1 is, for a number of reasons, a mostly *static* dataflow machine. The way instructions are routed is built in to the instructions and tokens themselves. There's no dynamic load balancing inherent to the machine, as that would add a nontrivial amount of additional logic. There are of course a few escapes for this. One is `exec`, which reads out memory directly as tokens, and the closely related `iram_write`, which replaces the contents of a cell in instruction memory. Another, not yet implemented, is `mkpkt`, which creates an arbitrary packet from two operands. + +### Function calls + +While obviously the `exec` instruction can effectively "call" a function, that is a high-overhead operation. Optimized code interleaves IRAM writes following `free_ctx` operations. \ No newline at end of file diff --git a/design-notes/alu-and-output-design.md b/design-notes/alu-and-output-design.md index 57e6c2e..3712a80 100644 --- a/design-notes/alu-and-output-design.md +++ b/design-notes/alu-and-output-design.md @@ -166,30 +166,30 @@ FREE_CTX Deallocate context slot (monadic). Clears the slot's occupied > IntEnum ordinal values that do NOT correspond to these bit patterns. > Final hardware encoding will be determined during physical build. -| Opcode | Mnemonic | Arity | Output Mode | Description | -|--------|----------|-------|-------------|-------------| -| 00000 | ADD | dyadic | DUAL or SINGLE | A + B | -| 00001 | SUB | dyadic | DUAL or SINGLE | A - B | -| 00010 | INC | monadic | DUAL or SINGLE | A + 1 | -| 00011 | DEC | monadic | DUAL or SINGLE | A - 1 | -| 00100 | AND | dyadic | DUAL or SINGLE | A & B | -| 00101 | OR | dyadic | DUAL or SINGLE | A \| B | -| 00110 | XOR | dyadic | DUAL or SINGLE | A ^ B | -| 00111 | NOT | monadic | DUAL or SINGLE | ~A | -| 01000 | SHL | monadic | DUAL or SINGLE | A << N (imm) | -| 01001 | SHR | monadic | DUAL or SINGLE | A >> N (imm, logical) | -| 01010 | ASR | monadic | DUAL or SINGLE | A >> N (imm, arithmetic) | -| 01011 | EQ | dyadic | DUAL or SINGLE | A == B → bool | -| 01100 | LT | dyadic | DUAL or SINGLE | A < B signed → bool | -| 01101 | GT | dyadic | DUAL or SINGLE | A > B signed → bool | -| 01110 | ULT | dyadic | DUAL or SINGLE | A < B unsigned → bool | -| 01111 | UGT | dyadic | DUAL or SINGLE | A > B unsigned → bool | -| 10000 | SWITCH | dyadic | SWITCH | route data by bool | -| 10001 | GATE | dyadic | GATE | pass or suppress by bool | -| 10010 | PASS | monadic | DUAL or SINGLE | identity | -| 10011 | CONST | monadic | DUAL or SINGLE | output = immediate | -| 10100 | FREE_CTX | monadic | SUPPRESS | deallocate slot | -| 10101-11111 | — | — | — | reserved for expansion | +| Opcode | Mnemonic | Arity | Output Mode | Description | +| ----------- | -------- | ------- | -------------- | ------------------------ | +| 00000 | ADD | dyadic | DUAL or SINGLE | A + B | +| 00001 | SUB | dyadic | DUAL or SINGLE | A - B | +| 00000 | INC | monadic | DUAL or SINGLE | A + 1 (imm const) | +| 00001 | DEC | monadic | DUAL or SINGLE | A - 1 (imm const) | +| 00100 | AND | dyadic | DUAL or SINGLE | A & B | +| 00101 | OR | dyadic | DUAL or SINGLE | A \| B | +| 00110 | XOR | dyadic | DUAL or SINGLE | A ^ B | +| 00111 | NOT | monadic | DUAL or SINGLE | ~A | +| 01000 | SHL | monadic | DUAL or SINGLE | A << N (imm) | +| 01001 | SHR | monadic | DUAL or SINGLE | A >> N (imm, logical) | +| 01010 | ASR | monadic | DUAL or SINGLE | A >> N (imm, arithmetic) | +| 01011 | EQ | dyadic | DUAL or SINGLE | A == B → bool | +| 01100 | LT | dyadic | DUAL or SINGLE | A < B signed → bool | +| 01101 | GT | dyadic | DUAL or SINGLE | A > B signed → bool | +| 01110 | ULT | dyadic | DUAL or SINGLE | A < B unsigned → bool | +| 01111 | UGT | dyadic | DUAL or SINGLE | A > B unsigned → bool | +| 10000 | SWITCH | dyadic | SWITCH | route data by bool | +| 10001 | GATE | dyadic | GATE | pass or suppress by bool | +| 10010 | PASS | monadic | DUAL or SINGLE | identity | +| 10011 | CONST | monadic | DUAL or SINGLE | output = immediate | +| 10100 | FREE_CTX | monadic | SUPPRESS | deallocate slot | +| 10101-11111 | — | — | — | reserved for expansion | The output mode column indicates the default. DUAL vs SINGLE is controlled by a flag in the IRAM instruction word (has_dest2), not by diff --git a/design-notes/architecture-overview.md b/design-notes/architecture-overview.md index 04e5015..7dd30a8 100644 --- a/design-notes/architecture-overview.md +++ b/design-notes/architecture-overview.md @@ -138,7 +138,7 @@ by SM_id. IO is memory-mapped into SM address space (typically SM00 at v0). ``` Dyadic wide (prefix 00, 2 flits): - flit 1: [0][0][PE:2][offset:5][ctx:4][port:1][gen:2] = 16 bits + flit 1: [0][0][port:1][PE:2][gen:2][offset:5][ctx:4] = 16 bits flit 2: [data:16] = 16 bits Monadic normal (prefix 010, 2 flits): @@ -150,12 +150,12 @@ Dyadic narrow (prefix 011+00, 2 flits): flit 2: [data:8][port:1][gen:2][spare:5] = 16 bits IRAM write (prefix 011+01, 2-3 flits): - flit 1: [0][1][1][PE:2][01][iram_addr:7][flags:2] = 16 bits + flit 1: [0][1][1][PE:2][01][flags:2][iram_addr:7] = 16 bits flit 2: [instruction_word_low:16] = 16 bits (flit 3: [instruction_word_high:8][spare:8] if needed) Monadic inline (prefix 011+10, 1 flit): - flit 1: [0][1][1][PE:2][10][offset:4][ctx:4][spare:1] = 16 bits + flit 1: [0][1][1][PE:2][10][spare:1][offset:4][ctx:4] = 16 bits SM standard (prefix 1, 2 flits): flit 1: [1][SM_id:2][op:3-5][addr:8-10] = 16 bits diff --git a/design-notes/assembler-architecture.md b/design-notes/assembler-architecture.md index 8fdf65e..245861e 100644 --- a/design-notes/assembler-architecture.md +++ b/design-notes/assembler-architecture.md @@ -1,31 +1,21 @@ -# Dynamic Dataflow CPU — Assembler Architecture +# OR-1 CPU - dfasm Assembler Architecture -Covers the `asm/` package: pipeline structure, IR design, pass -architecture, code generation modes, and key implementation decisions. +Covers the `asm/` package: pipeline structure, IR design, pass architecture, code generation modes, and key implementation decisions. See `architecture-overview.md` for the target hardware model. See `dfasm-primer.md` for the language itself. -See `asm/CLAUDE.md` for the contract-level summary. ## Role -The assembler translates dfasm source into emulator-ready configuration -objects (`PEConfig`, `SMConfig`, seed tokens) or a hardware-faithful -bootstrap token sequence. It bridges the gap between human-authored -dataflow graph programs and the structures the emulator (and eventually -hardware) consumes. +The assembler translates dfasm source into emulator-ready configuration objects (`PEConfig`, `SMConfig`, seed tokens) or a hardware-faithful bootstrap token sequence. It bridges the gap between human-authored dataflow graph programs and the structures the emulator (and eventually hardware) consumes. -The assembler does NOT optimise. It does not reorder instructions for -performance, fuse operations, or eliminate redundant subgraphs. It is a -faithful translator: the graph you write is the graph you get. Future -optimisation passes may be inserted between resolve and place, but the -current pipeline is intentionally thin — correctness first, cleverness -later. +The assembler does NOT (currently) optimize. It does not reorder instructions for performance, fuse operations, or eliminate redundant sub-graphs. It is a faithful translator: the graph you write is the graph you get. Future optimization passes may be inserted between resolve and place, but the current pipeline is intentionally thin. Correctness first, cleverness later. ## Pipeline Overview -Six stages, each a pure function from `IRGraph → IRGraph` (or -`IRGraph → output`). The pipeline is: +Six stages, each a pure function from `IRGraph → IRGraph` (or `IRGraph → output`). + +The pipeline is: ``` dfasm source @@ -50,17 +40,12 @@ dfasm source or SM init → ROUTE_SET → LOAD_INST → seeds (token mode) ``` -Each pass returns a new `IRGraph`. Graphs are never mutated after -construction — each pass produces a fresh copy with the new information -filled in. Errors accumulate in `IRGraph.errors` rather than failing -fast, so the assembler reports all problems in a single pass rather -than forcing the programmer to fix them one at a time. +Each pass returns a new `IRGraph`. Graphs are never mutated after construction, each pass produces a fresh copy with the new information filled in. Errors accumulate in `IRGraph.errors` rather than failing fast, so the assembler reports all problems in a single pass rather than forcing the programmer to fix them one at a time. -The public API orchestrates the pipeline and raises `ValueError` if any -stage produces errors: +The public API orchestrates the pipeline and raises `ValueError` if any stage produces errors: ```python -assemble(source: str) -> AssemblyResult # direct mode +assemble(source: str) -> AssemblyResult # direct mode assemble_to_tokens(source: str) -> list # token stream mode round_trip(source: str) -> str # parse → lower → serialize serialize_graph(graph: IRGraph) -> str # IRGraph → dfasm at any stage @@ -68,9 +53,6 @@ serialize_graph(graph: IRGraph) -> str # IRGraph → dfasm at any stage ## IR Types (`ir.py`) -All IR types are frozen dataclasses, following the conventions of -`tokens.py` and `cm_inst.py`. - ### Core Types | Type | Fields | Purpose | @@ -86,79 +68,51 @@ All IR types are frozen dataclasses, following the conventions of Destinations evolve through the pipeline: -1. **After lower**: `NameRef(name, port)` — symbolic, unresolved -2. **After allocate**: `ResolvedDest(name, addr)` — concrete - `Addr(a, port, pe)` with IRAM offset, port, and target PE - -This two-stage resolution means early passes can work with symbolic -names while later passes have concrete hardware addresses. +1. **After lower**: `NameRef(name, port)` - symbolic, unresolved +2. **After allocate**: `ResolvedDest(name, addr)` - concrete `Addr(a, port, pe)` with IRAM offset, port, and target PE +This two-stage resolution means early passes can work with symbolic names while later passes have concrete hardware addresses, while maintaining the names for debug clarity. ### Graph Traversal Utilities -`IRGraph` provides recursive traversal functions for working with nested -regions: - -- `iter_all_subgraphs()` — depth-first traversal of graph + all region - bodies -- `collect_all_nodes()` — flatten nodes from graph and all nested regions -- `collect_all_nodes_and_edges()` — flatten both -- `collect_all_data_defs()` — flatten data definitions -- `update_graph_nodes()` — recursively update nodes while preserving - region structure +`IRGraph` provides recursive traversal functions for working with nested regions: -These are necessary because function definitions create nested -`IRRegion` objects with their own `IRGraph` bodies. +- `iter_all_subgraphs()`: depth-first traversal of graph + all region bodies +- `collect_all_nodes()`: flatten nodes from graph and all nested regions +- `collect_all_nodes_and_edges()`: flatten both +- `collect_all_data_defs()`: flatten data definitions +- `update_graph_nodes()`: recursively update nodes while preserving region structure ## Pass Details ### Parse -Uses the Lark library with the Earley parser algorithm. The grammar is -in `dfasm.lark`. Earley is required (not LALR) because the grammar has -ambiguities between `location_dir` (a bare qualified reference) and -`weak_edge` (outputs before opcode) that require context-sensitive -resolution. - -The parser produces a concrete syntax tree (CST) — Lark `Tree` objects -with `Token` terminals. No semantic processing happens here. +Uses the Lark library with the Earley parser algorithm. The formal grammar is defined in `dfasm.lark`. Earley is required because the grammar has ambiguities between `location_dir` (a bare qualified reference) and `weak_edge` (outputs before opcode) that require context-sensitive resolution. +The parser produces a concrete syntax tree (CST), a Lark `Tree` objects with `Token` terminals. ### Lower (`lower.py`) -A Lark `Transformer` that walks the CST bottom-up, converting each -grammar rule into IR types. +A Lark `Transformer` that walks the CST bottom-up, converting each grammar rule into IR types. **Key transformations:** -- `inst_def` → `IRNode` with opcode, const, PE placement, named args -- `plain_edge` → `IREdge` with source, dest, port qualifiers -- `strong_edge` / `weak_edge` → anonymous `IRNode` + input/output +- `inst_def` -> `IRNode` with opcode, const, PE placement, named args +- `plain_edge` -> `IREdge` with source, dest, port qualifiers +- `strong_edge` / `weak_edge` -> anonymous `IRNode` + input/output `IREdge` set (creates a `CompositeResult`) - `func_def` → `IRRegion(kind=FUNCTION)` with a nested `IRGraph` body -- `location_dir` → `IRRegion(kind=LOCATION)` — subsequent statements +- `location_dir` -> `IRRegion(kind=LOCATION)`: subsequent statements are collected into its body during post-processing -- `data_def` → `IRDataDef` with SM placement and cell address +- `data_def` -> `IRDataDef` with SM placement and cell address - `system_pragma` → `SystemConfig` (stored on the transformer, attached to the final `IRGraph`) **Name qualification:** -Labels (`&name`) inside function regions are qualified with the function -scope: `&add` inside `$main` becomes `$main.&add`. This scoping is -transparent to the programmer — dfasm source uses bare `&label` names -within functions, and the assembler qualifies them internally. - -Node references (`@name`) and function references (`$name`) are -top-level and are not qualified. +Labels (`&name`) inside function regions are qualified with the function scope: `&add` inside `$main` becomes `$main.&add`. **Opcode mapping (`opcodes.py`):** -Mnemonic strings from the grammar are mapped to `ALUOp`, `MemOp`, or -`CfgOp` enum values via `MNEMONIC_TO_OP`. A complication: Python -`IntEnum` subclasses can share numeric values across types -(`ArithOp.ADD == 0 == MemOp.READ`), so the reverse mapping and set -membership tests use type-aware collections -(`TypeAwareOpToMnemonicDict`, `TypeAwareMonadicOpsSet`) that key on -`(type, value)` tuples internally. +Mnemonic strings from the grammar are mapped to `ALUOp`, `MemOp`, or `CfgOp` enum values via `MNEMONIC_TO_OP`. A complication: Python `IntEnum` sub-classes can share numeric values across types (`ArithOp.ADD == 0 == MemOp.READ`), so the reverse mapping and set membership tests use type-aware collections (`TypeAwareOpToMnemonicDict`, `TypeAwareMonadicOpsSet`) that key on `(type, value)` tuples internally. ### Resolve (`resolve.py`) @@ -166,34 +120,29 @@ Validates that all edge endpoints exist in the flattened namespace. **Process:** -1. Flatten all nodes from the graph and all nested regions into a single - namespace -2. Build a scope map: qualified name → defining scope +1. Flatten all nodes from the graph and all nested regions into a single namespace +2. Build a scope map: qualified name -> defining scope 3. For each edge, check that both source and dest exist -4. Detect cross-function label references (a `&label` in `$main` cannot - reference a `&label` in `$other`) and flag as SCOPE errors -5. For undefined references, compute Levenshtein distance against all - known names and suggest the closest match ("did you mean `&addr`?") +4. Detect cross-function label references (a `&label` in `$main` cannot reference a `&label` in `$other`) and flag as SCOPE errors +5. For undefined references, compute Levenshtein distance against all known names and suggest the closest match ("did you mean `&addr`?") -Resolve does not modify nodes — it only appends errors. +Resolve does not modify nodes, it only appends errors. ### Place (`place.py`) Assigns PE IDs to nodes that don't have explicit placement. -**Explicit placements** (from `|pe0` qualifiers in source) are validated -first: reject any `pe >= pe_count`. +**Explicit placements** (from `|pe0` qualifiers in source) are validated first. +Reject any `pe >= pe_count`. -**Auto-placement algorithm** for unplaced nodes (greedy, insertion -order): +**Auto-placement algorithm** for unplaced nodes (greedy, insertion order): 1. For each unplaced node, find its connected neighbours via edges 2. Count PE occurrences among placed neighbours (locality heuristic) -3. Sort candidate PEs by: most neighbours (descending), then most - remaining IRAM capacity (tie-break) +3. Sort candidate PEs by: most neighbours (descending), + then most remaining IRAM capacity (tie-break) 4. Place on the first PE with room for both IRAM and context slots -5. If no PE fits, record a placement error with per-PE utilisation - breakdown +5. If no PE fits, record a placement error with per-PE utilization breakdown **IRAM cost model:** @@ -202,45 +151,31 @@ order): **System config inference:** -If no `@system` pragma is provided, the placer infers `pe_count` from -the highest explicit PE ID and uses defaults for IRAM capacity (64) and -context slots (4). +If no `@system` pragma is provided, the placer infers `pe_count` from the highest explicit PE ID and uses defaults for IRAM capacity (64) and context slots (4). ### Allocate (`allocate.py`) -Assigns three things per node: IRAM offset, context slot, and resolved -destinations. +Assigns three things per node: IRAM offset, context slot, and resolved destinations. **IRAM layout (per PE):** -Dyadic instructions are packed at low offsets (0..D-1), monadic at -higher offsets (D..D+M-1). This matches the hardware contract in -`pe-design.md`: the token's offset field doubles as the matching store -entry for dyadic instructions, so they must occupy the dense low range. +Dyadic instructions are packed at low offsets (0..D-1), monadic at higher offsets (D..D+M-1). This matches the hardware contract in `pe-design.md`: the token's offset field doubles as the matching store entry for dyadic instructions, so they must occupy the dense low range. **Context slot assignment (per PE):** -Each function scope gets a distinct context slot. The root scope -(top-level) always gets slot 0. Additional scopes get slots 1, 2, ... -in order of first appearance. Overflow beyond `ctx_slots` is a RESOURCE -error. +Each function scope gets a distinct context slot. The root scope (top-level) always gets slot 0. Additional scopes get slots 1, 2, ... in order of first appearance. Overflow beyond `ctx_slots` is a RESOURCE error. **Destination resolution:** -For each node, outgoing edges are resolved to `ResolvedDest` objects -containing concrete `Addr(a=iram_offset, port=Port.L|R, pe=target_pe)`. -The allocator handles: +For each node, outgoing edges are resolved to `ResolvedDest` objects containing concrete `Addr(a=iram_offset, port=Port.L|R, pe=target_pe)`. The allocator handles: -- Single outgoing edge → `dest_l` -- Two outgoing edges → `dest_l` + `dest_r` (distinguished by - `source_port` qualifier or positional order) -- Port conflicts (duplicate ports, mixed explicit/implicit) → PORT error +- Single outgoing edge -> `dest_l` +- Two outgoing edges -> `dest_l` + `dest_r` (distinguished by `source_port` qualifier or positional order) +- Port conflicts (duplicate ports, mixed explicit/implicit) -> PORT error **SM ID assignment:** -For `MemOp` nodes, the allocator assigns the target SM ID. Single-SM -systems default to `sm_id=0`. Multi-SM systems with ambiguous targets -produce a RESOURCE error. +For `MemOp` nodes, the allocator assigns the target SM ID. Single-SM systems default to `sm_id=0`. Multi-SM systems with ambiguous targets produce a RESOURCE error. ### Codegen (`codegen.py`) @@ -250,37 +185,27 @@ Two output modes from the same allocated `IRGraph`: Produces immediately usable emulator configuration: -- `pe_configs`: list of `PEConfig` with populated IRAM (ALUInst/SMInst - by offset), context slot count, and route restrictions -- `sm_configs`: list of `SMConfig` with initial cell values from data - definitions -- `seed_tokens`: `MonadToken` for each `CONST` node with no incoming - edges (these kick off execution) +- `pe_configs`: list of `PEConfig` with populated IRAM (ALUInst/SMInst by offset), context slot count, and route restrictions +- `sm_configs`: list of `SMConfig` with initial cell values from data definitions +- `seed_tokens`: `CMToken` for each strongly-connected `CONST` node with no incoming edges -Route restrictions are computed by scanning all edges from each PE to -determine which other PEs and SMs it can reach. Self-routes are always -included. +Route restrictions are computed by scanning all edges from each PE to determine which other PEs and SMs it can reach. Self-routes are always included. **Token stream mode** (`generate_tokens() → list`): Produces a hardware-faithful bootstrap sequence: -1. **SM init tokens** — `SMToken(op=WRITE)` for each data definition -2. **ROUTE_SET tokens** — `RouteSetToken` per PE with route restrictions -3. **LOAD_INST tokens** — `LoadInstToken` per PE with full IRAM contents -4. **Seed tokens** — same `MonadToken` list as direct mode +1. **SM init tokens**: `SMToken(op=WRITE)` for each data definition +2. **IRAMWrite tokens**: `IRAMWrite` tokens per PE with instructions +3. **Seed tokens**: same `CMToken` list as direct mode -This ordering mirrors what the hardware bootstrap would do: initialise -structure memory, configure routing, load instruction memory, then -inject the initial tokens that start execution. +This ordering mirrors what the hardware bootstrap would do: initialize structure memory, load instruction memory, then inject the initial tokens that start execution. -Both modes reuse the same internal logic — token stream mode calls -`generate_direct()` internally and then reformats the result. +Both modes reuse the same internal logic. Token stream mode calls `generate_direct()` internally and then re-formats the result. ## Error Handling (`errors.py`) -Errors are structured with category, source location, message, and -optional suggestions. Categories: +Errors are structured with category, source location, message, and optional suggestions. | Category | Stage | Examples | |----------|-------|---------| @@ -305,105 +230,20 @@ error[SCOPE]: Duplicate label '&add' in function '$main' = help: First defined at line 2 ``` -## Key Design Decisions - -### Immutable Pass Pattern - -Each pass returns a new `IRGraph`. This simplifies debugging (you can -inspect intermediate representations), enables future caching, and -follows functional programming discipline. The tradeoff is allocation -overhead from copying, but for assembler-scale programs this is -negligible. - -### Dyadic-First IRAM Layout - -Dyadic instructions are packed at low IRAM offsets so the token's offset -field doubles as the matching store entry index. This is a hardware -constraint from `pe-design.md` (option b) — no extra bits or lookup -tables needed. The compiler must cooperate, and it does. - -### Greedy Placement - -The placer is intentionally simple: greedy bin-packing with a locality -heuristic. No simulated annealing, no ILP solver, no graph partitioning -library. For the target scale (4 PEs, tens of instructions), greedy is -adequate. The locality heuristic (prefer the PE where connected -neighbours already live) naturally minimises cross-PE token traffic. - -More sophisticated placement is a future concern — the pass interface -(`IRGraph → IRGraph`) means the placer is swappable without touching -anything else. - -### Error Accumulation - -All phases append errors to `IRGraph.errors` rather than raising -immediately. This means the programmer sees all problems at once, not -a frustrating one-at-a-time reveal. The pipeline orchestrator -(`__init__.py`) raises `ValueError` after each stage if errors are -present. - -### Type-Aware Opcode Collections - -Python `IntEnum` values collide across subclasses (`ArithOp.ADD = 0 = -MemOp.READ`). Plain dicts and sets lose type information. The -`TypeAwareOpToMnemonicDict` and `TypeAwareMonadicOpsSet` in -`opcodes.py` key on `(type(op), op.value)` tuples to avoid this. - -## Module Dependency Graph - -``` -dfasm.lark (grammar) - │ - ▼ -lower.py ──→ ir.py (types) ──→ opcodes.py (mnemonic mapping) - │ │ - ▼ ▼ -resolve.py errors.py (error types) - │ - ▼ -place.py - │ - ▼ -allocate.py - │ - ▼ -codegen.py ──→ cm_inst (ALUInst, SMInst, Addr) - │ ──→ tokens (MonadToken, SMToken, CfgToken variants) - │ ──→ emu/types (PEConfig, SMConfig) - │ ──→ sm_mod (Presence) - ▼ -__init__.py (pipeline orchestration, public API) -``` - -**Boundary rule**: `emu/` and root-level modules never import from -`asm/`. The assembler depends on the emulator's types, not the other -way around. - ## Serialization and Round-Tripping (`serialize.py`) -The serializer emits valid dfasm source from an `IRGraph` at any -pipeline stage. This enables: +The serializer emits valid dfasm source from an `IRGraph` at any pipeline stage. This enables: -- **Round-trip testing**: `source → parse → lower → serialize → source'` - verifies that the parser and lowering pass preserve the program -- **IR inspection**: dump the graph after any pass to see what the - assembler is doing -- **Code generation from IR**: future tools could construct `IRGraph` - objects programmatically and serialize them to dfasm +- **Round-trip testing**: `source → parse → lower → serialize → source'` verifies that the parser and lowering pass preserve the program +- **IR inspection**: dump the graph after any pass to see what the assembler is doing +- **Code generation from IR**: future tools could construct `IRGraph` objects programmatically and serialize them to dfasm -The serializer unqualifies names inside function regions (strips the -`$func.` prefix), preserves port and placement qualifiers, and formats -values as hex (if > 255) or decimal. +The serializer unqualifies names inside function regions (strips the `$func.` prefix), preserves port and placement qualifiers, and formats values as hex (if > 255) or decimal. ## Future Work -- **Optimisation passes** between resolve and place: dead node - elimination, constant folding, subgraph deduplication -- **Macro expansion**: the grammar already supports `#macro` syntax in - data definitions; the expansion pass is not yet implemented -- **Wider placement heuristics**: graph partitioning, min-cut - algorithms, or profile-guided placement for larger programs -- **Incremental reassembly**: modify part of the graph and re-run only - affected passes -- **Hardware encoding pass**: translate ALUInst/SMInst to bit-level - instruction words for actual IRAM loading +- **Optimization passes** between resolve and place: dead node elimination, constant folding, sub-graph deduplication +- **Macro expansion**: the grammar already supports `#macro` syntax; the expansion pass is not yet implemented +- **Wider placement heuristics**: graph partitioning, min-cut algorithms, or profile-guided placement for larger programs +- **Incremental reassembly**: modify part of the graph and re-run only affected passes +- **Hardware encoding pass**: translate ALUInst/SMInst to bit-level instruction words for actual IRAM loading diff --git a/design-notes/dfasm-primer.md b/design-notes/dfasm-primer.md index 928e792..c638af6 100644 --- a/design-notes/dfasm-primer.md +++ b/design-notes/dfasm-primer.md @@ -1,28 +1,20 @@ -# dfasm — Dataflow Graph Assembly Language +# dfasm - Dataflow Graph Assembly Language -A primer on the dfasm dialect: syntax, semantics, naming conventions, -and the mapping from source to executable configuration. +A primer on the assembly dialect used in the OR-1 See `assembler-architecture.md` for the assembler's internal pipeline. See `architecture-overview.md` for the hardware model dfasm targets. ## What dfasm Is -dfasm is a textual representation of dataflow graphs. Each instruction -is a **node** with zero, one, or two inputs and up to two outputs. -Connections between nodes are **edges**. Execution is data-driven: -a node fires when all its required operands have arrived as tokens on -the network. - -dfasm is not sequential assembly. There is no program counter, no -implicit instruction ordering. The order of statements in the source -file has no effect on execution — only the graph topology matters. +dfasm is a representation of low-level dataflow program graphs in text form. Each instruction forms a **node** with zero, one, or two inputs, and up to two outputs. Connections between nodes are conceived of as graph edges. Execution is entirely data-driven. A node fires when all its required operands have arrived as tokens. +It is *not* conventional assembly with strong implicit sequential behaviour. There is no program counter, and jumps and execution order are driven primarily by the graph topology. Writing things in a sensible order is up to you, and is for your own benefit. ## Syntax Overview ### Comments -Semicolons start line comments (traditional assembler convention): +Semicolons start line comments, as is fairly common for assembly. While the parser is sophisticated, I've chosen to do this to set a specific tone. dfasm, while it has a number of seemingly sophisticated features, remains an *assembly language*, tightly coupled to the low-level functions of the hardware. ```dfasm ; This is a comment @@ -33,19 +25,17 @@ Semicolons start line comments (traditional assembler convention): dfasm uses three sigil-prefixed naming conventions: -| Sigil | Scope | Use | -|-------|-------|-----| -| `@name` | Global (top-level) | Node references, data definitions | -| `&name` | Local (within enclosing function) | Labels for instructions | -| `$name` | Global | Function / subgraph definitions | +| Sigil | Scope | Use | +| ------- | --------------------------------- | --------------------------------- | +| `@name` | Global (top-level) | Node references, data definitions | +| `&name` | Local (within enclosing function) | Labels for instructions | +| `$name` | Global | Function / subgraph definitions | -Names are composed of `[a-zA-Z_][a-zA-Z0-9_]*`. Sigils are part of the -reference syntax, not the name itself. +Names are composed of `[a-zA-Z_][a-zA-Z0-9_]*`. ### Qualifier Chains -Names can be chained with placement and port qualifiers. No spaces -are allowed within a chain: +Names can be chained with placement and port qualifiers. No spaces are allowed within a chain. Placement indicators are mostly optional. The assembler will attempt to resolve and auto-place instructions, currently using a basic greedy locality heuristic. If an instruction cannot be placed, or the assembler places it badly, you can manually assign its placement. ```dfasm &sum|pe0:L ; label "sum", placed on PE 0, left port @@ -59,29 +49,38 @@ are allowed within a chain: | Port | `:L` or `:R` | Left or right input port (for edges) | | Cell address | `:N` | SM cell address (for data definitions) | -Placement is optional — the assembler auto-places unplaced nodes using -a greedy locality heuristic. - ## Statement Types -### System Pragma +### Pragma + +Pragmas are built-in nodes that provide specific information about how the program should be assembled. -Declares hardware configuration. Required for programs that need -specific PE/SM counts: +### `@system` + +Declares hardware configuration. Required for programs that need specific PE/SM counts: ```dfasm @system pe=4, sm=1, iram=128, ctx=4 ``` -| Parameter | Required | Default | Meaning | -|-----------|----------|---------|---------| -| `pe` | yes | — | Number of processing elements | -| `sm` | yes | — | Number of structure memory modules | -| `iram` | no | 64 | IRAM capacity per PE (instruction slots) | -| `ctx` | no | 4 | Context slots per PE | +| Parameter | Required | Default | Meaning | +| --------- | -------- | ------- | ---------------------------------------- | +| `pe` | yes | — | Number of processing elements | +| `sm` | yes | — | Number of structure memory modules | +| `iram` | no | 64 | IRAM capacity per PE (instruction slots) | +| `ctx` | no | 4 | Context slots per PE | At most one `@system` pragma per program. +### `@rom_data` + +Declares that the following section will be placed in ROM with an optional name and base address. If the address is not specified, it will be placed after the contents of the reset vector. It will *not* be emitted as tokens during bootstrapping. + +```dfasm +@rom_data [name=, addr=...] +``` + +Unlike the `@system` pragma, the `@rom_data` pragma can be used more than once. Functions placed in a `@rom_data` section can be loaded via `exec`, as can a named `@rom_data` section. A function with its seed tokens in its scope while be called with those tokens. ### Instruction Definition Defines a named node with an opcode and optional arguments: @@ -90,8 +89,8 @@ Defines a named node with an opcode and optional arguments: &label <| opcode [, arg ...] ``` -The `<|` operator reads as "receives from" — the node receives data -from whatever edges point to it. +The `<|` operator reads as "receives from". +The node receives data from whatever edges point to it. **Examples:** @@ -117,13 +116,12 @@ Wires a named source to one or more named destinations: &source |> &dest1:L, &dest2:R ; fan-out to two destinations ``` -The `|>` operator reads as "flows to" — data flows from source to -destination. Port qualifiers on the destination specify which input -the data arrives on. Port qualifiers on the source specify which -output slot it leaves from (relevant for dual-output nodes like -switch operations). +The `|>` operator reads as "flows to". Data flows from source to destination. Port qualifiers on the destination specify which input the data arrives on. Port qualifiers on the source specify which +output slot it leaves from (relevant for dual-output nodes like switch operations). -**Default port is L (left)** when no port is specified. +> `const` instructions on the left/source side create 'seed' tokens, injected into the machine after loading, at startup, or when their function enters scope. + +**When no port is specified, the default is L** ### Strong Edge (Inline Anonymous Node) @@ -137,9 +135,7 @@ opcode inputs... |> outputs... add &a, &b |> &result:L ; anonymous add of &a and &b → &result ``` -This is shorthand. The assembler creates a hidden node (named -`&__anon_N`) and wires the inputs and outputs. Useful for small, -one-off operations that don't need a label. +This is shorthand. The assembler creates a hidden node (named `&__anon_N`) and wires the inputs and outputs. Useful for small, one-off operations that don't need a label. ### Weak Edge (Reverse Inline) @@ -153,8 +149,7 @@ outputs... opcode <| inputs... &result:L add <| &a, &b ; same as: add &a, &b |> &result:L ``` -The distinction between strong and weak edges is purely syntactic — -they produce identical IR. +The distinction between strong and weak edges is currently purely syntactic, they produce identical IR. Future iterations of the OR-1 will execute a series of strong edges as a pseudo-sequential block. ### Function Definition @@ -172,16 +167,13 @@ $fib |> { } ``` -Labels (`&name`) inside a function are scoped to that function. You -cannot reference `&sub1` from outside `$fib`. Internally, the assembler -qualifies the name as `$fib.&sub1`. +Labels (`&name`) inside a function are scoped to that function. You cannot reference `&sub1` from outside `$fib`. Internally, the assembler qualifies the name as `$fib.&sub1`. -Node references (`@name`) are always global — they can be referenced -from anywhere. +> Node references (`@name`) are always global, and can be referenced from anywhere. ### Data Definition -Initialises a structure memory cell before execution begins: +Initializes a structure memory cell before execution begins: ```dfasm @data|sm0:5 = 0x42 ; SM 0, cell 5, value 0x42 @@ -189,9 +181,7 @@ Initialises a structure memory cell before execution begins: @msg|sm1:10 = "hello" ; string chars as packed 16-bit words ``` -Data definitions require SM placement (`|smN`) and a cell address -(`:N`). The assembler translates these into SM write tokens during -bootstrap. +Data definitions require SM placement (`|smN`) and a cell address (`:N`). The assembler translates these into SM write tokens during bootstrap, if placed in RAM, or into a text section of the ROM image if placed in ROM. ### Location Directive @@ -203,68 +193,63 @@ Sets a location context for subsequent definitions: &b <| add ``` -Statements following a location directive are collected into that -location's scope until the next function or location directive. +Statements following a location directive are collected into that location's scope until the next function or location directive. ## Opcodes ### Arithmetic (dyadic unless noted) -| Mnemonic | Arity | Description | -|----------|-------|-------------| -| `add` | dyadic | L + R | -| `sub` | dyadic | L − R | -| `inc` | monadic | data + 1 | -| `dec` | monadic | data − 1 | -| `shiftl` | monadic | shift left by const+1 bits | -| `shiftr` | monadic | logical shift right by const+1 bits | -| `ashiftr` | monadic | arithmetic shift right by const bits | +| Mnemonic | Arity | Description | +| --------- | ------- | -------------------------------- | +| `add` | dyadic | L + R | +| `sub` | dyadic | L − R | +| `inc` | monadic | data + 1 | +| `dec` | monadic | data − 1 | +| `shiftl` | monadic | shift left by 1 bits | +| `shiftr` | monadic | logical shift right by 1 bits | +| `ashiftr` | monadic | arithmetic shift right by 1 bits | ### Logical -| Mnemonic | Arity | Description | -|----------|-------|-------------| -| `and` | dyadic | bitwise AND | -| `or` | dyadic | bitwise OR | -| `xor` | dyadic | bitwise XOR | -| `not` | monadic | bitwise NOT | +| Mnemonic | Arity | Description | +| -------- | ------- | ----------- | +| `and` | dyadic | bitwise AND | +| `or` | dyadic | bitwise OR | +| `xor` | dyadic | bitwise XOR | +| `not` | monadic | bitwise NOT | +| | | | ### Comparison (dyadic, produce bool_out) -| Mnemonic | Description | -|----------|-------------| -| `eq` | L == R | -| `lt` | L < R (signed) | -| `lte` | L ≤ R (signed) | -| `gt` | L > R (signed) | -| `gte` | L ≥ R (signed) | +| Mnemonic | Description | +| -------- | -------------- | +| `eq` | L == R | +| `lt` | L < R (signed) | +| `lte` | L ≤ R (signed) | +| `gt` | L > R (signed) | +| `gte` | L ≥ R (signed) | -Comparison results are signed 2's complement interpretation of 16-bit -values. +Comparison results are signed 2's complement interpretation of 16-bit values. ### Routing / Switching / Branching (dyadic) -These operations route tokens based on a comparison result. They are -all dyadic — they compare L and R, then route accordingly. +These operations route tokens based on a comparison result. They are all dyadic — they compare L and R, then route accordingly. -**Branch operations** (`br*`): emit data to `dest_l` (taken) or -`dest_r` (not taken) based on comparison: +**Branch operations** (`br*`): emit data to `dest_l` (taken) or `dest_r` (not taken) based on comparison: -| Mnemonic | Condition | -|----------|-----------| -| `breq` | L == R | -| `brgt` | L > R | -| `brge` | L ≥ R | -| `brof` | overflow | -| `brty` | type match | +| Mnemonic | Condition | +| -------- | ---------- | +| `breq` | L == R | +| `brgt` | L > R | +| `brge` | L ≥ R | +| `brof` | overflow | +| `brty` | type match | -NOTE: `br*` ops use predicate register and internal-to-PE loopback route -if that hardware is implemented. +> NOTE: +>`br*` ops use predicate register and internal-to-PE loopback route if supported by hardware. -**Switch operations** (`sw*`): like branch, but when the condition is -true, data goes to `dest_l` and a trigger token (value 0) goes to -`dest_r`. When false, trigger goes to `dest_l` and data goes to -`dest_r`: +**Switch operations** (`sw*`): like branch, but when the condition is true, data goes to `dest_l` and a trigger token (value 0) goes to `dest_r`. +When false, trigger goes to `dest_l` and data goes to `dest_r`: | Mnemonic | Condition | |----------|-----------| @@ -276,39 +261,35 @@ true, data goes to `dest_l` and a trigger token (value 0) goes to **Other routing:** -| Mnemonic | Arity | Description | -|----------|-------|-------------| -| `gate` | dyadic | pass data through if bool_out is true, suppress if false | -| `sel` | dyadic | select between inputs | -| `merge` | dyadic | merge two inputs | +| Mnemonic | Arity | Description | +| -------- | ------ | -------------------------------------------------------- | +| `gate` | dyadic | pass data through if bool_out is true, suppress if false | +| `sel` | dyadic | select between inputs | +| `merge` | dyadic | merge two inputs | -### Data (monadic) +### Data -| Mnemonic | Description | -|----------|-------------| -| `pass` | pass data through unchanged | -| `const` | emit constant value (from const field) | -| `free_ctx` | deallocate context slot, no data output | +| Mnemonic | Arity | Description | +| ---------- | ------- | --------------------------------------- | +| `pass` | monadic | pass data through unchanged | +| `const` | monadic | emit constant value (from const field) | +| `free_ctx` | monadic | deallocate context slot, no data output | +| `call` | dyadic | | - `free_ctx` in particular is a special token used to handle function body and loop exits. ### Structure Memory -| Mnemonic | Arity | Description | -|----------|-------|-------------| -| `read` | monadic | read from SM cell (const = cell address) | -| `write` | context-dependent | write to SM cell — monadic if const is set (cell addr from const), dyadic if const is None (cell addr from L operand) | -| `clear` | monadic | clear SM cell | -| `alloc` | monadic | allocate SM cell | -| `free` | monadic | free SM cell | -| `rd_inc` | monadic | atomic read-and-increment | -| `rd_dec` | monadic | atomic read-and-decrement | -| `cmp_sw` | monadic | compare-and-swap | - -Note: `free_ctx` (ALU context deallocation) and `free` (SM cell free) -are disambiguated by mnemonic — `free_ctx` maps to `RoutingOp.FREE_CTX` -while `free` maps to `MemOp.FREE`. - +| Mnemonic | Arity | Description | +| -------- | ----------------- | --------------------------------------------------------------------------------------------------------------------- | +| `read` | monadic | read from SM cell (const = cell address) | +| `write` | context-dependent | write to SM cell — monadic if const is set (cell addr from const), dyadic if const is None (cell addr from L operand) | +| `clear` | monadic | clear SM cell | +| `alloc` | monadic | allocate SM cell | +| `free` | monadic | free SM cell | +| `rd_inc` | monadic | atomic read-and-increment | +| `rd_dec` | monadic | atomic read-and-decrement | +| `cmp_sw` | monadic | compare-and-swap | ### Configuration / System | Mnemonic | Description | @@ -319,8 +300,7 @@ while `free` maps to `MemOp.FREE`. | `iow` | I/O write | | `iorw` | I/O read-write | -These are rarely written by hand — `load_inst` and `route_set` are -generated by the assembler's token stream mode during bootstrap. +These are rarely written by hand — `load_inst` and `route_set` are generated by the assembler's token stream mode during bootstrap. ## Literals @@ -336,8 +316,7 @@ generated by the assembler's token stream mode during bootstrap. **Escape sequences** (in regular strings and char literals): `\n`, `\t`, `\r`, `\0`, `\\`, `\'`, `\"`, `\xHH` -**Multi-char packing:** when multiple char values appear in a data -definition, they are packed big-endian into 16-bit words: +**Multi-char packing:** when multiple char values appear in a data definition, they are packed big-endian into 16-bit words: ```vhdl @data|sm0:0 = 'h', 'i' ; → 0x6869 (h=0x68 in high byte, i=0x69 in low) @@ -347,8 +326,7 @@ All data values are 16-bit unsigned. ## Complete Example -A simple program that adds two constants and routes the result -across PEs: +A simple program that adds two constants and routes the result across PEs: ```vhdl ; Hardware: 2 PEs, no structure memory @@ -368,16 +346,11 @@ across PEs: **What happens at runtime:** -1. The assembler emits two seed tokens (for `&c1` and `&c2`) since - they are `CONST` nodes with no incoming edges. -2. Both tokens arrive at PE 0's matching store. `&result` is a dyadic - instruction — it waits for both operands. -3. When both arrive, the matching store pairs them. The left operand - (3) and right operand (7) feed the ALU. -4. The ALU computes `3 + 7 = 10` and emits a token to `&output` on - PE 1. -5. `&output` is monadic (`pass`) — it bypasses the matching store and - immediately emits the value 10. +1. The assembler emits two seed tokens (for `&c1` and `&c2`) since they are `CONST` nodes with no incoming edges. +2. Both tokens arrive at PE 0's matching store. `&result` is a dyadic instruction — it waits for both operands. +3. When both arrive, the matching store pairs them. The left operand (3) and right operand (7) feed the ALU. +4. The ALU computes `3 + 7 = 10` and emits a token to `&output` on PE 1. +5. `&output` is monadic (`pass`) — it bypasses the matching store and immediately emits the value 10. ## Structure Memory Example @@ -433,14 +406,11 @@ Branch on equality, routing data to the taken or not-taken path: ¬_taken |> &output:R ``` -Since `val == cmp` (both 5), `sweq` evaluates to true: data (5) goes -to `dest_l` (taken) and a trigger token (0) goes to `dest_r` -(not_taken). +Since `val == cmp` (both 5), `sweq` evaluates to true: data (5) goes to `dest_l` (taken) and a trigger token (0) goes to `dest_r` (not_taken). ## Auto-Placement -Nodes without explicit `|peN` qualifiers are automatically placed by -the assembler: +Nodes without explicit `|peN` qualifiers are automatically placed by the assembler: ```vhdl @system pe=3, sm=0 @@ -455,46 +425,24 @@ the assembler: &result |> &output:L ``` -The assembler's greedy placer assigns PEs based on connectivity — nodes -connected by edges prefer to share a PE (minimising cross-PE traffic). -The result is functionally identical to explicit placement. +The assembler's greedy placer assigns PEs based on connectivity. Nodes connected by edges prefer to share a PE (minimizing cross-PE traffic). The result is functionally identical to explicit placement. ## From Source to Execution ### Lowering and Resolution -After parsing, the assembler lowers the CST to an intermediate -representation (`IRGraph`). Names are qualified, scopes are created, -and edges are validated. The resolve pass checks that every edge -endpoint exists and produces suggestions for typos. +After parsing, the assembler lowers the CST to an intermediate representation (`IRGraph`). Names are qualified, scopes are created, and edges are validated. The resolve pass checks that every edge endpoint exists and produces suggestions for typos. ### Placement and Allocation -Unplaced nodes get PE assignments. Then the allocator assigns each node -an IRAM offset and context slot. Dyadic instructions are packed at low -IRAM offsets (0..D-1), monadic above (D..D+M-1). This layout matches -the hardware contract: the token's offset field doubles as the matching -store entry for dyadic instructions. +Unplaced nodes get PE assignments. Then the allocator assigns each node an IRAM offset and context slot. Dyadic instructions are packed at low IRAM offsets (0..D-1), monadic above (D..D+M-1). This layout matches the hardware contract: the token's offset field doubles as the matching store entry for dyadic instructions. -Context slots are assigned per function scope per PE — each function -body sharing a PE gets its own context slot, enabling concurrent -activations to coexist without operand interference. +Context slots are assigned per function scope per PE. Each function body sharing a PE gets its own context slot, enabling concurrent activations to coexist without operand interference. ### Code Generation -The assembler offers two output modes: - -**Direct mode** produces `PEConfig` objects (IRAM contents, route -restrictions, context slot count) and `SMConfig` objects (initial cell -values), plus seed tokens. This is the fast path for the emulator — -configuration is applied directly. +The assembler currently offers two output modes: -**Token stream mode** produces a bootstrap sequence: SM initialisation -writes, route configuration tokens, instruction load tokens, then seed -tokens. This mirrors the hardware bootstrap protocol — the same -sequence that the I/O controller would emit over the network to -configure a physical system. +**Direct mode** produces `PEConfig` objects (IRAM contents, route restrictions, context slot count) and `SMConfig` objects (initial cell values), plus seed tokens. This is the fast path for the emulator. Configuration is applied directly. -Both modes produce identical execution results. The token stream mode -exists because it validates the end-to-end bootstrap path that real -hardware will use. +**Token stream mode** produces a bootstrap sequence: SM initialization writes, route configuration tokens, instruction load tokens, then seed tokens. This mirrors the bootstrap process, loading the code stored at the reset vector. \ No newline at end of file diff --git a/design-notes/iram-and-function-calls.md b/design-notes/iram-and-function-calls.md new file mode 100644 index 0000000..ae3143c --- /dev/null +++ b/design-notes/iram-and-function-calls.md @@ -0,0 +1,658 @@ +# IRAM Format, SM Operations, and Function Call Design + +Covers the instruction memory word format, SM operation encoding, flit 1 +bit layout, and function call/return primitives. + +See `pe-design.md` for overall PE pipeline and matching store. +See `alu-and-output-design.md` for ALU operation set and output formatter. +See `bus-architecture-and-width-decoupling.md` for bus-level token format. +See `sm-design.md` for SM internals and I-structure semantics. + +--- + +## Design Context + +The IRAM word encodes everything the PE needs to execute an instruction +and form output tokens: ALU operation, operand source, output routing, +context management, and SM bus commands. The format must accommodate both +CM compute instructions and SM memory operations within a fixed-width +word. + +### Key Constraints + +- **32-bit effective width.** Two 8-bit SRAM chips read in two cycles + ("half 0" and "half 1"). This keeps per-PE SRAM chip count low (2 + chips for IRAM data, vs 4 for 32-bit parallel or 6 for 48-bit). +- **Two-cycle read overlaps with ALU.** Half 0 is read in cycle N and + feeds the decoder/ALU immediately. Half 1 is read in cycle N+1 and + latched for Stage 5 (output formatter). The ALU executes during the + half 1 read, so no pipeline bubble is introduced. +- **SM bus flit must be emittable without opcode translation.** The PE + does not interpret SM bus opcodes semantically. For const-addressed + SM ops, IRAM supplies the address; for ptr-addressed ops, token data + supplies the address. In both cases, the SM bus opcode bits on the + wire come from the decoder EEPROM's positional mapping, not from + runtime interpretation of the SM command. +- **const:8 feeds only the ALU.** The 8-bit immediate constant lives + entirely in half 0 and is available to the ALU on the first read + cycle. It is never split across halves. + +### IRAM Addressing + +``` +IRAM address = [offset:7][half:1] = 8 bits + half 0: opcode + control + const/params + half 1: destinations (CM) or SM bus data / return routing (SM) +``` + +128 instruction slots per PE. Each slot occupies 2 consecutive SRAM +addresses (half 0 at even, half 1 at odd, or equivalently low bit +selects half). Total SRAM usage: 256 bytes per PE. Fits comfortably +in a single 32Kx8 SRAM chip with address space to spare. + +--- + +## Flit 1 Bit Layout (Bus Token Format) + +All CM token types share a field-aligned layout enabling format-agnostic +hardware operations on ctx and PE fields. + +``` +DYADIC WIDE: [0][0][port:1][PE:2][gen:2][offset:5][ctx:4] + 15 14 13 12-11 10-9 8-4 3-0 + +MONADIC NORM: [0][1][0][PE:2][offset:7][ctx:4] + 15 14 13 12-11 10-4 3-0 + +DYADIC NARROW: [0][1][1][PE:2][0][0][offset:5][ctx:4] + 15 14 13 12-11 10 9 8-4 3-0 + +MONADIC INLINE: [0][1][1][PE:2][1][0][spare:1][offset:4][ctx:4] + 15 14 13 12-11 10 9 8 7-4 3-0 + +IRAM WRITE: [0][1][1][PE:2][0][1][flags:2][iram_addr:7] + 15 14 13 12-11 10 9 8-7 6-0 + +SM: [1][SM_id:2][op:3-5][addr:8-10] + 15 14-13 varies +``` + +### Field Alignment Invariants + +- **ctx** is always bits [3:0] on all standard CM token types. Hardware + that patches ctx (override, CHANGE_TAG) operates on a fixed 4-wire + position regardless of token format. +- **PE** is always bits [12:11] on all CM token types. Routing checks + and PE_id comparison use the same 2-bit position universally. +- **offset** low 5 bits are [8:4] on dyadic wide, dyadic narrow, and + the lower portion of monadic normal's 7-bit offset [10:4]. Monadic + inline uses [7:4] (4-bit offset) due to tighter encoding. +- **Matching store address** for dyadic tokens = bits [8:0] = + [offset:5][ctx:4]. Nine contiguous bits wired directly to matching + store SRAM address pins. No glue logic. + +### Misc Bucket Sub-Type Decode + +Tokens with prefix [011] (bits [15:13]) are discriminated by bits [10:9]: + +``` +[10:9] = 00 → dyadic narrow +[10:9] = 01 → IRAM write +[10:9] = 10 → monadic inline +[10:9] = 11 → reserved / spare +``` + +### dest_type Derivation + +The output token format (dyadic wide vs monadic normal vs monadic inline) +is derived from context rather than stored per-destination in IRAM: + +- SWITCH not-taken cycle: always monadic inline (hardwired in formatter) +- offset < 32: dyadic wide (offset bit 5 or higher is clear) +- offset >= 32: monadic normal + +This eliminates 2 bits of per-destination IRAM storage. Dyadic narrow +output is deferred to v1; all dyadic targets receive dyadic wide tokens. + +--- + +## IRAM Word Format + +### CM Compute (half 0 bit 15 = 0) + +``` +════════════════════════════════════════════════════════════════ +HALF 0 — feeds decoder + ALU on read cycle 1 +════════════════════════════════════════════════════════════════ + +[0][opcode:5][ctx_mode:2][const:8] + 15 14-10 9-8 7-0 +``` + +**opcode:5** — 32 slots. Selects ALU function, arity, output behaviour. +Decoded by EEPROM into control signals. See `alu-and-output-design.md` +for the operation set. + +**ctx_mode:2** — controls output token context source: + +``` +00 = INHERIT ctx and gen in output tokens come from pipeline latches + (inherited from the executing token's context). + const:8 is an 8-bit ALU immediate. +01 = CTX_OVRD ctx and gen in output tokens come from const:8, + reinterpreted as [ctx:4][gen:2][spare:2]. + Used for static cross-context calls. +10 = CHG_TAG output flit 1 comes entirely from left operand data + (16-bit packed tag value). const:8 ignored. + Used for dynamic function calls and returns. +11 = RESERVED future use. +``` + +**const:8** — 8-bit immediate. Interpretation depends on ctx_mode: + +``` +ctx_mode 00: ALU immediate operand (0-255, or signed -128..+127) +ctx_mode 01: [ctx:4][gen:2][spare:2] — context override for outputs +ctx_mode 10: ignored (routing from data in CHANGE_TAG mode) +``` + +``` +════════════════════════════════════════════════════════════════ +HALF 1 — latched for Stage 5, read overlaps with ALU execution +════════════════════════════════════════════════════════════════ + +Bit 15 = has_dest2 (single vs dual destination) + +────────────────────────────────────── +Single destination (has_dest2 = 0): + + [0][dest1_PE:2][dest1_offset:5][dest1_port:1][const_ext:7] + 15 14-13 12-8 7 6-0 + + Full 5-bit offset for dest1 (covers dyadic range 0-31). + const_ext:7 extends half 0's const:8 for multi-purpose use: + - CONST16 opcode: const_ext:7 + const:8 = 15-bit immediate. + Sufficient for any CM flit 1 tag (bit 15 is always 0 for CM). + - Future: wider offset (7-bit monadic targets), predicate + store fields, extended flags. + - Interpretation selected by opcode via decoder EEPROM. + +────────────────────────────────────── +Dual destination (has_dest2 = 1): + + [1][dest1_PE:2][dest1_offset:5][dest1_port:1][dest2_PE:2][dest2_offset:5] + 15 14-13 12-8 7 6-5 4-0 + + Both offsets limited to 5 bits (dyadic range 0-31). + dest2_port derived from convention: + DUAL mode: same as dest1_port + SWITCH mode: opposite of dest1_port (or irrelevant for + monadic inline not-taken trigger) +``` + +### SM Operation (half 0 bit 15 = 1) + +``` +════════════════════════════════════════════════════════════════ +HALF 0 — SM instruction header +════════════════════════════════════════════════════════════════ + +[1][sm_opcode:5][ctx_mode:2][const_addr_or_ret:8] + 15 14-10 9-8 7-0 +``` + +**sm_opcode:5** — PE-internal SM operation code. The decoder EEPROM maps +this to: + +- SM bus wire opcode bits (3-bit or 5-bit, depending on the operation) +- Arity signal (monadic vs dyadic, for matching store bypass) +- Flit 2 content signal (return routing vs data vs packed operands) +- Address source signal (token data vs IRAM const) + +The PE does not interpret SM bus opcodes semantically. The EEPROM +performs positional mapping only. + +**ctx_mode:2** — same semantics as CM compute. Controls whether return +routing uses inherited ctx/gen (mode 00) or overridden ctx/gen (mode 01). +Mode 10 (CHANGE_TAG) not applicable to SM ops. + +**const_addr_or_ret:8** — dual interpretation based on sm_opcode: + +``` +Ptr-addressed, result-returning (SM_READ, SM_RMW): + [ret_PE:2][ret_offset:5][ret_port:1] + Return routing assembled with pipeline ctx/gen. 5-bit ret_offset. + +Ptr-addressed, non-returning (SM_WRITE): + Don't care. + +Const-addressed ops (SM_READ_C, SM_WRITE_C, SM_RMW_C, SM_CAS): + [const_addr:8] + Direct 8-bit address into the target SM's address space. + +CMD ops (SM_EXEC, SM_CMD): + [param:8] + Operation-specific parameter (EXEC count, page value, etc.) +``` + +``` +════════════════════════════════════════════════════════════════ +HALF 1 — SM supplementary data (interpretation varies) +════════════════════════════════════════════════════════════════ + +Ptr-addressed ops (SM_READ, SM_WRITE, SM_RMW): + Don't care. Address comes from token data at runtime. + +Const-addressed, result-returning (SM_READ_C, SM_RMW_C): + [ret_PE:2][ret_offset:7][ret_port:1][SM_id:2][spare:4] + 15-14 13-7 6 5-4 3-0 + + Full 7-bit return offset (covers entire monadic range). + SM_id specifies which structure memory to target. + +Const-addressed, non-returning (SM_WRITE_C): + [SM_id:2][spare:14] — or don't care if SM_id is elsewhere. + 15-14 13-0 + +SM_CAS: + [SM bus flit 1, verbatim: 16 bits] + The hardcoded CAS target. Emitted directly to the bus. + +SM_EXEC / SM_CMD: + [extended_params:16] + Base address, configuration values, or other command parameters. +``` + +--- + +## SM Operation Summary + +SM operations come in paired variants: pointer-addressed (address from +token data, suffix-free) and const-addressed (address from IRAM const +field, suffix `_C`). This avoids burning IRAM slots on per-address +variants of common operations. + +### Addressing Modes + +**Pointer-addressed:** The token's data value is a structure pointer: + +``` +Structure pointer (16 bits): + [spare:4][SM_id:2][addr:10] + 15-12 11-10 9-0 +``` + +The pointer is a fat pointer embedding both the target SM identity and +the cell address. Stage 5 extracts SM_id and addr from the token data to +assemble the SM bus flit. One IRAM entry covers all addresses — array +traversal, pointer chasing, and computed addressing all use the same +instruction. + +**Const-addressed:** The IRAM const field provides an 8-bit address +(256 cells). SM_id comes from half 1. Used for fixed-location access: +IO registers, lock words, call descriptor tables, configuration cells. + +### Operation Table + +``` +sm_opcode mnemonic addr source arity flit 2 content result? +─────────────────────────────────────────────────────────────────────────────── +00000 SM_READ token data monadic return routing* yes +00001 SM_READ_C const monadic return routing** yes +00010 SM_WRITE token data dyadic right operand (data) no +00011 SM_WRITE_C const monadic token data (value) no +00100 SM_RMW token data monadic return routing* yes +00101 SM_RMW_C const monadic return routing** yes +00110 SM_CAS const (half1) dyadic return routing* yes +00111 SM_EXEC varies monadic return routing (opt) optional +01000 SM_CMD varies monadic varies no +01001- (reserved) + 11111 +``` + +`*` return routing assembled from half 0 ret field (5-bit offset) + +pipeline ctx/gen. + +`**` return routing assembled from half 1 ret field (7-bit offset) + +pipeline ctx/gen. SM_id also from half 1. + +### SM Bus Flit Assembly + +Stage 5 assembles SM flit 1 from two possible sources, selected by the +`addr_src` decoder signal: + +``` +Ptr-addressed: + flit 1 = [1][token_data[11:10]][wire_opcode from EEPROM][token_data[9:0] or [7:0]] + +Const-addressed: + flit 1 = [1][half1[5:4]][wire_opcode from EEPROM][half0[7:0]] + (SM_id from half 1, address from half 0 const field) + +CAS (special): + flit 1 = half 1 verbatim (pre-formed SM bus flit) +``` + +Hardware: SM_id mux (2-bit, 2:1) + addr mux (8-10-bit, 2:1) + hardwired +`[1]` prefix + wire opcode from EEPROM. ~2 chips total. + +### SM_WRITE Arity + +SM_WRITE (ptr-addressed) is dyadic: left operand = structure pointer, +right operand = data value. The matching store synchronises pointer and +data availability before the write fires. + +SM_WRITE_C (const-addressed) is monadic: the token's data value is the +value to write, the address comes from IRAM const. No matching needed. + +### SM_CAS + +CAS (compare-and-swap) always uses a const address from half 1 (verbatim +SM bus flit). Both operands provide expected and new values. CAS is a +3-input operation; with dyadic matching limited to 2 inputs, one operand +(the address) must be static. Lock words and atomic counters typically +live at fixed addresses, making this the natural choice. + +``` +SM_CAS bus output: + flit 1: half 1 verbatim (SM bus flit with hardcoded address) + flit 2: return routing (from half 0 ret field + pipeline ctx/gen) + flit 3: [left_operand[7:0]][right_operand[7:0]] (expected + new, 8-bit each) +``` + +--- + +## Output Token Context Source (ctx_mode) + +All output tokens need ctx and gen values. Three sources, selected by +ctx_mode in the IRAM word: + +``` +ctx_mode 00 (INHERIT): + ctx = pipeline latch (inherited from executing token) + gen = pipeline latch + Default for same-context execution. Zero overhead. + +ctx_mode 01 (CTX_OVRD): + ctx = IRAM const[7:4] + gen = IRAM const[3:2] + For static cross-context sends. Both destinations share + the same overridden ctx/gen. Compile-time constant. + +ctx_mode 10 (CHG_TAG / CHANGE_TAG): + Entire flit 1 = left operand data value (16 bits, verbatim). + The packed tag IS flit 1. No field extraction or assembly. + PE, offset, ctx, port, gen all come from the data value. + Right operand becomes flit 2 (payload data). +``` + +### Pipeline Latches (Baseline Infrastructure) + +ctx (4 bits) and gen (2 bits) must survive from Stage 2 (matching / +decode) through Stage 5 (output formation). This requires 6 bits of +pipeline latches across 3 stage boundaries = ~18 flip-flops. Estimated +2-3 TTL chips. + +These latches are required for basic machine operation (INHERIT mode), +not just for function calls. All other context modes (CTX_OVRD, +CHANGE_TAG) add muxing on top of this baseline. + +### Stage 5 Mux Structure + +``` +Tier 1: ctx/gen source select (ctx_mode 00 vs 01) + Mux on 6 wires (ctx:4 + gen:2). ~1 chip. + Select line from decoder EEPROM. + +Tier 2: flit 1 source select (assembled vs CHANGE_TAG bypass) + Mux on 16 wires. ~2 chips. + Select line from decoder EEPROM (ctx_mode == 10). +``` + +Tier 2 takes the entire flit 1 from the left operand bypass latch when +CHANGE_TAG is active, bypassing all assembly logic. The packed tag format +matches the flit 1 bit layout by design — no field rearrangement needed. + +### CHANGE_TAG Hardware + +In addition to the Stage 5 mux (shared infrastructure): + +- **Left operand bypass latch:** 16-bit register that preserves the left + operand value past the ALU (which would otherwise consume it). Loaded + when the decoder signals a CHANGE_TAG-class opcode. ~2 chips. +- **ALU behaviour:** right operand passes through as identity (becomes + flit 2 payload). Left operand is not consumed by ALU. + +Total CHANGE_TAG-specific hardware: ~4 chips per PE (bypass latch + +Stage 5 mux), on top of the ~3 chip baseline for pipeline latches. + +--- + +## Function Call Design + +### The Problem + +A function call in this architecture must solve: + +1. **Code residency** — callee instructions in IRAM on the right PEs. +2. **Context isolation** — fresh context slot for the new activation. +3. **Argument injection** — N argument values tagged into callee's context. +4. **Return linkage** — callee knows where to send results. +5. **Context teardown** — free slot(s) when activation completes. + +### Static Calls (v0) + +For non-recursive calls with compiler-known call graphs, all context +assignments are compile-time constants. The compiler assigns ctx slots to +function chunks when laying out IRAM. + +**Argument passing:** The caller's instructions producing argument values +have their destination fields set to the callee's (PE, offset, ctx) with +ctx_mode = 01 (CTX_OVRD). Arguments flow across context boundaries as +normal token routing. No special call instructions. + +**Return:** For single-call-site functions, the callee's return +instruction has its destination baked into IRAM, pointing back to the +caller's (PE, offset, ctx) with ctx_mode = 01. No dynamic return address. + +**Multiple call sites:** If a function is called from N sites, the callee +needs N return paths. Options: + +- Per-call-site return trampolines in IRAM (duplicate the return + instruction with different destinations). Burns IRAM slots. +- CHANGE_TAG for returns (see Dynamic Calls below). +- The compiler can often avoid the problem by inlining small functions. + +**Context allocation:** Compile-time. The compiler assigns non-overlapping +ctx slot ranges to functions based on the call graph. The generation +counter provides ABA protection if slots are reused across non-overlapping +lifetimes. + +**Context teardown:** Lazy generation invalidation (no explicit FREE_CTX +required). When a slot is reused with an incremented generation counter, +stale presence bits are cleared on first access: + +``` +Token arrives at matching store cell: + presence == 0: → store operand, set presence (normal) + presence == 1, gen matches: → match found, read partner (normal) + presence == 1, gen mismatch: → stored value is stale, overwrite + with new operand, update stored gen +``` + +Hardware cost: one gate changing the write-enable condition on gen +mismatch. The gen comparison already exists. 2-bit gen wraps after 4 +generations; for v0 workloads this is sufficient. 3-bit gen eliminates +wraparound risk if needed. + +**Total overhead for static calls: zero extra instructions.** Function +calls are just IRAM destination configuration. + +### Dynamic Calls (v1) + +For recursive calls, indirect calls (function pointers, trait objects), +and functions with multiple call sites that need dynamic return routing. + +**New primitives required:** + +| Primitive | Type | Hardware | Purpose | +|-----------|------|----------|---------| +| CHANGE_TAG | dyadic CM | ~4 chips/PE | Output routing from data operand | +| EXTRACT_TAG | monadic CM | ~2 chips/PE | Capture runtime ctx+gen as data | +| SM_READ_C | monadic SM | shared with SM | Fetch call descriptors | +| SM-based alloc | SM READ_INC | 0 PE chips | Runtime context slot allocation | + +**CHANGE_TAG (ctx_mode = 10):** Left operand is a 16-bit packed tag +(a pre-formed flit 1 value). Right operand is the data payload. Output +token's flit 1 = left operand verbatim. Flit 2 = right operand. Enables +sending a value to any destination computed at runtime. + +**EXTRACT_TAG:** Monadic instruction. Captures the executing token's +context information as a 16-bit data value (a return continuation). The +return offset comes from an IRAM immediate field; PE_id from hardware; +ctx and gen from pipeline latches. Output is a packed flit 1 value that +can be passed to CHANGE_TAG by the callee to route results back. + +**Call descriptor tables:** Pre-formed flit 1 values for callee argument +destinations, stored in SM at boot (loaded via EXEC). The caller fetches +descriptors via SM_READ_C (const-addressed, monadic, fast) and feeds +them to CHANGE_TAG. + +**Runtime context allocation:** An SM cell serves as an atomic counter. +The caller issues SM_RMW (READ_INC) on the counter; the returned value +(mod N) is the new context slot ID, used on all PEs the callee touches. +Zero PE hardware. SM round-trip latency is acceptable for function call +setup. + +### Dynamic Call Sequence + +``` +Caller (PE0, ctx=3) calls foo(a, b) → result dynamically: + + SM_READ_C(tag_table + 0) → tag_arg0 ; fetch packed flit 1 for arg 0 + SM_READ_C(tag_table + 1) → tag_arg1 ; fetch packed flit 1 for arg 1 + SM_READ_C(tag_table + 2) → tag_ret_dest ; where callee sends ret_cont + EXTRACT_TAG → ret_cont ; pack (PE0, ctx=3, ret_offset, gen) + CHANGE_TAG(tag_arg0, a) → arg 0 to callee + CHANGE_TAG(tag_arg1, b) → arg 1 to callee + CHANGE_TAG(tag_ret_dest, ret_cont) → return continuation to callee + +Callee (receives args + ret_cont via normal matching): + ; ... compute result ... + CHANGE_TAG(ret_cont, result) → routes result back to caller +``` + +SM_READ_C operations can be pipelined. CHANGE_TAG operations are +independent and can fire in parallel once their operands arrive. +Effective critical path: SM read latency + one CHANGE_TAG. Comparable +to a conventional function call with register setup + jump. + +### Partial Execution + +The dataflow execution model supports Amamiya-style partial function +execution naturally. If the callee's argument entry points are +independent instructions (not a single multi-input "begin" node), +arguments arriving early begin executing the callee's body before all +arguments are present. No special hardware support — the compiler +structures the callee's dataflow graph to expose this parallelism. + +### Tail Calls + +If the callee reuses the caller's context slot (no allocation, no +generation increment), the call is a tail call. The compiler simply +routes arguments with the inherited ctx. No CHANGE_TAG needed, no +allocation, no teardown. Falls out of ctx_mode = 00 (INHERIT) naturally. + +--- + +## 15-bit Immediate Constants (CONST16) + +Single-output instructions (has_dest2 = 0) can repurpose half 1's +const_ext:7 field to extend the 8-bit const from half 0: + +``` +CONST16 output value = [const_ext:7][const:8] = 15 bits +``` + +15 bits covers any CM flit 1 tag value (bit 15 is always 0 for CM +tokens). This enables producing packed tag constants for CHANGE_TAG +in a single instruction without SM_READ_C. + +The decoder EEPROM selects the "wide const" interpretation based on the +CONST16 opcode. No additional hardware — the concatenation is wiring. + +--- + +## Hardware Cost Summary + +### Baseline (required for any token output) + +| Component | Chips/PE | Purpose | +|-----------|----------|---------| +| Pipeline latches (ctx:4 + gen:2) | 2-3 | ctx/gen survival through pipeline | +| IRAM SRAM (2x 8-bit) | 2 | Instruction storage | + +### ctx_mode 01 (CTX_OVRD — static cross-context calls) + +| Component | Chips/PE | Purpose | +|-----------|----------|---------| +| Stage 5 ctx/gen mux | ~1 | Select inherited vs IRAM-specified ctx/gen | + +### ctx_mode 10 (CHANGE_TAG — dynamic calls) + +| Component | Chips/PE | Purpose | +|-----------|----------|---------| +| Left operand bypass latch | ~2 | Preserve left operand past ALU | +| Stage 5 flit 1 mux | ~2 | Select assembled flit vs raw data | + +### EXTRACT_TAG (v1) + +| Component | Chips/PE | Purpose | +|-----------|----------|---------| +| Data path mux | ~1-2 | Route pipeline state to ALU output bus | + +Pipeline latches already exist (baseline). EXTRACT_TAG just taps them +into the data output path. + +### SM flit assembly + +| Component | Chips/PE | Purpose | +|-----------|----------|---------| +| SM_id mux (2-bit 2:1) | ~0.5 | Select SM_id from pointer vs half 1 | +| Address mux (8-10-bit 2:1) | ~1.5 | Select addr from pointer vs const | + +### Total incremental cost: baseline → full dynamic calls + +~7-10 chips per PE, layered incrementally. Each capability is +independently useful and testable. + +--- + +## Open Design Questions + +1. **dest2_port convention:** Same as dest1 for DUAL, opposite for + SWITCH, or steal a spare bit for explicit control? Current assumption: + derived from opcode. + +2. **const_ext:7 interpretation table:** Which opcodes use it for wide + const vs wider offset vs predicate fields? Needs to be defined as + the instruction set solidifies. + +3. **SM_id for ptr-addressed ops:** Embedded in the structure pointer + format at bits [11:10]. Does this SM_id assignment need to be + configurable, or is it always a direct hardware ID? + +4. **EXEC return routing ("done" signal):** SM_EXEC could emit a + completion token to a specified destination when the EXEC sequence + finishes. Return routing from half 0 ret field. Useful for code + loading synchronisation. Not yet committed. + +5. **Lazy gen invalidation wraparound:** 2-bit gen wraps after 4 + generations. Sufficient for v0. Monitor during emulation; bump to + 3-bit if wraparound is observed. + +6. **5-bit offset limit on dual-dest and ptr-addressed SM return + routing:** Dual-dest instructions and ptr-addressed SM results can + only target offsets 0-31. Monadic targets at higher offsets require + a PASS trampoline or CHANGE_TAG. Acceptable for v0; monitor during + compilation of real programs. diff --git a/design-notes/loop-patterns-and-flow-control.md b/design-notes/loop-patterns-and-flow-control.md new file mode 100644 index 0000000..5c96fab --- /dev/null +++ b/design-notes/loop-patterns-and-flow-control.md @@ -0,0 +1,1031 @@ +# Loop Patterns and Flow Control Idioms + +Execution patterns for loops, reductions, and flow control in the +dataflow architecture. These are software/compiler conventions built +from existing hardware primitives — no dedicated loop hardware exists. + +Most patterns described here are candidates for **assembler macros**: +reusable expansions that emit the underlying instruction sequences. +The programmer writes `LOOP_COUNTED(counter, limit, body_label)` and +the assembler expands it into the token feedback arcs, SWITCH routing, +and permit structures described below. + +See `iram-and-function-calls.md` for IRAM format and ctx_mode details. +See `alu-and-output-design.md` for SWITCH, GATE, and output modes. +See `sm-design.md` for SM operations referenced by some patterns. + +--- + +## Core Loop Mechanism + +There is no program counter, no branch instruction, and no loop +construct in hardware. Loops are **token feedback arcs**: an +instruction's output token is routed back to an input of an earlier +instruction (or itself) in the dataflow graph. The loop "iterates" +each time a token completes the feedback circuit. + +### Minimal Counted Loop + +``` +graph: + CONST(0) ─────────────────────────────────┐ + │ + ┌───────────────────────────────────────────┤ + │ ▼ + │ ┌─────┐ ┌──────────┐ ┌────────────────┐ + └─►│ INC │────►│ LT limit │────►│ SWITCH │ + └─────┘ └──────────┘ │ true → body │ + ▲ i+1 bool │ false → exit │ + │ └────────┬───────┘ + │ data (i+1) to body ◄──────┘ + │ │ + └── i+1 fed back (same token) ──────┘ +``` + +Instructions (all on same PE, same context): + +```dfasm +; Counted loop: increment from 0, dispatch to body, exit when done + +&counter <| const, 0 ; initial counter value (seed token starts loop) +&step <| inc ; increment counter +&cmp <| lt ; compare counter < limit +&route <| sweq ; route by comparison result + +const |> &cmp:R ; limit value (or SM read if > 255) + +&counter |> &step ; seed → first increment +&step |> &cmp:L ; counter → comparison left +&step |> &route:L ; counter → switch data input (fan-out) +&cmp |> &route:R ; bool → switch control + +&route:L |> &body:L ; taken (true) → dispatch to body +&route:R |> &exit:L ; not-taken (false) → loop done +&route:L |> &step ; feedback arc: counter recirculates +``` + +The feedback arc is just a destination field in the SWITCH instruction +(or a PASS trampoline if SWITCH can't dual-route to both body dispatch +and the INC feedback simultaneously). The loop "runs" as long as tokens +keep flowing through the feedback arc. + +### Timing + +Each iteration traverses: INC → LT → SWITCH → bus → INC. With the v0 +pipeline (no local bypass, all tokens go through external bus), expect +roughly 6-10 cycles per loop control iteration depending on bus +contention and pipeline depth. + +The loop body executes concurrently with loop control — the dispatched +body token enters the body subgraph immediately while the counter +continues to the next iteration. If the body takes longer than one +control iteration, multiple body invocations can be in flight +simultaneously (with appropriate flow control — see Permit Tokens). + +--- + +## Permit-Token Flow Control + +When loop body iterations can execute concurrently, a throttling +mechanism prevents context slot exhaustion. **Permit tokens** are the +standard dataflow idiom for this. + +### Concept + +K permit tokens circulate through the system. Each dispatch to a body +context consumes one permit. Each body completion produces one permit. +At most K body iterations are in flight simultaneously. If no permits +are available, the dispatch GATE stalls — the loop control token waits +in the matching store until a permit arrives. + +``` + permits (K tokens, initially injected at boot) + │ + ▼ + ┌────────┐ + │ GATE │◄──── loop control produces (counter, body_data) + │ L: permit + │ R: dispatch_data + └───┬────┘ + │ (fires only when BOTH permit AND data are ready) + ▼ + dispatch to body context (CHANGE_TAG or CTX_OVRD) + │ + ▼ + body executes ... body completes + │ + ▼ + emit permit token back to GATE (port L) +``` + +### Implementation + +The GATE instruction is dyadic. Left port receives the permit token. +Right port receives the loop's dispatch data (counter value, array +pointer, whatever the body needs). GATE fires only when both are +present — this IS the backpressure mechanism. No special hardware +flow control. + +```dfasm +; Permit-gated dispatch +&gate <| gate ; dyadic: L=permit, R=dispatch data +&loop_output |> &gate:R ; loop control feeds data to gate + +; Body completion recycles the permit +&body_done |> &gate:L ; body's final instruction returns permit +``` + +The body's final instruction emits a token to `&gate:L` as one of its +destinations. This token is the recycled permit. Its data value is +irrelevant (just a trigger); what matters is its presence in the +matching store. + +K is chosen by the compiler: + +- K = 1: fully sequential, one body at a time. safe default. +- K = number of reserved body context slots: maximum parallelism. +- K = pipeline depth / body latency: optimal for throughput. + +### Initial Permit Injection + +At boot (or function entry), K permit tokens must be injected into +the GATE. Options: + +- **CONST chain:** K CONST instructions at sequential offsets, each + with dest targeting the GATE's left port. Triggered by the function + entry token via fan-out. Burns K IRAM slots but is simple. +- **SM EXEC:** pre-load K permit tokens in SM, EXEC emits them. + Uses one IRAM slot for the EXEC trigger. Better for large K. +- **Assembler macro:** `PERMITS(K, gate_offset)` expands to the + appropriate injection sequence. + +### Assembler Macro Sketch + +``` +; PERMIT_LOOP(K, limit, body_label, exit_label) +; Expands to: +; - K CONST instructions emitting initial permits +; - GATE (permit, dispatch_data) guarding body dispatch +; - INC/LT/SWITCH loop control chain +; - feedback arc from SWITCH to INC +; - body return path emitting permit on completion +``` + +The macro assigns IRAM offsets for the control structure and reserves +K context slots for body iterations. + +--- + +## Parallel Reduction + +A common pattern following parallel loop iterations: combine K partial +results into a single value. + +### Binary Reduction Tree + +``` +K=4 partial sums: s0 s1 s2 s3 + \ / \ / + ADD ADD + \ / + ADD + │ + total +``` + +Each ADD is a dyadic instruction in its own right. The partial results +arrive as tokens, match in the ADD's matching store entry, fire, and +produce the next level's input. The tree structure is pure dataflow — +no special reduction hardware. + +For K iterations, the tree has log2(K) levels and K-1 ADD instructions. +With K=8, that's 7 ADDs across 3 levels. All ADDs at the same level +can fire in parallel (they're on different matching store entries or +different PEs). + +### Assembler Macro Sketch + +``` +; REDUCE(op, inputs[], output) +; Expands to: +; - ceil(log2(N)) levels of binary op instructions +; - routing from each level's outputs to next level's inputs +; - final output routed to specified destination +``` + +--- + +## Loop-Carried Accumulators (Self-Loop Pattern) + +A value that updates every iteration and feeds back to itself. The +canonical example: `sum += a[i]`. + +### Matching-Store-as-Register + +A dyadic instruction whose output routes back to its own left port: + +```dfasm +; Self-loop accumulator: sum += each incoming value +&acc <| add ; dyadic: L=accumulated sum, R=new element +&acc |> &acc:L ; feedback: result → own left port + +; Initialise: deposit starting value before first element arrives +&init <| const, 0 +&init |> &acc:L ; seed the accumulator with 0 +``` + +The matching store cell at `&acc`'s (ctx, offset, port L) holds the +accumulator value between iterations. Each new element arriving +on port R triggers the ADD, which deposits the updated sum back +into port L's cell. + +**Timing:** Each accumulation step is a full round-trip: ALU → +output formatter → bus → input FIFO → matching store → ALU. Roughly +6-10 cycles at v0. This is the sequential bottleneck — the accumulator +feedback arc is inherently serial. + +**Extracting the final value:** After the last element is accumulated, +the sum sits in the matching store cell at port L. It needs a "drain" +event to extract it. Options: + +- A sentinel token on port R triggers one final ADD (or PASS), and + dest2 routes the result to the downstream consumer. +- A GATE controlled by a "loop done" boolean from the loop control. + When the loop completes, the GATE opens and the accumulated value + flows out. + +### With Parallel Iterations + +Each body context has its own matching store entries (different ctx → +different SRAM address). K parallel accumulators at the same IRAM +offset but different context slots operate independently. + +```dfasm +; All iterations share the same IRAM instruction for &acc. +; Different context slots → different matching store cells. +; No interference between iterations. + +; ctx=1: sum_1 accumulator (self-loop) +; ctx=2: sum_2 accumulator (self-loop) +; ctx=3: sum_3 accumulator (self-loop) +; ... all at the same &acc instruction, different contexts +``` + +After all iterations complete, a reduction tree combines partial sums. +The permit-token mechanism guarantees that partial sums are ready before +the reduction begins (the permits themselves can be chained to trigger +the reduction). + +--- + +## Predicate Register Optimisation (Future, ~1 Chip) + +A single shared 1-bit register (or small multi-bit register) that +stores a comparison result locally, bypassing the token network for +the boolean path. + +### Benefit + +In the standard loop control pattern, the comparison boolean travels +as a token: LT produces a bool token → bus → matching store → SWITCH +consumes it. The predicate register short-circuits this: + +``` +without predicate register: + LT → [bool token] → bus → matching → SWITCH + cost: full token round-trip for the boolean + +with predicate register: + LT writes bool to predicate register (side effect, no token) + SWITCH reads predicate register (local wire, no matching needed) + cost: zero additional cycles for the boolean path +``` + +The counter feedback arc still goes through the bus. But the boolean +path — typically half the loop control overhead — becomes free. + +### Constraints + +- **Not per-context.** Single shared register. The compiler must + guarantee only one activation uses the predicate at a time. +- **Not suitable for parallel iterations.** Each iteration would need + its own predicate state. Use the token-based boolean path for + parallel loop control. +- **IRAM encoding:** 1-2 bits per instruction (pred_write, pred_read). + Can be folded into opcode space as dedicated variants (LT_P, SWITCH_P) + or drawn from spare bits in half 1. + +### Hardware + +``` +1-bit predicate register: 1 flip-flop +write path (from comparator): 1 gate (write enable) +read mux (to SWITCH): 1 gate (bool_out source select) +Total: ~1 chip (fraction of a chip, really) +``` + +--- + +## Accumulator Register Optimisation (Future, ~3 Chips) + +A single shared 16-bit register writable by the ALU and readable as +an ALU input source. Eliminates the bus round-trip for tight +accumulation loops. + +### Benefit + +``` +without accumulator register: + ADD(acc, new) → output → bus → input → matching → ADD + cost: ~6-10 cycles per accumulation + +with accumulator register: + ACC_ADD: reads acc from register, adds new from token, writes result + back to register. monadic (only new element token needed). + cost: 1 pipeline pass per accumulation (~3-5 cycles) +``` + +### Constraints + +- **Not per-context.** Same single-activation restriction as predicate + register. +- **Monadic operation.** ACC_ADD/ACC_SUB are monadic — the accumulator + is an implicit operand from the register, the explicit operand comes + from the arriving token. No matching store entry consumed. +- **No matching store write conflict.** The register is a separate + storage element from the matching store. ALU writes to the register + at Stage 4; matching store writes happen at Stage 2. No port + conflict, no stall logic needed. +- **Stepping stone to SC blocks.** The accumulator register is + effectively the first register of a future local register file. + Adding a second register + sequential instruction counter yields a + minimal strongly-connected (SC) block capability. + +### Hardware + +``` +16-bit register (2x 74LS374): 2 chips +ALU source mux (add acc_reg input): 1 chip +write enable gating: ~0 chips (1 gate) +Total: ~3 chips per PE +``` + +--- + +## Assembler Macro and Function Call Strategy + +The patterns above are mechanical enough to be assembler macros. +Macros are expected to be simple text/token substitution (C-style +`#define` territory, not Zig comptime or C++ templates). This means: + +- No conditional logic within macros. Different strategies get + different macros, and the programmer picks which to use. +- No offset allocation intelligence. The assembler tracks placement + and validates the expansion (offset collisions, missing labels), + but the macro itself is dumb substitution. +- No type checking or context-slot tracking. The programmer is + responsible for not blowing the slot budget. + +### Macro Syntax + +A macro call uses `#macro_name` and follows the same syntax as any +other operation (arguments, edge wiring, port qualifiers). Macro +definitions follow a function-block-like structure. + +### Function Calls as Syntax + +Using a `$func` label as an instruction generates the appropriate +routing for static calls. Named arguments match against the +function's internal labels: + +```dfasm +; Function definition: +$add_pair |> { + add &a, &b |> #ret +} + +; Static call — named args wire to internal labels: +$add_pair a=&x, b=&y |> @output +``` + +`#ret` is a built-in macro that marks the function's return point. +The assembler resolves `a=&x` to mean "wire `&x`'s output to +`$add_pair.&a`, port L, with ctx_mode=01 and the allocator-assigned +context slot." The `|> @output` wires the return point to `@output`. + +For static calls (non-recursive, known call graph), this generates +only routing annotations on existing instructions — no extra IRAM +entries, no CHANGE_TAG, just destination fields set with ctx_mode=01. + +### Dynamic and Recursive Calls (v1, Manual / Macro-Assisted) + +The assembler does NOT handle recursive or indirect calls +automatically — that's compiler territory. Recursive calls require +runtime context allocation (SM READ_INC), CHANGE_TAG sequences, and +EXTRACT_TAG for return continuations. The assembler provides macros +to reduce boilerplate for the mechanical parts; the programmer +manages descriptor tables, context budgets, and flow control. + +#### The Problem + +A dynamic call to a function with N arguments requires: + +1. **Allocate context** — SM READ_INC on an allocator cell → new_ctx +2. **Build return continuation** — EXTRACT_TAG captures caller's + (PE, ctx, offset, gen) as a 16-bit packed tag value +3. **Fetch N+1 tag templates** — SM_READ_C from a descriptor table + (one per argument destination + one for return destination) +4. **Patch ctx into each tag** — OR new_ctx into bits [3:0] of + each template (templates are pre-built at boot with ctx=0) +5. **Send return continuation** — CHANGE_TAG with patched return tag +6. **Send each argument** — CHANGE_TAG with patched arg tag + value + +Done naively (one IRAM slot per step), this burns 4N+8 IRAM slots +per call site. Two recursive calls = 24+ slots for N=1. With 128 +slots per PE, that's unsustainable. + +#### The EXEC-Based Call Stub + +EXEC is a token cannon — it reads a sequence of pre-formed tokens +from SM/ROM and fires them onto the bus. The tokens can be anything: +SM read requests, CM tokens, triggers. One IRAM slot (the EXEC +trigger) replaces an arbitrary number of pre-staged operations. + +The key insight: steps 3-4 above (fetch tag templates, deliver them +to patching logic) are **identical for every call to the same +function**. The tag templates, their SM addresses, and where to +deliver them are all compile-time constants. Only the allocated +ctx and argument values change per call. + +This splits the call machinery into two parts: + +**Call stub (shared, loaded once per function):** + +IRAM instructions that receive runtime values (ctx, args, return +continuation) and perform the patching + dispatch. These live in +IRAM and are shared across all call sites for the same function. +Different call sites invoke the stub in different context slots, +so their matching store entries don't collide. + +**EXEC sequence (in SM/ROM, per function):** + +Pre-formed tokens that read tag templates from the descriptor table +and deliver them to the stub's OR instructions. Triggered by a +single EXEC instruction. Stored once, fired per call. + +``` +Per-function (loaded once): + call stub in IRAM: ctx fan-out + OR patches + CHANGE_TAGs + EXEC sequence in SM: SM_READ_C tokens targeting the stub + descriptor table: pre-formed flit 1 templates (ctx=0) + +Per-call-site (tiny): + 3 IRAM slots: rd_inc (allocate) + exec (trigger) + extract_tag (return) + wiring: feed ctx, return cont, and arg values into the stub +``` + +#### Call Stub Structure (Example: N=1 Argument) + +```dfasm +; ── call stub for $fib, loaded once, shared across call sites ── +; runs in caller's allocated ctx (different per call → no collision) + +; ctx fan-out: new_ctx needs to reach 2 OR instructions (ret + arg) +&__fib_ctx_fan <| pass +&__fib_ctx_fan |> &__fib_or_ret:R, &__fib_or_n:R + +; tag patching: template (from EXEC'd SM_READ_C) OR'd with new_ctx +&__fib_or_ret <| or ; L: ret tag template, R: new_ctx +&__fib_or_n <| or ; L: arg tag template, R: new_ctx + +; dispatch: patched tag + data → output token +&__fib_ct_ret <| change_tag ; L: patched ret tag, R: return continuation +&__fib_ct_n <| change_tag ; L: patched arg tag, R: argument value + +; internal wiring +&__fib_or_ret |> &__fib_ct_ret:L +&__fib_or_n |> &__fib_ct_n:L +``` + +Stub cost: 1 (PASS fan-out) + 2 (OR) + 2 (CHANGE_TAG) = 5 IRAM slots +for N=1. For N=2: add 1 more PASS in fan-out chain + 1 OR + 1 +CHANGE_TAG = 8 slots. General: 2 + 2N slots (fan-out chain + per-arg +OR and CHANGE_TAG). + +Note: the OR and CHANGE_TAG instructions are dyadic, consuming IRAM +slots in the low-offset range (0-31). The PASS fan-out chain is +monadic and can live in the monadic range (offsets 32+), where IRAM +space is more abundant — monadic instructions don't consume matching +store entries, so the 7-bit offset space is available. + +#### Per-Call-Site Expansion + +```dfasm +; ── per call site: 3 IRAM slots + wiring ── + +&__alloc <| rd_inc, @ctx_alloc ; allocate callee context +&__exec <| exec, @fib_call_seq ; fire tag-fetch sequence +&__extag <| extract_tag, ; capture return continuation + +; wire runtime values into stub (these are edge declarations, not IRAM) +&__alloc |> &__fib_ctx_fan ; new ctx → stub fan-out +&__extag |> &__fib_ct_ret:R ; return cont → stub +&arg_val |> &__fib_ct_n:R ; argument → stub +``` + +rd_inc and extract_tag are monadic. exec is monadic. The per-call-site +cost is 3 monadic IRAM slots — they sit in the monadic offset range +and don't consume matching store entries. + +#### EXEC Sequence Contents (in SM/ROM) + +Pre-formed tokens, stored at boot, fired by exec: + +``` +Token 0: SM_READ_C(@fib_desc + 0) → deliver to &__fib_or_ret:L +Token 1: SM_READ_C(@fib_desc + 1) → deliver to &__fib_or_n:L +``` + +Each token is a fully-formed 2-flit packet: flit 1 = SM read command, +flit 2 = return routing pointing at the stub's OR instruction. The +EXEC sequencer reads these from consecutive SM cells and emits them +onto the bus. The SM processes each read and returns the tag template +to the specified OR instruction. + +#### Fibonacci: Two Recursive Calls + +```dfasm +$fib |> { + ; ── function body ── + &n <| pass + lt &n, 2 |> &test + &test <| sweq + &n |> &test:L + + ; base case + &test:L |> #ret + + ; recursive case + sub &n, 1 |> &n1 + sub &n, 2 |> &n2 + + ; two calls, each 3 monadic IRAM slots + shared stub + &__alloc1 <| rd_inc, @ctx_alloc + &__exec1 <| exec, @fib_call_seq + &__extag1 <| extract_tag, 20 ; results arrive at offset 20 + + &__alloc1 |> &__fib_ctx_fan + &__extag1 |> &__fib_ct_ret:R + &n1 |> &__fib_ct_n:R + + &__alloc2 <| rd_inc, @ctx_alloc + &__exec2 <| exec, @fib_call_seq + &__extag2 <| extract_tag, 21 ; results arrive at offset 21 + + &__alloc2 |> &__fib_ctx_fan + &__extag2 |> &__fib_ct_ret:R + &n2 |> &__fib_ct_n:R + + ; reduction + add &r1, &r2 |> #ret ; r1 at offset 20, r2 at offset 21 +} +``` + +**Important:** The two calls share the same stub IRAM instructions +but run in different contexts (ctx allocated by rd_inc). The matching +store entries for `&__fib_or_ret` etc. are indexed by (ctx, offset), +so different ctx values → different cells → no collision. + +The calls ARE sequenced by data dependencies — the second call can't +fire its CHANGE_TAG until its rd_inc and exec complete, which are +independent of the first call. Both calls can be in flight +simultaneously. + +#### IRAM Budget + +``` + dyadic slots monadic slots + (0-31 range) (32-127 range) +───────────────────────────────────────────────────────── +function body (fib): ~6 ~4 +call stub (shared): 4 1 +per call site (×2): 0 6 +result reduction: 1 0 +───────────────────────────────────────────────────────── +total: ~11 ~11 +``` + +~22 IRAM slots total for recursive fibonacci. Well within 128. And +if fib is called from external sites, they pay only 3 monadic slots +each — the stub and body are already loaded. + +#### Stub Sharing Across Mutual Recursion + +Two-layer recursion (A calls B calls A) can share ctx allocation and +EXEC infrastructure. If A and B are on the same PE: + +- Each function has its own call stub (different tag templates) +- They share the same `@ctx_alloc` SM cell +- Their EXEC sequences are independent but stored in the same SM +- Both stubs live in IRAM simultaneously at different offsets + +If A and B have the same argument count and shape, a future +optimisation is a *generic* call stub parameterised only by which +EXEC sequence to fire. The tag templates in the descriptor table +encode all the per-function differences. The stub just patches ctx +and dispatches — it doesn't know or care which function it's calling. +This is essentially a vtable dispatch and emerges naturally from the +architecture. + +#### Descriptor Table Layout (in SM, initialised at boot) + +``` +@fib_desc + 0: return destination tag template (ctx=0) + [0][0][port][PE][gen][offset][0000] +@fib_desc + 1: arg 'n' destination tag template (ctx=0) + [0][0][port][PE][gen][offset][0000] + +; for N=2 function: +@func_desc + 0: return tag template +@func_desc + 1: arg 0 tag template +@func_desc + 2: arg 1 tag template +``` + +Templates are full 16-bit flit 1 values with ctx field set to 0. +The stub's OR instruction patches bits [3:0] with the allocated ctx. +Templates are written to SM during bootstrap (via EXEC from ROM or +explicit SM_WRITE_C during init). + +#### SM-Based Argument Passing (Large Functions) + +For functions with many arguments (N > 3), the IRAM cost of the +OR + CHANGE_TAG stub becomes prohibitive — each arg burns 2 dyadic +IRAM slots. An alternative: **stage arguments in SM cells and let +the EXEC sequence deliver them.** + +The caller writes argument values to a block of SM "call frame" +cells using SM_WRITE_C (monadic, const-addressed). The EXEC +sequence's tail end includes SM_READ_C tokens that read those cells +back out and deliver them as tokens to the callee's entry points. +The callee is oblivious — it just sees tokens arriving normally. + +``` +Caller writes args to SM call frame: + SM_WRITE_C(@frame + 0, arg0_value) ; monadic, 1 IRAM slot + SM_WRITE_C(@frame + 1, arg1_value) ; monadic + ... + SM_WRITE_C(@frame + N-1, argN_value) ; monadic + SM_WRITE_C(@frame + N, ret_cont) ; return continuation + EXEC @call_seq ; fire it all + +EXEC sequence (in SM/ROM): + SM_READ_C(@frame + 0) → deliver to callee &arg0:L + SM_READ_C(@frame + 1) → deliver to callee &arg1:L + ... + SM_READ_C(@frame + N-1) → deliver to callee &argN:L + SM_READ_C(@frame + N) → deliver to callee &ret_cont:L +``` + +**Costs:** + +``` + stub approach SM call frame + (per-arg OR+CT) (SM staging) +─────────────────────────────────────────────────────────── +caller IRAM: 3 monadic N+2 monadic (writes + exec + extag) +stub IRAM: 2N dyadic + fan-out 0 +EXEC sequence: N+1 tokens N+1 tokens +SM cells used: 0 (runtime) N+1 (call frame) +─────────────────────────────────────────────────────────── +dyadic IRAM slots: 2N+ 0 +monadic IRAM slots: 3 N+2 +``` + +The SM call frame approach uses zero dyadic IRAM for call overhead. +All caller instructions are monadic (SM_WRITE_C, EXEC, EXTRACT_TAG), +living in the abundant 32-127 offset range. The callee's IRAM is +pure function body — no call machinery whatsoever. + +**Tradeoffs:** + +- **Latency:** two SM round-trips per argument (write then read) vs + one CHANGE_TAG. Adds ~2× SM access latency to call setup. For + large N where the stub approach would serialise through a fan-out + chain anyway, the SM approach may not be worse. +- **SM cell pressure:** N+1 cells per call frame. Concurrent calls + need separate frames (different base addresses). The caller manages + this — either static allocation for known call depth, or an SM + frame pointer bumped via READ_INC. +- **No ctx patching needed:** the EXEC sequence tokens already have + the correct routing baked in (they target the callee's entry points + directly). Context allocation is still needed, but the patching + step (OR new_ctx into tag templates) is eliminated because the + EXEC sequence can be **rebuilt per call** from a template + + allocated ctx. Or, if the callee always runs in a fixed ctx + (static allocation), the EXEC sequence is truly static. + +**When to use which:** + +- N=1-2 args: stub approach. the OR + CHANGE_TAG chain is small, + latency is minimal, no SM cells consumed. +- N=3+ args: SM call frame starts winning on IRAM pressure. +- N=6+ args: SM call frame is clearly better. the stub would need + 12+ dyadic slots just for call overhead. +- one-shot EXEC'd functions (loaded on demand, run once): SM call + frame is natural — the EXEC that loads the code can also deliver + the arguments in one sequence. + +A function intended to be called via EXEC one-shot (loaded from ROM +into IRAM, executed, then discarded) can have its entire call +convention baked into the EXEC block: code loading tokens first, +then SM_READ tokens that deliver arguments from pre-staged cells. +The caller just writes args to SM and fires EXEC. The function +loads, receives its arguments, runs, sends results, done. + +--- + +### Macro System Requirements + +The call macros above need the following from the macro system. +All of these are within the capability of rust-style `macro_rules!` +or a purpose-built assembler template system. No conditionals, no +recursion in the macro evaluator, no type system. + +#### 1. Named Variadic Repetition + +``` +$($arg = $src),* +``` + +A comma-separated list of named pairs, expanded once per entry. +Rust `macro_rules!` provides this directly. + +#### 2. Token Pasting (Label Synthesis) + +``` +&__${arg}_tag +``` + +Concatenate a macro parameter into a label name. Produces unique +labels per repetition entry. C has `##`, rust proc macros have +`Ident::new()`, but `macro_rules!` does NOT have this natively — +would need an assembler-specific extension or a `paste!`-style +helper. + +This is the single most important extension beyond stock +`macro_rules!`. Without it, macros can't generate unique labels +for per-arg instructions. + +#### 3. Implicit Repetition Index + +``` +${_idx} +``` + +An auto-incrementing counter within a `$(...),*` expansion. +Used for descriptor table offset arithmetic (`$desc + ${_idx} + 1`). +Not available in rust `macro_rules!` — another assembler-specific +extension. Alternative: require the programmer to pass explicit +indices, which is ugly but functional. + +#### 4. Constant Arithmetic in Expressions + +``` +$desc + ${_idx} + 1 +``` + +Compile-time addition on constant/label expressions. The assembler +already evaluates constant expressions for instruction operands, so +the macro expander just needs to emit the expression text and let the +normal evaluator handle it. No new evaluation capability needed. + +#### 5. Label Reference Across Macro Boundaries + +``` +&__alloc |> $func.__stub.ctx_in +``` + +The per-call-site macro needs to reference labels inside the +per-function stub macro's expansion. This requires either: + +- A naming convention that both macros agree on (fragile but simple) +- The stub macro "exporting" label names via a known pattern + (`$func.__stub.*`) +- The assembler resolving qualified names across scopes + +The naming convention approach is the most macro-friendly: the +`call_stub` macro always emits labels named +`&__${func}_ctx_fan`, `&__${func}_or_ret`, etc., and the +`call_dyn` macro references them by constructing the same names. +Both macros must agree. This is a social contract, not a type system. + +#### What's NOT Needed + +- **Conditional expansion** — different call shapes get different + macros, not `if` inside a macro. +- **Recursive macro expansion** — the fan-out PASS chain has a fixed + structure per argument count. For N=1 it's one PASS with dual dest. + For N=2 it's two PASSes. Rather than recursing, provide + `call_stub_1`, `call_stub_2`, `call_stub_3` for common arities. + Ugly, pragmatic, correct. +- **Type checking** — the assembler validates after expansion (wrong + arity, missing labels, offset overflow). The macro doesn't check. +- **Hygiene** — label collisions between macro expansions ARE a risk. + Mitigated by the `&__${func}_` prefix convention. If two functions + have the same name, you have bigger problems. + +#### Example Macro Definitions + +```dfasm +; ── call stub for a 1-argument function ── +; emitted once per function, provides shared call infrastructure +.macro call_stub_1 $func, $desc { + ; ctx fan-out (1 arg + 1 return = 2 consumers, one PASS suffices) + &__${func}_ctx_fan <| pass + &__${func}_ctx_fan |> &__${func}_or_ret:R, &__${func}_or_arg0:R + + ; tag patching + &__${func}_or_ret <| or + &__${func}_or_arg0 <| or + + ; dispatch + &__${func}_ct_ret <| change_tag + &__${func}_ct_arg0 <| change_tag + + ; internal wiring + &__${func}_or_ret |> &__${func}_ct_ret:L + &__${func}_or_arg0 |> &__${func}_ct_arg0:L +} + +; ── call stub for a 2-argument function ── +.macro call_stub_2 $func, $desc { + ; ctx fan-out chain (3 consumers) + &__${func}_ctx_fan0 <| pass + &__${func}_ctx_fan1 <| pass + &__${func}_ctx_fan0 |> &__${func}_or_ret:R, &__${func}_ctx_fan1 + &__${func}_ctx_fan1 |> &__${func}_or_arg0:R, &__${func}_or_arg1:R + + ; tag patching + &__${func}_or_ret <| or + &__${func}_or_arg0 <| or + &__${func}_or_arg1 <| or + + ; dispatch + &__${func}_ct_ret <| change_tag + &__${func}_ct_arg0 <| change_tag + &__${func}_ct_arg1 <| change_tag + + ; internal wiring + &__${func}_or_ret |> &__${func}_ct_ret:L + &__${func}_or_arg0 |> &__${func}_ct_arg0:L + &__${func}_or_arg1 |> &__${func}_ct_arg1:L +} + +; ── per-call-site (works for any arity) ── +.macro call_dyn $func, $alloc, $call_seq, $ret_offset, $($arg = $src),* { + ; allocate + trigger + return continuation + &__call_alloc_${func} <| rd_inc, $alloc + &__call_exec_${func} <| exec, $call_seq + &__call_extag_${func} <| extract_tag, $ret_offset + + ; wire into stub + &__call_alloc_${func} |> &__${func}_ctx_fan + &__call_extag_${func} |> &__${func}_ct_ret:R + $( + $src |> &__${func}_ct_${arg}:R + ),* +} +``` + +Usage: + +```dfasm +; one-time setup +#call_stub_1 fib, @fib_desc + +; at each call site +#call_dyn fib, @ctx_alloc, @fib_call_seq, 20, n = &my_arg +``` + +The `call_stub_N` per-arity approach is admittedly clunky. A future +macro system with proper counted repetition could unify them. For +now, N=1 through N=3 covers the vast majority of functions, and +anything beyond N=3 can be hand-written — it's the same pattern, +just more of it. + +### Permit Injection — Two Macros + +For small K (roughly K <= 4), inline CONST injection: + +```dfasm +; Macro definition: +$permit_inject_inline K, &gate |> { + ; expands to K const instructions, each targeting &gate:L + ; each const needs its own trigger to fire +} + +; Usage: inject 3 permits into the gate +#permit_inject_inline 3, &dispatch_gate +``` + +For large K, use SM EXEC to batch-emit permits: + +```dfasm +; Macro definition: +$permit_inject_exec K, &gate, @sm_base |> { + ; expands to a single SM_EXEC reading K pre-formed permit + ; tokens from SM starting at @sm_base, each addressed to &gate:L +} + +; Usage: inject 8 permits via EXEC +#permit_inject_exec 8, &dispatch_gate, @permit_store +``` + +Programmer chooses based on K. No magic. + +### Loop Control Macro + +```dfasm +$loop_counted &limit, &body, &exit |> { + &counter <| const, 0 + &step <| inc + &test <| lt + &route <| sweq + + &counter |> &step + &step |> &test:L, &route:L ; fan-out: counter to both LT and SWITCH + &limit |> &test:R + &test |> &route:R ; bool from comparison → SWITCH control + &route:L |> &body ; taken → body dispatch + &route:R |> &exit ; not-taken → done + &route:L |> &step ; feedback arc: counter recirculates +} + +; Usage: +#loop_counted 64, &body_entry, &done +``` + +### Reduction Tree Macro + +```dfasm +$reduce_tree &op, &inputs[], &output |> { + ; expands to ceil(log2(N)) levels of binary &op instructions + ; N inferred from length of &inputs[] + ; &output receives the final reduced value +} + +; Usage: +#reduce_tree add, [&s0, &s1, &s2, &s3], @total +``` + +### Parallel Loop (Composition) + +A parallel loop is manual composition of macros and function calls. +No single macro tries to handle the full topology — each handles the +repetitive part it's good at. + +```dfasm +@system pe=4, sm=1, ctx=8 + +; The body as a function — self-loop accumulator +$body |> { + &acc <| add + &acc |> &acc:L ; feedback: acc recirculates + ; &i arrives as input, feeds &acc:R + ; &acc drains to #ret on completion +} + +; Loop control (macro expands to CONST, INC, LT, SWITCH + feedback) +#loop_counted 64, &dispatch, &done + +; Permit injection (pick one strategy) +#permit_inject_inline 4, &gate + +; Gated dispatch — permits throttle body launches +&gate <| gate +&dispatch |> &gate:R ; loop data → gate right port +; permits arrive at &gate:L from injection + body completion + +; Body invocations via function call syntax +$body i=&gate |> &partial + +; Reduction of partial results +#reduce_tree add, [&p0, &p1, &p2, &p3], @final_sum +``` + +--- + +## Pattern Cost Summary + +| Pattern | HW cost | IRAM slots | Iterations/cycle | Parallel? | +|---------|---------|------------|-------------------|-----------| +| Self-loop accumulator | 0 | 1 (the ADD) | ~1/8 (bus RT) | yes (per-ctx) | +| Permit-token throttle | 0 | K+2 (permits + GATE) | K in flight | yes | +| Counted loop control | 0 | 4 (CONST+INC+LT+SWITCH) | ~1/8 (bus RT) | no (sequential) | +| Binary reduction tree | 0 | K-1 (one per ADD) | log2(K) levels | yes | +| Predicate register | ~1 chip | +1 bit/instr | saves ~4 cycles/iter | no (shared) | +| Accumulator register | ~3 chips | 1 (ACC_ADD) | ~1/4 (no bus RT) | no (shared) | + +All zero-hardware patterns work with v0. Predicate and accumulator +registers are independent future additions that compose with the +existing patterns. diff --git a/design-notes/sm-design.md b/design-notes/sm-design.md index f36a275..7097811 100644 --- a/design-notes/sm-design.md +++ b/design-notes/sm-design.md @@ -324,19 +324,19 @@ Decode signal: `op[2] AND op[1]` — one gate. op_base ext bus opcode internal op addr bits name ───────────────────────────────────────────────────────────────── 000 aa 000 0000 10 (1024) READ - 001 aa 001 0001 10 WRITE - 010 aa 010 0010 10 ALLOC - 011 aa 011 0011 10 FREE - 100 aa 100 0100 10 CLEAR - 101 aa 101 0101 10 EXT (3-flit mode) + 001 aa 001 0001 10 WRITE + 010 aa 010 0010 10 ALLOC + 011 aa 011 0011 10 FREE + 100 aa 100 0100 10 EXEC + 101 aa 101 0101 10 EXT (3-flit mode) 110 00 11000 0110 8 (256) READ_INC - 110 01 11001 0111 8 READ_DEC - 110 10 11010 1000 8 CAS - 110 11 11011 1001 8 RAW_READ - 111 00 11100 1010 8 EXEC - 111 01 11101 1011 8 SET_PAGE - 111 10 11110 1100 8 WRITE_IMM - 111 11 11111 1101 8 (spare) + 110 01 11001 0111 8 READ_DEC + 110 10 11010 1000 8 CAS + 110 11 11011 1001 8 RAW_READ + 111 00 11100 1010 8 CLEAR + 111 01 11101 1011 8 SET_PAGE + 111 10 11110 1100 8 WRITE_IMM + 111 11 11111 1101 8 (spare) ``` 'aa' = address bits (part of 10-bit address). diff --git a/docs/design-plans/2026-02-28-dfasm-macros.md b/docs/design-plans/2026-02-28-dfasm-macros.md new file mode 100644 index 0000000..95f657e --- /dev/null +++ b/docs/design-plans/2026-02-28-dfasm-macros.md @@ -0,0 +1,410 @@ +# dfasm Macros, Function Calls, and Syntax Refinements + +## Summary + +dfasm is the assembly language for the OR1 dataflow CPU. Programs describe computation as a graph of nodes (instructions) connected by edges (token flows), where each node fires when its inputs arrive. Currently the assembler pipeline lowers source text directly to a flat intermediate representation without any abstraction mechanism — every node must be written out explicitly, and function-like reuse requires manual duplication and careful context-slot management. + +This design adds three capabilities on top of the existing pipeline. First, a macro system lets programmers define named graph templates with parameters and invoke them to expand boilerplate in place; a new `expand` pass inserted between the `lower` and `resolve` stages handles template cloning, parameter substitution, and scope qualification. Second, a function call syntax (`$func a=&x |> @output`) provides a structured way to invoke a named subgraph from one context slot into another, with the expander automatically inserting return trampolines and `free_ctx` nodes so context slots are released at runtime rather than held indefinitely. Third, a trailing-colon syntax change to location directives removes an existing grammar ambiguity, which may allow switching the parser from Earley to LALR. A built-in standard library of common graph patterns (loops, reductions, permit injection) ships as bundled dfasm source, loaded through the same pipeline as user code. + +## Definition of Done + +1. **Macro system** — a new IR-level expansion pass (between lower and resolve) that takes macro definitions parsed as dfasm into IR templates, expands macro invocations into fully-qualified IR nodes/edges within scoped namespaces, and supports parameter substitution with token pasting and (ideally) variadic repetition. + +2. **Macro definition and invocation syntax** — grammar extensions for `#name |> { body }` definitions with parameters and `#name args...` invocations. `#` sigil owns the entire macro namespace. + +3. **Function call syntax** — `$func a=&x, b=&y |> @output` generates cross-context edges with CTX_OVRD routing for static calls. `@ret` / `@ret_name` / `@ret:port` built-in nodes inside function bodies identify return points. The expand pass auto-inserts `free_ctx` on return paths. Cross-PE calls supported from the start. + +4. **Dot-notation scope resolution** — `$func.&label` and `#macro.&label` as user-facing syntax for referencing names inside scoped regions. + +5. **Location directive disambiguation** — trailing colon on region labels (`@region:`) to eliminate the ambiguity between location directives and node references that currently requires Earley parsing. + +6. **Built-in macro library** — standard macros (loop control, permit injection, reduction trees, call stubs) shipped as bundled dfasm text, loaded through the same pipeline. + +7. **Tests** — coverage for macro expansion, function call wiring, scope resolution, location directive syntax, and error cases (undefined macros, wrong arity, scope violations). + +## Acceptance Criteria + +### dfasm-macros.AC1: Macro definitions parse and lower to IR +- **dfasm-macros.AC1.1 Success:** `#name params |> { body }` parses as macro_def and lowers to MacroDef region +- **dfasm-macros.AC1.2 Success:** Macro body containing inst_def, plain_edge, strong_edge, weak_edge all lower into template IRGraph +- **dfasm-macros.AC1.3 Success:** ParamRef placeholders appear in template const fields and edge endpoints +- **dfasm-macros.AC1.4 Failure:** Macro definition with duplicate parameter names produces error +- **dfasm-macros.AC1.5 Failure:** Macro definition with reserved name (@ret) produces error + +### dfasm-macros.AC2: Macro invocations expand correctly +- **dfasm-macros.AC2.1 Success:** `#name args` expands to scope-qualified nodes (#name_N.&label) +- **dfasm-macros.AC2.2 Success:** Literal parameters substitute into const fields +- **dfasm-macros.AC2.3 Success:** Ref parameters substitute into edge endpoints +- **dfasm-macros.AC2.4 Success:** Nested macro calls expand recursively +- **dfasm-macros.AC2.5 Success:** Macro inside function body gets double-scoped ($func.#macro_N.&label) +- **dfasm-macros.AC2.6 Failure:** Undefined macro invocation produces NAME error with suggestions +- **dfasm-macros.AC2.7 Failure:** Wrong arity produces ARITY error listing expected vs actual +- **dfasm-macros.AC2.8 Failure:** Recursive expansion exceeding depth limit produces MACRO error + +### dfasm-macros.AC3: Token pasting and constant expressions +- **dfasm-macros.AC3.1 Success:** ParamRef with prefix/suffix concatenates into label names +- **dfasm-macros.AC3.2 Success:** Constant arithmetic ($desc + $idx + 1) evaluates at expansion time +- **dfasm-macros.AC3.3 Failure:** Non-numeric value in arithmetic context produces VALUE error + +### dfasm-macros.AC4: Static function calls wire correctly +- **dfasm-macros.AC4.1 Success:** `$func a=&x |> @out` generates cross-context input edges with ctx_override=True +- **dfasm-macros.AC4.2 Success:** @ret inside function body resolves to return trampoline +- **dfasm-macros.AC4.3 Success:** @ret:L and @ret:R handle dual-output return nodes +- **dfasm-macros.AC4.4 Success:** @ret_name handles named returns, wired via name=@dest at call site +- **dfasm-macros.AC4.5 Success:** free_ctx auto-inserted on every return path +- **dfasm-macros.AC4.6 Success:** Multiple call sites get distinct ctx slots and separate trampolines +- **dfasm-macros.AC4.7 Success:** Cross-PE function calls work (caller and callee on different PEs) +- **dfasm-macros.AC4.8 Success:** Assembled program with function calls runs correctly in emulator +- **dfasm-macros.AC4.9 Failure:** Named arg not matching any function body label produces NAME error +- **dfasm-macros.AC4.10 Failure:** Call to undefined function produces NAME error + +### dfasm-macros.AC5: Allocator handles new model +- **dfasm-macros.AC5.1 Success:** Context slots assigned per call site, not per function +- **dfasm-macros.AC5.2 Success:** CTX_OVRD (ctx_mode=01) emitted on cross-context edges +- **dfasm-macros.AC5.3 Success:** Auto-trampoline inserted when node needs both const and CTX_OVRD +- **dfasm-macros.AC5.4 Success:** Macro scopes don't consume context slots +- **dfasm-macros.AC5.5 Failure:** Context slot overflow produces RESOURCE error with per-PE breakdown + +### dfasm-macros.AC6: Location directive disambiguation +- **dfasm-macros.AC6.1 Success:** `@region:` parses as location_dir +- **dfasm-macros.AC6.2 Success:** `@node` without colon in edge context parses as node_ref +- **dfasm-macros.AC6.3 Failure:** Location directive without trailing colon produces PARSE error + +### dfasm-macros.AC7: Dot-notation scope resolution +- **dfasm-macros.AC7.1 Success:** $func.&label resolves to the qualified name inside the function +- **dfasm-macros.AC7.2 Success:** #macro.&label resolves into a macro expansion's scope +- **dfasm-macros.AC7.3 Failure:** Dot-ref into non-existent scope produces SCOPE error + +### dfasm-macros.AC8: Built-in macro library +- **dfasm-macros.AC8.1 Success:** Built-in macros available without explicit import +- **dfasm-macros.AC8.2 Success:** User macro with same name shadows built-in +- **dfasm-macros.AC8.3 Success:** #loop_counted expands to correct counted loop topology +- **dfasm-macros.AC8.4 Success:** Program using built-in macros assembles and runs in emulator + +## Glossary + +- **dfasm**: The assembly language for the OR1 dataflow CPU. Programs describe computation as a directed graph of instruction nodes connected by token-carrying edges. +- **Token**: A data packet that travels along edges between nodes. A node fires when all required input tokens arrive. Two families: `CMToken` (computation, targeting PEs) and `SMToken` (memory, targeting SMs). +- **PE (Processing Element)**: Hardware unit that matches incoming token pairs, fetches instructions from IRAM, executes via ALU, and emits output tokens. +- **SM (Structure Memory)**: Hardware unit with I-structure (single-assignment) cell semantics, deferred reads, and atomic operations. +- **IRAM**: Per-PE instruction storage indexed by `(ctx, offset)`. The allocate pass assigns these indices. +- **Context slot (ctx)**: 4-bit tag identifying which computation instance a token belongs to. Enables concurrent activations of the same instructions. 16 slots per PE. +- **IRGraph**: The assembler's intermediate representation containing `IRNode`, `IREdge`, `IRRegion`, and related types. +- **MacroDef**: New IR type representing a macro definition — name, parameters, and body `IRGraph` template with `ParamRef` placeholders. +- **ParamRef**: Placeholder within a macro template for a formal parameter. Supports prefix/suffix concatenation for token pasting. +- **expand pass**: New pipeline stage (`asm/expand.py`) between `lower` and `resolve`. Collects macro definitions, expands invocations, wires function calls, inserts return trampolines. +- **Token pasting**: Assembling a new identifier by concatenating a parameter value with literal prefix/suffix (e.g., `&__${func}_ctx_fan` → `&__fib_ctx_fan`). +- **CTX_OVRD**: Instruction encoding (`ctx_mode=01`) that substitutes the output token's context slot from the IRAM const field rather than inheriting from the executing token. Used for cross-context function call edges. +- **Return trampoline**: Synthetic `pass` node auto-inserted on function return paths. Routes the return token to the caller's context via CTX_OVRD and triggers `free_ctx`. +- **`free_ctx`**: Instruction that releases a context slot by rotating its generation counter, invalidating stale tokens. +- **`@ret`**: Reserved built-in node name inside function bodies marking return points. The expand pass replaces it with a return trampoline. +- **Dot-notation**: Syntax (`$func.&label`, `#macro.&label`) for referencing names inside scoped regions from outside. +- **Earley / LALR**: Parsing strategies supported by Lark. Earley handles ambiguous grammars (slower); LALR requires unambiguous grammar (faster). +- **Variadic repetition**: Stretch-goal macro feature where a template body repeats once per variadic argument with an implicit `${_idx}` index. Analogous to Rust `$(...)*`. + +## Architecture + +Three layers compose this design: grammar changes to the dfasm language, a macro system with IR-level expansion, and function call wiring that builds on both. + +### Pipeline Integration + +The assembler pipeline gains a new `expand` pass between `lower` and `resolve`: + +``` +parse → lower → expand → resolve → place → allocate → codegen +``` + +The expand pass handles two responsibilities: +1. **Macro expansion** — collect `MacroDef` regions, process `IRMacroCall` entries, clone IR templates with parameter substitution, splice expanded nodes/edges into the graph. +2. **Function call wiring** — process call-site syntax (`$func args |> outputs`), generate cross-context input edges, resolve `@ret` markers into return trampolines with auto-inserted `free_ctx`. + +After expand completes, the IR contains only concrete `IRNode`/`IREdge` entries. No `ParamRef` placeholders, no `MacroDef` regions, no `IRMacroCall` entries remain. Resolve sees a normal `IRGraph`. + +### Grammar Changes + +Five modifications to `dfasm.lark`: + +**Location directive disambiguation.** Trailing colon on region labels eliminates the ambiguity that currently requires Earley parsing: + +``` +; Before: +location_dir: qualified_ref + +; After: +location_dir: qualified_ref ":" +``` + +This may enable switching from Earley to LALR for a parse speed improvement. + +**Macro definition.** New rule paralleling `func_def`: + +``` +macro_def: "#" IDENT macro_params? FLOW_OUT "{" (_NL* statement)* _NL* "}" +macro_params: IDENT ("," IDENT)* +``` + +Example: `#loop_counted init, limit, body, exit |> { ... }` + +**Macro invocation as statement.** Extends `macro_call` beyond `data_def` context: + +``` +macro_call_stmt: "#" IDENT (argument)* +``` + +Uses `argument` (which includes `named_arg` and `positional_arg`) for both positional and named parameters. + +**Macro references in edges.** New ref type for `#name` in edge contexts: + +``` +qualified_ref: (node_ref | label_ref | func_ref | macro_ref | scoped_ref) + placement? port? +macro_ref: "#" IDENT +``` + +Enables `#macro.&label` as an edge endpoint (referencing into a macro expansion's scope). + +**Dot-notation scope resolution.** Extends `qualified_ref` for cross-scope references: + +``` +scoped_ref: (func_ref | macro_scope_ref) "." (label_ref | node_ref) +macro_scope_ref: "#" IDENT +``` + +Supports `$func.&label`, `#macro.&label`, `#macro.@node`. + +### Macro IR Representation + +New IR types in `asm/ir.py`: + +**`MacroParam`** — formal parameter in a macro definition. Has a `name` and optional `default` value. + +**`MacroDef`** — a macro definition consisting of a name, parameter list, and body `IRGraph` containing `ParamRef` placeholders. Stored as `IRRegion(kind=RegionKind.MACRO)`. + +**`ParamRef`** — placeholder for a macro parameter within the template IR. Carries the formal parameter name plus optional `prefix`/`suffix` strings for token pasting. Appears in: +- `IRNode.const` (widens to `Optional[int | ParamRef]`) +- Edge source/dest fields (widens to `str | ParamRef`) +- Node name fragments (for token-pasted label synthesis like `&__${func}_ctx_fan`) + +**`IRMacroCall`** — a macro invocation in the IR. Carries the macro name, positional args, named args, and source location. Stored in a new `IRGraph.macro_calls` field. + +**`RegionKind.MACRO`** — new enum value. Macro definition regions are consumed by the expand pass and removed before resolve. + +### Macro Expansion + +The expand pass (`asm/expand.py`) processes the IR in this order: + +1. **Collect definitions.** Walk regions, extract `RegionKind.MACRO` entries into a `macro_table: dict[str, MacroDef]`. Remove them from the graph. + +2. **Process invocations.** For each `IRMacroCall` (in root graph and recursively in function region bodies): + - Look up macro in table. Error if not found. + - Validate arity against `MacroDef.params`. + - Build substitution map: `{formal_name: actual_value}`. + - Deep-clone the template `IRGraph` body. + - Walk the clone, resolving all `ParamRef` instances: literal substitution for const fields, ref substitution for names/edges, string concatenation for token-pasted names. + - Qualify all `&label` names with expansion scope: `#macroname_N.&label` where N is a global expansion counter. + - Splice expanded nodes and edges into the parent graph. + +3. **Recursive expansion.** If an expansion contains further macro calls, expand those too. Depth limit of 32 prevents infinite recursion. + +Macros inside function bodies get double-scoped: `$fib.#loop_counted_3.&counter`. The macro scope is for name uniqueness only — it does not allocate a context slot. The enclosing function's context is inherited. + +### Function Call Wiring + +The expand pass processes function call syntax (`$func a=&x, b=&y |> @output`): + +**Input wiring.** Named arguments are matched to labels inside the function body. `a=&x` generates `IREdge(source="&x", dest="$func.&a", port=L)` with `ctx_override=True`. + +**Return wiring via `@ret`.** `@ret` is a reserved built-in node name recognised inside function bodies. The expand pass: +1. Finds all edges targeting `@ret` (with optional port qualifiers `:L`/`:R`) or named variants (`@ret_name`). +2. Creates a return trampoline node — a `pass` instruction that routes the return value back to the caller with CTX_OVRD. +3. Appends a `free_ctx` node triggered off the same return path to release the context slot. +4. Replaces the `@ret` edge destination with the trampoline, and wires the trampoline's output to the call site's specified destination. + +**Port-qualified returns.** `@ret:L` and `@ret:R` handle dual-output return nodes (e.g., a switch at the function boundary). **Named returns.** `@ret_name` handles multiple independent return paths, wired at the call site via `$func args |> name=@dest1, name2=@dest2`. + +**Context slot allocation.** Each call site allocates a fresh context slot on the PE(s) where the function body lives. The function's IRAM instructions are shared across all call sites. The `#ret` trampoline is duplicated per call site (each at a unique monadic IRAM offset) with the caller-specific return destination. + +**`free_ctx` on return paths.** Auto-inserted by the expand pass. The return trampoline fans out to both the caller destination and a `free_ctx` node. This makes context slots a concurrency budget rather than a program-wide limit — slots are reused at runtime via generation counter rotation. + +**Return strategy extensibility.** The trampoline approach works on all hardware. Future CHANGE_TAG-based dynamic returns can be substituted as an alternative strategy without changing the call-site syntax. The expansion logic should be structured so the return wiring strategy is a pluggable decision point (e.g., a strategy parameter on the expander or a system-level config). + +**Multi-site restriction.** Multiple call sites to the same function are supported. Each gets its own ctx slot and return trampoline. The cost is 1 monadic IRAM slot per `@ret` per call site. The assembler warns when context utilisation is high and errors on overflow. + +### Allocator Changes + +**Context slot assignment.** Rule changes from "one ctx per function scope per PE" to: +- Root scope gets ctx=0. +- Each *call site* to a function allocates a fresh ctx slot. +- Functions with no call sites (only direct edge wiring) retain a ctx slot by the existing scope rule. + +**Trampoline IRAM allocation.** Duplicated `@ret` nodes are monadic pass-through instructions allocated in the monadic offset range (32+). First call site reuses the original offset; subsequent call sites get new slots. + +**CTX_OVRD emission.** Cross-context edges (marked `ctx_override=True`) cause the allocator to set `ctx_mode=01` on the source instruction, packing `[target_ctx:4][target_gen:2][spare:2]` into the const field. Conflict detection: if a node needs both an ALU const and CTX_OVRD, the assembler auto-inserts a pass-through trampoline. + +**Macro scope handling.** `_extract_function_scope()` updated to recognise `#macro_N` scope segments. Macro scopes do not allocate context slots — only `$func` scopes do. + +### Built-in Macro Library + +Standard macros shipped as a dfasm string constant in `asm/builtins.py`, prepended to user source before parsing. Goes through the same parse → lower pipeline as user code. + +Initial library: + +| Macro | Purpose | Parameters | +|-------|---------|------------| +| `#loop_counted` | Counted loop with feedback arc | init, limit, body, exit | +| `#loop_while` | Condition-tested loop | test_node, body, exit | +| `#permit_inject_1` through `_4` | Inject K permit tokens | gate | +| `#reduce_2` through `_4` | Binary reduction tree | op, inputs, output | +| `#call_stub_1`, `_2` | Dynamic call stub (future) | func, desc | + +Per-arity variants (`_1`, `_2`, etc.) are used until variadic repetition is implemented, at which point they collapse into single generic macros. + +`@ret` is NOT a library macro — it is a built-in keyword recognised by the expand pass. + +If a user defines a macro with the same name as a built-in, the user's definition shadows the built-in (last definition wins in the macro table). + +## Existing Patterns + +Investigation of the existing `asm/` pipeline revealed these patterns this design follows: + +**Anonymous node synthesis.** `lower.py` already generates synthetic `&__anon_N` nodes for strong/weak edges via `_wire_anonymous_node()`. Macro expansion follows the same pattern at larger scale — synthetic nodes with qualified names, bundled with their edges as a composite result. + +**Name qualification via `_process_statements`.** The lowering pass qualifies `&label` names with `$func.` prefixes by walking statement results and calling `_qualify_name()`. Macro expansion applies the same qualification with `#macroname_N.` prefixes. + +**`IRGraph.update_graph_nodes()` for recursive updates.** Existing utility for modifying nodes while preserving region structure. The expand pass uses this for in-place resolution of `ParamRef` values. + +**`collect_all_nodes()` flattening.** Resolve already flattens all nodes from nested regions into a single namespace. Expanded macro nodes integrate naturally — as long as names follow the `scope.&label` convention, resolve picks them up. + +**Frozen dataclasses with `replace()`.** All IR types are frozen. Passes produce new instances via `dataclasses.replace()`. The expand pass follows this convention. + +**Divergence: new IR types.** `ParamRef`, `MacroDef`, `IRMacroCall` are new concepts with no existing analogues. `IRNode.const` and edge source/dest field types widen to accommodate `ParamRef`. This is the main structural change to the IR. + + +### Phase 1: Grammar and Location Directive + +**Goal:** Update the grammar for trailing-colon location directives, macro definition/invocation syntax, macro references, and dot-notation scope resolution. Update the lower pass to handle the new productions. + +**Components:** +- `dfasm.lark` — add `macro_def`, `macro_call_stmt`, `macro_ref`, `scoped_ref` rules; modify `location_dir` to require trailing colon; extend `qualified_ref` +- `asm/lower.py` — add transformer methods for `macro_def`, `macro_call_stmt`, `macro_ref`, `scoped_ref`; update `location_dir` handler for new syntax +- `asm/ir.py` — add `RegionKind.MACRO`, `MacroParam`, `MacroDef`, `IRMacroCall`, `ParamRef` types; add `macro_calls` field to `IRGraph` +- Existing dfasm test fixtures and example programs — update location directives to use trailing colon syntax + +**Dependencies:** None (first phase) + +**Done when:** Grammar parses all new syntax forms. Lower pass produces `MacroDef` regions and `IRMacroCall` entries in the IR. Location directives require trailing colon. Existing tests updated and passing. Earley-to-LALR switch evaluated (attempted if feasible). + + + +### Phase 2: Macro Expansion Pass — Core + +**Goal:** Implement the expand pass with basic parameter substitution (no token pasting or repetition yet). + +**Components:** +- `asm/expand.py` — new module: `MacroExpander` class with `expand(graph) -> IRGraph`, template cloning, parameter substitution, scope qualification with expansion counter +- `asm/__init__.py` — integrate expand into the pipeline between lower and resolve +- `asm/ir.py` — `IRGraph` utility methods for splicing expanded nodes/edges + +**Dependencies:** Phase 1 (grammar and IR types) + +**Done when:** Macros with literal and ref parameters expand correctly. Expanded nodes are scope-qualified (`#macro_N.&label`). Expanded IR passes through resolve, place, allocate, and codegen without errors. Recursive expansion works with depth limit. + + + +### Phase 3: Token Pasting and Constant Expressions + +**Goal:** Extend macro expansion to support `ParamRef` with prefix/suffix (token pasting) and basic constant arithmetic in macro arguments. + +**Components:** +- `asm/expand.py` — `ParamRef` resolution with prefix/suffix concatenation, constant expression evaluator for `$desc + $idx + 1` style expressions +- `asm/ir.py` — ensure `ParamRef` with prefix/suffix is handled in all contexts (node names, edge endpoints, const fields) + +**Dependencies:** Phase 2 (core expansion) + +**Done when:** Token-pasted labels generate correctly (e.g., `&__${func}_ctx_fan` → `&__fib_ctx_fan`). Constant arithmetic in macro arguments evaluates at expansion time. Error messages trace back to macro definition source locations. + + + +### Phase 4: Function Call Wiring — Static Calls + +**Goal:** Implement `$func a=&x, b=&y |> @output` syntax with `@ret` resolution, return trampolines, auto-inserted `free_ctx`, and CTX_OVRD edge marking. + +**Components:** +- `asm/expand.py` — function call wiring logic: argument matching, `@ret` resolution, trampoline generation, `free_ctx` insertion, `CallSite` metadata production +- `asm/ir.py` — `CallSite` dataclass, `ctx_override` field on `IREdge` +- `asm/allocate.py` — per-call-site context slot assignment, trampoline IRAM allocation, CTX_OVRD emission (ctx_mode=01 with packed const), auto-trampoline insertion for const+CTX_OVRD conflicts + +**Dependencies:** Phase 2 (expansion pass exists to host the wiring logic) + +**Done when:** Static function calls generate correct cross-context edges. Return trampolines are allocated in monadic IRAM range. `free_ctx` is auto-inserted on return paths. Multiple call sites to the same function each get distinct ctx slots and trampolines. CTX_OVRD is correctly emitted in codegen. End-to-end test: a program with function calls assembles and runs correctly in the emulator. + + + +### Phase 5: Allocator Updates + +**Goal:** Update the allocator for the new context slot model and macro scope handling. + +**Components:** +- `asm/allocate.py` — rewrite `_assign_context_slots()` for call-site-driven allocation; update `_extract_function_scope()` to handle `#macro_N` scope segments; add ctx budget warnings and overflow errors with diagnostic messages suggesting inlining +- `asm/codegen.py` — emit CTX_OVRD on cross-context edges, handle trampoline nodes in both direct and token stream modes + +**Dependencies:** Phase 4 (call site metadata and edge annotations exist) + +**Done when:** Context slots assigned per call site. Macro scopes don't consume ctx slots. Budget warnings emitted when utilisation exceeds 75%. Overflow errors include per-PE breakdown and actionable suggestions. Codegen produces correct IRAM words with ctx_mode=01. + + + +### Phase 6: Variadic Repetition (Stretch) + +**Goal:** Support `$($arg),*` style variadic repetition in macro bodies, with implicit index `${_idx}`. + +**Components:** +- `dfasm.lark` — repetition syntax within macro bodies (e.g., `$( ... ),*` delimiters) +- `asm/lower.py` — parse repetition blocks into IR template representation +- `asm/expand.py` — expand repetition blocks by iterating over variadic arguments, incrementing `${_idx}` per iteration +- `asm/ir.py` — IR representation for repetition blocks within `MacroDef` body templates + +**Dependencies:** Phase 3 (token pasting, as repetition bodies commonly use it) + +**Done when:** Variadic macros expand correctly. Per-arity built-in macros (`#permit_inject_1` through `_4`) can be replaced with single generic versions. `${_idx}` produces correct indices. + + + +### Phase 7: Built-in Macro Library + +**Goal:** Author and ship the standard macro library as bundled dfasm. + +**Components:** +- `asm/builtins.py` — string constant containing built-in macro definitions, loaded and prepended to user source in the pipeline entry points +- `asm/__init__.py` — prepend `BUILTIN_MACROS` in `assemble()`, `assemble_to_tokens()`, `run_pipeline()` + +**Dependencies:** Phase 2 (core expansion), Phase 3 (token pasting for call stubs), Phase 6 (variadic repetition, if available — otherwise ship per-arity variants) + +**Done when:** Built-in macros are available in all programs without explicit import. `#loop_counted`, `#loop_while`, `#permit_inject_N`, `#reduce_N` all expand correctly. User-defined macros shadow built-ins. Integration test: a program using built-in macros assembles and runs in the emulator. + + + +### Phase 8: Error Quality and Documentation + +**Goal:** Polish error messages for macro-related failures and update documentation. + +**Components:** +- `asm/errors.py` — new error categories: `MACRO` (undefined macro, arity mismatch, expansion depth exceeded, reserved name collision), `CALL` (undefined function, argument mismatch, ctx overflow) +- `asm/expand.py` — source location threading: error messages reference both the macro call site and the relevant position within the macro definition +- `design-notes/dfasm-primer.md` — update with macro definition/invocation syntax, function call syntax, `@ret`, trailing-colon location directives, dot-notation +- `design-notes/assembler-architecture.md` — update pipeline description to include expand pass + +**Dependencies:** All previous phases + +**Done when:** All error paths produce Rust-style formatted messages with source context. Macro errors include "expanded from #macro at line N" annotations. Documentation reflects all new syntax and pipeline changes. + + +## Additional Considerations + +**Earley to LALR.** The trailing-colon location directive change may eliminate the grammar ambiguity that requires Earley parsing. This should be evaluated in Phase 1 — if LALR works, switch to it for a meaningful parse speed improvement. If other ambiguities remain, stay on Earley. + +**Return strategy extensibility.** The trampoline return approach is the only strategy implemented in this design. Future CHANGE_TAG-based dynamic returns can be added as an alternative without changing call-site syntax. The expand pass should structure return wiring as a strategy dispatch point to make this straightforward. + +**Context slot pressure.** With 4-bit ctx (16 slots) and `free_ctx` enabling runtime reuse, the practical limit is concurrent activations, not total call sites. The assembler's static analysis cannot generally determine maximum concurrency in a dataflow program. The 75% warning threshold is a heuristic — programmers must reason about concurrency themselves. + +**`@ret` reservation.** The `@ret` prefix is reserved globally. Any user-defined node starting with `@ret` produces an error. This is a small namespace restriction in exchange for clean return syntax. -- 2.51.2