blob: e92e2f055d4b5603f1df3d19a005e8e51b3f22d7 [file] [edit]
#!/usr/bin/env python3
# Copyright (C) 2026 The Android Open Source Project
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Counts the instructions benchmarks run and checks them against a baseline.
Runs each benchmark listed in the baseline for a single iteration under
Valgrind's callgrind, counting only the timed loop: costs are zeroed when
Google Benchmark starts the loop and dumped when it finishes, so setup is
left out without changing the benchmark. Counts are deterministic, so they
can be checked on every commit, unlike wall-clock times on shared machines.
Linux only, as Valgrind is. Use an optimized build:
tools/bench_instructions out/linux_clang_release/perfetto_benchmarks
tools/bench_instructions --update out/linux_clang_release/perfetto_benchmarks
A change which moves a count by more than the threshold, either way, fails
the check until the baseline is updated in the same commit, so the baseline's
history is each commit's cost.
"""
import argparse
import concurrent.futures
import glob
import json
import os
import re
import subprocess
import sys
import tempfile
ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
BASELINE = os.path.join(ROOT_DIR, 'test', 'benchmark_instructions.txt')
HEADER = '''\
# Instructions each benchmark's timed loop runs in one iteration, as counted
# by tools/bench_instructions on an x86-64 Linux release build. Lines are
# "<benchmark> <instructions>"; "?" for a count not measured yet. Update with
# `tools/bench_instructions --update <perfetto_benchmarks>`.
'''
def read_baseline(path):
baseline = {}
with open(path) as f:
for line in f:
line = line.split('#', 1)[0].strip()
if not line:
continue
name, count = line.split()
baseline[name] = None if count == '?' else int(count)
return baseline
def write_baseline(path, counts):
width = max(len(name) for name in counts)
with open(path, 'w') as f:
f.write(HEADER)
for name, count in counts.items():
f.write(f'{name.ljust(width)} {count}\n')
def count(binary, name):
with tempfile.TemporaryDirectory() as tmp:
out = os.path.join(tmp, 'callgrind.out')
result = os.path.join(tmp, 'result.json')
cmd = [
'valgrind',
'--tool=callgrind',
f'--callgrind-out-file={out}',
'--zero-before=*State::StartKeepRunning*',
'--dump-before=*State::FinishKeepRunning*',
binary,
f'--benchmark_filter=^{re.escape(name)}$',
'--benchmark_min_time=0',
'--benchmark_repetitions=1',
f'--benchmark_out={result}',
'--benchmark_out_format=json',
]
proc = subprocess.run(cmd, capture_output=True, text=True)
if proc.returncode != 0:
raise RuntimeError(f'{name}: exited with {proc.returncode}\n'
f'{proc.stderr}')
with open(result) as f:
runs = json.load(f)['benchmarks']
if len(runs) != 1:
raise RuntimeError(f'{name}: matched {len(runs)} benchmarks, not one')
dumps = glob.glob(out + '.*')
if len(dumps) != 1:
raise RuntimeError(f'{name}: expected one timed loop, got {len(dumps)}')
with open(dumps[0]) as f:
for line in f:
if line.startswith(('totals:', 'summary:')):
# A benchmark may fix its own iteration count.
return int(line.split()[1]) // runs[0]['iterations']
raise RuntimeError(f'{name}: no instruction count in {dumps[0]}')
def main():
parser = argparse.ArgumentParser(
description=__doc__, formatter_class=argparse.RawTextHelpFormatter)
parser.add_argument('binary', help='Path to perfetto_benchmarks.')
parser.add_argument('--baseline', default=BASELINE)
parser.add_argument(
'--update', action='store_true', help='Rewrite the baseline.')
parser.add_argument(
'--threshold',
type=float,
default=1.0,
help='The change, in percent, beyond which a count fails the check.')
args = parser.parse_args()
baseline = read_baseline(args.baseline)
with concurrent.futures.ThreadPoolExecutor(os.cpu_count()) as pool:
counts = dict(
zip(baseline, pool.map(lambda n: count(args.binary, n), baseline)))
failed = False
print(f'{"benchmark":<48} {"baseline":>14} {"now":>14} {"change":>8}')
for name, now in counts.items():
before = baseline[name]
if before is None:
change = 'new'
else:
percent = (now - before) * 100.0 / max(before, 1)
change = f'{percent:+.2f}%'
if abs(percent) > args.threshold:
change += ' !'
failed = True
print(f'{name:<48} {before if before is not None else "?":>14} '
f'{now:>14} {change:>8}')
if args.update:
write_baseline(args.baseline, counts)
print(f'Updated {os.path.relpath(args.baseline, ROOT_DIR)}')
return 0
if failed:
print(f'\nCounts marked ! moved more than {args.threshold}%. If that is '
'expected, run with --update and commit the baseline.')
return 1
return 0
if __name__ == '__main__':
sys.exit(main())