diff --git a/python/assassyn/builder/type_oriented_namer.py b/python/assassyn/builder/type_oriented_namer.py index 1ea4066cd..56513e7f9 100644 --- a/python/assassyn/builder/type_oriented_namer.py +++ b/python/assassyn/builder/type_oriented_namer.py @@ -59,7 +59,7 @@ def _symbol_to_name(): """Convert operator symbols to descriptive names.""" return { '+': 'add', '-': 'sub', '*': 'mul', '/': 'div', '%': 'mod', - '&': 'and', '|': 'or', '^': 'xor', + '&': 'and_', '|': 'or_', '^': 'xor_', '<': 'lt', '>': 'gt', '<=': 'le', '>=': 'ge', '==': 'eq', '!=': 'neq', '<<': 'shl', '>>': 'shr', '!': 'not', diff --git a/python/assassyn/codegen/verilog/README.md b/python/assassyn/codegen/verilog/README.md index 68fa2d7e5..b0eca5847 100644 --- a/python/assassyn/codegen/verilog/README.md +++ b/python/assassyn/codegen/verilog/README.md @@ -94,7 +94,7 @@ Keeping these definitions in a runtime module ensures generated designs and user ## Handshake & Scheduling -- `executed_wire` gates side‑effects each cycle (built through `_format_reduction_expr` so OR / AND reductions share the same formatting): +- `executed_wire` gates side‑effects each cycle (built through `predicates.reduce_predicates` so OR / AND reductions share the same formatting): - Drivers: `trigger_counter_pop_valid [& WAIT_UNTIL]` - Downstreams: OR of upstream `inst_.executed` - FIFO push (producer of `.

`): diff --git a/python/assassyn/codegen/verilog/_expr/arith.py b/python/assassyn/codegen/verilog/_expr/arith.py index 6fbdc3fa3..1d4f27861 100644 --- a/python/assassyn/codegen/verilog/_expr/arith.py +++ b/python/assassyn/codegen/verilog/_expr/arith.py @@ -164,17 +164,15 @@ def codegen_select1hot(dumper, expr: Select1Hot) -> Optional[str]: num_values = len(values) selector_bits = max((num_values - 1).bit_length(), 1) if num_values == 2: - body = f"{cond}.as_bits()[1]" - else: - dumper.append_code(f"{cond}_res = Bits({selector_bits})(0)") - for i in range(num_values): - dumper.append_code( - f"{cond}_res = Mux({cond}[{i}] ," - f" {cond}_res , Bits({selector_bits})({i}))") - - values_str = ", ".join(values) - mux_code = f"{rval} = Mux({cond}_res, {values_str})" - dumper.append_code(mux_code) - return None - - return body + return f"{rval} = Mux({cond}.as_bits()[1], {values[0]}, {values[1]})" + + dumper.append_code(f"{cond}_res = Bits({selector_bits})(0)") + for i in range(num_values): + dumper.append_code( + f"{cond}_res = Mux({cond}[{i}] ," + f" {cond}_res , Bits({selector_bits})({i}))") + + values_str = ", ".join(values) + mux_code = f"{rval} = Mux({cond}_res, {values_str})" + dumper.append_code(mux_code) + return None diff --git a/python/assassyn/codegen/verilog/_expr/intrinsics.md b/python/assassyn/codegen/verilog/_expr/intrinsics.md index c4854ac35..2b7a0c080 100644 --- a/python/assassyn/codegen/verilog/_expr/intrinsics.md +++ b/python/assassyn/codegen/verilog/_expr/intrinsics.md @@ -124,7 +124,7 @@ The module uses several utility functions: - `dump_rval()` from [rval module](/python/assassyn/codegen/verilog/rval.md) for generating signal references - `unwrap_operand()` and `namify()` from [utils module](/python/assassyn/utils.md) for operand processing and name generation -- `get_pred()` from [CIRCTDumper](/python/assassyn/codegen/verilog/design.md) for getting current execution predicate +- `format_predicate()` from [CIRCTDumper](/python/assassyn/codegen/verilog/design.md) which consumes `expr.meta_cond` captured on each IR node to produce `Bits(1)` guards recorded alongside external exposures - `external_instance_names` / `external_wrapper_names` maps on the dumper to coordinate `ExternalIntrinsic` handling across passes The intrinsic expression generation is integrated into the main expression dispatch system through the [__init__.py](/python/assassyn/codegen/verilog/_expr/__init__.md) module, which routes different expression types to their appropriate code generation functions. diff --git a/python/assassyn/codegen/verilog/_expr/intrinsics.py b/python/assassyn/codegen/verilog/_expr/intrinsics.py index eb30409b6..768319a7b 100644 --- a/python/assassyn/codegen/verilog/_expr/intrinsics.py +++ b/python/assassyn/codegen/verilog/_expr/intrinsics.py @@ -157,47 +157,36 @@ def _handle_external_output(dumper, expr, intrinsic, rval): port_name = port_operand.value if hasattr(port_operand, 'value') else port_operand index_operand = expr.args[2] if len(expr.args) > 2 else None - result = None instance_owner = dumper.external_metadata.owner_for(instance) if instance_owner and instance_owner != dumper.current_module: - # Cross-module access: use the exposed port value provided on inputs. port_name_for_read = dumper.get_external_port_name(expr) - wire_key = dumper.get_external_wire_key(instance, port_name, index_operand) - assignment_key = (dumper.current_module, wire_key) - if assignment_key not in dumper.external_wire_assignment_keys: - dumper.external_wire_assignment_keys.add(assignment_key) - dumper.external_wire_assignments.append({ - 'consumer': dumper.current_module, - 'producer': instance_owner, - 'expr': expr, - 'wire': wire_key, - }) - result = f"{rval} = self.{port_name_for_read}" - else: - inst_name = dumper.external_instance_names.get(instance) - if inst_name is None: - inst_name = dumper.dump_rval(instance, False) - dumper.external_instance_names[instance] = inst_name + return f"{rval} = self.{port_name_for_read}" - port_specs = instance.external_class.port_specs() - wire_spec = port_specs.get(port_name) + result = None + inst_name = dumper.external_instance_names.get(instance) + if inst_name is None: + inst_name = dumper.dump_rval(instance, False) + dumper.external_instance_names[instance] = inst_name - if wire_spec is not None and wire_spec.kind == 'reg': - if index_operand is None: - result = f"{rval} = {inst_name}.{port_name}" - else: - idx_operand = unwrap_operand(index_operand) - if isinstance(idx_operand, Const) and idx_operand.value == 0: - result = f"{rval} = {inst_name}.{port_name}" - else: - index_code = dumper.dump_rval(index_operand, False) - result = f"{rval} = {inst_name}.{port_name}[{index_code}]" + port_specs = instance.external_class.port_specs() + wire_spec = port_specs.get(port_name) + + if wire_spec is not None and wire_spec.kind == 'reg': + if index_operand is None: + result = f"{rval} = {inst_name}.{port_name}" else: - if index_operand is None: + idx_operand = unwrap_operand(index_operand) + if isinstance(idx_operand, Const) and idx_operand.value == 0: result = f"{rval} = {inst_name}.{port_name}" else: index_code = dumper.dump_rval(index_operand, False) result = f"{rval} = {inst_name}.{port_name}[{index_code}]" + else: + if index_operand is None: + result = f"{rval} = {inst_name}.{port_name}" + else: + index_code = dumper.dump_rval(index_operand, False) + result = f"{rval} = {inst_name}.{port_name}[{index_code}]" return result @@ -259,8 +248,7 @@ def codegen_external_intrinsic(dumper, expr: ExternalIntrinsic) -> Optional[str] continue seen_keys.add(wire_key) output_name = f"{rval}_{entry.port_name}" - dumper.external_wire_outputs[wire_key] = output_name - current_pred = dumper.get_pred(expr) + meta_cond = expr.meta_cond if hasattr(expr, "meta_cond") else None exposures.setdefault(wire_key, { 'output_name': output_name, 'dtype': entry.expr.dtype, @@ -268,7 +256,7 @@ def codegen_external_intrinsic(dumper, expr: ExternalIntrinsic) -> Optional[str] 'port_name': entry.port_name, 'index_operand': entry.index_operand, 'index_key': wire_key[2], - 'condition': current_pred, + 'meta_cond': meta_cond, }) diff --git a/python/assassyn/codegen/verilog/cleanup.md b/python/assassyn/codegen/verilog/cleanup.md index 90f54f0c2..e2ac561db 100644 --- a/python/assassyn/codegen/verilog/cleanup.md +++ b/python/assassyn/codegen/verilog/cleanup.md @@ -6,6 +6,12 @@ This module provides post-generation cleanup utilities for Verilog code generati The cleanup module is responsible for generating the final control signals and interconnections after the main Verilog code generation is complete. It handles complex signal routing for arrays, ports, modules, and memory interfaces, ensuring proper connectivity between generated modules according to the credit-based pipeline architecture. As part of the external module flow, it also materialises producer-side `expose_*`/`valid_*` ports for any external register outputs that are consumed by another module, so cross-module reads can be wired up without duplicating logic. FIFO wiring now consumes the metadata snapshot produced by the pre-pass instead of mutating registries during emission. +Following the 2024-03 refresh the module now depends on +`python.assassyn.codegen.verilog.predicates` for all predicate reductions and mux-chain +construction. Shared helpers (`reduce_predicates`, `emit_predicate_mux_chain`) replace +the previous private `predicates.reduce_predicates` / `predicates.emit_predicate_mux_chain` pair, so the +cleanup logic mirrors the top-level harness and keeps ordering semantics in one place. + Metadata consumed here (`ModuleMetadata`, `ModuleInteractionView`, `ArrayMetadata`, and `FIFOInteractionView`) is provided by the `python.assassyn.codegen.verilog.metadata` package. Implementations live in the `metadata.module`, `metadata.array`, and @@ -25,7 +31,7 @@ def cleanup_post_generation(dumper): This is the main cleanup function that generates all the necessary control signals and interconnections after the primary Verilog code generation is complete. It performs the following steps: 1. **Execution Signal Generation**: Creates the `executed_wire` signal that determines when a module should execute: - - For downstream modules: Gathers upstream dependencies with `analysis.get_upstreams(module)` and ORs their `executed` flags via `_format_reduction_expr(..., op="or_", default_literal="Bits(1)(0)")`. + - For downstream modules: Gathers upstream dependencies with `analysis.get_upstreams(module)` and ORs their `executed` flags via `predicates.reduce_predicates(..., op="or_", default_literal="Bits(1)(0)")`. - For regular modules: ANDs the trigger-counter pop-valid input with any active `wait_until` predicate recorded during expression lowering using the same helper with `op="and_"` and a `Bits(1)(1)` default. 2. **Finish Signal Generation**: Reduces every FINISH site captured in @@ -38,13 +44,13 @@ This is the main cleanup function that generates all the necessary control signa `module_metadata.interactions.writes`: - Filters out arrays whose owner is a memory instance and satisfy `array.is_payload(owner)`, because those are handled by dedicated memory logic. - Uses the module view’s `writes(array)` tuples (which mirror the global array view maintained by the `InteractionMatrix`) to map interactions onto the precomputed port indices stored in the `ArrayMetadataRegistry`. - - Emits write-enable, write-data, and write-index signals per port, formatting each write’s `expr.meta_cond` with `dumper.format_predicate`. Multi-writer modules rely on `_emit_predicate_mux_chain` to collapse predicates and thread prioritised mux chains for data and indices, guaranteeing consistent selection semantics. + - Emits write-enable, write-data, and write-index signals per port, formatting each write’s `expr.meta_cond` with `dumper.format_predicate`. Multi-writer modules rely on `predicates.emit_predicate_mux_chain` to collapse predicates and thread prioritised mux chains for data and indices, guaranteeing consistent selection semantics. 5. **FIFO Signal Generation**: Walks `module_metadata.interactions.fifo_ports` to visit each FIFO touched by the module: - Pulls the per-port `FIFOInteractionView` directly from the shared matrix so the recorded `FIFOPush` / `FIFOPop` expressions stay in sync across consumers—predicates come from each expression’s `meta_cond`, push data from `expr.val`, and module ownership from the metadata view that registered the expression. - Applies backpressure via the parent module's `fifo_*_push_ready` signals and emits valid/data assignments driven purely from metadata captured during the pre-pass. - Produces the module-local `*_pop_ready` backpressure signal without consulting dumper internals. - - Reuses `_emit_predicate_mux_chain` so the push-valid reduction and push-data mux mirror the prioritisation used for array writes. + - Reuses `predicates.emit_predicate_mux_chain` so the push-valid reduction and push-data mux mirror the prioritisation used for array writes. 6. **Module Trigger Signal Generation**: Reads async trigger exposures from `dumper.interactions.async_ledger.calls_for_module(current_module)`, sums all predicates (each taken from the call’s `meta_cond` and converted to an 8-bit increment), and routes the result into `_trigger`. @@ -86,10 +92,10 @@ This function generates the control signals specifically for SRAM memory interfa - Understanding of [SRAM memory model](/python/assassyn/ir/memory/sram.md) - Knowledge of [array read/write operations](/python/assassyn/ir/expr/array.md) -### `_emit_predicate_mux_chain` +### `emit_predicate_mux_chain` ```python -def _emit_predicate_mux_chain(entries, *, render_predicate, render_value, default_value, aggregate_predicates): +def emit_predicate_mux_chain(entries, *, render_predicate, render_value, default_value, aggregate_predicates): """Return both the mux chain and aggregate predicate for *entries*.""" ``` @@ -113,7 +119,7 @@ The module uses several internal helper functions and imports utilities from oth - `dump_type()` and `dump_type_cast()` from [utils](/python/assassyn/codegen/verilog/utils.md) for type handling - `get_sram_info()` from [utils](/python/assassyn/codegen/verilog/utils.md) for SRAM information extraction - `namify()` and `unwrap_operand()` from [utils](/python/assassyn/utils.md) for name generation and operand handling -- `_format_reduction_expr(predicates, *, default_literal, op="or_")` canonicalises OR/AND-style predicate reductions, emitting caller-provided defaults for empty sequences while allowing any reducer supported by the dumper runtime. -- `_emit_predicate_mux_chain()` centralises predicate-driven mux construction so callers reuse ordering and reduction semantics. +- `predicates.reduce_predicates(predicates, *, default_literal, op="or_")` canonicalises OR/AND-style predicate reductions, emitting caller-provided defaults for empty sequences while allowing any reducer supported by the dumper runtime. +- `predicates.emit_predicate_mux_chain()` centralises predicate-driven mux construction so callers reuse ordering and reduction semantics. The cleanup process is tightly integrated with the [CIRCTDumper](/python/assassyn/codegen/verilog/design.md) class and is called as the final step in module generation to ensure all interconnections are properly established. diff --git a/python/assassyn/codegen/verilog/cleanup.py b/python/assassyn/codegen/verilog/cleanup.py index 7f47601d5..3e18312ed 100644 --- a/python/assassyn/codegen/verilog/cleanup.py +++ b/python/assassyn/codegen/verilog/cleanup.py @@ -1,8 +1,9 @@ """Post-generation cleanup and signal generation for Verilog codegen.""" from collections import defaultdict -from typing import TYPE_CHECKING, Callable, Dict, List, NamedTuple, Optional, Sequence, TypeVar +from typing import TYPE_CHECKING, Dict, List, NamedTuple, Sequence, TypeVar +from .predicates import emit_predicate_mux_chain, reduce_predicates from .utils import dump_type, dump_type_cast, get_sram_info from ...analysis.topo import get_upstreams @@ -52,7 +53,7 @@ def resolve_value_exposure_render(dumper, expr: Expr) -> ValueExposureRender: def generate_sram_control_signals(dumper, sram_info, module_view): """Generate control signals for SRAM memory interface.""" - array = sram_info['array'] + array = sram_info.array writes = list(module_view.writes.get(array, ())) reads = list(module_view.reads.get(array, ())) @@ -89,63 +90,13 @@ def generate_sram_control_signals(dumper, sram_info, module_view): elif read_addr: dumper.append_code(f'self.mem_address = {read_addr}.as_bits()') else: - dumper.append_code(f'self.mem_address = Bits({array.index_bits})(0)') + index_bits = array.index_bits if array.index_bits > 0 else 1 + dumper.append_code(f'self.mem_address = Bits({index_bits})(0)') dumper.append_code(f'self.mem_write_data = {write_data}') dumper.append_code('self.mem_read_enable = Bits(1)(1)') # Always enable reads -def _format_reduction_expr( - predicates: Sequence[str], - *, - default_literal: Optional[str], - op: str = "or_", -) -> str: - """Format a reduction expression with configurable operator and default literal.""" - - if not predicates: - if default_literal is None: - raise ValueError("Cannot build predicate reduction without a default literal") - return default_literal - - if default_literal is None and len(predicates) == 1: - return predicates[0] - - joined = ", ".join(predicates) - if default_literal is None: - return f"reduce({op}, [{joined}])" - - return f"reduce({op}, [{joined}], {default_literal})" - - -def _emit_predicate_mux_chain( - entries: Sequence[T], - *, - render_predicate: Callable[[T], str], - render_value: Callable[[T], str], - default_value: str, - aggregate_predicates: Callable[[Sequence[str]], str], -) -> tuple[str, str]: - """Return both the mux chain and aggregate predicate for *entries*.""" - - predicate_terms = [render_predicate(entry) for entry in entries] - aggregate_expr = aggregate_predicates(predicate_terms) - - if not entries: - return default_value, aggregate_expr - - value_terms = [render_value(entry) for entry in entries] - - if len(value_terms) == 1: - return value_terms[0], aggregate_expr - - mux_expr = default_value - for predicate_expr, value_expr in zip(predicate_terms, value_terms): - mux_expr = f"Mux({predicate_expr}, {mux_expr}, {value_expr})" - - return mux_expr, aggregate_expr - - # pylint: disable=too-many-locals,too-many-branches,too-many-statements,too-many-nested-blocks def cleanup_post_generation(dumper): """generating signals for connecting modules""" @@ -159,7 +110,7 @@ def cleanup_post_generation(dumper): for dep in upstream_modules ] - executed_expr = _format_reduction_expr( + executed_expr = reduce_predicates( dep_signals, default_literal="Bits(1)(0)", ) @@ -169,7 +120,7 @@ def cleanup_post_generation(dumper): if dumper.wait_until: exec_conditions.append(f"({dumper.wait_until})") - executed_expr = _format_reduction_expr( + executed_expr = reduce_predicates( exec_conditions, default_literal="Bits(1)(1)", op="and_", @@ -183,7 +134,7 @@ def cleanup_post_generation(dumper): for finish_site in module_metadata.finish_sites: predicate = dumper.format_predicate(getattr(finish_site, "meta_cond", None)) finish_terms.append(f"({predicate} & executed_wire)") - finish_expr = _format_reduction_expr( + finish_expr = reduce_predicates( finish_terms, default_literal="Bits(1)(0)", ) @@ -229,9 +180,9 @@ def render_array_value( return value_expr def aggregate_array(predicates: Sequence[str]) -> str: - return _format_reduction_expr(predicates, default_literal=None) + return reduce_predicates(predicates, default_literal=None) - wdata_expr, aggregated_predicates = _emit_predicate_mux_chain( + wdata_expr, aggregated_predicates = emit_predicate_mux_chain( module_writes, render_predicate=render_array_predicate, render_value=render_array_value, @@ -250,7 +201,7 @@ def reuse_aggregated( ) -> str: return combined - widx_expr, _ = _emit_predicate_mux_chain( + widx_expr, _ = emit_predicate_mux_chain( module_writes, render_predicate=render_array_predicate, render_value=render_array_index, @@ -302,7 +253,7 @@ def reuse_aggregated( f'({dumper.format_predicate(getattr(entry, "meta_cond", None))})' for entry in grouped_exposures ] - pred_condition = _format_reduction_expr( + pred_condition = reduce_predicates( predicate_terms, default_literal="Bits(1)(1)", ) @@ -344,9 +295,9 @@ def render_fifo_value(entry) -> str: def aggregate_fifo(predicates: Sequence[str]) -> str: wrapped = [f"({term})" for term in predicates] - return _format_reduction_expr(wrapped, default_literal="Bits(1)(0)") + return reduce_predicates(wrapped, default_literal="Bits(1)(0)") - fifo_data_expr, fifo_predicate_expr = _emit_predicate_mux_chain( + fifo_data_expr, fifo_predicate_expr = emit_predicate_mux_chain( local_pushes, render_predicate=render_fifo_predicate, render_value=render_fifo_value, @@ -371,7 +322,7 @@ def aggregate_fifo(predicates: Sequence[str]) -> str: f'({dumper.dump_rval(getattr(entry, "meta_cond", None), False)})' for entry in local_pops ] - final_pop_condition = _format_reduction_expr( + final_pop_condition = reduce_predicates( pop_predicates, default_literal="Bits(1)(0)", ) @@ -395,7 +346,8 @@ def aggregate_fifo(predicates: Sequence[str]) -> str: dumper.append_code(f'# External output exposure: {source_expr}') dumper.append_code(f'self.expose_{output_name} = {source_expr}') # Include the condition predicate for the valid signal if available - condition = data.get('condition', 'Bits(1)(1)') + predicate = data.get('meta_cond') + condition = dumper.format_predicate(predicate) dumper.append_code(f'self.valid_{output_name} = executed_wire & ({condition})') dumper.append_code('self.executed = executed_wire') diff --git a/python/assassyn/codegen/verilog/design.md b/python/assassyn/codegen/verilog/design.md index 827a10f5a..c91fbee2f 100644 --- a/python/assassyn/codegen/verilog/design.md +++ b/python/assassyn/codegen/verilog/design.md @@ -58,6 +58,14 @@ symbols while housing their implementations across `metadata.core`, `metadata.mo 4. **Stage Register Management**: Stage registers are managed for pipeline state 5. **Asynchronous Call Handling**: Asynchronous calls are handled through the pipeline +**Helper Structure (Post 2024-03 refresh):** + +1. **Predicate Utilities**: Reusable predicate reducers and mux-threading helpers now live in `python/assassyn/codegen/verilog/predicates.py`. Both the dumper cleanup pass and the top-level harness delegate to these utilities so that enable aggregation and prioritised mux chains stay consistent. +2. **Top-Level Emitters**: `python/assassyn/codegen/verilog/top.py` exposes a small set of typed helpers (`_emit_sram_blackboxes`, `_declare_fifo_wires`, `_emit_trigger_counters`, `_instantiate_modules`, `_connect_cross_module_wires`) that each focus on a single concern. `generate_top_harness` orchestrates these helpers while keeping side effects explicit and unit testable. +3. **Elaboration Resource Planning**: The elaboration entry point builds a `ResourceCopyPlan` dataclass which records helper files, alias modules, and external sources. File-copy helpers consume this plan instead of receiving loosely typed lists, producing deterministic output. +4. **SRAM Metadata Access**: SRAM-related helpers return structured `SRAMInfo` objects with named fields instead of bare dictionaries. The `enforce_type` decorator ensures these helpers are only invoked with known SRAM nodes. +5. **Testbench Template API**: The testbench generator consumes a `TestbenchTemplateConfig` structure that captures run thresholds, log snippets, and auxiliary sources. This replaces ad-hoc string concatenation and simplifies deterministic testing of the emitted Python harness. + **Function Name Inconsistencies (Documented as Potential Improvements):** The Verilog design generation has some function names that don't match their actual implementation: 1. **`cleanup_post_generation`**: Actually generates signal routing, not cleanup @@ -112,12 +120,13 @@ The CIRCTDumper class is the main visitor that converts Assassyn IR into Verilog 1. **Execution Control**: `wait_until` and per-expression `meta_cond` metadata decide when statements run, while FINISH gating now reads the precomputed `finish_sites` stored in module metadata instead of collecting tuples during emission. 2. **Module State**: `current_module` tracks traversal context, while port declarations are derived from immutable metadata instead of mutating dumper dictionaries. 3. **Array Management**: `array_metadata`, `memory_defs`, and ownership metadata ensure multi-port register arrays are emitted while memory payloads (`array.is_payload(memory)` returning `True`) are routed through dedicated generators. -4. **External Integration**: `external_metadata` (an `ExternalRegistry`) captures external classes, instance ownership, and cross-module reads. Runtime maps (`external_wrapper_names`, `external_instance_names`, `external_wire_assignments`, `external_wire_outputs`, and `external_output_exposures`) reuse that registry to materialise expose/valid ports and wire consumers to producers without recomputing analysis. -5. **Expression Naming**: `expr_to_name` and `name_counters` guarantee deterministic signal names whenever expression results must be reused across statements. -6. **Code Generation**: `code`, `logs`, and `indent` store emitted lines and diagnostic information used later by the testbench. -7. **Module Metadata**: `module_metadata` maps each `Module` to its `ModuleMetadata`. The structure tracks FINISH intrinsics, async calls, FIFO interactions (annotated with `expr.meta_cond`), and every array/value exposure required for cleanup. These entries are populated before the dumper is constructed via [`collect_fifo_metadata`](./analysis.md), so `CIRCTDumper` receives a frozen snapshot and never mutates it during emission. See [metadata module](/python/assassyn/codegen/verilog/metadata.md) for details. The dumper exposes this information via convenience helpers such as `async_callers(module)`, which forwards to the frozen `AsyncLedger` stored on the interaction matrix. +4. **External Integration**: `external_metadata` (an `ExternalRegistry`) captures external classes, instance ownership, and cross-module reads. Runtime maps (`external_wrapper_names`, `external_instance_names`, and `external_output_exposures`) reuse that registry to materialise expose/valid ports and wire consumers to producers without recomputing analysis; cross-module wiring is now derived directly from the registry when emitting the top-level harness. +5. **Predicate Preservation**: `external_output_exposures` caches each external output’s `Expr.meta_cond` so cleanup and module emitters can feed it through `format_predicate()` without re-resolving predicate chains or losing guard information. +6. **Expression Naming**: `expr_to_name` and `name_counters` guarantee deterministic signal names whenever expression results must be reused across statements. +7. **Code Generation**: `code`, `logs`, and `indent` store emitted lines and diagnostic information used later by the testbench. +8. **Module Metadata**: `module_metadata` maps each `Module` to its `ModuleMetadata`. The structure tracks FINISH intrinsics, async calls, FIFO interactions (annotated with `expr.meta_cond`), and every array/value exposure required for cleanup. These entries are populated before the dumper is constructed via [`collect_fifo_metadata`](./analysis.md), so `CIRCTDumper` receives a frozen snapshot and never mutates it during emission. See [metadata module](/python/assassyn/codegen/verilog/metadata.md) for details. The dumper exposes this information via convenience helpers such as `async_callers(module)`, which forwards to the frozen `AsyncLedger` stored on the interaction matrix. -During the cleanup pass the dumper feeds the precomputed metadata into `_emit_predicate_mux_chain`, producing both the `reduce(or_, …)` guards and prioritised mux chains shared by array writes and FIFO pushes. The helper now short-circuits single-entry collections to direct assignments and relies on caller-supplied defaults when metadata yields no interactions, keeping the emitted Verilog stable if predicate formatting or default literals change in the future. +During the cleanup pass the dumper feeds the precomputed metadata into `predicates.emit_predicate_mux_chain`, producing both the `reduce(or_, …)` guards and prioritised mux chains shared by array writes and FIFO pushes. The helper now short-circuits single-entry collections to direct assignments and relies on caller-supplied defaults when metadata yields no interactions, keeping the emitted Verilog stable if predicate formatting or default literals change in the future. #### Key Methods @@ -140,7 +149,7 @@ During the cleanup pass the dumper feeds the precomputed metadata into `_emit_pr **`visit_block`**: Visits conditional and cycled blocks, relying on the IR-level `meta_cond` metadata captured during construction to keep predicates aligned across code generation, metadata collection, and log emission. -**`get_pred(expr)`**: Formats the predicate metadata attached to `expr`. The dumper consumes the final carry exposed via `expr.meta_cond`, and expressions that lack `meta_cond` now trigger an explicit error so refactors cannot silently drop predicate capture. +**`format_predicate(predicate)`**: Normalises a predicate `Expr` (typically sourced from `expr.meta_cond`) into a `Bits(1)` Verilog expression. The helper accepts `None` and falls back to the canonical literal `Bits(1)(1)` so call sites can uniformly request formatting without open-coding default literals. **`get_external_port_name`**: Creates mangled port names for external values to avoid naming conflicts diff --git a/python/assassyn/codegen/verilog/design.py b/python/assassyn/codegen/verilog/design.py index 147f4ed49..e55a936d1 100644 --- a/python/assassyn/codegen/verilog/design.py +++ b/python/assassyn/codegen/verilog/design.py @@ -64,10 +64,6 @@ def __init__( self.memory_defs = set() self.expr_to_name = {} self.name_counters = defaultdict(int) - # Track external module wiring during emission - self.external_wire_assignments = [] - self.external_wire_assignment_keys = set() - self.external_wire_outputs = {} self.external_output_exposures = defaultdict(dict) self.external_wrapper_names = {} self.external_instance_names = {} @@ -81,10 +77,6 @@ def __init__( if not self.external_metadata.frozen: self.external_metadata.freeze() - def get_pred(self, expr: Expr) -> str: - """Format the predicate guarding *expr* (or return the default literal).""" - return self.format_predicate(expr.meta_cond) - def format_predicate(self, predicate: Optional[Expr]) -> str: """Format a predicate value as a Bits expression.""" if predicate is None: @@ -357,9 +349,9 @@ def generate_design(fname: Union[str, Path], sys: SysBuilder) -> None: if sram_modules: for sram in sram_modules: params = extract_sram_params(sram) - array_name = params['array_name'] - data_width = params['data_width'] - addr_width = params['addr_width'] + array_name = params.array_name + data_width = params.data_width + addr_width = params.addr_width dumper.memory_defs.add((data_width, addr_width, array_name)) # Write sramBlackbox module definitions diff --git a/python/assassyn/codegen/verilog/elaborate.md b/python/assassyn/codegen/verilog/elaborate.md index c3caf297c..c5115c920 100644 --- a/python/assassyn/codegen/verilog/elaborate.md +++ b/python/assassyn/codegen/verilog/elaborate.md @@ -54,7 +54,8 @@ This function is the main entry point for Verilog code generation, orchestrating 4. **Alias Discovery**: If a previous `Top.sv` exists, scans it for parameterised module aliases (e.g. `fifo_1`) so matching resource files can be cloned. 5. **Testbench Generation**: Calls `generate_testbench()` with the discovered alias list and external file names, ensuring the Cocotb harness imports every required HDL artifact. 6. **SRAM Blackbox Generation**: Invokes `generate_sram_blackbox_files()` so each SRAM downstream module receives a behavioural blackbox wrapper. -7. **Resource File Management**: Copies core support files (`fifo.sv`, `trigger_counter.sv`), materialises alias copies when required, and copies user-supplied SystemVerilog sources (resolving relative paths via `repo_path()`). +7. **Resource Planning**: Builds a `ResourceCopyPlan` dataclass that records core helper files, alias clones, external HDL sources, and SRAM payload metadata. The plan is passed to all copy helpers so ordering is deterministic and logging is centralised. +8. **Resource File Management**: Copies core support files (`fifo.sv`, `trigger_counter.sv`), materialises alias copies when required, and copies user-supplied SystemVerilog sources (resolving relative paths via `repo_path()`). The function handles complex file management: @@ -99,6 +100,7 @@ The generated wrappers provide a behavioural memory model suitable for simulatio The module uses several utility functions: - `extract_sram_params()` from [utils module](/python/assassyn/codegen/verilog/utils.md) for SRAM parameter extraction +- `ResourceCopyPlan` and `CopyAction` dataclasses that describe which resources should be materialised and how; these objects replace loose tuples and make the copy orchestration unit-testable. - `create_dir()` from [utils module](/python/assassyn/utils.md) for directory management - `repo_path()` from [utils module](/python/assassyn/utils.md) for repository path resolution - `generate_design()` from [design module](/python/assassyn/codegen/verilog/design.md) for main design generation @@ -108,7 +110,7 @@ The elaboration process handles several file types: - **Design Files**: Main Verilog design generated by CIRCTDumper - **Testbench Files**: Python-based Cocotb testbenches -- **Resource Files**: FIFO and trigger counter templates +- **Resource Files**: FIFO and trigger counter templates recorded in the copy plan - **External Files**: User-provided SystemVerilog modules - **SRAM Files**: Generated memory blackbox modules - **Alias Files**: Parameterized module aliases diff --git a/python/assassyn/codegen/verilog/elaborate.py b/python/assassyn/codegen/verilog/elaborate.py index 1582f7c87..11d1e62fa 100644 --- a/python/assassyn/codegen/verilog/elaborate.py +++ b/python/assassyn/codegen/verilog/elaborate.py @@ -1,155 +1,289 @@ +# pylint: disable=too-many-locals """Elaborate Assassyn IR to Verilog.""" +from __future__ import annotations + import os import re -from pathlib import Path import shutil -from .testbench import generate_testbench +from dataclasses import dataclass, field +from pathlib import Path +from textwrap import dedent +from typing import Iterable, Sequence + from .design import generate_design -from ...ir.memory.sram import SRAM +from .testbench import TestbenchTemplateConfig, generate_testbench from .utils import extract_sram_params - from ...builder import SysBuilder +from ...ir.memory.sram import SRAM from ...utils import create_dir, repo_path +from ...utils.enforce_type import enforce_type from ..simulator.external import collect_external_intrinsics -def _collect_external_sources(sys): +@dataclass(frozen=True) +class AliasResource: + """Alias module emitted by CIRCT that should mirror a core helper.""" + + source: str + alias: str + + +@dataclass(frozen=True) +class CopyAction: + """Concrete file copy to be materialised.""" + + source: Path + destination: Path + + def ensure_parent(self) -> None: + """Ensure the destination directory exists before copying.""" + self.destination.parent.mkdir(parents=True, exist_ok=True) + + +@dataclass +class ResourceCopyPlan: + """Deterministic plan describing the resources to materialise.""" + + destination: Path + core_helpers: Sequence[str] = field(default_factory=tuple) + alias_resources: Sequence[AliasResource] = field(default_factory=tuple) + external_sources: Sequence[Path] = field(default_factory=tuple) + + def core_actions(self, resource_root: Path) -> Iterable[CopyAction]: + """Yield copy actions for built-in helper files.""" + for helper in self.core_helpers: + yield CopyAction(resource_root / helper, self.destination / helper) + + def alias_actions(self, resource_root: Path) -> Iterable[tuple[AliasResource, CopyAction]]: + """Yield alias copy actions paired with their metadata.""" + for alias in self.alias_resources: + source = resource_root / alias.source + destination = self.destination / f"{alias.alias}.sv" + yield alias, CopyAction(source, destination) + + def external_actions(self) -> Iterable[CopyAction]: + """Yield copy actions for external SystemVerilog sources.""" + for src_path in self.external_sources: + yield CopyAction(src_path, self.destination / src_path.name) + + +CORE_HELPERS: tuple[str, ...] = ("fifo.sv", "trigger_counter.sv") + + +@enforce_type +def _collect_external_sources(sys: SysBuilder) -> set[Path]: """Gather SystemVerilog source files referenced by external intrinsics.""" - sources = set() + sources: set[Path] = set() + repo_root = Path(repo_path()) for intrinsic in collect_external_intrinsics(sys): source = intrinsic.external_class.metadata().get('source') if source: - sources.add(source) + src_path = Path(source) + if not src_path.is_absolute(): + src_path = repo_root / source + sources.add(src_path) return sources -def _resolve_alias_resources(top_sv_path: Path, files_to_copy): +@enforce_type +def _resolve_alias_resources( + top_sv_path: Path, + files_to_copy: Sequence[str], +) -> list[AliasResource]: """Infer CIRCT-generated aliases that need duplicate resource files.""" if not top_sv_path.exists(): return [] - alias_resource_files = [] + alias_resource_files: list[AliasResource] = [] top_content = top_sv_path.read_text(encoding='utf-8') for resource_file in files_to_copy: base_module = Path(resource_file).stem pattern = rf"\b{base_module}_(\d+)\b" - for suffix in set(re.findall(pattern, top_content)): + matches = sorted(set(re.findall(pattern, top_content))) + for suffix in matches: alias_module = f"{base_module}_{suffix}" - alias_resource_files.append((resource_file, alias_module)) + alias_resource_files.append(AliasResource(resource_file, alias_module)) return alias_resource_files -def _copy_core_resources(resource_path: Path, destination: Path, files_to_copy): +@enforce_type +def _prepare_resource_plan( + output_dir: Path, + sys: SysBuilder, + log_lines: Sequence[str], + sim_threshold: int, +) -> tuple[ResourceCopyPlan, TestbenchTemplateConfig]: + """Build the resource copy plan and matching testbench configuration.""" + + external_sources = tuple(sorted( + _collect_external_sources(sys), + key=lambda path_entry: path_entry.as_posix(), + )) + + top_sv_path = output_dir / "sv" / "hw" / "Top.sv" + alias_resources = _resolve_alias_resources(top_sv_path, CORE_HELPERS) + + additional_files = sorted({ + *(src.name for src in external_sources), + *(f"{alias.alias}.sv" for alias in alias_resources), + }) + + copy_plan = ResourceCopyPlan( + destination=output_dir, + core_helpers=CORE_HELPERS, + alias_resources=alias_resources, + external_sources=external_sources, + ) + + testbench_config = TestbenchTemplateConfig( + sim_threshold=sim_threshold, + log_lines=log_lines, + extra_sources=additional_files, + ) + + return copy_plan, testbench_config + + +def _copy_core_resources(resource_root: Path, plan: ResourceCopyPlan) -> None: """Copy standard SV helper files used by the testbench.""" - for file_name in files_to_copy: - source_file = resource_path / file_name - if source_file.is_file(): - destination_file = destination / file_name - shutil.copy(source_file, destination_file) - else: - print(f"Warning: Resource file not found: {source_file}") + for action in plan.core_actions(resource_root): + if not action.source.is_file(): + print(f"Warning: Resource file not found: {action.source}") + continue + action.ensure_parent() + shutil.copy(action.source, action.destination) -def _copy_alias_resources(resource_path: Path, destination: Path, alias_resource_files): +def _copy_alias_resources(resource_root: Path, plan: ResourceCopyPlan) -> None: """Materialize alias modules emitted by CIRCT to keep resource names in sync.""" - for base_file, alias_module in alias_resource_files: - source_file = resource_path / base_file - if not source_file.is_file(): - print(f"Warning: Cannot create alias for missing resource: {source_file}") + for alias, action in plan.alias_actions(resource_root): + if action.destination.exists(): continue - - alias_path = destination / f"{alias_module}.sv" - if alias_path.exists(): + if not action.source.is_file(): + print(f"Warning: Cannot create alias for missing resource: {action.source}") continue - content = source_file.read_text(encoding='utf-8') - base_module = Path(base_file).stem - alias_content = content.replace(f"module {base_module}", f"module {alias_module}", 1) - alias_path.write_text(alias_content, encoding='utf-8') - print(f"Copied {source_file} to {alias_path}") + content = action.source.read_text(encoding='utf-8') + base_module = Path(alias.source).stem + alias_content = content.replace(f"module {base_module}", f"module {alias.alias}", 1) + action.ensure_parent() + action.destination.write_text(alias_content, encoding='utf-8') + print(f"Copied {action.source} to {action.destination}") -def _copy_external_sources(external_sources, destination: Path): +def _copy_external_sources(plan: ResourceCopyPlan) -> None: """Copy user-provided SystemVerilog sources into the elaboration output.""" - for file_name in external_sources: - src_path = Path(file_name) - if not src_path.is_absolute(): - src_path = Path(repo_path()) / file_name - - if src_path.is_file(): - destination_file = destination / src_path.name - shutil.copy(src_path, destination_file) - print(f"Copied {src_path} to {destination_file}") - else: - print(f"Warning: External resource file not found: {src_path}") + for action in plan.external_actions(): + if not action.source.is_file(): + print(f"Warning: External resource file not found: {action.source}") + continue + action.ensure_parent() + shutil.copy(action.source, action.destination) + print(f"Copied {action.source} to {action.destination}") -def generate_sram_blackbox_files(sys, path, resource_base=None): +@enforce_type +def generate_sram_blackbox_files( + sys: SysBuilder, + path: Path, + resource_base: str | Path | None = None, +) -> None: """Generate separate Verilog files for SRAM memory blackboxes.""" - sram_modules = [m for m in sys.downstreams if isinstance(m, SRAM)] + + output_dir = Path(path) + output_dir.mkdir(parents=True, exist_ok=True) + init_root = Path(resource_base) if resource_base is not None else None + + sram_modules = sorted( + (module for module in sys.downstreams if isinstance(module, SRAM)), + key=lambda module: module.name, + ) + for sram in sram_modules: params = extract_sram_params(sram) - sram_info = params['sram_info'] - array_name = params['array_name'] - data_width = params['data_width'] - addr_width = params['addr_width'] - verilog_code = f'''`ifdef SYNTHESIS -(* blackbox *) -`endif -module sram_blackbox_{array_name} #( - parameter DATA_WIDTH = {data_width}, - parameter ADDR_WIDTH = {addr_width} -)( - input clk, - input [ADDR_WIDTH-1:0] address, - input [DATA_WIDTH-1:0] wd, - input banksel, - input read, - input write, - output reg [DATA_WIDTH-1:0] dataout, - input rst_n -); - - localparam DEPTH = 1 << ADDR_WIDTH; - reg [DATA_WIDTH-1:0] mem [DEPTH-1:0]; -''' - - if sram_info['init_file']: - init_file = sram_info['init_file'] - src_file = os.path.join(resource_base, init_file) if resource_base else init_file - verilog_code += f''' - initial begin - $readmemh("{src_file}", mem); - end - - always @ (posedge clk) begin -''' + sram_info = params.info + array_name = params.array_name + data_width = params.data_width + addr_width = params.addr_width + + init_file = sram_info.init_file + init_path = None + if init_file: + init_path = Path(init_file) + if init_root is not None and not init_path.is_absolute(): + init_path = init_root / init_path + + header = dedent( + f""" + `ifdef SYNTHESIS + (* blackbox *) + `endif + module sram_blackbox_{array_name} #( + parameter DATA_WIDTH = {data_width}, + parameter ADDR_WIDTH = {addr_width} + )( + input clk, + input [ADDR_WIDTH-1:0] address, + input [DATA_WIDTH-1:0] wd, + input banksel, + input read, + input write, + output reg [DATA_WIDTH-1:0] dataout, + input rst_n + ); + + localparam DEPTH = 1 << ADDR_WIDTH; + reg [DATA_WIDTH-1:0] mem [DEPTH-1:0]; + """ + ).strip() + + body_lines = [header] + if init_path is not None: + body_lines.append( + dedent( + f""" + initial begin + $readmemh("{init_path.as_posix()}", mem); + end + + always @ (posedge clk) begin + """ + ).rstrip() + ) else: - verilog_code += ''' - always @ (posedge clk) begin - if (!rst_n) begin - mem[address] <= {{DATA_WIDTH{{1'b0}}}}; - end -''' - verilog_code += ''' - if (write & banksel) begin - mem[address] <= wd; - end - end - - assign dataout = (read & banksel) ? mem[address] : {DATA_WIDTH{1'b0}}; - -endmodule -''' - - filename = os.path.join(path, f'sram_blackbox_{array_name}.sv') - with open(filename, 'w', encoding='utf-8') as f: - f.write(verilog_code) - - -# pylint: disable=too-many-locals,too-many-branches -def elaborate(sys: SysBuilder, **kwargs) -> str: + body_lines.append( + dedent( + """ + always @ (posedge clk) begin + if (!rst_n) begin + mem[address] <= {DATA_WIDTH{1'b0}}; + end + """ + ).rstrip() + ) + + body_lines.append( + dedent( + """ + if (write & banksel) begin + mem[address] <= wd; + end + end + + assign dataout = (read & banksel) ? mem[address] : {DATA_WIDTH{1'b0}}; + + endmodule + """ + ).strip() + ) + + blackbox_source = "\n".join(body_lines) + "\n" + output_path = output_dir / f"sram_blackbox_{array_name}.sv" + output_path.write_text(blackbox_source, encoding="utf-8") + + +def elaborate(sys: SysBuilder, **kwargs) -> str: # pylint: disable=too-many-locals,too-many-branches """Elaborate the system into Verilog. Args: @@ -172,32 +306,21 @@ def elaborate(sys: SysBuilder, **kwargs) -> str: create_dir(path) - external_sources = _collect_external_sources(sys) - external_file_names = sorted({Path(file_name).name for file_name in external_sources}) - logs = generate_design(path / "design.py", sys) - files_to_copy = ["fifo.sv", "trigger_counter.sv"] - top_sv_path = path / "sv" / "hw" / "Top.sv" - alias_resource_files = _resolve_alias_resources(top_sv_path, files_to_copy) - - additional_files = sorted( - set(external_file_names + [f"{alias}.sv" for _, alias in alias_resource_files]) - ) - - generate_testbench( - path / "tb.py", + copy_plan, testbench_config = _prepare_resource_plan( + path, sys, - kwargs['sim_threshold'], logs, - additional_files + kwargs['sim_threshold'], ) + generate_testbench(path / "tb.py", testbench_config) - default_home = os.getenv('ASSASSYN_HOME', os.getcwd()) - resource_path = Path(default_home) / "python/assassyn/codegen/verilog" + default_home = Path(os.getenv('ASSASSYN_HOME', os.getcwd())) + resource_path = default_home / "python/assassyn/codegen/verilog" generate_sram_blackbox_files(sys, path, kwargs.get('resource_base')) - _copy_core_resources(resource_path, path, files_to_copy) - _copy_alias_resources(resource_path, path, alias_resource_files) - _copy_external_sources(external_sources, path) + _copy_core_resources(resource_path, copy_plan) + _copy_alias_resources(resource_path, copy_plan) + _copy_external_sources(copy_plan) return path diff --git a/python/assassyn/codegen/verilog/module.py b/python/assassyn/codegen/verilog/module.py index a217dc93c..8a76acdf7 100644 --- a/python/assassyn/codegen/verilog/module.py +++ b/python/assassyn/codegen/verilog/module.py @@ -22,6 +22,7 @@ def generate_module_ports(dumper, node: Module) -> None: """ is_downstream = isinstance(node, Downstream) is_sram = isinstance(node, SRAM) + sram_info = get_sram_info(node) if is_sram else None async_callers = list(dumper.async_callers(node)) is_driver = not async_callers @@ -41,16 +42,14 @@ def generate_module_ports(dumper, node: Module) -> None: upstream_modules = sorted(get_upstreams(node), key=lambda mod: mod.name) for dep_mod in upstream_modules: dumper.append_code(f'{namify(dep_mod.name)}_executed = Input(Bits(1))') - if is_sram: - sram_info = get_sram_info(node) - if sram_info: - sram_array = sram_info['array'] - dumper.append_code(f'mem_dataout = Input({dump_type(sram_array.scalar_ty)})') - index_bits = sram_array.index_bits if sram_array.index_bits > 0 else 1 - dumper.append_code(f'mem_address = Output(Bits({index_bits}))') - dumper.append_code(f'mem_write_data = Output({dump_type(sram_array.scalar_ty)})') - dumper.append_code('mem_write_enable = Output(Bits(1))') - dumper.append_code('mem_read_enable = Output(Bits(1))') + if is_sram and sram_info is not None: + sram_array = sram_info.array + dumper.append_code(f'mem_dataout = Input({dump_type(sram_array.scalar_ty)})') + index_bits = sram_array.index_bits if sram_array.index_bits > 0 else 1 + dumper.append_code(f'mem_address = Output(Bits({index_bits}))') + dumper.append_code(f'mem_write_data = Output({dump_type(sram_array.scalar_ty)})') + dumper.append_code('mem_write_enable = Output(Bits(1))') + dumper.append_code('mem_read_enable = Output(Bits(1))') elif is_driver or async_callers: dumper.append_code('trigger_counter_pop_valid = Input(Bits(1))') @@ -121,9 +120,8 @@ def generate_module_ports(dumper, node: Module) -> None: # pylint: disable=too-many-nested-blocks for arr_container in dumper.sys.arrays: arr = arr_container - if is_sram: - sram_info = get_sram_info(node) - if sram_info and arr == sram_info['array']: + if is_sram and sram_info is not None: + if arr == sram_info.array: continue metadata = dumper.array_metadata.metadata_for(arr) if metadata is None: diff --git a/python/assassyn/codegen/verilog/predicates.py b/python/assassyn/codegen/verilog/predicates.py new file mode 100644 index 000000000..a2aa37040 --- /dev/null +++ b/python/assassyn/codegen/verilog/predicates.py @@ -0,0 +1,64 @@ +"""Shared predicate utilities for the Verilog backend.""" + +from __future__ import annotations + +from typing import Callable, Sequence, Tuple, TypeVar + +from ...utils import enforce_type + +__all__ = ["emit_predicate_mux_chain", "reduce_predicates"] + +T = TypeVar("T") + + +@enforce_type +def reduce_predicates( + predicates: Sequence[str], + *, + default_literal: str | None, + op: str = "or_", +) -> str: + """Format a reduction expression with configurable operator and default literal.""" + + if not predicates: + if default_literal is None: + raise ValueError("Cannot build predicate reduction without a default literal") + return default_literal + + if default_literal is None and len(predicates) == 1: + return predicates[0] + + joined = ", ".join(predicates) + if default_literal is None: + return f"reduce({op}, [{joined}])" + + return f"reduce({op}, [{joined}], {default_literal})" + + +@enforce_type +def emit_predicate_mux_chain( + entries: Sequence[T], + *, + render_predicate: Callable[[T], str], + render_value: Callable[[T], str], + default_value: str, + aggregate_predicates: Callable[[Sequence[str]], str], +) -> Tuple[str, str]: + """Return both the mux chain and aggregate predicate for *entries*.""" + + predicate_terms = [render_predicate(entry) for entry in entries] + aggregate_expr = aggregate_predicates(predicate_terms) + + if not entries: + return default_value, aggregate_expr + + value_terms = [render_value(entry) for entry in entries] + + if len(value_terms) == 1: + return value_terms[0], aggregate_expr + + mux_expr = default_value + for predicate_expr, value_expr in zip(predicate_terms, value_terms): + mux_expr = f"Mux({predicate_expr}, {mux_expr}, {value_expr})" + + return mux_expr, aggregate_expr diff --git a/python/assassyn/codegen/verilog/system.md b/python/assassyn/codegen/verilog/system.md index cff57e6fd..170cdf927 100644 --- a/python/assassyn/codegen/verilog/system.md +++ b/python/assassyn/codegen/verilog/system.md @@ -47,7 +47,7 @@ Before `generate_system` runs, the caller (typically [`generate_design`](./desig - **Array User Analysis**: Populates the registry with every module that reads or writes each array by iterating the flattened `module.body` lists directly, so downstream passes can query a single source of truth without relying on dumper-specific helpers. 3. **Module Analysis Phase**: - - **External Wiring**: Records which exposed values flow across module boundaries so the top-level harness can declare and route the corresponding wires; legacy `external_wire_assignments` have been retired in favour of the intrinsic-driven bookkeeping, which now includes both consumer-side port declarations and producer-side exposure planning. Async-call and dependency information are now read back from the frozen metadata when needed rather than re-collecting them here. + - **External Wiring**: Relies on the frozen `ExternalRegistry` to determine which exposed values flow across module boundaries; the top-level harness now queries the registry directly instead of consuming dumper-maintained assignment lists, while producer-side exposure planning continues to leverage `external_output_exposures`. Async-call and dependency information are read back from the frozen metadata when needed rather than re-collecting them here. 4. **Module Generation Phase**: - **Regular Module Generation**: Generates code for all recorded modules; pure external stubs are filtered out earlier when collecting external intrinsic metadata. diff --git a/python/assassyn/codegen/verilog/system.py b/python/assassyn/codegen/verilog/system.py index bc2763e65..61c24f8aa 100644 --- a/python/assassyn/codegen/verilog/system.py +++ b/python/assassyn/codegen/verilog/system.py @@ -25,9 +25,6 @@ def generate_system(dumper: CIRCTDumper, node: SysBuilder): dumper.sys = sys dumper.external_output_exposures.clear() - dumper.external_wire_assignments.clear() - dumper.external_wire_assignment_keys.clear() - dumper.external_wire_outputs.clear() dumper.external_instance_names.clear() dumper.external_wrapper_names.clear() diff --git a/python/assassyn/codegen/verilog/testbench.md b/python/assassyn/codegen/verilog/testbench.md index a4c7cfb85..52f4d2a3f 100644 --- a/python/assassyn/codegen/verilog/testbench.md +++ b/python/assassyn/codegen/verilog/testbench.md @@ -11,8 +11,8 @@ The testbench generation module creates Python-based testbenches using the Cocot ### `generate_testbench` ```python -def generate_testbench(fname: Union[str, Path], _sys: SysBuilder, sim_threshold: int, - dump_logger: List[str], external_files: List[str]): +@enforce_type +def generate_testbench(fname: Union[str, Path], config: TestbenchTemplateConfig) -> None: """Generate a testbench file for the given system.""" ``` @@ -21,9 +21,13 @@ def generate_testbench(fname: Union[str, Path], _sys: SysBuilder, sim_threshold: This function generates a complete Cocotb-based testbench for Verilog simulation. It performs the following steps: 1. **Template Processing**: Uses a predefined template to generate the testbench code + while substituting the values stored in `TestbenchTemplateConfig`. 2. **Log Integration**: Embeds the generated log statements from the design generation -3. **Source File Management**: Includes all necessary source files for simulation -4. **Simulation Control**: Sets up proper simulation parameters and control flow + (`config.log_lines`), preserving their order for deterministic tests. +3. **Source File Management**: Includes all necessary source files for simulation, + combining core HDL resources with `config.extra_sources`. +4. **Simulation Control**: Sets up proper simulation parameters and control flow using + `config.sim_threshold` and the clock/reset defaults captured in the template. The generated testbench includes: @@ -40,7 +44,8 @@ The testbench template handles: - **Reset Sequence**: Active-high reset for 500ns followed by normal operation - **Simulation Control**: Runs for the specified number of cycles or until finish - **Source File Management**: Includes all necessary Verilog source files -- **External File Support**: Includes additional external SystemVerilog files +- **External File Support**: Includes additional external SystemVerilog files supplied in + `config.extra_sources` **Project-specific Knowledge Required**: - Understanding of [Cocotb framework](https://docs.cocotb.org/) for Python-based verification @@ -50,13 +55,18 @@ The testbench template handles: ## Internal Constants -### `TEMPLATE` +### `TestbenchTemplateConfig` and `TEMPLATE` -The `TEMPLATE` constant contains the complete Cocotb testbench template with placeholders for: +`TestbenchTemplateConfig` is a dataclass that captures the information required to render +the template: -- **Simulation Threshold**: `{}` - Maximum number of simulation cycles -- **Log Statements**: `{}` - Generated log statements from the design -- **External Files**: `{}` - Additional external SystemVerilog files +- **sim_threshold**: Maximum number of simulation cycles +- **log_lines**: Ordered log statements emitted by `CIRCTDumper` +- **extra_sources**: Additional external SystemVerilog files +- **output_dir**: Optional override for the runner’s HDL path (defaults to `sv/hw`) + +The `TEMPLATE` constant contains the complete Cocotb testbench template with placeholders +that `generate_testbench` fills via `str.format`. The template includes: diff --git a/python/assassyn/codegen/verilog/testbench.py b/python/assassyn/codegen/verilog/testbench.py index 0dc9fa41d..c50c99d97 100644 --- a/python/assassyn/codegen/verilog/testbench.py +++ b/python/assassyn/codegen/verilog/testbench.py @@ -1,61 +1,88 @@ """Testbench generation for Verilog simulation.""" -from typing import List, Union -from pathlib import Path -from ...builder import SysBuilder +from __future__ import annotations -TEMPLATE = ''' -import os -import glob +from dataclasses import dataclass from pathlib import Path +from typing import Sequence, Union +from textwrap import dedent + +from ...utils.enforce_type import enforce_type + + +@dataclass(frozen=True) +class TestbenchTemplateConfig: + """Structured parameters required to render the Cocotb testbench.""" + + sim_threshold: int + log_lines: Sequence[str] + extra_sources: Sequence[str] + output_dir: Union[str, Path] = Path("./sv/hw") -import cocotb -from cocotb.triggers import Timer -from cocotb.runner import get_runner +TEMPLATE = dedent( + """ + import os + import glob + from pathlib import Path + import cocotb + from cocotb.triggers import Timer + from cocotb.runner import get_runner -@cocotb.test() -async def test_tb(dut): - dut.clk.value = 1 - dut.rst.value = 1 - await Timer(500, units="ns") - dut.clk.value = 0 - dut.rst.value = 0 - await Timer(500, units="ns") - for cycle in range({}): + @cocotb.test() + async def test_tb(dut): + dut.clk.value = 1 + dut.rst.value = 1 await Timer(500, units="ns") dut.clk.value = 0 + dut.rst.value = 0 await Timer(500, units="ns") - {} - if dut.global_finish.value == 1: - break + for cycle in range({threshold}): + dut.clk.value = 1 + await Timer(500, units="ns") + dut.clk.value = 0 + await Timer(500, units="ns") + {log_block} + if dut.global_finish.value == 1: + break + + def runner(): + sim = 'verilator' + path = Path('{output_dir}') + with open(path / 'filelist.f', 'r') as f: + srcs = [path / i.strip() for i in f.readlines()] + sram_blackbox_files = glob.glob('sram_blackbox_*.sv') + srcs = srcs + sram_blackbox_files + srcs = srcs + ['fifo.sv', 'trigger_counter.sv'{extras}] + runner = get_runner(sim) + runner.build(sources=srcs, hdl_toplevel='Top', always=True) + runner.test(hdl_toplevel='Top', test_module='tb') -def runner(): - sim = 'verilator' - path = Path('./sv/hw') - with open(path / 'filelist.f', 'r') as f: - srcs = [path / i.strip() for i in f.readlines()] - sram_blackbox_files = glob.glob('sram_blackbox_*.sv') - srcs = srcs + sram_blackbox_files - srcs = srcs + ['fifo.sv', 'trigger_counter.sv'{}] - runner = get_runner(sim) - runner.build(sources=srcs, hdl_toplevel='Top', always=True) - runner.test(hdl_toplevel='Top', test_module='tb') + if __name__ == "__main__": + runner() + """ +).strip() -if __name__ == "__main__": - runner()''' -def generate_testbench(fname: Union[str, Path], _sys: SysBuilder, sim_threshold: int, - dump_logger: List[str], external_files: List[str]): +@enforce_type +def generate_testbench(fname: Union[str, Path], config: TestbenchTemplateConfig) -> None: """Generate a testbench file for the given system.""" - with open(str(fname), "w", encoding='utf-8') as f: - dump_logger = '\n '.join(dump_logger) - extra_sources = ''.join(f", '{name}'" for name in external_files) - tb_dump = TEMPLATE.format(sim_threshold, dump_logger, extra_sources) - f.write(tb_dump) + + log_block = "\n ".join(config.log_lines) + extra_sources = "".join(f", '{name}'" for name in config.extra_sources) + output_dir = Path(config.output_dir) + + rendered = TEMPLATE.format( + threshold=config.sim_threshold, + log_block=log_block, + extras=extra_sources, + output_dir=output_dir.as_posix(), + ) + + Path(fname).write_text(rendered + "\n", encoding="utf-8") diff --git a/python/assassyn/codegen/verilog/top.md b/python/assassyn/codegen/verilog/top.md index 1f9f50ee2..fe66d9548 100644 --- a/python/assassyn/codegen/verilog/top.md +++ b/python/assassyn/codegen/verilog/top.md @@ -108,7 +108,32 @@ The function handles complex system-wide relationships: ## Internal Helpers -The function uses several utility functions and data structures: +`generate_top_harness` orchestrates a sequence of typed helper functions that each focus on +a single responsibility: + +1. `_emit_sram_blackboxes(dumper, builder)` declares temporary wires and instantiates the + SRAM blackboxes captured in `dumper.memory_defs`. It delegates clock/reset wiring and + taps the structured `SRAMInfo` objects returned by `utils.extract_sram_params`. +2. `_declare_fifo_wires(dumper, builder)` materialises the reusable FIFO wire bundle for + every producer port, including push/pop valid/data/ready signals. The helper uses + deterministic ordering to keep unit tests stable. +3. `_emit_trigger_counters(dumper, builder)` builds the top-level TriggerCounter wrapper, + instantiates one per module, and wires the `delta_ready`, `pop_valid`, and + `pop_ready` signals. +4. `_instantiate_modules(dumper, builder, *, module_order)` creates the PyCDE module + instances, passing the appropriate keyword arguments and gathering follow-up + assignments that tie off connections requiring cross-module data. +5. `_connect_cross_module_wires(dumper, builder, connection_plan)` consumes the + assignments gathered during instantiation and emits the final wiring statements, + including external FFI exposures and FIFO push plumbing. + +Each helper writes to a small `TopHarnessBuilder` (a typed struct that accumulates code +snippets and shared bindings) so logic is unit testable without invoking the full dumper. +Predicate reductions inside these helpers defer to +`python.assassyn.codegen.verilog.predicates` to keep mux semantics consistent with the +cleanup pass. + +The helpers rely on the following utilities and metadata snapshots: - `dump_type()` and `dump_type_cast()` from [utils module](/python/assassyn/codegen/verilog/utils.md) for type handling - `get_sram_info()` from [utils module](/python/assassyn/codegen/verilog/utils.md) for SRAM information @@ -118,7 +143,7 @@ The function uses several utility functions and data structures: - Metadata-driven checks for `FIFOPop` readiness: the predicated pop entries surfaced from `module_metadata.interactions.pops` (backed by the same tuples returned from `dumper.interactions.fifo_view(port).pops`) determine whether `_pop_ready` connections should be emitted, replacing the legacy dumper helper traversal. - `_connect_array()` from [CIRCTDumper](/python/assassyn/codegen/verilog/design.md) for array connections -The function manages several CIRCTDumper state variables: +Key `CIRCTDumper` state accessed during top-level emission includes: - `memory_defs`: SRAM memory definitions - `array_metadata`: Registry containing array write/read port assignments and usage diff --git a/python/assassyn/codegen/verilog/top.py b/python/assassyn/codegen/verilog/top.py index ff4329594..fe55d6753 100644 --- a/python/assassyn/codegen/verilog/top.py +++ b/python/assassyn/codegen/verilog/top.py @@ -3,7 +3,9 @@ """Top-level harness generation for Verilog designs.""" from collections import defaultdict -from typing import TYPE_CHECKING, Any +from contextlib import contextmanager +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any, Optional from .utils import ( dump_type, @@ -21,7 +23,7 @@ ) from ...ir.expr.intrinsic import ExternalIntrinsic from ...ir.dtype import Record -from ...utils import namify, unwrap_operand +from ...utils import enforce_type, namify, unwrap_operand from ...ir.const import Const if TYPE_CHECKING: @@ -29,6 +31,119 @@ else: CIRCTDumper = Any # type: ignore + +@dataclass +class TopHarnessBuilder: + """Collect and emit lines for the top-level harness.""" + + dumper: Optional["CIRCTDumper"] = None + indent: int = 0 + _buffer: list[str] = field(default_factory=list) + + def line(self, text: str = "") -> None: + """Emit a single line of code.""" + prefix = " " * self.indent + rendered = f"{prefix}{text}" if text else "" + if self.dumper is not None: + self.dumper.code.append(rendered) + else: + self._buffer.append(rendered) + + def blank_line(self) -> None: + """Emit an empty line.""" + self.line("") + + @contextmanager + def indent_block(self, delta: int = 4): + """Context manager that temporarily increases indentation.""" + self.indent += delta + try: + yield + finally: + self.indent -= delta + + def render(self) -> list[str]: + """Return the emitted lines (used in tests).""" + if self.dumper is not None: + return list(self.dumper.code) + return list(self._buffer) + + +@enforce_type +def _emit_sram_blackboxes(dumper: "CIRCTDumper", builder: TopHarnessBuilder) -> None: + """Emit wire declarations and instantiations for SRAM blackboxes.""" + if not getattr(dumper, "memory_defs", None): + return + + builder.blank_line() + builder.line("# --- SRAM Memory Blackbox Instances ---") + for data_width, addr_width, array_name in sorted(dumper.memory_defs, key=lambda item: item[2]): + builder.line(f"mem_{array_name}_dataout = Wire(Bits({data_width}))") + builder.line(f"mem_{array_name}_address = Wire(Bits({addr_width}))") + builder.line(f"mem_{array_name}_write_data = Wire(Bits({data_width}))") + builder.line(f"mem_{array_name}_write_enable = Wire(Bits(1))") + builder.line(f"mem_{array_name}_read_enable = Wire(Bits(1))") + builder.blank_line() + builder.line("# Instantiate memory blackbox module") + builder.line( + f"mem_{array_name}_inst = sramBlackbox_{array_name}()" + f"(clk=self.clk, rst_n=~self.rst, " + f"address=mem_{array_name}_address, " + f"wd=mem_{array_name}_write_data, " + "banksel=Bits(1)(1), " + f"read=mem_{array_name}_read_enable, " + f"write=mem_{array_name}_write_enable)" + ) + builder.blank_line() + builder.line(f"mem_{array_name}_dataout.assign(mem_{array_name}_inst.dataout)") + builder.blank_line() + + +@enforce_type +def _declare_fifo_wires(dumper: "CIRCTDumper", builder: TopHarnessBuilder) -> None: + """Declare shared FIFO wires for every producer port.""" + modules = sorted(getattr(dumper.sys, "modules", []), key=lambda mod: namify(mod.name)) + for module in modules: + module_name = namify(module.name) + ports = sorted(getattr(module, "ports", []), key=lambda port: namify(port.name)) + for port in ports: + fifo_base_name = f"fifo_{module_name}_{namify(port.name)}" + builder.line(f"# Wires for FIFO connected to {module.name}.{port.name}") + builder.line(f"{fifo_base_name}_push_valid = Wire(Bits(1))") + builder.line(f"{fifo_base_name}_push_data = Wire(Bits({port.dtype.bits}))") + builder.line(f"{fifo_base_name}_push_ready = Wire(Bits(1))") + builder.line(f"{fifo_base_name}_pop_valid = Wire(Bits(1))") + builder.line(f"{fifo_base_name}_pop_data = Wire(Bits({port.dtype.bits}))") + builder.line(f"{fifo_base_name}_pop_ready = Wire(Bits(1))") + + +@enforce_type +def _emit_trigger_counters(dumper: "CIRCTDumper", builder: TopHarnessBuilder) -> None: + """Declare trigger-counter wires and instantiate trigger counters.""" + modules = sorted(getattr(dumper.sys, "modules", []), key=lambda mod: namify(mod.name)) + for module in modules: + tc_base_name = f"{namify(module.name)}_trigger_counter" + builder.line(f"# Wires for {module.name}'s TriggerCounter") + builder.line(f"{tc_base_name}_delta = Wire(Bits(8))") + builder.line(f"{tc_base_name}_delta_ready = Wire(Bits(1))") + builder.line(f"{tc_base_name}_pop_valid = Wire(Bits(1))") + builder.line(f"{tc_base_name}_pop_ready = Wire(Bits(1))") + + if modules: + builder.blank_line() + + for module in modules: + tc_base_name = f"{namify(module.name)}_trigger_counter" + builder.line( + f"{tc_base_name}_inst = TriggerCounter(WIDTH=8)" + f"(clk=self.clk, rst_n=~self.rst, " + f"delta={tc_base_name}_delta, pop_ready={tc_base_name}_pop_ready)" + ) + builder.line(f"{tc_base_name}_delta_ready.assign({tc_base_name}_inst.delta_ready)") + builder.line(f"{tc_base_name}_pop_valid.assign({tc_base_name}_inst.pop_valid)") + builder.blank_line() + + # pylint: disable=too-many-locals,too-many-branches,too-many-statements def generate_top_harness(dumper: CIRCTDumper): """ @@ -47,31 +162,8 @@ def generate_top_harness(dumper: CIRCTDumper): dumper.append_code('def construct(self):') dumper.indent += 4 - sram_modules = [m for m in dumper.sys.downstreams if isinstance(m, SRAM)] - if sram_modules: - dumper.append_code('\n# --- SRAM Memory Blackbox Instances ---') - for data_width, addr_width, array_name in dumper.memory_defs: - dumper.append_code(f'mem_{array_name}_dataout = Wire(Bits({data_width}))') - dumper.append_code(f'mem_{array_name}_address = Wire(Bits({addr_width}))') - dumper.append_code(f'mem_{array_name}_write_data = Wire(Bits({data_width}))') - dumper.append_code(f'mem_{array_name}_write_enable = Wire(Bits(1))') - dumper.append_code(f'mem_{array_name}_read_enable = Wire(Bits(1))') - - # Instantiate memory blackbox (as external Verilog module) - dumper.append_code('# Instantiate memory blackbox module') - dumper.append_code( - f'mem_{array_name}_inst = sramBlackbox_{array_name}()' - '(clk=self.clk, rst_n=~self.rst, ' - f'address=mem_{array_name}_address, ' - f'wd=mem_{array_name}_write_data, ' - 'banksel=Bits(1)(1), ' - f'read=mem_{array_name}_read_enable, ' - f'write=mem_{array_name}_write_enable)' - ) - - # Now mem_{array_name}_dataout is properly driven by the module output - dumper.append_code(f'mem_{array_name}_dataout.assign(mem_{array_name}_inst.dataout)') - dumper.append_code('') + builder = TopHarnessBuilder(dumper=dumper, indent=dumper.indent) + _emit_sram_blackboxes(dumper, builder) dumper.append_code('\n# --- Global Cycle Counter ---') dumper.append_code('# A free-running counter for testbench control') @@ -84,25 +176,9 @@ def generate_top_harness(dumper: CIRCTDumper): # --- 1. Wire Declarations (Generic) --- dumper.append_code('# --- Wires for FIFOs, Triggers, and Arrays ---') - for module in dumper.sys.modules: - for port in module.ports: - fifo_base_name = f'fifo_{namify(module.name)}_{namify(port.name)}' - dumper.append_code(f'# Wires for FIFO connected to {module.name}.{port.name}') - dumper.append_code(f'{fifo_base_name}_push_valid = Wire(Bits(1))') - dumper.append_code(f'{fifo_base_name}_push_data = Wire(Bits({port.dtype.bits}))') - dumper.append_code(f'{fifo_base_name}_push_ready = Wire(Bits(1))') - dumper.append_code(f'{fifo_base_name}_pop_valid = Wire(Bits(1))') - dumper.append_code(f'{fifo_base_name}_pop_data = Wire(Bits({port.dtype.bits}))') - dumper.append_code(f'{fifo_base_name}_pop_ready = Wire(Bits(1))') - - # Wires for TriggerCounters (one per module) - for module in dumper.sys.modules: - tc_base_name = f'{namify(module.name)}_trigger_counter' - dumper.append_code(f'# Wires for {module.name}\'s TriggerCounter') - dumper.append_code(f'{tc_base_name}_delta = Wire(Bits(8))') - dumper.append_code(f'{tc_base_name}_delta_ready = Wire(Bits(1))') - dumper.append_code(f'{tc_base_name}_pop_valid = Wire(Bits(1))') - dumper.append_code(f'{tc_base_name}_pop_ready = Wire(Bits(1))') + builder.indent = dumper.indent + _declare_fifo_wires(dumper, builder) + _emit_trigger_counters(dumper, builder) for arr_container in dumper.sys.arrays: arr = arr_container @@ -217,18 +293,6 @@ def generate_top_harness(dumper: CIRCTDumper): ) # Instantiate TriggerCounters - for module in dumper.sys.modules: - tc_base_name = f'{namify(module.name)}_trigger_counter' - dumper.append_code( - f'{tc_base_name}_inst = TriggerCounter(WIDTH=8)' - f'(clk=self.clk, rst_n=~self.rst, ' - f'delta={tc_base_name}_delta, pop_ready={tc_base_name}_pop_ready)' - ) - dumper.append_code( - f'{tc_base_name}_delta_ready.assign({tc_base_name}_inst.delta_ready)' - ) - dumper.append_code(f'{tc_base_name}_pop_valid.assign({tc_base_name}_inst.pop_valid)') - all_driven_fifo_ports = set() dumper.append_code('\n# --- Module Instantiations and Connections ---') @@ -239,9 +303,6 @@ def generate_top_harness(dumper: CIRCTDumper): module_connection_map = {} pending_connection_assignments = defaultdict(list) declared_cross_module_wires = set() - external_assignments_by_consumer = defaultdict(list) - for entry in dumper.external_wire_assignments: - external_assignments_by_consumer[entry['consumer']].append(entry) def _queue_cross_module_assignments(producer_module, assignments): target_lines = module_connection_map.get(producer_module) @@ -256,25 +317,38 @@ def _declare_cross_module_wire(name, dtype_expr): declared_cross_module_wires.add(name) def _attach_consumer_external_entries(module, port_map): - consumer_external_entries = external_assignments_by_consumer.get(module, []) handled_consumer_ports = set() - for assignment in consumer_external_entries: - expr = assignment['expr'] + queued_keys = set() + for entry in dumper.external_metadata.reads_for_consumer(module): + expr = entry.expr consumer_port = dumper.get_external_port_name(expr) - if consumer_port in handled_consumer_ports: - continue - handled_consumer_ports.add(consumer_port) dtype = dump_type(expr.dtype) - _declare_cross_module_wire(consumer_port, dtype) - valid_name = f"{consumer_port}_valid" - _declare_cross_module_wire(valid_name, "Bits(1)") - port_map.append(f"{consumer_port}={consumer_port}") - port_map.append(f"{valid_name}={valid_name}") - producer_module = assignment['producer'] + if consumer_port not in handled_consumer_ports: + _declare_cross_module_wire(consumer_port, dtype) + valid_name = f"{consumer_port}_valid" + _declare_cross_module_wire(valid_name, "Bits(1)") + port_map.append(f"{consumer_port}={consumer_port}") + port_map.append(f"{valid_name}={valid_name}") + handled_consumer_ports.add(consumer_port) + else: + valid_name = f"{consumer_port}_valid" + + wire_key = dumper.get_external_wire_key( + entry.instance, + entry.port_name, + entry.index_operand, + ) + if (module, wire_key) in queued_keys: + continue + queued_keys.add((module, wire_key)) + + producer_module = entry.producer producer_name = namify(producer_module.name) - producer_port = dumper.external_wire_outputs.get(assignment['wire']) - if producer_port is None: + producer_exposures = dumper.external_output_exposures.get(producer_module, {}) + exposure = producer_exposures.get(wire_key) + if exposure is None: continue + producer_port = exposure['output_name'] assignments = [ f'{consumer_port}.assign(inst_{producer_name}.expose_{producer_port})', f'{valid_name}.assign(inst_{producer_name}.valid_{producer_port})', @@ -357,7 +431,7 @@ def _attach_external_values(module, port_map, handled_ports): if is_sram: sram_info = get_sram_info(module) - array = sram_info['array'] + array = sram_info.array array_name = namify(array.name) port_map.append(f'mem_dataout=mem_{array_name}_dataout') @@ -410,7 +484,7 @@ def _attach_external_values(module, port_map, handled_ports): if is_sram: sram_info = get_sram_info(module) - array = sram_info['array'] + array = sram_info.array array_name = namify(array.name) connection_lines.extend([ f'mem_{array_name}_address.assign(inst_{mod_name}.mem_address)', diff --git a/python/assassyn/codegen/verilog/utils.md b/python/assassyn/codegen/verilog/utils.md index 69697c528..a30ecffa5 100644 --- a/python/assassyn/codegen/verilog/utils.md +++ b/python/assassyn/codegen/verilog/utils.md @@ -55,20 +55,24 @@ The `bits` parameter allows specifying a custom bit width for the cast operation ### `get_sram_info` ```python -def get_sram_info(node: SRAM) -> dict: +@enforce_type +def get_sram_info(node: SRAM) -> SRAMInfo: """Extract SRAM-specific information.""" ``` **Explanation** -This function extracts essential information from an SRAM module for Verilog generation. It returns a dictionary containing: +This function extracts essential information from an SRAM module for Verilog generation and +returns an `SRAMInfo` dataclass with named fields: 1. **array**: The underlying array object (`node._payload`) 2. **init_file**: Initialization file path for the SRAM 3. **width**: Data width of the SRAM 4. **depth**: Depth (number of entries) of the SRAM -This information is used by other modules to generate appropriate SRAM interface signals and memory control logic. +The structured return type avoids dictionary lookups and surfaces IDE/type-checker support +throughout the backend. Other modules consume the dataclass to generate SRAM interface +signals and memory control logic. **Project-specific Knowledge Required**: - Understanding of [SRAM memory model](/python/assassyn/ir/memory/sram.md) @@ -77,28 +81,25 @@ This information is used by other modules to generate appropriate SRAM interface ### `extract_sram_params` ```python -def extract_sram_params(node: SRAM) -> dict: - """Extract common SRAM parameters from an SRAM module. - - Args: - sram: SRAM module object - - Returns: - dict: Dictionary containing array_name, data_width, and addr_width - """ +@enforce_type +def extract_sram_params(node: SRAM) -> SRAMParams: + """Extract common SRAM parameters from an SRAM module.""" ``` **Explanation** -This function provides a higher-level interface for extracting SRAM parameters needed for Verilog generation. It combines `get_sram_info()` with additional processing to provide: +This function provides a higher-level interface for extracting SRAM parameters needed for +Verilog generation. It combines `get_sram_info()` with additional processing to populate an +`SRAMParams` dataclass that surfaces: -1. **sram_info**: The raw SRAM information dictionary +1. **info**: The raw `SRAMInfo` snapshot 2. **array**: The underlying array object 3. **array_name**: Generated name for the array 4. **data_width**: Width of data elements in bits 5. **addr_width**: Width of address bus (minimum 1 bit) -The function ensures that address width is at least 1 bit even for single-element arrays. +The function ensures that address width is at least 1 bit even for single-element arrays and +keeps derived values grouped together for downstream consumers. **Project-specific Knowledge Required**: - Understanding of [SRAM memory model](/python/assassyn/ir/memory/sram.md) @@ -130,7 +131,7 @@ def ensure_bits(expr_str: str) -> str: **Explanation** -This function ensures that a Verilog expression string represents a Bits type, performing necessary conversions. It handles several cases: +This function ensures that a Verilog expression string represents a Bits type, performing necessary conversions. It handles several cases using pre-compiled regexes for determinism: 1. **UInt to Bits conversion**: Converts `UInt(width)(value)` to `Bits(width)(value)` 2. **Already Bits**: Returns unchanged if already a Bits type diff --git a/python/assassyn/codegen/verilog/utils.py b/python/assassyn/codegen/verilog/utils.py index 3ca42b7b0..9837a6b54 100644 --- a/python/assassyn/codegen/verilog/utils.py +++ b/python/assassyn/codegen/verilog/utils.py @@ -1,45 +1,73 @@ """Utility functions for the Verilog backend.""" + +from __future__ import annotations + import re +from dataclasses import dataclass from typing import Optional +from ...ir.array import Array from ...ir.module import Module from ...ir.memory.sram import SRAM from ...ir.expr import Intrinsic from ...ir.dtype import Int, UInt, Bits, DType, Record from ...utils import namify +from ...utils.enforce_type import enforce_type + +UINT_LITERAL = re.compile(r"UInt\(([^)]+)\)\(([^)]+)\)") +CONTROL_PATTERNS = ("executed_wire", "_valid", "_pop_valid", "_push_valid") + + +@dataclass(frozen=True) +class SRAMInfo: + """Snapshot of the payload backing an SRAM module.""" + + array: Array + init_file: Optional[str] + width: int + depth: int -def get_sram_info(node: SRAM) -> dict: - """Extract SRAM-specific information.""" - return { # pylint: disable=protected-access - 'array': node._payload, - 'init_file': node.init_file, - 'width': node.width, - 'depth': node.depth - } +@dataclass(frozen=True) +class SRAMParams: + """Commonly used SRAM parameters surfaced for codegen helpers.""" -def extract_sram_params(node: SRAM) -> dict: - """Extract common SRAM parameters from an SRAM module. + info: SRAMInfo + array: Array + array_name: str + data_width: int + addr_width: int - Args: - sram: SRAM module object - Returns: - dict: Dictionary containing array_name, data_width, and addr_width - """ - sram_info = get_sram_info(node) - array = sram_info['array'] +@enforce_type +def get_sram_info(node: SRAM) -> SRAMInfo: + """Extract SRAM-specific information.""" + payload = getattr(node, '_payload') # pylint: disable=protected-access + return SRAMInfo( + array=payload, + init_file=node.init_file, + width=node.width, + depth=node.depth, + ) + + +@enforce_type +def extract_sram_params(node: SRAM) -> SRAMParams: + """Extract common SRAM parameters from an SRAM module.""" + + info = get_sram_info(node) + array = info.array array_name = namify(array.name) data_width = array.scalar_ty.bits addr_width = array.index_bits if array.index_bits > 0 else 1 - return { - 'sram_info': sram_info, - 'array': array, - 'array_name': array_name, - 'data_width': data_width, - 'addr_width': addr_width - } + return SRAMParams( + info=info, + array=array, + array_name=array_name, + data_width=data_width, + addr_width=addr_width, + ) def find_wait_until(module: Module) -> Optional[Intrinsic]: """Find the WAIT_UNTIL intrinsic in a module if it exists.""" @@ -53,16 +81,11 @@ def find_wait_until(module: Module) -> Optional[Intrinsic]: def ensure_bits(expr_str: str) -> str: """Ensure an expression is of Bits type, converting if necessary.""" - uint_pattern = r'UInt\(([^)]+)\)\(([^)]+)\)' - if re.search(uint_pattern, expr_str): - expr_str = re.sub(uint_pattern, r'Bits(\1)(\2)', expr_str) - return expr_str - if "Bits(" in expr_str: - return expr_str - if ".as_bits()" in expr_str: + if UINT_LITERAL.search(expr_str): + return UINT_LITERAL.sub(r"Bits(\1)(\2)", expr_str) + if "Bits(" in expr_str or ".as_bits()" in expr_str: return expr_str - if any(pattern in expr_str for pattern in \ - ["executed_wire", "_valid", "_pop_valid", "_push_valid"]): + if any(pattern in expr_str for pattern in CONTROL_PATTERNS): return expr_str return f"{expr_str}.as_bits()" diff --git a/python/ci-tests/test_select1hot.py b/python/ci-tests/test_select1hot.py index ef6bf22f2..c34a0b17b 100644 --- a/python/ci-tests/test_select1hot.py +++ b/python/ci-tests/test_select1hot.py @@ -1,7 +1,20 @@ +from pathlib import Path + import pytest -from assassyn.frontend import * -from assassyn.test import run_test +from assassyn.frontend import * # type: ignore +from assassyn.test import run_test # type: ignore +from assassyn.codegen.verilog.design import generate_design # type: ignore + + +def _emit_design(sys_builder: SysBuilder, tmp_path: Path) -> str: + """Generate design.py for the provided system and return its contents.""" + + out_dir = tmp_path / "select1hot" + out_dir.mkdir(parents=True, exist_ok=True) + design_path = out_dir / "design.py" + generate_design(str(design_path), sys_builder) + return design_path.read_text(encoding="utf-8") class Driver(Module): @@ -34,5 +47,43 @@ def check(raw: str): def test_select1hot(): run_test('select1hot', top, check) + +def test_select1hot_two_value_case_emits_assignment(tmp_path): + sys_builder = SysBuilder("select1hot_assign") + + with sys_builder: + + class Harness(Module): # type: ignore[misc] + + def __init__(self): + super().__init__( + ports={ + "sink": Port(UInt(8)), + }, + no_arbiter=True, + ) + + @module.combinational + def build(self): + cond = UInt(2)(1) + v0 = UInt(8)(3) + v1 = UInt(8)(7) + result = cond.select1hot(v0, v1) + self.sink.push(result) + + Harness().build() + + text = _emit_design(sys_builder, tmp_path) + select_lines = [ + line.strip() + for line in text.splitlines() + if "select1hot" in line or "Mux(" in line + ] + + assert any(" = " in line for line in select_lines), ( + "expected select1hot helper to assign to destination; " + f"captured lines: {select_lines}" + ) + if __name__ == '__main__': test_select1hot() diff --git a/python/unit-tests/codegen/test_cleanup.py b/python/unit-tests/codegen/test_cleanup.py index c0e30b420..1192eda90 100644 --- a/python/unit-tests/codegen/test_cleanup.py +++ b/python/unit-tests/codegen/test_cleanup.py @@ -17,9 +17,9 @@ push_condition, pop_condition, ) -from assassyn.codegen.verilog.cleanup import ( # type: ignore - _emit_predicate_mux_chain, - _format_reduction_expr, +from assassyn.codegen.verilog.predicates import ( # type: ignore + emit_predicate_mux_chain, + reduce_predicates, ) from assassyn.codegen.verilog.design import CIRCTDumper # type: ignore from assassyn.codegen.verilog.analysis import collect_fifo_metadata # type: ignore @@ -302,18 +302,18 @@ def test_fifo_push_single_entry_passthrough(): assert assignments == expected -def test_format_reduction_expr_supports_and_operator_with_defaults(): +def test_reduce_predicates_supports_and_operator_with_defaults(): """Generalised helper emits AND reductions and surfaces defaults.""" assert ( - _format_reduction_expr([], default_literal="Bits(1)(1)", op="and_") + reduce_predicates([], default_literal="Bits(1)(1)", op="and_") == "Bits(1)(1)" ) assert ( - _format_reduction_expr(["lhs"], default_literal="Bits(1)(1)", op="and_") + reduce_predicates(["lhs"], default_literal="Bits(1)(1)", op="and_") == "reduce(and_, [lhs], Bits(1)(1))" ) assert ( - _format_reduction_expr(["lhs", "rhs"], default_literal="Bits(1)(1)", op="and_") + reduce_predicates(["lhs", "rhs"], default_literal="Bits(1)(1)", op="and_") == "reduce(and_, [lhs, rhs], Bits(1)(1))" ) @@ -325,12 +325,12 @@ def test_emit_predicate_mux_chain_preserves_custom_reduce(): def render_predicate(entry): return f"{entry}_pred" - mux_expr, predicate_expr = _emit_predicate_mux_chain( + mux_expr, predicate_expr = emit_predicate_mux_chain( entries, render_predicate=render_predicate, render_value=lambda entry: entry, default_value="DEFAULT", - aggregate_predicates=lambda preds: _format_reduction_expr( + aggregate_predicates=lambda preds: reduce_predicates( preds, default_literal="Bits(1)(1)", op="and_", @@ -346,9 +346,9 @@ def test_emit_predicate_mux_chain_empty_sequence_defaults(): default_value = "UInt(8)(0)" def aggregate(predicates): - return _format_reduction_expr(predicates, default_literal="Bits(1)(0)") + return reduce_predicates(predicates, default_literal="Bits(1)(0)") - mux_expr, predicate_expr = _emit_predicate_mux_chain( + mux_expr, predicate_expr = emit_predicate_mux_chain( [], render_predicate=lambda entry: entry, render_value=lambda entry: entry, diff --git a/python/unit-tests/codegen/test_external_exposure_predicate.py b/python/unit-tests/codegen/test_external_exposure_predicate.py new file mode 100644 index 000000000..38b28de9e --- /dev/null +++ b/python/unit-tests/codegen/test_external_exposure_predicate.py @@ -0,0 +1,125 @@ +"""Ensure external exposure bookkeeping retains raw predicate metadata.""" + +from collections import defaultdict +from types import SimpleNamespace + +from assassyn.codegen.verilog._expr.intrinsics import codegen_external_intrinsic +from assassyn.ir.const import Const +from assassyn.ir.dtype import Bits, UInt +from assassyn.ir.expr.intrinsic import ExternalIntrinsic +from assassyn.ir.module.external import ExternalSV, WireSpec + + +class ModuleStub: + """Hashable module surrogate for dumper bookkeeping.""" + + def __init__(self, name: str): + self.name = name + + def __hash__(self) -> int: # pragma: no cover - trivial helper + return hash(self.name) + + def __eq__(self, other): # pragma: no cover - trivial helper + if not isinstance(other, ModuleStub): + return NotImplemented + return self.name == other.name + + +class _StubRegistry: + """Minimal registry exposing reads recorded for an external instance.""" + + def __init__(self, mapping): + self._mapping = mapping + self.frozen = True + + def reads_for_instance(self, instance): + """Return the stored reads for *instance*.""" + return self._mapping.get(instance, ()) + + +class _StubDumper: + """Provide the subset of CIRCTDumper used by codegen_external_intrinsic.""" + + def __init__(self, registry, current_module): + self.external_metadata = registry + self.current_module = current_module + self.external_wrapper_names = {} + self.external_instance_names = {} + self.external_output_exposures = defaultdict(dict) + + def dump_rval(self, node, _with_namespace, module_name=None): + """Return a deterministic binding name for *node*.""" + if hasattr(node, "as_operand"): + return node.as_operand() + return repr(node) + + def get_external_wire_key(self, instance, port_name, index_operand): + """Match CIRCTDumper's wire-key normalisation for index-less ports.""" + idx_key = None + if index_operand is not None: + idx_key = ("expr", index_operand) + return (instance, port_name, idx_key) + + def get_pred(self, expr): + """Legacy helper retained so the pre-refactor path still executes.""" + del expr # unused + return "Bits(1)(1)" + + +class DummyExternal(ExternalSV): # type: ignore[misc] + """Simple external module used for predicate propagation tests.""" + + +DummyExternal.set_metadata({ + "source": "dummy.sv", + "module_name": "DummyExternal", +}) +DummyExternal.set_port_specs( + { + "value": WireSpec( + name="value", + dtype=UInt(8), + direction="out", + kind="wire", + ), + } +) + + +def test_external_intrinsic_tracks_raw_meta_cond(): + """External exposures should retain the original Expr.meta_cond value.""" + + producer = ModuleStub("producer") + consumer = ModuleStub("consumer") + + instance = ExternalIntrinsic(DummyExternal) + instance.parent = producer + + predicate = Const(Bits(1), 1) + instance._meta_cond = predicate # pylint: disable=protected-access + + read_expr = SimpleNamespace(dtype=UInt(8)) + registry = _StubRegistry({ + instance: ( + SimpleNamespace( + expr=read_expr, + producer=producer, + consumer=consumer, + instance=instance, + port_name="value", + index_operand=None, + ), + ) + }) + + dumper = _StubDumper(registry, consumer) + + result = codegen_external_intrinsic(dumper, instance) + assert "DummyExternal_ffi" in result + + exposures = dumper.external_output_exposures[consumer] + assert exposures, "External outputs should be registered for the consumer module" + data = next(iter(exposures.values())) + + assert data["meta_cond"] is predicate + assert "condition" not in data diff --git a/python/unit-tests/codegen/test_top_harness_sections.py b/python/unit-tests/codegen/test_top_harness_sections.py new file mode 100644 index 000000000..b6036d72a --- /dev/null +++ b/python/unit-tests/codegen/test_top_harness_sections.py @@ -0,0 +1,60 @@ +"""Unit coverage for the refactored top-level harness helpers.""" + +from types import SimpleNamespace + +import pytest + +from assassyn.codegen.verilog.top import ( # type: ignore + TopHarnessBuilder, + _declare_fifo_wires, + _emit_sram_blackboxes, + _emit_trigger_counters, +) + + +def _make_builder() -> TopHarnessBuilder: + builder = TopHarnessBuilder() + builder.line("# preamble") + return builder + + +def test_emit_sram_blackboxes_declares_wires(): + dumper = SimpleNamespace( + memory_defs={(16, 8, "tensor")}, + sys=SimpleNamespace(downstreams=[SimpleNamespace(name="tensor_mem")]), + ) + + builder = _make_builder() + _emit_sram_blackboxes(dumper, builder) + lines = builder.render() + + assert any("mem_tensor_dataout = Wire(Bits(16))" in line for line in lines) + assert any("sramBlackbox_tensor()" in line for line in lines) + + +def test_declare_fifo_wires_is_deterministic(): + port_a = SimpleNamespace(name="outA", dtype=SimpleNamespace(bits=8)) + port_b = SimpleNamespace(name="outB", dtype=SimpleNamespace(bits=16)) + module = SimpleNamespace(name="Producer", ports=[port_b, port_a]) + dumper = SimpleNamespace(sys=SimpleNamespace(modules=[module])) + + builder = _make_builder() + _declare_fifo_wires(dumper, builder) + lines = [line.strip() for line in builder.render() if "fifo_producer_" in line.lower()] + + assert lines[0].startswith("fifo_Producer_outA_push_valid") + assert lines[1].startswith("fifo_Producer_outA_push_data") + assert any("fifo_Producer_outB_push_valid" in line for line in lines) + + +def test_emit_trigger_counters_wires_trigger_counter_bundle(): + module = SimpleNamespace(name="Producer") + dumper = SimpleNamespace(sys=SimpleNamespace(modules=[module])) + + builder = _make_builder() + _emit_trigger_counters(dumper, builder) + block = "\n".join(builder.render()) + + assert "Producer_trigger_counter_inst = TriggerCounter" in block + assert "Producer_trigger_counter_delta_ready.assign" in block + assert "Producer_trigger_counter_pop_valid.assign" in block