新着:HeliusがLight Protocolを買収
Solana Virtual Machine(SVM)とは?
ブログ/基礎

Solana Virtual Machine(SVM)とは?

Developer Experience EngineerXの0xIchigoLinkedInの0xIchigoGitHubの0xIchigo
読了時間:67分

本稿の初期版をレビューしてくださった Lostin、Alessandro、Brian、Brady、Daniel Cumming の各氏に深く感謝します。 

実践的なポイント

  • SVM はトランザクション実行スタック全体を包含します。一方、EVM は明確にバイトコード実行器を指します。
  • トランザクションに対し、実行前にアクセスするアカウントの宣言を求めることで、複数の CPU コアにまたがる並列実行とローカル手数料市場が可能になります。
  • Rust ソースは rustc を通じて LLVM IR にコンパイルされ、続いて LLVM の eBPF バックエンド、具体的には Solana のフォークである sBPF によって sBPF バイトコードへ変換されます。つまり、LLVM フロントエンドを持つ任意の言語(C、C++、Zig など)で Solana プログラムを記述できます。
  • sBPF は Linux の eBPF を Solana がフォークしたもので、歴史的に追加された機能を除けば、実質的にはほぼ同じです。ただし、これらの追加機能は今後取り消される予定です。意味のある唯一の違いは、アップストリームの eBPF 関数では引数が最大 5 個であるのに対し、Solana のフォークではそれ以上を使用できる点です。
  • コンパイル済みの sBPF バイトコードは、命令と定数のセクション、および再配置テーブルを含む ELF ファイルに保存されます。リンカーは syscall 参照を決定論的な 32 ビット Murmur3 ハッシュへ解決し、移植性を確保するために内部関数を相対ジャンプとして書き換えます。 
  • BPF Loader Upgradeable はインプレースのアップグレードとデプロイに 2 アカウントモデルを使用しますが、今後の Loader V4 では、オプションの圧縮を備えた単一アカウントへ簡素化されます。すべてのバイトコードは、実行可能とマークされる前に静的検証されます。
  • トランザクションには、読み取り・書き込み権限を持つアカウントアドレスの配列、命令、署名が含まれます。この構造化された形式により、競合を検出し、競合しないトランザクションを並列にスケジュールできます。
  • TPU の Banking Stage は、競合しないトランザクションを並列にスケジュールします。Bank は AccountsDB からアカウント状態を読み込みます。BPF Loaders は、境界が設定されたメモリ領域とコンピュート予算を持つ分離された sBPF VM を用意します。成功した状態変更はアトミックにコミットされ、失敗時には完全にロールバックされます。
  • 正式な仕様は SVM ISA のみです。Alpenglow のホワイトペーパーと、Toly が当初執筆した一連の記事を除けば、単一の「SVM 仕様」は存在しません。ランタイムは Bank、スケジューラー、BPF Loaders、sBPF VM 自体の相互作用によって成立します。

はじめに

Solana Virtual Machine(SVM)は、現在のブロックチェーンで最も誤解されているシステムの一つです。オペコード実行器を明確に指す Ethereum Virtual Machine(EVM)とは異なり、SVM という用語は、Banking Stage のスケジューラーから sBPF バイトコードインタープリター自体まで、トランザクション実行パイプライン全体を包含します。この曖昧さは Solana のアーキテクチャ上の違いを反映しています。「SVM」を単独で定義する従来型の仕様はありません。最も近いのは Solana Virtual Machine Instruction Set Architecture(SVM ISA)の仕様で、sBPF バイトコードの実行方法は定義しますが、より広範なランタイムについては何も規定していません。

本稿では、Anza の Agave バリデーター実装の観点から、SVM とは何か、どのように機能するのか、そしてなぜ根本的に異なるのかを包括的に解説します。Firedancer クライアントの動作や、SVM ISA に準拠した独自の仮想マシン実装については本稿の範囲外です。

ここでは抽象的な仕様ではなく、実際のコードベースを検証します。Rust ソースコードが LLVM を経て sBPF バイトコードへコンパイルされる方法、プログラムのデプロイと検証方法、ランタイムが並列実行用の分離環境を用意する方法、トランザクションがデプロイ済みバイトコードとやり取りする方法まで、実行パイプライン全体を追います。

最初の数セクションでは、SVM の曖昧さを背景とともに整理し、その仕組みを大局的に説明します。残りは、Solana の実行レイヤーを厳密に理解したい、より技術的な読者を対象としています。

議論を呼ぶ定義

「Solana Virtual Machine」(SVM)という用語は、特にネットワーク拡張や Solana 上に構築される他のレイヤーブロックチェーンが登場して以来、コミュニティ内で激しい議論を引き起こしてきました。争点はこの用語の範囲です。SVM は低レベルの sBPF インタープリターのみを指すのでしょうか。それとも、トランザクション実行スタック全体を包含するのでしょうか。 

狭義では、SVM を EVM のオペコード実行器のような従来型の仮想マシン(VM)と同等に捉えます。より具体的には、バイトコードを解釈し JIT コンパイルする eBPF 由来の仮想マシン(以前は rBPF、現在は sBPF)です。この見方では、SVM は ALU 演算や Solana 固有のシステムコールなどの命令を処理する、サンドボックス化されたレジスタベースの実行器です。要するに、SVM は Linux eBPF の安全性モデルに着想を得つつ、ブロックチェーンインフラ向けにカスタマイズされています。これはバリデーターコード内の SVM ISA(Instruction Set Architecture)という表現とも一致し、そこでは SVM は VM レイヤーのみを意味します。

広義では、SVM を Solana バリデーターのトランザクション実行レイヤー全体と定義します。これにはバイトコードの実行だけでなく、Banking Stage のスケジューラー、コンピュートユニットの予算管理、通称 AccountsDB であるアカウントデータベースを介した状態更新など、上流のコンポーネントも含まれます。これは生のトランザクションを検証済みの状態変更へ変換する「ランタイム」です。

この曖昧さが生じるのは、Solana の公式発信で「ランタイム」と「SVM」が互換的に使われ、単一の固定された定義がないためです。Anza はこの議論に待望の明確さをもたらしました。議論の存在を明示的に認めながら、エンジニアリングに根ざした実践的で行動志向の見方を提示しています。eBPF VM を用意する Bank 主導のランタイムとして SVM を捉えることで、パイプライン全体を含むはるかに広い視点が得られ、SVM を適切に定義できます。

これは Anza の公式 SVM 仕様で形式化されています。同仕様は SVM を「トランザクション実行を担うコンポーネント」と定義し、バリデーター、不正証明、サイドカーなどで利用できるスタンドアロンライブラリとしてパッケージ化しています。

本稿では、Solana Virtual Machine を次のように定義します。

Solana バリデーター内の分離されたランタイムインターフェースおよびトランザクション処理パイプライン。Bank コンポーネントによって駆動され、命令とオンチェーンプログラムの並列実行を調整し、安全なバイトコード解釈、JIT コンパイル、リソース計測のために、カスタマイズされた eBPF ベースの仮想マシンを用意します。

SVM の概要

Solana Virtual Machine(SVM)は、ネットワーク全体のオンチェーンプログラムとやり取りするトランザクションの処理環境として機能します。コードと状態が交わるランタイムレイヤーであり、暗号署名されたトランザクションを検証済みの状態変更へ変換する実行環境です。 

SVM を本当に理解するには、まずブロックチェーンの文脈で仮想マシンが何を意味するのかを理解する必要があります。

仮想マシン

仮想マシン(VM)は、コンピューターシステムを仮想化またはエミュレートし、物理ハードウェアのように動作する分離実行環境を提供するソフトウェアです。この概念は、IBM が 1960 年代に行ったメインフレームシステムの研究から生まれ、複数のユーザーが同じ物理マシン上で異なるオペレーティングシステムを実行できるようにしました。VM には、システム VM とプロセス VM の 2 つの主要カテゴリがあります。前者は実機の代替を提供し、後者はプラットフォームに依存しない環境でプログラムを実行するよう設計されています。本稿が対象とするのはシステム仮想マシンで、以降は「仮想マシン」または単に「VM」と呼びます。

仮想マシンは、いくつかの根本的な問題を解決します。まず、ハードウェアを抽象化します。つまり、VM 向けに書かれたプログラムは、書き直すことなく、その VM をサポートするあらゆる物理ハードウェアで実行できます。Java の「一度書けば、どこでも実行できる」という理念がその代表例です。Java バイトコードは、Java Virtual Machine(JVM)がインストールされた Windows、macOS、Linux などで同じように動作します。

VM は分離性とセキュリティも保証します。各 VM インスタンスはサンドボックス内で動作するため、明示的に許可されない限り、ホストシステムのリソースや他の VM にアクセスできません。そのため、プログラムがクラッシュしたり悪意あるコードを含んでいたりしても、被害はその VM インスタンス内に限定されます。Google Cloud や AWS などのクラウドプロバイダーが顧客のワークロードを分離するために VM を使用するのは、この分離原則があるためです。

VM は予測可能な出力も提供します。基盤となるハードウェアに関係なく、同じ入力から常に同じ出力が得られる制御された環境です。この予測可能性は、デバッグ、テスト、分散システム間でのコンセンサス形成に不可欠です。

VM は非常に高い性能も発揮できます。最新の VM は Just-In-Time(JIT)コンパイルを使用して、性能上のオーバーヘッドを最小限に抑えます。JIT コンパイルは実行時に VM バイトコードをネイティブマシンコードへ変換し、移植性と前述のセキュリティ保証を維持しながら、ネイティブに近い性能を実現します。

ブロックチェーンにおける仮想マシン

ブロックチェーンは、世界中の何千台もの独立したコンピューターが信頼できないコードを実行し、同一の結果へ到達するにはどうすればよいかという独自の課題を解決するため、VM の概念を応用しました。VM は、スマートコントラクト(Solana ではプログラム)を実行し、ネットワークの状態(ネットワーク全体の全アカウント、残高、その他データの現在の状態)を管理する決定論的ランタイム環境として機能します。

トランザクションがブロックチェーンへ送信されると、VM は次の処理を担います。

  • 必要なアカウントデータをストレージから読み込む。
  • トランザクションで指定されたプログラムのバイトコードを実行する。
  • 無限ループやサービス拒否(DoS)攻撃を防ぐため、リソース消費量を計測する。
  • すべての状態変更がネットワークで事前定義されたコンセンサスルールに従っていることを検証する。
  • 更新された状態を永続ストレージ(台帳)へコミットする。

状態遷移の具体的なルールは、VM の命令セットアーキテクチャとランタイム制約によって定義されます。

SVM の仕組み

SVM は、トランザクションを安全かつ効率的に実行するために連携するサブシステムのパイプラインです。Bank は特定のスロットの実行を調整し、アカウント状態を管理し、コンセンサスルールを適用し、Banking Stage と永続ストレージ(AccountsDB)の間を調整します。各 Bank は特定のスロットにおける全アカウントの状態を表し、アクティブ(新しいトランザクションを受け付ける)、凍結(スロットが完了し、新しいトランザクションを受け付けない)、ルート化(正規チェーンの一部)という 3 つのライフサイクルを進みます。

Banking Stage は、バリデーターの Transaction Processing Unit(TPU)内でトランザクションを実行する場所です。SigVerify ステージから検証済みトランザクションを受け取ってバッファリングし、アカウントロックの競合検出を利用して並列実行をスケジュールします。Banking Stage のワーカースレッドは競合しないトランザクションのバッチを処理し、Bank の実行メソッドを呼び出してアカウントを読み込み、各命令用の sBPF VM インスタンスを用意し、プログラムのバイトコードを実行して、結果を収集します。Banking Stage は、スロット境界で Bank が凍結されるまで、競合しないトランザクションのバッチを処理し続けます。なお、バッチはエントリとは異なります。エントリは、複製とコンセンサスのために台帳へ書き込まれる、記録上のトランザクション単位です。

BPF Loaders は、デプロイ、JIT コンパイル、アップグレード、実行というプログラムのライフサイクルを管理します。命令が特定のプログラムを対象とすると、独自のメモリ領域とコンピュート予算を持つ sBPF VM が用意され、実行がプログラムのバイトコードへ引き渡されます。

sBPF VM は、プログラムのバイトコードが実際に動作するサンドボックス化された実行環境です。Linux の eBPF から派生し、11 個の汎用レジスタを持つレジスタベースのアーキテクチャを採用しています。VM は、明示的な境界と権限を持つ 5 つの異なるメモリ領域を通じてメモリ分離を強制します。また、暴走する実行を防ぐためにコンピュートユニットの消費量を計測し、暗号処理、ロギング、Cross-Program Invocations(CPI)などの特権操作にシステムコールを振り分けます。

AccountsDB は、すべてのアカウントデータが存在する永続状態レイヤーです。実行前にアカウント状態が読み込まれ、頻繁にアクセスされるアカウントのディスク読み取りを繰り返さないようキャッシュが活用されます。実行に成功すると、更新内容が AccountsDB へコミットされます。失敗した場合、すべての状態変更がアトミックにロールバックされます。

これらのコンポーネントが一体となって、分離され再利用可能な実行エンジンである SVM を形成します。

SVM の特長:事前のアカウント宣言

SVM を特徴づけるアーキテクチャ上の決定は、すべてのトランザクションが、実行開始前に読み書きするアカウントを明示的に宣言しなければならないことです。トランザクション形式自体に組み込まれたこの単純な要件によって、Solana を際立たせる 2 つの革新的な機能、並列実行とローカル手数料市場が実現します。

並列実行(Sealevel)

トランザクションを 1 件ずつ順番に処理し、各処理の完了後に次へ進む Ethereum Virtual Machine(EVM)とは異なり、SVM は複数の CPU コアで複数のトランザクションを同時に実行し、水平スケーリングを実現します。この並列化が可能なのは、すべての Solana トランザクションが実行開始前に、読み書きするアカウントを明示的に宣言するためです。 

トランザクションが読み書きするアカウントを宣言することで、ランタイムはアカウントの依存関係を分析して競合を検出し、競合しないトランザクションをスケジュールできます。

  • 完全に異なるアカウントへアクセスするトランザクションは、調整のオーバーヘッドなしで並列実行できます。
  • 同じアカウントから読み取るだけのトランザクションも、読み取り同士は競合しないため並列実行できます。
  • 同じアカウントへ書き込もうとするトランザクションは、競合状態を防ぎ、状態の整合性を確保するため順次実行されます。 

ローカル手数料市場

ランタイムは実行前に各トランザクションがアクセスするアカウントを正確に把握できるため、ネットワーク全体でグローバルに競争するのではなく、特定のアカウントに手数料を局所化できます。これはローカル手数料市場と呼ばれる概念です。 

Ethereum やその他の EVM チェーンでは、すべてのトランザクションが単一のグローバル手数料市場で競争します。友人への ETH 送金、NFT のミント、Uniswap での取引は、すべて同じブロックスペースを奪い合います。ある領域で需要が急増すると、まったく別の操作をしようとしている場合でも、全員の手数料が上昇します。 

Solana では、同じアカウントへアクセスするトランザクションだけが互いに競争します。2 つのアカウント間で SOL を送金するユーザーが、同時に発生している人気 NFT のミントを気にする必要はありません。トランザクションの優先手数料は、アカウントの競合のみで決まります。この局所化により、Solana のトランザクションは高負荷時でも低価格を維持できます。

たとえば 10 月 10 日、暗号資産市場では史上最大の清算イベントが発生しました。記録的なアクティビティ急増にもかかわらず、Solana のトランザクションは比較的低価格を維持しました。トランザクション手数料の中央値は 0.007 ドル、平均は一時 0.10 ドルに達し、上位 1% のトランザクションでもピークは 1.00 ドルをわずかに上回る程度でした。同じ期間に、Ethereum と Arbitrum の手数料中央値はどちらも 100 ドルを超え、Base の手数料は 3 ドル超でピークに達しました。

パラダイムシフト

項目EVMSVM
アーキテクチャスタックベース VMレジスタベース VM(eBPF 由来)
実行逐次並列(競合検出)
手数料市場グローバルローカル(アカウント単位の競合)
アカウント宣言事前宣言は不要事前宣言が必要
ISA約 140 個のオペコード、スタック操作約 100 個のオペコード、RISC 風レジスタ
JIT コンパイル任意(クライアント依存)標準(ネイティブ性能)
状態モデルコントラクトストレージ手数料フラットなアカウントデータベース
言語Solidity/Vyper → EVM バイトコードRust/C/C++ → LLVM → sBPF

SVM は、ブロックチェーン実行に対する根本的に異なるアプローチです。Bitcoin はプログラム可能な通貨をもたらしました。Ethereum は汎用スマートコントラクトと任意のオンチェーン実行をもたらしました。しかし、どちらも逐次実行とグローバル手数料市場に制約されています。これらのアーキテクチャ上の決定は、スループットとコストの両方に根本的な限界をもたらします。

SVM は従来の制約から離れ、プログラマビリティを犠牲にしたり、ユーザーを法外に高額な手数料オークションへ追い込んだりすることなく、高いスループットを処理できるネットワークを実現します。アカウントの事前宣言を必須にする決定は単純ながら強力で、CPU コア間の並列実行を可能にし、手数料をアカウント単位の市場へ局所化します。

もちろん、Solana が他のブロックチェーンと比べて提供する最適化はこれだけではありません。Solana のモットーである帯域幅を増やし、レイテンシを減らすという姿勢と、Internet Capital Marketsの実現に対する徹底的なこだわりにより、高スループットのネットワークを育てるさまざまな性能最適化、設計上の選択、実装が生まれました。

以降では、Rust ソースコードがバイトコードへコンパイルされる方法、そのバイトコードがデプロイ、検証される方法、そして厳格な決定論とセキュリティ保証を維持しながら何千ものプログラムを安全に並列実行するため、ランタイムが分離実行環境を用意する方法を詳しく見ていきます。 

Rust ソースから sBPF バイトコードへ:コンパイルパイプライン

Rust

Rust は Solana プログラム開発の共通言語です。Anchor のようなフレームワークは、開発者が安全なプログラムを効率よく構築するための堅牢で明確な方針を持つ手法を提供します。solana_program は、すべてのオンチェーンプログラムの基盤ライブラリとして想定されています。最近では、高度に最適化された依存関係ゼロのライブラリである Pinocchio が、ネイティブ Solana プログラムを構築する開発者に好まれる選択肢となっています。 

使用するフレームワークやライブラリに関係なく、すべてのプログラムには、呼び出し時にランタイムが実行するエントリーポイントがあります。solana_program のマクロ entrypoint は、プログラム実行の開始に必要な標準ボイラープレートを生成します。具体的には、入力のデシリアライズ、グローバルアロケーター、panic ハンドラーの設定です。Pinocchio も同様に機能するエントリーポイントマクロをエクスポートしますが、エントリーポイントをヒープアロケーターや panic ハンドラーの設定から分離し、開発者へより多くの選択肢を提供します。 

基本的なプログラム構造 

プログラムは、コードを実行できるアカウントの一種です。より具体的には、BPF Loader が所有するアカウントに sBPF バイトコードのバイナリを保存し、一意の公開鍵を持つ実行可能アカウントです。プログラムは設計上ステートレスです。永続データはすべて別のアカウントにあり、プログラムは呼び出された際にそれらを読み書きできます。

SVM は、すべてのプログラムに 3 つの入力を受け取るエントリーポイントという特定の骨格を求めます。

  • Program ID:プログラム自体のアドレスで、所有権などの自己参照チェックに使用されます。
  • Accounts:アカウントメタデータ(公開鍵、lamport 残高、データバッファ、所有者、フラグ)の配列です。プログラムが読み書きする必要のある「状態」です。
  • Instruction Data:トランザクションから渡される任意データのバイトスライスです。

プログラムは、エントリーポイントを通じてこれらの入力を処理し、関連する書き込み可能アカウントを変更し、ログやイベントを出力したうえで、すべての処理に成功したかを示す成功ステータスを返す必要があります。これは process_instruction 関数に集約されます。

solana_program クレートを使用して Rust で書いた単純なプログラムは次のようになります。

simple_rust_program.rs
use solana_program::{
    account_info::AccountInfo,
    entrypoint,
    entrypoint::ProgramResult,
    msg,
    pubkey::Pubkey,
};

entrypoint!(process_instruction);

pub fn process_instruction(
    _program_id: &Pubkey,
    _accounts: &[AccountInfo],
    _instruction_data: &[u8],
) -> ProgramResult {
    msg!("Hello, Solana!");

    Ok(())
}

内部では、これらすべてが SVM の Application Binary Interface(ABI)によって定義されています。これについては後ほど説明します。

Rust コンパイラーと LLVM IR

Rust は他の多くのプログラミング言語と同様、アセンブリ上に構築された二次的な抽象化です。人間がハードウェアとの細かなやり取りを逐一管理せず、安全で並行性があり、読みやすいコードを書けるよう設計されています。しかし、コンピューターは Rust を理解せず、そもそも高水準言語を直接理解しません。 

コンピューターが理解するのは、特定のアーキテクチャまたは仮想マシン向けに調整されたバイナリ命令であるマシンコードです。すべてのプログラムは最終的にバイナリコードへ変換され、この変換自体もコンピューターが行います。コンパイルとは、高水準の抽象化を取り除き、効率を最適化し、実行可能なバイトコードを出力する複数段階の変換プロセスです。

rustc は Rust の公式コンパイラーです。多くの開発者は通常、rustc を直接操作せず、Rust のパッケージマネージャーである Cargo を介して呼び出します。rustc は、実行可能なバイトコードを出力する前に、Rust ソースコードを主に 3 つの段階へ通します。各段階で抽象化を取り除き、安全性を適用し、次の変換に向けてコードを準備します。

  • 構文解析と展開
  • MIR(Mid-level Intermediate Representation)
  • LLVM IR(Low Level Virtual Machine Intermediate Representation)

構文解析と展開

コンパイラーは、拡張子 .rs で示される Rust ファイルをプレーンテキストとして読み取ります。字句解析と呼ばれるプロセスで、特定のトークン(use、fn、None、impl、&[u8] など)を探します。字句トークン化とは、テキストを特定カテゴリ(識別子、演算子、区切り文字、リテラル、キーワードなど)に属する意味のある字句トークンへ変換するプロセスです。 

rustc はこれらの字句トークンを、Abstract Syntax Tree(AST)と呼ばれるデータ構造へ変換します。この木構造は、Rust ソースコードの入れ子になった階層構造を表します。つまり、関数にはブロックが含まれ、ブロックには式が含まれ、式には演算子が含まれるという構造です。AST はまだ高水準ですが、ソースコードとその基礎となるロジックを忠実に表現します。

AST の構築後、コンパイラーはいくつかの主要な変換を行います。

  • マクロ展開:entrypoint! や println! などのマクロを生の Rust コードへ展開し、すべてのマクロを通常の AST ノードへ変換します。
  • 低水準化:コードを読みやすくする高水準の省略構文を、より原始的な形式へ書き換える処理です。たとえば for ループは、手動反復を伴う loop へ変換されます。この結果が High-Level Intermediate Representation(HIR)です。
  • 借用チェックと安全性分析:Rust は HIR に対して型チェック、トレイト解決、型推論を行います。この結果が Typed High-Level Intermediate Representation(THIR)です。

