From 06b91bd0c47b864875746f72dc60c1b61b72279c Mon Sep 17 00:00:00 2001 From: rindicomfort Date: Sun, 28 Jun 2026 10:02:34 +0100 Subject: [PATCH 1/2] feat: add gas-based profiling and regression checks to Soroban CI pipeline --- .github/workflows/ci.yml | 42 +++- contracts/tests/integration_soroban.rs | 322 ++++++++++++++++++++++++- gas-benchmarks/README.md | 77 ++++++ scripts/analyze-gas.py | 253 +++++++++++++++++++ scripts/gas-benchmark.sh | 54 +++++ 5 files changed, 746 insertions(+), 2 deletions(-) create mode 100644 gas-benchmarks/README.md create mode 100644 scripts/analyze-gas.py create mode 100755 scripts/gas-benchmark.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f8a37d17..0b434a46 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -232,6 +232,43 @@ jobs: working-directory: ./contracts run: cargo build --release + rust-gas-benchmark: + name: Soroban Gas Profiling & Regression Gate + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@master + with: + toolchain: ${{ env.RUST_VERSION }} + + - name: Cache Rust dependencies + uses: Swatinem/rust-cache@v2 + with: + workspaces: './contracts -> target' + + - name: Run Gas Benchmarks & Profiling + run: | + if [ "${{ github.event_name }}" = "push" ] && [ "${{ github.ref }}" = "refs/heads/main" ]; then + echo "Main branch update: updating baseline gas snapshot..." + ./scripts/gas-benchmark.sh --generate-baseline + + # Commit and push updated baseline & trends back to main branch + git config --global user.name "rindicomfort" + git config --global user.email "kwarpojonathanrindi@gmail.com" + git add gas-benchmarks/ + if git commit -m "chore: update baseline gas snapshot and trends [skip ci]"; then + git push + fi + else + echo "PR / Dev branch: running gas regression check..." + ./scripts/gas-benchmark.sh + fi + # ───────────────────────────────────────────────────────── # Merge Protection (only on PRs) # ───────────────────────────────────────────────────────── @@ -251,6 +288,7 @@ jobs: rust-clippy, rust-tests, rust-build, + rust-gas-benchmark, ] steps: - name: All checks passed @@ -275,6 +313,7 @@ jobs: rust-clippy, rust-tests, rust-build, + rust-gas-benchmark, ] steps: - name: Check for failures @@ -291,7 +330,8 @@ jobs: [ "${{ needs.rust-format.result }}" != "success" ] || \ [ "${{ needs.rust-clippy.result }}" != "success" ] || \ [ "${{ needs.rust-tests.result }}" != "success" ] || \ - [ "${{ needs.rust-build.result }}" != "success" ]; then + [ "${{ needs.rust-build.result }}" != "success" ] || \ + [ "${{ needs.rust-gas-benchmark.result }}" != "success" ]; then echo "One or more CI checks failed" exit 1 fi diff --git a/contracts/tests/integration_soroban.rs b/contracts/tests/integration_soroban.rs index ccbacc9a..9376a62a 100644 --- a/contracts/tests/integration_soroban.rs +++ b/contracts/tests/integration_soroban.rs @@ -1,6 +1,6 @@ use soroban_sdk::{ contract, contractimpl, - testutils::{Address as _, Ledger}, + testutils::{Address as _, Ledger, Env as _}, token, Address, Env, String, }; use subtrackr::{Interval, SubTrackrContract, SubTrackrContractClient, SubscriptionStatus}; @@ -192,3 +192,323 @@ fn integration_multiple_contract_interactions_work() { assert_eq!(token.balance(&setup.merchant), 500); assert_eq!(second_token.balance(&second_merchant), 900); } + +fn setup_client_helper(env: &Env) -> (SubTrackrContractClient<'_>, Address, Address, Address, Address) { + env.mock_all_auths_allowing_non_root_auth(); + let contract_id = env.register_contract(None, SubTrackrContract); + let client = SubTrackrContractClient::new(env, &contract_id); + let admin = Address::generate(env); + let merchant = Address::generate(env); + let subscriber = Address::generate(env); + let token_admin = Address::generate(env); + let token_id = env.register_stellar_asset_contract_v2(token_admin); + + client.initialize(&admin); + (client, admin, merchant, subscriber, token_id.address()) +} + +#[test] +fn test_gas_benchmarks() { + // We will run each benchmarked function and print the cost. + // 1. initialize + { + let env = Env::default(); + env.mock_all_auths_allowing_non_root_auth(); + let contract_id = env.register_contract(None, SubTrackrContract); + let client = SubTrackrContractClient::new(&env, &contract_id); + let admin = Address::generate(&env); + + env.enable_invocation_metering(); + client.initialize(&admin); + let resources = env.cost_estimate().resources(); + println!("GAS_BENCHMARK:initialize:{:?}", resources); + } + + // Helper to get initialized contract client + let setup_client = setup_client_helper; + + // 2. create_plan + { + let env = Env::default(); + let (client, _admin, merchant, _subscriber, token) = setup_client(&env); + let name = String::from_str(&env, "Standard Plan"); + + env.enable_invocation_metering(); + let _plan_id = client.create_plan(&merchant, &name, &100_i128, &token, &Interval::Monthly); + let resources = env.cost_estimate().resources(); + println!("GAS_BENCHMARK:create_plan:{:?}", resources); + } + + // 3. deactivate_plan + { + let env = Env::default(); + let (client, _admin, merchant, _subscriber, token) = setup_client(&env); + let name = String::from_str(&env, "Standard Plan"); + let plan_id = client.create_plan(&merchant, &name, &100_i128, &token, &Interval::Monthly); + + env.enable_invocation_metering(); + client.deactivate_plan(&merchant, &plan_id); + let resources = env.cost_estimate().resources(); + println!("GAS_BENCHMARK:deactivate_plan:{:?}", resources); + } + + // 4. subscribe + { + let env = Env::default(); + let (client, _admin, merchant, subscriber, token) = setup_client(&env); + let name = String::from_str(&env, "Standard Plan"); + let plan_id = client.create_plan(&merchant, &name, &100_i128, &token, &Interval::Monthly); + + env.enable_invocation_metering(); + let _sub_id = client.subscribe(&subscriber, &plan_id); + let resources = env.cost_estimate().resources(); + println!("GAS_BENCHMARK:subscribe:{:?}", resources); + } + + // 5. cancel_subscription + { + let env = Env::default(); + let (client, _admin, merchant, subscriber, token) = setup_client(&env); + let name = String::from_str(&env, "Standard Plan"); + let plan_id = client.create_plan(&merchant, &name, &100_i128, &token, &Interval::Monthly); + let sub_id = client.subscribe(&subscriber, &plan_id); + + env.enable_invocation_metering(); + client.cancel_subscription(&subscriber, &sub_id); + let resources = env.cost_estimate().resources(); + println!("GAS_BENCHMARK:cancel_subscription:{:?}", resources); + } + + // 6. pause_subscription + { + let env = Env::default(); + let (client, _admin, merchant, subscriber, token) = setup_client(&env); + let name = String::from_str(&env, "Standard Plan"); + let plan_id = client.create_plan(&merchant, &name, &100_i128, &token, &Interval::Monthly); + let sub_id = client.subscribe(&subscriber, &plan_id); + + env.enable_invocation_metering(); + client.pause_subscription(&subscriber, &sub_id); + let resources = env.cost_estimate().resources(); + println!("GAS_BENCHMARK:pause_subscription:{:?}", resources); + } + + // 7. pause_by_subscriber + { + let env = Env::default(); + let (client, _admin, merchant, subscriber, token) = setup_client(&env); + let name = String::from_str(&env, "Standard Plan"); + let plan_id = client.create_plan(&merchant, &name, &100_i128, &token, &Interval::Monthly); + let sub_id = client.subscribe(&subscriber, &plan_id); + + env.enable_invocation_metering(); + client.pause_by_subscriber(&subscriber, &sub_id, &1000_u64); + let resources = env.cost_estimate().resources(); + println!("GAS_BENCHMARK:pause_by_subscriber:{:?}", resources); + } + + // 8. resume_subscription + { + let env = Env::default(); + let (client, _admin, merchant, subscriber, token) = setup_client(&env); + let name = String::from_str(&env, "Standard Plan"); + let plan_id = client.create_plan(&merchant, &name, &100_i128, &token, &Interval::Monthly); + let sub_id = client.subscribe(&subscriber, &plan_id); + client.pause_subscription(&subscriber, &sub_id); + + env.enable_invocation_metering(); + client.resume_subscription(&subscriber, &sub_id); + let resources = env.cost_estimate().resources(); + println!("GAS_BENCHMARK:resume_subscription:{:?}", resources); + } + + // 9. charge_subscription + { + let env = Env::default(); + let (client, _admin, merchant, subscriber, token) = setup_client(&env); + let name = String::from_str(&env, "Standard Plan"); + let plan_id = client.create_plan(&merchant, &name, &100_i128, &token, &Interval::Monthly); + let sub_id = client.subscribe(&subscriber, &plan_id); + + // Mint some tokens to the subscriber so that the charge transfer can succeed + let token_admin_client = token::StellarAssetClient::new(&env, &token); + token_admin_client.mint(&subscriber, &1000); + + // Advance ledger time by 1 month so payment is due + env.ledger().set_timestamp(env.ledger().timestamp() + Interval::Monthly.seconds() + 10); + + env.enable_invocation_metering(); + client.charge_subscription(&sub_id); + let resources = env.cost_estimate().resources(); + println!("GAS_BENCHMARK:charge_subscription:{:?}", resources); + } + + // 10. request_refund + { + let env = Env::default(); + let (client, _admin, merchant, subscriber, token) = setup_client(&env); + let name = String::from_str(&env, "Standard Plan"); + let plan_id = client.create_plan(&merchant, &name, &100_i128, &token, &Interval::Monthly); + let sub_id = client.subscribe(&subscriber, &plan_id); + + let token_admin_client = token::StellarAssetClient::new(&env, &token); + token_admin_client.mint(&subscriber, &1000); + + env.ledger().set_timestamp(env.ledger().timestamp() + Interval::Monthly.seconds() + 10); + client.charge_subscription(&sub_id); + + env.enable_invocation_metering(); + client.request_refund(&sub_id, &50_i128); + let resources = env.cost_estimate().resources(); + println!("GAS_BENCHMARK:request_refund:{:?}", resources); + } + + // 11. approve_refund + { + let env = Env::default(); + let (client, _admin, merchant, subscriber, token) = setup_client(&env); + let name = String::from_str(&env, "Standard Plan"); + let plan_id = client.create_plan(&merchant, &name, &100_i128, &token, &Interval::Monthly); + let sub_id = client.subscribe(&subscriber, &plan_id); + + let token_admin_client = token::StellarAssetClient::new(&env, &token); + token_admin_client.mint(&subscriber, &1000); + + env.ledger().set_timestamp(env.ledger().timestamp() + Interval::Monthly.seconds() + 10); + client.charge_subscription(&sub_id); + client.request_refund(&sub_id, &50_i128); + + env.enable_invocation_metering(); + client.approve_refund(&sub_id); + let resources = env.cost_estimate().resources(); + println!("GAS_BENCHMARK:approve_refund:{:?}", resources); + } + + // 12. reject_refund + { + let env = Env::default(); + let (client, _admin, merchant, subscriber, token) = setup_client(&env); + let name = String::from_str(&env, "Standard Plan"); + let plan_id = client.create_plan(&merchant, &name, &100_i128, &token, &Interval::Monthly); + let sub_id = client.subscribe(&subscriber, &plan_id); + + let token_admin_client = token::StellarAssetClient::new(&env, &token); + token_admin_client.mint(&subscriber, &1000); + + env.ledger().set_timestamp(env.ledger().timestamp() + Interval::Monthly.seconds() + 10); + client.charge_subscription(&sub_id); + client.request_refund(&sub_id, &50_i128); + + env.enable_invocation_metering(); + client.reject_refund(&sub_id); + let resources = env.cost_estimate().resources(); + println!("GAS_BENCHMARK:reject_refund:{:?}", resources); + } + + // 13. request_transfer + { + let env = Env::default(); + let (client, _admin, merchant, subscriber, token) = setup_client(&env); + let name = String::from_str(&env, "Standard Plan"); + let plan_id = client.create_plan(&merchant, &name, &100_i128, &token, &Interval::Monthly); + let sub_id = client.subscribe(&subscriber, &plan_id); + let recipient = Address::generate(&env); + + env.enable_invocation_metering(); + client.request_transfer(&sub_id, &recipient); + let resources = env.cost_estimate().resources(); + println!("GAS_BENCHMARK:request_transfer:{:?}", resources); + } + + // 14. accept_transfer + { + let env = Env::default(); + let (client, _admin, merchant, subscriber, token) = setup_client(&env); + let name = String::from_str(&env, "Standard Plan"); + let plan_id = client.create_plan(&merchant, &name, &100_i128, &token, &Interval::Monthly); + let sub_id = client.subscribe(&subscriber, &plan_id); + let recipient = Address::generate(&env); + client.request_transfer(&sub_id, &recipient); + + env.enable_invocation_metering(); + client.accept_transfer(&sub_id, &recipient); + let resources = env.cost_estimate().resources(); + println!("GAS_BENCHMARK:accept_transfer:{:?}", resources); + } + + // 15. get_plan + { + let env = Env::default(); + let (client, _admin, merchant, _subscriber, token) = setup_client(&env); + let name = String::from_str(&env, "Standard Plan"); + let plan_id = client.create_plan(&merchant, &name, &100_i128, &token, &Interval::Monthly); + + env.enable_invocation_metering(); + let _plan = client.get_plan(&plan_id); + let resources = env.cost_estimate().resources(); + println!("GAS_BENCHMARK:get_plan:{:?}", resources); + } + + // 16. get_subscription + { + let env = Env::default(); + let (client, _admin, merchant, subscriber, token) = setup_client(&env); + let name = String::from_str(&env, "Standard Plan"); + let plan_id = client.create_plan(&merchant, &name, &100_i128, &token, &Interval::Monthly); + let sub_id = client.subscribe(&subscriber, &plan_id); + + env.enable_invocation_metering(); + let _sub = client.get_subscription(&sub_id); + let resources = env.cost_estimate().resources(); + println!("GAS_BENCHMARK:get_subscription:{:?}", resources); + } + + // 17. get_user_subscriptions + { + let env = Env::default(); + let (client, _admin, merchant, subscriber, token) = setup_client(&env); + let name = String::from_str(&env, "Standard Plan"); + let plan_id = client.create_plan(&merchant, &name, &100_i128, &token, &Interval::Monthly); + let _sub_id = client.subscribe(&subscriber, &plan_id); + + env.enable_invocation_metering(); + let _subs = client.get_user_subscriptions(&subscriber); + let resources = env.cost_estimate().resources(); + println!("GAS_BENCHMARK:get_user_subscriptions:{:?}", resources); + } + + // 18. get_merchant_plans + { + let env = Env::default(); + let (client, _admin, merchant, _subscriber, token) = setup_client(&env); + let name = String::from_str(&env, "Standard Plan"); + let _plan_id = client.create_plan(&merchant, &name, &100_i128, &token, &Interval::Monthly); + + env.enable_invocation_metering(); + let _plans = client.get_merchant_plans(&merchant); + let resources = env.cost_estimate().resources(); + println!("GAS_BENCHMARK:get_merchant_plans:{:?}", resources); + } + + // 19. get_plan_count + { + let env = Env::default(); + let (client, _admin, _merchant, _subscriber, _token) = setup_client(&env); + + env.enable_invocation_metering(); + let _count = client.get_plan_count(); + let resources = env.cost_estimate().resources(); + println!("GAS_BENCHMARK:get_plan_count:{:?}", resources); + } + + // 20. get_subscription_count + { + let env = Env::default(); + let (client, _admin, _merchant, _subscriber, _token) = setup_client(&env); + + env.enable_invocation_metering(); + let _count = client.get_subscription_count(); + let resources = env.cost_estimate().resources(); + println!("GAS_BENCHMARK:get_subscription_count:{:?}", resources); + } +} diff --git a/gas-benchmarks/README.md b/gas-benchmarks/README.md new file mode 100644 index 00000000..c3ef9e87 --- /dev/null +++ b/gas-benchmarks/README.md @@ -0,0 +1,77 @@ +# ⛽ Soroban Smart Contract Gas Profiling Pipeline + +This directory contains the profiling results, baselines, and trend charts generated by the automated gas benchmarking pipeline. + +## 📋 Directory Contents + +- `baseline.json`: The baseline gas costs snapshot. Checked in and updated on `main` branch pushes. +- `trends.json`: Historical log of gas costs per function over commits, keeping up to the last 50 commits. +- `gas_trend.svg`: An automatically generated line chart showing the CPU instructions consumed by top functions over time. +- `README.md`: This documentation. + +--- + +## 🚀 How to Run the Profiling Pipeline + +### 1. Run Benchmarks and Check for Regressions +To run the benchmarks and verify that current gas usage does not exceed the baseline by more than the threshold (default: 10%): +```bash +./scripts/gas-benchmark.sh +``` + +### 2. Configure Regression Threshold +You can customize the regression threshold (e.g., set to 15%) using either the `--threshold` flag or the `GAS_REGRESSION_THRESHOLD` environment variable: +```bash +# Via flag +./scripts/gas-benchmark.sh --threshold 0.15 + +# Via environment variable +GAS_REGRESSION_THRESHOLD=0.15 ./scripts/gas-benchmark.sh +``` + +### 3. Generate or Update Baseline +To overwrite the baseline with the current contract performance: +```bash +./scripts/gas-benchmark.sh --generate-baseline +``` + +--- + +## 🛠️ How to Add New Benchmarks + +All gas benchmarks are defined in the integration test suite located at: +[`contracts/tests/integration_soroban.rs`](../contracts/tests/integration_soroban.rs#L196) + +To add a new benchmark: +1. Open the file and navigate to the `test_gas_benchmarks` function. +2. Add a new code block for the function or scenario you want to test. +3. Construct the required test setup (e.g., using `setup_client`). +4. Enable invocation metering: + ```rust + env.enable_invocation_metering(); + ``` +5. Invoke the contract function you wish to profile. +6. Retrieve the invocation resources and print them to stdout using the exact prefix format: + ```rust + let resources = env.cost_estimate().resources(); + println!("GAS_BENCHMARK:my_new_function:{:?}", resources); + ``` + +The script will automatically parse this output line, record it in historical trends, compare it to the baseline, and construct a stratified call tree for it. + +--- + +## 📊 Interpreting the Results + +### 1. CPU Instructions +Represent the computational resource limits of the Stellar network. A transaction has a hard limit of 100M instructions on Soroban. Lowering CPU instruction counts directly reduces the network fee. + +### 2. Stratified Call Trees +The pipeline outputs a stratified call tree to show where resources are allocated: +- **WASM Execution**: Gas spent executing compiled WebAssembly bytecode on the Soroban host. +- **Storage Reads**: Gas associated with reading entries from the ledger. (Estimated at 12,000 CPU instructions per read). +- **Storage Writes**: Gas associated with writing data back to the ledger. (Estimated at 25,000 CPU instructions per write + 30 instructions per byte written). +- **Host/Auth/Events**: Overhead from event publishing, authorization checks, and built-in host functions. + +### 3. Historical Trends +The line chart (`gas_trend.svg`) displays performance trends over time, helping developers visualize whether optimizations are working or if new features are introducing gradual regression over multiple commits. diff --git a/scripts/analyze-gas.py b/scripts/analyze-gas.py new file mode 100644 index 00000000..e4090845 --- /dev/null +++ b/scripts/analyze-gas.py @@ -0,0 +1,253 @@ +import sys +import os +import re +import json + +# Setup paths +BENCHMARK_DIR = "gas-benchmarks" +BASELINE_PATH = os.path.join(BENCHMARK_DIR, "baseline.json") +TRENDS_PATH = os.path.join(BENCHMARK_DIR, "trends.json") +SVG_PATH = os.path.join(BENCHMARK_DIR, "gas_trend.svg") + +os.makedirs(BENCHMARK_DIR, exist_ok=True) + +# Regex patterns +pattern = re.compile(r'GAS_BENCHMARK:([^:]+):(.*)') +kv_pattern = re.compile(r'(\w+):\s*(\d+)') + +current_results = {} +for line in sys.stdin: + sys.stdout.write(line) + match = pattern.search(line) + if match: + func_name = match.group(1) + debug_str = match.group(2) + kvs = kv_pattern.findall(debug_str) + metrics = {k: int(v) for k, v in kvs} + if metrics: + current_results[func_name] = metrics + +if not current_results: + print("Error: No benchmark results parsed from test output. Make sure test_gas_benchmarks runs.", file=sys.stderr) + sys.exit(1) + +# Get commit metadata +commit_sha = os.environ.get("COMMIT_SHA", "unknown") +commit_time = int(os.environ.get("COMMIT_TIME", "0")) + +# Load or generate baseline +baseline = {} +if os.path.exists(BASELINE_PATH): + try: + with open(BASELINE_PATH, "r") as f: + baseline = json.load(f) + except Exception as e: + print(f"Warning: failed to load baseline: {e}", file=sys.stderr) + +generate_baseline_mode = os.environ.get("GENERATE_BASELINE") == "true" +if generate_baseline_mode or not baseline: + print(f"Saving current results as new baseline to {BASELINE_PATH}...") + with open(BASELINE_PATH, "w") as f: + json.dump(current_results, f, indent=2) + baseline = current_results + +# Load and update historical trends +trends = [] +if os.path.exists(TRENDS_PATH): + try: + with open(TRENDS_PATH, "r") as f: + trends = json.load(f) + except Exception: + pass + +trends.append({ + "sha": commit_sha, + "timestamp": commit_time, + "results": current_results +}) +trends = trends[-50:] +with open(TRENDS_PATH, "w") as f: + json.dump(trends, f, indent=2) + +# Generate trend SVG +def generate_svg(trends, svg_path): + funcs = list(current_results.keys()) + funcs.sort(key=lambda fn: current_results.get(fn, {}).get("instructions", 0), reverse=True) + plot_funcs = funcs[:5] + + width, height = 800, 400 + padding = 60 + + points_by_func = {fn: [] for fn in plot_funcs} + shas = [] + for t in trends[-10:]: + shas.append(t["sha"]) + for fn in plot_funcs: + val = t.get("results", {}).get(fn, {}).get("instructions", 0) + points_by_func[fn].append(val) + + if not shas: + return + + max_val = max(max(vals) if vals else 0 for vals in points_by_func.values()) + max_val = max(max_val, 1) + + colors = ["#4f46e5", "#06b6d4", "#10b981", "#f59e0b", "#ef4444"] + + svg = [] + svg.append(f'') + svg.append(f'Gas Consumption Trends (CPU Instructions)') + + for i in range(5): + y = padding + i * (height - 2 * padding) // 4 + val = int(max_val - i * max_val / 4) + svg.append(f'') + svg.append(f'{val:,}') + + num_commits = len(shas) + x_step = (width - 2 * padding) / max(num_commits - 1, 1) + for idx, sha in enumerate(shas): + x = padding + idx * x_step + svg.append(f'{sha}') + + for fn_idx, fn in enumerate(plot_funcs): + vals = points_by_func[fn] + color = colors[fn_idx % len(colors)] + + path_data = [] + for idx, val in enumerate(vals): + x = padding + idx * x_step + y = height - padding - (val / max_val) * (height - 2 * padding) + path_data.append(f"{'M' if idx == 0 else 'L'} {x:.1f} {y:.1f}") + svg.append(f'') + + if path_data: + svg.append(f'') + + leg_x = width - padding - 180 + leg_y = padding + fn_idx * 20 + svg.append(f'') + svg.append(f'{fn}') + + svg.append('') + + with open(svg_path, "w") as f: + f.write("\n".join(svg)) + +try: + generate_svg(trends, SVG_PATH) +except Exception as e: + print(f"Warning: failed to generate SVG: {e}", file=sys.stderr) + +# Regressions analysis +threshold = float(os.environ.get("GAS_REGRESSION_THRESHOLD", "0.10")) +regressions = [] +summary_table = [] +call_trees = [] + +summary_table.append("| Function | Baseline (CPU) | Current (CPU) | Change | Status |") +summary_table.append("| --- | --- | --- | --- | --- |") + +for func, metrics in current_results.items(): + current_cpu = metrics.get("instructions", 0) + baseline_metrics = baseline.get(func, {}) + baseline_cpu = baseline_metrics.get("instructions", 0) + + change_pct = 0.0 + change_str = "0.0%" + status = "✅ Pass" + + if baseline_cpu > 0: + change_pct = (current_cpu - baseline_cpu) / baseline_cpu + change_str = f"{change_pct * 100:+.2f}%" + if change_pct > threshold: + status = "⚠️ Regression" + regressions.append((func, baseline_cpu, current_cpu, change_pct)) + elif change_pct < -0.01: + status = "⚡ Optimized" + else: + change_str = "New" + + summary_table.append(f"| `{func}` | {baseline_cpu:,} | {current_cpu:,} | {change_str} | {status} |") + + # Stratified Call Tree + mem = metrics.get("mem_bytes", 0) + reads = metrics.get("disk_read_entries", 0) or metrics.get("read_entries", 0) or 0 + writes = metrics.get("write_entries", 0) or 0 + write_b = metrics.get("write_bytes", 0) or 0 + + est_read_cost = reads * 12000 + est_write_cost = writes * 25000 + write_b * 30 + total_est_storage = est_read_cost + est_write_cost + + wasm_cost = current_cpu - total_est_storage + min_wasm = int(current_cpu * 0.15) + if wasm_cost < min_wasm: + wasm_cost = min_wasm + remaining = current_cpu - wasm_cost + if total_est_storage > 0: + est_read_cost = int(est_read_cost * remaining / total_est_storage) + est_write_cost = int(est_write_cost * remaining / total_est_storage) + else: + wasm_cost = current_cpu + + other_host = current_cpu - wasm_cost - est_read_cost - est_write_cost + if other_host < 0: + other_host = 0 + + p_wasm = (wasm_cost / current_cpu) * 100 if current_cpu > 0 else 0 + p_read = (est_read_cost / current_cpu) * 100 if current_cpu > 0 else 0 + p_write = (est_write_cost / current_cpu) * 100 if current_cpu > 0 else 0 + p_other = (other_host / current_cpu) * 100 if current_cpu > 0 else 0 + + tree = f"""**`{func}`** (Total: {current_cpu:,} CPU instructions, {mem:,} Bytes RAM) +├── **WASM Execution**: {wasm_cost:,} CPU ({p_wasm:.1f}%) +├── **Storage Reads**: {est_read_cost:,} CPU ({p_read:.1f}%) [{reads} entry reads] +├── **Storage Writes**: {est_write_cost:,} CPU ({p_write:.1f}%) [{writes} entry writes, {write_b} bytes] +└── **Host/Auth/Events**: {other_host:,} CPU ({p_other:.1f}%)""" + call_trees.append(tree) + +print("\n" + "="*50) +print(" SOROBAN GAS BENCHMARK REPORT ") +print("="*50) +for r in summary_table: + print(r) +print("\n" + "="*50) +print(" STRATIFIED CALL TREES ") +print("="*50) +for t in call_trees: + print(t) + print() + +step_summary_file = os.environ.get("GITHUB_STEP_SUMMARY") +if step_summary_file: + with open(step_summary_file, "w") as sf: + sf.write("## ⛽ Soroban Smart Contract Gas Profiling\n\n") + + if regressions: + sf.write("### ⚠️ Gas Cost Regressions Detected!\n") + sf.write(f"The following functions exceeded the baseline by more than the threshold of **{threshold*100:.0f}%**:\n\n") + for name, base, cur, pct in regressions: + sf.write(f"- **`{name}`**: {base:,} -> {cur:,} (**{pct*100:+.2f}%**)\n") + sf.write("\n") + else: + sf.write("### ✅ All Gas Benchmarks Passed\n") + sf.write("No gas regressions detected against baseline.\n\n") + + sf.write("### 📊 Performance Summary\n") + sf.write("\n".join(summary_table) + "\n\n") + + sf.write("### 📈 Gas Consumption Trends\n") + sf.write(f"![Gas Consumption Trends](https://raw.githubusercontent.com/rindicomfort/SubTrackr/feat/gas-profiling-pipeline/gas-benchmarks/gas_trend.svg)\n\n") + + sf.write("### 🌳 Stratified Call Trees\n") + sf.write("Identifies high-cost operations per function:\n\n") + for t in call_trees: + sf.write("```text\n" + t + "\n```\n\n") + +if regressions: + print(f"\n❌ Fail: {len(regressions)} gas cost regressions detected!", file=sys.stderr) + sys.exit(1) +else: + print("\n✅ Success: All gas benchmarks within baseline limits.") + sys.exit(0) diff --git a/scripts/gas-benchmark.sh b/scripts/gas-benchmark.sh new file mode 100755 index 00000000..01877f63 --- /dev/null +++ b/scripts/gas-benchmark.sh @@ -0,0 +1,54 @@ +#!/bin/bash +# scripts/gas-benchmark.sh - Run gas cost profiling and check for regressions + +set -euo pipefail + +# Root directory of workspace +WORKSPACE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$WORKSPACE_DIR" + +# Defaults +THRESHOLD="0.10" +GENERATE_BASELINE="false" + +# Helper for usage +show_help() { + echo "Usage: $0 [options]" + echo "Options:" + echo " --generate-baseline Generate/overwrite baseline gas snapshot" + echo " --threshold Regression threshold as a fraction (default: 0.10)" + echo " -h, --help Show this help message" +} + +# Parse arguments +while [[ $# -gt 0 ]]; do + case "$1" in + --generate-baseline) + GENERATE_BASELINE="true" + shift + ;; + --threshold) + THRESHOLD="$2" + shift 2 + ;; + -h|--help) + show_help + exit 0 + ;; + *) + echo "Unknown option: $1" + show_help + exit 1 + ;; + esac +done + +echo "=== Running Soroban Contract Gas Benchmarks ===" +export GAS_REGRESSION_THRESHOLD="$THRESHOLD" +export GENERATE_BASELINE="$GENERATE_BASELINE" +export COMMIT_SHA="$(git rev-parse --short HEAD 2>/dev/null || echo "unknown")" +export COMMIT_TIME="$(git log -1 --format=%ct 2>/dev/null || date +%s)" + +# Run Cargo test in contracts workspace and pipe to Python analyzer +cd "$WORKSPACE_DIR/contracts" +cargo test --test integration_soroban test_gas_benchmarks -- --nocapture | python3 "$WORKSPACE_DIR/scripts/analyze-gas.py" From 82b431fa94b5ac53d6e18e1fe2b1f97cc88747ae Mon Sep 17 00:00:00 2001 From: rindicomfort Date: Sun, 28 Jun 2026 10:12:37 +0100 Subject: [PATCH 2/2] chore: target subtrackr-proxy package in gas-benchmark script --- scripts/gas-benchmark.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/gas-benchmark.sh b/scripts/gas-benchmark.sh index 01877f63..aa940051 100755 --- a/scripts/gas-benchmark.sh +++ b/scripts/gas-benchmark.sh @@ -51,4 +51,4 @@ export COMMIT_TIME="$(git log -1 --format=%ct 2>/dev/null || date +%s)" # Run Cargo test in contracts workspace and pipe to Python analyzer cd "$WORKSPACE_DIR/contracts" -cargo test --test integration_soroban test_gas_benchmarks -- --nocapture | python3 "$WORKSPACE_DIR/scripts/analyze-gas.py" +cargo test --package subtrackr-proxy --test integration_soroban test_gas_benchmarks -- --nocapture | python3 "$WORKSPACE_DIR/scripts/analyze-gas.py"