Timing Closure and Fmax

Fmax — the maximum clock frequency at which a synchronous design is guaranteed to work — is set by exactly one thing: the single slowest combinational path between two flip-flops. Not the average path, not the design’s complexity, not the gate count. One path. Every other path in a design of ten thousand registers can have nanoseconds to spare, and the clock still stops where the worst one says it does. Timing closure is the iterative process of finding that path in the synthesis tool’s report, understanding why it is slow, restructuring the register-transfer-level (RTL) description so that it is not, and repeating until the report says the constraint is met. The report’s central number is slack — required time minus arrival time, positive means it fits, negative means it does not — and learning to read a timing report is the difference between guessing at your clock and knowing it. On the Tang Nano 20K’s Gowin GW2AR-18 part, a from-scratch RV32IMC core will land far below the ESP32-C3’s 160 MHz, and knowing precisely why is the interesting result — which is what makes this Stage 9’s hardware half in definitely-not-esp32 MOC.

No synthesis was run for this note uncertain

There is no synthesis tool on the machine where this note was written — no Gowin EDA, no Yosys, no nextpnr, no vendor FPGA toolchain of any kind. Therefore no real timing report was produced, and every number that describes “what this design achieves” would be an invention. Nothing of that kind appears here.

What is here, and what it rests on:

  • The report’s structure and every field name come from Gowin’s own documentation — Gowin Design Timing Constraints User Guide, SUG940-2.0.4E, dated 06/17/2026 — read for this note on 2026-09-04. Section 4 of that guide enumerates the report’s sections field by field, and this note follows it.
  • The open-flow report’s exact format comes from nextpnr’s source (common/kernel/timing_log.cc at master, read 2026-09-04), where the log_info format strings are the report.
  • The device delay numbers — LUT4 delay, clock-to-output, block-RAM access time, clock-tree delay — come from the GW2AR series Data Sheet, DS226-2.7E, dated 2026-07-31, Section 3.4 “Switching Characteristics”, for the exact speed grade on the Tang Nano 20K. These are vendor-published silicon characteristics, not measurements of any design.
  • Any arithmetic combining those numbers is labelled as arithmetic, and where it assumes a clock frequency, the assumption is stated.

To resolve: run the design through Gowin EDA (or Yosys → nextpnr-himbaechel → Apicula), capture the actual report, and replace the illustrative examples below with the real one — keeping the commit hash of the RTL and the tool version alongside it. Vendor FPGA documentation changes silently; every Gowin document cited here is pinned by revision and date for exactly that reason.

The concept companion is Field-Programmable Gate Array, which explains what the fabric is; Register-Transfer Level explains the abstraction you write in; Classic Five-Stage Pipeline explains the structure whose critical path you are about to measure. This note is about the number the tools give back.

Mental Model — One Path Sets the Clock

A synchronous digital design is a set of flip-flops separated by clouds of combinational logic. On every rising clock edge, each flip-flop captures whatever value is standing at its D input and holds it steady at Q for the whole cycle. In between edges, the logic between the flip-flops computes. The clock period must therefore be long enough for the slowest of those computations to finish and settle before the next edge arrives — and since one clock drives them all, the slowest computation sets the period for every register in the design.

That slowest register-to-register path is the critical path. Fmax is simply the reciprocal of the time it takes.

flowchart LR
    CLK(["clk"]) -. "launch edge" .-> FF1
    CLK -. "capture edge<br/>(one period later)" .-> FF2
    FF1["FF₁<br/>launch flop"] -->|"t_CO = 0.23 ns<br/>clock to Q"| L1["LUT4"]
    L1 -->|"route"| L2["LUT4"]
    L2 -->|"route"| L3["LUT4"]
    L3 -->|"route"| L4["LUT4<br/>(4 logic levels<br/>= 4 × 0.46 ns)"]
    L4 -->|"route"| FF2["FF₂<br/>capture flop<br/>needs D stable<br/>t_setup before the edge"]

One register-to-register path, with the delay terms named. What it shows: the total time from the launch edge to the data being stable at the capture flop is t_CO + the logic delays + every wire delay between them, and it must all fit inside one clock period minus the capture flop’s setup requirement. The insight to take: there are three independent contributors and only one of them is your logic. t_CO and t_setup are fixed properties of the silicon you cannot change; the LUT delays are set by how many levels of logic you wrote; the routing delays are set by where the placer happened to put things. The delays shown are the real published maxima for the GW2AR-18 at speed grade C8/I7 — t_CO_CFU = 0.23 ns and t_LUT4_CFU = 0.46 ns (DS226-2.7E Table 3-13) — and the routing arrows are deliberately unlabelled, because on a small FPGA they are usually the biggest term and the tool decides them, not you.

The arithmetic that follows from this picture, for a design that must run at frequency f with period T = 1/f:

Symbol by symbol: T is the clock period in nanoseconds; t_CO (clock-to-output, sometimes t_CQ) is the delay from the launch flop seeing its clock edge to its Q output actually changing; t_logic is the sum of the combinational cell delays along the path; t_route is the sum of the wire delays between those cells; t_setup is how long before the capture edge the data must already be stable at the capture flop’s D input; and t_skew is how much later the capture flop sees its clock edge than the launch flop does — positive skew in this direction buys you time, which is why it is subtracted.

Fmax is 1/T for the largest right-hand side in the design. If one path needs 20 ns and everything else needs 4 ns, Fmax is 50 MHz. Fixing 999 paths changes nothing; fixing the one changes everything. This is why timing closure is a search problem — the tool’s job is to tell you which path, and yours is to work out why.

A corollary that catches people: adding logic somewhere else in the design can lower your Fmax even though you did not touch the critical path. Not because the new logic is slow, but because it competes for placement and routing resources, so the placer puts the critical path’s cells further apart and its routing delay grows. On a nearly-full FPGA this effect dominates, and it is why “it met timing yesterday” is not an argument.

Setup, Hold, and Slack

A flip-flop is not an instantaneous device. It has a small window around the clock edge during which its D input must not change, or the flop may go metastable — settling to an unpredictable value after an unbounded delay. That window has two halves, and they fail in opposite directions.

Setup time (t_setup) is the interval before the capture edge during which D must already be stable. A setup violation means the data arrived too late. Setup is a speed problem: slow the clock down and it goes away. This is the constraint that determines Fmax.

Hold time (t_hold) is the interval after the capture edge during which D must remain stable. A hold violation means the data arrived too early — the new value from the launch flop raced through the logic and reached the capture flop before it had finished latching the old one. Hold is a race problem, and it is frequency-independent: slowing the clock does not fix it, because both edges move together. A hold violation is a broken chip, not a slow one.

That asymmetry is why the two are analysed at opposite silicon corners. Gowin’s static timing analysis (STA) says so explicitly: “Setup Delay Model: The delay model used for setup time analysis. By default, the Slow model in the device Speed grade is used” and “Hold Delay Model: … By default, the Fast model” (SUG940-2.0.4E §4.1.1). Setup is checked assuming everything is as slow as it can be (hot, low voltage, slow silicon); hold is checked assuming everything is as fast as it can be (cold, high voltage, fast silicon). Both must pass, at both corners, simultaneously.

Slack is the report’s headline number and the one you learn to read first. Gowin defines it precisely: “Path Slack: It is equal to the time of required by data minus the time of data arrival, and the timing is not satisfied when it is negative” (SUG940-2.0.4E §4.2.1). So:

Positive slack is margin you are not using. Negative slack is the amount by which you missed. Worst negative slack (WNS) is the single worst path; total negative slack (TNS) is the sum over all violating endpoints, which Gowin reports per clock in its “Total Negative Slack Summary” and describes as counting “only the worst paths … for common endpoints” (SUG940-2.0.4E §4.1.4).

WNS and TNS together tell you what kind of problem you have, and that diagnosis is the most useful thing the summary gives you:

ObservationWhat it meansWhat to do
WNS = −0.2 ns, TNS = −0.2 ns, 1 endpointOne path, barely missingLook at that path; a small restructure or a placement hint fixes it
WNS = −0.3 ns, TNS = −40 ns, 300 endpointsEverything is marginally lateSystemic: the clock target is simply too aggressive, or a global signal (reset, enable) has huge fanout
WNS = −8 ns, TNS = −8 ns, 1 endpointOne catastrophically slow pathAlmost always a combinational loop you did not intend, or a long unpipelined chain (a divider, a wide comparator, a big mux)
WNS positive, hold violations presentFast enough, but racingA clock-domain crossing without synchronizers, or logic between flops on different clock edges
“No timing paths to get frequency of *”The tool found nothing to analyseYour clock is not constrained, or the path you care about is asynchronous and being ignored

Reading the summary as a diagnosis. What it shows: the relationship between WNS and TNS distinguishes a single bad path from a systemically over-constrained design. The insight to take: the endpoint count is the fastest triage you have. One endpoint means go read one path report; three hundred endpoints means stop reading path reports and go look at your clock constraint or your reset fanout, because you will not fix three hundred paths one at a time.

The remaining terms that appear in a Gowin report and are worth defining once:

TermDefinition (per SUG940-2.0.4E)Why it matters
Data Arrival Time“The time of data arrival at a path”The left half of the slack subtraction
Data Required Time“The time required by data”The right half; derived from the clock constraint
Clock Skew“The time difference between the arrival of the launch clock … and the latch clock”Steals from setup in one direction, from hold in the other
Logic Level“Logic levels” — the count of combinational cells on the pathThe single most actionable number in the report
Relation“The time relationship between the launch clock and the latch clock”Tells you whether this is a same-edge, cross-edge, or multi-cycle path
Clock Uncertainty (tUnc)jitter and margin subtracted from the required timeWhy your effective budget is slightly less than T
Recovery / Removalsetup/hold equivalents for asynchronous reset releaseA reset released too close to a clock edge is the same failure
Minimum Pulse Width“the duration of active high/low level signals” the cell can recogniseCatches clocks that are technically fast enough but too narrow

The vocabulary of a timing report. What it shows: every field name the Gowin report will print, defined once. The insight to take: “Logic Level” is where to look first. It is the count of cells between the two flops, and it is the one number that maps directly back to a line of your Verilog. Slack tells you that you failed; logic level tells you what you wrote that caused it.

Reading the Report, Section by Section

The Gowin report

Gowin’s STA emits an HTML or text report with a navigation bar down the left, and “the title in the navigation bar will be highlighted in red when there are timing analysis violations” (SUG940-2.0.4E §4). Read it top to bottom in this order.

1. Timing Summaries → STA Tool Run Summary. Which delay models were used, how many paths and endpoints were analysed, and — the two numbers you actually want — “Numbers of Setup Violated Endpoints” and “Numbers of Hold Violated Endpoints”. Zero and zero means stop reading; you closed.

2. Timing Summaries → Clock Summary. Every clock the tool found, with period, frequency, source, and whether it is a base or generated clock. Check this even when everything passes, because of the trap documented in the same section: “If the clock in the design is not constrained, the software will automatically create a clock by default. The clock for Arora family is 100 MHz, and the clock for LittleBee family is 50 MHz” (SUG940-2.0.4E §4.1.2). The GW2AR-18 on a Tang Nano 20K is an Arora part, so a design with no SDC file is silently analysed against 100 MHz — a target it will almost certainly miss, producing a red report that says nothing about the 27 MHz you actually intend to run at. Conversely, if your real clock is faster than the default, a passing report means nothing.

3. Timing Summaries → Max Frequency Summary. The Fmax answer, per clock: “Constraint: Clock frequency of SDC or the default clock frequency when there is no SDC constraint”, “Actual Fmax: The max. actual frequency obtained by Gowin Software after PnR”, plus “Logic Level” and “Entity” — where “Entity” is the module, defaulting to TOP (SUG940-2.0.4E §4.1.3). Two caveats are printed right there: “The maximum clock frequency is reported only for synchronous clocks”, and Gowin “analyzes asynchronous clocks by default, but does not perform timing-driven analysis on them” — so an asynchronous path is not in your Fmax number at all.

4. Timing Summaries → Total Negative Slack Summary. WNS/TNS per clock, split by setup and hold, with the endpoint count. This is the triage table above.

5. Timing Details → Path Slacks Table. “The worst 25 paths are printed by default” (SUG940-2.0.4E §4.2.1), with From Node, To Node, From Clock, To Clock, Relation, Clock Skew, and Data Delay per path. If the path you care about is not in the top 25, the guide tells you the escape hatch: the SDC command report_timing.

6. Timing Details → Timing Report By Analysis Type. The detailed per-path report, generated by report_timing -setup (and -hold, plus recovery and removal variants). Each path is broken into four blocks:

  • Path Summary — Slack, Data Arrival Time, Data Required Time, From, To, Launch Clock, Latch Clock (each annotated R or F for the edge).
  • Data Arrival Path — the itemised walk, one row per node, with columns At (cumulative time), DELAY (this step), TYPE, RF, FANOUT, LOC (physical position, or UNPLACE), and NODE. The TYPE codes are the ones to learn: tC2Q clock-to-output, tINS component delay, tNET net delay, tCL clock source latency, tIn input delay (SUG940-2.0.4E §4.2.3).
  • Data Required Path — the clock’s journey to the capture flop, with tSu (setup), tUnc (clock uncertainty), tOut (output delay).
  • Path Statistics — Clock Skew, Setup Relationship, Logic Level, and the delay split, where “Cell indicates the logical delay time; Route indicates the route delay time, and tC2Q indicates the internal delay time.”

Path Statistics is where the answer usually is. Add up the tINS rows and the tNET rows separately — or just read the Cell/Route split the tool computes for you — and the ratio tells you whether you have a logic problem or a placement problem. They need completely different fixes, and guessing wrong wastes a day.

The open-flow report (nextpnr)

The Yosys → nextpnr-himbaechel → Apicula flow that the Tang Nano 20K note describes prints a different-looking but equivalent report to the terminal. Because nextpnr is open source, the report is its format strings, which is a pleasant thing to be able to say about a tool. From common/kernel/timing_log.cc at master (read 2026-09-04):

Info: Critical path report for clock '<name>' (posedge -> posedge):
Info:       type curr  total name
Info:    CLK_TO_Q  0.23  0.23 Source cpu.alu_result_reg_0.CLK
Info:     ROUTING  1.94  2.17 Net alu_out[0] (12,7) -> (18,11)
Info:                         Sink cpu.wb_mux.I0
Info:       LOGIC  0.46  2.63 Source cpu.wb_mux.F
       ...
Info: 3.10 ns logic, 14.20 ns routing
Info: Max frequency for clock 'clk': 57.14 MHz (PASS at 50.00 MHz)

Uncertain

Verify: the specific numbers in the block above. Reason: they are illustrative, not measured. The shape — the column headers type curr total name, the Source/Net/Sink rows, the segment type names (CLK_TO_Q, ROUTING, LOGIC, SETUP, HOLD, CLK_SKEW, CLK_TO_CLK), the summary line "%.2f ns logic, %.2f ns routing", and the Fmax line "Max frequency for clock '%s': %.02f MHz (%s at %.02f MHz)" — is transcribed from nextpnr’s log_info calls and is accurate. The instance names, delays, and coordinates are invented placeholders showing where real values go. To resolve: run the flow and paste the real output. uncertain

Three things the open flow gives you that are worth knowing about. First, it prints the logic/routing split as one line"%.2f ns logic, %.2f ns routing" — which is the single most diagnostic number in the whole report, handed over without asking. Second, it emits a machine-readable summary: --report writes JSON containing “post-pack utilization and maximum achieved frequency for each clock domain”, with fmax: { <domain>: { achieved, constraint } } (nextpnr docs/report.md) — which is what you script your regression against, so that a commit that costs you 5 MHz is caught the day it lands. Third, it prints a slack histogram, a text bar chart of endpoints binned by slack, which answers the WNS-versus-TNS question visually.

Does the open flow have real Gowin timing data? Yes, and this is worth confirming rather than assuming, because a place-and-route tool with no delay model will happily report a meaningless Fmax. nextpnr’s Gowin backend parses the speed grade straight out of the part number with the regex (.*)(C[0-9]/I[0-9])$ and calls ctx->set_speed_grade() with it (himbaechel/uarch/gowin/gowin.cc); the architecture generator then builds per-speed-grade cell timing from the Apicula database, adding combinational arcs for LUT4 (I0..I3F), the MUX2_LUT5/6/7/8 cascade, the ALU including its carry arc CINCOUT, and setup/hold plus clock-to-Q for every DFF variant (gowin_arch_gen.py). Wire delays come from a per-wire timing class table, asserted present for every wire (assert wire in db.wire_delay). So the numbers are grounded in a reverse-engineered but real model — not the vendor’s own sign-off model, which is a difference worth remembering before betting a production design on it.

What Actually Limits a Small RISC-V Core

The MOC’s phrasing for this stage is “find out what is really limiting your Fmax — it is rarely what you assumed.” The assumption is nearly always “the multiplier” or “the divider”, because those are the operations that feel expensive. On a five-stage RV32IMC core they usually are not, because they are either given their own multi-cycle state machine or mapped onto a hard DSP block that is faster than anything you could build from LUTs.

The paths that actually show up at the top of the report:

Candidate pathWhy it is longTypical fix
EX-stage ALU through the forwarding multiplexersThe operand does not come from the register file; it comes through a 3-or-4-way mux selected by hazard logic, then through the ALU, then to the result mux. The mux is in series with the ALU, so forwarding does not just add a mux — it adds a mux to the longest existing pathPipeline the ALU (TWO_CYCLE_ALU); or forward late and accept a bubble
Branch comparison → PC redirectCompare two 32-bit values, then select the next PC, then feed the fetch address — often the longest combinational chain in the whole core. PicoRV32 names this directly: TWO_CYCLE_COMPARE “relaxes the longest data path a bit by adding an additional FF stage at the cost of adding an additional clock cycle delay to the conditional branch instructions” (PicoRV32 README)Register the comparison; move the branch decision one stage later and flush more
Register file read → operand mux → ALUOn an FPGA the 32×32 register file is either distributed LUT RAM (fast but area-hungry) or a block RAM (dense but with an access time measured in nanoseconds, see below). Either way the read is at the head of the EX pathRegister the read output; or use a block RAM with its output register enabled and re-time the pipeline around it
The address decoderThe unglamorous one, and the one that surprises people. Every bus access compares the address against every peripheral’s range and selects a response — a wide comparator feeding a wide mux, on a path that also includes the memory’s own access time. It is pure “glue” nobody thinks of as logicRegister the decode; make ranges power-of-two aligned so the comparator is a few bits, not 32
A wide reset or enable fanoutNot a data path at all. A signal driving thousands of flops has enormous routing delay on its last few sinks. Gowin’s report has a dedicated High Fanout Nets Report for exactly this (SUG940-2.0.4E §4.2.5)Pipeline the reset into a small tree; or use synchronous reset only where needed
The barrel shifterA 32-bit barrel shifter is five levels of 2:1 mux. Real, but bounded and usually not the worstLeave it, or use the multi-stage shift PicoRV32 defaults to
The multiplier / dividerUsually not the problem — mapped to a DSP block (the GW2AR-18’s 18×18 multiplier is rated to 275 MHz at C8/I7, DS226-2.7E Table 3-16) or given its own multi-cycle unitNothing; verify it is inferring the DSP and move on

Where the critical path really lives in a small core. What it shows: the top four candidates all involve a multiplexer in series with something else, which is the structural pattern to look for. The insight to take: the reason the answer is “rarely what you assumed” is that people think in terms of operations (multiply is expensive) while the tool reports in terms of paths (this chain of cells is long). A cheap operation on the end of a long chain beats an expensive operation that has a pipeline stage to itself. When the report names a path, resist the urge to explain it — read the Logic Level count and the Cell/Route split first.

The corroborating evidence that this list is not folklore is that a mature open-source softcore ships parameters whose entire purpose is to break these specific paths. PicoRV32 offers TWO_CYCLE_ALU (“adds an additional FF stage in the ALU data path, improving timing at the cost of an additional clock cycle for all instructions that use the ALU”), TWO_CYCLE_COMPARE for the branch path, and TWO_STAGE_SHIFT/BARREL_SHIFTER for the shifter. Those three knobs are a published map of where a small RV32 core’s critical paths are.

The FPGA-Specific Story

On an application-specific integrated circuit, delay is dominated by gates. On a small FPGA it usually is not, and the numbers for the exact part on a Tang Nano 20K make the case concretely. All of these come from the GW2AR series Data Sheet DS226-2.7E (2026-07-31), Section 3.4, at speed grade C8/I7 — which is the grade marked on the Tang Nano 20K’s GW2AR-LV18QN88C8/I7. Note that C8 and I7 describe the same silicon: per DS226 §4.1, Gowin screens every device to industrial standards, and “if the chip meets speed grade 8 in commercial grade applications, its speed grade will be 7 in industrial grade applications” — the number changes only because the industrial temperature ceiling is 100 °C rather than 85 °C. On the same scale, 6 is the slowest grade and 9 the fastest.

ParameterSymbolValue (C8/I7)What it costs you
LUT4 delaytLUT4_CFU0.31–0.46 nsOne level of logic
Clock to register outputtCO_CFU0.20–0.23 nsFixed, once per path
Set/reset to register outputtSR_CFU1.10–1.15 ns2.5× a LUT — asynchronous resets are not free
Block RAM, clock → output from read addresstCOAD_BSRAM2.26 nsAlmost 5 LUT levels, for one memory read
Block RAM, clock → output from output registertCOOR_BSRAM0.31 ns7.3× faster — but costs a pipeline stage
Global clock tree delayTGCLKdly1.77 nsInsertion delay; matters for skew and I/O
High-speed clock tree delayTHCLKdly0.82 nsThe faster, more limited tree
Pin → LUT → Pin3.83 nsWhy pure combinational I/O paths are slow
DSP 18×18 multiplier275 MHz maxNot your bottleneck

The GW2AR-18’s published delays, at the Tang Nano 20K’s speed grade. What it shows: the whole silicon budget in one table, in the units the timing report uses. The insight to take: compare row 1 with row 4. A single block-RAM read without an output register costs the same as five levels of combinational logic. If your instruction memory is block RAM and its output feeds a decoder that feeds the register file that feeds the ALU, the memory has eaten a fifth of a 10 ns budget before your logic does anything. That is the “it is rarely what you assumed” result, and it is available from the datasheet before you synthesize anything.

Four consequences worth doing the arithmetic on

LUT depth is your logic budget, and it is small. At 0.46 ns per level, a 10 ns period (100 MHz) holds at most about 21 levels of pure logic — and that is before t_CO, setup, skew, and any routing. A 20 ns period (50 MHz) holds about 43. That sounds generous until you notice how quickly naive RTL builds depth: a 32-bit equality comparator built as a plain reduction tree of 4-input LUTs is log₄(32) ≈ 3 levels, fine; but a 32-bit magnitude comparator or an unaided ripple adder is not.

Carry chains are why arithmetic is not the disaster it should be. A 32-bit adder built from generic LUT4s as a ripple-carry chain would be roughly 32 levels deep — about 14.7 ns at 0.46 ns per level, nearly the whole budget at 50 MHz. FPGAs avoid this with a dedicated carry path through the logic cells that does not go through the general routing at all. That the GW2AR fabric has one is visible in nextpnr’s timing model, which registers an explicit combinational arc from CIN to COUT on the ALU cell type (gowin_arch_gen.py). The practical rule: write a + b, not your own adder, so the synthesizer infers the carry chain; and be aware that a carry chain is a physically contiguous column of cells, so a wide adder constrains the placer, which can push other logic further apart.

Uncertain

Verify: the per-bit delay of the GW2AR-18’s carry chain, and therefore the real depth cost of a 32-bit add. Reason: DS226-2.7E Table 3-13 publishes only tLUT4_CFU, tSR_CFU, and tCO_CFU for the CFU — there is no carry-chain (CINCOUT) parameter and no register setup time (tSU) in the datasheet at all. Both exist in the tools’ delay models but are not published. The 14.7 ns ripple-carry figure above is therefore an upper bound on a structure the fabric does not use, offered to show the scale of the problem the carry chain solves — not a prediction of a real adder’s delay. To resolve: read the CINCOUT arc value out of the Apicula timing database, or synthesize a 32-bit adder and read the path report. uncertain

