Skip to content
On this page

L2-8: Gas Golf(Gas 优化)

1. 问题

在以太坊上,每一笔交易的执行都需要消耗 Gas(以 gwei 计价的 EVM 计算资源)。一个未经优化的合约可能消耗 200,000 gas,而经过专业优化后可能仅需 80,000 gas——超过 2.5 倍的差异直接转化为用户的交易成本。对于高频调用场景(如 DEX 兑换、NFT 铸造),gas 优化不是锦上添花,而是产品竞争力的核心。

本挑战(参考 src/level2/GasGolf.sol)要求系统性地掌握多种 gas 优化技术,并通过对比函数直观展示每种技术的 gas 节省效果。挑战涵盖六大技术维度:immutable/constant vs storage、变量打包、calldata vs memory、unchecked 算术、自定义错误 vs require 字符串、以及短路评估。

2. 原因

Gas 优化本质上是 EVM 操作码级别的成本控制。每一个 SSTORE(存储写入)消耗 20,000 gas(冷写入),而 immutable 变量仅在构造函数写入一次、后续每次读取仅约 5 gas。这 4,000 倍的差异说明:理解数据存储机制比任何代码技巧都重要。

Gas Golf 不仅教你怎么省 gas,更教你"gas 的代价是什么"。例如,calldata 的每个非零字节花费 16 gas,零字节花费 4 gas——这解释了为什么 address(0) 作为参数反而更便宜,以及为什么压缩参数类型的尺寸(uint256 → uint128)能省 gas。同样,unchecked 块省掉了每个算术操作的溢出检查 opcode(ADD vs unchecked_add),在循环累加场景省 gas 尤为显著。

更重要的是,Gas Golf 培养"gas 感知"的编程习惯——在写每一行 Solidity 代码时,潜意识里思考"这行代码花多少 gas?有没有更便宜的方式?"。这是从"能跑就行"到"专业合约开发"的质变。

3. 方案

GasGolf 合约(参考 src/level2/GasGolf.sol)展示了六种 gas 优化技术的对比实现:

技术 1:immutable 和 constant vs storage

solidity
uint256 public constant MAX_SUPPLY = 10_000;   // 编译时内联,~5 gas/读
uint256 public immutable i_ownerFee;             // 构造时设定,~5 gas/读
uint256 public storageFee = 100;                 // SSTORE存储,2100+ gas/读

function readImmutable() public view returns (uint256) {
    return i_ownerFee + MAX_SUPPLY;  // 两个都是 ~5 gas
}

function readStorage() public view returns (uint256) {
    return storageFee + MAX_SUPPLY;  // storageFee 需 SLOAD (2100 gas)
}

原理constant 在编译时直接替换为字面量(内联到字节码中),immutable 在合约部署时写入合约代码的特殊段(同样只需要 ~5 gas 读取),而 storage 变量需要执行 SLOAD 操作码(冷读取 2100 gas)。

技术 2:变量打包

solidity
struct PackedData {
    uint128 valueA;
    uint128 valueB;
}  // 两个 uint128 共享一个 32 字节 slot → 一次 SSTORE

struct UnpackedData {
    uint256 valueA;
    uint256 valueB;
}  // 两个独立 slot → 两次 SSTORE

function writePacked(uint128 _a, uint128 _b) public {
    packed = PackedData(_a, _b);  // 1 × 20000 gas (cold)
}

function writeUnpacked(uint256 _a, uint256 _b) public {
    unpacked = UnpackedData(_a, _b);  // 2 × 20000 gas (cold)
}

原理:EVM 的存储槽是 32 字节(256 位)对齐的。两个 uint128(各 16 字节)可以并排放入同一个 32 字节槽,EVM 编译器会将它们合并为一次 SSTORE。而两个 uint256 各自占满一个槽,需要两次 SSTORE。

技术 3:calldata vs memory

solidity
function sumCalldata(uint256[] calldata _data) public pure returns (uint256) {
    uint256 total;
    for (uint256 i; i < _data.length; ++i) {
        total += _data[i];  // 直接从 calldata 读取
    }
    return total;
}

function sumMemory(uint256[] memory _data) public pure returns (uint256) {
    uint256 total;
    for (uint256 i; i < _data.length; ++i) {
        total += _data[i];  // 从 memory 读取(需要先拷贝)
    }
    return total;
}

原理memory 参数在函数入口处会从 calldata 完整拷贝到 memory 中——数组越大,拷贝成本越高。calldata 是只读的,指向交易原始输入数据,完全不需要拷贝。

技术 4:unchecked 算术

solidity
function sumUnchecked(uint256[] calldata _data) public pure returns (uint256) {
    uint256 total;
    unchecked {
        for (uint256 i; i < _data.length; ++i) {
            total += _data[i];  // 跳过溢出检查
        }
    }
    return total;
}

原理:Solidity 0.8+ 默认对每次 +-* 操作插入溢出检查。在循环内部可以使用 unchecked 包裹来跳过这些检查——前提是你确信不会溢出(例如 i 不可能达到 2^256)。

技术 5:自定义错误 vs require 字符串

solidity
error InvalidValue(uint256 value);

