Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
481 changes: 378 additions & 103 deletions compiler/frontend/pycircuit/cli.py

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions compiler/frontend/pycircuit/tests/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@

100 changes: 100 additions & 0 deletions compiler/frontend/pycircuit/tests/test_cli_cmodel.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
from __future__ import annotations

import json
import subprocess
import sys
from pathlib import Path
from types import SimpleNamespace

import pytest

from pycircuit.cli import (
_TopIface,
_collect_testbench_payload,
_emit_cmodel_scaffold,
_find_testbench_fn,
)
from pycircuit.design import testbench


def test_find_testbench_fn_is_optional() -> None:
assert _find_testbench_fn(SimpleNamespace()) is None


def test_find_testbench_fn_rejects_undecorated_tb() -> None:
with pytest.raises(SystemExit, match="decorated with `@testbench`"):
_find_testbench_fn(SimpleNamespace(tb=lambda t: None))


def test_collect_testbench_payload_exports_program_without_backend_text() -> None:
iface = _TopIface(sym="DemoTop", in_raw=["clk", "a"], in_tys=["i1", "i8"], out_raw=["y"], out_tys=["i8"])

@testbench
def tb(t) -> None:
t.clock("clk")
t.timeout(4)
t.finish(at=1)

tb_name, payload_json, program = _collect_testbench_payload(tb, iface)

payload = json.loads(payload_json)
assert tb_name == "tb_DemoTop"
assert program["tb_name"] == tb_name
assert "cpp_text" in payload
assert "sv_text" in payload
assert "cpp_text" not in program
assert "sv_text" not in program


def test_emit_cmodel_scaffold_writes_bridge_and_program(tmp_path: Path) -> None:
iface = _TopIface(sym="DemoTop", in_raw=["clk", "a"], in_tys=["i1", "i8"], out_raw=["y"], out_tys=["i8"])
tb_program = {"tb_name": "tb_DemoTop", "timeout_cycles": 4, "ports": {"inputs": [], "outputs": []}}

paths = _emit_cmodel_scaffold(out_dir=tmp_path, iface=iface, tb_program=tb_program)

bridge_text = paths["bridge_hpp"].read_text(encoding="utf-8")
main_text = paths["entry_cpp"].read_text(encoding="utf-8")
readme_text = paths["readme"].read_text(encoding="utf-8")
tb_program_text = paths["tb_program"].read_text(encoding="utf-8")

assert "pyc::gen::DemoTop" in bridge_text
assert "Stimulus bridge: tb_program.json" in main_text
assert "dut.a" in readme_text
assert json.loads(tb_program_text)["tb_name"] == "tb_DemoTop"


def test_gen_cmake_from_manifest_supports_entry_cpp(tmp_path: Path) -> None:
src = tmp_path / "dut.cpp"
entry = tmp_path / "main.cpp"
runtime_src = tmp_path / "pyc_runtime.cpp"
src.write_text("int dut_helper() { return 0; }\n", encoding="utf-8")
entry.write_text("int main() { return 0; }\n", encoding="utf-8")
runtime_src.write_text("void pyc_runtime_stub() {}\n", encoding="utf-8")

manifest = tmp_path / "cpp_project_manifest.json"
manifest.write_text(
json.dumps(
{
"sources": [str(src)],
"entry_cpp": str(entry),
"include_dirs": [str(tmp_path)],
"runtime_sources": [str(runtime_src)],
"cxx_standard": "c++17",
"executable_name": "pyc_cmodel",
},
indent=2,
)
+ "\n",
encoding="utf-8",
)

out_dir = tmp_path / "cmake"
script = Path(__file__).resolve().parents[4] / "flows" / "tools" / "gen_cmake_from_manifest.py"
subprocess.run(
[sys.executable, str(script), "--manifest", str(manifest), "--out-dir", str(out_dir)],
check=True,
)

cmake_text = (out_dir / "CMakeLists.txt").read_text(encoding="utf-8")
assert "add_executable(pyc_cmodel ${PYC_TB_SOURCES})" in cmake_text
assert "\"main.cpp\"" in cmake_text
4 changes: 3 additions & 1 deletion docs/FRONTEND_API.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@
- `value_params` are runtime module IO values (not specialization params)
- `@function`: inline helper (inlined into the caller)
- `@const`: compile-time helper (pure; may not emit IR or mutate the module)
- `@testbench`: host-side cycle test program lowered via a `.pyc` payload
- `@testbench`: optional host-side cycle test program lowered via a `.pyc` payload