Block-RAM output registers are a genuine architectural decision, not a checkbox. Using the output register turns a 2.26 ns access into a 0.31 ns one — a 1.95 ns saving, worth more than four LUT levels — at the cost of one extra cycle of read latency. For a five-stage pipeline that means the instruction fetched in IF is not available until the end of ID, which changes your hazard logic and your branch penalty. This is exactly the CPI-versus-clock trade that Cycles Per Instruction describes: you can buy megahertz with a cycle, and whether that is a win depends on how often the extra cycle is taken. The right way to settle it is to measure both, which is why Stage 9 pairs the Fmax work with the CPI work.

Routing usually dominates, and you can size the effect before you build anything. Suppose a core closes at 50 MHz — a 20 ns period. Suppose its critical path has six levels of logic and one clock-to-Q. The logic and cell delay is then 0.23 + 6 × 0.46 = 2.99 ns, about 15% of the period. The other ~85% is routing, clock skew, setup, and uncertainty. That ratio is why nextpnr prints "%.2f ns logic, %.2f ns routing" as its bottom line, and why the reflex “simplify the expression” so often changes nothing: you removed 0.46 ns from a 20 ns path. On a small, congested part, moving cells closer together beats making them do less.

nextpnr’s own placement cost function makes the geometry explicit. Its Gowin routing-delay estimate is

return delay_c + delay_m * (std::max(dist_x - 4, 0) + std::max(dist_y - 4, 0) +
                            2 * (std::min(dist_x, 4) + std::min(dist_y, 4)));

with delay_c = 300, delay_m = 60 for GW2A/GW2AR parts (gowin.cc, values in nextpnr’s internal delay units). Read the shape rather than the constants: there is a fixed cost for any connection at all (delay_c), the first four tiles in each direction cost double (short local routing is charged at 2 × delay_m per tile), and beyond four tiles the cost is linear (delay_m per tile) because longer, faster wires take over. The lesson encoded in that formula is that distance is the variable, and the placer is the thing that sets it.

Fixing It: Pipelining, Retiming, and Restructuring

Pipelining is the general fix. If a path is too long, cut it in half with a register. The two halves each take roughly half the time, so the clock can roughly double — at the cost of one extra cycle of latency for every operation that traverses the path, and one extra pipeline stage’s worth of hazard logic.

flowchart TB
    subgraph BEFORE["Before — one 8-level path"]
        direction LR
        A1["FF"] --> A2["L·L·L·L"] --> A3["L·L·L·L"] --> A4["FF"]
    end
    subgraph AFTER["After — two 4-level paths"]
        direction LR
        B1["FF"] --> B2["L·L·L·L"] --> B3["FF<br/>(new)"] --> B4["L·L·L·L"] --> B5["FF"]
    end
    BEFORE -->|"insert one register<br/>in the middle"| AFTER
    AFTER --> R1["path delay:<br/>8 levels → 4 levels<br/>Fmax roughly doubles"]
    AFTER --> R2["latency:<br/>1 cycle → 2 cycles<br/>CPI gets worse"]
    AFTER --> R3["new hazards:<br/>one more stage to<br/>forward from and flush"]

Pipelining a critical path. What it shows: splitting an eight-level combinational cloud with a register halves the longest register-to-register distance, so the clock period can halve. The insight to take: the three consequences on the right are the whole trade, and only the first is a win. Doubling Fmax while adding a cycle to every ALU operation is a net gain only if the extra cycle is not taken often — which is a CPI question, measured on a real workload, not a timing question. Stage 9 of definitely-not-esp32 MOC asks for both numbers with the workload named for exactly this reason: MHz and CPI move in opposite directions and only the product is performance.

Retiming is the automatic version. A retiming pass moves registers across combinational logic without changing the circuit’s input/output behaviour, balancing the delay between them — so instead of you finding the midpoint of the cloud by hand, the tool slides the register to wherever the delay is actually balanced. This is why PicoRV32’s documentation attaches the same footnote to both of its timing knobs: “Enabling this parameter will be most effective when retiming (aka ‘register balancing’) is enabled in the synthesis flow” (PicoRV32 README). You add a register anywhere in the path; retiming moves it to the right place.

Uncertain

Verify: whether GowinSynthesis performs general register retiming / balancing at all. Reason: I could not find it documented. SUG100-4.4.6E’s complete synthesis option list (§4.3, “General” and “Optimization Options”) offers Optimization Goal (Auto / Area / Timing), DSP Style, RAM Style, ROM Style, Max Fanout, Shift Registers Style, Map Options and Netlist Hierarchyno retiming or register-balancing option appears. The only occurrence of the word “Retiming” anywhere in SUG100 is SerDes Retiming under Place & Route, which is a different thing (“Move logic resources closer to the SerDes node”) and is “supported only by Arora V devices” — the GW2AR-18 is first-generation Arora, so it is not even available. SUG550-2.4E (GowinSynthesis User Guide) likewise yields no retiming or register-balancing hits. The working assumption for this project must therefore be that you pipeline by hand and the tool will not rebalance for you — which raises the value of getting the register position right in the RTL. To resolve: search Gowin’s release notes and support forum for a retiming attribute, and empirically test by synthesizing a deliberately unbalanced pipeline and reading the resulting Fmax. uncertain

Restructuring is what you do when neither is available or acceptable. Four techniques, in the order they are usually worth trying:

  1. Balance the tree, do not shorten the chain. a+b+c+d written left-associatively is three adders in series; written as (a+b)+(c+d) it is two levels. Synthesizers often do this for you, but not through a mux or across a module boundary.
  2. Precompute in the previous stage. If the EX stage compares rs1 == rs2 and the operands were already available in ID, do the compare in ID and pass one bit forward. This is the same move as TWO_CYCLE_COMPARE, done by hand.
  3. Cut fanout. A signal driving 500 loads has a routing problem, not a logic problem. Gowin exposes both a synthesis-side Max Fanout (default 1000, which is very permissive) and a route-side Route Maxfan (default 23 on the GW2AR family) (SUG100-4.4.6E §4.3); the report’s High Fanout Nets Report tells you which nets to worry about. Note that Gowin’s automatic Replicate Resources option — “Replicate resources with high fanout to reduce fanout and get better timing results” — is “Only Arora V and Arora III devices”, so on the GW2AR-18 you must duplicate registers manually.
  4. Change the memory style. RAM Style and ROM Style accept Auto, Block_ram, Distributed_ram / Distributed_rom, and Registers / Logic. A 32×32 register file inferred into a block RAM inherits the 2.26 ns access time; forced into distributed LUT RAM it is much faster and much larger. This one attribute can move a small core’s Fmax substantially, and it is a one-line change.

Turn the tool’s own effort up before rewriting anything. Gowin’s defaults are tuned for compile speed, not for Fmax, and the GW2AR family gets the conservative settings. Worth trying, in this order, because each is free: Optimization Goal = Timing; Map Options = 4 (“LUT5/LUT6-driven mapping mode that may increase LUT resource utilization to achieve the best timing performance”); Route Option = 1 (“the compilation speed is sacrificed to try to find a better route” — the GW2AR default is 0); and confirm Run Timing Driven = True (it is, by default) (SUG100-4.4.6E §4.3). Do this before you touch the RTL, and record which settings the reported Fmax was obtained with — a frequency quoted without its tool settings is not reproducible.

Hold violations are usually not your problem to fix. Gowin’s router has Correct Hold Violation“Automatic repair of timing Hold problems via routing” — enabled by default. The tool fixes hold by adding routing delay to the too-fast path, which it can do because it has slack to spare there. If hold violations survive that, you almost certainly have a clock-domain crossing without synchronizers, and no amount of routing will save it.

The Closure Loop

Timing closure is a loop, and the discipline is to change one thing per iteration so that the next report is attributable.

flowchart TD
    START(["RTL + an SDC constraint<br/>stating the real target clock"]) --> SYN["synthesize"]
    SYN --> PNR["place and route"]
    PNR --> REP{"read the report:<br/>WNS ≥ 0 ?"}
    REP -->|"yes"| DONE(["closed — record Fmax,<br/>tool version, and settings"])
    REP -->|"no"| TRIAGE{"how many<br/>violating endpoints?"}
    TRIAGE -->|"hundreds"| GLOBAL["systemic: is the constraint<br/>realistic? check high-fanout<br/>nets and the clock tree"]
    TRIAGE -->|"a handful"| PATH["open the worst path report"]
    PATH --> SPLIT{"Path Statistics:<br/>Cell vs Route ?"}
    SPLIT -->|"Cell dominates"| LOGIC["logic problem:<br/>reduce Logic Level —<br/>rebalance, precompute,<br/>or pipeline"]
    SPLIT -->|"Route dominates"| PLACE["placement problem:<br/>raise tool effort, cut fanout,<br/>group the module, reduce area"]
    GLOBAL --> ONE
    LOGIC --> ONE["change ONE thing"]
    PLACE --> ONE
    ONE --> SYN
    DONE --> BENCH["measure CPI on the same build<br/>— MHz alone is not performance"]

The timing-closure loop. What it shows: the report drives the diagnosis, and the Cell-versus-Route split at the bottom of Path Statistics is the fork that decides which half of the toolbox you reach for. The insight to take: the loop begins with “state the real target clock”, and that step is not optional — without an SDC, Gowin analyses an Arora part against a default 100 MHz and every conclusion you draw is about the wrong design. The loop also ends somewhere unexpected: at a CPI measurement, because closing timing at a higher clock by adding pipeline stages can make the design slower.

