INTERFACE SILICON
Insights · 2026-10-22

FPGA Timing Closure Basics for Teams New to FPGAs

How to read Vivado and Quartus timing reports, fix setup and hold violations, write honest clock and CDC constraints, and pipeline RTL until it closes.

Teams new to FPGAs usually hit the same wall: the bitstream builds, the board seems to work, and the timing report is red. Or worse, it is green because half the design was never constrained. Timing closure is not polishing. It is the proof that every path meets its requirement at every corner the device is specified for.

This article covers reading the report, constraining clocks, crossings and I/O honestly, the RTL changes that fix real paths, and a closure checklist. Examples use AMD Vivado (XDC), with Intel Quartus (SDC) equivalents noted.

Read the report before touching anything

In Vivado, start with report_timing_summary. In Quartus, open the Timing Analyzer and run the setup, hold and pulse-width summaries. The numbers that matter:

  • WNS (Worst Negative Slack): the slack of the single worst setup path. Negative means at least one path fails.
  • TNS (Total Negative Slack): the sum of negative slack over all failing endpoints. A WNS of -0.4 ns with a TNS of -0.4 ns is one path. The same WNS with a TNS of -900 ns is a design-wide problem.
  • WHS / THS (Worst and Total Hold Slack): the same idea for hold.
  • WPWS (Worst Pulse Width Slack): minimum period and pulse-width checks on clocks and clocked primitives such as BRAM and DSP blocks.

Then open the worst path and classify it. Compare the logic delay to the route delay, count the logic levels, and look at clock skew and uncertainty. A path with ten LUT levels has a logic problem. A path with two LUTs and 80% route delay has a placement or fanout problem. A path with large skew between unrelated-looking clocks often has a constraint problem. In Vivado, report_design_analysis -logic_level_distribution shows whether you are fixing one path or a whole architecture.

Before believing any of it, check what is not being checked. In Vivado, run check_timing and report_methodology; in Quartus, report_ucp lists unconstrained paths. Unconstrained clocks, inputs and outputs produce zero violations and zero meaning.

What setup and hold violations mean

A setup violation means data launched on one edge does not reach the capturing register early enough before the next capturing edge. The path is too slow for the clock. Fix the path (less logic, better placement, more pipelining) or, if the requirement is wrong, the constraint.

A hold violation means new data arrives too soon after a capturing edge and corrupts the value the register is trying to hold. It is independent of clock frequency, so slowing the clock does not fix it. Hold failures on a single clock are usually caused by large skew, often from clocks routed through fabric logic instead of dedicated clock buffers, or from a wrong multicycle constraint. The router fixes small hold violations by adding delay; a large negative WHS after routing points to a structural problem.

Constrain the clocks completely

Every clock entering the device needs create_clock. Clocks produced by an MMCM or PLL are derived automatically in Vivado. In Quartus Prime Standard, derive_pll_clocks creates them, and in Quartus Prime Pro the PLL IP supplies its own constraints. A clock you divide in fabric logic needs create_generated_clock, although a dedicated clocking resource or a clock enable is almost always a better design.

Clock-domain crossings then need an explicit decision, and the three common options are not interchangeable:

  • set_clock_groups -asynchronous removes timing analysis in both directions between whole clock groups. It is simple and broad: every path between those clocks is ignored, including the ones you got wrong.
  • set_false_path removes specific paths. Use it for paths that genuinely have no timing relationship, such as the asynchronous input of a reset synchronizer or a configuration register written once while the consumer is idle.
  • set_max_delay -datapath_only keeps a bound on the data path but ignores clock skew. This is the right constraint for gray-coded FIFO pointers and other multi-bit synchronized buses, usually one source-clock period or less, paired with set_bus_skew so the bits arrive close together.

In Vivado, clock groups take precedence over max-delay constraints, so if two clocks are declared asynchronous, a set_max_delay between them is overridden (report_exceptions -ignored shows it). Pick one approach per clock pair. Quartus has no -datapath_only option; its dual-clock FIFO IP ships constraints built on set_net_delay and set_max_skew, and custom crossings should follow that pattern. Constraints never fix a broken synchronizer; they only tell the tool what not to check. Vivado's report_cdc and report_clock_interaction show which crossings exist and how each is treated.

A starting XDC

# Primary clocks (differential inputs: constrain the _p port)
create_clock -name sys_clk -period 5.000 [get_ports sys_clk_p]
create_clock -name eth_rxc -period 8.000 [get_ports rgmii_rxc]