function validateWithCustomError(uint256 _value) public pure returns (bool) {
    if (_value == 0) revert InvalidValue(_value);  // 仅 4 字节 selector
    return true;
}

function validateWithRequire(uint256 _value) public pure returns (bool) {
    require(_value != 0, "Value cannot be zero");  // 完整字符串存于字节码
    return true;
}

原理require 的 error string 完整存储在合约字节码中(每个字符都是一个字节,长信息可能上百字节),revert 时全部返回给调用者。自定义错误仅存储 4 字节的函数选择器 + ABI 编码参数,大幅减少字节码大小和返回数据量。

技术 6:短路评估

solidity
// 先检查便宜的(constant 读取),再检查贵的(storage 读取)
function checkShortCircuit(address _addr, uint256 _amount) public view returns (bool) {
    if (_amount <= MAX_SUPPLY && storageFee > 0 && _addr != address(0)) {
        return true;
    }
    return false;
}

// 不良实践:先读 storage
function checkNoShortCircuit(address _addr, uint256 _amount) public view returns (bool) {
    if (storageFee > 0 && _amount <= MAX_SUPPLY && _addr != address(0)) {
        return true;
    }
    return false;
}

原理:Solidity 的 && 是短路操作符——如果第一个条件为 false,后续条件不会执行。将最便宜的条件(constant 读取、address(0) 比较)放在前面,最贵(storage 读取)放在后面。

4. 遭遇的陷阱

  • constant 不等于 immutable:在声明 constant 时给的值必须在编译时确定(不能依赖构造函数参数),否则编译失败;而 immutable 可以在构造函数中赋值
  • 打包结构体的读取成本:打包节省了写 gas,但读取其中某个字段时需要额外的移位操作(与 mask + shift),这在某些场景下可能增加读取成本
  • unchecked 导致静默溢出:在 unchecked 块中如果发生了溢出,不会 revert 而是静默绕回——你的合约可能会以错误的状态继续执行
  • calldata 数组不能修改:在 calldata 数组上尝试写操作(如 arr[0] = 5)会编译失败,因为它是指向只读数据的指针
  • 过度使用 unchecked 在非循环场景:对于单次 a + b 运算,节省的 gas 极少(约 3-5 gas),但引入的溢出风险可能很大

5. 陷阱的原因

constant vs immutable 的根本区别在于赋值时机:constant 在编译时求值,必须是编译期常量表达式;immutable 在构造时求值,可以依赖构造函数参数。编译时求值意味着 constant 变量在字节码中被直接替换为字面量;构造时求值意味着 immutable 变量被写入合约代码段(类似 ROM),不可再修改。因此 constant 不能是 address(this)block.chainid 这些运行时才确定的值。

打包结构体的读取开销来源于 EVM 的字长:32 字节。当你读取打包在同一个 slot 中的 uint128 时,EVM 先读取整个 32 字节(SLOAD),然后用 mask 提取低 128 位并用右移提取高 128 位。虽然 SLOAD 的成本远高于 AND/SHR 操作,但如果你频繁读取打包变量且从不写入,打包反而不带来收益。

unchecked 溢出不 revert 的原因是 Solidity 编译器在 unchecked 中直接使用 EVM 的 ADD 操作码(带有自然溢出回绕行为),而非 ADD + 溢出检查组合。这是有意为之的性能设计,但要求开发者自己保证安全性。

6. 如何解决陷阱

使用 immutable 处理构造时才确定的固定值(如 owner fee rate),使用 constant 处理完全在编译时确定的值(如 fixed supply cap)。它们的声明语法不同:

solidity
uint256 public constant MAX_SUPPLY = 10_000;               // 字面量
uint256 public immutable i_ownerFee;                        // 在构造函数赋值
constructor(uint256 _ownerFee) { i_ownerFee = _ownerFee; }

打包结构体时权衡读写频率:如果结构体频繁读取单个字段,考虑是否值得打包;如果是批量初始化后偶尔读取,打包总是划算的。可以使用 memory 缓存来摊销读取成本:

solidity
function readPacked() public view returns (uint128 a, uint128 b) {
    PackedData memory p = packed;  // 一次 SLOAD,之后在 memory 中操作
    return (p.valueA, p.valueB);
}

unchecked 块中,可以使用注释标注安全原因,并使用 Foundry 的 fuzz 测试验证边界条件不溢出。对于循环计数器,几乎总是安全的(不可能达到 2^256),但对于累加器,需要确保最大输入不会溢出。

始终使用 calldata 处理外部函数的引用类型参数,除非你确实需要修改参数。对于需要修改的场景,在函数内部创建 memory 副本后再修改。

7. 技术要点

要点说明
SSTORE (cold 0→非0)20,000 gas — 首次写入最高成本
SSTORE (warm)5,000 gas — 非零修改非零
SLOAD (cold)2,100 gas — 首次读取
immutable/constant~5 gas — 内联到字节码,不经过 storage
calldata 每非零字节16 gas
calldata 每零字节4 gas
变量打包两个 uint128 = 一个 32 字节槽 → 一次 SSTORE
unchecked 算术跳过 ADD/MUL/SUB 的溢出检查 opcode
自定义 error4 字节 selector vs require string 的完整文本
短路评估&& 和 `

Built with AiAda