EVM vs SVM: Solanが並列実行できる理由
1. 概要
Ethereum Virtual Machine(EVM)はトランザクションを順序立てて処理し、各blockは連続した操作のキューです。一方、Solana Virtual Machine(SVM)はその逆で、複数のCPU coreで毎秒数万のトランザクションを並列に実行します。この記事はその理由を説明します。SVMが魔法的に「より速い」わけではなく、そのaccountモデルがruntimeに事前に誰が何に接触するかを知らせるためです。読み終わった後、あなたはSealevelのメカニズムを理解し、Javaで並列schedulerを自分でシミュレートでき、このモデルがいつ失敗するかを知ることができます。
2. EVMの順序実行モデル
EVMはすべてのsmart contractで共有されるMerkle Patricia Trieツリーに状態全体を保存します。contract Aのincrement()関数を呼び出すトランザクションは、どのstorage slotに接触するかを事前に宣言しません。これは実行中にのみわかります。EVMはTuring完全な汎用状態機械であり、分岐(if/else)は入力データに応じてどのslotに書き込むかを決定できるためです。
contract Counter {
uint256 public count;
function increment() public {
count += 1; // 唯一のストレージスロット
}
}
countは固定のstorageスロットですが、EVM runtimeには、bytecodeを実際に実行してみるまで、この関数がこのslotに正確に接触することを知る方法はありません。bytecodeが任意だから「事前の推測」は不可能であり、クライアントのEthereum(geth、Nethermind…)はblock内のトランザクションを順序実行することを強制されます。こうすることで、後続トランザクションは常に先行トランザクションが更新した状態を見ることができます。
2.1. 競合を事前に予測できない理由
EVMのbytecodeのすべてのパスを分析して、実行前に影響を受けるストレージスロットを推測するのに十分に安価なstatic analysisメカニズムはありません。Monad や Block-STM(Aptos)のようなプロジェクトは、optimistic concurrencyで対応しています。並列に実行し、競合を検出したらrollbackして再実行します。これは「実行してから確認する」というアプローチです。SVMが行う「事前に宣言して、その後にスケジュール」するアプローチとは完全に異なります。
Block EVM(デフォルトはmainnet):
tx1 -> tx2 -> tx3 -> tx4 (順序実行、1つずつ)
3. SVM(Sealevel)の明示的なアカウントモデル
Solanは、ネットワークに送信する前に、すべてのトランザクションがtransaction message内で、読み取りまたは書き込みするaccountのリストを明示的に宣言することを強制しています。runtimeは実行してみる必要がありません。clientが明確に説明しているからです。
pub fn process_instruction(
_program_id: &Pubkey,
accounts: &[AccountInfo],
_instruction_data: &[u8],
) -> ProgramResult {
let accounts_iter = &mut accounts.iter();
let counter_account = next_account_info(accounts_iter)?;
let mut data = counter_account.try_borrow_mut_data()?;
let mut count = u64::from_le_bytes(data[0..8].try_into().unwrap());
count += 1;
data[0..8].copy_from_slice(&count.to_le_bytes());
Ok(())
}
accountsは外部から渡されるsliceです。clientはcounter_accountがどのaccountであるか、writableか読み取り専用か、transaction buildの時点で明示する必要があります。プログラムは途中で他のaccountを自動的に「検出」することはできません。まさにこの制約は窮屈に見えるかもしれませんが、Sealevelが並列スケジューリングを行うことを可能にする基盤となるものです。
3.1. トランザクション内でのwritable/readonlyの宣言
Transaction message(簡略):
instruction: increment_counter
accounts:
- counter_pda (writable)
- fee_payer (writable, signer)
- system_program (readonly)
runtimeはこのmetadataを読むだけで—bytecodeを実行する必要がなく—このトランザクションがcounter_pdaとfee_payerに書き込み、system_programを読み取ることを知ることができます。これがEVMとの本質的な違いです。
4. 競合検出と並列バッチスケジューリングのメカニズム
runtimeが各トランザクションのread-set/write-setを持ったら、並列スケジューリングの問題はおなじみのgraph coloring問題に変わります。2つのトランザクションは、それらがアクセスするaccountのセットがwrite-writeまたはwrite-read方式で交わらない場合、同時に実行できます。
public record BlockTransaction(String id, Set<String> writes, Set<String> reads) {}
writesとreadsはそれぞれ、トランザクションが変更するaccountと読み取り専用のaccountに対応します。2つのトランザクションが競合するのは、以下の場合です:
public final class SealevelScheduler {
public static boolean conflicts(BlockTransaction a, BlockTransaction b) {
return !Collections.disjoint(a.writes(), b.writes())
|| !Collections.disjoint(a.writes(), b.reads())
|| !Collections.disjoint(a.reads(), b.writes());
}
}
説明すると:
writes∩writesが空でない → 両方とも同じaccountに書き込みたい → 順序実行が必須writes∩readsが空でない → 一方は読み取り、他方は同じaccountに書き込み → 順序実行が必須reads∩readsが空でない → 問題なし、同時読み取りは排他的ロックを必要としません
4.1. Javaでのシミュレーション:バッチのグループ化と並列実行
public final class ParallelExecutor {
private final ExecutorService pool = Executors.newVirtualThreadPerTaskExecutor();
public List<BlockTransaction> nextNonConflictingBatch(
Deque<BlockTransaction> queue, Set<String> lockedAccounts) {
List<BlockTransaction> batch = new ArrayList<>();
for (BlockTransaction tx : queue) {
boolean overlaps = Stream.concat(tx.writes().stream(), tx.reads().stream())
.anyMatch(lockedAccounts::contains);
if (!overlaps) {
batch.add(tx);
lockedAccounts.addAll(tx.writes());
}
}
queue.removeAll(batch);
return batch;
}
public void runBatch(List<BlockTransaction> batch, Consumer<BlockTransaction> execute) {
List<CompletableFuture<Void>> futures = batch.stream()
.map(tx -> CompletableFuture.runAsync(() -> execute.accept(tx), pool))
.toList();
futures.forEach(CompletableFuture::join);
}
}
nextNonConflictingBatchはトランザクション キューをスキャンし、現在のlockedAccountsに接触しないトランザクションをバッチに追加し、それが書き込むaccountをロックします。runBatchはvirtual thread(Java 21)を使用してバッチ全体を並列に実行し、joinはバッチが完了するまで待機してから次のバッチを形成します。これはSealevelスケジューラーがトランザクションを「wave」ごとにグループ化する方法と同じです。
注: これは概念を説明するための簡単なシミュレーションです。実際のSealevelはより複雑なアルゴリズム(各accountごとのdependency graph、順序付きバッチだけではなく)を使用して、並列度を最大化します。
4.2. JUnit 5での競合ロジックの検証
class SealevelSchedulerTest {
@Test
void noConflictWhenAccountsDiffer() {
var tx1 = new BlockTransaction("tx1", Set.of("walletA"), Set.of());
var tx2 = new BlockTransaction("tx2", Set.of("walletB"), Set.of());
assertThat(SealevelScheduler.conflicts(tx1, tx2)).isFalse();
}
@Test
void conflictWhenWritingSameAccount() {
var tx1 = new BlockTransaction("tx1", Set.of("poolAccount"), Set.of());
var tx2 = new BlockTransaction("tx2", Set.of("poolAccount"), Set.of());
assertThat(SealevelScheduler.conflicts(tx1, tx2)).isTrue();
}
@Test
void noConflictWhenBothReading() {
var tx1 = new BlockTransaction("tx1", Set.of(), Set.of("priceOracle"));
var tx2 = new BlockTransaction("tx2", Set.of(), Set.of("priceOracle"));
assertThat(SealevelScheduler.conflicts(tx1, tx2)).isFalse();
}
}
これらの3つのテストは、read-readはロック不要、write-writeはロック必須、write-readはロック必須という原理を正確に説明しています。これはSVM並列化問題全体の基礎です。
5. EVMとSVMの比較
| 基準 | EVM | SVM |
|---|---|---|
| ステートモデル | 共有ツリー、bytecodeを通じた暗黙的アクセス | 離散的なaccount、transaction内での明示的な宣言 |
| 競合を知る時期 | 実行後(またはテスト実行後)のみ認識 | 実行前に認識、transaction metadataから |
| 実行 | block内の順序に従って順序実行 | 非交差バッチによる並列実行 |
| クロスコントラクト呼び出し | runtime時に任意に他のcontractを呼び出す | Cross-Program Invocation(CPI)は依然としてaccountの事前宣言が必要 |
| 開発者の責任 | accountセットについて考える必要がない | 正確かつ十分なaccountを宣言する必要があります。宣言不足でエラー、過剰宣言で並列度喪失 |
| 並列化の改善方向 | 楽観的並行実行(Block-STM、Monad) | dependency graphスケジューリングの最適化(Sealevel、Firedancer) |
6. Solanプログラム作成時のトレードオフと一般的なエラー
明示的なaccount宣言は並列化の源ですが、同時に2つの一般的なエラーの源でもあります。
accountの宣言不足。 プログラムのロジックがtransactionでclientがリストアップしていないaccountに書き込む必要がある場合、プログラムはruntime時に「accounts listに見つかりません」というようなエラーで失敗します。SVMはEVMのように途中でaccountを「検出」することを許可していないためです。
account宣言の過剰またはhotspotの作成。 もし多くのトランザクションが同じaccountに書き込む場合(例えば、単一のliquidityプール、グローバルカウンター)、それらは順序実行を強制されます。これはEVMの状況と同じですが、違いはあなた自身がそれを作成してしまったということです。これはEVMに慣れた開発者の典型的な思考ミスです。「1つのグローバル変数」という習慣をSolanに持ち込み、accountを適切に分割しないことです。
誤り: すべてのユーザーのトランザクションが`global_stats`に書き込まれる -> 並列度喪失
正しい: 各ユーザーは独自のaccount(user別のPDA)を持ち、定期的に集約
7. 並列化が役に立たない場合
注: アプリケーションが本質的に順序付きキューである場合(例えば、指値注文の提出順序で価格決定するオークション)、runtimeレベルでの並列化は役に立ちません。あなたは依然としてビジネスロジックを自分でserializeする必要があり、SVMは独立しているトランザクション間の部分でのみ節約できます。
同様に、集約型システム(blockchainではない)を設計していて、スループットを増やすためにSealevelモデルを「学ぶ」場合、ほとんどのタスクのread/write setが明確に分離されている場合にのみ適用する価値があります。すべてのタスクが共有テーブル/recordに触れる場合、dependency graphを管理するコストは単純な順序ロックよりも高くなります。
8. 結論
EVMが順序実行するのは、bytecodeがruntimeに事前に誰が何に接触するかを知らせていないため、競合推測はテスト実行またはrollbackによってのみ可能です。SVMは並列実行します。すべてのトランザクションが最初からread/write accountを宣言することを強制しているため、Sealevelはdependency graphを構築し、非交差バッチを形成して複数のcore上で実行できるからです。トレードオフとしてSolana開発者は自分でaccountを分離して設計し、hotspotを避ける必要があります。そうしないと、意図せずにSVMを遅い順序実行EVMに変えてしまいます。