Vyper的内存分配器——深入研究

  • vyperlang
  • 发布于 2025-03-28 10:23
  • 阅读 13

本文介绍了 Vyper 编译器如何建模和维护EVM内存,解释了Vyper函数的内存布局,变量如何分配和释放,以及调用约定如何与内存分配交织。它可以帮助开发者理解如何构建合约以节省gas,以及如何防止与DynArrays分配相关的某些DoS场景。同时,对于研究Vyper编译器的人来说,这是一份有用的资料,文中包含了许多对Vyper代码库的引用。

作者:cyberthirst

本文介绍 Vyper 编译器如何建模和维护内存(如 EVM 内存位置)。它解释了 Vyper 函数的内存布局、变量的分配和释放如何进行,以及调用约定如何与内存分配交织在一起。

它可以帮助开发者了解如何构建他们的合约以节省 gas,以及如何防止与 DynArrays 分配相关的某些 DoS 场景。此外,对于任何有兴趣研究 Vyper 编译器的人来说,它都是有用的材料 - 本文包含许多对 Vyper 代码库的引用。

概述

编译器维护一个上下文,用于变量管理、内存分配、作用域处理或常量跟踪等目的。它使用对内存分配器的引用进行初始化。分配器分配/释放内存,检查边界,并强制对齐。上下文除其他外,还具有用于创建/删除变量的 API,并且在底层,这些操作与分配和释放交互。

当前版本(v0.4.1)默认情况下不执行复杂的内存分析算法(尽管 --experimental-codegen 提供了越来越多的优化),因此它不会在变量不再活动时立即释放内存(但会执行基于作用域的释放),也不会执行例如别名分析。

EVM 级别的分配和释放

在我们分析具体的分配策略和场景之前,让我们看看分配在 EVM 中实际上是什么样的。

Vyper 内存分配器抽象地对 EVM 的内存进行建模。它为每个变量分配一定的内存范围(请注意,与 Solidity 相比,它默认使用内存而不是堆栈)。这意味着该变量表示为指向其起始地址的指针,然后分配器保证下一个变量将在 free_mem_ptr(与 Solidity 中的概念不同)处分配。因此,在 EVM 级别,变量表示为数字。

考虑这个简单的例子:

if True:
    a: uint256 = 1

分配器为变量 a 分配一个地址,例如 200。对于此地址,编译器将生成 mstore 200 1 来完成赋值。要加载此变量,我们将执行 mload 200。EVM 没有分配的概念 - 它会自动根据我们访问的指针扩展内存。请注意,在 EVM 中,不存在释放的概念,EVM 内存无法缩小。

上下文和内存分配器

当编译器需要创建一个新变量时,它通过 Context 类来实现。分配函数检查变量的类型需要多少 bytes,并将通过内存分配器 类保留内存范围。

例如,要分配 array,编译器将保留 352 字节(32 用于 len + 10*32):

def foo():
    array: DynArray[uint256, 10] = [1, 2]

不同类型的变量具有不同的作用域(见下文)。在变量的作用域完成后,将释放该变量。释放意味着内存分配器将停止保留变量的内存范围,并且该内存范围可以重用。

变量如何分配?

变量是在内存中分配的 - 这也是 Vyper 不会遭受堆栈溢出错误的原因。我们获取变量的类型并检索所需的最大内存字节数,并分配此字节数。这仅发生在编译器中的抽象内存分配器中。为了实际在 EVM 中分配内存,我们必须实际访问相应的地址。因此,如果我们从未在运行时访问这些地址,则该变量仅在抽象上保持分配状态。

为每种类型分配最大内存字节数的一个令人惊讶的副作用是,对于 DynArrays[typ, size],我们将始终分配足够的字节来容纳“最坏情况”,即运行时大小与类型定义中的 size 匹配的情况。

作用域

以下段落讨论 Vyper 的作用域规则:

块作用域

块作用域(if、else、for)局部变量在块作用域结束时释放。

if True:
    a: uint256 = 0
    b: uint256 = 1
    c: uint256 = a + b
    # <--- dealloc a,b, c
else:
    pass

内部变量作用域

每个语句都有自己的作用域,用于内部变量管理。内部变量是编译器在底层生成的变量;它们是实现的细节。例如,slice 内置函数分配一个内部变量,切片的结果将存储在该变量中。

def foo(s: String[32]) -> String[5]:
    # 0. 为 `s` 分配缓冲区
    # 1. 为 slice(s, 4, 5) 的结果分配内部缓冲区
    # 2. 将内部缓冲区分配给 `s`
    # 3. 在编译 assign 语句后,释放内部缓冲区(但保持 s 不变)
    s: uint256 = slice(s, 4, 5)
    return s

可以看出,还有优化的空间 - 内部变量不是绝对必要的,并且分配是冗余的(在示例中,我们可以将 slice 的结果直接存储到 s 中)。消除内存是在 Venom 管道中要添加的进一步优化的一个领域。

函数级别的局部变量如何释放?

