Skip to content

Commit 55d323b

Browse files
oharboeclaude
andcommitted
replace GNU time + tee with pure-Python run_command.py
Replace the GNU `time` binary dependency and `tee` shell pipeline with a pure-Python wrapper (run_command.py) that measures wall time, CPU time, and peak memory using Python stdlib (time.monotonic, resource.getrusage). Output is streamed line-by-line with flush after each line so `tail -f` works in real time for monitoring long runs. This eliminates: - The `env time` / GNU time dependency (not available in Bazel sandbox) - The TIME_CMD / TIME_BIN / TIME_TEST Make variable machinery - The STDBUF_CMD dependency (stdbuf -o L) - The `eval "$TIME_CMD ..."` fragile shell expansion pattern - The `(cmd) 2>&1 | tee` subshell+pipe pattern (~15 locations) - The TIME_CMD exclusion from get_variables export filtering Works on both Linux and macOS (ru_maxrss is KB on Linux, bytes on macOS — normalized automatically). 19 unit tests cover timing output format, streaming flush (tail -f use case), log discoverability via ps, exit code propagation, and end-to-end parsing by genElapsedTime.py. Triggered by The-OpenROAD-Project/bazel-orfs#651: `env: 'time': No such file or directory` in Bazel sandboxed builds. History of pain this eliminates (28+ commits, 10+ PRs, 4+ authors, 5 years): - e7b140d Fix /usr/bin/time output formatting #109 - a88a7c0 New time format to include CPU seconds (broke parsers) - 44c455c TIME_CMD is not portable, do not save it - 00749e7 Adjust wall time, cpu and peak memory regex to new format - 16b0be7 Adjust wall time, cpu and peak memory regex to new format - f53e1a2 make: fix gaffe in elapsed seconds summary, account for hours #722 - 93ad8d4 Fix genElapsedTime.py ("0:02.08" parsed as 2m8s not 2s) #1036 - ab171aa Handle zero elapsed time in genElapsedTime.py #1044 #1043 - 2694cfd tests: simpler to maintain test_genElapsedTime.py #1968 - 7cd9e14 makefile: fix elapsed time for empty log files #1716 - afccf3f makefile: elapsed time python code fewer red lines in editor - b947b5c utl: do not look for elapsed time in eqy files #1761 - 87e80c4 Adding total elapsed time (6 comments of discussion) #1663 - f226341 genElapsedTime.py: handle lines after the elapsed time line #3307 - 31b6b5f exclude lec check log from elapsed time extraction #3927 - Elapsed time regression (couldn't distinguish hours from minutes) #967 - genElapsedTime module and test adjustments #1014 Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> Signed-off-by: Øyvind Harboe <[email protected]>
1 parent a166175 commit 55d323b

8 files changed

Lines changed: 501 additions & 31 deletions

File tree

docs/contrib/DeveloperGuide.md

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,63 @@
55
- Continuous Integration: [Guide](./CI.md).
66
- How do I update the codebase? There are different ways to update your codebase depending on the method you installed it. We provide detailed instructions in this [guide](../user/FAQS.md).
77
- How do I contribute? Follow our Git Quickstart guide [here](./GitGuide.md).
8+
9+
## Timing and Logging (`run_command.py`)
10+
11+
Every flow stage (synthesis, floorplan, CTS, routing, etc.) is wrapped by
12+
`flow/scripts/run_command.py`, which replaces the previous GNU `time` + `tee`
13+
shell pipeline with a pure-Python implementation.
14+
15+
### What it does
16+
17+
`run_command.py` runs a command and:
18+
19+
- Measures **wall-clock time**, **CPU time** (user + sys), and **peak memory**
20+
using Python's `time.monotonic()` and `resource.getrusage()`.
21+
- Streams output **line-by-line** to both the console and a log file
22+
(replacing `tee`), flushing after every line for real-time `tail -f`
23+
observability.
24+
- Appends an `Elapsed time: ...` line in the format expected by
25+
`genElapsedTime.py` and `genMetrics.py`.
26+
27+
### Usage
28+
29+
```
30+
python3 flow/scripts/run_command.py [--log FILE] [--append] [--tee] -- command [args...]
31+
```
32+
33+
| Flag | Effect |
34+
|------------|--------|
35+
| `--log FILE` | Write command output + timing line to FILE |
36+
| `--append` | Append to log file instead of overwriting |
37+
| `--tee` | Also write output to stdout (like the `tee` command) |
38+
39+
### Monitoring long-running stages
40+
41+
When running under Bazel (`bazelisk test ...`) or other batch systems that
42+
hide console output, you can monitor progress by finding and tailing the log:
43+
44+
```bash
45+
# Find the running stage's log file
46+
ps -Af | grep run_command
47+
# or
48+
ps -Af | grep tmp.log
49+
50+
# Watch it in real time
51+
tail -f /path/to/logs/4_cts.tmp.log
52+
```
53+
54+
Output appears immediately in the log file (line-buffered with flush),
55+
so `tail -f` shows real-time progress.
56+
57+
### Cross-platform support
58+
59+
Works on both Linux and macOS using only Python standard library modules.
60+
Peak memory is normalized automatically (`ru_maxrss` is KB on Linux,
61+
bytes on macOS).
62+
63+
### Testing
64+
65+
```bash
66+
python3 -m pytest flow/test/test_run_command.py -v
67+
```

