十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

EIP-7923 深度解读:EVM 线性、基于页面的内存成本模型(Linear, Page-Based Memory Costing)

EIP-7923 深度解读:EVM 线性、基于页面的内存成本模型(Linear, Page-Based Memory Costing) EIP-7923 深度解读EVM 线性、基于页面的内存成本模型Linear, Page-Based Memory Costing【免费下载链接】EIPsThe Ethereum Improvement Proposal repository项目地址: https://gitcode.com/GitHub_Trending/ei/EIPsEIP-7923Draft 状态Standards Track / Core提出用「按页计价 虚拟寻址」的线性内存成本模型取代 EVM 沿用至今的二次方内存扩展计费公式并引入交易级内存硬上限使内存限制不再受消息调用栈状态影响。本文以 EIPS/eip-7923.md 为骨架结合仓库内 EIP-7686、EIP-150 等相关提案与 py-evm 参考补丁完整还原其动机、规格、设计原理与实现细节。读完本文你将理解现行二次方内存定价的痛点、分页模型的三条计价规则以及如何在客户端中以mmap或手动虚拟寻址两种方式落地这套模型。提案概览从二次方计价到分页计价EIP-7923 的核心主张可以用一句话概括用「分配页」取代「扩展字节」作为内存计费的基本单位。现行 EVM 的内存计价与内存内容本身耦合每扩展一次内存都要按扩展后的总大小重新套用二次方公式并补收差额。EIP-7923 则将内存视为虚拟可寻址的地址空间只在某条指令真正触及某个 4KB 页面时对该页面收取一次性分配费线性项与二次方项全部移除MLOAD/MSTORE等指令的基础费用保持不变。提案同时规定了两条新的硬性约束内存地址限制为 32 位超出2**32 - 1触发异常停机exceptional halt整个交易累计分配的页数不得超过MAXIMUM_MEMORY_SIZE // PAGE_SIZE 16384即 64MB超出同样触发异常停机。由于该上限以交易为全局作用域交易可分配的内存总量从此与消息调用栈的深度、递归次数、63/64规则等状态彻底解耦——这正是抽象Abstract中memory limits are invariant to the state of the message call stack的含义。动机现行二次方内存模型的四个痛点1. 定价与时代脱节anachronistic现行定价公式EIP-7686 中给出了标准形式memory_size_word (memory_byte_size 31) / 32 memory_cost (memory_size_word ** 2) / 512 (3 * memory_size_word)即使在 3000 万 gas 的上限下一次消息调用也最多只能使用约 3MB 内存且会烧光全部 gas。更关键的是二次方项从约 724 字节23 个 32 字节字此时x²/512 ≈ 1就开始起作用实际可用的内存远低于 3MB——使用 64KB 内存上世纪 80 年代初一台 PC 的内存规模就要付出 14336 gas3×2048 2048²/512 6144 8192。对现代智能合约而言这种计费方式既不经济也不符合直觉。2. 难以推导交易可分配的内存上限在二次方模型下回答一笔交易究竟能分配多少内存是一个优化问题需要先根据调用栈深度上限以及 EIP-150 引入的 63/64 规则推算出能递归进入多少层消息调用再对每层消息调用分别最大化其内存使用。仓库中的 EIP-1153 恰好给出了这类分析的实例单上下文用MSTORE分配内存时3000 万 gas 只能换来约 3.75MB而通过在每层上下文只花 100 万 gas 扩容、再借助调用重置内存扩展成本总共可分配约 20MB。这种数学推导 攻击性构造的复杂性正是二次方模型难以被理性设计的根源。3. 阻碍高级语言利用虚拟内存现代高级语言普遍维护「堆heap」与「调用栈call stack」两个区域堆从内存底部向上增长、存活期超出当前函数帧调用栈从内存顶部向下增长、只服务于当前函数帧。两者互不干扰的前提是虚拟分页内存操作系统自 90 年代初就具备的能力。但在二次方计价的 EVM 内存模型下Vyper、Solidity 等智能合约语言无法实现这种布局只能退而求其次在内存模型中引入额外的低效设计。4. 与硬件现实脱节层次化存储、CPU Cache 与 TLB提案强调今天的内存硬件是层次化的热内存近期访问过远比冷内存快。冷内存慢有两个来源CPU Cache基于 LRU 的缓存命中远快于从 RAM 取值TLBTranslation Lookaside Buffer将虚拟页映射到物理页的哈希表。抖动thrashing——访问大量不同地址——会把数据挤出热缓存、把页挤出 TLB。因此合理的设计应当把单笔交易可用的内存限制在能驻留于热集hot set的范围内。EIP-7923 的 64MB 上限、按页计价、虚拟按需分配正是对这套硬件现实的直接回应。相关探索仓库中的 EIP-7686线性化内存定价并非孤立尝试。EIPS/eip-7686.mdLinear EVM memory limitsVitalik Buterin 提出Stagnant 状态同样移除了二次方项改为纯线性3 * memory_size_word并增加内存字节数不得超过当前调用初始 gas 上限的硬限制从而保证N gas 的执行最多消耗 N 字节内存。EIP-7923 与 EIP-7686 目标相近但路径不同前者采用按页计价 独立硬上限的虚拟内存模型后者保留按字节线性计价并绑定 gas。二者共同说明了社区对可推理、线性化内存成本的强烈诉求。规格新的内存计价算法核心常量EIP-7923 定义了三个常量ALLOCATE_PAGE_COST 100 # 分配一个新页的 gas 费用 PAGE_SIZE 4096 # 页大小与主流硬件页大小一致 MAXIMUM_MEMORY_SIZE 64 * 1024 * 1024 # 64MB交易级内存上限其中给定内存地址计算其所属页非常简单page_id memory_address 12屏蔽最低 12 位。三条计价规则按页收分配费指令每触及一个页面若该页在本条消息调用中尚未被触及过则收取ALLOCATE_PAGE_COST100gas页 0 免费。基础费用不变所有内存指令如MLOAD、MSTORE的基础 gas 费用保持为 3。移除扩展项内存扩展成本中的线性项与二次方项全部删除。也就是说内存成本从随使用量二次增长变为随触及页数线性增长且只对首次触及收费同页重复访问不再额外计费。MSIZE 语义不变MSIZE的行为保持不变返回任何内存指令触及的最大字节地址向上取整到 32 的倍数。保留这一语义是为了避免破坏依赖MSIZE线性增长行为的存量合约采用新虚拟寻址方案的新合约则不应再依赖MSIZE做内存分配。32 位地址空间限制内存地址被限制为 32 位。任何大于2**32 - 1的地址访问将触发异常停机。选择2**32上限的原因是现代 64 位计算机为每个进程预留的虚拟地址空间普遍远大于 4GB客户端运行在操作系统与架构各异的平台上2**32是一个所有平台都能轻松容纳的量级。未来若 gas 上限或客户端内存规模大幅提升该值可以重新评估。交易级内存上限16384 页交易全局累计分配的页数若超过MAXIMUM_MEMORY_SIZE // PAGE_SIZE 1638464MB触发异常停机。注意该限制是交易级而非消息调用级——即使每层调用释放了自己的页交易级计数器仍会持续累加杜绝了通过递归调用绕开限制的路径见下文参考实现中num_pages_anchor的恢复逻辑。设计原理Rationale基准测试与定价推导提案在一台 2019 年款的 CPU 上做了基准测试。该 CPU 的keccak256吞吐约 256MB/s而keccak256每 32 字节收费 6 gas由此得到每 1 gas ≈ 20ns的换算比例。测试数据如下操作耗时分配一个新页fresh page1–2 µs从 2MB 范围内随机读一个字节1.8 ns从 32MB 范围内随机读一个字节7 ns从 4GB 范围内随机读一个字节40 ns更新含 512 项的哈希表8 ns更新含 8192 项的哈希表9 ns更新含 500 万项的哈希表108 ns执行mmap系统调用230 ns据此推导出定价分配一个页收 100 gas。验证如下分配新页耗时 1–2 µs1000–2000 ns按 20 ns/gas 折算约 50–100 gas页分配需要维护哈希集合哈希表更新在合理规模下仅 8–9 ns与 100 gas 相比可忽略即使页集合增长到 500 万项更新也仅需 108 ns约 5.4 gas仍远低于 100 gas 的收费。为什么 page 0 免费执行mmap系统调用约 230 ns按 20 ns/gas 折算约 11 gas——这个开销已经被 CALL 系列指令 100 gas 的基础费用充分覆盖。同理页 0 之所以免费是因为 CALL 的开销已经为初始页分配付过费了。免费页 0 还带来一个兼容性红利首 4KB 内任何访问的成本要么与现行定价持平、要么更低这对向后兼容至关重要。硬性上限与 gas 上限解耦客户端实现普遍希望能独立于 gas 上限实施全局限制以防 DoS。典型场景RPC 提供方可能允许大量并发eth_call计算且为它们配置远高于主网的 gas 上限。若内存限制隐式绑定 gas 上限就多了一个配置失误的攻防向量。EIP-7923 明确引入独立的硬上限把推理范围收窄。提案也承认未来未必不能设计出随硬件提升而扩展的干净公式例如与 gas 上限的平方根成正比但本提案刻意限制作用域先行引入硬上限。交易级 vs 消息调用级限制一个看似合理的替代方案是限制单次消息调用的页分配数例如 2MB但它并不能显著降低实现复杂度。交易级全局限制反而更简单、更安全从内存分配的角度看它天然免疫调用栈深度变化带来的影响让一笔交易最多分配 64MB成为不变的全局不变量。两种实现路径提案明确给出两种客户端实现方式且强调计费所需的数据结构不必与内存实现本身耦合——这为使用 POSIXmmap或其 Windows 对应物VirtualAlloc的优雅实现铺平了道路。路径一手动虚拟寻址适用于没有mmap或虚拟寻址能力的系统。实现需维护一张映射表map[page_id - char[4096]] # page_id memory_address 12当读写跨越页边界时需要同时加载两个页并合并读写结果。路径二基于 mmap适用于具备mmap或类似设施的系统更简洁用匿名mmap映射一块2**32字节4GB的虚拟地址区域来承载真实数据。匿名映射下操作系统不会预先分配整个缓冲区而是按页按需分配——页只在被触碰时才真正落地内存操作直接退化为对这块缓冲区的读写pages集合仍然需要但它不存数据只用于追踪哪些页已被分配、供计价使用。因此该实现只有两个数据结构职责完全分离memory char[2**32] # 仅用于内存读写 allocated_pages set[page_id] # 仅用于 gas 计价这种数据区与计费区分离的设计正是参考实现中eth/vm/memory.py的核心思路。参考实现py-evm 补丁逐段解析提案附带一份约 60 行净改动diff 展开后更长的参考实现以补丁形式针对py-evm代码库提交fec63b8c4b9dad9fcb1022c48c863bdd584820c6给出作者注明这是参考实现例如不含分叉选择规则。完整补丁如下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, titleMemory start position) validate_uint256(size, titleMemory size) - before_size ceil32(len(self._memory)) - after_size ceil32(start_position size) - - before_cost memory_gas_cost(before_size) - after_cost memory_gas_cost(after_size) - - if self.logger.show_debug2: - self.logger.debug2( - fMEMORY: size ({before_size} - {after_size}) | - fcost ({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) if size 0: return ALLOCATE_PAGE_COST 100 LOWER_BITS 12 # bits ignored for page calculations PAGE_SIZE 4096 assert 2**LOWER_BITS PAGE_SIZE # sanity check MAXIMUM_MEMORY_SIZE 64 * 1024 * 1024 TRANSACTION_MAX_PAGES MAXIMUM_MEMORY_SIZE // PAGE_SIZE end_position start_position size - 1 start_page start_position LOWER_BITS end_page end_position 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 fAllocating page {hex(page LOWER_BITS)} self._gas_meter.consume_gas(ALLOCATE_PAGE_COST, reason) self._memory.pages[page] True def memory_write(self, start_position: int, size: int, value: bytes) - None: return self._memory.write(start_position, size, value) diff --git a/eth/vm/forks/frontier/computation.py b/eth/vm/forks/frontier/computation.py index 51666ae0..443f82b5 100644 --- a/eth/vm/forks/frontier/computation.py b/eth/vm/forks/frontier/computation.py -29,6 29,7 from eth.exceptions import ( InsufficientFunds, OutOfGas, StackDepthLimit, VMError, ) from eth.vm.computation import ( BaseComputation, -87,12 88,21 class FrontierComputation(BaseComputation): state.touch_account(message.storage_address) - computation cls.apply_computation( - state, - message, - transaction_context, - parent_computationparent_computation, - ) # implement transaction-global memory limit num_pages_anchor transaction_context.num_pages try: computation cls.apply_computation( state, message, transaction_context, parent_computationparent_computation, ) 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 transaction_context.num_pages num_pages_anchor if computation.is_error: state.revert(snapshot) 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 -43,7 43,7 def mload(computation: ComputationAPI) - None: def msize(computation: ComputationAPI) - None: - computation.stack_push_int(len(computation._memory)) 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,8 1,11 import logging import mmap from eth._utils.numeric import ( ceil32, ) from eth.exceptions import VMError from eth.abc import ( MemoryAPI, ) -13,52 16,48 from eth.validation import ( validate_uint256, ) class Memory(MemoryAPI): - __slots__ [_bytes] __slots__ (pages, msize) logger logging.getLogger(eth.vm.memory.Memory) def __init__(self) - None: - self._bytes bytearray() self.memview mmap.mmap(-1, 2**32, flagsmmap.MAP_PRIVATE | mmap.MAP_ANONYMOUS) self.pages {0: True} # page 0 is free (per spec) self.msize 0 def extend(self, start_position: int, size: int) - None: if size 0: return - new_size ceil32(start_position size) - if new_size len(self): if start_position size self.msize: self.msize ceil32(start_position size) def write(self, start_position: int, size: int, value: bytes) - None: if size 0: return - size_to_extend new_size - len(self) - try: - self._bytes.extend(bytearray(size_to_extend)) - except BufferError: - # we cant extend the buffer (which might involve relocating it) if a - # memoryview (which stores a pointer into the buffer) has been created by - # read() and not released. Callers of read() will never try to write to the - # buffer so were not missing anything by making a new buffer and forgetting - # about the old one. Were 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) - - def __len__(self) - int: - return len(self._bytes) if start_position size 2**32: raise VMError(Non 32-bit address) - def write(self, start_position: int, size: int, value: bytes) - None: - if size: - validate_uint256(start_position) - validate_uint256(size) - validate_is_bytes(value) - validate_length(value, lengthsize) - validate_lte(start_position size, maximumlen(self)) validate_uint256(start_position) validate_uint256(size) validate_is_bytes(value) validate_length(value, lengthsize) end_position start_position size - self._bytes[start_position : start_position len(value)] value self.memview[start_position : end_position] value # unused def read(self, start_position: int, size: int) - memoryview: - return memoryview(self._bytes)[start_position : start_position size] return memoryview(self.memview)[start_position : start_position size] def read_bytes(self, start_position: int, size: int) - bytes: - return bytes(self._bytes[start_position : start_position size]) return bytes(self.memview[start_position : start_position size]) def copy(self, destination: int, source: int, length: int) - None: if length 0: -69,5 68,5 class Memory(MemoryAPI): validate_uint256(length) validate_lte(max(destination, source) length, maximumlen(self)) - buf memoryview(self._bytes) buf memoryview(self.memview) buf[destination : destination length] buf[source : source length] 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,9 class BaseTransactionContext(TransactionContextAPI): # post-cancun self._blob_versioned_hashes blob_versioned_hashes or [] # eip-7923 self.num_pages 0 def get_next_log_counter(self) - int: return next(self._log_counter)以下按文件逐一解析这条补丁的关键改动。eth/vm/computation.py以页为单位的扩展计费这是改动最核心的部分。BaseComputation的extend_memory对应补丁上下文中的内存扩展逻辑从计算before_cost/after_cost并补差额改为size 0直接返回计算触及页区间[start_page, end_page]end_position start_position size - 1注意对触及字节而非分配字节的精确处理遍历区间内每个页若不在self._memory.pages中先检查交易级页数上限超限抛VMError(Out Of Memory)否则递增transaction_context.num_pages、按ALLOCATE_PAGE_COST扣 gas、并将页标记为已分配。msize不再等于len(self._memory)而是由新的Memory.msize字段单独维护见 memory.py 改动。eth/vm/forks/frontier/computation.py交易级限制的锚点恢复这是实现交易全局语义的关键子消息调用会向transaction_context.num_pages累加页数但当子调用返回或回滚时必须把这些页归还否则交易级计数器会被耗尽。实现采用锚点anchor finally 恢复模式调用前记录num_pages_anchorapply_computation在try中执行finally中通过不变量断言num_pages num_pages_anchor 子调用分配的页数后将计数器恢复为num_pages_anchor。这样页数统计以单次消息调用为生命周期决定是否重复收费而上限以交易为生命周期决定总量是否超限两者通过计数器正确区分。注意原补丁中allocated pages缺了下划线应为allocated_pages这是参考实现的笔误不影响语义。eth/vm/logic/memory.pyMSIZE 取新字段msize操作码从len(computation._memory)改为computation._memory.msize。由于虚拟寻址下内存不再是从 0 连续增长的字节数组len()已无意义改为显式维护的msize字段在extend时按ceil32(start_position size)更新。eth/vm/memory.pymmap 支撑的虚拟内存Memory类从bytearray改为三件套memview匿名mmap的 4GB 虚拟区域mmap.MAP_PRIVATE | mmap.MAP_ANONYMOUS操作系统按需分配物理页pages {0: True}页分配集合页 0 预置为已分配落实页 0 免费msize维护 MSIZE 语义的边界值。write中新增start_position size 2**32时抛VMError(Non 32-bit address)落实 32 位地址限制读写直接作用于memview彻底删除了原先bytearray扩容的BufferError处理分支。eth/vm/transaction_context.py计数器挂载BaseTransactionContext.__init__中新增self.num_pages 0作为交易级页数计数器的载体被computation.py的分配逻辑与frontier/computation.py的锚点恢复逻辑共同读写。向后兼容性提案的结论是不破坏任何向后兼容性。主要原因有二页 0 免费首 4KB 内的访问成本不高于现行定价移除二次方扩展项后内存计费整体只降不升唯一可能的行为变化是——部分此前会因内存扩展耗尽 gas 的合约现在可能成功执行完成。对这类合约而言这是语义上的放宽而非破坏。安全考量是否会破坏现有合约即存量合约是否依赖内存被限制这一事实提案的评估是除 gas 计费外现有合约至多是从gas 耗尽变为执行完成不构成破坏。是否开启内存型 DoS 攻击提案给出了最大内存使用分析以当前约 3000 万 gas 的上限为例递归调用一个每层分配 256KB 内存的合约总计可分配约54MB内存——这与本提案提出的 64MB 上限并无本质差异。更重要的是交易级全局内存上限提供了一个有用的不变量未来无论调用栈上限如何调整都不会改变一笔交易可分配的内存总量。而 64MB 的限制本身也与交易可用内存应能驻留于热集的硬件约束相匹配从根源上限制了内存抖动的攻击面。总结EIP-7923 是对 EVM 内存模型的一次系统性重构以 4KB 页为计价单位、以 100 gas 为单页价格、以 64MB 为交易级硬上限辅以 32 位虚拟寻址与mmap按需分配将无法推理、与硬件脱节的二次方模型替换为线性、可推理、贴近现代操作系统的分页模型。其参考实现虽然只是一份 py-evm 补丁并附带如allocated pages笔误、copy中残留len(self)等参考实现常见的不完善之处但完整演示了计费与内存数据分离、消息调用级计费与交易级限额并存这两大设计要点。对语言实现者而言它打开了堆/栈分离虚拟内存的大门对客户端实现者而言它提供了独立于 gas 的 DoS 防护维度。该提案仍处于 Draft 状态相关讨论Ethereum Magicians 论坛与实现细节可通过 EIPS/eip-7923.md 及其参考链接持续跟踪版权遵循 CC0 协议。【免费下载链接】EIPsThe Ethereum Improvement Proposal repository项目地址: https://gitcode.com/GitHub_Trending/ei/EIPs创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表