diff --git a/fpga_diff/Makefile b/fpga_diff/Makefile index b10dbf17..037280be 100755 --- a/fpga_diff/Makefile +++ b/fpga_diff/Makefile @@ -23,7 +23,7 @@ PRJ_NAME = fpga_$(CPU)$(if $(strip $(SUFFIX)),-$(strip $(SUFFIX)),) PRJ_DIR ?= $(ENV_SCRIPTS_HOME)/$(PRJ_NAME) PRJ ?= $(PRJ_DIR)/$(PRJ_NAME).xpr -.PHONY: bitstream vivado dump_ila write_jtag_flash +.PHONY: bitstream route vivado dump_ila write_jtag_flash incremental-flow cpu-dcp-interface cpu-dcp-flow # Get Vivado version VIVADO_VERSION := $(shell vivado -version 2>/dev/null | head -1 | grep -o '[0-9]\{4\}\.[0-9]' || echo "unknown") @@ -39,6 +39,10 @@ synth: bitstream: vivado -mode batch -source ./tools/gen_bitstream.tcl -tclargs $(PRJ) impl_1 $(VIVADO_JOBS) +# Run implementation through route_design without writing a bitstream +route: + vivado -mode batch -source ./tools/run_impl_route.tcl -tclargs $(PRJ) impl_1 $(VIVADO_JOBS) + # Update file list for core RTL files update_core_flist: rm -rf $(CORE_DIR)/rtl/verification @@ -98,6 +102,75 @@ add_sys_option: get_impl_log: cat $(PRJ_DIR)/$(PRJ_NAME).runs/impl_1/runme.log +INCREMENTAL_BASELINE_RELEASE ?= +INCREMENTAL_MODIFIED_RELEASE ?= +INCREMENTAL_OUT_DIR ?= +INCREMENTAL_EXTRA_ARGS ?= + +# Run whole-project incremental synthesis and implementation. +incremental-flow: + tools/cpu_dcp_split/run_incremental_flow.py \ + --baseline-release "$(INCREMENTAL_BASELINE_RELEASE)" \ + --modified-release "$(INCREMENTAL_MODIFIED_RELEASE)" \ + --cpu "$(CPU)" \ + $(if $(strip $(INCREMENTAL_OUT_DIR)),--out-dir "$(INCREMENTAL_OUT_DIR)",) \ + $(if $(strip $(VIVADO_JOBS)),--jobs "$(VIVADO_JOBS)",) \ + $(INCREMENTAL_EXTRA_ARGS) + + +CPU_DCP_MODIFIED_RELEASE ?= +CPU_DCP_CPU_MODULE ?= +CPU_DCP_PARTITION_MODULE ?= +CPU_DCP_INTERFACE_MODE ?= stub +CPU_DCP_INTERFACE_OUT ?= +CPU_DCP_INTERFACE_JSON ?= +CPU_DCP_BASELINE_RELEASE ?= +CPU_DCP_REFERENCE_DCP ?= +CPU_DCP_REFERENCE_SYNTH_DCP ?= +CPU_DCP_REFERENCE_FINGERPRINT ?= +CPU_DCP_CPU_CELL ?= +CPU_DCP_CPU_INSTANCE ?= +CPU_DCP_SUBPARTITION_PRESET ?= +CPU_DCP_DCP ?= +CPU_DCP_OUT_DIR ?= +CPU_DCP_STOP_AFTER ?= route +CPU_DCP_SYNTH_INCREMENTAL_MODE ?= default +CPU_DCP_EXTRA_ARGS ?= + +# Copy the generated CPU RTL port contract onto a CpuDcpTop-style partition +# module. This is useful for inspecting or regenerating only the DCP shell +# interface without running Vivado. +cpu-dcp-interface: + tools/cpu_dcp_split/sync_cpu_dcp_interface.py \ + --release "$(CPU_DCP_MODIFIED_RELEASE)" \ + $(if $(strip $(CPU_DCP_CPU_MODULE)),--cpu-module "$(CPU_DCP_CPU_MODULE)",) \ + --partition-module "$(or $(strip $(CPU_DCP_PARTITION_MODULE)),CpuDcpTop)" \ + --mode "$(CPU_DCP_INTERFACE_MODE)" \ + $(if $(strip $(CPU_DCP_INTERFACE_OUT)),--out "$(CPU_DCP_INTERFACE_OUT)" --force,) \ + $(if $(strip $(CPU_DCP_INTERFACE_JSON)),--json-out "$(CPU_DCP_INTERFACE_JSON)",) + + +# Run the DiffTest hot path with CPU fixed as a DCP partition. If no routed +# reference is provided, the flow builds one from the baseline release. +cpu-dcp-flow: + tools/cpu_dcp_split/run_cpu_dcp_flow.py \ + --baseline-release "$(CPU_DCP_BASELINE_RELEASE)" \ + --modified-release "$(CPU_DCP_MODIFIED_RELEASE)" \ + $(if $(strip $(CPU)),--cpu "$(CPU)",) \ + $(if $(strip $(CPU_DCP_REFERENCE_DCP)),--reference-routed-dcp "$(CPU_DCP_REFERENCE_DCP)",) \ + $(if $(strip $(CPU_DCP_REFERENCE_SYNTH_DCP)),--reference-synth-dcp "$(CPU_DCP_REFERENCE_SYNTH_DCP)",) \ + $(if $(strip $(CPU_DCP_REFERENCE_FINGERPRINT)),--reference-fingerprint "$(CPU_DCP_REFERENCE_FINGERPRINT)",) \ + $(if $(strip $(CPU_DCP_CPU_CELL)),--cpu-cell "$(CPU_DCP_CPU_CELL)",) \ + $(if $(strip $(CPU_DCP_CPU_MODULE)),--module "$(CPU_DCP_CPU_MODULE)",) \ + $(if $(strip $(CPU_DCP_CPU_INSTANCE)),--cpu-instance "$(CPU_DCP_CPU_INSTANCE)",) \ + $(if $(strip $(CPU_DCP_SUBPARTITION_PRESET)),--subpartition-preset "$(CPU_DCP_SUBPARTITION_PRESET)",--partition-module "$(or $(strip $(CPU_DCP_PARTITION_MODULE)),CpuDcpTop)") \ + $(if $(strip $(CPU_DCP_DCP)),--cpu-dcp "$(CPU_DCP_DCP)",) \ + $(if $(strip $(CPU_DCP_REFERENCE_DCP)),,--build-reference) \ + $(if $(strip $(CPU_DCP_OUT_DIR)),--out-dir "$(CPU_DCP_OUT_DIR)",) \ + --stop-after "$(CPU_DCP_STOP_AFTER)" \ + --synth-incremental-mode "$(CPU_DCP_SYNTH_INCREMENTAL_MODE)" \ + $(if $(strip $(VIVADO_JOBS)),--jobs "$(VIVADO_JOBS)",) \ + $(CPU_DCP_EXTRA_ARGS) # Display synthesis log get_synth_log: cat $(PRJ_DIR)/$(PRJ_NAME).runs/synth_1/runme.log diff --git a/fpga_diff/README.md b/fpga_diff/README.md index ec464c50..d3c28472 100755 --- a/fpga_diff/README.md +++ b/fpga_diff/README.md @@ -48,4 +48,50 @@ FPGA_DDR_LOAD_CMD="bash -lc ' \ ./fpga-host --diff -i .bin ``` +## Incremental Vivado Flows +The incremental drivers consume generated release directories containing +`build/rtl` and write checkpoints, logs, and manifests outside the release. + +### Whole Project + +```sh +make incremental-flow \ + CPU=nutshell \ + INCREMENTAL_BASELINE_RELEASE=/path/to/baseline-release \ + INCREMENTAL_MODIFIED_RELEASE=/path/to/modified-release \ + INCREMENTAL_OUT_DIR=/path/to/output \ + VIVADO_JOBS=8 +``` + +Use `INCREMENTAL_EXTRA_ARGS` for `--stop-after route`, +`--synth-incremental-mode quick`, `--impl-directive RuntimeOptimized`, or +`--dry-run`. Inspect `/manifest.env`, checkpoint files, incremental +reuse reports, timing, and route status before accepting a result. + +Both flows also write `implementation-fingerprint.json`. It hashes the release +build inputs, implementation Tcl/Make inputs, CPU and partition interfaces, and +the Vivado/implementation settings. Its `decision.recommended_action` states +whether no implementation changed, the CPU DCP is reusable, or a whole-project +incremental route is required. + +### CPU-DCP + +```sh +make cpu-dcp-flow \ + CPU=nutshell \ + CPU_DCP_BASELINE_RELEASE=/path/to/baseline-release \ + CPU_DCP_MODIFIED_RELEASE=/path/to/modified-release \ + CPU_DCP_OUT_DIR=/path/to/output \ + VIVADO_JOBS=8 +``` + +This flow checks release compatibility, prepares an OOC CPU checkpoint, imports +it into the top-level partition, then performs incremental implementation. Use +`make cpu-dcp-interface` to generate or validate only a `CpuDcpTop` interface. +When a routed reference is supplied, inspect `report_incremental_reuse`, +`timing_summary.rpt`, and `route_status.rpt` under the output directory. +For an externally retained routed DCP, also pass its prior fingerprint through +`CPU_DCP_REFERENCE_FINGERPRINT=/path/to/implementation-fingerprint.json`; the +flow rejects reuse if the reference baseline, interface, or implementation +context no longer matches. diff --git a/fpga_diff/src/rtl/nutshell/SimTop_wrapper.sv b/fpga_diff/src/rtl/nutshell/SimTop_wrapper.sv index 4df1c88f..cd90a8bf 100755 --- a/fpga_diff/src/rtl/nutshell/SimTop_wrapper.sv +++ b/fpga_diff/src/rtl/nutshell/SimTop_wrapper.sv @@ -186,6 +186,18 @@ SimTop u_SimTop ( .clock (inter_soc_clk), .reset (~sys_rstn_i), + .difftest_exit (), + .difftest_step (), + .difftest_perfCtrl_clean (1'b0), + .difftest_perfCtrl_dump (1'b0), + .difftest_logCtrl_begin (64'b0), + .difftest_logCtrl_end (64'hFFFFFFFFFFFFFFFF), + .difftest_logCtrl_level (64'b0), + .difftest_uart_out_valid (), + .difftest_uart_out_ch (), + .difftest_uart_in_valid (), + .difftest_uart_in_ch (8'b0), + // AXI4 MEMORY (flattened port names) .difftest_mem_awready (mem_core_awready), .difftest_mem_awvalid (mem_core_awvalid), @@ -234,98 +246,98 @@ SimTop u_SimTop ( .difftest_mem_ruser ('b0), // AXI4 MMIO - .io_mmio_awready (peri_awready), - .io_mmio_awvalid (peri_awvalid), - .io_mmio_awaddr (peri_awaddr), - .io_mmio_awprot (peri_awprot), - .io_mmio_awid (peri_awid), - .io_mmio_awuser (/* TODO */), - .io_mmio_awlen (peri_awlen), - .io_mmio_awsize (peri_awsize), - .io_mmio_awburst (peri_awburst), - .io_mmio_awlock (peri_awlock), - .io_mmio_awcache (peri_awcache), - .io_mmio_awqos (peri_awqos), - - .io_mmio_wready (peri_wready), - .io_mmio_wvalid (peri_wvalid), - .io_mmio_wdata (peri_wdata), - .io_mmio_wstrb (peri_wstrb), - .io_mmio_wlast (peri_wlast), - - .io_mmio_bready (peri_bready), - .io_mmio_bvalid (peri_bvalid), - .io_mmio_bresp (peri_bresp), - .io_mmio_bid (peri_bid), - .io_mmio_buser (/* TODO */), - - .io_mmio_arready (peri_arready), - .io_mmio_arvalid (peri_arvalid), - .io_mmio_araddr (peri_araddr), - .io_mmio_arprot (peri_arprot), - .io_mmio_arid (peri_arid), - .io_mmio_aruser (/* TODO */), - .io_mmio_arlen (peri_arlen), - .io_mmio_arsize (peri_arsize), - .io_mmio_arburst (peri_arburst), - .io_mmio_arlock (peri_arlock), - .io_mmio_arcache (peri_arcache), - .io_mmio_arqos (peri_arqos), - - .io_mmio_rready (peri_rready), - .io_mmio_rvalid (peri_rvalid), - .io_mmio_rresp (peri_rresp), - .io_mmio_rdata (peri_rdata), - .io_mmio_rlast (peri_rlast), - .io_mmio_rid (peri_rid), - .io_mmio_ruser (/* TODO */), + .io_mmio_aw_ready (peri_awready), + .io_mmio_aw_valid (peri_awvalid), + .io_mmio_aw_bits_addr (peri_awaddr), + .io_mmio_aw_bits_prot (peri_awprot), + .io_mmio_aw_bits_id (peri_awid), + .io_mmio_aw_bits_user (), + .io_mmio_aw_bits_len (peri_awlen), + .io_mmio_aw_bits_size (peri_awsize), + .io_mmio_aw_bits_burst (peri_awburst), + .io_mmio_aw_bits_lock (peri_awlock), + .io_mmio_aw_bits_cache (peri_awcache), + .io_mmio_aw_bits_qos (peri_awqos), + + .io_mmio_w_ready (peri_wready), + .io_mmio_w_valid (peri_wvalid), + .io_mmio_w_bits_data (peri_wdata), + .io_mmio_w_bits_strb (peri_wstrb), + .io_mmio_w_bits_last (peri_wlast), + + .io_mmio_b_ready (peri_bready), + .io_mmio_b_valid (peri_bvalid), + .io_mmio_b_bits_resp (peri_bresp), + .io_mmio_b_bits_id (peri_bid), + .io_mmio_b_bits_user (1'b0), + + .io_mmio_ar_ready (peri_arready), + .io_mmio_ar_valid (peri_arvalid), + .io_mmio_ar_bits_addr (peri_araddr), + .io_mmio_ar_bits_prot (peri_arprot), + .io_mmio_ar_bits_id (peri_arid), + .io_mmio_ar_bits_user (), + .io_mmio_ar_bits_len (peri_arlen), + .io_mmio_ar_bits_size (peri_arsize), + .io_mmio_ar_bits_burst (peri_arburst), + .io_mmio_ar_bits_lock (peri_arlock), + .io_mmio_ar_bits_cache (peri_arcache), + .io_mmio_ar_bits_qos (peri_arqos), + + .io_mmio_r_ready (peri_rready), + .io_mmio_r_valid (peri_rvalid), + .io_mmio_r_bits_resp (peri_rresp), + .io_mmio_r_bits_data (peri_rdata), + .io_mmio_r_bits_last (peri_rlast), + .io_mmio_r_bits_id (peri_rid), + .io_mmio_r_bits_user (1'b0), // AXI4 DMA / FRONTEND - .io_frontend_awready (dma_core_awready), - .io_frontend_awvalid (dma_core_awvalid), - .io_frontend_awaddr (dma_core_awaddr[31:0]), - .io_frontend_awprot (dma_core_awprot), - .io_frontend_awid (dma_core_awid), - .io_frontend_awuser (/* TODO */), - .io_frontend_awlen (dma_core_awlen), - .io_frontend_awsize (dma_core_awsize), - .io_frontend_awburst (dma_core_awburst), - .io_frontend_awlock (dma_core_awlock), - .io_frontend_awcache (dma_core_awcache), - .io_frontend_awqos (dma_core_awqos), - - .io_frontend_wready (dma_core_wready), - .io_frontend_wvalid (dma_core_wvalid), - .io_frontend_wdata (dma_core_wdata[63:0]), - .io_frontend_wstrb (dma_core_wstrb[7:0]), - .io_frontend_wlast (dma_core_wlast), - - .io_frontend_bready (dma_core_bready), - .io_frontend_bvalid (dma_core_bvalid), - .io_frontend_bresp (dma_core_bresp), - .io_frontend_bid (dma_core_bid), - .io_frontend_buser (/* TODO */), - - .io_frontend_arready (dma_core_arready), - .io_frontend_arvalid (dma_core_arvalid), - .io_frontend_araddr (dma_core_araddr[31:0]), - .io_frontend_arprot (dma_core_arprot), - .io_frontend_arid (dma_core_arid), - .io_frontend_aruser (/* TODO */), - .io_frontend_arlen (dma_core_arlen), - .io_frontend_arsize (dma_core_arsize), - .io_frontend_arburst (dma_core_arburst), - .io_frontend_arlock (dma_core_arlock), - .io_frontend_arcache (dma_core_arcache), - .io_frontend_arqos (dma_core_arqos), - - .io_frontend_rready (dma_core_rready), - .io_frontend_rvalid (dma_core_rvalid), - .io_frontend_rresp (dma_core_rresp), - .io_frontend_rdata (dma_core_rdata[63:0]), - .io_frontend_rlast (dma_core_rlast), - .io_frontend_rid (dma_core_rid), - .io_frontend_ruser (/* TODO */), + .io_frontend_aw_ready (dma_core_awready), + .io_frontend_aw_valid (dma_core_awvalid), + .io_frontend_aw_bits_addr (dma_core_awaddr[31:0]), + .io_frontend_aw_bits_prot (dma_core_awprot), + .io_frontend_aw_bits_id (dma_core_awid), + .io_frontend_aw_bits_user (1'b0), + .io_frontend_aw_bits_len (dma_core_awlen), + .io_frontend_aw_bits_size (dma_core_awsize), + .io_frontend_aw_bits_burst (dma_core_awburst), + .io_frontend_aw_bits_lock (dma_core_awlock), + .io_frontend_aw_bits_cache (dma_core_awcache), + .io_frontend_aw_bits_qos (dma_core_awqos), + + .io_frontend_w_ready (dma_core_wready), + .io_frontend_w_valid (dma_core_wvalid), + .io_frontend_w_bits_data (dma_core_wdata[63:0]), + .io_frontend_w_bits_strb (dma_core_wstrb[7:0]), + .io_frontend_w_bits_last (dma_core_wlast), + + .io_frontend_b_ready (dma_core_bready), + .io_frontend_b_valid (dma_core_bvalid), + .io_frontend_b_bits_resp (dma_core_bresp), + .io_frontend_b_bits_id (dma_core_bid), + .io_frontend_b_bits_user (), + + .io_frontend_ar_ready (dma_core_arready), + .io_frontend_ar_valid (dma_core_arvalid), + .io_frontend_ar_bits_addr (dma_core_araddr[31:0]), + .io_frontend_ar_bits_prot (dma_core_arprot), + .io_frontend_ar_bits_id (dma_core_arid), + .io_frontend_ar_bits_user (1'b0), + .io_frontend_ar_bits_len (dma_core_arlen), + .io_frontend_ar_bits_size (dma_core_arsize), + .io_frontend_ar_bits_burst (dma_core_arburst), + .io_frontend_ar_bits_lock (dma_core_arlock), + .io_frontend_ar_bits_cache (dma_core_arcache), + .io_frontend_ar_bits_qos (dma_core_arqos), + + .io_frontend_r_ready (dma_core_rready), + .io_frontend_r_valid (dma_core_rvalid), + .io_frontend_r_bits_resp (dma_core_rresp), + .io_frontend_r_bits_data (dma_core_rdata[63:0]), + .io_frontend_r_bits_last (dma_core_rlast), + .io_frontend_r_bits_id (dma_core_rid), + .io_frontend_r_bits_user (), // MIP .io_meip (io_extIntrs[1:0]), diff --git a/fpga_diff/tools/cpu_dcp_split/build_cpu_ooc_dcp.py b/fpga_diff/tools/cpu_dcp_split/build_cpu_ooc_dcp.py new file mode 100755 index 00000000..1452ad0d --- /dev/null +++ b/fpga_diff/tools/cpu_dcp_split/build_cpu_ooc_dcp.py @@ -0,0 +1,90 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import datetime as dt +import os +import shlex +from pathlib import Path + +import flow_common as flow + + +DEFAULT_DEFINES = ( + "XIANGSHAN_FPGA,RANDOMIZE_GARBAGE_ASSIGN,RANDOMIZE_REG_INIT," + "RANDOMIZE_MEM_INIT,RANDOMIZE_DELAY=1,SRAM_SYN" +) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description=( + "Build an out-of-context CPU synthesis checkpoint " + "from a FpgaDiff release RTL directory." + ) + ) + parser.add_argument("--release", required=True, help="Release directory") + parser.add_argument("--cpu", choices=["kmh", "nanhu", "nutshell"], default="kmh") + parser.add_argument("--top", help="OOC top module") + parser.add_argument("--out-dir", help="Output directory") + parser.add_argument("--vivado", default=os.environ.get("VIVADO", "vivado")) + parser.add_argument("--defines", default=DEFAULT_DEFINES) + parser.add_argument("--dry-run", action="store_true") + return parser.parse_args() + + +def main() -> int: + args = parse_args() + paths = flow.init_paths(__file__) + release_dir = Path(args.release).resolve() + rtl_dir = release_dir / "build" / "rtl" + flow.require_existing_dir(rtl_dir, "RTL directory") + + top_module = args.top + if not top_module: + top_module = "NutShell" if args.cpu == "nutshell" else "XSTop" + + if args.out_dir: + out_dir = flow.resolve_m(args.out_dir) + else: + stamp = dt.datetime.now().strftime("%Y%m%d-%H%M%S") + out_dir = paths.repo_root / "build" / "fpga-diff-cpu-dcp" / args.cpu / f"{stamp}-{top_module}" + (out_dir / "logs").mkdir(parents=True, exist_ok=True) + + vivado_cmd = flow.resolve_vivado(args.vivado, args.dry_run) + manifest = [ + f"repo_root={paths.repo_root}", + f"release_dir={release_dir}", + f"rtl_dir={rtl_dir}", + f"cpu={args.cpu}", + f"top_module={top_module}", + f"out_dir={out_dir}", + f"defines={args.defines}", + f"dry_run={int(args.dry_run)}", + f"vivado_command={vivado_cmd}", + ] + flow.write_lines(out_dir / "manifest.env", manifest) + + cmd = [ + vivado_cmd, + "-mode", + "batch", + "-source", + str(paths.script_dir / "build_cpu_ooc_dcp.tcl"), + "-tclargs", + str(rtl_dir), + top_module, + str(out_dir), + "xcvu19p-fsva3824-2-e", + args.defines, + ] + command_log = out_dir / "logs" / "vivado-command.log" + command_log.write_text(shlex.join(cmd) + "\n", encoding="utf-8") + + runner = flow.FlowRunner(out_dir, dry_run=args.dry_run) + runner.run("vivado", cmd) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/fpga_diff/tools/cpu_dcp_split/build_cpu_ooc_dcp.tcl b/fpga_diff/tools/cpu_dcp_split/build_cpu_ooc_dcp.tcl new file mode 100644 index 00000000..19cb416e --- /dev/null +++ b/fpga_diff/tools/cpu_dcp_split/build_cpu_ooc_dcp.tcl @@ -0,0 +1,139 @@ +######################################################################## +# Build a CPU out-of-context synthesis checkpoint from a +# FpgaDiff release RTL directory. +# +# Usage: +# vivado -mode batch -source env-scripts/fpga_diff/tools/cpu_dcp_split/build_cpu_ooc_dcp.tcl \ +# -tclargs [part] [defines-csv] +# +# This is a prototype helper for studying CPU/DiffTest partitioning. It only +# proves whether a named CPU top can be synthesized as an OOC checkpoint from +# the release RTL. A reusable CPU DCP still requires a stable partition cell in +# the top-level design. +######################################################################## + +proc usage {} { + puts stderr "USAGE: vivado -mode batch -source env-scripts/fpga_diff/tools/cpu_dcp_split/build_cpu_ooc_dcp.tcl -tclargs ?part? ?defines-csv?" +} + +if {[llength $::argv] < 3 || [llength $::argv] > 5} { + usage + exit 2 +} + +set rtl_dir [file normalize [lindex $::argv 0]] +set top_module [string trim [lindex $::argv 1]] +set out_dir [file normalize [lindex $::argv 2]] +set part [expr {[llength $::argv] >= 4 && [string trim [lindex $::argv 3]] ne "" ? [string trim [lindex $::argv 3]] : "xcvu19p-fsva3824-2-e"}] +set defines_csv [expr {[llength $::argv] >= 5 ? [string trim [lindex $::argv 4]] : ""}] + +if {![file isdirectory $rtl_dir]} { + puts stderr "ERROR: RTL directory not found: $rtl_dir" + exit 1 +} +if {$top_module eq ""} { + puts stderr "ERROR: top-module is empty" + exit 1 +} +file mkdir $out_dir + +proc collect_files {root patterns} { + set result [list] + foreach pattern $patterns { + foreach path [glob -nocomplain -directory $root -types f -- $pattern] { + lappend result [file normalize $path] + } + } + foreach dir [glob -nocomplain -directory $root -types d -- *] { + set result [concat $result [collect_files $dir $patterns]] + } + return $result +} + +proc uniq_sorted {values} { + set data [lsort -unique $values] + return $data +} + +proc split_csv {value} { + set result [list] + foreach item [split $value ","] { + set item [string trim $item] + if {$item ne ""} { + lappend result $item + } + } + return $result +} + +proc json_escape {value} { + set s [string map { + "\\" "\\\\" + "\"" "\\\"" + "\n" "\\n" + "\r" "\\r" + "\t" "\\t" + } $value] + return "\"$s\"" +} + +set build_dir [file dirname $rtl_dir] +set rtl_files [uniq_sorted [collect_files $rtl_dir [list "*.v" "*.sv"]]] +set header_files [uniq_sorted [collect_files $build_dir [list "*.vh" "*.svh"]]] +set include_dirs [list $rtl_dir] +foreach header $header_files { + lappend include_dirs [file dirname $header] +} +set include_dirs [uniq_sorted $include_dirs] + +if {[llength $rtl_files] == 0} { + puts stderr "ERROR: no RTL files found under $rtl_dir" + exit 1 +} + +set dcp_path [file join $out_dir cpu-synth.dcp] +set util_path [file join $out_dir utilization.rpt] +set timing_path [file join $out_dir timing_synth.rpt] +set manifest_path [file join $out_dir manifest.json] + +puts "INFO: RTL directory: $rtl_dir" +puts "INFO: Top module: $top_module" +puts "INFO: Part: $part" +puts "INFO: RTL files: [llength $rtl_files]" +puts "INFO: Header files: [llength $header_files]" +puts "INFO: Include dirs: [llength $include_dirs]" + +set_property include_dirs $include_dirs [current_fileset] +read_verilog -sv $rtl_files + +set define_args [list] +foreach define [split_csv $defines_csv] { + lappend define_args -verilog_define $define +} + +set synth_args [list -top $top_module -part $part -mode out_of_context -flatten_hierarchy rebuilt] +set synth_args [concat $synth_args $define_args] +puts "INFO: synth_design $synth_args" +synth_design {*}$synth_args + +write_checkpoint -force $dcp_path +report_utilization -file $util_path +report_timing_summary -file $timing_path + +set mf [open $manifest_path w] +puts $mf "{" +puts $mf " \"rtl_dir\": [json_escape $rtl_dir]," +puts $mf " \"top_module\": [json_escape $top_module]," +puts $mf " \"part\": [json_escape $part]," +puts $mf " \"defines_csv\": [json_escape $defines_csv]," +puts $mf " \"rtl_file_count\": [llength $rtl_files]," +puts $mf " \"header_file_count\": [llength $header_files]," +puts $mf " \"include_dir_count\": [llength $include_dirs]," +puts $mf " \"dcp\": [json_escape $dcp_path]," +puts $mf " \"utilization_report\": [json_escape $util_path]," +puts $mf " \"timing_report\": [json_escape $timing_path]" +puts $mf "}" +close $mf + +puts "INFO: Wrote $dcp_path" +puts "INFO: Wrote $manifest_path" diff --git a/fpga_diff/tools/cpu_dcp_split/check_cpu_dcp_reuse.py b/fpga_diff/tools/cpu_dcp_split/check_cpu_dcp_reuse.py new file mode 100755 index 00000000..e1ad7f59 --- /dev/null +++ b/fpga_diff/tools/cpu_dcp_split/check_cpu_dcp_reuse.py @@ -0,0 +1,223 @@ +#!/usr/bin/env python3 +import argparse +import csv +import json +import re +from dataclasses import dataclass +from pathlib import Path + +import cpu_rtl_interface as rtl_if + +MODULE_RE = re.compile(r"(?m)^\s*module\s+([A-Za-z_][A-Za-z0-9_$]*)\b") +ENDMODULE_RE = re.compile(r"(?m)^\s*endmodule\b") + + +@dataclass(frozen=True) +class ModuleInfo: + name: str + rel_path: str + digest: str + + +def classify_name(name: str, rel_path: str) -> str: + haystack = f"{name} {rel_path}" + if re.search(r"Difftest|DiffTest|Gateway|Batch|Delta|Host|XDMA|DifftestClockGate", haystack): + return "difftest" + if re.search( + r"XSTop|XSCore|XSTile|xiangshan|XiangShan|NutShell|NutCore|CpuDcpTop|" + r"Frontend|Backend|IFU|BPU|IDU|ISU|EXU|LSU|WBU|CSR|ALU|MDU|" + r"Cache|TLB|SRAMTemplate|array_|rf_", + haystack, + ): + return "cpu" + if name == "SimTop" or "SimTop" in rel_path: + return "mixed-boundary" + return "unknown" + + +def iter_rtl_files(root: Path) -> list[Path]: + return sorted( + path for path in root.rglob("*") + if path.is_file() and path.suffix.lower() in {".v", ".sv", ".svh"} + ) + + +def digest(text: str) -> str: + return rtl_if.sha256_text(rtl_if.strip_comments(text)) + + +def file_digest(path: Path) -> str: + return digest(path.read_text(encoding="utf-8", errors="replace")) + + +def compare_files(baseline_rtl: Path, modified_rtl: Path) -> list[dict[str, str]]: + baseline_files = [path.relative_to(baseline_rtl).as_posix() for path in iter_rtl_files(baseline_rtl)] + modified_files = [path.relative_to(modified_rtl).as_posix() for path in iter_rtl_files(modified_rtl)] + baseline_set = set(baseline_files) + modified_set = set(modified_files) + common_files = sorted(baseline_set & modified_set) + + added_files = sorted(modified_set - baseline_set) + removed_files = sorted(baseline_set - modified_set) + changed_files = [ + rel for rel in common_files + if file_digest(baseline_rtl / rel) != file_digest(modified_rtl / rel) + ] + + rows: list[dict[str, str]] = [] + for status, files in ( + ("added", added_files), + ("removed", removed_files), + ("changed", changed_files), + ): + for rel in files: + rows.append({ + "status": status, + "area": classify_name("", rel), + "path": rel, + }) + return rows + + +def extract_modules(rtl_root: Path) -> dict[str, ModuleInfo]: + modules: dict[str, ModuleInfo] = {} + for path in iter_rtl_files(rtl_root): + rel_path = path.relative_to(rtl_root).as_posix() + text = path.read_text(errors="replace") + matches = list(MODULE_RE.finditer(text)) + if not matches: + modules[f"file:{rel_path}"] = ModuleInfo( + name=f"file:{rel_path}", + rel_path=rel_path, + digest=digest(text), + ) + continue + + for idx, match in enumerate(matches): + start = match.start() + next_start = matches[idx + 1].start() if idx + 1 < len(matches) else len(text) + end_match = ENDMODULE_RE.search(text, match.end(), next_start) + end = end_match.end() if end_match else next_start + name = match.group(1) + key = name if name not in modules else f"{name}@{rel_path}" + modules[key] = ModuleInfo(name=name, rel_path=rel_path, digest=digest(text[start:end])) + return modules + + +def compare_modules(base: dict[str, ModuleInfo], mod: dict[str, ModuleInfo]) -> list[dict[str, str]]: + rows: list[dict[str, str]] = [] + for key in sorted(set(base) | set(mod)): + before = base.get(key) + after = mod.get(key) + info = after or before + assert info is not None + if before is None: + status = "added" + elif after is None: + status = "removed" + elif before.digest != after.digest: + status = "changed" + else: + continue + rows.append({ + "status": status, + "area": classify_name(info.name, info.rel_path), + "module": info.name, + "path": info.rel_path, + }) + return rows + + +def count_by(rows: list[dict[str, str]], key: str) -> dict[str, int]: + counts: dict[str, int] = {} + for row in rows: + value = row.get(key) or "unknown" + counts[value] = counts.get(value, 0) + 1 + return counts + + +def gate_from_rows(rows: list[dict[str, str]]) -> dict[str, object]: + area_counts = count_by(rows, "area") + examples: dict[str, list[str]] = {} + for row in rows: + area = row.get("area") or "unknown" + examples.setdefault(area, []) + if len(examples[area]) < 8: + module = row.get("module") or row.get("path") or "" + examples[area].append(f"{module} ({row.get('path', '')})") + + cpu_changed = area_counts.get("cpu", 0) > 0 + unknown_changed = area_counts.get("unknown", 0) > 0 + mixed_changed = area_counts.get("mixed-boundary", 0) > 0 + + if not rows: + verdict = "no-change" + reason = "No generated RTL changes were detected." + exit_code = 0 + elif cpu_changed: + verdict = "cpu-dcp-invalid" + reason = "CPU-area module changes were detected; do not assume a baseline CPU checkpoint is reusable." + exit_code = 1 + elif unknown_changed: + verdict = "needs-review" + reason = "Unknown-area module changes were detected; review them before treating the run as DiffTest-only." + exit_code = 2 + elif mixed_changed: + verdict = "mixed-boundary-only" + reason = "No CPU-area modules changed, but mixed SimTop boundary modules changed; whole-project incremental is valid, true CPU DCP reuse still needs a separated boundary." + exit_code = 0 + else: + verdict = "difftest-only" + reason = "Only DiffTest-area modules changed; this is the best case for incremental reuse." + exit_code = 0 + + return { + "verdict": verdict, + "reason": reason, + "area_counts": area_counts, + "examples": examples, + "exit_code": exit_code, + } + + +def write_csv(path: Path, fieldnames: list[str], rows: list[dict[str, str]]) -> None: + with path.open("w", newline="") as f: + writer = csv.DictWriter(f, fieldnames=fieldnames) + writer.writeheader() + writer.writerows(rows) + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Compare FpgaDiff release RTL changes and gate CPU-DCP reuse") + parser.add_argument("baseline_release") + parser.add_argument("modified_release") + parser.add_argument("out_dir") + args = parser.parse_args() + + baseline_release = Path(args.baseline_release).resolve() + modified_release = Path(args.modified_release).resolve() + baseline_rtl = baseline_release / "build" / "rtl" + modified_rtl = modified_release / "build" / "rtl" + out_dir = Path(args.out_dir).resolve() + for path in (baseline_rtl, modified_rtl): + if not path.is_dir(): + raise SystemExit(f"ERROR: RTL directory not found: {path}") + out_dir.mkdir(parents=True, exist_ok=True) + + file_rows = compare_files(baseline_rtl, modified_rtl) + write_csv(out_dir / "summary.csv", ["status", "area", "path"], file_rows) + + rows = compare_modules(extract_modules(baseline_rtl), extract_modules(modified_rtl)) + csv_path = out_dir / "module-boundary.csv" + write_csv(csv_path, ["status", "area", "module", "path"], rows) + + gate = gate_from_rows(file_rows + rows) + (out_dir / "cpu-difftest-gate.json").write_text(json.dumps(gate, indent=2, sort_keys=True) + "\n") + print(f"CPU-DCP gate: {gate['verdict']}") + print(f"Reason: {gate['reason']}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/fpga_diff/tools/cpu_dcp_split/cpu_dcp_import.tcl b/fpga_diff/tools/cpu_dcp_split/cpu_dcp_import.tcl new file mode 100644 index 00000000..88fc0eb4 --- /dev/null +++ b/fpga_diff/tools/cpu_dcp_split/cpu_dcp_import.tcl @@ -0,0 +1,518 @@ +######################################################################## +# Import CPU partition DCPs into a synthesized top design. +# +# Usage: +# vivado -mode batch -source .../cpu_dcp_import.tcl \ +# -tclargs probe |@ \ +# |- [synth-run] [out-dir] +# +# vivado -mode batch -source .../cpu_dcp_import.tcl \ +# -tclargs impl |@ \ +# |- [synth-run] [out-dir] \ +# [reference-routed.dcp] [route|bitstream] [Default|RuntimeOptimized] +######################################################################## + +proc usage {} { + puts stderr "USAGE: vivado -mode batch -source env-scripts/fpga_diff/tools/cpu_dcp_split/cpu_dcp_import.tcl -tclargs probe|impl |@ |- ?synth-run? ?out-dir? ?reference-routed.dcp? ?route|bitstream? ?Default|RuntimeOptimized?" +} + +proc json_escape {value} { + set s [string map { + "\\" "\\\\" + "\"" "\\\"" + "\n" "\\n" + "\r" "\\r" + "\t" "\\t" + } $value] + return "\"$s\"" +} + +proc write_json_manifest {path entries} { + set fh [open $path w] + puts $fh "{" + set count [llength $entries] + set i 0 + foreach entry $entries { + incr i + set key [lindex $entry 0] + set value [lindex $entry 1] + set comma [expr {$i < $count ? "," : ""}] + puts $fh " [json_escape $key]: [json_escape $value]$comma" + } + puts $fh "}" + close $fh +} + +proc read_partition_imports {cell_arg dcp_arg} { + set entries [list] + if {[string match {@*} $cell_arg]} { + set path [file normalize [string range $cell_arg 1 end]] + if {![file exists $path]} { + puts stderr "ERROR: partition import TSV not found: $path" + exit 1 + } + set fh [open $path r] + set line_no 0 + while {[gets $fh line] >= 0} { + incr line_no + set line [string trim $line] + if {$line eq "" || [string match "#*" $line]} { + continue + } + set fields [split $line "\t"] + if {[llength $fields] < 3} { + close $fh + puts stderr "ERROR: malformed partition import TSV line $line_no in $path" + exit 1 + } + set role [string trim [lindex $fields 0]] + set cell [string trim [lindex $fields 1]] + set dcp [file normalize [string trim [lindex $fields 2]]] + if {$role eq "" || $cell eq "" || $dcp eq ""} { + close $fh + puts stderr "ERROR: empty field in partition import TSV line $line_no in $path" + exit 1 + } + if {![file exists $dcp]} { + close $fh + puts stderr "ERROR: partition checkpoint not found for $role: $dcp" + exit 1 + } + lappend entries [list $role $cell $dcp] + } + close $fh + if {[llength $entries] == 0} { + puts stderr "ERROR: partition import TSV contains no entries: $path" + exit 1 + } + return $entries + } + + if {$dcp_arg eq ""} { + puts stderr "ERROR: CPU checkpoint path is empty" + exit 1 + } + lappend entries [list cpu $cell_arg $dcp_arg] + return $entries +} + +proc join_partition_field {entries index} { + set values [list] + foreach entry $entries { + lappend values [lindex $entry $index] + } + return [join $values ";"] +} + +proc find_cell_by_exact_name {cell_name} { + set direct [get_cells -quiet $cell_name] + if {[llength $direct] > 0} { + return $direct + } + + set matches [list] + foreach c [get_cells -hierarchical -quiet *] { + if {[get_property NAME $c] eq $cell_name} { + lappend matches $c + } + } + return $matches +} + +proc write_missing_cell_candidates {path} { + set max_candidates 500 + set count 0 + set fh [open $path w] + puts $fh "# Candidate cells containing CPU-ish names. This file is capped at $max_candidates entries." + foreach c [get_cells -hierarchical -quiet -regexp {.*(U_CPU_TOP|u_XSTop|cpu|XSTop|XSCore|XSTile|NutShell|frontend|backend|Frontend|Backend).*}] { + incr count + if {$count <= $max_candidates} { + puts $fh [get_property NAME $c] + } + } + if {$count > $max_candidates} { + puts $fh "# truncated: total_candidates=$count written=$max_candidates" + } else { + puts $fh "# total_candidates=$count" + } + close $fh +} + +proc resolve_partition_imports {partition_imports out_dir} { + set resolved_imports [list] + foreach entry $partition_imports { + set role [lindex $entry 0] + set cell [lindex $entry 1] + set dcp [lindex $entry 2] + set cell_obj [find_cell_by_exact_name $cell] + if {[llength $cell_obj] == 0} { + set candidates_file [file join $out_dir missing-cell-candidates.txt] + write_missing_cell_candidates $candidates_file + puts stderr "ERROR: CPU partition cell not found for $role: $cell" + puts stderr "Candidate list: $candidates_file" + exit 1 + } + if {[llength $cell_obj] > 1} { + puts stderr "ERROR: CPU partition cell path is ambiguous for $role: $cell" + foreach c $cell_obj { + puts stderr " [get_property NAME $c]" + } + exit 1 + } + set cell_obj [lindex $cell_obj 0] + lappend resolved_imports [list $role [get_property NAME $cell_obj] $dcp] + } + return $resolved_imports +} + +proc run_step {step_name command out_dir status_var message_var} { + upvar $status_var status + upvar $message_var message + puts "INFO: Running $step_name" + set step_log [file join $out_dir "${step_name}.log"] + set fh [open $step_log w] + puts $fh "command=$command" + close $fh + if {[catch {uplevel 1 $command} err]} { + set status "${step_name}_failed" + set message $err + set fh [open $step_log a] + puts $fh "status=$status" + puts $fh "error=$err" + close $fh + return 0 + } + set fh [open $step_log a] + puts $fh "status=ok" + close $fh + return 1 +} + +proc project_has_scoped_cpu_dcp {cpu_dcp cpu_cell} { + set normalized_cpu_dcp [file normalize $cpu_dcp] + foreach f [get_files -quiet -all *] { + if {[file normalize [get_property NAME $f]] ne $normalized_cpu_dcp} { + continue + } + set scoped_cells "" + if {![catch {get_property SCOPED_TO_CELLS $f} scoped_cells]} { + if {[lsearch -exact $scoped_cells $cpu_cell] >= 0} { + return 1 + } + } + set scoped_cell "" + if {![catch {get_property SCOPED_TO_CELL $f} scoped_cell]} { + if {$scoped_cell eq $cpu_cell} { + return 1 + } + } + } + return 0 +} + +proc import_cpu_checkpoint {role resolved_cell cpu_dcp out_dir status_var message_var import_mode_var} { + upvar $status_var status + upvar $message_var message + upvar $import_mode_var import_mode + + set step_name "import_${role}_dcp" + regsub -all {[^A-Za-z0-9_]} $step_name "_" step_name + set command [list read_checkpoint -cell $resolved_cell $cpu_dcp] + if {[run_step $step_name $command $out_dir status message]} { + set import_mode "read_checkpoint_cell" + return 1 + } + + if {[string first "is not a black-box" $message] >= 0 && [project_has_scoped_cpu_dcp $cpu_dcp $resolved_cell]} { + puts "INFO: CPU cell is already populated from the scoped DCP in this project; continuing without a second read_checkpoint -cell." + set import_mode "already_scoped_in_project" + set status "unknown" + set message "" + set fh [open [file join $out_dir "${step_name}.log"] a] + puts $fh "recovered_by=already_scoped_in_project" + close $fh + return 1 + } + + return 0 +} + +proc write_reports {out_dir} { + foreach report_cmd { + {report_timing_summary -file timing_summary.rpt} + {report_utilization -file utilization.rpt} + {report_route_status -file route_status.rpt} + {report_drc -file drc.rpt} + } { + set cmd [lindex $report_cmd 0] + set file_name [lindex $report_cmd 2] + if {[catch {$cmd -file [file join $out_dir $file_name]} err]} { + puts "WARNING: $cmd failed: $err" + } + } +} + +proc write_incremental_reuse_report {out_dir name} { + if {[catch {report_incremental_reuse -file [file join $out_dir $name]} err]} { + puts "WARNING: report_incremental_reuse failed for $name: $err" + } +} + +proc write_impl_manifest {out_dir proj_path synth_run resolved_cell resolved_dcp import_mode partition_count reference_dcp stop_after impl_directive routed_dcp bitstream_path status message} { + write_json_manifest [file join $out_dir cpu-dcp-import-impl.json] [list \ + [list project $proj_path] \ + [list synth_run $synth_run] \ + [list cpu_cell $resolved_cell] \ + [list cpu_dcp $resolved_dcp] \ + [list import_mode $import_mode] \ + [list partition_count $partition_count] \ + [list reference_dcp $reference_dcp] \ + [list final_step $stop_after] \ + [list implementation_directive $impl_directive] \ + [list routed_dcp $routed_dcp] \ + [list bitstream $bitstream_path] \ + [list status $status] \ + [list message $message]] +} + +proc prepare_design {proj_path run_name out_dir_suffix out_dir_arg open_name} { + puts "INFO: Opening project: $proj_path" + open_project $proj_path + + set project_dir [get_property DIRECTORY [current_project]] + set out_dir $out_dir_arg + if {$out_dir eq ""} { + set out_dir [file normalize [file join $project_dir $out_dir_suffix]] + } + file mkdir $out_dir + + set run_obj [get_runs -quiet $run_name] + if {[llength $run_obj] == 0} { + puts stderr "ERROR: synthesis run not found: $run_name" + exit 1 + } + + puts "INFO: Opening synthesis run: $run_name" + open_run $run_name -name $open_name + return $out_dir +} + +proc run_probe {proj_path cpu_cell cpu_dcp run_name out_dir_arg} { + set out_dir [prepare_design $proj_path $run_name cpu-dcp-import-probe $out_dir_arg cpu_dcp_import_probe] + set resolved_imports [resolve_partition_imports [read_partition_imports $cpu_cell $cpu_dcp] $out_dir] + set resolved_cell [join_partition_field $resolved_imports 1] + set resolved_dcp [join_partition_field $resolved_imports 2] + + set manifest [file join $out_dir cpu-dcp-import-probe.json] + set log [file join $out_dir cpu-dcp-import-probe.log] + set status "read_checkpoint_ok" + set message "" + + puts "INFO: Importing CPU checkpoint into cell: $resolved_cell" + set log_fh [open $log w] + puts $log_fh "project=$proj_path" + puts $log_fh "run=$run_name" + puts $log_fh "cpu_cell=$resolved_cell" + puts $log_fh "cpu_dcp=$resolved_dcp" + puts $log_fh "partition_count=[llength $resolved_imports]" + + foreach entry $resolved_imports { + set role [lindex $entry 0] + set cell [lindex $entry 1] + set dcp [lindex $entry 2] + puts "INFO: Importing CPU checkpoint into $role cell: $cell" + puts $log_fh "import_role=$role" + puts $log_fh "import_cell=$cell" + puts $log_fh "import_dcp=$dcp" + if {[catch {read_checkpoint -cell $cell $dcp} err]} { + set status "read_checkpoint_failed" + set message $err + puts $log_fh "status=$status" + puts $log_fh "error=$err" + break + } + } + if {$status eq "read_checkpoint_ok"} { + puts $log_fh "status=$status" + if {[catch {report_utilization -hierarchical -file [file join $out_dir utilization_after_import.rpt]} err]} { + puts $log_fh "report_utilization_error=$err" + } + if {[catch {write_checkpoint -force [file join $out_dir top-after-cpu-import.dcp]} err]} { + puts $log_fh "write_checkpoint_error=$err" + } + } + close $log_fh + + write_json_manifest $manifest [list \ + [list project $proj_path] \ + [list run $run_name] \ + [list cpu_cell $resolved_cell] \ + [list cpu_dcp $resolved_dcp] \ + [list partition_count [llength $resolved_imports]] \ + [list status $status] \ + [list message $message] \ + [list log $log]] + + puts "INFO: Wrote $manifest" + if {$status ne "read_checkpoint_ok"} { + puts stderr "ERROR: CPU DCP import failed: $message" + exit 1 + } +} + +proc run_impl {proj_path cpu_cell cpu_dcp synth_run out_dir_arg reference_dcp stop_after impl_directive} { + set out_dir [prepare_design $proj_path $synth_run cpu-dcp-import-impl $out_dir_arg cpu_dcp_import_impl] + set resolved_imports [resolve_partition_imports [read_partition_imports $cpu_cell $cpu_dcp] $out_dir] + set resolved_cell [join_partition_field $resolved_imports 1] + set resolved_dcp [join_partition_field $resolved_imports 2] + + set status "unknown" + set message "" + set import_mode "unknown" + set routed_dcp "" + set bitstream_path "" + + foreach entry $resolved_imports { + set role [lindex $entry 0] + set cell [lindex $entry 1] + set dcp [lindex $entry 2] + if {![import_cpu_checkpoint $role $cell $dcp $out_dir status message import_mode]} { + write_impl_manifest $out_dir $proj_path $synth_run $resolved_cell $resolved_dcp $import_mode [llength $resolved_imports] $reference_dcp $stop_after $impl_directive $routed_dcp $bitstream_path $status $message + puts stderr "ERROR: CPU DCP import failed for $role: $message" + exit 1 + } + } + + if {![run_step opt_design {opt_design} $out_dir status message]} { + write_reports $out_dir + write_checkpoint -force [file join $out_dir failed-post-opt_design.dcp] + write_impl_manifest $out_dir $proj_path $synth_run $resolved_cell $resolved_dcp $import_mode [llength $resolved_imports] $reference_dcp $stop_after $impl_directive $routed_dcp $bitstream_path $status $message + puts stderr "ERROR: opt_design failed: $message" + exit 1 + } + + if {$reference_dcp ne ""} { + if {![run_step read_incremental_checkpoint [list read_checkpoint -incremental -directive $impl_directive $reference_dcp] $out_dir status message]} { + write_impl_manifest $out_dir $proj_path $synth_run $resolved_cell $resolved_dcp $import_mode [llength $resolved_imports] $reference_dcp $stop_after $impl_directive $routed_dcp $bitstream_path $status $message + puts stderr "ERROR: incremental checkpoint setup failed: $message" + exit 1 + } + } + + set place_cmd [expr {$impl_directive eq "Default" ? [list place_design] : [list place_design -directive $impl_directive]}] + set route_cmd [expr {$impl_directive eq "Default" ? [list route_design] : [list route_design -directive $impl_directive]}] + foreach step [list \ + [list place_design $place_cmd] \ + {phys_opt_design {phys_opt_design}} \ + [list route_design $route_cmd]] { + set step_name [lindex $step 0] + set command [lindex $step 1] + if {![run_step $step_name $command $out_dir status message]} { + write_reports $out_dir + write_checkpoint -force [file join $out_dir failed-post-${step_name}.dcp] + write_impl_manifest $out_dir $proj_path $synth_run $resolved_cell $resolved_dcp $import_mode [llength $resolved_imports] $reference_dcp $stop_after $impl_directive $routed_dcp $bitstream_path $status $message + puts stderr "ERROR: $step_name failed: $message" + exit 1 + } + if {$reference_dcp ne "" && $step_name eq "place_design"} { + write_incremental_reuse_report $out_dir post-place-incremental-reuse.rpt + } + if {$reference_dcp ne "" && $step_name eq "route_design"} { + write_incremental_reuse_report $out_dir post-route-incremental-reuse.rpt + } + } + + write_reports $out_dir + if {$reference_dcp ne ""} { + write_incremental_reuse_report $out_dir final-incremental-reuse.rpt + } + set routed_dcp [file join $out_dir post-route-cpu-dcp-import.dcp] + write_checkpoint -force $routed_dcp + + if {$stop_after eq "bitstream"} { + set bitstream_path [file join $out_dir fpga_top_debug.bit] + if {![run_step write_bitstream [list write_bitstream -force $bitstream_path] $out_dir status message]} { + write_impl_manifest $out_dir $proj_path $synth_run $resolved_cell $resolved_dcp $import_mode [llength $resolved_imports] $reference_dcp $stop_after $impl_directive $routed_dcp $bitstream_path $status $message + puts stderr "ERROR: write_bitstream failed: $message" + exit 1 + } + } + + set status "implementation_ok" + set message "" + write_impl_manifest $out_dir $proj_path $synth_run $resolved_cell $resolved_dcp $import_mode [llength $resolved_imports] $reference_dcp $stop_after $impl_directive $routed_dcp $bitstream_path $status $message + + puts "INFO: Wrote [file join $out_dir cpu-dcp-import-impl.json]" + puts "INFO: Wrote routed checkpoint: $routed_dcp" + if {$bitstream_path ne ""} { + puts "INFO: Wrote bitstream: $bitstream_path" + } +} + +if {[llength $::argv] < 4 || [llength $::argv] > 9} { + usage + exit 2 +} + +set mode [string trim [lindex $::argv 0]] +if {[lsearch -exact {probe impl} $mode] < 0} { + usage + exit 2 +} +if {$mode eq "probe" && [llength $::argv] > 6} { + usage + exit 2 +} + +set proj_path [file normalize [lindex $::argv 1]] +set cpu_cell [string trim [lindex $::argv 2]] +set cpu_dcp_arg [string trim [lindex $::argv 3]] +set cpu_dcp [expr {$cpu_dcp_arg eq "" || $cpu_dcp_arg eq "-" ? "" : [file normalize $cpu_dcp_arg]}] +set run_name [expr {[llength $::argv] >= 5 && [string trim [lindex $::argv 4]] ne "" ? [string trim [lindex $::argv 4]] : "synth_1"}] +set out_dir "" +if {[llength $::argv] >= 6 && [string trim [lindex $::argv 5]] ne ""} { + set out_dir [file normalize [lindex $::argv 5]] +} +set reference_dcp "" +if {$mode eq "impl" && [llength $::argv] >= 7 && [string trim [lindex $::argv 6]] ne ""} { + set reference_dcp [file normalize [lindex $::argv 6]] +} +set stop_after "route" +if {$mode eq "impl" && [llength $::argv] >= 8 && [string trim [lindex $::argv 7]] ne ""} { + set stop_after [string trim [lindex $::argv 7]] +} +set impl_directive "RuntimeOptimized" +if {$mode eq "impl" && [llength $::argv] >= 9 && [string trim [lindex $::argv 8]] ne ""} { + set impl_directive [string trim [lindex $::argv 8]] +} + +if {![file exists $proj_path]} { + puts stderr "ERROR: project not found: $proj_path" + exit 1 +} +if {$cpu_cell eq ""} { + puts stderr "ERROR: cpu-cell-path or partition import TSV is empty" + exit 1 +} +if {$cpu_dcp ne "" && ![file exists $cpu_dcp]} { + puts stderr "ERROR: CPU checkpoint not found: $cpu_dcp" + exit 1 +} +if {$reference_dcp ne "" && ![file exists $reference_dcp]} { + puts stderr "ERROR: reference routed checkpoint not found: $reference_dcp" + exit 1 +} +if {[lsearch -exact {route bitstream} $stop_after] < 0} { + puts stderr "ERROR: final step must be route or bitstream, got '$stop_after'" + exit 1 +} +if {[lsearch -exact {Default RuntimeOptimized} $impl_directive] < 0} { + puts stderr "ERROR: implementation directive must be Default or RuntimeOptimized, got '$impl_directive'" + exit 1 +} + +if {$mode eq "probe"} { + run_probe $proj_path $cpu_cell $cpu_dcp $run_name $out_dir +} else { + run_impl $proj_path $cpu_cell $cpu_dcp $run_name $out_dir $reference_dcp $stop_after $impl_directive +} diff --git a/fpga_diff/tools/cpu_dcp_split/cpu_rtl_interface.py b/fpga_diff/tools/cpu_dcp_split/cpu_rtl_interface.py new file mode 100755 index 00000000..4153e5ec --- /dev/null +++ b/fpga_diff/tools/cpu_dcp_split/cpu_rtl_interface.py @@ -0,0 +1,322 @@ +#!/usr/bin/env python3 +import hashlib +import json +import re +from pathlib import Path + + +def sha256_bytes(data: bytes) -> str: + return hashlib.sha256(data).hexdigest() + + +def sha256_text(text: str) -> str: + return hashlib.sha256(text.encode("utf-8", errors="replace")).hexdigest() + + +def sha256_file(path: Path) -> str: + h = hashlib.sha256() + with path.open("rb") as f: + for chunk in iter(lambda: f.read(1024 * 1024), b""): + h.update(chunk) + return h.hexdigest() + + +def stable_json_hash(value: object) -> str: + encoded = json.dumps(value, sort_keys=True, separators=(",", ":")).encode() + return hashlib.sha256(encoded).hexdigest() + + +def skip_line_comment(text: str, idx: int) -> int: + end = text.find("\n", idx) + return len(text) if end < 0 else end + 1 + + +def skip_block_comment(text: str, idx: int) -> int: + end = text.find("*/", idx + 2) + return len(text) if end < 0 else end + 2 + + +def strip_comments(text: str) -> str: + text = re.sub(r"/\*.*?\*/", "", text, flags=re.S) + return re.sub(r"//.*", "", text) + + +def strip_comments_preserve_offsets(text: str) -> str: + def blank(match: re.Match) -> str: + value = match.group(0) + return "".join("\n" if char == "\n" else " " for char in value) + + text = re.sub(r"/\*.*?\*/", blank, text, flags=re.S) + return re.sub(r"//.*", blank, text) + + +def normalize_ws(text: str) -> str: + return re.sub(r"\s+", " ", text).strip() + + +def line_no(text: str, offset: int) -> int: + return text.count("\n", 0, offset) + 1 + + +def find_matching_paren(text: str, open_idx: int) -> int: + depth = 0 + idx = open_idx + while idx < len(text): + if text.startswith("//", idx): + idx = skip_line_comment(text, idx) + continue + if text.startswith("/*", idx): + idx = skip_block_comment(text, idx) + continue + char = text[idx] + if char == "(": + depth += 1 + elif char == ")": + depth -= 1 + if depth == 0: + return idx + idx += 1 + raise ValueError(f"unmatched '(' at offset {open_idx}") + + +def find_header_port_open(text: str, module_match_end: int) -> int: + idx = module_match_end + while idx < len(text): + if text.startswith("//", idx): + idx = skip_line_comment(text, idx) + continue + if text.startswith("/*", idx): + idx = skip_block_comment(text, idx) + continue + if text[idx].isspace(): + idx += 1 + continue + if text[idx] == "#": + param_open = text.find("(", idx) + if param_open < 0: + raise ValueError("parameter list has no opening parenthesis") + idx = find_matching_paren(text, param_open) + 1 + continue + if text[idx] == "(": + return idx + raise ValueError(f"unexpected token before port list at offset {idx}") + raise ValueError("module has no ANSI port list") + + +def find_header_semicolon(text: str, close_idx: int) -> int: + idx = close_idx + 1 + while idx < len(text): + if text.startswith("//", idx): + idx = skip_line_comment(text, idx) + continue + if text.startswith("/*", idx): + idx = skip_block_comment(text, idx) + continue + if text[idx] == ";": + return idx + idx += 1 + raise ValueError("module header has no terminating semicolon") + + +def extract_module_header(text: str, module_name: str) -> str: + match = re.search(rf"(?m)^\s*module\s+{re.escape(module_name)}\b", text) + if not match: + raise ValueError(f"module not found: {module_name}") + + try: + open_idx = find_header_port_open(text, match.end()) + close_idx = find_matching_paren(text, open_idx) + semi_idx = find_header_semicolon(text, close_idx) + except ValueError as exc: + raise ValueError(f"cannot parse module header for {module_name}: {exc}") from exc + + return text[match.start():semi_idx + 1].strip() + + +def rename_module_header(header: str, old_name: str, new_name: str) -> str: + pattern = re.compile(rf"(?m)^(\s*module\s+){re.escape(old_name)}(\b)") + renamed, count = pattern.subn(rf"\1{new_name}\2", header, count=1) + if count != 1: + raise ValueError(f"cannot rename module header {old_name} -> {new_name}") + return renamed + + +def split_top_level_commas(text: str) -> list[str]: + parts: list[str] = [] + start = 0 + parens = 0 + brackets = 0 + braces = 0 + for idx, char in enumerate(text): + if char == "(": + parens += 1 + elif char == ")": + parens -= 1 + elif char == "[": + brackets += 1 + elif char == "]": + brackets -= 1 + elif char == "{": + braces += 1 + elif char == "}": + braces -= 1 + elif char == "," and parens == 0 and brackets == 0 and braces == 0: + parts.append(text[start:idx].strip()) + start = idx + 1 + tail = text[start:].strip() + if tail: + parts.append(tail) + return parts + + +def parse_width(range_text: str) -> int | None: + match = re.fullmatch(r"\[\s*([0-9]+)\s*:\s*([0-9]+)\s*\]", range_text) + if not match: + return None + left = int(match.group(1)) + right = int(match.group(2)) + return abs(left - right) + 1 + + +def parse_header_ports(header_text: str) -> list[dict]: + open_idx = header_text.find("(") + close_idx = header_text.rfind(")") + if open_idx < 0 or close_idx < open_idx: + raise ValueError("module header has no port list") + + body = strip_comments(header_text[open_idx + 1:close_idx]) + ports: list[dict] = [] + current_direction = "" + current_range = "" + current_kind = "" + + for raw in split_top_level_commas(body): + item = normalize_ws(raw) + if not item: + continue + + direction_match = re.match(r"^(input|output|inout)\b\s*(.*)$", item) + if direction_match: + current_direction = direction_match.group(1) + item = direction_match.group(2).strip() + current_range = "" + current_kind = "" + + kinds: list[str] = [] + while True: + kind_match = re.match(r"^(wire|reg|logic|bit|tri|signed|unsigned)\b\s*(.*)$", item) + if not kind_match: + break + kinds.append(kind_match.group(1)) + item = kind_match.group(2).strip() + if kinds: + current_kind = " ".join(kinds) + + range_match = re.match(r"^(\[[^\]]+\])\s*(.*)$", item) + if range_match: + current_range = normalize_ws(range_match.group(1)) + item = range_match.group(2).strip() + + name_match = re.search(r"([A-Za-z_][A-Za-z0-9_$]*)\s*(?:=.*)?$", item) + if not name_match: + continue + name = name_match.group(1) + ports.append({ + "index": len(ports), + "name": name, + "direction": current_direction, + "kind": current_kind, + "range": current_range, + "width": parse_width(current_range) or 1, + }) + return ports + + +def port_signature(ports: list[dict]) -> list[dict]: + return [ + { + "name": port.get("name", ""), + "direction": port.get("direction", ""), + "range": port.get("range", ""), + "width": port.get("width", 1), + } + for port in ports + ] + + +def interface_hash_from_ports(ports: list[dict]) -> str: + return stable_json_hash(port_signature(ports)) + + +def make_wrapper(header: str, cpu_module: str, partition_module: str, source: Path) -> str: + partition_header = rename_module_header(header, cpu_module, partition_module) + return "\n".join([ + f"// Generated from {source}", + "// CPU partition wrapper. The port contract matches the source CPU RTL.", + "(* keep_hierarchy = \"yes\" *)", + partition_header, + f" (* keep_hierarchy = \"yes\" *) {cpu_module} cpu_impl (.*);", + "endmodule", + "", + ]) + + +def make_stub(header: str, cpu_module: str, partition_module: str, source: Path) -> str: + partition_header = rename_module_header(header, cpu_module, partition_module) + return "\n".join([ + f"// Generated from {source}", + "// CPU partition stub for read_checkpoint -cell.", + "(* black_box = \"yes\", syn_black_box = 1, keep_hierarchy = \"yes\" *)", + partition_header, + "endmodule", + "", + ]) + + +def read_cpu_interface(cpu_rtl: Path, cpu_module: str) -> dict: + text = cpu_rtl.read_text(encoding="utf-8", errors="replace") + header = extract_module_header(text, cpu_module) + ports = parse_header_ports(header) + return { + "cpu_module": cpu_module, + "source_cpu_rtl": str(cpu_rtl), + "source_cpu_rtl_sha256": sha256_file(cpu_rtl), + "header": header, + "header_line_count": len(header.splitlines()), + "port_count": len(ports), + "ports": ports, + "port_signature": port_signature(ports), + "interface_hash": interface_hash_from_ports(ports), + } + + +def make_partition_rtl( + interface: dict, + partition_module: str, + mode: str, +) -> str: + cpu_module = str(interface["cpu_module"]) + cpu_rtl = Path(str(interface["source_cpu_rtl"])) + header = str(interface["header"]) + if mode == "wrapper": + return make_wrapper(header, cpu_module, partition_module, cpu_rtl) + if mode == "stub": + return make_stub(header, cpu_module, partition_module, cpu_rtl) + raise ValueError(f"unsupported partition RTL mode: {mode}") + + +def read_module_interface(module_rtl: Path, module_name: str) -> dict: + text = module_rtl.read_text(encoding="utf-8", errors="replace") + header = extract_module_header(text, module_name) + ports = parse_header_ports(header) + return { + "module": module_name, + "source_rtl": str(module_rtl), + "source_rtl_sha256": sha256_file(module_rtl), + "header": header, + "header_line_count": len(header.splitlines()), + "port_count": len(ports), + "ports": ports, + "port_signature": port_signature(ports), + "interface_hash": interface_hash_from_ports(ports), + } diff --git a/fpga_diff/tools/cpu_dcp_split/cpu_subpartition.py b/fpga_diff/tools/cpu_dcp_split/cpu_subpartition.py new file mode 100755 index 00000000..ffa7f1d4 --- /dev/null +++ b/fpga_diff/tools/cpu_dcp_split/cpu_subpartition.py @@ -0,0 +1,497 @@ +#!/usr/bin/env python3 +import argparse +import csv +import json +import re +import shutil +import sys +from pathlib import Path + +import cpu_rtl_interface as rtl_if + + +INSTANCE_RE = re.compile( + r"(?m)^\s*([A-Za-z_][A-Za-z0-9_$]*)\s+" + r"(?:#\s*\((?:.|\n)*?\)\s*)?" + r"([A-Za-z_][A-Za-z0-9_$]*)\s*\(" +) + + +def read_text(path: Path) -> str: + return path.read_text(encoding="utf-8", errors="replace") + + +def rtl_dir(release: Path) -> Path: + return release / "build" / "rtl" + + +def read_modules(release: Path) -> dict[str, Path]: + root = rtl_dir(release) + if not root.is_dir(): + raise ValueError(f"RTL directory not found: {root}") + result: dict[str, Path] = {} + for path in root.glob("*.sv"): + text = read_text(path) + for match in re.finditer(r"(?m)^\s*module\s+([A-Za-z_][A-Za-z0-9_$]*)\b", text): + result.setdefault(match.group(1), path) + return result + + +def module_body(text: str, module: str) -> str: + header = rtl_if.extract_module_header(text, module) + start = text.find(header) + if start < 0: + return text + start += len(header) + match = re.search(r"(?m)^\s*endmodule\b", text[start:]) + if not match: + return text[start:] + return text[start:start + match.start()] + + +def module_text(text: str, module: str) -> str: + header = rtl_if.extract_module_header(text, module) + start = text.find(header) + if start < 0: + raise ValueError(f"module header not found: {module}") + after_header = start + len(header) + match = re.search(r"(?m)^\s*endmodule\b", text[after_header:]) + if not match: + raise ValueError(f"endmodule not found for module: {module}") + end = after_header + match.end() + return text[start:end] + + +def module_instances(release: Path, module: str, modules: dict[str, Path]) -> list[dict[str, str]]: + path = modules.get(module) + if path is None: + return [] + body = module_body(read_text(path), module) + result: list[dict[str, str]] = [] + for match in INSTANCE_RE.finditer(body): + child_module = match.group(1) + inst = match.group(2) + if child_module in {"if", "for", "while", "case", "assign", "always", "initial"}: + continue + if child_module not in modules: + continue + result.append({"module": child_module, "instance": inst}) + return result + + +def find_partition( + release: Path, + cpu_module: str, + modules: dict[str, Path], + role: str, + module_regex: str, +) -> dict[str, object]: + pattern = re.compile(module_regex) + queue: list[tuple[str, list[str], list[str]]] = [(cpu_module, [], [])] + visited: set[tuple[str, tuple[str, ...]]] = set() + candidates: list[dict[str, object]] = [] + + while queue: + module, inst_path, module_path_parts = queue.pop(0) + key = (module, tuple(inst_path)) + if key in visited: + continue + visited.add(key) + + if inst_path and pattern.search(module): + candidates.append({ + "role": role, + "module": module, + "instance_path": "/".join(inst_path), + "module_path": "/".join(module_path_parts + [module]), + "depth": len(inst_path), + }) + continue + + for child in module_instances(release, module, modules): + queue.append(( + child["module"], + inst_path + [child["instance"]], + module_path_parts + [module], + )) + + if not candidates: + raise ValueError( + f"could not find {role} partition below {cpu_module} with regex {module_regex!r}" + ) + candidates.sort(key=lambda item: (int(item["depth"]), str(item["module"]))) + return candidates[0] + + +def dependency_closure(release: Path, top: str, modules: dict[str, Path]) -> list[str]: + seen: set[str] = set() + ordered: list[str] = [] + + def visit(module: str) -> None: + if module in seen: + return + seen.add(module) + ordered.append(module) + for child in module_instances(release, module, modules): + visit(child["module"]) + + visit(top) + return ordered + + +def closure_hash(release: Path, top: str, modules: dict[str, Path]) -> tuple[str, list[str]]: + closure = dependency_closure(release, top, modules) + signature: list[dict[str, str]] = [] + for module in closure: + path = modules.get(module) + if path is None: + continue + text = read_text(path) + signature.append({ + "module": module, + "relative_path": str(path.relative_to(rtl_dir(release))), + "sha256": rtl_if.sha256_text( + rtl_if.strip_comments(module_text(text, module)) + ), + }) + return rtl_if.stable_json_hash(signature), closure + + +def write_tsv(path: Path, rows: list[list[str]]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + "\n".join("\t".join(str(col) for col in row) for row in rows) + "\n", + encoding="utf-8", + ) + + +def changed_cpu_modules(module_boundary_csv: Path) -> list[str]: + if not module_boundary_csv.is_file(): + return [] + result: list[str] = [] + with module_boundary_csv.open(newline="") as f: + for row in csv.DictReader(f): + if row.get("status") in {"added", "removed", "changed"} and row.get("area") == "cpu": + module = row.get("module", "") + if module: + result.append(module) + return sorted(set(result)) + + +def modules_in_file(release: Path, rel_path: str) -> list[str]: + path = rtl_dir(release) / rel_path + if not path.is_file(): + return [] + text = read_text(path) + return [ + match.group(1) + for match in re.finditer(r"(?m)^\s*module\s+([A-Za-z_][A-Za-z0-9_$]*)\b", text) + ] + + +def changed_cpu_file_modules(summary_csv: Path, baseline: Path, modified: Path) -> list[str]: + if not summary_csv.is_file(): + return [] + result: list[str] = [] + with summary_csv.open(newline="") as f: + for row in csv.DictReader(f): + if row.get("status") not in {"added", "removed", "changed"} or row.get("area") != "cpu": + continue + rel_path = row.get("path", "") + if not rel_path: + continue + release = baseline if row.get("status") == "removed" else modified + modules = modules_in_file(release, rel_path) + if modules: + result.extend(modules) + else: + result.append(f"file:{rel_path}") + return sorted(set(result)) + + +def build_plan_rows(partitions: list[dict[str, object]], which: str) -> list[list[str]]: + rows: list[list[str]] = [] + for part in partitions: + if which == "current" and not part.get("changed"): + continue + dcp = part[f"{which}_dcp"] + rows.append([ + str(part["role"]), + str(part["module"]), + str(part[f"{which}_source_release"]), + str(Path(str(dcp)).parent), + str(dcp), + ]) + return rows + + +def import_rows(partitions: list[dict[str, object]], which: str) -> list[list[str]]: + rows: list[list[str]] = [] + for part in partitions: + rows.append([ + str(part["role"]), + str(part["cell"]), + str(part[f"{which}_dcp"]), + ]) + return rows + + +def run_plan(args: argparse.Namespace) -> int: + baseline = Path(args.baseline_release).resolve() + modified = Path(args.modified_release).resolve() + dcp_root = Path(args.dcp_root).resolve() + try: + baseline_modules = read_modules(baseline) + modified_modules = read_modules(modified) + roles = [ + ("frontend", args.frontend_regex), + ("backend", args.backend_regex), + ] + partitions: list[dict[str, object]] = [] + for role, module_regex in roles: + part = find_partition( + modified, + args.cpu_module, + modified_modules, + role, + module_regex, + ) + module = str(part["module"]) + if module not in baseline_modules: + raise ValueError(f"{role} module not found in baseline RTL: {module}") + baseline_hash, baseline_closure = closure_hash(baseline, module, baseline_modules) + modified_hash, modified_closure = closure_hash(modified, module, modified_modules) + changed = baseline_hash != modified_hash + cell = f"{args.cpu_cell.rstrip('/')}/{part['instance_path']}" + reference_dcp = dcp_root / "reference" / role / "cpu-synth.dcp" + current_dcp = ( + dcp_root / "current" / role / "cpu-synth.dcp" + if changed else reference_dcp + ) + current_source = modified if changed else baseline + part.update({ + "cell": cell, + "module_regex": module_regex, + "baseline_rtl": str(baseline_modules[module].relative_to(rtl_dir(baseline))), + "modified_rtl": str(modified_modules[module].relative_to(rtl_dir(modified))), + "baseline_closure_hash": baseline_hash, + "modified_closure_hash": modified_hash, + "changed": changed, + "baseline_closure": baseline_closure, + "modified_closure": modified_closure, + "current_source_release": str(current_source), + "reference_source_release": str(baseline), + "current_dcp": str(current_dcp), + "reference_dcp": str(reference_dcp), + }) + partitions.append(part) + cpu_changes = changed_cpu_modules(Path(args.module_boundary_csv)) if args.module_boundary_csv else [] + cpu_file_changes = ( + changed_cpu_file_modules(Path(args.file_summary_csv), baseline, modified) + if args.file_summary_csv else [] + ) + cpu_changes = sorted(set(cpu_changes + cpu_file_changes)) + covered_modules = set() + for part in partitions: + covered_modules.update(str(module) for module in part.get("modified_closure", [])) + covered_modules.update(str(module) for module in part.get("baseline_closure", [])) + uncovered_cpu_changes = sorted(module for module in cpu_changes if module not in covered_modules) + except ValueError as exc: + print(f"ERROR: {exc}", file=sys.stderr) + return 1 + + manifest = { + "schema_version": 1, + "preset": args.preset, + "baseline_release": str(baseline), + "modified_release": str(modified), + "cpu_module": args.cpu_module, + "cpu_cell": args.cpu_cell, + "dcp_root": str(dcp_root), + "partitions": partitions, + "changed_cpu_modules": cpu_changes, + "changed_cpu_file_modules": cpu_file_changes, + "uncovered_cpu_changes": uncovered_cpu_changes, + "subpartition_reuse_candidate": not uncovered_cpu_changes, + } + + json_out = Path(args.json_out).resolve() + json_out.parent.mkdir(parents=True, exist_ok=True) + json_out.write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8") + write_tsv(Path(args.current_import_tsv).resolve(), import_rows(partitions, "current")) + write_tsv(Path(args.reference_import_tsv).resolve(), import_rows(partitions, "reference")) + write_tsv(Path(args.current_build_tsv).resolve(), build_plan_rows(partitions, "current")) + write_tsv(Path(args.reference_build_tsv).resolve(), build_plan_rows(partitions, "reference")) + + for part in partitions: + print( + f"{part['role']}: module={part['module']} cell={part['cell']} " + f"changed={part['changed']}" + ) + if uncovered_cpu_changes: + print( + "ERROR: CPU changes outside selected subpartitions: " + + ", ".join(uncovered_cpu_changes), + file=sys.stderr, + ) + return 1 + print(f"Wrote partition plan: {json_out}") + return 0 + + +def copy_release(src: Path, dst: Path, force: bool) -> None: + if dst.exists(): + if not force: + raise ValueError(f"output directory already exists: {dst}") + shutil.rmtree(dst) + shutil.copytree(src, dst) + + +def replace_module_text(text: str, module: str, replacement: str) -> str: + original = module_text(text, module) + start = text.find(original) + if start < 0: + raise ValueError(f"module body not found for replacement: {module}") + return text[:start] + replacement.rstrip() + "\n" + text[start + len(original):] + + +def write_stub( + release: Path, + out_release: Path, + module: str, + rtl_rel_path: str | None, +) -> dict[str, object]: + source = rtl_dir(release) / (rtl_rel_path or f"{module}.sv") + target = rtl_dir(out_release) / (rtl_rel_path or f"{module}.sv") + if not source.is_file(): + raise ValueError(f"partition module RTL not found: {source}") + interface = rtl_if.read_cpu_interface(source, module) + stub = rtl_if.make_stub(str(interface["header"]), module, module, source) + if not target.is_file(): + raise ValueError(f"overlay RTL target not found: {target}") + target_text = target.read_text(encoding="utf-8", errors="replace") + target.write_text(replace_module_text(target_text, module, stub), encoding="utf-8") + return { + "module": module, + "source_rtl": str(source), + "source_rtl_sha256": rtl_if.sha256_file(source), + "stub_rtl": str(target), + "stub_rtl_sha256": rtl_if.sha256_file(target), + "interface_hash": interface["interface_hash"], + "port_count": interface["port_count"], + } + + +def partition_rtl_rel_path(part: dict[str, object], release: Path, plan: dict[str, object]) -> str | None: + baseline_release = Path(str(plan.get("baseline_release", ""))).resolve() + modified_release = Path(str(plan.get("modified_release", ""))).resolve() + if release == baseline_release: + return str(part.get("baseline_rtl", "")) or None + if release == modified_release: + return str(part.get("modified_rtl", "")) or None + + for key in ("modified_rtl", "baseline_rtl"): + rel_path = str(part.get(key, "")) + if rel_path and (rtl_dir(release) / rel_path).is_file(): + return rel_path + return None + + +def run_overlay(args: argparse.Namespace) -> int: + release = Path(args.release).resolve() + out_dir = Path(args.out_dir).resolve() + plan_path = Path(args.partitions_json).resolve() + if not release.is_dir(): + print(f"ERROR: release directory not found: {release}", file=sys.stderr) + return 1 + if not plan_path.is_file(): + print(f"ERROR: partition plan not found: {plan_path}", file=sys.stderr) + return 1 + + try: + plan = json.loads(plan_path.read_text(encoding="utf-8")) + partitions = plan.get("partitions", []) + if not partitions: + raise ValueError(f"no partitions in {plan_path}") + copy_release(release, out_dir, args.force) + stubs = [] + for part in partitions: + module = str(part.get("module", "")) + if not module: + raise ValueError(f"partition missing module: {part}") + rtl_rel_path = partition_rtl_rel_path(part, release, plan) + stubs.append(write_stub(release, out_dir, module, rtl_rel_path)) + except (OSError, ValueError, json.JSONDecodeError) as exc: + print(f"ERROR: {exc}", file=sys.stderr) + return 1 + + manifest = { + "schema_version": 1, + "kind": "cpu_subpartition_overlay", + "source_release": str(release), + "out_release": str(out_dir), + "partitions_json": str(plan_path), + "preset": plan.get("preset", ""), + "stubs": stubs, + } + manifest_path = Path(args.json_out).resolve() if args.json_out else out_dir / "cpu-subpartition-overlay.json" + manifest_path.parent.mkdir(parents=True, exist_ok=True) + manifest_path.write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8") + + print(f"Wrote CPU subpartition overlay manifest: {manifest_path}") + for stub in stubs: + print(f"Wrote black-box stub: {stub['stub_rtl']}") + return 0 + + +def add_plan_parser(subparsers: argparse._SubParsersAction) -> None: + parser = subparsers.add_parser( + "plan", + help="plan frontend/backend CPU subpartition DCP reuse from generated RTL", + ) + parser.add_argument("--baseline-release", required=True) + parser.add_argument("--modified-release", required=True) + parser.add_argument("--cpu-module", required=True) + parser.add_argument("--cpu-cell", required=True) + parser.add_argument("--preset", choices=["frontend-backend"], default="frontend-backend") + parser.add_argument("--dcp-root", required=True) + parser.add_argument("--json-out", required=True) + parser.add_argument("--current-import-tsv", required=True) + parser.add_argument("--reference-import-tsv", required=True) + parser.add_argument("--current-build-tsv", required=True) + parser.add_argument("--reference-build-tsv", required=True) + parser.add_argument("--module-boundary-csv", default="") + parser.add_argument("--file-summary-csv", default="") + parser.add_argument("--frontend-regex", default=r"Frontend") + parser.add_argument("--backend-regex", default=r"Backend") + parser.set_defaults(func=run_plan) + + +def add_overlay_parser(subparsers: argparse._SubParsersAction) -> None: + parser = subparsers.add_parser( + "overlay", + help="create a release overlay with selected CPU submodules as black boxes", + ) + parser.add_argument("--release", required=True, help="Source release directory") + parser.add_argument("--partitions-json", required=True, help="Partition plan JSON") + parser.add_argument("--out-dir", required=True, help="Output release directory") + parser.add_argument("--force", action="store_true") + parser.add_argument("--json-out", help="Optional overlay manifest") + parser.set_defaults(func=run_overlay) + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Plan and materialize CPU subpartition DCP reuse overlays") + subparsers = parser.add_subparsers(dest="command") + add_plan_parser(subparsers) + add_overlay_parser(subparsers) + args = parser.parse_args() + if not hasattr(args, "func"): + parser.print_help(sys.stderr) + return 2 + return args.func(args) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/fpga_diff/tools/cpu_dcp_split/create_cpu_partition_overlay.py b/fpga_diff/tools/cpu_dcp_split/create_cpu_partition_overlay.py new file mode 100755 index 00000000..2aca7dfc --- /dev/null +++ b/fpga_diff/tools/cpu_dcp_split/create_cpu_partition_overlay.py @@ -0,0 +1,262 @@ +#!/usr/bin/env python3 +import argparse +import json +import re +import shutil +import sys +from pathlib import Path + +import cpu_rtl_interface as cpu_if + + +def sha256_file(path: Path) -> str: + return cpu_if.sha256_file(path) + + +def replace_cpu_instance( + simtop_text: str, + cpu_module: str, + partition_module: str, + cpu_instance: str, +) -> tuple[str, int]: + pattern = re.compile( + rf"(?m)^(\s*){re.escape(cpu_module)}(\s+{re.escape(cpu_instance)}\s*\()" + ) + replaced, count = pattern.subn(rf"\1{partition_module}\2", simtop_text) + return replaced, count + + +def copy_release(src: Path, dst: Path) -> None: + if dst.exists(): + shutil.rmtree(dst) + shutil.copytree(src, dst) + + +def write_text(path: Path, text: str) -> None: + path.write_text(text, encoding="utf-8") + + +def create_ooc_release( + release: Path, + out_dir: Path, + cpu_module: str, + partition_module: str, +) -> dict: + copy_release(release, out_dir) + rtl_dir = out_dir / "build" / "rtl" + source_cpu = release / "build" / "rtl" / f"{cpu_module}.sv" + target_wrapper = rtl_dir / f"{partition_module}.sv" + + try: + interface = cpu_if.read_cpu_interface(source_cpu, cpu_module) + except ValueError as exc: + raise SystemExit(f"ERROR: {exc}") from exc + header = str(interface["header"]) + wrapper_text = cpu_if.make_wrapper(header, cpu_module, partition_module, source_cpu) + write_text(target_wrapper, wrapper_text) + + return { + "kind": "ooc_release", + "release": str(out_dir), + "generated_rtl": str(target_wrapper), + "generated_rtl_sha256": sha256_file(target_wrapper), + "cpu_interface_hash": interface["interface_hash"], + "cpu_port_count": interface["port_count"], + "header_line_count": len(header.splitlines()), + "wrapper_line_count": len(wrapper_text.splitlines()), + } + + +def create_top_release( + release: Path, + out_dir: Path, + cpu_module: str, + partition_module: str, + simtop_module: str, + cpu_instance: str, +) -> dict: + copy_release(release, out_dir) + rtl_dir = out_dir / "build" / "rtl" + source_cpu = release / "build" / "rtl" / f"{cpu_module}.sv" + source_simtop = release / "build" / "rtl" / f"{simtop_module}.sv" + target_stub = rtl_dir / f"{partition_module}.sv" + target_simtop = rtl_dir / f"{simtop_module}.sv" + + try: + interface = cpu_if.read_cpu_interface(source_cpu, cpu_module) + except ValueError as exc: + raise SystemExit(f"ERROR: {exc}") from exc + header = str(interface["header"]) + stub_text = cpu_if.make_stub(header, cpu_module, partition_module, source_cpu) + write_text(target_stub, stub_text) + + simtop_text = target_simtop.read_text(encoding="utf-8") + new_simtop, replacements = replace_cpu_instance( + simtop_text, + cpu_module=cpu_module, + partition_module=partition_module, + cpu_instance=cpu_instance, + ) + if replacements != 1: + raise SystemExit( + "ERROR: expected exactly one CPU instance replacement in " + f"{target_simtop}, got {replacements}" + ) + write_text(target_simtop, new_simtop) + + return { + "kind": "top_release", + "release": str(out_dir), + "generated_stub": str(target_stub), + "generated_stub_sha256": sha256_file(target_stub), + "cpu_interface_hash": interface["interface_hash"], + "cpu_port_count": interface["port_count"], + "modified_simtop": str(target_simtop), + "modified_simtop_sha256": sha256_file(target_simtop), + "simtop_instance_replacements": replacements, + "header_line_count": len(header.splitlines()), + "stub_line_count": len(stub_text.splitlines()), + } + + +def create_blackbox_release( + release: Path, + out_dir: Path, + cpu_module: str, +) -> dict: + copy_release(release, out_dir) + rtl_dir = out_dir / "build" / "rtl" + source_cpu = release / "build" / "rtl" / f"{cpu_module}.sv" + target_stub = rtl_dir / f"{cpu_module}.sv" + + try: + interface = cpu_if.read_cpu_interface(source_cpu, cpu_module) + except ValueError as exc: + raise SystemExit(f"ERROR: {exc}") from exc + header = str(interface["header"]) + stub_text = cpu_if.make_stub(header, cpu_module, cpu_module, source_cpu) + write_text(target_stub, stub_text) + + return { + "kind": "blackbox_release", + "release": str(out_dir), + "generated_stub": str(target_stub), + "generated_stub_sha256": sha256_file(target_stub), + "cpu_interface_hash": interface["interface_hash"], + "cpu_port_count": interface["port_count"], + "header_line_count": len(header.splitlines()), + "stub_line_count": len(stub_text.splitlines()), + } + + +def write_manifest(path: Path, data: dict) -> None: + path.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n", encoding="utf-8") + + +def main() -> int: + parser = argparse.ArgumentParser( + description=( + "Create generated-RTL CpuDcpTop overlays for FpgaDiff CPU-DCP " + "split flows." + ) + ) + parser.add_argument("--release", required=True, help="Source release directory containing build/rtl") + parser.add_argument("--cpu-module", required=True, help="Original generated CPU module, for example NutShell") + parser.add_argument("--partition-module", default="CpuDcpTop", help="Generated CPU partition module name") + parser.add_argument("--simtop-module", default="SimTop", help="Generated top module containing the CPU instance") + parser.add_argument("--cpu-instance", default="cpu", help="CPU instance name inside SimTop") + parser.add_argument( + "--mode", + choices=["both", "ooc", "top", "blackbox"], + default="both", + help="Which overlay release(s) to create", + ) + parser.add_argument("--out-dir", required=True, help="Output directory or release directory") + parser.add_argument("--force", action="store_true", help="Remove an existing output directory first") + parser.add_argument("--json-out", help="Optional manifest path") + args = parser.parse_args() + + release = Path(args.release).resolve() + out_dir = Path(args.out_dir).resolve() + rtl_dir = release / "build" / "rtl" + cpu_rtl = rtl_dir / f"{args.cpu_module}.sv" + simtop_rtl = rtl_dir / f"{args.simtop_module}.sv" + + if not release.is_dir(): + print(f"ERROR: release directory not found: {release}", file=sys.stderr) + return 1 + if not rtl_dir.is_dir(): + print(f"ERROR: release RTL directory not found: {rtl_dir}", file=sys.stderr) + return 1 + if not cpu_rtl.is_file(): + print(f"ERROR: CPU module RTL not found: {cpu_rtl}", file=sys.stderr) + return 1 + if args.mode in {"both", "top"} and not simtop_rtl.is_file(): + print(f"ERROR: SimTop RTL not found: {simtop_rtl}", file=sys.stderr) + return 1 + + if out_dir.exists(): + if not args.force: + print(f"ERROR: output directory already exists: {out_dir}", file=sys.stderr) + print("Use --force to replace it.", file=sys.stderr) + return 1 + shutil.rmtree(out_dir) + + created: list[dict] = [] + if args.mode == "both": + out_dir.mkdir(parents=True) + ooc_dir = out_dir / "ooc-release" + top_dir = out_dir / "top-release" + created.append(create_ooc_release(release, ooc_dir, args.cpu_module, args.partition_module)) + created.append(create_top_release( + release, + top_dir, + args.cpu_module, + args.partition_module, + args.simtop_module, + args.cpu_instance, + )) + default_manifest = out_dir / "cpu-partition-overlay.json" + elif args.mode == "ooc": + created.append(create_ooc_release(release, out_dir, args.cpu_module, args.partition_module)) + default_manifest = out_dir / "cpu-partition-overlay.json" + elif args.mode == "top": + created.append(create_top_release( + release, + out_dir, + args.cpu_module, + args.partition_module, + args.simtop_module, + args.cpu_instance, + )) + default_manifest = out_dir / "cpu-partition-overlay.json" + else: + created.append(create_blackbox_release(release, out_dir, args.cpu_module)) + default_manifest = out_dir / "cpu-blackbox-overlay.json" + + effective_partition_module = args.cpu_module if args.mode == "blackbox" else args.partition_module + manifest = { + "schema_version": 1, + "source_release": str(release), + "mode": args.mode, + "cpu_module": args.cpu_module, + "partition_module": effective_partition_module, + "simtop_module": args.simtop_module, + "cpu_instance": args.cpu_instance, + "source_cpu_rtl": str(cpu_rtl), + "source_cpu_rtl_sha256": sha256_file(cpu_rtl), + "source_simtop_rtl": str(simtop_rtl) if simtop_rtl.is_file() else "", + "source_simtop_rtl_sha256": sha256_file(simtop_rtl) if simtop_rtl.is_file() else "", + "created": created, + } + manifest_path = Path(args.json_out).resolve() if args.json_out else default_manifest + write_manifest(manifest_path, manifest) + + print(f"Wrote CPU partition overlay manifest: {manifest_path}") + for item in created: + print(f"Wrote {item['kind']}: {item['release']}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/fpga_diff/tools/cpu_dcp_split/extract_simtop_partition_contract.py b/fpga_diff/tools/cpu_dcp_split/extract_simtop_partition_contract.py new file mode 100755 index 00000000..2d628aae --- /dev/null +++ b/fpga_diff/tools/cpu_dcp_split/extract_simtop_partition_contract.py @@ -0,0 +1,691 @@ +#!/usr/bin/env python3 +import argparse +import json +import re +from pathlib import Path + +import cpu_rtl_interface as rtl_if + +IDENT_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_$]*") +MODULE_RE = re.compile(r"(?m)^\s*module\s+([A-Za-z_][A-Za-z0-9_$]*)\b") +ENDMODULE_RE = re.compile(r"(?m)^\s*endmodule\b") + +strip_comments = rtl_if.strip_comments +strip_comments_preserve_offsets = rtl_if.strip_comments_preserve_offsets +normalize_ws = rtl_if.normalize_ws +line_no = rtl_if.line_no +find_matching_paren = rtl_if.find_matching_paren +split_top_level_commas = rtl_if.split_top_level_commas +parse_width = rtl_if.parse_width +stable_json_hash = rtl_if.stable_json_hash + + +def locate_simtop(path_arg: str, module_name: str) -> tuple[Path, Path | None]: + path = Path(path_arg).resolve() + candidates: list[Path] = [] + release_root: Path | None = None + + if path.is_file(): + candidates.append(path) + release_root = None + elif (path / "build" / "rtl" / f"{module_name}.sv").is_file(): + release_root = path + candidates.append(path / "build" / "rtl" / f"{module_name}.sv") + elif (path / f"{module_name}.sv").is_file(): + candidates.append(path / f"{module_name}.sv") + release_root = path.parent.parent if path.name == "rtl" else None + else: + candidates.extend(path.rglob(f"{module_name}.sv") if path.is_dir() else []) + + if not candidates: + raise SystemExit(f"ERROR: cannot find {module_name}.sv under {path}") + simtop_path = sorted(candidates, key=lambda p: len(p.as_posix()))[0] + return simtop_path, release_root + + +def module_region(text: str, module_name: str) -> tuple[int, int, int, int]: + match = re.search(rf"(?m)^\s*module\s+{re.escape(module_name)}\b", text) + if not match: + raise SystemExit(f"ERROR: module not found: {module_name}") + + open_idx = text.find("(", match.end()) + if open_idx < 0: + raise SystemExit(f"ERROR: module header has no port list: {module_name}") + close_idx = find_matching_paren(text, open_idx) + semi_idx = text.find(";", close_idx) + if semi_idx < 0: + raise SystemExit(f"ERROR: module header has no terminating semicolon: {module_name}") + + end_match = ENDMODULE_RE.search(text, semi_idx) + if not end_match: + raise SystemExit(f"ERROR: module has no endmodule: {module_name}") + return match.start(), open_idx, semi_idx, end_match.end() + + +def module_header_text(text: str, module_name: str) -> str: + module_start, _open_idx, header_end, _module_end = module_region(text, module_name) + return text[module_start:header_end] + + +def locate_module_rtl(release_root: Path | None, simtop_path: Path, module_name: str) -> Path | None: + candidates: list[Path] = [] + if release_root: + candidates.append(release_root / "build" / "rtl" / f"{module_name}.sv") + candidates.append(simtop_path.parent / f"{module_name}.sv") + for candidate in candidates: + if candidate.is_file(): + return candidate.resolve() + return None + + +def parse_header_ports(header_text: str) -> list[dict]: + """Parse ports once, then annotate the SimTop-specific classifications.""" + return [ + { + **port, + "area": classify_port(str(port["name"])), + "family": port_family(str(port["name"])), + } + for port in rtl_if.parse_header_ports(header_text) + ] + + +def parse_declared_signals(body_text: str) -> dict[str, dict]: + clean = strip_comments(body_text) + signals: dict[str, dict] = {} + decl_re = re.compile(r"(?m)^\s*(wire|reg|logic)\s+([^;]+);") + + for match in decl_re.finditer(clean): + kind = match.group(1) + rest = normalize_ws(match.group(2)) + range_text = "" + range_match = re.match(r"^(\[[^\]]+\])\s*(.*)$", rest) + if range_match: + range_text = normalize_ws(range_match.group(1)) + rest = range_match.group(2) + for raw_name in split_top_level_commas(rest): + name_part = raw_name.split("=", 1)[0].strip() + name_match = re.match(r"([A-Za-z_][A-Za-z0-9_$]*)", name_part) + if not name_match: + continue + name = name_match.group(1) + signals[name] = { + "name": name, + "kind": kind, + "range": range_text, + "width": parse_width(range_text) or 1, + } + return signals + + +def parse_connection_list(text: str) -> dict[str, str]: + conns: dict[str, str] = {} + idx = 0 + while idx < len(text): + match = re.search(r"\.\s*([A-Za-z_][A-Za-z0-9_$]*)\s*\(", text[idx:]) + if not match: + break + port = match.group(1) + open_idx = idx + match.end() - 1 + close_idx = find_matching_paren(text, open_idx) + expr = normalize_ws(text[open_idx + 1:close_idx]) + conns[port] = expr + idx = close_idx + 1 + return conns + + +def parse_instances(text: str, body_start: int, body_end: int) -> list[dict]: + body = text[body_start:body_end] + clean = strip_comments_preserve_offsets(body) + inst_re = re.compile( + r"(?m)^\s*([A-Za-z_][A-Za-z0-9_$]*)\s+" + r"(?:#\s*\((?:.|\n)*?\)\s*)?" + r"([A-Za-z_][A-Za-z0-9_$]*)\s*\(" + ) + instances: list[dict] = [] + search_idx = 0 + while True: + match = inst_re.search(clean, search_idx) + if not match: + break + module = match.group(1) + name = match.group(2) + if module in {"if", "else", "for", "while", "always", "assign", "initial"}: + search_idx = match.end() + continue + open_idx = match.end() - 1 + try: + close_idx = find_matching_paren(clean, open_idx) + except ValueError: + search_idx = match.end() + continue + semi_idx = clean.find(";", close_idx) + if semi_idx < 0: + search_idx = close_idx + 1 + continue + conn_text = clean[open_idx + 1:close_idx] + start_offset = body_start + match.start() + instances.append({ + "module": module, + "name": name, + "area": classify_instance(module, name), + "line": line_no(text, start_offset), + "connections": parse_connection_list(conn_text), + }) + search_idx = semi_idx + 1 + return instances + + +def classify_instance(module: str, name: str) -> str: + haystack = f"{module} {name}" + if name == "cpu" or re.search(r"\b(NutShell|XSTop|XSCore|XSTile|Nanhu|XiangShan)\b", haystack): + return "cpu" + if re.search(r"\b(Gateway|GatewayEndpoint|Endpoint)\b", haystack): + return "difftest_gateway" + if "DifftestMemCtrl" in haystack: + return "difftest_memory_transport" + if re.search(r"XDMA|HostEndpoint|ConfigBar", haystack): + return "difftest_transport" + if re.search(r"\b(Difftest|DiffTest|Batch|Delta|ClockGate)\b", haystack): + return "difftest" + return "unknown" + + +def port_family(name: str) -> str: + if name in {"clock", "reset"} or name.endswith("_clock") or name.endswith("_clk"): + return "clock_reset" + if name.startswith("difftest_cfg_axilite_"): + return "difftest_cfg_axilite" + if name.startswith("difftest_to_host_axis_"): + return "difftest_to_host_axis" + if name.startswith("difftest_from_host_axis_"): + return "difftest_from_host_axis" + if name.startswith("difftest_hostCtrl_"): + return "difftest_host_control" + if name.startswith("difftest_mem_"): + return "difftest_external_memory_axi" + if name.startswith("difftest_") and name in { + "difftest_ref_clock", + "difftest_ref_reset", + "difftest_pcie_clock", + "difftest_clock_enable", + }: + return "difftest_clock_control" + if name.startswith("difftest_"): + return "difftest_status_control" + if name.startswith("io_"): + return "cpu_soc_io" + return "top_other" + + +def classify_port(name: str) -> str: + family = port_family(name) + if family in {"clock_reset", "cpu_soc_io", "top_other"}: + return "cpu_or_top" + if family in { + "difftest_cfg_axilite", + "difftest_to_host_axis", + "difftest_from_host_axis", + "difftest_host_control", + "difftest_clock_control", + }: + return "difftest_transport" + if family == "difftest_external_memory_axi": + return "difftest_memory_transport" + return "difftest_status_control" + + +def signal_refs(expr: str) -> list[str]: + refs = [] + for token in IDENT_RE.findall(expr): + if token in {"h", "b", "d", "x", "z"}: + continue + if re.fullmatch(r"[0-9]+", token): + continue + refs.append(token) + return refs + + +def count_by(items: list[dict], key: str) -> dict[str, int]: + counts: dict[str, int] = {} + for item in items: + value = item.get(key, "unknown") or "unknown" + counts[value] = counts.get(value, 0) + 1 + return counts + + +def port_lookup(ports: list[dict]) -> dict[str, dict]: + return {port["name"]: port for port in ports} + + +def first_instance(instances: list[dict], area: str) -> dict | None: + for inst in instances: + if inst["area"] == area: + return inst + return None + + +def instances_by_area(instances: list[dict], area: str) -> list[dict]: + return [inst for inst in instances if inst["area"] == area] + + +def public_instance(inst: dict) -> dict: + return { + "name": inst["name"], + "module": inst["module"], + "area": inst["area"], + "line": inst["line"], + "connection_count": len(inst["connections"]), + } + + +def enrich_connection_with_cpu_port( + row: dict, + cpu_ports: dict[str, dict], +) -> dict: + enriched = dict(row) + cpu_port = cpu_ports.get(row["port"], {}) + if cpu_port: + enriched["cpu_direction"] = cpu_port.get("direction", "") + enriched["cpu_range"] = cpu_port.get("range", "") + enriched["cpu_width"] = cpu_port.get("width") + return enriched + + +def canonical_partition_port(row: dict, role: str) -> dict: + direction = row.get("cpu_direction", "") + if role == "difftest_probe_output": + direction = "output" + return { + "name": row["port"], + "role": role, + "direction": direction, + "width": row.get("cpu_width") or row.get("width") or 1, + "range": row.get("cpu_range") or "", + "expr": row.get("expr", ""), + } + + +def build_cpu_partition_manifest( + simtop_path: Path, + release_root: Path | None, + interfaces: dict, + instances: list[dict], +) -> dict: + cpu = first_instance(instances, "cpu") + if not cpu: + return {} + + cpu_rtl = locate_module_rtl(release_root, simtop_path, cpu["module"]) + cpu_ports: dict[str, dict] = {} + cpu_header_hash = "" + if cpu_rtl: + cpu_text = cpu_rtl.read_text(errors="replace") + cpu_header = module_header_text(cpu_text, cpu["module"]) + cpu_ports = port_lookup(parse_header_ports(cpu_header)) + cpu_header_hash = stable_json_hash(port_summary(list(cpu_ports.values()))) + + def enrich(rows: list[dict]) -> list[dict]: + return [enrich_connection_with_cpu_port(row, cpu_ports) for row in rows] + + clock_reset = enrich(interfaces["cpu_clock_reset_connections"]) + soc = enrich(interfaces["cpu_soc_top_port_connections"]) + memory = enrich(interfaces["cpu_memory_connections"]) + probes = enrich(interfaces["cpu_gateway_probe_outputs"]) + + partition_ports = ( + [canonical_partition_port(row, "clock_reset") for row in clock_reset] + + [canonical_partition_port(row, "soc_top_port") for row in soc] + + [canonical_partition_port(row, "memory_axi_cpu_side") for row in memory] + + [canonical_partition_port(row, "difftest_probe_output") for row in probes] + ) + interface_payload = { + "cpu_module": cpu["module"], + "cpu_instance": cpu["name"], + "partition_ports": partition_ports, + } + + return { + "schema_version": 1, + "purpose": "source-level CpuDcpTop boundary seed for CPU-DCP reuse", + "cpu_module": cpu["module"], + "cpu_instance": cpu["name"], + "cpu_rtl": str(cpu_rtl) if cpu_rtl else "", + "cpu_header_hash": cpu_header_hash, + "interface_hash": stable_json_hash(interface_payload), + "port_counts": { + "clock_reset": len(clock_reset), + "soc_top_ports": len(soc), + "memory_axi_cpu_side": len(memory), + "difftest_probe_outputs": len(probes), + "total": len(partition_ports), + }, + "partition_ports": partition_ports, + "source_level_requirements": [ + "CPU elaboration must export these partition ports from CpuDcpTop.", + "FPGA shell elaboration must consume the same interface hash without elaborating CPU internals.", + "Gateway bundle order, widths, delays, and generated DiffTest metadata must be persisted with this manifest.", + ], + } + + +def port_summary(ports: list[dict], family: str | None = None) -> list[dict]: + selected = ports if family is None else [port for port in ports if port["family"] == family] + return [ + { + "name": port["name"], + "direction": port["direction"], + "range": port["range"], + "width": port["width"], + "family": port["family"], + } + for port in selected + ] + + +def annotate_connection(port_name: str, expr: str, signals: dict[str, dict], top_ports: dict[str, dict]) -> dict: + refs = signal_refs(expr) + primary = refs[0] if len(refs) == 1 else "" + width = None + if primary and primary in signals: + width = signals[primary].get("width") + elif primary and primary in top_ports: + width = top_ports[primary].get("width") + return { + "port": port_name, + "expr": expr, + "signal_refs": refs, + "primary_signal": primary, + "width": width, + } + + +def build_interface_contract(ports: list[dict], instances: list[dict], signals: dict[str, dict]) -> dict: + top_ports = port_lookup(ports) + cpu = first_instance(instances, "cpu") + gateway = first_instance(instances, "difftest_gateway") + memctrl = first_instance(instances, "difftest_memory_transport") + + cpu_memory: list[dict] = [] + cpu_probes: list[dict] = [] + cpu_soc_ports: list[dict] = [] + cpu_clocks_resets: list[dict] = [] + cpu_other: list[dict] = [] + + if cpu: + for port_name, expr in sorted(cpu["connections"].items()): + row = annotate_connection(port_name, expr, signals, top_ports) + if port_name.startswith("io_mem_"): + cpu_memory.append(row) + elif "gatewayIn_packed" in port_name or any("gatewayIn_packed" in ref for ref in row["signal_refs"]): + cpu_probes.append(row) + elif expr in top_ports and top_ports[expr]["family"] == "cpu_soc_io": + cpu_soc_ports.append(row) + elif port_name in {"clock", "reset"}: + cpu_clocks_resets.append(row) + else: + cpu_other.append(row) + + memctrl_cpu_side: list[dict] = [] + memctrl_external_side: list[dict] = [] + if memctrl: + for port_name, expr in sorted(memctrl["connections"].items()): + row = annotate_connection(port_name, expr, signals, top_ports) + if port_name.startswith("io_cpu_"): + memctrl_cpu_side.append(row) + elif port_name.startswith("io_mem_"): + memctrl_external_side.append(row) + + gateway_inputs: list[dict] = [] + gateway_outputs: list[dict] = [] + if gateway: + for port_name, expr in sorted(gateway["connections"].items()): + row = annotate_connection(port_name, expr, signals, top_ports) + if port_name in {"clock", "reset", "in", "fpgaIO_ready"}: + gateway_inputs.append(row) + else: + gateway_outputs.append(row) + + memory_links: list[dict] = [] + memctrl_by_expr = {row["expr"]: row for row in memctrl_cpu_side} + for row in cpu_memory: + link = { + "cpu_port": row["port"], + "cpu_expr": row["expr"], + "width": row["width"], + } + peer = memctrl_by_expr.get(row["expr"]) + if peer: + link["memctrl_port"] = peer["port"] + memory_links.append(link) + + return { + "cpu_clock_reset_connections": cpu_clocks_resets, + "cpu_soc_top_port_connections": cpu_soc_ports, + "cpu_memory_connections": cpu_memory, + "cpu_memory_to_difftest_memctrl_links": memory_links, + "cpu_gateway_probe_outputs": cpu_probes, + "cpu_other_connections": cpu_other, + "gateway_inputs": gateway_inputs, + "gateway_outputs": gateway_outputs, + "memctrl_cpu_side_connections": memctrl_cpu_side, + "memctrl_external_memory_connections": memctrl_external_side, + "top_level_ports": { + "clock_reset": port_summary(ports, "clock_reset"), + "cpu_soc_io": port_summary(ports, "cpu_soc_io"), + "difftest_status_control": port_summary(ports, "difftest_status_control"), + "difftest_clock_control": port_summary(ports, "difftest_clock_control"), + "difftest_cfg_axilite": port_summary(ports, "difftest_cfg_axilite"), + "difftest_to_host_axis": port_summary(ports, "difftest_to_host_axis"), + "difftest_from_host_axis": port_summary(ports, "difftest_from_host_axis"), + "difftest_host_control": port_summary(ports, "difftest_host_control"), + "difftest_external_memory_axi": port_summary(ports, "difftest_external_memory_axi"), + }, + } + + +def build_candidates(instances: list[dict], interfaces: dict) -> dict: + cpu = first_instance(instances, "cpu") + gateway = first_instance(instances, "difftest_gateway") + transport = ( + instances_by_area(instances, "difftest_transport") + + instances_by_area(instances, "difftest_memory_transport") + ) + cpu_name = cpu["name"] if cpu else "" + gateway_name = gateway["name"] if gateway else "" + transport_names = [inst["name"] for inst in transport] + + cpu_only_ports = { + "clock_reset": interfaces["cpu_clock_reset_connections"], + "soc_top_ports": interfaces["cpu_soc_top_port_connections"], + "memory_axi_cpu_side": interfaces["cpu_memory_connections"], + "difftest_probe_packed_outputs": interfaces["cpu_gateway_probe_outputs"], + } + cpu_plus_gateway_ports = { + "clock_reset": interfaces["cpu_clock_reset_connections"], + "gateway_clock_reset": interfaces["gateway_inputs"], + "soc_top_ports": interfaces["cpu_soc_top_port_connections"], + "memory_axi_cpu_side": interfaces["cpu_memory_connections"], + "gateway_fpga_stream": interfaces["gateway_outputs"], + } + + return { + "cpu_only": { + "goal": "Maximize CPU DCP reuse when DiffTest/Gateway/transport implementation changes but probe layout and CPU RTL stay unchanged.", + "include_instances": [name for name in [cpu_name] if name], + "exclude_instances": [name for name in [gateway_name] if name] + transport_names, + "boundary_ports": cpu_only_ports, + "invalidated_by": [ + "CPU RTL or CPU config changes", + "gatewayIn_packed probe count/width/layout changes", + "CPU memory AXI shape changes", + "CPU clock/reset semantics at the partition boundary change", + ], + }, + "cpu_plus_gateway": { + "goal": "Smaller first prototype for transport-only edits; Gateway changes invalidate this checkpoint.", + "include_instances": [name for name in [cpu_name, gateway_name] if name], + "exclude_instances": transport_names, + "boundary_ports": cpu_plus_gateway_ports, + "invalidated_by": [ + "CPU RTL or CPU config changes", + "GatewayEndpoint implementation changes", + "Gateway fpgaIO stream width or ready/valid semantics change", + "CPU memory AXI shape changes", + ], + }, + } + + +def build_checks(instances: list[dict], interfaces: dict) -> list[dict]: + cpu_instances = instances_by_area(instances, "cpu") + gateway_instances = instances_by_area(instances, "difftest_gateway") + transport_instances = ( + instances_by_area(instances, "difftest_transport") + + instances_by_area(instances, "difftest_memory_transport") + ) + checks = [ + { + "name": "single_cpu_instance", + "passed": len(cpu_instances) == 1, + "detail": f"cpu_instances={[inst['name'] for inst in cpu_instances]}", + }, + { + "name": "gateway_present", + "passed": len(gateway_instances) >= 1, + "detail": f"gateway_instances={[inst['name'] for inst in gateway_instances]}", + }, + { + "name": "transport_present_in_current_simtop", + "passed": len(transport_instances) > 0, + "detail": f"transport_instances={[inst['name'] for inst in transport_instances]}", + }, + { + "name": "current_simtop_is_not_cpu_only_checkpoint", + "passed": len(transport_instances) == 0 and len(gateway_instances) == 0, + "detail": ( + "Current SimTop still contains DiffTest/Gateway transport logic; " + "a new CpuDcpTop boundary is required before cell-level CPU DCP reuse." + ), + }, + { + "name": "cpu_memory_links_detected", + "passed": len(interfaces["cpu_memory_to_difftest_memctrl_links"]) > 0, + "detail": f"links={len(interfaces['cpu_memory_to_difftest_memctrl_links'])}", + }, + { + "name": "cpu_probe_outputs_detected", + "passed": len(interfaces["cpu_gateway_probe_outputs"]) > 0, + "detail": f"probe_ports={len(interfaces['cpu_gateway_probe_outputs'])}", + }, + ] + return checks + + +def extract_contract(simtop_path: Path, release_root: Path | None, module_name: str) -> dict: + text = simtop_path.read_text(errors="replace") + module_start, open_idx, header_end, module_end = module_region(text, module_name) + header = text[module_start:header_end] + body_start = header_end + 1 + body = text[body_start:module_end] + + ports = parse_header_ports(header) + signals = parse_declared_signals(body) + instances = parse_instances(text, body_start, module_end) + interfaces = build_interface_contract(ports, instances, signals) + cpu_partition_manifest = build_cpu_partition_manifest( + simtop_path, + release_root, + interfaces, + instances, + ) + checks = build_checks(instances, interfaces) + + transport_instances = ( + instances_by_area(instances, "difftest_transport") + + instances_by_area(instances, "difftest_memory_transport") + ) + gateway_instances = instances_by_area(instances, "difftest_gateway") + cpu_instances = instances_by_area(instances, "cpu") + + return { + "schema_version": 1, + "release": str(release_root) if release_root else "", + "simtop_path": str(simtop_path), + "module": { + "name": module_name, + "line": line_no(text, module_start), + "ports": len(ports), + "declared_signals": len(signals), + "instances": len(instances), + }, + "counts": { + "ports_by_family": count_by(ports, "family"), + "ports_by_area": count_by(ports, "area"), + "instances_by_area": count_by(instances, "area"), + }, + "instances": [public_instance(inst) for inst in instances], + "cpu_instances": [public_instance(inst) for inst in cpu_instances], + "gateway_instances": [public_instance(inst) for inst in gateway_instances], + "transport_instances": [public_instance(inst) for inst in transport_instances], + "current_boundary": { + "simtop_contains_cpu": bool(cpu_instances), + "simtop_contains_gateway": bool(gateway_instances), + "simtop_contains_difftest_transport": bool(transport_instances), + "cpu_only_checkpoint_candidate": bool(cpu_instances) and not gateway_instances and not transport_instances, + "conclusion": ( + "mixed-simtop-needs-cpudcptop" + if gateway_instances or transport_instances + else "simtop-may-be-cpu-only" + ), + }, + "interfaces": interfaces, + "cpu_partition_manifest": cpu_partition_manifest, + "candidate_boundaries": build_candidates(instances, interfaces), + "checks": checks, + } + + +def print_summary(contract: dict) -> None: + current = contract["current_boundary"] + counts = contract["counts"] + print(f"SimTop: {contract['simtop_path']}") + print(f"Instances by area: {counts['instances_by_area']}") + print(f"Ports by family: {counts['ports_by_family']}") + print(f"Current boundary: {current['conclusion']}") + print(f"CPU-only checkpoint candidate: {current['cpu_only_checkpoint_candidate']}") + for check in contract["checks"]: + state = "PASS" if check["passed"] else "FAIL" + print(f"{state}: {check['name']} - {check['detail']}") + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Extract a machine-readable SimTop CPU/DiffTest partition contract from generated RTL") + parser.add_argument( + "release_or_rtl", + help="FpgaDiff release directory, build/rtl directory, or SimTop.sv path") + parser.add_argument("--module", default="SimTop", help="Generated top module name") + parser.add_argument("--json-out", help="Write contract JSON to this path") + parser.add_argument( + "--require-cpu-only", + action="store_true", + help="Return non-zero if the current module still contains Gateway/transport instances") + args = parser.parse_args() + + simtop_path, release_root = locate_simtop(args.release_or_rtl, args.module) + contract = extract_contract(simtop_path, release_root, args.module) + + if args.json_out: + out = Path(args.json_out).resolve() + out.parent.mkdir(parents=True, exist_ok=True) + out.write_text(json.dumps(contract, indent=2, sort_keys=True) + "\n") + + print_summary(contract) + if args.require_cpu_only and not contract["current_boundary"]["cpu_only_checkpoint_candidate"]: + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/fpga_diff/tools/cpu_dcp_split/flow_common.py b/fpga_diff/tools/cpu_dcp_split/flow_common.py new file mode 100755 index 00000000..dda53edd --- /dev/null +++ b/fpga_diff/tools/cpu_dcp_split/flow_common.py @@ -0,0 +1,300 @@ +#!/usr/bin/env python3 +"""Shared helpers for the FpgaDiff incremental build flows.""" + +from __future__ import annotations + +import json +import os +import resource +import shlex +import shutil +import subprocess +import sys +import time +from dataclasses import dataclass +from pathlib import Path +from typing import Callable, Iterable, Sequence + + +@dataclass(frozen=True) +class FlowPaths: + script_dir: Path + fpga_diff_dir: Path + repo_root: Path + + +def resolve_m(path: str | Path) -> Path: + """Approximate `realpath -m`: absolute, normalized, and not strict.""" + return Path(path).expanduser().resolve(strict=False) + + +def init_paths(caller: str | Path) -> FlowPaths: + script_dir = Path(caller).resolve(strict=False).parent + if (script_dir / "../..").is_dir() and (script_dir / "../../Makefile").is_file(): + fpga_diff_dir = resolve_m(script_dir / "../..") + repo_root = resolve_m(script_dir / "../../../..") + elif (script_dir / "../../env-scripts/fpga_diff").is_dir(): + repo_root = resolve_m(script_dir / "../..") + fpga_diff_dir = repo_root / "env-scripts" / "fpga_diff" + else: + raise SystemExit(f"ERROR: cannot locate fpga_diff Makefile from {script_dir}") + return FlowPaths(script_dir=script_dir, fpga_diff_dir=fpga_diff_dir, repo_root=repo_root) + + +def require_existing_dir(path: Path, label: str = "directory") -> None: + if not path.is_dir(): + raise SystemExit(f"ERROR: {label} not found: {path}") + + +def require_release_dirs(*releases: Path) -> None: + for release in releases: + require_existing_dir(release) + require_existing_dir(release / "build") + + +def require_positive_int(name: str, value: str | None) -> None: + if value and (not value.isdecimal() or int(value) <= 0): + raise SystemExit(f"ERROR: {name} must be a positive integer, got {value}") + + +def read_json(path: Path) -> dict: + if not path.is_file(): + return {} + try: + return json.loads(path.read_text(errors="replace")) + except json.JSONDecodeError: + return {} + + +def infer_cpu_dcp_config(paths: FlowPaths, release: Path, cpu: str | None = None, *, required: bool = False) -> dict[str, str]: + cmd = [ + str(paths.script_dir / "infer_cpu_dcp_config.py"), + "--release", + str(release), + "--fpga-diff-dir", + str(paths.fpga_diff_dir), + ] + if cpu: + cmd += ["--cpu", cpu] + proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, check=False) + if proc.returncode != 0: + lines = proc.stderr.splitlines()[:5] + message = f"could not auto-infer CPU-DCP parameters from {release}" + if required: + detail = "\n".join(lines) + raise SystemExit(f"ERROR: {message}\n{detail}\nPass --cpu kmh, --cpu nanhu, or --cpu nutshell explicitly.") + print(f"WARNING: {message}:") + print("\n".join(lines)) + return {} + return {key: str(value) for key, value in json.loads(proc.stdout).items() if not isinstance(value, (dict, list))} + + +def default_jobs(value: str | None) -> str: + if value: + return value + count = os.cpu_count() or 1 + return str(max(1, (count + 1) // 2)) + + +def find_vivado() -> str: + vivado = os.environ.get("VIVADO", "") + if vivado and os.access(vivado, os.X_OK): + return str(resolve_m(vivado)) + + found = shutil.which("vivado") + if found: + return found + + roots: list[str] = [] + roots.extend(v for v in (os.environ.get("VIVADO_HOME"), os.environ.get("XILINX_VIVADO")) if v) + for root in roots: + for candidate in (Path(root) / "bin" / "vivado", Path(root) / "Vivado" / "bin" / "vivado"): + if os.access(candidate, os.X_OK): + return str(resolve_m(candidate)) + + settings_candidates: list[Path] = [] + if os.environ.get("XILINX_SETTINGS64"): + settings_candidates.append(Path(os.environ["XILINX_SETTINGS64"])) + for root in roots: + settings_candidates.extend([Path(root) / "settings64.sh", Path(root) / "Vivado" / "settings64.sh"]) + + for settings in settings_candidates: + if not settings.is_file(): + continue + proc = subprocess.run( + [ + "bash", + "-lc", + 'source "$1" >/dev/null 2>&1 || exit 0; ' + 'vivado_path=$(command -v vivado || true); ' + '[ -n "$vivado_path" ] || exit 0; ' + 'printf "%s\\n__ENV__\\n" "$vivado_path"; env -0', + "bash", + str(settings), + ], + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + text=False, + ) + if proc.returncode != 0 or b"\n__ENV__\n" not in proc.stdout: + continue + vivado_bytes, env_bytes = proc.stdout.split(b"\n__ENV__\n", 1) + vivado_path = vivado_bytes.decode(errors="replace").strip() + for item in env_bytes.split(b"\0"): + if not item or b"=" not in item: + continue + key, value = item.split(b"=", 1) + os.environ[key.decode(errors="replace")] = value.decode(errors="replace") + if vivado_path: + return vivado_path + + raise SystemExit( + "ERROR: vivado not found. Put vivado in PATH, set VIVADO=/path/to/vivado, " + "or set VIVADO_HOME/XILINX_VIVADO/XILINX_SETTINGS64." + ) + + +def resolve_vivado(vivado_bin: str, dry_run: bool) -> str: + if dry_run: + return vivado_bin + + resolved = shutil.which(vivado_bin) + if resolved is None and vivado_bin == "vivado": + resolved = find_vivado() + if resolved is None: + raise SystemExit( + f"ERROR: vivado not found: {vivado_bin}\n" + "Set --vivado /path/to/vivado or run this on the Vivado host with vivado in PATH." + ) + if not os.access(resolved, os.X_OK): + raise SystemExit(f"ERROR: vivado not executable: {resolved}") + + os.environ["PATH"] = str(Path(resolved).parent) + os.pathsep + os.environ.get("PATH", "") + return resolved + + +def vivado_version(vivado_cmd: str, dry_run: bool) -> str: + if dry_run: + return "dry-run" + try: + proc = subprocess.run( + [vivado_cmd, "-version"], + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + text=True, + ) + except OSError: + return "" + return proc.stdout.splitlines()[0] if proc.stdout.splitlines() else "" + + +def write_lines(path: Path, lines: Iterable[str]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("\n".join(lines) + "\n", encoding="utf-8") + + +def make_cmd( + fpga_diff_dir: Path, + target: str, + cpu: str, + suffix: str, + *, + core_dir: Path | None = None, + jobs: str | None = None, +) -> list[str]: + cmd = ["make", "-C", str(fpga_diff_dir), target, f"CPU={cpu}", f"SUFFIX={suffix}"] + if core_dir is not None: + cmd.append(f"CORE_DIR={core_dir}") + if jobs: + cmd.append(f"VIVADO_JOBS={jobs}") + return cmd + + +def vivado_batch_cmd(vivado_cmd: str, source: Path, *tclargs: object) -> list[str]: + return [ + vivado_cmd, + "-mode", + "batch", + "-source", + str(source), + "-tclargs", + *[str(arg) for arg in tclargs], + ] + + +def write_empty_stage(out_dir: Path, label: str, message: str) -> None: + log = out_dir / "logs" / f"{label}.log" + time_file = out_dir / "logs" / f"{label}.time" + write_lines(log, [message.rstrip()]) + write_lines(time_file, ["elapsed_sec="]) + print(message) + + +class FlowRunner: + def __init__( + self, + out_dir: Path, + dry_run: bool = False, + dry_skip: Callable[[str], bool] | None = None, + ) -> None: + self.out_dir = out_dir + self.logs_dir = out_dir / "logs" + self.logs_dir.mkdir(parents=True, exist_ok=True) + self.dry_run = dry_run + self.dry_skip = dry_skip or (lambda _label: True) + + def record_dry_run(self, label: str, cmd: Sequence[str]) -> int: + log_file = self.logs_dir / f"{label}.log" + time_file = self.logs_dir / f"{label}.time" + command = shlex.join(str(part) for part in cmd) + time_file.write_text(f"dry_run_command={command}\n", encoding="utf-8") + log_file.write_text(command + "\n", encoding="utf-8") + print(command) + return 0 + + def run(self, label: str, cmd: Sequence[str], check: bool = True) -> int: + cmd = [str(part) for part in cmd] + log_file = self.logs_dir / f"{label}.log" + time_file = self.logs_dir / f"{label}.time" + print(f"INFO: [{label}] {shlex.join(cmd)}") + if self.dry_run and self.dry_skip(label): + return self.record_dry_run(label, cmd) + + start = time.monotonic() + before = resource.getrusage(resource.RUSAGE_CHILDREN) + with log_file.open("w", encoding="utf-8", errors="replace") as log: + try: + proc = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + errors="replace", + ) + except OSError as exc: + log.write(f"ERROR: {exc}\n") + if check: + raise SystemExit(1) from exc + return 1 + assert proc.stdout is not None + for line in proc.stdout: + sys.stdout.write(line) + log.write(line) + rc = proc.wait() + after = resource.getrusage(resource.RUSAGE_CHILDREN) + elapsed = time.monotonic() - start + user = max(0.0, after.ru_utime - before.ru_utime) + sys_sec = max(0.0, after.ru_stime - before.ru_stime) + max_rss = max(0, after.ru_maxrss - before.ru_maxrss) + time_file.write_text( + f"elapsed_sec={elapsed:.2f}\n" + f"user_sec={user:.2f}\n" + f"sys_sec={sys_sec:.2f}\n" + f"max_rss_kb={max_rss}\n", + encoding="utf-8", + ) + if rc != 0 and check: + raise SystemExit(rc) + return rc diff --git a/fpga_diff/tools/cpu_dcp_split/implementation_fingerprint.py b/fpga_diff/tools/cpu_dcp_split/implementation_fingerprint.py new file mode 100755 index 00000000..a70dd699 --- /dev/null +++ b/fpga_diff/tools/cpu_dcp_split/implementation_fingerprint.py @@ -0,0 +1,274 @@ +#!/usr/bin/env python3 +"""Record and compare the inputs that determine incremental implementation reuse.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +from pathlib import Path + +import cpu_rtl_interface as rtl_if + + +PROJECT_INPUTS = ( + "Makefile", + "core_flist.awk", + "chi_flist.awk", + "ram_declare.py", + "tools/gen_synth.tcl", + "tools/gen_bitstream.tcl", + "tools/run_impl_route.tcl", +) +IMPLEMENTATION_ENV = ( + "CHI_DIR", + "DDR_RANK_WIDTH", + "ENABLE_ILA", + "ILA_DEPTH", + "XDMA_LINK_WIDTH", +) + + +sha256_file = rtl_if.sha256_file + + +def stable_hash(value: object) -> str: + encoded = json.dumps(value, sort_keys=True, separators=(",", ":")).encode() + return hashlib.sha256(encoded).hexdigest() + + +def semantic_file_hash(path: Path) -> str: + """Hash RTL after removing layout-only changes that do not reach synthesis.""" + if path.suffix.lower() not in {".v", ".sv", ".svh"}: + return sha256_file(path) + text = path.read_text(encoding="utf-8", errors="replace") + return hashlib.sha256(rtl_if.strip_comments(text).encode()).hexdigest() + + +def fingerprint_files(root: Path, paths: list[Path]) -> dict[str, object]: + files = [ + { + "path": path.relative_to(root).as_posix(), + "sha256": sha256_file(path), + "semantic_sha256": semantic_file_hash(path), + } + for path in sorted(paths) + ] + semantic_files = [ + {"path": entry["path"], "sha256": entry["semantic_sha256"]} + for entry in files + ] + return { + "file_count": len(files), + "files": files, + "sha256": stable_hash([{key: value for key, value in entry.items() if key != "semantic_sha256"} for entry in files]), + "semantic_sha256": stable_hash(semantic_files), + } + + +def build_fingerprint(release: Path) -> dict[str, object]: + build = release / "build" + paths = [path for path in build.rglob("*") if path.is_file()] + return fingerprint_files(build, paths) + + +def project_fingerprint(fpga_diff_dir: Path) -> dict[str, object]: + paths = [fpga_diff_dir / rel for rel in PROJECT_INPUTS if (fpga_diff_dir / rel).is_file()] + paths.extend(path for path in (fpga_diff_dir / "src").rglob("*") if path.is_file()) + return fingerprint_files(fpga_diff_dir, paths) + + +def interface_fingerprint(release: Path, module: str, *, cpu: bool) -> dict[str, object]: + if not module: + return {"module": "", "present": False, "interface_hash": "", "source_sha256": "", "semantic_sha256": ""} + source = release / "build" / "rtl" / f"{module}.sv" + if not source.is_file(): + return {"module": module, "present": False, "interface_hash": "", "source_sha256": "", "semantic_sha256": ""} + interface = ( + rtl_if.read_cpu_interface(source, module) + if cpu + else rtl_if.read_module_interface(source, module) + ) + return { + "module": module, + "present": True, + "interface_hash": interface["interface_hash"], + "source_sha256": interface.get("source_cpu_rtl_sha256", interface.get("source_rtl_sha256", "")), + "semantic_sha256": semantic_file_hash(source), + "port_count": interface["port_count"], + } + + +def checkpoint_fingerprint(path_arg: str) -> dict[str, str]: + if not path_arg: + return {"path": "", "sha256": ""} + path = Path(path_arg).resolve() + return {"path": str(path), "sha256": sha256_file(path) if path.is_file() else ""} + + +def load_json(path_arg: str) -> dict: + if not path_arg: + return {} + path = Path(path_arg).resolve() + if not path.is_file(): + raise SystemExit(f"ERROR: reference fingerprint not found: {path}") + try: + return json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + raise SystemExit(f"ERROR: invalid reference fingerprint: {path}: {exc}") from exc + + +def reference_compatibility(reference: dict, current: dict) -> dict[str, object]: + if not reference: + return {"checked": False, "compatible": True, "reasons": []} + + reference_context = str(reference.get("implementation_context_sha256", "")) + reference_baseline = reference.get("baseline", {}) + reference_checkpoints = reference.get("reference_checkpoints", {}) + current_baseline = current["baseline"] + current_checkpoints = current["reference_checkpoints"] + checks = { + "implementation_context_changed": reference_context != current["implementation_context_sha256"], + "reference_source_does_not_match_baseline": ( + reference_baseline.get("build", {}).get("semantic_sha256", "") + != current_baseline["build"]["semantic_sha256"] + ), + "cpu_interface_changed": ( + reference_baseline.get("cpu_interface", {}).get("interface_hash", "") + != current_baseline["cpu_interface"]["interface_hash"] + ), + "partition_interface_changed": ( + reference_baseline.get("partition_interface", {}).get("interface_hash", "") + != current_baseline["partition_interface"]["interface_hash"] + ), + "routed_reference_dcp_changed": ( + bool(current_checkpoints["routed"]["sha256"]) + and reference_checkpoints.get("routed", {}).get("sha256", "") + != current_checkpoints["routed"]["sha256"] + ), + "synthesis_reference_dcp_changed": ( + bool(current_checkpoints["synth"]["sha256"]) + and reference_checkpoints.get("synth", {}).get("sha256", "") + != current_checkpoints["synth"]["sha256"] + ), + } + reasons = [name for name, failed in checks.items() if failed] + return {"checked": True, "compatible": not reasons, "reasons": reasons} + + +def release_record(release: Path, cpu_module: str, partition_module: str) -> dict[str, object]: + return { + "path": str(release), + "build": build_fingerprint(release), + "cpu_interface": interface_fingerprint(release, cpu_module, cpu=True), + "partition_interface": interface_fingerprint(release, partition_module, cpu=False), + } + + +def route_decision(baseline: dict, modified: dict, reference: dict) -> dict[str, object]: + build_changed = baseline["build"]["semantic_sha256"] != modified["build"]["semantic_sha256"] + cpu_dcp_checked = bool( + baseline["cpu_interface"]["present"] and modified["cpu_interface"]["present"] + ) + cpu_source_changed = baseline["cpu_interface"]["semantic_sha256"] != modified["cpu_interface"]["semantic_sha256"] + cpu_interface_changed = baseline["cpu_interface"]["interface_hash"] != modified["cpu_interface"]["interface_hash"] + partition_interface_changed = ( + baseline["partition_interface"]["interface_hash"] + != modified["partition_interface"]["interface_hash"] + ) + if not build_changed: + action = "no-implementation-change" + elif not cpu_dcp_checked: + action = "whole-project-incremental-route" + elif cpu_interface_changed or partition_interface_changed or not reference["compatible"]: + action = "whole-project-incremental-route" + elif cpu_source_changed: + action = "whole-project-incremental-route" + else: + action = "reuse-cpu-dcp-and-incremental-route" + return { + "route_required": build_changed, + "cpu_dcp_checked": cpu_dcp_checked, + "cpu_dcp_reusable": cpu_dcp_checked and not cpu_source_changed and not cpu_interface_changed and not partition_interface_changed and reference["compatible"], + "recommended_action": action, + "reasons": { + "build_changed": build_changed, + "cpu_source_changed": cpu_source_changed, + "cpu_interface_changed": cpu_interface_changed, + "partition_interface_changed": partition_interface_changed, + "reference_compatible": reference["compatible"], + }, + } + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Fingerprint FpgaDiff inputs and choose a safe incremental routing path" + ) + parser.add_argument("--baseline-release", required=True) + parser.add_argument("--modified-release", required=True) + parser.add_argument("--fpga-diff-dir", required=True) + parser.add_argument("--cpu", required=True) + parser.add_argument("--cpu-module", default="") + parser.add_argument("--partition-module", default="") + parser.add_argument("--cpu-cell", default="") + parser.add_argument("--vivado-version", required=True) + parser.add_argument("--synth-incremental-mode", required=True) + parser.add_argument("--impl-directive", required=True) + parser.add_argument("--stop-after", required=True) + parser.add_argument("--reference-routed-dcp", default="") + parser.add_argument("--reference-synth-dcp", default="") + parser.add_argument("--reference-fingerprint", default="") + parser.add_argument("--require-reference-compatible", action="store_true") + parser.add_argument("--out", required=True) + args = parser.parse_args() + + baseline = Path(args.baseline_release).resolve() + modified = Path(args.modified_release).resolve() + fpga_diff_dir = Path(args.fpga_diff_dir).resolve() + for release in (baseline, modified): + if not (release / "build").is_dir(): + raise SystemExit(f"ERROR: release build directory not found: {release / 'build'}") + + context = { + "cpu": args.cpu, + "cpu_module": args.cpu_module, + "partition_module": args.partition_module, + "cpu_cell": args.cpu_cell, + "environment": {name: os.environ.get(name, "") for name in IMPLEMENTATION_ENV}, + "project": project_fingerprint(fpga_diff_dir), + "vivado_version": args.vivado_version, + "synth_incremental_mode": args.synth_incremental_mode, + "implementation_directive": args.impl_directive, + "stop_after": args.stop_after, + } + result: dict[str, object] = { + "schema_version": 2, + "implementation_context": context, + "implementation_context_sha256": stable_hash(context), + "reference_checkpoints": { + "routed": checkpoint_fingerprint(args.reference_routed_dcp), + "synth": checkpoint_fingerprint(args.reference_synth_dcp), + }, + "baseline": release_record(baseline, args.cpu_module, args.partition_module), + "modified": release_record(modified, args.cpu_module, args.partition_module), + } + reference = reference_compatibility(load_json(args.reference_fingerprint), result) + result["reference_compatibility"] = reference + result["decision"] = route_decision(result["baseline"], result["modified"], reference) + + out = Path(args.out).resolve() + out.parent.mkdir(parents=True, exist_ok=True) + out.write_text(json.dumps(result, indent=2, sort_keys=True) + "\n", encoding="utf-8") + decision = result["decision"] + print(f"Incremental fingerprint: {decision['recommended_action']}") + print(f"Wrote implementation fingerprint: {out}") + if args.require_reference_compatible and not reference["compatible"]: + print("ERROR: reference fingerprint is incompatible: " + ", ".join(reference["reasons"])) + return 2 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/fpga_diff/tools/cpu_dcp_split/infer_cpu_dcp_config.py b/fpga_diff/tools/cpu_dcp_split/infer_cpu_dcp_config.py new file mode 100755 index 00000000..e26a4bab --- /dev/null +++ b/fpga_diff/tools/cpu_dcp_split/infer_cpu_dcp_config.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +import argparse +import json +import re +import shlex +import sys +from pathlib import Path + +import cpu_rtl_interface as rtl_if +from extract_simtop_partition_contract import extract_contract, locate_simtop + + +def infer_wrapper_instance( + fpga_diff_dir: Path, + cpu: str, + simtop_module: str, +) -> tuple[str, list[str], str]: + wrapper = fpga_diff_dir / "src" / "rtl" / cpu / "SimTop_wrapper.sv" + if not wrapper.is_file(): + return "", [], str(wrapper) + + text = rtl_if.strip_comments_preserve_offsets(wrapper.read_text(errors="replace")) + pattern = re.compile( + rf"(?m)^\s*{re.escape(simtop_module)}\s+" + r"(?:#\s*\((?:.|\n)*?\)\s*)?" + r"([A-Za-z_][A-Za-z0-9_$]*)\s*\(" + ) + matches = pattern.findall(text) + unique = sorted(set(matches)) + if len(unique) == 1: + return unique[0], unique, str(wrapper) + return "", unique, str(wrapper) + + +def infer_cpu_kind(cpu_module: str) -> str: + if cpu_module == "NutShell": + return "nutshell" + if cpu_module in {"XSTop", "XSCore", "XSTile"}: + return "kmh" + return "" + + +def shell_assign(data: dict[str, object]) -> str: + lines = [] + for key, value in data.items(): + if isinstance(value, (dict, list)): + continue + lines.append(f"{key}={shlex.quote(str(value))}") + return "\n".join(lines) + "\n" + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Infer CPU-DCP split parameters from a generated release") + parser.add_argument("--release", required=True, help="Release directory containing build/rtl") + parser.add_argument("--fpga-diff-dir", required=True, help="env-scripts/fpga_diff directory") + parser.add_argument("--cpu", default="", help="FpgaDiff CPU target, for wrapper hierarchy inference") + parser.add_argument("--simtop-module", default="SimTop") + parser.add_argument("--cell-prefix", default="core_def/U_CPU_TOP") + parser.add_argument("--json-out", help="Optional JSON output path") + parser.add_argument("--shell", action="store_true", help="Print shell assignments") + args = parser.parse_args() + + release = Path(args.release).resolve() + fpga_diff_dir = Path(args.fpga_diff_dir).resolve() + try: + simtop_path, release_root = locate_simtop(str(release), args.simtop_module) + contract = extract_contract(simtop_path, release_root, args.simtop_module) + except (OSError, SystemExit, ValueError) as exc: + print(f"ERROR: cannot infer CPU partition from {release}: {exc}", file=sys.stderr) + return 1 + + cpu_instances = contract.get("cpu_instances", []) + if len(cpu_instances) != 1: + print( + "ERROR: expected exactly one CPU instance in " + f"{simtop_path}, got {len(cpu_instances)}", + file=sys.stderr, + ) + return 1 + + cpu_inst = cpu_instances[0] + cpu_module = str(cpu_inst.get("module", "")) + cpu_instance = str(cpu_inst.get("name", "")) + cpu = args.cpu or infer_cpu_kind(cpu_module) + wrapper_instance = "" + wrapper_candidates: list[str] = [] + wrapper_path = "" + if cpu: + wrapper_instance, wrapper_candidates, wrapper_path = infer_wrapper_instance( + fpga_diff_dir, + cpu, + args.simtop_module, + ) + + cpu_cell = "" + if wrapper_instance and cpu_instance: + cpu_cell = f"{args.cell_prefix}/{wrapper_instance}/{cpu_instance}" + + result: dict[str, object] = { + "release": str(release), + "simtop_path": str(simtop_path), + "simtop_module": args.simtop_module, + "cpu": cpu, + "cpu_module": cpu_module, + "cpu_instance": cpu_instance, + "simtop_wrapper": wrapper_path, + "simtop_wrapper_instance": wrapper_instance, + "simtop_wrapper_instance_candidates": wrapper_candidates, + "cpu_cell": cpu_cell, + "cpu_partition_interface_hash": contract.get("cpu_partition_manifest", {}).get("interface_hash", ""), + } + + if args.json_out: + Path(args.json_out).write_text(json.dumps(result, indent=2, sort_keys=True) + "\n") + if args.shell: + print(shell_assign(result), end="") + else: + print(json.dumps(result, indent=2, sort_keys=True)) + return 0 if cpu_module and cpu_instance else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/fpga_diff/tools/cpu_dcp_split/run_combined_incremental_flow.py b/fpga_diff/tools/cpu_dcp_split/run_combined_incremental_flow.py new file mode 100755 index 00000000..b90b1921 --- /dev/null +++ b/fpga_diff/tools/cpu_dcp_split/run_combined_incremental_flow.py @@ -0,0 +1,154 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import datetime as dt +import shlex +import subprocess +from pathlib import Path + +import flow_common as flow + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description=( + "Run whole-project incremental compile and CPU-DCP hot-path flows " + "for the same baseline and modified releases." + ) + ) + parser.add_argument("--baseline-release", required=True) + parser.add_argument("--modified-release", required=True) + parser.add_argument("--cpu", choices=["kmh", "nanhu", "nutshell"]) + parser.add_argument("--module") + parser.add_argument("--cpu-instance") + parser.add_argument("--cpu-cell") + parser.add_argument("--subpartition-preset", choices=["frontend-backend"]) + parser.add_argument("--out-dir") + parser.add_argument("--vivado") + parser.add_argument("--jobs") + parser.add_argument("--stop-after", choices=["route", "bitstream"], default="route") + parser.add_argument( + "--synth-incremental-mode", + choices=["quick", "default", "aggressive", "off"], + default="default", + ) + parser.add_argument( + "--impl-directive", + choices=["Default", "RuntimeOptimized"], + default="RuntimeOptimized", + ) + parser.add_argument("--extra-args", default="") + parser.add_argument("--whole-extra-args", default="") + parser.add_argument("--cpu-dcp-extra-args", default="") + return parser.parse_args() + + +def infer_cpu(paths: flow.FlowPaths, modified_release: Path) -> str: + cpu = flow.infer_cpu_dcp_config(paths, modified_release, required=True).get("cpu", "") + if not cpu: + raise SystemExit(f"ERROR: could not infer CPU target from {modified_release}; pass --cpu explicitly.") + print(f"INFO: inferred CPU target: {cpu}") + return cpu + + +def run(cmd: list[str]) -> None: + print("INFO:", shlex.join(cmd), flush=True) + subprocess.run(cmd, check=True) + + +def main() -> int: + args = parse_args() + paths = flow.init_paths(__file__) + baseline_release = Path(args.baseline_release).resolve() + modified_release = Path(args.modified_release).resolve() + flow.require_release_dirs(baseline_release, modified_release) + + cpu = args.cpu or infer_cpu(paths, modified_release) + out_dir = flow.resolve_m(args.out_dir) if args.out_dir else ( + paths.repo_root + / "build" + / "fpga-diff-combined" + / f"combined-{dt.datetime.now().strftime('%Y%m%d-%H%M%S')}" + ) + out_dir.mkdir(parents=True, exist_ok=True) + whole_dir = out_dir / "whole-project" + cpu_dcp_dir = out_dir / "cpu-dcp" + + common_extra = shlex.split(args.extra_args) + whole_extra = shlex.split(args.whole_extra_args) + cpu_dcp_extra = shlex.split(args.cpu_dcp_extra_args) + vivado_arg = ["--vivado", args.vivado] if args.vivado else [] + jobs_arg = ["--jobs", args.jobs] if args.jobs else [] + module_arg = ["--module", args.module] if args.module else [] + cpu_instance_arg = ["--cpu-instance", args.cpu_instance] if args.cpu_instance else [] + cpu_cell_arg = ["--cpu-cell", args.cpu_cell] if args.cpu_cell else [] + partition_args = ( + ["--subpartition-preset", args.subpartition_preset] + if args.subpartition_preset + else ["--partition-module", "CpuDcpTop"] + ) + + print(f"INFO: [whole-project] output: {whole_dir}", flush=True) + run( + [ + str(paths.script_dir / "run_incremental_flow.py"), + "--baseline-release", + str(baseline_release), + "--modified-release", + str(modified_release), + "--cpu", + cpu, + "--out-dir", + str(whole_dir), + "--stop-after", + args.stop_after, + "--synth-incremental-mode", + args.synth_incremental_mode, + "--impl-directive", + args.impl_directive, + *vivado_arg, + *jobs_arg, + *common_extra, + *whole_extra, + ] + ) + + print(f"INFO: [cpu-dcp] output: {cpu_dcp_dir}", flush=True) + run( + [ + str(paths.script_dir / "run_cpu_dcp_flow.py"), + "--baseline-release", + str(baseline_release), + "--modified-release", + str(modified_release), + "--cpu", + cpu, + *module_arg, + *cpu_instance_arg, + *cpu_cell_arg, + *partition_args, + "--build-reference", + "--out-dir", + str(cpu_dcp_dir), + "--stop-after", + args.stop_after, + "--synth-incremental-mode", + args.synth_incremental_mode, + "--impl-directive", + args.impl_directive, + *vivado_arg, + *jobs_arg, + *common_extra, + *cpu_dcp_extra, + ] + ) + + print(f"INFO: combined incremental flow complete: {out_dir}") + print(f"INFO: whole-project output: {whole_dir}") + print(f"INFO: CPU-DCP output: {cpu_dcp_dir}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/fpga_diff/tools/cpu_dcp_split/run_cpu_dcp_flow.py b/fpga_diff/tools/cpu_dcp_split/run_cpu_dcp_flow.py new file mode 100755 index 00000000..95e810f8 --- /dev/null +++ b/fpga_diff/tools/cpu_dcp_split/run_cpu_dcp_flow.py @@ -0,0 +1,590 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import datetime as dt +import os +import shutil +import sys +from pathlib import Path + +import flow_common as flow + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Run the CPU-DCP split flow for FpgaDiff." + ) + parser.add_argument("--baseline-release", required=True) + parser.add_argument("--modified-release", required=True) + parser.add_argument("--reference-routed-dcp") + parser.add_argument("--reference-synth-dcp") + parser.add_argument( + "--reference-fingerprint", + help="fingerprint JSON created with the routed reference DCP; reject incompatible reuse", + ) + parser.add_argument("--cpu", choices=["kmh", "nanhu", "nutshell"]) + parser.add_argument("--module", dest="cpu_module") + parser.add_argument("--cpu-instance") + parser.add_argument("--partition-module") + parser.add_argument("--subpartition-preset", choices=["frontend-backend"]) + parser.add_argument("--cpu-cell") + parser.add_argument("--cpu-dcp") + parser.add_argument("--out-dir") + parser.add_argument("--vivado", default=os.environ.get("VIVADO", "vivado")) + parser.add_argument("--jobs", default=os.environ.get("VIVADO_JOBS", "")) + parser.add_argument("--stop-after", choices=["route", "bitstream"], default="route") + parser.add_argument( + "--synth-incremental-mode", + choices=["quick", "default", "aggressive", "off"], + default="default", + ) + parser.add_argument( + "--impl-directive", + choices=["Default", "RuntimeOptimized"], + default="RuntimeOptimized", + help="directive for incremental placement and routing after CPU-DCP import", + ) + parser.add_argument("--build-reference", action="store_true") + parser.add_argument( + "--allow-unknown-changes", + action="store_true", + help="continue after manually reviewing RTL changes that the reuse gate cannot classify", + ) + parser.add_argument("--dry-run", action="store_true") + args = parser.parse_args() + if args.subpartition_preset and args.partition_module: + parser.error("--subpartition-preset and --partition-module are mutually exclusive") + return args + + +def main() -> int: + args = parse_args() + paths = flow.init_paths(__file__) + flow.require_positive_int("--jobs", args.jobs or None) + jobs = flow.default_jobs(args.jobs or None) + + baseline_release = Path(args.baseline_release).resolve() + modified_release = Path(args.modified_release).resolve() + flow.require_release_dirs(baseline_release, modified_release) + + inferred: dict[str, str] = {} + if not args.cpu_module or not args.cpu_instance or not args.cpu_cell or not args.cpu: + inferred = flow.infer_cpu_dcp_config(paths, modified_release, args.cpu) + + cpu = args.cpu or inferred.get("cpu") or "kmh" + if cpu not in {"kmh", "nanhu", "nutshell"}: + raise SystemExit(f"ERROR: --cpu must be kmh, nanhu, or nutshell, got {cpu}") + cpu_module = args.cpu_module or inferred.get("cpu_module") or ("NutShell" if cpu == "nutshell" else "XSTop") + cpu_instance = args.cpu_instance or inferred.get("cpu_instance") or "cpu" + cpu_cell = args.cpu_cell or inferred.get("cpu_cell") or "" + if not cpu_cell and cpu == "nutshell": + cpu_cell = f"core_def/U_CPU_TOP/u_SimTop/{cpu_instance}" + if not cpu_cell: + raise SystemExit( + f"ERROR: could not infer --cpu-cell for cpu={cpu}.\n" + "Pass --cpu-cell explicitly, for example core_def/U_CPU_TOP//cpu." + ) + + reference_routed_dcp = Path(args.reference_routed_dcp).resolve() if args.reference_routed_dcp else None + if reference_routed_dcp and not reference_routed_dcp.is_file(): + raise SystemExit(f"ERROR: reference routed DCP not found: {reference_routed_dcp}") + if not reference_routed_dcp and not args.build_reference: + raise SystemExit("ERROR: pass --reference-routed-dcp or --build-reference") + reference_synth_dcp_arg = Path(args.reference_synth_dcp).resolve() if args.reference_synth_dcp else None + if reference_synth_dcp_arg and not reference_synth_dcp_arg.is_file(): + raise SystemExit(f"ERROR: reference synthesis DCP not found: {reference_synth_dcp_arg}") + + cpu_dcp_arg = Path(args.cpu_dcp).resolve() if args.cpu_dcp and not args.subpartition_preset else None + if cpu_dcp_arg and not cpu_dcp_arg.is_file(): + raise SystemExit(f"ERROR: CPU DCP not found: {cpu_dcp_arg}") + + vivado_cmd = flow.resolve_vivado(args.vivado, args.dry_run) + timestamp = dt.datetime.now().strftime("%Y%m%d-%H%M%S") + out_dir = flow.resolve_m(args.out_dir) if args.out_dir else ( + paths.repo_root / "build" / "fpga-diff-cpu-dcp" / cpu / f"split-{timestamp}" + ) + for d in ("logs", "checkpoints", "releases"): + (out_dir / d).mkdir(parents=True, exist_ok=True) + + cpu_ooc_dir = out_dir / "cpu-ooc" + cpu_dcp = cpu_dcp_arg or (cpu_ooc_dir / "cpu-synth.dcp") + subpartition_dir = out_dir / "subpartitions" + subpartition_plan_json = subpartition_dir / "partitions.json" + subpartition_current_import_tsv = subpartition_dir / "current-import.tsv" + subpartition_reference_import_tsv = subpartition_dir / "reference-import.tsv" + subpartition_current_build_tsv = subpartition_dir / "current-build.tsv" + subpartition_reference_build_tsv = subpartition_dir / "reference-build.tsv" + overlay_release = out_dir / "releases" / "modified-cpu-bb" + partition_ooc_release = out_dir / "releases" / "baseline-cpu-partition-ooc" + reference_partition_release = out_dir / "releases" / "baseline-cpu-partition-top" + overlay_core_dir = overlay_release / "build" + cpu_ooc_release = baseline_release + cpu_ooc_top = cpu_module + overlay_module = cpu_module + + reference_suffix = f"cpu-dcp-ref-{timestamp}" + if args.partition_module: + overlay_release = out_dir / "releases" / "modified-cpu-partition-top" + overlay_core_dir = overlay_release / "build" + cpu_ooc_top = args.partition_module + overlay_module = args.partition_module + overlay_suffix = f"cpu-dcp-part-{timestamp}" + else: + overlay_suffix = f"cpu-dcp-bb-{timestamp}" + if args.subpartition_preset: + overlay_release = out_dir / "releases" / "modified-cpu-subpartition-top" + overlay_core_dir = overlay_release / "build" + overlay_module = cpu_module + overlay_suffix = f"cpu-dcp-subpart-{timestamp}" + reference_partition_release = out_dir / "releases" / "baseline-cpu-subpartition-top" + + import_cell_arg = cpu_cell + import_dcp_arg = str(cpu_dcp) + reference_import_cell_arg = cpu_cell + reference_import_dcp_arg = str(cpu_dcp) + if args.subpartition_preset: + import_cell_arg = f"@{subpartition_current_import_tsv}" + import_dcp_arg = "-" + reference_import_cell_arg = f"@{subpartition_reference_import_tsv}" + reference_import_dcp_arg = "-" + cpu_dcp = subpartition_current_import_tsv + + reference_prj_name = f"fpga_{cpu}-{reference_suffix}" + overlay_prj_name = f"fpga_{cpu}-{overlay_suffix}" + reference_xpr = paths.fpga_diff_dir / reference_prj_name / f"{reference_prj_name}.xpr" + overlay_xpr = paths.fpga_diff_dir / overlay_prj_name / f"{overlay_prj_name}.xpr" + if not reference_routed_dcp and args.build_reference: + reference_routed_dcp = out_dir / "checkpoints" / "reference-routed.dcp" + reference_synth_dcp = reference_synth_dcp_arg or (out_dir / "checkpoints" / "reference-synth.dcp") + if not reference_synth_dcp_arg and reference_routed_dcp and reference_routed_dcp.name == "reference-routed.dcp": + sibling_synth_dcp = reference_routed_dcp.with_name("reference-synth.dcp") + if sibling_synth_dcp.is_file(): + reference_synth_dcp = sibling_synth_dcp + + def dry_skip(label: str) -> bool: + run_in_dry = { + "rtl-diff", + "cpu-dcp-interface", + "cpu-ooc-dcp", + "cpu-subpartition-plan", + "cpu-partition-ooc-overlay", + "cpu-partition-top-overlay", + "reference-partition-top-overlay", + "reference-subpartition-top-overlay", + "cpu-subpartition-top-overlay", + "reference-synth-dcp", + "implementation-fingerprint", + "implementation-fingerprint-final", + } + return label not in run_in_dry + + runner = flow.FlowRunner(out_dir, dry_run=args.dry_run, dry_skip=dry_skip) + vivado_version = flow.vivado_version(vivado_cmd, args.dry_run) + + def write_manifest() -> None: + flow.write_lines( + out_dir / "manifest.env", + [ + f"repo_root={paths.repo_root}", + f"cpu={cpu}", + f"cpu_module={cpu_module}", + f"cpu_instance={cpu_instance}", + f"cpu_cell={cpu_cell}", + f"subpartition_preset={args.subpartition_preset or ''}", + f"subpartition_plan={subpartition_plan_json}", + f"subpartition_current_import={subpartition_current_import_tsv}", + f"subpartition_reference_import={subpartition_reference_import_tsv}", + f"jobs={jobs}", + f"baseline_release={baseline_release}", + f"modified_release={modified_release}", + f"overlay_release={overlay_release}", + f"partition_module={args.partition_module or ''}", + f"overlay_module={overlay_module}", + f"cpu_ooc_release={cpu_ooc_release}", + f"cpu_ooc_top={cpu_ooc_top}", + f"reference_project={reference_xpr}", + f"overlay_project={overlay_xpr}", + f"reference_routed_dcp={reference_routed_dcp or ''}", + f"reference_synth_dcp={reference_synth_dcp}", + f"reference_fingerprint={args.reference_fingerprint or ''}", + f"cpu_dcp={cpu_dcp}", + f"stop_after={args.stop_after}", + f"synth_incremental_mode={args.synth_incremental_mode}", + f"implementation_directive={args.impl_directive}", + f"build_reference={int(args.build_reference)}", + f"dry_run={int(args.dry_run)}", + f"vivado_command={vivado_cmd}", + f"vivado_version={vivado_version}", + ], + ) + + def capture_reference_synth_dcp(project_xpr: Path) -> None: + run_dcp = Path(str(project_xpr).removesuffix(".xpr") + ".runs/synth_1/fpga_top_debug.dcp") + log_file = out_dir / "logs" / "reference-synth-dcp.log" + time_file = out_dir / "logs" / "reference-synth-dcp.time" + if args.dry_run: + flow.write_lines( + log_file, + [ + f"dry_run_command=capture_reference_synth_dcp {project_xpr}", + f"expected_source={run_dcp}", + f"target={reference_synth_dcp}", + ], + ) + flow.write_lines(time_file, ["elapsed_sec=", "mode=dry-run"]) + return + if run_dcp.is_file(): + shutil.copy2(run_dcp, reference_synth_dcp) + flow.write_lines(log_file, ["Copied reference synthesis DCP", f"source={run_dcp}", f"target={reference_synth_dcp}"]) + flow.write_lines(time_file, ["elapsed_sec=", "mode=copied-project-synth-dcp", f"source={run_dcp}"]) + else: + runner.run( + "reference-synth-dcp", + flow.vivado_batch_cmd( + vivado_cmd, + paths.script_dir / "write_run_checkpoint.tcl", + project_xpr, + "synth_1", + reference_synth_dcp, + "normal", + ), + ) + + def fpga_make(target: str, suffix: str, core_dir: Path | None = None) -> list[str]: + jobs_arg = jobs if target != "all" else None + return flow.make_cmd(paths.fpga_diff_dir, target, cpu, suffix, core_dir=core_dir, jobs=jobs_arg) + + def run_reference_project(core_dir: Path) -> None: + runner.run("reference-project", fpga_make("all", reference_suffix, core_dir)) + runner.run("reference-synth", fpga_make("synth", reference_suffix)) + capture_reference_synth_dcp(reference_xpr) + + def write_reference_import_checkpoint() -> None: + source = out_dir / "reference-import-impl" / "post-route-cpu-dcp-import.dcp" + if args.dry_run: + with (out_dir / "logs" / "reference-routed-dcp.log").open("a", encoding="utf-8") as f: + f.write(f"dry_run_reference_routed_dcp={reference_routed_dcp}\n") + f.write(f"expected_source={source}\n") + else: + shutil.copy2(source, reference_routed_dcp) + + def run_reference_import(cell_arg: str, dcp_arg: str) -> None: + runner.run( + "reference-routed-dcp", + flow.vivado_batch_cmd( + vivado_cmd, + paths.script_dir / "cpu_dcp_import.tcl", + "impl", + reference_xpr, + cell_arg, + dcp_arg, + "synth_1", + out_dir / "reference-import-impl", + "", + "route", + ), + ) + write_reference_import_checkpoint() + + def run_checkpoint_writer(label: str, project_xpr: Path, run_name: str, checkpoint: Path) -> None: + runner.run( + label, + flow.vivado_batch_cmd( + vivado_cmd, + paths.script_dir / "write_run_checkpoint.tcl", + project_xpr, + run_name, + checkpoint, + ), + ) + + def partition_overlay_cmd(release: Path, mode: str, out_release: Path) -> list[str]: + cmd = [ + str(paths.script_dir / "create_cpu_partition_overlay.py"), + "--release", + str(release), + "--cpu-module", + cpu_module, + "--mode", + mode, + "--out-dir", + str(out_release), + "--force", + ] + if args.partition_module: + cmd.extend(["--cpu-instance", cpu_instance, "--partition-module", args.partition_module]) + return cmd + + def subpartition_overlay_cmd(release: Path, out_release: Path, json_out: Path) -> list[str]: + return [ + str(paths.script_dir / "cpu_subpartition.py"), + "overlay", + "--release", + str(release), + "--partitions-json", + str(subpartition_plan_json), + "--out-dir", + str(out_release), + "--force", + "--json-out", + str(json_out), + ] + + def build_subpartition_dcps(label_prefix: str, build_tsv: Path) -> None: + if not build_tsv.is_file(): + return + for line in build_tsv.read_text(encoding="utf-8").splitlines(): + if not line.strip(): + continue + role, module, source_release, part_out_dir, dcp_path = line.split("\t")[:5] + dcp = Path(dcp_path) + if dcp.is_file(): + flow.write_empty_stage(out_dir, f"{label_prefix}-{role}", f"Using existing {role} DCP: {dcp}") + continue + cmd = [ + str(paths.script_dir / "build_cpu_ooc_dcp.py"), + "--release", + source_release, + "--cpu", + cpu, + "--top", + module, + "--out-dir", + part_out_dir, + "--vivado", + vivado_cmd, + ] + if args.dry_run: + cmd.append("--dry-run") + runner.run(f"{label_prefix}-{role}", cmd) + + write_manifest() + fingerprint_cmd = [ + sys.executable, str(paths.script_dir / "implementation_fingerprint.py"), + "--baseline-release", str(baseline_release), + "--modified-release", str(modified_release), + "--fpga-diff-dir", str(paths.fpga_diff_dir), + "--cpu", cpu, + "--cpu-module", cpu_module, + "--partition-module", args.partition_module or cpu_module, + "--cpu-cell", cpu_cell, + "--vivado-version", vivado_version, + "--synth-incremental-mode", args.synth_incremental_mode, + "--impl-directive", args.impl_directive, + "--stop-after", args.stop_after, + "--reference-routed-dcp", str(reference_routed_dcp or ""), + "--reference-synth-dcp", str(reference_synth_dcp), + "--out", str(out_dir / "implementation-fingerprint.json"), + ] + if args.reference_fingerprint: + fingerprint_cmd.extend([ + "--reference-fingerprint", args.reference_fingerprint, + "--require-reference-compatible", + ]) + runner.run("implementation-fingerprint", fingerprint_cmd) + + runner.run( + "rtl-diff", + [str(paths.script_dir / "check_cpu_dcp_reuse.py"), str(baseline_release), str(modified_release), str(out_dir / "rtl-diff")], + ) + gate_json = out_dir / "rtl-diff" / "cpu-difftest-gate.json" + gate_rc = flow.read_json(gate_json).get("exit_code", 3) + if gate_rc == 1 and not args.subpartition_preset: + raise SystemExit("ERROR: CPU-DCP reuse gate failed because CPU RTL changed") + if gate_rc == 1 and args.subpartition_preset: + print( + "WARNING: CPU-DCP whole-CPU reuse gate failed; " + f"subpartition plan will check whether CPU changes are covered by {args.subpartition_preset}." + ) + elif gate_rc == 2 and not args.allow_unknown_changes: + raise SystemExit( + "ERROR: CPU-DCP reuse gate found unknown RTL changes. " + "Review rtl-diff, then pass --allow-unknown-changes to continue." + ) + elif gate_rc == 2: + print("WARNING: continuing after explicit approval of unknown RTL changes.") + elif gate_rc != 0: + raise SystemExit(gate_rc) + + if args.subpartition_preset: + subpartition_dir.mkdir(parents=True, exist_ok=True) + runner.run( + "cpu-subpartition-plan", + [ + str(paths.script_dir / "cpu_subpartition.py"), + "plan", + "--baseline-release", + str(baseline_release), + "--modified-release", + str(modified_release), + "--cpu-module", + cpu_module, + "--cpu-cell", + cpu_cell, + "--preset", + args.subpartition_preset, + "--dcp-root", + str(subpartition_dir / "dcp"), + "--json-out", + str(subpartition_plan_json), + "--current-import-tsv", + str(subpartition_current_import_tsv), + "--reference-import-tsv", + str(subpartition_reference_import_tsv), + "--current-build-tsv", + str(subpartition_current_build_tsv), + "--reference-build-tsv", + str(subpartition_reference_build_tsv), + "--module-boundary-csv", + str(out_dir / "rtl-diff" / "module-boundary.csv"), + "--file-summary-csv", + str(out_dir / "rtl-diff" / "summary.csv"), + ], + ) + build_subpartition_dcps("cpu-subpartition-ooc-reference", subpartition_reference_build_tsv) + build_subpartition_dcps("cpu-subpartition-ooc-current", subpartition_current_build_tsv) + elif Path(cpu_dcp).is_file(): + flow.write_empty_stage(out_dir, "cpu-ooc-dcp", f"Using existing CPU DCP: {cpu_dcp}") + else: + if args.partition_module: + runner.run( + "cpu-partition-ooc-overlay", + partition_overlay_cmd(baseline_release, "ooc", partition_ooc_release), + ) + cpu_ooc_release = partition_ooc_release + write_manifest() + cpu_ooc_cmd = [ + str(paths.script_dir / "build_cpu_ooc_dcp.py"), + "--release", + str(cpu_ooc_release), + "--cpu", + cpu, + "--top", + cpu_ooc_top, + "--out-dir", + str(cpu_ooc_dir), + "--vivado", + vivado_cmd, + ] + if args.dry_run: + cpu_ooc_cmd.append("--dry-run") + runner.run("cpu-ooc-dcp", cpu_ooc_cmd) + + if args.build_reference and reference_routed_dcp and not reference_routed_dcp.is_file(): + if args.subpartition_preset: + runner.run( + "reference-subpartition-top-overlay", + subpartition_overlay_cmd( + baseline_release, + reference_partition_release, + out_dir / "subpartitions" / "reference-overlay.json", + ), + ) + run_reference_project(reference_partition_release / "build") + run_reference_import(reference_import_cell_arg, reference_import_dcp_arg) + elif args.partition_module: + runner.run( + "reference-partition-top-overlay", + partition_overlay_cmd(baseline_release, "top", reference_partition_release), + ) + run_reference_project(reference_partition_release / "build") + run_reference_import(cpu_cell, str(cpu_dcp)) + else: + run_reference_project(baseline_release / "build") + runner.run("reference-bitstream", fpga_make("bitstream", reference_suffix)) + run_checkpoint_writer("reference-routed-dcp", reference_xpr, "impl_1", reference_routed_dcp) + + if args.subpartition_preset: + runner.run( + "cpu-subpartition-top-overlay", + subpartition_overlay_cmd( + modified_release, + overlay_release, + out_dir / "subpartitions" / "current-overlay.json", + ), + ) + elif args.partition_module: + runner.run( + "cpu-partition-top-overlay", + partition_overlay_cmd(modified_release, "top", overlay_release), + ) + runner.run( + "cpu-dcp-interface", + [ + str(paths.script_dir / "sync_cpu_dcp_interface.py"), + "--release", + str(modified_release), + "--cpu-module", + cpu_module, + "--partition-module", + args.partition_module, + "--mode", + "stub", + "--check-existing", + str(overlay_release / "build" / "rtl" / f"{args.partition_module}.sv"), + "--json-out", + str(out_dir / "partition-contract" / "modified-cpudcptop-interface.json"), + ], + ) + else: + runner.run( + "cpu-blackbox-overlay", + partition_overlay_cmd(modified_release, "blackbox", overlay_release), + ) + + runner.run("overlay-project", fpga_make("all", overlay_suffix, overlay_core_dir)) + if (args.dry_run or reference_synth_dcp.is_file()) and args.synth_incremental_mode != "off": + runner.run( + "overlay-synth", + flow.vivado_batch_cmd( + vivado_cmd, + paths.script_dir / "run_incremental_synth_only.tcl", + overlay_xpr, + reference_synth_dcp, + "synth_1", + jobs, + args.synth_incremental_mode, + ), + ) + else: + runner.run("overlay-synth", fpga_make("synth", overlay_suffix)) + + runner.run( + "cpu-dcp-import-probe", + flow.vivado_batch_cmd( + vivado_cmd, + paths.script_dir / "cpu_dcp_import.tcl", + "probe", + overlay_xpr, + import_cell_arg, + import_dcp_arg, + "synth_1", + out_dir / "import-probe", + ), + ) + runner.run( + "cpu-dcp-import-impl", + flow.vivado_batch_cmd( + vivado_cmd, + paths.script_dir / "cpu_dcp_import.tcl", + "impl", + overlay_xpr, + import_cell_arg, + import_dcp_arg, + "synth_1", + out_dir / "import-impl", + reference_routed_dcp or "", + args.stop_after, + args.impl_directive, + ), + ) + runner.run("implementation-fingerprint-final", fingerprint_cmd) + + print(f"INFO: CPU-DCP flow complete: {out_dir}") + print(f"INFO: implementation manifest: {out_dir / 'import-impl' / 'cpu-dcp-import-impl.json'}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/fpga_diff/tools/cpu_dcp_split/run_incremental_flow.py b/fpga_diff/tools/cpu_dcp_split/run_incremental_flow.py new file mode 100755 index 00000000..dca5eeeb --- /dev/null +++ b/fpga_diff/tools/cpu_dcp_split/run_incremental_flow.py @@ -0,0 +1,187 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import datetime as dt +import os +import sys +from pathlib import Path + +import flow_common as flow + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description=( + "Build baseline and modified FpgaDiff Vivado projects and run " + "whole-project incremental synthesis and implementation." + ) + ) + parser.add_argument("--baseline-release", required=True) + parser.add_argument("--modified-release", required=True) + parser.add_argument("--cpu", choices=["kmh", "nutshell", "nanhu"], default="kmh") + parser.add_argument("--jobs", default=os.environ.get("VIVADO_JOBS", "")) + parser.add_argument("--out-dir") + parser.add_argument("--vivado", default=os.environ.get("VIVADO", "vivado")) + parser.add_argument("--stop-after", choices=["route", "bitstream"], default="bitstream") + parser.add_argument( + "--synth-incremental-mode", + choices=["quick", "default", "aggressive", "off"], + default="default", + ) + parser.add_argument( + "--impl-directive", + choices=["Default", "RuntimeOptimized"], + default="RuntimeOptimized", + help="implementation directive for the routed checkpoint reuse path", + ) + parser.add_argument("--dry-run", action="store_true") + return parser.parse_args() + + +def main() -> int: + args = parse_args() + paths = flow.init_paths(__file__) + flow.require_positive_int("--jobs", args.jobs or None) + jobs = flow.default_jobs(args.jobs or None) + + baseline_release = Path(args.baseline_release).resolve() + modified_release = Path(args.modified_release).resolve() + flow.require_release_dirs(baseline_release, modified_release) + + vivado_cmd = flow.resolve_vivado(args.vivado, args.dry_run) + timestamp = dt.datetime.now().strftime("%Y%m%d-%H%M%S") + out_dir = flow.resolve_m(args.out_dir) if args.out_dir else ( + paths.repo_root / "build" / "fpga-diff-incremental" / f"{timestamp}-{args.cpu}" + ) + (out_dir / "logs").mkdir(parents=True, exist_ok=True) + (out_dir / "checkpoints").mkdir(parents=True, exist_ok=True) + + base_suffix = f"inc-base-{timestamp}" + diff_suffix = f"inc-diff-{timestamp}" + base_prj_name = f"fpga_{args.cpu}-{base_suffix}" + diff_prj_name = f"fpga_{args.cpu}-{diff_suffix}" + base_xpr = paths.fpga_diff_dir / base_prj_name / f"{base_prj_name}.xpr" + diff_xpr = paths.fpga_diff_dir / diff_prj_name / f"{diff_prj_name}.xpr" + base_synth_dcp = out_dir / "checkpoints" / "base-synth.dcp" + base_impl_dcp = out_dir / "checkpoints" / "base-routed.dcp" + + runner = flow.FlowRunner( + out_dir, + dry_run=args.dry_run, + dry_skip=lambda label: label != "implementation-fingerprint", + ) + vivado_version = flow.vivado_version(vivado_cmd, args.dry_run) + + def fpga_make(target: str, suffix: str, core_dir: Path | None = None) -> list[str]: + return flow.make_cmd( + paths.fpga_diff_dir, + target, + args.cpu, + suffix, + core_dir=core_dir, + jobs=jobs if target != "all" else None, + ) + + def write_manifest() -> None: + flow.write_lines( + out_dir / "manifest.env", + [ + f"repo_root={paths.repo_root}", + f"cpu={args.cpu}", + f"jobs={jobs}", + f"baseline_release={baseline_release}", + f"modified_release={modified_release}", + f"baseline_project={base_xpr}", + f"incremental_project={diff_xpr}", + f"baseline_synth_dcp={base_synth_dcp}", + f"baseline_impl_dcp={base_impl_dcp}", + f"dry_run={int(args.dry_run)}", + f"stop_after={args.stop_after}", + f"synth_incremental_mode={args.synth_incremental_mode}", + f"implementation_directive={args.impl_directive}", + f"vivado_command={vivado_cmd}", + f"vivado_version={vivado_version}", + ], + ) + + write_manifest() + runner.run( + "implementation-fingerprint", + [ + sys.executable, str(paths.script_dir / "implementation_fingerprint.py"), + "--baseline-release", str(baseline_release), + "--modified-release", str(modified_release), + "--fpga-diff-dir", str(paths.fpga_diff_dir), + "--cpu", args.cpu, + "--vivado-version", vivado_version, + "--synth-incremental-mode", args.synth_incremental_mode, + "--impl-directive", args.impl_directive, + "--stop-after", args.stop_after, + "--out", str(out_dir / "implementation-fingerprint.json"), + ], + ) + + runner.run( + "baseline-project", + fpga_make("all", base_suffix, baseline_release / "build"), + ) + runner.run( + "baseline-synth", + flow.vivado_batch_cmd( + vivado_cmd, + paths.script_dir / "run_incremental_synth_checkpoint.tcl", + base_xpr, + base_synth_dcp, + "synth_1", + jobs, + ), + ) + if args.stop_after == "route": + runner.run( + "baseline-route", + fpga_make("route", base_suffix), + ) + else: + runner.run( + "baseline-bitstream", + fpga_make("bitstream", base_suffix), + ) + runner.run( + "baseline-impl-dcp", + flow.vivado_batch_cmd( + vivado_cmd, + paths.script_dir / "write_run_checkpoint.tcl", + base_xpr, + "impl_1", + base_impl_dcp, + ), + ) + runner.run( + "incremental-project", + fpga_make("all", diff_suffix, modified_release / "build"), + ) + runner.run( + "incremental-run", + flow.vivado_batch_cmd( + vivado_cmd, + paths.script_dir / "run_incremental_impl.tcl", + diff_xpr, + base_impl_dcp, + "impl_1", + jobs, + base_synth_dcp, + args.synth_incremental_mode, + args.stop_after, + args.impl_directive, + ), + ) + + print(f"INFO: whole-project incremental flow complete: {out_dir}") + print(f"INFO: incremental project: {diff_xpr}") + print(f"INFO: incremental reports: {diff_xpr.parent / 'incremental-reports' / 'impl_1'}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/fpga_diff/tools/cpu_dcp_split/run_incremental_impl.tcl b/fpga_diff/tools/cpu_dcp_split/run_incremental_impl.tcl new file mode 100644 index 00000000..7a05e0bf --- /dev/null +++ b/fpga_diff/tools/cpu_dcp_split/run_incremental_impl.tcl @@ -0,0 +1,163 @@ +######################################################################## +# FpgaDiff Vivado incremental implementation helper. +# +# Expected use: +# vivado -mode batch -source env-scripts/fpga_diff/tools/cpu_dcp_split/run_incremental_impl.tcl \ +# -tclargs [impl-run] [jobs] \ +# [reference-synth.dcp] [synth-incremental-mode] [route|bitstream] \ +# [Default|RuntimeOptimized] +# +# The script opens an existing fpga_diff project, points the implementation +# run at checkpoints from a baseline build, resets the target runs, launches +# synthesis and implementation, and emits reuse/timing/util reports. +######################################################################## + +proc usage {} { + puts stderr "USAGE: vivado -mode batch -source env-scripts/fpga_diff/tools/cpu_dcp_split/run_incremental_impl.tcl -tclargs ?impl_run? ?jobs? ?reference_synth.dcp? ?synth_incremental_mode? ?route|bitstream? ?Default|RuntimeOptimized?" +} + +source [file join [file dirname [info script]] vivado_common.tcl] + +if {[llength $::argv] < 2 || [llength $::argv] > 8} { + usage + exit 2 +} + +set proj_path [file normalize [lindex $::argv 0]] +set reference_dcp [file normalize [lindex $::argv 1]] +set impl_run [expr {[llength $::argv] >= 3 && [string trim [lindex $::argv 2]] ne "" ? [lindex $::argv 2] : "impl_1"}] +set jobs_arg [expr {[llength $::argv] >= 4 ? [string trim [lindex $::argv 3]] : ""}] +set reference_synth_dcp "" +if {[llength $::argv] >= 5 && [string trim [lindex $::argv 4]] ne ""} { + set reference_synth_dcp [file normalize [lindex $::argv 4]] +} +set synth_incremental_mode "default" +if {[llength $::argv] >= 6 && [string trim [lindex $::argv 5]] ne ""} { + set synth_incremental_mode [string trim [lindex $::argv 5]] +} +set final_step "bitstream" +if {[llength $::argv] >= 7 && [string trim [lindex $::argv 6]] ne ""} { + set final_step [string trim [lindex $::argv 6]] +} +set impl_directive "RuntimeOptimized" +if {[llength $::argv] >= 8 && [string trim [lindex $::argv 7]] ne ""} { + set impl_directive [string trim [lindex $::argv 7]] +} + +flow_require_file $proj_path "project" +flow_require_file $reference_dcp "reference checkpoint" +if {$reference_synth_dcp ne ""} { + flow_require_file $reference_synth_dcp "reference synthesis checkpoint" +} +flow_require_positive_int $jobs_arg "jobs" +flow_require_choice $synth_incremental_mode {quick default aggressive off} "synth_incremental_mode" +flow_require_choice $final_step {route bitstream} "final step" +flow_require_choice $impl_directive {Default RuntimeOptimized} "implementation directive" +set jobs [flow_default_jobs $jobs_arg] + +puts "INFO: Opening project: $proj_path" +open_project $proj_path + +set impl_obj [get_runs -quiet $impl_run] +if {[llength $impl_obj] == 0} { + puts stderr "ERROR: implementation run not found: $impl_run" + exit 1 +} +set impl_obj [lindex $impl_obj 0] +set synth_obj [get_runs -quiet [get_property PARENT $impl_obj]] +if {[llength $synth_obj] == 0} { + puts stderr "ERROR: synthesis parent run not found for $impl_run" + exit 1 +} +set synth_obj [lindex $synth_obj 0] + +puts "INFO: Setting incremental checkpoint on $impl_run: $reference_dcp" +flow_ensure_utils_file $reference_dcp +flow_set_optional_property AUTO_INCREMENTAL_CHECKPOINT 0 $impl_obj +flow_set_optional_property INCREMENTAL_CHECKPOINT.DIRECTIVE $impl_directive $impl_obj +set_property INCREMENTAL_CHECKPOINT $reference_dcp $impl_obj + +set report_dir [file normalize [file join [get_property DIRECTORY [current_project]] incremental-reports $impl_run]] +file mkdir $report_dir + +set setup_report [open [file join $report_dir incremental_setup.txt] w] +puts $setup_report "project=$proj_path" +puts $setup_report "impl_run=$impl_run" +puts $setup_report "reference_impl_dcp=$reference_dcp" +puts $setup_report "reference_synth_dcp=$reference_synth_dcp" +puts $setup_report "synth_incremental_mode=$synth_incremental_mode" +puts $setup_report "final_step=$final_step" +puts $setup_report "implementation_directive=$impl_directive" +puts $setup_report "jobs=$jobs" + +if {$reference_synth_dcp ne ""} { + puts "INFO: Setting synthesis incremental checkpoint on [get_property NAME $synth_obj]: $reference_synth_dcp" + flow_ensure_utils_file $reference_synth_dcp + set synth_checkpoint_ok 1 + if {$synth_incremental_mode eq "off"} { + set synth_checkpoint_ok 0 + puts "INFO: Synthesis incremental mode is off; reference synthesis checkpoint will not be used." + } elseif {![flow_set_optional_property INCREMENTAL_CHECKPOINT $reference_synth_dcp $synth_obj]} { + set synth_checkpoint_ok 0 + } + set synth_mode_ok 0 + if {$synth_incremental_mode ne "off"} { + set synth_mode_ok [flow_set_optional_property STEPS.SYNTH_DESIGN.ARGS.INCREMENTAL_MODE $synth_incremental_mode $synth_obj] + } + set write_inc_synth_ok [flow_set_optional_property WRITE_INCREMENTAL_SYNTH_CHECKPOINT 1 $synth_obj] + puts $setup_report "synthesis_incremental_checkpoint_set=$synth_checkpoint_ok" + puts $setup_report "synthesis_incremental_mode_property_set=$synth_mode_ok" + puts $setup_report "write_incremental_synth_checkpoint_property_set=$write_inc_synth_ok" + puts $setup_report "synthesis_incremental_property_set=$synth_checkpoint_ok" +} else { + puts "INFO: No synthesis checkpoint supplied; synthesis will run normally." + puts $setup_report "synthesis_incremental_checkpoint_set=0" + puts $setup_report "synthesis_incremental_mode_property_set=0" + puts $setup_report "write_incremental_synth_checkpoint_property_set=0" + puts $setup_report "synthesis_incremental_property_set=0" +} +close $setup_report + +puts "INFO: Resetting runs: [get_property NAME $synth_obj], $impl_run" +reset_run $synth_obj +reset_run $impl_obj + +puts "INFO: Launching synthesis with -jobs $jobs" +launch_runs $synth_obj -jobs $jobs +wait_on_run $synth_obj +set synth_status [get_property STATUS $synth_obj] +puts "INFO: Synthesis status: $synth_status" +if {![string match "*Complete*" $synth_status]} { + puts stderr "ERROR: synthesis did not complete" + exit 1 +} + +set vivado_final_step [expr {$final_step eq "route" ? "route_design" : "write_bitstream"}] +puts "INFO: Launching incremental implementation to $vivado_final_step with directive $impl_directive and -jobs $jobs" +launch_runs $impl_obj -to_step $vivado_final_step -jobs $jobs +wait_on_run $impl_obj +set impl_status [get_property STATUS $impl_obj] +puts "INFO: Implementation status: $impl_status" +if {![string match "*Complete*" $impl_status]} { + puts stderr "ERROR: implementation did not complete" + exit 1 +} + +puts "INFO: Opening implemented design for reports" +open_run $impl_run -name implemented_design + +set reuse_report [file join $report_dir report_incremental_reuse.rpt] +if {![catch {report_incremental_reuse -file $reuse_report} err]} { + puts "INFO: Wrote $reuse_report" +} else { + puts "WARNING: report_incremental_reuse failed: $err" +} + +report_timing_summary -file [file join $report_dir timing_summary.rpt] +report_utilization -file [file join $report_dir utilization.rpt] +report_route_status -file [file join $report_dir route_status.rpt] + +set post_impl_dcp [file join $report_dir post_route.dcp] +write_checkpoint -force $post_impl_dcp +puts "INFO: Wrote post-route checkpoint: $post_impl_dcp" +puts "INFO: Reports are under: $report_dir" diff --git a/fpga_diff/tools/cpu_dcp_split/run_incremental_synth_checkpoint.tcl b/fpga_diff/tools/cpu_dcp_split/run_incremental_synth_checkpoint.tcl new file mode 100644 index 00000000..2fb8dc1a --- /dev/null +++ b/fpga_diff/tools/cpu_dcp_split/run_incremental_synth_checkpoint.tcl @@ -0,0 +1,120 @@ +######################################################################## +# Run synthesis and emit an incremental-synthesis reference checkpoint. +# +# Usage: +# vivado -mode batch -source env-scripts/fpga_diff/tools/cpu_dcp_split/run_incremental_synth_checkpoint.tcl \ +# -tclargs [synth-run] [jobs] +# +# Older Vivado releases require write_checkpoint -incremental_synth to run in +# the same Vivado session that executed synth_design. Vivado 2024.1 exposes the +# synthesis INCREMENTAL_CHECKPOINT run property but rejects the +# -incremental_synth option at runtime, so the hook falls back to a normal +# synthesized DCP. That normal DCP is still accepted by the project-mode +# incremental synthesis run property in 2024.1. +######################################################################## + +proc usage {} { + puts stderr "USAGE: vivado -mode batch -source env-scripts/fpga_diff/tools/cpu_dcp_split/run_incremental_synth_checkpoint.tcl -tclargs ?synth-run? ?jobs?" +} + +source [file join [file dirname [info script]] vivado_common.tcl] + +if {[llength $::argv] < 2 || [llength $::argv] > 4} { + usage + exit 2 +} + +set proj_path [file normalize [lindex $::argv 0]] +set out_dcp [file normalize [lindex $::argv 1]] +set synth_run_name "synth_1" +if {[llength $::argv] >= 3 && [string trim [lindex $::argv 2]] ne ""} { + set synth_run_name [string trim [lindex $::argv 2]] +} +set jobs_arg "" +if {[llength $::argv] >= 4 && [string trim [lindex $::argv 3]] ne ""} { + set jobs_arg [string trim [lindex $::argv 3]] +} + +flow_require_file $proj_path "project" +flow_require_positive_int $jobs_arg "jobs" +set jobs [flow_default_jobs $jobs_arg 1] + +file mkdir [file dirname $out_dcp] +set hook_dir [file normalize [file join [file dirname $out_dcp] hooks]] +file mkdir $hook_dir +set hook_path [file join $hook_dir "write_incremental_synth_checkpoint.post.tcl"] + +set hook [open $hook_path w] +puts $hook "# Auto-generated by run_incremental_synth_checkpoint.tcl" +puts $hook [list set out_dcp $out_dcp] +puts $hook {file mkdir [file dirname $out_dcp]} +puts $hook {puts "INFO: Writing baseline synthesis checkpoint for incremental synthesis: $out_dcp"} +puts $hook {set_param constraints.enableBinaryConstraints false} +puts $hook [list source [file join [file dirname [info script]] vivado_common.tcl]] +puts $hook {set mode [flow_write_checkpoint_with_mode $out_dcp incremental_synth]} +puts $hook {puts "INFO: Wrote baseline synthesis checkpoint for incremental synthesis: $out_dcp (mode=$mode)"} +close $hook + +puts "INFO: Opening project: $proj_path" +open_project $proj_path + +set synth_obj [get_runs -quiet $synth_run_name] +if {[llength $synth_obj] == 0} { + puts stderr "ERROR: synthesis run not found: $synth_run_name" + exit 1 +} +set synth_obj [lindex $synth_obj 0] + +puts "INFO: Installing synthesis post-hook: $hook_path" +set post_hook_ok [flow_set_optional_property STEPS.SYNTH_DESIGN.TCL.POST $hook_path $synth_obj] +set write_inc_prop_ok [flow_set_optional_property WRITE_INCREMENTAL_SYNTH_CHECKPOINT 1 $synth_obj] +if {!$post_hook_ok} { + puts stderr "ERROR: could not install synthesis post-hook on $synth_run_name" + exit 1 +} + +set setup_path [file normalize [file join [file dirname $out_dcp] baseline_incremental_synth_setup.txt]] +set setup [open $setup_path w] +puts $setup "project=$proj_path" +puts $setup "synth_run=$synth_run_name" +puts $setup "out_dcp=$out_dcp" +puts $setup "post_hook=$hook_path" +puts $setup "post_hook_property_set=$post_hook_ok" +puts $setup "write_incremental_synth_checkpoint_property_set=$write_inc_prop_ok" +puts $setup "jobs=$jobs" +close $setup + +puts "INFO: Resetting synthesis run: $synth_run_name" +reset_run $synth_obj +puts "INFO: Launching $synth_run_name with -jobs $jobs" +launch_runs $synth_obj -jobs $jobs +wait_on_run $synth_obj + +set status [get_property STATUS $synth_obj] +puts "INFO: $synth_run_name status: $status" +if {![string match "*Complete*" $status]} { + puts stderr "ERROR: synthesis did not complete" + exit 1 +} +if {![file exists $out_dcp]} { + puts stderr "ERROR: incremental synthesis checkpoint was not created: $out_dcp" + exit 1 +} +if {[file size $out_dcp] <= 0} { + puts stderr "ERROR: incremental synthesis checkpoint is empty: $out_dcp" + exit 1 +} + +set mode_file [file rootname $out_dcp].mode +set checkpoint_mode "unknown" +if {[file exists $mode_file]} { + set mode_fh [open $mode_file r] + set mode_text [read $mode_fh] + close $mode_fh + if {[regexp {checkpoint_mode=([^\r\n]+)} $mode_text _ parsed_mode]} { + set checkpoint_mode $parsed_mode + } +} + +puts "INFO: Synthesis checkpoint for incremental synthesis is ready: $out_dcp" +puts "INFO: checkpoint_mode=$checkpoint_mode" diff --git a/fpga_diff/tools/cpu_dcp_split/run_incremental_synth_only.tcl b/fpga_diff/tools/cpu_dcp_split/run_incremental_synth_only.tcl new file mode 100644 index 00000000..cbe91839 --- /dev/null +++ b/fpga_diff/tools/cpu_dcp_split/run_incremental_synth_only.tcl @@ -0,0 +1,97 @@ +######################################################################## +# Run only synthesis with a Vivado incremental-synthesis checkpoint. +# +# Usage: +# vivado -mode batch -source env-scripts/fpga_diff/tools/cpu_dcp_split/run_incremental_synth_only.tcl \ +# -tclargs [synth-run] [jobs] [mode] +######################################################################## + +proc usage {} { + puts stderr "USAGE: vivado -mode batch -source env-scripts/fpga_diff/tools/cpu_dcp_split/run_incremental_synth_only.tcl -tclargs ?synth-run? ?jobs? ?quick|default|aggressive|off?" +} + +source [file join [file dirname [info script]] vivado_common.tcl] + +if {[llength $::argv] < 2 || [llength $::argv] > 5} { + usage + exit 2 +} + +set proj_path [file normalize [lindex $::argv 0]] +set reference_synth_dcp [file normalize [lindex $::argv 1]] +set synth_run_name "synth_1" +if {[llength $::argv] >= 3 && [string trim [lindex $::argv 2]] ne ""} { + set synth_run_name [string trim [lindex $::argv 2]] +} +set jobs_arg "" +if {[llength $::argv] >= 4 && [string trim [lindex $::argv 3]] ne ""} { + set jobs_arg [string trim [lindex $::argv 3]] +} +set synth_incremental_mode "default" +if {[llength $::argv] >= 5 && [string trim [lindex $::argv 4]] ne ""} { + set synth_incremental_mode [string trim [lindex $::argv 4]] +} + +flow_require_file $proj_path "project" +flow_require_file $reference_synth_dcp "reference synthesis checkpoint" +flow_require_positive_int $jobs_arg "jobs" +flow_require_choice $synth_incremental_mode {quick default aggressive off} "synth_incremental_mode" +set jobs [flow_default_jobs $jobs_arg] + +puts "INFO: Opening project: $proj_path" +open_project $proj_path + +set synth_obj [get_runs -quiet $synth_run_name] +if {[llength $synth_obj] == 0} { + puts stderr "ERROR: synthesis run not found: $synth_run_name" + exit 1 +} +set synth_obj [lindex $synth_obj 0] + +set utils_ok 0 +set synth_checkpoint_ok 0 +set synth_mode_ok 0 +if {$synth_incremental_mode eq "off"} { + puts "INFO: Synthesis incremental mode is off; reference synthesis checkpoint will not be used." +} else { + puts "INFO: Setting synthesis incremental checkpoint on $synth_run_name: $reference_synth_dcp" + set utils_ok [flow_ensure_utils_file $reference_synth_dcp] + set synth_checkpoint_ok [flow_set_optional_property INCREMENTAL_CHECKPOINT $reference_synth_dcp $synth_obj] + set synth_mode_ok [flow_set_optional_property STEPS.SYNTH_DESIGN.ARGS.INCREMENTAL_MODE $synth_incremental_mode $synth_obj] +} +set write_inc_synth_ok [flow_set_optional_property WRITE_INCREMENTAL_SYNTH_CHECKPOINT 1 $synth_obj] + +set report_dir [file normalize [file join [get_property DIRECTORY [current_project]] incremental-synth-only $synth_run_name]] +file mkdir $report_dir +set setup_report [open [file join $report_dir incremental_synth_setup.txt] w] +puts $setup_report "project=$proj_path" +puts $setup_report "synth_run=$synth_run_name" +puts $setup_report "reference_synth_dcp=$reference_synth_dcp" +puts $setup_report "synth_incremental_mode=$synth_incremental_mode" +puts $setup_report "utils_file_set=$utils_ok" +puts $setup_report "synthesis_incremental_checkpoint_set=$synth_checkpoint_ok" +puts $setup_report "synthesis_incremental_mode_property_set=$synth_mode_ok" +puts $setup_report "write_incremental_synth_checkpoint_property_set=$write_inc_synth_ok" +puts $setup_report "jobs=$jobs" +close $setup_report + +if {$synth_incremental_mode ne "off" && !$synth_checkpoint_ok} { + puts stderr "ERROR: could not set synthesis incremental checkpoint" + exit 1 +} + +puts "INFO: Resetting synthesis run: $synth_run_name" +reset_run $synth_obj +puts "INFO: Launching $synth_run_name with -jobs $jobs" +launch_runs $synth_obj -jobs $jobs +wait_on_run $synth_obj + +set status [get_property STATUS $synth_obj] +puts "INFO: $synth_run_name status: $status" +if {![string match "*Complete*" $status]} { + puts stderr "ERROR: synthesis did not complete" + exit 1 +} + +puts "INFO: Incremental synthesis-only run completed." +puts "INFO: Setup report: [file join $report_dir incremental_synth_setup.txt]" diff --git a/fpga_diff/tools/cpu_dcp_split/sync_cpu_dcp_interface.py b/fpga_diff/tools/cpu_dcp_split/sync_cpu_dcp_interface.py new file mode 100755 index 00000000..f126720e --- /dev/null +++ b/fpga_diff/tools/cpu_dcp_split/sync_cpu_dcp_interface.py @@ -0,0 +1,203 @@ +#!/usr/bin/env python3 +import argparse +import json +import sys +from pathlib import Path + +from cpu_rtl_interface import ( + make_partition_rtl, + read_cpu_interface, + read_module_interface, + sha256_file, +) +from extract_simtop_partition_contract import extract_contract, locate_simtop + + +def infer_cpu_module(release: Path, simtop_module: str) -> tuple[str, str, str]: + simtop_path, release_root = locate_simtop(str(release), simtop_module) + contract = extract_contract(simtop_path, release_root, simtop_module) + cpu_instances = contract.get("cpu_instances", []) + if len(cpu_instances) != 1: + raise ValueError( + f"expected exactly one CPU instance in {simtop_path}, got {len(cpu_instances)}" + ) + cpu_inst = cpu_instances[0] + return ( + str(cpu_inst.get("module", "")), + str(cpu_inst.get("name", "")), + str(simtop_path), + ) + + +def default_output_path(release: Path, partition_module: str) -> Path: + return release / "build" / "rtl" / f"{partition_module}.sv" + + +def write_json(path: Path, data: dict) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n", encoding="utf-8") + + +def main() -> int: + parser = argparse.ArgumentParser( + description=( + "Read the generated CPU RTL port list and copy that interface onto " + "a CpuDcpTop-style partition RTL module." + ) + ) + parser.add_argument("--release", required=True, help="Release directory containing build/rtl") + parser.add_argument("--cpu-module", default="", help="CPU RTL module name; inferred from SimTop if omitted") + parser.add_argument("--simtop-module", default="SimTop", help="Generated top module used for inference") + parser.add_argument("--partition-module", default="CpuDcpTop", help="Output partition module name") + parser.add_argument( + "--mode", + choices=["stub", "wrapper"], + default="stub", + help="Generate a black-box top stub or an OOC wrapper around the CPU module", + ) + parser.add_argument( + "--out", + help=( + "Output RTL path. Defaults to /build/rtl/.sv " + "when --write-default is set." + ), + ) + parser.add_argument( + "--write-default", + action="store_true", + help="Write to /build/rtl/.sv when --out is omitted", + ) + parser.add_argument("--force", action="store_true", help="Overwrite an existing output RTL") + parser.add_argument("--json-out", help="Optional interface manifest path") + parser.add_argument( + "--check-existing", + help="Compare an existing partition RTL module against the CPU RTL interface", + ) + parser.add_argument( + "--print-ports", + action="store_true", + help="Print one port per line as: direction range name", + ) + args = parser.parse_args() + + release = Path(args.release).resolve() + rtl_dir = release / "build" / "rtl" + if not release.is_dir(): + print(f"ERROR: release directory not found: {release}", file=sys.stderr) + return 1 + if not rtl_dir.is_dir(): + print(f"ERROR: release RTL directory not found: {rtl_dir}", file=sys.stderr) + return 1 + + cpu_module = args.cpu_module + cpu_instance = "" + simtop_path = "" + if not cpu_module: + try: + cpu_module, cpu_instance, simtop_path = infer_cpu_module(release, args.simtop_module) + except (OSError, SystemExit, ValueError) as exc: + print(f"ERROR: cannot infer CPU module from {release}: {exc}", file=sys.stderr) + return 1 + + cpu_rtl = rtl_dir / f"{cpu_module}.sv" + if not cpu_rtl.is_file(): + print(f"ERROR: CPU RTL not found: {cpu_rtl}", file=sys.stderr) + return 1 + + try: + cpu_interface = read_cpu_interface(cpu_rtl, cpu_module) + except ValueError as exc: + print(f"ERROR: {exc}", file=sys.stderr) + return 1 + + out_path: Path | None = None + if args.out: + out_path = Path(args.out).resolve() + elif args.write_default: + out_path = default_output_path(release, args.partition_module).resolve() + + generated_rtl = make_partition_rtl( + cpu_interface, + partition_module=args.partition_module, + mode=args.mode, + ) + + check_result: dict[str, object] = {} + if args.check_existing: + check_path = Path(args.check_existing).resolve() + if not check_path.is_file(): + print(f"ERROR: existing partition RTL not found: {check_path}", file=sys.stderr) + return 1 + try: + existing = read_module_interface(check_path, args.partition_module) + except ValueError as exc: + print(f"ERROR: cannot parse existing partition RTL: {exc}", file=sys.stderr) + return 1 + matches = existing["port_signature"] == cpu_interface["port_signature"] + check_result = { + "path": str(check_path), + "matches_cpu_interface": matches, + "existing_interface_hash": existing["interface_hash"], + "cpu_interface_hash": cpu_interface["interface_hash"], + "existing_port_count": existing["port_count"], + "cpu_port_count": cpu_interface["port_count"], + } + + if out_path: + if out_path.exists() and not args.force: + print(f"ERROR: output RTL already exists: {out_path}", file=sys.stderr) + print("Use --force to overwrite it.", file=sys.stderr) + return 1 + out_path.parent.mkdir(parents=True, exist_ok=True) + out_path.write_text(generated_rtl, encoding="utf-8") + + manifest = { + "schema_version": 1, + "purpose": "CpuDcpTop interface copied from generated CPU RTL", + "release": str(release), + "simtop_module": args.simtop_module, + "simtop_path": simtop_path, + "cpu_module": cpu_module, + "cpu_instance": cpu_instance, + "partition_module": args.partition_module, + "mode": args.mode, + "source_cpu_rtl": str(cpu_rtl), + "source_cpu_rtl_sha256": cpu_interface["source_cpu_rtl_sha256"], + "generated_rtl": str(out_path) if out_path else "", + "generated_rtl_sha256": sha256_file(out_path) if out_path and out_path.is_file() else "", + "port_count": cpu_interface["port_count"], + "interface_hash": cpu_interface["interface_hash"], + "ports": cpu_interface["port_signature"], + "check_existing": check_result, + "notes": [ + "This copies the RTL module interface onto the partition shell.", + "Vivado still requires the imported DCP top ports to match the target cell ports.", + "If the real CPU implementation ports changed, rebuild the CPU DCP or add a deliberate adapter.", + ], + } + + if args.json_out: + write_json(Path(args.json_out).resolve(), manifest) + + if args.print_ports: + for port in cpu_interface["port_signature"]: + direction = port["direction"] or "-" + range_text = port["range"] or "-" + print(f"{direction:6} {range_text:12} {port['name']}") + elif not args.json_out and not out_path: + print(json.dumps(manifest, indent=2, sort_keys=True)) + + if out_path: + print(f"Wrote {args.partition_module} {args.mode}: {out_path}") + if args.json_out: + print(f"Wrote interface manifest: {Path(args.json_out).resolve()}") + if check_result: + state = "matches" if check_result["matches_cpu_interface"] else "differs" + print(f"Existing partition interface {state}: {check_result['path']}") + if not check_result["matches_cpu_interface"]: + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/fpga_diff/tools/cpu_dcp_split/vivado_common.tcl b/fpga_diff/tools/cpu_dcp_split/vivado_common.tcl new file mode 100644 index 00000000..97867ef9 --- /dev/null +++ b/fpga_diff/tools/cpu_dcp_split/vivado_common.tcl @@ -0,0 +1,92 @@ +proc flow_require_file {path label} { + if {![file exists $path]} { + puts stderr "ERROR: $label not found: $path" + exit 1 + } +} + +proc flow_require_positive_int {value label} { + if {$value ne "" && (![string is integer -strict $value] || $value < 1)} { + puts stderr "ERROR: $label must be a positive integer, got '$value'" + exit 1 + } +} + +proc flow_require_choice {value choices label} { + if {[lsearch -exact $choices $value] < 0} { + puts stderr "ERROR: $label must be one of [join $choices /], got '$value'" + exit 1 + } +} + +proc flow_default_jobs {jobs_arg {half_default 0}} { + if {$jobs_arg ne ""} { + return $jobs_arg + } + + set jobs 1 + if {![catch {exec nproc} nproc_out]} { + set nproc_trim [string trim $nproc_out] + if {[scan $nproc_trim "%d" jobs] != 1 || $jobs < 1} { + set jobs 1 + } elseif {$half_default} { + set jobs [expr {int(ceil($jobs / 2.0))}] + } + } + if {$jobs < 1} { + set jobs 1 + } + return $jobs +} + +proc flow_set_optional_property {name value obj} { + if {[catch {set_property $name $value $obj} err]} { + puts "WARNING: could not set $name on [get_property NAME $obj]: $err" + return 0 + } + return 1 +} + +proc flow_ensure_utils_file {path} { + if {[llength [get_filesets -quiet utils_1]] == 0} { + if {[catch {create_fileset -utils utils_1} err]} { + puts "WARNING: could not create utils_1 fileset: $err" + return 0 + } + } + if {[llength [get_files -quiet $path]] == 0} { + if {[catch {add_files -fileset utils_1 -norecurse $path} err]} { + puts "WARNING: could not add checkpoint to utils_1: $path: $err" + return 0 + } + } + return 1 +} + +proc flow_write_checkpoint_mode {out_dcp mode} { + set mode_file [file rootname $out_dcp].mode + set mode_fh [open $mode_file w] + puts $mode_fh "checkpoint_mode=$mode" + close $mode_fh +} + +proc flow_write_checkpoint_with_mode {out_dcp checkpoint_mode {record_mode 1}} { + set actual_mode $checkpoint_mode + if {$checkpoint_mode eq "incremental_synth"} { + if {[catch {write_checkpoint -force -incremental_synth $out_dcp} err]} { + puts "WARNING: write_checkpoint -incremental_synth failed: $err" + puts "WARNING: Falling back to write_checkpoint -force. This is expected on Vivado 2024.1." + set actual_mode "normal" + if {[catch {write_checkpoint -force $out_dcp} fallback_err]} { + puts stderr "ERROR: failed to write fallback synthesis checkpoint: $fallback_err" + return -code error $fallback_err + } + } + } else { + write_checkpoint -force $out_dcp + } + if {$record_mode} { + flow_write_checkpoint_mode $out_dcp $actual_mode + } + return $actual_mode +} diff --git a/fpga_diff/tools/cpu_dcp_split/write_run_checkpoint.tcl b/fpga_diff/tools/cpu_dcp_split/write_run_checkpoint.tcl new file mode 100644 index 00000000..b282eb27 --- /dev/null +++ b/fpga_diff/tools/cpu_dcp_split/write_run_checkpoint.tcl @@ -0,0 +1,54 @@ +######################################################################## +# Save a Vivado checkpoint from an existing FpgaDiff run. +# +# Usage: +# vivado -mode batch -source env-scripts/fpga_diff/tools/cpu_dcp_split/write_run_checkpoint.tcl \ +# -tclargs [normal|incremental_synth] +######################################################################## + +proc usage {} { + puts stderr "USAGE: vivado -mode batch -source env-scripts/fpga_diff/tools/cpu_dcp_split/write_run_checkpoint.tcl -tclargs ?normal|incremental_synth?" +} + +source [file join [file dirname [info script]] vivado_common.tcl] + +if {[llength $::argv] < 3 || [llength $::argv] > 4} { + usage + exit 2 +} + +set proj_path [file normalize [lindex $::argv 0]] +set run_name [string trim [lindex $::argv 1]] +set out_dcp [file normalize [lindex $::argv 2]] +set checkpoint_mode "normal" +if {[llength $::argv] >= 4 && [string trim [lindex $::argv 3]] ne ""} { + set checkpoint_mode [string trim [lindex $::argv 3]] +} + +flow_require_file $proj_path "project" +if {$run_name eq ""} { + puts stderr "ERROR: run-name is empty" + exit 1 +} +flow_require_choice $checkpoint_mode {normal incremental_synth} "checkpoint mode" + +file mkdir [file dirname $out_dcp] + +puts "INFO: Opening project: $proj_path" +open_project $proj_path + +set run_obj [get_runs -quiet $run_name] +if {[llength $run_obj] == 0} { + puts stderr "ERROR: run not found: $run_name" + exit 1 +} + +puts "INFO: Opening run: $run_name" +open_run $run_name +if {$checkpoint_mode eq "incremental_synth"} { + puts "INFO: Writing incremental synthesis checkpoint" + flow_write_checkpoint_with_mode $out_dcp incremental_synth +} else { + flow_write_checkpoint_with_mode $out_dcp normal 0 +} +puts "INFO: Wrote checkpoint: $out_dcp" diff --git a/fpga_diff/tools/run_impl_route.tcl b/fpga_diff/tools/run_impl_route.tcl new file mode 100644 index 00000000..2850ba19 --- /dev/null +++ b/fpga_diff/tools/run_impl_route.tcl @@ -0,0 +1,80 @@ +######################################################################## +# Open a project from -tclargs and launch/wait on impl_1 through route_design. +# +# Usage: +# vivado -mode batch -source tools/run_impl_route.tcl \ +# -tclargs [impl-run] [jobs] +######################################################################## + +proc usage {} { + puts stderr "USAGE: vivado -mode batch -source tools/run_impl_route.tcl -tclargs ?impl-run? ?jobs?" +} + +if {[llength $::argv] < 1 || [llength $::argv] > 3} { + usage + exit 2 +} + +set proj_path [file normalize [lindex $::argv 0]] +set impl_run_name "impl_1" +if {[llength $::argv] >= 2 && [string trim [lindex $::argv 1]] ne ""} { + set impl_run_name [string trim [lindex $::argv 1]] +} +set requested_jobs "" +if {[llength $::argv] >= 3 && [string trim [lindex $::argv 2]] ne ""} { + set requested_jobs [string trim [lindex $::argv 2]] +} elseif {[info exists ::env(VIVADO_JOBS)] && [string trim $::env(VIVADO_JOBS)] ne ""} { + set requested_jobs [string trim $::env(VIVADO_JOBS)] +} + +if {![file exists $proj_path]} { + puts stderr "ERROR: .xpr not found: $proj_path" + exit 1 +} +if {$requested_jobs ne "" && (![string is integer -strict $requested_jobs] || $requested_jobs < 1)} { + puts stderr "ERROR: VIVADO_JOBS must be a positive integer, got '$requested_jobs'" + exit 1 +} + +set jobs 1 +if {$requested_jobs ne ""} { + set jobs $requested_jobs +} elseif {![catch {exec nproc} nproc_out]} { + set nproc_trim [string trim $nproc_out] + if {[scan $nproc_trim "%d" jobs] != 1 || $jobs < 1} { + set jobs 1 + } else { + set jobs [expr {int(ceil($jobs / 2.0))}] + } +} +if {$jobs < 1} { + set jobs 1 +} + +puts "INFO: Opening project: $proj_path" +open_project $proj_path + +set impl_runs [get_runs -quiet $impl_run_name] +if {[llength $impl_runs] == 0} { + puts stderr "ERROR: implementation run not found: $impl_run_name" + exit 1 +} +set impl_run [lindex $impl_runs 0] +set status [get_property STATUS $impl_run] +puts "INFO: $impl_run_name status: $status" + +if {![string match "route_design Complete*" $status] && ![string match "write_bitstream Complete*" $status]} { + puts "INFO: Launching $impl_run_name to route_design with -jobs $jobs" + launch_runs $impl_run -to_step route_design -jobs $jobs + wait_on_run $impl_run + set status [get_property STATUS $impl_run] +} else { + puts "INFO: $impl_run_name already routed." +} + +if {![string match "route_design Complete*" $status] && ![string match "write_bitstream Complete*" $status]} { + puts stderr "ERROR: $impl_run_name did not route successfully: $status" + exit 1 +} + +puts "INFO: Route flow finished."