新着:HeliusがLight Protocolを買収
Solanaプログラムの最適化
ブログ/開発

Solanaプログラムの最適化

インテグレーションエンジニアXのHet DagliLinkedInのHet Dagli
読了時間:12分

実践的なポイント

  • 大規模なデータ構造や高頻度の処理にはゼロコピーのデシリアライズを使用します
  • 肥大化したsolana_program’sの代わりにnostd_entrypointを使用します
  • 動的割り当てを最小限に抑え、スタックベースのデータ構造を優先します
  • Borshのオーバーヘッドを避けるため、独自のシリアライズ/デシリアライズを実装します
  • パフォーマンス向上が見込める重要な関数に#[inline(always)]を付けます
  • 命令を効率的に解析するため、ビット操作を使用します
  • sol_invoke_signed_cなど、Solana固有のCシステムコールを使用します
  • コンピュートユニットの使用量を測定し、最適化の判断材料にします

はじめに

Solana開発者はプログラムを作成する際、使いやすさ、パフォーマンス、安全性のバランスについてさまざまな判断を迫られます。その選択肢は、一定のオーバーヘッドと引き換えに開発を簡素化する使いやすいAnchorフレームワークから、unsafe Rustや直接システムコールを使用する低レベルなアプローチまで多岐にわたります。後者は最高のパフォーマンスを実現できる一方で、複雑さや潜在的なセキュリティリスクが増します。開発者にとって重要なのは、最適化の方法だけでなく、いつ、どの程度まで最適化するかです。

このブログ記事では、これらの選択肢を詳しく掘り下げ、開発者が最適化の全体像を把握するためのロードマップを示します。以下の抽象化レベルについて見ていきます。

  1. Anchor:大半の開発者にとって定番となる、規約に基づく強力な高レベルフレームワーク
  2. ゼロコピーを使用するAnchor:大規模なデータ構造向けに最適化されたAnchorコード
  3. 制御性と使いやすさのバランスを取る純粋なRust
  4. unsafe Rustと直接的なシステムコール(syscall):パフォーマンスを限界まで引き出すアプローチ

目標は、あらゆるケースに当てはまる単一の解決策を示すことではありません。開発者が固有のユースケースに応じてプログラムの実装方法を適切に判断できるよう、必要な知識を提供することです。

この記事を最後まで読むと、さまざまな抽象化レベルをどう捉え、いつ次の最適化段階へ進むべきかをより深く理解できます。最も最適化されたコードが常に最善とは限りません。プロジェクトのニーズに合ったバランスを見つけることが重要です。

この記事では、Rustの基礎、Solanaのアカウントモデル、Anchorフレームワークに関する知識があることを前提としています。 

先に結論を知りたい方へ:

コンピュートユニット

Solanaの高性能なアーキテクチャは、効率的なリソース管理に支えられています。その中核となるのがコンピュートユニット(CU)です。これは、特定のトランザクションを処理するためにバリデータが消費する計算リソースの指標です。

‍コンピュートユニットが重要な理由

  1. トランザクションの成功: 各トランザクションにはCU上限があり、超過すると失敗します
  2. コスト効率: CU使用量が少ないほど、トランザクション手数料も低くなります
  3. ユーザー体験: 最適化されたプログラムはより速く実行され、全体的なUXが向上します
  4. スケーラビリティ: 効率的なプログラムではブロックあたりのトランザクション数が増え、ネットワークのスループットが向上します

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

solana_program::log::sol_log_compute_units()システムコールは、プログラムの実行中に特定の時点までに消費したコンピュートユニット数をログへ出力します。

以下は、このシステムコールを使用したシンプルなcompute_fn!マクロの実装です。

コード
#[macro_export]
macro_rules! compute_fn {
    ($msg:expr=> $($tt:tt)*) => {
        ::solana_program::msg!(concat!($msg, " {"));
        ::solana_program::log::sol_log_compute_units();
        let res = { $($tt)* };
        ::solana_program::log::sol_log_compute_units();
        ::solana_program::msg!(concat!(" } // ", $msg));
        res
    };
}

