Solidity进阶之gas优化

  • Deep Defi
  • 更新于 2022-06-04 16:35
  • 阅读 3922

Solidty的gas优化关键点在于减少storage和内存的读写。

计算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
n < 12

也就是说设置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的readgas开销跟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;
    }
}

分别部署CalleeCaller,在开启编译器优化(runs=200)的情况下,测得testExternalCalltestInternalCall多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();
}
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++

++i是预递增操作,只需要两个步骤:

  • 增加i的值
  • 返回i的值

i++是后递增操作,需要4个步骤:

  • 保存i的旧值
  • 递增i的值,并存放到一个内存临时变量
  • 返回i的旧值
  • 将内存临时变量赋给i

参考

https://docs.soliditylang.org/en/v0.8.14/internals/optimizer.html

https://github.com/wolflo/evm-opcodes

https://blog.polymath.network/solidity-tips-and-tricks-to-save-gas-and-reduce-bytecode-size-c44580b218e6

点赞 4
收藏 15
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
Deep Defi
Deep Defi
0x824C...b1db
Bitcoin holder,Web3 developer。微信公众号Deep Defi。