この段階で、コンパイラーは unsafe コードを処理します。unsafe コードにより、開発者は Rust の安全性保証を回避する操作(生ポインターの参照外し、外部関数の呼び出し、unsafe トレイトの実装など)を実行できます。これは低レベル制御のために意図された回避手段で、特定のルールを緩和しつつ、Rust のセマンティクスに従ってコンパイルできることは引き続き求めます。これは Solana プログラムにとって重要です。たとえば Pinocchio の Account 用ラッパー構造体のように、ゼロコピーのアカウントデシリアライズなど性能が重要な処理で、unsafe コードを限定的に使用できるためです。    

unsafe コードは、字句解析後の AST 構築時に unsafe トークンが認識され、特殊なノードとしてマークされることで最初に特定されます。その後、AST 展開後の型チェックと借用チェックで処理されます。コンパイラーは unsafe 操作が unsafe コンテキスト内に限定されていることを確認し、そうでない場合はエラー(「unsafe の外部では生ポインターを参照解除できない」など)を報告します。一方、unsafe コードがメモリを破壊したり誤管理したりしていないかまでは検査しません。

この段階の終了時には、展開済み AST は THIR となり、検証され低水準化された Rust ソースコードの表現になります。

MIR

次に THIR は Mid-Level Intermediate Representation(MIR)へ低水準化されます。MIR は、ソースコードを簡略化した制御フローグラフ(CFG)として表す Rust 中心の形式です。Rust 固有の糖衣構文や複雑な構造(パターンマッチ、トレイト、クロージャなど)はすべて、代入と分岐を含む基本ブロックとして表現されます。これらの基本ブロックはジャンプ、より具体的には Goto と分岐によって接続されるため、プログラムの流れを容易に推論できます。 

MIR は絶対に必要なわけではありません。コンパイラーは THIR を直接 LLVM IR へ低水準化することもできます。しかし MIR は Rust を認識するレイヤーを提供し、LLVM の汎用最適化が適用される前に、コンパイラーが Rust 固有のルールを適用して最適化できるようにします。そのため MIR は、LLVM には高水準すぎ、THIR には低水準すぎる次のようなチェックと変換に最適です。

  • 借用チェック:最初のセマンティックチェックは THIR の型分析中に行われます。一方、MIR の簡略化された CFG により、所有権、借用、ライフタイムの全ルールを適用する完全で精密な借用チェックが可能になります。
  • Move と Drop のチェック:すべての値が Rust のメモリ安全性保証に従って move、drop されることを確認し、解放後使用エラーを防ぎます。
  • 初期化分析:すべての変数が使用前に初期化されていることを確認します。
  • インライン化と初期最適化:小さな関数のインライン化、算術式の簡略化、到達不能コードの除去を行えます。

たとえば先ほどの Rust サンプルプログラムでは msg!(“Hello, Solana!”) を呼び出しました。これは solana-program クレートで定義されたマクロで、静的文字列のような単一式の場合、マクロ展開段階で sol_log($msg) の直接呼び出しへ展開されます。ここで $msg は式です。sol_log syscall は文字列データへのポインターとその長さを受け取り、フォーマットのオーバーヘッドなしで SVM の出力へ記録します。MIR では次のように簡略化される場合があります。

コード
bb0: {
  _0 = const "Hello, Solana!"; // Constant string allocation
  _1 = len(_0); // Compute length
  sol_log(move _0, move _1); // Syscall invocation with explicit moves for ownership
  return = Ok(());
}

ここでは、

  • _0 = const “Hello, Solana!”;—MIR は中間値用の一時変数(_0)を導入します。文字列は読み取り専用データに割り当てられた定数スライスとして扱われます。
  • _1 = len(_0);—MIR は、定数畳み込みの候補としてスライス上の単純な長さ取得処理を明示します。
  • sol_log(move _0, move _1);—syscall の呼び出しで、レジスタを読み込み syscall ID を呼び出す sBPF 命令列へ変換されます。後のセクションで正確な意味を詳しく説明しますが、重要なのは、これらの move が Rust の所有権セマンティクスに結びつき、コンパイル時に適用されることです。
  • Return = Ok(());—ターミネーターでブロックを終了し、SVM に成功を通知します。

Rust のセマンティクスに重点を置く MIR は、非効率性を発見し、CU コストの高いパターンをデバッグするのに最適です。たとえば、ログに動的文字列が含まれる場合、MIR は最適化可能な追加の割り当てやループを特定することがあります。開発者は cargo rustc -- -Z dump-mir=all コマンドで MIR をダンプできます。

MIR は、最終的に LLVM IR へ低水準化する前に、コードが意味的に正しく、最適化され、Rust 固有のルールがすべて取り除かれていることを保証します。

これは一般にコード生成フェーズと呼ばれます。必ずしも LLVM とは限りませんが、Rust のコード生成といえば LLVM が一般的で、多くの人が想定するものです。Rust コンパイラーには GCC と Cranelift のバックエンドも同梱され、それぞれ GIMPLE と CLIF を出力します。本稿では Solana の文脈で LLVM IR に焦点を当てますが、Rust 全般で常にそうであるとは限りません。

LLVM IR

LLVM は、もともと「Low Level Virtual Machine」を意味し、モジュール式のコンパイラーフレームワークを指します。LLVM は単一のコンパイラーではなく、コンパイラー、オプティマイザー、コードジェネレーターを構築するための再利用可能なコンポーネント群です。多くの言語(Rust、C、C++、Julia、Swift、Brainfuck、Zig など)が、x86 CPU から仮想 ISA まで多様なアーキテクチャを対象にできる LLVM の能力を活用しています。 

rustc は MIR を LLVM IR(Low Level Virtual Machine Intermediate Representation)へ変換します。これは Rust のセマンティクスと、最終的に Solana へデプロイされるバイトコードをつなぐ橋です。明示的なメモリ割り当て(alloca)、ストア、ロード、関数呼び出しを持ち、マシンコードにかなり近い形式です。所有権、ライフタイム、トレイトという概念はありません。これらの Rust の抽象化はすでに展開され、存在しなくなっているためです。それ以前の段階で得られた保証は、その後も維持されます。

この段階では、次のようなさまざまな最適化が適用されます。

  • 定数畳み込み(コンパイル時に定数を評価)。
  • インライン化(呼び出しを関数本体で置換)。
  • デッドコード除去(結果に影響しない命令を削除)。
  • ループ展開とベクトル化(ループを高速に実行できるよう書き換え)。

したがって、LLVM は次のものを提供します。

  • LLVM IR:移植可能なアセンブリ風の中間形式。
  • 最適化パス:LLVM IR の作成に、各変数が一度だけ代入されることを保証する Static Single Assignment(SSA)を使用し、インライン化やデッドコード除去などの最適化を可能にします。
  • コードジェネレーター:LLVM IR を実際のマシンコード(x86_64、ARM、WebAssembly、eBPF など)へ低水準化するターゲットです。

Rust プログラムは通常、x86_64 や ARM などのハードウェアターゲット向けにコンパイルされます。しかし、Solana プログラムはハードウェア上で直接動作せず、Solana Virtual Machine 内で動作します。そのため LLVM バックエンドは LLVM IR を BPF バイトコードへ低水準化し、Solana 上では sBPF バイトコードになります。sBPF は、非決定論的な機能を削除し、Solana 固有の syscall を導入した eBPF のフォークです。

Rust は Solana プログラム開発の共通言語ですが、LLVM の BPF バックエンドをターゲットにできる任意の言語(C、Nim、Swift、Zig など)を使用できます。

eBPF

LLVM IR は、Solana のランタイムの基盤となるレジスタベース ISA、eBPF へ低水準化されます。eBPF(Extended Berkeley Packet Filter)は Berkeley Packet Filter(BPF)に由来します。BPF は 1992 年、Steven McCanne と Van Jacobson が Lawrence Berkeley Laboratory に在籍していた際、Berkeley Software Distribution(BSD)Unix システム向けに開発しました。BPF は基本的に、修飾子を活用し、データをコピーせずにオペレーティングシステムレベルでネットワークパケットを取得、フィルタリングできるネットワークタップおよびパケットフィルターです。 

その後 eBPF は発展(拡張)し、Linux カーネル内の汎用サンドボックス VM になりました。その可能性は、JavaScript が Web 開発にもたらしたものに似ています。カーネル向けの安全なスクリプトエンジンです。eBPF により、開発者は性能監視、オブザーバビリティ、セキュリティ、ネットワーキングなどの用途に向け、制約された命令セットを持つ小規模な検証済みプログラムを Linux カーネル内で直接実行できます。

これが重要なのは、開発者が次の利点を得られるためです。

  • サンドボックス実行:eBPF プログラムはカーネル内の制限された仮想マシンで動作するため、カーネルメモリをクラッシュさせたり破壊したりできません。
  • 安全性保証:eBPF バイトコードは読み込み前に静的検証され、不正なメモリアクセス、範囲外へのジャンプ、その他の特権操作が発生しないことを確認します。これにより、実行時オーバーヘッドなしで安全性を確保します。
  • 効率性:eBPF は EVM のようなスタックベースではなくレジスタベースで、軽量な設計(完全な OS のオーバーヘッドがない)により、マシンコードへ JIT コンパイルしてネイティブに近い速度を実現できます。
  • 柔軟性:eBPF は syscall とも呼ばれるシステムコールを公開します。これは実質的にカーネル機能へのフックです。命令セットを再設計せず、新しい機能で syscall を拡張できます。

Solana には、バリデーターセット全体で信頼できないプログラムを実行するための、決定論的で安全かつ高性能な VM が必要でした。eBPF は実績ある安全性モデル、何千もの軽量プログラムを実行するために設計された移植可能で効率的な ISA、性能向上のための JIT サポートを提供します。そのため Solana は、まったく新しい VM を発明するのではなく、eBPF をフォークして sBPF を作成しました。

sBPF 

当初、Solana Labs は Quentin Monnet による rBPF をフォークして Solana 版の rBPF を作成し、特定のプログラムと入力を実行した際に、すべてのバリデーターが完全に同じ結果を生成するバイトコード形式を持てるようにしました。 

Solana のコンセンサスには決定論的実行と制限されたリソース使用が必要だったため、eBPF のフォークが必要だと考えられていました。eBPF 自体も決定論的ですが、Solana には追加の保証とブロックチェーン固有の機能が必要でした。

  • 固定された命令タイミングとコスト。
  • ユーザー空間での実行。
  • 決定論的ランタイム。

特筆すべき点として、カーネルではなくユーザー空間で動作するよう設計されており、カーネル権限や変更が不要です。これにより、root アクセスやカスタムカーネルモジュールなしで、さまざまな OS 環境へデプロイできます。ユーザー空間は、移植性、テスト、容易なデプロイという点で Solana にとって実用的な選択です。ユーザー空間で動作しても、JIT はネイティブに近い性能を実現します。さらに、カーネルへアクセスせずにテストやファジングも行えます。

rBPF は現在使用されていません。Anza の設立時に rBPF をフォークし、sBPF(Solana Berkeley Packet Filter)を作成しました。Solana Labs が所有する rBPF の GitHub リポジトリは、2025 年 1 月 10 日にアーカイブされました。

SVM ISA

SVM ISA(Solana Virtual Machine Instruction Set Architecture)は、Solana 互換 VM(Agave の sBPF や Firedancer の再実装など)がプログラムを実行する方法を定義する中核仕様です。VM 自体ではなく、さまざまな SVM 実装間の一貫性とプロトコル準拠を保証する標準または契約です。eBPF に安全性と決定論の制約を課し、カーネル中心の機能を削除しつつ、ブロックチェーン固有の機能を追加するのが SVM ISA です。

ISA は、レジスタ、命令エンコーディング、オペコード、クラス、検証ルール、panic 条件、Application Binary Interface(ABI)を規定します。SVM ISA の変更は、この命令セットの制御された進化を支え、バリデーター間での決定論的実行を保証するため、SIMD を通じて実装する必要があります。

