diff --git a/.github/workflows/memory-benchmark.yml b/.github/workflows/memory-benchmark.yml new file mode 100644 index 00000000..992587b6 --- /dev/null +++ b/.github/workflows/memory-benchmark.yml @@ -0,0 +1,44 @@ +name: Python SDK memray memory benchmark + +on: + pull_request: + types: + - opened + - reopened + - synchronize + - labeled + +permissions: + contents: read + +jobs: + memory-benchmark: + name: Python SDK memray memory benchmark + # Needs to match the arch the baseline was generated on. + runs-on: ubuntu-24.04-arm + if: | + contains(github.event.pull_request.labels.*.name, 'check-memory-benchmark') && + ( + github.event.pull_request.author_association == 'COLLABORATOR' || + github.event.pull_request.author_association == 'MEMBER' || + github.event.pull_request.author_association == 'OWNER' + ) + steps: + - uses: actions/checkout@v4 + + # Build the perf image. + - name: Build memray perf image + run: make perf-image-rebuild + + # Uses the Dockerfile environment for repeatable runs. + - name: Run memray memory benchmark + run: make memory-use-bench + + # Upload all three flamegraph views per scenario (peak/leaks/temporary). + - name: Upload flamegraph reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: memray-flamegraphs + path: tests/perf/reports/*.html + if-no-files-found: warn diff --git a/Makefile b/Makefile index 06afe998..d4e7fffd 100644 --- a/Makefile +++ b/Makefile @@ -62,10 +62,6 @@ test: $(PYTHON) ./tests/test_unit_tests.py $(PYTHON) ./tests/test_unit_tests_threaded.py -# Runs benchmarks in the venv -benchmark: - $(PYTHON) -m pytest tests/benchmark.py -v - # Tests building and installing a local wheel package # Downloads required artifacts, builds the wheel, installs it, and verifies the installation test-local-wheel-build: @@ -148,10 +144,26 @@ MEMRAY_ITERATIONS ?= 100 MEMRAY_THRESHOLD ?= 1.1 SCENARIO ?= SCENARIO_ARG := $(if $(SCENARIO),--scenario $(SCENARIO),) +# In CI, use en vars to write the report to the job run +GH_SUMMARY_MOUNT := $(if $(GITHUB_STEP_SUMMARY),-v $(GITHUB_STEP_SUMMARY):$(GITHUB_STEP_SUMMARY),) +# Build the perf Docker image only if it is missing. The repo is bind-mounted at +# run time and the Dockerfile only COPYs requirements*.txt, so latest Python code +# is picked up without a rebuild; rebuild is only needed when deps/Dockerfile +# change (use perf-image-rebuild for that). +.PHONY: perf-image +perf-image: + @docker image inspect c2pa-memray-$(PERF_ENV) >/dev/null 2>&1 || \ + docker build -f tests/perf/Dockerfiles/$(PERF_ENV)-perf-Dockerfile -t c2pa-memray-$(PERF_ENV) . + +# Force a clean rebuild of the memray perf Docker image +.PHONY: perf-image-rebuild +perf-image-rebuild: + docker build --no-cache --pull -f tests/perf/Dockerfiles/$(PERF_ENV)-perf-Dockerfile -t c2pa-memray-$(PERF_ENV) . + +# Runs memory benchmarks. Pre-requisite: Docker image built using `make perf-image-rebuild`. .PHONY: memory-use-bench memory-use-bench: - docker build -f tests/perf/Dockerfiles/$(PERF_ENV)-perf-Dockerfile -t c2pa-memray-$(PERF_ENV) . - docker run --rm -v $(PWD):/workspace -e PYTHONPATH=/workspace/src -e PERF_ENV=$(PERF_ENV) -e MEMRAY_ITERATIONS=$(MEMRAY_ITERATIONS) -e MEMRAY_THRESHOLD=$(MEMRAY_THRESHOLD) c2pa-memray-$(PERF_ENV) python -m tests.perf.run_profile $(SCENARIO_ARG) $(PERF_ARGS) + docker run --rm -v $(PWD):/workspace $(GH_SUMMARY_MOUNT) -e PYTHONPATH=/workspace/src -e PERF_ENV=$(PERF_ENV) -e MEMRAY_ITERATIONS=$(MEMRAY_ITERATIONS) -e MEMRAY_THRESHOLD=$(MEMRAY_THRESHOLD) -e GITHUB_TOKEN -e GITHUB_STEP_SUMMARY c2pa-memray-$(PERF_ENV) python -m tests.perf.run_profile $(SCENARIO_ARG) $(PERF_ARGS) @echo "" @echo "Reports written to tests/perf/reports/" @echo "Open tests/perf/reports/-{peak,leaks,temporary}.html in a browser" diff --git a/requirements-dev.txt b/requirements-dev.txt index ae6c7a61..083439e7 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -6,7 +6,6 @@ toml==0.10.2 # For reading pyproject.toml files # Testing dependencies pytest>=8.1.0 -pytest-benchmark>=5.1.0 # for downloading the library artifacts requests>=2.0.0 diff --git a/tests/benchmark.py b/tests/benchmark.py deleted file mode 100644 index c576c272..00000000 --- a/tests/benchmark.py +++ /dev/null @@ -1,147 +0,0 @@ -# Copyright 2025 Adobe. All rights reserved. -# This file is licensed to you under the Apache License, -# Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) -# or the MIT license (http://opensource.org/licenses/MIT), -# at your option. - -# Unless required by applicable law or agreed to in writing, -# this software is distributed on an "AS IS" BASIS, WITHOUT -# WARRANTIES OR REPRESENTATIONS OF ANY KIND, either express or -# implied. See the LICENSE-MIT and LICENSE-APACHE files for the -# specific language governing permissions and limitations under -# each license. - -import os -import io -import json -import shutil -from c2pa import Reader, Builder, Signer, C2paSigningAlg, C2paSignerInfo - -PROJECT_PATH = os.getcwd() - -# Test paths -test_path = os.path.join(PROJECT_PATH, "tests", "fixtures", "C.jpg") -temp_dir = os.path.join(PROJECT_PATH, "tests", "temp") -output_path = os.path.join(temp_dir, "python_out.jpg") - -# Ensure temp directory exists -os.makedirs(temp_dir, exist_ok=True) - -manifestDefinition = { - "claim_generator": "python_test", - "claim_generator_info": [{ - "name": "python_test", - "version": "0.0.1", - }], - "format": "image/jpeg", - "title": "Python Test Image", - "ingredients": [], - "assertions": [ - { - "label": "c2pa.actions", - "data": { - "actions": [ - { - "action": "c2pa.created", - "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation" - } - ] - } - } - ] -} - -# Load private key and certificates -private_key = open("tests/fixtures/ps256.pem", "rb").read() -certs = open("tests/fixtures/ps256.pub", "rb").read() - -# Create a local Ps256 signer with certs and a timestamp server -signer_info = C2paSignerInfo( - alg=b"ps256", - sign_cert=certs, - private_key=private_key, - ta_url=b"http://timestamp.digicert.com" -) -signer = Signer.from_info(signer_info) -builder = Builder(manifestDefinition) - -# Load source image -source = open(test_path, "rb").read() - -# Run the benchmark: python -m pytest tests/benchmark.py -v - - -def test_files_read(): - """Benchmark reading a C2PA asset from a file.""" - with open(test_path, "rb") as f: - reader = Reader("image/jpeg", f) - result = reader.json() - reader.close() - assert result is not None - # Parse the JSON string into a dictionary - result_dict = json.loads(result) - # Additional assertions to verify the structure of the result - assert "active_manifest" in result_dict - assert "manifests" in result_dict - assert "validation_state" in result_dict - assert result_dict["validation_state"] == "Valid" - - -def test_streams_read(): - """Benchmark reading a C2PA asset from a stream.""" - with open(test_path, "rb") as file: - source = file.read() - reader = Reader("image/jpeg", io.BytesIO(source)) - result = reader.json() - reader.close() - assert result is not None - # Parse the JSON string into a dictionary - result_dict = json.loads(result) - # Additional assertions to verify the structure of the result - assert "active_manifest" in result_dict - assert "manifests" in result_dict - assert "validation_state" in result_dict - assert result_dict["validation_state"] == "Valid" - - -def test_files_build(): - """Benchmark building a C2PA asset from a file.""" - # Delete the output file if it exists - if os.path.exists(output_path): - os.remove(output_path) - with open(test_path, "rb") as source_file: - with open(output_path, "w+b") as dest_file: - builder.sign(signer, "image/jpeg", source_file, dest_file) - - -def test_streams_build(): - """Benchmark building a C2PA asset from a stream.""" - output = io.BytesIO(bytearray()) - with open(test_path, "rb") as source_file: - builder.sign(signer, "image/jpeg", source_file, output) - - -def test_files_reading(benchmark): - """Benchmark file-based reading.""" - benchmark(test_files_read) - - -def test_streams_reading(benchmark): - """Benchmark stream-based reading.""" - benchmark(test_streams_read) - - -def test_files_builder_signer_benchmark(benchmark): - """Benchmark file-based building.""" - benchmark(test_files_build) - - -def test_streams_builder_benchmark(benchmark): - """Benchmark stream-based building.""" - benchmark(test_streams_build) - - -def teardown_module(module): - """Clean up temporary files after all tests.""" - if os.path.exists(temp_dir): - shutil.rmtree(temp_dir) diff --git a/tests/perf/Dockerfiles/python-3.10-slim-perf-Dockerfile b/tests/perf/Dockerfiles/python-3.10-slim-perf-Dockerfile index 0db28b11..100a082f 100644 --- a/tests/perf/Dockerfiles/python-3.10-slim-perf-Dockerfile +++ b/tests/perf/Dockerfiles/python-3.10-slim-perf-Dockerfile @@ -2,7 +2,7 @@ FROM python:3.10.20-slim-bookworm WORKDIR /workspace -# libunwind for memray native stack unwinding +# libunwind-dev for memray native stack unwinding. RUN apt-get update && apt-get install -y --no-install-recommends \ libunwind-dev \ ca-certificates \ diff --git a/tests/perf/Dockerfiles/python-3.12-slim-perf-Dockerfile b/tests/perf/Dockerfiles/python-3.12-slim-perf-Dockerfile index 1e387d1c..03968dbc 100644 --- a/tests/perf/Dockerfiles/python-3.12-slim-perf-Dockerfile +++ b/tests/perf/Dockerfiles/python-3.12-slim-perf-Dockerfile @@ -2,7 +2,7 @@ FROM python:3.12.13-slim-bookworm WORKDIR /workspace -# libunwind for memray native stack unwinding +# libunwind-dev for memray native stack unwinding. RUN apt-get update && apt-get install -y --no-install-recommends \ libunwind-dev \ ca-certificates \ diff --git a/tests/perf/README.md b/tests/perf/README.md index cccc60de..1e2baf41 100644 --- a/tests/perf/README.md +++ b/tests/perf/README.md @@ -67,6 +67,16 @@ The trailing `VAR=value` arguments (e.g. `PERF_ENV=ubuntu-24.04`, `PERF_ARGS=--u Reports are written to `tests/perf/reports/` on the local machine. Three HTML files per scenario, one per suffix (described below). Open any in a browser. After a run, the run also reports if the scenarios were or were not all within baseline threshold (baseline +10% memory use tolerance). +## Running in CI + +The `.github/workflows/memory-benchmark.yml` workflow runs the Docker-based benchmarks on a PR, but only when the PR has the `check-memory-benchmark` label. This runs `make memory-use-bench`, so: + +- A regression (peak or leaked > baseline +10%) makes the benchmark job exit non-zero. +- A values report table is written to the job's Step Summary. +- All three flamegraph HTML views per scenario are uploaded as the `memray-flamegraphs` artifact. + +The gate only acts as regression test once a `tests/perf/baseline.json` is committed on the branch. Without one, `run_profile.py` treats the run as baseline creation (exits 0, no gating). + ## Report views Each scenario produces three [memray flamegraphs](https://bloomberg.github.io/memray/flamegraph.html). All three are flamegraphs of the same run. They differ only in which allocations they count. diff --git a/tests/perf/baseline.json b/tests/perf/baseline.json index 6908e7da..7af9ffb5 100644 --- a/tests/perf/baseline.json +++ b/tests/perf/baseline.json @@ -2,194 +2,224 @@ "_meta": { "memray_version": "1.19.3", "python_version": "3.12.13", - "c2pa_native_version": "c2pa-v0.88.0", - "iterations": 250, + "c2pa_native_version": "c2pa-v0.89.0", + "iterations": 100, "perf_env": "python-3.12-slim", "arch": "aarch64" }, "reader_jpeg_legacy": { - "peak_bytes": 3762136, - "leaked_bytes": 3262031, - "total_allocations": 1660100 + "peak_bytes": 3791301, + "leaked_bytes": 3292672, + "total_allocations": 722823 }, "reader_jpeg_with_context": { - "peak_bytes": 3756118, - "leaked_bytes": 3254069, - "total_allocations": 1646550 + "peak_bytes": 3785583, + "leaked_bytes": 3284873, + "total_allocations": 717272 + }, + "reader_manifest_data_context": { + "peak_bytes": 7576945, + "leaked_bytes": 3407648, + "total_allocations": 619356 }, "reader_mp4": { - "peak_bytes": 4208976, - "leaked_bytes": 3253426, - "total_allocations": 4963836 + "peak_bytes": 4159582, + "leaked_bytes": 3283805, + "total_allocations": 2089508 }, "reader_wav": { - "peak_bytes": 4431268, - "leaked_bytes": 3263354, - "total_allocations": 888612 + "peak_bytes": 4464615, + "leaked_bytes": 3293702, + "total_allocations": 413484 }, "builder_sign_jpeg_legacy": { - "peak_bytes": 7694334, - "leaked_bytes": 3377383, - "total_allocations": 1250156 + "peak_bytes": 7724599, + "leaked_bytes": 3408513, + "total_allocations": 560691 }, "builder_sign_jpeg_with_context": { - "peak_bytes": 7687683, - "leaked_bytes": 3370546, - "total_allocations": 1235555 + "peak_bytes": 7716613, + "leaked_bytes": 3400514, + "total_allocations": 554788 }, "builder_sign_png_legacy": { - "peak_bytes": 7933540, - "leaked_bytes": 3377209, - "total_allocations": 4502325 + "peak_bytes": 7961139, + "leaked_bytes": 3406984, + "total_allocations": 1981843 }, "builder_sign_png_with_context": { - "peak_bytes": 7924904, - "leaked_bytes": 3370312, - "total_allocations": 4487743 + "peak_bytes": 7955799, + "leaked_bytes": 3401929, + "total_allocations": 1975867 }, "builder_sign_jpeg_parallel_split_pool": { - "peak_bytes": 45790186, - "leaked_bytes": 3770876, - "total_allocations": 1247400 + "peak_bytes": 44002804, + "leaked_bytes": 3801899, + "total_allocations": 566497 }, "builder_sign_jpeg_parallel_split_barrier": { - "peak_bytes": 45758269, - "leaked_bytes": 3769617, - "total_allocations": 1246063 + "peak_bytes": 45784452, + "leaked_bytes": 3800550, + "total_allocations": 565361 }, "builder_sign_png_parallel_split_pool": { - "peak_bytes": 42884934, - "leaked_bytes": 3806845, - "total_allocations": 4499456 + "peak_bytes": 46054163, + "leaked_bytes": 3801867, + "total_allocations": 1987868 }, "builder_sign_png_parallel_split_barrier": { - "peak_bytes": 45995848, - "leaked_bytes": 3805431, - "total_allocations": 4498202 + "peak_bytes": 44206900, + "leaked_bytes": 3800490, + "total_allocations": 1986526 }, "builder_sign_gif": { - "peak_bytes": 14545704, - "leaked_bytes": 3369959, - "total_allocations": 19592169 + "peak_bytes": 14574556, + "leaked_bytes": 3400735, + "total_allocations": 8550061 }, "builder_sign_heic": { - "peak_bytes": 4609675, - "leaked_bytes": 3369960, - "total_allocations": 1865279 + "peak_bytes": 4644811, + "leaked_bytes": 3407275, + "total_allocations": 831824 }, "builder_sign_m4a": { - "peak_bytes": 18848657, - "leaked_bytes": 3369911, - "total_allocations": 6143845 + "peak_bytes": 18885052, + "leaked_bytes": 3407378, + "total_allocations": 2647270 }, "builder_sign_webp": { - "peak_bytes": 8901476, - "leaked_bytes": 3369960, - "total_allocations": 1108971 + "peak_bytes": 8928848, + "leaked_bytes": 3399564, + "total_allocations": 499272 }, "builder_sign_avi": { - "peak_bytes": 7041162, - "leaked_bytes": 3369959, - "total_allocations": 105383089 + "peak_bytes": 7068483, + "leaked_bytes": 3399340, + "total_allocations": 45032279 }, "builder_sign_mp4": { - "peak_bytes": 6163626, - "leaked_bytes": 3369959, - "total_allocations": 4502723 + "peak_bytes": 6198764, + "leaked_bytes": 3407319, + "total_allocations": 1944326 }, "builder_sign_tiff": { - "peak_bytes": 13123408, - "leaked_bytes": 3369960, - "total_allocations": 13221796 + "peak_bytes": 13151779, + "leaked_bytes": 3400355, + "total_allocations": 5472611 }, "builder_sign_jpeg_parent_of": { - "peak_bytes": 14175499, - "leaked_bytes": 3370412, - "total_allocations": 3049271 + "peak_bytes": 14204315, + "leaked_bytes": 3401481, + "total_allocations": 1292637 }, "builder_sign_jpeg_component_of": { - "peak_bytes": 14176177, - "leaked_bytes": 3370939, - "total_allocations": 3105680 + "peak_bytes": 14204923, + "leaked_bytes": 3400730, + "total_allocations": 1315125 }, "builder_sign_jpeg_parent_and_component": { - "peak_bytes": 14521625, - "leaked_bytes": 3466959, - "total_allocations": 5528187 + "peak_bytes": 14588185, + "leaked_bytes": 3549911, + "total_allocations": 2299453 }, "builder_sign_jpeg_parent_and_component_mixed_mime": { - "peak_bytes": 14476222, - "leaked_bytes": 3370421, - "total_allocations": 6500129 + "peak_bytes": 14506586, + "leaked_bytes": 3401364, + "total_allocations": 2796028 }, "builder_sign_jpeg_two_components_same_mime": { - "peak_bytes": 14584523, - "leaked_bytes": 3506901, - "total_allocations": 5502714 + "peak_bytes": 14601941, + "leaked_bytes": 3558116, + "total_allocations": 2289245 }, "builder_sign_jpeg_two_components_mixed_mime": { - "peak_bytes": 14473585, - "leaked_bytes": 3370669, - "total_allocations": 6474343 + "peak_bytes": 14503695, + "leaked_bytes": 3401276, + "total_allocations": 2785702 }, "builder_sign_jpeg_archive_roundtrip": { - "peak_bytes": 14206327, - "leaked_bytes": 3389587, - "total_allocations": 4247160 + "peak_bytes": 14236247, + "leaked_bytes": 3420354, + "total_allocations": 1777705 + }, + "builder_to_archive_with_ingredient": { + "peak_bytes": 14010137, + "leaked_bytes": 3278101, + "total_allocations": 957452 + }, + "builder_sign_jpeg_archive_roundtrip_ingredient_in_archive": { + "peak_bytes": 14225579, + "leaked_bytes": 3420822, + "total_allocations": 2981495 + }, + "builder_write_ingredient_archive": { + "peak_bytes": 14010131, + "leaked_bytes": 3278099, + "total_allocations": 944654 + }, + "builder_sign_jpeg_add_ingredient_from_archive": { + "peak_bytes": 14075511, + "leaked_bytes": 3421125, + "total_allocations": 1753642 + }, + "builder_ingredient_archive_roundtrip": { + "peak_bytes": 14222976, + "leaked_bytes": 3419885, + "total_allocations": 2609017 + }, + "builder_sign_jpeg_two_ingredient_archives": { + "peak_bytes": 14075190, + "leaked_bytes": 3421103, + "total_allocations": 2161232 }, "reader_error_no_manifest": { - "peak_bytes": 3474039, - "leaked_bytes": 3232411, - "total_allocations": 303795 + "peak_bytes": 3504260, + "leaked_bytes": 3263283, + "total_allocations": 178873 }, "builder_error_invalid_manifest": { - "peak_bytes": 3271093, - "leaked_bytes": 3211670, - "total_allocations": 120613 + "peak_bytes": 3292723, + "leaked_bytes": 3235293, + "total_allocations": 96622 }, "reader_string_apis": { - "peak_bytes": 3888863, - "leaked_bytes": 3254426, - "total_allocations": 2806719 + "peak_bytes": 3915891, + "leaked_bytes": 3284213, + "total_allocations": 1185492 }, "fork_reader_collect": { - "peak_bytes": 3760272, - "leaked_bytes": 3261839, - "total_allocations": 1615850 + "peak_bytes": 3789318, + "leaked_bytes": 3291267, + "total_allocations": 705123 }, "fork_contended_mutex": { - "peak_bytes": 7585897, - "leaked_bytes": 3392170, - "total_allocations": 82616370 + "peak_bytes": 7617864, + "leaked_bytes": 3421711, + "total_allocations": 33591148 }, "fork_thread_local_orphan": { - "peak_bytes": 3845601, - "leaked_bytes": 3348583, - "total_allocations": 1681204 + "peak_bytes": 3876269, + "leaked_bytes": 3379616, + "total_allocations": 731511 }, "fork_gc_cycle": { - "peak_bytes": 3760522, - "leaked_bytes": 3261588, - "total_allocations": 1620079 + "peak_bytes": 3790340, + "leaked_bytes": 3291400, + "total_allocations": 706802 }, "fork_parent_frees_after_fork": { - "peak_bytes": 5959081, - "leaked_bytes": 3260442, - "total_allocations": 30560082 + "peak_bytes": 5989167, + "leaked_bytes": 3289951, + "total_allocations": 12461253 }, "fork_child_sys_exit": { - "peak_bytes": 3760808, - "leaked_bytes": 3261849, - "total_allocations": 1624602 + "peak_bytes": 3789334, + "leaked_bytes": 3291456, + "total_allocations": 708625 }, "fork_stream_cleanup": { - "peak_bytes": 3382176, - "leaked_bytes": 3210242, - "total_allocations": 111383 - }, - "reader_manifest_data_context": { - "peak_bytes": 7575548, - "leaked_bytes": 3399851, - "total_allocations": 1414270 + "peak_bytes": 3402893, + "leaked_bytes": 3230663, + "total_allocations": 93141 } } \ No newline at end of file diff --git a/tests/perf/run_profile.py b/tests/perf/run_profile.py index 6c0edc93..16640fac 100644 --- a/tests/perf/run_profile.py +++ b/tests/perf/run_profile.py @@ -177,6 +177,49 @@ def _fmt(n: int) -> str: return f"{n} B" +def _delta_pct(current: int, base: int) -> str: + """Signed percentage change vs baseline, or '-' when no baseline.""" + if not base: + return "-" + return f"{(current - base) / base * 100:+.1f}%" + + +def _write_github_summary(results: dict, baseline: dict) -> None: + """Append a values table to $GITHUB_STEP_SUMMARY when running in CI. + """ + summary_path = os.environ.get("GITHUB_STEP_SUMMARY") + if not summary_path or not results: + return + + lines = [ + "## Memory benchmark (memray)", + "", + f"Iterations: {ITERATIONS} · threshold: +{(THRESHOLD - 1) * 100:.0f}%" + f"{f' · env: {PERF_ENV}' if PERF_ENV else ''}", + "", + "| scenario | peak | allocs | peak Δ% | memory used Δ% | status |", + "|----------|------|--------|---------|----------------|--------|", + ] + for name, m in results.items(): + b = baseline.get(name, {}) if baseline else {} + peak_base = b.get("peak_bytes", 0) + leaked_base = b.get("leaked_bytes", 0) + regressed = ( + (peak_base and m["peak_bytes"] > peak_base * THRESHOLD) + or (leaked_base and m["leaked_bytes"] > leaked_base * THRESHOLD) + ) + status = "REGRESSED" if regressed else "ok" + lines.append( + f"| {name} | {_fmt(m['peak_bytes'])} " + f"| {m['total_allocations']} | {_delta_pct(m['peak_bytes'], peak_base)} " + f"| {_delta_pct(m['leaked_bytes'], leaked_base)} | {status} |" + ) + lines.append("") + + with open(summary_path, "a", encoding="utf-8") as fh: + fh.write("\n".join(lines) + "\n") + + def main() -> None: parser = argparse.ArgumentParser(description="c2pa-python memory profiler") parser.add_argument( @@ -319,6 +362,9 @@ def main() -> None: verb = "Updated" if prior_baseline else "Created" print(f"\n{verb} baseline: {BASELINE_FILE}") + # Emit the report table to the PR's Step Summary in CI. + _write_github_summary(results, baseline) + if render_failures: print("\nFLAMEGRAPH RENDERS FAILED (capture + metrics still recorded):", file=sys.stderr) for r in render_failures: