# Open-source core benchmarks
:::{note}
This page times *framework-authored* testbenches over vendored open-source
cores. It is distinct from the
[ports of real testbenches](../open-core-ports.md), where upstream's own
verification environments — Ibex's 944-test directed suite with Spike
co-simulation, the icache UVM environment, the riscv-arch-test suite — run
ported to cpptb and compared result for result against the originals.
:::
The open-core suite applies the four-mode comparison to unmodified,
production-style RTL rather than project-authored benchmark DUTs. Each core
is pinned with its upstream license, and each mode performs the same signal
updates, waits, checks, and checksum folding.
| Workload | Core | Verification shape |
|---|---|---|
| PicoRV32 firmware | [YosysHQ/picorv32](https://github.com/YosysHQ/picorv32) | CPU execution with infrequent testbench interaction |
| AES-128 ECB | [secworks/aes](https://github.com/secworks/aes) | Register programming and multi-cycle command completion |
| Ethernet FCS64 | [alexforencich/verilog-ethernet](https://github.com/alexforencich/verilog-ethernet) | Sustained 64-bit AXI-stream traffic and per-frame checking |
The upstream sources are vendored under
`benchmarks/framework_comparison/open_cores/third_party/`. cpptb-specific
wrappers do not modify the core RTL.
:::{note}
These benchmark testbenches predate the standard write model and run as
legacy immediate-write builds, matched to their cocotb peers — which is why
they drive off `FallingEdge` and settle with explicit delays. They are
measurement peers, not authoring templates: for the shape to copy, see the
[counter example](counter.md) and
[the write model](../scheduling.md#the-write-model).
:::
## PicoRV32 firmware
The testbench writes a small RV32I program into the benchmark memory while
reset is asserted, releases the CPU, and waits for a memory-mapped completion
store. The program runs a dependency-heavy xorshift loop whose iteration count
is supplied as data, not generated RTL.
cpptb (C++ DPI)
```cpp
for (uint32_t index = 0; index < kFirmware.size(); ++index)
co_await program_word(context, index * 4u, kFirmware[index]);
co_await program_word(context, 0x100u, context.iterations);
co_await FallingEdge{context.dut.clk};
context.dut.cpu_prog_we.set(0);
context.dut.rst_n.set(1);
co_await RisingEdge{context.dut.cpu_done};
co_await Delay{1_ps};
check(context, "firmware result", context.dut.cpu_result.get(), expected);
check(context, "CPU trap", context.dut.cpu_trap.get(), 0);
```
Cocotb
```python
async def run_picorv32(dut, state):
await FallingEdge(dut.clk)
dut.rst_n.value = 0
for index, instruction in enumerate(FIRMWARE):
await program_word(dut, index * 4, instruction)
await program_word(dut, 0x100, state.iterations)
await FallingEdge(dut.clk)
dut.cpu_prog_we.value = 0
dut.rst_n.value = 1
await RisingEdge(dut.cpu_done)
await Timer(1, unit="ps")
expected = 0x12345678
for _ in range(state.iterations):
expected = xorshift32(expected)
state.check("firmware result", int(dut.cpu_result.value), expected)
state.check("CPU trap", int(dut.cpu_trap.value), 0)
```
Most cycles execute wholly inside Verilator, making this workload useful for
measuring framework overhead when the testbench does not cross the DPI
boundary every clock.
## secworks AES-128
AES is programmed through the core's documented 32-bit register interface.
The bench expands one AES-128 key and checks the four ECB vectors from NIST SP
800-38A.
cpptb (C++ DPI)
```cpp
for (uint32_t block = 0; block < context.iterations; ++block) {
const uint32_t vector = block & 3u;
for (uint32_t word = 0; word < 4; ++word)
co_await aes_write(context, 0x20u + word,
kAesPlaintext[vector][word]);
co_await aes_write(context, 0x08u, 2u);
co_await aes_wait_status(context, 2u);
for (uint32_t word = 0; word < 4; ++word) {
const uint32_t actual = co_await aes_read(context, 0x30u + word);
check(context, "AES ciphertext word", actual,
kAesCiphertext[vector][word]);
fold(context, actual);
}
}
```
Cocotb
```python
async def run_aes(dut, state):
await reset_dut(dut)
await aes_write(dut, 0x0A, 1)
for index, value in enumerate(AES_KEY):
await aes_write(dut, 0x10 + index, value)
await aes_write(dut, 0x08, 1)
await aes_wait_status(dut, 1)
for block in range(state.iterations):
vector = block & 3
for word, value in enumerate(AES_PLAINTEXT[vector]):
await aes_write(dut, 0x20 + word, value)
await aes_write(dut, 0x08, 2)
await aes_wait_status(dut, 2)
for word, expected in enumerate(AES_CIPHERTEXT[vector]):
actual = await aes_read(dut, 0x30 + word)
state.check("AES ciphertext word", actual, expected)
state.fold(actual)
state.transactions += 1
```
`aes_wait_status` requires the selected status bit to clear and then set. This
avoids accepting the wrapper's one-cycle-old `ready` or `valid` value as the
completion of a new command.
## 64-bit Ethernet FCS
The streaming bench generates legal 64-1518 byte Ethernet frame lengths,
packs bytes into little-endian AXI-stream lanes, handles a partial final beat,
and inserts a deterministic idle cycle every 17 beats.
cpptb (C++ DPI)
```cpp
for (uint32_t offset = 0; offset < length; offset += 8u, ++beat) {
const uint32_t bytes = std::min(8u, length - offset);
uint64_t data = 0;
for (uint32_t lane = 0; lane < bytes; ++lane) {
const uint8_t value = frame_byte(packet, offset + lane);
data |= static_cast(value) << (lane * 8u);
expected = crc32_byte(expected, value);
}
if (((packet + beat) % 17u) == 0u) {
co_await FallingEdge{context.dut.clk};
context.dut.fcs_tvalid.set(0);
co_await RisingEdge{context.dut.clk};
}
co_await FallingEdge{context.dut.clk};
context.dut.fcs_tdata.set(data);
context.dut.fcs_tkeep.set((1u << bytes) - 1u);
context.dut.fcs_tlast.set(offset + bytes == length);
context.dut.fcs_tvalid.set(1);
co_await RisingEdge{context.dut.clk};
}
co_await Delay{1_ps};
check(context, "Ethernet FCS", context.dut.fcs_result.get(), ~expected);
```
Cocotb
```python
async def run_fcs(dut, state):
await reset_dut(dut)
for packet in range(state.iterations):
length = frame_length(packet)
expected = 0xFFFFFFFF
for beat, offset in enumerate(range(0, length, 8)):
chunk = min(8, length - offset)
data = 0
for lane in range(chunk):
value = frame_byte(packet, offset + lane)
data |= value << (lane * 8)
expected = crc32_byte(expected, value)
if (packet + beat) % 17 == 0:
await FallingEdge(dut.clk)
dut.fcs_tvalid.value = 0
await RisingEdge(dut.clk)
await FallingEdge(dut.clk)
dut.fcs_tdata.value = data
dut.fcs_tkeep.value = (1 << chunk) - 1
dut.fcs_tlast.value = int(offset + chunk == length)
dut.fcs_tvalid.value = 1
await RisingEdge(dut.clk)
await Timer(1, unit="ps")
actual = int(dut.fcs_result.value)
state.check("Ethernet FCS", actual, ~expected)
state.fold(actual)
state.transactions += 1
```
## Reference results
This July 16, 2026 reference run used Verilator 5.050 and Cocotb 2.0.1. Each
value is the median whole-process wall time over four serialized, mode-rotated
samples and is normalized to the matching pure-SV testbench.
| Workload | Pure SV | C++ DPI | C++ VPI | Cocotb |
|---|---:|---:|---:|---:|
| PicoRV32, 20,000 loops | 274.8 ms / 1.00x | 357.7 ms / 1.30x | 625.2 ms / 2.27x | 2434.2 ms / 8.86x |
| AES-128, 4,000 blocks | 122.2 ms / 1.00x | 172.6 ms / 1.41x | 315.1 ms / 2.58x | 3986.8 ms / 32.62x |
| Ethernet FCS, 2,000 frames | 261.3 ms / 1.00x | 251.6 ms / 0.96x | 841.6 ms / 3.22x | 7110.3 ms / 27.21x |
The DPI/pure-SV advisory threshold is exceeded by PicoRV32 and AES and passes
for Ethernet FCS. Absolute timings are machine-specific; the
[Performance](../performance.md#open-source-core-comparison) page records the
simulation cycles, system-load caveat, and complete methodology.
## Run the suite
```sh
make framework-comparison-open-cores-test
make framework-comparison-open-cores-benchmark
```
Run a single Cocotb example directly:
```sh
make framework-comparison-open-cores-cocotb-run \
OPEN_CORES_COCOTB_WORKLOAD=secworks_aes128 \
OPEN_CORES_COCOTB_ITERS=100
```
The complete testbench is
`benchmarks/framework_comparison/open_cores/testbenches/cocotb/test_open_cores.py`.
The adjacent `run_cocotb.py` is simulator launch glue rather than authored
stimulus.
Run one core independently when measuring on a busy workstation:
```sh
python3 benchmarks/framework_comparison/open_cores/run_benchmark.py \
--skip-build --workload picorv32_firmware
```
The runner launches only one simulator process at a time and rotates mode
order over four measured rounds. Every sample must match the pure-SV
transactions, checks, simulation cycles, checksum, and failure count.
Raw per-round results are retained beside the suite under `results/`.