深入学习一点 Solidity 的优化方法,不然很多时候书到用时方恨少,主要内容来源于 The Optimization Iceberg

还没完全写完,有空了再补充(挖坑)

数据存放原理

在 EVM 中,存放数据的位置有 stack、calldata、memory、storage、code 和 log

  • stack:即运行时栈,用于保存 EVM 指令的输入和输出数据。stack 的最大深度为 1024,在运行的时候只能访问栈顶的 16 个元素[1],每个元素的长度为 256 bits;
  • calldata[3]:函数调用的输入,对应的是交易的 data 域。在正确调用合约时,calldata 的前 8 个字节用于指定函数,后跟的数据用于存放调用参数;
  • memory:临时的数据存放区域,在函数运行期间存放数据,在函数结束运行后销毁。在运行前 memory 是空的,每一次需要使用 memory 时会进行扩容,如果访问 memory 的数据超出页面大小,它会自动增大 32 字节,扩容需要耗费 gas[2]
  • storage:用于对数据进行持久化的存储,它以数据槽(slot)为单位进行数据的存放,每个 slot 长度为 256 bits,每个合约下可以使用最多 $2^{256}$ 个 slot[4]

code 和 log 分别用于在特定区域存放合约代码和日志,log 只能在合约外通过 API 读取

类型优化

变量打包

EVM 中合约的属性变量被存放在 storage 中,属性变量被存放在 storage 的每个 slot 中。如果独立地声明每个属性变量,它们都会被依次存放在独立的 slot 下。读取或写入时 EVM 会通过 SLOAD 或 SSTORE 两种字节码实现[5]

  • SLOAD:加载 slot 中的数据,在一笔交易内首次加载花费 2100 gas,后续加载每一次花费 100 gas;
  • SSTORE:如果涉及到之前未使用的 slot,首次存储花费 20000 gas,首次存储时候花费 2200 gas(SLOAD 加载对应 slot + 修改的费用),后续每一次修改花费 100 gas;

通过将长度之和不大于 256 bits 的变量打包到一个 slot 下,可以有效地降低 SSTORE 带来的 gas 开销

1
2
3
4
5
6
7
8
9
10
11
contract BadPacking {
uint8 public var0;
uint256 public var1;
uint16 public var2;

function set() public {
var0 = 1;
var1 = 3;
var2 = 55;
}
}

在上面的合约中,var0var2 可以打包在一起来减少一个 slot 的开销,手动打包是将它们放在一个结构体下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
struct Data {
uint8 public var0;
uint16 public var2;
}

contract Packing {
Data public data;
uint256 public var1;

function set() public {
data.var0 = 1;
data.var2 = 55;
var1 = 3;
}
}

这样声明的 Data 类结构体可以直接被存放在一个 slot 下,从而节省 slot 声明时的开销,也可以直接调整顺序,开启优化后 solidity 编译器会尝试进行优化

1
2
3
4
5
contract BadPacking {
uint8 public var0;
uint16 public var2;
uint256 public var1;
}

常量与变量

常量与变量体现在合约函数的编写和调用中

  1. 在合约函数中,声明一个固定不变的值是常量类型,它就主要被硬编码在合约的 bytecode 中,而不用从 storage 中通过 SLOAD 加载;
  2. 在调用函数时,通过 calldata 声明是一个常量,就不需要将数据存放到 memory 中来开辟内存;

函数内声明

通过 constant 关键字来声明常量

1
2
3
contract Constant{
uint256 public constant c = 0x30000000000000;
}

调用时声明

通过 calldata 声明传入的参数不再被修改,如果使用 memory 则涉及到 SLOAD 和 SSTORE,需要更多的 gas 开销

1
2
3
4
5
6
contract Calldata {
string public s;
function f(string calldata input) external {
s = input;
}
}

缓存机制

Memory

读取 storage 中的数据时,首次读取消费 2100 gas,后续每一次读取需要 100 gas。而从 memory 中读取所消耗的 gas 会小很多,所以在必要时可以将数据先读取到 memory 后,再使用 memory 中的数据作为变量

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
contract Cached {
uint256 public a = 10;

function f(uint256 input) external returns (uint256) {
uint256 i = 0;
uint256 res = 0;
// 这里将 a 加载到 memory
uint256 aCached = a;

for (i; i < aCached; i++) {
res += input;
}

return res;
}
}

操作符

i++ & ++i:在进行自增时通常会使用 ++ 操作符,这里的原理和 C 语言里比较相似(沟槽的 C 语言还在追我),也是通过 ++i 自增的时候不会生成临时变量,所以它的 gas 开销会更低一点

unchecked:solidity 0.8 后添加了溢出检查,但并不是每次的检查都是有必要的,可以通过 unchecked 来声明一段代码不进行溢出检查:

1
2
3
4
5
6
7
contract Uncheck {
function add(uint256 a, uint 256 b) public returns(uint256) {
unchecked {
return a + b;
}
}
}

比较符号:使用 >< 的比较符时需要在栈内通过 SWAP 来实现,而使用 ==!= 时则没有这样的操作,所以可以进行替换来节省gas

1
for (i; i != n; i++) {}

短路操作:一种在其他语言力也很常见的优化方法,在进行布尔运算的时候,如果是 || 运算,尽量将可能为真的条件放在前面,这样第一个条件命中就会直接判断为真。如果是 && 运算,尽量将可能为假的条件放在前面,第一个条件不命中时判断为假。

数据类型声明:在非必要的情况下都直接使用 uint256,而不是较短的 uint8uint32 之类,这是因为 EVM 对进行掩码计算,以保证数据类型在范围内:

1
2
3
DUP
PUSH 0xff
AND

位运算:同样是常见的优化方法,对于2的整数倍的乘法或除法,可以直接使用位运算来节省 gas;