Solidity进阶之gas优化
本文作者: Deep Defi
计算 gas
一笔交易发送到 Ethereum 所需要的 gas:
gas = txGas + dataGas + opGas
如果交易没有创建新的合约,则
txGas
为 21000,否则
txGas
为 53000。交易中
data
的每个零字节需要花费 4 个 gas,每个非零字节需要花费 16 个 gas。
opGas
是指运行完所有的 op 所需要的 gas。
在交易 gas 的构成中,
dataGas
一般远小于
opGas
,优化的空间也比较小,优化 gas 的主要焦点在
opGas
上。大部分的 OP 消耗的 gas 数量是固定的(比如
ADD
消耗 3 个 gas),少部分 OP 的 gas 消耗是可变的,也是 gas 消耗的大头(比如
SLOAD
一个全新的非零值需要花费至少 20000 个 gas)。
Memory extension
如果 OP 对内存地址的引用,不管是读、写还是其它操作(比如
CALL
)只要超过了当前内存的长度,就会带来额外 gas 消耗。
gas_cost = Cmem(new_state) - Cmem(old_state)
gas_cost = (new_mem_size_words ^ 2 / 512) + (3 * new_mem_size_words) - Cmem(old_state)
new_mem_size_words = (new_mem_size + 31) / 32
Access Sets
当交易提交给 EVM 时,EVM 会创建两个 Set:
- touched_addresses : Set[Address],初始化时包含 tx.origin 和 tx.to
- touched_storage_slots : Set[(Address, Bytes32)],初始化为空
当 OP 需要访问地址或者 storage 的 slot 时会先检查是否在这两个 Set 中,如果在的话(热访问)gas 的消耗就比较低,否则(冷访问)gas 的消耗就较高。访问之后的地址和 slot 也会添加到这两个 Set 中。
COLD_ACCOUNT_ACCESS_COST = 2600 // 地址集合的冷访问开销
COLD_SLOAD_COST = 2100 // storage的冷访问开销
WARM_STORAGE_READ_COST = 100 // storage的热访问开销
EXP
gas_cost = 10 + 50 * byte_len_exponent
byte_len_exponent
是指数的字节长度,比如指数为 4,只需要 1 个字节,那么
gas=60
。
SHA3
gas_cost = 30 + 6 * data_size_words + mem_expansion_cost
data_size_words = (data_size + 31) / 32
EVM 还支持 sha256,只不过 sha256 是以预编译合约的形式存在的,并非 OP。sha256 的 gas 消耗为
gas_cost = 60 + 12 * data_size_words
在没有额外内存开销的情况下,在 solidity 中使用 keccak256(sha3)比使用 sha256 便宜。
CALLDATACOPY, CODECOPY, RETURNDATACOPY
gas_cost = 3 + 3 * data_size_words + mem_expansion_cost
EXTCODECOPY
gas_cost = access_cost + 3 * data_size_words + mem_expansion_cost
access_cost = 100 (热访问)
access_cost = 2600 (冷访问)
EXTCODECOPY 跟 CODECOPY 不一样,EXTCODECOPY 访问的是地址,因此有
access_cost
。
BALANCE, EXTCODESIZE, EXTCODEHASH
访问的是地址,因此有
access_cost
。
gas_cost = 100 (热访问)
gas_cost = 2600 (冷访问)
MLOAD, MSTORE, MSTORE8
gas_cost = 3 + mem_expansion_cost
RETURN, REVERT
gas_cost = mem_expansion_cost
在 solidity 中 require 的错误消息越短越好。
SLOAD
访问的是 storage,因此有
access_cost
。
gas_cost = 100 (热访问)
gas_cost = 2100 (冷访问)
SSTORE
SSTORE 的计算规则比较复杂,简单来说:
- 首次对一个 slot 存储一个非零值最多需要 20000 个 gas
- 更新 slot(还是非零值)最多需要 2900 个 gas
- 将 slot 清零会得到最多 19900 个 gas 的返还
- 冷访问需要额外的 2100 个 gas
openzepplin 的 ReentrancyGuard 的实现中,用于描述是否重入的状态的两个值分别是 1 和 2,并非 0 和 1。这是因为 slot 的值在[0,1]之间的互换带来的 gas 消耗要高于[1,2]之间互换。
// The values being non-zero value makes deployment a bit more expensive,
// but in exchange the refund on every call to nonReentrant will be lower in
// amount. Since refunds are capped to a percentage of the total
// transaction's gas, it is best to keep them low in cases like this one, to
// increase the likelihood of the full refund coming into effect.
uint256 private constant _NOT_ENTERED = 1;
uint256 private constant _ENTERED = 2;
LOG0-LOG8
gas_cost = 375 + 375 * num_topics + 8 * data_size + mem_expansion_cost
每个 topic 消耗的 gas 都不少,如果没有 filter 查询的必要则要避免在 log 中使用 topic。
CALL, CALLCODE, DELEGATECALL, STATICCALL
这几个 OP 的
gas_cost
都包含两部分
gas_cost = base_gas + gas_sent_with_call
base_gas = access_cost + mem_expansion_cost
access_cost = 100 (热访问) 或者 2600 (冷访问)
gas_sent_with_call
是给
callee
地址发送的 gas。
CALL
if (call_value > 0) base_gas += 9000
if (is_empty(target_addr)) base_gas += 25000
CALLCODE
if (call_value > 0) base_gas += 9000
测量 gas
要想知道优化 gas 的效果得先知道优化前后合约消耗 gas 的差异,最简单的测量 gas 的方式就是打印出交易的
gasUsed
:
let txr = await provider.getTransactionReceipt(tx.hash);
console.log(txr.gasUsed);
当业务逻辑比较复杂,还可以借助于 hardhat 的 console 合约,在合约里面输出剩余的 gas:
import "hardhat/consol.sol";
console.logUint(gasleft());
// 一段gas消耗量比较大的代码
console.logUint(gasleft());
这样可以精确的定位 gas 消耗量比较大的代码逻辑然后进行优化。
优化 gas
编译器优化
编译器优化是最常见的一种优化手段,比如一般在配置 hardhat.config 文件时会设置编译器的 optimizer 属性,如下所示:
settings: {
optimizer: {
enabled: true,
runs: 200
enabled
表示开启优化,
runs
表示合约中的每个 op 在整个合约的生命周期内会被执行的次数,这个次数是开发者预期的。比如
push32 0x0100000000000000000000000000000000000000000000000000000000000000
可以被优化为
push1 0x01 push1 248 shl
指标 | 优化前 | 优化后 |
---|---|---|
deploy 代码体积 | 33 字节 | 5 字节 |
deploy 时 pubdata 消耗的 gas | 156 | 80 |
运行一次消耗的 gas | 3 | 9 |
pubdata 中每个非零字节消耗 16 个 gas,零字节消耗 4 个 gas。push1,push32 以及 shl 消耗的 gas 都是 3。
不考虑代码体积上的区别,令当 op 运行 n 次时优化有收益,即:
156 + 3 * n > 80 + 9 *n
也就是说设置
runs
的值小于 12 时,编译器会给代码做上述优化,否则优化就没有收益。无论
runs
设置成什么值,编译器都会把所有能优化的手段都用上,只是会根据
runs
的大小来判断优化是否值得,值得的话就执行优化。
大部分情况下
runs
越大越好,但是超过一定的数值(这个需要实测)优化效果就不明显,并且过大的
runs
值可能导致优化后的合约代码体积超过最大限制(目前是 24576 个字节)。
编译器对 solidity 代码的优化分为两个部分:
- EVM op 的优化
- Yul IR(intermediate-representation)代码的优化
编译器会将一些等价的 op 进行合并,会去掉一些无用的 op,比如连续两个
SWAP
对栈其实没有影响就可以去掉。编译器还有一些特殊的优化
规则
,比如
AND(X, X)
就可以改为直接在栈上放置一个
X
。
Storage 优化
减少 Slot 的数量
在合约中对 Storage 的读写永远是 gas 消耗的大头,Storage 优化的思路之一就是尽可能减少 slot 的数量,比如
- 用一个 slot 尽可能的表示多个变量
- 将多个可以共享一个 slot 的变量紧挨在一起
每个 slot 都是 32 个字节(256 位),可以存放 8 个 uint32,而 uint32 常常用来表示时间戳(最大可以到 2106 年)。
contract Example0 {
uint256 public timestamps;
function init(uint32[8] calldata _ts) external {
uint256 t;
for(uint i = 0; i < 8; ++i) {
t |= uint256(_ts[i]) << i * 32;
timestamps = t;
function read() external view returns (uint32[8] memory _ts) {
uint256 t = timestamps;
_ts[0] = uint32(t);
_ts[1] = uint32(t >> 32);
_ts[2] = uint32(t >> 64);
_ts[3] = uint32(t >> 96);
_ts[4] = uint32(t >> 128);
_ts[5] = uint32(t >> 160);
_ts[6] = uint32(t >> 192);
_ts[7] = uint32(t >> 224);
contract Example1 {
// timestamp0-7这8个变量可以共享一个slot
uint32 public timestamp0;
uint32 public timestamp1;
uint32 public timestamp2;
uint32 public timestamp3;
uint32 public timestamp4;
uint32 public timestamp5;
uint32 public timestamp6;
uint32 public timestamp7;
function init(uint32[8] calldata _ts) external {
timestamp0 = _ts[0];
timestamp1 = _ts[1];
timestamp2 = _ts[2];
timestamp3 = _ts[3];
timestamp4 = _ts[4];
timestamp5 = _ts[5];
timestamp6 = _ts[6];
timestamp7 = _ts[7];
function read() external view returns (uint32[8] memory _ts) {
_ts[0] = timestamp0;
_ts[1] = timestamp1;
_ts[2] = timestamp2;
_ts[3] = timestamp3;
_ts[4] = timestamp4;
_ts[5] = timestamp5;
_ts[6] = timestamp6;
_ts[7] = timestamp7;
contract Example2 {
// timestamp0-7这8个变量每个占用1个slot
uint32 public timestamp0;
uint public g0;
uint32 public timestamp1;
uint public g1;
uint32 public timestamp2;
uint public g2;
uint32 public timestamp3;
uint public g3;
uint32 public timestamp4;
uint public g4;
uint32 public timestamp5;
uint public g5;
uint32 public timestamp6;
uint public g6;
uint32 public timestamp7;
// 其它代码与Example1相同
}
测试这三个合约
read
接口调用的 gas 花费:
合约 | readGasCost |
---|---|
Example0 | 24546 |
Example1 | 24601 |
Example2 | 39206 |
减少 Slot 的读写
另一个优化思路是要减少 slot 的读写频率,比如一段业务逻辑中需要频繁的读一个 storage 变量,可以先将这个 storage 变量载入内存,内存的读写是比较便宜的。
contract Example3 {
// 其它与Example0相同
function read() external view returns (uint32[8] memory _ts) {
_ts[0] = uint32(timestamps);
_ts[1] = uint32(timestamps >> 32);
_ts[2] = uint32(timestamps >> 64);
_ts[3] = uint32(timestamps >> 96);
_ts[4] = uint32(timestamps >> 128);
_ts[5] = uint32(timestamps >> 160);
_ts[6] = uint32(timestamps >> 192);
_ts[7] = uint32(timestamps >> 224);
}
在开启编译器优化的情况下 Example3 的
read
gas 开销跟 Example0 相同,这是因为编译器会给自动给合约进行上述优化处理。而未开启编译器优化的情况下:
合约 | readGasCost |
---|---|
Example0 | 26648 |
Example3 | 27335 |
减少对外部合约地址的调用
先看两个简单的合约之间的调用:
contract Caller {
uint256 public x;
Callee public callee;
constructor(Callee _callee) {
callee = _callee;
function testExternalCall() external {
x = callee.add(1, 1);
function testInternalCall() external {
x = add(1, 1);
function add(uint256 a, uint256 b) internal pure returns (uint256) {
return a + b;
contract Callee {
function add(uint256 a, uint256 b) external pure returns (uint256) {
return a + b;
}
分别部署
Callee
和
Caller
,在开启编译器优化(runs=200)的情况下,测得
testExternalCall
比
testInternalCall
多 5385 个 gas。经过分析可知前者在执行过程中有这么几步:
-
将
callee
载入到内存,访问了 storage 的 slot1,由于是首次访问 slot 因此需要花费 2100 个 gas。 -
调用
callee
之前需要执行EXTCODESIZE
来判断地址是否为合约,由于是首次访问地址因此需要花费 2600 个 gas。 -
对
callee
执行STATICCALL
,第二次访问地址(热访问),因此需要花费 100 个 gas。
所以
testExternalCall
额外的刚性开销为 4800 个 gas。有时候合约的业务逻辑比较复杂使得单个合约地址的体积超过了最大限制,因此开发者面临两种选择:
- 将业务逻辑拆分在不同的合约中,分别部署,然后使用 call 来调用。
- 将业务逻辑拆分在不同的合约中,分别部署,然后使用 delegatecall 来调用。
首次调用地址的接口 delegatecall 比 call 少花至少 2700 个 gas,但是 delegatecall 在使用的难度上要比 call 高。因此如果是 gas 不太敏感的业务逻辑建议还是使用 call。如果是 gas 极其敏感的业务逻辑可以使用 delegatecall。
预热 Access Sets
EIP-2930
引入了一个新的交易类型,这个交易可以携带一个
accessList
参数,将交易执行过程中需要访问的地址和 slot 集合进行预初始化,这样可以减少冷访问的开销。
避免内存扩展
当数据量较大时,内存扩展带来的开销也比较可观,比如:
contract Mem0 {
function hashData0(bytes[] calldata _datas) external pure returns (bytes32 hash) {
for (uint i = 0; i < _datas.length; ++i) {
bytes32 h1 = keccak256(_datas[i]);
hash = concatTwoHash(hash, h1);
function hashData1(bytes[] calldata _datas) external pure returns (bytes32 hash) {
bytes memory _allData;
for (uint i = 0; i < _datas.length; ++i) {
_allData = bytes.concat(_allData, _datas[i]);
hash = keccak256(_allData);
function concatTwoHash(bytes32 a, bytes32 b) internal pure returns (bytes32 value) {
assembly {
mstore(0x00, a)
mstore(0x20, b)
value := keccak256(0x00, 0x40)
}
当
_datas
的长度为 10,每个数组元素都是 10 个字节时,测试结果:
接口 | gasCost |
---|---|
hashData0 | 33068 |
hashData1 | 34445 |
两种计算 hash 的代码中 hashData0 的
keccak256
的调用次数是明显比 hashData1 多的,但是 hashData1 在拼接 hash 数据的时候不停地发生内存扩展,所以实际上的开销更大。
在接口中使用 calldata 也能减少内存扩展,比如:
contract Mem1 {
function getByte0(bytes calldata _data) external pure returns (bytes1 b) {
b = _data[0];
function getByte1(bytes memory _data) external pure returns (bytes1 b) {
b = _data[0];
}
当
_data
为一个长度 10 的字节数组时,测试结果:
接口 | gasCost |
---|---|
getByte0 | 22133 |
getByte1 | 22258 |
使用 remix 进行 debug 时可以发现 getByte0 的内存初始状态为
{
"0x0": "00000000000000000000000000000000\t????????????????",
"0x10": "00000000000000000000000000000000\t????????????????",
"0x20": "00000000000000000000000000000000\t????????????????",
"0x30": "00000000000000000000000000000000\t????????????????",
"0x40": "00000000000000000000000000000000\t????????????????",
"0x50": "00000000000000000000000000000080\t????????????????"
而 getByte1 的内存初始状态为
{
"0x0": "00000000000000000000000000000000\t????????????????",
"0x10": "00000000000000000000000000000000\t????????????????",
"0x20": "00000000000000000000000000000000\t????????????????",
"0x30": "00000000000000000000000000000000\t????????????????",
"0x40": "00000000000000000000000000000000\t????????????????",
"0x50": "000000000000000000000000000000c0\t????????????????",
"0x60": "00000000000000000000000000000000\t????????????????",
"0x70": "00000000000000000000000000000000\t????????????????",
"0x80": "00000000000000000000000000000000\t????????????????",
"0x90": "0000000000000000000000000000000a\t???????????????\n",
"0xa0": "01010101010101010101000000000000\t????????????????",
"0xb0": "00000000000000000000000000000000\t????????????????",
"0xc0": "00000000000000000000000000000000\t????????????????",
"0xd0": "00000000000000000000000000000000\t????????????????"
[0x80-0x9f]存放了
_data
的长度,[0xa0-0xbf]存放了
_data
的内容,新的可用内存起始地址变成了 0xc0。
其它 Tips
避免使用 public
如果某个接口既需要被外部访问,也需要被相同合约内的其它接口访问,那么接口实现可以这样:
// good
function f0() external {
_f();