Gas优化的核心 - 以太坊数据存储布局及内存优化

1. 数据存储布局

1. ⭐️EVM结构

以太坊中,EVM的结构如下图所示 image.png 其中,各组件的内容以及交互如下: - 程序计数器Program Counter): 用于标识当前操作指令(opcode)的位置,控制程序的执行流程。

  • 合约字节码EVM code):solidity 编译后的代码。每次调用合约时候,从这里读取逐行执行。

  • 操作指令operations):EVM的指令集,如:ADDMLOADSSTORECALL等,每条指令都需要消耗相应的 gas。

  • 可用 gasGas Avaliable):当前交易还剩下多少 Gas 可用,每条指令都会从这里扣除 gas

  • 操作数栈stacks):所有的操作都需要从这里读取和写入。例如 ADD 指令会弹出两个值,计算后再压入一个结果。

  • 临时可变内存Memory):用于存储合约运行时的临时数据处理,例如引用类型的局部变量(基本类型在栈上)、ABI 编码、函数参数处理、动态数组等。用完释放,写入内存消耗较低 gas ,读取免费。

  • 账户存储Storage):用于持久化合约的状态变量,每个合约都有一份自己的 storage。是一个key-value 类型的映射,可以类比与数据库。底层采用 MPT 树来进行存储。读写都需要 gas ,写操作更昂贵。

2. ☀️栈中内存布局

以太坊栈是一种先进先出的结构。 - 每个元素单位是 32byte256bit),最大深度为 1024 个元素。

  • 栈中只有最上面的 16 个元素能被访问到(超过 16 个会出现 stack too deep 错误),因为 EVM 是通过 DUP 复制和 SWAP 交换 这两个操作码来进行栈内存交互的,最大只能访问到栈顶 16 个。
contract StackTooDeepDemo {
    function tooManyVariables() public pure returns (uint256) {
        uint256 a = 1;
        uint256 b = 2;
        uint256 c = 3;
        uint256 d = 4;
        uint256 e = 5;
        uint256 f = 6;
        uint256 g = 7;
        uint256 h = 8;
        uint256 i = 9;
        uint256 j = 10;
        uint256 k = 11;
        uint256 l = 12;
        uint256 m = 13;
        uint256 n = 14;
        uint256 o = 15;
        uint256 p = 16;

        // 添加一个额外变量,就触发 stack too deep(取决于编译器)
        uint256 q = 17;

        return a + b + c + d + e + f + g + h + i + j + k + l + m + n + o + p + q;
    }
}

image.png

