This guide explains how to interpret the gas snapshots generated by our CI and which knobs to turn when you need to optimize CPU or memory usage in RemitWise contracts. It is intended for contributors writing code, reviewers verifying intent, and the support team answering performance questions.
When gas benchmarks run, results are captured in gas_results.json. Each entry represents a specific contract operation and its resource cost.
Here is a concrete example from our remittance_split benchmarks:
{
"contract": "remittance_split",
"method": "distribute_usdc",
"scenario": "4_recipients_all_nonzero",
"cpu": 787445,
"mem": 115986
}contract/method: The exact smart contract and entrypoint being benchmarked (e.g., thedistribute_usdcfunction in theremittance_splitcrate).scenario: The specific state or scale being tested. Benchmarks intentionally target worst-case scenarios (e.g.,50_schedules_worst_case) alongside happy paths to ensure limits hold under pressure.cpu: Soroban CPU instructions consumed. If this spikes past threshold percentages (defined inbenchmarks/thresholds.json), CI will block the PR.mem: Peak memory bytes allocated during execution.
When a benchmark fails or a contract feels expensive, consider these specific tuning strategies:
The most expensive operations in Soroban are state reads and writes.
- Cache aggregates for heavy reads: If
cpuis too high on read-heavy paths, introduce caches. For example,bill_paymentsmaintains a cachedUNPD_TOT(unpaid total) duringcreate_billandpay_bill. This preventsget_total_unpaidfrom iterating over all bills. - Hybrid lookup paths: For
savings_goals::get_all_goals, we use a full-scan fast path when the owner owns all goals, and an index lookup path (OWN_GOAL) when they only own a subset. - Batch your writes: Write to persistent or temporary storage only once at the end of your logic rather than updating iteratively in a loop.
If a method consumes too much CPU without hitting storage:
- Bypass Event Emission Internally: If a function is called as an internal helper (e.g.,
calculate_splitwithindistribute_usdc), use a non-event emitting variant. Generating and allocating vectors for events you don't need burns CPU and memory. - Avoid redundant clones: Do not clone
AddressorEnvobjects inside loops. - Reuse calculated lengths: In
family_wallet::configure_multisig, we reuse the result ofsigners.len()rather than recounting or cloning the vector to validate thresholds.
If scale-testing scenarios (like 50_schedules_worst_case) fail:
- Enforce Batch Limits: Clamp operations using shared constants like
MAX_BATCH_SIZE. - Use Cursor-based Pagination: Always implement cursor-based pagination for large datasets (e.g.,
get_schedules_paginated) to predictably bound the maximum CPU and memory per call.
To verify your tuning on a specific contract without waiting for CI, run:
RUST_TEST_THREADS=1 cargo test -p <crate_name> --test gas_bench -- --nocapture(For example: cargo test -p remittance_split --test gas_bench -- --nocapture)