HW Design-Space Agent

/case-study

Case study: CORDIC sin/cos

The design space of milestone 1, from the repository's tested models.

The project explores one function so far, a CORDIC sin/cos unit on an Artix-7 FPGA, starting from the owner's CORDIC repository: synthesisable SystemVerilog, with an iterative and a pipelined sin/cos module, 16-bit data and 14 iterations. The agent may choose from four architecture families and five numeric knobs (data width 8–28 bits, 4–30 iterations, the angle-path width, 0–4 guard bits, truncation or rounding), plus the unrolling factor kk or the register spacing mm. That is 635,040 designs, few enough that the repository scores every one of them to get the exact answer every run is graded against.

The algorithm, bit for bit

CORDIC computes sin and cos with nothing but shifts and adds. It starts with the vector (K,0)(K, 0) and rotates it towards the input angle by a fixed sequence of ever-smaller angles, ±arctan⁡2−i\pm\arctan 2^{-i}, choosing the sign each time from the residual angle zz. The rotations stretch the vector by a known factor, so the start value KK pre-compensates for it, and after NN iterations x≈cos⁡θx \approx \cos\theta and y≈sin⁡θy \approx \sin\theta.

Loading the animation…

The animation runs a TypeScript port of the repository's golden model. That model is bit-exact against the reference RTL: for every one of the 65,536 input angles of the 16-bit design it produces the same output codes as an Icarus Verilog simulation of both reference modules, a check that runs in the repository's CI. This site's port reproduces the Python model exactly, register by register, on every configuration its tests cover, and all 2^W outputs for every W up to 16 (the site's CI checks a SHA-256 of them).

Every accuracy number on this site is exact: the error against the true sin and cos, taken over every input angle. The reference design's worst-case error is 10.5 LSB (10.6 accurate bits), and its RMS error is 2.26 LSB. In the reference design that error is dominated by truncation in the x and y shifts; guard bits fix that, and a wider angle path then fixes the atan-LUT quantisation that takes over (the repository's own analysis). Those are knobs the agent can turn. Drag the iterations down and the rotation stops short: the residual angle zz is left over and the error grows.

The families as datapaths

All four families compute exactly the same integer arithmetic, so they share one accuracy number per configuration. What differs is how the NN micro-rotations map onto clock cycles, and that is where the area, speed and latency trade-offs come from.

Loading the animation…

  • iterative: one micro-rotation per cycle on a single shared datapath, with barrel shifters (the shift changes every cycle) and a small state machine. Smallest, slowest: one result every N+3N+3 cycles. This is the reference RTL's cordic_rotation_iterative.
  • unrolled_k: kk micro-rotations chained in each cycle. Fewer cycles per result, but a longer critical path and more logic.
  • pipelined: one registered stage per micro-rotation; the shifts are constants, so they are just wiring. A new angle every cycle. This is the reference RTL's cordic_rotation_pipelined.
  • pipelined_m: a register only every mm stages. Still one result per cycle, with fewer flip-flops and a slower clock.

The counters come from a TypeScript port of the repository's analytical cost model, again identical to the Python on every field the tests compare. It counts the structure each family needs (adder bits, barrel-shifter multiplexers, ROM bits, registers, logic levels and carry chains on the critical path) and converts it to Artix-7 LUTs, flip-flops and Fmax with five constants solved from two real synthesis results. It reproduces both anchors: the iterative design at 170 LUTs and 198 MHz, the pipelined design at 745 LUTs and 273 MHz.

The Pareto front

For each feasible spec, the exhaustive ground truth gives the true Pareto front: the designs that no other design beats on every objective at once. This is the curve the agent and the baselines are trying to find, and the yardstick for the hypervolume scores on the home page.

Loading the chart…

The three specs have very different fronts. dds_250msps asks for at least 250 MSPS, so only fully pipelined designs make it, trading LUTs for accuracy. low_area_control needs just 1 MSPS, so the small iterative designs win. high_precision is the odd one: its front collapses to a single point (two designs with identical objectives), which is why one near-optimal design already scores more than 95% of the true hypervolume there.

Estimates against measurements

Milestone 2 synthesised what the model only estimated: 39 points through Yosys and nextpnr-xilinx (open-source synthesis, placement and routing on the anchors' Artix-7 part), and 14 generated designs through Vivado 2025.2, each after synthesis and after routing. Then it refitted the cost model to those measurements, tool by tool, and asked whether any spec's winner would change. Watch the M1 model's estimates meet the measurements, then move to the refit's predictions:

Loading the animation…

Three things stand out. The tools disagree with each other, so the refit keeps one correction factor per tool and metric rather than pretending they measure the same thing. Fmax is the hardest number: the open-source flow's routed timing is far below Vivado's synthesis estimate on the pipelined designs, and Vivado's own routed Fmax is 21%–32% below its post-synthesis figure on the short-path designs (pipelined, and pipelined_m with m=2). And on the Vivado points the refit does not predict unseen designs better than the untouched M1 model (leave-one-out 15.4% LUT and 12.5% Fmax RMS, against M1's 15.4% and 11.7%), so the default cost model stays M1's.

