Files
greptimedb/.github/scripts/tracesbench-summary.py
T
Weny Xu 6e1a137411 ci: enhance benchmark workflows with version comparisons and diagnostics (#9383)
* ci: update Agent and Traces benchmark runtime

Signed-off-by: WenyXu <wenymedia@gmail.com>

* ci: use unrestricted benchmark runtimes by default

Signed-off-by: WenyXu <wenymedia@gmail.com>

* ci: retry benchmark image pulls before generating datasets

Signed-off-by: WenyXu <wenymedia@gmail.com>

* ci: increase tracesbench defaults to 8 CPU and 32 GiB

Signed-off-by: WenyXu <wenymedia@gmail.com>

* ci: configure tracesbench query timeout and update runtime

Signed-off-by: WenyXu <wenymedia@gmail.com>

* ci: update tracesbench runtime and configure import scan workers

Signed-off-by: WenyXu <wenymedia@gmail.com>

* ci: use shared parallel tracesbench import preparation

Signed-off-by: WenyXu <wenymedia@gmail.com>

* ci: update tracesbench runtime to preserve Tempo trace requests

Signed-off-by: WenyXu <wenymedia@gmail.com>

* ci: summarize tracesbench query results and lifecycle

Signed-off-by: WenyXu <wenymedia@gmail.com>

* ci: show benchmark resource usage and update parallel loader runtime

Signed-off-by: WenyXu <wenymedia@gmail.com>

* fix(ci): resolve shared summary module in existing test harness

Signed-off-by: WenyXu <wenymedia@gmail.com>

* ci: select tracesbench engines and GreptimeDB image version

Signed-off-by: WenyXu <wenymedia@gmail.com>

* ci: update benchmark runtime with parallel Agent preparation

Signed-off-by: WenyXu <wenymedia@gmail.com>

* ci: select benchmark runtime registry by cloud provider

Signed-off-by: WenyXu <wenymedia@gmail.com>

* ci: retry long-range benchmark image pulls

Signed-off-by: WenyXu <wenymedia@gmail.com>

* ci: compare baseline and candidate GreptimeDB benchmarks

Signed-off-by: WenyXu <wenymedia@gmail.com>

* ci: pin published dual-version benchmark runtime

Signed-off-by: WenyXu <wenymedia@gmail.com>

* ci: prepare agent benchmark indexes with updated runtime

Signed-off-by: WenyXu <wenymedia@gmail.com>

* ci: validate agent admission and render traces version comparison

Signed-off-by: WenyXu <wenymedia@gmail.com>

* ci: use runtime with traces index preparation

Signed-off-by: WenyXu <wenymedia@gmail.com>

* ci: use consolidated runtime for benchmark version reports

Signed-off-by: WenyXu <wenymedia@gmail.com>

* test: add license header to benchmark comparison tests

Signed-off-by: WenyXu <wenymedia@gmail.com>

* fix(ci): read legacy Tempo ingestion metrics in traces summaries

Signed-off-by: WenyXu <wenymedia@gmail.com>

* ci: require tracesbench query plan artifacts

Signed-off-by: WenyXu <wenymedia@gmail.com>

* ci: use optimized tracesbench report runtime

Signed-off-by: WenyXu <wenymedia@gmail.com>

* ci: update tracesbench runtime to merged report validation

Signed-off-by: WenyXu <wenymedia@gmail.com>

---------

Signed-off-by: WenyXu <wenymedia@gmail.com>
2026-10-09 12:39:51 +00:00

170 lines
8.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Copyright 2023 Greptime Team
#
# 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.
import argparse
import json
import html
import math
from pathlib import Path
from benchmark_resource_summary import render_resources
TARGETS = ('greptimedb', 'victoriatraces', 'tempo')
def duration(ms):
if not isinstance(ms, (int, float)) or not math.isfinite(ms) or ms < 0:
return '—'
if ms < 1:
return f'{ms * 1000:.0f} µs'
if ms < 1000:
return f'{ms:.1f} ms'
if ms < 60_000:
return f'{ms / 1000:.2f} s'
minutes, seconds = divmod(round(ms / 1000), 60)
hours, minutes = divmod(minutes, 60)
return f'{hours}h {minutes}m {seconds}s' if hours else f'{minutes}m {seconds}s'
def cell(value):
return html.escape(str(value if value is not None else '—')).replace('|', '&#124;').replace('\n', ' ')
def read(path):
if not path.exists():
return {}
return json.loads(path.read_text())
def table(headers, rows):
return '\n'.join(['| ' + ' | '.join(map(cell, headers)) + ' |',
'| ' + ' | '.join('---' for _ in headers) + ' |'] +
['| ' + ' | '.join(map(cell, row)) + ' |' for row in rows])
def delta(value, baseline):
if not all(isinstance(v, (int, float)) and math.isfinite(v) for v in (value, baseline)):
return '—'
return f'{(value / baseline - 1) * 100:+.1f}%' if baseline > 0 and value >= 0 else '—'
def load(root):
report = read(root / 'report.json')
runs = [read(p) for p in sorted((root / 'runs').glob('*/run.json'))]
return report, runs
def benchmark(report, runs, targets=TARGETS):
lines = ['# Tracesbench benchmark summary', '',
'Median Δ = (target / GreptimeDB − 1) × 100%; negative is faster. '
'Min/median/max come from the validated report, not recalculated samples. '
'Counts exclude warmup. Missing or excluded results have no latency comparison.',
'', f'Classification: {cell(report.get("classification"))}. '
'See the uploaded HTML for query semantics and client-side compensation.', '']
statistics = {}
for corpus in report.get('per_corpus', []):
for stat in corpus.get('statistics', []):
if stat['target'] not in targets:
continue
statistics[(corpus['corpus'], stat['round'], stat['target'], stat['query_id'])] = stat
rows, details = [], []
for target in targets:
selected = [run for run in runs if run.get('target') == target]
if not selected:
lines.append(f'- **{target}**: unavailable / not run.')
for run in selected:
lines.append(f'- **{target}**: lifecycle '
f'{"completed" if run.get("success") is True else "failed / incomplete"}; '
f'query failures: {len(run.get("query_failures", []))}.')
for qid in (f'T{i:02}' for i in range(1, 9)):
correct = [q for q in run.get('correctness', []) if q.get('query_id') == qid]
measured = [q for q in run.get('samples', [])
if q.get('query_id') == qid and q.get('phase') == 'measured']
errors = [q for q in correct + run.get('samples', [])
if q.get('query_id') == qid and q.get('outcome') not in ('passed', 'not_executed')]
reason = (run.get('exclusions', {}).get(qid) or
run.get('execution', {}).get('query_skips', {}).get(qid))
stat = statistics.get((run.get('corpus'), run.get('round'), target, qid), {})
base = statistics.get((run.get('corpus'), run.get('round'), 'greptimedb', qid), {})
status = ('failed' if errors else 'excluded' if reason else
'passed' if stat else
'correctness passed; report unavailable' if correct and
all(q.get('outcome') == 'passed' for q in correct) else 'unavailable / incomplete')
rows.append([run.get('corpus'), run.get('round'), qid, target, status,
duration(stat.get('min_ms')), duration(stat.get('median_ms')),
duration(stat.get('max_ms')),
delta(stat.get('median_ms'), base.get('median_ms')) if target != 'greptimedb' else '—',
sum(q.get('outcome') == 'passed' for q in measured),
sum(q.get('outcome') != 'passed' for q in measured)])
if reason or errors:
messages = [str(q.get('error') or q.get('outcome')) for q in errors]
details.append(f'- **{target} / {qid}**: {cell(str(reason or "") + " " + "; ".join(messages))[:4000]}')
lines += ['', table(['Dataset', 'Round', 'Query', 'DB', 'Status', 'Min', 'Median', 'Max',
'Median Δ', 'Measured success', 'Measured errors'], rows), '',
'<details>', '<summary>Query failures and exclusions</summary>', '', *details, '', '</details>']
if not report:
lines += ['', 'Validated report unavailable; raw outcomes are shown without latency statistics.']
return '\n'.join(lines)
def lifecycle(report, runs, root, targets=TARGETS):
rows = []
for target in targets:
selected = [r for r in runs if r.get('target') == target] or [{}]
for run in selected:
load = run.get('load', {})
ingest = load.get('ingest', {})
if target == 'tempo' and 'ingest' not in load:
ingest = load.get('transport', {}).get('ingest', {})
health = run.get('post_load_health', {})
execution = run.get('execution', {})
start, finish = run.get('started_unix_ns'), run.get('finished_unix_ns')
elapsed = (finish - start) / 1_000_000 if isinstance(start, int) and isinstance(finish, int) else None
seconds = ingest.get('wall_seconds')
rows.append([target, run.get('corpus'), run.get('load_summary', {}).get('after'),
duration(elapsed), duration(seconds * 1000 if isinstance(seconds, (int, float)) else None),
ingest.get('successful_requests'), ingest.get('failed_requests'),
execution.get('load_workers_requested'), execution.get('max_in_flight'),
execution.get('db_cpus'), execution.get('db_memory'),
health.get('running'), health.get('oom_killed'),
run.get('cleanup', {}).get('verified_absent')])
return '\n'.join(['# Tracesbench load / lifecycle summary', '',
'Total is the recorded target lifecycle; ingest is the sender wall time, '
'not the entire load/readiness phase. Health is the post-load snapshot. '
'Missing evidence remains blank.', '',
table(['DB', 'Dataset', 'Loaded spans', 'Total', 'Ingest',
'Write success', 'Write errors', 'Load workers', 'Max in flight',
'DB CPU', 'DB memory', 'Running after load', 'OOM after load',
'Container removed'], rows), '',
render_resources([('dataset', root / 'runs' / 'generate')] +
[(t, root / 'runs' / t) for t in targets], table)])
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--root', type=Path, required=True)
parser.add_argument('--section', choices=('benchmark', 'lifecycle'), required=True)
parser.add_argument('--targets', default=','.join(TARGETS), help='Comma-separated selected engines')
args = parser.parse_args()
targets = args.targets.split(',')
if not targets or any(t not in TARGETS for t in targets) or len(set(targets)) != len(targets):
parser.error('targets must be a non-empty comma-separated subset of greptimedb,victoriatraces,tempo')
report, runs = load(args.root)
print(benchmark(report, runs, targets) if args.section == 'benchmark' else lifecycle(report, runs, args.root, targets))
if __name__ == '__main__':
main()