# Clock divided in fabric (prefer an MMCM output or a BUFGCE_DIV)
create_generated_clock -name clk_div2 -source [get_ports sys_clk_p] \
    -divide_by 2 [get_pins clk_div_reg/Q]

# Async FIFO pointers: bound the data path, ignore skew, limit bit-to-bit skew
set_max_delay -datapath_only 5.000 \
    -from [get_cells {u_rx_fifo/wptr_gray_reg[*]}] \
    -to   [get_cells {u_rx_fifo/wptr_sync1_reg[*]}]
set_bus_skew 4.000 \
    -from [get_cells {u_rx_fifo/wptr_gray_reg[*]}] \
    -to   [get_cells {u_rx_fifo/wptr_sync1_reg[*]}]

# Reset synchronizer: the asynchronous assertion has no timing relationship
set_false_path -to [get_pins {u_rst_sync/sync_reg[*]/PRE}]

# Two-cycle path behind a clock enable: move setup AND hold together
set_multicycle_path 2 -setup -from [get_cells {u_acc/acc_reg[*]}] -to [get_cells {u_acc/avg_reg[*]}]
set_multicycle_path 1 -hold -end -from [get_cells {u_acc/acc_reg[*]}] -to [get_cells {u_acc/avg_reg[*]}]

# System-synchronous I/O relative to sys_clk
set_input_delay  -clock sys_clk -max  2.500 [get_ports {adc_data[*]}]
set_input_delay  -clock sys_clk -min  0.500 [get_ports {adc_data[*]}]
set_output_delay -clock sys_clk -max  1.500 [get_ports {dac_data[*]}]
set_output_delay -clock sys_clk -min -0.500 [get_ports {dac_data[*]}]

Names must match your netlist; check them with get_cells in the synthesized design, because a pattern that matches nothing yields a warning and no constraint. Braces keep Tcl from treating [*] as a command. The multicycle pair matters: a setup multiplier of 2 without the hold adjustment moves the hold check a cycle later and creates new hold violations. Use a multicycle path only when the logic guarantees it, such as a clock enable that fires every other cycle.

I/O constraints basics

Input and output delays describe the outside world: the external device's clock-to-out or setup/hold requirements plus board delay. Without them, I/O paths are unanalyzed. -max drives the setup check and -min drives the hold check; you need both. Take the numbers from datasheets and the board, not from whatever makes the report pass. Registering signals in the I/O tiles (the IOB property in Vivado, fast input/output register assignments in Quartus) gives repeatable timing. Memory and transceiver IP generate their own constraints; do not override them.

Logic levels and pipelining

When the report shows deep logic, the fix is in the RTL. As a rough guide, paths much beyond five to eight LUT levels struggle at a few hundred MHz on modern mid-range devices; measure against your own device and speed grade.

Pipelining splits a path with registers and accepts latency in return; control signals must travel with the data. Arithmetic is the clearest case, because DSP blocks contain optional registers on their inputs, multiplier output and adder output. Code those registers explicitly, without a reset, and synthesis can usually absorb them into the DSP primitive (DSP48 in AMD devices, the variable-precision DSP block in Intel devices):

module mac_pipe #(parameter int W = 16) (
  input  logic                  clk,
  input  logic                  rst_n,
  input  logic                  in_valid,
  input  logic signed [W-1:0]   a,
  input  logic signed [W-1:0]   b,
  input  logic signed [2*W-1:0] c,
  output logic                  out_valid,
  output logic signed [2*W:0]   y
);
  logic signed [W-1:0]   a_q, b_q;
  logic signed [2*W-1:0] c_q, c_qq;
  logic signed [2*W-1:0] m_q;
  logic [1:0]            v_q;

  // Datapath: no reset, so synthesis can pack these into the DSP block
  always_ff @(posedge clk) begin
    a_q  <= a;            // stage 1: register the inputs
    b_q  <= b;
    c_q  <= c;
    m_q  <= a_q * b_q;    // stage 2: register the product
    c_qq <= c_q;          //          keep c aligned with the product
    y    <= m_q + c_qq;   // stage 3: register the sum
  end

  // Control: valid travels with the data, 3 cycles of latency
  always_ff @(posedge clk or negedge rst_n) begin
    if (!rst_n) {v_q, out_valid} <= '0;
    else        {v_q, out_valid} <= {in_valid, v_q};
  end
