Solidty的gas优化关键点在于减少storage和内存的读写。
一笔交易发送到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)。
如果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
当交易提交给EVM时,EVM会创建两个Set:
当OP需要访问地址或者storage的slot时会先检查是否在这两个Set中,如果在的话(热访问)gas的消耗就比较低,否则(冷访问)gas的消耗就较高。访问之后的地址和slot也会添加到这两个Set中。
COLD_ACCOUNT_ACCESS_COST = 2600 // 地址集合的冷访问开销
COLD_SLOAD_COST = 2100 // storage的冷访问开销
WARM_STORAGE_READ_COST = 100 // storage的热访问开销
gas_cost = 10 + 50 * byte_len_exponent
byte_len_exponent
是指数的字节长度,比如指数为4,只需要1个字节,那么gas=60
。
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便宜。
gas_cost = 3 + 3 * data_size_words + mem_expansion_cost
gas_cost = access_cost + 3 * data_size_words + mem_expansion_cost
access_cost = 100 (热访问)
access_cost = 2600 (冷访问)
EXTCODECOPY跟CODECOPY不一样,EXTCODECOPY访问的是地址,因此有access_cost
。
访问的是地址,因此有access_cost
。
gas_cost = 100 (热访问)
gas_cost = 2600 (冷访问)
gas_cost = 3 + mem_expansion_cost
gas_cost = mem_expansion_cost
在solidity中require的错误消息越短越好。
访问的是storage,因此有access_cost
。
gas_cost = 100 (热访问)
gas_cost = 2100 (冷访问)
SSTORE的计算规则比较复杂,简单来说:
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;
gas_cost = 375 + 375 * num_topics + 8 * data_size + mem_expansion_cost
每个topic消耗的gas都不少,如果没有filter查询的必要则要避免在log中使用topic。
这几个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。
if (call_value > 0) base_gas += 9000
if (is_empty(target_addr)) base_gas += 25000
if (call_value > 0) base_gas += 9000
要想知道优化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消耗量比较大的代码逻辑然后进行优化。
编译器优化是最常见的一种优化手段,比如一般在配置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
n < 12
也就是说设置runs
的值小于12时,编译器会给代码做上述优化,否则优化就没有收益。无论runs
设置成什么值,编译器都会把所有能优化的手段都用上,只是会根据runs
的大小来判断优化是否值得,值得的话就执行优化。
大部分情况下runs
越大越好,但是超过一定的数值(这个需要实测)优化效果就不明显,并且过大的runs
值可能导致优化后的合约代码体积超过最大限制(目前是24576个字节)。
编译器对solidity代码的优化分为两个部分:
编译器会将一些等价的op进行合并,会去掉一些无用的op,比如连续两个SWAP
对栈其实没有影响就可以去掉。编译器还有一些特殊的优化规则,比如AND(X, X)
就可以改为直接在栈上放置一个X
。
在合约中对Storage的读写永远是gas消耗的大头,Storage优化的思路之一就是尽可能减少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的读写频率,比如一段业务逻辑中需要频繁的读一个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。有时候合约的业务逻辑比较复杂使得单个合约地址的体积超过了最大限制,因此开发者面临两种选择:
首次调用地址的接口delegatecall比call少花至少2700个gas,但是delegatecall在使用的难度上要比call高。因此如果是gas不太敏感的业务逻辑建议还是使用call。如果是gas极其敏感的业务逻辑可以使用delegatecall。
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。
如果某个接口既需要被外部访问,也需要被相同合约内的其它接口访问,那么接口实现可以这样:
// good
function f0() external {
_f();
}
function _f() internal {}
// bad, expensive
function f0() public {}
访问internal方法只需要JUMP到对应的OP就可以,参数通过栈上的内存地址来传递。但是访问pubic方法需要进行参数的内存拷贝,如果参数较大带来的gas开销就比较高。
solidity中的变量都有默认值,因此初始化会浪费一点gas。
uint256 hello = 0; //bad, expensive
uint256 world; //good, cheap
++i是预递增操作,只需要两个步骤:
i++是后递增操作,需要4个步骤:
https://docs.soliditylang.org/en/v0.8.14/internals/optimizer.html
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!