写合约不省 Gas 是因为你不懂 Vyper 的内存分配逻辑!
本文介绍了 Vyper 编译器如何建模和维护内存(即 EVM 的内存位置)。文章解释了 Vyper 函数的内存布局、变量是如何分配和释放的,以及函数调用约定如何与内存分配交织在一起。
作者 | cyberthirst
🔗 原文链接:https://blog.vyperlang.org/posts/memory-allocation/
本文介绍了 Vyper 编译器如何建模和维护内存(即 EVM 的内存位置)。文章解释了 Vyper 函数的内存布局、变量是如何分配和释放的,以及函数调用约定如何与内存分配交织在一起。
本文有助于开发者理解如何优化合约结构以节省 Gas,并预防与动态数组(DynArrays)分配相关的某些拒绝服务(DoS)场景。同时,它也是研究 Vyper 编译器的有用材料——文中包含大量对 Vyper 代码库的引用。
总体介绍
编译器维护一个上下文(Context),用于管理变量、分配内存、处理作用域以及跟踪常量状态等功能。这个上下文在初始化时会引用一个内存分配器。内存分配器负责内存的分配和释放,边界检查以及对齐校验。上下文还提供了用于创建和删除变量的 API,在底层,这些操作会与内存的分配和释放相互关联。
当前版本(v0.4.1)默认并未实现复杂的内存分析算法(尽管 --experimental-codegen 提供了越来越多的优化)。因此,当变量不再“活跃”时,并不会立即释放其内存(但基于作用域的内存释放仍然会执行),例如也不会做别名分析(aliasing)等。
EVM 层级的分配与释放
在深入分析具体的内存分配策略和场景之前,我们先来看看在 EVM 中内存分配的基本情况。
Vyper 的内存分配器是对 EVM 内存的抽象建模。它为每个变量分配一段内存范围(与 Solidity 默认使用栈不同,Vyper 默认使用内存)。这意味着变量会被表示为某个起始地址的指针,然后分配器确保下一个变量从 free_mem_ptr 开始分配(这里的 free_mem_ptr 与 Solidity 中的概念不同)。所以,在 EVM 层,变量本质上就是一些数字。
比如这个简单示例:
if True:
a: uint256 = 1
分配器会给变量 a 分配一个地址,例如 200。编译器会生成 mstore 200 1 来完成赋值操作。如果要读取该变量,就会执行 mload 200 。EVM 本身没有“分配”这个概念——它会根据访问的地址自动扩展内存。同时,EVM 也没有“释放内存”的概念,内存一旦分配,大小就无法缩减。
Context 与内存分配器
当编译器需要创建一个新变量时,会通过 Context 类来完成。分配函数会检查该变量类型需要多少字节,并通过内存分配器保留这段内存范围。
例如,要分配一个数组时,编译器会预留 352 字节(32 字节用于存储数组长度 + 10 × 32 字节用于存储最多 10 个元素):
def foo():
array: DynArray[uint256, 10] = [1, 2]
不同类型的变量具有不同的作用域(见下文)。当变量作用域结束后,该变量会被释放。释放意味着内存分配器不再保留这段内存范围,该范围可以被后续变量复用。
变量是如何被分配的?
变量被分配在内存中——这也是 Vyper 不会遇到 “stack-too-deep”(栈太深)问题的原因。我们会根据变量类型,获取其所需的最大字节数,并分配相应数量的字节。这一过程仅发生在编译器中的抽象内存分配器中。要在 EVM 中真正分配内存,我们必须在运行时“触碰”相应地址。也就是说,如果运行时从未访问这些地址,变量就只在抽象层面上被分配。
这种为每种类型预分配最大字节数的策略可能带来一个令人惊讶的副作用:对于 DynArray[typ, size] 类型,我们始终会预留足够的内存,以应对“最坏情况”——也就是运行时数组长度等于类型定义中最大长度的情况。
作用域(Scoping)
以下几段将讨论 Vyper 中的作用域规则:
代码块作用域(Block Scope)
在 if、else、for 等代码块中定义的局部变量,会在代码块结束时被释放。
if True:
a: uint256 = 0
b: uint256 = 1
c: uint256 = a + b
# <--- a、b、c 在这里被释放
else:
pass
内部变量作用域(Internal Variable Scope)
每条语句在内部变量管理上都有自己的作用域。内部变量是指由编译器在幕后自动生成的变量,它们属于实现细节。例如,内建函数 slice 会创建一个内部变量用于存储切片结果:
def foo(s: String[32]) -> String[5]:
# 0. 为参数 `s` 分配内存缓冲区
# 1. 为 `slice(s, 4, 5)` 的结果分配内部缓冲区
# 2. 将内部缓冲区的结果赋值给 `s`
# 3. 赋值语句编译完成后,释放内部缓冲区(但保留 `s`)
s: uint256 = slice(s, 4, 5)
return s
如你所见,这里存在优化空间——这个内部变量并非绝对必要,内存分配有些冗余(在这个例子中,我们可以直接把切片结果赋值给 s)。省略冗余内存操作是 Venom 编译优化流程中未来的重点方向之一。
函数级局部变量是如何释放的?
不会被释放。每个函数都有静态分配的内存区域(函数帧),该帧在整个消息调用期间始终保留。
多函数内存分配器的处理方式
当编译一个函数(无论是外部函数还是内部函数)时,都会创建一个新的 Context。该上下文会被初始化为一个新的 MemoryAllocator(内存分配器)。但这里有个特别之处——这个内存分配器是从一个特定地址开始分配内存的(例如可能从地址 500 开始)。那这个初始地址是怎么确定的呢?接下来几段将解答这个问题。
栈帧(Stack Frames)
函数栈帧是编译器为每个函数执行过程中分配的数据结构,用于跟踪相关信息。在传统编程语言中,栈帧通常在函数调用时动态分配(由编译器维护栈指针 SP)。但在 Vyper 中,栈帧是静态的——这是由于 Vyper 不允许递归调用。
如果允许递归,那么函数调用的实际结构将依赖于运行时的值,因此需要动态栈帧。但由于 Vyper 禁止递归,其函数调用图是静态的、可预计算的,因此可以使用静态栈帧。
在 Vyper 中,栈帧就是一段固定大小的内存区域(静态字节缓冲区),函数通过它来执行内存操作。
栈帧的分配
随着内存的分配和释放进行,内存分配器会记录当前分配过的最大内存地址。即使某个变量被释放、指针回退,我们仍然要根据历史上的最大值来预留内存,以确保能容纳过去分配过的所有变量。这一最大值就构成了该函数的栈帧大小。
一个合约可以有多个函数——那么每个函数的栈帧如何映射到内存中呢?
在 Vyper 中,函数调用图不会成环。调用链始终由一个外部函数开始,然后可能调用若干内部函数。
为了分配内存,我们会从调用树的叶子节点开始,为其分配内存,然后逐步向上处理调用者函数……如此递归,直到初始的外部函数。
因此,外部函数会使用内存地址最高的区域。
回答原问题:内存分配器的初始化地址如何获取?
我们需要统计当前函数所调用的所有子函数的栈帧大小,取其最大值作为起始地址偏移:
# 计算当前函数的初始栈帧地址
callees = func_t.called_functions
# 从所有被调用函数中取最大的栈帧大小
max_callee_frame_size = 0
forc_func_t in callees:
frame_info = c_func_t._ir_info.frame_info
max_callee_frame_size = max(max_callee_frame_size, frame_info.frame_size)
栈帧大小是在哪里设置的?当一个函数(无论是内部函数还是外部函数)编译完成之后,编译器会调用 tag_frame_info 函数。该函数会从内存分配器中获取当前使用的内存大小,并将其设置为当前函数的栈帧大小。
需要注意的是,栈帧大小并不是 memory_size - frame_start 的差值,而是包括 frame_start 之前的地址在内,即包含整个范围。
函数参数是如何分配的?
首先,我们来看外部函数参数的分配方式。外部函数的参数最初位于 calldata 中。对于每个参数,编译器会计算其在 calldata 中的起始位置(calldata 的不同部分对应不同的参数)。
如果参数需要验证,我们会创建一个新的内部变量,并将验证后的参数复制进去;
如果不需要验证,该变量就直接表示为 calldata 指针。
哪些类型不需要验证?
那些其编码格式与 Vyper 内部编码一致的类型,例如:
静态数组 / 元组,且其中元素也不需要验证;
uint256 / int256(因为 32 字节长度的数据始终是这类类型的有效表示);
其他类型(具体参考 needs_clamp 函数的判断逻辑)。
返回值缓冲区是如何分配的?
对于内部函数,由调用者来分配返回值的缓冲区。
调用者由 Call 表达式表示。返回缓冲区是一个内部变量(实现细节上的“内部”),它在解析 Call 表达式时被分配。
返回缓冲区的地址会被传递给被调用函数(callee),函数在执行 return 语句时将返回值写入该缓冲区。
函数调用时参数是如何传递的?
Vyper 的函数调用遵循 传值调用(pass-by-value) 方式,意味着所有参数都会被复制。
在函数内存分配时,所有参数会被分配在函数帧的起始位置。因此在调用函数时,参数会被复制到这段预分配的位置中。这个复制操作对应的中间表示(IR)由编译器中的 make_setter 函数生成。
关于赋值的一些说明
编译器前端在处理赋值语句时会生成复制操作。复制的前提是必须存在一个目标缓冲区(即复制的落地位置),这就需要提前进行内存分配。目标缓冲区的大小总是按该类型可能出现的最大尺寸来分配。
优化器(尤其是 Venom)能够识别并消除其中一部分冗余的复制操作。
例如,下面这个合约中的 a 和 b 在编译器前端阶段都会被强制分配内存缓冲区:
def foo() -> DynArray[uint256, 1000]:
a: DynArray[uint256, 1000] = [1, 2, 3]
b: DynArray[uint256, 1000] = a
return b
优化器的任务就是在后续优化过程中移除这些多余的复制。
动态数组是如何分配的?
对于局部变量而言,动态数组会分配其最大可能尺寸的内存。例如下面的数组:
def bar():
array: DynArray[uint256, 1000] = []
编译器会在内存中预留 32 + 1000 * 32 = 32032 字节(32 字节用于存储数组长度,1000 × 32 字节用于存储元素)。
如果数组来自 calldata(即该变量是外部函数的参数),那么它会被立即复制到内存中,我们随后不再使用 calldata 指针,而是用内存中的内部变量来处理。
这种做法的优势在于,它让数组的处理不再依赖 ABI 编码格式(因为 ABI 编码是可变的),而我们可以使用一个固定的内存指针来操作,从而更简单、更高效。
那么“动态”是指什么?“动态”是指数组的长度是动态的(但受到类型定义中声明的上限约束)。但即使如此,动态长度数组依然被放置在一段静态分配的内存缓冲区中,这段缓冲区的大小足以容纳数组的最大长度。
结语
本文展示了 Vyper 是如何管理函数帧的——包括它们是如何构建与分配的。我们还详细讨论了局部变量的分配,包括变量是如何表示的、其“活跃状态”是如何定义的。此外,还探讨了 动态数组(DynArray) 的内存分配机制。
但本文没有涉及的是:这些内存操作在编译器后期阶段是如何进一步优化的。我们的重点是说明编译器前端如何生成中间表示(IR),而这些 IR 会在后续阶段被进一步优化,许多低效之处也会被移除。
值得进一步探讨的一些有趣话题包括:
内存别名分析(memory aliasing),更广义地说是指针分析;
将变量从内存提升到栈上;
融合内存操作,以减少冗余步骤等。
这些优化中,有些已经在 Venom 编译优化流程中实现,另外一些仍在规划之中。