diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 65abc1b..5b33bef 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,10 +20,19 @@ jobs: with: jvm: adopt:11 apps: sbt - - name: Setup Dependencies - run: sudo apt-get install verilator + - name: Install OSS CAD Suite + run: | # always fetch the most recent release + wget \ + $(wget -qO- \ + https://api.github.com/repos/YosysHQ/oss-cad-suite-build/releases/latest \ + | grep browser_download_url \ + | grep 'linux-x64.*tgz' \ + | cut -d '"' -f 4) + tar -xzf oss-cad-suite-linux-x64*.tgz - name: Run tests - run: sbt test + run: | + source oss-cad-suite/environment + sbt test docs: name: Generate and Deploy Docs diff --git a/src/main/scala/approx/util/Synthesis.scala b/src/main/scala/approx/util/Synthesis.scala new file mode 100644 index 0000000..7e1038f --- /dev/null +++ b/src/main/scala/approx/util/Synthesis.scala @@ -0,0 +1,440 @@ +package approx.util + +import chisel3.RawModule + +import circt.stage.ChiselStage + +import java.nio.file.{Files, Path, Paths} + +import scala.jdk.CollectionConverters._ +import scala.sys.process._ +import scala.util.{Try, Success, Failure} + +object Synthesis { + + final val BuildDir = "build" + final val VivadoBuildDir = s"${BuildDir}/vivado" + final val YosysBuildDir = s"${BuildDir}/yosys" + + final val VerilogModuleNameRegex = """module\s+([A-Za-z_]\w*)\s*\(""".r + + final val VivadoSynthesisLUTRegex = """\|\s*Slice\s+LUTs\*\s*\|\s*(\d+)\s*\|""".r + final val VivadoImplementationLUTRegex = """\|\s*Slice\s+LUTs\s*\|\s*(\d+)\s*\|""".r + final val VivadoFFRegex = """\|\s*Slice\s+Registers\s*\|\s*(\d+)\s*\|""".r + final val VivadoDSPRegex = """\|\s*DSPs\s*\|\s*(\d+)\s*\|""".r + + case class VivadoSynthesisResults(buildDir: String, report: Option[String], lut: Option[Int], ff: Option[Int], dsp: Option[Int]) + case class VivadoImplementationResults(buildDir: String, report: Option[String], lut: Option[Int], ff: Option[Int], dsp: Option[Int]) + + case class YosysSynthesisResults(buildDir: String, report: Option[String], nand: Option[Int], not: Option[Int], ff: Option[Int], others: Map[String, Int]) + + /** Generate a helper Makefile for synthesis and implementation + * + * @param dir the directory where the Makefile should be created + */ + private[Synthesis] def generateHelperMakefile(dir: String) = { + Files.createDirectories(Paths.get(dir)) + val make = s""" + |include *.mk + | + |.PHONY: print-all-variables + |## Print all variables and their values (excluding environment, default, and automatic variables) + |print-all-variables: + |\t@$$(foreach V,$$(sort $$(.VARIABLES)), \\ + |\t\t$$(if $$(filter-out environment% default automatic,$$(origin $$V)), \\ + |\t\t\t$$(info $$(V) = $$($$(V))) \\ + |\t\t) \\ + |\t) + | + |.PHONY: help + |## Print available targets + |help: + |\t@awk '\\ + |\t\t/^## / { help=$$$$0; sub(/^## /, "", help); next } \\ + |\t\t/^[a-zA-Z0-9_-]+:/ { \\ + |\t\t\tif (help != "") { \\ + |\t\t\t\tprintf " %-24s %s\\n", $$$$1, help; \\ + |\t\t\t\thelp="" \\ + |\t\t\t} \\ + |\t\t} \\ + |\t' $$(MAKEFILE_LIST) + |""".stripMargin + Files.write(Paths.get(dir, "Makefile"), make.getBytes) + } + + /** Run a make target in a given directory + * + * @param dir the directory where the Makefile is located + * @param target the make target to run + * @return a tuple of (exit code, stdout/stderr content) + */ + private[Synthesis] def runMakeTarget(dir: String, target: String): (Int, String) = { + println(s"Running make target: make -C ${dir} ${target}") + val stdout = new StringBuilder + val logger = ProcessLogger(line => stdout.append(line).append("\n")) + val exitCode = Process(Seq("make", "-C", dir, target)).!(logger) + (exitCode, stdout.toString) + } + + /** Get (the first) file in a given directory + * + * @param dir the directory to search + * @param pattern a pattern to match files against + * @return an Option containing the first matching file path, if any + */ + private[Synthesis] def getFileInDir(dir: String, pattern: String): Option[Path] = { + val stream = Files.newDirectoryStream(Paths.get(dir), pattern) + try { + stream.iterator().asScala.toList.headOption + } finally { + stream.close() + } + } + + /** Generates Vivado synthesis and implementation TCL scripts along with the + * corresponding SystemVerilog source for a given Chisel module + * + * @param gen a function that generates the Chisel module to be synthesized + * @param part the target FPGA part number (defaults to "xc7a35t") + * @return a Try containing a tuple of (build directory, SV filename) + */ + def generateVivadoSources(gen: () => RawModule, part: String = "xc7a35t"): Try[(String, String)] = { + Try { + // Generate SystemVerilog source first to get the module name; + // regex extraction assumes the top module is the last one defined + val sv = ChiselStage.emitSystemVerilog(gen(), firtoolOpts = Array("--disable-layers", "Verification")) + val topName = VerilogModuleNameRegex.findAllMatchIn(sv) + .map(_.group(1)).toList + .lastOption + .getOrElse(throw new RuntimeException("Failed to extract top module name from generated SystemVerilog")) + + // Ensure unique build directory exists + val buildDir = Paths.get(s"${VivadoBuildDir}/${topName}_${part}") + Files.createDirectories(buildDir) + + // Generate SystemVerilog source file + val svFile = s"${topName}.sv" + Files.write(buildDir.resolve(svFile), sv.getBytes) + + // Generate helper Makefile + val make = s""" + |SV_FILE :=${svFile} + | + |VIVADO_PART :=${part} + | + |VIVADO_PROJ_DIR :=${topName} + |VIVADO_PROJ_TCL :=${topName}_proj.tcl + |VIVADO_PROJ_XPR :=$$(VIVADO_PROJ_DIR)/${topName}.xpr + | + |VIVADO_SYN_TCL :=${topName}_syn.tcl + |VIVADO_SYN_DCP :=${topName}_syn.dcp + |VIVADO_SYN_REPORT :=${topName}_syn.rpt + | + |VIVADO_IMPL_TCL :=${topName}_impl.tcl + |VIVADO_IMPL_DCP :=${topName}_impl.dcp + |VIVADO_IMPL_REPORT :=${topName}_impl.rpt + | + |# Macros to generate TCL scripts for synthesis and implementation + |define VIVADO_SYN_TCL_CONTENT + |read_verilog $$(SV_FILE) + |synth_design -top ${topName} -part $$(VIVADO_PART) + |report_utilization -file $$(VIVADO_SYN_REPORT) + |write_checkpoint -force $$(VIVADO_SYN_DCP) + | + |endef + | + |$$(VIVADO_SYN_TCL): + |\t$$(file >$$@,$$(VIVADO_SYN_TCL_CONTENT)) + | + |define VIVADO_IMPL_TCL_CONTENT + |read_checkpoint $$(VIVADO_SYN_DCP) + |link_design + |opt_design + |place_design + |route_design + |report_utilization -file $$(VIVADO_IMPL_REPORT) + |write_checkpoint -force $$(VIVADO_IMPL_DCP) + | + |endef + | + |$$(VIVADO_IMPL_TCL): + |\t$$(file >$$@,$$(VIVADO_IMPL_TCL_CONTENT)) + | + |define VIVADO_PROJ_TCL_CONTENT + |create_project -force $$(VIVADO_PROJ_XPR) -part $$(VIVADO_PART) + |add_files $$(SV_FILE) + |set_property top ${topName} [current_fileset] + |update_compile_order -fileset sources_1 + | + |endef + | + |$$(VIVADO_PROJ_TCL): + |\t$$(file >$$@,$$(VIVADO_PROJ_TCL_CONTENT)) + | + |# Helpers to generate and open a Vivado project for interactive exploration + |$$(VIVADO_PROJ_XPR): $$(VIVADO_PROJ_TCL) + |\tvivado -nolog -nojournal -mode batch -source $$< + | + |.PHONY: generate-vivado-project + |## Generate Vivado project file + |generate-vivado-project: $$(VIVADO_PROJ_XPR) + | + |.PHONY: open-vivado-gui + |## Open Vivado GUI with the generated project + |open-vivado-gui: $$(VIVADO_PROJ_XPR) + |\tvivado -nolog -nojournal $$< + | + |.PHONY: open-vivado-tcl + |## Open Vivado TCL with the generated project + |open-vivado-tcl: $$(VIVADO_PROJ_XPR) + |\tvivado -nolog -nojournal -mode tcl $$< + | + |# Helper clean targets for generated files + |.PHONY: clean-vivado + |## Remove all generated Vivado files + |clean-vivado: clean-vivado-project clean-vivado-tcl clean-vivado-rpt clean-vivado-dcp + | + |.PHONY: clean-vivado-project + |## Remove generated Vivado project files + |clean-vivado-project: + |\trm -rf $$(VIVADO_PROJ_DIR) + | + |.PHONY: clean-vivado-tcl + |## Remove generated TCL scripts + |clean-vivado-tcl: + |\trm -f $$(VIVADO_PROJ_TCL) $$(VIVADO_SYN_TCL) $$(VIVADO_IMPL_TCL) + | + |.PHONY: clean-vivado-rpt + |## Remove generated reports + |clean-vivado-rpt: + |\trm -f $$(VIVADO_SYN_REPORT) $$(VIVADO_IMPL_REPORT) + | + |.PHONY: clean-vivado-dcp + |## Remove generated checkpoints + |clean-vivado-dcp: + |\trm -f $$(VIVADO_SYN_DCP) $$(VIVADO_IMPL_DCP) + | + |$$(VIVADO_SYN_REPORT): $$(SV_FILE) $$(VIVADO_SYN_TCL) + |\tvivado -nolog -nojournal -mode batch -source $$(VIVADO_SYN_TCL) + | + |.PHONY: vivado-syn + |## Run synthesis to generate synthesis report $$(VIVADO_SYN_REPORT) + |vivado-syn: $$(VIVADO_SYN_REPORT) + | + |$$(VIVADO_IMPL_REPORT): $$(VIVADO_SYN_REPORT) $$(VIVADO_IMPL_TCL) + |\tvivado -nolog -nojournal -mode batch -source $$(VIVADO_IMPL_TCL) + | + |.PHONY: vivado-impl + |## Run implementation to generate implementation report $$(VIVADO_IMPL_REPORT) + |vivado-impl: $$(VIVADO_IMPL_REPORT) + |""".stripMargin + Files.write(buildDir.resolve("vivado.mk"), make.getBytes) + + // Generate local and common helper Makefiles + generateHelperMakefile(buildDir.toString) + + (buildDir.toString, svFile) + } + } + + /** Runs Vivado synthesis using the generated Makefile and parses the + * resulting synthesis report to extract and bundle resource utilization + * metrics into a structured result + * + * @param dir the directory containing the generated Makefile and sources + * @return a [[VivadoSynthesisResults]] instance including status and + * resource utilization metrics, if available + */ + def runVivadoSynthesis(dir: String): VivadoSynthesisResults = { + // Attempt to launch Vivado synthesis using the generated Makefile assumed + // to exist under dir + val (exitCode, stdout) = runMakeTarget(dir, "vivado-syn") + if (exitCode != 0) { + println(s"Vivado synthesis failed with exit code $exitCode") + println(s"Vivado output:\n${stdout}") + return VivadoSynthesisResults(dir, None, None, None, None) + } + + // Parse synthesis report to extract resource utilization + val synRptOpt = getFileInDir(dir, "*_syn.rpt") + synRptOpt match { + case None => + println(s"Synthesis report not found in directory: $dir") + return VivadoSynthesisResults(dir, None, None, None, None) + case _ => + } + + // Read the synthesis report and extract LUT, FF, and DSP counts using regex + val synRptContent = new String(Files.readAllBytes(synRptOpt.get)) + val lutCount = VivadoSynthesisLUTRegex.findFirstMatchIn(synRptContent).map(_.group(1).toInt) + val ffCount = VivadoFFRegex .findFirstMatchIn(synRptContent).map(_.group(1).toInt) + val dspCount = VivadoDSPRegex .findFirstMatchIn(synRptContent).map(_.group(1).toInt) + VivadoSynthesisResults(dir, synRptOpt.map(_.toString), lutCount, ffCount, dspCount) + } + + /** Runs Vivado implementation using the generated Makefile and parses the + * resulting implementation report to extract and bundle resource utilization + * metrics into a structured result + * + * @param dir the directory containing the generated Makefile and sources + * @return a [[VivadoImplementationResults]] instance including status and + * resource utilization metrics, if available + */ + def runVivadoImplementation(dir: String): VivadoImplementationResults = { + // Attempt to launch Vivado implementation using the generated Makefile + // assumed to exist under dir + val (exitCode, stdout) = runMakeTarget(dir, "vivado-impl") + if (exitCode != 0) { + println(s"Vivado implementation failed with exit code $exitCode") + println(s"Vivado output:\n${stdout}") + return VivadoImplementationResults(dir, None, None, None, None) + } + + // Parse implementation report to extract resource utilization + val implRptOpt = getFileInDir(dir, "*_impl.rpt") + implRptOpt match { + case None => + println(s"Implementation report not found in directory: $dir") + return VivadoImplementationResults(dir, None, None, None, None) + case _ => + } + + // Read the implementation report and extract LUT, FF, and DSP counts using regex + val implRptContent = new String(Files.readAllBytes(implRptOpt.get)) + val lutCount = VivadoImplementationLUTRegex.findFirstMatchIn(implRptContent).map(_.group(1).toInt) + val ffCount = VivadoFFRegex .findFirstMatchIn(implRptContent).map(_.group(1).toInt) + val dspCount = VivadoDSPRegex .findFirstMatchIn(implRptContent).map(_.group(1).toInt) + VivadoImplementationResults(dir, implRptOpt.map(_.toString), lutCount, ffCount, dspCount) + } + + /** Generates Yosys synthesis sources and returns the directory and SV file paths + * + * @param gen a function that generates the Chisel module to be synthesized + * @return a Try containing a tuple of (build directory, SV filename) + */ + def generateYosysSources(gen: () => RawModule): Try[(String, String)] = { + Try { + // Generate SystemVerilog source first to get the module name; + // regex extraction assumes the top module is the last one defined + val sv = ChiselStage.emitSystemVerilog(gen(), firtoolOpts = Array("--disable-layers", "Verification")) + val topName = VerilogModuleNameRegex.findAllMatchIn(sv) + .map(_.group(1)).toList + .lastOption + .getOrElse(throw new RuntimeException("Failed to extract top module name from generated SystemVerilog")) + + // Ensure unique build directory exists + val buildDir = Paths.get(s"${YosysBuildDir}/${topName}") + Files.createDirectories(buildDir) + + // Generate SystemVerilog source file + val svFile = s"${topName}.sv" + Files.write(buildDir.resolve(svFile), sv.getBytes) + + // Generate helper Makefile + val make = s""" + |SV_FILE :=${svFile} + | + |YOSYS_SYN_YS :=${topName}_syn.ys + |YOSYS_SYN_NL_JSON :=${topName}_syn.json + |YOSYS_SYN_NL_VLOG :=${topName}_syn.v + |YOSYS_SYN_REPORT :=${topName}_syn.rpt + | + |# Macros to generate Yosys synthesis script + |define YOSYS_SYN_YS_CONTENT + |read_verilog -sv $$(SV_FILE) + |hierarchy -check -top ${topName} + | + |# Generic synthesis flow + |proc; opt + |fsm; opt + |memory; opt + |flatten -noscopeinfo; opt_clean + |techmap; opt + |abc -g NAND; opt_clean + | + |# Statistics in json format + |tee -o $$(YOSYS_SYN_REPORT) stat -json + | + |# Netlist outputs + |write_json $$(YOSYS_SYN_NL_JSON) + |write_verilog -sv $$(YOSYS_SYN_NL_VLOG) + | + |endef + | + |$$(YOSYS_SYN_YS): + |\t$$(file >$$@,$$(YOSYS_SYN_YS_CONTENT)) + | + |# Helper clean targets for generated files + |.PHONY: clean-yosys + |## Remove all generated Yosys files + |clean-yosys: clean-yosys-syn-netlist clean-yosys-syn-rpt + | + |.PHONY: clean-yosys-syn-netlist + |## Remove generated Yosys synthesis files + |clean-yosys-syn-netlist: + |\trm -f $$(YOSYS_SYN_YS) $$(YOSYS_SYN_NL_JSON) $$(YOSYS_SYN_NL_VLOG) + | + |.PHONY: clean-yosys-syn-rpt + |## Remove generated Yosys synthesis report + |clean-yosys-syn-rpt: + |\trm -f $$(YOSYS_SYN_REPORT) + | + |$$(YOSYS_SYN_REPORT): $$(SV_FILE) $$(YOSYS_SYN_YS) + |\tyosys -s $$(YOSYS_SYN_YS) + | + |.PHONY: yosys-syn + |## Run Yosys synthesis to generate report $$(YOSYS_SYN_REPORT) + |yosys-syn: $$(YOSYS_SYN_REPORT) + |""".stripMargin + Files.write(buildDir.resolve("yosys.mk"), make.getBytes) + + // Generate local and common helper Makefiles + generateHelperMakefile(buildDir.toString) + + (buildDir.toString, svFile) + } + } + + /** Runs Yosys synthesis using the generated Makefile and parses the + * resulting synthesis report to extract and bundle resource utilization + * metrics into a structured result + * + * @param dir the directory containing the generated Makefile and sources + * @return a [[YosysSynthesisResults]] instance including status and + * resource utilization metrics, if available + */ + def runYosysSynthesis(dir: String): YosysSynthesisResults = { + // Attempt to launch Yosys synthesis using the generated Makefile assumed + // to exist under dir + val (exitCode, stdout) = runMakeTarget(dir, "yosys-syn") + if (exitCode != 0) { + println(s"Yosys synthesis failed with exit code $exitCode") + println(s"Yosys output:\n${stdout}") + return YosysSynthesisResults(dir, None, None, None, None, Map.empty) + } + + // Parse synthesis report to extract resource utilization + val synRptOpt = getFileInDir(dir, "*_syn.rpt") + synRptOpt match { + case None => + println(s"Synthesis report not found in directory: $dir") + return YosysSynthesisResults(dir, None, None, None, None, Map.empty) + case _ => + } + + // Read the synthesis report and extract NAND, NOT, and FF counts using + // json parsing; hardcoded keys for now + val synRptContent = ujson.read(Files.readString(synRptOpt.get)) + val cellCounts = synRptContent("design")("num_cells_by_type").obj + val nandCount = cellCounts.get("$_NAND_").map(_.num.toInt) + val notCount = cellCounts.get("$_NOT_").map(_.num.toInt) + val ffCount = { + val cnts = cellCounts.collect { case (k, v) if k.startsWith("$_DFF") => v.num.toInt } + if (cnts.isEmpty) None else Some(cnts.sum) + } + val otherCounts = cellCounts.collect { + case (k, v) if k != "$_NAND_" && k != "$_NOT_" && !k.startsWith("$_DFF") => k -> v.num.toInt + }.toMap + YosysSynthesisResults(dir, synRptOpt.map(_.toString), nandCount, notCount, ffCount, otherCounts) + } +} diff --git a/src/test/scala/approx/util/SynthesisSpec.scala b/src/test/scala/approx/util/SynthesisSpec.scala new file mode 100644 index 0000000..d79f2ff --- /dev/null +++ b/src/test/scala/approx/util/SynthesisSpec.scala @@ -0,0 +1,96 @@ +package approx.util + +import java.nio.file.{Files, Paths} + +import scala.sys.process._ +import scala.util.{Try, Success, Failure} + +import org.scalatest.matchers.should.Matchers +import org.scalatest.flatspec.AnyFlatSpec + +class SynthesisSpec extends AnyFlatSpec with Matchers { + behavior of "Synthesis" + + val runVivado = sys.env.contains("XILINX_VIVADO") + // oss-cad-suite environment does not set a clear environment variable + val runYosys = Try { Seq("yosys", "-V").! }.toOption.contains(0) + + if (runVivado) { + println(s"Vivado environment detected") + + it should "generate Vivado synthesis and implementation sources for an RCA adder" in { + val (dir, sv) = Synthesis.generateVivadoSources(() => new approx.addition.RCA(8)) match { + case Success(result) => result + case Failure(exp) => fail(s"Source generation failed with exception: ${exp.getMessage}") + } + val svPath = Paths.get(dir, sv) + Files.exists(svPath) shouldBe true + } + + it should "run Vivado synthesis and parse results for an RCA adder" in { + val (dir, sv) = Synthesis.generateVivadoSources(() => new approx.addition.RCA(8)) match { + case Success(result) => result + case Failure(exp) => fail(s"Source generation failed with exception: ${exp.getMessage}") + } + val results = Synthesis.runVivadoSynthesis(dir) + println(s"Synthesis results: ${results}") + results.report shouldBe defined + results.lut shouldBe defined + results.lut should equal(Some(8)) + results.ff shouldBe defined + results.ff should equal(Some(0)) + results.dsp shouldBe defined + results.dsp should equal(Some(0)) + } + + it should "run Vivado implementation and parse results for an RCA adder" in { + val (dir, sv) = Synthesis.generateVivadoSources(() => new approx.addition.RCA(8)) match { + case Success(result) => result + case Failure(exp) => fail(s"Source generation failed with exception: ${exp.getMessage}") + } + // implementation indirectly calls synthesis + val implResults = Synthesis.runVivadoImplementation(dir) + println(s"Implementation results: ${implResults}") + implResults.report shouldBe defined + implResults.lut shouldBe defined + implResults.lut should equal(Some(8)) + implResults.ff shouldBe defined + implResults.ff should equal(Some(0)) + implResults.dsp shouldBe defined + implResults.dsp should equal(Some(0)) + } + } else { + println("Vivado environment not detected; skipping synthesis tests") + } + + if (runYosys) { + println(s"Yosys environment detected") + + it should "generate Yosys synthesis sources for an RCA adder" in { + val (dir, sv) = Synthesis.generateYosysSources(() => new approx.addition.RCA(8)) match { + case Success(result) => result + case Failure(exp) => fail(s"Source generation failed with exception: ${exp.getMessage}") + } + val svPath = Paths.get(dir, sv) + Files.exists(svPath) shouldBe true + } + + it should "run Yosys synthesis and parse results for an RCA adder" in { + val (dir, sv) = Synthesis.generateYosysSources(() => new approx.addition.RCA(8)) match { + case Success(result) => result + case Failure(exp) => fail(s"Source generation failed with exception: ${exp.getMessage}") + } + val results = Synthesis.runYosysSynthesis(dir) + println(s"Synthesis results: ${results}") + results.report shouldBe defined + results.nand shouldBe defined + results.nand should equal(Some(76)) + results.not shouldBe defined + results.not should equal(Some(25)) + results.ff should not be defined // no FFs in an RCA + results.others shouldBe empty + } + } else { + println("Yosys environment not detected; skipping synthesis tests") + } +}