レジスタ

レジスタは、命令の実行中に数値やアドレスを保持する VM 内部の小さな記憶領域で、作業台の変数やラベル付きの箱に似ています。SVM ISA は、11 個の汎用レジスタ(R0-R10)と非表示のプログラムカウンターを持つ 64 ビットレジスタアーキテクチャを定義します。レジスタは整数とアドレスに対して 64 ビット幅を持ち、大きな値やポインターを効率的に扱えます。R0 は関数の戻り値を保持し、R1-R5 はパラメーターのように最初の 5 個の関数引数を渡します。R6-R9 は呼び出し先保存レジスタで、関数呼び出し後も保持されます。R10 は現在のスタックフレームを示す読み取り専用フレームポインターです。非表示のプログラムカウンターは実行を追跡し、次に実行する命令を示します。

命令

命令とは、「この 2 つの数値を加算する」「コードのこの行へジャンプする」など、VM が実行できる単一の操作です。x86 のように数千のオペコードを持つ CISC アーキテクチャに対し、約 100 個のオペコードによる RISC 風の設計を採用しているため、検証が速く、JIT コンパイルも効率的です。

命令は、次の構造を持つ Little Endian 形式の 64 ビット値としてエンコードされます。

  • opcode:8 ビット
  • dst_reg:4 ビット
  • src_reg:4 ビット
  • offset:16 ビット(符号付き)
  • immediate:32 ビット(符号付き)

opcode は実行内容、dst_reg は結果の格納先、src_reg は入力元、offset は参照するメモリオフセットを示し、immediate は命令に含められる追加の定数です。

lddw(load double word)は、完全な 64 ビット即値をサポートするために 2 つの 64 ビットスロットを占有する、唯一のワイド命令です。 

命令は、メモリ操作、算術または論理操作、条件付き・無条件分岐、関数の呼び出しと戻り、エンディアン変換などのクラスに分類されます。

メモリ領域

ISA は 5 つのメモリ領域を定義し、それぞれについてプログラムが読み書きできる範囲([addr, addr+len])を明示します。

  • プログラムコード:コンパイルされた命令そのもの(読み取り + 実行)。
  • スタック:関数用の一時作業領域(読み取り + 書き込み、通常はフレームあたり 4KB)。
  • ヒープ:プログラムが要求できる動的メモリ(読み取り + 書き込み)。
  • 入力データ:トランザクションとともに渡される読み取り専用バイト。
  • 読み取り専用データ:定数と変更不可能な値。

プログラムには事前定義された仮想メモリマップがあります。コンパイルバージョンに応じてプログラムコードはアドレス 0x000000000 または 0x100000000 から、スタックフレームは 0x200000000 から、ヒープは 0x300000000 から、入力データは 0x400000000 から始まります。

検証器

検証器は実行前に静的解析を行います。プログラムを実行せず、考えられるすべてのコードパスを調査し、実行時ではなく読み込み時に安全性を保証します。確認項目は次のとおりです。

  • 未知または未対応の命令がないこと。
  • すべてのジャンプ先が有効な命令境界にあり、後方ジャンプが処理されること。
  • 到達不能なコードパスがないこと。
  • 関数呼び出し深度の上限が適用されること。
  • ゼロによる除算または剰余演算が静的に拒否されること。
  • プログラムが最大サイズ制限以内に収まること。

検証器は有用ですが、開発者が予期しない動作を持ち込むことまでは防げません。たとえば、Solana プログラム内に解放後使用やバッファオーバーフローのエラーを持ち込むことは依然として可能です。 

Panic 条件

panic 条件は、SVM ISA が定義する実行時エラーケースの一覧です。次のものが含まれます。

  • 無効または未対応の命令。
  • ゼロによる除算または剰余演算。
  • 範囲外のメモリアクセス。
  • 異なるメモリ領域に対する無効なメモリアクセス(権限違反)。
  • スタックオーバーフロー。
  • 呼び出し深度の超過。
  • 許可された命令数の上限超過。
  • プログラムがエラーコードを返した。

ABI

Application Binary Interface(ABI)は、Solana プログラムと SVM の間の形式上の契約です。先の基本的なプログラム構造のセクションでは Rust での動作(3 つの入力を持つ process_instruction)を示しましたが、ABI はそれらの入出力がメモリ上でどのように表現されるかを規定し、すべてのバリデーターがプログラムを決定論的に実行できるようにします。

大まかに言うと、ABI はエントリーポイント規約、呼び出し規約とレジスタ、メモリレイアウトの 3 つを定義します。

すべての Solana プログラムは、エントリーポイント関数を公開しなければなりません。ローダーはプログラム入力を、プログラム ID、アカウント配列、命令データという正規の順序で VM のメモリ空間へシリアライズします。その後 VM は、プログラムのエントリーポイントでこれらの領域へのポインターを渡します。

最初の 5 つのレジスタ(R1-R5)はエントリーポイント引数用に予約され、戻り値レジスタ(R0)はプログラムの終了コードを保持します。終了コード 0 は成功と見なされ、0 以外の値は特定の InstructionError に対応する失敗です。これにより、すべてのプログラムが一貫した方法でステータスコードを返します。また、最初の 5 個を超えるパラメーターはスタック上で渡されます。R6-R9 は呼び出し先保存規約に従うため、関数がこれらの値を使用する場合は保持する必要があります。

アカウントとデータは、バイトスライスとして VM の線形メモリにシリアライズされます。プログラムは、それらをより高レベルな Rust 型(AccountInfo、Pubkey など)にデシリアライズする必要があります。ABI は厳密な境界を適用し、プログラムが割り当てられた領域外のメモリにアクセスできないようにします。

これらのルールが一体となって、ABI は高レベルの開発者体験と低レベルの ISA を結び付ける「接着剤」の役割を果たします。単純な Rust 関数シグネチャが、正しいレジスタ使用、メモリレイアウト、戻りコードへとコンパイルされ、すべてのバリデータが特定のプログラムを毎回まったく同じように解釈できるようにします。

システムコール

ISA は意図的に最小限に設計されており、組み込みのアカウントや状態はありません。また、ロギング、ハッシュ化、クロスプログラム呼び出しなどの高レベル機能も直接提供しません。代わりに、プログラムが外部とやり取りできるよう VM に組み込まれた特別な関数であるシステムコールを公開します。これらの呼び出しは一般に syscalls と呼ばれます。

Syscalls は、VM が提供する API と考えることができます。各プログラムが特定の暗号プリミティブやアカウントロジックを再実装する代わりに、syscalls はすべてのバリデータで決定論的に動作することが保証された、安全で標準化された操作を公開します。

代表的な syscall のカテゴリは次のとおりです。

  • ロギングとデバッグ(たとえば、sol_log syscall は UTF-8 文字列をプログラムログに書き込み、内部では msg! によって使用されます)。
  • クロスプログラム呼び出し(CPI)(たとえば、sol_invoke_signed を使用すると、アカウントと命令データを渡して別のオンチェーンプログラムを呼び出せます。これは Solana のコンポーザビリティに不可欠です)。
  • 暗号処理(たとえば、sol_sha256、sol_keccak256、sol_ed25519_verify syscall は、開発者が独自に実装することなく、決定論的で高速な暗号プリミティブを追加します)。
  • メモリとアカウントのユーティリティ(アカウントデータの借用、メモリの再割り当て、プログラム所有のヒープ割り当てを扱うヘルパーを公開する syscall です)。
  • コンピュート予算と計測(各 syscall は CU を消費し、ランタイムの計測システムによって適用されます)。

Syscalls は、一意のハッシュ識別子を持つ特別な CALL_IMM 命令を使用して呼び出されます。プログラムが syscall を呼び出すと、sBPF VM は実行をトラップし、syscall レジストリでハッシュを検索して、特権ランタイムコードで動作するネイティブ実装へディスパッチします。Syscalls はランタイム状態にアクセスできるサンドボックス外で実行されるため、プログラム内の通常の関数呼び出しとはまったく異なります。

Syscalls は通常の関数と同じ ABI に従い、最初の 5 つの引数がレジスタ R1 から R5 に渡され、戻り値は R0 に格納されます。各 syscall には固定のコンピュートユニットコストがあり、リソース消費の決定性が保証されます。たとえば、secp256k1_recover syscall のすべての呼び出しは 25,000 CU を消費します。

Syscalls は制御されたセキュリティ境界を形成します。各 syscall は、特権操作を実行する前に入力を検証し、関連する権限を確認します。たとえば、クロスプログラム呼び出し(CPI)の syscall は、渡されるアカウントについて呼び出し元が適切な権限を持っていることを検証します。

新しい syscall は ISA 自体を変更せず、フィーチャーゲートを通じて追加できます。これにより Solana は、既存プログラムとの後方互換性を維持しながら、新しい暗号プリミティブのサポートを含む VM 機能を拡張できます。  

プログラムバイナリ

コンパイルの最後には、Rust ソースから LLVM IR、eBPF、sBPF、そして SVM ISA への準拠に至るすべての段階から、プログラムバイナリという単一の出力が生成されます。実際に Solana にデプロイされるのはこのバイナリです。 

ELF

Solana プログラムは、Unix 系システムで広く使用されている標準バイナリ形式である Executable and Linkable Format(ELF)ファイルにコンパイルされます。ELF 形式は、プラットフォーム非依存性を維持しながら、特定のプログラムの実行に VM が必要とするすべてをパッケージ化するコンテナとして機能します。

ELF ファイルには通常、次のセクションが含まれます。

  • バイトコードセクション:コンパイル済みの sBPF 命令を .text セクションに格納します。
  • 読み取り専用データセクション:定数、静的文字列、不変値を .rodata セクションに保持します。
  • BSS およびデータセクション:グローバルまたは静的な可変変数を、それぞれ .bss および .data セクションに格納します。ただし、Solana では可変データは許可されません。つまり、ELF は .rodata を持てますが、BSS およびデータセクションは持てません。 
  • シンボルテーブルとリロケーションテーブル:ロード時に関数呼び出し、syscalls、メモリ参照を解決する方法を定義します。シンボルには .symtab および .strtab セクション、リロケーションエントリには .rel.dyn および .rela.dyn セクションを使用します。

各 ELF ファイルには、アーキテクチャ、命令幅(64 ビット)、エンディアン(リトルエンディアン)、エントリポイントアドレスを記述するヘッダーも含まれます。

リンクとリロケーション

コンパイラ出力を単一の実行可能な ELF ファイルに変換するプロセスには、最後のコンポーネントであるリンカーが関与します。リンカーは、複数のコンパイル済みコード単位を一つのまとまったバイナリに結合します。また、コンパイラによって未解決のまま残されたすべてのシンボリック参照(プレースホルダー)も解決します。 たとえば、

  • プログラムが sol_log などの関数を呼び出す際、コンパイラはその関数がメモリ内のどこにあるかを認識していないため、プレースホルダーを使用します。
  • リンカーは、このシンボリック参照を syscall 固有のハッシュ識別子(決定論的な 32 ビット Murmur3 ハッシュ)に置き換えます。
  • 同様に、内部関数間の呼び出しは、.text セクション内の命令オフセットへの相対ジャンプとして書き換えられます。

シンボリック参照を具体的なアドレスまたはハッシュ化された syscall ID に書き換えるこのプロセスを、リロケーションと呼びます。ただし、リロケーションは根本的な要件というより、初期ツールの構築方法に由来する部分が大きい点には注意が必要です。実際、デプロイプロセスを簡素化するため、将来のツールチェーンではリロケーションを完全に削除する計画があります。

絶対メモリアドレスやシステム固有のシンボルを埋め込まず、同じ ELF バイナリがすべてのバリデータで同一に動作することを保証するために、このリロケーション手順が必要です。 

