Solidity进阶之gas优化

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();