Chiplet Disaggregation + UCIe (v5)
v5 splits the v4 monolithic system_top into three dies connected by modeled UCIe (Universal Chiplet Interconnect Express) links. The external system_top port list is unchanged — cocotb still drives the same chip boundary — but internally, what used to be same-module wiring is now flit traffic crossing die boundaries with credit-based flow control and fixed crossing latency.
This mirrors real chiplet packages (AMD CCD/IOD, Intel tile splits): compute dies stay "dumb" from an I/O standpoint, and a single base/I-O die aggregates shared memory and all external I/O.
Why split it, and why UCIe raw mode
Duck Server's inter-die traffic is a custom instruction/result stream, not memory-mapped PCIe TLPs or CXL.mem/cache semantics — UCIe's raw (streaming) mode is the right fit: the protocol layer is fully custom, carried over the standard UCIe die-to-die adapter + physical layer bring-up sequence. It's also the natural next step for a project already modeling a heterogeneous SoC — CPU and NPU compute stay on their own dies, the base die owns the network ingress, MOESI L2, and external DRAM/DMA, and interconnect behavior (credits, training, crossing latency) becomes an explicit, inspectable model instead of implicit same-cycle wiring.
Each ucie_chan instance carries exactly one flit type in one direction — five dedicated channels total instead of a generic N-VC crossbar — because Duck Server's actual traffic classes are few and well-known ahead of time (see the flit table below). This is the same idea real UCIe virtual channels solve (avoid head-of-line blocking between unrelated traffic), sized down to fit.
Package Diagram
┌─────────────────────────────────────────────┐
│ base_die_top │
│ │
net_valid/ │ ┌───────────┐ ┌──────────────────┐ │
instruction ──►│ │ noc_router│ │ duck_coherency_ctrl│ │
net_ready ◄───│ └─────┬─────┘ │ (MOESI L2) │ │
│ │ └─────────┬─────────┘ │
│ cpu_accept cpu_read/write/addr │
│ │ (via mem_req/mem_rsp │
│ │ bridge FSM) │
│ │ │ │
│ │ dram_* │ npu_dma_* │
└─────────┼────────────────┬───────┼─────────────┘
│ ▼ (top-level ports)
instr_load │ External DRAM
(base→cpu) │ npu_disp (base→npu)
mem_req│ npu_result (npu→base)
mem_rsp │ │
│ │ │
┌──────────▼──────▼───┐ │ ┌──────────────────────┐
│ ucie_link_ctrl │ │ │ ucie_link_ctrl │
│ (CPU link training) │ │ │ (NPU link training) │
└──────────┬────────────┘ │ └───────────┬────────────┘
│ cpu_link_up │ npu_link_up│
┌───────────▼────────────────▼──────────────────▼───────────┐
│ 3× ucie_chan (CPU link) 2× ucie_chan (NPU link) │
│ instr_load / mem_req / mem_rsp npu_disp / npu_result │
└──────────┬──────────────────────────────────┬─────────────┘
│ │
┌──────────▼─────────────┐ ┌─────────▼───────────────┐
│ cpu_chiplet_top │ │ npu_chiplet_top │
│ ┌───────────────────┐ │ │ ┌────────────────────┐ │
│ │ imem (256 words) │ │ │ │ 16-entry result FIFO│ │
│ └─────────┬─────────┘ │ │ └──────────┬─────────┘ │
│ ▼ │ │ ▼ │
│ riscv_core │ │ npu │
│ (unmodified) │ │ (unmodified) │
│ halt ──────────────┼─► top-level halt (unchanged pin) │
└─────────────────────────┘ └──────────────────────────┘
noc_router.sv, duck_coherency_ctrl.sv, riscv_core.sv, and npu.sv are all unmodified — every *_chiplet_top.sv / base_die_top.sv file is a wrapper that translates that sub-module's native level-held handshakes into flit traffic on its side of a link.
The Three Dies
| File | Die | Contains |
|---|---|---|
base_die_top.sv |
Base / I-O die | noc_router, duck_coherency_ctrl (MOESI L2), all external I/O (network, DRAM, NPU DMA, NPU result bus), plus the two link-facing bridge adapters |
cpu_chiplet_top.sv |
CPU chiplet | riscv_core, the 256-word imem front end, halt detection, the mem_req/mem_rsp bridge FSM |
npu_chiplet_top.sv |
NPU chiplet | npu (16×16 MAC array), a 16-entry result buffer + drain FSM |
Why does the base die keep the L2/coherency controller instead of the CPU? Because the NPU DMA port also needs coherent access to the same memory — keeping MOESI on the base die means both compute dies reach it the same way (CPU over the mem_req/mem_rsp UCIe channels, NPU DMA over its own top-level port), rather than routing NPU DMA traffic through the CPU chiplet.
Links, Channels, and Flits
Two physical links, five channels total:
| Link | Channel | Direction | Depth | Carries |
|---|---|---|---|---|
| CPU link | instr_load |
base → cpu | 256 (matches imem) |
Accepted network instruction word |
| CPU link | mem_req |
cpu → base | 2 (single outstanding) | Load/store request (addr, data, byte_en, type) |
| CPU link | mem_rsp |
base → cpu | 2 | Load/store completion (rdata) |
| NPU link | npu_disp |
base → npu | 1 (mirrors original npu_ready) |
Dispatch instruction (custom-1 word) |
| NPU link | npu_result |
npu → base | 4 (absorbs output bursts) | One accumulator result (tag = row index) |
Flit opcodes and channel depths live in rtl/duck_ucie_pkg.sv (FLIT_* / CH_DEPTH_* parameters). The package is not imported by any RTL module — same convention as duck_cache_pkg.sv — because Icarus Verilog 11 / cocotb 2.0 need flat signals rather than packages/structs on module ports. It exists purely as documentation; each ucie_chan instance carries one flit type per instantiation site, with unused fields tied to 0.
Each channel is a single-purpose, single-direction ucie_chan instance (rtl/ucie_chan.sv) modeling:
- Credit-based flow control —
tx_readyreflects remaining receiver buffer credit (DEPTH), consumed at send and returned when the far side pops. Credit return is idealized as instantaneous (no modeled crossing latency on the return path) — only the forward data path models the die-to-die hop. - Fixed crossing latency —
LATENCYcycles (2, in every instantiation) of shift-register delay standing in for SerDes + interposer propagation, applied only forward (tx → rx). - A receive-side buffer (depth
DEPTH) so the consumer drains at its own pace.
link_up (from ucie_link_ctrl) gates all activity — while low, tx_ready=0, rx_valid=0, and internal state is held in reset.
Link Training
rtl/ucie_link_ctrl.sv models the UCIe bring-up sequence at the state-name level (not bit-accurate PHY/eye-training):
RESET → SBINIT → PARAM → MBINIT → MBTRAIN → LINKINIT → ACTIVE
Each pre-ACTIVE state holds for TRAIN_CYCLES cycles (4, in system_top.sv) then advances; ACTIVE is sticky for the design's lifetime — no dynamic retrain or L1/L2 low-power entry is modeled, though the state encoding leaves room for it. One instance per physical link (CPU link, NPU link); link_up gates every ucie_chan on that link.
Handshake Translations
CPU link
instr_load(base → cpu):ld_tx_validfires on the NoC router's existingcpu_acceptsingle-cycle pulse — same accept semantics as v4. The key change:cpu_ready(the router's own input, gating whether it can accept a new network word) is now wired to the channel's credit (ld_tx_ready) instead of!core_stall. This decouples network ingestion from the CPU's internal MUL/DIV stall cycles — imem's 256-word capacity is the credit pool, the same limit as before, just enforced by the channel instead of by throttling the network. This is the change that exposed BUG-007 — see that writeup for the tradeoff.mem_req/mem_rsp(bidirectional): a small FSM inbase_die_top.svstands in for "riscv_core" fromduck_coherency_ctrl's point of view — latches the incoming request, holdscpu_read/write/addr/wdata/byte_enuntilcpu_stallclears (unmodified coherency controller timing), then ships the response back as a single flit. On the CPU chiplet side,cpu_chiplet_top.sv's bridge FSM turnsriscv_core's heldmem_read/mem_write/... signals into a single flit send, waits for the response flit, then presentsmem_rdataand dropsmem_stallon the exact cycle the response arrives — reproducing the L2/coherency timing contractriscv_corealready expects, just relocated across a die boundary.
NPU link
npu_disp(base → npu): direct forward of the router's existingnpu_valid/npu_instructionhold register.npu_ready(the router's input) now comes from the channel's credit, which only returns once the NPU chiplet's adapter actually pops the flit — i.e. exactly when the NPU isIDLE— reproducing the originalnoc_router↔npuhandshake unchanged, plus crossing latency.npu_result(npu → base): the NPU chiplet drainsnpu's result bus into a local 16-entry FIFO (one slot per accumulator row) as fast as the NPU produces it, fully decoupling the compute pipeline'sOUTPUT_STtiming from the UCIe link's latency/credit. A small drain FSM streams the buffered results out at its own pace; on the base die,res_rx_*forwards straight onto the top-levelnpu_result_valid/index/data/readypins, unchanged from the pre-split contract.
halt
halt is treated as an auxiliary/debug pin carried alongside — not through — the CPU link's mainband flits, matching how real packages often expose low-rate debug/status signals outside the high-speed lanes. It's computed in cpu_chiplet_top.sv (imem now lives there) and wired straight through system_top unchanged from the top level's point of view. See BUG-007 for a subtlety in how it's gated.
Deliberate Simplifications
- Zero-latency credit return. Real link bandwidth is symmetric; here only the forward data path costs
LATENCYcycles, and credit returns instantly on pop. This keeps the model tractable without changing the functional contract any channel's producer/consumer see. - No dynamic retrain / low-power states.
ucie_link_ctrlonly models bring-up;ACTIVEis sticky. - Fixed
LATENCY=2everywhere, not derived from any physical floorplan — a stand-in for "some SerDes + interposer hop," not a timing-closed number. - One flit type per channel rather than a multiplexed multi-VC stream sharing one physical channel — sized to this SoC's five known traffic classes instead of a generic crossbar.
Testing
Makefile adds the five new RTL files (ucie_chan.sv, ucie_link_ctrl.sv, cpu_chiplet_top.sv, npu_chiplet_top.sv, base_die_top.sv) ahead of system_top.sv in VERILOG_SOURCES. No testbench changes were needed beyond updating hierarchical paths that reach into riscv_core's register file for checks — dut.cpu_core_inst... became dut.cpu_chiplet_inst.cpu_core_inst... in tb/tb_server_dispatch_unit.py — and raising HALT_DRAIN_CYCLES per BUG-007. Both suites match their pre-split baseline: regression is TESTS=11 PASS=11 FAIL=0, and MOESI integration is 1 PASS / 2 FAIL, unchanged and tracked separately as BUG-006.
Run: docker run --rm -e ASM_TEST=cpu_peak_tests.asm -e MODULE=tb_server_dispatch_unit -v "$(pwd)":/usr/src/server_dispatch riscv_sim:latest (or ./run_sims.command for both suites).