Benchmark Extrinsic Worst-case Scenarios
Severity: High
Description
In Polkadot SDK, benchmarks are used to measure the computational cost of runtime operations, such as extrinsics, storage accesses, and logic execution. These costs are quantified in weights, which represent the time and resources required to execute a specific operation on the blockchain.
Through benchmarking, developers define the WeightInfo
trait, which associates each extrinsic with a weight value based on the benchmarks. The runtime then uses these weights to enforce limits on block execution and calculate fees dynamically.
By performing benchmarks that account for worst-case scenarios, developers ensure their runtime remains efficient, secure, and scalable under real-world conditions. However, benchmarks that only cover typical scenarios may underestimate execution weights, potentially leading to resource overuse or transaction failures in real-world usage. To ensure accurate weight calculations, benchmarks must account for the heaviest possible execution path.
What should be avoided
The following code benchmarks a typical scenario, which may not account for the heaviest possible execution path, leading to underestimated weights:
Example 1
#![allow(unused)] fn main() { #[benchmark] fn typical_scenario() { // Benchmark with a small data set let items = generate_data(10); #[block] { process_items(items); } } }
In this example:
- The benchmark uses a small data set (
10
items), which may not reflect the workload in a worst-case scenario.
Example 2
#![allow(unused)] fn main() { // ---- In pallet/lib.rs ---- #[pallet::call_index(0)] #[pallet::weight(T::WeightInfo::some_extrinsic())] pub fn some_extrinsic(origin: OriginFor<T>, input: bool) -> DispatchResult { let computation_result = match input { true => 1, false => very_heavy_function(), }; // Do something with the result. Ok(()) } // ---- In pallet/benchmarks.rs ---- #[benchmark] fn some_extrinsic() { let input = true; // Execution #[extrinsic_call] _(RawOrigin::Signed(account), input); } }
In this example:
- The worst path occurs when input is false, as this triggers the
very_heavy_function
call. However, in the benchmark, input is set to true, meaning the benchmark will only measure the faster execution path. Consequently, the calculated execution cost will be an underestimate.
Best practice
Benchmark at least the worst-case path by simulating the heaviest possible workload, ensuring the calculated weight accurately reflects maximum resource usage.
Example 1
#![allow(unused)] fn main() { // ---- In pallet/benchmarks.rs ---- #[benchmark] fn worst_case_scenario(s: Linear<1, MAX_ITEMS>) { // Benchmark with dynamic data let items = generate_data(s); #[block] { process_items(items); } } }
In this improved example:
generate_data(s)
creates the maximum allowed data set to simulate a heavy load.- By covering the worst-case path, this benchmark provides a realistic weight that prevents unexpected performance issues during peak loads.
Example 2
#![allow(unused)] fn main() { // ---- In pallet/benchmarks.rs ---- #[benchmark] fn some_extrinsic() { // Set the value to execute the worst-case path. let input = false; // Execution #[extrinsic_call] _(RawOrigin::Signed(account), input); } }
Example 3
For mission-critical extrinsics that are used frequently, consider benchmarking each execution path independently for finer weight granularity:
#![allow(unused)] fn main() { // ---- In pallet/lib.rs ---- #[pallet::call_index(0)] #[pallet::weight( T::WeightInfo::some_extrinsic_path_1().max( T::WeightInfo::some_extrinsic_path_2()) )] pub fn some_extrinsic(origin: OriginFor<T>, input: bool) -> DispatchResultWithPostInfo { let (result, weight) = match input { true => (1, T::WeightInfo::some_extrinsic_path_1()), false => (very_heavy_function(), T::WeightInfo::some_extrinsic_path_2()) }; // Do something with the result. Ok(Some(weight).into()) } // ---- In pallet/benchmarks.rs ---- #[benchmark] fn some_extrinsic_path_1() { // Set the value to execute the path where the variable is true. let input = true; // Execution #[block] { some_extrinsic(RawOrigin::Signed(account), input); } } #[benchmark] fn some_extrinsic_path_2() { // Set the value to execute the path where the variable is false. let input = false; // Execution #[block] { some_extrinsic(RawOrigin::Signed(account), input); } } }
In this example:
- The fee corresponding to the worst-case execution path is initially charged to the user.
- If the actual execution consumes fewer resources than the worst-case scenario, the difference is refunded to the user.