When `@testbench` is omitted, `pycircuit build --target cpp` still emits the DUT plus a generated `cmodel/` bridge for external C++ / TLM drivers.

## Top-level imports (recommended)

Expand Down
12 changes: 11 additions & 1 deletion docs/PIPELINE.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ Frontend responsibilities:
- materialize `@module(value_params=...)` as runtime boundary input ports
- emit one `.pyc` per specialized module
- emit a deterministic `project_manifest.json`
- emit a testbench `.pyc` payload from `@testbench`
- emit a testbench `.pyc` payload from `@testbench` when present
- emit `cmodel/` bridge artifacts for external C++ / TLM flows when building C++

All emitted modules are stamped with:
- `pyc.frontend.contract = "pycircuit"`
Expand Down Expand Up @@ -49,6 +50,15 @@ Build a project (multi-module + testbench):
python3 -m pycircuit.cli build <tb_or_top.py> --out-dir <dir> --target cpp|verilator|both --jobs <N>
```

Build a DUT without `@testbench` and get an external C++ driver scaffold:

```bash
python3 -m pycircuit.cli build <top.py> --out-dir <dir> --target cpp --jobs <N>
```

In DUT-only mode, `cpp_project_manifest.json` points at the generated `cmodel/<Top>_main.cpp`, and `cmodel_project_manifest.json`
describes the reusable external-driver interface.

Simulation (Verilator):

```bash
Expand Down
14 changes: 14 additions & 0 deletions docs/QUICKSTART.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,17 @@ python3 -m pycircuit.cli build \
--target both \
--jobs 8
```

Build the DUT only and generate an external C++ / TLM scaffold:

```bash
PYTHONPATH=/Users/zhoubot/pyCircuit/compiler/frontend \
PYC_TOOLCHAIN_ROOT=/Users/zhoubot/pyCircuit/.pycircuit_out/toolchain/install \
python3 -m pycircuit.cli build \
/Users/zhoubot/pyCircuit/designs/examples/counter/counter.py \
--out-dir /tmp/counter_model \
--target cpp \
--jobs 8
```

The output includes `cmodel/README.md`, `cmodel/<Top>_cmodel.hpp`, and `cmodel_project_manifest.json`.
6 changes: 5 additions & 1 deletion docs/TESTBENCH.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@
- frontend emits a TB `.pyc` payload (JSON encoded in module attrs)
- backend (`pycc`) lowers that payload to C++ or SystemVerilog testbench text

`@testbench` is optional for `pycircuit build`.
- If `tb(...)` is present and decorated, pyCircuit emits C++/SV testbench artifacts as before.
- If `tb(...)` is absent, pyCircuit still builds the DUT and emits a `cmodel/` scaffold for external C++/TLM drivers.

Observation points (pyc4.0):

- `phase="pre"` samples at **TICK-OBS** (after combinational settle, before state commit).
Expand All @@ -30,7 +34,7 @@ def tb(t: Tb):
t.finish(at=10)
```

`pycircuit build` expects `tb` to be decorated with `@testbench`.
If `tb` is present, it must be decorated with `@testbench`.

## Tb API (selected)

