建议在阅读本文之前,先掌握关于Substrate中交易费用设计的基本概念。如果还没有了解的童鞋,请移步:Kaichao:Substrate 区块链应用的交易费用设计zhuanlan.zhihu.com
读完Substrate区块链应用的交易费用设计的小伙伴,应该掌握:
- 什么是权重(weight)
- 交易费用包括:
基本费用
,字节费用
,权重费用
- 交易级别:
Normal
和Operational
- 如何自定义一个权重计算方法:
#[weight = FunctionOf(...)]
- 如何使用固定权重值,
#[weight = SimpleDispatchInfo::FixNormal(X)]
这篇文章会详细介绍,如何合理地设计runtime中dispatchable function (用户可调用的方法)的weight,学习这篇文章,希望大家可以掌握:
- 如何写出合格的weight document;
- 如何使用benchmark帮助计算weight
0. 概要
作为一名runtime developer, 在weights和fees方面,我们应该:
- 最小化runtime function的复杂度和占用的计算资源;
- 精确地定义相关runtime function的权重(weight)
为了达到以上要求,我们应该:
- 在写runtime时,尽量多参考和follow优秀的设计和写法
- 在注释中尽量写清楚方法的时间复杂度
- 计算这些方法在真实世界中的成本(benchmark),并把它和时间复杂度结合在一起思考
1. Follow Runtime Best Practices
https://github.com/paritytech/substrate
2. Weights注释
首先,我们应该为在runtime中的dispatchable方法完善关于weight的注释。这不仅可以帮助我们确定weight值的设定,也可以帮助我们更有效地优化代码。关于weight的注释结果以时间复杂度表示的结果展示,例如:
O(A + logA + BlogC)
2.1 注释要写什么
weight相关的注释应当要包含对runtime方法的执行成本有明显影响的部分。比如:
- 存储相关的操作 (read, write, mutate, etc.)
- Codec(Encode/Decode)相关操作(序列化/反序列化 vecs或者大的结构体)
- search/sort等成本高的计算
- 调用其他pallet中的方法
- ...
2.2 举例分析
对下面一段代码进行weight注释时的分析:
// Join a group of members.
fn join(origin) {let who = ensure_signed(origin)?;let deposit = T::Deposit::get(); // configuration constantlet sorted_members: Vec<T::AccountId> = Self::members();ensure!(sorted_members.len() <= 100, "Membership Full");match sorted_members.binary_search(&who) {// User is not a member.Err(i) => {T::Currency::reserve(&who, deposit)?;members.insert(i, who.clone());<Members<T>>::put(sorted_members);Ok(())},// User is already a member, do nothing.Ok(_) => Ok(()),}Self::deposit_event(RawEvent::Joined(who));
}
Storage和Codec操作
访问存储是一个成本很高的操作,所以我们应当写好注释并优化。
每一个存储操作都应当结合相关的Codec复杂度,写好注释。
比如,如果你需要从一个存储项中读取一个vec中的值,weight应该这样写:
- One storage read to get the member of this pallet: `O(M)`
在这个例子中,在存储中读取vec有一个codec复杂度O(M)
,因为要对member M
进行反序列化操作。
稍后在module中,可能还会把一个数据再写入存储中,这也应该要有对应的注释:
- One storage write to update the members of this pallet: `O(M)`
Search, Sort 以及其他昂贵的计算
如果在runtime中需要搜索或者排序的话,同样也需要标注相应的复杂度。比如,如果你在一个已经排序的list中执行搜索,binary_search
操作的时间复杂度为O(logM)
, 如果是一个未经排序的list的话,复杂度为O(M)
。所以注释应当像下面这样写:
- Insert a new member into sorted list: O(logM)
调用其他pallet和trait
如果你调用其他FRAME pallet的方法,直接调用或者通过trait设置,需要记录调用的那个方法的复杂度。比如,如果你写的方法保留了一下余额(在Balances里)或者发送一个event(通过System pallet),你再注释里应该写上:
- One balance reserve operation: O(B)
- One event emitted: O(E)
最终的注释
把以上的操作注释结合在一起,我们就可以为一个方法写上完整的注释:
# <weight>
Key: M (len of members), B (reserve balance), E (event)
- One storage read to get the members of this pallet: `O(M)`.
- One balance reserve operation: O(B)
- Insert a new member into sorted list: O(logM).
- One storage write to update the members of this pallet: `O(M)`.
- One event emitted: O(E)Total Complexity: O(M + logM + B + E)
# </weight>
注意: 在为方法写weight注释时可能引入了不同的参数,记得吧每个参数都标记好。
如果仔细看上面的样例代码,就可以看到有两个操作的时间复杂度都是O(M)
(存储读和写),但是整体的时间复杂度O并没有把这部分考虑进来。
所以我们没法区分有同样复杂度的两个方法,这意味着可以在这个方法里加入很多复杂度为O(M), O(logM)...
的操作,但是并不会对最后的复杂度标记做任何更改:
weight(M, B, E) = K_1 + K_2 * M + K_3 * logM + B + E
对这部分区别,我们通过on-chain tests来衡量。
3. 如何大致推断weight
总结:综合考虑时间复杂度和具体的操作,参考目前FRAME中一些标志性的extrinsic(比如transfer),大致确定当前方法的weight数量级
对weight有了更深的了解之后,在测试之前,我们可以给runtime方法先设定一个暂时的权重值。更多时候,我们的extrinsics大概率都是normal transactions
,所以关于权重的声明大概率会是像下面这样:
#[weight = SimpleDispatchInfo::FixedNormal(YOUR_WEIGHT)]
在深入探讨更加细致的方法之前,我们可以简单地参考一下现有pallet中方法的weight,给大家一个简单的参考:
- System: Remark - 没有任何逻辑。就用权重值允许的最小值标注。
/// Make some on-chain remark.
#[weight = SimpleDispatchInfo::FixedNormal(10_000)]
fn remark(origin, _remark: Vec<u8>) { ensure_signed(origin)?;
}
- Staking: Set Controller. - 一次固定复杂度的存储读+写 (500,000)
#[weight = SimpleDispatchInfo::FixedNormal(500_000)]
fn set_payee(origin, payee: RewardDestination) { let controller = ensure_signed(origin)?; let ledger = Self::ledger(&controller).ok_or(Error::<T>::NotController)?; let stash = &ledger.stash;<Payee<T>>::insert(stash, payee);
}
- Balances: Transfer. - 固定的时间复杂度 (1,000,000)
#[weight = SimpleDispatchInfo::FixedNormal(1_000_000)]
pub fn transfer(origin, dest: <T::Lookup as StaticLookup>::Source, #[compact] value: T::Balance
) { let transactor = ensure_signed(origin)?; let dest = T::Lookup::lookup(dest)?; <Self as Currency<_>>::transfer(&transactor, &dest, value, ExistenceRequirement::AllowDeath)?;
}
- Elections: Present Winner - O(voters) 复杂度的计算加上一次写操作 (10,000,000)
#[weight = SimpleDispatchInfo::FixedNormal(10_000_000)]
fn present_winner( ... ) {//--lots-of-code--
}
- 如果想要在一个执行方法A的extrinsic最终会执行方法B,那么会简单地在B的权重值的基础上加上
10_000
,作为passthrough weight
。 比如sudo中的sudo
方法,详见:
https://github.com/paritytech/substrate/blob/master/frame/sudo/src/lib.rs#L123
https://github.com/paritytech/substrate/pull/4946
4. 如何计算weight
给runtime中的某个方法定一个权重值,是一件有些主观的事情,思考的维度也有很多,这里重点从技术角度,介绍如何给runtime中的方法进行性能测试,可以为确定方法的权重值多一些事实参考。
一般来说,想要给一个方法一个确定的权重值,可以从以下几个角度考虑:
- 如果一个区块里只包括这一个方法的extrinsic,你希望最多包括多少笔;
- 如果以
balances.transfer
作为基准,那这个方法要用多少权重; - ...
从不同的侧重点触发,可能得到的结果也会稍有不同。这里只介绍最后一种思考方式,如果我们相信Substrate目前FRAME中的function weight是合理的话 。
4.1 benchmark
简单来说,benchmark就是测试在指定的上下文中,执行指定的runtime function 花费了多少时间(in nanosencond)。
benchmark介绍
Substrate中有关于benchmark的宏,benchmarks!
,大家直接就可以使用。
使用benchmarks!
的整体结构如下,
benchmarks! {
_ {}
scenarioA {}: functionA(...)
scenarioB {}: functionA(...)
scenarioC {}: functionC(...)
}
像_
, scenarioA
, scenarioB
, scenarioC
这部分,被称为arms
,可以简单地理解为性能测试中的场景/分支。可以针对一个runtime function构建多种场景,比如最好情况、一般情况、最坏情况等;而后面的functionA
, functionB
就是构建完场景(上下文)后具体执行的runtime function。如果该方法和arms重名的话,可以用_
代替省略。
建议大家在性能测试时尽可能保守,即考虑最坏情况来确定weight
关于构建测试场景(上下文),大家可以参考substrate现有的做法,比如:
- 输入参数可以构造成升序、降序、随机;
- 尽可能执行function中尽可能多的operations,比如转转账时涉及到创建/清空地址;
- ...
benchmark示例参考:
benchmark示例参考:
https://github.com/paritytech/substrate/blob/master/frame/balances/src/benchmarking.rs
https://github.com/paritytech/substrate/blob/master/frame/identity/src/benchmarking.rs
暴露benchmark runtime api
在impl_runtime_apis
中,给实现了benchmarking的pallet添加对应的benchmark接口;
impl_runtime_apis! {impl frame_benchmarking::Benchmark<Block> for Runtime {fn dispatch_benchmark(module: Vec<u8>,extrinsic: Vec<u8>,steps: Vec<u32>,repeat: u32,) -> Option<Vec<frame_benchmarking::BenchmarkResults>> {use frame_benchmarking::Benchmarking;match module.as_slice() {b"pallet-balances" | b"balances" => Balances::run_benchmark(extrinsic, steps, repeat).ok(),b"pallet-timestamp" | b"timestamp" => Timestamp::run_benchmark(extrinsic, steps, repeat).ok(),_ => None,}}}
}
benchmark CLI
最后一步,为了使substrate CLI可以使用类似substrate benchmark
(或者node-template benchmark
)这样的subcommand来执行指定pallet的性能测试,我们还需要为cli添加对应的subcommand以及暴露benchmark host functions。因为目前node-template不是默认支持CLI中使用benchmark,所以我们需要对node-template做一些必要的微调:
具体做法请参考:
https://github.com/hammewang/substrate-multisig/github.com之所以在substrate官方的node-template中没有集成benchmark,也是Parity考虑后的结果:
https://github.com/paritytech/substrate/pull/4875
benchmark CLI如何使用
以multisig-template举例:
./target/release/node-template benchmark --chain dev --pallet multisig --extrinsic create_multisig_wallet --execution wasm --repeat 2 --steps 10
这里会把测试结果写成一个csv,方便后续统计。补充说明一下常用参数:
- --execution wasm: 在wasm环境中执行性能测试,这里支持的所有可能的参数:[Native, Wasm, Both, NativeElseWasm]
- --pallet: 想要测试的pallet,这里可以填
pallet-multisig
或者multisig
- --chain: 链运行的初始状态,比如
dev
,local
,或者在chain_spec
自定义 - --steps: 从默认(1)开始,执行的最多样本点数量
- --repeat: 每个样本点重复次数
4.2 如何得到相对准确的weight
在执行完上述命令,会得到如下结果:
Pallet: "multisig", Extrinsic: "create_multisig_wallet", Steps: [10], Repeat: 2
u,extrinsic_time,storage_root_time
1,418000,39000
1,217000,29000
100,200000,28000
100,200000,29000
199,203000,29000
199,195000,27000
298,201000,28000
298,202000,28000
397,197000,28000
397,196000,29000
496,201000,28000
496,244000,28000
595,234000,31000
595,199000,28000
694,231000,32000
694,246000,29000
793,218000,31000
793,196000,28000
892,205000,29000
892,222000,29000
991,244000,32000
991,228000,31000
根据第一行head可以看到,第二列就是执行这个extrinsic,在指定执行环境中,所花费的时间。大家可以根据需要对其进行处理,比如取平均值。
然后,大家可以在自己的机器上,再执行一遍balances.transfer
的benchmark(我们选择了balances.transfer
的weight作为基准参考)。
然后把两个性能测试的结果做一下对比,大致就可以得到一个相对准确的weight值。
再次友情提醒:请大家注意性能测试时,为了准确,覆盖尽可能多的情况(最好、最坏、一般)
这里贴一下Substrate目前已经有的部分benchmark结果:
补充
目前Substrate中的benchmark仍然处于早期状态,后期很可能会出现breaking changes。本文写于f41677d0, 想直接使用的童鞋请认准依赖版本。
希望可以给大家提供到一些,关于weight设定的基本概念,以及可行但粗糙的实践方法。欢迎共同交流。
最新动态
github repo和substrate官方dev文档是更新最及时、也是知识结构最系统的地方,值得star和收藏:
https://github.com/paritytech/substrategithub.comhttps://substrate.dev/substrate.dev或者中文资料:
http://subdev.cn/subdev.cn参考资料
https://substrate.dev/docs/en/conceptual/runtime/weight
https://github.com/paritytech/substrate/pull/3157