Skip to content

dbSta: store the timing constraints in the .odb (write_db -sdc) - #11260

Open
oharboe wants to merge 10 commits into
The-OpenROAD-Project:masterfrom
oharboe:sdc-in-odb
Open

dbSta: store the timing constraints in the .odb (write_db -sdc)#11260
oharboe wants to merge 10 commits into
The-OpenROAD-Project:masterfrom
oharboe:sdc-in-odb

Conversation

@oharboe

@oharboe oharboe commented Aug 28, 2026

Copy link
Copy Markdown
Collaborator

Store the timing constraints in the .odb

write_db -sdc stores the design's timing constraints in the block. read_db
restores whatever the block carries, as soon as the design it reads is linked
against a liberty; an .odb without constraints, or a read without a liberty,
restores nothing and says nothing. ord::sdc_in_db_kind reports what the
loaded block carries, native, text or none, so a flow can decide whether
to read an .sdc instead. The opt-in is on the producer; readers need remember
nothing, and no existing command changes its result.

Why

.odb already subsumes the netlist, the tech, placement, routing, PDN, scan,
power domains and dont_touch. Timing constraints are the conspicuous
omission, so every flow carries a matching .sdc next to every .odb and has
to work out which goes with which. In ORFS that is a glob of results/, a
dictionary sort and "the greatest .sdc at or below this .odb's stem". With
the constraints in the database the artifact is self-describing: any build
system, and the C++ and Python APIs, get a constrained design from one file.

read_sdc and write_sdc are untouched. They are the human interface,
forgiving on the way in and canonical on the way out, and a flow still reads
the human-written .sdc once. What changes is that constraints no longer have
to travel between tools and stages as text.

How

The constraints are stored as a native, id-based record in a block string
property (sta.sdc.native): pins as dbBTerm/dbITerm ids, instances and nets
as ids, clocks by name, floats as hex floats, every set in a canonical order.
Restoring it is a linear pass that calls the Sta constraint makers directly:
no Tcl, no name lookup, no pattern match. The record covers clocks,
propagated clocks, latency, insertion, uncertainty, input/output delays,
false/min/max/multicycle path exceptions and group_path with from/through/to
sets, case analysis, clock groups and the design-level fanout/transition/
capacitance limits. An Sdc with a construct outside that set (generated
clocks, derating, environment, ...) is stored as write_sdc text (sta.sdc)
and replayed through Tcl, so whatever write_sdc can say the .odb can carry.

Both payloads are opaque to odb: no odb -> sta dependency, and an OpenROAD that
predates this reads the file unchanged and ignores the properties.

The record is guarded against going stale. Its header carries a digest over
the full names of every object it references; read_db recomputes it from
the block before applying anything and fails with STA-3012 if the design no
longer matches, rather than restoring onto the wrong pins. write_db without
-sdc on a linked design drops any stored record (the caller chose not to
store, and edits may have invalidated it); an odb-only session, with no
liberty, carries the record through untouched. The digest costs about 40 ms
on write_db -sdc and 10 ms on restore for a 50k-register design.