flow/Makefile

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -241,7 +241,7 @@ synth-report: synth
241241

242242
.PHONY: do-synth-report
243243
do-synth-report:
244-
($(TIME_CMD) $(OPENROAD_CMD) $(SCRIPTS_DIR)/synth_metrics.tcl) 2>&1 | tee $(abspath $(LOG_DIR)/1_2_yosys_metrics.log)
244+
$(RUN_CMD) --log $(abspath $(LOG_DIR)/1_2_yosys_metrics.log) --tee -- $(OPENROAD_CMD) $(SCRIPTS_DIR)/synth_metrics.tcl
245245

246246
.PHONY: memory
247247
memory:
@@ -641,7 +641,7 @@ generate_abstract: $(RESULTS_DIR)/6_final.gds $(RESULTS_DIR)/6_final.def $(RESU
641641
.PHONY: do-generate_abstract
642642
do-generate_abstract:
643643
mkdir -p $(LOG_DIR) $(REPORTS_DIR)
644-
($(TIME_CMD) $(OPENROAD_CMD) $(SCRIPTS_DIR)/generate_abstract.tcl -metrics $(LOG_DIR)/generate_abstract.json) 2>&1 | tee $(abspath $(LOG_DIR)/generate_abstract.log)
644+
$(RUN_CMD) --log $(abspath $(LOG_DIR)/generate_abstract.log) --tee -- $(OPENROAD_CMD) $(SCRIPTS_DIR)/generate_abstract.tcl -metrics $(LOG_DIR)/generate_abstract.json
645645

646646
.PHONY: clean_abstract
647647
clean_abstract:
@@ -661,15 +661,16 @@ gds: $(GDS_FINAL_FILE)
661661
# Merge wrapped macros using Klayout
662662
#-------------------------------------------------------------------------------
663663
$(WRAPPED_GDSOAS): $(OBJECTS_DIR)/klayout_wrap.lyt $(WRAPPED_LEFS)
664-
($(TIME_CMD) $(SCRIPTS_DIR)/klayout.sh -zz -rd design_name=$(basename $(notdir $@)) \
664+
$(RUN_CMD) --log $(abspath $(LOG_DIR)/6_merge_$(basename $(notdir $@)).log) --tee -- \
665+
$(SCRIPTS_DIR)/klayout.sh -zz -rd design_name=$(basename $(notdir $@)) \
665666
-rd in_def=$(OBJECTS_DIR)/def/$(notdir $(@:$(STREAM_SYSTEM_EXT)=def)) \
666667
-rd in_files="$(ADDITIONAL_GDSOAS)" \
667668
-rd config_file=$(FILL_CONFIG) \
668669
-rd seal_file="" \
669670
-rd out_file=$@ \
670671
-rd tech_file=$(OBJECTS_DIR)/klayout_wrap.lyt \
671672
-rd layer_map=$(GDS_LAYER_MAP) \
672-
-r $(UTILS_DIR)/def2stream.py) 2>&1 | tee $(abspath $(LOG_DIR)/6_merge_$(basename $(notdir $@)).log)
673+
-r $(UTILS_DIR)/def2stream.py
673674

674675
# Merge GDS using Klayout
675676
#-------------------------------------------------------------------------------
@@ -678,14 +679,15 @@ $(GDS_MERGED_FILE): check-klayout $(RESULTS_DIR)/6_final.def $(OBJECTS_DIR)/klay
678679

679680
.PHONY: do-gds-merged
680681
do-gds-merged:
681-
($(TIME_CMD) $(STDBUF_CMD) $(SCRIPTS_DIR)/klayout.sh -zz -rd design_name=$(DESIGN_NAME) \
682+
$(RUN_CMD) --log $(abspath $(LOG_DIR)/6_1_merge.log) --tee -- \
683+
$(SCRIPTS_DIR)/klayout.sh -zz -rd design_name=$(DESIGN_NAME) \
682684
-rd in_def=$(RESULTS_DIR)/6_final.def \
683685
-rd in_files="$(GDSOAS_FILES) $(WRAPPED_GDSOAS)" \
684686
-rd seal_file="$(SEAL_GDSOAS)" \
685687
-rd out_file=$(GDS_MERGED_FILE) \
686688
-rd tech_file=$(OBJECTS_DIR)/klayout.lyt \
687689
-rd layer_map=$(GDS_LAYER_MAP) \
688-
-r $(UTILS_DIR)/def2stream.py) 2>&1 | tee $(abspath $(LOG_DIR)/6_1_merge.log)
690+
-r $(UTILS_DIR)/def2stream.py
689691

690692
$(RESULTS_DIR)/6_final.v: $(LOG_DIR)/6_report.log
691693

@@ -702,17 +704,18 @@ drc: $(REPORTS_DIR)/6_drc.lyrdb
702704

703705
$(REPORTS_DIR)/6_drc.lyrdb: $(GDS_FINAL_FILE) $(KLAYOUT_DRC_FILE)
704706
ifneq ($(KLAYOUT_DRC_FILE),)
705-
($(TIME_CMD) $(SCRIPTS_DIR)/klayout.sh -zz -rd in_gds="$<" \
707+
$(RUN_CMD) --log $(abspath $(LOG_DIR)/6_drc.log) --tee -- \
708+
$(SCRIPTS_DIR)/klayout.sh -zz -rd in_gds="$<" \
706709
-rd report_file=$(abspath $@) \
707-
-r $(KLAYOUT_DRC_FILE)) 2>&1 | tee $(abspath $(LOG_DIR)/6_drc.log)
710+
-r $(KLAYOUT_DRC_FILE)
708711
# Hacky way of getting DRV count (don't error on no matches)
709712
grep -c "<value>" $@ > $(REPORTS_DIR)/6_drc_count.rpt || [[ $$? == 1 ]]
710713
else
711714
echo "DRC not supported on this platform" > $@
712715
endif
713716

714717
$(RESULTS_DIR)/6_final.cdl: $(RESULTS_DIR)/6_final.v
715-
($(TIME_CMD) $(OPENROAD_CMD) $(SCRIPTS_DIR)/cdl.tcl) 2>&1 | tee $(abspath $(LOG_DIR)/6_cdl.log)
718+
$(RUN_CMD) --log $(abspath $(LOG_DIR)/6_cdl.log) --tee -- $(OPENROAD_CMD) $(SCRIPTS_DIR)/cdl.tcl
716719

717720
$(OBJECTS_DIR)/6_final_concat.cdl: $(RESULTS_DIR)/6_final.cdl $(CDL_FILE)
718721
cat $^ > $@
@@ -722,10 +725,11 @@ lvs: $(RESULTS_DIR)/6_lvs.lvsdb
722725

723726
$(RESULTS_DIR)/6_lvs.lvsdb: $(GDS_FINAL_FILE) $(KLAYOUT_LVS_FILE) $(OBJECTS_DIR)/6_final_concat.cdl
724727
ifneq ($(KLAYOUT_LVS_FILE),)
725-
($(TIME_CMD) $(SCRIPTS_DIR)/klayout.sh -b -rd in_gds="$<" \
728+
$(RUN_CMD) --log $(abspath $(LOG_DIR)/6_lvs.log) --tee -- \
729+
$(SCRIPTS_DIR)/klayout.sh -b -rd in_gds="$<" \
726730
-rd cdl_file=$(abspath $(OBJECTS_DIR)/6_final_concat.cdl) \
727731
-rd report_file=$(abspath $@) \
728-
-r $(KLAYOUT_LVS_FILE)) 2>&1 | tee $(abspath $(LOG_DIR)/6_lvs.log)
732+
-r $(KLAYOUT_LVS_FILE)
729733
else
730734
echo "LVS not supported on this platform" > $@
731735
endif

flow/scripts/flow.sh

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@ echo "Running $2.tcl, stage $1"
1111
eval "$OPENROAD_EXE $OPENROAD_ARGS -exit \"$SCRIPTS_DIR/noop.tcl\"" \
1212
>"$LOG_DIR/$1.tmp.log" 2>&1
1313

14-
eval "$TIME_CMD $OPENROAD_CMD -no_splash \"$SCRIPTS_DIR/$2.tcl\" -metrics \"$LOG_DIR/$1.json\"" \
15-
2>&1 | tee -a "$(realpath "$LOG_DIR/$1.tmp.log")"
14+
$PYTHON_EXE "$SCRIPTS_DIR/run_command.py" --log "$(realpath "$LOG_DIR/$1.tmp.log")" --append --tee -- \
15+
$OPENROAD_CMD -no_splash "$SCRIPTS_DIR/$2.tcl" -metrics "$LOG_DIR/$1.json"
1616
)
1717