このマクロは、CU最適化用のSolana Developers GitHubリポジトリから引用しています。このコードスニペットは、initializeとincrementという2つの命令を持つカウンタープログラムを実装しています。

この記事では、同じ2つの命令initializeとincrementを持つ同一のカウンタープログラムを4通りの方法で実装し、それぞれのCU使用量を比較します。対象はAnchor、ゼロコピーのデシリアライズを使用するAnchor、ネイティブRust、unsafe Rustです。

アカウントを初期化し、そのアカウントに小さな変更を加える処理(今回はインクリメント)は、これらのアプローチを比較するうえで妥当なベンチマークです。現時点ではPDAsを使用しません。

先に結論を知りたい方のために、4つのアプローチのCU比較を示します。

それでは始めましょう。

ゼロコピーのデシリアライズ

ゼロコピーのデシリアライズを使用すると、新しいメモリを割り当てたりデータをコピーしたりせず、アカウントデータを直接解釈できます。この手法によりCPU使用量やメモリ消費量を抑え、命令をより効率的にできる可能性があります。

まずは基本的なAnchorカウンタープログラムから始めます。‍

コード
use anchor_lang::prelude::*;

declare_id!("37oUa3WkeqwnFxSCqyMnpC3CfTSwtvyJxnwYQc3u6U7C");

#[program]
pub mod counter {
    use super::*;

    pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
        let counter = &mut ctx.accounts.counter;
        counter.count = 0;
        Ok(())
    }

    pub fn increment(ctx: Context<Update>) -> Result<()> {
        let counter = &mut ctx.accounts.counter;
        //Not doing checked_add, wrapping add or any overflow checks
        //to keep it simple
        counter.count += 1;
        Ok(())
    }
}

#[derive(Accounts)]
pub struct Initialize<'info> {
    #[account(init, payer = user, space = 8 + 8)]
    pub counter: Account<'info, Counter>,
    #[account(mut)]
    pub user: Signer<'info>,
    pub system_program: Program<'info, System>,
}

#[derive(Accounts)]
pub struct Update<'info> {
    #[account(mut)]
    pub counter: Account<'info, Counter>,
    pub user: Signer<'info>,
}

#[account]
pub struct Counter {
    pub count: u64,
}

ここまでは特別なことはありません。次はzero_copyを使って高度にします。

コード
use anchor_lang::prelude::*;

declare_id!("7YkAh5yHbLK4uZSxjGYPsG14VUuDD6RQbK6k4k3Ji62g");

#[program]
pub mod counter {
 use super::*;

pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
 let mut counter = ctx.accounts.counter.load_init()?;
 counter.count = 0;
 Ok(())
 }

pub fn increment(ctx: Context<Update>) -> Result<()> {
 let mut counter = ctx.accounts.counter.load_mut()?;
 counter.count += 1;
 Ok(())
 }
}

#[derive(Accounts)]
pub struct Initialize<'info> {
 #[account(init, payer = user, space = 8 + std::mem::size_of::<CounterData>())]
 pub counter: AccountLoader<'info, CounterData>,
 #[account(mut)]
 pub user: Signer<'info>,
 pub system_program: Program<'info, System>,
}

#[derive(Accounts)]
pub struct Update<'info> {
 #[account(mut)]
 pub counter: AccountLoader<'info, CounterData>,
 pub user: Signer<'info>,
}

#[account(zero_copy)]
pub struct CounterData {
 pub count: u64,
}

主な変更点:

主な変更点は以下のとおりです。

1. Accountの代わりにAccountLoaderを使用

Account<’info, Counter>の代わりにAccountLoader<’info, CounterData>を使用します。これにより、アカウントデータへゼロコピーでアクセスできます。

2. ゼロコピー属性

CounterDataに付けた#[account(zero_copy)]属性は、この構造体をメモリ上の生バイト列から直接解釈できることを示します。

3. データへの直接アクセス

initialize関数とincrement関数では、それぞれload_init()とload_mut()を使用し、コピーせずにアカウントデータへの可変アクセスを取得します。

