本文深入探讨了EVM兼容链上智能合约的Gas优化技术。首先介绍了Gas的基本概念和费用构成,然后详细讲解了预估和测量Gas消耗的各种方法,包括使用Foundry进行离线测试、预执行测量和后执行测量。最后,文章总结了常见的Gas优化模式和高级技巧,旨在帮助开发者编写更高效、更经济的智能合约,提升用户体验并降低网络拥堵。
在 EVM 兼容链上编写智能合约不仅仅是关于正确性和安全性,还在于效率。你在链上执行的每条指令都会消耗 gas,而每个单位的 gas 都有实际成本。优化不佳的合约可能导致用户不必要的高额交易费用、用户体验下降,甚至在高峰使用期间加剧网络拥堵。
这篇文章将开始深入探讨 gas 优化技术。在我们探索具体的模式和技巧之前,让我们先建立一个通用的词汇表:
Gas: 一种衡量以太坊计算工作的单位。每个操作码、存储读/写和内存操作都会消耗预定数量的 gas。
Gas Price (gwei): 你愿意为每个单位的 gas 支付的金额,以 gwei 报价(1 gwei = 10⁹ wei)。
Tip (priority fee): 一项额外的费用(EIP-1559 中的 maxPriorityFeePerGas),用于激励矿工更快地包含你的交易,在基础费用之上支付。
Transaction Fee: 因交易类型(Legacy、EIP-2930、EIP-1559)而异,但根本上取决于使用的 gas 和有效的 gas 价格。在所有情况下,减少 gas 使用量都会降低总体成本。(这个主题在之前的文章中解释过:交易类型:第 1 部分 和 第 2 部分)
在我们了解了所有交易类型并编写了一些智能合约之后,让我们了解如何优化我们编写的合约上的 gas 使用量,以及为什么这很重要。
在本文中,我们将学习:
1. EVM Gas 成本基础
2. 测量你的 Gas
3. 常见的优化模式
4. 高级技巧
掌握了这些基础知识,我们就可以开始识别我们的合约在哪里花费最多的 gas,以及如何使每个操作更经济。
在应用优化之前,了解 gas 在操作码和数据结构级别上的消耗位置至关重要。总的来说,gas 成本主要来自四个类别:
存储 (SSTORE / SLOAD)
以太坊 gas 费用是按操作码和数据操作计算的。以下是 gas 支出最多的详细分类,基于 EIP-2200 和 EIP-2929 的准确数据:
合约存储中的持久性键值对槽。最昂贵的操作:
SSTORE (write): 使用 EIP-2200 中定义的动态计量:
SSTORE_SET_GAS = 20 000
gasSSTORE_RESET_GAS = 5 000
gasGAS = 800
,对于热访问 100
(无状态更改)(在柏林分叉中引入了一个新的 EIP-2929,正如我们在 之前的文章 中学到的,它为热访问提供了折扣)SSTORE_CLEARS_SCHEDULE = 15 000
gas 返还SLOAD (read): 冷访问 GAS = 800
,热访问 100
(EIP-2929)
注意: 存储写入是迄今为止最昂贵的操作。重用存储位置或批量写入可以为每个交易节省数万 gas。
gas 成本示例:取自官方 EIP-2200 摘要并翻译成伪代码
## Constants (old → new values)
SLOAD_GAS = 800 # was 200
SSTORE_SET_GAS = 20000 # unchanged
SSTORE_RESET_GAS = 5000 # unchanged
SSTORE_CLEARS_SCHEDULE = 15000 # unchanged
GAS_STIPEND = 2300 # the 2 300-gas stipend for transfers
function performSSTORE(originalValue, currentValue, newValue):
# 1. stipend check
if gasleft() ≤ GAS_STIPEND:
revert("out of gas")
# 2. no-op store
if currentValue == newValue:
deductGas(SLOAD_GAS)
return
# 3. value actually changes
if originalValue == currentValue:
# 3.a first write in this TX
if originalValue == 0:
deductGas(SSTORE_SET_GAS) # zero → non-zero
else:
deductGas(SSTORE_RESET_GAS) # non-zero → different non-zero
if newValue == 0:
refund(SSTORE_CLEARS_SCHEDULE) # track zero-clears
else:
# 3.b slot is “dirty” (already written in this TX)
deductGas(SLOAD_GAS)
if originalValue ≠ 0:
if currentValue == 0:
removeRefund(SSTORE_CLEARS_SCHEDULE) # undo prior zero-clear
if newValue == 0:
refund(SSTORE_CLEARS_SCHEDULE) # new zero-clear
# 3.b.iii if we’ve reset back to original
if originalValue == newValue:
if originalValue == 0:
refund(SSTORE_SET_GAS - SLOAD_GAS)
else:
refund(SSTORE_RESET_GAS - SLOAD_GAS)
注意:
original_value
是事务开始时存储槽的值。current_value
是此SSTORE
之前的槽的值,反映了同一事务中的任何先前写入。
内存
3
gas,外加一个二次项⌊a² ÷ 512⌋
,在访问先前未使用的内存时应用。(在 以太坊黄皮书 中指定)
解释:它增加了一个小的超线性惩罚,以便随着你分配更多内存,边际成本缓慢增加(例如,对于 64 个字,⌊64²/512⌋ = ⌊4096/512⌋ = 8 个额外 gas)。
C_mem(a) = G_memory·a + ⌊a² ÷ 512⌋
3
gas)。注意:通过预先调整数组大小或使用 calldata 处理大型输入来最大程度地减少内存扩展。
Calldata
3
gas(不可变且不可扩展。在 以太坊黄皮书 中指定)。堆栈和其他操作码
3
gas2–400
gas~3 000
gas375
gas + 主题/数据成本(每个字节 8
gas)通过了解这些类别和所涉及的确切 gas 成本,你可以识别合约中的热点,尤其是存储写入和内存扩展,并应用有针对性的优化来减少交易费用。
分析 gas 使用情况是实现有意义的优化的第一步:如果没有准确的数据,你可能会将注意力集中在次要成本上,而忽略主要的 gas 消耗。在本节中,我们将介绍各种链下和链上测量技术,这些技术共同构成了一个全面的工具包,用于建立你的 gas 使用基线。
使用 foundry 进行链下测量(构建智能合约时)
以下是如何在你的测试套件中进行链下操作:
对于此示例,我们将使用 foundry 和一个智能合约(/src/Storage.sol
),该示例来自 之前的博客文章 之一。
pragma solidity ^0.8.12;
contract Storage {
struct my_storage_struct {
uint256 number;
string owner;
}
my_storage_struct my_storage;
function store(my_storage_struct calldata new_storage) public {
if (new_storage.number > 100) {
revert("Number too large");
}
my_storage = new_storage;
}
function retrieve() public view returns (my_storage_struct memory){
return my_storage;
}
}
以及来自 ( /test/Storage.t.sol
) 的测试
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.12;
import "forge-std/Test.sol";
import "../src/Storage.sol";
contract StorageTest is Test {
Storage public storageContract;
function setUp() public {
storageContract = new Storage();
}
function testStoreStruct() public {
Storage.my_storage_struct memory input = Storage.my_storage_struct({
number: 25,
owner: "bob"
});
storageContract.store(input);
}
function testStoreStructReverts() public {
Storage.my_storage_struct memory input = Storage.my_storage_struct({
number: 101,
owner: "too much"
});
vm.expectRevert("Number too large");
storageContract.store(input);
}
}
有了这个智能合约及其测试,我们可以检查执行方法 store
将花费多少 gas。为此,我们将运行:
forge test --gas-report
我们应该看到如下输出:
我们在这里看到什么:
store()
函数的测试套件 两次。(其中一个套件使用了 SSTORE,而另一个套件在 store
保护上失败)更便宜的情况(当函数调用在 if
保护上失败时)。(请记住,从第一篇博客文章中,基本交易成本为 21000 gas,这里我们有 1.5k 更多的 gas 用于读取和检查值)
昂贵的情况(函数成功完成并且内存被写入),它支付了完整的 20 000 gas SSTORE 成本加上冷访问附加费和函数自身的的操作码开销。
两次运行之间的中点,可用作典型调用的粗略预期。
注意:可以通过运行以下命令来运行特定方法:
forge test --match-test testStoreStruct --gas-report
预执行测量
当合约已部署并且我们想要检查运行特定输入将使用多少 gas 时可以使用。
对于此示例,让我们使用之前使用的相同的智能合约 /src/Storage.sol
并部署它。
让我们使用 Anvil(来自 foundry)来分叉区块链
anvil
并部署智能合约
forge create src/Storage.sol:Storage \
--rpc-url http://localhost:8545 \
--private-key <your-key> \
--broadcast
注意: 从
anvil
输出中选择一个私钥
输出将如下所示:
[⠊] Compiling...
No files changed, compilation skipped
Deployer: <your-address>
Deployed to: <deployed-contract-address>
Transaction hash: <tx-hash>
现在让我们估算交易 gas 成本
## encode function selector
export SIG=$(cast sig "store((uint256,string))")
## encode function arguments
export ARGS=$(cast abi-encode "store((uint256,string))" "(25,\"bob\")")
## build the calldata: encoded function_selector + encoded params
## (#0x removes the 0x from the $ARGS)
export CALLDATA="${SIG}${ARGS#0x}"
## output should be:
## 0xddd356b3000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000190000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000003626f620000000000000000000000000000000000000000000000000000000000
cast rpc eth_estimateGas \
'{"from":"<your-address>","to":"contract-address","value":"0x0","data":"'"$CALLDATA"'"}' \
'latest' \
--rpc-url http://127.0.0.1:8545
## output shoud be:
## 0x10944 = 67908 // exactly the number we got previously in off-chain exampl
注意: 我们在这里使用了 foundry cli 工具,但同样的方法适用于任何 EVM 链上真实部署的合约调用
后执行测量
发送签名交易后,我们可以看到此交易在链上实际使用了多少 gas。
让我们使用前面的示例,并使用 foundry cli 工具来发布交易(或者按照之前文章 这里 或 这里 中的学习,创建一个实际的区块链交易)
cast send <your-deployed-contract-address>\
"store((uint256,string))" \
'(25,"bob")' \
--rpc-url http://127.0.0.1:8545 \
--private-key <your-key>
在输出中,你应该看到如下内容:
...
transactionHash 0xe69ac7819787dc5ca8e5e5b0420936cdde171fce8760931bce5e0fc03e68cbac
...
然后让我们获取交易收据,看看实际使用了多少 gas
cast rpc eth_getTransactionReceipt \
'"<your-transaction-hash>"' \
--rpc-url http://127.0.0.1:8545
## the output shoud have this field
"gasUsed":"0x10944" # Same value we saw before
在本节中,我们将实现实用的技术来减少 Solidity 代码中的 gas。每个模式都针对先前确定的主要成本类别之一。
最大程度地减少存储写入
uintX
或 bool
分组到单个 32 字节的槽中。例如:struct Packed {
uint128 a;
uint64 b;
uint64 c;
}
if (x != newX) {
x = newX;
}
缓存存储加载
uint256 localVar = storageVar;
// 多次使用 localVar 而不是重复的存储读取
对外部数组使用 Calldata
calldata
而不是 memory
,以避免内存扩展成本:function batchTransfer(address[] calldata recipients) external {
// 比使用 memory 更便宜
}
循环中未经检查的算术
for (uint256 i = 0; i < n; ) {
// operations
unchecked { i++; }
}
短路和逻辑简化
function withdraw(uint256 amount) external {
if (amount == 0) return;
require(balances[msg.sender] >= amount, "Insufficient");
// proceed
}
链下预先计算常量
uint256 constant RATE_MULTIPLIER = 1e18;
利用 Immutable 变量
immutable
,以节省存储读取成本:address public immutable owner;
constructor() {
owner = msg.sender;
}
优化事件与存储
event TransferLogged(address from, address to, uint256 amount); // emit TransferLogged() instead of storing to an array
对热路径使用汇编
assembly {
// 低级操作
}
注意: 还有更多优化技巧可以搜索,但这些是你可以随时记住的基本知识。此外,尽量避免对动态数组进行循环,在大型输入中可能会因 gas 不足而失败
除了常见的模式之外,这些高级技术对于额外的 gas 节省可能很有用:(此处不再赘述,但会给出最少的解释,你可以探索)
extcodecopy
读取,避免了只读数据的高成本存储写入。uint256
中,以最大程度地减少使用的存储槽的数量。multicall
(我们将在即将到来的博客文章中介绍这一点)模式将多个逻辑操作聚合到一个事务中,从而分摊每次调用的开销并减少总 gas。你现在已经对 gas 成本如何在 EVM 链上累积有了实际的了解,从主导交易费用的昂贵存储写入到内存、calldata 和常见操作码的细微成本。你还知道如何可靠地测量 gas 使用情况,使用链下 RPC 估计、链上收据和测试内报告器。有了这些数据,你可以应用经过验证的优化模式,最大程度地减少存储写入,利用 calldata,使用未经检查的循环等等,以立即降低 gas 消耗。对于需要更多精度的场景,访问列表、最小代理和数据编码策略等高级技术可提供进一步的节省。
你现在可以做什么:
通过遵循这种方法,你将确保你的智能合约提供更快的执行速度、更低的费用以及任何 EVM 兼容网络上用户的更好体验。
- 原文链接: medium.com/@andrey_obruc...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!