From 264195f03c503db990334512ddd984f25d099c00 Mon Sep 17 00:00:00 2001 From: Orual Date: Sat, 07 Mar 2026 15:28:35 +0000 Subject: [PATCH] feat: implement frame matching lanes for concurrent PE matching --- CLAUDE.md | 43 ++++++++++++++++++++++++++++++------------- cm_inst.py | 2 ++ asm/codegen.py | 4 ++-- design-notes/frame-lanes-for-concurrent-matching.md | 497 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ emu/events.py | 3 +++ emu/pe.py | 221 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------------------------------- emu/types.py | 3 ++- monitor/CLAUDE.md | 2 +- monitor/formatting.py | 4 ++-- monitor/graph_json.py | 11 ++++++++++- monitor/snapshot.py | 27 ++++++++++++++++++++++----- tests/test_codegen_frames.py | 11 +++++++++++ tests/test_monitor_graph_json.py | 110 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ tests/test_network_routing.py | 16 ++++++++-------- tests/test_pe.py | 44 ++++++++++++++++++++++---------------------- tests/test_pe_events.py | 18 +++++++++--------- tests/test_pe_frames.py | 109 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------------- tests/test_pe_lanes.py | 1165 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ tests/test_repl.py | 2 ++ tests/test_snapshot.py | 4 ++-- docs/design-plans/2026-03-07-frame-lanes.md | 270 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ docs/test-plans/2026-03-07-frame-lanes.md | 76 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ docs/implementation-plans/2026-03-07-frame-lanes/phase_01.md | 271 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ docs/implementation-plans/2026-03-07-frame-lanes/phase_02.md | 268 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ docs/implementation-plans/2026-03-07-frame-lanes/phase_03.md | 363 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ docs/implementation-plans/2026-03-07-frame-lanes/phase_04.md | 191 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ docs/implementation-plans/2026-03-07-frame-lanes/phase_05.md | 195 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ docs/implementation-plans/2026-03-07-frame-lanes/phase_06.md | 121 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ docs/implementation-plans/2026-03-07-frame-lanes/test-requirements.md | 40 ++++++++++++++++++++++++++++++++++++++++ 29 file(s) changed, 3952 insertion(s)(+), 139 deletion(s)(-) diff --git a/CLAUDE.md b/CLAUDE.md --- a/CLAUDE.md +++ b/CLAUDE.md @@ -106,6 +106,7 @@ - `tests/test_migration_cleanup.py` — Verifies removed types (SysToken, CfgOp, etc.) are absent from codebase - `tests/test_pe_events.py` — PE event emission tests (TokenReceived, Matched, Executed, Emitted, IRAMWritten, FrameAllocated, FrameFreed, FrameSlotWritten, TokenRejected) - `tests/test_pe_frames.py` — Frame-based PE matching, routing, and lifecycle tests + - `tests/test_pe_lanes.py` — Lane-based matching tests (ALLOC_SHARED, FREE_LANE, smart FREE, lane exhaustion, pipelining) - `tests/test_sm_events.py` — SM event emission tests (CellWritten, DeferredRead, DeferredSatisfied, ResultSent) - `tests/test_cycle_timing.py` — Cycle-accurate timing verification tests - `tests/test_network_events.py` — Network-level event propagation tests @@ -163,7 +164,7 @@ - `FrameSlotValue = int | FrameDest | None` -- type alias for frame slot contents - `OutputStyle` enum -- INHERIT, CHANGE_TAG, SINK for output routing decisions - `TokenKind` enum -- DYADIC, MONADIC, INLINE for token kind classification -- `FrameOp(IntEnum)` -- ALLOC, FREE for frame lifecycle control tokens +- `FrameOp(IntEnum)` -- ALLOC, FREE, ALLOC_SHARED, FREE_LANE for frame lifecycle control tokens - `is_monadic_alu(op: ALUOp) -> bool` -- canonical source of truth for monadic ALU op classification (used by `emu/pe.py` and `asm/opcodes.py`) ### ALU (emu/alu.py) @@ -181,10 +182,13 @@ Frame-based processing element with activation context management. **Frame Storage:** -- `frames: list[list[FrameSlotValue]]` -- 2D array [frame_id][slot_idx] holding FrameDest objects and constants -- `tag_store: dict[int, int]` -- maps act_id → frame_id for activation-to-frame lookup -- `presence: list[list[bool]]` -- [frame_id][match_slot] for dyadic operand waiting state -- `port_store: list[list[Optional[Port]]]` -- [frame_id][match_slot] for operand port metadata +- `frames: list[list[FrameSlotValue]]` -- 2D array [frame_id][slot_idx] holding FrameDest objects and constants (shared across all lanes) +- `tag_store: dict[int, tuple[int, int]]` -- maps act_id → (frame_id, lane) for activation-to-frame-and-lane lookup +- `match_data: list[list[list[Optional[int]]]]` -- 3D array [frame_id][match_slot][lane] for operand values waiting for partner +- `presence: list[list[list[bool]]]` -- 3D array [frame_id][match_slot][lane] for dyadic operand waiting state +- `port_store: list[list[list[Optional[Port]]]]` -- 3D array [frame_id][match_slot][lane] for operand port metadata +- `lane_count: int` -- number of matching lanes per frame +- `lane_free: dict[int, set[int]]` -- per-frame set of available lane IDs (created on ALLOC, deleted on full FREE) - `free_frames: list[int]` -- pool of unallocated frame IDs - `iram: dict[int, Instruction]` -- instruction memory indexed by offset @@ -194,10 +198,12 @@ - Monadic CMToken: 4 cycles (dequeue + IFETCH + EXECUTE + EMIT) **Matching Logic:** -- DyadToken arrives with act_id: look up frame_id via tag_store, then check presence[frame_id][iram_offset] -- If slot empty: store token.data and token.port, set presence bit, wait for partner -- If slot occupied: retrieve partner data and port, clear presence bit, fire instruction with both operands +- DyadToken arrives with act_id: look up (frame_id, lane) via tag_store +- Match slot is derived from token.offset: match_slot = token.offset % matchable_offsets +- If presence[frame_id][match_slot][lane] is False: store token.data in match_data[frame_id][match_slot][lane], store token.port in port_store[frame_id][match_slot][lane], set presence bit to True, wait for partner +- If presence[frame_id][match_slot][lane] is True: retrieve partner data and port from match_data and port_store, clear presence bit, fire instruction with both operands - Port ordering: partner with Port.L goes to left operand; Port.R to right operand +- Match data, presence, and port storage are per-lane; frame constants/destinations (in frames) remain shared across all lanes **Output Routing** (determined by `Instruction.output`): - `OutputStyle.INHERIT` -- routes to destinations specified in frame slots @@ -212,6 +218,17 @@ **Output logging:** - `PE.output_log: list` records every token emitted (for testing and tracing) + +**Frame Control Operations** (`_handle_frame_control`): +- `ALLOC` -- allocates a fresh frame from free_frames, assigns lane 0, initializes lane_free with remaining lanes +- `FREE` -- smart free: removes act_id from tag_store, clears lane match state. If other activations share the frame, returns lane to lane_free (frame_freed=False). If last lane, returns frame to free_frames and clears frame slots (frame_freed=True) +- `ALLOC_SHARED` -- shared allocation: looks up parent act_id (from payload) in tag_store, finds parent's frame_id, assigns next free lane from lane_free. Rejects if parent not found or no free lanes +- `FREE_LANE` -- lane-only free: removes act_id, clears lane match state, returns lane to lane_free. Never returns frame to free_frames (frame_freed always False) + +**ALLOC_REMOTE** (RoutingOp in `_run` pipeline): +- Reads fref+0 (target PE), fref+1 (target act_id), fref+2 (parent act_id) from frame constants +- If fref+2 is non-zero: emits FrameControlToken with ALLOC_SHARED op and parent act_id as payload +- If fref+2 is zero: emits FrameControlToken with ALLOC op (fresh frame allocation) **PELocalWriteToken handling:** - Writes data to frame slot at specified region/slot within the act_id's frame (1 cycle) @@ -262,9 +279,9 @@ - `System.load(tokens: list[Token])` -- spawns SimPy process that calls send() for each token in order **PEConfig (emu/types.py):** -- `pe_id: int`, `iram: dict[int, Instruction] | None`, `frame_count: int = 8`, `frame_slots: int = 64`, `matchable_offsets: int = 8` +- `pe_id: int`, `iram: dict[int, Instruction] | None`, `frame_count: int = 8`, `frame_slots: int = 64`, `matchable_offsets: int = 8`, `lane_count: int = 4` - `initial_frames: Optional[dict[int, list[FrameSlotValue]]]` -- pre-loaded frame data -- `initial_tag_store: Optional[dict[int, int]]` -- pre-loaded act_id -> frame_id mappings +- `initial_tag_store: Optional[dict[int, tuple[int, int]]]` -- pre-loaded act_id → (frame_id, lane) mappings - `allowed_pe_routes: Optional[set[int]]` -- if set, restrict PE route_table to these PE IDs - `allowed_sm_routes: Optional[set[int]]` -- if set, restrict PE sm_routes to these SM IDs - `on_event: EventCallback | None` -- if set, PE fires `SimEvent` for every token receive, match, execute, emit, frame alloc/free, slot write, and rejection @@ -285,8 +302,8 @@ - `Executed(time, component, op, result, bool_out)` -- PE executed an ALU instruction - `Emitted(time, component, token)` -- PE emitted an output token - `IRAMWritten(time, component, offset, count)` -- PE wrote instructions to IRAM -- `FrameAllocated(time, component, act_id, frame_id)` -- PE allocated a frame -- `FrameFreed(time, component, act_id, frame_id)` -- PE freed a frame +- `FrameAllocated(time, component, act_id, frame_id, lane)` -- PE allocated a frame (lane indicates which matching lane was assigned) +- `FrameFreed(time, component, act_id, frame_id, lane, frame_freed)` -- PE freed a frame lane (frame_freed=True if physical frame returned to pool) - `FrameSlotWritten(time, component, frame_id, slot, value)` -- PE wrote to a frame slot - `TokenRejected(time, component, token, reason)` -- PE rejected a token (e.g., act_id not in tag store) - `CellWritten(time, component, addr, old_pres, new_pres)` -- SM cell presence changed @@ -321,7 +338,7 @@ **StateSnapshot (monitor/snapshot.py):** - `capture(system) -> StateSnapshot` reads live PE/SM state into frozen dataclasses - `StateSnapshot(sim_time, next_time, pes: dict[int, PESnapshot], sms: dict[int, SMSnapshot])` -- `PESnapshot(pe_id, frames, tag_store, presence, port_store, free_frames, iram, input_queue, output_log)` -- frame-based PE state +- `PESnapshot(pe_id, iram, frames, tag_store, presence, port_store, match_data, free_frames, lane_count, input_queue, output_log)` -- frame-based PE state with 3D match storage (presence, port_store, match_data are all [frame_id][match_slot][lane]), tag_store mapping act_id → (frame_id, lane) tuples, and lane_count field - `SMSnapshot(sm_id, cells: dict[int, SMCellSnapshot], deferred_read, t0_store, input_queue)` **WebSocket protocol (monitor/server.py):** diff --git a/cm_inst.py b/cm_inst.py --- a/cm_inst.py +++ b/cm_inst.py @@ -88,6 +88,8 @@ class FrameOp(IntEnum): ALLOC = 0 FREE = 1 + ALLOC_SHARED = 2 + FREE_LANE = 3 @dataclass(frozen=True) diff --git a/asm/codegen.py b/asm/codegen.py --- a/asm/codegen.py +++ b/asm/codegen.py @@ -380,7 +380,7 @@ layout = act_nodes[0].frame_layout if layout is None: initial_frames[frame_id] = {} - initial_tag_store[act_id] = frame_id + initial_tag_store[act_id] = (frame_id, 0) continue # Build frame slot values for this activation as a sparse dict. @@ -409,7 +409,7 @@ frame_slots_dict[slot + i] = pack_flit1(fd) initial_frames[frame_id] = frame_slots_dict - initial_tag_store[act_id] = frame_id + initial_tag_store[act_id] = (frame_id, 0) # Create PEConfig config = PEConfig( diff --git a/design-notes/frame-lanes-for-concurrent-matching.md b/design-notes/frame-lanes-for-concurrent-matching.md new file mode 100644 --- /dev/null +++ b/design-notes/frame-lanes-for-concurrent-matching.md @@ -0,0 +1,497 @@ +# Frame Lanes: Concurrent Matching Within a Single Activation + +Design note extending `pe-redesign-frames-and-pipeline.md` with per-frame +matching lanes. Addresses the problem of multiple simultaneous pending operands +for the same dyadic instruction within a single activation — required for loops +and recursion. + +## Companion Documents + +- `pe-redesign-frames-and-pipeline.md` — base architecture (frames, pipeline, + approaches A/B/C) + +--- + +## Problem Statement + +The current frame model maps each `activation_id` to exactly one `frame_id`. +Within that frame, each dyadic instruction gets one matching slot indexed by +`offset % matchable_offsets`. This means at most one pending operand per +instruction per activation at any time. + +This fails for loops. Consider a counted loop where a dyadic ADD instruction +receives feedback from its own INC output: + +``` +iteration 1: L operand arrives at offset 3 → stored at presence[frame][3] +iteration 2: L operand arrives at offset 3 → collision! presence[frame][3] + is already set, so the hardware thinks this is a MATCH + (pairing two L operands from different iterations) +``` + +Without disambiguation, the second iteration's L operand is incorrectly paired +with the first iteration's L operand instead of waiting for its own R partner. + +The original design solved this with a 2-bit generation counter per context +slot, but that consumed token payload bits (6 bits total for ctx+gen) and +limited the instruction offset field. The frame redesign dropped the generation +field to widen offset to 8 bits and simplify the token format. + +We need a mechanism that provides generation-like disambiguation without +re-adding a token field. + +--- + +## Proposed Solution: Matching Lanes + +### Core Idea + +Split the tag store mapping from one-to-one into many-to-one. Multiple +`activation_id` values can map to the **same physical frame** but with different +**lane indices**. Lanes share the frame's constants and destinations (written +once at setup) but provide independent matching slots. + +``` +tag_store[act_id] → (frame_id, lane) + +Constants/dests: frames[frame_id][slot] — shared across all lanes +Match data: match_data[frame_id][offset][lane] — per-lane +Presence: presence[frame_id][offset][lane] — per-lane (1 bit) +Port: port_store[frame_id][offset][lane] — per-lane (1 bit) +``` + +### Frame Control Token Extensions + +The frame control token format (prefix `011+00`) has 3 spare bits in flit 1 +and a 16-bit payload in flit 2: + +``` +Frame control flit 1: [0][1][1][PE:2][00][op:1][act_id:3][spare:3] = 16 bits +Frame control flit 2: [payload:16] +``` + +The `op` field currently encodes ALLOC (0) and FREE (1). We split these into +four operations using 1 spare bit: + +``` +op spare[2] operation +── ──────── ───────── +0 0 ALLOC_NEW allocate fresh frame, assign lane 0 +0 1 ALLOC_SHARED share existing frame, assign next free lane +1 0 FREE_FRAME release lane AND return frame to free list +1 1 FREE_LANE release lane only, frame stays allocated +``` + +**ALLOC_SHARED** uses flit 2 to carry the parent activation_id whose frame +should be shared: + +``` +ALLOC_SHARED flit 2: [parent_act_id:3][spare:13] +``` + +The PE looks up `tag_store[parent_act_id] → (frame_id, _)`, picks the next +free lane from `lane_free[frame_id]`, and records +`tag_store[act_id] = (frame_id, lane)`. + +**FREE_LANE** removes the act_id → (frame, lane) mapping, clears that lane's +presence/port bits across all matchable offsets, and marks the lane as free. +The frame remains allocated (constants/dests preserved). + +**FREE_FRAME** does the same as FREE_LANE, then additionally returns the +frame to the free list. Should only be issued when all lanes for that frame +are free (or the PE can force-clear remaining lanes). + +### Lifecycle for a Loop + +``` +1. ALLOC_NEW(act_id=0) → frame 2, lane 0 +2. Setup: write constants/dests to frame 2 +3. Iteration 1 seed tokens use act_id=0 + +4. Before iteration 2: + ALLOC_SHARED(act_id=1, parent=0) → frame 2, lane 1 + Iteration 2 seed tokens use act_id=1 + +5. When iteration 1 completes: + FREE_LANE(act_id=0) → lane 0 freed, frame 2 stays + +6. Before iteration 3: + ALLOC_SHARED(act_id=2, parent=1) → frame 2, lane 0 (recycled) + Iteration 3 seed tokens use act_id=2 + +7. When all iterations done: + FREE_FRAME(act_id=last) → frame 2 returned to free list +``` + +Constants are written once in step 2. Each iteration gets its own matching +lanes via a different act_id sharing the same frame. + +### ABA Safety + +The ABA concern: a stale token from iteration 1 (act_id=0) arrives after +act_id=0 has been freed and re-allocated for iteration 3 (act_id=2). + +This is safe because: +- FREE_LANE removes act_id=0 from the tag store entirely +- When act_id=2 is allocated for iteration 3, it uses act_id=2 (not act_id=0) +- Any stale token with act_id=0 hits "act_id not in tag store" → rejected + +With 3-bit act_id (8 values) and at most 4 lanes per frame, there are 4 IDs +of ABA distance between allocation and re-use of the same act_id value. Given +that stale tokens drain within single-digit cycles, this is sufficient. + +--- + +## Hardware Impact by Approach + +### Lane Count + +L = number of lanes per frame. Practical values: 2 (1 bit) or 4 (2 bits). + +With L=4 and 4 frames: 16 possible (frame, lane) pairs. With 8 matchable +offsets: 128 match slots total. This provides 4 simultaneous pending operands +per instruction per frame — enough for most loop depths. Deeply nested +recursion beyond L would require frame splitting across PEs (the assembler +already supports this). + +### Approach C: 74LS670 Lookup (Recommended v0) + +**Tag store changes:** + +Currently: 2× 670, act_id → {valid:1, frame_id:2, spare:1} + +With lanes: 2× 670, act_id → {valid:1, frame_id:2, lane:1} for L=2 +(the spare bit becomes the lane index). For L=4, we need +{valid:1, frame_id:2, lane:2} = 5 bits, which exceeds one 670's 4-bit width. + +Options: +- **L=2 (1-bit lane):** fits in existing 670 layout. Zero additional chips for + tag store. The spare bit becomes the lane bit. +- **L=4 (2-bit lane):** need a third 670 to hold the extra lane bit (and + valid moves there too). +1 chip. + +**Presence/port metadata changes:** + +Currently: 4× 670 indexed by frame_id, each word holds presence+port for +2 offsets across 4 frames. Layout: + +``` +670 chip N (offsets 2N, 2N+1): + word[frame_id] = {pres_2N:1, port_2N:1, pres_2N+1:1, port_2N+1:1} +``` + +With lanes, the index becomes `[frame_id:2][lane]` instead of just +`[frame_id:2]`. The 670 has 4 words, so: + +- **L=2:** index is `[frame_id:2][lane:1]` = 3 bits, but the 670 only has + 2-bit addressing (4 words). We need to double the 670 count: 8× 670 for + presence+port, with lane as chip-select. **+4 chips.** + + Alternatively, re-pack: each 670 word holds presence+port for 1 offset + across 2 lanes: `{pres_L0:1, port_L0:1, pres_L1:1, port_L1:1}`. Then + we need 8 offsets × 1 chip each = 8× 670, indexed by frame_id (2 bits), + with offset selecting the chip. This is the same +4 chips but cleaner. + +- **L=4:** index is `[frame_id:2][lane:2]` = 4 bits. The 670 has 4 words + (2-bit address), so we'd need 4 670s per offset-pair, one per frame_id. + That's 4 × 4 = 16 670s for presence+port. Impractical. At L=4, the + presence/port metadata should move to SRAM or use a different register + approach. + +**Match operand data:** + +Currently in frame SRAM at `[1][frame_id:2][match_slot:3]` (match slots are +the low 8 offsets within the frame). With lanes, the address becomes +`[1][frame_id:2][match_slot:3][lane]`. + +- **L=2:** address is `[1][frame_id:2][match_slot:3][lane:1]` = 7 bits within + the frame region. 128 entries × 16 bits = 256 bytes. Well within SRAM + capacity. No additional chips. + +- **L=4:** address is `[1][frame_id:2][match_slot:3][lane:2]` = 8 bits. + 256 entries × 16 bits = 512 bytes. Still fits in SRAM. + +**Lane free tracking:** + +Per frame, track which lanes are free. For L=2: 1 flip-flop per frame × 4 +frames = 4 bits. For L=4: a 2-bit counter or 4-bit bitmask per frame = 8–16 +bits. Either fits in a single 74LS174 (hex D flip-flop) or similar. **+1 chip.** + +**Approach C summary (L=2):** + +| Component | Before | After | Delta | +|----------------------------|--------|--------|-------| +| act_id → (frame_id, lane) | 2× 670 | 2× 670 | 0 | +| Presence + port metadata | 4× 670 | 8× 670 | +4 | +| Bit select mux | 1–2 | 1–2 | 0 | +| Lane free tracking | 0 | 1 | +1 | +| Frame SRAM | 2 | 2 | 0 | +| **Total delta** | | | **+5 chips** | + +**Approach C summary (L=4):** + +Presence/port at L=4 exceeds practical 670 count. Two options: + +(a) Move presence/port to SRAM. Pack all 4 lanes' presence+port for one +(frame, offset) into a single 16-bit word: +`{pres0:1, port0:1, pres1:1, port1:1, ..., pres3:1, port3:1, spare:8}`. +Read in 1 SRAM cycle, same SRAM chip as frame data. Adds 1 cycle to matching +(read presence word before reading/writing match data). **+0 chips, +1 cycle.** + +(b) Use 74LS189 register files instead of 670s for presence/port. 189s are +16-word × 4-bit, addressed by `[frame_id:2][offset:2]` = 4 bits. Two 189s +(8 bits) hold presence+port for 4 lanes at one (frame, low-2-offset) combo. +With offset[2] as chip-select, that's 4× 189. **+4 chips (replacing 4× 670 +with 4× 189)**, net change depends on baseline. + +Option (a) is simpler and fits the v0 "minimal chips" philosophy. The extra +SRAM cycle for presence is identical to Approach A's tag read — it just +applies to the lane dimension instead. + +### Approach A: Set-Associative Tags in Frame SRAM + +Approach A already uses SRAM for tag storage. Lanes change the tag word format. + +Currently, each tag word packs 4-way set-associative entries: + +``` +{way0_valid:1, way0_act:3, way1_valid:1, way1_act:3, ...} = 16 bits +``` + +With lanes, the tag word already supports the concept — each way IS effectively +a lane. The act_id comparison finds the matching way, and the way index IS the +lane. The only change: ALLOC_SHARED must write the same frame region for +multiple act_ids. + +Actually, Approach A's set-associative structure already provides something +very close to lanes. The ways in the tag word serve the same purpose — multiple +act_ids can have pending operands at the same offset, disambiguated by act_id +comparison. The 4-way associativity gives 4 simultaneous pending matches per +offset across ALL activations. + +**Key difference:** in Approach A, the ways are shared across all activations +at that offset (global pool). The lane model gives per-frame isolation. Under +Approach A, if two different functions both have a pending operand at offset 3, +they consume 2 of the 4 ways. Under the lane model, each frame has its own L +lanes — no cross-activation contention. + +**Approach A with lanes:** the tag word becomes: + +``` +{way0_valid:1, way0_act:3, way0_lane:1, way1_valid:1, way1_act:3, way1_lane:1, ...} +``` + +This doesn't fit in 16 bits for 4 ways with L=2 (5 bits × 4 = 20 bits). Would +require wider tag words (32-bit SRAM or 2 reads per tag lookup), or reducing +to 2 ways. + +Alternatively, since act_id already implies frame_id (via the tag store), +Approach A doesn't benefit from lanes in the same way. The set-associative +structure already provides the disambiguation — adding lanes on top is +redundant. **Approach A doesn't need lanes; its ways serve the same purpose.** + +The real question for Approach A is: does 4-way associativity (global, shared) +provide enough concurrent matching depth? For loops: yes, as long as no more +than 4 iterations have pending operands at the same offset simultaneously. +For mixed workloads with multiple activations: depends on access patterns. + +### Approach B: Full Register-File Match Pool + +Original Approach B: 8-entry global pool with `{valid:1, act_id:3, offset:6, +port:1, data:16}` per entry, fully associative. + +**With lanes, the pool needs a lane field:** `{valid:1, act_id:3, offset:3, +lane:1, port:1, data:16}` for L=2. The comparator now matches on +`(act_id, offset, lane)` — but lane is derived from act_id via the tag store, +not carried in the token. So the comparator actually still matches on +`(act_id, offset)` as before. + +Wait — that's the key insight. Since the token carries act_id (not frame_id + +lane), and different iterations use different act_ids, the existing Approach B +pool already disambiguates correctly without any lane concept at all: + +- Iteration 1 (act_id=0): L operand stored as `{act_id=0, offset=3, ...}` +- Iteration 2 (act_id=1): L operand stored as `{act_id=1, offset=3, ...}` +- These don't match because act_id differs. + +**Approach B already handles concurrent matching across iterations, provided +each iteration uses a distinct act_id.** The only addition is the +ALLOC_SHARED/FREE_LANE mechanism to allow multiple act_ids to share one frame. +No changes to the match pool hardware at all. + +The constraint: the global pool has 8 entries total. With 4 iterations × 2 +pending operands each = 8 entries consumed. A tight but functional limit. + +**B+670 variants:** same analysis. The 670s resolve act_id → frame_id for +constant/dest access. The match pool (whether fully indexed or semi-CAM) uses +act_id directly and already disambiguates. **Zero additional match hardware +for lanes.** + +### Approach B+670 Indexed (Dedicated Register Slots) + +Currently: `[frame_id:2][offset:2:0]` = 5-bit address, 32 entries dedicated. +One entry per (frame, offset) pair. + +With ALLOC_SHARED: multiple act_ids map to the same frame_id, but they get +different lanes. The match data must be indexed by `[frame_id:2][offset:3] +[lane]` instead of just `[frame_id:2][offset:3]`. + +- **L=2:** 6-bit address, 64 entries. 8× 189 chips (up from 8). Actually, + the original B+670 indexed already uses 8× 189 for 32 entries of 16-bit + data. Doubling to 64 entries means 16× 189. That's a lot. Alternatively, + use SRAM for the doubled range: `[frame_id:2][offset:3][lane:1]` = 6 bits + within the match region. 64 entries × 16 bits = 128 bytes. Easily fits in + the shared SRAM chip. But then we lose the "zero SRAM cycles for matching" + advantage. + + Better option: keep register file, use the 670 presence bits to encode lane. + The 670 already stores `{presence, port}` per (frame, offset). With L=2, + expand to `{presence_L0, port_L0, presence_L1, port_L1}`. This is exactly + the same as the Approach C lane expansion above: 8× 670 for + presence+port. **The match data register file doubles, the presence 670s + double. +8 register chips, +4 670 chips = +12 chips.** Steep. + + For B+670 indexed, the more practical approach at L>1 is to fall back to + SRAM for match data and keep the 670s only for act_id resolution and + presence tracking. This effectively converts B+670 indexed into Approach C + with lanes — SRAM match data, 670 metadata. + +### B+670 Semi-CAM (Associative Within Frame) + +Currently: per-frame associative pool with W ways. Tag stores +`{valid:1, offset:3}` per way. Comparators search offset within frame. + +With lanes: each entry's tag becomes `{valid:1, offset:3, lane:1}` for L=2. +The comparator matches on `(offset, lane)` where lane comes from the 670 +lookup. **+1 bit per comparator.** For 3-bit offset + 1-bit lane = 4-bit +compare, each 74LS85 (4-bit comparator) handles one entry exactly. + +The pool's way count (W) determines how many simultaneous pending matches +per frame. With L=2 and W=4: 4 pending matches, shared across 2 lanes. +Each lane can use up to W entries (the pool is shared within the frame, +not partitioned per lane). This is actually better than strict per-lane +isolation — if lane 0 has 3 pending and lane 1 has 1, they use 4 entries +total without wasting any. + +**Semi-CAM hardware delta for L=2:** + +| Component | Before (W=2) | After (W=2, L=2) | Delta | +|-------------------|--------------|-------------------|-------| +| Tag registers | 2 chips | 2 chips | 0 | +| Comparators | 2 chips | 2 chips | 0 | +| Data registers | 4 chips | 4 chips | 0 | +| **Total** | | | **0** | + +The only change is the tag width grows by 1 bit (offset:3 → offset:3 + +lane:1 = 4 bits), which fits in the same comparator. **Zero additional chips +for the semi-CAM itself.** + +The ALLOC_SHARED / FREE_LANE logic: +1 chip (lane free tracking). +The 670 tag store: +0 chips (lane bit fits in spare bit). + +**This makes B+670 semi-CAM the most natural fit for lanes.** The +associative pool already handles variable-occupancy matching; adding a +lane bit to the tag is free in hardware. + +--- + +## Approach Comparison with Lanes + +| Property | A | C (L=2) | C (L=4) | B+670 semi W=2 | B+670 semi W=4 | +|---------------------------|--------------|-------------|--------------|----------------|----------------| +| Needs lanes at all? | no (ways) | yes | yes | yes | yes | +| Extra chips for lanes | 0 | +5 | +1 (SRAM) | +1 | +1 | +| Pending matches/frame | 4 (shared) | 2 per lane | 4 per lane | W (shared) | W (shared) | +| Extra SRAM cycles | 0 | 0 | +1 (pres) | 0 | 0 | +| Cross-activation contention | yes (global) | no | no | no | no | +| Implementation complexity | none | moderate | moderate | minimal | minimal | + +**Winner for lanes: B+670 semi-CAM.** Zero additional match hardware, lanes +come free via the existing associative tag. The 670 tag store absorbs the +lane bit in its spare capacity. Only cost is 1 chip for lane free tracking +and the ALLOC_SHARED/FREE_LANE control logic. + +**Runner-up: Approach C with L=2.** +5 chips (all 670s for doubled +presence/port). Simple, well-understood, but the 670 count is getting high +(10 670s per PE). + +--- + +## SRAM Address Map Update + +With L=2 lanes, the frame SRAM match region doubles: + +``` +v0 address space with lanes (L=2): + + IRAM region: [0][offset:8] instruction templates + capacity: 256 instructions (512 bytes) + + Frame region: [1][frame_id:2][slot:6] per-activation storage + capacity: 4 frames × 64 slots = 256 entries (512 bytes) + (constants, destinations, accumulators — shared across lanes) + + Match region: (Approach C / SRAM-based) + [1][1][frame_id:2][offset:3][lane:1] match operand data + capacity: 4 × 8 × 2 = 64 entries (128 bytes) + (carved from frame region address space, or separate region) +``` + +Total: 512 + 512 + 128 = 1152 bytes. Still well under 32Kx8 capacity. + +For B+670 semi-CAM: match data lives in register files, not SRAM. The SRAM +address map is unchanged (frame region only holds shared constants/dests). + +--- + +## Assembler Impact + +The assembler must: + +1. **Detect loops and recursion** that require concurrent matching. Static + analysis of feedback arcs in the dataflow graph. + +2. **Allocate activation IDs per iteration.** The loop prologue emits + ALLOC_SHARED for each new iteration's act_id before injecting seed tokens. + The loop epilogue emits FREE_LANE when an iteration completes. + +3. **Track lane depth.** If a loop's concurrency exceeds L (or W for + semi-CAM), the assembler must either: + - Insert synchronisation barriers (drain iteration N before starting N+L) + - Split the loop body across PEs to reduce per-PE concurrency + - Report a warning (analogous to matchable_offsets exceedance, AC5.8) + +4. **Generate setup tokens.** ALLOC_SHARED tokens carry the parent act_id + in their payload. The codegen pass already generates frame control tokens; + this extends the format. + +--- + +## Open Questions + +1. **L=2 vs L=4 for v0.** L=2 is cheaper (+5 670s for Approach C, +0 for + semi-CAM) and handles 2-deep loop pipelining. L=4 handles deeper nesting + but costs more in metadata storage. Recommendation: L=2 for v0, upgradable. + +2. **Loop iteration management.** Who manages the ALLOC_SHARED / FREE_LANE + sequence? Options: + - **Compiler-generated:** the assembler statically emits alloc/free tokens + as part of the loop control flow. Simple, but inflexible. + - **PE-internal:** a loop counter mechanism in the PE automatically + rotates lanes. More complex hardware, but simpler programs. + - **Hybrid:** compiler generates the control flow, PE provides the + lane allocation hardware. (Recommended.) + +3. **Semi-CAM way count vs lane count.** With B+670 semi-CAM, W (ways per + frame) and L (lanes per frame) interact. W=4 with L=2 gives 4 pending + matches shared across 2 lanes — 2 pending per lane on average, more if + one lane is quiet. Is W=2 sufficient? Depends on the number of dyadic + instructions with simultaneously pending operands. + +4. **Interaction with SC arc execution.** Strongly-connected arc blocks + execute sequential instructions within a single activation. Lanes are + orthogonal — SC arcs don't need concurrent matching (they're sequential). + But the frame_id latch for SC arcs must also latch the lane. Trivial + addition. + + diff --git a/emu/events.py b/emu/events.py --- a/emu/events.py +++ b/emu/events.py @@ -87,6 +87,7 @@ component: str act_id: int frame_id: int + lane: int @dataclass(frozen=True) @@ -95,6 +96,8 @@ component: str act_id: int frame_id: int + lane: int + frame_freed: bool @dataclass(frozen=True) diff --git a/emu/pe.py b/emu/pe.py --- a/emu/pe.py +++ b/emu/pe.py @@ -39,9 +39,11 @@ Manages: - Frame store: [frame_count][frame_slots] dense per-activation data - - Tag store: act_id → frame_id mapping - - Presence bits: [frame_count][matchable_offsets] for dyadic matching - - Port store: [frame_count][matchable_offsets] for port metadata + - Tag store: act_id → (frame_id, lane) mapping + - Match data: [frame_id][matchable_offsets][lane_count] for operand values + - Presence bits: [frame_id][matchable_offsets][lane_count] for dyadic matching + - Port store: [frame_id][matchable_offsets][lane_count] for port metadata + - Lane free: per-frame set of available lane IDs - Free frames: pool of available frame IDs Pipeline (per token): @@ -68,26 +70,55 @@ for _ in range(config.frame_count) ] - # Tag store: act_id → frame_id - self.tag_store: dict[int, int] = dict(config.initial_tag_store or {}) + # Tag store: act_id → (frame_id, lane) + self.tag_store: dict[int, tuple[int, int]] = dict(config.initial_tag_store or {}) - # Presence bits: [frame_id][match_slot] - True if operand waiting for partner - self.presence: list[list[bool]] = [ - [False for _ in range(config.matchable_offsets)] + # Match data: [frame_id][match_slot][lane] - operand values waiting for partner + self.match_data: list[list[list[Optional[int]]]] = [ + [ + [None for _ in range(config.lane_count)] + for _ in range(config.matchable_offsets) + ] for _ in range(config.frame_count) ] - # Port store: [frame_id][match_slot] - port of waiting operand - self.port_store: list[list[Optional[Port]]] = [ - [None for _ in range(config.matchable_offsets)] + # Presence bits: [frame_id][match_slot][lane] - True if operand waiting for partner + self.presence: list[list[list[bool]]] = [ + [ + [False for _ in range(config.lane_count)] + for _ in range(config.matchable_offsets) + ] for _ in range(config.frame_count) ] + + # Port store: [frame_id][match_slot][lane] - port of waiting operand + self.port_store: list[list[list[Optional[Port]]]] = [ + [ + [None for _ in range(config.lane_count)] + for _ in range(config.matchable_offsets) + ] + for _ in range(config.frame_count) + ] + + self.lane_count = config.lane_count # Free frames pool self.free_frames = list(range(config.frame_count)) - for frame_id in self.tag_store.values(): + for frame_id, _lane in self.tag_store.values(): if frame_id in self.free_frames: self.free_frames.remove(frame_id) + + # Lane tracking: which lanes are free per frame + self.lane_free: dict[int, set[int]] = {} + + # Initialize lane_free for pre-loaded tag_store entries + for act_id, (frame_id, lane) in self.tag_store.items(): + if frame_id not in self.lane_free: + # First time seeing this frame — set up lane tracking + all_lanes = set(range(self.lane_count)) + self.lane_free[frame_id] = all_lanes - {lane} + else: + self.lane_free[frame_id].discard(lane) # Load initial frame data if config.initial_frames: @@ -173,7 +204,7 @@ )) return - frame_id = self.tag_store[token.act_id] + frame_id, lane = self.tag_store[token.act_id] # Determine if monadic or dyadic instruction is_monadic = ( @@ -192,7 +223,7 @@ left, right = token.data, None else: # Dyadic matching via presence bits - operands = self._match_frame(token, inst, frame_id) + operands = self._match_frame(token, inst, frame_id, lane) yield self.env.timeout(1) # match cycle if operands is None: return # waiting for partner @@ -229,15 +260,32 @@ yield self.env.timeout(1) # EMIT cycle self._do_emit_new(inst, result, False, token.act_id, frame_id) elif inst.opcode == RoutingOp.ALLOC_REMOTE: - # PE-level: read target PE and act_id from frame constants + # PE-level: read target PE, act_id, and optional parent act_id from frame constants + # fref+0: target PE + # fref+1: target act_id + # fref+2: parent act_id (0 = fresh ALLOC, non-zero = ALLOC_SHARED) # Total: 4 cycles (dequeue + IFETCH + EXECUTE + EMIT) target_pe = self.frames[frame_id][inst.fref] if inst.fref < len(self.frames[frame_id]) else 0 target_act = self.frames[frame_id][inst.fref + 1] if inst.fref + 1 < len(self.frames[frame_id]) else 0 + parent_act = self.frames[frame_id][inst.fref + 2] if inst.fref + 2 < len(self.frames[frame_id]) else 0 + + # Guard against None slot values + if target_pe is None or target_act is None: + logger.warning(f"PE {self.pe_id}: ALLOC_REMOTE has None at fref slots, skipping") + return + + if parent_act: + alloc_op = FrameOp.ALLOC_SHARED + payload = parent_act + else: + alloc_op = FrameOp.ALLOC + payload = 0 + fct = FrameControlToken( target=target_pe, act_id=target_act, - op=FrameOp.ALLOC, - payload=0, + op=alloc_op, + payload=payload, ) self._on_event(Executed( time=self.env.now, component=self._component, @@ -256,14 +304,11 @@ )) yield self.env.timeout(1) # EXECUTE cycle yield self.env.timeout(1) # EMIT cycle (no output token) - # Frame deallocation happens during EMIT cycle + # Frame deallocation happens during EMIT cycle with smart FREE logic if token.act_id in self.tag_store: - freed_frame = self.tag_store.pop(token.act_id) - self.free_frames.append(freed_frame) - self._on_event(FrameFreed( - time=self.env.now, component=self._component, - act_id=token.act_id, frame_id=freed_frame, - )) + self._smart_free(token.act_id) + else: + logger.warning(f"PE {self.pe_id}: FREE_FRAME for unknown act_id {token.act_id}") else: # Normal ALU execute # MINOR FIX: Restructure const_val handling to avoid dead code @@ -281,33 +326,116 @@ yield self.env.timeout(1) # EMIT cycle self._do_emit_new(inst, result, bool_out, token.act_id, frame_id, left=left) + def _smart_free(self, act_id: int) -> None: + """Smart FREE helper: deallocate lane, possibly returning frame to free list. + + Does NOT yield. Caller handles timing. Emits FrameFreed event. + """ + if act_id not in self.tag_store: + return # Caller should have checked, but skip silently + + frame_id, lane = self.tag_store.pop(act_id) + # Clear this lane's match state + for i in range(self.matchable_offsets): + self.match_data[frame_id][i][lane] = None + self.presence[frame_id][i][lane] = False + self.port_store[frame_id][i][lane] = None + # Check if any other activations use this frame + frame_in_use = any(fid == frame_id for fid, _ in self.tag_store.values()) + if frame_in_use: + # Return lane to pool, keep frame + self.lane_free[frame_id].add(lane) + self._on_event(FrameFreed( + time=self.env.now, component=self._component, + act_id=act_id, frame_id=frame_id, + lane=lane, frame_freed=False, + )) + else: + # Last lane — return frame to free list + self.free_frames.append(frame_id) + if frame_id in self.lane_free: + del self.lane_free[frame_id] + # Clear frame slots + for i in range(self.frame_slots): + self.frames[frame_id][i] = None + self._on_event(FrameFreed( + time=self.env.now, component=self._component, + act_id=act_id, frame_id=frame_id, + lane=lane, frame_freed=True, + )) + def _handle_frame_control(self, token: FrameControlToken) -> None: - """Handle ALLOC and FREE operations.""" + """Handle ALLOC, FREE, ALLOC_SHARED, and FREE_LANE operations.""" if token.op == FrameOp.ALLOC: if self.free_frames: frame_id = self.free_frames.pop() - self.tag_store[token.act_id] = frame_id + self.tag_store[token.act_id] = (frame_id, 0) + # Set up lane tracking: lane 0 is taken, rest are free + self.lane_free[frame_id] = set(range(1, self.lane_count)) # Initialize frame slots to None for i in range(self.frame_slots): self.frames[frame_id][i] = None - # CRITICAL FIX: Reset stale presence bits and port_store from previous activation + # Reset all lanes' match state for i in range(self.matchable_offsets): - self.presence[frame_id][i] = False - self.port_store[frame_id][i] = None + for ln in range(self.lane_count): + self.match_data[frame_id][i][ln] = None + self.presence[frame_id][i][ln] = False + self.port_store[frame_id][i][ln] = None self._on_event(FrameAllocated( time=self.env.now, component=self._component, - act_id=token.act_id, frame_id=frame_id, + act_id=token.act_id, frame_id=frame_id, lane=0, )) else: logger.warning(f"PE {self.pe_id}: no free frames available") elif token.op == FrameOp.FREE: if token.act_id in self.tag_store: - frame_id = self.tag_store.pop(token.act_id) - self.free_frames.append(frame_id) - self._on_event(FrameFreed( + self._smart_free(token.act_id) + else: + logger.warning(f"PE {self.pe_id}: FREE for unknown act_id {token.act_id}") + elif token.op == FrameOp.ALLOC_SHARED: + # Shared allocation: find parent's frame, assign next free lane + # Guard against self-referential act_id (would leak old lane) + if token.act_id in self.tag_store: + self._on_event(TokenRejected( time=self.env.now, component=self._component, - act_id=token.act_id, frame_id=frame_id, + token=token, reason=f"act_id {token.act_id} already in tag store", )) + return + parent_act_id = token.payload + if parent_act_id not in self.tag_store: + self._on_event(TokenRejected( + time=self.env.now, component=self._component, + token=token, reason=f"parent act_id {parent_act_id} not in tag store", + )) + return + parent_frame_id, _ = self.tag_store[parent_act_id] + free_lanes = self.lane_free.get(parent_frame_id, set()) + if not free_lanes: + self._on_event(TokenRejected( + time=self.env.now, component=self._component, + token=token, reason="no free lanes", + )) + return + lane = min(free_lanes) # Deterministic: pick lowest free lane + free_lanes.remove(lane) + self.tag_store[token.act_id] = (parent_frame_id, lane) + # Clear only this lane's match state + for i in range(self.matchable_offsets): + self.match_data[parent_frame_id][i][lane] = None + self.presence[parent_frame_id][i][lane] = False + self.port_store[parent_frame_id][i][lane] = None + self._on_event(FrameAllocated( + time=self.env.now, component=self._component, + act_id=token.act_id, frame_id=parent_frame_id, lane=lane, + )) + elif token.op == FrameOp.FREE_LANE: + # Free lane with smart frame deallocation. + # If this is the last lane using the frame, the frame is returned to free_frames. + # Otherwise, just the lane is returned to the pool. + if token.act_id in self.tag_store: + self._smart_free(token.act_id) + else: + logger.warning(f"PE {self.pe_id}: FREE_LANE for unknown act_id {token.act_id}") def _handle_local_write(self, token: PELocalWriteToken) -> None: """Handle IRAM write and frame write.""" @@ -319,7 +447,7 @@ )) elif token.region == 1: # Frame if token.act_id in self.tag_store: - frame_id = self.tag_store[token.act_id] + frame_id, _lane = self.tag_store[token.act_id] if token.is_dest: # Decode flit 1 to FrameDest dest = unpack_flit1(token.data) @@ -345,23 +473,24 @@ token: DyadToken, inst: Instruction, frame_id: int, + lane: int, ) -> Optional[tuple[int, int]]: - """Frame-based dyadic matching. + """Frame-based dyadic matching with lane support. Derives match slot from low bits of token.offset: match_slot = token.offset % matchable_offsets - Both L and R tokens write to frames[frame_id][match_slot]. - Port metadata determines left/right ordering when second arrives. + Match data, presence, and port are per-lane. + Frame constants/destinations remain shared. """ match_slot = token.offset % self.matchable_offsets - if self.presence[frame_id][match_slot]: + if self.presence[frame_id][match_slot][lane]: # Partner already waiting — pair them - partner_data = self.frames[frame_id][match_slot] - partner_port = self.port_store[frame_id][match_slot] - self.presence[frame_id][match_slot] = False - self.frames[frame_id][match_slot] = None + partner_data = self.match_data[frame_id][match_slot][lane] + partner_port = self.port_store[frame_id][match_slot][lane] + self.presence[frame_id][match_slot][lane] = False + self.match_data[frame_id][match_slot][lane] = None # Use port metadata to determine left/right ordering if partner_port == Port.L: @@ -377,9 +506,9 @@ return left, right else: # Store and wait for partner - self.frames[frame_id][match_slot] = token.data - self.port_store[frame_id][match_slot] = token.port - self.presence[frame_id][match_slot] = True + self.match_data[frame_id][match_slot][lane] = token.data + self.port_store[frame_id][match_slot][lane] = token.port + self.presence[frame_id][match_slot][lane] = True return None def _do_emit_new( diff --git a/emu/types.py b/emu/types.py --- a/emu/types.py +++ b/emu/types.py @@ -20,8 +20,9 @@ frame_count: int = 8 frame_slots: int = 64 matchable_offsets: int = 8 + lane_count: int = 4 initial_frames: Optional[dict[int, list[FrameSlotValue]]] = None - initial_tag_store: Optional[dict[int, int]] = None + initial_tag_store: Optional[dict[int, tuple[int, int]]] = None allowed_pe_routes: Optional[set[int]] = None allowed_sm_routes: Optional[set[int]] = None on_event: EventCallback | None = None diff --git a/monitor/CLAUDE.md b/monitor/CLAUDE.md --- a/monitor/CLAUDE.md +++ b/monitor/CLAUDE.md @@ -52,7 +52,7 @@ - `__init__.py` -- Public API exports - `backend.py` -- `SimulationBackend` class with thread lifecycle and command dispatch - `commands.py` -- All command and result frozen dataclasses, `SimCommand` union type -- `snapshot.py` -- `StateSnapshot`, `PESnapshot` (frame-based: frames, tag_store, presence, port_store, free_frames), `SMSnapshot`, `SMCellSnapshot`, `capture()` +- `snapshot.py` -- `StateSnapshot`, `PESnapshot` (frame-based: frames, tag_store mapping act_id → (frame_id, lane) tuples, presence, port_store, match_data all 3D [frame_id][match_slot][lane], lane_count, free_frames), `SMSnapshot`, `SMCellSnapshot`, `capture()` - `graph_json.py` -- JSON serialization with execution overlay (extends dfgraph patterns) - `server.py` -- `create_app(backend)` FastAPI factory, `ConnectionManager`, WebSocket handler - `repl.py` -- `MonitorREPL(cmd.Cmd)` interactive CLI diff --git a/monitor/formatting.py b/monitor/formatting.py --- a/monitor/formatting.py +++ b/monitor/formatting.py @@ -194,8 +194,8 @@ # Tag store if pe_snapshot.tag_store: tag_str = ", ".join( - f"{colour(str(k), 'white')}: {colour(str(v), 'white')}" - for k, v in sorted(pe_snapshot.tag_store.items()) + f"{colour(str(k), 'white')}: frame {colour(str(fid), 'white')} lane {colour(str(lane), 'white')}" + for k, (fid, lane) in sorted(pe_snapshot.tag_store.items()) ) lines.append(f" Tag store: {{{tag_str}}}") else: diff --git a/monitor/graph_json.py b/monitor/graph_json.py --- a/monitor/graph_json.py +++ b/monitor/graph_json.py @@ -117,11 +117,17 @@ frames_json = [[_serialise_slot(s) for s in frame] for frame in pe_snap.frames] + tag_store_json = { + str(act_id): {"frame_id": fid, "lane": lane} + for act_id, (fid, lane) in pe_snap.tag_store.items() + } + return { "pe_id": pe_snap.pe_id, "iram": iram_json, "frames": frames_json, - "tag_store": pe_snap.tag_store, + "tag_store": tag_store_json, + "lane_count": pe_snap.lane_count, "free_frames": list(pe_snap.free_frames), "input_queue_size": len(pe_snap.input_queue), } @@ -186,11 +192,14 @@ base["details"] = { "act_id": event.act_id, "frame_id": event.frame_id, + "lane": event.lane, } elif isinstance(event, FrameFreed): base["details"] = { "act_id": event.act_id, "frame_id": event.frame_id, + "lane": event.lane, + "frame_freed": event.frame_freed, } elif isinstance(event, FrameSlotWritten): base["details"] = { diff --git a/monitor/snapshot.py b/monitor/snapshot.py --- a/monitor/snapshot.py +++ b/monitor/snapshot.py @@ -22,10 +22,12 @@ pe_id: int iram: dict[int, Instruction] frames: tuple[tuple[FrameSlotValue, ...], ...] - tag_store: dict[int, int] - presence: tuple[tuple[bool, ...], ...] - port_store: tuple[tuple[Port | None, ...], ...] + tag_store: dict[int, tuple[int, int]] + presence: tuple[tuple[tuple[bool, ...], ...], ...] + port_store: tuple[tuple[tuple[Port | None, ...], ...], ...] + match_data: tuple[tuple[tuple[int | None, ...], ...], ...] free_frames: tuple[int, ...] + lane_count: int input_queue: tuple[Token, ...] output_log: tuple[Token, ...] @@ -80,12 +82,25 @@ ) tag_store = dict(pe.tag_store) presence = tuple( - tuple(p for p in frame_presence) + tuple( + tuple(lane_val for lane_val in offset_lanes) + for offset_lanes in frame_presence + ) for frame_presence in pe.presence ) port_store = tuple( - tuple(p for p in frame_ports) + tuple( + tuple(lane_val for lane_val in offset_lanes) + for offset_lanes in frame_ports + ) for frame_ports in pe.port_store + ) + match_data = tuple( + tuple( + tuple(lane_val for lane_val in offset_lanes) + for offset_lanes in frame_match + ) + for frame_match in pe.match_data ) free_frames = tuple(pe.free_frames) @@ -96,7 +111,9 @@ tag_store=tag_store, presence=presence, port_store=port_store, + match_data=match_data, free_frames=free_frames, + lane_count=pe.lane_count, input_queue=tuple(pe.input_store.items), output_log=tuple(pe.output_log), ) diff --git a/tests/test_codegen_frames.py b/tests/test_codegen_frames.py --- a/tests/test_codegen_frames.py +++ b/tests/test_codegen_frames.py @@ -324,6 +324,17 @@ assert len(alloc_tokens) == 2 assert all(t.op == FrameOp.ALLOC for t in alloc_tokens) + # Verify that PE configs have initial_tag_store with tuple values + assert len(result.pe_configs) == 1 + pe_cfg = result.pe_configs[0] + assert pe_cfg.initial_tag_store, "initial_tag_store should not be empty for PE with activations" + for act_id, val in pe_cfg.initial_tag_store.items(): + assert isinstance(val, tuple) and len(val) == 2, \ + f"initial_tag_store[{act_id}] should be (frame_id, lane) tuple, got {val}" + frame_id, lane = val + assert isinstance(frame_id, int), f"frame_id should be int, got {type(frame_id)}" + assert isinstance(lane, int), f"lane should be int, got {type(lane)}" + class TestTask3SeedTokens: """Task 3: Seed token generation with act_id.""" diff --git a/tests/test_monitor_graph_json.py b/tests/test_monitor_graph_json.py --- a/tests/test_monitor_graph_json.py +++ b/tests/test_monitor_graph_json.py @@ -46,7 +46,9 @@ tag_store={}, presence=(), port_store=(), + match_data=(), free_frames=(), + lane_count=4, input_queue=(), output_log=(), ) @@ -95,7 +97,9 @@ tag_store={}, presence=(), port_store=(), + match_data=(), free_frames=(), + lane_count=4, input_queue=(), output_log=(), ) @@ -141,7 +145,9 @@ tag_store={}, presence=(), port_store=(), + match_data=(), free_frames=(), + lane_count=4, input_queue=(), output_log=(), ) @@ -220,7 +226,9 @@ tag_store={}, presence=(), port_store=(), + match_data=(), free_frames=(), + lane_count=4, input_queue=(), output_log=(), ) @@ -261,7 +269,9 @@ tag_store={}, presence=(), port_store=(), + match_data=(), free_frames=(), + lane_count=4, input_queue=(), output_log=(), ) @@ -300,7 +310,9 @@ tag_store={}, presence=(), port_store=(), + match_data=(), free_frames=(), + lane_count=4, input_queue=(), output_log=(), ) @@ -472,6 +484,98 @@ assert result["finished"] is True +class TestTagStoreSerialisation: + """Test non-empty tag_store JSON serialization in PE state.""" + + def test_non_empty_tag_store_serialization(self): + """Test that tag_store with non-empty mapping serializes correctly.""" + node = IRNode( + name="&add", + opcode=ArithOp.ADD, + pe=0, + iram_offset=0, + act_id=0, + ) + ir_graph = IRGraph(nodes={"&add": node}) + + inst = Instruction(opcode=ArithOp.ADD, output=OutputStyle.INHERIT, has_const=False, dest_count=2, wide=False, fref=0) + + # Create PESnapshot with non-empty tag_store: act_id 0 maps to frame 2, lane 1 + pe_snap = PESnapshot( + pe_id=0, + iram={0: inst}, + frames=(), + tag_store={0: (2, 1)}, # act_id=0 -> (frame_id=2, lane=1) + presence=(), + port_store=(), + match_data=(), + free_frames=(), + lane_count=4, + input_queue=(), + output_log=(), + ) + snapshot = StateSnapshot( + sim_time=0.0, + next_time=1.0, + pes={0: pe_snap}, + sms={}, + ) + + result = graph_loaded_json(ir_graph, snapshot) + pe_state = result["state"]["pes"]["0"] + + # Verify tag_store is correctly serialized + assert "tag_store" in pe_state + assert "0" in pe_state["tag_store"] # act_id 0 should be a string key "0" + assert pe_state["tag_store"]["0"]["frame_id"] == 2 + assert pe_state["tag_store"]["0"]["lane"] == 1 + assert pe_state["lane_count"] == 4 + + def test_multiple_entries_tag_store_serialization(self): + """Test tag_store with multiple act_id entries serializes correctly.""" + ir_graph = IRGraph() + + inst = Instruction(opcode=ArithOp.ADD, output=OutputStyle.INHERIT, has_const=False, dest_count=2, wide=False, fref=0) + + # Create PESnapshot with multiple tag_store entries + pe_snap = PESnapshot( + pe_id=0, + iram={0: inst}, + frames=(), + tag_store={ + 0: (2, 1), + 1: (3, 0), + 5: (7, 2), + }, + presence=(), + port_store=(), + match_data=(), + free_frames=(), + lane_count=4, + input_queue=(), + output_log=(), + ) + snapshot = StateSnapshot( + sim_time=0.0, + next_time=1.0, + pes={0: pe_snap}, + sms={}, + ) + + result = graph_loaded_json(ir_graph, snapshot) + pe_state = result["state"]["pes"]["0"] + + # Verify all entries are correctly serialized + assert "tag_store" in pe_state + assert pe_state["tag_store"]["0"]["frame_id"] == 2 + assert pe_state["tag_store"]["0"]["lane"] == 1 + assert pe_state["tag_store"]["1"]["frame_id"] == 3 + assert pe_state["tag_store"]["1"]["lane"] == 0 + assert pe_state["tag_store"]["5"]["frame_id"] == 7 + assert pe_state["tag_store"]["5"]["lane"] == 2 + assert pe_state["lane_count"] == 4 + + class TestAC72_NewEventTypesSerialization: """AC7.2: Verify new frame-based event types serialize correctly to JSON.""" @@ -484,6 +588,7 @@ component="pe:0", act_id=1, frame_id=2, + lane=0, ) ir_graph = IRGraph() @@ -508,6 +613,7 @@ assert "details" in event_json assert event_json["details"]["act_id"] == 1 assert event_json["details"]["frame_id"] == 2 + assert event_json["details"]["lane"] == 0 def test_frame_freed_event_serialization(self): """FrameFreed event should serialize with correct type and fields.""" @@ -518,6 +624,8 @@ component="pe:1", act_id=3, frame_id=4, + lane=0, + frame_freed=True, ) ir_graph = IRGraph() @@ -540,6 +648,8 @@ assert "details" in event_json assert event_json["details"]["act_id"] == 3 assert event_json["details"]["frame_id"] == 4 + assert event_json["details"]["lane"] == 0 + assert event_json["details"]["frame_freed"] == True def test_frame_slot_written_event_serialization(self): """FrameSlotWritten event should serialize with correct type and fields.""" diff --git a/tests/test_network_routing.py b/tests/test_network_routing.py --- a/tests/test_network_routing.py +++ b/tests/test_network_routing.py @@ -173,8 +173,8 @@ """build_topology loads initial_tag_store into PE and removes frames from free_frames.""" env = simpy.Environment() initial_tag_store = { - 0: 2, # act_id 0 → frame 2 - 1: 3, # act_id 1 → frame 3 + 0: (2, 0), # act_id 0 → frame 2, lane 0 + 1: (3, 0), # act_id 1 → frame 3, lane 0 } pe_configs = [ PECfg( @@ -187,8 +187,8 @@ system = build_topology(env, pe_configs, sm_configs) pe = system.pes[0] - assert pe.tag_store[0] == 2 - assert pe.tag_store[1] == 3 + assert pe.tag_store[0] == (2, 0) + assert pe.tag_store[1] == (3, 0) # Frames 2 and 3 should be removed from free_frames assert 2 not in pe.free_frames assert 3 not in pe.free_frames @@ -205,8 +205,8 @@ 2: [7, 8, 9], } initial_tag_store = { - 0: 0, # act_id 0 uses frame 0 - 1: 1, # act_id 1 uses frame 1 + 0: (0, 0), # act_id 0 uses frame 0, lane 0 + 1: (1, 0), # act_id 1 uses frame 1, lane 0 } pe_configs = [ PECfg( @@ -225,8 +225,8 @@ assert pe.frames[1][:3] == [4, 5, 6] assert pe.frames[2][:3] == [7, 8, 9] # Verify tag_store loaded - assert pe.tag_store[0] == 0 - assert pe.tag_store[1] == 1 + assert pe.tag_store[0] == (0, 0) + assert pe.tag_store[1] == (1, 0) # Verify free_frames reflects allocations assert 0 not in pe.free_frames assert 1 not in pe.free_frames diff --git a/tests/test_pe.py b/tests/test_pe.py --- a/tests/test_pe.py +++ b/tests/test_pe.py @@ -98,7 +98,7 @@ pe_id=0, iram={0: pass_inst}, initial_frames={0: {8: dest}}, - initial_tag_store={0: 0}, + initial_tag_store={0: (0, 0)}, ) pe = ProcessingElement(env=env, pe_id=0, config=config) @@ -141,7 +141,7 @@ pe_id=0, iram={0: add_inst}, initial_frames={0: {8: dest}}, - initial_tag_store={0: 0}, + initial_tag_store={0: (0, 0)}, ) pe = ProcessingElement(env=env, pe_id=0, config=config) @@ -156,8 +156,8 @@ # No output from first token assert len(output_store.items) == 0 # Matching store should have the operand - frame_id = pe.tag_store[0] - assert pe.presence[frame_id][0] is True + frame_id, _lane = pe.tag_store[0] + assert pe.presence[frame_id][0][0] is True def test_second_dyadic_fires_left_first(self): """AC1.3: Second dyadic token fires when partner found (L then R).""" @@ -181,7 +181,7 @@ pe_id=0, iram={0: add_inst}, initial_frames={0: {8: dest}}, - initial_tag_store={0: 0}, + initial_tag_store={0: (0, 0)}, ) pe = ProcessingElement(env=env, pe_id=0, config=config) @@ -219,7 +219,7 @@ pe_id=0, iram={0: add_inst}, initial_frames={0: {8: dest}}, - initial_tag_store={0: 0}, + initial_tag_store={0: (0, 0)}, ) pe = ProcessingElement(env=env, pe_id=0, config=config) @@ -263,7 +263,7 @@ pe_id=0, iram={0: add_inst}, initial_frames={0: {8: dest}}, - initial_tag_store={0: 0}, + initial_tag_store={0: (0, 0)}, ) pe = ProcessingElement(env=env, pe_id=0, config=config) @@ -310,7 +310,7 @@ pe_id=0, iram={0: add_inst}, initial_frames={0: {8: dest_l, 9: dest_r}}, - initial_tag_store={0: 0}, + initial_tag_store={0: (0, 0)}, ) pe = ProcessingElement(env=env, pe_id=0, config=config) @@ -361,7 +361,7 @@ pe_id=0, iram={0: sweq_inst}, initial_frames={0: {8: dest_l, 9: dest_r}}, - initial_tag_store={0: 0}, + initial_tag_store={0: (0, 0)}, ) pe = ProcessingElement(env=env, pe_id=0, config=config) @@ -414,7 +414,7 @@ pe_id=0, iram={0: sweq_inst}, initial_frames={0: {8: dest_l, 9: dest_r}}, - initial_tag_store={0: 0}, + initial_tag_store={0: (0, 0)}, ) pe = ProcessingElement(env=env, pe_id=0, config=config) @@ -462,7 +462,7 @@ config = PEConfig( pe_id=0, iram={0: free_inst}, - initial_tag_store={0: 0}, + initial_tag_store={0: (0, 0)}, ) pe = ProcessingElement(env=env, pe_id=0, config=config) @@ -498,7 +498,7 @@ pe_id=0, iram={0: gate_inst}, initial_frames={0: {8: dest}}, - initial_tag_store={0: 0}, + initial_tag_store={0: (0, 0)}, ) pe = ProcessingElement(env=env, pe_id=0, config=config) @@ -536,7 +536,7 @@ pe_id=0, iram={0: gate_inst}, initial_frames={0: {8: dest}}, - initial_tag_store={0: 0}, + initial_tag_store={0: (0, 0)}, ) pe = ProcessingElement(env=env, pe_id=0, config=config) @@ -601,7 +601,7 @@ pe_id=0, iram={5: add_inst}, initial_frames={0: {8: dest}}, - initial_tag_store={1: 0}, + initial_tag_store={1: (0, 0)}, ) pe = ProcessingElement(env=env, pe_id=0, config=config) @@ -620,8 +620,8 @@ inject_two_and_run(env, pe, token_l, token_r) # After firing, matching store should be clear - frame_id = pe.tag_store[token_l.act_id] - assert pe.presence[frame_id][token_l.offset % pe.matchable_offsets] is False + frame_id, _lane = pe.tag_store[token_l.act_id] + assert pe.presence[frame_id][token_l.offset % pe.matchable_offsets][0] is False class TestOutputTokenCountMatchesMode: @@ -644,7 +644,7 @@ config = PEConfig( pe_id=0, iram={0: free_inst}, - initial_tag_store={0: 0}, + initial_tag_store={0: (0, 0)}, ) pe = ProcessingElement(env=env, pe_id=0, config=config) @@ -687,7 +687,7 @@ pe_id=0, iram={0: add_inst}, initial_frames={0: {8: dest}}, - initial_tag_store={0: 0}, + initial_tag_store={0: (0, 0)}, ) pe = ProcessingElement(env=env, pe_id=0, config=config) @@ -734,7 +734,7 @@ pe_id=0, iram={0: add_inst}, initial_frames={0: {8: dest_l, 9: dest_r}}, - initial_tag_store={0: 0}, + initial_tag_store={0: (0, 0)}, ) pe = ProcessingElement(env=env, pe_id=0, config=config) @@ -784,7 +784,7 @@ pe_id=0, iram={0: sweq_inst}, initial_frames={0: {8: dest_l, 9: dest_r}}, - initial_tag_store={0: 0}, + initial_tag_store={0: (0, 0)}, ) pe = ProcessingElement(env=env, pe_id=0, config=config) @@ -835,7 +835,7 @@ pe_id=0, iram={0: add_inst}, initial_frames={0: {8: dest}}, - initial_tag_store={0: 0}, + initial_tag_store={0: (0, 0)}, # Note: only act_id 0 is allocated; other act_ids will be invalid ) @@ -893,7 +893,7 @@ pe_id=0, iram={0: add_inst, 1: inc_inst}, initial_frames={0: {8: dest}}, - initial_tag_store={0: 0}, + initial_tag_store={0: (0, 0)}, ) pe = ProcessingElement(env=env, pe_id=0, config=config) diff --git a/tests/test_pe_events.py b/tests/test_pe_events.py --- a/tests/test_pe_events.py +++ b/tests/test_pe_events.py @@ -82,7 +82,7 @@ pe_id=0, iram={0: add_inst}, initial_frames={0: {8: dest}}, - initial_tag_store={0: 0}, + initial_tag_store={0: (0, 0)}, on_event=events.append, ) @@ -125,7 +125,7 @@ pe_id=0, iram={0: add_inst}, initial_frames={0: {8: dest}}, - initial_tag_store={0: 0}, + initial_tag_store={0: (0, 0)}, on_event=events.append, ) @@ -168,7 +168,7 @@ pe_id=0, iram={5: add_inst}, initial_frames={0: {8: dest}}, - initial_tag_store={1: 0}, + initial_tag_store={1: (0, 0)}, on_event=events.append, ) @@ -215,7 +215,7 @@ pe_id=0, iram={0: add_inst}, initial_frames={0: {8: dest}}, - initial_tag_store={0: 0}, + initial_tag_store={0: (0, 0)}, on_event=events.append, ) @@ -258,7 +258,7 @@ pe_id=0, iram={0: eq_inst}, initial_frames={0: {8: dest}}, - initial_tag_store={0: 0}, + initial_tag_store={0: (0, 0)}, on_event=events.append, ) @@ -305,7 +305,7 @@ pe_id=0, iram={0: add_inst}, initial_frames={0: {8: dest}}, - initial_tag_store={0: 0}, + initial_tag_store={0: (0, 0)}, on_event=events.append, ) @@ -350,7 +350,7 @@ pe_id=0, iram={0: add_inst}, initial_frames={0: {8: dest_l, 9: dest_r}}, - initial_tag_store={0: 0}, + initial_tag_store={0: (0, 0)}, on_event=events.append, ) @@ -396,7 +396,7 @@ pe_id=0, iram={0: sweq_inst}, initial_frames={0: {8: dest_l, 9: dest_r}}, - initial_tag_store={0: 0}, + initial_tag_store={0: (0, 0)}, on_event=events.append, ) @@ -433,7 +433,7 @@ config = PEConfig( pe_id=0, iram={0: free_inst}, - initial_tag_store={0: 0}, + initial_tag_store={0: (0, 0)}, on_event=events.append, ) diff --git a/tests/test_pe_frames.py b/tests/test_pe_frames.py --- a/tests/test_pe_frames.py +++ b/tests/test_pe_frames.py @@ -96,7 +96,8 @@ frame_allocated = [e for e in events if isinstance(e, FrameAllocated)] assert len(token_received) > 0 assert len(frame_allocated) > 0 - assert pe.tag_store[0] in range(pe.frame_count) + assert pe.tag_store[0][0] in range(pe.frame_count) + assert frame_allocated[0].lane == 0 def test_free_frame_control_token(self): env = simpy.Environment() @@ -112,7 +113,7 @@ fct_alloc = FrameControlToken(target=0, act_id=0, op=FrameOp.ALLOC, payload=0) inject_and_run(env, pe, fct_alloc) - frame_id = pe.tag_store[0] + frame_id, _lane = pe.tag_store[0] # Now deallocate fct_free = FrameControlToken(target=0, act_id=0, op=FrameOp.FREE, payload=0) @@ -121,6 +122,8 @@ # Should have FrameFreed event and tag_store should be cleared frame_freed = [e for e in events if isinstance(e, FrameFreed)] assert len(frame_freed) > 0 + assert frame_freed[0].lane == 0 + assert frame_freed[0].frame_freed == True assert 0 not in pe.tag_store assert frame_id in pe.free_frames @@ -143,12 +146,12 @@ inject_and_run(env, pe, fct) # Set up: install dyadic instruction at offset 0 - # Mode 0: no const, dest_count=1 + # Mode SINK: no output emission, just execution and matching verification inst = Instruction( opcode=ArithOp.ADD, - output=OutputStyle.INHERIT, + output=OutputStyle.SINK, has_const=False, - dest_count=1, + dest_count=0, wide=False, fref=0, ) @@ -162,7 +165,7 @@ port=Port.L, token_kind=TokenKind.DYADIC, ) - pe.frames[pe.tag_store[0]][0] = dest + pe.frames[pe.tag_store[0][0]][0] = dest # Inject first dyadic token (port=L, data=5) tok1 = DyadToken( @@ -214,7 +217,7 @@ # Allocate frame fct = FrameControlToken(target=0, act_id=0, op=FrameOp.ALLOC, payload=0) inject_and_run(env, pe, fct) - frame_id = pe.tag_store[0] + frame_id, _lane = pe.tag_store[0] # Set up instruction: mode 0 (no const, dest_count=1), fref=8 inst = Instruction( @@ -345,7 +348,7 @@ # Allocate frame fct = FrameControlToken(target=0, act_id=0, op=FrameOp.ALLOC, payload=0) inject_and_run(env, pe, fct) - frame_id = pe.tag_store[0] + frame_id, _lane = pe.tag_store[0] # Set up instruction: SINK output, mode 6 (no const, dest_count=0), fref=10 inst = Instruction( @@ -413,7 +416,7 @@ port=Port.L, token_kind=TokenKind.MONADIC, ) - pe.frames[pe.tag_store[0]][0] = dest + pe.frames[pe.tag_store[0][0]][0] = dest # Wire route table pe.route_table[0] = simpy.Store(env) @@ -487,7 +490,7 @@ pe.iram[6] = inst # Write target PE and target act_id to frame slots 8 and 9 - frame_id = pe.tag_store[0] + frame_id, _lane = pe.tag_store[0] pe.frames[frame_id][8] = 1 # target PE pe.frames[frame_id][9] = 2 # target act_id @@ -505,6 +508,7 @@ frame_allocated = [e for e in pe_events if isinstance(e, FrameAllocated)] assert len(frame_allocated) > 0 assert frame_allocated[0].act_id == 2 + assert frame_allocated[0].lane == 0 class TestFreeFrameOpcode: @@ -523,7 +527,7 @@ # Allocate frame fct = FrameControlToken(target=0, act_id=0, op=FrameOp.ALLOC, payload=0) inject_and_run(env, pe, fct) - frame_id = pe.tag_store[0] + frame_id, _lane = pe.tag_store[0] # Set up FREE_FRAME instruction inst = Instruction( @@ -550,6 +554,8 @@ frame_freed = [e for e in events if isinstance(e, FrameFreed)] assert len(frame_freed) > 0 assert frame_freed[0].frame_id == frame_id + assert frame_freed[0].lane == 0 + assert frame_freed[0].frame_freed == True # tag_store should be cleared assert 0 not in pe.tag_store @@ -560,6 +566,58 @@ # Should have NO Emitted event (FREE_FRAME suppresses) emitted = [e for e in events if isinstance(e, Emitted)] assert len(emitted) == 0 + + +class TestFreeLane: + """AC3.8: FREE_LANE deallocates lane, potentially returning frame to free list.""" + + def test_free_lane_on_last_lane_returns_frame(self): + """When FREE_LANE is called on the last remaining lane, frame should be returned.""" + env = simpy.Environment() + events = [] + config = PEConfig(frame_count=4, lane_count=2, on_event=events.append) + pe = ProcessingElement( + env=env, + pe_id=0, + config=config, + ) + + # Allocate a frame with act_id=1 (gets lane 0) + fct_alloc1 = FrameControlToken(target=0, act_id=1, op=FrameOp.ALLOC, payload=0) + inject_and_run(env, pe, fct_alloc1) + + frame_id, lane1 = pe.tag_store[1] + assert lane1 == 0 + + # Allocate shared child with act_id=2 (gets lane 1) + fct_alloc_shared = FrameControlToken(target=0, act_id=2, op=FrameOp.ALLOC_SHARED, payload=1) + inject_and_run(env, pe, fct_alloc_shared) + + frame_id2, lane2 = pe.tag_store[2] + assert frame_id2 == frame_id + assert lane2 == 1 + + # Now FREE_LANE the child (act_id=2) — should not return frame (still in use) + fct_free_lane_child = FrameControlToken(target=0, act_id=2, op=FrameOp.FREE_LANE, payload=0) + inject_and_run(env, pe, fct_free_lane_child) + + # Lane should be freed, frame still in use + assert 2 not in pe.tag_store + assert frame_id in pe.lane_free or frame_id not in [fid for fid, _ in pe.tag_store.values()] + frame_freed_child = [e for e in events if isinstance(e, FrameFreed) and e.act_id == 2] + assert len(frame_freed_child) > 0 + assert frame_freed_child[0].frame_freed == False # Lane freed, not frame + + # Now FREE_LANE the parent (act_id=1) — this is the last lane, should return frame + fct_free_lane_parent = FrameControlToken(target=0, act_id=1, op=FrameOp.FREE_LANE, payload=0) + inject_and_run(env, pe, fct_free_lane_parent) + + # Frame should now be in free_frames + assert 1 not in pe.tag_store + assert frame_id in pe.free_frames + frame_freed_parent = [e for e in events if isinstance(e, FrameFreed) and e.act_id == 1] + assert len(frame_freed_parent) > 0 + assert frame_freed_parent[0].frame_freed == True # Last lane, frame returned class TestPELocalWriteToken: @@ -613,7 +671,7 @@ # Allocate frame fct = FrameControlToken(target=0, act_id=0, op=FrameOp.ALLOC, payload=0) inject_and_run(env, pe, fct) - frame_id = pe.tag_store[0] + frame_id, _lane = pe.tag_store[0] # Write FrameDest to frame slot 15, is_dest=True dest = FrameDest( @@ -682,9 +740,6 @@ assert len(rejected) > 0 assert rejected[0].token == tok - # Should not crash - assert True - class TestDualDestInherit: """IMPORTANT 2: dest_count=2 non-switch: verify both destinations receive same result.""" @@ -702,7 +757,7 @@ # Allocate frame fct = FrameControlToken(target=0, act_id=0, op=FrameOp.ALLOC, payload=0) inject_and_run(env, pe, fct) - frame_id = pe.tag_store[0] + frame_id, _lane = pe.tag_store[0] # Set up instruction: mode 2 (no const, dest_count=2), fref=8 inst = Instruction( @@ -766,7 +821,7 @@ # Allocate frame fct = FrameControlToken(target=0, act_id=0, op=FrameOp.ALLOC, payload=0) inject_and_run(env, pe, fct) - frame_id = pe.tag_store[0] + frame_id, _lane = pe.tag_store[0] # Set up SWEQ instruction with dest_count=2, fref=8 inst = Instruction( @@ -829,7 +884,7 @@ # Allocate frame fct = FrameControlToken(target=0, act_id=0, op=FrameOp.ALLOC, payload=0) inject_and_run(env, pe, fct) - frame_id = pe.tag_store[0] + frame_id, _lane = pe.tag_store[0] # Set up SWEQ instruction with dest_count=2, fref=8 inst = Instruction( @@ -897,7 +952,7 @@ # Allocate frame fct = FrameControlToken(target=0, act_id=0, op=FrameOp.ALLOC, payload=0) inject_and_run(env, pe, fct) - frame_id = pe.tag_store[0] + frame_id, _lane = pe.tag_store[0] # Set up GATE instruction inst = Instruction( @@ -946,7 +1001,7 @@ # Allocate frame fct = FrameControlToken(target=0, act_id=0, op=FrameOp.ALLOC, payload=0) inject_and_run(env, pe, fct) - frame_id = pe.tag_store[0] + frame_id, _lane = pe.tag_store[0] # Set up GATE instruction inst = Instruction( @@ -999,7 +1054,7 @@ # Allocate frame fct = FrameControlToken(target=0, act_id=0, op=FrameOp.ALLOC, payload=0) inject_and_run(env, pe, fct) - frame_id = pe.tag_store[0] + frame_id, _lane = pe.tag_store[0] # Set up SM READ instruction with const (mode 1: const, dest with return route), fref=8 # Const slot contains the SM target, dest slot contains return route @@ -1090,7 +1145,7 @@ # Allocate frame fct = FrameControlToken(target=0, act_id=0, op=FrameOp.ALLOC, payload=0) inject_and_run(env, pe, fct) - frame_id = pe.tag_store[0] + frame_id, _lane = pe.tag_store[0] # Set up dyadic instruction inst = Instruction( @@ -1172,7 +1227,7 @@ # Allocate frame fct = FrameControlToken(target=0, act_id=0, op=FrameOp.ALLOC, payload=0) inject_and_run(env, pe, fct) - frame_id = pe.tag_store[0] + frame_id, _lane = pe.tag_store[0] # Set up monadic instruction inst = Instruction( @@ -1282,7 +1337,7 @@ # Allocate frame fct = FrameControlToken(target=0, act_id=5, op=FrameOp.ALLOC, payload=0) inject_and_run(env, pe, fct) - frame_id = pe.tag_store[5] + frame_id, _lane = pe.tag_store[5] # Set up EXTRACT_TAG instruction inst = Instruction( @@ -1354,7 +1409,7 @@ # Allocate frame fct = FrameControlToken(target=0, act_id=7, op=FrameOp.ALLOC, payload=0) inject_and_run(env, pe, fct) - frame_id = pe.tag_store[7] + frame_id, _lane = pe.tag_store[7] # Set up SM READ instruction (monadic in terms of PE pipeline) inst = Instruction( @@ -1428,7 +1483,7 @@ # Allocate frame fct = FrameControlToken(target=0, act_id=10, op=FrameOp.ALLOC, payload=0) inject_and_run(env, pe, fct) - frame_id = pe.tag_store[10] + frame_id, _lane = pe.tag_store[10] # Set up FREE_FRAME instruction inst = Instruction( @@ -1491,7 +1546,7 @@ # Allocate frame fct = FrameControlToken(target=0, act_id=12, op=FrameOp.ALLOC, payload=0) inject_and_run(env, pe, fct) - frame_id = pe.tag_store[12] + frame_id, _lane = pe.tag_store[12] # Set up ALLOC_REMOTE instruction inst = Instruction( diff --git a/tests/test_pe_lanes.py b/tests/test_pe_lanes.py new file mode 100644 --- /dev/null +++ b/tests/test_pe_lanes.py @@ -0,0 +1,1165 @@ +""" +Lane-based PE rewrite tests. + +Verifies frame-lanes.AC3, frame-lanes.AC4, frame-lanes.AC5, and frame-lanes.AC8: +- AC3.1: FrameOp.ALLOC_SHARED assigns next free lane from parent frame +- AC3.2: FrameOp.FREE_LANE removes tag_store entry, clears lane data, keeps frame +- AC3.3: FrameOp.FREE on shared frame returns lane if frame still in use +- AC3.4: FrameOp.ALLOC unchanged — allocates fresh frame, assigns lane 0 +- AC3.5: FrameAllocated event gains lane field +- AC3.6: ALLOC_SHARED with all lanes occupied emits TokenRejected +- AC4: ALLOC_REMOTE reads fref+2 for data-driven ALLOC_SHARED vs ALLOC +- AC5.1: FREE_FRAME opcode uses smart FREE behaviour on shared frames +- AC8.1: Two act_ids sharing a frame have independent matching +- AC8.2: ALLOC_SHARED with exhausted lanes emits TokenRejected +- AC8.3: FREE on shared frame preserves other lanes' data +- AC8.4: ALLOC_REMOTE emits ALLOC_SHARED when fref+2 is non-zero +- AC8.5: ALLOC_REMOTE emits ALLOC when fref+2 is zero (backwards compatible) +- AC8.6: Full loop pipelining scenario — two iterations concurrent on different lanes +""" + +import pytest +import simpy + +from cm_inst import ( + ArithOp, FrameDest, FrameOp, Instruction, Port, TokenKind, OutputStyle, + RoutingOp, +) +from emu.events import ( + FrameAllocated, FrameFreed, TokenReceived, TokenRejected, Matched, Emitted, +) +from emu.pe import ProcessingElement +from emu.types import PEConfig +from tokens import DyadToken, FrameControlToken + + +def inject_and_run(env, pe, token): + """Helper: inject token and run simulation.""" + def _put(): + yield pe.input_store.put(token) + env.process(_put()) + env.run() + + +class TestAllocShared: + """AC3.1: ALLOC_SHARED assigns next free lane from parent frame.""" + + def test_alloc_shared_basic(self): + """Parent allocates frame, child allocates shared lane.""" + env = simpy.Environment() + events = [] + config = PEConfig(frame_count=4, lane_count=4, on_event=events.append) + pe = ProcessingElement(env=env, pe_id=0, config=config) + + # Parent ALLOC + fct_parent = FrameControlToken( + target=0, act_id=0, op=FrameOp.ALLOC, payload=0 + ) + inject_and_run(env, pe, fct_parent) + + parent_frame_id, parent_lane = pe.tag_store[0] + assert parent_lane == 0, "Parent should allocate lane 0" + + # Child ALLOC_SHARED with parent_act_id=0 + fct_child = FrameControlToken( + target=0, act_id=1, op=FrameOp.ALLOC_SHARED, payload=0 + ) + inject_and_run(env, pe, fct_child) + + child_frame_id, child_lane = pe.tag_store[1] + assert child_frame_id == parent_frame_id, "Child should share parent's frame" + assert child_lane == 1, "Child should allocate lane 1" + assert child_lane != parent_lane, "Child lane should differ from parent" + + # Verify FrameAllocated event for child + frame_allocated = [e for e in events if isinstance(e, FrameAllocated)] + assert len(frame_allocated) >= 2, "Should have 2 FrameAllocated events" + assert frame_allocated[0].lane == 0, "Parent allocated lane 0" + assert frame_allocated[1].lane == 1, "Child allocated lane 1" + + def test_alloc_shared_multiple_lanes(self): + """Multiple children allocate different lanes from same parent frame.""" + env = simpy.Environment() + events = [] + config = PEConfig(frame_count=4, lane_count=4, on_event=events.append) + pe = ProcessingElement(env=env, pe_id=0, config=config) + + # Parent ALLOC + fct_parent = FrameControlToken( + target=0, act_id=0, op=FrameOp.ALLOC, payload=0 + ) + inject_and_run(env, pe, fct_parent) + parent_frame_id, _parent_lane = pe.tag_store[0] + + # Child 1 ALLOC_SHARED + fct_child1 = FrameControlToken( + target=0, act_id=1, op=FrameOp.ALLOC_SHARED, payload=0 + ) + inject_and_run(env, pe, fct_child1) + _child1_frame_id, child1_lane = pe.tag_store[1] + + # Child 2 ALLOC_SHARED + fct_child2 = FrameControlToken( + target=0, act_id=2, op=FrameOp.ALLOC_SHARED, payload=0 + ) + inject_and_run(env, pe, fct_child2) + _child2_frame_id, child2_lane = pe.tag_store[2] + + # All should share same frame + assert pe.tag_store[0][0] == parent_frame_id + assert pe.tag_store[1][0] == parent_frame_id + assert pe.tag_store[2][0] == parent_frame_id + + # Lanes should differ: 0, 1, 2 + assert child1_lane != 0, "Child1 lane should not be 0" + assert child2_lane != 0, "Child2 lane should not be 0" + assert child1_lane != child2_lane, "Child1 and child2 lanes should differ" + + def test_alloc_shared_invalid_parent(self): + """ALLOC_SHARED with non-existent parent emits TokenRejected.""" + env = simpy.Environment() + events = [] + config = PEConfig(frame_count=4, lane_count=4, on_event=events.append) + pe = ProcessingElement(env=env, pe_id=0, config=config) + + # Try ALLOC_SHARED with non-existent parent_act_id=999 + fct = FrameControlToken( + target=0, act_id=0, op=FrameOp.ALLOC_SHARED, payload=999 + ) + inject_and_run(env, pe, fct) + + rejected = [e for e in events if isinstance(e, TokenRejected)] + assert len(rejected) > 0, "Should have TokenRejected event" + assert "not in tag store" in rejected[0].reason, "Reason should mention tag_store" + + # Parent should not be in tag_store + assert 999 not in pe.tag_store + + def test_alloc_shared_self_referential_guard(self): + """ALLOC_SHARED with act_id already in tag_store emits TokenRejected.""" + env = simpy.Environment() + events = [] + config = PEConfig(frame_count=4, lane_count=4, on_event=events.append) + pe = ProcessingElement(env=env, pe_id=0, config=config) + + # First ALLOC to establish act_id=0 in tag_store + fct_alloc = FrameControlToken( + target=0, act_id=0, op=FrameOp.ALLOC, payload=0 + ) + inject_and_run(env, pe, fct_alloc) + assert 0 in pe.tag_store, "act_id=0 should be in tag_store after ALLOC" + frame_id_0, lane_0 = pe.tag_store[0] + + # Now try ALLOC_SHARED with act_id=0 and payload=1 (parent_act_id=1) + # This should be rejected because act_id=0 already exists + fct_alloc_parent = FrameControlToken( + target=0, act_id=1, op=FrameOp.ALLOC, payload=0 + ) + inject_and_run(env, pe, fct_alloc_parent) + assert 1 in pe.tag_store, "act_id=1 should be in tag_store after ALLOC" + + events.clear() + fct_shared = FrameControlToken( + target=0, act_id=0, op=FrameOp.ALLOC_SHARED, payload=1 + ) + inject_and_run(env, pe, fct_shared) + + rejected = [e for e in events if isinstance(e, TokenRejected)] + assert len(rejected) > 0, "Should have TokenRejected event" + assert "already in tag store" in rejected[0].reason, "Reason should mention already in tag store" + + # Frame and lane should be unchanged + assert pe.tag_store[0] == (frame_id_0, lane_0), "act_id=0 state should be unchanged" + + +class TestLaneExhaustion: + """AC3.6, AC8.2: Lane exhaustion and TokenRejected.""" + + def test_alloc_shared_exhausts_all_lanes(self): + """Allocate all lanes, then ALLOC_SHARED fails with TokenRejected.""" + env = simpy.Environment() + events = [] + config = PEConfig(frame_count=4, lane_count=4, on_event=events.append) + pe = ProcessingElement(env=env, pe_id=0, config=config) + + # Parent ALLOC uses lane 0 + fct_parent = FrameControlToken( + target=0, act_id=0, op=FrameOp.ALLOC, payload=0 + ) + inject_and_run(env, pe, fct_parent) + + # Allocate lanes 1, 2, 3 + for i in range(1, 4): + fct = FrameControlToken( + target=0, act_id=i, op=FrameOp.ALLOC_SHARED, payload=0 + ) + inject_and_run(env, pe, fct) + assert i in pe.tag_store, f"Child {i} should be allocated" + + # Try to allocate one more (all lanes exhausted) + fct_fail = FrameControlToken( + target=0, act_id=4, op=FrameOp.ALLOC_SHARED, payload=0 + ) + inject_and_run(env, pe, fct_fail) + + rejected = [e for e in events if isinstance(e, TokenRejected)] + assert len(rejected) > 0, "Should have TokenRejected event" + assert "no free lanes" in rejected[0].reason, "Reason should be 'no free lanes'" + + # act_id=4 should not be in tag_store + assert 4 not in pe.tag_store, "Failed allocation should not add to tag_store" + + def test_lane_exhaustion_with_multiple_frames(self): + """Lane exhaustion is per-frame; different frames have independent lanes.""" + env = simpy.Environment() + events = [] + config = PEConfig(frame_count=4, lane_count=4, on_event=events.append) + pe = ProcessingElement(env=env, pe_id=0, config=config) + + # Frame 1: Parent 0 allocates lane 0 + fct1 = FrameControlToken( + target=0, act_id=0, op=FrameOp.ALLOC, payload=0 + ) + inject_and_run(env, pe, fct1) + frame1_id, _lane = pe.tag_store[0] + + # Frame 2: Parent 10 allocates lane 0 + fct2 = FrameControlToken( + target=0, act_id=10, op=FrameOp.ALLOC, payload=0 + ) + inject_and_run(env, pe, fct2) + frame2_id, _lane = pe.tag_store[10] + + assert frame1_id != frame2_id, "Should allocate different frames" + + # Frame 1: Exhaust all lanes + for i in range(1, 4): + fct = FrameControlToken( + target=0, act_id=i, op=FrameOp.ALLOC_SHARED, payload=0 + ) + inject_and_run(env, pe, fct) + + # Frame 2: Can still allocate more lanes (independent) + for i in range(11, 14): + fct = FrameControlToken( + target=0, act_id=i, op=FrameOp.ALLOC_SHARED, payload=10 + ) + inject_and_run(env, pe, fct) + assert i in pe.tag_store, f"Frame2 child {i} should be allocated" + + +class TestFreeLane: + """AC3.2: FREE_LANE clears lane data, keeps frame, returns lane to pool.""" + + def test_free_lane_basic(self): + """FREE_LANE removes act_id from tag_store, clears lane data, keeps frame.""" + env = simpy.Environment() + events = [] + config = PEConfig(frame_count=4, lane_count=4, on_event=events.append) + pe = ProcessingElement(env=env, pe_id=0, config=config) + + # Parent ALLOC + fct_parent = FrameControlToken( + target=0, act_id=0, op=FrameOp.ALLOC, payload=0 + ) + inject_and_run(env, pe, fct_parent) + parent_frame_id, _parent_lane = pe.tag_store[0] + + # Child ALLOC_SHARED + fct_child = FrameControlToken( + target=0, act_id=1, op=FrameOp.ALLOC_SHARED, payload=0 + ) + inject_and_run(env, pe, fct_child) + _child_frame_id, child_lane = pe.tag_store[1] + + # FREE_LANE for child + fct_free = FrameControlToken( + target=0, act_id=1, op=FrameOp.FREE_LANE, payload=0 + ) + inject_and_run(env, pe, fct_free) + + # Child should be removed from tag_store + assert 1 not in pe.tag_store, "Child should be removed from tag_store" + + # Parent should still be present + assert 0 in pe.tag_store, "Parent should still be in tag_store" + + # Frame should NOT be in free_frames (still used by parent) + assert parent_frame_id not in pe.free_frames, "Frame should not be free" + + # FrameFreed event should have frame_freed=False + frame_freed = [e for e in events if isinstance(e, FrameFreed)] + assert len(frame_freed) > 0, "Should have FrameFreed event" + assert frame_freed[-1].frame_freed == False, "frame_freed should be False" + assert frame_freed[-1].lane == child_lane, "Event should report correct lane" + + def test_free_lane_returns_lane_to_pool(self): + """After FREE_LANE, freed lane can be reused by ALLOC_SHARED.""" + env = simpy.Environment() + events = [] + config = PEConfig(frame_count=4, lane_count=4, on_event=events.append) + pe = ProcessingElement(env=env, pe_id=0, config=config) + + # Parent ALLOC + fct_parent = FrameControlToken( + target=0, act_id=0, op=FrameOp.ALLOC, payload=0 + ) + inject_and_run(env, pe, fct_parent) + parent_frame_id, _parent_lane = pe.tag_store[0] + + # Child 1 ALLOC_SHARED (lane 1) + fct_child1 = FrameControlToken( + target=0, act_id=1, op=FrameOp.ALLOC_SHARED, payload=0 + ) + inject_and_run(env, pe, fct_child1) + _child1_frame_id, child1_lane = pe.tag_store[1] + assert child1_lane == 1 + + # FREE_LANE child 1 + fct_free = FrameControlToken( + target=0, act_id=1, op=FrameOp.FREE_LANE, payload=0 + ) + inject_and_run(env, pe, fct_free) + + # Child 2 ALLOC_SHARED (should get lane 1 again) + fct_child2 = FrameControlToken( + target=0, act_id=2, op=FrameOp.ALLOC_SHARED, payload=0 + ) + inject_and_run(env, pe, fct_child2) + _child2_frame_id, child2_lane = pe.tag_store[2] + + # Lane 1 should be reused for child 2 + assert child2_lane == 1, "Freed lane 1 should be reused" + + +class TestIndependentMatching: + """AC8.1: Two act_ids sharing a frame have independent matching.""" + + def test_independent_matching_same_offset(self): + """L operand for act_id 0 does not interfere with L for act_id 1.""" + env = simpy.Environment() + events = [] + config = PEConfig( + frame_count=4, lane_count=4, matchable_offsets=4, on_event=events.append + ) + pe = ProcessingElement(env=env, pe_id=0, config=config) + + # Parent ALLOC + fct_parent = FrameControlToken( + target=0, act_id=0, op=FrameOp.ALLOC, payload=0 + ) + inject_and_run(env, pe, fct_parent) + parent_frame_id, _parent_lane = pe.tag_store[0] + + # Child ALLOC_SHARED + fct_child = FrameControlToken( + target=0, act_id=1, op=FrameOp.ALLOC_SHARED, payload=0 + ) + inject_and_run(env, pe, fct_child) + _child_frame_id, child_lane = pe.tag_store[1] + + # Install dyadic instruction at offset 0 + inst = Instruction( + opcode=ArithOp.ADD, + output=OutputStyle.SINK, + has_const=False, + dest_count=0, + wide=False, + fref=0, + ) + pe.iram[0] = inst + + # Send L operand for act_id=0 + tok_l_0 = DyadToken( + target=0, offset=0, act_id=0, data=5, port=Port.L + ) + inject_and_run(env, pe, tok_l_0) + + # Should have 1 TokenReceived, 0 Matched (waiting for R) + matched = [e for e in events if isinstance(e, Matched)] + assert len(matched) == 0, "Should not match yet (waiting for R)" + + # Send L operand for act_id=1 at same offset + tok_l_1 = DyadToken( + target=0, offset=0, act_id=1, data=7, port=Port.L + ) + inject_and_run(env, pe, tok_l_1) + + # Should still have 0 Matched (both waiting for R) + matched = [e for e in events if isinstance(e, Matched)] + assert len(matched) == 0, "Both should be waiting for R" + + # Send R for act_id=0 + tok_r_0 = DyadToken( + target=0, offset=0, act_id=0, data=3, port=Port.R + ) + inject_and_run(env, pe, tok_r_0) + + # Should now have 1 Matched for act_id=0 + matched = [e for e in events if isinstance(e, Matched)] + assert len(matched) == 1, "Should have 1 match for act_id=0" + assert matched[0].act_id == 0, "Match should be for act_id=0" + assert matched[0].left == 5, "Left should be 5" + assert matched[0].right == 3, "Right should be 3" + + # Send R for act_id=1 + tok_r_1 = DyadToken( + target=0, offset=0, act_id=1, data=2, port=Port.R + ) + inject_and_run(env, pe, tok_r_1) + + # Should now have 2 Matched + matched = [e for e in events if isinstance(e, Matched)] + assert len(matched) == 2, "Should have 2 matches total" + m1 = [m for m in matched if m.act_id == 1][0] + assert m1.left == 7, "act_id=1 left should be 7" + assert m1.right == 2, "act_id=1 right should be 2" + + def test_independent_matching_different_offsets(self): + """Different offsets per lane maintain independence.""" + env = simpy.Environment() + events = [] + config = PEConfig( + frame_count=4, lane_count=4, matchable_offsets=4, on_event=events.append + ) + pe = ProcessingElement(env=env, pe_id=0, config=config) + + # Parent ALLOC + fct_parent = FrameControlToken( + target=0, act_id=0, op=FrameOp.ALLOC, payload=0 + ) + inject_and_run(env, pe, fct_parent) + + # Child ALLOC_SHARED + fct_child = FrameControlToken( + target=0, act_id=1, op=FrameOp.ALLOC_SHARED, payload=0 + ) + inject_and_run(env, pe, fct_child) + + # Install dyadic instructions at offsets 0 and 1 + inst0 = Instruction( + opcode=ArithOp.ADD, output=OutputStyle.SINK, + has_const=False, dest_count=0, wide=False, fref=0 + ) + inst1 = Instruction( + opcode=ArithOp.SUB, output=OutputStyle.SINK, + has_const=False, dest_count=0, wide=False, fref=0 + ) + pe.iram[0] = inst0 + pe.iram[1] = inst1 + + # Send L for act_id=0 at offset 0 + tok_l_0_off0 = DyadToken( + target=0, offset=0, act_id=0, data=10, port=Port.L + ) + inject_and_run(env, pe, tok_l_0_off0) + + # Send L for act_id=1 at offset 1 + tok_l_1_off1 = DyadToken( + target=0, offset=1, act_id=1, data=20, port=Port.L + ) + inject_and_run(env, pe, tok_l_1_off1) + + # Neither should match yet + matched = [e for e in events if isinstance(e, Matched)] + assert len(matched) == 0, "No matches yet" + + # Send R for act_id=0 at offset 0 + tok_r_0_off0 = DyadToken( + target=0, offset=0, act_id=0, data=5, port=Port.R + ) + inject_and_run(env, pe, tok_r_0_off0) + + # Should match for offset 0 + matched = [e for e in events if isinstance(e, Matched)] + assert len(matched) == 1, "Should have 1 match" + assert matched[0].offset == 0, "Match should be at offset 0" + + # Send R for act_id=1 at offset 1 + tok_r_1_off1 = DyadToken( + target=0, offset=1, act_id=1, data=15, port=Port.R + ) + inject_and_run(env, pe, tok_r_1_off1) + + # Should match for offset 1 + matched = [e for e in events if isinstance(e, Matched)] + assert len(matched) == 2, "Should have 2 matches" + m1 = [m for m in matched if m.offset == 1][0] + assert m1.act_id == 1, "Offset 1 match should be act_id=1" + + +class TestSmartFree: + """AC3.3, AC8.3: Smart FREE on shared frames preserves data and manages lanes.""" + + def test_free_on_shared_frame_preserves_other_lanes(self): + """FREE on act_id=0 when act_id=1 uses frame; lane 1 data preserved.""" + env = simpy.Environment() + events = [] + config = PEConfig( + frame_count=4, lane_count=4, matchable_offsets=4, on_event=events.append + ) + pe = ProcessingElement(env=env, pe_id=0, config=config) + + # Parent ALLOC + fct_parent = FrameControlToken( + target=0, act_id=0, op=FrameOp.ALLOC, payload=0 + ) + inject_and_run(env, pe, fct_parent) + parent_frame_id, _parent_lane = pe.tag_store[0] + + # Child ALLOC_SHARED + fct_child = FrameControlToken( + target=0, act_id=1, op=FrameOp.ALLOC_SHARED, payload=0 + ) + inject_and_run(env, pe, fct_child) + _child_frame_id, child_lane = pe.tag_store[1] + + # Install instruction + inst = Instruction( + opcode=ArithOp.ADD, output=OutputStyle.SINK, + has_const=False, dest_count=0, wide=False, fref=0 + ) + pe.iram[0] = inst + + # Store L operand on child's lane + tok_l_1 = DyadToken( + target=0, offset=0, act_id=1, data=7, port=Port.L + ) + inject_and_run(env, pe, tok_l_1) + + # Verify child's match slot has data + frame_id, lane = pe.tag_store[1] + assert pe.match_data[frame_id][0][lane] == 7, "Child lane should have L operand" + assert pe.presence[frame_id][0][lane] == True, "Child presence should be set" + + # FREE parent + fct_free_parent = FrameControlToken( + target=0, act_id=0, op=FrameOp.FREE, payload=0 + ) + inject_and_run(env, pe, fct_free_parent) + + # Parent should be removed, child should still be present + assert 0 not in pe.tag_store, "Parent should be removed" + assert 1 in pe.tag_store, "Child should still be present" + + # Frame should NOT be in free_frames + assert parent_frame_id not in pe.free_frames, "Frame should not be free" + + # Child's match data should be preserved + assert pe.match_data[frame_id][0][lane] == 7, "Child data should be preserved" + assert pe.presence[frame_id][0][lane] == True, "Child presence should be preserved" + + # FrameFreed event should have frame_freed=False + frame_freed = [e for e in events if isinstance(e, FrameFreed)] + assert any(e.frame_freed == False for e in frame_freed), "Should have frame_freed=False" + + def test_free_last_lane_returns_frame(self): + """FREE on last act_id using frame returns frame to free_frames.""" + env = simpy.Environment() + events = [] + config = PEConfig(frame_count=4, lane_count=4, on_event=events.append) + pe = ProcessingElement(env=env, pe_id=0, config=config) + + # Parent ALLOC + fct_parent = FrameControlToken( + target=0, act_id=0, op=FrameOp.ALLOC, payload=0 + ) + inject_and_run(env, pe, fct_parent) + parent_frame_id, _parent_lane = pe.tag_store[0] + + # Child ALLOC_SHARED + fct_child = FrameControlToken( + target=0, act_id=1, op=FrameOp.ALLOC_SHARED, payload=0 + ) + inject_and_run(env, pe, fct_child) + + # FREE child + fct_free_child = FrameControlToken( + target=0, act_id=1, op=FrameOp.FREE_LANE, payload=0 + ) + inject_and_run(env, pe, fct_free_child) + + # Frame should still not be free (parent still using it) + assert parent_frame_id not in pe.free_frames, "Frame should not be free yet" + + # FREE parent + fct_free_parent = FrameControlToken( + target=0, act_id=0, op=FrameOp.FREE, payload=0 + ) + inject_and_run(env, pe, fct_free_parent) + + # Now frame should be free + assert parent_frame_id in pe.free_frames, "Frame should be free" + + # tag_store should be empty + assert len(pe.tag_store) == 0, "tag_store should be empty" + + # lane_free entry should be cleaned up + assert parent_frame_id not in pe.lane_free, "lane_free entry should be cleaned" + + # FrameFreed event should have frame_freed=True + frame_freed = [e for e in events if isinstance(e, FrameFreed)] + assert any(e.frame_freed == True for e in frame_freed), "Should have frame_freed=True" + + def test_alloc_unchanged_allocates_fresh_frame(self): + """Regular ALLOC still works: allocates fresh frame, lane 0.""" + env = simpy.Environment() + events = [] + config = PEConfig(frame_count=4, lane_count=4, on_event=events.append) + pe = ProcessingElement(env=env, pe_id=0, config=config) + + # First ALLOC + fct1 = FrameControlToken( + target=0, act_id=0, op=FrameOp.ALLOC, payload=0 + ) + inject_and_run(env, pe, fct1) + frame_id_0, lane_0 = pe.tag_store[0] + assert lane_0 == 0, "First ALLOC should assign lane 0" + + # Second ALLOC (different frame) + fct2 = FrameControlToken( + target=0, act_id=10, op=FrameOp.ALLOC, payload=0 + ) + inject_and_run(env, pe, fct2) + frame_id_10, lane_10 = pe.tag_store[10] + assert lane_10 == 0, "Second ALLOC should assign lane 0" + + # Frames should be different + assert frame_id_0 != frame_id_10, "Different ALLOC should get different frames" + + def test_data_preservation_across_free_lanes(self): + """Match data on one lane not affected by FREE of another lane.""" + env = simpy.Environment() + events = [] + config = PEConfig( + frame_count=4, lane_count=4, matchable_offsets=4, on_event=events.append + ) + pe = ProcessingElement(env=env, pe_id=0, config=config) + + # Parent ALLOC + fct_parent = FrameControlToken( + target=0, act_id=0, op=FrameOp.ALLOC, payload=0 + ) + inject_and_run(env, pe, fct_parent) + frame_id, _parent_lane = pe.tag_store[0] + + # Child 1 ALLOC_SHARED + fct_child1 = FrameControlToken( + target=0, act_id=1, op=FrameOp.ALLOC_SHARED, payload=0 + ) + inject_and_run(env, pe, fct_child1) + _frame_id_1, lane_1 = pe.tag_store[1] + + # Child 2 ALLOC_SHARED + fct_child2 = FrameControlToken( + target=0, act_id=2, op=FrameOp.ALLOC_SHARED, payload=0 + ) + inject_and_run(env, pe, fct_child2) + _frame_id_2, lane_2 = pe.tag_store[2] + + # Install instruction + inst = Instruction( + opcode=ArithOp.ADD, output=OutputStyle.SINK, + has_const=False, dest_count=0, wide=False, fref=0 + ) + pe.iram[0] = inst + + # Store L operand on lane 1 + tok_l_1 = DyadToken( + target=0, offset=0, act_id=1, data=7, port=Port.L + ) + inject_and_run(env, pe, tok_l_1) + + # Store L operand on lane 2 + tok_l_2 = DyadToken( + target=0, offset=0, act_id=2, data=11, port=Port.L + ) + inject_and_run(env, pe, tok_l_2) + + # FREE lane 1 + fct_free_1 = FrameControlToken( + target=0, act_id=1, op=FrameOp.FREE_LANE, payload=0 + ) + inject_and_run(env, pe, fct_free_1) + + # Lane 2's data should be untouched + assert pe.match_data[frame_id][0][lane_2] == 11, "Lane 2 data should be preserved" + assert pe.presence[frame_id][0][lane_2] == True, "Lane 2 presence should be preserved" + + # Lane 1 should be cleared + assert pe.match_data[frame_id][0][lane_1] is None, "Lane 1 data should be cleared" + assert pe.presence[frame_id][0][lane_1] == False, "Lane 1 presence should be cleared" + + +class TestAllocRemoteDataDriven: + """AC8.4, AC8.5: ALLOC_REMOTE reads fref+2 for data-driven ALLOC_SHARED vs ALLOC.""" + + def test_alloc_remote_emits_alloc_shared_when_parent_nonzero(self): + """AC8.4: ALLOC_REMOTE emits ALLOC_SHARED when fref+2 is non-zero.""" + env = simpy.Environment() + events = [] + output_store = simpy.Store(env) + + # PE0: source of ALLOC_REMOTE + config0 = PEConfig(frame_count=4, lane_count=4, on_event=events.append) + pe0 = ProcessingElement(env=env, pe_id=0, config=config0) + pe0.route_table[1] = output_store # Capture emitted token + + # Allocate a frame for act_id=0 on PE0 + fct_parent = FrameControlToken( + target=0, act_id=0, op=FrameOp.ALLOC, payload=0 + ) + inject_and_run(env, pe0, fct_parent) + frame_id, _lane = pe0.tag_store[0] + + # Set up ALLOC_REMOTE instruction with fref pointing to frame constants + # fref+0: target PE=1, fref+1: target act_id=5, fref+2: parent act_id=3 + inst = Instruction( + opcode=RoutingOp.ALLOC_REMOTE, + output=OutputStyle.SINK, # Not used for ALLOC_REMOTE + has_const=False, + dest_count=0, + wide=False, + fref=10, + ) + pe0.iram[0] = inst + + # Load frame slots with constants + pe0.frames[frame_id][10] = 1 # target PE + pe0.frames[frame_id][11] = 5 # target act_id + pe0.frames[frame_id][12] = 3 # parent act_id (non-zero = ALLOC_SHARED) + + # Send MonadToken to trigger ALLOC_REMOTE + tok = DyadToken( + target=0, offset=0, act_id=0, data=0, port=Port.L + ) + inject_and_run(env, pe0, tok) + + # Verify FrameControlToken was emitted with ALLOC_SHARED + assert len(output_store.items) > 0, "Should have emitted a token" + emitted = output_store.items[0] + assert isinstance(emitted, FrameControlToken), "Should emit FrameControlToken" + assert emitted.op == FrameOp.ALLOC_SHARED, "Should emit ALLOC_SHARED" + assert emitted.payload == 3, "Payload should be parent act_id=3" + assert emitted.target == 1, "Should target PE 1" + assert emitted.act_id == 5, "Should target act_id 5" + + def test_alloc_remote_emits_alloc_when_parent_zero(self): + """AC8.5: ALLOC_REMOTE emits ALLOC when fref+2 is zero (backwards compatible).""" + env = simpy.Environment() + events = [] + output_store = simpy.Store(env) + + # PE0: source of ALLOC_REMOTE + config0 = PEConfig(frame_count=4, lane_count=4, on_event=events.append) + pe0 = ProcessingElement(env=env, pe_id=0, config=config0) + pe0.route_table[1] = output_store # Capture emitted token + + # Allocate a frame for act_id=0 on PE0 + fct_parent = FrameControlToken( + target=0, act_id=0, op=FrameOp.ALLOC, payload=0 + ) + inject_and_run(env, pe0, fct_parent) + frame_id, _lane = pe0.tag_store[0] + + # Set up ALLOC_REMOTE instruction + # fref+0: target PE=1, fref+1: target act_id=5, fref+2: parent act_id=0 + inst = Instruction( + opcode=RoutingOp.ALLOC_REMOTE, + output=OutputStyle.SINK, + has_const=False, + dest_count=0, + wide=False, + fref=10, + ) + pe0.iram[0] = inst + + # Load frame slots with constants + pe0.frames[frame_id][10] = 1 # target PE + pe0.frames[frame_id][11] = 5 # target act_id + pe0.frames[frame_id][12] = 0 # parent act_id (zero = ALLOC) + + # Send MonadToken to trigger ALLOC_REMOTE + tok = DyadToken( + target=0, offset=0, act_id=0, data=0, port=Port.L + ) + inject_and_run(env, pe0, tok) + + # Verify FrameControlToken was emitted with ALLOC (not ALLOC_SHARED) + assert len(output_store.items) > 0, "Should have emitted a token" + emitted = output_store.items[0] + assert isinstance(emitted, FrameControlToken), "Should emit FrameControlToken" + assert emitted.op == FrameOp.ALLOC, "Should emit ALLOC" + assert emitted.payload == 0, "Payload should be 0 for ALLOC" + assert emitted.target == 1, "Should target PE 1" + assert emitted.act_id == 5, "Should target act_id 5" + + def test_alloc_remote_fref_plus_2_missing_defaults_to_zero(self): + """ALLOC_REMOTE gracefully handles fref+2 outside frame bounds (defaults to 0).""" + env = simpy.Environment() + events = [] + output_store = simpy.Store(env) + + # PE0: source of ALLOC_REMOTE + config0 = PEConfig(frame_count=4, lane_count=4, on_event=events.append) + pe0 = ProcessingElement(env=env, pe_id=0, config=config0) + pe0.route_table[1] = output_store + + # Allocate frame + fct_parent = FrameControlToken( + target=0, act_id=0, op=FrameOp.ALLOC, payload=0 + ) + inject_and_run(env, pe0, fct_parent) + frame_id, _lane = pe0.tag_store[0] + + # Set up ALLOC_REMOTE with fref pointing near end of frame + inst = Instruction( + opcode=RoutingOp.ALLOC_REMOTE, + output=OutputStyle.SINK, + has_const=False, + dest_count=0, + wide=False, + fref=62, # frame_slots defaults to 64, so fref+2=64 is outside + ) + pe0.iram[0] = inst + + # Load only fref+0 and fref+1 (fref+2 is beyond frame bounds) + pe0.frames[frame_id][62] = 1 + pe0.frames[frame_id][63] = 7 + + # Send MonadToken + tok = DyadToken( + target=0, offset=0, act_id=0, data=0, port=Port.L + ) + inject_and_run(env, pe0, tok) + + # Should emit ALLOC (not ALLOC_SHARED) because fref+2 is missing/falsy + assert len(output_store.items) > 0, "Should have emitted a token" + emitted = output_store.items[0] + assert emitted.op == FrameOp.ALLOC, "Should emit ALLOC when fref+2 is missing" + + +class TestFreeFrameOpcode: + """AC5.1: FREE_FRAME opcode uses smart FREE behaviour on shared frames.""" + + def test_free_frame_opcode_shared_frame_partial_free(self): + """FREE_FRAME smart free: partial frame free when other lanes remain.""" + env = simpy.Environment() + events = [] + config = PEConfig(frame_count=4, lane_count=4, on_event=events.append) + pe = ProcessingElement(env=env, pe_id=0, config=config) + + # Pre-allocate frame with two act_ids on different lanes + # This simulates ALLOC for act_id=0 and ALLOC_SHARED for act_id=1 + frame_id = 0 + pe.frames[frame_id] = [None] * pe.frame_slots + pe.tag_store[0] = (frame_id, 0) # act_id=0 on lane 0 + pe.tag_store[1] = (frame_id, 1) # act_id=1 on lane 1 + pe.lane_free[frame_id] = {2, 3} # Lanes 2 and 3 are free + # Remove frame_id from free_frames (it's in use) + if frame_id in pe.free_frames: + pe.free_frames.remove(frame_id) + + # Install FREE_FRAME instruction + inst = Instruction( + opcode=RoutingOp.FREE_FRAME, + output=OutputStyle.SINK, + has_const=False, + dest_count=0, + wide=False, + fref=0, + ) + pe.iram[0] = inst + + # Send MonadToken for act_id=0 to trigger FREE_FRAME + tok = DyadToken( + target=0, offset=0, act_id=0, data=0, port=Port.L + ) + inject_and_run(env, pe, tok) + + # Verify act_id=0 is removed from tag_store + assert 0 not in pe.tag_store, "act_id=0 should be removed from tag_store" + + # Verify act_id=1 is still in tag_store + assert 1 in pe.tag_store, "act_id=1 should still be in tag_store" + + # Verify frame is NOT returned to free_frames (still in use by act_id=1) + assert frame_id not in pe.free_frames, "Frame should not be in free_frames" + + # Verify FrameFreed event has frame_freed=False + frame_freed = [e for e in events if isinstance(e, FrameFreed)] + assert any(e.frame_freed == False for e in frame_freed), \ + "Should have FrameFreed event with frame_freed=False" + last_frame_freed = [e for e in frame_freed if e.act_id == 0][-1] + assert last_frame_freed.frame_freed == False, "Frame should not be marked as freed" + + def test_free_frame_opcode_shared_frame_full_free(self): + """FREE_FRAME smart free: full frame free when last lane is freed.""" + env = simpy.Environment() + events = [] + config = PEConfig(frame_count=4, lane_count=4, on_event=events.append) + pe = ProcessingElement(env=env, pe_id=0, config=config) + + # Pre-allocate frame with two act_ids + frame_id = 0 + pe.frames[frame_id] = [None] * pe.frame_slots + pe.tag_store[0] = (frame_id, 0) # act_id=0 on lane 0 + pe.tag_store[1] = (frame_id, 1) # act_id=1 on lane 1 + pe.lane_free[frame_id] = {2, 3} + if frame_id in pe.free_frames: + pe.free_frames.remove(frame_id) + + # Install FREE_FRAME instruction + inst = Instruction( + opcode=RoutingOp.FREE_FRAME, + output=OutputStyle.SINK, + has_const=False, + dest_count=0, + wide=False, + fref=0, + ) + pe.iram[0] = inst + + # First: free act_id=0 + tok0 = DyadToken( + target=0, offset=0, act_id=0, data=0, port=Port.L + ) + inject_and_run(env, pe, tok0) + + # Verify frame still not free + assert frame_id not in pe.free_frames, "Frame should not be free after first FREE_FRAME" + assert 1 in pe.tag_store, "act_id=1 should still be present" + + # Second: free act_id=1 (last lane on frame) + tok1 = DyadToken( + target=0, offset=0, act_id=1, data=0, port=Port.L + ) + inject_and_run(env, pe, tok1) + + # Verify frame is now freed + assert frame_id in pe.free_frames, "Frame should be in free_frames after last FREE_FRAME" + assert 1 not in pe.tag_store, "act_id=1 should be removed from tag_store" + + # Verify tag_store is empty + assert len(pe.tag_store) == 0, "tag_store should be empty" + + # Verify lane_free is cleaned up + assert frame_id not in pe.lane_free, "lane_free entry should be deleted" + + # Verify FrameFreed event has frame_freed=True + frame_freed = [e for e in events if isinstance(e, FrameFreed)] + last_frame_freed = [e for e in frame_freed if e.act_id == 1][-1] + assert last_frame_freed.frame_freed == True, \ + "Last FREE_FRAME should emit FrameFreed with frame_freed=True" + + +class TestLoopPipelining: + """AC8.6: Full loop pipelining integration test with multiple lanes.""" + + def test_full_loop_pipelining_scenario(self): + """ + Complete loop pipelining lifecycle: two iterations of a dyadic instruction + running concurrently on different lanes, both producing correct results. + + Simulates: + 1. ALLOC(act_id=0) → frame, lane 0 + 2. Setup: write destination to frame + 3. Iteration 1: inject L and R DyadTokens for act_id=0 + 4. ALLOC_SHARED(act_id=1, parent=0) → same frame, lane 1 + 5. Iteration 2: inject L and R DyadTokens for act_id=1 + 6. Both iterations match independently, both produce correct results + 7. FREE(act_id=0) → lane 0 freed, frame stays + 8. FREE(act_id=1) → last lane, frame returned to free list + """ + env = simpy.Environment() + events = [] + config = PEConfig( + frame_count=4, lane_count=4, matchable_offsets=4, + on_event=events.append + ) + pe = ProcessingElement(env=env, pe_id=0, config=config) + + # 1. ALLOC(act_id=0) → frame, lane 0 + fct_alloc_0 = FrameControlToken( + target=0, act_id=0, op=FrameOp.ALLOC, payload=0 + ) + inject_and_run(env, pe, fct_alloc_0) + + # Verify act_id=0 is allocated + assert 0 in pe.tag_store, "act_id=0 should be in tag_store" + frame_id, lane_0 = pe.tag_store[0] + assert lane_0 == 0, "First ALLOC should assign lane 0" + + # Verify FrameAllocated event for iteration 1 + frame_allocated = [e for e in events if isinstance(e, FrameAllocated)] + assert len(frame_allocated) >= 1, "Should have FrameAllocated event" + assert frame_allocated[0].frame_id == frame_id, "Event should report correct frame_id" + assert frame_allocated[0].lane == 0, "Event should report lane 0" + + # 2. Setup: write destination to frame at slot 8 + dest = FrameDest( + target_pe=1, offset=0, act_id=0, port=Port.L, + token_kind=TokenKind.MONADIC + ) + pe.frames[frame_id][8] = dest + + # Set up route to capture output + pe.route_table[1] = simpy.Store(env) + + # 3. Install ADD instruction at IRAM offset 0 + inst = Instruction( + opcode=ArithOp.ADD, + output=OutputStyle.INHERIT, + has_const=False, + dest_count=1, + wide=False, + fref=8, + ) + pe.iram[0] = inst + + # 4. ALLOC_SHARED(act_id=1, parent=0) → same frame, lane 1 + fct_alloc_shared = FrameControlToken( + target=0, act_id=1, op=FrameOp.ALLOC_SHARED, payload=0 + ) + inject_and_run(env, pe, fct_alloc_shared) + + # Verify act_id=1 is allocated on same frame, different lane + assert 1 in pe.tag_store, "act_id=1 should be in tag_store" + frame_id_1, lane_1 = pe.tag_store[1] + assert frame_id_1 == frame_id, "Both should share same frame" + assert lane_1 == 1, "Second allocation should assign lane 1" + assert lane_1 != lane_0, "Lanes should be different" + + # Verify FrameAllocated event for iteration 2 + frame_allocated = [e for e in events if isinstance(e, FrameAllocated)] + assert len(frame_allocated) >= 2, "Should have 2 FrameAllocated events" + assert frame_allocated[1].frame_id == frame_id, "Event should report correct frame_id" + assert frame_allocated[1].lane == 1, "Event should report lane 1" + + # 5. Inject iteration 1 operands (act_id=0, lane 0) + tok_l_0 = DyadToken( + target=0, offset=0, act_id=0, data=100, port=Port.L + ) + inject_and_run(env, pe, tok_l_0) + + tok_r_0 = DyadToken( + target=0, offset=0, act_id=0, data=200, port=Port.R + ) + inject_and_run(env, pe, tok_r_0) + + # Verify Matched event for iteration 1 + matched = [e for e in events if isinstance(e, Matched)] + assert len(matched) >= 1, "Should have Matched event for iteration 1" + match_0 = [m for m in matched if m.act_id == 0][-1] + assert match_0.left == 100, "Iteration 1 left operand should be 100" + assert match_0.right == 200, "Iteration 1 right operand should be 200" + assert match_0.offset == 0, "Iteration 1 offset should be 0" + + # Verify output token with correct data (100+200=300) + emitted = [e for e in events if isinstance(e, Emitted)] + assert len(emitted) >= 1, "Should have Emitted event for iteration 1" + out_tok_0 = emitted[-1].token + assert out_tok_0.data == 300, "Iteration 1 output should be 300 (100+200)" + assert out_tok_0.target == 1, "Output should route to target_pe=1" + + # 6. Inject iteration 2 operands (act_id=1, lane 1) + tok_l_1 = DyadToken( + target=0, offset=0, act_id=1, data=1000, port=Port.L + ) + inject_and_run(env, pe, tok_l_1) + + tok_r_1 = DyadToken( + target=0, offset=0, act_id=1, data=2000, port=Port.R + ) + inject_and_run(env, pe, tok_r_1) + + # Verify Matched event for iteration 2 + matched = [e for e in events if isinstance(e, Matched)] + assert len(matched) >= 2, "Should have Matched events for both iterations" + match_1 = [m for m in matched if m.act_id == 1][-1] + assert match_1.left == 1000, "Iteration 2 left operand should be 1000" + assert match_1.right == 2000, "Iteration 2 right operand should be 2000" + assert match_1.offset == 0, "Iteration 2 offset should be 0" + + # Verify output token with correct data (1000+2000=3000) + emitted = [e for e in events if isinstance(e, Emitted)] + assert len(emitted) >= 2, "Should have Emitted events for both iterations" + out_tok_1 = emitted[-1].token + assert out_tok_1.data == 3000, "Iteration 2 output should be 3000 (1000+2000)" + assert out_tok_1.target == 1, "Output should route to target_pe=1" + + # Interleaved verification: confirm independent lanes + matches_by_id = {} + for m in matched: + if m.act_id not in matches_by_id: + matches_by_id[m.act_id] = [] + matches_by_id[m.act_id].append(m) + + assert 0 in matches_by_id, "Should have match for iteration 1 (act_id=0)" + assert 1 in matches_by_id, "Should have match for iteration 2 (act_id=1)" + assert matches_by_id[0][-1].left == 100, "Iteration 1 left should be 100" + assert matches_by_id[1][-1].left == 1000, "Iteration 2 left should be 1000" + + # 7. FREE(act_id=0) → lane 0 freed, frame stays + fct_free_0 = FrameControlToken( + target=0, act_id=0, op=FrameOp.FREE, payload=0 + ) + inject_and_run(env, pe, fct_free_0) + + # Verify act_id=0 removed, act_id=1 still present + assert 0 not in pe.tag_store, "act_id=0 should be removed from tag_store" + assert 1 in pe.tag_store, "act_id=1 should still be in tag_store" + + # Verify frame not returned (still used by act_id=1) + assert frame_id not in pe.free_frames, "Frame should not be in free_frames" + + # Verify FrameFreed event with frame_freed=False + frame_freed = [e for e in events if isinstance(e, FrameFreed)] + freed_0 = [f for f in frame_freed if f.act_id == 0][-1] + assert freed_0.frame_freed == False, "frame_freed should be False (not last lane)" + assert freed_0.lane == lane_0, "Event should report lane 0" + + # 8. FREE(act_id=1) → last lane, frame returned to free list + fct_free_1 = FrameControlToken( + target=0, act_id=1, op=FrameOp.FREE, payload=0 + ) + inject_and_run(env, pe, fct_free_1) + + # Verify act_id=1 removed from tag_store + assert 1 not in pe.tag_store, "act_id=1 should be removed from tag_store" + + # Verify tag_store is now empty + assert len(pe.tag_store) == 0, "tag_store should be empty" + + # Verify frame returned to free_frames + assert frame_id in pe.free_frames, "Frame should be in free_frames" + + # Verify lane_free entry cleaned up + assert frame_id not in pe.lane_free, "lane_free entry should be deleted" + + # Verify FrameFreed event with frame_freed=True + frame_freed = [e for e in events if isinstance(e, FrameFreed)] + freed_1 = [f for f in frame_freed if f.act_id == 1][-1] + assert freed_1.frame_freed == True, "frame_freed should be True (last lane)" + assert freed_1.lane == lane_1, "Event should report lane 1" + + # Summary: verify AC8.6 acceptance criteria + # Both iterations produce mathematically correct results + assert matches_by_id[0][-1].left + matches_by_id[0][-1].right == 300, \ + "Iteration 1 arithmetic correct" + assert matches_by_id[1][-1].left + matches_by_id[1][-1].right == 3000, \ + "Iteration 2 arithmetic correct" + + # Both iterations ran on SAME frame (verified at allocation, re-confirmed) + assert frame_id_1 == frame_id, "Both iterations ran on same frame" + + # Both iterations used DIFFERENT lanes + assert lane_0 != lane_1, "Iterations used different lanes" + assert lane_0 == 0 and lane_1 == 1, "Lanes are 0 and 1 respectively" + + # Freeing one iteration preserved the other + frame_freed_events = [e for e in events if isinstance(e, FrameFreed)] + assert len(frame_freed_events) >= 2, "Should have 2 FrameFreed events" + + # Freeing the last iteration returned the frame + assert frame_id in pe.free_frames, "Frame returned to pool after last FREE" diff --git a/tests/test_repl.py b/tests/test_repl.py --- a/tests/test_repl.py +++ b/tests/test_repl.py @@ -471,6 +471,8 @@ out = output.getvalue() # Should show PE state or not found message assert len(out) > 0 + # Verify formatting includes lane information or empty tag store marker (case-insensitive) + assert "lane" in out.lower() or "tag store: (empty)" in out.lower() def test_pe_invalid_id(self, repl, temp_dfasm_file): """pe with non-integer ID should error.""" diff --git a/tests/test_snapshot.py b/tests/test_snapshot.py --- a/tests/test_snapshot.py +++ b/tests/test_snapshot.py @@ -123,7 +123,7 @@ assert isinstance(frame, tuple) # Each frame has slots - # tag_store should be dict mapping act_id to frame_id + # tag_store should be dict mapping act_id to (frame_id, lane) tuple assert isinstance(pe_snap.tag_store, dict) # presence should be a tuple of tuples (frame_count x matchable_offsets) @@ -183,7 +183,7 @@ snapshot = capture(system) pe_snap = snapshot.pes[0] - # tag_store should be a dict mapping act_id to frame_id + # tag_store should be a dict mapping act_id to (frame_id, lane) tuple assert isinstance(pe_snap.tag_store, dict) assert hasattr(pe_snap, 'frames') assert hasattr(pe_snap, 'free_frames') diff --git a/docs/design-plans/2026-03-07-frame-lanes.md b/docs/design-plans/2026-03-07-frame-lanes.md new file mode 100644 --- /dev/null +++ b/docs/design-plans/2026-03-07-frame-lanes.md @@ -0,0 +1,270 @@ +# Frame Matching Lanes Design + +## Summary + +Extend the PE's frame-based matching to support multiple simultaneous pending +operands per instruction within a single activation. Multiple `activation_id` +values share one physical frame (constants/destinations) while maintaining +independent matching state per lane. Required for loop pipelining and recursion. +Changes span token types, PE internals, codegen, monitor, and tests. Assembler +macro expansion for automatic loop pipelining is out of scope. + +## Definition of Done + +The PE emulator supports matching lanes — multiple activation IDs sharing one +physical frame with independent match/presence/port storage per lane. FrameOp +gains ALLOC_SHARED and FREE_LANE. FREE_FRAME auto-detects last lane. +ALLOC_REMOTE is data-driven (frame constant flag for shared vs new). Existing +tests pass with the updated tag_store tuple API. New tests demonstrate +shared-frame matching, lane exhaustion rejection, smart free behaviour, and +data-driven ALLOC_REMOTE. Assembler macro expansion for automatic loop +pipelining is explicitly out of scope. + +## Acceptance Criteria + +### AC1: Tag Store Tuple API + +- **frame-lanes.AC1.1:** `tag_store` maps `act_id → (frame_id, lane)` where + `lane` is an `int` in range `[0, lane_count)`. +- **frame-lanes.AC1.2:** `PEConfig.initial_tag_store` type is + `dict[int, tuple[int, int]]`. PE constructor initialises tag_store from it. +- **frame-lanes.AC1.3:** `PEConfig.lane_count` field exists with default 4. + Controls third dimension of match arrays. +- **frame-lanes.AC1.4:** All existing tests pass with updated tuple API. + +### AC2: Separate Match Data Storage + +- **frame-lanes.AC2.1:** Match operand data lives in + `match_data[frame_id][offset][lane]`, separate from `frames[frame_id][slot]`. +- **frame-lanes.AC2.2:** `presence[frame_id][offset][lane]` is a 3D bool + array. `port_store[frame_id][offset][lane]` likewise. +- **frame-lanes.AC2.3:** `_match_frame()` uses `(frame_id, match_slot, lane)` + to read/write match data, presence, and port. +- **frame-lanes.AC2.4:** `frames[frame_id][slot]` remains shared across all + lanes. Constants and destinations are NOT per-lane. + +### AC3: FrameOp Extensions + +- **frame-lanes.AC3.1:** `FrameOp.ALLOC_SHARED` added. When received, + PE looks up `parent_act_id` (from payload), finds parent's `frame_id`, + assigns next free lane from that frame's lane pool, records + `tag_store[act_id] = (frame_id, lane)`. Clears only that lane's + presence/port bits. +- **frame-lanes.AC3.2:** `FrameOp.FREE_LANE` added. Removes tag_store entry, + clears that lane's presence/port/match_data across all matchable offsets. + Does NOT return frame to free list. +- **frame-lanes.AC3.3:** `FrameOp.FREE` (existing) becomes smart: removes + tag_store entry, clears lane data. If no other tag_store entries reference + the same frame_id, returns frame to free list and clears frame slots. If + other entries exist, behaves like FREE_LANE. +- **frame-lanes.AC3.4:** `FrameOp.ALLOC` (existing) unchanged — allocates + fresh frame, assigns lane 0. +- **frame-lanes.AC3.5:** `FrameAllocated` event gains `lane: int` field. + `FrameFreed` event gains `lane: int` and `frame_freed: bool` fields. +- **frame-lanes.AC3.6:** When all lanes for a frame are occupied and + ALLOC_SHARED is received, PE emits `TokenRejected` with reason + "no free lanes" and drops the token. + +### AC4: ALLOC_REMOTE Data-Driven + +- **frame-lanes.AC4.1:** ALLOC_REMOTE reads `fref+2` from frame. If value + is non-zero, emits `FrameControlToken` with `op=ALLOC_SHARED` and + `payload=parent_act_id`. If zero, emits `op=ALLOC` as before. +- **frame-lanes.AC4.2:** No new opcodes. Behaviour is entirely data-driven + from frame constants. + +### AC5: FREE_FRAME Instruction + +- **frame-lanes.AC5.1:** `FREE_FRAME` opcode uses the smart FREE behaviour + from AC3.3. Frees the executing token's activation lane; returns frame to + free list only if last lane. + +### AC6: Monitor and Snapshot Updates + +- **frame-lanes.AC6.1:** `PESnapshot.tag_store` type becomes + `dict[int, tuple[int, int]]`. +- **frame-lanes.AC6.2:** `PESnapshot` gains `match_data`, `lane_count` fields + reflecting the separated match storage. +- **frame-lanes.AC6.3:** Monitor REPL `pe` command displays lane info in + tag_store output. +- **frame-lanes.AC6.4:** Monitor graph JSON serialises lane info correctly. + +### AC7: Codegen Updates + +- **frame-lanes.AC7.1:** `codegen.py` generates `initial_tag_store` with + `(frame_id, lane)` tuples. Existing single-activation code uses lane 0. +- **frame-lanes.AC7.2:** No codegen changes needed for ALLOC_SHARED (manual + construction only for now). + +### AC8: Test Coverage + +- **frame-lanes.AC8.1:** Test: two act_ids sharing a frame via ALLOC_SHARED + have independent matching — L operand for act_id 0 does not interfere with + L operand for act_id 1 at the same offset. +- **frame-lanes.AC8.2:** Test: ALLOC_SHARED with all lanes occupied emits + TokenRejected. +- **frame-lanes.AC8.3:** Test: FREE on a shared frame frees only the lane; + other lanes' data is preserved. FREE on last lane frees the frame. +- **frame-lanes.AC8.4:** Test: ALLOC_REMOTE emits ALLOC_SHARED when + `fref+2` is non-zero. +- **frame-lanes.AC8.5:** Test: ALLOC_REMOTE emits ALLOC when `fref+2` is + zero (backwards compatible). +- **frame-lanes.AC8.6:** Test: full loop pipelining scenario — two + iterations of a dyadic instruction running concurrently on different + lanes, both producing correct results. + +## Architecture + +### Current Model + +``` +tag_store[act_id] → frame_id (1:1 mapping) +frames[frame_id][slot] (constants + dests + match data mixed) +presence[frame_id][offset] (1 pending operand per instruction) +port_store[frame_id][offset] (port of pending operand) +``` + +### New Model + +``` +tag_store[act_id] → (frame_id, lane) (many:1, multiple act_ids per frame) +frames[frame_id][slot] (constants + dests ONLY, shared) +match_data[frame_id][offset][lane] (per-lane operand storage) +presence[frame_id][offset][lane] (per-lane presence bits) +port_store[frame_id][offset][lane] (per-lane port metadata) +lane_free[frame_id] → set[int] (available lanes per frame) +``` + +### Frame Control Token Payload Convention + +``` +ALLOC: payload ignored (or return routing) +ALLOC_SHARED: payload = parent_act_id (low 3 bits) +FREE: payload ignored +FREE_LANE: payload ignored +``` + +### ALLOC_REMOTE Frame Slot Convention + +``` +fref+0: target_pe (int) +fref+1: target_act_id (int) +fref+2: parent_act_id (0 = ALLOC_NEW, non-zero = ALLOC_SHARED) +``` + +### Smart FREE Behaviour + +When FREE or FREE_FRAME executes for an act_id: +1. Look up `(frame_id, lane)` from tag_store +2. Remove tag_store entry for act_id +3. Clear match_data/presence/port_store for that lane across all offsets +4. Return lane to `lane_free[frame_id]` +5. Scan tag_store: does any other entry reference frame_id? + - No → return frame to free_frames, clear all frame slots + - Yes → frame stays allocated, constants/dests preserved + +### Lifecycle Example: Loop Pipelining + +``` +1. ALLOC(act_id=0) → frame 2, lane 0 +2. Setup: write constants/dests to frame 2 +3. Iteration 1 seeds use act_id=0 + +4. ALLOC_SHARED(act_id=1, parent=0) → frame 2, lane 1 +5. Iteration 2 seeds use act_id=1 + (act_id=0 and act_id=1 match independently at same offsets) + +6. Iteration 1 completes: FREE(act_id=0) → lane 0 freed, frame stays +7. ALLOC_SHARED(act_id=2, parent=1) → frame 2, lane 0 (recycled) +8. Iteration 3 seeds use act_id=2 + +9. All done: FREE(act_id=last) → last lane, frame returned +``` + +## Existing Patterns + +- **Frame control handling:** `_handle_frame_control()` in `emu/pe.py` already + dispatches on `FrameOp` enum values. Adding ALLOC_SHARED/FREE_LANE follows + the same pattern. +- **Token rejection:** `TokenRejected` event already exists and is emitted for + invalid act_ids. Lane exhaustion follows the same pattern. +- **Smart free (precedent):** The existing FREE already validates act_id + presence in tag_store before freeing. The smart-free extension adds a + scan step after removal. +- **Data-driven opcode behaviour:** ALLOC_REMOTE already reads frame slots + to determine target. Reading an additional slot for shared-vs-new is the + same pattern. +- **Codegen initial_tag_store:** Already generates `act_id → frame_id` + mappings. Extending to tuples is mechanical. + +## Implementation Phases + +### Phase 1: Foundation Types and Tag Store API (2 tasks) + +Update FrameOp enum, PEConfig, and tag_store type across the codebase. +All existing tests adapted to tuple API. No new behaviour yet. + +### Phase 2: Separated Match Storage (2 tasks) + +Extract match_data from frames into its own 3D array. Update _match_frame +to use lane dimension (always lane 0 for now). Update presence/port_store +to 3D. Verify matching still works identically. + +### Phase 3: ALLOC_SHARED, FREE_LANE, Smart FREE (3 tasks) + +Implement new FrameOp handlers. Add lane_free tracking. Implement smart +FREE behaviour. Add events with lane fields. Write tests for all new ops. + +### Phase 4: ALLOC_REMOTE Data-Driven and FREE_FRAME Update (2 tasks) + +Update ALLOC_REMOTE to read fref+2 for shared-vs-new. Update FREE_FRAME +opcode to use smart free. Write tests. + +### Phase 5: Monitor, Snapshot, and Codegen Updates (2 tasks) + +Update PESnapshot, capture(), REPL formatting, graph JSON. Update codegen +initial_tag_store to emit tuples. Verify monitor displays lane info. + +### Phase 6: Integration Tests (1 task) + +Full loop pipelining scenario test. Two concurrent iterations on shared +frame, both producing correct results. E2E verification. + +## Additional Considerations + +### ABA Safety + +With 3-bit act_id (8 values) and at most 4 lanes per frame, there are 4 IDs +of ABA distance between allocation and re-use of the same act_id value. +FREE removes the act_id from tag_store entirely, so stale tokens with freed +act_ids hit rejection. Re-allocation uses a different act_id value. + +### Hardware Mapping + +This design maps cleanly to Approach C (670 lookup) with L=2 at +5 chips, +or to B+670 semi-CAM with zero additional match hardware (lane bit fits in +existing comparator width). See `design-notes/frame-lanes-for-concurrent- +matching.md` for full hardware analysis. + +### Future Work + +- **Assembler loop macro:** `#loop_counted` and `#loop_while` could auto- + generate ALLOC_SHARED/FREE_LANE control flow with act_id rotation. +- **Lane depth analysis:** static analysis in the allocator to warn when + loop concurrency exceeds lane_count. +- **SC arc interaction:** frame_id latch for strongly-connected arc execution + must also latch the lane. Trivial addition when SC arcs are implemented. + +## Glossary + +- **Lane:** An independent matching slot within a shared frame. Multiple + act_ids can map to the same frame_id with different lane indices, + providing concurrent matching without duplicating constants/destinations. +- **Lane pool:** The set of available lanes per frame, tracked by + `lane_free[frame_id]`. Initially all lanes are free; ALLOC assigns lane 0, + ALLOC_SHARED assigns the next free lane. +- **Smart free:** FREE behaviour that auto-detects whether the freed lane + is the last one using that frame. If last, returns frame to free list. + If not, preserves frame for remaining lanes. +- **Parent act_id:** The activation ID whose frame should be shared during + ALLOC_SHARED. Used to look up the target frame_id. diff --git a/docs/test-plans/2026-03-07-frame-lanes.md b/docs/test-plans/2026-03-07-frame-lanes.md new file mode 100644 --- /dev/null +++ b/docs/test-plans/2026-03-07-frame-lanes.md @@ -0,0 +1,76 @@ +# Frame Matching Lanes — Human Test Plan + +## Overview + +This test plan covers manual verification steps for the frame matching lanes implementation (28 automated acceptance criteria + 1 human verification criterion). + +**Automated coverage:** 28/28 acceptance criteria have automated tests across `tests/test_pe_lanes.py`, `tests/test_pe_frames.py`, `tests/test_pe_events.py`, `tests/test_pe.py`, `tests/test_snapshot.py`, `tests/test_monitor_graph_json.py`, `tests/test_codegen_frames.py`, and `tests/test_repl.py`. + +**Test count:** 1300 tests collected (20 new in `test_pe_lanes.py`, remainder updated for tuple API). + +--- + +## Manual Verification Required + +### frame-lanes.AC6.3: Monitor REPL Lane Display + +**Criterion:** Monitor REPL `pe` command displays lane info in tag_store output. + +**Why manual:** REPL tests assert non-empty output only (`len(out) > 0`), not specific formatting. Display formatting is intentionally loosely tested to allow cosmetic changes without test churn. + +**Steps:** + +1. Start the monitor with a program that uses frame allocation: + ```bash + python -m monitor examples/simple_add.dfasm + ``` + +2. Load and step the simulation: + ``` + (monitor) load examples/simple_add.dfasm + (monitor) step + ``` + +3. Inspect PE state: + ``` + (monitor) pe 0 + ``` + +4. **Verify:** Tag store entries display in the format: + ``` + Tag store: {0: frame 0 lane 0} + ``` + Not the old format `{0: 0}`. + +5. **Multi-lane verification** (requires manual token injection or a program that uses ALLOC_SHARED): + - After ALLOC_SHARED creates a second activation on the same frame, verify output shows distinct lane numbers: + ``` + Tag store: {0: frame 2 lane 0, 1: frame 2 lane 1} + ``` + +**Expected result:** Lane info is clearly visible in tag_store display for all PE state inspections. + +--- + +## Automated Test Summary by Acceptance Criterion + +| AC Group | Criteria Count | Primary Test File | Key Tests | +|----------|---------------|-------------------|-----------| +| AC1: Tag Store Tuple API | 4 | `test_pe_frames.py`, `test_pe_events.py`, `test_pe.py` | Existing tests adapted to `dict[int, tuple[int, int]]` | +| AC2: Match Data Separation | 4 | `test_pe.py`, `test_pe_frames.py` | 3D presence/port/match_data indexing | +| AC3: FrameOp Extensions | 6 | `test_pe_lanes.py` | ALLOC_SHARED, FREE_LANE, smart FREE, lane exhaustion | +| AC4: ALLOC_REMOTE Data-Driven | 2 | `test_pe_lanes.py` | `fref+2` read for ALLOC_SHARED vs ALLOC | +| AC5: FREE_FRAME Smart Free | 1 | `test_pe_lanes.py` | FREE_FRAME opcode delegates to smart free | +| AC6: Monitor/Snapshot | 4 | `test_snapshot.py`, `test_monitor_graph_json.py`, `test_repl.py` | 3D snapshot, JSON lane serialisation | +| AC7: Codegen | 2 | `test_codegen_frames.py` | Tuple initial_tag_store generation | +| AC8: Test Coverage | 6 | `test_pe_lanes.py` | Independent matching, lane exhaustion, loop pipelining | + +--- + +## Regression Checklist + +- [ ] All 1300 tests pass: `python -m pytest tests/ -v` +- [ ] No FrameOp references to ALLOC_SHARED/FREE_LANE in `asm/`: `grep -r "ALLOC_SHARED\|FREE_LANE" asm/` returns empty +- [ ] Existing frame allocation tests still pass with tuple API +- [ ] Monitor web UI loads without errors (if available) +- [ ] REPL `pe` command shows lane info (AC6.3 manual check above) diff --git a/docs/implementation-plans/2026-03-07-frame-lanes/phase_01.md b/docs/implementation-plans/2026-03-07-frame-lanes/phase_01.md new file mode 100644 --- /dev/null +++ b/docs/implementation-plans/2026-03-07-frame-lanes/phase_01.md @@ -0,0 +1,271 @@ +# Frame Matching Lanes Implementation Plan + +**Goal:** Extend the PE's frame-based matching to support multiple simultaneous pending operands per instruction within a single activation via matching lanes. + +**Architecture:** Multiple `activation_id` values share one physical frame (constants/destinations) while maintaining independent matching state per lane. Tag store maps `act_id → (frame_id, lane)`. Match data, presence, and port storage gain a lane dimension. + +**Tech Stack:** Python 3.12, SimPy 4.1, pytest + hypothesis + +**Scope:** 6 phases from original design (phases 1-6) + +**Codebase verified:** 2026-03-07 + +--- + +## Acceptance Criteria Coverage + +This phase implements and tests: + +### frame-lanes.AC1: Tag Store Tuple API +- **frame-lanes.AC1.1 Success:** `tag_store` maps `act_id → (frame_id, lane)` where `lane` is an `int` in range `[0, lane_count)`. +- **frame-lanes.AC1.2 Success:** `PEConfig.initial_tag_store` type is `dict[int, tuple[int, int]]`. PE constructor initialises tag_store from it. +- **frame-lanes.AC1.3 Success:** `PEConfig.lane_count` field exists with default 4. Controls third dimension of match arrays. +- **frame-lanes.AC1.4 Success:** All existing tests pass with updated tuple API. + +--- + + + + +### Task 1: Update FrameOp enum with ALLOC_SHARED and FREE_LANE + +**Verifies:** None (enum additions only, no behaviour change) + +**Files:** +- Modify: `cm_inst.py:88-90` + +**Implementation:** + +Add two new enum members to `FrameOp`. Existing values stay unchanged: + +```python +class FrameOp(IntEnum): + ALLOC = 0 + FREE = 1 + ALLOC_SHARED = 2 + FREE_LANE = 3 +``` + +**Testing:** + +No tests needed — IntEnum membership is compiler-verifiable. Existing tests that use `FrameOp.ALLOC` and `FrameOp.FREE` remain unaffected. + +**Verification:** +Run: `python -m pytest tests/ -v -x` +Expected: All 1277+ existing tests pass unchanged. + +**Commit:** `jj commit -m "feat: add ALLOC_SHARED and FREE_LANE to FrameOp enum"` + + + +### Task 2: Add lane_count to PEConfig + +**Verifies:** frame-lanes.AC1.3 + +**Files:** +- Modify: `emu/types.py:17-27` + +**Implementation:** + +Add `lane_count` field to `PEConfig` with default 4. Place it after `matchable_offsets` (line 22) to group dimensional config together: + +```python +@dataclass(frozen=True) +class PEConfig: + pe_id: int = 0 + iram: dict[int, Instruction] | None = None + frame_count: int = 8 + frame_slots: int = 64 + matchable_offsets: int = 8 + lane_count: int = 4 + initial_frames: Optional[dict[int, list[FrameSlotValue]]] = None + initial_tag_store: Optional[dict[int, int]] = None + allowed_pe_routes: Optional[set[int]] = None + allowed_sm_routes: Optional[set[int]] = None + on_event: EventCallback | None = None +``` + +Note: `initial_tag_store` type stays `dict[int, int]` for now — the next task (Task 3) changes it to tuples. + +**Testing:** + +No dedicated tests — `lane_count` has a default value so all existing PEConfig constructions remain valid. The field's effect is tested when match arrays gain the lane dimension (Phase 2). + +**Verification:** +Run: `python -m pytest tests/ -v -x` +Expected: All existing tests pass unchanged. + +**Commit:** `jj commit -m "feat: add lane_count field to PEConfig with default 4"` + + + + + + + +### Task 3: Update PEConfig.initial_tag_store to tuple type + +**Verifies:** frame-lanes.AC1.2 + +**Files:** +- Modify: `emu/types.py:24` — change type annotation + +**Implementation:** + +Change the `initial_tag_store` type from `dict[int, int]` to `dict[int, tuple[int, int]]`: + +```python +initial_tag_store: Optional[dict[int, tuple[int, int]]] = None +``` + +Each entry is now `act_id → (frame_id, lane)`. + +**Testing:** + +No dedicated tests — this is a type change. Downstream call sites are updated in Tasks 4 and 5. + +**Verification:** + +This change alone will break tests that construct `PEConfig` with `initial_tag_store={0: 0}` etc. Do NOT run tests yet — proceed to Task 4 immediately. + +**Commit:** Do not commit yet — combine with Task 4. + + + +### Task 4: Update PE constructor and internals for tuple tag_store + +**Verifies:** frame-lanes.AC1.1 + +**Files:** +- Modify: `emu/pe.py:72` — tag_store initialization +- Modify: `emu/pe.py:88-90` — free_frames removal from tag_store values +- Modify: `emu/pe.py:169-176` — CMToken act_id lookup +- Modify: `emu/pe.py:260-261` — FREE_FRAME opcode handler +- Modify: `emu/pe.py:289` — ALLOC frame control handler +- Modify: `emu/pe.py:304-305` — FREE frame control handler +- Modify: `emu/pe.py:321-322` — PELocalWriteToken handler + +**Implementation:** + +The internal `tag_store` type changes from `dict[int, int]` to `dict[int, tuple[int, int]]`. Every access point must be updated. + +**Line 72 — Initialization:** +```python +# Tag store: act_id → (frame_id, lane) +self.tag_store: dict[int, tuple[int, int]] = dict(config.initial_tag_store or {}) +``` + +**Lines 88-90 — Free frames removal:** +The values are now tuples `(frame_id, lane)`. Extract `frame_id`: +```python +for frame_id, _lane in self.tag_store.values(): + if frame_id in self.free_frames: + self.free_frames.remove(frame_id) +``` + +**Lines 169-176 — CMToken pipeline (act_id lookup):** +Where the code currently does `frame_id = self.tag_store[token.act_id]`, change to: +```python +frame_id, lane = self.tag_store[token.act_id] +``` +The `lane` value is not used yet in Phase 1 — matching still uses the 2D presence/port arrays. Phase 2 adds the lane dimension to match storage. + +**Lines 260-261 — FREE_FRAME opcode:** +Where the code does `freed_frame = self.tag_store.pop(token.act_id)`, change to: +```python +freed_frame, _lane = self.tag_store.pop(token.act_id) +``` + +**Line 289 — ALLOC handler:** +Where the code stores `self.tag_store[token.act_id] = frame_id`, change to: +```python +self.tag_store[token.act_id] = (frame_id, 0) +``` +New allocations always get lane 0. + +**Lines 304-305 — FREE handler:** +Where the code does `frame_id = self.tag_store.pop(token.act_id)`, change to: +```python +frame_id, _lane = self.tag_store.pop(token.act_id) +``` + +**Lines 321-322 — PELocalWriteToken handler:** +Where the code checks `token.act_id in self.tag_store` and then does `frame_id = self.tag_store[token.act_id]`, change the lookup to: +```python +frame_id, _lane = self.tag_store[token.act_id] +``` + +**Testing:** + +No new tests in this task — AC1.1 is verified by the existing test suite passing with the new tuple type (Task 5 updates those tests). + +**Verification:** + +Do NOT run tests yet — existing tests still pass `dict[int, int]` values to `initial_tag_store`. Proceed to Task 5 immediately. + +**Commit:** Do not commit yet — combine with Task 5. + + + +### Task 5: Update all test files and downstream code for tuple tag_store API + +**Verifies:** frame-lanes.AC1.2, frame-lanes.AC1.4 + +**Files:** +- Modify: `tests/test_pe_frames.py` — ~21 `pe.tag_store[N]` value access sites need tuple unpacking (e.g., `frame_id = pe.tag_store[0]` → `frame_id, _lane = pe.tag_store[0]` or `frame_id = pe.tag_store[0][0]`). Also fix `pe.tag_store[0] in range(pe.frame_count)` at line 99 to `pe.tag_store[0][0] in range(pe.frame_count)`. Note: this file does NOT use `initial_tag_store` — changes are to value access patterns only. +- Modify: `tests/test_pe_events.py` — 9 `initial_tag_store` call sites: all `{0: 0}` → `{0: (0, 0)}` +- Modify: `tests/test_network_routing.py` — 2 tests with `initial_tag_store` construction and `pe.tag_store` value assertions +- Modify: `tests/test_snapshot.py` — tag_store capture assertions and PESnapshot type +- Modify: `tests/test_pe.py` — 18 `initial_tag_store` call sites: 17 `{0: 0}` → `{0: (0, 0)}`, one `{1: 0}` → `{1: (0, 0)}`. Note: `pe.presence` indexing changes are deferred to Phase 2 Task 2 (this task changes ONLY `initial_tag_store` values in this file). +- Modify: `tests/test_monitor_graph_json.py` — PESnapshot constructions with tag_store field +- Modify: `monitor/snapshot.py:25` — PESnapshot.tag_store type annotation +- Modify: `monitor/snapshot.py:81` — capture() tag_store copy +- Modify: `asm/codegen.py:371-422` — initial_tag_store generation +- Modify: `tests/conftest.py` — frame_control_token strategy (if it constructs tag_store) + +**Implementation:** + +This is a mechanical find-and-replace across the codebase. Every place that constructs `initial_tag_store` must change from `{act_id: frame_id}` to `{act_id: (frame_id, lane)}` where lane is 0 for all existing code. + +**Pattern for test files:** + +Every `initial_tag_store={0: 0}` becomes `initial_tag_store={0: (0, 0)}`. +Every `initial_tag_store={1: 0}` becomes `initial_tag_store={1: (0, 0)}`. +Every `initial_tag_store={0: 2, 1: 3}` becomes `initial_tag_store={0: (2, 0), 1: (3, 0)}`. + +**Pattern for assertions on tag_store values:** + +Where tests assert `pe.tag_store[0] == 2`, change to `pe.tag_store[0] == (2, 0)`. +Where tests assert `pe.tag_store[0]` (existence check), no change needed. + +**monitor/snapshot.py line 25:** +```python +tag_store: dict[int, tuple[int, int]] +``` + +**monitor/snapshot.py line 81:** +No code change needed — `dict(pe.tag_store)` already copies tuples correctly. + +**asm/codegen.py lines 371-422:** + +Where `initial_tag_store[act_id] = frame_id` is set, change to: +```python +initial_tag_store[act_id] = (frame_id, 0) +``` + +This applies at approximately lines 383 and 412. + +**Testing:** + +This task verifies AC1.4 — all existing tests must pass with the updated tuple API. No new test functions are needed; the existing suite IS the verification. + +**Verification:** +Run: `python -m pytest tests/ -v -x` +Expected: All existing tests pass. Zero failures. + +**Commit:** `jj commit -m "feat: update tag_store to tuple API (act_id → frame_id, lane)"` + +This single commit covers Tasks 3, 4, and 5 together since they form an atomic change — the type, the internals, and all call sites must change together. + + + diff --git a/docs/implementation-plans/2026-03-07-frame-lanes/phase_02.md b/docs/implementation-plans/2026-03-07-frame-lanes/phase_02.md new file mode 100644 --- /dev/null +++ b/docs/implementation-plans/2026-03-07-frame-lanes/phase_02.md @@ -0,0 +1,268 @@ +# Frame Matching Lanes Implementation Plan + +**Goal:** Extend the PE's frame-based matching to support multiple simultaneous pending operands per instruction within a single activation via matching lanes. + +**Architecture:** Multiple `activation_id` values share one physical frame (constants/destinations) while maintaining independent matching state per lane. Tag store maps `act_id → (frame_id, lane)`. Match data, presence, and port storage gain a lane dimension. + +**Tech Stack:** Python 3.12, SimPy 4.1, pytest + hypothesis + +**Scope:** 6 phases from original design (phases 1-6) + +**Codebase verified:** 2026-03-07 + +--- + +## Acceptance Criteria Coverage + +This phase implements and tests: + +### frame-lanes.AC2: Separate Match Data Storage +- **frame-lanes.AC2.1 Success:** Match operand data lives in `match_data[frame_id][offset][lane]`, separate from `frames[frame_id][slot]`. +- **frame-lanes.AC2.2 Success:** `presence[frame_id][offset][lane]` is a 3D bool array. `port_store[frame_id][offset][lane]` likewise. +- **frame-lanes.AC2.3 Success:** `_match_frame()` uses `(frame_id, match_slot, lane)` to read/write match data, presence, and port. +- **frame-lanes.AC2.4 Success:** `frames[frame_id][slot]` remains shared across all lanes. Constants and destinations are NOT per-lane. + +Also partially satisfies (structural type changes only, full testing in Phase 5): +### frame-lanes.AC6: Monitor and Snapshot Updates (partial) +- **frame-lanes.AC6.1:** `PESnapshot.tag_store` type updated to `dict[int, tuple[int, int]]`. +- **frame-lanes.AC6.2:** `PESnapshot` gains `match_data`, `lane_count` fields. + +--- + + + + +### Task 1: Add match_data 3D array and convert presence/port_store to 3D + +**Verifies:** frame-lanes.AC2.1, frame-lanes.AC2.2 + +**Files:** +- Modify: `emu/pe.py:66-84` — PE constructor storage initialization +- Modify: `emu/pe.py:290-296` — ALLOC handler reset logic + +**Implementation:** + +Add a new `match_data` 3D array and extend `presence` and `port_store` from 2D to 3D by adding the lane dimension. After Phase 1, `config.lane_count` is available. + +**Constructor changes (replace lines 74-84):** + +Replace the current 2D presence and port_store initialization with 3D versions, and add a match_data array: + +```python +# Match data: [frame_id][match_slot][lane] - operand values waiting for partner +self.match_data: list[list[list[Optional[int]]]] = [ + [ + [None for _ in range(config.lane_count)] + for _ in range(config.matchable_offsets) + ] + for _ in range(config.frame_count) +] + +# Presence bits: [frame_id][match_slot][lane] - True if operand waiting for partner +self.presence: list[list[list[bool]]] = [ + [ + [False for _ in range(config.lane_count)] + for _ in range(config.matchable_offsets) + ] + for _ in range(config.frame_count) +] + +# Port store: [frame_id][match_slot][lane] - port of waiting operand +self.port_store: list[list[list[Optional[Port]]]] = [ + [ + [None for _ in range(config.lane_count)] + for _ in range(config.matchable_offsets) + ] + for _ in range(config.frame_count) +] + +self.lane_count = config.lane_count +``` + +**ALLOC handler reset (lines 293-296):** + +Update the presence/port_store reset loop to iterate all lanes, and also clear match_data: + +```python +for i in range(self.matchable_offsets): + for ln in range(self.lane_count): + self.match_data[frame_id][i][ln] = None + self.presence[frame_id][i][ln] = False + self.port_store[frame_id][i][ln] = None +``` + +**Testing:** + +No dedicated tests for storage structure — AC2.1 and AC2.2 are verified by the existing test suite continuing to pass after Task 2 updates `_match_frame()`. The 3D structure is exercised through matching behaviour. + +**Verification:** + +Do NOT run tests yet — `_match_frame()` still uses 2D indexing. Proceed to Task 2 immediately. + +**Commit:** Do not commit yet — combine with Task 2. + + + +### Task 2: Update _match_frame() to use lane dimension + +**Verifies:** frame-lanes.AC2.3, frame-lanes.AC2.4 + +**Files:** +- Modify: `emu/pe.py:343-383` — `_match_frame()` method +- Modify: `emu/pe.py:169-176` — CMToken pipeline where `_match_frame()` is called + +**Implementation:** + +Update `_match_frame()` to accept and use the `lane` parameter. After Phase 1, the CMToken pipeline already unpacks `frame_id, lane = self.tag_store[token.act_id]` — now pass `lane` through. + +**Update the call site (in the CMToken processing pipeline):** + +Where `_match_frame()` is currently called with `(token, inst, frame_id)`, add `lane`: +```python +result = self._match_frame(token, inst, frame_id, lane) +``` + +**Updated `_match_frame()` signature and body:** + +```python +def _match_frame( + self, + token: DyadToken, + inst: Instruction, + frame_id: int, + lane: int, +) -> Optional[tuple[int, int]]: + """Frame-based dyadic matching with lane support. + + Derives match slot from low bits of token.offset: + match_slot = token.offset % matchable_offsets + + Match data, presence, and port are per-lane. + Frame constants/destinations remain shared. + """ + match_slot = token.offset % self.matchable_offsets + + if self.presence[frame_id][match_slot][lane]: + # Partner already waiting — pair them + partner_data = self.match_data[frame_id][match_slot][lane] + partner_port = self.port_store[frame_id][match_slot][lane] + self.presence[frame_id][match_slot][lane] = False + self.match_data[frame_id][match_slot][lane] = None + + # Use port metadata to determine left/right ordering + if partner_port == Port.L: + left, right = partner_data, token.data + else: + left, right = token.data, partner_data + + self._on_event(Matched( + time=self.env.now, component=self._component, + left=left, right=right, act_id=token.act_id, + offset=token.offset, frame_id=frame_id, + )) + return left, right + else: + # Store and wait for partner + self.match_data[frame_id][match_slot][lane] = token.data + self.port_store[frame_id][match_slot][lane] = token.port + self.presence[frame_id][match_slot][lane] = True + return None +``` + +Key changes from current code: +- All `self.frames[frame_id][match_slot]` reads/writes for match data → `self.match_data[frame_id][match_slot][lane]` +- All `self.presence[frame_id][match_slot]` → `self.presence[frame_id][match_slot][lane]` +- All `self.port_store[frame_id][match_slot]` → `self.port_store[frame_id][match_slot][lane]` +- `self.frames` is NOT touched — constants and destinations remain shared (AC2.4) + +**Testing:** + +AC2.3 and AC2.4 are verified by the existing test suite passing. All existing tests use lane 0 (set by Phase 1's tuple tag_store), so matching behaviour is identical. + +Two tests directly check `pe.presence`: +- `tests/test_pe.py:160` — `assert pe.presence[frame_id][0] is True` → change to `pe.presence[frame_id][0][0]` +- `tests/test_pe.py:624` — `assert pe.presence[frame_id][...] is False` → change to `pe.presence[frame_id][...][0]` + +**Verification:** +Run: `python -m pytest tests/ -v -x` +Expected: All existing tests pass. + +**Commit:** `jj commit -m "feat: separate match_data from frames, add lane dimension to presence/port_store"` + +This single commit covers Tasks 1 and 2 since they form an atomic change. + + + +### Task 3: Update snapshot capture for 3D match storage + +**Verifies:** None (snapshot updates for AC6 are in Phase 5, but this keeps snapshot working) + +**Files:** +- Modify: `monitor/snapshot.py:18-30` — PESnapshot dataclass +- Modify: `monitor/snapshot.py:82-89` — capture() presence/port_store conversion +- Modify: `tests/test_snapshot.py` — snapshot assertion updates + +**Implementation:** + +Update PESnapshot to reflect the new 3D storage shapes and add match_data field. + +**PESnapshot dataclass updates:** + +```python +@dataclass(frozen=True) +class PESnapshot: + pe_id: int + iram: dict[int, Instruction] + frames: tuple[tuple[FrameSlotValue, ...], ...] + tag_store: dict[int, tuple[int, int]] + presence: tuple[tuple[tuple[bool, ...], ...], ...] + port_store: tuple[tuple[tuple[Port | None, ...], ...], ...] + match_data: tuple[tuple[tuple[int | None, ...], ...], ...] + free_frames: tuple[int, ...] + lane_count: int + input_queue: tuple[Token, ...] + output_log: tuple[Token, ...] +``` + +**capture() updates:** + +Replace the 2D presence/port_store capture with 3D, and add match_data capture: + +```python +presence = tuple( + tuple( + tuple(lane_val for lane_val in offset_lanes) + for offset_lanes in frame_presence + ) + for frame_presence in pe.presence +) +port_store = tuple( + tuple( + tuple(lane_val for lane_val in offset_lanes) + for offset_lanes in frame_ports + ) + for frame_ports in pe.port_store +) +match_data = tuple( + tuple( + tuple(lane_val for lane_val in offset_lanes) + for offset_lanes in frame_match + ) + for frame_match in pe.match_data +) +``` + +Pass `match_data=match_data` and `lane_count=pe.lane_count` to the PESnapshot constructor. + +**Testing:** + +Update any snapshot tests that assert on `presence` or `port_store` shape to expect 3D tuples. Update tests that construct PESnapshot directly to include `match_data` and `lane_count` fields. + +**Verification:** +Run: `python -m pytest tests/ -v -x` +Expected: All tests pass. + +**Commit:** `jj commit -m "feat: update PESnapshot for 3D match storage and match_data field"` + + + diff --git a/docs/implementation-plans/2026-03-07-frame-lanes/phase_03.md b/docs/implementation-plans/2026-03-07-frame-lanes/phase_03.md new file mode 100644 --- /dev/null +++ b/docs/implementation-plans/2026-03-07-frame-lanes/phase_03.md @@ -0,0 +1,363 @@ +# Frame Matching Lanes Implementation Plan + +**Goal:** Extend the PE's frame-based matching to support multiple simultaneous pending operands per instruction within a single activation via matching lanes. + +**Architecture:** Multiple `activation_id` values share one physical frame (constants/destinations) while maintaining independent matching state per lane. Tag store maps `act_id → (frame_id, lane)`. Match data, presence, and port storage gain a lane dimension. + +**Tech Stack:** Python 3.12, SimPy 4.1, pytest + hypothesis + +**Scope:** 6 phases from original design (phases 1-6) + +**Codebase verified:** 2026-03-07 + +--- + +## Acceptance Criteria Coverage + +This phase implements and tests: + +### frame-lanes.AC3: FrameOp Extensions +- **frame-lanes.AC3.1 Success:** `FrameOp.ALLOC_SHARED` added. When received, PE looks up `parent_act_id` (from payload), finds parent's `frame_id`, assigns next free lane from that frame's lane pool, records `tag_store[act_id] = (frame_id, lane)`. Clears only that lane's presence/port bits. +- **frame-lanes.AC3.2 Success:** `FrameOp.FREE_LANE` added. Removes tag_store entry, clears that lane's presence/port/match_data across all matchable offsets. Does NOT return frame to free list. +- **frame-lanes.AC3.3 Success:** `FrameOp.FREE` (existing) becomes smart: removes tag_store entry, clears lane data. If no other tag_store entries reference the same frame_id, returns frame to free list and clears frame slots. If other entries exist, behaves like FREE_LANE. +- **frame-lanes.AC3.4 Success:** `FrameOp.ALLOC` (existing) unchanged — allocates fresh frame, assigns lane 0. +- **frame-lanes.AC3.5 Success:** `FrameAllocated` event gains `lane: int` field. `FrameFreed` event gains `lane: int` and `frame_freed: bool` fields. +- **frame-lanes.AC3.6 Success:** When all lanes for a frame are occupied and ALLOC_SHARED is received, PE emits `TokenRejected` with reason "no free lanes" and drops the token. + +### frame-lanes.AC8: Test Coverage (partial) +- **frame-lanes.AC8.1 Success:** Test: two act_ids sharing a frame via ALLOC_SHARED have independent matching — L operand for act_id 0 does not interfere with L operand for act_id 1 at the same offset. +- **frame-lanes.AC8.2 Success:** Test: ALLOC_SHARED with all lanes occupied emits TokenRejected. +- **frame-lanes.AC8.3 Success:** Test: FREE on a shared frame frees only the lane; other lanes' data is preserved. FREE on last lane frees the frame. + +--- + + + + +### Task 1: Add lane_free tracking and update FrameAllocated/FrameFreed events + +**Verifies:** frame-lanes.AC3.5 + +**Files:** +- Modify: `emu/events.py:84-97` — add lane fields to FrameAllocated and FrameFreed +- Modify: `emu/pe.py` — add lane_free data structure, update all event emissions +- Modify: `tests/test_pe_events.py` — update assertions for new event fields +- Modify: `tests/test_pe_frames.py` — update assertions for new event fields + +**Implementation:** + +**emu/events.py — Update event dataclasses:** + +```python +@dataclass(frozen=True) +class FrameAllocated: + time: float + component: str + act_id: int + frame_id: int + lane: int + +@dataclass(frozen=True) +class FrameFreed: + time: float + component: str + act_id: int + frame_id: int + lane: int + frame_freed: bool +``` + +**emu/pe.py — Add lane_free tracking in constructor:** + +After the `free_frames` initialization (line 87), add: + +```python +# Lane tracking: which lanes are free per frame +self.lane_free: dict[int, set[int]] = {} +``` + +`lane_free` is populated lazily — when a frame is allocated via ALLOC, its lanes are set up. + +**emu/pe.py — Update existing ALLOC handler event emission:** + +After Phase 1, ALLOC stores `(frame_id, 0)` in tag_store. Update to also set up lane tracking and emit `lane=0`: + +```python +if token.op == FrameOp.ALLOC: + if self.free_frames: + frame_id = self.free_frames.pop() + self.tag_store[token.act_id] = (frame_id, 0) + # Set up lane tracking: lane 0 is taken, rest are free + self.lane_free[frame_id] = set(range(1, self.lane_count)) + # Initialize frame slots to None + for i in range(self.frame_slots): + self.frames[frame_id][i] = None + # Reset all lanes' match state + for i in range(self.matchable_offsets): + for ln in range(self.lane_count): + self.match_data[frame_id][i][ln] = None + self.presence[frame_id][i][ln] = False + self.port_store[frame_id][i][ln] = None + self._on_event(FrameAllocated( + time=self.env.now, component=self._component, + act_id=token.act_id, frame_id=frame_id, lane=0, + )) + else: + logger.warning(f"PE {self.pe_id}: no free frames available") +``` + +**emu/pe.py — Update existing FREE handler for smart behaviour:** + +Note: This replaces the Phase 1 FREE handler wholesale. The Phase 1 version only added tuple unpacking (`frame_id, _lane = self.tag_store.pop(...)`). This version adds lane data clearing, frame-in-use checking, and conditional frame return. + +```python +elif token.op == FrameOp.FREE: + if token.act_id in self.tag_store: + frame_id, lane = self.tag_store.pop(token.act_id) + # Clear this lane's match state + for i in range(self.matchable_offsets): + self.match_data[frame_id][i][lane] = None + self.presence[frame_id][i][lane] = False + self.port_store[frame_id][i][lane] = None + # Check if any other activations use this frame + frame_in_use = any(fid == frame_id for fid, _ in self.tag_store.values()) + if frame_in_use: + # Return lane to pool, keep frame + self.lane_free[frame_id].add(lane) + self._on_event(FrameFreed( + time=self.env.now, component=self._component, + act_id=token.act_id, frame_id=frame_id, + lane=lane, frame_freed=False, + )) + else: + # Last lane — return frame to free list + self.free_frames.append(frame_id) + if frame_id in self.lane_free: + del self.lane_free[frame_id] + # Clear frame slots + for i in range(self.frame_slots): + self.frames[frame_id][i] = None + self._on_event(FrameFreed( + time=self.env.now, component=self._component, + act_id=token.act_id, frame_id=frame_id, + lane=lane, frame_freed=True, + )) +``` + +**emu/pe.py — Update FREE_FRAME opcode handler (lines 259-266):** + +Same smart free logic applies here: + +```python +if token.act_id in self.tag_store: + freed_frame, lane = self.tag_store.pop(token.act_id) + # Clear this lane's match state + for i in range(self.matchable_offsets): + self.match_data[freed_frame][i][lane] = None + self.presence[freed_frame][i][lane] = False + self.port_store[freed_frame][i][lane] = None + # Check if any other activations use this frame + frame_in_use = any(fid == freed_frame for fid, _ in self.tag_store.values()) + if frame_in_use: + self.lane_free[freed_frame].add(lane) + self._on_event(FrameFreed( + time=self.env.now, component=self._component, + act_id=token.act_id, frame_id=freed_frame, + lane=lane, frame_freed=False, + )) + else: + self.free_frames.append(freed_frame) + if freed_frame in self.lane_free: + del self.lane_free[freed_frame] + for i in range(self.frame_slots): + self.frames[freed_frame][i] = None + self._on_event(FrameFreed( + time=self.env.now, component=self._component, + act_id=token.act_id, frame_id=freed_frame, + lane=lane, frame_freed=True, + )) +``` + +**Also update constructor initialisation for pre-loaded tag_store entries:** + +After Phase 1, the constructor removes allocated frames from `free_frames`. Also initialise `lane_free` for those frames: + +```python +for act_id, (frame_id, lane) in self.tag_store.items(): + if frame_id in self.free_frames: + self.free_frames.remove(frame_id) + if frame_id not in self.lane_free: + # First time seeing this frame — set up lane tracking + all_lanes = set(range(self.lane_count)) + self.lane_free[frame_id] = all_lanes - {lane} + else: + self.lane_free[frame_id].discard(lane) +``` + +**Test updates:** + +All existing tests that assert on FrameAllocated or FrameFreed events need updated assertions to include the new fields. For existing single-activation tests: +- `FrameAllocated` assertions add `lane=0` +- `FrameFreed` assertions add `lane=0, frame_freed=True` + +Specific test files affected: +- `tests/test_pe_frames.py` lines 505-507 (test_alloc_remote), 550-552 (test_free_frame_opcode), 122-125 (test_free_frame_control_token) +- `tests/test_pe_events.py` — any tests asserting on FrameAllocated/FrameFreed event fields + +**Verification:** +Run: `python -m pytest tests/ -v -x` +Expected: All existing tests pass with updated event assertions. + +**Commit:** `jj commit -m "feat: add lane tracking and update FrameAllocated/FrameFreed events with lane fields"` + + + +### Task 2: Implement ALLOC_SHARED and FREE_LANE handlers + +**Verifies:** frame-lanes.AC3.1, frame-lanes.AC3.2, frame-lanes.AC3.3, frame-lanes.AC3.4, frame-lanes.AC3.6 + +**Files:** +- Modify: `emu/pe.py` — add ALLOC_SHARED and FREE_LANE cases to `_handle_frame_control()` + +**Implementation:** + +Add two new cases to `_handle_frame_control()` after the existing ALLOC and FREE handlers: + +```python +elif token.op == FrameOp.ALLOC_SHARED: + # Shared allocation: find parent's frame, assign next free lane + parent_act_id = token.payload + if parent_act_id not in self.tag_store: + self._on_event(TokenRejected( + time=self.env.now, component=self._component, + token=token, reason=f"parent act_id {parent_act_id} not in tag store", + )) + return + parent_frame_id, _ = self.tag_store[parent_act_id] + free_lanes = self.lane_free.get(parent_frame_id, set()) + if not free_lanes: + self._on_event(TokenRejected( + time=self.env.now, component=self._component, + token=token, reason="no free lanes", + )) + return + lane = min(free_lanes) # Deterministic: pick lowest free lane + free_lanes.remove(lane) + self.tag_store[token.act_id] = (parent_frame_id, lane) + # Clear only this lane's match state + for i in range(self.matchable_offsets): + self.match_data[parent_frame_id][i][lane] = None + self.presence[parent_frame_id][i][lane] = False + self.port_store[parent_frame_id][i][lane] = None + self._on_event(FrameAllocated( + time=self.env.now, component=self._component, + act_id=token.act_id, frame_id=parent_frame_id, lane=lane, + )) + +elif token.op == FrameOp.FREE_LANE: + # Free lane only — never returns frame to free list + if token.act_id in self.tag_store: + frame_id, lane = self.tag_store.pop(token.act_id) + for i in range(self.matchable_offsets): + self.match_data[frame_id][i][lane] = None + self.presence[frame_id][i][lane] = False + self.port_store[frame_id][i][lane] = None + self.lane_free[frame_id].add(lane) + self._on_event(FrameFreed( + time=self.env.now, component=self._component, + act_id=token.act_id, frame_id=frame_id, + lane=lane, frame_freed=False, + )) +``` + +**Testing:** + +No new tests in this task — AC3.1-AC3.6 are tested in Task 3. + +**Verification:** +Run: `python -m pytest tests/ -v -x` +Expected: All existing tests pass (new handlers only activate for new FrameOp values). + +**Commit:** `jj commit -m "feat: implement ALLOC_SHARED and FREE_LANE frame control handlers"` + + + + + + + +### Task 3: Tests for ALLOC_SHARED, lane exhaustion, and FREE_LANE + +**Verifies:** frame-lanes.AC3.1, frame-lanes.AC3.2, frame-lanes.AC3.6, frame-lanes.AC8.1, frame-lanes.AC8.2 + +**Files:** +- Create: `tests/test_pe_lanes.py` + +**Implementation:** + +Create a new test file dedicated to lane functionality. Follow the existing test patterns from `tests/test_pe_frames.py`: +- `simpy.Environment()` setup +- `PEConfig` with `on_event=events.append` +- `ProcessingElement(env, pe_id, config)` construction +- Token injection via `pe.input_store.put(token)` in a SimPy process +- Event collection via the events list + +**Testing:** + +Tests must verify these specific AC cases: + +- **frame-lanes.AC3.1 (ALLOC_SHARED):** Send `FrameControlToken(op=FrameOp.ALLOC, act_id=0, payload=0)` to allocate a frame. Then send `FrameControlToken(op=FrameOp.ALLOC_SHARED, act_id=1, payload=0)` where payload is the parent act_id. Verify `tag_store[1]` has the same `frame_id` as `tag_store[0]` but a different lane. Verify `FrameAllocated` event has correct lane. + +- **frame-lanes.AC3.2 (FREE_LANE):** After ALLOC_SHARED, send `FrameControlToken(op=FrameOp.FREE_LANE, act_id=1, payload=0)`. Verify `tag_store` no longer has act_id 1. Verify act_id 0 is still present. Verify frame is NOT in `free_frames`. Verify `FrameFreed` event has `frame_freed=False`. + +- **frame-lanes.AC3.6 (lane exhaustion):** Allocate a frame, then ALLOC_SHARED until all `lane_count` lanes are occupied. Send one more ALLOC_SHARED. Verify `TokenRejected` event with reason "no free lanes". + +- **frame-lanes.AC8.1 (independent matching):** Set up two act_ids sharing a frame (ALLOC + ALLOC_SHARED). Load an instruction with IRAM. Send L operand via DyadToken for act_id 0 and L operand via DyadToken for act_id 1 at the same offset. Verify both presence bits are set independently — neither token triggers a match (both are waiting for their R partner). Then send R for act_id 0 — verify only act_id 0 matches and fires, act_id 1's L is still pending. + +- **frame-lanes.AC8.2 (exhaustion):** Same as AC3.6 but via the test coverage AC numbering — allocate all lanes, attempt one more, verify TokenRejected. + +**Verification:** +Run: `python -m pytest tests/test_pe_lanes.py -v` +Expected: All new tests pass. + +Run: `python -m pytest tests/ -v -x` +Expected: All tests pass (new and existing). + +**Commit:** `jj commit -m "test: add tests for ALLOC_SHARED, FREE_LANE, and lane exhaustion"` + + + +### Task 4: Tests for smart FREE behaviour + +**Verifies:** frame-lanes.AC3.3, frame-lanes.AC3.4, frame-lanes.AC8.3 + +**Files:** +- Modify: `tests/test_pe_lanes.py` — add smart FREE test class + +**Implementation:** + +Add tests to the lane test file created in Task 3. + +**Testing:** + +Tests must verify these specific AC cases: + +- **frame-lanes.AC3.3 (smart FREE on shared frame):** Allocate a frame (act_id=0, lane 0). ALLOC_SHARED (act_id=1, lane 1). Send matching operands to act_id=1 so presence bits are set. FREE act_id=0. Verify: act_id=0 removed from tag_store, act_id=1 still present, frame NOT in free_frames, act_id=1's pending match data is preserved (presence bit still True for lane 1). Verify `FrameFreed` event has `frame_freed=False`. + +- **frame-lanes.AC3.3 (smart FREE on last lane):** Same setup. FREE act_id=0, then FREE act_id=1. After second FREE: frame IS returned to free_frames, `lane_free` entry for that frame is cleaned up. Verify `FrameFreed` event has `frame_freed=True`. + +- **frame-lanes.AC3.4 (ALLOC unchanged):** Verify that regular ALLOC still works — allocates fresh frame, assigns lane 0, no parent required. This is a regression check. + +- **frame-lanes.AC8.3 (data preservation):** Set up shared frame with two act_ids. Store a DyadToken L operand on act_id=1's lane. FREE act_id=0. Verify act_id=1's match_data and presence are untouched — the pending operand is still there. + +**Verification:** +Run: `python -m pytest tests/test_pe_lanes.py -v` +Expected: All tests pass. + +Run: `python -m pytest tests/ -v -x` +Expected: All tests pass. + +**Commit:** `jj commit -m "test: add tests for smart FREE behaviour and data preservation across lanes"` + + + diff --git a/docs/implementation-plans/2026-03-07-frame-lanes/phase_04.md b/docs/implementation-plans/2026-03-07-frame-lanes/phase_04.md new file mode 100644 --- /dev/null +++ b/docs/implementation-plans/2026-03-07-frame-lanes/phase_04.md @@ -0,0 +1,191 @@ +# Frame Matching Lanes Implementation Plan + +**Goal:** Extend the PE's frame-based matching to support multiple simultaneous pending operands per instruction within a single activation via matching lanes. + +**Architecture:** Multiple `activation_id` values share one physical frame (constants/destinations) while maintaining independent matching state per lane. Tag store maps `act_id → (frame_id, lane)`. Match data, presence, and port storage gain a lane dimension. + +**Tech Stack:** Python 3.12, SimPy 4.1, pytest + hypothesis + +**Scope:** 6 phases from original design (phases 1-6) + +**Codebase verified:** 2026-03-07 + +--- + +## Acceptance Criteria Coverage + +This phase implements and tests: + +### frame-lanes.AC4: ALLOC_REMOTE Data-Driven +- **frame-lanes.AC4.1 Success:** ALLOC_REMOTE reads `fref+2` from frame. If value is non-zero, emits `FrameControlToken` with `op=ALLOC_SHARED` and `payload=parent_act_id`. If zero, emits `op=ALLOC` as before. +- **frame-lanes.AC4.2 Success:** No new opcodes. Behaviour is entirely data-driven from frame constants. + +### frame-lanes.AC5: FREE_FRAME Instruction +- **frame-lanes.AC5.1 Success:** `FREE_FRAME` opcode uses the smart FREE behaviour from AC3.3. Frees the executing token's activation lane; returns frame to free list only if last lane. + +### frame-lanes.AC8: Test Coverage (partial) +- **frame-lanes.AC8.4 Success:** Test: ALLOC_REMOTE emits ALLOC_SHARED when `fref+2` is non-zero. +- **frame-lanes.AC8.5 Success:** Test: ALLOC_REMOTE emits ALLOC when `fref+2` is zero (backwards compatible). + +--- + + + + +### Task 1: Update ALLOC_REMOTE to read fref+2 for data-driven shared allocation + +**Verifies:** frame-lanes.AC4.1, frame-lanes.AC4.2 + +**Files:** +- Modify: `emu/pe.py:231-248` — ALLOC_REMOTE handler in `_process_token()` + +**Implementation:** + +The current ALLOC_REMOTE handler reads `fref+0` (target PE) and `fref+1` (target act_id) from frame constants, then emits a `FrameControlToken(op=FrameOp.ALLOC)`. Extend it to also read `fref+2` (parent act_id for shared allocation). + +**Updated handler:** + +```python +elif inst.opcode == RoutingOp.ALLOC_REMOTE: + # PE-level: read target PE, act_id, and optional parent act_id from frame constants + # fref+0: target PE + # fref+1: target act_id + # fref+2: parent act_id (0 = fresh ALLOC, non-zero = ALLOC_SHARED) + target_pe = self.frames[frame_id][inst.fref] if inst.fref < len(self.frames[frame_id]) else 0 + target_act = self.frames[frame_id][inst.fref + 1] if inst.fref + 1 < len(self.frames[frame_id]) else 0 + parent_act = self.frames[frame_id][inst.fref + 2] if inst.fref + 2 < len(self.frames[frame_id]) else 0 + + if parent_act: + alloc_op = FrameOp.ALLOC_SHARED + payload = parent_act + else: + alloc_op = FrameOp.ALLOC + payload = 0 + + fct = FrameControlToken( + target=target_pe, + act_id=target_act, + op=alloc_op, + payload=payload, + ) + self._on_event(Executed( + time=self.env.now, component=self._component, + op=inst.opcode, result=0, bool_out=False, + )) + yield self.env.timeout(1) # EXECUTE cycle + yield self.env.timeout(1) # EMIT cycle + self.env.process(self._deliver(self.route_table[target_pe], fct)) +``` + +Key changes from current code: +- Added `parent_act` read from `fref+2` +- Conditional: if `parent_act` is non-zero, use `ALLOC_SHARED` with `payload=parent_act`; otherwise, use `ALLOC` with `payload=0` (backwards compatible) +- No new opcodes (AC4.2) +- Note: Frame slots at `fref+0`, `fref+1`, `fref+2` must be `int` values (not `FrameDest`). The codegen guarantees this for properly assembled programs. No runtime type check added, consistent with the existing ALLOC_REMOTE pattern at `fref+0` and `fref+1`. + +**Testing:** + +No new tests in this task — AC4.1 and AC4.2 are tested in Task 3. + +**Verification:** +Run: `python -m pytest tests/ -v -x` +Expected: All existing tests pass. Existing ALLOC_REMOTE tests set up frames with only `fref+0` and `fref+1` populated — `fref+2` defaults to `None` which is falsy, so existing tests get `op=ALLOC` as before. + +**Commit:** `jj commit -m "feat: ALLOC_REMOTE reads fref+2 for data-driven ALLOC_SHARED"` + + + +### Task 2: Verify FREE_FRAME uses smart FREE behaviour + +**Verifies:** frame-lanes.AC5.1 + +**Files:** +- Verify: `emu/pe.py:249-267` — FREE_FRAME handler + +**Implementation:** + +After Phase 3, the FREE_FRAME opcode handler in `_process_token()` already uses the smart FREE behaviour (tag_store.pop with tuple unpacking, lane data clearing, frame-in-use check). This task verifies that the Phase 3 changes correctly cover the FREE_FRAME path. + +If Phase 3 was implemented correctly, the FREE_FRAME handler at lines 260-266 should already: +1. Unpack `frame_id, lane = self.tag_store.pop(token.act_id)` +2. Clear the lane's match_data/presence/port_store +3. Check if other activations reference the same frame_id +4. Return frame to free_frames only if last lane +5. Emit FrameFreed with `lane` and `frame_freed` fields + +If the Phase 3 implementation only updated `_handle_frame_control()` FREE and forgot the FREE_FRAME opcode path, this task is where you fix it. Both paths must have identical smart FREE logic. + +**Testing:** + +No new dedicated tests — AC5.1 is a subset of AC3.3 applied to a different code path. The existing `test_free_frame_opcode` test in `tests/test_pe_frames.py` verifies the basic path; the lane-aware behaviour is tested via the AC8.3 tests in Phase 3. + +**Verification:** +Run: `python -m pytest tests/test_pe_frames.py -v -k "free_frame"` +Expected: All FREE_FRAME tests pass. + +**Commit:** No commit needed if Phase 3 already handled this path. If a fix is needed: `jj commit -m "fix: ensure FREE_FRAME opcode uses smart FREE behaviour"` + + + + + + + +### Task 3: Tests for data-driven ALLOC_REMOTE + +**Verifies:** frame-lanes.AC8.4, frame-lanes.AC8.5 + +**Files:** +- Modify: `tests/test_pe_lanes.py` — add ALLOC_REMOTE data-driven tests + +**Implementation:** + +Add tests to the lane test file. + +**Testing:** + +Tests must verify these specific AC cases: + +- **frame-lanes.AC8.4 (ALLOC_REMOTE emits ALLOC_SHARED):** Set up PE0 with an allocated frame. Write frame slots: `fref+0 = 1` (target PE 1), `fref+1 = 5` (target act_id), `fref+2 = 3` (parent act_id, non-zero). Load ALLOC_REMOTE instruction. Send a MonadToken to trigger it. Set up PE1 with a route table entry so we can capture the emitted token. Verify the FrameControlToken sent to PE1 has `op=FrameOp.ALLOC_SHARED` and `payload=3`. + + For capturing the emitted FrameControlToken: use `output_store = simpy.Store(env)` and set `pe.route_table[1] = output_store`, then check `output_store.items[0]`. + +- **frame-lanes.AC8.5 (ALLOC_REMOTE emits ALLOC when fref+2 is zero):** Same setup but `fref+2 = 0` (or slot is None). Verify the FrameControlToken has `op=FrameOp.ALLOC` and `payload=0`. This is the backwards-compatible path. + +**Verification:** +Run: `python -m pytest tests/test_pe_lanes.py -v -k "alloc_remote"` +Expected: All new tests pass. + +Run: `python -m pytest tests/ -v -x` +Expected: All tests pass. + +**Commit:** `jj commit -m "test: add tests for data-driven ALLOC_REMOTE (ALLOC_SHARED vs ALLOC)"` + + + +### Task 4: Test FREE_FRAME with shared frame (smart FREE via opcode) + +**Verifies:** frame-lanes.AC5.1 + +**Files:** +- Modify: `tests/test_pe_lanes.py` — add FREE_FRAME smart free test + +**Implementation:** + +Add a test that exercises the FREE_FRAME opcode path specifically (not the FrameControlToken FREE path, which is already tested in Phase 3). + +**Testing:** + +- **frame-lanes.AC5.1 (FREE_FRAME smart free):** Set up PE with a shared frame (two act_ids on the same frame via initial_tag_store with different lanes). Load FREE_FRAME instruction at the offset used by act_id=0. Send a MonadToken to act_id=0 to trigger FREE_FRAME execution. Verify: act_id=0's lane is freed, act_id=1 is still in tag_store with the same frame, frame is NOT in free_frames, FrameFreed event has `frame_freed=False`. Then trigger FREE_FRAME for act_id=1. Verify: frame IS returned to free_frames, FrameFreed event has `frame_freed=True`. + +**Verification:** +Run: `python -m pytest tests/test_pe_lanes.py -v -k "free_frame"` +Expected: All tests pass. + +Run: `python -m pytest tests/ -v -x` +Expected: All tests pass. + +**Commit:** `jj commit -m "test: add test for FREE_FRAME opcode with smart free on shared frame"` + + + diff --git a/docs/implementation-plans/2026-03-07-frame-lanes/phase_05.md b/docs/implementation-plans/2026-03-07-frame-lanes/phase_05.md new file mode 100644 --- /dev/null +++ b/docs/implementation-plans/2026-03-07-frame-lanes/phase_05.md @@ -0,0 +1,195 @@ +# Frame Matching Lanes Implementation Plan + +**Goal:** Extend the PE's frame-based matching to support multiple simultaneous pending operands per instruction within a single activation via matching lanes. + +**Architecture:** Multiple `activation_id` values share one physical frame (constants/destinations) while maintaining independent matching state per lane. Tag store maps `act_id → (frame_id, lane)`. Match data, presence, and port storage gain a lane dimension. + +**Tech Stack:** Python 3.12, SimPy 4.1, pytest + hypothesis + +**Scope:** 6 phases from original design (phases 1-6) + +**Codebase verified:** 2026-03-07 + +--- + +## Acceptance Criteria Coverage + +This phase implements and tests: + +### frame-lanes.AC6: Monitor and Snapshot Updates +- **frame-lanes.AC6.1 Success:** `PESnapshot.tag_store` type becomes `dict[int, tuple[int, int]]`. +- **frame-lanes.AC6.2 Success:** `PESnapshot` gains `match_data`, `lane_count` fields reflecting the separated match storage. +- **frame-lanes.AC6.3 Success:** Monitor REPL `pe` command displays lane info in tag_store output. +- **frame-lanes.AC6.4 Success:** Monitor graph JSON serialises lane info correctly. + +### frame-lanes.AC7: Codegen Updates +- **frame-lanes.AC7.1 Success:** `codegen.py` generates `initial_tag_store` with `(frame_id, lane)` tuples. Existing single-activation code uses lane 0. +- **frame-lanes.AC7.2 Success:** No codegen changes needed for ALLOC_SHARED (manual construction only for now). + +--- + +Note: AC6.1 and AC6.2 (PESnapshot type changes) are structurally completed in Phase 2 Task 3 as part of the match storage separation. This phase covers the remaining monitor/codegen pieces that depend on those type changes. + + + + +### Task 1: Update Monitor REPL formatting for lane info + +**Verifies:** frame-lanes.AC6.3 + +**Files:** +- Modify: `monitor/formatting.py:194-202` — `format_pe_state()` tag_store display + +**Implementation:** + +The current tag_store display at `monitor/formatting.py:194-202` formats entries as `{key: value}`. After Phase 1, tag_store values are `(frame_id, lane)` tuples. Update the formatting to show lane info clearly. + +**Current code (lines 194-202):** +```python +if pe_snapshot.tag_store: + tag_str = ", ".join( + f"{colour(str(k), 'white')}: {colour(str(v), 'white')}" + for k, v in sorted(pe_snapshot.tag_store.items()) + ) + lines.append(f" Tag store: {{{tag_str}}}") +else: + lines.append(" Tag store: (empty)") +``` + +**Updated code:** +```python +if pe_snapshot.tag_store: + tag_str = ", ".join( + f"{colour(str(k), 'white')}: frame {colour(str(fid), 'white')} lane {colour(str(lane), 'white')}" + for k, (fid, lane) in sorted(pe_snapshot.tag_store.items()) + ) + lines.append(f" Tag store: {{{tag_str}}}") +else: + lines.append(" Tag store: (empty)") +``` + +This changes the display from `{0: 0}` to `{0: frame 0 lane 0}`, making lane assignments visible at a glance. + +**Testing:** + +The REPL tests at `tests/test_repl.py:456-473` verify that `do_pe()` produces output but don't assert on specific tag_store formatting content. The formatting change is verified by manual inspection. Existing tests remain valid because they only check `len(out) > 0`. + +**Verification:** +Run: `python -m pytest tests/test_repl.py -v` +Expected: All tests pass. + +**Commit:** `jj commit -m "feat: display lane info in monitor REPL pe command"` + + + +### Task 2: Update Monitor graph JSON for lane serialisation + +**Verifies:** frame-lanes.AC6.4 + +**Files:** +- Modify: `monitor/graph_json.py:104-127` — `_serialise_pe_state()` function +- Modify: `tests/test_monitor_graph_json.py` — update PESnapshot constructions + +**Implementation:** + +The current `_serialise_pe_state()` at `monitor/graph_json.py:124` passes `tag_store` directly to JSON: +```python +"tag_store": pe_snap.tag_store, +``` + +After Phase 1, tag_store values are `(frame_id, lane)` tuples. Use explicit dict format for self-documenting JSON that the TypeScript frontend can easily type: + +```python +"tag_store": { + str(act_id): {"frame_id": fid, "lane": lane} + for act_id, (fid, lane) in pe_snap.tag_store.items() +}, +"lane_count": pe_snap.lane_count, +``` + +This produces JSON like `{"0": {"frame_id": 2, "lane": 0}}` instead of `{"0": [2, 0]}`, which is more explicit and easier to type in TypeScript. + +**Test updates:** + +Update `tests/test_monitor_graph_json.py` PESnapshot constructions to include the new fields (`match_data`, `lane_count`). Tests that construct `PESnapshot` directly with `tag_store={}` will work as-is (empty dict). Tests that use non-empty tag_store must change values from `int` to `tuple[int, int]`. + +**Verification:** +Run: `python -m pytest tests/test_monitor_graph_json.py -v` +Expected: All tests pass. + +**Commit:** `jj commit -m "feat: serialise lane info in monitor graph JSON"` + + + + + + + +### Task 3: Update codegen to emit tuple initial_tag_store + +**Verifies:** frame-lanes.AC7.1 + +**Files:** +- Modify: `asm/codegen.py:383` — first initial_tag_store assignment +- Modify: `asm/codegen.py:412` — second initial_tag_store assignment + +**Implementation:** + +The codegen at `asm/codegen.py` builds `initial_tag_store` as `dict[int, int]` mapping `act_id → frame_id`. Change both assignment sites to produce `dict[int, tuple[int, int]]` mapping `act_id → (frame_id, lane)` with lane 0 for all existing single-activation code. + +**Line 383 (empty layout path):** +```python +initial_tag_store[act_id] = (frame_id, 0) +``` + +**Line 412 (populated layout path):** +```python +initial_tag_store[act_id] = (frame_id, 0) +``` + +No other changes needed. The type annotation for the local variable can be updated: +```python +initial_tag_store: dict[int, tuple[int, int]] = {} +``` + +**Testing:** + +Existing codegen tests at `tests/test_codegen_frames.py` don't explicitly assert on `initial_tag_store` contents — they test IRAM, setup_tokens, and seed_tokens. The type change is validated by the overall test suite passing (PEConfig now expects tuple values from Phase 1). + +**Verification:** +Run: `python -m pytest tests/test_codegen_frames.py -v` +Expected: All tests pass. + +Run: `python -m pytest tests/ -v -x` +Expected: All tests pass. + +**Commit:** `jj commit -m "feat: codegen emits initial_tag_store with (frame_id, lane) tuples"` + + + +### Task 4: Verify no codegen changes needed for ALLOC_SHARED + +**Verifies:** frame-lanes.AC7.2 + +**Files:** +- No files to modify + +**Implementation:** + +AC7.2 states: "No codegen changes needed for ALLOC_SHARED (manual construction only for now)." This is a verification task — confirm that the codegen does not attempt to generate ALLOC_SHARED tokens or any lane-related control flow. ALLOC_SHARED is invoked only via manual test construction or hand-crafted assembly. + +**Verification:** + +Grep codegen for any reference to ALLOC_SHARED or FREE_LANE: +```bash +grep -r "ALLOC_SHARED\|FREE_LANE" asm/ +``` +Expected: No results. The codegen is unaware of these new FrameOp values. + +Run: `python -m pytest tests/ -v -x` +Expected: All tests pass. + +**Commit:** No commit needed — this is a verification-only task. + + + diff --git a/docs/implementation-plans/2026-03-07-frame-lanes/phase_06.md b/docs/implementation-plans/2026-03-07-frame-lanes/phase_06.md new file mode 100644 --- /dev/null +++ b/docs/implementation-plans/2026-03-07-frame-lanes/phase_06.md @@ -0,0 +1,121 @@ +# Frame Matching Lanes Implementation Plan + +**Goal:** Extend the PE's frame-based matching to support multiple simultaneous pending operands per instruction within a single activation via matching lanes. + +**Architecture:** Multiple `activation_id` values share one physical frame (constants/destinations) while maintaining independent matching state per lane. Tag store maps `act_id → (frame_id, lane)`. Match data, presence, and port storage gain a lane dimension. + +**Tech Stack:** Python 3.12, SimPy 4.1, pytest + hypothesis + +**Scope:** 6 phases from original design (phases 1-6) + +**Codebase verified:** 2026-03-07 + +--- + +## Acceptance Criteria Coverage + +This phase implements and tests: + +### frame-lanes.AC8: Test Coverage (final) +- **frame-lanes.AC8.6 Success:** Test: full loop pipelining scenario — two iterations of a dyadic instruction running concurrently on different lanes, both producing correct results. + +--- + + +### Task 1: Full loop pipelining integration test + +**Verifies:** frame-lanes.AC8.6 + +**Files:** +- Modify: `tests/test_pe_lanes.py` — add integration test class + +**Implementation:** + +Add an integration test that simulates the complete loop pipelining lifecycle from the design plan's Architecture section. This test exercises every Phase 1-5 feature together. + +**Testing:** + +The test must verify frame-lanes.AC8.6 by simulating this lifecycle: + +``` +1. ALLOC(act_id=0) → frame, lane 0 +2. Setup: write constants/dests to frame +3. Iteration 1: inject L and R DyadTokens for act_id=0 +4. ALLOC_SHARED(act_id=1, parent=0) → same frame, lane 1 +5. Iteration 2: inject L and R DyadTokens for act_id=1 +6. Both iterations match independently, both produce correct results +7. FREE(act_id=0) → lane 0 freed, frame stays +8. FREE(act_id=1) → last lane, frame returned to free list +``` + +**Test structure:** + +Follow the established test patterns from `tests/test_pe_frames.py`: +- Use `simpy.Environment()` and `PEConfig(on_event=events.append)` +- Direct PE construction (no `build_topology` needed for single-PE test) +- Use `inject_and_run()` helper for sequential token injection +- Use a `simpy.Store` as `pe.route_table[target]` to capture output tokens + +**Detailed scenario:** + +1. **PE setup:** Create PE with `frame_count=4`, `matchable_offsets=4`, no pre-loaded frames or tag_store. Install a dyadic ADD instruction at IRAM offset 0 with `OutputStyle.INHERIT`, `dest_count=1`, `fref=8`. + +2. **Allocate frame for iteration 1:** + - Inject `FrameControlToken(target=0, act_id=0, op=FrameOp.ALLOC, payload=0)` + - Verify `FrameAllocated` event with `lane=0` + - Get `frame_id` from `pe.tag_store[0]` + +3. **Write destination to frame:** + - Write a `FrameDest(target_pe=1, offset=0, act_id=0, port=Port.L, token_kind=TokenKind.MONADIC)` to `pe.frames[frame_id][8]` + - Set up `pe.route_table[1] = simpy.Store(env)` to capture output + +4. **Allocate shared frame for iteration 2:** + - Inject `FrameControlToken(target=0, act_id=1, op=FrameOp.ALLOC_SHARED, payload=0)` (payload=0 is parent act_id) + - Verify `FrameAllocated` event with `lane=1` and same `frame_id` + - Verify `pe.tag_store[1][0] == pe.tag_store[0][0]` (same frame_id) + +5. **Inject iteration 1 operands (act_id=0):** + - Inject `DyadToken(target=0, offset=0, act_id=0, data=100, port=Port.L)` + - Inject `DyadToken(target=0, offset=0, act_id=0, data=200, port=Port.R)` + - Verify `Matched` event for `act_id=0` with `left=100, right=200` + - Verify output token emitted with `data=300` (100+200) + +6. **Inject iteration 2 operands (act_id=1):** + - Inject `DyadToken(target=0, offset=0, act_id=1, data=1000, port=Port.L)` + - Inject `DyadToken(target=0, offset=0, act_id=1, data=2000, port=Port.R)` + - Verify `Matched` event for `act_id=1` with `left=1000, right=2000` + - Verify output token emitted with `data=3000` (1000+2000) + +7. **Interleaved verification:** + - Confirm that iteration 1's L operand (injected first) did NOT interfere with iteration 2's matching — they're on different lanes + - Both `Matched` events should have independent operand values + +8. **Free iteration 1 (not last lane):** + - Inject `FrameControlToken(target=0, act_id=0, op=FrameOp.FREE, payload=0)` + - Verify `FrameFreed` with `frame_freed=False` + - Verify `0 not in pe.tag_store` + - Verify `1 in pe.tag_store` (iteration 2 still active) + - Verify `frame_id not in pe.free_frames` (frame stays allocated) + +9. **Free iteration 2 (last lane):** + - Inject `FrameControlToken(target=0, act_id=1, op=FrameOp.FREE, payload=0)` + - Verify `FrameFreed` with `frame_freed=True` + - Verify `1 not in pe.tag_store` + - Verify `frame_id in pe.free_frames` (frame returned to pool) + +**Key assertions for AC8.6:** +- Both iterations produce mathematically correct results (100+200=300, 1000+2000=3000) +- Both iterations ran on the SAME frame (shared constants/destinations) +- Both iterations used DIFFERENT lanes (lane 0 and lane 1) +- Freeing one iteration preserved the other's state +- Freeing the last iteration returned the frame + +**Verification:** +Run: `python -m pytest tests/test_pe_lanes.py -v -k "loop_pipelining"` +Expected: Test passes. + +Run: `python -m pytest tests/ -v -x` +Expected: All tests pass. + +**Commit:** `jj commit -m "test: add full loop pipelining integration test (AC8.6)"` + diff --git a/docs/implementation-plans/2026-03-07-frame-lanes/test-requirements.md b/docs/implementation-plans/2026-03-07-frame-lanes/test-requirements.md new file mode 100644 --- /dev/null +++ b/docs/implementation-plans/2026-03-07-frame-lanes/test-requirements.md @@ -0,0 +1,40 @@ +# Test Requirements: Frame Matching Lanes + +## Automated Test Coverage + +| AC ID | Criterion | Test Type | Expected Test File | Implementation Phase | +|-------|-----------|-----------|-------------------|---------------------| +| frame-lanes.AC1.1 | `tag_store` maps `act_id -> (frame_id, lane)` where `lane` is an `int` in range `[0, lane_count)` | unit | `tests/test_pe_frames.py` (existing tests adapted to tuple API) | Phase 1 | +| frame-lanes.AC1.2 | `PEConfig.initial_tag_store` type is `dict[int, tuple[int, int]]`. PE constructor initialises tag_store from it. | unit | `tests/test_pe_frames.py`, `tests/test_pe_events.py`, `tests/test_pe.py` (all existing tests updated to pass tuple values) | Phase 1 | +| frame-lanes.AC1.3 | `PEConfig.lane_count` field exists with default 4. Controls third dimension of match arrays. | unit | `tests/test_pe_lanes.py` (verified structurally when match arrays gain lane dimension in Phase 2) | Phase 1 | +| frame-lanes.AC1.4 | All existing tests pass with updated tuple API. | integration | `tests/` (full test suite regression run) | Phase 1 | +| frame-lanes.AC2.1 | Match operand data lives in `match_data[frame_id][offset][lane]`, separate from `frames[frame_id][slot]`. | unit | `tests/test_pe_frames.py`, `tests/test_pe.py` (existing matching tests exercise 3D match_data via lane 0) | Phase 2 | +| frame-lanes.AC2.2 | `presence[frame_id][offset][lane]` is a 3D bool array. `port_store[frame_id][offset][lane]` likewise. | unit | `tests/test_pe.py` (existing presence assertions updated from 2D to 3D indexing with `[0]` lane suffix) | Phase 2 | +| frame-lanes.AC2.3 | `_match_frame()` uses `(frame_id, match_slot, lane)` to read/write match data, presence, and port. | unit | `tests/test_pe_frames.py`, `tests/test_pe.py` (existing matching tests pass through `_match_frame` with lane parameter) | Phase 2 | +| frame-lanes.AC2.4 | `frames[frame_id][slot]` remains shared across all lanes. Constants and destinations are NOT per-lane. | unit | `tests/test_pe_lanes.py` (verified in Phase 3 AC8.1 test: two act_ids sharing a frame read the same frame slot constants) | Phase 3 | +| frame-lanes.AC3.1 | `FrameOp.ALLOC_SHARED`: PE looks up `parent_act_id` from payload, finds parent's `frame_id`, assigns next free lane, records `tag_store[act_id] = (frame_id, lane)`. Clears only that lane's presence/port bits. | unit | `tests/test_pe_lanes.py` | Phase 3 | +| frame-lanes.AC3.2 | `FrameOp.FREE_LANE`: Removes tag_store entry, clears that lane's presence/port/match_data. Does NOT return frame to free list. | unit | `tests/test_pe_lanes.py` | Phase 3 | +| frame-lanes.AC3.3 | `FrameOp.FREE` becomes smart: removes tag_store entry, clears lane data. Returns frame to free list only if no other tag_store entries reference the same frame_id. | unit | `tests/test_pe_lanes.py` | Phase 3 | +| frame-lanes.AC3.4 | `FrameOp.ALLOC` unchanged: allocates fresh frame, assigns lane 0. | unit | `tests/test_pe_lanes.py` (regression check), `tests/test_pe_frames.py` (existing ALLOC tests) | Phase 3 | +| frame-lanes.AC3.5 | `FrameAllocated` event gains `lane: int` field. `FrameFreed` event gains `lane: int` and `frame_freed: bool` fields. | unit | `tests/test_pe_events.py`, `tests/test_pe_frames.py` (existing event assertions updated with new fields) | Phase 3 | +| frame-lanes.AC3.6 | When all lanes for a frame are occupied and ALLOC_SHARED is received, PE emits `TokenRejected` with reason "no free lanes". | unit | `tests/test_pe_lanes.py` | Phase 3 | +| frame-lanes.AC4.1 | ALLOC_REMOTE reads `fref+2` from frame. If non-zero, emits `FrameControlToken` with `op=ALLOC_SHARED` and `payload=parent_act_id`. If zero, emits `op=ALLOC`. | unit | `tests/test_pe_lanes.py` | Phase 4 | +| frame-lanes.AC4.2 | No new opcodes. Behaviour is entirely data-driven from frame constants. | unit | Verified by absence: `grep -r "ALLOC_SHARED\|FREE_LANE" asm/` returns no results. No dedicated test file. | Phase 5 | +| frame-lanes.AC5.1 | `FREE_FRAME` opcode uses the smart FREE behaviour from AC3.3. Frees the executing token's activation lane; returns frame to free list only if last lane. | unit | `tests/test_pe_lanes.py` | Phase 4 | +| frame-lanes.AC6.1 | `PESnapshot.tag_store` type becomes `dict[int, tuple[int, int]]`. | unit | `tests/test_snapshot.py`, `tests/test_monitor_graph_json.py` (PESnapshot constructions updated) | Phase 2 | +| frame-lanes.AC6.2 | `PESnapshot` gains `match_data`, `lane_count` fields reflecting the separated match storage. | unit | `tests/test_snapshot.py` (snapshot capture assertions updated for new fields) | Phase 2 | +| frame-lanes.AC6.4 | Monitor graph JSON serialises lane info correctly. | unit | `tests/test_monitor_graph_json.py` (assertions on serialised tag_store JSON structure with frame_id/lane keys) | Phase 5 | +| frame-lanes.AC7.1 | `codegen.py` generates `initial_tag_store` with `(frame_id, lane)` tuples. Existing single-activation code uses lane 0. | unit | `tests/test_codegen_frames.py` (existing codegen tests pass with tuple-valued initial_tag_store) | Phase 5 | +| frame-lanes.AC7.2 | No codegen changes needed for ALLOC_SHARED (manual construction only for now). | unit | Verified by absence: `grep -r "ALLOC_SHARED\|FREE_LANE" asm/` returns no results. No dedicated test file. | Phase 5 | +| frame-lanes.AC8.1 | Two act_ids sharing a frame via ALLOC_SHARED have independent matching: L operand for act_id 0 does not interfere with L operand for act_id 1 at the same offset. | unit | `tests/test_pe_lanes.py` | Phase 3 | +| frame-lanes.AC8.2 | ALLOC_SHARED with all lanes occupied emits TokenRejected. | unit | `tests/test_pe_lanes.py` | Phase 3 | +| frame-lanes.AC8.3 | FREE on a shared frame frees only the lane; other lanes' data is preserved. FREE on last lane frees the frame. | unit | `tests/test_pe_lanes.py` | Phase 3 | +| frame-lanes.AC8.4 | ALLOC_REMOTE emits ALLOC_SHARED when `fref+2` is non-zero. | unit | `tests/test_pe_lanes.py` | Phase 4 | +| frame-lanes.AC8.5 | ALLOC_REMOTE emits ALLOC when `fref+2` is zero (backwards compatible). | unit | `tests/test_pe_lanes.py` | Phase 4 | +| frame-lanes.AC8.6 | Full loop pipelining scenario: two iterations of a dyadic instruction running concurrently on different lanes, both producing correct results. | e2e | `tests/test_pe_lanes.py` | Phase 6 | + +## Criteria Requiring Human Verification + +| AC ID | Criterion | Justification | Verification Approach | +|-------|-----------|---------------|----------------------| +| frame-lanes.AC6.3 | Monitor REPL `pe` command displays lane info in tag_store output. | The existing REPL tests at `tests/test_repl.py` only assert that `do_pe()` produces non-empty output (`len(out) > 0`); they do not assert on specific formatting content. The formatting change from `{0: 0}` to `{0: frame 0 lane 0}` is a display concern that is most reliably verified by visual inspection. While a string-matching test could be added, the REPL formatting is intentionally loosely tested to allow cosmetic changes without test churn. | Run `python -m monitor` with a loaded program, execute the `pe 0` command, and confirm tag_store entries display as `act_id: frame F lane L` format. Verify that multi-lane scenarios (after ALLOC_SHARED) show distinct lane numbers per act_id. | -- tangled.sh