Design (generated RTL)LUT / FFFmax MHz: synthesis → routeM1 estimate and its errorYosys LUT / nextpnr Fmax
iterative W=16 N=14batch 1175 / 96172.8 → 158.1170 / 96 / 198.3-3% / 0% / +15%225 / 157.2
pipelined_m W=16 N=14 m=2batch 1716 / 415185.9 → 146.2745 / 421 / 171+4% / +1% / -8%719 / 120.5
pipelined_m W=16 N=14 m=4batch 1716 / 252103.8 → 99.37745 / 254 / 97.85+4% / +1% / -6%695 / 63.94
pipelined W=12 N=14batch 1533 / 557341.2 → 230.6581 / 607 / 281.9+9% / +9% / -17%537 / 191.3
pipelined W=16 N=14batch 1720 / 751327.4 → 232.2745 / 777 / 272.9+3% / +3% / -17%727 / 195.7
pipelined W=24 N=14batch 11,024 / 1,076303.2 → 204.81,074 / 1,116 / 256.5+5% / +4% / -15%1,094 / 163
unrolled_k W=16 N=14 k=2batch 1246 / 95113.8 → 114.7253 / 95 / 105.4+3% / 0% / -7%269 / 90.69
unrolled_k W=16 N=14 k=4batch 1561 / 9866.16 → 66.85352 / 94 / 72.14-37% / -4% / +9%498 / 52.46
iterative W=12 N=14batch 2131 / 76175.8 → 166.4124 / 76 / 203-5% / 0% / +15%—
iterative W=24 N=14batch 2251 / 136166.2 → 155.2262 / 136 / 189.5+4% / 0% / +14%—
pipelined_m W=26 N=22 m=6batch 21,784 / 39265.39 → 60.761,864 / 395 / 62.52+4% / +1% / -4%1,827 / 35.73
pipelined_m W=26 N=22 m=8batch 21,783 / 30749.88 → 46.461,864 / 310 / 48+5% / +1% / -4%—
unrolled_k W=16 N=14 k=3batch 2532 / 9769.76 → 73.69320 / 95 / 71.74-40% / -2% / +3%561 / 55.94
unrolled_k W=16 N=14 k=8batch 2681 / 9442.18 → 41.32616 / 93 / 36.86-10% / -1% / -13%659 / 31.2

measured Vivado 2025.2, xc7a35tcpg236-1, 10 ns clock, Fmax = 1000 / (10 − WNS): post-synthesis (the anchors' flow, and so the cost model's timing scale) and after opt_design; place_design; route_design. W=16 N=14 unless named. M1 estimate estimate: the default two-anchor calibration, recomputed with the Python reference; its error is estimate ÷ post-synthesis measurement − 1 (LUT / FF / Fmax). The Yosys/nextpnr column is the open-source L4 row for the same design (post-route, median of three seeds), where one exists.

Tool (points)LUT RMS error, M1 → refitFFFmax
vivado (14)15.4 → 13.1%3.1 → 3.1%11.7 → 10.3%
vivado post-route (14)reported, not fitted16.0 → 14.5%3.1 → 3.1%16.1 → 19.5%
yosys+nextpnr-xilinx (37)16.0 → 10.8%7.1 → 3.6%43.5 → 13.8%
leave-one-out, Vivado (14)13.1 → 15.4%3.1 → 3.7%10.3 → 12.5%

From the repository's L5 report for the vivado-2025.2 refit (51 fitted points; one correction factor per tool and metric, Vivado = 1). The leave-one-out row is in-sample → out-of-sample on the Vivado points. Under the refit, no spec's ground-truth winner changes, so the default cost model stays M1's and the eval was not re-run.

The winner that measurement had to settle

One decision in the eval sits closer to a constraint than any model's error: high_precision's winner, pipelined_m with a register every 6 stages, against its slightly smaller neighbour with one every 8. The M1 model and the refit both call m=8 too slow; a third, candidate refit called it fast enough. Only Vivado can settle it.

Loading the animation…

The verdict is now measured at the spec's own clock. The first Vivado runs used the anchors' 10 ns constraint; there m=6 routed at 60.76 MSPS (+21.5%) and m=8 at 46.46 (-7.1%), but m=8's post-synthesis figure, 49.88 MSPS, was only 0.24% short, well inside every model's error. After M4 both designs were synthesised and routed again at 20 ns measured: m=8 misses 50 MSPS in both views, 49.88 MSPS post-synthesis (WNS -0.050 ns; relaxing the clock did not move it) and 48.31 post-route (3.4% short), and m=6 meets in both (65.39 and 53.96). m=6 stays the winner.

Two things the measurement corrected. A refit with one timing factor per family class had cut the Vivado Fmax leave-one-out error from 12.5% to 7.4%, but it predicted m=8 at 52.51 MSPS, feasible; a rule written before the fit required it to get this boundary right, so it was rejected (the decision). And m=6's real margin is +7.9% after routing, not the +25.0% to +27.5% the models' unrouted figures suggest: it loses 17% of its clock to routing, which no model here sees.