はじめに

『DApps開発入門』という本や色々記事を書いているかるでねです。

今回は、EVMの二次関数的なメモリコストモデルを線形のページベースコストモデルに置き換えることを提案しているEIP7923についてまとめていきます!

以下にまとめられているものを翻訳・要約・補足しながらまとめていきます。

他にも様々なEIP・BIP・SLIP・CAIP・ENSIP・RFC・ACPについてまとめています。

概要

EIP7923とは、EVMの二次関数的なメモリモデルを線形のページベースコストモデルに置き換える提案のことです。 メモリは仮想的にアドレス指定可能になり、ページ割り当てがコストモデルに含まれます。 この提案を適用した後、メモリ制限はメッセージコールスタックの状態に依存しなくなります。

ページベースコストモデルとは、メモリを4096バイト(4KB)の「ページ」という単位で区切り、ページごとに固定のガスコストを課す方式です。 PCやスマートフォンのOSがメモリを管理する仕組みと同じ考え方で、必要なページだけを割り当てるため、使わないメモリに余計なコストがかかりません。 現行のEVMでは、メモリを多く使うほど二乗でコストが跳ね上がりますが、この提案ではページ1つあたり100ガスの固定コストになります。

主要な特徴

特徴 説明
線形コスト 二次関数的なメモリ拡張コストを廃止
ページベース 4096バイト単位でメモリをページとして管理
仮想アドレッシング メモリページはアクセスされるまで割り当てられない
トランザクショングローバル制限 64MBの最大メモリ制限
32ビットアドレス制限 メモリアドレスは32ビットに制限

現在のEVMでは、メモリ拡張のコストは二次関数的に増加します。 これは、724バイトを超えるとメモリ使用量に対して二乗のガスコストがかかることを意味します。 この設計はDoS攻撃への防御として導入されましたが、現代のハードウェアに対しては時代遅れになっています。

動機

EVMは現在、メモリに対して二次関数的な価格設定モデルを使用しています。 これは元々DoS攻撃から防御するために導入されました。 しかし、このメモリモデルにはいくつかの欠点があります。

現行モデルの問題点

時代遅れの設計

3000万ガスのガスリミットでも、ユーザーはメッセージコールで3MBのメモリしか使用できません(そしてそれはすべてのガスを消費します)。 メモリ拡張の二次項は724バイトから効き始めるため、実際にはユーザーはさらに少ないメモリしか使用しません。 64KBのメモリ(1980年代初期のPCで利用可能だった量)を使用するだけでも14336ガスかかります。

推論の困難さ

二次モデルでは、トランザクションがどれだけのメモリを割り当てられるかを推論することが困難です。 コールスタック制限に基づいて再帰できるメッセージコールの数を計算し(EIP150以降は63/64ルール)、メッセージコールごとに使用されるメモリを最大化する最適化問題を解く必要があります。

仮想メモリの恩恵を受けられない

二次モデルでは、高水準スマートコントラクト言語が仮想メモリの恩恵を受けることが不可能です。 ほとんどの現代のプログラミング言語は「ヒープ」と「コールスタック」を維持しています。 ヒープは現在の関数フレームのライフタイムを超えて生存するオブジェクトを割り当てるために使用され、コールスタックは現在の関数フレーム内で生存するオブジェクトを割り当てるために使用されます。

重要なのは、コールスタックはメモリの上部から始まって下に成長し、ヒープはメモリの下部から始まって上に成長することです。 これにより、言語実装はメモリの2つの領域が互いに干渉することを心配する必要がありません。 これは1990年代初期からオペレーティングシステムに存在している仮想ページメモリによって可能になる機能です。 しかし、VyperやSolidityなどのスマートコントラクト言語はこれを実装できず、メモリモデルに非効率性が生じています。

新しいモデルの利点

EIP7923は、今日のハードウェアをより正確に反映する線形コストモデルを提案しています。 現代のハードウェアは階層的(「ホット」メモリは「コールド」メモリよりもはるかに高速にアクセスできる)であり、仮想アドレス指定されています(メモリは連続して割り当てる必要がなく、オペレーティングシステムによって「オンデマンド」で提供される)。

項目 現行モデル 新モデル
コスト計算 二次関数的 線形(ページ単位)
メモリ拡張 連続的に拡張 仮想アドレッシング
64KB使用コスト 14336ガス 約1600ガス(16ページ × 100ガス)
最大メモリ コールスタックに依存 64MB固定

仕様

定数

以下の定数が定義されています。

python
ALLOCATE_PAGE_COST = 100
PAGE_SIZE = 4096
MAXIMUM_MEMORY_SIZE = 64 * 1024 * 1024

