신규: 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 syscall을 사용하세요
  • 컴퓨트 유닛 사용량을 측정해 최적화 방향을 정하세요

소개

Solana 개발자는 프로그램을 작성할 때 사용 편의성, 성능, 안전성 사이에서 여러 결정을 내려야 합니다. 선택지는 일부 오버헤드를 감수하는 대신 개발을 간소화하는 사용자 친화적인 Anchor 프레임워크부터 unsafe Rust와 직접 syscall을 사용하는 저수준 접근 방식까지 다양합니다. 후자는 최고의 성능을 제공하지만 복잡성과 잠재적인 보안 위험이 커집니다. 개발자에게 중요한 질문은 단순히 어떻게 최적화할지가 아니라 언제, 어느 수준까지 최적화할지입니다.

이 글에서는 이러한 선택지를 자세히 살펴보고 개발자가 최적화 환경을 탐색할 수 있는 로드맵을 제공합니다. 다음과 같은 추상화 수준을 살펴보겠습니다.

  1. Anchor: 대부분의 개발자가 선택하는 강력하고 독자적인 고수준 프레임워크
  2. 제로 카피를 적용한 Anchor: 대규모 데이터 구조에 최적화된 Anchor 코드
  3. 제어력과 사용 편의성의 균형을 위한 순수 Rust
  4. 직접 시스템 호출(syscall)을 사용하는 Unsafe Rust: 성능의 한계에 도전하는 방식

목표는 모든 상황에 맞는 하나의 해법을 제시하는 것이 아닙니다. 개발자가 구체적인 사용 사례에 따라 프로그램 작성 방식을 합리적으로 결정할 수 있도록 필요한 지식을 제공하는 것입니다.

이 글을 모두 읽으면 다양한 추상화 수준을 어떻게 바라봐야 하는지, 언제 더 낮은 수준의 최적화를 고려해야 하는지 더 잘 이해할 수 있습니다. 가장 많이 최적화된 코드가 항상 최선은 아닙니다. 프로젝트 요구 사항에 맞는 균형을 찾는 것이 중요합니다.

이 글은 Rust 기초, Solana의 계정 모델, Anchor 프레임워크에 익숙하다고 가정합니다. 

결과부터 보고 싶다면 다음과 같습니다.

컴퓨트 유닛

Solana의 고성능 아키텍처는 효율적인 리소스 관리에 기반합니다. 이 시스템의 중심에는 컴퓨트 유닛(CU)이 있습니다. 검증인이 특정 트랜잭션을 처리하는 데 사용한 컴퓨팅 리소스를 나타내는 단위입니다.

‍컴퓨트 유닛이 중요한 이유는 무엇인가요?

  1. 트랜잭션 성공: 각 트랜잭션에는 CU 한도가 있습니다. 한도를 초과하면 트랜잭션이 실패합니다
  2. 비용 효율성: CU 사용량이 적으면 트랜잭션 수수료도 낮아집니다
  3. 사용자 경험: 최적화된 프로그램은 더 빠르게 실행되어 전반적인 UX를 개선합니다
  4. 확장성: 효율적인 프로그램은 블록당 더 많은 트랜잭션을 허용해 네트워크 처리량을 높입니다

컴퓨트 유닛 측정

solana_program::log::sol_log_compute_units() syscall은 프로그램 실행 중 특정 시점까지 소비한 컴퓨트 유닛 수를 로그에 기록합니다.

다음은 syscall을 사용하는 간단한 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이라는 두 명령어가 있는 카운터 프로그램을 구현합니다.

이 글에서는 동일한 두 명령어 initialize과 increment을 사용하는 같은 카운터 프로그램을 네 가지 방식으로 작성하고 CU 사용량을 비교합니다. Anchor, 제로 카피 역직렬화를 사용하는 Anchor, 네이티브 Rust, unsafe Rust입니다.

계정을 초기화하고 해당 계정에 작은 변경을 적용하는 작업은 여기서 증가 연산에 해당하며, 여러 접근 방식을 비교하기에 적절한 벤치마크입니다. 지금은 PDA를 사용하지 않습니다.

결과부터 보고 싶다면 네 가지 접근 방식의 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에서는 계정의 별도 복사본을 생성하고 변경한 뒤 같은 주소로 다시 복사합니다. 이 과정에서 동일한 계정이 한 트랜잭션에 여러 번 포함되면 불일치가 발생할 수 있습니다.

