本文档详细介绍了Solidity智能合约开发中的各种Gas优化技巧,涵盖存储、错误处理、数学运算和函数调用等多个方面,包括避免初始化默认变量、存储打包、常量与不可变状态变量、缓存存储变量、使用unchecked{}、calldata代替memory等关键优化策略,旨在帮助开发者编写更高效、更节省Gas的Solidity合约。
由于存储操作是最昂贵的指令之一,因此也最有可能节省 gas。
合约存储中所有位置的默认值为零(uint
为 0
,bool
为 false
,地址为 0x00..00
,等)。因此,使用默认值初始化变量并不必要,只会浪费 gas。
// 部署成本: 12666 gas
contract DefaultVarsOptimized {
uint256 internal a;
bool internal b;
address internal c;
bytes32 internal e;
}
// 部署成本: 19308 gas
contract DefaultVars {
uint256 internal a = 0;
bool internal b = false;
address internal c = address(0);
bytes32 internal e = bytes32("");
}
部署初始化默认变量的示例合约成本额外约 6.6k gas,相比于优化版。如果我们查看编译器生成的 Yul(forge inspect DefaultVars ir-optimized
),可以看到原因:
/// @src 23:193:349 "contract DefaultVars {..."
let _1 := memoryguard(0x80)
mstore(64, _1)
if callvalue() { revert(0, 0) }
sstore(0x00, 0x00)
sstore(0x01, and(sload(0x01), not(sub(shl(168, 1), 1))))
sstore(0x02, 0x00)
let _2 := datasize("DefaultVars_29531_deployed")
codecopy(_1, dataoffset("DefaultVars_29531_deployed"), _2)
return(_1, _2)
有三个额外的 SSTORE 指令,每个成本 2.2k gas(另见 StorageTest 中的 testZeroToZero()
)。
为什么只有三个 sstore 指令,而我们总共有 4 个变量?答案是变量打包。编译器将 bool 和地址放在同一个存储槽中,因为它们都适合 32 字节。因此只需要一个 SSTORE 操作 -> 存储打包。
forge test --mc DefaultVarsTest -vvvv
- 运行 gas 测试forge inspect DefaultVars ir-optimized
- 显示优化后的 Yul 汇编我们可以通过将使用少于 32 字节的变量相邻放置来节省存储。
存储打包在读取或写入同一存储槽中的多个值时特别有用。在这种情况下,只需要一个 SLOAD 或 SSTORE 操作,从而将访问存储变量的成本降低一半或更多。这种情况通常出现在结构体中:
struct Entry {
uint128 id;
uint128 value;
}
Entry f;
// 执行成本: 22323 gas
function writeStruct() external {
// 我们在存储两个变量,但只为一个 SSTORE 付费
f = Entry(1, 2);
}
下面是 StoragePacking.sol 中函数 writeStruct
的 Yul 表示。编译器将这两个 uint128 变量放在同一个存储槽中,因此只需要一个 SSTORE 操作。id
(0x01)存储在存储槽的低 128 位(右对齐),value
(0x02)存储在存储槽的高 128 位(左对齐)。
case 0x33fe0dda { // writeStruct()
// --snip--
// store Entry(1, 2);
sstore(4, 0x0200000000000000000000000000000001)
return(mload(64), _2)
}
虽然存储打包通常节省 gas,但需要注意的是,它也可能增加 gas 使用。因为 EVM 每次以 32 字节为单位操作。如果元素小于 32 字节,EVM 必须使用更多操作将元素从 32 字节缩减到所需大小。例如,请参见 StoragePacking.sol 中的 writeUint128
和 writeUint256
函数。
// 执行成本: 22306 gas
function writeUint256() external {
++b[0];
}
// 执行成本: 22557 gas
function writeUint128() external {
// 写入单个减少大小的变量 比 写入 uint256 更昂贵,
// 因为 EVM 总是以 32 字节为单位操作。
++c[0];
}
执行 writeUint128
的成本为 22,557 gas,而执行 writeUint256
的成本为 22,306 gas。通过检查 forge inspect StoragePacking ir-optimized
的输出,我们可以看到编译器执行了额外的位操作以将 uint256 变量的大小减少到 128 位。
case 0x102f49a5 { // writeUint256()
// --snip--
if eq(_3, not(0)) // 检查 b 是否大于 max(uint256)
{
mstore(_2, shl(224, 0x4e487b71))
mstore(4, 0x11)
revert(_2, 0x24)
}
sstore(/** "b" */ 0x01, add(_3, /** "b" */ 0x01))
/// @src 38:492:1251 "contract StoragePacking is StorageLayout {..."
return(_1, _2)
}
case 0x11c3a83a { // writeUint128()
// --snip--
let _5 := 0xffffffffffffffffffffffffffffffff
let value := and(_4, _5)
if eq(value, _5) // 检查 c 是否大于 max(uint128)
{
mstore(_2, shl(224, 0x4e487b71))
mstore(4, 0x11)
revert(_2, 0x24)
}
// 我们有额外的按位操作将 c 存储在存储槽的低 128 位
sstore(/** "c" */ 0x03, or(and(_4, not(0xffffffffffffffffffffffffffffffff)), and(add(value, 1), _5)))
return(mload(64), _2)
}
forge test --mc StoragePackingTest -vvvv
- 运行 gas 测试forge inspect StoragePackingTest ir-optimized
- 显示优化后的 Yul 汇编与常规状态变量相比,常量和不可变变量的 gas 成本要低得多。
对于常量变量,赋值给它的表达式会被复制到所有访问该变量的地方,并且每次都会重新评估。这允许进行局部优化。不可变变量仅在构造时评估一次,其值被复制到代码中访问它的所有位置。对于这些值,预留 32 字节,即使它们可以适应更少的字节。因此,常量值有时比不可变值便宜。
如果我们通过 forge debug Constant --sig "readConstant()"
和 forge debug Immutable --sig "readImmutable()"
调试 Constant.sol 中的函数,可以看到,编译器将不可变变量替换为 PUSH32(value)
,将常量变量替换为 PUSH4(value)
。
// size(address) = 20 bytes, but 32 bytes are reserved for immutable variables
// 编译器将 `a` 替换为 `PUSH32(address)`
address immutable a;
// 4 bytes, will be replaced with `PUSH4(0xaabbccdd)`
bytes32 constant b = bytes32(hex"AABBCCDD");
// 152 gas
function readConstant() public pure returns (bytes32) {
return c;
}
// 167 gas
function readImmutable() public view returns (address) {
return a;
}
只有值类型(例如 bool
、intN
/uintN
、address
、bytesN
、enum
)可以声明为不可变。所有前面的类型也可以声明为常量,还包括 string
、bytes
和 contract
变量。
forge test --mc ConstantTest -vvvv
- 运行 gas 测试forge inspect ConstantTest ir-optimized
- 显示优化后的 Yul 汇编forge debug Constant --sig "readConstant()"
forge debug Immutable --sig "readImmutable()"
作为一般规则,使用 bytes 进行任意长度的原始字节数据,使用 string 进行任意长度的字符串(UTF-8)数据。如果可以将长度限制为特定字节数,请始终使用值类型(bytes1
到 bytes32
)中的一种,因为它们便宜得多。
数组也是如此:如果知道最多会有特定数量的元素,请始终使用固定数组而不是动态数组。原因是固定数组在存储中不需要长度参数,因此节省一个存储槽。
// 22260 gas
function setFixedArray() public {
fixedArray[0] = 1;
}
// 44440 gas
function setDynamicArray() public {
dynamicArray.push(1);
}
// 22244 gas
function setFixedBytes() public {
fixedBytes = "test test test test test";
}
// 22748 gas
function setDynamicBytes() public {
dynamicBytes = "test test test test test";
}
forge test --mc FixedSizeTest -vvvv
- 运行 gas 测试forge inspect FixedSizeTest ir-optimized
- 显示优化后的 Yul 汇编存储读取是昂贵的:第一次 SLOAD
成本 2.1k gas,所有其他 SLOAD
操作成本 100 gas。因此,如果在同一函数中多次读取一个变量,将存储变量缓存到内存中是个好主意。
在下面的示例中,我们计算存储数组中元素的总和。缓存数组长度和结果和可为我们每次函数调用节省大约 2k gas。
uint256[10] myArray = [1, 2, 3, 4, 5, 6, 7, 8, 9];
uint256 sum;
// 45495 gas
function sumArrayOptimized() public {
uint256 length = myArray.length; // SLOAD
uint256 localSum;
for (uint256 i = 0; i < length; i++) {
localSum += myArray[i]; // SLOAD
}
sum = localSum; // SSTORE
}
// 47506 gas
function sumArray() public {
for (uint256 i = 0; i < myArray.length; i++) { // SLOAD
sum += myArray[i]; // SSTORE + 2x SLOAD
}
}
在优化的函数中,我们总共有 11 次 SLOAD 操作和 1 次 SSTORE 操作。在未优化的函数中,我们有 30 次 SLOAD 操作和 10 次 SSTORE 操作。然而,它们之间的 gas 成本差异相对较小。原因是优化器:它检测到在循环中保持不变的表达式,并将它们移到循环外。在我们的示例中,表达式 myArray.length
是不变的,因此被移到循环外。这将差距减少到额外 10 次 SLOAD 操作和 9 次 SSTORE 操作,每次成本 100 gas。
forge test --mc VariableCachingTest -vvvv
- 运行 gas 测试forge inspect VariableCachingTest ir-optimized
- 显示优化后的 Yul 汇编瞬态存储是一个仅在调用执行期间可用的特殊存储区域。两个新操作码将添加到 EVM:
瞬态存储的 gas 成本远低于合约存储:两者均为 100 gas。瞬态存储的潜在用例是重入锁,从 5100 gas 降低到 300 gas。
abstract contract ReentrancyGuard {
uint256 private constant _NOT_ENTERED = 1;
uint256 private constant _ENTERED = 2;
uint256 private _status;
constructor() {
_status = _NOT_ENTERED;
}
modifier nonReentrant() {
require(_status != _ENTERED, "ReentrancyGuard: reentrant call"); // 之前: 2.1k gas (SLOAD); 之后: 100 gas (TLOAD)
_status = _ENTERED; // 之前: 2.9k gas (SSTORE); 之后: 100 gas (TSTORE)
_;
_status = _NOT_ENTERED; // 之前: 100 gas (SSTORE); 之后: 100 gas (TSTORE)
}
}
瞬态存储将在即将到来的 Cancun 更新中包含。
Solidity 提供两种执行算术运算的方法:检查和不检查。检查操作在发生溢出或下溢时会抛出异常,而未检查操作则不会。
在 for 循环中使用 unchecked{}
特别有用,因为不可能在不先耗尽 gas 的情况下溢出(在正常情况下 -> 确保你的代码是安全的且不能溢出!)。
// 22352 gas
function increment() public {
number++;
}
// 22247 gas
function incrementUnchecked() public {
unchecked {
number++;
}
}
如果我们检查上述代码的 Yul 表示,我们可以观察到,在我们递增 number
时,会调用 increment_uint256(value)
函数。另一方面,unchecked { number++; }
直接编译为 sstore(_2, add(sload(_2), 1))
,在不进行任何检查的情况下递增它。
let _2 := 0
case 0xc7fd0347 { // incrementUnchecked()
// --snip--
sstore(_2, add(sload(_2), 1))
return(_1, _2)
}
case 0xd09de08a { // increment()
// --snip--
sstore(_2, increment_uint256(sload(_2)))
return(mload(64), _2)
}
function increment_uint256(value) -> ret
{
if eq(value, not(0)) // 溢出/下溢检查
{
mstore(0, shl(224, 0x4e487b71))
mstore(4, 0x11)
revert(0, 0x24)
}
ret := add(value, 1)
}
forge test --mc UncheckedTest -vvvv
- 运行 gas 测试forge inspect Unchecked ir-optimized
- 显示优化后的 Yul 汇编众所周知的 gas 优化技巧之一是使用 ++i
而不是 i++
。前者略微便宜,因为 i++
会在递增之前保存原始值,需要额外的 DUP
和 POP
操作,分别消耗 3 和 2 gas。
然而,这种优化适用于旧/遗留编译器。在新的基于 IR 的编译(via_ir = true)中,gas 成本的差异消失。
// 22308 gas(使用旧代码生成)
// 22245 gas(--via-ir 启用)
function postIncrement() public {
number++;
}
// 22303 gas(使用旧代码生成)
// 22245 gas(--via-ir 启用)
function preIncrement() public {
++number;
}
基于 IR 的编译(两个函数的代码相同):
case 0x016e4842 { // postIncrement()
// --snip--
sstore(_1, increment_uint256(sload(_1)))
return(mload(64), _1)
}
case 0x5b59b0c8 { // preIncrement()
// --snip--
sstore(_1, increment_uint256(sload(_1)))
return(mload(64), _1)
}
forge test --mc IncrementTest -vvvv
- 运行 gas 测试forge test --mc IncrementTest -vvvv --via-ir
- 运行启用 IR 基于编译的 gas 测试forge inspect Increment ir-optimized
- 显示优化后的 Yul 汇编Calldata 比内存便宜。如果输入参数不需要被修改,请考虑在外部函数中使用 calldata:
// 391 gas
function argAsCalldata(string calldata name) external pure {}
// 515 gas
function argAsMemory(string memory name) external pure {}
如果使用 calldata
,数据是通过 calldataload
直接读取的。另一方面,如果使用 memory
,数据首先会被复制到内存中,使用额外的指令。
forge test --mc CalldataMemoryTest -vvvv
- 运行 gas 测试使用自定义错误提供了一种高效的方式来回退事务,无论是执行成本还是部署成本。
自定义错误使用错误语句定义,可以在合约内部和外部使用,包括接口和库。
以下示例演示了自定义错误的用法:
error OnlyOwner();
contract CustomError {
address owner = msg.sender;
function setOwner() public {
if (msg.sender != owner) revert OnlyOwner();
owner = msg.sender;
}
}
检查 Yul,我们可以看到传统的 require
错误语句需要多个 mstore
操作,而自定义错误只需要一个(用于错误签名):
// require(msg.sender == owner, "Only owner can call this function");
if iszero(eq(caller(), and(_3, sub(shl(160, 1), 1))))
{
mstore(_1, shl(229, 4594637))
mstore(add(_1, 4), 32)
mstore(add(_1, 36), 33)
mstore(add(_1, 68), "Only owner can call this functio")
mstore(add(_1, 100), "n")
revert(_1, 132)
}
// error OnlyOwner()
if iszero(eq(caller(), and(_3, sub(shl(160, 1), 1))))
{
mstore(_1, shl(224, 0x5fc483c5))
revert(_1, 4)
}
我们还可以看到每个 32 字节的块(32 个字符)的回退字符串需要额外的 mstore
操作。这也引出了下一个 gas 优化技巧:短回退字符串。
forge test --mc CustomErrorTest -vvvv
- 运行 gas 测试forge inspect CustomError ir-optimized
- 显示优化后的 Yul 汇编尽量将错误字符串的长度保持在 32 个字符以下,如果你通过 require
语句处理错误,以限制 mstore
操作的使用。你的回退字符串越短,部署成本也越便宜。
// 部署成本: 53416 gas
contract RevertShort {
address owner = msg.sender;
// 2363 gas
function setOwner() public {
require(msg.sender == owner, "!owner");
owner = msg.sender;
}
}
// 部署成本: 60222 gas
contract RevertLong {
address owner = msg.sender;
// 2381 gas
function setOwner() public {
require(msg.sender == owner, "Only the contract owner can call this function!");
owner = msg.sender;
}
}
forge test --mc RevertStringsTest -vvvv
- 运行 gas 测试forge inspect RevertStrings ir-optimized
- 显示优化后的 Yul 汇编函数的执行成本是 gas。尽量尽早回退,以避免无用的执行成本。
// 225 gas
function earlyRevert() public {
require(false, "Early revert");
}
// 111290 gas
function lateRevert() public {
for (uint i = 0; i < 1000; i++) {
// do nothing
}
require(false, "Late revert");
}
forge test --mc RevertEarlyTest -vvvv
- 运行 gas 测试如果有多个需要检查的条件,建议不要使用 &&
或 ||
将它们组合在一起。相反,使用要求链,其中每个条件都通过多个 require 语句单独检查。
然而,需要注意的是,如果在要求语句中使用错误字符串,则合约的部署成本将增加。
// 2279 gas
function requireChained() public payable {
require(msg.sender == owner);
require(msg.value == 0);
require(block.timestamp < 1000_000);
}
// 2317 gas
function requireNotChained() public payable {
require(msg.sender == owner && msg.value == 0 && block.timestamp < 1000_000);
}
forge test --mc RequireChainingTest -vvvv
- 运行 gas 测试在 Solidity 中,没有对像 >=
或 <=
这样的表达式的特定操作码。如果使用 >=
或 <=
,编译器将生成一个额外的 iszero
操作码,消耗额外 3 gas。
因此,建议使用 <
或 >
操作符来检查一个值是否大于或小于另一个值,因为它们不需要额外的 iszero
操作码。
// 267 gas
function greater(uint256 a, uint256 b) external pure returns (bool) {
return a > b;
}
// 270 gas
function greaterEqual(uint256 a, uint256 b) external pure returns (bool) {
return a >= b;
}
我们可以在 Yul 中看到额外的 isZero
指令:
case 0x71343515 { // greater
// --snip--
mstore(memPos_1, /** @src 17:184:189 "a > b" */ gt(param_4, param_5))
return(memPos_1, 32)
}
case 0x82a1f94b { // greaterEqual
// --snip--
mstore(memPos_2, /** @src 17:333:339 "a >= b" */ iszero(lt(param_6, param_7)))
return(memPos_2, 32)
}
forge test --mc ComparisonTest -vvvv
- 运行 gas 测试forge inspect Comparison ir-optimized
- 显示优化后的 Yul 汇编当使用 &&
运算符检查多个条件时,将最有可能失败的条件放在前面。这样,如果第一个条件失败,第二个条件将不会被检查。
另一方面,当使用 ||
运算符时,建议将最有可能成功的条件放在前面。这样,如果第一个条件成功(评估为 true),第二个条件将不会被评估,从而优化 gas 使用。
如果需要将一个数字除以或乘以 2 的幂,可以通过使用位移来优化操作。右移(>>
)等同于除以 2,左移(<<
)等同于乘以 2。
// 241 gas
function divide(uint256 a) external pure returns (uint256) {
return a >> 2; // 除以 2^2 = 4
}
// 317 gas
function divide(uint256 a) external pure returns (uint256) {
return a / 4;
}
注意:当启用基于 IR 的编译时(via_ir=true),此优化会被"优化掉"。在这种情况下,两个函数的编译输出相同且都费用 153 gas。
if eq(0x3e823f79, shr(224, calldataload(0)))
{
if callvalue() { revert(0, 0) }
if slt(add(calldatasize(), not(3)), 32) { revert(0, 0) }
mstore(_1, shr(0x02, calldataload(4)))
return(_1, 32)
}
forge test --mc BitShiftTest -vvvv
- 运行 gas 测试forge inspect BitShift ir-optimized
- 显示优化后的 Yul 汇编forge inspect NoBitShift ir-optimized
- 显示优化后的 Yul 汇编在执行取模操作时,使用 addmod()
和 mulmod()
,它们将算术和取模操作结合为一个步骤。
// 274 gas
function addMod(uint256 a) external pure returns (uint256) {
return addmod(a, 1, 2);
}
// 395 gas
function addMod(uint256 a) external pure returns (uint256) {
return (a + 1) % 2;
}
// 296 gas
function mulMod (uint256 a) external pure returns (uint256) {
return mulmod(a, 1, 2);
}
// 434 gas
function mulMod (uint256 a) external pure returns (uint256) {
return (a * 1) % 2;
}
比较 Yul 中的两个 addMod
函数,可以明显看出,使用 addmod
或 mulmod
更便宜:由于 addmod
或 mulmod
设计为自动处理溢出,因此不需要额外的溢出检查。
case 0xb1d818a1 { // addModBad
// --snip--
let value := calldataload(4)
if gt(value, add(value, 1))
{
mstore(_2, shl(224, 0x4e487b71))
mstore(4, 0x11)
revert(_2, 0x24)
}
mstore(_1, addmod(value, 1, 0x02))
return(_1, 32)
}
case 0xb1d818a1 { // addModGood
// --snip--
mstore(_1, addmod(calldataload(4), 1, 0x02))
return(_1, 32)
}
forge test --mc ModuloTest -vvvv
- 运行 gas 测试forge inspect ModuloGood ir-optimized
- 显示优化后的 Yul 汇编forge inspect ModuloBad ir-optimized
- 显示优化后的 Yul 汇编默认情况下,Solidity 中的函数是非支付的,这意味着它们不接受以太支付。然而,如果你明确地将函数声明为可支付,则编译器将省略调用该函数时的 msg.value == zero
检查。
// 部署成本: 9642 gas
contract Payable {
constructor() payable {}
// 74 gas
function foo() external payable {}
}
// 部署成本: 12066 gas
contract NonPayable {
constructor() {}
// 98 gas
function bar() external {}
}
上述函数的 Yul 表示:
case 0xc2985578 { // foo()
if slt(add(calldatasize(), not(3)), _2) { revert(_2, _2) }
return(_1, _2)
}
case 0xfebb0f7e { // bar()
if callvalue() { revert(_2, _2) } // 此检查在 foo() 中缺失
if slt(add(calldatasize(), not(3)), _2) { revert(_2, _2) }
return(_1, _2)
}
需要注意的是,将函数声明为可支付可能存在安全风险。确保不要破坏合约的功能。
forge test --mc PayableTest -vvvv
- 运行 gas 测试forge inspect PayableCombined ir-optimized
- 显示优化后的 Yul 汇编在调用函数时,EVM 跳过函数选择器列表,直到找到匹配项。函数选择器按十六进制顺序排列,每次跳跃消耗 22 gas。如果有很多函数,可以通过将最常调用的函数放在顶部来节省 gas。
例如,考虑以下合约:
contract FunctionOrder {
function a() external pure{}
function b() external pure{}
function c() external pure{}
function d() external pure{}
}
使用 forge inspect FunctionOrder methods
显示函数选择器:
{
"a()": "0dbe671f",
"b()": "4df7e3d0",
"c()": "c3da42b8",
"d()": "8a054ac2"
}
由于函数选择器按十六进制顺序排列,函数的顺序是 a, b, d, c
。查看 Yul 代码,我们可以确认这一点:
{
switch shr(224, calldataload(0))
case 0x0dbe671f { external_fun_a() } // a
case 0x4df7e3d0 { external_fun_a() } // b
case 0x8a054ac2 { external_fun_a() } // d
case 0xc3da42b8 { external_fun_a() } // c
}
forge test --mc FunctionOrderTest -vvvv
- 运行 gas 测试forge inspect FunctionOrder methods
- 显示函数选择器forge inspect FunctionOrder ir-optimized
- 显示优化后的 Yul 汇编当你在 Solidity 中添加一个函数修饰符时,修改后的函数代码会嵌入到修饰符中。如果同一个修饰符被多次使用,代码会被重复,增加字节码大小。
另一方面,内部函数会单独调用,节省部署中的字节码。内部函数会因函数调用而产生轻微的运行时开销。这意味着它们在执行成本上稍微贵一些,但在部署中节省了大量冗余字节码。
你可以在 Solidity 中的事件中包含最多三个(匿名事件为四个)索引参数。这些索引参数存储在一个特殊数据结构中,称为“主题”,而不是事件日志的数据部分。事件的第一个主题总是事件签名,除非事件被声明为匿名,这样使用它们的成本最低。
使用索引参数可以有效地在过滤块序列时查找特定事件。每个包含在事件中的索引参数成本额外 375 gas。
事件的 gas 成本:
static_gas = 375
dynamic_gas = 375 * topic_count + 8 * size + memory_expansion_cost
根据参数类型的不同,将其声明为索引的成本也不同。例如,字符串参数因内存扩展成本高于 375 gas,因此将其声明为索引更便宜。
对于 uint256 参数则相反:将它们声明为非索引更高效。添加主题的成本比直接将 uint256 值存储在事件日志的数据部分更高。
// 1352 gas
function anonLog() public {
emit AnonymousLog(1, 2, 3);
}
// 1817 gas
function logNum() public {
emit LogNum(1, 2, 3);
}
// 2121 gas
function logNumIndexed() public {
emit LogNumIndexed(1, 2, 3);
}
// 2286 gas
function logStringIndexed() public {
emit LogStringIndexed("Hello", "World", "!");
}
// 3463 gas
function logString() public {
emit LogString("Hello", "World", "!");
}
forge test --mc EventsTest -vvvv
- 运行 gas 测试
- 原文链接: github.com/beskay/gas-gu...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!