Guarantees (src/dbSta/test/sdc_in_db1..11, intent-based per #11281)

  • write_sdc after read_db is byte-identical to write_sdc before
    write_db -sdc, on asap7-style constraints (three set_max_delay and four
    group_path over all_inputs/all_registers, false path, multicycle,
    clock groups, latency, uncertainty, propagated clock, set_max_fanout,
    case analysis) and on a derated design that takes the text fallback.
  • Two write_db -sdc of the same design, and a write_db -sdc after a
    restore in a fresh process, produce byte-identical .odb files. Restore
    then write is a fixpoint.
  • An .odb without constraints restores nothing, silently; sdc_in_db_kind
    says none. Restore runs only after a complete read_db.
  • write_db without -sdc on a linked design drops the record; an odb-only
    round trip (no liberty) preserves it; a record whose design was edited
    underneath it is rejected with STA-3012.
  • The tests assert these properties through the odb and STA APIs and fail on
    the spot; the .ok files hold two verdict lines each.

Forwards compatibility

Nothing existing changes behaviour, and a flow adopts this by choosing to:

  • write_db without -sdc and read_db on an .odb without constraints
    are what they were. A flow that does not opt in produces the same files and
    runs the same way as today.
  • An OpenROAD that predates this reads an .odb written with -sdc
    unchanged: the constraints are string properties it ignores.
  • read_db restores only when the design is linked with a liberty, so an
    odb-only read (a GUI opened without timing, a DEF or LEF utility) never pays
    for constraints it cannot use, with no flag to remember.
  • A flow can probe for the capability (ord::sdc_in_db_kind exists) and run
    unchanged on an older binary. The ORFS shape is a variable that adds -sdc
    to every write_db, with load_design reading the .sdc alongside only
    when sdc_in_db_kind reports none; once this OpenROAD is the floor, the
    fallback, the intermediate .sdc files and their Makefile plumbing retire.

Numbers

Measured in bazel-orfs (The-OpenROAD-Project/bazel-orfs#947, a throwaway
harness), same machine, OpenROAD master 5d6f167 with and without this change,
identical synthesis outputs; the restore path measured is the one read_db
runs, under an earlier read_db -sdc spelling. The synthetic designs are N flops of pipeline behind a 64-bit bus
with the stock ORFS asap7 platform constraints, whose -from [all_inputs] -to [all_registers] set_max_delay and group_path commands make write_sdc
name every register clock pin seven times. nangate45/bp_be is the ORFS CI
design with the most ports, as the real-design control.

Load time (read_db + read_sdc before, read_db with restore after)

design before after speedup
50k flops 4.72 s 0.51 s 9.2x
200k flops 21.50 s 2.34 s 9.2x
800k flops 87.53 s 10.37 s 8.4x
nangate45 bp_be 0.27 s 0.14 s 1.9x

Peak memory (OpenSTA memory_usage at process end: before = read_db, read_sdc, write_sdc, write_db; after = read_db with restore, write_sdc)

design before after
50k flops 0.48 GB 0.33 GB
200k flops 1.43 GB 0.81 GB
800k flops 5.20 GB 2.69 GB
nangate45 bp_be 0.22 GB 0.16 GB

Disk per flow (ORFS writes 20 .odb files per run; today also 9 .sdc files, after only the 2 exports; synth-stage sizes stand in for every stage)

design before after net
50k flops 1169.7 MB 1097.7 MB -72 MB (-6.2%)
200k flops 4652.2 MB 4379.2 MB -273 MB (-5.9%)
800k flops 18648.6 MB 17623.9 MB -1025 MB (-5.5%)
nangate45 bp_be 601.2 MB 608.8 MB +8 MB (+1.3%)

write_db time (the record is encoded on every write)

design before after
50k flops 0.05 s 0.18 s
200k flops 0.19 s 0.70 s
800k flops 0.74 s 2.87 s
nangate45 bp_be 0.03 s 0.10 s

Whether the native record can carry an Sdc is decided by inspecting the Sdc
through its public accessors (mirroring WriteSdc category by category), so
write_db writes and scans no text. The .odb grows by the native record, a
fraction of the .sdc it replaces. The one construct not observable through
the public Sdc API is set_clock_sense -positive|-negative (only
-stop_propagation is queryable); it is called out in the code, and a small
accessor in OpenSTA would close it.

Reference: sizes (synth stage)

design registers ports 1_synth.sdc .odb before .odb after
50k flops 49984 130 15.5 MB 51.5 MB 53.3 MB
200k flops 200000 130 63.0 MB 204.3 MB 212.7 MB
800k flops 800000 130 254.2 MB 818.0 MB 855.8 MB
nangate45 bp_be 8580 3029 0.3 MB 29.9 MB 30.4 MB

Reference: step times

design read_db read_sdc read_db with restore write_sdc write_db before write_db after
50k flops 0.23 s 4.49 s 0.51 s 0.73 s 0.05 s 0.18 s
200k flops 0.94 s 20.56 s 2.34 s 3.33 s 0.19 s 0.70 s
800k flops 3.94 s 83.59 s 10.37 s 14.92 s 0.74 s 2.87 s
nangate45 bp_be 0.13 s 0.14 s 0.14 s 0.01 s 0.03 s 0.10 s

Consumer

The-OpenROAD-Project/bazel-orfs#947 is the measurement harness and carries an
earlier spelling of this change together with the ORFS adoption in two steps
(first load_design restores from the .odb when it can and reads the .sdc
otherwise; then the intermediate .sdc files, their Makefile plumbing and
find_sdc_file go). The ORFS patch itself will follow this PR.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request enables storing and restoring timing constraints (SDC) directly within the OpenDB (.odb) database file, making it self-describing and removing the need for a separate .sdc file. Feedback on the changes suggests using the secure utl::ScopedTemporaryFile utility instead of a custom temporary file class to prevent insecure temporary file creation, and ensuring that SDC constraints are only restored if the database is successfully loaded without throwing an exception.

Comment thread src/dbSta/src/dbSta.cc
Comment thread src/dbSta/src/dbSta.cc Outdated
Comment thread src/OpenRoad.cc Outdated
@oharboe

oharboe commented Aug 28, 2026

Copy link
Copy Markdown
Collaborator Author

out of sight, out of mind for now

.odb already subsumes the netlist, tech, placement, routing and even
dont_touch. Timing constraints are the conspicuous omission, so a flow
has to carry a matching .sdc next to every .odb and then work out which
.sdc goes with which .odb. That inference is a guess, and the only new
freedom it buys anyone is the freedom to load the wrong constraints.

write_db now stores the constraints in the block, and read_db -sdc
replays them, so an .odb can describe itself.

The payload is a dbStringProperty, opaque to odb, so no odb -> sta
dependency is created and an older binary still reads the file. Replay
is opt-in, so read_db behaves exactly as before unless asked otherwise.
write_sdc is untouched and remains the way to export constraints for
inspection or for a standalone OpenSTA bug report.

Verified on the largest asap7 designs: write_db, then read_db -sdc,
then write_sdc reproduces the original constraints byte for byte
(swerv_wrapper, 89615 lines; mock-cpu, 94253 lines).

Signed-off-by: Øyvind Harboe <oyvind.harboe@zylin.com>
write_db stores the timing constraints as a native record of the Sdc
instead of write_sdc text, and read_db -sdc restores it without going
through Tcl: every pin, instance and net is an odb object id, so
restoring is a table lookup per object and the Sta constraint makers
are called directly.

Why: the text form costs exactly what read_sdc costs. A generated .sdc
names every register clock pin once per constraint over
[all_registers], and every [get_ports {x}] walks all ports of the
design, so a flow pays a quadratic-in-ports, linear-in-registers name
resolution at every stage boundary. The native form pays none of it,
and keeps full float precision where the text rounds to four digits.

The Sdc has no single accessor for everything it holds, but write_sdc
does enumerate all of it; its output is therefore the completeness
oracle: if every command in the text is one the native encoder covers,
the native form is stored, otherwise the text is stored and replayed
as before. Nothing is ever dropped: whatever write_sdc can say, the
.odb carries. Left to the text fallback for now: generated clocks,
clock uncertainty and propagation in pin form (not enumerable from the
Sdc), derating, disables, the drive/load environment, design rules on
ports and cells, and commands carrying a -comment.

Both payloads are dbStringProperty values on the block. That type has
been serialized by name and type tag since 2021
(odb/src/db/dbProperty.cpp), so an OpenROAD that predates this reads
the new .odb unchanged and ignores the properties.

ord::sdc_in_db_kind reports which form the current block carries:
native, text or none. Tests: sdc_in_db1 shows the native record;
sdc_in_db4 proves byte-identical write_sdc after a cross-process
restore of asap7-style constraints (set_max_delay and group_path over
all_inputs/all_registers, false path, multicycle, clock groups,
latency, uncertainty, propagated clock, design fanout and transition
limits); sdc_in_db5 does the same through the text fallback.

Signed-off-by: Øyvind Harboe <oyvind.harboe@zylin.com>
read_db -sdc returns 1 when constraints were found and restored and 0
when the .odb carries none, and write_db stores nothing for a linked
design that has no constraints. A flow can therefore call read_db -sdc
first and fall back to read_sdc only when it has to, which is the
contract ORFS is written against, and an .odb written before this
change, or by a flow that never read an .sdc, is not mistaken for a
constrained one.

Without -sdc, read_db is unchanged. sdc_in_db6 covers the unconstrained
case; the man page documents the return value and ord::sdc_in_db_kind.

Signed-off-by: Øyvind Harboe <oyvind.harboe@zylin.com>
… the .sdc scratch file

Two review points on the stored-constraints change.

read_db -sdc now restores inside the same try block that reads the
database, so a read that fails (ORD-54) never reaches the restore; the
logger error is noreturn, but the structure now says so on its own.
sdc_in_db7 reads an empty file with -sdc and shows the error and no
restore.

The scratch file write_db uses for the write_sdc text is created with
mkstemp instead of a pid-plus-pointer name. utl::ScopedTemporaryFile
was considered and does not fit: it exposes only a FILE* (its path is
private and hard-wired under /tmp, sta::writeSdc needs a path) and it
logs an INFO line on every construction, which write_db must not do.
utl has no path-yielding temporary-file helper, so this keeps a local
one and says why.

Signed-off-by: Øyvind Harboe <oyvind.harboe@zylin.com>
The Sdc keeps input/output delays, exceptions and case-analysis pins in
pointer-ordered containers, so the record could depend on where the
heap put things. The encoder now walks every set in a canonical order:
pins, instances and nets by odb object id (PinSet and friends already
are), clocks by definition index (what write_sdc uses, so the restored
clock order matches), port delays by pin id then clock, exceptions with
write_sdc's own ExceptionPathLess, case-analysis and logic values by
pin id.

sdc_in_db8 proves it: after one read_sdc, two write_db in the same
process and a third from a fresh process that restored the constraints
with read_db -sdc give byte-identical .odb files, not just identical
records, so restoring and writing again is a fixpoint.

Signed-off-by: Øyvind Harboe <oyvind.harboe@zylin.com>
write_db no longer runs write_sdc to find out whether the native form
covers the Sdc. On an 800k-register design that oracle cost 14.9 s per
write_db, and ORFS writes the database about fifteen times per flow.

Coverage is now decided by asking the Sdc directly, mirroring
WriteSdc::writeTiming/writeEnvironment/writeDesignRules/writeVariables
category by category through public accessors: generated clocks and
comments, clock slew limits, inter-clock uncertainty over the clock
pairs, disables, operating conditions, wire load, net resistances,
voltages, max area and power, the propagate-all-clocks and
preset/clear variables, port loads, input drives and port limits over
the top-level ports, and global derating through a top-level pin. The
categories the Sdc keeps only in per-object maps with no enumerator
-- pin clock uncertainty, propagated clock pins, data checks, pin
capacitance limits, latch borrow limits, min pulse widths, net loads
and voltages, instance and net derating, clock senses -- are asked
object by object in one pass over the leaf pins; each is a hash lookup
in a map that is almost always empty. write_sdc now runs only when
the text fallback is actually taken.

Not observable from the public Sdc API: a clock sense set with
set_clock_sense -positive or -negative (only -stop_propagation is
queryable, via clkStopPropagation). That construct alone is not
detected and would be lost from the native form.

On the 50k-register fixture write_db goes from 0.51 s to 0.10 s; the
.odb written after a restore is still byte-identical to the original.
sdc_in_db9 shows a generated clock and a port load, both detected from
the Sdc, still taking the text form and round-tripping intact.

Signed-off-by: Øyvind Harboe <oyvind.harboe@zylin.com>
Buildifier's unsorted-dict-items and tclfmt's brace spacing plus the
100-column limit in sdc_in_db8.tcl, all flagged by CI.

Signed-off-by: Øyvind Harboe <oyvind.harboe@zylin.com>
@oharboe oharboe changed the title feat(dbSta): store timing constraints in the .odb dbSta: store the timing constraints in the .odb (read_db -sdc) Sep 7, 2026
read_db never returned anything, and giving it a 1/0 result for the
-sdc flag was an API change nobody asked for. Drop it: OpenRoad::readDb
and ord::read_db_cmd are void again and the read_db proc returns
nothing. With -sdc, read_db restores the constraints the .odb carries
and does nothing if it carries none; a flow that wants to know which
case it is in asks ord::sdc_in_db_kind (native, text or none)
afterwards and reads an .sdc instead when it has to.

The rest of the contract stands: a linked design with no constraints
stores nothing, and without -sdc read_db is unchanged. The round-trip
tests print the kind after the restore instead of the dropped return
value.

Signed-off-by: Øyvind Harboe <oyvind.harboe@zylin.com>
@oharboe
oharboe marked this pull request as ready for review September 7, 2026 16:38
@oharboe
oharboe requested review from a team as code owners September 7, 2026 16:38
The sdc_in_db tests recorded whatever happened to be printed -- the
native record with its object ids, write_sdc dumps, liberty warnings,
info lines -- so any unrelated change to those would have failed them
without saying anything about stored constraints. Each test now asserts
the properties it exists to verify through the Tcl, odb and STA APIs
with `check`, fails on the spot if one is violated, and ends with
exit_summary; the .ok files shrink to the two verdict lines.

What each test asserts:
  1  write_db stores the native form and only it, headed and with one
     clock record per clock
  2  read_db -sdc restores clocks, periods and port delays from the
     .odb alone
  3  read_db without -sdc leaves the stored constraints unrestored
  4  native round trip across processes: write_sdc before == after
  5  text fallback (derate) round trip: before == after
  6  an unconstrained design stores nothing, restores nothing
  7  a failed read_db -sdc errors (ORD-0054) and restores nothing
  8  the record and the .odb are byte-identical across two same-process
     writes and a fresh-process restore-and-write
  9  a construct detected from the Sdc (generated clock, port load)
     takes the text form and round-trips intact

The fixture moves from liberty1/reg1 to a small Nangate45 netlist,
sdc_in_db.v, because every Nangate45 liberty cell has a LEF master and
linking prints nothing to record; sdc_in_db_common.tcl holds the shared
helpers and quiets the LEF and restore info lines. Nothing in the tests
or their goldens depends on paths, ids, or process output beyond the
verdicts.

Signed-off-by: Øyvind Harboe <oyvind.harboe@zylin.com>
…nked read_db

The opt-in moves from the reader to the producer. `write_db -sdc` stores
the current constraints in the block; plain `read_db` restores whatever
the block carries whenever the design is linked with liberty, and
leaves the record alone otherwise. `read_db -sdc` is gone.

Why the producer: a reader has nothing to remember, so every script
that reads an .odb gets the constraints for free. An unmodified flow
never passes -sdc, writes .odb files without constraints and behaves
exactly as before. An odb-only session -- no liberty, no STA, the
GUI_TIMING=0 kind of read -- never pays for a restore because there is
nothing to restore into, and it carries an existing record through
unchanged, so odb-only round trips do not lose the constraints.
`write_db` without -sdc on a linked design drops any record the block
carried: the caller chose not to store, and a record written before an
edit could be stale.

Stale-record guard: the native record now lists the odb objects it
refers to (bterms, iterms, module iterms, instances, nets) in canonical
order and carries a 64-bit FNV-1a digest of their full names. Restoring
recomputes the digest over the same ids in the block first and errors
out (STA-3012, "the timing constraints stored in the database do not
match the design") instead of applying constraints to the wrong
objects. On the 50k-register fixture the digest costs about 40 ms on
write_db (0.095 s -> 0.13 s) and about 10 ms on the restore (0.38 s ->
0.39 s, under 3% of it); an .odb written with -sdc after a restore is
still byte-identical to the original.

Tests, all intent-based: 1..9 write with -sdc and read plainly; 3 now
proves the odb-only round trip preserves the record; 10 proves that
write_db without -sdc on a linked design drops it; 11 builds the stale
case odb-only (read without liberty, rename a referenced instance,
write) and asserts the linked reader's STA-3012.

Signed-off-by: Øyvind Harboe <oyvind.harboe@zylin.com>
@oharboe oharboe changed the title dbSta: store the timing constraints in the .odb (read_db -sdc) dbSta: store the timing constraints in the .odb (write_db -sdc) Sep 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant