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.
1---2name: study-pr3description: 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.4---56## What a study PR is78A study answers one question with data and is published as a pull request9that is **opened to be read, then closed**. The PR body is the report. The10branch carries everything needed to re-take the numbers: harness, patches,11raw results, figures. Nothing merges from it; anything durable is split into12its own PR first (one concern per PR). There is **no write-up in the tree**,13no `.md` under `docs/` or `ideas/`: a table typed by hand rots the first time14the campaign is re-run, and the report generator is what keeps a partial15study from reading as a complete one.1617Precedents to copy the shape from: #965 (gpl runtime), #968 (threads18policy), #958/#963 (pre-route pessimism, and its merged harness under19`test/pre_route_pessimism/`), #966 (two probes, no BUILD at all).2021## Two kinds of patch, two homes2223A study patches OpenROAD, ORFS or yosys for two different reasons, and they24must not be mixed in one file:2526| kind | purpose | where it lives | fate |27| --- | --- | --- | --- |28| 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 |29| 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** |3031Every arm that claims to measure a fix runs a **real binary** built with the32patch through the module graph, not a hypothesis. Each candidate patch gets a33header saying what it fixes, that it is not upstreamed, and how it retires,34plus a Chesterton's Fence paragraph in the PR body: why the existing code35looked reasonable, so the next reader does not draw the same wrong36conclusion. Instrumentation should extend a pattern the tool already has37(rsz's `utl::DebugScopedTimer`, ORFS's `report_metrics`) rather than invent38one. Whether it gets a `patches/` file or stays in a checkout, its output is39what the harvester parses, so pin its format before running arms.4041Upstream repositories stay read-only: candidates are **listed** in the body42with their measured effect, never opened. The human decides when.4344## The edit/measure loop4546- Iterate with a bring-your-own binary (`byo-openroad` skill) or a deployed47 `_deps` tree; record arms with the carried patch through the module graph.48- Time from the log, not from bazel: `--@bazel-orfs//:log_timestamps`49 stamps every line with elapsed seconds, and a cached stage rebuilds for50 free, so a bazel wall clock measures the cache. Use the tool's own runtime51 lines (`RSZ-0505` style) and progress tables as the primary signal, `perf52 record` on the main thread as the cross-check that the timers add up53 (a BYO build wants `--copt=-g1 --copt=-fno-omit-frame-pointer`).54- Preflight the code path before an hours-long run (`orfs-deps-preflight`):55 a `SKIP_*` default that routes around the thing under test produces a56 clean, fast, meaningless number. For repair questions, the traps in57 `repair-timing-grt` apply.58- Big reproducers that cannot be committed go in a bazel-orfs GitHub release59 and are linked from the body.6061## Noise discipline6263A number without these is an anecdote:64651. **Byte-identical inputs.** Build the stage input once and share it via66 `previous_stage` (see `study_arm` in `test/pre_route_pessimism/arms.bzl`);67 only the arm's variable changes.682. **Fixed, witnessed knobs.** Pin `-threads` for every arm and pin the69 process with `taskset`; read the knob back from the log70 (`ORD-0030 Using N thread(s)`) and **discard** a sample whose witness71 disagrees with its arm rather than averaging it in.723. **An idle machine, asserted.** Refuse to record when the 1-minute load73 average exceeds a threshold at arm start, and store `loadavg_at_start` in74 every result. A concurrent compile turned 1.8 s of placement into 16.8 s.754. **Repeats, 2σ, resolution.** Report individual repeat values, not a mean.76 Spread is 2σ; the resolvable difference at `k` runs per arm is77 `2σ·sqrt(2/k)`. Inside it the verdict is **"did not resolve"**, never78 "no effect". 2σ of a single run is zero, which is honest.795. **QoR as a hash where none is claimed, as picoseconds where some is.** A80 byte-identical ODB (or ORFS's `result_sha1`) is the strongest statement a81 change can make. Where QoR moves, quote `clk_period - WNS` in ps plus TNS82 and area, never a percentage of WNS. A design that closes before the step83 under test measures nothing about it.846. **Tens of seconds per case.** A 5 s baseline drowns in noise; pick85 vehicles where the step is long, or build a synthetic sweep.867. **One result file per sample** `(design, stage, arm, repeat)` so a campaign87 is resumable and a new arm appears in the tables by being run.8889## Harness shape9091Under `test/<study_slug>/`:9293- `BUILD.bazel` with every flow-running target `tags = ["manual"]`94 (`bazelisk query` proves it), and non-manual `py_test`s over every parser.95- `campaign.py` (run arms, idle gate, pinning, resumable results),96 `harvest.py` or `elapsed.py` (parse logs into JSON), `report.py`97 (generate the PR body's tables from whatever `results/` holds),98 `plots.py`.99- The report **discovers** results; no result is a declared build input.100 With `results/` deleted, `report.py` fails with "run the campaign first",101 and a section with no data renders as **"Not yet measured"**.102- Results and figures under `docs/studies/<slug>/` (or103 `test/<slug>/results/`), committed on the study branch. If the raw data is104 a few hundred rows, a fenced ```csv comment on the PR is enough and nothing105 is committed under `results/` (#968).106- Probes that load an ODB source `stage_src.tcl`: `RESULTS_DIR` derives from107 the package **declaring** the run, so a probe declared elsewhere fails with108 `ORD-0007`.109110## The PR111112**Branch** `study/<question-slug>`, with the upstream PR number appended when113the study is about one (`study/size-down-fanout-11320`). Commits use a114`study(<area>):` scope. Never delete the branch: the body's image URLs point115into it.116117**Title**: the finding as a sentence, `study: where a single global placement118spends its time, with the patches it points at`. Not the question.119120**Body**, in this order:1211221. Disposition paragraph, first line, bold: *This is a documentation PR: it is123 here to be read, then closed.* Name what it carries (patches, harness,124 figures) and where anything durable went (`#NNN merged`).1252. TL;DR / the finding, in the form an upstream PR can quote.1263. Method: unit of measurement, machine (cores, SMT, memory), thread pin,127 repeats, what was held byte-identical.1284. Results: **every quantitative claim in a table**; a figure's alt text129 states the claim. Recurring shapes: per-arm130 `wall | delta | 2σ | verdict | same result`; per-serial-item attribution131 `item | wall | what it is`; the quadrant table132 `hypothesis | QoR | runtime | status` where status is one of *carried,133 patches/00NN* / *measured, refuted, not carried* / *not done, upper134 bound X*; cost-to-re-run `axis | target | leaves | wall`.1355. Negative results kept: "What did not move the needle", "Three things136 this rules out". A refuted patch gets its numbers and the reason the137 evidence misled.1386. Limits and caveats, written before anyone asks.1397. Upstream candidates: `PR-to-be | patches | measured effect, alone`, with140 the Chesterton's Fence paragraph per patch. Listed, not acted on.1418. Reproducing: fenced `sh` with the exact bazel lines, the `_deps` deploy,142 the plot regeneration, and the traps hit on the way.143144**Images**: commit PNG/SVG to the branch and reference them by **pinned-SHA145raw URL** so later commits do not move them:146`https://raw.githubusercontent.com/The-OpenROAD-Project/bazel-orfs/<sha>/docs/studies/<slug>/fig.png`.147Relative paths break on a closed PR. For small charts, a ```mermaid148`xychart-beta` block or an ASCII bar chart renders in the body with no asset.149150**Comments**: raw samples as one fenced ```csv comment with the host line151and column semantics above it, so every table can be recomputed without152re-running. The **closing comment** states intent and what stays reachable:153154> Closing as intended, reference only. Self-contained: the patches, harness,155> figures and raw data are all on this branch, and the write-up is in the PR156> body above. Infrastructure proposed for merge in #NNN.157158## Before pushing159160Run the Confidentiality purge from `CLAUDE.md` over the body, the comments,161the figures' text and the committed scripts: no local paths, hostnames or162user names (a machine is "48 threads on 24 cores, 256 GB", not its name), no163private URLs, no ally names. `bazelisk run //:fix_lint` on the branch; the164harness's unit tests pass; `bazelisk query` shows every flow target manual.