EIP-7923: 线性、基于页面的内存计费
将内存计费线性化,并用基于页面的成本模型替换当前二次方公式。
Authors | Charles Cooper (@charles-cooper), Qi Zhou (@qizhou) |
---|---|
Created | 2025-03-27 |
Discussion Link | https://ethereum-magicians.org/t/eip-linearize-memory-costing/23290 |
摘要
本 EIP 将 EVM 中二次方内存模型替换为线性的、基于页面的成本模型。内存是虚拟可寻址的。页面分配和页面抖动都包含在成本模型中。应用此 EIP 后,内存限制对于消息调用堆栈的状态是不变的。
动机
EVM 当前对其内存使用二次方定价模型。最初是为了防御 DoS 攻击而设置的。但是,内存模型有几个缺点。
- 它是过时的。即使在 3000 万 gas 的 gas 限制下,用户也只能在消息调用中使用 3MB 的内存(这会消耗所有 gas)。由于内存扩展的二次项在 724 字节时开始生效,因此用户在实践中使用的内存要少得多。即使使用 64KB 的内存——这是 80 年代早期 PC 可用的内存量——也要花费 14336 gas!
- 二次方模型使得难以推理事务可以分配多少内存。它需要解决一个优化问题,该问题涉及计算基于调用堆栈限制(以及 EIP-150 之后的 63/64ths 规则)可以递归到多少消息调用,然后最大化每个消息调用使用的内存。
- 二次方模型使得高级智能合约语言无法获得虚拟内存的好处。大多数现代编程语言都维护着所谓的“堆”和“调用堆栈”。堆用于分配超出其当前函数帧生命周期的对象,而调用堆栈用于分配存在于当前函数帧中的对象。重要的是,调用堆栈从内存顶部开始并向下增长,而堆从内存底部开始并向上增长,因此语言实现不需要担心内存的两个区域相互干扰。这是虚拟分页内存启用的一项功能,自 90 年代初以来就已出现在操作系统中。但是,像 Vyper 和 Solidity 这样的智能合约语言无法实现这一点,从而导致其内存模型效率低下。
本 EIP 提出了一个线性成本模型,该模型更接近于当今的硬件,它是分层的(访问“热”内存比访问“冷”内存快得多),并且是虚拟寻址的(内存不需要连续分配,而是由操作系统“按需”提供)。
首先,一些预备知识。在大多数架构上,一个页面是 4096 字节。给定一个内存地址,通过屏蔽掉最右边的 12 位,可以很容易地计算出它的页面。
有两个因素导致“冷”内存(即最近未使用的内存)变慢:CPU 缓存和 TLB(转换后备缓冲区)缓存。CPU 缓存是最近最少使用的内存缓存,它比从 RAM 中完全获取要快得多。TLB 通常是一些哈希表,它将虚拟页面(用户使用)映射到 RAM 中的物理页面。“颠簸”,或访问大量不同的内存地址,会做两件事:它将内存从热缓存中推到冷内存中,并且它将页面推出 TLB 缓存。
本 EIP 使用虚拟寻址方案,以便在实际访问内存页面之前不会分配它们。此外,它还增加了一个附加费,用于访问 EVM 定义的“热”区域之外的内存。
值得注意的是,用于计算内存成本的数据结构不需要成为内存实现本身的一部分,这表明可以使用 POSIX mmap
系统调用(或其在 Windows 上的对应物 VirtualAlloc
)进行优雅的实现。
可以通过两种方式来实现。第一种方法是“手动”实现虚拟寻址。这适用于没有 mmap
或虚拟寻址功能的系统。该实现需要维护一个从 map[page_id -> char[4096]]
的映射,其中 page_id
是一个整数,计算为 memory_address >> 12
。此外,出于成本计算目的,维护了一组 512 个 page_id
(set[page_id]
)。这仅用于定价操作,它实际上并不包含数据。
对于具有 mmap
或类似设施的系统,另一种实现更容易。为了保存内存的实际数据,该实现 mmap
一个 2**32
字节的内存区域。然后,可以将内存操作简单地实现为针对此缓冲区的读取或写入。(使用匿名 mmap
,操作系统不会预先分配整个缓冲区,而是会在触摸页面时“按需”分配页面)。pages
映射仍然是必要的,但它不保存任何数据,它只是用于跟踪已分配的页面,以用于定价目的。在此实现中,有三个数据结构:memory char[2**32]
、allocated_pages set[page_id]
、hot_pages set[page_id]
。memory
数据结构仅用于内存读取和写入。allocated_pages
和 hot_pages
仅用于 gas 成本计算。
规范
考虑以下常量:
ALLOCATE_PAGE_COST = 100
THRASH_PAGE_COST = 6
LRU_SIZE = 512
PAGE_SIZE = 4096
MAXIMUM_MEMORY_SIZE = 64 * 1024 * 1024
内存成本计算算法更改如下:
- 对于指令访问的每个页面
- 如果它在此消息调用中之前未被访问过,则收取
ALLOCATE_PAGE_COST
gas。 - 如果它不在
LRU_SIZE
最近最少访问的页面中,则收取THRASH_PAGE_COST
gas。
- 如果它在此消息调用中之前未被访问过,则收取
- 所有内存指令(例如
MLOAD
和MSTORE
)的基本 gas 成本保持不变,为 3。
msize
的行为保持不变。它返回任何内存指令访问的最大字节数,向上舍入 32。
内存地址限制为 32 位。如果地址大于 2**32 - 1
,则应引发异常停止。
施加事务全局内存限制。如果事务中分配的页面数超过 MAXIMUM_MEMORY_SIZE // PAGE_SIZE
(即 16384),则应引发异常停止。
原理
基准测试是在 2019 年代的 CPU 上进行的,它能够以大约 256MB/s 的速度进行 keccak256
,使其 gas 与 ns 的比率为每 1 gas 20 ns(考虑到 keccak256
每 32 字节花费 6 gas)。执行了以下基准测试:
- 分配新页面的时间:1-2us
- 从 2MB 范围随机读取一个字节的时间:1.8ns
- 从 32MB 范围随机读取一个字节的时间:7ns
- 从 4GB 范围随机读取一个字节的时间:40ns
- 使用 512 个项目更新哈希映射的时间:8ns
- 使用 8192 个项目更新哈希映射的时间:9ns
- 使用 5mm 个项目更新哈希映射的时间:108ns
- 执行
mmap
系统调用的时间:230ns
这些建议以下价格:
- 100 gas 用于分配页面,以及
- 6 gas 用于页面抖动
请注意,执行 mmap
的成本(约 11 gas)已经可以通过 CALL 系列指令的基本成本(100 gas)很好地支付。
由于命中页面和抖动页面之间的增量(包括簿记开销)约为 120ns,我们可以忽略资源成本,而只需将每个内存操作的基本成本从 3 gas 增加到 6 gas。但是,由于利用成本局部性的内存操作非常便宜,因此为 gas 计划的未来改进留下了“空间”,包括将内存操作的基本成本降低到 1 gas。此外,如下面的参考实现所示,检查抖动只需很少的簿记开销(一个额外的数据结构和四行代码)。因此,我们使用一级层次结构对内存进行建模。虽然这比大多数具有多个级别内存层次结构的真实 CPU 更简单,但它对于我们的目的来说足够精细。
客户端实现希望能够将全局限制与 gas 限制分开执行,原因在于 DoS。例如,RPC 提供程序可能被设计为允许许多并发的 eth_call
计算,其 gas 限制远高于主网上的 gas 限制。不要隐式地将内存限制与 gas 限制联系起来,可以减少一个配置错误的向量。这并不是说将来无法创建一个允许内存限制随未来硬件改进而扩展(例如,与 gas 限制的平方根成比例),但是为了限制此 EIP 需要推理的事情的范围,引入了硬限制。
硬限制被指定为事务全局限制,而不是消息调用限制。也就是说,一个合理的替代方案可能是限制可以在给定消息调用中分配的页面数,例如,限制为 2MB,但这并不能显着提高实现复杂性。此外,用户可能会提出使用大量(相对于 2MB)内存的有用用例,并且可以通过向实现添加抖动成本来解决这些问题。
关于保持 MSIZE
的语义不变:
由于用户期望使用完整的地址空间而不是线性地增长它,因此保持 MSIZE
语义不变的原因是为了避免破坏与期望它线性增长的现有合约的向后兼容性。利用新虚拟寻址方案的新合约不应依赖 MSIZE
进行内存分配。
在寻址方面强制执行了 2**32
字节的硬限制。这使得现代 64 位计算机更容易分配那么多虚拟地址空间,因为客户端在各种操作系统和体系结构上运行,这些操作系统和体系结构对每个进程的虚拟内存限制不同。将来可能会重新讨论这一点,例如,如果 gas 限制或客户端内存大小显着增加。
向后兼容性
在安全注意事项部分中讨论。没有破坏向后兼容性,尽管一些以前耗尽 gas 的合约现在可以成功完成。
测试用例
参考实现
下面提供了一个大约 60 行的参考实现。它是作为针对提交 ethereum/py-evm@fec63b8c4b9dad9fcb1022c48c863bdd584820c6 的 py-evm
代码库的补丁实现的。(这是一个参考实现,例如,它不包含分叉选择规则)。
diff --git a/eth/vm/computation.py b/eth/vm/computation.py
index bf34fbee..db85aee7 100644
--- a/eth/vm/computation.py
+++ b/eth/vm/computation.py
@@ -454,34 +454,40 @@ class BaseComputation(ComputationAPI, Configurable):
validate_uint256(start_position, title="Memory start position")
validate_uint256(size, title="Memory size")
- before_size = ceil32(len(self._memory))
- after_size = ceil32(start_position + size)
+ before_size = ceil32(len(self._memory)) // 计算内存扩展之前的内存大小
+ after_size = ceil32(start_position + size) // 计算内存扩展之后的内存大小
+
+ before_cost = memory_gas_cost(before_size) // 计算内存扩展之前的 gas 成本
+ after_cost = memory_gas_cost(after_size) // 计算内存扩展之后的 gas 成本
+
+ if self.logger.show_debug2:
+ // 如果启用了调试模式,则记录扩展内存的大小和 gas 成本
+ self.logger.debug2(
+ f"MEMORY: size ({before_size} -> {after_size}) | "
+ f"cost ({before_cost} -> {after_cost})"
+ )
+
+ if size:
+ // 如果 before_cost 小于 after_cost,则计算 gas 费用
+ if before_cost < after_cost:
+ gas_fee = after_cost - before_cost
+ self._gas_meter.consume_gas( // 从 gas 计量器中扣除 gas 费用
+ gas_fee,
+ reason=" ".join( // 设置 gas 消耗的原因
+ (
+ "Expanding memory",
+ str(before_size),
+ "->",
+ str(after_size),
+ )
+ ),
+ )
- before_cost = memory_gas_cost(before_size)
- after_cost = memory_gas_cost(after_size)
-
- if self.logger.show_debug2:
- self.logger.debug2(
- f"MEMORY: size ({before_size} -> {after_size}) | "
- f"cost ({before_cost} -> {after_cost})"
- )
-
- if size:
- if before_cost < after_cost:
- gas_fee = after_cost - before_cost
- self._gas_meter.consume_gas(
- gas_fee,
- reason=" ".join(
- (
- "Expanding memory",
- str(before_size),
- "->",
- str(after_size),
- )
- ),
- )
-
- self._memory.extend(start_position, size)
+ self._memory.extend(start_position, size) // 扩展内存
if size == 0:
return
@@ -493,27 +499,32 @@
end = start_position + size
- start_page = start_position >> LOWER_BITS
- end_page = end >> LOWER_BITS
+ start_page = start_position >> LOWER_BITS //内存起始位置的页号
+ end_page = end >> LOWER_BITS // 内存终止为止的页号
for page in range(start_page, end_page + 1):
+ // 遍历所有被访问的页面
if page not in self._memory.pages:
+ // 如果该页面之前未被访问过
if self.transaction_context.num_pages >= TRANSACTION_MAX_PAGES:
+ // 如果事务分配的页面数大于允许分配的最大值
raise VMError("Out Of Memory")
+ // 则报内存溢出错误
self.transaction_context.num_pages += 1
- reason = f"Allocating page {hex(page << LOWER_BITS)}"
+ reason = f"Allocating page {hex(page << LOWER_BITS)}" // 设置 gas 消耗的原因
self._gas_meter.consume_gas(ALLOCATE_PAGE_COST, reason)
+ // 从 gas 计量器中扣除分配页面的 gas 费用
self._memory.pages[page] = True
+ // 标记该页面已经被分配
if page not in self._memory.lru_pages:
+ // 如果该页面没有被 LRU 缓存
reason = f"Page {hex(page << LOWER_BITS)} not in LRU pages"
+ // 设置 gas 消耗的原因
self._gas_meter.consume_gas(THRASH_PAGE_COST, reason)
+ // 从 gas 计量器中扣除页面交换的 gas 费用
# insert into the lru_pages data structure.
- # it's important to do it here rather than after
- # the loop, since this could evict a page we haven't
- # visited yet, increasing the cost.
self._memory.lru_pages[page] = True
def memory_write(self, start_position: int, size: int, value: bytes) -> None:
@@ -43,6 +49,7 @@ from eth.exceptions import (
InsufficientFunds,
OutOfGas,
StackDepthLimit,
+ // 定义 VMError 异常
VMError,
)
from eth.vm.computation import (
@@ -73,6 +80,7 @@ class FrontierComputation(BaseComputation):
state.touch_account(message.storage_address)
+ //从 gas 计量器中扣除 gas 费用,如果gas不足则返回错误
computation = cls.apply_computation(
state,
message,
@@ -89,6 +97,7 @@
finally:
# "deallocate" all the pages allocated in the child computation
+ // 在子计算完成之后,“释放” 所有子计算中分配的的页面
# sanity check an invariant:
allocated_pages = len(computation._memory.pages)
assert transaction_context.num_pages == num_pages_anchor + allocated pages
diff --git a/eth/vm/logic/memory.py b/eth/vm/logic/memory.py
index 806dbd8b..247b3c74 100644
--- a/eth/vm/logic/memory.py
+++ b/eth/vm/logic/memory.py
@@ -17,11 +17,13 @@ def mload(computation: ComputationAPI) -> None:
def msize(computation: ComputationAPI) -> None:
+ // 将当前内存大小压入堆栈
computation.stack_push_int(computation._memory.msize)
def mcopy(computation: ComputationAPI) -> None:
diff --git a/eth/vm/memory.py b/eth/vm/memory.py
index 2ccfd090..9002b559 100644
--- a/eth/vm/memory.py
+++ b/eth/vm/memory.py
@@ -1,14 +1,16 @@
import logging
+ //导入 mmap 模块
import mmap
from eth._utils.numeric import (
ceil32,
)
+ // 定义 VMError
from eth.exceptions import VMError
from eth.abc import (
MemoryAPI,
+ //从堆栈中弹出一个项
)
from eth.validation import (
validate_uint256,
@@ -17,100 +19,108 @@ from eth.validation import (
validate_uint256,
)
+ // 导入 LRU 缓存类
from lru import LRU
class Memory(MemoryAPI):
- __slots__ = ["_bytes"]
+ __slots__ = ("pages", "lru_pages", "msize") // 定义类的插槽,用于存储页数据,LRU 缓存数据和内存大小
logger = logging.getLogger("eth.vm.memory.Memory")
def __init__(self) -> None:
- self._bytes = bytearray()
+ self.memview = mmap.mmap(-1, 2**32, flags=mmap.MAP_PRIVATE | mmap.MAP_ANONYMOUS) // 创建一个匿名共享内存区域,大小为 2**32 字节
+ self.pages = {} // 创建一个字典用于存储内存页,键为页号
+ // LRU 缓存,最大容量为 512
+ self.lru_pages = LRU(512)
+ self.msize = 0 // 内存大小,初始化为 0
+
+ // 扩展内存,使其至少可以容纳从 start_position 开始的 size 大小的数据
+ def extend(self, start_position: int, size: int) -> None:
+ // 如果 size 为 0,则直接返回不执行任何操作
+ if size == 0:
+ return
+
+ // 如果 内存起始位置 + size 大于当前内存大小 msize
+ if start_position + size > self.msize:
+ // 则设置新的内存大小 为 向上取整到 32byte
+ self.msize = ceil32(start_position + size)
+ //将数据 value 写入到内存中,从 start_position 位置开始,共写入 size 个字节
+ def write(self, start_position: int, size: int, value: bytes) -> None:
+ // 如果 size 为 0,则直接返回不执行任何操作
+ if size == 0:
+ return
+ // 如果 内存起始位置 + size 大于等于 2**32
+ if start_position + size >= 2**32:
+ // 如果地址超出范围,则抛出异常 VMError
+ raise VMError("Non 32-bit address")
+ //校验 start_position 是一个 uint256 类型的整数
+ validate_uint256(start_position)
+ //校验 size 是一个 uint256 类型的整数
+ validate_uint256(size)
+ // value 是一个字节串
+ validate_is_bytes(value)
+ // value 长度为 size
+ validate_length(value, length=size)
+
+ end_position = start_position + size // 计算出结束位置
+
+ self.memview[start_position : end_position] = value // 将 value 写入内存
+
+ // 未使用
+ //从内存中读取数据,从 start_position 开始读取 size 个字节
+ def read(self, start_position: int, size: int) -> memoryview:
+ // 返回一个 memoryview 对象,该对象指向内存中的指定区域
+ return memoryview(self.memview)[start_position : start_position + size]
+
+ // 从内存中读取字节码,从 start_position 开始读取 size 个字节
+ def read_bytes(self, start_position: int, size: int) -> bytes:
+ // 返回一个字节串,包含从内存中读取的数据
+ return bytes(self.memview[start_position : start_position + size])
+ //将内存中的一段区域的数据复制到另一段区域,从 source 复制 length 个字节到 destination
+ def copy(self, destination: int, source: int, length: int) -> None:
+ // 如果 length 为 0,则直接返回不执行任何操作
+ if length == 0:
+ return
+ //校验 destination 是一个 uint256 类型的整数
+ validate_uint256(destination)
+ //校验 source 是一个 uint256 类型的整数
+ validate_uint256(source)
+ //校验 length 是一个 uint256 类型的整数
+ validate_uint256(length)
+
+ buf = memoryview(self.memview) // 创建一个 memoryview 对象,该对象指向内存中的指定区域
+ // 将内存中的一段区域的数据复制到另一段区域,从 source 复制 length 个字节到 destination
+ buf[destination : destination + length] = buf[source : source + length]
+
+ // 如果 destination, source 加上 length 的最大值 大于内存大小,则抛出异常
+
+ // 如果没有数据需要追加,则直接返回
+
+ // 获取当前内存的大小
+ // 将数据追加到内存中
+ // 如果长度大于等于 2**32
+
+ // 创建一个新的字节数组,长度为 size_to_extend
new_size = ceil32(start_position + size)
if new_size <= len(self):
return
size_to_extend = new_size - len(self)
- try:
- self._bytes.extend(bytearray(size_to_extend))
- except BufferError:
+ try:
+ // try to extend memory by allocating new pages
+ self.memview.extend(bytearray(size_to_extend))
+ except BufferError: // 如果扩展内存失败
# we can't extend the buffer (which might involve relocating it) if a
- # memoryview (which stores a pointer into the buffer) has been created by
+ //如果无法扩展缓冲区(可能涉及重新定位),原因是由 read() 创建的 memoryview(存储指向缓冲区的指针)尚未释放。
# read() and not released. Callers of read() will never try to write to the
- # buffer so we're not missing anything by making a new buffer and forgetting
- # about the old one. We're keeping too much memory around but this is still
- # a net savings over having read() return a new bytes() object every time.
- self._bytes = self._bytes + bytearray(size_to_extend)
+ // read() 的调用者永远不会尝试写入缓冲区,因此我们通过创建一个新缓冲区并忘记旧缓冲区,不会错过任何内容。
+ self.memview = self.memview + bytearray(size_to_extend) // 将新的字节数组追加到 `self._bytes` 的末尾
- def __len__(self) -> int:
- return len(self._bytes)
+ //计算并设置新的尺寸, 从 msize 字节扩展到 new_size 字节
+ //返回内存的内存视图
+ //返回当前的内存大小
+ //将 value 数据写入内存,从 start_position 开始,写入 size 大小的数据
+
+ //从内存中读取指定长度的数据
+
+ //将内存拷贝到指定位置
diff --git a/eth/vm/transaction_context.py b/eth/vm/transaction_context.py
index 79b570e9..5943f897 100644
--- a/eth/vm/transaction_context.py
+++ b/eth/vm/transaction_context.py
@@ -36,6 +36,7 @@ class BaseTransactionContext(TransactionContextAPI):
# post-cancun
self._blob_versioned_hashes = blob_versioned_hashes or []
+ // 初始化 num_pages 属性,用于统计交易中使用的总页数
# eip-7923
self.num_pages = 0
安全注意事项
关于此 EIP,有两个主要的安全注意事项。
一,它是否会破坏现有合约?也就是说,现有合约是否依赖于内存受到限制?
在 gas 成本计算之外,现有合约可能会简单地执行到完成,而不是耗尽 gas。
二,这是否会针对客户端启用基于内存的 DoS 攻击?
这需要进行最大内存使用量分析。根据目前 3000 万 gas 的 gas 限制,递归调用在每个调用堆栈中分配 256KB 内存的合约可以分配 54MB 的总内存,这与本 EIP 中提议的 64MB 限制没有显着差异。请注意,本 EIP 中提议的事务全局内存限制提供了一个有用的不变量,即未来对调用堆栈限制的更改不会影响给定事务中可以分配的总内存量。
版权
通过 CC0 放弃版权和相关权利。
Citation
Please cite this document as:
Charles Cooper (@charles-cooper), Qi Zhou (@qizhou), "EIP-7923: 线性、基于页面的内存计费 [DRAFT]," Ethereum Improvement Proposals, no. 7923, March 2025. [Online serial]. Available: https://eips.ethereum.org/EIPS/eip-7923.