Expand Down
4 changes: 4 additions & 0 deletions docs/simulation.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ pyCircuit 的 C++ 仿真引擎采用 **静态编译-直接执行 (Compiled-Code
寄存器实例(`pyc_reg`)以及组合逻辑求值函数(`eval()`/`tick()`)。
仿真通过反复调用这些方法来推进时钟周期,在主机 CPU 上直接执行原生 C++ 代码。

从当前版本开始,`pycircuit build --target cpp` 还会生成 `cmodel/` 外部驱动脚手架:
- 有 `@testbench` 时,保留 pyCircuit 生成的 TB,并额外导出 `cmodel/tb_program.json`
- 无 `@testbench` 时,生成可编译的 `cmodel/<Top>_main.cpp` 和 `<Top>_cmodel.hpp`,供手写 C++ / TLM 直接接入

```
┌─────────────────────────────────────────────────────────┐
│ Python 测试驱动 (ctypes) │
Expand Down
22 changes: 13 additions & 9 deletions flows/tools/gen_cmake_from_manifest.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,10 @@ def main() -> int:
data = _load(manifest_path)

srcs = [Path(s).resolve() for s in data.get("sources", []) if isinstance(s, str) and s]
tb_cpp = Path(str(data.get("tb_cpp", ""))).resolve()
entry_cpp_raw = str(data.get("entry_cpp", "")).strip()
if not entry_cpp_raw:
entry_cpp_raw = str(data.get("tb_cpp", "")).strip()
entry_cpp = Path(entry_cpp_raw).resolve() if entry_cpp_raw else Path()
incs = [Path(s).resolve() for s in data.get("include_dirs", []) if isinstance(s, str) and s]
runtime_srcs = [Path(s).resolve() for s in data.get("runtime_sources", []) if isinstance(s, str) and s]
runtime_incs = [Path(s).resolve() for s in data.get("runtime_include_dirs", []) if isinstance(s, str) and s]
Expand All @@ -54,11 +57,12 @@ def main() -> int:
runtime_cfg_exists = bool(runtime_cfg) and (Path(runtime_cfg) / "pycircuitConfig.cmake").is_file()
runtime_toolchain_root = str(runtime.get("toolchain_root_hint", ""))
std = str(data.get("cxx_standard", "c++17"))
exe_name = str(data.get("executable_name", "pyc_tb")).strip() or "pyc_tb"

if not srcs:
raise SystemExit("manifest missing `sources`")
if not tb_cpp.is_file():
raise SystemExit(f"missing tb cpp: {tb_cpp}")
if not entry_cpp_raw or not entry_cpp.is_file():
raise SystemExit(f"missing entry cpp: {entry_cpp_raw or '<empty>'}")
for s in srcs:
if not s.is_file():
raise SystemExit(f"missing source: {s}")
Expand All @@ -73,12 +77,12 @@ def main() -> int:
lines.append("set(PYC_TB_SOURCES\n")
for s in srcs:
lines.append(f" \"{_rel(s, out_dir)}\"\n")
lines.append(f" \"{_rel(tb_cpp, out_dir)}\"\n")
lines.append(f" \"{_rel(entry_cpp, out_dir)}\"\n")
lines.append(")\n\n")

lines.append("add_executable(pyc_tb ${PYC_TB_SOURCES})\n")
lines.append(f"add_executable({exe_name} ${{PYC_TB_SOURCES}})\n")
if incs:
lines.append("target_include_directories(pyc_tb PRIVATE\n")
lines.append(f"target_include_directories({exe_name} PRIVATE\n")
for i in incs:
lines.append(f" \"{_rel(i, out_dir)}\"\n")
lines.append(")\n")
Expand All @@ -88,15 +92,15 @@ def main() -> int:
lines.append(
f"find_package({runtime_pkg} CONFIG REQUIRED PATHS \"{_cmake_str(runtime_cfg)}\" NO_DEFAULT_PATH)\n"
)
lines.append(f"target_link_libraries(pyc_tb PRIVATE {runtime_target})\n")
lines.append(f"target_link_libraries({exe_name} PRIVATE {runtime_target})\n")
elif runtime_lib_files:
lines.append("add_library(pyc4_runtime_prebuilt STATIC IMPORTED GLOBAL)\n")
lines.append("set_target_properties(pyc4_runtime_prebuilt PROPERTIES\n")
lines.append(f" IMPORTED_LOCATION \"{_cmake_str(str(runtime_lib_files[0]))}\"\n")
if runtime_incs:
lines.append(f" INTERFACE_INCLUDE_DIRECTORIES \"{_cmake_list(runtime_incs)}\"\n")
lines.append(")\n")
lines.append("target_link_libraries(pyc_tb PRIVATE pyc4_runtime_prebuilt)\n")
lines.append(f"target_link_libraries({exe_name} PRIVATE pyc4_runtime_prebuilt)\n")
elif runtime_srcs:
lines.append("set(PYC_RUNTIME_SOURCES\n")
for s in runtime_srcs:
Expand All @@ -108,7 +112,7 @@ def main() -> int:
for i in runtime_incs:
lines.append(f" \"{_rel(i, out_dir)}\"\n")
lines.append(")\n")
lines.append("target_link_libraries(pyc_tb PRIVATE pyc4_runtime)\n")
lines.append(f"target_link_libraries({exe_name} PRIVATE pyc4_runtime)\n")
lines.append("\n")

out = out_dir / "CMakeLists.txt"
Expand Down
Loading