它们不会;每个函数都是静态分配的,并且其函数帧在整个消息调用期间保持不变。

分配器如何处理多个函数

当编译一个函数(外部和内部)时,会创建一个新的上下文。该上下文也使用 MemoryAllocator 进行初始化。这有一个特殊的转折 - 内存分配器使用它开始分配的地址进行初始化(例如,它可能从地址 500 开始)。以下几个段落将回答以下问题:如何获取内存分配器初始化的地址?

堆栈帧

函数堆栈帧是合约使用的数据结构(由编译器创建),用于在函数执行期间跟踪信息。在传统语言中,堆栈帧通常随着函数调用动态分配(编译器管理堆栈指针 SP)。在 Vyper 中,它们不是;它们是静态的 - 这是因为 Vyper 不允许递归。递归需要为函数调用动态创建堆栈帧,因为实际的调用图可能依赖于运行时值,即在编译时无法计算。

在 Vyper 的上下文中,堆栈帧只是一个静态内存范围(一个静态字节缓冲区),函数将其用于内存操作。

堆栈帧的分配

在执行分配和释放时,内存分配器会记录到目前为止分配的最高内存指针。因此,即使在释放后指针减小,我们仍然需要分配最大值以适应先前的分配。此最大值是函数堆栈帧的大小。

一个合约可以有多个函数 - 那么各个堆栈帧如何映射到内存?Vyper 中的函数调用不能形成循环。调用链总是从一个外部函数开始,该函数可以潜在地调用其他内部函数。

为了分配内存,我们获取调用树的叶子并为其分配内存。然后我们删除叶子并递归地进行分配......依此类推,直到我们命中初始的外部函数。因此,外部函数将使用最高的内存地址。

因此,为了回答关于如何初始化内存分配器的原始问题 - 我们获取被调用者帧的大小并计算 max

## 计算起始帧
callees = func_t.called_functions
## 我们从最大的被调用者帧开始我们的函数帧
max_callee_frame_size = 0
for c_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 的内部编码匹配的类型 - 静态数组/元组,其元素不需要验证,(u)int256(该值不能太大/太小 - 32B 始终是此类型的有效值)和其他类型(请参阅 needs_clamp 例程以获取完整列表)。

返回缓冲区如何分配?

对于内部函数,调用者分配返回缓冲区。调用者由 Call 表达式表示。返回缓冲区由一个内部变量表示(内部表示“实现”细节),该变量是在解析 Call 表达式 时分配的。然后,返回缓冲区的地址传递给被调用者,被调用者在执行 return 语句时填充它。

函数调用期间如何传递参数?

Vyper 的调用约定是按值传递,这意味着所有参数都会被复制。当为函数分配内存时,所有参数都会在函数帧的开头分配。因此,在调用期间,参数会复制到此预先分配的位置。复制的 IR 由 make_setter 编译器例程创建。

关于赋值的一些说明

编译器的前端会为赋值发出一个副本。反过来,副本意味着必须有一个用于复制的目标缓冲区(反过来需要分配),并且目标缓冲区的大小始终由给定类型的最大可能大小参数化。优化器,尤其是 Venom,能够消除其中一些副本。

例如,以下合约将强制为 ab 分配缓冲区(在编译器的前端):

def foo() -> DynArray[uint256, 1000]:
    a: DynArray[uint256, 1000] = [1, 2, 3]
    b: DynArray[uint256, 1000] = a
    return b

优化器的工作是优化掉这些无用代码。

动态数组如何分配?

对于局部变量,会分配数组的完整潜在大小。对于以下数组 array,将在内存中保留 32+1000*32 字节:

def bar():
    array: DynArray[uint256, 1000] = []

如果数组来自 calldata(即,变量是外部函数的参数),则它会立即复制到内存中,并且我们不再使用 calldata 指针,而是使用内部内存变量。这很方便,因为它使其独立于可变的 abi 编码,并允许我们使用静态指针。

那么 dynamic 代表什么?数组的长度是动态的(但受类型中声明的大小限制),但是动态大小的数组位于静态分配的内存缓冲区中(该缓冲区始终足够大以包含最大长度)。

结束语

我们展示了 Vyper 如何管理其函数帧 - 它们是如何构造和分配的。还详细讨论了局部变量分配 - 我们展示了变量如何表示以及如何定义它们的活跃度。我们还讨论了 DynArray 分配是如何发生的。

未涵盖的是如何在编译器的后期阶段进一步优化内存操作。我们专注于概述编译器的前端如何生成 IR,但是此 IR 得到了进一步的优化,并且稍后会删除许多效率低下的代码。

进一步涵盖的有趣主题是内存别名(更笼统地说是指针分析)、将变量从内存提升到堆栈或如何将内存操作融合在一起等概念。其中一些已经在 Venom 管道中实现,另一些则正在计划中。

  • 原文链接: blog.vyperlang.org/posts...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
vyperlang
vyperlang
江湖只有他的大名,没有他的介绍。