반면 제로 카피는 동일한 메모리 주소에서 직접 읽고 씁니다. 따라서 트랜잭션 내에서 계정을 참조하는 모든 작업이 동일한 데이터를 사용하며, 중복 계정으로 인한 불일치 위험을 제거합니다.

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는 컴퓨팅 비용이 매우 크므로 그만한 노력에는 가치가 있습니다.

최적화 여정은 여기서 끝나지 않습니다. 다음 섹션에서는 직접 syscall을 활용하고 Rust 표준 라이브러리를 사용하지 않는 방식으로 한계를 더 넓혀보겠습니다.

이 접근 방식은 어렵지만 Solana 런타임의 내부 동작에 관한 흥미로운 통찰을 제공할 것입니다.

Unsafe Rust와 직접 Syscall로 한계에 도전하기

이제 카운터 프로그램의 성능을 한계까지 끌어올리기 위해 unsafe Rust와 직접 syscall을 살펴보겠습니다. Unsafe Rust를 사용하면 표준 안전성 검사를 우회해 메모리를 직접 조작하고 저수준 최적화를 적용할 수 있습니다. Syscall은 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 엔트리포인트를 다룬 GitHub 저장소를 공개한 cavemanloverboy에게 감사를 전합니다.

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 syscall에 직접 전달할 수 있는 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. 직접 Syscall과 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 런타임을 대상으로 할 때만 이 코드가 컴파일되도록 합니다. 이러한 syscall은 해당 환경에서만 사용할 수 있으므로 반드시 필요합니다.

Unsafe Rust의 잠재적 문제 

Unsafe Rust는 강력하지만 올바르게 처리하지 않으면 심각한 문제를 일으킬 수 있습니다.

  • 메모리 누수 및 손상
  • 정의되지 않은 동작
  • 경쟁 상태

Unsafe Rust 사용 시 위험을 줄이는 방법은 다음과 같습니다.

  • unsafe 블록은 필요한 경우에만 최소한으로 사용하세요
  • 모든 안전성 가정과 불변 조건을 문서화하세요
  • 테스트에 Miri와 Rust 기본 sanitizer 같은 도구를 활용하세요
  • 핵심 섹션에는 정형 검증 기법을 고려하세요
  • unsafe 블록에 초점을 맞춰 철저하게 코드를 검토하세요

요약

이 모든 내용은 흥미롭지만 실제 자금을 보호하는 프로덕션용 프로그램에 이처럼 극단적으로 최적화된 방식을 적용하기는 어렵습니다. 복잡성이 커지고 오류 가능성이 높아지며 유지보수가 어려워지기 때문입니다. 대부분의 애플리케이션에서는 치명적인 버그가 생길 위험이 성능상의 이점보다 큽니다.

이 접근 방식을 사용하면 성급한 최적화의 함정에 빠질 가능성이 매우 높습니다.

‍하지만 쉽게 재현할 수 있는 몇 가지 방법도 있습니다.

  1. solana_program이 생성하는 비대한 entrypoint 대신 nostd_entrypoint 사용
  2. 가능한 모든 곳에서 인라인 함수 사용
  3. 동적 할당을 최소화하고 스택 기반 데이터 구조 우선‍

결론 

이 글에서는 고수준 Anchor 개발부터 직접 syscall을 사용하는 저수준 unsafe Rust까지 Solana 프로그램을 최적화하는 여러 수준을 살펴봤습니다. 각 접근 방식이 사용 편의성, 안전성, 성능 사이에서 서로 다른 절충점을 제공한다는 점을 확인했습니다.‍

핵심 요약:

  • Anchor는 사용자 친화적인 프레임워크를 제공하지만 일부 성능 오버헤드가 있습니다
  • 제로 카피 역직렬화는 대규모 데이터 구조의 효율을 크게 높일 수 있습니다
  • 네이티브 Rust는 더 높은 제어력과 최적화 가능성을 제공합니다
  • Unsafe Rust와 직접 syscall은 최고의 성능을 제공하지만 복잡성과 위험이 커집니다

최적화 수준은 구체적인 사용 사례, 성능 요구 사항, 위험 감수 수준에 따라 선택해야 합니다. 항상 최적화의 영향을 측정하고 선택에 따른 장기적인 유지보수 부담을 고려하세요.

끝까지 읽어주셔서 감사합니다! 아래에 이메일 주소를 입력하고 Solana의 새로운 소식을 빠짐없이 받아보세요. 더 깊이 알아볼 준비가 되셨나요? 지금 Helius 블로그의 최신 글을 살펴보고 Solana 여정을 이어가세요.

추가 자료

Helius 구독하기

최신 Solana 개발 소식을 확인하고 새 게시물 알림을 받아보세요

확대 이미지