blob: 7030ad11ad42159f419c91fbd545f887ee1b9346 [file] [edit]
#!/usr/bin/env python3
"""Produce a Perfetto/Chrome trace of test-suite timings.
ctest reports an accurate *duration* per test (`... Passed X.XX sec`) but no
sub-second per-test *start* time in any of its output formats, so a real parallel
timeline has to be anchored externally. By default this runs the real `ctest` and
timestamps its own output stream: the `Start <id>: <name>` line gives each test's
launch time and the result line gives its exact duration. This adds no measurement
overhead, so the timeline reflects a genuine run.
The `--strace` mode instead traces every process (execve/exit via seccomp-bpf) to
show per-process structure (each `lean`, `grep`, ... within a test). It is useful
for drilling into one test, but its ptrace overhead inflates and serializes
process-heavy tests (e.g. lake), so it is not the way to measure real timings.
Output is Chrome Trace Event Format JSON, loadable at https://ui.perfetto.dev or
chrome://tracing. Slices are packed into lanes (one trace "thread" each) via greedy
interval partitioning, so the number of occupied lanes at any instant is the actual
concurrency.
Usage:
tests/trace_timings.py -o trace.json # whole suite (-j defaults to nproc)
tests/trace_timings.py -o trace.json -R 'elab/' # a subset
tests/trace_timings.py --strace -R 'lake/.*hello' -o proc.json # per-process detail
tests/trace_timings.py --strace --processes -R 'elab/abst' -o proc.json # every process
tests/trace_timings.py --strace --log _tmp_strace.log --json _tmp_ctest_tests.json -o t.json
Unrecognized arguments (e.g. -R <regex>, -j <n>, -E <regex>) are passed to ctest.
"""
import argparse
import json
import os
import re
import subprocess
import sys
import time
# --- ctest stdout parsing (default collector) ---
START = re.compile(r'^\s*Start\s+(\d+):\s+(.+?)\s*$')
RESULT = re.compile(r'Test\s+#(\d+):\s')
DURATION = re.compile(r'([\d.]+)\s+sec\s*$')
# --- strace parsing (--strace collector) ---
# `PID TIMESTAMP rest` produced by `strace -f -ttt`.
LINE = re.compile(r'^(\d+)\s+(\d+\.\d+)\s+(.*)$')
# `execve("<path>", ` up to (but not including) the argv `[`.
EXECVE_HEAD = re.compile(r'execve\("(?:[^"\\]|\\.)*",\s*\[')
EXIT = re.compile(r'(?:exit_group|exit)\((\d+)')
CHDIR = re.compile(r'^chdir\("((?:[^"\\]|\\.)*)"\)')
# A completed clone/fork (possibly `<... clone resumed>`) returns the child pid.
CLONE_RET = re.compile(r'(?:clone3?|vfork|fork)\b.*=\s*(\d+)\s*$')
SYSCALLS = 'execve,clone,clone3,vfork,fork,chdir,exit,exit_group'
def category(name):
return name.split('/', 1)[0] if '/' in name else name
# ---------------------------------------------------------------------------
# Collector 1: timestamp ctest's own output (default; no measurement overhead)
# ---------------------------------------------------------------------------
def collect_via_ctest(build_dir, passthrough):
cmd = ['stdbuf', '-oL', 'ctest', '--test-dir', build_dir] + passthrough
print('running:', ' '.join(cmd), file=sys.stderr)
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
text=True, bufsize=1)
starts = {} # test id -> (start_s, name)
items = []
for line in proc.stdout:
ts = time.monotonic()
sys.stdout.write(line) # echo so the user sees live progress
sys.stdout.flush()
sm = START.match(line)
if sm:
starts[int(sm.group(1))] = (ts, sm.group(2))
continue
rm = RESULT.search(line)
if not rm:
continue
tid = int(rm.group(1))
dm = DURATION.search(line)
start, name = starts.pop(tid, (None, None))
if name is None: # result without a seen Start: recover from the line
nm = re.search(r'Test\s+#\d+:\s+(\S+)', line)
name = nm.group(1) if nm else f'test#{tid}'
dur = float(dm.group(1)) if dm else None
if start is None:
start = ts - (dur or 0.0)
end = start + dur if dur is not None else ts
items.append({'name': name, 'cat': category(name), 'start': start, 'end': end,
'code': 0 if ' Passed ' in line else 1,
'args': {'duration_s': round(end - start, 3),
'status': 'Passed' if ' Passed ' in line else line.split('***')[-1].split(' ')[0].strip() or 'failed',
'test_id': tid}})
proc.wait()
return items
# ---------------------------------------------------------------------------
# Collector 2: strace (per-process detail; --strace)
# ---------------------------------------------------------------------------
def extract_argv(rest):
"""Return the argv list of an execve line, or None if this line has no argv."""
m = EXECVE_HEAD.search(rest)
if not m:
return None
i = m.end() - 1 # index of '['
args, depth, inq, n = [], 0, False, len(rest)
buf = []
while i < n:
c = rest[i]
if inq:
if c == '\\':
buf.append(rest[i + 1] if i + 1 < n else '')
i += 2
continue
if c == '"':
inq = False
args.append(''.join(buf))
buf = []
while i + 1 < n and rest[i + 1] not in ',]': # skip a `"foo"...` truncation marker
i += 1
else:
buf.append(c)
else:
if c == '"':
inq = True
elif c == '[':
depth += 1
elif c == ']':
depth -= 1
if depth == 0:
return args
i += 1
return args
def parse_strace(log_path):
"""Reconstruct one slice per process lifetime: (pid, argv, start, end, code, cwd).
Each slice spans a PID's first execve to its exit, so an intra-PID exec chain
(bash exec'ing `./test.sh`) stays one slice identified by ctest's command. cwd is
tracked via clone() inheritance plus chdir(), which tells apart tests with
identical command lines (lake's `./test.sh`, run in different dirs). PID reuse is
handled by closing a PID's slice at its exit.
"""
open_slice = {} # pid -> (start_s, argv, cwd); present only while alive
cwd = {}
slices = []
last_ts = {}
with open(log_path, errors='replace') as f:
for line in f:
m = LINE.match(line)
if not m:
continue
pid, ts, rest = int(m.group(1)), float(m.group(2)), m.group(3)
last_ts[pid] = ts
if rest.startswith('execve('):
if pid in open_slice: # exec chain within one lifetime: keep the first
continue
argv = extract_argv(rest)
if argv is not None:
open_slice[pid] = (ts, argv, cwd.get(pid))
elif rest.startswith('chdir('):
cm = CHDIR.match(rest)
if cm:
cwd[pid] = cm.group(1)
elif rest.startswith(('exit_group(', 'exit(')):
em = EXIT.match(rest)
prev = open_slice.pop(pid, None)
if prev:
slices.append((pid, tuple(prev[1]), prev[0], ts,
int(em.group(1)) if em else None, prev[2]))
cwd.pop(pid, None)
else:
cm = CLONE_RET.search(rest)
if cm: # parent line; child inherits parent's cwd
cwd[int(cm.group(1))] = cwd.get(pid)
for pid, (start, argv, wd) in open_slice.items(): # killed without a traced exit
slices.append((pid, tuple(argv), start, last_ts.get(pid, start), None, wd))
return slices
def load_test_map(json_path):
"""Build a lookup from a process's (argv, cwd) to its ctest test name."""
d = json.load(open(json_path))
by_argv, by_argv_cwd, by_sig = {}, {}, {}
for t in d['tests']:
argv = tuple(t['command'])
props = {p['name']: p['value'] for p in t.get('properties', [])}
wd = os.path.normpath(props['WORKING_DIRECTORY']) if 'WORKING_DIRECTORY' in props else None
by_argv.setdefault(argv, set()).add(t['name'])
if wd is not None:
by_argv_cwd[(argv, wd)] = t['name']
by_sig.setdefault((argv[0] if argv else None, len(argv), wd), set()).add(t['name'])
def lookup(argv, cwd):
names = by_argv.get(argv)
if names and len(names) == 1:
return next(iter(names))
if cwd is None:
return None
ncwd = os.path.normpath(cwd)
if (argv, ncwd) in by_argv_cwd:
return by_argv_cwd[(argv, ncwd)]
cand = by_sig.get((argv[0] if argv else None, len(argv), ncwd)) # argv truncated by -s
return next(iter(cand)) if cand and len(cand) == 1 else None
return lookup
def short_name(argv):
base = os.path.basename(argv[0]) if argv else '?'
rest = next((a for a in argv[1:] if not a.startswith('-')), '')
return f'{base} {os.path.basename(rest)}'.strip()
def strace_items(slices, lookup, processes_mode):
items = []
for pid, argv, start, end, code, wd in slices:
name = lookup(argv, wd)
if name is not None:
cat = category(name)
elif processes_mode:
name = short_name(list(argv))
cat = os.path.basename(argv[0]) if argv else '?'
else:
continue
items.append({'name': name, 'cat': cat, 'start': start, 'end': max(end, start),
'code': code, 'args': {'os_pid': pid, 'argv': ' '.join(argv)}})
return items
# ---------------------------------------------------------------------------
# Shared: lane packing, Perfetto emission, summary
# ---------------------------------------------------------------------------
def assign_lanes(items):
"""Greedy interval partitioning; returns (lane per item, lane count)."""
import heapq
order = sorted(range(len(items)), key=lambda i: items[i]['start'])
free, next_lane, lanes = [], 0, [0] * len(items)
for i in order:
if free and free[0][0] <= items[i]['start']:
_, lane = heapq.heappop(free)
else:
lane, next_lane = next_lane, next_lane + 1
lanes[i] = lane
heapq.heappush(free, (items[i]['end'], lane))
return lanes, next_lane
def emit_trace(items, out_path, proc_name):
t0 = min(it['start'] for it in items)
lanes, nlanes = assign_lanes(items)
# Lanes map to trace "threads" tid = lane + 1: Perfetto treats tid 0 as the
# kernel idle task and merges it with another track, which would overlap slices.
events = [{'ph': 'M', 'name': 'process_name', 'pid': 1, 'tid': 0,
'args': {'name': proc_name}}]
for lane in range(nlanes):
events.append({'ph': 'M', 'name': 'thread_name', 'pid': 1, 'tid': lane + 1,
'args': {'name': f'lane {lane}'}})
for it, lane in zip(items, lanes):
events.append({'name': it['name'], 'cat': it['cat'], 'ph': 'X',
'ts': round((it['start'] - t0) * 1e6, 3),
'dur': round((it['end'] - it['start']) * 1e6, 3),
'pid': 1, 'tid': lane + 1,
'args': {**it['args'], 'exit_code': it['code']}})
with open(out_path, 'w') as f:
json.dump({'traceEvents': events, 'displayTimeUnit': 'ms'}, f)
return nlanes
def print_summary(items, kind):
t0 = min(it['start'] for it in items)
t1 = max(it['end'] for it in items)
wall = t1 - t0
busy = sum(it['end'] - it['start'] for it in items)
fails = [it for it in items if it['code'] not in (0, None)]
print(f'\n{len(items)} {kind}, wall {wall:.1f}s, summed {busy:.1f}s '
f'({busy / wall:.1f}x concurrency), {len(fails)} nonzero exit')
print(f'top {min(15, len(items))} slowest:')
for it in sorted(items, key=lambda x: x['end'] - x['start'], reverse=True)[:15]:
print(f' {it["end"] - it["start"]:8.2f}s {it["name"]}')
def main():
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument('-o', '--out', default='test_suite_trace.json', help='output trace JSON')
ap.add_argument('--build-dir', default='build/release/stage1', help='ctest build dir')
ap.add_argument('--strace', action='store_true',
help='trace every process via strace (per-process detail; adds overhead)')
ap.add_argument('--processes', action='store_true',
help='with --strace, emit every process, not just tests')
ap.add_argument('--log', help='--strace: reuse an existing strace log (skip running)')
ap.add_argument('--json', help='--strace: reuse an existing ctest json')
ap.add_argument('--keep', action='store_true', help='--strace: keep intermediate log / json')
args, extra = ap.parse_known_args()
build_dir = os.path.abspath(args.build_dir)
out = os.path.abspath(args.out)
passthrough = [a for a in extra if a != '--']
if not any(a == '-j' or a.startswith('-j') for a in passthrough):
passthrough += ['-j', str(os.cpu_count())]
if not args.strace:
items = collect_via_ctest(build_dir, passthrough)
if not items:
sys.exit('no test results parsed from ctest output')
nlanes = emit_trace(items, out, 'test suite')
print(f'\nwrote {out} ({os.path.getsize(out) // 1024} KiB, {len(items)} tests, {nlanes} lanes)')
print_summary(items, 'tests')
return
# --strace path
if args.log:
log_path, json_path = args.log, (args.json or 'ctest_tests.json')
else:
log_path = os.path.abspath('_tmp_strace.log')
json_path = os.path.abspath('_tmp_ctest_tests.json')
with open(json_path, 'w') as jf:
subprocess.run(['ctest', '--test-dir', build_dir, '--show-only=json-v1'],
stdout=jf, check=True)
cmd = ['strace', '-f', '-ttt', '--seccomp-bpf', '-e', f'trace={SYSCALLS}',
'-s', '256', '-qq', '-o', log_path, 'ctest', '--test-dir', build_dir] + passthrough
print('running:', ' '.join(cmd), file=sys.stderr)
subprocess.run(cmd)
lookup = load_test_map(json_path)
slices = parse_strace(log_path)
print(f'parsed {len(slices)} process slices from {log_path}', file=sys.stderr)
items = strace_items(slices, lookup, args.processes)
if not items:
sys.exit('no slices matched; nothing to write')
nlanes = emit_trace(items, out, 'process timeline' if args.processes else 'test suite')
print(f'wrote {out} ({os.path.getsize(out) // 1024} KiB, {len(items)} slices, {nlanes} lanes)')
print_summary(items, 'processes' if args.processes else 'tests')
if not args.log and not args.keep:
for p in (log_path, json_path):
try:
os.remove(p)
except OSError:
pass
if __name__ == '__main__':
main()