Two practices that make the loop converge instead of oscillate. Constrain the clock you actually want, plus a little. Constraining at exactly your target gives the tool no reason to try harder than barely passing, and leaves you with zero margin against the next commit; constraining wildly above it wastes runtime chasing an impossible number and buries the real path under noise. Ten to twenty percent above target is the usual habit. And record every closed result. The tool version, the option settings, the RTL commit, the reported Fmax, and the critical path’s endpoints — because “we used to run at 60 MHz” is not a fact you can act on six weeks later, and nextpnr’s --report JSON exists to make this a one-line addition to a Makefile.

Failure Modes and Common Misunderstandings

“It passed timing, so it works.” Static timing analysis verifies the synchronous paths it was told about. It says nothing about asynchronous inputs, clock-domain crossings you did not declare, or a reset that is released asynchronously — hence the separate recovery/removal and minimum-pulse-width analyses. Gowin states plainly that it “analyzes asynchronous clocks by default, but does not perform timing-driven analysis on them” (SUG940-2.0.4E §4.1.3).

“It works on the bench, so it meets timing.” A design with negative slack often works at room temperature on the one board you have. The slow corner exists because silicon is slower when hot and at the low end of its voltage tolerance. A design that fails STA and passes on the bench is a design that fails in July.

Reading Fmax without reading the constraint. The Max Frequency Summary prints Constraint next to Actual Fmax for a reason, and nextpnr prints (PASS at 50.00 MHz) in the same line as the achieved figure. An “Fmax” quoted without the clock it was constrained against is close to meaningless — and on an unconstrained Arora design, the constraint is a default the designer never chose.

Chasing the worst path forever. Fix the worst path and the second worst becomes the worst. If the top twenty paths all sit within a few hundred picoseconds of each other, you have hit the design’s natural frequency and the next megahertz costs an architectural change, not a tweak. The Path Slacks Table’s 25 rows exist partly so you can see this: a steep slack gradient means one fixable path; a flat one means you are done tweaking.

Confusing latency with throughput. Pipelining raises Fmax and raises latency simultaneously. For a CPU, the thing that matters is instructions per second — frequency ÷ CPI — and a change that improves the first while worsening the second must be evaluated on the product. This is the trade Cycles Per Instruction formalizes and the reason Stage 9 measures both.

Believing the open flow and the vendor flow will agree. They use different delay models — the vendor’s own characterized sign-off model versus the reverse-engineered Apicula database — different placers, and different routers. Expect different Fmax numbers for the same RTL. Neither is “wrong”, but only the vendor’s is a sign-off, and a number quoted from one flow should say which.

Ignoring the clock tree. TGCLKdly is 1.77 ns on this part — longer than three levels of logic. Insertion delay largely cancels between launch and capture flops on the same tree, which is why global clock resources exist; a clock routed on general fabric does not get that cancellation and will produce skew that eats your budget. If your design gates or muxes a clock in logic rather than using the dedicated clock primitives, this is where it shows up.

Honest Framing for This Project

The Tang Nano 20K will land far below the ESP32-C3, and the definitely-not-esp32 MOC is explicit that saying so precisely is the result. The gap has three separable components, and separating them is the whole exercise:

Process and implementation medium. The GW2AR-18 is a 55 nm FPGA. The ESP32-C3’s ESP-RISC-V core is hardened silicon running at up to 160 MHz. A LUT4 that takes 0.46 ns is doing the work of a handful of gates that would take a small fraction of that as dedicated logic, and every wire between LUTs goes through programmable switches rather than metal. This part of the gap is not an engineering failure and cannot be closed by better RTL — it is the price of the FPGA being programmable, and it is the single largest term.

Microarchitecture. The C3 uses a 4-stage pipeline; the project’s core is a five-stage design with forwarding, branch prediction, and no cache. Different stage counts, different hazard structures, and different memory latencies produce different CPI on the same code. This part of the gap is yours to move, and it is what the pipelining decisions above trade against.

Effort. A shipping part has had person-years of timing closure applied to it. A first from-scratch core has had none. The difference between the first synthesis result and the number after a week of reading path reports is routinely large — which is the optimistic reading of everything above.

Uncertain

Verify: what Fmax the definitely-not-esp32 core actually achieves on the GW2AR-18. Reason: no synthesis was run and no such number exists yet. Tang Nano 20K currently records that “practical clock targets for a small soft RISC-V core on this fabric are in the 50–75 MHz range without aggressive pipelining”, attributed to secondary launch coverage rather than a synthesis run — treat it as an expectation, not a measurement. To resolve: synthesize, read the Max Frequency Summary, and record it here with the tool version, the option settings, and the RTL commit. uncertain

The deliverable from this stage is not a number, it is a decomposition. “We run at X MHz; the critical path is this; it is Y% logic and Z% routing; the ESP32-C3 runs at 160 MHz and the difference is this much process, this much microarchitecture.” That is a genuinely interesting engineering result, and — unlike a raw clock comparison — it is one a from-scratch project can actually produce and defend. It also feeds directly into Stage 10’s benchmark discipline: a comparison table whose conditions are not written down is an anecdote, and “the clock we closed timing at, with these settings, on this tool version” is one of the conditions.

See Also