背景
在以太坊系公链中,合约部署和调用是需要发送交易并消耗 gas 的,而 gas 的使用量决定了该笔交易的费用。因此,设计省钱的合约是很重要的。
在部署合约时,我们希望减小合约编译后的字节码大小,来减少合约部署时的 gas 消耗。而好的代码实现,能够减少合约调用时的 gas 消耗。
减少 gas 消耗的方法
以下介绍一些减少合约 gas 消耗的具体方法。
1、编译合约时使用优化器
使用编译器 solc 编译合约时启动优化器 optimizer,它将简化复杂的表达方式,能减小编译后的合约字节码大小,从而减少部署时的 gas 消耗,同时也能减少合约调用时的消耗。
基于 opcode 的优化器会执行一系列的简化规则,把重复的代码合并,把多余的代码删除。目前(v0.8.11 版本),--optimize
参数激活的是基于 opcode 的优化器。
solc --optimize --optimize-runs 200
运行次数 --optimize-runs
指定了部署的代码的每个操作码在合同的生命周期内被执行的大致频率。这意味着它是代码大小(部署成本)和代码执行成本(部署后的成本)之间的一个折衷参数。次数越小,编译出的字节码越小,但是调用该合约函数可能需要更多 gas。
此外还有基于 Yul 的优化器,更加强大,因为它可以跨函数调用工作。
详情见文档。
2、SSTORE 指令
在链上存储变量的值,要用到 SSTORE
指令。在不同情况下,该指令消耗的 gas 不同。
https://eips.ethereum.org/EIPS/eip-1087
我们使用如下合约代码部署在 CSC
测试链上进行测试:
pragma solidity ^0.8.0;
contract Test {
uint256 x;
function emptySet(uint256 _x) public {}
function set(uint256 _x) public {
x = _x;
}
}
实际测试结果如下:
- 将 x 变量从零值设置为非零值 1,消耗 41406 gas,其中交易基础 gas 21000,推算存储实际消耗 20000 gas。
- 将 x 变量从非零值设置为零值,消耗 13197 gas,其中交易基础 gas 21000,推算存储实际返还约 8000 gas。
- 将 x 变量从非零值 1 设置为另一个非零值 10000,消耗 26418 gas,推算存储实际消耗 5000 gas。
- 将 x 变量从非零值设置为同一个非零值,消耗 22218 gas,推算存储实际消耗约 800 gas。
- 将 x 变量从零值设置为零值,消耗 22194 gas,推算存储实际消耗约 800 gas。
这里扩展一点,合约自毁和将变成从非零值设置为零值一样,是会返回 gas 的。
3、变量存储原则
从 SSTORE
指令的 gas 消耗测试结果可以看到,存储操作是非常消耗 gas 的,特别是将变量从零值设置为非零值时。
因此,我们应该考虑:
- 避免在链上存储用不上、不重要的数据,如介绍、描述信息等。
- 考虑使用事件来存储,要比将它们存储在变量中便宜得多。
- 在 IPFS 上存储较大的数据,如图片、文档等,在合约中只储存其哈希值。
- 无状态合约,即用交易数据和事件等来保存数据,而不是改变合约的存储状态。比如发送一个交易并传递你想要存储的值,而不是真正存储它。更多可见这篇文章。
同样消耗 gas 比较多的操作指令还有 CREATE
、 CREATE2
等,我们使用时应该注意。
4、选择变量数据类型
不同数据类型的存储消耗不同。在满足业务的情况下,我们应该选择 gas 消耗更小的数据类型。
- 在没有办法将多个变量放入同一个插槽时(在【5、紧凑状态变量打包】中说明),尽量使用 256 位的变量,例如
uint256
和bytes32
。
在使用小于 32 字节的变量数据类型时,合约的 gas 使用量可能会高于使用 32 字节的类型。这是因为 EVM 每次操作 32 个字节, 所以如果变量大小比 32 字节小,EVM 必须执行额外的操作以便将 32 字节大小缩减到到所需的大小。
我们将如下两个合约部署在 CSC
测试链上:
// 消耗 gas 68820
contract A {
uint8 x = 0;
}
// 消耗 gas 67900
contract A {
uint256 x = 0;
}
发现存储一个 uint256
变量比 uint8
变量消耗的 gas 更少。
此外,在 EVM 执行计算也需要额外的操作,除 uint256
之外的其他 uint
类型在计算时需要耗费额外的 gas 进行转换。
- 尽量使用定长数组,通常它们更省 gas。比如使用定长字节数组
bytes1
,bytes2
...bytes32
,而不是变长字节数组bytes
。如果要使用变长字节数组,则尽量使用bytes
而不是[]byte
,后者会更加浪费存储空间。详情可见文档。
5、紧凑状态变量打包
首先,Solidity 合约数据存储的方案是为合约每个变量指定一个可计算的存储位置,数据存在容量为 2 ** 256 超级数组中,数组中每项数据的初始值为 0。
每个插槽可存储 256 位/32 字节数据:
合约状态变量存储结构相关描述可见文档。
根据文档描述,静态大小的变量(除映射 mapping 和动态数组之外的所有类型)都从位置 0
开始连续放置在存储插槽(storage slot)中的。如果可能的话,存储大小少于 32 字节的多个变量会被打包到一个存储插槽中(每个存储插槽 256 位/32 字节),规则如下:
- 存储插槽中的第一项会以低位对齐(即右对齐)的方式储存。
- 值类型仅使用存储它们所需的字节数。
- 如果存储插槽中的剩余空间不足以储存一个值类型变量,那么它会被移入下一个存储插槽。
- 结构体和数组数据会使用一个新插槽进行存储,但结构体或数组中的各项,都会以这些规则进行打包。
- 结构体和数组数据之后的变量会使用一个新插槽。
紧凑状态变量打包,就是将多个不需要用到 32 字节的值类型数据存储在一个插槽中。通过合理地排列状态变量的顺序、结构体的字段的顺序,使得尽可能多的状态变量打包到一个存储插槽中,最终使用更少的存储插槽,减少 gas 消耗。
注意,要在编译的时候使用优化器进行优化。
以定义一个结构体为例,我们将如下两个合约部署在 CSC
测试链上:
contract Test {
// 字段 a, b, c 分别使用了一个存储插槽,共使用 3 个
struct A {
uint a;
uint b;
uint c;
}
A a = A(10, 20, 30);
}
contract Test {
// 字段 a, b 共需要 8 字节,可以共用一个存储插槽
// 字段 c 需要 32 字节,前一个插槽不够放,因此开启使用一个新的存储插槽
// 共使用 2 个存储插槽
struct A {
uint32 a; // uint32 类型大小为 32 位/4 字节
uint32 b;
uint c;
}
A a = A(10, 20, 30);
}
// 第一个合约部署消耗 gas 127633,第二个合约部署消耗 gas 108833,减少 18800,近 20000 gas。
因为使用了紧凑变量打包,所以第二个合约少使用了一个存储插槽,减少了 gas 消耗。
因此我们可以考虑使用更小的 uint
子类型或者 bytes
子类型,通过合理地排序它们的位置,可以将存储空间最小化。
除结构体和数组数据外的其他变量,同样部署以下两个合约进行测试:
// 部署消耗 gas 129366
contract Test {
uint128 x = 10;
uint256 y = 10;
uint128 z = 10;
}
// 部署消耗 gas 108674,少 20692 gas
contract Test {
uint256 y = 10;
uint128 x = 10;
uint128 z = 10;
}
6、紧凑状态变量赋值
当我们使用紧凑状态变量打包时,多个变量被打包在一个存储插槽中,这时,同时读取和写入该插槽中的多个变量,多个读或写会合并为一个单一的操作,这样能够节省 gas。而如果你只是读或者写该插槽中的一个变量,效果可能相反,当一个变量的值被写入一个多变量存储插槽中时,存储槽必须先被读取,然后与新值结合,这样同一个插槽中的其他数据就不会被破坏。
在实际测试中,我们也发现,某些情况下统一插槽内变量的读或写没有优化合并为一个操作。
以下四种设置方式,我们设置 a 的值为 2,b 的值为 1,看实际的 gas 消耗。
contract structWrite {
struct Object {
uint64 v1;
uint64 v2;
uint64 v3;
uint64 v4;
}
Object obj = Object(1, 1, 1, 1);
// gas cost 33211
function set1(uint64 a, uint64 b) public {
obj.v1 = a + b;
obj.v2 = a - b;
obj.v3 = a * b;
obj.v4 = a / (b + 1);
}
// gas cost 28411
function set2(uint64 a, uint64 b) public {
setObject(a + b, a - b, a * b, a / (b + 1));
}
function setObject(uint64 v1, uint64 v2, uint64 v3, uint64 v4) private {
obj.v1 = v1;
obj.v2 = v2;
obj.v3 = v3;
obj.v4 = v4;
}
// gas cost 28381
function set3(uint64 a, uint64 b) public {
uint64 v1 = a + b;
uint64 v2 = a - b;
uint64 v3 = a * b;
uint64 v4 = a / (b + 1);
obj.v1 = v1;
obj.v2 = v2;
obj.v3 = v3;
obj.v4 = v4;
}
// gas cost 28613
function set4(uint64 a, uint64 b) public {
obj = Object(a + b, a - b, a * b, a / (b + 1));
}
// gas cost 22383
function set5(uint64 a, uint64 b) public {
uint64 v1 = a + b;
uint64 v2 = a - b;
uint64 v3 = a * b;
uint64 v4 = a / (b + 1);
}
}
实际我们看几个 set 方法编译出来的 opcode,发现 set1 使用了 4 个 SLOAD 和 4 个 SSTORE,而其他 set 方法只使用了 1 个 SSLOAD 和 1 个 SSTORE,编译器对其他写法进行了优化。
编译器将 4 个字段的读取和写入优化为一次操作,而第一种写法无法优化,因此要多消耗 5000 左右的 gas。因此,我们应该避免第一种写法。
7、内联汇编打包变量
编写内联汇编 (Inline Assembly) ,手动将多个变量堆叠在一起,打包到单个插槽中。
语法:使用 assembly{ ... }
来嵌入汇编代码段。
// 编码时将多个变量一起储存。
function encode(uint64 _a, uint64 _b, uint64 _c, uint64 _d) internal pure returns (bytes32 x) {
assembly {
let y := 0
mstore(0x20, _d)
mstore(0x18, _c)
mstore(0x10, _b)
mstore(0x8, _a)
x := mload(0x20)
}
}
function decode(bytes32 x) internal pure returns (uint64 a, uint64 b, uint64 c, uint64 d) {
assembly {
d := x
mstore(0x18, x)
a := mload(0)
mstore(0x10, x)
b := mload(0)
mstore(0x8, x)
c := mload(0)
}
}
这种方式虽然节省了 gas,但是牺牲了代码的可读性,容易出错。
更多资料:Solidity Tutorial : all about Assembly
8、无需使用默认值初始化变量
无需使用默认值初始化变量。
// 部署消耗 gas 67054
contract Test {
uint256 x;
}
// 部署消耗 gas 67912
contract Test {
uint256 x = 0;
}
9、常量
在 solidity 中,声明为 constant
或者 immutable
的状态变量即常量。
constant
修饰的常量的值在编译时确定,而 immutable
修饰的常量的值在部署时确定。详情可见文档。
尽量使用常量,常量是合约字节码的一部分,不占用存储插槽,使用常量比变量更省 gas。
在部署时,常量消耗的 gas 更少。
// 消耗 83681 gas,相比使用变量节省 20078 gas
contract A {
uint256 public constant x = 1000;
}
// 消耗 90046 gas,相比使用变量节省 13713 gas
contract A {
uint256 public immutable x = 1000;
}
// 消耗 103759 gas
contract A {
uint256 public x = 1000;
}
在读取时,常量消耗的 gas 也更少。
contract A {
uint256 public result;
uint256 public constant x = 100; // 调用 cal 方法消耗 41236 gas
// uint256 public immutable x = 100; // 调用 cal 方法消耗 41236 gas
// uint256 public x = 100; // 调用 cal 方法消耗 42036 gas,读取存储变量消耗多消耗 800 gas,
function cal() public {
result = x;
}
// function cal() public { // 调用 cal 方法消耗 41236 gas
// result = 100;
// }
}
10、函数修饰符
使用函数修饰符 view、pure。
- 函数声明为 view,表示该函数不修改状态。
- 函数声明为 pure,表示该函数不读取或修改状态。
详情可见文档。
在以太坊中,如果不对状态进行修改,则可以发起一笔调用进行查询或其他操作,调用是不需要费用的。如果要对状态产生变更,则需发起一笔交易,交易是需要消耗 gas 和支付费用的。
在智能合约中,函数如果声明为 view 或者 pure ,则外部账户直接调用这些函数只需发起一次调用即可。如果不加这些修饰符,以太坊网络会把你的操作理解为一笔交易。
需要注意的是,如果在一笔交易中,某个未声明为 view 或者 pure 的合约函数的内部调用了声明为 view 或者 pure 的函数,还是需要消耗 gas 的。
部署下面的合约进行测试,其中 add 和 sub 方法都没有读取或修改状态,add 方法没有声明为 pure,则需要发起一笔交易并支付交易费用才能调用,而 sub 方法声明为 pure,则无需发起交易只发起调用即可,无需费用。
contract Test {
function add(uint _x, uint _y) public returns (uint) {
return _x + _y;
}
function sub(uint _x, uint _y) public pure returns (uint) {
return _x - _y;
}
}
11、避免重复修改状态变量
避免重复修改状态变量,比如在循环中重复修改状态变量的值。
contract Test {
uint256 public count;
// bad,消耗 gas 58582
function set1() public {
for (uint256 i = 0; i < 10; i++) {
count++;
}
}
// good,消耗 gas 24046
function set2() public {
uint256 temp;
for (uint256 i = 0; i < 10; i++) {
temp++;
}
count = temp;
}
}
12、使用短路规则
操作符 ||
和 &&
适用常见的短路规则。
这意味着,假设f(x)
和 g(y)
返回 true 的概率一样,那么:
- 在表达式
f(x) || g(y)
中,如果f(x)
的计算结果为真,则不会执行g(y)
。因此应该将贵的方法放在后面。 - 在表达式
f(x) && g(y)
中,如果f(x)
的计算结果为假,则不会执行g(y)
。因此应该将贵的方法放在后面。
当然,实际情况是还需要考虑两个方法执行的失败概率,从而整体评估方法的排序。
13、布尔类型
在 solidity 中,布尔类型 bool
实际为 uint8
,即使用 8 位的存储空间,每个存储插槽能装入 32 个布尔类型值。而布尔值只能有两个值:True 或 False,其实只需要在单个存储位中就可以保存布尔值。
在有非常多个布尔类型变量,或者是需要布尔类型的数组时,你可以考虑使用一个 uint256
变量,并使用其所有 256 位来表示各个布尔值。
要从 uint256
中获取单个布尔值,请使用以下函数:
function getBoolean(uint256 _packedBools, uint256 _boolNumber) public view returns(bool) {
uint256 flag = (_packedBools >> _boolNumber) & uint256(1);
return (flag == 1 ? true : false);
}
要设置或清除布尔值:
function setBoolean(
uint256 _packedBools,
uint256 _boolNumber,
bool _value
) public view returns(uint256) {
if (_value)
return _packedBools | uint256(1) << _boolNumber;
else
return _packedBools & ~(uint256(1) << _boolNumber);
}
可以使用 BitMap 代替 mapping(uint256 => bool)
, 同样是使用了位操作处理。
14、默克尔树
使用默克尔树。在合约中保存一组数据的 merkleRoot
,提供 verify
方法验证某条数据在这组数据中。相比使用一个 mapping 或数组来保存全部数据,减少了 gas 消耗。
以 ERC20 代币空投为例。参考 ENS 空投合约。
核心代码:
bytes32 public merkleRoot;
function claimTokens(uint256 amount, address delegate, bytes32[] calldata merkleProof) external {
bytes32 leaf = keccak256(abi.encodePacked(msg.sender, amount));
(bool valid, uint256 index) = MerkleProof.verify(merkleProof, merkleRoot, leaf);
require(valid, "ENS: Valid proof required.");
require(!isClaimed(index), "ENS: Tokens already claimed.");
claimed.set(index);
emit Claim(msg.sender, amount);
_delegate(msg.sender, delegate);
_transfer(address(this), msg.sender, amount);
}
function verify(
bytes32[] memory proof,
bytes32 root,
bytes32 leaf
) internal pure returns (bool, uint256) {
bytes32 computedHash = leaf;
uint256 index = 0;
for (uint256 i = 0; i < proof.length; i++) {
index *= 2;
bytes32 proofElement = proof[i];
if (computedHash <= proofElement) {
// Hash(current computed hash + current element of the proof)
computedHash = keccak256(abi.encodePacked(computedHash, proofElement));
} else {
// Hash(current element of the proof + current computed hash)
computedHash = keccak256(abi.encodePacked(proofElement, computedHash));
index += 1;
}
}
// Check if the computed hash (root) is equal to the provided root
return (computedHash == root, index);
}
同样可参考 Uniswap 空投使用的 merkle-distributor 以及 OneSwap 空投。
15、压缩交易输入数据
在函数参数较多的时候,我们可以压缩输入数据,类似紧凑状态变量打包,当有多个小于 32 字节大小的参数时,将多个参数打包为一个参数。
参考 Compress input in smart contract
16、调用外部合约
调用外部合约函数比调用内部函数消耗更多 gas。除非必要,否则不建议拆分多个合约,可以使用多个继承来管理和组织代码。
首先测试内部函数调用的 gas 消耗:
contract Math {
function add(uint _x, uint _y) public pure returns (uint) {
return _x + _y;
}
}
contract Test is Math {
uint sum;
// 消耗 41710 gas
function calculate(uint _x, uint _y) public {
sum = add(_x, _y);
}
}
再测试外部函数调用的 gas 消耗,先部署被调用外部合约,得到合约地址:
contract Math {
function add(uint _x, uint _y) public pure returns (uint) {
return _x + _y;
}
}
再部署调用合约,并测试:
contract Math {
function add(uint _x, uint _y) public pure returns (uint) {
return _x + _y;
}
}
contract Test {
uint sum;
address constant MathContractAddr = 0x9549DfbBd66b3Cc078AD834C74b9EE1808Ef3AEB;
// 消耗 43693 gas
function calculate(uint _x, uint _y) public {
sum = Math(MathContractAddr).add(_x, _y);
}
}
17、状态变量重复读取
多次读取状态变量,不会重复使用 SLOAD 指令,而是将值缓存起来。
我们部署下面两个合约进行测试:
contract Test {
uint one = 1;
// 消耗 22218 gas
function test() public returns (uint) {
return one + one + one;
}
}
contract Test {
uint one = 1;
// 消耗 22218 gas
function test() public returns (uint) {
return one + 1 + 1;
}
}
因此我们不用另外增加一个内存变量来避免重复读取。
18、操作合约和数据合约分离
在使用工厂合约创建合约的情况下,可以将创建的合约分离为操作合约和数据合约。
操作合约只创建一次,工厂合约每次只创建数据合约,而不是每次都创建一整个合约,从而减少 gas 消耗。
19、将复杂的计算逻辑放在链下
考虑在链下进行复杂的计算逻辑,在链上存储结果。
20、尽量使用批量操作
因为一笔交易的基础 gas 消耗是 21000,批量操作相比多次操作,能减少多次的交易带来的基础 gas 消耗。