さらに、これらのリロケーションがすでに適用されたバイトコードはメモリにキャッシュされるため、それ以降のすべての実行では、リロケーションを再処理せずに更新済みバイトコードを使用します。

リンカーが完全にリロケーションされた ELF ファイルを生成すると、プログラムをデプロイできる状態になります。最終的なバイナリには次の特性があります。

  • ポータブル:どのバリデータまたは SVM 実装でも同一に動作します。
  • 決定論的:非決定論的な syscall や OS 依存関係を含みません。
  • 自己完結型:実行に必要なすべてのバイトコードとメタデータを保持します。

バイトコードを Solana にアップロードする仕組み

Solana プログラムが有効な ELF ファイルにコンパイルおよびリンクされたら、次はバリデータが実行できるようブロックチェーンにアップロードします。プログラムのデプロイと呼ばれるこのプロセスには、BPF Loader、アカウントモデル、バイトコード検証、状態管理という、連携して動作する複数のコンポーネントが関与します。

BPF Loader プログラム

BPF Loader は、ELF ファイルを検証およびリロケーションし、実行可能としてマークするネイティブプログラムです。基本的に、デプロイされたプログラムのライフサイクルを管理し、アカウントを初期化する命令の処理、バイトコードの書き込み、プログラムのデプロイ、アップグレードの処理を行います。

Solana のローダーは複数回にわたって進化し、各バージョンで以前のものが改善されてきました。

  • BPF Loader:静的でアップグレード不可能なプログラム向けの元のローダーです。現在はサポートされていません。 
  • BPF Loader V2:管理命令を持たない簡素化されたローダーです。
  • BPF Loader Upgradeable:プログラムのアップグレード機能を導入した現行のローダーです。
  • BPF Loader V4:デプロイ機能を改善した最新バージョンで、現在の 2 アカウントモデルを 1 アカウントモデルに簡素化します。

デプロイアーキテクチャ:アカウントモデル

現在のアカウントモデル

現在のローダーは、プログラムロジックとプログラムデータを分離するため、2 アカウントアーキテクチャを使用します。そのため、各プログラムには Program アカウントと ProgramData アカウントの 2 つがあります。

Program アカウントは約 36 バイトの小さなアカウントで、メタデータを保持し、実行可能としてマークされます。UpgradeableLoaderState::Program { programdata_address } を介してProgramData への参照を格納します。

ProgramData アカウントは、実際の ELF バイトコードとデプロイメタデータ(スロット、アップグレード権限アドレスなど)を UpgradeableLoaderState::ProgramData を介して格納する、より大きなアカウントです。

2 つのアカウントを分離することで、インプレースアップグレードが可能になります。つまり、Program アカウントは同じアドレスに維持される一方、ProgramData アカウントのバイトコードは置き換えられます。

将来のアカウントモデル

Loader V4 は、1 アカウントモデルによってデプロイプロセスの効率化を目指しています。このモデルでは、プログラムアカウントにメタデータとバイトコードを直接格納し、別の ProgramData アカウントを不要にします。また、レンタルコストを抑えるため、zstd 圧縮イメージを格納する選択肢も開発者に提供します。

Solana プログラムのデプロイ方法

デプロイプロセスでは、コンパイル済み ELF バイナリをアップロードし、BPF Loader がそれを検証、キャッシュして、実行可能としてマークします。前のセクションで説明したデプロイアーキテクチャにより、アップグレード可能なローダーと V4 ではプロセスが若干異なります。

BPF Loader Upgradeable

アップグレード可能なローダーを使用する現在のデプロイプロセスでは、ELF バイトコードをステージングするバッファアカウントを初期化します。デプロイ実行者は BPF Loader Upgradeable に InitializeBuffer 命令を送信します。これにより、ローダーが所有する新しいアカウントが作成され、アカウント状態が UpgradeableLoaderState::Buffer { authority_address } に設定されて、バッファへの書き込みを許可されたアドレスが記録されます。 

コンパイル済み ELF バイナリは、Write { offset, bytes } 命令を使用してチャンク単位でバッファにアップロードされます。各書き込み命令は、署名者がバッファの権限と一致することを検証し、バッファがまだ可変であること(まだデプロイされていないこと)を確認して、メタデータヘッダーより後の指定オフセットにバイトを書き込みます。トランザクションサイズの制限により、大規模なプログラムで ELF ファイル全体をアップロードするには、複数の Write 命令が必要です。

バッファに完全な ELF が格納されると、デプロイ実行者は DeployWithMaxDataLen { max_data_len } 命令を送信します。これは、アカウント検証から状態の確定まで実際のデプロイを統括するため、デプロイプロセス全体で最も複雑な手順です。

まずローダーは、デプロイプロセス内のすべてのアカウントを検証し、次の点を確認します。

  • プログラムアカウントが未初期化で、rent-exempt であること。
  • バッファに有効なデータが含まれ、バッファを初期化した権限者がトランザクションに署名していること。
  • max_data_len がバッファのデータを格納するのに十分な大きさであること。
  • 合計サイズが MAX_PERMITTED_DATA_LENGTH(10MiB、つまり 10,485,760 バイト)を超えていないこと。 

次にローダーは、プログラム ID とローダー ID を使用して PDA としてアドレスを導出し、ProgramData アカウントを作成します。デプロイ後はバッファアカウントが不要になるため、バッファの lamports を支払者へ返します。 

さらに、System Program への CPI を介して ProgramData アカウントを作成し、メタデータと max_data_len バイトに十分な領域を割り当てます。その後、ローダーは PDA の bump seed を使用して CPI に署名します。 

deploy_program! マクロは、バイトコードを安全に実行できることを保証します。まず ELF ファイル構造を解析して ELF マジックバイト(0x7f ‘E’ ‘L’ ‘F’)とヘッダー(64 ビット、リトルエンディアン)を検証し、プログラムセクションを抽出してリロケーションテーブルを処理し、セクションの境界とアラインメントを検証します。ELF が不正な形式であるか、サポートされていない機能を使用している場合、ロードは直ちに失敗します。

次に、RequisiteVerifier(sBPF のベリファイア)が、プログラムを実行せずに可能なすべての実行パスを静的解析し、命令を実行する前に安全性を証明します。ベリファイアは、前述の SVM ISA 制約も適用します。検証に失敗すると、デプロイは InstructionError::InvalidAccountData で拒否され、プログラムが実行可能としてマークされることはありません。

検証に合格すると、バイトコードがコンパイルされ、実行用にキャッシュされます。load_program_from_bytes 関数は ProgramCacheEntry を作成します。これには次のものが含まれます。

  • JIT コンパイル済み実行可能コード:sBPF バイトコードは、バリデータの CPU アーキテクチャ向けネイティブマシンコードに Just-In-Time(JIT)コンパイルされます。これにより、安全性を維持しながらネイティブに近い実行速度が得られます。
  • スロットメタデータ:プログラムのデプロイ時刻と可視化時刻が、それぞれ deployment_slot と effective_slot として記録されます。この遅延により、プログラムがデプロイされた同じスロットで使用されるのを防ぎます。
  • ランタイム環境:利用可能な syscall を定義する syscall レジストリへの参照と、プログラム実行時に使用される実行設定です。 

キャッシュエントリは program_cache_for_tx_batch に格納され、後続のトランザクションでプログラムを実行できるようになります。 プログラムの検証とキャッシュが正常に完了すると、ローダーはアカウント状態を更新してデプロイを確定します。ProgramData アカウントの状態が更新され、プログラムのデプロイ時期とアップグレード権限者が記録されます。ELF バイトコードもバッファからアカウントにコピーされます。Program アカウントの状態も更新されて ProgramData アカウントにリンクされ、実行可能としてマークされます。最後に、バッファのデータ長がメタデータサイズに設定され、実質的にバイトコードがゼロ化されて領域が回収されます。

これでプログラムは完全にデプロイされ、トランザクションから呼び出せるようになります。

BPF Loader V4

BPF Loader V4 は、別個の ProgramData アカウントを不要にし、プログラムアカウントにバイトコードを直接格納できるようにすることで、デプロイを効率化します。また、zstd 圧縮された ELF の格納もサポートします。ロード時にオンデマンドで展開しながら、レンタルコストを大幅に削減できます。

デプロイ実行者は SetProgramLength { new_size } を呼び出し、プログラムのメタデータとバイトコード用の領域を割り当てます。新しいプログラムでは、これによってアカウントが LoaderV4State::Retracted ステータスで初期化され、権限者が記録され、アカウントが実行可能としてマークされます。ただし、この時点ではまだ呼び出せません。

次にデプロイ実行者は、Write { offset, bytes } 命令を使用して ELF バイナリをプログラムアカウントへ直接書き込みます。これらの書き込みは、プログラムが Retracted 状態の場合にのみ許可されます。Copy 命令を使用すれば、ローダーのバージョンに関係なく別のプログラムからバイトコードをコピーできるため、移行に役立ちます。

次に Deploy 命令を使用して、プログラムを Retracted 状態から Deployed 状態へ移行します。基本的に、この命令はプログラムアカウントのオフセット位置からバイトコードを抽出し、BPF Loader Upgradeable とまったく同じ検証パイプライン(ELF 解析、静的検証、JIT コンパイル、キャッシュ)を実行します。検証に成功すると、プログラムの状態が LoaderV4Status::Deployed に更新され、デプロイスロットが記録されます。

これでプログラムは完全にデプロイされ、トランザクションから呼び出せるようになります。

Loader V4 は、再デプロイ攻撃を防ぐため、状態遷移(デプロイと retract)の間にクールダウン期間も設けています。プログラムは、最後のデプロイから1 スロット以内にデプロイまたは retract できません。これにより、悪意のある攻撃者がプログラムを急速に更新して競合状態を悪用したり、ユーザーを混乱させたりすることを防ぎ、複数スロットの遅延ではなくスロット単位の原子性を保証します。このクールダウンは Deploy 命令と Retract 命令の両方に適用されます。

Finalize 命令を使用して、プログラムを不変にすることもできます。これにより、プログラムは Deployed ステータスから Finalized ステータスへ移行し、それ以降 retract やアップグレードができなくなります。権限フィールドは「次のバージョン」のプログラムアドレスを指すように転用され、プログラムの不変性を維持しながら明示的なアップグレードパスを実現します。

SVM における実行の仕組み

SVM はバリデータ内のトランザクション処理エンジンであり、プログラム呼び出しの実行と、それに応じた状態更新を担います。 

トランザクションがバリデータに到着すると、検証、アカウントのロード、隔離された sBPF VM でのプログラム実行、不変条件の検証、状態のコミットという複数段階のパイプラインを通過します。すべての命令が成功すると、アカウントの変更が AccountsDB に書き込まれます。いずれかの命令が失敗すると、トランザクション全体がアトミックにロールバックされます。

SVM は分離された実行エンジンとして動作します。つまり、コンセンサス、ネットワーク、台帳履歴は管理しません。その代わり、プログラムを安全、決定論的、効率的に実行することだけに集中します。 

Bank は SVM の実行を統括し、ランタイムコンテキスト(ブロックハッシュ、rent、フィーチャーセットなど)を提供して、結果を永続ストレージにコミットします。SVM は、バイトコードのロードからコンピュート予算の適用まで、プログラムの実行を管理します。この責務の分離により、SVM はバリデータ以外でも再利用できます。

トランザクション 

トランザクションは Solana、そしてあらゆるブロックチェーンの生命線です。トランザクションがプログラムを呼び出し、状態を変更します。 

トランザクションは、どのアクションをどのアカウントに対して実行するか、またその実行に必要な権限があるかを定義する命令の集合です。 

命令は、1 回のプログラム呼び出しに対する指示です。実行ロジックの最小単位であり、Solana における最も基本的な操作単位として機能します。

プログラムは、命令から渡されたデータを解釈し、指定されたアカウントを操作します。命令には、プログラム ID(呼び出されるプログラム)、読み書きするアカウントのリスト、プログラムに渡される入力が含まれます。

トランザクションは、別のアカウントに 10 SOL を送るといった目標をユーザーが定義した時点で始まります。この意図は、アカウント A からアカウント B へ 10 SOL を送るよう System Program に指示する命令へ変換されます。アカウント A は書き込み可能な署名者として、アカウント B は書き込み可能なアカウントとしてトランザクションに渡されます。その後、命令は手数料支払者、署名者、最近のブロックハッシュも指定するトランザクションにパッケージ化されます。

通常、このトランザクションは Helius などの RPC プロバイダーに送信されます。トランザクションを受信した RPC ノードは、必要な署名がすべて揃っており有効であること、トランザクションがまだ処理されていないこと、指定された最近のブロックハッシュがまだ有効であること、トランザクションが最大サイズ(1232 バイト)を超えていないことを検証します。

その後、RPC はトランザクションを現在のリーダーの Transaction Processing Unit(TPU)へ転送します。 

Transaction Processing Unit(TPU)

Transaction Processing Unit(TPU)は、Solana バリデータ内のトランザクション取り込みおよび処理パイプラインです。Solana の台帳にコミットされる前に、トランザクションを受信、検証、スケジューリング、実行する複数のステージがあります。

ここでは、Bank と sBPF VM のプロビジョニングに進む前に、Fetch Stage、SigVerify Stage、Banking Stage を詳しく見ていきます。 

TPU の詳細については、Stake-Weighted Quality of Service:知っておくべきすべてをご覧ください。

Fetch Stage

Fetch Stage は TPU パイプラインの最初のステージです。基盤のトランスポート層に UDP ソケットを使用する QUIC 接続を介して、受信するすべてのトランザクションを受け取り、後続処理向けにバッチ化します。

3 つの UDP ソケットが作成されます。

  • tpu:トークン転送、NFT ミント、プログラム操作などの通常のトランザクションです。
  • tpu_vote:バリデータからの投票トランザクションです。投票トランザクションが廃止される Alpenglow では変更される予定です。
  • tpu_forwards:前のリーダーが時間内に処理できず、転送した未処理トランザクションです。

これらのソケットはバリデータの gossip サービスに登録され、ContactInfo 構造体に格納されます。これにより、他のバリデータや RPC ノードはトランザクションの送信先を検出できます。

Fetch Stage はソケットごとに 1 つのスレッドを生成し、それぞれが継続的に次の処理を行います。

  • 受信パケットについて UDP ソケットをポーリングします。
  • 64 パケットのバッチを作成します。
  • 無制限チャネルを通じてバッチを送信します。

現在、後続ステージにバッチを渡すために無制限チャネルが使用されており、チャネル容量に上限はありません。また、無制限チャネルを使用することで、Fetch Stage は後続処理の速度に左右されず動作できます。 

これによりトラフィック急増時の即時パケットドロップは防げますが、メモリの問題につながる可能性があります。後続ステージが Fetch Stage に追いつけない場合、チャネルが無制限に増大し、速度低下やメモリ不足(OOM)によるクラッシュを引き起こす恐れがあります。 

適切なバックプレッシャーを備えた有界チャネルを実装し、システムが輻輳を通知してメモリの無制限な増加を防げるようにする作業が進められています。

Fetch Stage は、転送されたパケットの処理専用スレッドも作成します。これらのパケットには FORWARDED フラグが付けられ、リーダースケジュールに基づいて保持または破棄されます。

  • 処理:現在のバリデータが間もなくリーダーになる場合、転送されたパケットは通常の TPU チャネルを介して処理され、次のステージへ送られます。
  • 破棄:現在のバリデータが間もなくリーダーにならない場合、無駄な処理を防ぐため、転送されたパケットは破棄されます。 

Fetch Stage は PacketBatchRecycler を使用して、各 1,024 パケットを含む 1,000 個のパケットバッチを事前に割り当てます。毎回新しいバッチを割り当てる代わりにパケットバッチのメモリを再利用し、メモリ割り当てのオーバーヘッドを削減するためです。以前、このリサイクラーは CUDA のメモリピン留めに必要でしたが、現在その機能は動作しておらず、ほぼ技術的負債と見なせます。 

SigVerify Stage

SigVerify Stage は TPU パイプラインの第 2 ステージで、その名のとおり署名の検証を担います。Ed25519 の署名検証は、トランザクション実行ほどではないものの計算コストが高いため、パイプラインの早い段階で行われます。実行前にトランザクションを検証することで、バリデータは不正なトランザクションを拒否し、サービス拒否攻撃を防ぎ、正しい形式のトランザクションだけが Banking Stage に到達するようにできます。

SigVerify Stage は単一のスレッドとして動作し、Fetch Stage のチャネルからパケットを継続的に受信して、検証パイプラインで処理します。単一スレッドでありながら、内部では大幅な並列化が行われています。

デフォルトでは、並列イテレータを使用して CPU 上で署名が検証されます。これにより、利用可能なすべての CPU コアに検証が分散され、各コアが署名の一部を独立して検証できます。 

perf_libs::api() を介してパフォーマンスライブラリが検出された場合、署名検証を GPU にオフロードすることもできます。GPU を使用できるのは、パケットが 64 個以上あり、その 90% が有効と予想される場合に限られます。これは、GPU ではセットアップと転送に ~15~20ms のオーバーヘッドがある一方、CPU は 64 個の署名を ~10~20ms で検証できるためです。実際には、レイテンシのオーバーヘッドによりこの機能は本番環境で実用的ではなく、現実的なワークロードでは CPU 検証より大幅に遅いことが判明しています。この未使用のコードパスは削除される予定です。

検証プロセスは比較的単純です。

  • Fetch Stage の無制限チャネルからパケットバッチを受信します。
  • パケット量が 165,000 を超えた場合、ロードシェディングによってトランザクションをランダムに破棄します。
  • 重複したトランザクションを削除します。
  • 単一の IP が検証帯域幅を独占しないよう、超過パケットを破棄します。
  • キャッシュ局所性を改善してメモリの無駄を減らすため、バッチを事前に縮小して再編成します。
  • 署名を検証します。

有効なパケットはすべて Banking Stage に進みます。 

Banking Stage

Banking Stage では、トランザクションが実行されます。ここでトランザクションがバッファリング、スケジューリングされ、並列ワーカースレッドによって実行されます。

中央スケジューラパターンが使用され、投票トランザクションと非投票トランザクションの処理方法が分離されています。

  • 1 つのワーカースレッドが、投票トランザクションと gossip 投票トランザクションを処理します。
  • 4 つのワーカースレッドが、すべての非投票トランザクションを処理します。
  • 1 つのスレッドが、ワーカースレッド間の作業分配を調整します。

受信パケットはデシリアライズされ、最大 100,000 件のトランザクションまでバッファリングされます。スケジューラは、このバッファを管理しながら新しいトランザクションを継続的に受信し、一度に最大 10,000 件のトランザクションを確認するキュークリーンアップ処理で、期限切れまたは無効なトランザクションを破棄します。

スケジューラは競合検出(同じアカウントに対して読み取りロックと書き込みロックを確保しようとするトランザクション)を使用して、トランザクションの実行順序を決定します。デフォルトでは、次の 2 つのスケジューラ実装を利用できます。

  • PrioGraphScheduler:アカウントの競合を検出するために優先度グラフを構築するスケジューラです。
  • GreedyScheduler:より単純な FIFO 方式でトランザクションを順序付けるデフォルトのスケジューラです。

その後、スケジューラは競合しないトランザクションを選択し、ワーカースレッドへ振り分けます。各ワーカーはバッチを受け取り、処理を開始します。

Bank によるオーケストレーション

Bank は、特定のスロットにおけるすべてのアカウントの状態を表します。アカウントデータを管理し、ランタイムルールを適用するとともに、トランザクション実行時に Banking Stage のワーカーと sBPF VM の間を統括する中心的なデータ構造です。 

ライフサイクル

各 Bank は 3 つの状態を経ます。

  • Active:新しく作成され、トランザクションを受け付けている Bank です。Bank が目標 tick 数に達するか、スロット内のすべてのエントリが処理されるまで、Banking Stage のワーカーがトランザクションを適用します。
  • Frozen:tick 数に達するか、すべてのエントリが処理されると、Bank は凍結されます。それ以降、トランザクションは適用できません。この時点で、トランザクション手数料がブロックリーダーに集計され、sysvar アカウントが更新され、最終的な Bank ハッシュが計算されます。
  • Rooted:凍結された Bank がバリデータから十分な投票を受けると、root 化されます。状態が確定し、チェーンの台帳の一部になります。

ジェネシス Bank を除く各 Bank は親 Bank を参照し、台帳の異なるフォークを表すツリー構造を形成します。 

実行フロー

Banking Stage のワーカーは、現在の作業中の Bank(現在のスロット向けに構築中で、アクティブかつ未凍結の Bank)を操作します。ワーカーがスケジューラからトランザクションバッチを受け取ると、Bank が実行を統括します。

  • アカウントのロック:ワーカーは prepare_sanitized_batch_with_results() 関数を呼び出し、トランザクションで参照されるすべてのアカウントをロックして同時変更を防ぎます。
  • アカウントのロード:Bank は、ロックされたすべてのアカウントについて AccountsDB からアカウントデータを取得します。
  • 手数料の控除:Bank は実行前に、手数料支払者のアカウントからトランザクション手数料を差し引きます。
  • 検証:Bank はブロックハッシュが最近のものであることを検証し、nonce アカウントの状態とアカウントの所有権を確認します。
  • VM への引き渡し:Bank は load_and_execute_transactions() を呼び出し、ロードされたアカウントを sBPF VM に渡します。
  • 実行:sBPF VM は各命令のプログラムバイトコードを実行します。
  • 結果:sBPF VM はすべての実行出力(LoadAndExecuteTransactionOutput)を返します。
  • コミット:Bank は bank.commit_transactions() を介して、更新されたアカウント状態を AccountsDB に書き戻します。

ここから実行は SVM 本体(sBPF VM)に入ります。Bank が load_and_execute_transactions() を呼び出した後、トランザクションは複数段階のパイプラインを通過します。各命令は、プログラムのバイトコードを実行するためにプロビジョニングされた、新しく隔離された sBPF VM インスタンスを使用して処理されます。

トランザクションの命令は順番に実行されます。各命令のパイプラインは次のとおりです。

  • プログラムの特定:命令のプログラム ID からプログラムアカウントを検索します。
  • キャッシュからのロード:対象プログラムがプログラムキャッシュ内ですでに JIT コンパイルされているか確認します。 
  • VM のプロビジョニング:特定のメモリ領域とコンピュート予算を持つ、隔離された sBPF VM インスタンスを作成します。
  • バイトコードの実行:命令の入力を使用して、プログラムのエントリポイントを実行します。
  • 不変条件の検証:実行がランタイムルールに違反していないことを確認します。
  • 結果の収集:実行結果をパッケージ化します。

プログラムのロード

プログラムを実行する前に、オンチェーンアカウントからロードし、検証して、ネイティブマシンコードにコンパイルする必要があります。これは、プログラムキャッシュと JIT コンパイルパイプラインを通じて行われます。

プログラムキャッシュは、呼び出しのたびにプログラムを再ロード、再コンパイルすることを避けるためのパフォーマンス最適化です。トランザクションバッチ単位で維持され、次の内容を持つ ProgramCacheEntry オブジェクトを格納します。

  • JIT コンパイル済み実行可能コード:バリデータの CPU アーキテクチャ向けネイティブマシンコードです。
  • デプロイメタデータ:プログラムがデプロイされ、有効になったスロットです。
  • ランタイム環境:syscall レジストリと実行設定への参照です。

トランザクションがプログラム ID を参照すると、次の順序で検索されます。

  • トランザクションバッチキャッシュの確認:キャッシュされた JIT コンパイル済みバージョンを探します。
  • グローバルプログラムキャッシュの確認:バッチキャッシュにない場合、バリデータのグローバルキャッシュを確認します。
  • アカウントからのロード:すべてのキャッシュで見つからない場合、AccountsDB からプログラムアカウントをロードします。
  • ELF の解析:プログラムアカウントデータからバイトコードを抽出します。
  • バイトコードの検証:静的ベリファイアを実行して安全性を確認します。
  • JIT コンパイル:sBPF バイトコードをネイティブマシンコードに変換します。
  • キャッシュエントリ:コンパイル済みプログラムを今後の呼び出し用に格納します。

つまり、新しくデプロイされたプログラムの最初の呼び出しでは、ロード、検証、コンパイルの全コストが発生しますが、それ以降の呼び出しではキャッシュ済みネイティブコードが直接実行されます。 

キャッシュミスが発生すると、プログラムをオンチェーンアカウントからロードする必要があります。BPF Loader Upgradeable が所有するプログラムの場合、プログラムアカウントには ProgramData アカウントへの参照が含まれ、実際にはそのアカウントが AccountsDB からロードされます。Loader V4 の 1 アカウントモデルでは、バイトコードがプログラムアカウントから直接ロードされます。zstd 圧縮されている場合は展開が必要になることがあります。

抽出された ELF バイトが解析され、実行可能なバイトコードと前述のセクション(.text、.rodata、.data / .bss、.symtab / .strtab)が特定されます。ELF の解析に成功すると、前のセクションで説明したように、RequisiteVerifier(sBPF の静的アナライザー)がプログラムを実際に実行することなく、可能なすべての実行パスを検証します。

JIT コンパイル

検証に合格すると、バイトコードは Just-In-Time(JIT)でネイティブマシンコードにコンパイルされます。JIT コンパイラは、各 sBPF 命令をバリデータのアーキテクチャ向けの同等なネイティブ CPU 命令に変換します。 

JIT コンパイルにより、sBPF VM は Solana の高いスループットを処理できるだけの性能を発揮します。JIT がなければ、VM は sBPF バイトコードを命令ごとに解釈する必要があり、大きなオーバーヘッドが発生します。 

各バイトコード命令では、フェッチ、デコード、ハンドラーコードへのディスパッチが必要となり、解釈のオーバーヘッドが加わります。また、解釈されたコードは、パイプライン処理、分岐予測、アウトオブオーダー実行などの CPU レベルの最適化を活用できません。そのため、すべての sBPF 命令がインタープリター内の関数呼び出しになります。

JIT コンパイルは、CPU 上で直接実行されるネイティブマシンコードを生成することで、これらのコストを完全に排除します。その結果、ネイティブに近い性能、最適化された境界チェック、インライン化されたコンピュート計測、ハードウェアレベルの最適化、明確なレジスタ割り当てマッピングが実現します。

JIT コンパイラは、sBPF バイトコードからネイティブマシンコードへのシングルパス変換を行います。各 sBPF 命令に対して、次の処理を実行します。

  • 命令をデコードし、オペコード、レジスタ、オフセット、即値を抽出します。
  • スタックフレームを設定し、呼び出し先保存レジスタを保存します。
  • 各操作がネイティブ命令にコンパイルされるよう、sBPF 操作を同等の CPU 命令にマッピングします。たとえば x86-64 のマッピングでは、rax は R0 に、rbp は R10 に対応します。
  • メモリアクセス用の境界チェックとソフトウェアアドレス変換を生成します。ゲストアドレスからホストアドレスへの変換は、オーバーヘッドが大きいため VM で最も遅い操作の一つです。
  • スタックを分離した状態に保ちます(ゲストスタックはホストスタックではなく、ヒープに割り当てられたバッファに存在します)。
  • CU の差し引きと予算チェックを挿入し、コンピュート計測を生成します。
  • レジスタを復元し、スタックをクリーンアップします。

命令の変換

JIT コンパイラは、さまざまな命令タイプ(算術演算、メモリアクセス、ストア操作、条件分岐、syscall ディスパッチ)をそれぞれ固有の方法で変換します。 

算術演算は、sBPF のレジスタがバリデータの対応するハードウェアレジスタにうまくマッピングされるため、オーバーヘッドなしで単一のネイティブ CPU 命令へ直接マッピングされます。 

メモリアクセス操作では、範囲外の読み書きを防ぐために境界チェックが必要です。JIT コンパイラは、各メモリアクセスの下限と上限を有効な領域境界と照合する検証コードを生成します。 

基本的には、実効アドレスを計算し、想定された境界内にあることを確認して、実際のロードを行う 3~6 個のネイティブ命令が関与します。境界違反はまれなため、分岐予測によって比較的効率よく処理されます。 

ストア操作には、境界チェックと書き込み権限の検証が含まれます。コンパイル済みコードは、メモリへ書き込む前に、対象アドレスが境界内にあり、そのメモリ領域で書き込み権限が有効であることを検証します。

条件分岐は、ネイティブの条件付きジャンプ命令にコンパイルされます。JIT コンパイラはコンパイル時にすべてのジャンプ先を解決し、sBPF の相対命令オフセットをネイティブコード内の絶対アドレスに変換します。 

syscall ディスパッチでは、VM の状態(11 個すべてのレジスタ)を保存し、ネイティブ syscall ハンドラーを呼び出す必要があります。その後、戻り値とともに VM の状態が復元されます。この状態管理のオーバーヘッドにより、syscalls には通常の命令より高い固定 CU コストが設定されています。

コンピュートユニットの計測

JIT コンパイラは、コンピュートユニットの追跡を生成コードに直接インライン化します。各 sBPF 命令には、残りの CU 数から命令ごとに差し引きながら、予算が枯渇していないことを確認するチェックが含まれます。 

このインライン化によって関数呼び出しのオーバーヘッドを回避できます。また、分岐予測、アウトオブオーダー実行(CU チェックと操作を並列実行できます)、命令レベルの並列性によって効率的に処理できます。

可変コストの syscall(データ長に応じてスケーリングする sol_sha256 syscall など)では、VM に戻る前にネイティブ syscall 実装内でコストが計算されます。

コンパイル済みコードのキャッシュ

JIT コンパイルが完了すると、ネイティブ実行可能コードが ProgramCacheEntry に格納されます。これには次のものが含まれます。

  • JIT コンパイル済みコード
  • デプロイスロットと有効スロットのメタデータ
  • syscall レジストリへの参照
  • ランタイム環境の設定

キャッシュエントリは、トランザクションバッチキャッシュとグローバルプログラムキャッシュに配置されます。前者は現在のバッチ内のすべての命令から利用でき、後者は今後のすべてのトランザクションから利用できます。 

プログラムがアップグレードされた場合(新しいバイトコードがデプロイされた場合)、プログラムアカウントが閉じられた場合、フィーチャーゲートによって syscall の利用可否が変わった場合、またはバリデータがキャッシュをフラッシュした場合、キャッシュエントリは無効化されることがあります。

有効スロットの遅延

スロット n でデプロイされたプログラムは、スロット n + 1 まで呼び出せません。この遅延により、すべてのバリデータがデプロイを確認し、プログラムキャッシュがネットワーク全体で同期され、スロット単位の原子性が適用されます。 

sBPF VM のプロビジョニング

プログラムがロードされて JIT コンパイルされるか、キャッシュから取得されると、命令を実行するたびに新しい sBPF VM がプロビジョニングされます。このプロビジョニングは BPF Loader 内で行われ、5 つの異なるメモリ領域の設定、コンピュート予算の初期化、syscalls の登録が含まれます。

メモリ領域

SVM ISA のセクションで説明したとおり、VM は 5 つの異なるメモリ領域を作成します。これらの領域が一体となって、Solana プログラムが実行される隔離されたサンドボックスを形成します。 プログラムメモリ領域は通常、アドレス 0x100000000 から始まります。ここには実行対象の JIT コンパイル済みネイティブコード、つまりバリデータの CPU アーキテクチャに応じた実際のマシンコードが格納されます。デバッグのために JIT が無効化されている場合、この領域には代わりに解釈対象の sBPF バイトコードが格納されます。このセクションの権限は、読み取りと実行のみです。

読み取り専用データも 0x100000000 に含まれます。ここには、プログラムのロード時に ELF の .rodata セクションから抽出された定数と静的文字列が格納されます。このメモリ領域の目的は、ヒープ割り当てを必要とせずにコンパイル時定数へ効率よくアクセスできるようにすることです。 

スタックは 0x200000000 から始まり、ローカル変数、関数呼び出しフレーム、戻りアドレスを格納します。実行中の一時的な計算はここで行われます。レジスタ R10(フレームポインタ)が現在のフレーム境界を示し、領域の上端から下方向に伸びます。この領域では読み取りと書き込みが許可され、サイズは呼び出しフレームごとに 4KB に固定されています。この制限を超えると StackAccessViolation エラーが発生し、スタックオーバーフローを示します。スタックのサイズを小さくすることで、スタックベースのストレージに依存するのではなく、ヒープを使用するか、さらに望ましくはアカウントにデータを格納するよう開発者に促します。

ヒープは 0x300000000 から始まり、スタックやアカウントに収まらないランタイムデータ構造向けに動的に割り当てられたメモリを格納します。サイズはデフォルトの 32KB から最大 256KB までです。以前は、プログラムが sol_alloc_free syscall を介してヒープを拡張できました。しかし、sol_alloc_free syscall は非推奨となり、新しいプログラムのデプロイでは無効化されています。プログラムは動的に拡張するのではなく、デプロイ時に必要なヒープサイズを指定する必要があります。 

注:ヒープの拡張では、式 (heap_size / 32KB) * 8,000 CUs に基づいてコンピュートユニットが消費され、デフォルトのヒープコストは 8 CU です。

入力データメモリ領域はアドレス 0x400000000 から始まります。ここには、呼び出し時にプログラムが受け取る、シリアライズされたエントリポイントパラメータが格納されます。実際のサイズがトランザクションによって異なる読み取り専用メモリ領域です。シリアライズされる 3 つのコンポーネントは、呼び出されるプログラムの 32 バイトの pubkey、アカウントの配列、命令データです。

メモリアクセスの適用

すべてのメモリロード命令とストア命令は、VM によって境界チェックされます。各メモリアクセスの前に、VM はアドレスが領域の有効範囲内にあり、アクセス種別(読み取りまたは書き込み)がその領域で許可されていることを検証します。 

いずれかのチェックに失敗すると、実行は AccessViolation エラーで直ちに中止されます。トランザクション全体がロールバックされ、状態変更はコミットされません。 

JIT コンパイラがこれらのチェックを CPU で直接実行できる効率的なネイティブコードにコンパイルするため、この適用によるランタイムコストはほぼゼロです。違反はまれなので、最新の分岐予測によってチェックが効率的に処理されます。 

コンピュート予算の初期化

各 VM インスタンスは、特定のプログラムが実行できる作業の総量を制限するコンピュートユニット予算で初期化されます。この制限付き実行モデルにより、プログラムが無期限に動作することを防ぎ、すべてのバリデータが予測可能な時間枠内でトランザクションを実行できるようにします。

現在の予算パラメータは次のとおりです。

  • 命令ごとのデフォルト Compute Unit 上限: 200,000 CU。
  • トランザクションごとの最大 Compute Unit 上限: 1,400,000 CU。
  • 組み込み命令の上限: 3,000 CU。

トランザクションごとのデフォルト CU は min(1_400_00, (200_000*non_reserve_instructions + 3_000*reserve_instructions)) です。これは実質的に、最大 Compute Unit 上限と、指定された命令に基づく命令タイプごとのデフォルトコストのうち、小さい方です。

Compute Budget は、実行中を通じて残りのユニット数を追跡します。各 sBPF 命令が実行されるたびに、その CU コストが残りの予算から差し引かれます。プログラムの完了前に予算がゼロになると、実行は直ちに InstructionError::ComputationalBudgetExceeded で停止します。トランザクションは失敗し、状態変更はコミットされませんが、バリデータがトランザクションを処理した対価として、手数料支払者には引き続きトランザクション手数料が課されます。 

Syscall レジストリ

VM のプロビジョニング処理では、各 syscall 固有の 32 ビット Murmur ハッシュ識別子とネイティブの Rust 実装とのマッピングも作成され、利用可能なすべての syscall が登録されます。 

プログラムが syscall ハッシュを指定して CALL_IMM 命令を実行すると、次の処理が行われます。

  • VM が sBPF 命令ストリームを一時停止します。
  • syscall ディスパッチャーが、ハッシュを使用してレジストリ内の実装を検索します。
  • syscall が、呼び出し元に必要な権限があることを検証します(例: CPU の作成、アカウントの所有権)。
  • syscall の Rust 実装が、サンドボックス外でランタイムへ完全にアクセスして実行されます。
  • syscall の固定コストが、残りの Compute Budget から差し引かれます。
  • 結果がレジスタ R0 に格納され、sBPF の実行が再開されます。 

プログラムの実行

sBPF VM のプロビジョニングが完了すると、プログラムのエントリーポイント関数から実行が始まります。JIT コンパイルされたプログラムでは、VM がネイティブマシンコードへ直接ジャンプし、バリデータの CPU がそれをネイティブに実行します。前のセクションで説明したとおり、JIT コンパイル済みコードには、メモリ境界チェック、Compute Metering、制御フロー検証といった必要な計測処理がすべて含まれ、最大限のパフォーマンスを得るためにインライン化されています。

レジスタ R1 には、シリアライズされた 3 つのパラメータ(呼び出されるプログラムの pubkey、アカウントの配列、命令データ)が格納されている入力データ領域へのポインタが含まれます。 

注: アカウントデータはコピーされず、ポインタを介してアクセスされます。ポインタを使用すると、プログラムはアカウントデータをその場で読み取り、変更できます。これはパフォーマンスに不可欠です。

関数が呼び出されるたびに新しい 4KB のスタックフレームが割り当てられ、レジスタ R10 は新しいフレームを指すように更新されます。Compute は、前述の Compute Budget に従って計測されます。

クロスプログラム呼び出し(CPI)

実行中、プログラムはクロスプログラム呼び出し(CPI)を通じて他のプログラムを呼び出せます。これは SVM のコンポーザビリティを支える基盤です。 

CPI は sol_invoke_signed syscall を介して開始されます。コストは 1,000 CU に加え、渡されるシリアライズ済みアカウントデータに応じた追加コストです。アカウントデータと命令データのシリアライズには、どちらも 1 CU あたり 250 バイトのコストがかかります。

プログラムが CPI を実行すると、専用の命令スタックフレームを持つ新しい実行コンテキストが作成されます。本稿執筆時点で、命令スタックの最大深度は 5、または SIMD-0268 が有効な場合は 9 です。つまり、プログラムは別のプログラムを呼び出し、そのプログラムがさらに別のプログラムを呼び出すことを、深度上限まで繰り返せます。ネストされた各呼び出しは、書き込み可能なアカウントと署名者権限の独自セットを保持します。1 回の CPI では、最大 16 の署名者を指定し、128 個の AccountInfo 構造体を渡せます。 

呼び出し元は、対象プログラム ID、アカウント、命令データをシリアライズしてから syscall を呼び出します。現在のプログラムの実行は一時停止され、前述と同じプロビジョニング処理に従って、呼び出し先プログラム用の新しい sBPF VM インスタンスがプロビジョニングされます。その後、呼び出し先の実行が始まります。呼び出し先は、呼び出し元の残り予算から割り当てられた独自の Compute Budget で実行されます。つまり、CPI 呼び出しはトランザクション全体の Compute Budget を共有します。

プログラムは、Program Derived Address(PDA)を介して、自身が所有するアカウントの代理で署名できます。sol_invoke_signed で呼び出す場合、呼び出し元は PDA の所有権を証明するシードを提供します。呼び出し先に署名権限を付与する前に、PDA の導出が検証されます。 

呼び出し先の処理が完了すると、制御は呼び出し元に戻ります。呼び出し先によるアカウントの変更は呼び出し元からも確認できるため、状態を呼び出しチェーン全体に伝播できます。CPI チェーン内のいずれかのプログラムが失敗すると、トランザクション全体が中止され、すべての状態変更が元に戻されます。

注: sol_invoke は、シードなしで sol_invoke_signed を呼び出すヘルパーです。

実行後の検証

プログラムの実行が完了すると、直接実行された場合でも CPI チェーンの一部として実行された場合でも、状態の一貫性とセキュリティ上の不変条件を確保するため、複数の実行後チェックが行われます。 

たとえばランタイムは、書き込み可能としてマークされたすべてのアカウントが、実際にプログラムによって所有されているか、適切に署名されていることを検証します。プログラムは、明示的に書き込み可能とマークされ、所有者から権限が付与されていない限り、自身が所有していないアカウントを変更できません。これにより、不正な状態変更を防ぎます。

またランタイムは、System Program 命令によって lamport が明示的に転送された場合を除き、トランザクション内の全アカウントにわたる lamport の合計が同じであることを確認します。この保存則チェックにより、プログラムが lamport を生成または消滅させることを防ぎ、SOL の総供給量を一定に保ちます。

ランタイムは、実行可能フラグが設定されたすべてのアカウント(つまりプログラム)が変更されていないことを検証します。通常の実行中にプログラムのデータを変更することはできず、BPF Loader のアップグレード権限メカニズムを通じてのみアップグレードできます。

実行結果

実行後の検証が完了すると、実行結果が Bank のトランザクションプロセッサに返されます。結果には、成功またはエラーのステータス(それぞれゼロまたはゼロ以外の結果)、消費された Compute Unit 数、アカウント状態に加えられた変更が含まれます。 

実行が成功すると、Bank はすべてのアカウント変更をアトミックにコミットします。更新されたアカウントデータ、lamport 残高、メタデータは AccountsDB に書き込まれ、後続のトランザクションから参照できるようになります。消費された Compute Unit は、トランザクション手数料の計算とネットワーク指標のためにログへ記録されます。

失敗したトランザクションでは状態変更はコミットされません。Solana ではトランザクション全体がロールバックされるため、部分的なリバートはありません。ただし、実行された計算処理の対価をバリデータに支払うため、トランザクション手数料は引き続き手数料支払者のアカウントから差し引かれます。エラーコードと消費された Compute Unit は、デバッグと分析のためにトランザクションのメタデータへ記録されます。

実行結果は Banking Stage のスケジューラへ戻されます。スケジューラは内部メトリクスを更新し、次のトランザクションへ進みます。成功したトランザクションも失敗したトランザクションも Proof of History ストリームに記録され、現在構築中のブロックに組み込まれます。失敗したトランザクションも含めることで、リプレイ攻撃を防ぎ、完全なトランザクション履歴を維持できます。

スロットが完了して最大 tick 数に達すると、Bank は frozen 状態へ移行します。freeze は、新しいトランザクションのコミットを防ぎ、Bank のハッシュを計算する一方向の処理です。frozen は finalized を意味しないことに注意してください。そのスロットは、破棄されるフォーク上にまだ存在する可能性があります。 

バリデータが BankForks::set_root() を呼び出し、Bank を正規チェーンの一部として指定すると、その Bank は root 化されます。root 化によって squash 処理がトリガーされ、root 化された Bank のアカウント状態が AccountsDB にフラット化されます。すべての親状態がマージされ、バリデータの観点では永続化されます。root 化されていないフォークは枝刈りされ、破棄されます。Solana には異なるコミットメントレベルがあるため、root 化された Bank であっても、クラスタの観点ではまだ finalized ではありません。

今後の展望

Solana Virtual Machine は、ブロックチェーン実行に対する根本的に異なるアプローチです。並列処理、ローカル手数料市場、高性能で決定論的な eBPF 派生ランタイムにより、誰もが利用できるスケーラブルなブロックチェーンを実現します。 

SVM を理解するには、Rust ソースコードのコンパイルから LLVM、sBPF、そして分離された VM インスタンスのプロビジョニングに至るまで、実行パイプライン全体を検証する必要があります。 

SVM を定義する単一の「仕様」はありません。SVM は、Bank、スケジューラ、BPF Loader、sBPF VM、SVM ISA の相互作用から成り立っています。

SVM が進化を続けるなか、その未来には大きな期待が寄せられています。 

Solana のツールチェーンは、長年にわたり開発者のオンボーディングを妨げてきたカスタム LLVM インフラストラクチャを排除するため、全面的な刷新が進められています。 

現在のアプローチでは、開発者はプラットフォーム固有のスクリプトを介してカスタムツールチェーンをインストールする必要があります。その解決策は、Rust eBPF ライブラリの Aya が使用するものと同じツールチェーンを採用することです。開発者は、次の 2 つのシンプルなコマンドを実行するだけで、eBPF バイトコードへ直接コンパイルできるようになります。

コード
rustup toolchain install nightly
cargo build --target=bpfel-unknown-none

スクリプトは不要です。カスタム LLVM フォークも不要です。アップストリームの bpfel-unknown-none ターゲットを使用し、長年にわたる Linux カーネル開発と LLVM インフラストラクチャの改善を活用して、標準の Rust ツールだけで eBPF バイトコードへ直接コンパイルできます。

SVM ISA も SIMD-0377 によって更新される予定です。これは、Solana の eBPF 実装(つまり sBPF)を最新の eBPF 標準に合わせることを提案しています。JMP32 命令バリアント、符号付き除算と剰余演算、間接ジャンプ、動的スタックフレームの導入が含まれます。これらの変更は、プログラムの計算コストを削減し、アップストリームの LLVM インフラストラクチャとの互換性を高め、より効率的なコード生成を可能にします。 

SVM は、Compute Budget によって計測されるシステムです。SIMD-0370 は、ブロック単位の Compute 上限を撤廃し、場合によってはトランザクション上限も撤廃することで、この仕組みを変えようとしています。これらの Compute 上限を撤廃すれば、ブロック生成者は人為的な制限ではなく、自身のハードウェア性能に基づいてスループットを最大化できます。Alpenglow のタイムアウトメカニズムと組み合わせることで、プロトコルレベルの制約ではなく、市場原理によって最適なブロックサイズを決定できるようになります。もちろん、これはかなり先を見据えた構想です。Anza はこのような上限を撤廃する前に、まず CU 上限を 100m+ へ引き上げることを目指しています。

これらすべての変更の根底には、安全性、決定性、分散性を犠牲にすることなく、ブロックチェーンで実現できることの限界を押し広げようとする、ビルダーの精神があります。 

SVM は単なるバイトコードインタープリタではありません。ブロックチェーンの能力を革新する、完全な実行パイプラインです。スループットと低レイテンシを優先したアーキテクチャ上の意思決定の集大成です。Solana の成熟に伴い、SVM は高性能で資本効率の高いアプリケーションを支えるため、今後も進化し続けます。

Internet Capital Markets という夢を実現するには、世界規模の金融システムが求めるスループット、レイテンシ、コスト要件に対応できるインフラストラクチャが必要です。Solana Virtual Machine は、そのビジョンを実現するための重要な一歩です。

その他のリソース

Heliusを購読

Solana開発の最新情報や新しい記事の公開通知を受け取れます

拡大画像