Multicycle paths

Multicycle{K,T}: slow logic whose inputs are slower still

Sometimes the logic is slow but its inputs are slower. A correlation over a long shift register that only shifts once per symbol has no need of pipeline stages, only of time: if the register holds still for sixteen clocks, the logic may take sixteen clocks to settle. Telling the vendor tools so is a multicycle path constraint, and it is the kind of constraint that is easy to write wrongly, because it names registers by hand and nothing checks it.

A Multicycle{K,T} is a wire allowed K cycles to settle, on the condition that the registers it reads hold still that long. The compiler writes the constraint, the settle counter and the simulator’s check from the same traced logic.

Declaring one

@quartz struct Corr
  @in  x::Bool
  @in  sample::Bool
  @out mfo::Bits{6}
  seq::Bits{31} = 0x2f5a2c07
  bb::Bits{31} = 0
  xcorr::Multicycle{8,Bits{6}}
end

@wire Corr xcorr  Bits{6}(count_ones(bb  seq))

@on Corr posedge(clk) begin
  if sample                        # once per symbol, eight clocks or more apart
    mfo  xcorr
    bb  bb << 1 | x
  end
end

A multicycle wire is driven by a @wire block and read bare. Its sources must be registers of this module — an input is copied into a register first — and the compiler reads them off the wire’s logic, together with the registers the value reaches.

From that one reading it produces three things:

  • the timing exception in the constraint file, naming every source and every sink;
  • a settle counter in the Verilog, restarted whenever a source is written, that isready(xcorr) reads;
  • the same counter in the simulator, which raises an error if the design reads the wire before its time.

Reading one

isready(m) says the condition has held since the sources last moved: it is true from the K-th edge after a source was written, which is when a register at the end of the path may safely capture. A design that reads by its own timing, as the correlator above does with sample, needs no isready — the simulator checks the timing for it, and complains if sample ever comes too soon:

let m = Corr()
  for i in 1:40
    m = step(m; x=isodd(i), sample=i % 8 == 0)
  end
  Int(m.mfo)
end
15
let m = Corr()
  for i in 1:6
    m = step(m; x=true, sample=true)   # every cycle: far too soon
  end
end
xcorr is read 1 cycle after its sources moved, and its path is declared 8 cycles; guard the read with isready(xcorr)
Stacktrace:
  [1] error(s::String)
    @ Base ./error.jl:44
  [2] getindex
    @ ~/Projects/QuartzHDL.jl/src/core/reg.jl:497 [inlined]
  [3] macro expansion
    @ ~/Projects/QuartzHDL.jl/qdocs/multicycle.qmd:36 [inlined]
  [4] var"#on#Corr#2"(this::Corr, kw#281::@NamedTuple{x::Bool, sample::Bool})
    @ Main.Notebook ~/Projects/QuartzHDL.jl/src/core/blocks.jl:343
  [5] macro expansion
    @ ~/Projects/QuartzHDL.jl/src/core/blocks.jl:1142 [inlined]
  [6] _runedge(::Corr, ::@NamedTuple{x::Bool, sample::Bool}, ::Vector{Symbol}, ::Val{:clk}, ::Val{:posedge}, ::Val{(:comb, Symbol(""), Symbol(""), (:xcorr,), (), ())}, ::Val{(:on, :clk, :posedge, (:mfo, :bb), (:sample, :x), ())})
    @ QuartzHDL ~/Projects/QuartzHDL.jl/src/core/blocks.jl:1136
  [7] macro expansion
    @ ~/Projects/QuartzHDL.jl/src/core/blocks.jl:1134 [inlined]
  [8] _runclock
    @ ~/Projects/QuartzHDL.jl/src/core/blocks.jl:1134 [inlined]
  [9] _runclock
    @ ~/Projects/QuartzHDL.jl/src/core/blocks.jl:1131 [inlined]
 [10] _stepinner(m::Corr, clk::Val{:clk}, kw::@NamedTuple{x::Bool, sample::Bool}, ctx::QuartzHDL.PadContext{@NamedTuple{x::Bool, sample::Bool}, @NamedTuple{}})
    @ QuartzHDL ~/Projects/QuartzHDL.jl/src/core/blocks.jl:1074
 [11] _stepwith
    @ ~/Projects/QuartzHDL.jl/src/core/blocks.jl:1067 [inlined]
 [12] #step#47
    @ ~/Projects/QuartzHDL.jl/src/core/blocks.jl:204 [inlined]
 [13] step
    @ ~/Projects/QuartzHDL.jl/src/core/blocks.jl:201 [inlined]
 [14] top-level scope
    @ ~/Projects/QuartzHDL.jl/qdocs/multicycle.qmd:67

Constraints by hand

A multicycle path between two registers, with no Multicycle wire on it — a slow transfer from one to the other, say — can still be declared for the constraint file, once per module:

@multicycle Sys 15 a => b

This writes the exception and nothing else: no counter and no check, since there is no wire to derive them from. Prefer a Multicycle wire where one fits.

WarningNo retiming

The constraints name the design’s own registers, and the settle counter counts from them, so they assume synthesis adds no registers of its own along the path. A tool that retimes or pipelines logic across a multicycle path both moves the endpoints out from under the constraint and adds latency the counter does not know about. The strategy Diamond(board) writes turns Synplify retiming off (PROP_SYN_EdfRunRetiming = None); keep it off in any strategy of your own for a design with a multicycle path.

TipIf you know Verilog

The Verilog for a Multicycle is an assign for the wire, a small counter, and a ready bit. The constraint file gets MULTICYCLE lines from the same source, so the two cannot disagree about which registers the path runs between — which is the usual way hand-written multicycle constraints go wrong.

Pipeline or multicycle?

  • The inputs change every cycle and you need a result every cycle: a Pipeline. It costs registers and adds latency, and it is always safe.
  • The inputs hold still for K cycles at a time: a Multicycle. It costs a counter, and the condition is checked in simulation and stated to the tools.

Next

Submodules: modules inside modules.