diff --git a/fpga/build_images.ini b/fpga/build_images.ini new file mode 100644 index 000000000..83bb09295 --- /dev/null +++ b/fpga/build_images.ini @@ -0,0 +1,18 @@ +[general] +prefix = corundum +parallel = 16 +synth_parallel = 8 +dirs = + mqnic + +[vivado] +settings_file = /opt/Xilinx/vivado-settings + +[ise] +settings_file = /opt/Xilinx/ise-settings + +[quartus] +settings_file = /opt/altera/quartus-settings + +[quartus-pro] +settings_file = /opt/altera/quartus-pro-settings diff --git a/fpga/build_images.py b/fpga/build_images.py new file mode 100755 index 000000000..d029733cd --- /dev/null +++ b/fpga/build_images.py @@ -0,0 +1,383 @@ +#!/usr/bin/env python3 +""" + +Copyright (c) 2022 Alex Forencich + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +""" + +import argparse +import asyncio +import configparser +import datetime +import os +import re +import subprocess + + +config = configparser.ConfigParser() + + +def run_cmd(cmd, cwd=None): + return subprocess.run(cmd, cwd=cwd, stdout=subprocess.PIPE).stdout.decode('utf-8').strip() + + +async def run_cmd_async(cmd, *args, cwd=None): + proc = await asyncio.create_subprocess_exec( + cmd, + *args, + cwd=cwd, + stdout=asyncio.subprocess.PIPE) + + stdout, stderr = await proc.communicate() + + if stdout: + return stdout.decode() + + return None + + +async def run_cmd_shell_async(cmd, cwd=None): + proc = await asyncio.create_subprocess_shell( + cmd, + cwd=cwd, + stdout=asyncio.subprocess.PIPE) + + stdout, stderr = await proc.communicate() + + if stdout: + return stdout.decode() + + return None + + +class Build: + def __init__(self, design, build_dir, prefix, output): + self.design = design + self.prefix = prefix + self.output = output + + self.settings_file = "" + self.build_dir = build_dir + self.build_cmd = "sleep 5" + + self.outname = '-'.join(self.design) + if prefix: + self.outname = prefix + "-" + self.outname + + self.output_file = "" + self.output_ext = ".bin" + + self.start_time = None + self.elapsed_time = None + + self.wns = None + self.tns = None + + self.phase = "Idle" + + def get_status(self): + s = f"{'/'.join(self.design)}: {self.phase}" + + if self.wns is not None: + s += f" (WNS: {self.wns}, TNS: {self.tns})" + + if self.elapsed_time is not None: + s += " ["+str(self.elapsed_time).split('.')[0]+"]" + elif self.start_time is not None: + s += " ["+str(datetime.datetime.now() - self.start_time).split('.')[0]+"]" + + return s + + def synth_done(self): + if self.synth_sem is not None: + self.synth_sem.release() + self.synth_sem = None + + def build_done(self): + if self.build_sem is not None: + self.build_sem.release() + self.build_sem = None + + async def run(self, build_sem, synth_sem): + self.build_sem = build_sem + self.synth_sem = synth_sem + + self.phase = "Waiting (build)" + if self.build_sem is not None: + await self.build_sem.acquire() + + self.phase = "Waiting (synth)" + if self.synth_sem is not None: + await self.synth_sem.acquire() + + self.phase = "Starting" + self.start_time = datetime.datetime.now() + + build_cmd = self.build_cmd + if self.settings_file: + build_cmd = f"source {self.settings_file}; {build_cmd}" + + proc = await asyncio.create_subprocess_shell( + build_cmd, + cwd=self.build_dir, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE) + + while True: + line = await proc.stdout.readline() + if not line: + break + line = line.decode('utf-8').strip() + + self.scan_log_line(line) + + self.synth_done() + self.build_done() + + if os.path.isfile(self.output_file): + self.phase = "Copying output file" + await run_cmd_async("cp", "-p", self.output_file, os.path.join(self.output, self.outname+self.output_ext)) + self.phase = "Zipping output file" + await run_cmd_async("zip", self.outname+".zip", self.outname+self.output_ext, cwd=self.output) + self.phase = "Done" + else: + self.phase = "Failed" + + self.elapsed_time = datetime.datetime.now() - self.start_time + + def scan_log_line(self, line): + pass + + +class VivadoBuild(Build): + def __init__(self, design, build_dir, prefix, output): + super().__init__(design, build_dir, prefix, output) + + self.settings_file = config['vivado'].get('settings_file') + self.build_cmd = "make" + + self.output_ext = ".bit" + self.output_file = os.path.join(self.build_dir, "fpga"+self.output_ext) + + self.vivado_phase = "init" + + def scan_log_line(self, line): + if line == "vivado -nojournal -nolog -mode batch -source create_project.tcl": + self.vivado_phase = "init" + self.phase = f"[{self.vivado_phase}] Creating Vivado project" + if line == "vivado -nojournal -nolog -mode batch -source run_synth.tcl": + self.vivado_phase = "synthesis" + self.phase = f"[{self.vivado_phase}] Starting Vivado synthesis" + if line == "vivado -nojournal -nolog -mode batch -source run_impl.tcl": + self.synth_done() + self.vivado_phase = "implementation" + self.phase = f"[{self.vivado_phase}] Starting Vivado implementation" + if line == "Starting Placer Task": + self.vivado_phase = "implementation (placement)" + if line == "Starting Routing Task": + self.vivado_phase = "implementation (routing)" + if line == "vivado -nojournal -nolog -mode batch -source generate_bit.tcl": + self.vivado_phase = "bitfile generation" + self.phase = f"[{self.vivado_phase}] Starting Vivado bitfile generation" + + if line.startswith("Start") or line.startswith("Running") or line.startswith("Phase"): + self.phase = f"[{self.vivado_phase}] "+line.split("|", 1)[0].split(":", 1)[0].strip() + + m = re.search(r".*Timing Summary\s+\|\s+WNS=(\S+)\s+\|\s+TNS=(\S+)", line) + if m: + self.wns = m.group(1) + self.tns = m.group(2) + + +class IseBuild(Build): + def __init__(self, design, build_dir, prefix, output): + super().__init__(design, build_dir, prefix, output) + + self.settings_file = config['ise'].get('settings_file') + self.build_cmd = "make" + + self.output_ext = ".bit" + self.output_file = os.path.join(self.build_dir, "fpga"+self.output_ext) + + def scan_log_line(self, line): + if line.startswith('xst'): + self.phase = "Running synthesis" + elif line.startswith('ngdbuild'): + self.synth_done() + self.phase = "Running translate" + elif line.startswith('map'): + self.phase = "Running map" + elif line.startswith('par'): + self.phase = "Running placement and routing" + elif line.startswith('trce'): + self.phase = "Running timing analysis" + elif line.startswith('bitgen'): + self.phase = "Running bitfile generation" + + +class QuartusBuild(Build): + def __init__(self, design, build_dir, prefix, output): + super().__init__(design, build_dir, prefix, output) + + self.settings_file = config['quartus'].get('settings_file') + self.build_cmd = "make" + + self.output_ext = ".sof" + self.output_file = os.path.join(self.build_dir, "fpga"+self.output_ext) + + def scan_log_line(self, line): + if line.startswith('quartus_map'): + self.phase = "Running synthesis and mapping" + elif line.startswith('quartus_fit'): + self.synth_done() + self.phase = "Running placement and routing" + elif line.startswith('quartus_sta'): + self.phase = "Running timing analysis" + elif line.startswith('quartus_asm'): + self.phase = "Running assembler" + + +class QuartusProBuild(QuartusBuild): + def __init__(self, design, build_dir, prefix, output): + super().__init__(design, build_dir, prefix, output) + + self.settings_file = config['quartus-pro'].get('settings_file') + + +async def monitor_status(jobs): + start_time = datetime.datetime.now() + + while True: + + print("") + + done_count = 0 + + for job in jobs: + print(job.get_status()) + if job.elapsed_time is not None: + done_count += 1 + + s = f"Overall progress: {done_count}/{len(jobs)} jobs ({done_count/len(jobs)*100:.01f}%)" + s += " ["+str(datetime.datetime.now() - start_time).split('.')[0]+"]" + + print(s) + + await asyncio.sleep(1) + + +async def main(): + config.read("build_images.ini") + + parser = argparse.ArgumentParser() + parser.add_argument('--output_dir', type=str, default=None, help="Output directory") + parser.add_argument('--prefix', type=str, default=config['general'].get('prefix', ''), help="Prefix") + parser.add_argument('--clean', action='store_true', help="Clean") + parser.add_argument('--parallel', type=int, default=config['general'].getint('parallel', 8), help="Parallel build runs") + parser.add_argument('--synth_parallel', type=int, default=config['general'].getint('synth_parallel', 8), help="Parallel synthesis runs") + + args = parser.parse_args() + + version = run_cmd(["git", "describe", "--always", "--tags"]) + + prefix = args.prefix+"-"+version + + if args.output_dir: + output_dir = args.output_dir + else: + output_dir = os.path.abspath(os.path.join("bitfiles", prefix)) + + print(f"Git version: {version}") + print(f"Output directory: {output_dir}") + + print("Scanning...") + + scan_dirs = [x.strip() for x in config['general'].get('dirs', '').strip().split()] + jobs = [] + + if len(scan_dirs) == 0: + scan_dirs = [os.getcwd()] + + for d in scan_dirs: + path = os.path.abspath(d) + for root, dirs, files in os.walk(path): + for file in files: + if file == 'Makefile' and os.path.basename(root).startswith('fpga'): + # found a makefile + + design = os.path.relpath(root, path).split(os.sep) + if len(scan_dirs) > 1: + design = [d]+design + design = [x for x in design if x != 'fpga'] + design = [x.removeprefix('fpga').strip("_") for x in design] + + if os.path.exists(os.path.join(root, "..", "common", "vivado.mk")): + # Vivado + jobs.append(VivadoBuild(design, root, prefix, output_dir)) + + if os.path.exists(os.path.join(root, "..", "common", "xilinx.mk")): + # ISE + jobs.append(IseBuild(design, root, prefix, output_dir)) + + if (os.path.exists(os.path.join(root, "..", "common", "altera.mk")) or + os.path.exists(os.path.join(root, "..", "common", "quartus.mk"))): + # Quartus Prime + jobs.append(QuartusBuild(design, root, prefix, output_dir)) + + if (os.path.exists(os.path.join(root, "..", "common", "quartus_pro.mk"))): + # Quartus Prime Pro + jobs.append(QuartusProBuild(design, root, prefix, output_dir)) + + jobs.sort(key=lambda job: job.design) + + print(f"Found {len(jobs)} design variants") + + print("Building...") + + os.makedirs(output_dir, exist_ok=True) + + build_sem = asyncio.Semaphore(args.parallel) + synth_sem = asyncio.Semaphore(args.synth_parallel) + + job_coros = [] + + for job in jobs: + if args.clean: + job.build_cmd = "make clean" + + job_coros.append(asyncio.create_task(job.run(build_sem, synth_sem))) + + status = asyncio.create_task(monitor_status(jobs)) + + await asyncio.wait(job_coros) + + await asyncio.sleep(1) + + status.cancel() + + run_cmd(["./collect_utilization.py", + "--csv", os.path.join(output_dir, "utilization.csv"), + "--log", os.path.join(output_dir, "utilization.txt") + ]) + + +if __name__ == '__main__': + asyncio.run(main()) diff --git a/fpga/collect_utilization.py b/fpga/collect_utilization.py new file mode 100755 index 000000000..a9936e5ba --- /dev/null +++ b/fpga/collect_utilization.py @@ -0,0 +1,284 @@ +#!/usr/bin/env python3 +""" + +Copyright (c) 2022 Alex Forencich + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +""" + +import argparse +import os +import re + + +class record(object): + def __init__(self): + self.file = None + self.project_dir = None + self.tool = None + self.date = None + self.device = None + self.lut_used = None + self.lut_total = None + self.ff_used = None + self.ff_total = None + self.bram_used = None + self.bram_total = None + self.uram_used = None + self.uram_total = None + self.wns = None + self.tns = None + + def format_str(self): + s = f"File: {self.file}\n" + s += f"Project directory: {self.project_dir}\n" + s += f"Tool: {self.tool}\n" + s += f"Date: {self.date}\n" + s += f"Device: {self.device}\n" + s += f"LUTs: {self.lut_used} / {self.lut_total} ({self.lut_used/self.lut_total*100:.2f}%)\n" + s += f"FFs: {self.ff_used} / {self.ff_total} ({self.ff_used/self.ff_total*100:.2f}%)\n" + if self.bram_total: + s += f"BRAM: {self.bram_used} / {self.bram_total} ({self.bram_used/self.bram_total*100:.2f}%)\n" + else: + s += "BRAM: N/A\n" + if self.uram_total: + s += f"URAM: {self.uram_used} / {self.uram_total} ({self.uram_used/self.uram_total*100:.2f}%)\n" + else: + s += "URAM: N/A\n" + s += f"Slack: WNS {self.wns} ns, TNS {self.tns} ns" + return s + + def format_csv(self): + s = f"\"{self.file}\"," + s += f"\"{self.project_dir}\"," + s += f"\"{self.tool}\"," + s += f"\"{self.date}\"," + s += f"\"{self.device}\"," + s += f"{self.lut_used},{self.lut_total}," + s += f"{self.ff_used},{self.ff_total}," + s += f"{self.bram_used},{self.bram_total}," + s += f"{self.uram_used},{self.uram_total}," + s += f"{self.wns},{self.tns}\n" + return s + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument('-d', '--dir', type=str, default='.', help="directory") + parser.add_argument('--csv', type=str, help="CSV file") + parser.add_argument('--log', type=str, help="log file") + + args = parser.parse_args() + + records = [] + + for root, dirs, files in os.walk(args.dir): + for file in files: + # Vivado + if file.endswith("_utilization_placed.rpt"): + r = record() + + fn = os.path.join(root, file) + r.file = fn + r.project_dir = os.path.dirname(os.path.dirname(root)) + + s = '' + with open(fn, 'r') as f: + s = f.read() + + m = re.search(r'Tool Version\s*:\s*(.+)', s) + r.tool = m.group(1) + + m = re.search(r'Date\s*:\s*(.+)', s) + r.date = m.group(1) + + m = re.search(r'Device\s*:\s*(.+)', s) + r.device = m.group(1) + + m = re.search(r'^\|\s*(?:CLB|Slice) LUTs\s*\|(.+)\|$', s, re.M) + lst = m.group(1).split("|") + r.lut_used = int(lst[0]) + r.lut_total = int(lst[2 if '.' in lst[3] else 3]) + + m = re.search(r'^\|\s*(?:CLB|Slice) Registers\s*\|(.+)\|$', s, re.M) + lst = m.group(1).split("|") + r.ff_used = int(lst[0]) + r.ff_total = int(lst[2 if '.' in lst[3] else 3]) + + m = re.search(r'^\|\s*Block RAM Tile\s*\|(.+)\|$', s, re.M) + lst = m.group(1).split("|") + r.bram_used = float(lst[0]) + r.bram_total = int(lst[2 if '.' in lst[3] else 3]) + + m = re.search(r'^\|\s*URAM\s*\|(.+)\|$', s, re.M) + if m: + lst = m.group(1).split("|") + r.uram_used = int(lst[0]) + r.uram_total = int(lst[2 if '.' in lst[3] else 3]) + + fn = os.path.join(root, file.replace("_utilization_placed.rpt", "_timing_summary_routed.rpt")) + if os.path.isfile(fn): + + lines = [] + with open(fn, 'r') as f: + lines = f.readlines() + + line = None + for i in range(len(lines)): + if "Design Timing Summary" in lines[i]: + line = i + + fields = lines[line+6].split() + + r.wns = fields[0] + r.tns = fields[1] + + records.append(r) + + # ISE + if file.endswith("_map.mrp"): + r = record() + r.tool = "ISE" + + fn = os.path.join(root, file) + r.file = fn + r.project_dir = root + + s = '' + with open(fn, 'r') as f: + s = f.read() + + m = re.search(r'Release\s*(\S+)\s*Map', s) + r.tool = "ISE "+m.group(1) + + m = re.search(r'Mapped Date\s*:\s*(.+)', s) + r.date = m.group(1) + + m = re.search(r'Target Device\s*:\s*(.+)', s) + r.device = m.group(1) + m = re.search(r'Target Package\s*:\s*(.+)', s) + r.device += m.group(1) + m = re.search(r'Target Speed\s*:\s*(.+)', s) + r.device += m.group(1) + + m = re.search(r'Slice LUTs\:\s*(\S+)\s*out of\s*(\S+)\s*(\d+)', s) + r.lut_used = int(m.group(1).replace(',', '')) + r.lut_total = int(m.group(2).replace(',', '')) + + m = re.search(r'Slice Registers\:\s*(\S+)\s*out of\s*(\S+)\s*(\d+)', s) + r.ff_used = int(m.group(1).replace(',', '')) + r.ff_total = int(m.group(2).replace(',', '')) + + m = re.search(r'(?:RAMB16BWER|RAMB36E1)[^:]*\:\s*(\S+)\s*out of\s*(\S+)\s*(\d+)', s) + r.bram_used = float(m.group(1).replace(',', '')) + r.bram_total = int(m.group(2).replace(',', '')) + + records.append(r) + + # Quartus + if file.endswith(".fit.summary"): + r = record() + r.tool = "Quartus" + + fn = os.path.join(root, file) + r.file = fn + r.project_dir = root + + s = '' + with open(fn, 'r') as f: + s = f.read() + + m = re.search(r'(Quartus.+Version\s*:\s*.+)', s) + r.tool = m.group(1) + + m = re.search(r'Fitter Status.+-\s*(.+)', s) + r.date = m.group(1) + + m = re.search(r'Device\s*:\s*(.+)', s) + r.device = m.group(1) + + m = re.search(r'Total combinational functions[^:]*\:\s*(\S+)\s*/\s*(\S+)\s*\([^\d]+(\d+)', s) + if m: + r.lut_used = int(m.group(1).replace(',', '')) + r.lut_total = int(m.group(2).replace(',', '')) + else: + m = re.search(r'Logic utilization[^:]*\:\s*(\S+)\s*/\s*(\S+)\s*\([^\d]+(\d+)', s) + r.lut_used = int(m.group(1).replace(',', '')) + r.lut_total = int(m.group(2).replace(',', '')) + + m = re.search(r'Dedicated logic registers[^:]*\:\s*(\S+)\s*/\s*(\S+)\s*\([^\d]+(\d+)', s) + if m: + r.ff_used = int(m.group(1).replace(',', '')) + r.ff_total = int(m.group(2).replace(',', '')) + + m = re.search(r'Total (?:dedicated logic)?\s*registers[^:]*\:\s*(\S+)', s) + if m: + r.ff_used = int(m.group(1).replace(',', '')) + r.ff_total = r.lut_total + + m = re.search(r'RAM Blocks[^:]*\:\s*(\S+)\s*/\s*(\S+)\s*\([^\d]+(\d+)', s) + if m: + r.bram_used = int(m.group(1).replace(',', '')) + r.bram_total = int(m.group(2).replace(',', '')) + + fn = os.path.join(root, file.replace(".fit.summary", ".sta.summary")) + if os.path.isfile(fn): + + lines = [] + with open(fn, 'r') as f: + lines = f.readlines() + + wns = 1e9 + tns = 0 + + for line in lines: + m = re.search(r'(\S+)\s*\:\s*(\S+)', line) + if m: + if m.group(1) == "Slack": + wns = min(wns, float(m.group(2))) + if m.group(1) == "TNS": + tns += float(m.group(2)) + + r.wns = wns + r.tns = tns + + records.append(r) + + records.sort(key=lambda r: r.file) + + for r in records: + print(r.format_str()) + print() + + if args.log: + with open(args.log, 'w') as f: + for r in records: + f.write(r.format_str()) + f.write("\n\n") + + if args.csv: + with open(args.csv, 'w') as f: + f.write("file,project_dir,tool,date,device,lut_used,lut_total,ff_used,ff_total,bram_used,bram_total,uram_used,uram_total,wns,tns\n") + for r in records: + f.write(r.format_csv()) + + +if __name__ == '__main__': + main() diff --git a/fpga/watch.sh b/fpga/watch.sh new file mode 100755 index 000000000..dc6371429 --- /dev/null +++ b/fpga/watch.sh @@ -0,0 +1,4 @@ +#!/bin/bash + +watch "./collect_utilization.py --csv output.csv --log output.log > /dev/null ; cat output.csv | column -s, -t -H 1 -T 3 -c 210" +