endmodule

The written-in-one-line version, y <= a * b + c;, computes the same result with one cycle of latency, and on most devices it will not close at the DSP block's rated speed. The same applies to block RAM: enabling the output register adds a cycle of read latency and removes most of the clock-to-out delay from the next path.

Two interface traps recur. Wide arbiters and multiplexers grow deeper with every input, so a 32-way arbiter often needs a registered grant or a two-level tree. And a combinational READY rippling back through many valid/ready stages becomes one long path; break it with skid buffers, as described in Valid/Ready Backpressure: Deadlocks, Bubbles, and Fixes.

Fanout and high-fanout nets

A register driving thousands of loads, such as a reset, clock enable or stall signal, forces long routes however shallow the logic is. Vivado's report_high_fanout_nets lists them. Fixes, roughly in order:

  1. Remove the need. Datapath registers rarely need a reset; dropping it shrinks the reset tree and allows DSP and BRAM register packing.
  2. Replicate the driver with Vivado's MAX_FANOUT attribute, the Quartus Maximum Fan-Out assignment, or hand-duplicated registers protected from merging.
  3. Pipeline the distribution toward distant loads and account for the extra cycles.
  4. Use global routing for very high-fanout signals that tolerate the insertion delay.

Retiming caveats

Both vendors can move registers across logic: Vivado through synth_design -retiming or phys_opt_design -retime, Quartus through register retiming and, on Hyperflex devices, Hyper-Retiming. It has limits. Registers with asynchronous resets, initial values, DONT_TOUCH/KEEP or debug attributes are often blocked from moving. Retiming cannot create registers that do not exist, so the RTL must still provide enough pipeline stages. It also renames registers, which breaks debug probes and constraints that name them. Use retiming to balance stages you wrote, not to replace them.

When RTL is not the problem

If failing paths are mostly route delay, look for physical causes: logic spread across SLRs, congestion around a large IP, or I/O pinned far from its logic. Pblocks (Vivado) or Logic Lock regions (Quartus), registered SLR crossings, and alternative implementation strategies can recover slack. If a design needs heavy floorplanning to close, ask whether the target is realistic for that device, speed grade and architecture. Knowing when to stop is part of closure.

Why "it works on the bench" is not closure

A bench board is one device, at one temperature and voltage, with one placement. A failing path may work there because that silicon beats the worst case the timing model covers. Another lot, a hot enclosure, a low supply rail, or a rebuild with new placement can turn a near-miss into intermittent corruption that is very hard to reproduce. Unconstrained CDC paths are worse: they fail randomly regardless of slack. Closure means zero setup, hold and pulse-width violations on a fully constrained design, using the speed grade and temperature range you will ship.

Timing closure checklist

  • [ ] check_timing / report_ucp show no unconstrained clocks, inputs, outputs or endpoints.
  • [ ] Every primary clock has create_clock; fabric-divided clocks have create_generated_clock or are replaced by clocking resources.
  • [ ] Every clock-domain crossing has a reviewed synchronizer and an intentional constraint: clock groups, false path, or set_max_delay -datapath_only with set_bus_skew.
  • [ ] Every multicycle path is guaranteed by the logic and has matching setup and hold constraints.
  • [ ] I/O delays come from datasheets and board data, with both -max and -min.
  • [ ] WNS, WHS and WPWS are non-negative and TNS and THS are zero, on the shipping speed grade.
  • [ ] Logic-level distribution is reviewed; deep paths are pipelined with control signals aligned.
  • [ ] DSP and BRAM internal registers are used on fast paths; datapath registers have no unnecessary reset.
  • [ ] High-fanout nets are replicated, pipelined or removed.
  • [ ] Results hold across rebuilds, not just one lucky placement.

Where this comes from

My background is RTL design, integration and CPU work at Intel, iPhone camera hardware at Samsung, and an FPGA AI accelerator built from the ground up. On each, timing worked best as a design input from the first RTL commit, not a cleanup task in the final week. More on how I approach FPGA work is on the FPGA development page.

Stuck at negative slack before a demo? Describe the design, the device and the worst path through the contact page, and I'll reply with how I would approach it.

Working on something similar?

Tell us what you’re building.

Share the interface or block involved and what you’re seeing.

Start a project brief ↗
Technology photographs and conceptual illustrations; not client product photographs. Image credits