diff --git a/fud2/scripts/synth-verilog-to-report.rhai b/fud2/scripts/synth-verilog-to-report.rhai index 38415f987..e9565611b 100644 --- a/fud2/scripts/synth-verilog-to-report.rhai +++ b/fud2/scripts/synth-verilog-to-report.rhai @@ -36,7 +36,7 @@ fn synth_setup(e) { // Python scripts for parsing reports for visualization and extracting JSON summary e.rule("parse-rpt", "synthrep viz -t flamegraph -f $in > $out"); e.rule("extract-util-json", "synthrep summary -m utilization > $out"); - e.rule("extract-hierarchy-json", "synthrep summary -m hierarchy > $out"); + e.rule("extract-hierarchy-json", "aext vivado out/hierarchical_utilization_placed.rpt > $out"); } fn flamegraph_setup(e) { diff --git a/fud2/tests/snapshots/tests__test@plan_area-report-to-flamegraph.snap b/fud2/tests/snapshots/tests__test@plan_area-report-to-flamegraph.snap index 72627fdbf..ae7de95e6 100644 --- a/fud2/tests/snapshots/tests__test@plan_area-report-to-flamegraph.snap +++ b/fud2/tests/snapshots/tests__test@plan_area-report-to-flamegraph.snap @@ -29,7 +29,7 @@ rule parse-rpt rule extract-util-json command = synthrep summary -m utilization > $out rule extract-hierarchy-json - command = synthrep summary -m hierarchy > $out + command = aext vivado out/hierarchical_utilization_placed.rpt > $out flamegraph-script = /test/calyx/non-existent.script create-visuals-script = $calyx-base/tools/profiler/create-visuals.sh diff --git a/fud2/tests/snapshots/tests__test@plan_synth-verilog-to-area-report.snap b/fud2/tests/snapshots/tests__test@plan_synth-verilog-to-area-report.snap index 00c500316..3091b44cf 100644 --- a/fud2/tests/snapshots/tests__test@plan_synth-verilog-to-area-report.snap +++ b/fud2/tests/snapshots/tests__test@plan_synth-verilog-to-area-report.snap @@ -29,7 +29,7 @@ rule parse-rpt rule extract-util-json command = synthrep summary -m utilization > $out rule extract-hierarchy-json - command = synthrep summary -m hierarchy > $out + command = aext vivado out/hierarchical_utilization_placed.rpt > $out build main.sv: copy /input.ext build device.xdc: copy $device_xdc diff --git a/fud2/tests/snapshots/tests__test@plan_synth-verilog-to-hierarchy-json.snap b/fud2/tests/snapshots/tests__test@plan_synth-verilog-to-hierarchy-json.snap index e9f403cfd..83f9fd2dc 100644 --- a/fud2/tests/snapshots/tests__test@plan_synth-verilog-to-hierarchy-json.snap +++ b/fud2/tests/snapshots/tests__test@plan_synth-verilog-to-hierarchy-json.snap @@ -29,7 +29,7 @@ rule parse-rpt rule extract-util-json command = synthrep summary -m utilization > $out rule extract-hierarchy-json - command = synthrep summary -m hierarchy > $out + command = aext vivado out/hierarchical_utilization_placed.rpt > $out build main.sv: copy /input.ext build device.xdc: copy $device_xdc diff --git a/fud2/tests/snapshots/tests__test@plan_synth-verilog-to-timing-report.snap b/fud2/tests/snapshots/tests__test@plan_synth-verilog-to-timing-report.snap index e58662582..59249e461 100644 --- a/fud2/tests/snapshots/tests__test@plan_synth-verilog-to-timing-report.snap +++ b/fud2/tests/snapshots/tests__test@plan_synth-verilog-to-timing-report.snap @@ -29,7 +29,7 @@ rule parse-rpt rule extract-util-json command = synthrep summary -m utilization > $out rule extract-hierarchy-json - command = synthrep summary -m hierarchy > $out + command = aext vivado out/hierarchical_utilization_placed.rpt > $out build main.sv: copy /input.ext build device.xdc: copy $device_xdc diff --git a/fud2/tests/snapshots/tests__test@plan_synth-verilog-to-util-json.snap b/fud2/tests/snapshots/tests__test@plan_synth-verilog-to-util-json.snap index 76956aa4f..2db6b8f9d 100644 --- a/fud2/tests/snapshots/tests__test@plan_synth-verilog-to-util-json.snap +++ b/fud2/tests/snapshots/tests__test@plan_synth-verilog-to-util-json.snap @@ -29,7 +29,7 @@ rule parse-rpt rule extract-util-json command = synthrep summary -m utilization > $out rule extract-hierarchy-json - command = synthrep summary -m hierarchy > $out + command = aext vivado out/hierarchical_utilization_placed.rpt > $out build main.sv: copy /input.ext build device.xdc: copy $device_xdc diff --git a/fud2/tests/snapshots/tests__test@plan_synth-verilog-to-util-report.snap b/fud2/tests/snapshots/tests__test@plan_synth-verilog-to-util-report.snap index 04a57831d..655cf4daa 100644 --- a/fud2/tests/snapshots/tests__test@plan_synth-verilog-to-util-report.snap +++ b/fud2/tests/snapshots/tests__test@plan_synth-verilog-to-util-report.snap @@ -29,7 +29,7 @@ rule parse-rpt rule extract-util-json command = synthrep summary -m utilization > $out rule extract-hierarchy-json - command = synthrep summary -m hierarchy > $out + command = aext vivado out/hierarchical_utilization_placed.rpt > $out build main.sv: copy /input.ext build device.xdc: copy $device_xdc diff --git a/graph.pdf b/graph.pdf new file mode 100644 index 000000000..6918600a9 Binary files /dev/null and b/graph.pdf differ diff --git a/tools/AreaExtract/AreaExtract/__init__.py b/tools/AreaExtract/AreaExtract/__init__.py new file mode 100644 index 000000000..2d30abe58 --- /dev/null +++ b/tools/AreaExtract/AreaExtract/__init__.py @@ -0,0 +1,4 @@ +"""AreaExtract is a tool to extract area information from synthesis data +and compile it in a Common Data Format.""" + +__version__ = "0.1.0" diff --git a/tools/AreaExtract/AreaExtract/bin/__init__.py b/tools/AreaExtract/AreaExtract/bin/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tools/AreaExtract/AreaExtract/bin/extract.py b/tools/AreaExtract/AreaExtract/bin/extract.py new file mode 100644 index 000000000..9daafc982 --- /dev/null +++ b/tools/AreaExtract/AreaExtract/bin/extract.py @@ -0,0 +1,65 @@ +import argparse +import json +from pathlib import Path + +from AreaExtract.lib.parse.vivado import rpt_to_design_with_metadata +from AreaExtract.lib.parse.yosys import il_to_design_with_metadata + + +def main(): + parser = argparse.ArgumentParser( + description=( + "Parse FPGA synthesis reports into a Common Data Format.\n\n" + "Supported origins:\n" + " - Vivado: single hierarchical .rpt file\n" + " - Yosys: .il (intermediate language) and .json (stat) file\n\n" + "Output is a JSON serialization of the Common Data Format." + ), + formatter_class=argparse.RawTextHelpFormatter, + ) + subparsers = parser.add_subparsers(dest="origin", required=True) + vivado = subparsers.add_parser( + "vivado", + help="parse a Vivado utilization .rpt file", + ) + vivado.add_argument( + "rpt", + type=Path, + help="path to Vivado utilization report (.rpt)", + ) + yosys = subparsers.add_parser( + "yosys", + help="parse Yosys IL and stat JSON files", + ) + yosys.add_argument( + "il", + type=Path, + help="path to Yosys IL file (.il)", + ) + yosys.add_argument( + "json", + type=Path, + help="path to Yosys stat file (.json)", + ) + parser.add_argument( + "-o", + "--output", + type=Path, + help="optional output file for JSON (defaults to stdout)", + ) + args = parser.parse_args() + if args.origin == "vivado": + design = rpt_to_design_with_metadata(args.rpt) + elif args.origin == "yosys": + design = il_to_design_with_metadata(args.il, args.json) + else: + parser.error("unknown origin") + json_str = json.dumps(design, default=lambda o: o.__dict__, indent=2) + if args.output: + args.output.write_text(json_str) + else: + print(json_str) + + +if __name__ == "__main__": + main() diff --git a/tools/AreaExtract/AreaExtract/lib/cdf/__init__.py b/tools/AreaExtract/AreaExtract/lib/cdf/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tools/AreaExtract/AreaExtract/lib/cdf/cdf.py b/tools/AreaExtract/AreaExtract/lib/cdf/cdf.py new file mode 100644 index 000000000..f9b1c0c4d --- /dev/null +++ b/tools/AreaExtract/AreaExtract/lib/cdf/cdf.py @@ -0,0 +1,72 @@ +from dataclasses import dataclass + + +@dataclass +class VivadoRsrc: + """ + Vivado resources for a cell. + """ + + lut: int + llut: int + lutram: int + srl: int + ff: int + ramb36: int + ramb18: int + uram: int + dsp: int + + +type YosysRsrc = dict[str, int] +""" +Yosys resources for a cell. +""" + + +type Rsrc = VivadoRsrc | YosysRsrc +""" +Map representing resources used by a cell. +""" + + +@dataclass +class Cell: + """ + Cell with resources. + """ + + # Unqualified cell name. + name: str + # Cell type. + type: str + # Whether the cell was generated in synthesis. + generated: bool + # Cell resources. + rsrc: Rsrc + + +@dataclass +class Metadata: + """ + Design metadata. + """ + + # Origin of the design (Vivado, Yosys). + origin: str + + +type Design = dict[str, Cell] +""" +Design with qualified cell names and associated cells. +""" + + +@dataclass +class DesignWithMetadata: + """ + Design with metadata. + """ + + design: Design + metadata: Metadata diff --git a/tools/AreaExtract/AreaExtract/lib/parse/__init__.py b/tools/AreaExtract/AreaExtract/lib/parse/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tools/AreaExtract/AreaExtract/lib/parse/vivado.py b/tools/AreaExtract/AreaExtract/lib/parse/vivado.py new file mode 100644 index 000000000..ef2884ef2 --- /dev/null +++ b/tools/AreaExtract/AreaExtract/lib/parse/vivado.py @@ -0,0 +1,287 @@ +from pathlib import Path +from AreaExtract.lib.cdf.cdf import ( + VivadoRsrc, + Cell, + Design, + Metadata, + DesignWithMetadata, +) +import re + + +class RPTParser: + """ + Class implementing parsing functionality of RPT files generated by Xilinx + tools. The core functionality is extracting tables out of these files. + """ + + SKIP_LINE = re.compile(r"^.*(\+-*)*\+$") + + def __init__(self, filepath): + with open(filepath, "r") as data: + self.lines = data.read().split("\n") + + @staticmethod + def _clean_and_strip(elems, preserve_index=None): + """ + Remove all empty elements from the list and strips each string element + while preserving the left indentation of the element at index `preserve_index`. + """ + indexed = filter(lambda ie: ie[1] != "\n" and ie[1] != "", enumerate(elems)) + cleaned = map( + lambda ie: ie[1].rstrip("\n ") + if ie[0] == preserve_index + else ie[1].strip(), + indexed, + ) + return list(cleaned) + + @staticmethod + def _parse_simple_header(line): + assert re.search(r"\s*\|", line), ( + "Simple header line should have | as first non-whitespace character" + ) + return RPTParser._clean_and_strip(line.split("|")) + + @staticmethod + def _parse_multi_header(lines): + """ + Extract header from the form: + +------+--------+--------+----------+-----------+-----------+ + | | Latency | Iteration| Initiation Interval | + | Name | min | max | Latency | achieved | target | + +------+--------+--------+----------+-----------+-----------+ + + into: ["Name", "Latency_min", "Latency_max", "Iteration Latency", ...] + + This will fail to correctly parse this header. See the comment below + for an explanation: + +------+--------+--------+--------+--------+ + | | Latency | Foo | + | Name | min | max | bar | baz | + +------+--------+--------+--------+--------+ + turns into: ["Name", "Latency_min", "Latency_max", + "Latecy_bar", "Latency_baz", "Foo"] + """ + + multi_headers = [] + secondary_hdrs = lines[1].split("|") + + # Use the following heuristic to generate header names: + # - If header starts with a small letter, it is a secondary header. + # - If the last save sequence of headers doesn't already contain this + # header name, add it to the last one. + # - Otherwise add a new sub header class. + for idx, line in enumerate(secondary_hdrs, 1): + clean_line = line.strip() + if len(clean_line) == 0: + continue + elif ( + clean_line[0].islower() + and len(multi_headers) > 0 + and multi_headers[-1][0].islower() + and clean_line not in multi_headers[-1] + ): + multi_headers[-1].append(clean_line) + else: + multi_headers.append([clean_line]) + + # Extract base headers and drop the starting empty lines and ending '\n'. + base_hdrs = lines[0].split("|")[1:-1] + + if len(base_hdrs) != len(multi_headers): + raise Exception( + "Something went wrong while parsing multi header " + + "base len: {}, mult len: {}".format( + len(base_hdrs), len(multi_headers) + ) + ) + + hdrs = [] + for idx in range(0, len(base_hdrs)): + for mult in multi_headers[idx]: + hdrs.append((base_hdrs[idx].strip() + " " + mult).strip()) + + return hdrs + + @staticmethod + def _parse_table(table_lines, multi_header, preserve_indent): + """ + Parses a simple table of the form: + +--------+-------+----------+------------+ + | Clock | Target| Estimated| Uncertainty| + +--------+-------+----------+------------+ + |ap_clk | 7.00| 4.39| 1.89| + |ap_clk | 7.00| 4.39| 1.89| + +--------+-------+----------+------------+ + |ap_clk | 7.00| 4.39| 1.89| + +--------+-------+----------+------------+ + + The might be any number of rows after the headers. The input parameter + is a list of lines of the table starting with the top most header line. + Return a list of dicts, one per row, whose keys come from the header + row. + + """ + + # Extract the headers and set table start + table_start = 0 + if multi_header: + header = RPTParser._parse_multi_header(table_lines[1:3]) + table_start = 3 + else: + header = RPTParser._parse_simple_header(table_lines[1]) + table_start = 2 + + assert len(header) > 0, "No header found" + + rows = [] + for line in table_lines[table_start:]: + if not RPTParser.SKIP_LINE.match(line): + rows.append( + RPTParser._clean_and_strip( + line.split("|"), 1 if preserve_indent else None + ) + ) + + ret = [ + {header[i]: row[i] for i in range(len(header))} + for row in rows + if len(row) == len(header) + ] + return ret + + @staticmethod + def _get_indent_level(instance): + """ + Compute the hierarchy depth of an instance based on its leading spaces. + Assumes 2 spaces per indentation level. + """ + return (len(instance) - len(instance.lstrip(" "))) // 2 + + def get_table(self, reg, off, multi_header=False, preserve_indent=False): + """ + Parse table `off` lines after `reg` matches the files in the current + file. + + The table format is: + +--------+-------+----------+------------+ + | Clock | Target| Estimated| Uncertainty| + +--------+-------+----------+------------+ + |ap_clk | 7.00| 4.39| 1.89| + |ap_clk | 7.00| 4.39| 1.89| + +--------+-------+----------+------------+ + |ap_clk | 7.00| 4.39| 1.89| + +--------+-------+----------+------------+ + """ + start = 0 + end = 0 + for idx, line in enumerate(self.lines, 1): + if reg.search(line): + start = idx + off + + # If start doesn't point to valid header, continue searching + if ( + self.lines[start].strip() == "" + or self.lines[start].strip()[0] != "+" + ): + continue + + end = start + while self.lines[end].strip() != "": + end += 1 + + if end <= start: + return None + + return self._parse_table(self.lines[start:end], multi_header, preserve_indent) + + @classmethod + def build_hierarchy_tree(self, table): + """ + Construct a hierarchical tree from a list of dictionary rows representing + indented instances in a flat table. Each row must contain an 'Instance' key, + where indentation indicates the depth in the hierarchy. + + Returns a nested dictionary tree with 'children' fields populated accordingly. + """ + stack = [] + root = {} + for row in table: + raw_instance = row["Instance"] + name = raw_instance.strip() + level = self._get_indent_level(raw_instance) + row["Instance"] = row["Instance"].strip() + row["children"] = {} + while len(stack) > level: + stack.pop() + if not stack: + root[name] = row + stack.append(row) + else: + parent = stack[-1] + parent["children"][name] = row + stack.append(row) + return root + + +def create_tree(filename): + rpt_file = Path(filename) + parser = RPTParser(rpt_file) + table = parser.get_table( + re.compile(r"^\d+\. Utilization by Hierarchy$"), 2, preserve_indent=True + ) + tree = parser.build_hierarchy_tree(table) + return tree + + +def flatten_tree(tree, prefix=""): + flat = {} + for name, node in tree.items(): + fq_name = f"{prefix}.{name}" if prefix else name + flat[fq_name] = {k: v for k, v in node.items() if k != "children"} + if node.get("children"): + flat.update(flatten_tree(node["children"], fq_name)) + return flat + + +def to_vivado_rsrc(d: dict) -> VivadoRsrc: + mapping = { + "Total LUTs": "lut", + "Logic LUTs": "llut", + "LUTRAMs": "lutram", + "SRLs": "srl", + "FFs": "ff", + "RAMB36": "ramb36", + "RAMB18": "ramb18", + "URAM": "uram", + "DSP Blocks": "dsp", + } + + kwargs = {} + for rpt_key, field_name in mapping.items(): + raw_value = d.get(rpt_key, "0").strip() + kwargs[field_name] = int(raw_value) if raw_value.isdigit() else 0 + + return VivadoRsrc(**kwargs) + + +def to_cell(d: dict) -> Cell: + return Cell( + name=d["Instance"], + type=d["Module"], + generated=False, + rsrc=to_vivado_rsrc(d), + ) + + +def to_design(flat_tree: dict) -> Design: + return {fq_name: to_cell(node) for fq_name, node in flat_tree.items()} + + +def rpt_to_design_with_metadata(filename: str) -> DesignWithMetadata: + tree = create_tree(filename) + flat_tree = flatten_tree(tree) + design = to_design(flat_tree) + metadata = Metadata(origin="Vivado") + return DesignWithMetadata(design=design, metadata=metadata) diff --git a/tools/AreaExtract/AreaExtract/lib/parse/yosys.py b/tools/AreaExtract/AreaExtract/lib/parse/yosys.py new file mode 100644 index 000000000..219281770 --- /dev/null +++ b/tools/AreaExtract/AreaExtract/lib/parse/yosys.py @@ -0,0 +1,138 @@ +import re +import json +from dataclasses import dataclass +from AreaExtract.lib.cdf.cdf import ( + YosysRsrc, + Cell, + Design, + Metadata, + DesignWithMetadata, +) + +toplevel: str = "main" + + +@dataclass +class CellWithParams: + """ + Class representing a cell and its parameters. + """ + + cell_name: str + cell_type: str + cell_params: dict[str, int] + + +""" +Map from modules to cell names to cells with parameters. +""" +type ModuleCellTypes = dict[str, dict[str, CellWithParams]] + + +def parse_il_file_old(path: str) -> ModuleCellTypes: + module_to_name_to_type: ModuleCellTypes = {} + current_module = None + with open(path, "r") as f: + for line in f: + line = line.strip() + if line.startswith("module"): + current_module = line.split()[1] + module_to_name_to_type[current_module] = {} + elif line.startswith("cell"): + match = re.match(r"cell\s+(\S+)\s+(\S+)", line) + if match: + cell_type, cell_name = match.groups() + module_to_name_to_type[current_module][cell_name] = cell_type + return module_to_name_to_type + + +def parse_il_file(path: str) -> ModuleCellTypes: + module_to_name_to_type: ModuleCellTypes = {} + current_module = None + current_cell = None + with open(path, "r") as f: + for line in f: + line = line.strip() + if line.startswith("module"): + current_module = line.split()[1] + module_to_name_to_type[current_module] = {} + elif line.startswith("cell") and current_module: + current_cell = line.split()[2] + cell_type = line.split()[1] + module_to_name_to_type[current_module][current_cell] = CellWithParams( + current_cell, cell_type, {} + ) + elif line.startswith("parameter") and current_cell: + param_name = line.split()[1] + param_val = line.split()[2] + module_to_name_to_type[current_module][current_cell].cell_params[ + param_name + ] = param_val + elif line.startswith("end") and current_cell: + current_cell = None + elif line.startswith("end") and current_module: + current_module = None + return module_to_name_to_type + + +def flatten_il_rec_helper( + module_to_name_to_type: ModuleCellTypes, module: str, pref: str +): + design_map: Design = {} + for cell_name, cell_with_params in module_to_name_to_type[module].items(): + generated_type = cell_with_params.cell_type[0] == "$" + generated_name = cell_name[0] == "$" + if generated_type: + width = max( + { + int(v) + for k, v in cell_with_params.cell_params.items() + if k.endswith("WIDTH") + }, + default=None, + ) + if cell_with_params.cell_type.startswith("$paramod"): + new_width = cell_with_params.cell_type.split("\\")[2] + width = int(new_width.split("'")[1], 2) + design_map[f"{pref}.{cell_name[1:]}"] = Cell( + cell_name[1:], + cell_with_params.cell_type[1:], + generated_name, + {"width": width}, + ) + else: + design_map |= flatten_il_rec_helper( + module_to_name_to_type, + cell_with_params.cell_type, + f"{pref}.{cell_name[1:]}", + ) + return design_map + + +def flatten_il(module_to_name_to_type: ModuleCellTypes): + return flatten_il_rec_helper(module_to_name_to_type, "\\main", "main") + + +def parse_stat_file(path: str) -> dict: + with open(path, "r") as f: + return json.load(f) + + +def populate_stats(design_map: Design, stat: dict): + for k, v in design_map.items(): + if v.type.startswith("paramod"): + filtered_rsrc: YosysRsrc = { + k: v + for k, v in stat["modules"][f"${v.type}"].items() + if isinstance(v, int) + } + design_map[k].rsrc.update(filtered_rsrc) + v.type = v.type.split("\\")[1] + + +def il_to_design_with_metadata(il_path: str, stat_path: str) -> DesignWithMetadata: + modules = parse_il_file(il_path) + design = flatten_il(modules) + stat = parse_stat_file(stat_path) + populate_stats(design, stat) + return DesignWithMetadata(design=design, metadata=Metadata(origin="Yosys")) diff --git a/tools/AreaExtract/README.md b/tools/AreaExtract/README.md new file mode 100644 index 000000000..f5906f768 --- /dev/null +++ b/tools/AreaExtract/README.md @@ -0,0 +1,79 @@ +# AreaExtract + +AreaExtract is a tool that replaces previous technology-specific frontends for +the Calyx profiler, Petal. It offers a combined frontend for several sources of +area data for accelerator designs, and outputs processed data in a common data +format that is parseable by Petal. Currently, the following technologies are +supported: + +- Vivado, as hierarchical area synthesis reports +- Yosys, as both IL and statistics files + +## Usage + +``` +$ aext -h +usage: aext [-h] [-o OUTPUT] {vivado,yosys} ... + +Parse FPGA synthesis reports into a Common Data Format. + +Supported origins: + - Vivado: single hierarchical .rpt file + - Yosys: .il (intermediate language) and .json (stat) file + +Output is a JSON serialization of the Common Data Format. + +positional arguments: + {vivado,yosys} + vivado parse a Vivado utilization .rpt file + yosys parse Yosys IL and stat JSON files + +options: + -h, --help show this help message and exit + -o OUTPUT, --output OUTPUT + optional output file for JSON (defaults to stdout) +``` + +## Obtaining area data + +This section provides instructions to obtain area data for designs from +supported technologies, to use as input for AreaExtract. + +### Vivado + +The simplest way to obtain a hierarchical area RPT file is to use Fud2 to run +synthesis on a Calyx design: + +``` +fud2 .futil --to area-report > .rpt +``` + +Alternatively, it is possible to use Fud2 to obtain a synthesis-ready Verilog +file, and then use Vivado directly to conduct synthesis. The relevant TCL +command for Vivado is: + +``` +report_utilization -hierarchical -file .rpt +``` + +### Yosys + +Using the OSS-CAD suite, IL and statistics files can be obtained as follows: + +``` +yosys -p "read_verilog -sv .sv; hierarchy -top main; opt; write_rtlil .il; tee -o .json stat -json" +``` + +It is also possible to pass Liberty files to [the `stat` command](https://yosyshq.readthedocs.io/projects/yosys/en/0.47/cmd/stat.html) +through the flag `-liberty `. + +## Future work + +This tool is not yet a full replacement of its technology-specific predecessors, +`synthrep` for Vivado and `aprof` for Yosys, as it is not able to produce area-only +visualizations, which is a desirable feature. In addition, some of `synthrep`'s +functionality is unrelated to area, and is not in scope for AreaExtract. Another +area that is being explored is the addition of other technologies, especially +OpenROAD as it targets ASICs instead of FPGAs. While Yosys also offers ASIC +capabilities, it is primarily oriented towards FPGAs; Vivado exclusively targets +AMD FPGAs. diff --git a/tools/AreaExtract/pyproject.toml b/tools/AreaExtract/pyproject.toml new file mode 100644 index 000000000..40391457f --- /dev/null +++ b/tools/AreaExtract/pyproject.toml @@ -0,0 +1,14 @@ +[build-system] +requires = ["flit_core >=3.2,<4"] +build-backend = "flit_core.buildapi" + +[project] +name = "AreaExtract" +authors = [{ name = "The Calyx Authors" }] +classifiers = ["License :: OSI Approved :: MIT License"] +dynamic = ["version", "description"] +dependencies = [] +readme = "README.md" + +[project.scripts] +aext = "AreaExtract.bin.extract:main" diff --git a/tools/area-profiler/README.md b/tools/area-profiler/README.md new file mode 100644 index 000000000..fe687981c --- /dev/null +++ b/tools/area-profiler/README.md @@ -0,0 +1,43 @@ +# Area estimation tool + +This tool estimates and visualizes hardware design areas from Yosys IL and stat files. Yosys IL and stat files can be obtained from a Verilog file via: + +```bash +yosys -p "read_verilog -sv inline.sv; hierarchy -top main; opt; write_rtlil inline.il; tee -o inline.json stat -json" +``` + +## Install + +The tool can be installed with: + +```bash +uv tool install . +``` + +Additionally, on `havarti`, feel free to use Pedro's installation of the Yosys environment, located in `/scratch/pedro`. The environment can be loaded using the `environment` or `environment.fish` scripts. + +## Usage + +```bash +aprof-parse -h +aprof-plot -h +``` + +### Commands + +**`aprof-parse`** – convert IL + stat files into JSON summary + +```bash +aprof parse [-o OUTPUT] +``` + +- `-o` optional output JSON (default stdout) + +**`aprof-plot`** – visualize JSON summary + +```bash +aprof plot [-o OUTPUT] +``` + +- `MODE` one of `bar`, `treemap` +- `-o` optional output HTML (default depends on mode) diff --git a/tools/area-profiler/area_profiler/__init__.py b/tools/area-profiler/area_profiler/__init__.py new file mode 100644 index 000000000..c18cf5666 --- /dev/null +++ b/tools/area-profiler/area_profiler/__init__.py @@ -0,0 +1,3 @@ +"""WIP.""" + +__version__ = "0.1.0" diff --git a/tools/area-profiler/area_profiler/parse.py b/tools/area-profiler/area_profiler/parse.py new file mode 100644 index 000000000..379b348ea --- /dev/null +++ b/tools/area-profiler/area_profiler/parse.py @@ -0,0 +1,195 @@ +import pathlib +import re +import json +from dataclasses import dataclass, asdict, is_dataclass +import argparse + +toplevel: str = "main" + + +# Intermediate representation types +@dataclass +class CellWithParams: + """ + Class representing a cell and its parameters. + """ + + cell_name: str + cell_type: str + cell_params: dict[str, int] + + +""" +Map from modules to cell names to cells with parameters. +""" +type ModuleCellTypes = dict[str, dict[str, CellWithParams]] + +# Output representation types +""" +Map representing resources used by a cell. +""" +type Rsrc = dict[str, int] + + +@dataclass +class CellRsrc: + """ + Class representing a cell and its resources. + """ + + cell_name: str + cell_type: str + cell_width: int | None + generated: bool + rsrc: Rsrc + + +""" +Map between qualified cell names and cell resource values. +""" +type DesignRsrc = dict[str, CellRsrc] + + +def parse_il_file_old(path: str) -> ModuleCellTypes: + module_to_name_to_type: ModuleCellTypes = {} + current_module = None + with open(path, "r") as f: + for line in f: + line = line.strip() + if line.startswith("module"): + current_module = line.split()[1] + module_to_name_to_type[current_module] = {} + elif line.startswith("cell"): + match = re.match(r"cell\s+(\S+)\s+(\S+)", line) + if match: + cell_type, cell_name = match.groups() + module_to_name_to_type[current_module][cell_name] = cell_type + return module_to_name_to_type + + +def parse_il_file(path: str) -> ModuleCellTypes: + module_to_name_to_type: ModuleCellTypes = {} + current_module = None + current_cell = None + with open(path, "r") as f: + for line in f: + line = line.strip() + if line.startswith("module"): + current_module = line.split()[1] + module_to_name_to_type[current_module] = {} + elif line.startswith("cell") and current_module: + current_cell = line.split()[2] + cell_type = line.split()[1] + module_to_name_to_type[current_module][current_cell] = CellWithParams( + current_cell, cell_type, {} + ) + elif line.startswith("parameter") and current_cell: + param_name = line.split()[1] + param_val = line.split()[2] + module_to_name_to_type[current_module][current_cell].cell_params[ + param_name + ] = param_val + elif line.startswith("end") and current_cell: + current_cell = None + elif line.startswith("end") and current_module: + current_module = None + return module_to_name_to_type + + +def flatten_il_rec_helper( + module_to_name_to_type: ModuleCellTypes, module: str, pref: str +): + design_map: DesignRsrc = {} + for cell_name, cell_with_params in module_to_name_to_type[module].items(): + generated_type = cell_with_params.cell_type[0] == "$" + generated_name = cell_name[0] == "$" + if generated_type: + width = max( + { + int(v) + for k, v in cell_with_params.cell_params.items() + if k.endswith("WIDTH") + }, + default=None, + ) + if cell_with_params.cell_type.startswith("$paramod"): + new_width = cell_with_params.cell_type.split("\\")[2] + width = int(new_width.split("'")[1], 2) + design_map[f"{pref}.{cell_name[1:]}"] = CellRsrc( + cell_name[1:], + cell_with_params.cell_type[1:], + width, + generated_name, + {}, + ) + else: + design_map |= flatten_il_rec_helper( + module_to_name_to_type, + cell_with_params.cell_type, + f"{pref}.{cell_name[1:]}", + ) + return design_map + + +def flatten_il(module_to_name_to_type: ModuleCellTypes): + return flatten_il_rec_helper(module_to_name_to_type, "\\main", "main") + + +def parse_stat_file(path: str) -> dict: + with open(path, "r") as f: + return json.load(f) + + +def populate_stats(design_map: DesignRsrc, stat: dict): + for k, v in design_map.items(): + if v.cell_type.startswith("paramod"): + filtered_rsrc = { + k: v + for k, v in stat["modules"][f"${v.cell_type}"].items() + if isinstance(v, int) + } + design_map[k].rsrc.update(filtered_rsrc) + v.cell_type = v.cell_type.split("\\")[1] + + +def main(): + parser = argparse.ArgumentParser( + description="Utility to process Yosys IL and stat files and dump design map as JSON" + ) + parser.add_argument("il_file", type=pathlib.Path, help="path to the IL file") + parser.add_argument("stat_file", type=pathlib.Path, help="path to the stat file") + parser.add_argument( + "-o", + "--output", + type=pathlib.Path, + help="output JSON", + ) + args = parser.parse_args() + + name_to_type = parse_il_file(args.il_file) + design_map = flatten_il(name_to_type) + stat = parse_stat_file(args.stat_file) + populate_stats(design_map, stat) + + output_path = args.output + + if output_path: + with open(output_path, "w") as f: + json.dump( + design_map, + f, + indent=2, + default=lambda o: asdict(o) if is_dataclass(o) else str, + ) + else: + print( + json.dumps( + design_map, + indent=2, + default=lambda o: asdict(o) if is_dataclass(o) else str, + ) + ) + + +if __name__ == "__main__": + main() diff --git a/tools/area-profiler/area_profiler/plot.py b/tools/area-profiler/area_profiler/plot.py new file mode 100644 index 000000000..34d2c2df3 --- /dev/null +++ b/tools/area-profiler/area_profiler/plot.py @@ -0,0 +1,96 @@ +import argparse +import json +import plotly.express as px +from collections import defaultdict +from pathlib import Path + +AREA_WEIGHTS = { + "and": 1.0, + "or": 1.0, + "not": 0.5, + "eq": 3.0, + "logic_not": 2.0, + "mux": 4.0, + "std_wire": 0.2, + "std_reg": 8.0, +} + + +def load_data(path: Path): + with open(path) as f: + return json.load(f) + + +def compute_areas(data): + areas = [] + for name, cell in data.items(): + t = cell["cell_type"] + w = cell["cell_width"] + weight = AREA_WEIGHTS.get(t, 1.0) + area = weight * w + areas.append({"cell_name": name, "cell_type": t, "width": w, "area": area}) + return areas + + +def make_bar_chart(areas, output): + type_area = defaultdict(float) + for a in areas: + type_area[a["cell_type"]] += a["area"] + summary = [{"cell_type": t, "total_area": area} for t, area in type_area.items()] + + fig = px.bar( + summary, + x="cell_type", + y="total_area", + title="estimated area", + labels={"total_area": "Estimated area"}, + ) + fig.write_html(output) + + +def make_treemap(areas, output): + fig = px.treemap( + areas, + path=["cell_type", "cell_name"], + values="area", + title="estimated area treemap", + ) + fig.write_html(output) + + +def main(): + parser = argparse.ArgumentParser( + description="Estimate and plot cell areas based on a heuristic" + ) + parser.add_argument( + "input", + type=Path, + help="path to input JSON file", + ) + parser.add_argument( + "mode", + choices=["bar", "treemap"], + help="visualization type", + ) + parser.add_argument( + "-o", + "--output", + type=Path, + help="output HTML file (default: area_by_type.html for bar, area_treemap.html for treemap)", + ) + + args = parser.parse_args() + + data = load_data(args.input) + areas = compute_areas(data) + + if args.mode == "bar": + output = args.output or Path("area_by_type.html") + make_bar_chart(areas, output) + elif args.mode == "treemap": + output = args.output or Path("area_treemap.html") + make_treemap(areas, output) + + +if __name__ == "__main__": + main() diff --git a/tools/area-profiler/pyproject.toml b/tools/area-profiler/pyproject.toml new file mode 100644 index 000000000..eda195121 --- /dev/null +++ b/tools/area-profiler/pyproject.toml @@ -0,0 +1,15 @@ +[build-system] +requires = ["flit_core >=3.2,<4"] +build-backend = "flit_core.buildapi" + +[project] +name = "area-profiler" +authors = [{ name = "The Calyx Authors" }] +classifiers = ["License :: OSI Approved :: MIT License"] +dynamic = ["version", "description"] +dependencies = ["pandas", "plotly"] +readme = "README.md" + +[project.scripts] +aprof-parse = "area_profiler.parse:main" +aprof-plot = "area_profiler.plot:main" diff --git a/tools/profiler/profiler/classes/tracedata.py b/tools/profiler/profiler/classes/tracedata.py index 7de327e71..134bd33c7 100644 --- a/tools/profiler/profiler/classes/tracedata.py +++ b/tools/profiler/profiler/classes/tracedata.py @@ -163,26 +163,28 @@ def find_leaf_groups(self) -> set[str]: @dataclass -class Utilization: +class Area: """ - Hierarchical utilization wrapper. + Hierarchical area wrapper. """ map: dict[str, dict[str, str]] accessed: set[str] + origin: str def __init__(self, json_dict): - self.map = json_dict + self.map = json_dict.get("design", {}) self.accessed = set() + self.origin = json_dict.get("metadata", {}).get("origin", "") def get_module(self, name: str) -> dict[str, str]: """ - Get the utilization map for a module. `name` is a fully qualified name + Get the area map for a module. `name` is a fully qualified name of a module on a stack. """ if name in self.map: self.accessed.add(name) - return self.map.get(name, {}) + return self.map.get(name, {}).get("rsrc", {}) def get_unaccessed(self): """ @@ -192,14 +194,14 @@ def get_unaccessed(self): return module_set.difference(self.accessed) -class UtilizationCycleTrace(CycleTrace): +class AreaCycleTrace(CycleTrace): """ List of stacks that are active in a single cycle, containing utilization information (both aggregated and per primitive). """ # Reference to the global utilization map from all primitives to their utilization - global_utilization: Utilization + global_utilization: Area # Aggregated utilization of all the primitives in this cycle # Ex. {'Total LUTs': 21, 'Logic LUTs': 5, 'LUTRAMs': 16, 'SRLs': 0, 'FFs': 38, 'RAMB36': 0, 'RAMB18': 0, 'URAM': 0, 'DSP Blocks': 0} utilization: dict @@ -212,7 +214,7 @@ class UtilizationCycleTrace(CycleTrace): def __init__( self, - utilization: Utilization, + utilization: Area, control_metadata: ControlMetadata, stacks_this_cycle: list[list[StackElement]] | None = None, ): @@ -248,7 +250,7 @@ def add_stack(self, stack, main_shortname="main"): # NOT include control primitives! for p in self.primitives_active: util = { - k: int(v) if v.isdigit() else v + k: int(v) if type(v) is str and v.isdigit() else v for k, v in self.global_utilization.get_module(p).items() } self.utilization_per_primitive[p] = util @@ -435,7 +437,7 @@ def create_trace_with_control_groups( control_groups_trace: dict[int, set[str]], cell_metadata: CellMetadata, control_metadata: ControlMetadata, - utilization: Utilization | None = None, + utilization: Area | None = None, ): """ Populates the field trace_with_control_groups by combining control group information (from control_groups_trace) with self.trace. @@ -447,7 +449,7 @@ def create_trace_with_control_groups( new_cycletrace = ( CycleTrace() if utilization is None - else UtilizationCycleTrace(utilization, control_metadata) + else AreaCycleTrace(utilization, control_metadata) ) # fully qualified control group --> path descriptor active_control_group_to_desc: dict[str, str] = ( diff --git a/tools/profiler/profiler/construct_trace.py b/tools/profiler/profiler/construct_trace.py index 85cd5d390..db5fff2fd 100644 --- a/tools/profiler/profiler/construct_trace.py +++ b/tools/profiler/profiler/construct_trace.py @@ -4,8 +4,8 @@ from profiler.classes.control_metadata import ControlMetadata from profiler.classes.tracedata import ( CycleTrace, - Utilization, - UtilizationCycleTrace, + Area, + AreaCycleTrace, TraceData, StackElement, StackElementType, @@ -188,7 +188,7 @@ def postprocess( self, shared_cells_map: dict[str, dict[str, str]], control_metadata: ControlMetadata | None = None, - utilization: Utilization | None = None, + utilization: Area | None = None, ): """ Postprocess data mapping timestamps to events (signal changes) @@ -548,7 +548,7 @@ def create_utilization_cycle_trace( info_this_cycle: dict[str, str | dict[str, str]], shared_cell_map: dict[str, dict[str, str]], include_primitives: bool, - utilization: Utilization, + utilization: Area, ): """ Creates a UtilizationCycleTrace object for stack elements in this cycle, computing the dependencies between them. @@ -556,7 +556,7 @@ def create_utilization_cycle_trace( cycle_trace = create_cycle_trace( cell_info, info_this_cycle, shared_cell_map, include_primitives ) - return UtilizationCycleTrace(utilization, control_metadata, cycle_trace.stacks) + return AreaCycleTrace(utilization, control_metadata, cycle_trace.stacks) def add_control_enables( diff --git a/tools/profiler/profiler/main.py b/tools/profiler/profiler/main.py index f9b9f1046..b1a7bec4b 100644 --- a/tools/profiler/profiler/main.py +++ b/tools/profiler/profiler/main.py @@ -12,7 +12,7 @@ from profiler.classes.cell_metadata import CellMetadata from profiler.classes.control_metadata import ControlMetadata -from profiler.classes.tracedata import TraceData, ControlRegUpdateType, Utilization +from profiler.classes.tracedata import TraceData, ControlRegUpdateType, Area def setup_metadata(args): @@ -56,7 +56,7 @@ def process_vcd( control_metadata: ControlMetadata, tracedata: TraceData, vcd_filename: str, - utilization: Utilization | None = None, + utilization: Area | None = None, ): """ Wrapper function to process the VCD file to produce a trace. @@ -196,20 +196,14 @@ def main(): enable_thread_metadata, ) = setup_metadata(args) - utilization: Utilization | None = None + utilization: Area | None = None utilization_variable: str | None = None if args.utilization_report_json is not None: print("Utilization report mode enabled.") with open(args.utilization_report_json) as f: - utilization = Utilization(json.load(f)) - varmap = { - "ff": "FFs", - "lut": "Total LUTs", - "llut": "Logic LUTs", - "lutram": "LUTRAMs", - } - utilization_variable = varmap[args.utilization_variable] + utilization = Area(json.load(f)) + utilization_variable = args.utilization_variable control_reg_updates_per_cycle: dict[int, ControlRegUpdateType] = process_vcd( cell_metadata, diff --git a/tools/profiler/profiler/visuals/utilization_plots.py b/tools/profiler/profiler/visuals/utilization_plots.py index b90b28269..af20cd380 100644 --- a/tools/profiler/profiler/visuals/utilization_plots.py +++ b/tools/profiler/profiler/visuals/utilization_plots.py @@ -1,7 +1,7 @@ from collections import Counter import pandas as pd import plotly.express as px -from profiler.classes.tracedata import UtilizationCycleTrace +from profiler.classes.tracedata import PTrace class Plotter: @@ -9,14 +9,14 @@ class Plotter: Wrapper around related utilization plotting functions. """ - def __init__(self, data: dict[int, UtilizationCycleTrace]): - self.data = data + def __init__(self, data: PTrace): + self.data = data.trace def plot_utilization_per_cycle(self, var: str, out_dir: str): """Plot the value of `var` per cycle.""" records = [ {"cycle": cycle_id, "value": obj.utilization.get(var, 0)} - for cycle_id, obj in self.data.items() + for cycle_id, obj in enumerate(self.data) ] df = pd.DataFrame(records) fig = px.bar( @@ -27,7 +27,7 @@ def plot_utilization_per_cycle(self, var: str, out_dir: str): def plot_cycles_per_primitive(self, var: str, out_dir: str): """Plot the number of cycles each primitive has a nonzero value for `var`.""" counter = Counter() - for obj in self.data.values(): + for obj in self.data: for prim, varmap in obj.utilization_per_primitive.items(): if var in varmap and varmap[var] != 0: counter[prim] += 1 @@ -48,7 +48,7 @@ def plot_heatmap(self, var: str, out_dir: str): usage_sum = Counter() active_cycles = Counter() - for cycle, trace in self.data.items(): + for cycle, trace in enumerate(self.data): for prim, vars_dict in trace.utilization_per_primitive.items(): if var in vars_dict: value = int(vars_dict[var]) @@ -67,6 +67,7 @@ def plot_heatmap(self, var: str, out_dir: str): sorted_primitives = sorted(ratios, key=ratios.get, reverse=True) df = pd.DataFrame(aggregated) + print(df) heatmap_data = df.pivot(index="primitive", columns="cycle", values="value") all_cycles = range(df["cycle"].min(), df["cycle"].max() + 1) heatmap_data = heatmap_data.reindex(columns=all_cycles) @@ -86,7 +87,7 @@ def plot_ratio(self, var: str, out_dir: str): usage_sum = Counter() active_cycles = Counter() - for obj in self.data.values(): + for obj in self.data: for prim, varmap in obj.utilization_per_primitive.items(): if var in varmap: usage_sum[prim] = int(varmap[var])