1818
# Log the hash for this step. The summary "make elapsed" in "make finish",

flow/scripts/run_command.py

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
#!/usr/bin/env python3
2+
"""Run a command with timing and optional log file output.
3+
4+
Replaces the GNU `time` + `tee` shell pipeline with a pure-Python
5+
implementation that works on both Linux and macOS without external
6+
dependencies.
7+
8+
Usage:
9+
run_command.py [--log FILE] [--append] [--tee] -- command [args...]
10+
11+
Output is streamed line-by-line and flushed after every line so that
12+
`tail -f <logfile>` works in real time. The log path is visible in
13+
`ps` output for discoverability (replaces `ps -Af | grep tee`).
14+
15+
On completion an "Elapsed time: ..." line is appended to stderr (and
16+
the log file) in the same format that the rest of the ORFS
17+
infrastructure expects.
18+
"""
19+
20+
import argparse
21+
import resource
22+
import subprocess
23+
import sys
24+
import time
25+
26+
27+
def _maxrss_kb(ru_maxrss: int) -> int:
28+
"""Normalize ru_maxrss to kilobytes (Linux reports KB, macOS reports bytes)."""
29+
if sys.platform == "darwin":
30+
return ru_maxrss // 1024
31+
return ru_maxrss
32+
33+
34+
def _format_elapsed(seconds: float) -> str:
35+
"""Format seconds as [H:]MM:SS.ff matching GNU time %E output."""
36+
h = int(seconds // 3600)
37+
m = int((seconds % 3600) // 60)
38+
s = seconds % 60
39+
if h:
40+
return f"{h}:{m:02d}:{s:05.2f}"
41+
return f"{m}:{s:05.2f}"
42+
43+
44+
def _build_timing_line(
45+
wall: float,
46+
user: float,
47+
sys_: float,
48+
peak_kb: int,
49+
) -> str:
50+
cpu_pct = int(100 * (user + sys_) / wall) if wall > 0 else 0
51+
return (
52+
f"Elapsed time: {_format_elapsed(wall)}[h:]min:sec. "
53+
f"CPU time: user {user:.2f} sys {sys_:.2f} ({cpu_pct}%). "
54+
f"Peak memory: {peak_kb}KB."
55+
)
56+
57+
58+
def run(argv: list[str] | None = None) -> int:
59+
parser = argparse.ArgumentParser(
60+
description="Run a command with timing, optional tee-to-log."
61+
)
62+
parser.add_argument("--log", help="Log file path")
63+
parser.add_argument(
64+
"--append", action="store_true", help="Append to log file instead of overwrite"
65+
)
66+
parser.add_argument(
67+
"--tee",
68+
action="store_true",
69+
help="Also write output to stdout (like tee)",
70+
)
71+
parser.add_argument("command", nargs=argparse.REMAINDER)
72+
args = parser.parse_args(argv)
73+
74+
cmd = args.command
75+
if cmd and cmd[0] == "--":
76+
cmd = cmd[1:]
77+
if not cmd:
78+
parser.error("No command specified")
79+
80+
# Snapshot children resource usage before the subprocess.
81+
before = resource.getrusage(resource.RUSAGE_CHILDREN)
82+
83+
log_file = None
84+
if args.log:
85+
log_file = open(args.log, "a" if args.append else "w")
86+
87+
wall_start = time.monotonic()
88+
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
89+
90+
try:
91+
for line in iter(proc.stdout.readline, b""):
92+
if args.tee:
93+
sys.stdout.buffer.write(line)
94+
sys.stdout.buffer.flush()
95+
if log_file:
96+
log_file.write(line.decode(errors="replace"))
97+
log_file.flush()
98+
proc.wait()
99+
except BaseException:
100+
proc.kill()
101+
proc.wait()
102+
raise
103+
finally:
104+
wall = time.monotonic() - wall_start
105+
106+
after = resource.getrusage(resource.RUSAGE_CHILDREN)
107+
user = after.ru_utime - before.ru_utime
108+
sys_ = after.ru_stime - before.ru_stime
109+
peak_kb = _maxrss_kb(after.ru_maxrss)
110+
111+
timing_line = _build_timing_line(wall, user, sys_, peak_kb)
112+
113+
sys.stderr.write(timing_line + "\n")
114+
sys.stderr.flush()
115+
if log_file:
116+
log_file.write(timing_line + "\n")
117+
log_file.flush()
118+
log_file.close()
119+
120+
return proc.returncode
121+
122+
123+
if __name__ == "__main__":
124+
sys.exit(run())

flow/scripts/synth.sh

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,5 @@
22
set -u -eo pipefail
33
mkdir -p $RESULTS_DIR $LOG_DIR $REPORTS_DIR $OBJECTS_DIR
44
$YOSYS_EXE -V > $(realpath $2)
5-
eval "$TIME_CMD $YOSYS_EXE $YOSYS_FLAGS -c $1" 2>&1 | tee --append $(realpath $2)
5+
$PYTHON_EXE "$SCRIPTS_DIR/run_command.py" --log "$(realpath $2)" --append --tee -- \
6+
$YOSYS_EXE $YOSYS_FLAGS -c $1

flow/scripts/variables.mk

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -72,13 +72,7 @@ export NUM_CORES
7272
# setup all commands used within this flow
7373
export PYTHON_EXE ?= $(shell command -v python3)
7474

75-
export TIME_BIN ?= env time
76-
TIME_CMD = $(TIME_BIN) -f 'Elapsed time: %E[h:]min:sec. CPU time: user %U sys %S (%P). Peak memory: %MKB.'
77-
TIME_TEST = $(shell $(TIME_CMD) echo foo 2>/dev/null)
78-
ifeq (,$(strip $(TIME_TEST)))
79-
TIME_CMD = $(TIME_BIN)
80-
endif
81-
export TIME_CMD
75+
export RUN_CMD = $(PYTHON_EXE) $(FLOW_HOME)/scripts/run_command.py
8276

8377
# The following determine the executable location for each tool used by this flow.
8478
# Priority is given to
@@ -128,9 +122,6 @@ export KLAYOUT_CMD := $(shell command -v klayout)
128122
endif
129123
endif
130124

131-
ifneq ($(shell command -v stdbuf),)
132-
STDBUF_CMD ?= stdbuf -o L
133-
endif
134125

135126
#-------------------------------------------------------------------------------
136127
WRAPPED_LEFS = $(foreach lef,$(notdir $(WRAP_LEFS)),$(OBJECTS_DIR)/lef/$(lef:.lef=_mod.lef))
@@ -191,7 +182,7 @@ export RESULTS_V = $(notdir $(sort $(wildcard $(RESULTS_DIR)/*.v)))
191182
export GDS_MERGED_FILE = $(RESULTS_DIR)/6_1_merged.$(STREAM_SYSTEM_EXT)
192183

193184
define get_variables
194-
$(foreach V, $(.VARIABLES),$(if $(filter-out $(1), $(origin $V)), $(if $(filter-out .% %QT_QPA_PLATFORM% %TIME_CMD% KLAYOUT% GENERATE_ABSTRACT_RULE% do-step% do-copy% OPEN_GUI% OPEN_GUI_SHORTCUT% SUB_MAKE% UNSET_VARS% export%, $(V)), $V$ )))
185+
$(foreach V, $(.VARIABLES),$(if $(filter-out $(1), $(origin $V)), $(if $(filter-out .% %QT_QPA_PLATFORM% KLAYOUT% GENERATE_ABSTRACT_RULE% do-step% do-copy% OPEN_GUI% OPEN_GUI_SHORTCUT% SUB_MAKE% UNSET_VARS% export%, $(V)), $V$ )))
195186
endef
196187

197188
export UNSET_VARIABLES_NAMES := $(call get_variables,command% line environment% default automatic)

0 commit comments

Comments
 (0)