これらの定数は、ページ割り当てコスト、ページサイズ、最大メモリサイズを定義しています。 ALLOCATE_PAGE_COSTは新しいページを割り当てる時に課されるガスコストです。 PAGE_SIZEは4096バイト(4KB)で、ほとんどのアーキテクチャで使用されるページサイズと一致します。 MAXIMUM_MEMORY_SIZEは64MBで、トランザクション全体で使用できる最大メモリです。

メモリコストアルゴリズムの変更

メモリコストアルゴリズムは以下のように変更されます。

  1. 命令によってアクセスされた各ページについて、このメッセージコールで以前にアクセスされていない場合はALLOCATE_PAGE_COSTガスを課す。ただし、ページ0は無料。
  2. すべてのメモリ命令(例: MLOAD、MSTORE)のベースガスコストは同じ3のまま維持。
  3. メモリ拡張コストの線形項と二次項は削除。
要素 変更内容
新規ページアクセス 100ガス(ページ0を除く)
ベースメモリコスト 3ガス(変更なし)
線形項・二次項 削除

MSIZEの動作

MSIZEの動作は同じままです。 任意のメモリ命令によってアクセスされた最大バイト位置を、32で切り上げて返します。

アドレス制限

メモリアドレスは32ビットに制限されます。 アドレスが2**32 - 1より大きい場合、例外的な停止が発生する必要があります。

トランザクショングローバルメモリ制限

トランザクショングローバルなメモリ制限が課されます。 トランザクションで割り当てられたページ数がMAXIMUM_MEMORY_SIZE // PAGE_SIZE(つまり16384)を超えた場合、例外的な停止が発生する必要があります。

補足

ベンチマーク結果

ベンチマークは2019年代のCPUで実行され、約256MB/秒でkeccak256を実行できることがわかりました。 これにより、1ガスあたり20ナノ秒のガス対ナノ秒比率が得られます(keccak256が32バイトあたり6ガスかかることを考慮)。

操作 時間
新しいページの割り当て 1-2マイクロ秒
2MB範囲からのランダムバイト読み取り 1.8ナノ秒
32MB範囲からのランダムバイト読み取り 7ナノ秒
4GB範囲からのランダムバイト読み取り 40ナノ秒
512項目のハッシュマップ更新 8ナノ秒
8192項目のハッシュマップ更新 9ナノ秒
500万項目のハッシュマップ更新 108ナノ秒
mmapシステムコールの実行 230ナノ秒

これらの結果から、ページ割り当てに100ガスの価格が提案されています。

ページ0が無料である理由

mmapの実行コスト(約11ガス)は、CALL系命令のベースコスト(100ガス)によってすでに十分に支払われています。 同じ理由で、ページ0は無料です。 CALLのオーバーヘッドが最初のページ割り当てをすでに支払っているためです。 これにより後方互換性も向上し、最初のページ内のアクセスのコストは現在の価格と同じかそれ以下になります。

トランザクショングローバル制限の根拠

クライアント実装者の間では、DoSの理由からガスリミットとは別にグローバル制限を強制できることへの要望があります。 例えば、RPCプロバイダーはメインネットよりもはるかに高いガスリミットで多くの並行eth_call計算を許可するように設計されている場合があります。 メモリ制限をガスリミットに暗黙的に結びつけないことで、設定ミスのベクトルが1つ減ります。

ハード制限は、メッセージコール制限ではなくトランザクショングローバル制限として指定されています。 つまり、特定のメッセージコールで割り当てられるページ数を制限する(例: 2MBまで)という合理的な代替案もありますが、実装の複雑さを大幅に改善するわけではありません。

MSIZEのセマンティクスを維持する理由

ユーザーがアドレス空間全体を線形に成長させる代わりに使用することが期待されているため、MSIZEのセマンティクスを同じに保つ理由は、線形に成長することを期待する既存のコントラクトとの後方互換性を壊さないためです。 新しい仮想アドレッシングスキームを利用する新しいコントラクトは、メモリ割り当てにMSIZEに依存すべきではありません。

32ビットアドレス制限の理由

アドレッシングに2**32バイトのハード制限が課されました。 これにより、クライアントがプロセスごとに異なる仮想メモリ制限を持つ多様なオペレーティングシステムとアーキテクチャで実行されることを考慮して、現代の64ビットコンピュータがその量の仮想アドレス空間を割り当てることが簡単になります。 これは将来的に、例えばガスリミットやクライアントのメモリサイズが大幅に増加した場合に再検討される可能性があります。

実装アプローチ

実装は2つの方法でアプローチできます。

手動実装

mmapや仮想アドレッシング機能を持たないシステム向けです。 実装はmap[page_id -> char[4096]]からのマップを維持する必要があります。 ここでpage_idは整数で、memory_address >> 12として計算されます。 ページ境界をまたぐ読み書き時には、両方のページをロードして結果を結合する必要があります。

mmap実装

