diff --git a/.github/workflows/benchmark_comment.yml b/.github/workflows/benchmark_comment.yml new file mode 100644 index 00000000..3aa27c49 --- /dev/null +++ b/.github/workflows/benchmark_comment.yml @@ -0,0 +1,123 @@ +name: CI / Benchmark Comment + +permissions: + actions: read + contents: read + issues: write + pull-requests: write + +defaults: + run: + shell: bash + +on: + workflow_run: + workflows: ["CI / Benchmark Compare"] + types: [completed] + +jobs: + benchmark-comment: + if: github.event.workflow_run.event == 'pull_request' + runs-on: ubuntu-24.04 + timeout-minutes: 10 + + steps: + - name: Find benchmark artifact + id: artifact + uses: actions/github-script@v7 + with: + script: | + const run = context.payload.workflow_run; + const {owner, repo} = context.repo; + let pr = run.pull_requests && run.pull_requests[0]; + if (!pr) { + const prs = await github.paginate( + github.rest.repos.listPullRequestsAssociatedWithCommit, + {owner, repo, commit_sha: run.head_sha}); + pr = prs.find((item) => item.state === 'open') || prs[0]; + } + if (!pr) { + core.setOutput('skip', 'true'); + return; + } + + const artifacts = await github.paginate( + github.rest.actions.listWorkflowRunArtifacts, + {owner, repo, run_id: run.id}); + const artifact = artifacts.find((item) => + item.name === 'benchmark-compare' && !item.expired); + if (!artifact) { + core.setOutput('skip', 'true'); + return; + } + + core.setOutput('skip', 'false'); + core.setOutput('pr_number', String(pr.number)); + core.setOutput('artifact_id', String(artifact.id)); + + - name: Download benchmark artifact + if: steps.artifact.outputs.skip != 'true' + env: + GH_TOKEN: ${{ github.token }} + ARTIFACT_ID: ${{ steps.artifact.outputs.artifact_id }} + REPOSITORY: ${{ github.repository }} + run: | + gh api "repos/${REPOSITORY}/actions/artifacts/${ARTIFACT_ID}/zip" > "$RUNNER_TEMP/benchmark-compare.zip" + python3 - <<'PY' + import os + import zipfile + + zip_path = os.path.join(os.environ["RUNNER_TEMP"], "benchmark-compare.zip") + out_path = os.path.join(os.environ["RUNNER_TEMP"], "benchmark_comment.md") + + with zipfile.ZipFile(zip_path) as archive: + names = [ + name for name in archive.namelist() + if name.endswith("benchmark_comment.md") + or name.endswith("benchmark_summary.md") + ] + if not names: + raise SystemExit("benchmark markdown result not found in artifact") + with archive.open(names[0]) as src, open(out_path, "wb") as dst: + dst.write(src.read()) + PY + + - name: Comment benchmark result + if: steps.artifact.outputs.skip != 'true' + uses: actions/github-script@v7 + env: + PR_NUMBER: ${{ steps.artifact.outputs.pr_number }} + with: + script: | + const fs = require('fs'); + const path = require('path'); + const marker = ''; + const commentPath = path.join(process.env.RUNNER_TEMP, 'benchmark_comment.md'); + const result = fs.existsSync(commentPath) + ? fs.readFileSync(commentPath, 'utf8') + : '## Benchmark\n\nBenchmark comparison did not produce a result. Check the workflow logs and artifacts.'; + const body = `${marker}\n${result}`; + const {owner, repo} = context.repo; + const issue_number = Number(process.env.PR_NUMBER); + const comments = await github.paginate(github.rest.issues.listComments, { + owner, + repo, + issue_number, + }); + const existing = comments.find((comment) => + comment.body && comment.body.includes(marker)); + if (existing) { + await github.rest.issues.updateComment({ + owner, + repo, + comment_id: existing.id, + body, + }); + } else { + await github.rest.issues.createComment({ + owner, + repo, + issue_number, + body, + }); + } diff --git a/.github/workflows/benchmark_compare.yml b/.github/workflows/benchmark_compare.yml new file mode 100644 index 00000000..f1ac25b6 --- /dev/null +++ b/.github/workflows/benchmark_compare.yml @@ -0,0 +1,117 @@ +name: CI / Benchmark Compare + +permissions: + contents: read + pull-requests: read + +defaults: + run: + shell: bash + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +on: + pull_request: + workflow_dispatch: + +jobs: + benchmark-compare: + runs-on: ubuntu-24.04 + timeout-minutes: 60 + + steps: + - name: Initialize benchmark result + run: | + cat > "$RUNNER_TEMP/benchmark_comment.md" <<'EOF' + ## Benchmark + + Benchmark comparison did not complete. Check the workflow logs. + + Overall severity: `critical` + + Conclusion: informational only. Benchmark issues are reported in this summary/artifact and do not block the workflow. + EOF + + - name: Checkout base + uses: actions/checkout@v4 + with: + repository: ${{ github.event.pull_request.base.repo.full_name || github.repository }} + ref: ${{ github.event.pull_request.base.sha || github.sha }} + path: base + fetch-depth: 1 + + - name: Checkout head + uses: actions/checkout@v4 + with: + repository: ${{ github.event.pull_request.head.repo.full_name || github.repository }} + ref: ${{ github.event.pull_request.head.sha || github.sha }} + path: head + fetch-depth: 1 + + - name: Cache Bazel + uses: actions/cache@v4 + with: + path: | + ~/.cache/bazel + ~/.cache/bazelisk + key: ${{ runner.os }}-benchmark-bazel-${{ hashFiles('base/.bazelversion', 'head/.bazelversion', 'base/MODULE.bazel.lock', 'head/MODULE.bazel.lock', 'base/WORKSPACE.bzlmod', 'head/WORKSPACE.bzlmod', 'base/BUILD.bazel', 'head/BUILD.bazel') }} + restore-keys: | + ${{ runner.os }}-benchmark-bazel- + + - name: Benchmark base + id: benchmark_base + continue-on-error: true + working-directory: base + run: | + source scripts/bazel_common.sh + BAZEL_BIN="$(sonic_pick_bazel "$PWD")" + "$BAZEL_BIN" run --compilation_mode=opt --//:sonic_arch=haswell --//:sonic_dispatch=static :benchmark -- --benchmark_filter=Sonic --benchmark_repetitions=5 --benchmark_report_aggregates_only=true --benchmark_out="$RUNNER_TEMP/sonic_bench_base.json" --benchmark_out_format=json + + - name: Benchmark head + id: benchmark_head + continue-on-error: true + working-directory: head + run: | + source scripts/bazel_common.sh + BAZEL_BIN="$(sonic_pick_bazel "$PWD")" + "$BAZEL_BIN" run --compilation_mode=opt --//:sonic_arch=haswell --//:sonic_dispatch=static :benchmark -- --benchmark_filter=Sonic --benchmark_repetitions=5 --benchmark_report_aggregates_only=true --benchmark_out="$RUNNER_TEMP/sonic_bench_head.json" --benchmark_out_format=json + + - name: Write benchmark execution failure + if: steps.benchmark_base.outcome == 'failure' || steps.benchmark_head.outcome == 'failure' + run: | + cat > "$RUNNER_TEMP/benchmark_comment.md" <> "$GITHUB_STEP_SUMMARY" + + - name: Compare benchmark + if: steps.benchmark_base.outcome == 'success' && steps.benchmark_head.outcome == 'success' + id: compare + working-directory: head + run: | + python3 scripts/tools/compare-benchmark.py "$RUNNER_TEMP/sonic_bench_base.json" "$RUNNER_TEMP/sonic_bench_head.json" --include-regex=Sonic --warn-threshold=3 --fail-threshold=10 --output="$RUNNER_TEMP/benchmark_comment.md" + cat "$RUNNER_TEMP/benchmark_comment.md" >> "$GITHUB_STEP_SUMMARY" + + - name: Upload benchmark artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: benchmark-compare + if-no-files-found: warn + path: | + ${{ runner.temp }}/sonic_bench_base.json + ${{ runner.temp }}/sonic_bench_head.json + ${{ runner.temp }}/benchmark_comment.md diff --git a/.github/workflows/test_coverage.yml b/.github/workflows/test_coverage.yml index 2ec0bba6..e1b2676b 100644 --- a/.github/workflows/test_coverage.yml +++ b/.github/workflows/test_coverage.yml @@ -72,8 +72,19 @@ jobs: bazel-coverage-html coverage.dat - - name: Upload coverage to CodeCov + - name: Upload pull request coverage to CodeCov + if: github.event_name == 'pull_request' uses: codecov/codecov-action@v4 with: files: coverage.dat verbose: true + fail_ci_if_error: true + + - name: Upload push coverage to CodeCov + if: github.event_name == 'push' + uses: codecov/codecov-action@v4 + with: + use_oidc: true + files: coverage.dat + verbose: true + fail_ci_if_error: true diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..4f351115 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,345 @@ +# sonic-cpp Agent Guide + +This file is for AI coding tools and human contributors working in this +repository. It follows the common AGENTS.md shape used by public projects: +project overview, commands, architecture, style, testing, security, and +agent-specific rules. Keep it practical: prefer facts that can be verified from +source, build files, and tests. + +If a subdirectory gets its own `AGENTS.md` later, treat that more specific file +as overriding this root guide for files under that subtree. + +## Project Shape + +`sonic-cpp` is a header-only, SIMD-accelerated C++ JSON parser/serializer. +The public API is under namespace `sonic_json` and is normally consumed through: + +- `include/sonic/sonic.h` +- `sonic_json::Document` +- `sonic_json::Node` + +The README says C++11 or above, but the active CMake and Bazel builds use +C++17. Treat build files and tests as the practical source of truth. + +Primary source directories: + +- `include/sonic/dom/`: DOM tree, document, parser, handlers, JSON pointer. +- `include/sonic/internal/`: low-level stack, SIMD helpers, arch dispatch. +- `include/sonic/internal/arch/`: x86/Arm/SVE2 implementation details. +- `include/sonic/jsonpath/`: JSONPath query and dump helpers. +- `include/sonic/experiment/`: experimental helpers such as lazy update. +- `tests/`: GoogleTest unit tests. +- `benchmark/`: benchmark binary sources. +- `fuzz/`: CMake-only fuzz target. + +Important public plumbing: + +- Errors: `include/sonic/error.h` +- Allocator: `include/sonic/allocator.h` +- Internal stack: `include/sonic/internal/stack.h` +- Write buffer: `include/sonic/writebuffer.h` +- Parse flags: `include/sonic/dom/flags.h` + +## Agent Workflow + +Before changing behavior: + +1. Read the relevant public header and its tests. +2. Search with `rg`; avoid broad filesystem scans. +3. Preserve existing API style unless the task explicitly asks for a breaking + API change. +4. Add focused tests before or with behavior changes. +5. Run the smallest relevant test first, then the broader suite if the change + touches shared parsing, DOM, allocator, SIMD, or serialization code. + +When reviewing changes: + +- Lead with correctness bugs, memory-safety risks, API compatibility problems, + and missing tests. +- Pay special attention to allocation failure paths. Silent failure is usually + not acceptable in new code. +- Do not simplify arch dispatch or SIMD code without checking both build flags + and tests. + +## Change Checklist + +Use this checklist before handing work back: + +1. The change is scoped to the requested behavior. +2. Public API compatibility has been considered and documented if affected. +3. Allocation failures either propagate `SonicError` or preserve the old object + state for legacy APIs. +4. Relevant unit tests were added or updated. +5. The smallest relevant test was run. +6. Broader tests were run when shared parser, DOM, allocator, SIMD, or + serialization behavior changed. +7. No build outputs, dependency caches, or benchmark artifacts were edited. + +## Build And Test Commands + +Quick command recap: + +| Task | Command | +| --- | --- | +| Configure CMake build | `cmake -S . -B build` | +| Build CMake unit test | `cmake --build build --target unittest -j` | +| Run CMake unit test | `./build/tests/unittest` | +| Run Bazel unit test | `bazel run :unittest --//:sonic_arch=haswell --//:sonic_dispatch=static` | +| Run full Bazel helper | `bash scripts/unittest.sh -g --arch=haswell --dispatch=static` | +| Run benchmark with Bazel | `bazel run :benchmark --compilation_mode=opt` | + +### CMake + +Common local flow: + +```bash +cmake -S . -B build +cmake --build build --target unittest -j +./build/tests/unittest +``` + +Useful CMake options: + +- `BUILD_UNITTEST=ON` by default. +- `BUILD_FUZZ=OFF` by default. +- `BUILD_BENCH=OFF` by default. +- `ENABLE_SVE2_128=OFF` by default. + +Sanitizers in CMake tests: + +- `tests/CMakeLists.txt` enables ASAN by default through `ENABLE_ASAN=ON`. +- UBSAN can be enabled with `-DENABLE_UBSAN=ON`. + +### Bazel + +Bazel uses Bzlmod. `.bazelversion` pins the expected version. + +Useful commands: + +```bash +bazel run :unittest --//:sonic_arch=haswell --//:sonic_dispatch=static +bazel run :benchmark --compilation_mode=opt +bash scripts/unittest.sh -g --arch=haswell --dispatch=static +``` + +Bazel flags: + +- `--//:sonic_arch={default|arm|sve2|westmere|haswell}` +- `--//:sonic_dispatch={static|dynamic}` +- `--//:sonic_sanitizer={no|gcc|clang}` + +Note: `scripts/unittest.sh` accepts `--arch=aarch64` and `--arch=arm64`, then +maps them to Bazel's `arm` setting. When invoking Bazel directly, use +`--//:sonic_arch=arm`. + +## Error Handling And Allocation Rules + +Code in this repository often runs in parser, serializer, allocator, and SIMD +hot paths. Allocation failure handling must be explicit enough that callers can +distinguish resource failure from valid empty/null JSON values. + +Preferred patterns: + +- Parser/handler failures should return a concrete `SonicError`, especially + `kErrorNoMem` for allocation failure. +- APIs that can fail should expose failure through the existing project style: + `ParseResult`, `SonicError`, boolean success, or allocator error state. +- Legacy public APIs may need to keep source-compatible return types. In that + case, preserve the previous object state on allocation failure whenever + practical and provide or use a checked path for callers that need diagnostics. +- Do not ignore return values from low-level buffer, stack, allocator, parser, + or handler methods that can fail. +- Check integer overflow before size arithmetic for allocations, padding, + capacity growth, and SIMD lookahead buffers. + +Key places to inspect for allocation-sensitive changes: + +- `include/sonic/allocator.h` +- `include/sonic/internal/stack.h` +- `include/sonic/writebuffer.h` +- `include/sonic/dom/parser.h` +- `include/sonic/dom/handler.h` +- `include/sonic/dom/schema_handler.h` +- `include/sonic/dom/dynamicnode.h` +- `tests/allocator_test.cpp` +- parser/DOM tests under `tests/` + +## DOM And Memory Model + +`GenericDocument` owns or references an allocator and is also the root JSON +node. Re-parsing a document discards the previous tree; any raw pointer, +iterator, or node reference from the old tree must be reacquired after parse. + +`DNode` stores arrays and objects in compact contiguous buffers. Object member +keys are part of the container's lookup invariants; avoid APIs or internal +changes that let callers mutate keys in a way that invalidates cached lookup +structures. + +String storage has two modes: + +- Non-owning string views for parsed/raw/const strings. +- Allocator-backed copies for APIs that copy strings. + +When copying or mutating DOM nodes: + +- Prefer commit-after-success updates for operations that can fail halfway. +- Keep source compatibility for existing public APIs where possible. +- Avoid `memmove`/raw byte copies for non-trivial node/member objects unless + the type is explicitly safe for that operation. + +## ParseOnDemand And JSONPath + +`ParseOnDemand` is optimized to find a target subtree without fully materializing +the document. + +Rule of thumb: + +- Default behavior should preserve the fast short-circuit path. +- Full-document validation in on-demand paths is a semantic and performance + choice; do not add it by default unless the API or caller explicitly asks for + it. +- Skipped branches still must not swallow local parse errors such as malformed + strings, invalid numbers, missing separators, or impossible object/array + syntax. + +Relevant files: + +- `include/sonic/dom/parser.h` +- `include/sonic/internal/arch/simd_skip.h` +- `include/sonic/jsonpath/*.h` +- `tests/document_test.cpp` +- `tests/jsonpath_test.cpp` +- `tests/json_tuple_test.cpp` + +## SIMD And Architecture Dispatch + +The SIMD layer has static and dynamic dispatch modes. Do not assume only AVX2 +exists even though x86 AVX2 is the primary documented target. + +Important files: + +- `include/sonic/internal/arch/sonic_cpu_feature.h` +- `include/sonic/internal/arch/simd_dispatch.h` +- `include/sonic/internal/arch/avx2/` +- `include/sonic/internal/arch/common/` +- `include/sonic/internal/arch/neon/` +- `include/sonic/internal/arch/sve2-128/` +- `include/sonic/internal/arch/x86_ifuncs/` + +When touching shared SIMD helpers, validate both parser and skip/on-demand +tests. If possible, also test `--//:sonic_dispatch=dynamic` on x86. + +## Coding Style + +- Follow root `.clang-format` (`BasedOnStyle: Google`). +- Keep public headers self-contained. +- Do not add heavyweight dependencies to the header-only library. +- Prefer simple, explicit control flow in parser and allocator code. +- Keep comments sparse and useful; explain invariants and failure handling, + not obvious assignments. +- Default to ASCII in source and docs unless a file already uses non-ASCII. + +## Testing Guidance + +Choose tests based on the touched area: + +- Allocator/stack/write buffer: `tests/allocator_test.cpp`, + `tests/writebuffer_test.cpp`, `tests/parser_oom_test.cpp`. +- DOM mutation/copy/member map: `tests/node_test.cpp`, + `tests/document_test.cpp`, `tests/parser_oom_test.cpp`. +- Full parser and lazy parser: `tests/parser_oom_test.cpp`, + `tests/document_test.cpp`. +- Parse schema: `tests/parse_schema_test.cpp`. +- Parse on demand / JSON pointer: `tests/document_test.cpp`, + `tests/json_pointer_test.cpp`. +- JSONPath / tuple extraction: `tests/jsonpath_test.cpp`, + `tests/json_tuple_test.cpp`. +- SIMD skip scanner: `tests/skip_test.cpp`. + +For broad validation, run: + +```bash +cmake --build build --target unittest -j +./build/tests/unittest +``` + +or: + +```bash +bash scripts/unittest.sh -g --arch=haswell --dispatch=static +``` + +Testing philosophy: + +- Test public behavior first; avoid overfitting tests to private helper details. +- For bug fixes, add a regression test that fails on the old behavior. +- For allocation-failure fixes, assert both the reported error and the + preserved state when preservation is part of the contract. +- For parser fixes, include malformed input around the exact branch being + changed, not only a happy-path JSON sample. +- For performance-sensitive parser/SIMD changes, keep tests deterministic and + put speed measurements in benchmarks, not unit tests. + +## Benchmarking + +Use benchmarks for changes that affect: + +- object lookup or member insertion/removal, +- parser hot loops, +- SIMD skip/string paths, +- serialization, +- allocator growth behavior. + +Commands: + +```bash +cmake -S . -B build-bench -DBUILD_BENCH=ON +cmake --build build-bench --target bench -j +./build-bench/benchmark/bench +``` + +or: + +```bash +bazel run :benchmark --compilation_mode=opt +``` + +## Common Pitfalls + +- Do not treat `operator[]` missing-member behavior as mutable storage; prefer + `FindMember`. +- Do not mutate object member keys through iterators or internal aliases unless + every affected lookup structure is rebuilt or updated. +- Do not add parse-on-demand full validation by default unless the task accepts + a performance/semantic change. +- Do not ignore trailing characters in full parse paths. +- Do not use throwing allocation in low-level parser/SAX paths when the + surrounding code expects explicit error propagation. +- Do not change public type layout casually; this is a header-only library and + downstream code may depend on source-level details. +- Do not modify generated build outputs, `build/`, `bazel-*`, or benchmark + result artifacts unless explicitly asked. + +## PR / Handoff Notes + +When summarizing work for another tool or reviewer: + +- List behavior changes first, then files touched. +- State which tests were run and which were not run. +- Call out API compatibility, memory-safety, and performance tradeoffs. +- Mention any remaining risk if only a narrow test was run. +- For large parser or SIMD changes, include the exact architecture/dispatch mode + used for validation. + +## Security Notes + +Inputs should be treated as untrusted JSON. The usage docs state that UTF-8 is +assumed and not verified by default. Always check parse results with: + +- `HasParseError()` +- `GetParseError()` +- `GetErrorOffset()` +- `ErrorMsg(...)` + +Security issues should not be disclosed through public issues. Follow +`CONTRIBUTING.md` for the reporting contact. diff --git a/scripts/tools/compare-benchmark.py b/scripts/tools/compare-benchmark.py new file mode 100644 index 00000000..63467bcb --- /dev/null +++ b/scripts/tools/compare-benchmark.py @@ -0,0 +1,163 @@ +#!/usr/bin/env python3 +import argparse +import json +import re +import sys + +TIME_UNIT_SCALE = { + "": 1.0, + "ns": 1e-9, + "us": 1e-6, + "ms": 1e-3, + "s": 1.0, +} + + +def load_rows(path, include_regex): + include_pattern = re.compile(include_regex) if include_regex else None + with open(path, "r", encoding="utf-8") as f: + data = json.load(f) + + grouped = {} + for row in data.get("benchmarks", []): + name = row.get("run_name") or row.get("name") + if not name: + continue + if include_pattern and not include_pattern.search(name): + continue + if name.endswith("_stddev") or name.endswith("_cv"): + continue + grouped.setdefault(name, []).append(row) + + selected = {} + for name, rows in grouped.items(): + choice = next((r for r in rows if r.get("aggregate_name") == "median"), + None) + if choice is None: + choice = next((r for r in rows if r.get("aggregate_name") == "mean"), + None) + if choice is None: + choice = rows[0] + selected[name] = choice + return selected + + +def real_time(row): + try: + return float(row.get("real_time", 0.0)) + except (TypeError, ValueError): + return 0.0 + + +def normalized_real_time(row): + unit = time_unit(row) + if unit not in TIME_UNIT_SCALE: + raise ValueError(f"unsupported Google Benchmark time_unit: {unit!r}") + return real_time(row) * TIME_UNIT_SCALE[unit] + + +def time_unit(row): + return row.get("time_unit", "") + + +def format_time(value, unit): + if unit: + return f"{value:.2f} {unit}" + return f"{value:.2f}" + + +def row_severity(delta, warn_threshold, critical_threshold): + if delta > critical_threshold: + return "critical" + if delta > warn_threshold: + return "warning" + return "info" + + +def main(): + parser = argparse.ArgumentParser( + description="Compare two Google Benchmark JSON outputs.") + parser.add_argument("base") + parser.add_argument("head") + parser.add_argument("--include-regex", default="Sonic") + parser.add_argument("--warn-threshold", type=float, default=3.0) + parser.add_argument("--fail-threshold", type=float, default=10.0) + parser.add_argument( + "--fail-on-critical", + action="store_true", + help="Exit non-zero when overall severity is critical.") + parser.add_argument("--output", required=True) + args = parser.parse_args() + + base_rows = load_rows(args.base, args.include_regex) + head_rows = load_rows(args.head, args.include_regex) + names = sorted(set(base_rows) & set(head_rows)) + mode = "gating" if args.fail_on_critical else "informational" + + lines = [ + "## Benchmark", + "", + f"Mode: `{mode}`", + f"Warning threshold: `>{args.warn_threshold:g}%` regression", + f"Critical threshold: `>{args.fail_threshold:g}%` regression", + f"Included benchmarks: `{args.include_regex or 'all'}`", + "", + "| Severity | Benchmark | base real_time | head real_time | delta |", + "| --- | --- | ---: | ---: | ---: |", + ] + + severity = "ok" + emitted = False + for name in names: + base_normalized_time = normalized_real_time(base_rows[name]) + head_normalized_time = normalized_real_time(head_rows[name]) + if base_normalized_time <= 0.0: + continue + + delta = ((head_normalized_time - base_normalized_time) * 100.0 / + base_normalized_time) + if abs(delta) >= args.warn_threshold: + emitted = True + base_time = real_time(base_rows[name]) + head_time = real_time(head_rows[name]) + base_unit = time_unit(base_rows[name]) + head_unit = time_unit(head_rows[name]) + item_severity = row_severity(delta, args.warn_threshold, + args.fail_threshold) + lines.append( + f"| {item_severity} | `{name}` | " + f"{format_time(base_time, base_unit)} | " + f"{format_time(head_time, head_unit)} | {delta:+.2f}% |") + if delta > args.fail_threshold: + severity = "critical" + elif delta > args.warn_threshold and severity != "critical": + severity = "warning" + + if not names: + severity = "critical" + lines.append("| critical | No comparable benchmark rows found. | | | |") + elif not emitted: + lines.append("| ok | No benchmark changed by the warning threshold. | | | |") + + lines.extend([ + "", + f"Overall severity: `{severity}`", + "", + ("Conclusion: critical benchmark regressions fail this run." + if args.fail_on_critical else + "Conclusion: informational only. Benchmark regressions are reported " + "in this summary/artifact and do not block the workflow."), + ]) + + with open(args.output, "w", encoding="utf-8") as f: + f.write("\n".join(lines)) + f.write("\n") + + print("\n".join(lines)) + if args.fail_on_critical and severity == "critical": + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main())