[cts] -useful_skew: add a test to document and enforce the intent at block boundaries
Maintainers usually reply within 1 day
@precisionmoon is already working on this.
Since Oct 31, 2026.
Assessment
This issue has not been assessed yet.
Description
I asked Claude to make a report on CTS useful skew on XiangShan and ask for the intended behavior to have a doc-as-test for pins.
Could a test be added that documents and enforces what clock_tree_synthesis -useful_skew (#11930) is meant to do for a register that also launches a path to an output port under set_max_delay? On a hardened block, every boundary register is such a register.
A test like that is gold for AI and humans alike: it states the intent clearly and formally, right next to the code, and CI keeps it up to date automatically. It goes well with the prose in the README.
I don't know if the repair is working as intended here or not, but the result for CTS today is that such a register gets no useful skew at all. On origin/master 18f08d7ead3eb2013ab840146a2b3e28d531ef32, save the script at the bottom as src/cts/test/useful_skew_port.tcl and run it there with your own build:
openroad -exit useful_skew_port.tcl
It is useful_skew.tcl's design unchanged, plus one output port po driven by r2_0 under set_max_delay 0.01, a budget no clock schedule can meet. Chain 1 is the same chain without the port:
[INFO RSZ-0603] compute_skew_budget: closure infeasible by skew alone; achievable WNS = -0.206.
[INFO CTS-0545] inserted 15 delay buffers for applying useful skew
r1->r2 slack, chain 0 (r2_0 drives po): -218 -> -190 ps
r1->r2 slack, chain 1 (no port): -218 -> -30 ps

Without the port (useful_skew.tcl as is), all eight chains reach about -35 ps. With the same port on r3_0 instead, a register the schedule does not need to delay, every chain still gets its skew.
As far as I can read the code, this follows from the formulation, which is why I'm asking about the intent rather than calling it a bug. Phase 1 maximises the worst slack epsilon over all edges, and phase 2 minimises the total shift while keeping every edge at epsilon or better:
A path to a port is an edge to the ground register, fixed at t = 0:
and CTS asks for a one-sided schedule, delays only:
So the r2_0 -> po edge gives epsilon <= slack_po - t(r2_0). It is the worst edge, so it sets epsilon, and phase 2 can then only keep t(r2_0) = 0. The clock-gating checks are frozen to don't-worsen for a related reason; port edges are not, and freezing them would not let r2_0 move either:
The two cases the test would settle: a violating boundary budget on a register that skew would delay (held at zero skew, or traded?), and the boundary constraint style to use for blocks built with -useful_skew.
Background on where this came from. XiangShan is moving along, so this is a snapshot from 2026-10-31; for the latest, see https://github.com/The-OpenROAD-Project/bazel-orfs/tree/main/test/coremark_joule/designs/asap7/xiangshan
We are taking XiangShan's tile, an open-source RISC-V core, through ORFS on asap7 with bazel-orfs: about 8.6 M standard cells in a parent and five hardened blocks. Turnaround time is QoR here. Setting up a design like this and iterating on its problems takes on the order of 100 builds, and one build is about 9 hours (blocks 3.8 h, then the parent 4.9 h), so in practice you watch one a day: 100 builds is 100 days. An AI loop needs turnaround in minutes to hours, and that is why the design is broken into macros; a flat repair_design on the core alone ran 6.5 hours and did not finish.
With macros, CTS is where it gets hard. Every block port carries a set_max_delay budget, and a one-sided schedule can only help a path that crosses a block boundary from its capture side. The block's schedule and the parent's are separate runs, so the budgets are the only way the two sides coordinate, and tuning them is a loop of rebuilds that takes a long time to converge at this turnaround.

Measured at global route, reg2reg period and the mass of endpoints over a 2,000 ps target, one run each so a few percent may be noise:
| no useful skew | -useful_skew in the parent | |
|---|---|---|
| period | 2,206 ps | 2,315 ps |
| endpoints over 2,000 ps | 1,377 | 1,266 |
| excess over 2,000 ps | 122,800 ps | 119,600 ps |
| useful-skew delay buffers | 0 | 60,567 |
| RSZ-0603 achievable WNS | -4,604 ps (worst reg2reg after global route: -1,842 ps) |
The worst path got 109 ps worse, and it ends at a block's input pin. Most of what remains over target is in the parent's own paths:

# What does clock_tree_synthesis -useful_skew do for a register that also
# launches a path to an output port under set_max_delay, as every boundary
# register of a hardened block does?
#
# useful_skew.tcl's design, unchanged: eight chains r1 -> (16 buffers) ->
# r2 -> r3, where delaying r2's clock helps the tight r1->r2 stage, and a
# macro so CTS builds the hierarchical tree the skew pass needs. Added: r2_0,
# and only r2_0, also drives output port po under set_max_delay 0.01, a
# budget no clock schedule can meet. Chain 1 is identical without the port.
#
# Save in src/cts/test and run there: openroad -exit useful_skew_port.tcl
source "helpers.tcl"
source Nangate45/Nangate45.vars
read_liberty $liberty_file
read_liberty array_tile_ins_delay.lib
read_lef Nangate45/Nangate45.lef
read_lef array_tile_ins_delay.lef
set num_chains 8
set chain_len 16
set clk_period 0.30
set row_pitch 6
set db [ord::get_db]
set tech [ord::get_db_tech]
set chip [odb::dbChip_create $db $tech]
set block [odb::dbBlock_create $chip "useful_skew_top"]
$block setDefUnits 2000
set dbu [$tech getDbUnitsPerMicron]
set dff [$db findMaster "DFF_X1"]
set buf [$db findMaster "BUF_X1"]
# Clock input port/net.
set clk [odb::dbNet_create $block "clk"]
$clk setSigType CLOCK
set clk_bterm [odb::dbBTerm_create $clk "clk"]
$clk_bterm setSigType CLOCK
$clk_bterm setIoType INPUT
set clk_bpin [odb::dbBPin_create $clk_bterm]
$clk_bpin setPlacementStatus PLACED
odb::dbBox_create $clk_bpin [$tech findLayer "metal5"] 0 0 2000 2000
proc place_inst { inst x_um y_um dbu } {
$inst setLocation [expr int($x_um * $dbu)] [expr int($y_um * $dbu)]
$inst setPlacementStatus PLACED
}
# Register pipelines with an asymmetric (tight r1->r2, loose r2->r3) slack.
for { set c 0 } { $c < $num_chains } { incr c } {
set yy [expr 2 + $c * $row_pitch]
set r1 [odb::dbInst_create $block $dff "r1_$c"]
set r2 [odb::dbInst_create $block $dff "r2_$c"]
set r3 [odb::dbInst_create $block $dff "r3_$c"]
place_inst $r1 10 $yy $dbu
place_inst $r2 185 $yy $dbu
place_inst $r3 192 $yy $dbu
foreach r [list $r1 $r2 $r3] {
[$r findITerm "CK"] connect $clk
}
# Tight stage: r1.Q -> chain of buffers -> r2.D
set prev [$r1 findITerm "Q"]
for { set i 0 } { $i < $chain_len } { incr i } {
set b [odb::dbInst_create $block $buf "u_${c}_$i"]
place_inst $b [expr 20 + $i * 10] $yy $dbu
set net [odb::dbNet_create $block "s1_${c}_$i"]
$prev connect $net
[$b findITerm "A"] connect $net
set prev [$b findITerm "Z"]
}
set net [odb::dbNet_create $block "s1l_$c"]
$prev connect $net
[$r2 findITerm "D"] connect $net
# Loose stage: r2.Q -> r3.D
set net [odb::dbNet_create $block "s2_$c"]
[$r2 findITerm "Q"] connect $net
[$r3 findITerm "D"] connect $net
}
# Macro sink (insertion delay) so CTS builds a hierarchical clock tree.
set tile [$db findMaster "array_tile"]
set macro [odb::dbInst_create $block $tile "macro0"]
place_inst $macro 40 [expr 6 + $num_chains * $row_pitch] $dbu
[$macro findITerm "clk"] connect $clk
# The only change to useful_skew.tcl's design: r2_0 also drives port po.
set po_net [odb::dbNet_create $block "po"]
set po_bterm [odb::dbBTerm_create $po_net "po"]
$po_bterm setIoType OUTPUT
set po_bpin [odb::dbBPin_create $po_bterm]
$po_bpin setPlacementStatus PLACED
odb::dbBox_create $po_bpin [$tech findLayer "metal5"] 398000 0 400000 2000
[[$block findInst "r2_0"] findITerm "Q"] connect $po_net
ord::design_created
set die_h [expr 4 + $num_chains * $row_pitch + 80]
initialize_floorplan \
-die_area [list 0 0 200 $die_h] \
-core_area [list 0 0 200 $die_h] \
-site $site
source $tracks_file
source Nangate45/Nangate45.rc
create_clock -name clk -period $clk_period clk
set_max_delay 0.01 -to [get_ports po]
set_wire_rc -signal -layer $wire_rc_layer
set_wire_rc -clock -layer $wire_rc_layer_clk
estimate_parasitics -placement
proc stage_slack { c } {
# the tight r1_c -> r2_c stage
return [get_property [find_timing_paths -path_delay max -to [get_pins r2_$c/D]] slack]
}
set before0 [stage_slack 0]
set before1 [stage_slack 1]
set_cts_config -root_buf CLKBUF_X3 \
-buf_list CLKBUF_X3 \
-sink_clustering_size 8 \
-sink_clustering_max_diameter 60
clock_tree_synthesis -sink_clustering_enable -useful_skew
set_propagated_clock [all_clocks]
estimate_parasitics -placement
# picoseconds; the library's time unit is ns
puts [format "r1->r2 slack, chain 0 (r2_0 drives po): %.0f -> %.0f ps" [expr { $before0 * 1e3 }] [expr { [stage_slack 0] * 1e3 }]]
puts [format "r1->r2 slack, chain 1 (no port): %.0f -> %.0f ps" [expr { $before1 * 1e3 }] [expr { [stage_slack 1] * 1e3 }]]
- Dominant language
- Verilog
- Stars
- 3.2k
- Forks
- 1.1k
- Avg merge
- 2d 9h
- Merged PRs (30d)
- 276
Getting set up
- Ships a Dockerfile or Docker Compose file
- Has a pull request template
- Read the contributing guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
More from The-OpenROAD-Project/OpenROAD
-
odb: Bazel build fails on linux/aarch64 — char fc compared to EOF in def/lef lexersPossibly taken @shivarammysore claimed this today. Openbazel bug odb
Difficulty 1/5 Under an hour Newbie friendliness 80/100
The-OpenROAD-Project/OpenROAD#12095 · 2 comments ·
Maintainers usually reply within 1 day
-
OpenRCX: extMeasure::calcRes0 segfaults on a null getRes() result when a rules file has sparse per-layer tablesPossibly taken @AcKoucher claimed this 1 day ago. Openbug rcx
Difficulty 2/5 1-3 hours Newbie friendliness 78/100
The-OpenROAD-Project/OpenROAD#12052 · 2 comments · 1 assignee ·
Maintainers usually reply within 1 day
-
syn: acd_resynth -apply segfaults on a database resynthesized in an earlier processPossibly taken @zzf2024 claimed this 3 days ago. Openbug syn
Difficulty 2/5 1-3 hours Newbie friendliness 77/100
The-OpenROAD-Project/OpenROAD#12046 · 2 comments ·
Maintainers usually reply within 1 day
-
check_placement: padding check flags a legal cell above a fixed block whose bottom is in the upper half of a rowPossibly taken @gudeh claimed this 1 day ago. Openbug dpl
Difficulty 2/5 1-3 hours Newbie friendliness 78/100
The-OpenROAD-Project/OpenROAD#12038 · 1 comment · 1 assignee ·
Maintainers usually reply within 1 day
-
dft: ScanStitch::Stitch dereferences std::deque::end() on chains of 3+ cellsPossibly taken @mmporong claimed this 9 days ago. Opendft
Difficulty 2/5 1-3 hours Newbie friendliness 75/100
The-OpenROAD-Project/OpenROAD#11985 · 1 comment ·
Maintainers usually reply within 1 day