mmapまたは同様の機能を持つシステム向けで、より簡単な実装です。 メモリの実際のデータを保持するために、実装は2**32バイトのメモリ領域をmmapします。 その後、メモリ操作はこのバッファに対する読み書きとして簡単に実装できます。 匿名mmapでは、オペレーティングシステムは全体のバッファを前もって割り当てず、アクセスされた時に「オンデマンド」でページを割り当てます。

実装方式 特徴
手動実装 mmapなしのシステム向け、ページIDマップを維持
mmap実装 より簡単、OSが自動的にオンデマンド割り当て

参照実装

以下はpy-evmコードベースに対するパッチ形式の参照実装です。

メモリ拡張ロジックの変更(computation.py)
diff
-        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 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 = f"Allocating page {hex(page << LOWER_BITS)}"
+                self._gas_meter.consume_gas(ALLOCATE_PAGE_COST, reason)
+                self._memory.pages[page] = True

この変更により、従来の二次関数的なメモリコスト計算が、ページベースの線形コスト計算に置き換えられます。 各ページ(4096バイト)のアクセス時に100ガスを課金し、トランザクショングローバルなページ数制限をチェックします。

トランザクショングローバルメモリ制限(frontier/computation.py)
diff
+        # implement transaction-global memory limit
+        num_pages_anchor = transaction_context.num_pages
+        try:
+            computation = cls.apply_computation(
+                state,
+                message,
+                transaction_context,
+                parent_computation=parent_computation,
+            )
+        finally:
+            # "deallocate" all the pages allocated in the child computation
+            allocated_pages = len(computation._memory.pages)
+            assert transaction_context.num_pages == num_pages_anchor + allocated_pages
+            transaction_context.num_pages = num_pages_anchor

子コンピュテーションで割り当てられたページを追跡し、完了後に「解放」することで、トランザクショングローバルな制限を実装します。

MSIZEオペコードの変更(memory.py)
diff
 def msize(computation: ComputationAPI) -> None:
-    computation.stack_push_int(len(computation._memory))
+    computation.stack_push_int(computation._memory.msize)

MSIZEはメモリバッファの長さではなく、msizeプロパティを返すように変更されます。

Memoryクラスの変更(memory.py)
diff
 class Memory(MemoryAPI):
-    __slots__ = ["_bytes"]
+    __slots__ = ("pages", "msize")

     def __init__(self) -> None:
-        self._bytes = bytearray()
+        self.memview = mmap.mmap(-1, 2**32, flags=mmap.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:
-            self._bytes = self._bytes + bytearray(size_to_extend)
+        if start_position + size >= 2**32:
+            raise VMError("Non 32-bit address")
+        end_position = start_position + size
+        self.memview[start_position : end_position] = value

Memoryクラスはmmapを使用して4GBの仮想アドレス空間を確保し、ページの追跡とmsizeプロパティを維持します。 ページ0は仕様に従い無料で初期化されます。

後方互換性

セキュリティ考慮事項セクションで対処されています。 後方互換性は壊れませんが、以前はガス切れで失敗していた一部のコントラクトが正常に完了する可能性があります。

既存のコントラクトがメモリ制限に依存しているかどうかという点については、ガスコスト以外では、既存のコントラクトはガス切れになる代わりに単に実行が完了する可能性があります。

セキュリティ

この提案に関する主なセキュリティ考慮事項は2つあります。

既存コントラクトへの影響

既存のコントラクトを壊すかどうかという問題があります。 つまり、既存のコントラクトはメモリが制限されていることに依存しているかどうかです。 ガスコスト以外では、既存のコントラクトはガス切れになる代わりに単に実行が完了する可能性があります。

メモリベースのDoS攻撃

これがクライアントに対するメモリベースのDoS攻撃を可能にするかどうかという問題があります。 これには最大メモリ使用量の分析が必要です。

現在の3000万ガスのガスリミットに基づくと、各コールスタックで256KBのメモリを割り当てるコントラクトを再帰的に呼び出すことで、合計54MBのメモリを割り当てることができます。 これは、この提案で示されている64MB制限と大きく異なりません。

セキュリティ考慮事項 説明
既存コントラクトへの影響 ガス切れの代わりに実行が完了する可能性あり
DoS攻撃耐性 現行モデルと同程度(約54MB vs 64MB制限)
トランザクショングローバル制限 コールスタック制限の変更がメモリ総量に影響しない

この提案のトランザクショングローバルメモリ制限は有用な不変条件を提供します。 これは、コールスタック制限への将来の変更が、特定のトランザクションで割り当てられる総メモリ量に影響しないことを意味します。

引用

最後に

今回は「EIP7923によるEVMメモリの線形ページベースコストモデル」についてまとめてきました! いかがだったでしょうか?

質問などがある方は以下のTwitterのDMなどからお気軽に質問してください!

他の媒体でも情報発信しているのでぜひ他も見ていってください!