Benchmarking
How to use the slides - Full screen (new tab)
FRAME Benchmarking
Lesson 1
Overview
- Quick Recap of Weights
- Deep Dive Into Benchmarking
Blockchains are Limited
Blockchain systems are extremely limited environments.
Limited in:
- Execution Time / Block Time
- Available Storage
- Available Memory
- Network Bandwidth
- etc...
Performance vs Centralization
Nodes are expected to be decentralized and distributed.
Increasing the system requirements can potentially lead to centralization in who can afford to run that hardware, and where such hardware may be available.
Why do we need benchmarking?
Benchmarking ensures that when users interact with our Blockchain, they are not using resources beyond what is available and expected for our network.
What is Weight?
Weight is a general concept used to track consumption of limited blockchain resources.
What is Weight in Substrate?
We currently track just two main limitations:
- Execution Time on "Reference Hardware"
- Size of Data Required to Create a Merkle Proof
#![allow(unused)] fn main() { pub struct Weight { /// The weight of computational time used based on some reference hardware. ref_time: u64, /// The weight of storage space used by proof of validity. proof_size: u64, } }
This was already expanded once, and could be expanded in the future.
Weight limits are specific to each blockchain.
- 1 second of compute on different computers allows for different amounts of computation.
- Weights of your blockchain will evolve over time.
- Higher hardware requirements will result in a more performant blockchain (i.e. TXs per second), but will limit the kinds of validators that can safely participate in your network.
- Proof size limitations can be relevant for parachains, but ignored for solo-chains.
What can affect relative Weight?
- Processor
- Memory
- Hard Drive
- HDD vs. SSD vs. NVME
- Operating System
- Drivers
- Rust Compiler
- Runtime Execution Engine
- compiled vs. interpreted
- Database
- RocksDB vs. ParityDB vs. ?
- Merkle trie / storage format
- and more!
Block Import Weight Breakdown
The Benchmarking Framework
The Benchmarking Plan
- Use empirical measurements of the runtime to determine the time and space it takes to execute extrinsics and other runtime logic.
- Run benchmarks using worst case scenario conditions.
- Primary goal is to keep the runtime safe.
- Secondary goal is to be as accurate as possible to maximize throughput.
The #[benchmarks]
Macro
#![allow(unused)] fn main() { #[benchmarks] mod benchmarks { use super::*; #[benchmark] fn benchmark_name() { /* setup initial state */ /* execute extrinsic or function */ #[extrinsic_call] extrinsic_name(); /* verify final state */ assert!(true) } } }
Multiple Linear Regression Analysis
- We require that no functions in Substrate have superlinear complexity.
- Ordinary least squared linear regression.
- linregress crate
- Supports multiple linear coefficients.
- Y = Ax + By + Cz + k
- For constant time functions, we simply use the median value.
The benchmark
CLI
Compile your node with --features runtime-benchmarks
.
➜ ~ substrate benchmark --help
Sub-commands concerned with benchmarking.
The pallet benchmarking moved to the `pallet` sub-command
Usage: polkadot benchmark <COMMAND>
Commands:
pallet Benchmark the extrinsic weight of FRAME Pallets
storage Benchmark the storage speed of a chain snapshot
overhead Benchmark the execution overhead per-block and per-extrinsic
block Benchmark the execution time of historic blocks
machine Command to benchmark the hardware
extrinsic Benchmark the execution time of different extrinsics
help Print this message or the help of the given subcommand(s)
Options:
-h, --help Print help information
-V, --version Print version information
pallet
Subcommand
- Benchmark the weight of functions within pallets.
- Any arbitrary code can be benchmarked.
- Outputs Autogenerated Weight files.
#![allow(unused)] fn main() { pub trait WeightInfo { fn transfer() -> Weight; fn transfer_keep_alive() -> Weight; fn set_balance_creating() -> Weight; fn set_balance_killing() -> Weight; fn force_transfer() -> Weight; } }
Deep Dive
So let’s walk through the steps of a benchmark!
Reference: frame/benchmarking/src/lib.rs
#![allow(unused)] fn main() { -> fn run_benchmark(...) }
The Benchmarking Process
For each component and repeat:
- Select component to benchmark
- Generate range of values to test (steps)
- Whitelist known DB keys
- Setup benchmarking state
- Commit state to the DB, clearing cache
- Get system time (start)
- Execute extrinsic / benchmark function
- Get system time (end)
- Count DB reads and writes
- Record Data
Benchmarking Components
- Imagine a function with 3 components
- let x in 1..2;
- let y in 0..5;
- let z in 0..10;
- We set number of steps to 3.
- Vary one component at a time, select high value for the others.
Δx | Δy | Δy | Δz | Δz | max | |
---|---|---|---|---|---|---|
x | 1 | 2 | 2 | 2 | 2 | 2 |
y | 5 | 0 | 2 | 5 | 5 | 5 |
z | 10 | 10 | 10 | 0 | 5 | 10 |
Benchmarks Evaluated Over Components
Whitelisted DB Keys
#![allow(unused)] fn main() { /// The current block number being processed. Set by `execute_block`. #[pallet::storage] #[pallet::whitelist_storage] #[pallet::getter(fn block_number)] pub(super) type Number<T: Config> = StorageValue<_, BlockNumberFor<T>, ValueQuery>; }
- Some keys are accessed every block:
- Block Number
- Events
- Total Issuance
- etc…
- We don’t want to count these reads and writes in our benchmarking results.
- Applied to all benchmarks being run.
- This includes a “whitelisted account” provided by FRAME.
Example Benchmark
The Identity Pallet
Identity Pallet
- Identity can have variable amount of information
- Name
- etc…
- Identity can be judged by a variable amount of registrars.
- Identity can have a two-way link to “sub-identities”
- Other accounts that inherit the identity status of the “super-identity”
Extrinsic: Kill Identity
#![allow(unused)] fn main() { pub fn kill_identity( origin: OriginFor<T>, target: AccountIdLookupOf<T>, ) -> DispatchResultWithPostInfo { T::ForceOrigin::ensure_origin(origin)?; // Figure out who we're meant to be clearing. let target = T::Lookup::lookup(target)?; // Grab their deposit (and check that they have one). let (subs_deposit, sub_ids) = <SubsOf<T>>::take(&target); let id = <IdentityOf<T>>::take(&target).ok_or(Error::<T>::NotNamed)?; let deposit = id.total_deposit() + subs_deposit; for sub in sub_ids.iter() { <SuperOf<T>>::remove(sub); } // Slash their deposit from them. T::Slashed::on_unbalanced(T::Currency::slash_reserved(&target, deposit).0); Self::deposit_event(Event::IdentityKilled { who: target, deposit }); Ok(()) } }
Handling Configurations
kill_identity
will only execute if theForceOrigin
is calling.
#![allow(unused)] fn main() { T::ForceOrigin::ensure_origin(origin)?; }
- However, this is configurable by the pallet developer.
- Our benchmark needs to always work independent of the configuration.
- We added a special function behind a feature flag:
#![allow(unused)] fn main() { /// Returns an outer origin capable of passing `try_origin` check. /// /// ** Should be used for benchmarking only!!! ** #[cfg(feature = "runtime-benchmarks")] fn successful_origin() -> OuterOrigin; }
External Logic / Hooks
#![allow(unused)] fn main() { // Figure out who we're meant to be clearing. let target = T::Lookup::lookup(target)?; }
- In general, hooks like these are configurable in the runtime.
- Each blockchain will have their own logic, and thus their own weight.
- We run benchmarks against the real runtime, so we get the real results.
- IMPORTANT! You need to be careful that the limitations of these hooks are well understood by the pallet developer and users of your pallet, otherwise, your benchmark will not be accurate.
Deterministic Storage Reads / Writes
#![allow(unused)] fn main() { // Grab their deposit (and check that they have one). let (subs_deposit, sub_ids) = <SubsOf<T>>::take(&target); let id = <IdentityOf<T>>::take(&target).ok_or(Error::<T>::NotNamed)?; }
- 2 storage reads and writes.
- The size of these storage items will depends on:
- Number of Registrars
- Number of Additional Fields
Variable Storage Reads / Writes
#![allow(unused)] fn main() { for sub in sub_ids.iter() { <SuperOf<T>>::remove(sub); } }
enchmarkinghere you store balances!
- What happens with slashed funds is configurable too!
Whitelisted Storage
#![allow(unused)] fn main() { Self::deposit_event(Event::IdentityKilled { who: target, deposit }); }
- We whitelist changes to the Events storage item, so generally this is “free” beyond computation and in-memory DB weight.
Preparing to Write Your Benchmark
-
3 Components
R
- number of registrarsS
- number of sub-accountsX
- number of additional fields
-
Need to:
- Set up account with funds.
- Register an identity with additional fields.
- Set up worst case scenario for registrars and sub-accounts.
- Take into account
ForceOrigin
to make the call.
Kill Identity Benchmark
#![allow(unused)] fn main() { #[benchmark] fn kill_identity( r: Linear<1, T::MaxRegistrars::get()>, s: Linear<0, T::MaxSubAccounts::get()>, x: Linear<0, T::MaxAdditionalFields::get()>, ) -> Result<(), BenchmarkError> { add_registrars::<T>(r)?; let target: T::AccountId = account("target", 0, SEED); let target_origin: <T as frame_system::Config>::RuntimeOrigin = RawOrigin::Signed(target.clone()).into(); let target_lookup = T::Lookup::unlookup(target.clone()); let _ = T::Currency::make_free_balance_be(&target, BalanceOf::<T>::max_value()); let info = create_identity_info::<T>(x); Identity::<T>::set_identity(target_origin.clone(), Box::new(info.clone()))?; let _ = add_sub_accounts::<T>(&target, s)?; // User requests judgement from all the registrars, and they approve for i in 0..r { let registrar: T::AccountId = account("registrar", i, SEED); let balance_to_use = T::Currency::minimum_balance() * 10u32.into(); let _ = T::Currency::make_free_balance_be(®istrar, balance_to_use); Identity::<T>::request_judgement(target_origin.clone(), i, 10u32.into())?; Identity::<T>::provide_judgement( RawOrigin::Signed(registrar).into(), i, target_lookup.clone(), Judgement::Reasonable, T::Hashing::hash_of(&info), )?; } ensure!(IdentityOf::<T>::contains_key(&target), "Identity not set"); let origin = T::ForceOrigin::successful_origin(); #[extrinsic_call] kill_identity<T::RuntimeOrigin>(origin, target_lookup) ensure!(!IdentityOf::<T>::contains_key(&target), "Identity not removed"); Ok(()) } }
Benchmarking Components
#![allow(unused)] fn main() { fn kill_identity( r: Linear<1, T::MaxRegistrars::get()>, s: Linear<0, T::MaxSubAccounts::get()>, x: Linear<0, T::MaxAdditionalFields::get()>, ) -> Result<(), BenchmarkError> { ... } }
- Our components.
- R = Number of Registrars
- S = Number of Sub-Accounts
- X = Number of Additional Fields on the Identity.
- Note all of these have configurable, known at compile time maxima.
- Part of the pallet configuration trait.
- Runtime logic should enforce these limits.
Set Up Logic
#![allow(unused)] fn main() { add_registrars::<T>(r)?; let target: T::AccountId = account("target", 0, SEED); let target_origin: <T as frame_system::Config>::RuntimeOrigin = RawOrigin::Signed(target.clone()).into(); let target_lookup = T::Lookup::unlookup(target.clone()); let _ = T::Currency::make_free_balance_be(&target, BalanceOf::<T>::max_value()); }
- Adds registrars to the runtime storage.
- Set up an account with the appropriate funds.
- Note this is just like writing runtime tests!
Reusable Setup Functions
#![allow(unused)] fn main() { let info = create_identity_info::<T>(x); Identity::<T>::set_identity(target_origin.clone(), Box::new(info.clone()))?; let _ = add_sub_accounts::<T>(&target, s)?; }
- Using some custom functions defined in the benchmarking file:
- Give that account an Identity with x additional fields.
- Give that Identity
s
sub-accounts.
Set Up Worst Case Scenario
#![allow(unused)] fn main() { // User requests judgement from all the registrars, and they approve for i in 0..r { let registrar: T::AccountId = account("registrar", i, SEED); let balance_to_use = T::Currency::minimum_balance() * 10u32.into(); let _ = T::Currency::make_free_balance_be(®istrar, balance_to_use); Identity::<T>::request_judgement(target_origin.clone(), i, 10u32.into())?; Identity::<T>::provide_judgement( RawOrigin::Signed(registrar).into(), i, target_lookup.clone(), Judgement::Reasonable, T::Hashing::hash_of(&info), )?; } }
- Add r registrars.
- Have all of them give a judgement to this identity.
Execute and Verify the Benchmark:
#![allow(unused)] fn main() { ensure!(IdentityOf::<T>::contains_key(&target), "Identity not set"); let origin = T::ForceOrigin::successful_origin(); #[extrinsic_call] kill_identity<T::RuntimeOrigin>(origin, target_lookup) ensure!(!IdentityOf::<T>::contains_key(&target), "Identity not removed"); Ok(()) }
- First ensure statement verifies the “before” state is as we expect.
- We need to use our custom origin.
- Verify block ensures our “final” state is as we expect.
Executing the Benchmark
./target/production/substrate benchmark pallet \
--chain=dev \ # Configurable Chain Spec
--steps=50 \ # Number of steps across component ranges
--repeat=20 \ # Number of times we repeat a benchmark
--pallet=pallet_identity \ # Select the pallet
--extrinsic=* \ # Select the extrinsic(s)
--wasm-execution=compiled \ # Always used `wasm-time`
--heap-pages=4096 \ # Not really needed, adjusts memory
--output=./frame/identity/src/weights.rs \ # Output results into a Rust file
--header=./HEADER-APACHE2 \ # Custom header file to include with template
--template=./.maintain/frame-weight-template.hbs # Handlebar template
Looking at Raw Benchmarking Data
Results: Extrinsic Time vs. # of Registrars
Notes:
Graph source: https://www.shawntabrizi.com/substrate-graph-benchmarks/old/
Results: Extrinsic Time vs. # of Sub-Accounts
Notes:
Graph source: https://www.shawntabrizi.com/substrate-graph-benchmarks/old/
Results: Extrinsic Time vs. Additional Fields
Notes:
Graph source: https://www.shawntabrizi.com/substrate-graph-benchmarks/old/
Result: DB Operations vs. Sub Accounts
Notes:
Graph source: https://www.shawntabrizi.com/substrate-graph-benchmarks/old/
Final Weight
#![allow(unused)] fn main() { // Storage: Identity SubsOf (r:1 w:1) // Storage: Identity IdentityOf (r:1 w:1) // Storage: System Account (r:1 w:1) // Storage: Identity SuperOf (r:0 w:100) /// The range of component `r` is `[1, 20]`. /// The range of component `s` is `[0, 100]`. /// The range of component `x` is `[0, 100]`. fn kill_identity(r: u32, s: u32, x: u32, ) -> Weight { // Minimum execution time: 68_794 nanoseconds. Weight::from_ref_time(52_114_486 as u64) // Standard Error: 4_808 .saturating_add(Weight::from_ref_time(153_462 as u64).saturating_mul(r as u64)) // Standard Error: 939 .saturating_add(Weight::from_ref_time(1_084_612 as u64).saturating_mul(s as u64)) // Standard Error: 939 .saturating_add(Weight::from_ref_time(170_112 as u64).saturating_mul(x as u64)) .saturating_add(T::DbWeight::get().reads(3 as u64)) .saturating_add(T::DbWeight::get().writes(3 as u64)) .saturating_add(T::DbWeight::get().writes((1 as u64).saturating_mul(s as u64))) } }
WeightInfo Generation
#![allow(unused)] fn main() { /// Weight functions needed for pallet_identity. pub trait WeightInfo { fn add_registrar(r: u32, ) -> Weight; fn set_identity(r: u32, x: u32, ) -> Weight; fn set_subs_new(s: u32, ) -> Weight; fn set_subs_old(p: u32, ) -> Weight; fn clear_identity(r: u32, s: u32, x: u32, ) -> Weight; fn request_judgement(r: u32, x: u32, ) -> Weight; fn cancel_request(r: u32, x: u32, ) -> Weight; fn set_fee(r: u32, ) -> Weight; fn set_account_id(r: u32, ) -> Weight; fn set_fields(r: u32, ) -> Weight; fn provide_judgement(r: u32, x: u32, ) -> Weight; fn kill_identity(r: u32, s: u32, x: u32, ) -> Weight; fn add_sub(s: u32, ) -> Weight; fn rename_sub(s: u32, ) -> Weight; fn remove_sub(s: u32, ) -> Weight; fn quit_sub(s: u32, ) -> Weight; } }
WeightInfo Integration
#![allow(unused)] fn main() { #[pallet::weight(T::WeightInfo::kill_identity( T::MaxRegistrars::get(), // R T::MaxSubAccounts::get(), // S T::MaxAdditionalFields::get(), // X ))] pub fn kill_identity( origin: OriginFor<T>, target: AccountIdLookupOf<T>, ) -> DispatchResultWithPostInfo { // -- snip -- Ok(Some(T::WeightInfo::kill_identity( id.judgements.len() as u32, // R sub_ids.len() as u32, // S id.info.additional.len() as u32, // X )) .into()) } }
Initial Weight
#![allow(unused)] fn main() { #[pallet::weight(T::WeightInfo::kill_identity( T::MaxRegistrars::get(), // R T::MaxSubAccounts::get(), // S T::MaxAdditionalFields::get(), // X ))] }
- Use the WeightInfo function as the weight definition for your function.
- Note that we assume absolute worst case scenario to begin since we cannot know these specific values until we query storage.
Final Weight (Refund)
#![allow(unused)] fn main() { pub fn kill_identity(...) -> DispatchResultWithPostInfo { ... } }
#![allow(unused)] fn main() { Ok(Some(T::WeightInfo::kill_identity( id.judgements.len() as u32, // R sub_ids.len() as u32, // S id.info.additional.len() as u32, // X )) .into()) }
- Then we return the actual weight used at the end!
- We use the same WeightInfo formula, but using the values that we queried from storage as part of executing the extrinsic.
- This only allows you to decrease the final weight. Nothing will happen if you return a bigger weight than the initial weight.
Questions
In another presentation we will cover some of the things we learned while benchmarking, and best practices.