| name | study-pr |
| description | Run a measurement study on the flow and publish it as a closed, reference-only bazel-orfs pull request. Covers the two kinds of patch a study carries into OpenROAD, ORFS or yosys (instrumentation that stays on the study branch, candidate fixes carried in patches/ one concern each), the edit/measure loop, the noise discipline that makes a runtime or QoR number trustworthy (byte-identical inputs, fixed and witnessed knobs, repeats with 2σ and a stated resolution, an idle machine asserted not hoped), the harness shape (manual flow targets, unit-tested parsers, a report that discovers results and says "not yet measured"), and the PR shape (study/<slug> branch, the finding as the title, graphs and tables in the body with images committed to the branch, raw data as a CSV comment, a closing comment). Use when asked to "study", "measure", "find out where the time goes", "A/B a knob or patch", or to document design decisions that go into an upstream PR. |
What a study PR is
A study answers one question with data and is published as a pull request
that is opened to be read, then closed. The PR body is the report. The
branch carries everything needed to re-take the numbers: harness, patches,
raw results, figures. Nothing merges from it; anything durable is split into
its own PR first (one concern per PR). There is no write-up in the tree,
no .md under docs/ or ideas/: a table typed by hand rots the first time
the campaign is re-run, and the report generator is what keeps a partial
study from reading as a complete one.
Precedents to copy the shape from: #965 (gpl runtime), #968 (threads
policy), #958/#963 (pre-route pessimism, and its merged harness under
test/pre_route_pessimism/), #966 (two probes, no BUILD at all).
Two kinds of patch, two homes
A study patches OpenROAD, ORFS or yosys for two different reasons, and they
must not be mixed in one file:
| kind | purpose | where it lives | fate |
|---|
| instrumentation | timers, counters, extra reports; changes no result | the study branch only, as a patches/00NN-*-instrument-*.patch or a BYO checkout | dies with the branch; never an upstream candidate |
| candidate fix | makes something faster or better | patches/00NN-<tool>-<concern>.patch, one concern per file, wired into MODULE.bazel (archive_override(patches=…)) or ORFS_PATCHES in orfs_source.bzl | measured alone; if it wins it is a listed upstream candidate, if it loses it is written up and not carried |
Every arm that claims to measure a fix runs a real binary built with the
patch through the module graph, not a hypothesis. Each candidate patch gets a
header saying what it fixes, that it is not upstreamed, and how it retires,
plus a Chesterton's Fence paragraph in the PR body: why the existing code
looked reasonable, so the next reader does not draw the same wrong
conclusion. Instrumentation should extend a pattern the tool already has
(rsz's utl::DebugScopedTimer, ORFS's report_metrics) rather than invent
one. Whether it gets a patches/ file or stays in a checkout, its output is
what the harvester parses, so pin its format before running arms.
Upstream repositories stay read-only: candidates are listed in the body
with their measured effect, never opened. The human decides when.
The edit/measure loop
- Iterate with a bring-your-own binary (
byo-openroad skill) or a deployed
_deps tree; record arms with the carried patch through the module graph.
- Time from the log, not from bazel:
--@bazel-orfs//:log_timestamps
stamps every line with elapsed seconds, and a cached stage rebuilds for
free, so a bazel wall clock measures the cache. Use the tool's own runtime
lines (RSZ-0505 style) and progress tables as the primary signal, perf record on the main thread as the cross-check that the timers add up
(a BYO build wants --copt=-g1 --copt=-fno-omit-frame-pointer).
- Preflight the code path before an hours-long run (
orfs-deps-preflight):
a SKIP_* default that routes around the thing under test produces a
clean, fast, meaningless number. For repair questions, the traps in
repair-timing-grt apply.
- Big reproducers that cannot be committed go in a bazel-orfs GitHub release
and are linked from the body.
Noise discipline
A number without these is an anecdote:
- Byte-identical inputs. Build the stage input once and share it via
previous_stage (see study_arm in test/pre_route_pessimism/arms.bzl);
only the arm's variable changes.
- Fixed, witnessed knobs. Pin
-threads for every arm and pin the
process with taskset; read the knob back from the log
(ORD-0030 Using N thread(s)) and discard a sample whose witness
disagrees with its arm rather than averaging it in.
- An idle machine, asserted. Refuse to record when the 1-minute load
average exceeds a threshold at arm start, and store
loadavg_at_start in
every result. A concurrent compile turned 1.8 s of placement into 16.8 s.
- Repeats, 2σ, resolution. Report individual repeat values, not a mean.
Spread is 2σ; the resolvable difference at
k runs per arm is
2σ·sqrt(2/k). Inside it the verdict is "did not resolve", never
"no effect". 2σ of a single run is zero, which is honest.
- QoR as a hash where none is claimed, as picoseconds where some is. A
byte-identical ODB (or ORFS's
result_sha1) is the strongest statement a
change can make. Where QoR moves, quote clk_period - WNS in ps plus TNS
and area, never a percentage of WNS. A design that closes before the step
under test measures nothing about it.
- Tens of seconds per case. A 5 s baseline drowns in noise; pick
vehicles where the step is long, or build a synthetic sweep.
- One result file per sample
(design, stage, arm, repeat) so a campaign
is resumable and a new arm appears in the tables by being run.
Harness shape
Under test/<study_slug>/:
BUILD.bazel with every flow-running target tags = ["manual"]
(bazelisk query proves it), and non-manual py_tests over every parser.
campaign.py (run arms, idle gate, pinning, resumable results),
harvest.py or elapsed.py (parse logs into JSON), report.py
(generate the PR body's tables from whatever results/ holds),
plots.py.
- The report discovers results; no result is a declared build input.
With
results/ deleted, report.py fails with "run the campaign first",
and a section with no data renders as "Not yet measured".
- Results and figures under
docs/studies/<slug>/ (or
test/<slug>/results/), committed on the study branch. If the raw data is
a few hundred rows, a fenced ```csv comment on the PR is enough and nothing
is committed under results/ (#968).
- Probes that load an ODB source
stage_src.tcl: RESULTS_DIR derives from
the package declaring the run, so a probe declared elsewhere fails with
ORD-0007.
The PR
Branch study/<question-slug>, with the upstream PR number appended when
the study is about one (study/size-down-fanout-11320). Commits use a
study(<area>): scope. Never delete the branch: the body's image URLs point
into it.
Title: the finding as a sentence, study: where a single global placement spends its time, with the patches it points at. Not the question.
Body, in this order:
- Disposition paragraph, first line, bold: This is a documentation PR: it is
here to be read, then closed. Name what it carries (patches, harness,
figures) and where anything durable went (
#NNN merged).
- TL;DR / the finding, in the form an upstream PR can quote.
- Method: unit of measurement, machine (cores, SMT, memory), thread pin,
repeats, what was held byte-identical.
- Results: every quantitative claim in a table; a figure's alt text
states the claim. Recurring shapes: per-arm
wall | delta | 2σ | verdict | same result; per-serial-item attribution
item | wall | what it is; the quadrant table
hypothesis | QoR | runtime | status where status is one of carried,
patches/00NN / measured, refuted, not carried / not done, upper
bound X; cost-to-re-run axis | target | leaves | wall.
- Negative results kept: "What did not move the needle", "Three things
this rules out". A refuted patch gets its numbers and the reason the
evidence misled.
- Limits and caveats, written before anyone asks.
- Upstream candidates:
PR-to-be | patches | measured effect, alone, with
the Chesterton's Fence paragraph per patch. Listed, not acted on.
- Reproducing: fenced
sh with the exact bazel lines, the _deps deploy,
the plot regeneration, and the traps hit on the way.
Images: commit PNG/SVG to the branch and reference them by pinned-SHA
raw URL so later commits do not move them:
https://raw.githubusercontent.com/The-OpenROAD-Project/bazel-orfs/<sha>/docs/studies/<slug>/fig.png.
Relative paths break on a closed PR. For small charts, a ```mermaid
xychart-beta block or an ASCII bar chart renders in the body with no asset.
Comments: raw samples as one fenced ```csv comment with the host line
and column semantics above it, so every table can be recomputed without
re-running. The closing comment states intent and what stays reachable:
Closing as intended, reference only. Self-contained: the patches, harness,
figures and raw data are all on this branch, and the write-up is in the PR
body above. Infrastructure proposed for merge in #NNN.
Before pushing
Run the Confidentiality purge from CLAUDE.md over the body, the comments,
the figures' text and the committed scripts: no local paths, hostnames or
user names (a machine is "48 threads on 24 cores, 256 GB", not its name), no
private URLs, no ally names. bazelisk run //:fix_lint on the branch; the
harness's unit tests pass; bazelisk query shows every flow target manual.