4. 重複アカウント脆弱性の軽減 

ゼロコピーのデシリアライズは、Borshのシリアライズに存在し得る脆弱性に対処します。Borshではアカウントの個別コピーが作成され、変更後に同じアドレスへ書き戻されます。同じアカウントが1つのトランザクションに複数回含まれていると、この処理によって不整合が生じる可能性があります。

一方、ゼロコピーでは同じメモリアドレスを直接読み書きします。そのため、トランザクション内にある同一アカウントへのすべての参照が同じデータを操作し、重複アカウントによる不整合のリスクを排除できます。

5. メモリレイアウトの保証

zero_copy属性は、CounterDataのメモリレイアウトを一定に保ち、生バイト列から安全に再解釈できるようにします。この実装により、initialize命令のCU使用量は5095から5022へ、increment命令は1162から1124へ減少しました。

今回のケースでは、ゼロコピーによる改善はわずかで、ほとんど無視できる程度です。ただし、大規模なデータ構造を扱う場合には、ゼロコピーのデシリアライズが役立つ可能性があります。複雑または大容量のデータを格納するアカウントを扱う際、CPUとメモリの使用量を大幅に削減できるためです。

トレードオフと考慮事項

ゼロコピーにも課題があります。

1. 複雑性の増加

生データを慎重に扱う必要があるため、コードは若干複雑になります。

2. 互換性

すべてのデータ構造がゼロコピーのデシリアライズに適しているわけではありません。予測可能なメモリレイアウトが必要です。たとえば、VecやStringのように動的サイズのフィールドを持つ構造体は、ゼロコピーのデシリアライズと互換性がありません。

ゼロコピーを使用するかどうかは、具体的なユースケースに基づいて判断する必要があります。今回のカウンターのような単純なプログラムでは、メリットはわずかかもしれません。しかし、プログラムが複雑になり、より大規模なデータ構造を扱うようになると、ゼロコピーは強力な最適化手段になります。

単純なカウンタープログラムではゼロコピーによる大幅な改善を得られませんでしたが、効率化の余地はまだあります。次は、Anchorフレームワークを使わずにRustでネイティブSolanaプログラムを作成する方法を見ていきます。このアプローチでは複雑さが増す一方、より細かな制御と最適化の可能性が得られます。

ネイティブRustの使用

ネイティブRustプログラムは、より低レベルなインターフェースを提供します。そのため開発者は、Anchorが自動化しているさまざまなタスクを自ら処理する必要があります。これには、アカウントのデシリアライズ、シリアライズ、各種セキュリティチェックが含まれます。開発者の負担は増えますが、きめ細かな最適化を行えるようになります。

カウンタープログラムのネイティブRust実装を見てみましょう。‍

コード
use solana_program::{
    account_info::{next_account_info, AccountInfo},
    entrypoint,
    entrypoint::ProgramResult,
    program_error::ProgramError,
    pubkey::Pubkey,
    rent::Rent,
    system_instruction,
    program::invoke,
    sysvar::Sysvar,
};
use std::mem::size_of;

// Define the state struct
struct Counter {
    count: u64,
}

// Declare and export the program's entrypoint
entrypoint!(process_instruction);

// Program entrypoint's implementation
pub fn process_instruction(
    program_id: &Pubkey,
    accounts: &[AccountInfo],
    instruction_data: &[u8],
) -> ProgramResult {
    let instruction = instruction_data
        .get(0)
        .ok_or(ProgramError::InvalidInstructionData)?;

    match instruction {
        0 => initialize(program_id, accounts),
        1 => increment(accounts),
        _ => Err(ProgramError::InvalidInstructionData),
    }
}

fn initialize(program_id: &Pubkey, accounts: &[AccountInfo]) -> ProgramResult {
    let account_info_iter = &mut accounts.iter();
    let counter_account = next_account_info(account_info_iter)?;
    let user = next_account_info(account_info_iter)?;
    let system_program = next_account_info(account_info_iter)?;

    if !user.is_signer {
        return Err(ProgramError::MissingRequiredSignature);
    }

    if counter_account.owner != program_id {
        let rent = Rent::get()?;
        let space = size_of::<Counter>();
        let rent_lamports = rent.minimum_balance(space);

        invoke(
            &system_instruction::create_account(
                user.key,
                counter_account.key,
                rent_lamports,
                space as u64,
                program_id,
            ),
            &[user.clone(), counter_account.clone(), system_program.clone()],
        )?;
    }

    let mut counter_data = Counter { count: 0 };
    counter_data.serialize(&mut &mut counter_account.data.borrow_mut()[..])?;

    Ok(())
}

fn increment(accounts: &[AccountInfo]) -> ProgramResult {
    let account_info_iter = &mut accounts.iter();
    let counter_account = next_account_info(account_info_iter)?;
    let user = next_account_info(account_info_iter)?;

    if !user.is_signer {
        return Err(ProgramError::MissingRequiredSignature);
    }

    let mut counter_data = Counter::deserialize(&counter_account.data.borrow())?;

    //Not doing checked_add, wrapping add or any overflow checks to keep it simple
    counter_data.count += 1;
    counter_data.serialize(&mut &mut counter_account.data.borrow_mut()[..])?;

    Ok(())
}

impl Counter {
    fn serialize(&self, data: &mut [u8]) -> ProgramResult {
        if data.len() < size_of::<Self>() {
            return Err(ProgramError::AccountDataTooSmall);
        }

        //First 8 bytes is the count
        data[..8].copy_from_slice(&self.count.to_le_bytes());
        Ok(())
    }

    fn deserialize(data: &[u8]) -> Result<Self, ProgramError> {
        if data.len() < size_of::<Self>() {
            return Err(ProgramError::AccountDataTooSmall);
        }

        //First 8 bytes is the count
        let count = u64::from_le_bytes(data[..8].try_into().unwrap());
        Ok(Self { count })
    }
}

主な違いと考慮事項

考慮すべき点は以下のとおりです。

1. 命令の手動解析

命令を自動的にルーティングするAnchorとは異なり、命令データを手動で解析し、適切な関数へルーティングします。

コード
let instruction = instruction_data
        .get(0)
        .ok_or(ProgramError::InvalidInstructionData)?;

    match instruction {
        0 => initialize(program_id, accounts),
        1 => increment(accounts),
        _ => Err(ProgramError::InvalidInstructionData),
    }

2. アカウント管理

next_account_infoを使用してアカウントを反復処理し、署名者と所有者を手動で確認します。Anchorでは、#[derive(Accounts)]マクロがこれを自動的に処理します。

コード
let account_info_iter = &mut accounts.iter();
    let counter_account = next_account_info(account_info_iter)?;
    let user = next_account_info(account_info_iter)?;

    if !user.is_signer {
        return Err(ProgramError::MissingRequiredSignature);
}

3. 独自のシリアライズ

Counter構造体に独自のserializeメソッドとdeserializeメソッドを実装します。AnchorはデフォルトでBorshシリアライズを使用し、この処理を抽象化しています。

コード
impl Counter {
    fn serialize(&self, data: &mut [u8]) -> ProgramResult {
        if data.len() < size_of::<Self>() {
            return Err(ProgramError::AccountDataTooSmall);
        }

        //First 8 bytes is the count
        data[..8].copy_from_slice(&self.count.to_le_bytes());
        Ok(())
    }

    fn deserialize(data: &[u8]) -> Result<Self, ProgramError> {
        if data.len() < size_of::<Self>() {
            return Err(ProgramError::AccountDataTooSmall);
        }

        //First 8 bytes is the count
        let count = u64::from_le_bytes(data[..8].try_into().unwrap());
        Ok(Self { count })
    }
}

4. System Programとの連携

アカウントを作成するには、invokeを使用してSystem Programと直接やり取りし、クロスプログラム呼び出し(CPI)を行います。Anchorではinit制約によってこの処理が簡素化されています。

コード
invoke(
      &system_instruction::create_account(
          user.key,
          counter_account.key,
          rent_lamports,
          space as u64,
          program_id,
      ),
      &[user.clone(), counter_account.clone(), system_program.clone()],
)?;

5. きめ細かな制御

一般にネイティブプログラムは、単一の規約型フレームワークに従わないため、データレイアウトと処理をより細かく制御でき、コードをさらに最適化できます。

AnchorとネイティブRustをどう捉えるべきか

検討すべき点は以下のとおりです。

1. 明示的か暗黙的か

ネイティブプログラムでは、Anchorが暗黙的に管理する多くの要素を明示的に処理する必要があります。これには、アカウントの検証、シリアライズ、命令のルーティングが含まれます。

2. セキュリティ上の考慮事項

Anchorに組み込まれたチェックを使用しない場合、開発者はアカウントの所有権や署名者ステータスの確認など、適切なセキュリティ対策を確実に実装する必要があります。

3. パフォーマンスチューニング

ネイティブプログラムでは、よりきめ細かなパフォーマンス最適化が可能ですが、Solanaランタイムの動作を深く理解する必要があります。

4. ボイラープレートコード

Anchorが抽象化する一般的な処理について、より多くのボイラープレートコードを書く必要があります。

5. 学習曲線

ネイティブプログラミングは効率が高まる可能性がある一方、学習曲線が急で、Solanaのアーキテクチャに関するより深い知識が必要です。

要約

Anchorからネイティブへ移行する際の最大の制約は、シリアライズとデシリアライズを自ら処理する必要があることです。今回のケースでは比較的単純でしたが、状態管理が複雑になるほど難易度も増していきます。

一方、Anchorで使用されるBorshは計算コストが非常に高いため、取り組む価値はあります。

最適化の取り組みはここで終わりません。次のセクションでは、直接システムコールを利用し、Rust標準ライブラリを使わないことで、さらに限界へ挑みます。

このアプローチは難易度が高いものの、Solanaランタイムの内部動作について興味深い知見が得られるはずです。

unsafe Rustと直接システムコールで限界を追求する

カウンタープログラムのパフォーマンスを限界まで高めるため、unsafe Rustと直接システムコールの使用を検討します。unsafe Rustでは標準の安全性チェックを回避でき、メモリの直接操作や低レベルな最適化が可能になります。一方、システムコールはSolanaランタイムへの直接的なインターフェースを提供します。

このアプローチは複雑で慎重な開発が必要ですが、CUを大幅に節約できる可能性があります。ただし、Solanaのアーキテクチャをより深く理解し、プログラムの安全性にも細心の注意を払う必要があります。パフォーマンスの向上幅は大きいものの、それに伴う責任も増します。

これらの高度な手法を活用して大幅に最適化したカウンタープログラムを見てみましょう。

コード
use solana_nostd_entrypoint::{
    basic_panic_impl, entrypoint_nostd, noalloc_allocator,
    solana_program::{
        entrypoint::ProgramResult, log, program_error::ProgramError, pubkey::Pubkey, system_program,
    },
    InstructionC, NoStdAccountInfo,
};

entrypoint_nostd!(process_instruction, 32);

pub const ID: Pubkey = solana_nostd_entrypoint::solana_program::pubkey!(
    "EgB1zom79Ek4LkvJjafbkUMTwDK9sZQKEzNnrNFHpHHz"
);

noalloc_allocator!();
basic_panic_impl!();

const ACCOUNT_DATA_LEN: usize = 8; // 8 bytes for u64 counter

/*
 * Program Entrypoint
 * ------------------
 * Entrypoint receives:
 * - program_id: The public key of the program's account
 * - accounts: An array of accounts required for the instruction
 * - instruction_data: A byte array containing the instruction data
 *
 * Instruction data format:
 * ------------------------
 * | Bit 0 | Bits 1-7 |
 * |-------|----------|
 * |  0/1  |  Unused  |
 *
 * 0: Initialize
 * 1: Increment
 */
#[inline(always)]
pub fn process_instruction(
    _program_id: &Pubkey,
    accounts: &[NoStdAccountInfo],
    instruction_data: &[u8],
) -> ProgramResult {

    if instruction_data.is_empty() {
        return Err(ProgramError::InvalidInstructionData);
    }

    // Use the least significant bit to determine the instruction
    match instruction_data[0] & 1 {
        0 => initialize(accounts),
        1 => increment(accounts),
        _ => unreachable!(),
    }
}

/*
 * Initialize Function
 * -------------------
 * This function initializes a new counter account.
 *
 * Account structure:
 * ------------------
 * 1. Payer account (signer, writable)
 * 2. Counter account (writable)
 * 3. System program
 *
 * Memory layout of instruction_data:
 * -----------------------------------------
 * | Bytes    | Content                     |
 * |----------|----------------------------|
 * | 0-3      | Instruction discriminator  |
 * | 4-11     | Required lamports (u64)    |
 * | 12-19    | Space (u64)                |
 * | 20-51    | Program ID                 |
 * | 52-55    | Unused                     |
 */
#[inline(always)]
fn initialize(accounts: &[NoStdAccountInfo]) -> ProgramResult {

    let [payer, counter, system_program] = match accounts {
        [payer, counter, system_program, ..] => [payer, counter, system_program],
        _ => return Err(ProgramError::NotEnoughAccountKeys),
    };

    if counter.key() == &system_program::ID {
        return Err(ProgramError::InvalidAccountData);
    }

    let rent = solana_program::rent::Rent::default();
    let required_lamports = rent.minimum_balance(ACCOUNT_DATA_LEN);

    let mut instruction_data = [0u8; 56];
    instruction_data[4..12].copy_from_slice(&required_lamports.to_le_bytes());
    instruction_data[12..20].copy_from_slice(&(ACCOUNT_DATA_LEN as u64).to_le_bytes());
    instruction_data[20..52].copy_from_slice(ID.as_ref());

    let instruction_accounts = [
        payer.to_meta_c(),
        counter.to_meta_c(),
    ];

    let instruction = InstructionC {
        program_id: &system_program::ID,
        accounts: instruction_accounts.as_ptr(),
        accounts_len: instruction_accounts.len() as u64,
        data: instruction_data.as_ptr(),
        data_len: instruction_data.len() as u64,
    };

    let infos = [payer.to_info_c(), counter.to_info_c()];

    // Invoke system program to create account
    #[cfg(target_os = "solana")]
    unsafe {
        solana_program::syscalls::sol_invoke_signed_c(
            &instruction as *const InstructionC as *const u8,
            infos.as_ptr() as *const u8,
            infos.len() as u64,
            std::ptr::null(),
            0,
        );
    }

    // Initialize counter to 0
    let mut counter_data = counter.try_borrow_mut_data().ok_or(ProgramError::AccountBorrowFailed)?;
    counter_data[..8].copy_from_slice(&0u64.to_le_bytes());

    Ok(())
}

/*
 * Increment Function
 * ------------------
 * This function increments the counter in the counter account.
 *
 * Account structure:
 * ------------------
 * 1. Counter account (writable)
 * 2. Payer account (signer)
 *
 * Counter account data layout:
 * ----------------------------
 * | Bytes | Content        |
 * |-------|----------------|
 * | 0-7   | Counter (u64)  |
 */
#[inline(always)]
fn increment(accounts: &[NoStdAccountInfo]) -> ProgramResult {

    let [counter, payer] = match accounts {
        [counter, payer, ..] => [counter, payer],
        _ => return Err(ProgramError::NotEnoughAccountKeys),
    };

    if !payer.is_signer() || counter.owner() != &ID {
        return Err(ProgramError::IllegalOwner);
    }

    let mut counter_data = counter.try_borrow_mut_data().ok_or(ProgramError::AccountBorrowFailed)?;

    if counter_data.len() != 8 {
        return Err(ProgramError::UninitializedAccount);
    }

    let mut value = u64::from_le_bytes(counter_data[..8].try_into().unwrap());
    value += 1;
    counter_data[..8].copy_from_slice(&value.to_le_bytes());

    Ok(())
}

主な違いと最適化

最適化の内容を見ていきます。

1. no-std環境

no-std環境を提供するsolana_nostd_entrypointを使用します。これによりRust標準ライブラリのオーバーヘッドがなくなり、プログラムサイズを削減し、パフォーマンスを向上できる可能性があります。Solanaプログラム向けのno-stdエントリーポイントを公開したcavemanloverboyと同氏のGitHubリポジトリに感謝します。

2. インライン関数

重要な関数には#[inline(always)]を付けます。インライン化とは、関数本体を呼び出し箇所に挿入し、関数呼び出しのオーバーヘッドをなくすコンパイラ最適化です。特に、小さく頻繁に呼び出される関数では、実行速度が向上する可能性があります。

3. 命令解析でのビット操作

命令の種類を判定するためにビット操作instruction_data[0] & 1を使用します。これは他の解析方法より効率的な場合があります。

コード
// Use the least significant bit to determine the instruction
    match instruction_data[0] & 1 {
        0 => initialize(accounts),
        1 => increment(accounts),
        _ => unreachable!(),
 }

4. ゼロコストのメモリ管理と最小限のパニック処理

noalloc_allocator!マクロとbasic_panic_impl!マクロは、オーバーヘッドのない最小限のメモリ管理とパニック処理を実装します。

Noalloc_allocator!は、あらゆる割り当ての試行時にパニックを発生させ、解放時には何もしない独自のアロケータを定義します。これをSolanaプログラムのグローバルアロケータに設定すると、実行時の動的メモリ割り当てを実質的に防止できます。

コード
#[macro_export]
macro_rules! noalloc_allocator {
    () => {
        pub mod allocator {
            pub struct NoAlloc;
            extern crate alloc;
            unsafe impl alloc::alloc::GlobalAlloc for NoAlloc {
                #[inline]
                unsafe fn alloc(&self, _: core::alloc::Layout) -> *mut u8 {
                    panic!("no_alloc :)");
                }
                #[inline]
                unsafe fn dealloc(&self, _: *mut u8, _: core::alloc::Layout) {}
            }

            #[cfg(target_os = "solana")]
            #[global_allocator]
            static A: NoAlloc = NoAlloc;
        }
    };
}

これが重要な理由は以下のとおりです。

  1. メモリの割り当てと解放に伴うオーバーヘッドを排除します
  2. 開発者にスタックベースまたは静的メモリの使用を強制します。これらは一般に高速で、パフォーマンスも予測しやすくなります
  3. プログラムのメモリ使用量を削減します

basic_panic_impl!は、「panicked!」というメッセージをログに出力するだけの最小限のパニックハンドラを提供します。

コード
#[macro_export]
macro_rules! basic_panic_impl {
    () => {
        #[cfg(target_os = "solana")]
        #[no_mangle]
        fn custom_panic(_info: &core::panic::PanicInfo<'_>) {
            log::sol_log("panicked!");
        }
    };
}

5. 効率的なCPIの準備

InstructionC構造体、to_meta_c関数、to_info_c関数は、CPI用のデータを低レベルかつ効率的に準備する方法を提供します。

コード
let instruction_accounts = [
        payer.to_meta_c(),
        counter.to_meta_c(),
    ];

    let instruction = InstructionC {
        program_id: &system_program::ID,
        accounts: instruction_accounts.as_ptr(),
        accounts_len: instruction_accounts.len() as u64,
        data: instruction_data.as_ptr(),
        data_len: instruction_data.len() as u64,
 	};

 let infos = [payer.to_info_c(), counter.to_info_c()
];

これらの関数は、sol_invoke_signed_cシステムコールへ直接渡せるC互換の構造体を作成します。Rustの高レベルな抽象化によるオーバーヘッドを避け、生ポインタとC互換の構造体を直接扱うことで、CPIの準備にかかる計算コストを最小限に抑えます。

このアプローチでは、より抽象度の高いRust型を使用した場合に通常発生するメモリ割り当て、コピー、変換を減らし、CUを節約できます。

たとえば、to_info_cメソッドは直接的なポインタ演算を使用して、AccountInfoC構造体を効率的に構築します。

コード
pub fn to_info_c(&self) -> AccountInfoC {
  AccountInfoC {
  key: offset(self.inner, 8),
  lamports: offset(self.inner, 72),
  data_len: self.data_len() as u64,
  data: offset(self.inner, 88),
  owner: offset(self.inner, 40),
  // … other fields …
  }
}

このようにメモリレイアウトを直接操作すると、CPIに必要な構造体を極めて効率的に作成でき、これらの処理にかかるCUコストを削減できます。

6. 直接システムコールとunsafe Rust

このアプローチは通常のRust抽象化を迂回してSolanaランタイムと直接やり取りするため、パフォーマンスを大幅に向上できます。ただし、複雑さが増し、unsafe Rustを慎重に扱う必要があります。

コード
// Invoke system program to create account
#[cfg(target_os = "solana")]
unsafe {
    solana_program::syscalls::sol_invoke_signed_c(
        &instruction as *const InstructionC as *const u8,
        infos.as_ptr() as *const u8,
        infos.len() as u64,
        std::ptr::null(),
        0,
    );
}

7. 条件付きコンパイル:

#[cfg(target_os = “solana”)]属性により、Solanaランタイムをターゲットとする場合にのみこのコードがコンパイルされます。これらのシステムコールはその環境でのみ利用できるため、この設定が必要です。

unsafe Rustの潜在的な問題 

unsafe Rustは強力ですが、正しく扱わないと深刻な問題につながる可能性があります。

  • メモリリークとメモリ破損
  • 未定義動作
  • 競合状態

unsafe Rustを使用する際のリスクを軽減するには、以下を実践します。

  • unsafeブロックは必要な場合に限り、最小限に使用します
  • 安全性に関するすべての前提条件と不変条件を文書化します
  • テストにはMiriやRust組み込みのサニタイザーなどのツールを活用します
  • 重要なセクションには形式検証の手法を検討します
  • unsafeブロックに重点を置いた徹底的なコードレビューを実施します

要約

これらは非常に興味深い手法ですが、実際の資産を保護する本番対応プログラムにこの高度な最適化アプローチを採用するのは簡単ではありません。複雑性が増し、エラーが発生しやすくなり、メンテナンスも困難になるためです。大半のアプリケーションでは、重大なバグを持ち込むリスクがパフォーマンス上の利点を上回ります。

このアプローチを使うと、早すぎる最適化の罠に陥る可能性が非常に高くなります。

‍ただし、簡単に再現できるものもあります。

  1. solana_programによる肥大化したentrypointの代わりにnostd_entrypointを使用します
  2. 可能な限りインライン関数を使用します
  3. 動的割り当てを最小限に抑え、スタックベースのデータ構造を優先します‍

まとめ 

この記事では、高レベルなAnchor開発から直接システムコールを使用する低レベルなunsafe Rustまで、Solanaプログラムにおけるさまざまな最適化レベルを紹介しました。それぞれのアプローチで、使いやすさ、安全性、パフォーマンスのトレードオフが異なることを確認しました。‍

主なポイント:

  • Anchorは使いやすいフレームワークですが、一定のパフォーマンスオーバーヘッドがあります
  • ゼロコピーのデシリアライズは、大規模なデータ構造の効率を大幅に向上できる可能性があります
  • ネイティブRustでは、より細かな制御と最適化が可能です
  • unsafe Rustと直接システムコールは最高のパフォーマンスを実現しますが、複雑性とリスクも増加します

どの最適化レベルを選ぶかは、具体的なユースケース、パフォーマンス要件、許容できるリスクによって異なります。最適化の効果を必ず測定し、その選択が長期的なメンテナンスに与える影響も考慮してください。

ここまでお読みいただき、ありがとうございます!以下にメールアドレスを入力して、Solanaの最新情報を見逃さないようにしてください。さらに詳しく知りたい方は、Heliusブログの最新記事を読み、今すぐSolanaの探求を続けましょう。

追加リソース

Heliusを購読

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

拡大画像