3.🐬 合约状态变量存储布局

  • EVM 中,合约的状态变量存储通过 key-value 的形式在 EVM 的外部,即一棵 MPT 的树形结构中。

  • 每个状态变量都分配一个槽(slot),一个槽的大小为 32byte256bit

  • 多个连续小变量会被打包进一个槽中(内存对齐)

  • 总共有 2e256 个槽,这是一个非常大的数值,所以并不用担心容量不够的情况出现(更担心的是不够钱付 gas

image.png

1. 🐟定长类型

solidity 中,值类型所占存储是固定的。有 booluintint 等。在编译合约时,编译器会严格按照我们编写的顺序进行设定槽位,并不会给我们自动优化。

contract NoPacking {
    uint128 a = 1; // 16 字节
    uint64  b = 2; // 8 字节
    uint128 c = 3; // 16 字节
}

上面代码一个占用了三个槽位。由于每个槽位只能存储32字节的数据,但是由于 uint128uint64 类型不同,并不能做到内存的合并。

contract WithPacking {
    uint128 a = 1; // 16 字节
    uint128 c = 3; // 16 字节
    uint64  b = 2; // 8 字节
}

如果是这样写,可以优化到占用 2 个槽位。变量 a 和变量 b 会被打包合并在一个槽位进行存储。相对于第一种写法,节省了一个槽位的空间。这就是 gas 优化的重要方向

2. 🤡动态数据

1. 👬🏻字符串

  • 字符串类型属于动态类型,如果这个字符串的大小整体 < 31 字节(最后一个字节保存长度),则会被直接存储在某个 slot 中。例如:
string public s = "hello world";
  • 但如果这个字符串的大小 > 31 字节,则:
    1. 在这个固定的 slot 中,存储这个字符串的 长度 + 动态标识
    2. 实际数据存在 keccak256(这个槽的下标) 这个 slot 中。

2. 🪐动态数组

  • 动态数组和字符串的存储类似
    1. 固定的 slot 中,保存动态数组的 长度
    2. 真是数据存储在 keccak256(这个槽的下标) 以及后续的 连续 slots
uint[] public arr = [1, 2, 3];

3. 🌛字典Mapping

对于字典 Mapping 类型,solidity 是不会存储 key 的!!!而对于 mapping 类型所占用的槽位来说,是通过以下方式来进行计算: 1. 一个 mapping 占用一个 slot,称为 base_slot ,但这个 slot 是空的,不存储任何信息。 2. 真实的 mapping 对应的信息是通过 keccak(abi.encode(key, base_slot))来确定这个 key 对应的 value 所在的真实槽位。

contract Demo {
    mapping(uint => uint) public data; // 假设 data 占用 slot 0
}

在存储data[5] = 123 的时候,底层:

slot = keccak256(abi.encode(5, uint256(0))) // 是一个storage中的地址
storage[slot] = 123; // 通过地址保存数据

4. ❓为什么字典Mapping不能遍历

  • 因为 solidity 考虑到 gas 成本,在 mapping 的实现中,不保存 mapkey,而是通过 hash 函数来通过 key 来直接存储和获取 value 的。所以 mapping 结构无法进行遍历操作。
slot = keccak256(abi.encode(5, uint256(0))) // 是一个storage中的地址
storage[slot] = 123; // 通过地址保存数据
  • 如果需要实现类似遍历的效果,需要手动维护一个数组来实现。

5. ❓动态类型使用 keccak256 不怕碰撞导致数据覆盖吗?

  • keccak256 哈希函数,256 位的输出,碰撞概率极低,可以近似认为不会出现碰撞的情况。(与私钥的碰撞类似,近似认为不会发生碰撞)
  • 对于 mapping 类型,即使 key 相同,slot 不同,也不会发生冲突。因为在使用 keccak256() 时,需要传入 keyslot 来进行 hash

2. 💰Gas费优化思路

1.🚀 Gas成本高的操作

  • 读写在合约存储的状态变量
  • 外部函数调用
  • 循环操作

2.📈 优化方案

1. 减少状态变量的使用。

状态变量 storage 在 gas 的消耗上,远高于 memory 内存的消耗。
- 将永久数据存储在内存中。
- 减少修改的次数:通过将中间状态保存在内存中,最后一次性保存到状态变量上。

2. 变量打包(内存对齐)

如我们上面提到的,我们对状态变量的编写方式,会影响到槽位的占用。我们可以通过手动内存对齐的方式,来降低 slot  的占用,达到节省 gas 费的效果。

3. 数据类型优化

一个变量可以有多种数据类型来表示,例如 uint8 和 uint256。由于我们 EVM 采用的是 256bit 来操作数据的。所以在 EVM 的内部,还会进行一次的单位转化。所以,在不考虑变量打包的情况下,uint256 所消耗的 gas 甚至还会更低一点(但如果进行变量打包, 那变量打包的操作所节省的 gas 会更优)

4. 使用固定大小变量

一般来说, 固定类型的变量消耗的 gas 会比动态类型来得低。如果数据可以控制在 32 字节内,建议使用 bytes32 来替代 bytes或者strings。尽量选择最小的数据类型来存储数据。

5. 数组和映射

映射在大多数情况下,gas 费会更低。如果在数组和映射都可以存储数据的情况下,建议优先选择数组来存储数据。

6. calldata和memory

 函数参数中的变量可以声明为calldata和memory。memory为可变的参数,而calldata只读参数。如果这个参数在函数中不会发生变动,优先使用calldata会更加节省gas费。

7. constant 和 immutable

constant 是常量,只能在声明时赋值。immutable是不可更改变量,只能在声明时赋值或者构造函数上赋值,一旦赋值,不可改动。两者都会写入到合约的字节码上,相对于memory和storage中的数据,消耗的gas费更低。在允许的情况下,优先使用两者。
全部评论(0)