BARU: Helius mengakuisisi Light Protocol
Mengoptimalkan Program Solana
Blog/Pengembangan

Mengoptimalkan Program Solana

Insinyur IntegrasiHet Dagli di XHet Dagli di LinkedIn
Bacaan 12 menit

Wawasan Praktis

  • Gunakan deserialisasi zero-copy untuk struktur data besar dan operasi berfrekuensi tinggi
  • Gunakan nostd_entrypoint alih-alih entrypoint solana_program’s yang membengkak
  • Minimalkan alokasi dinamis dengan mengutamakan struktur data berbasis stack
  • Terapkan serialisasi/deserialisasi khusus untuk menghindari overhead Borsh
  • Tandai fungsi penting dengan #[inline(always)] untuk potensi peningkatan performa
  • Gunakan manipulasi bit untuk parsing instruksi yang efisien
  • Gunakan syscall C khusus Solana seperti sol_invoke_signed_c
  • Ukur penggunaan unit komputasi untuk memandu upaya pengoptimalan

Pendahuluan

Developer Solana menghadapi beberapa keputusan saat menulis program: menyeimbangkan kemudahan penggunaan, performa, dan keamanan. Spektrum ini dimulai dari framework Anchor yang ramah pengguna, yang menyederhanakan pengembangan dengan konsekuensi sejumlah overhead, hingga pendekatan tingkat rendah yang menggunakan unsafe Rust dan syscall langsung. Meskipun pendekatan terakhir menawarkan performa puncak, kompleksitas dan potensi risiko keamanannya juga meningkat. Pertanyaan utama bagi developer bukan sekadar bagaimana melakukan pengoptimalan, tetapi kapan dan sejauh apa.

Artikel blog ini membahas berbagai opsi tersebut secara mendalam dan menyediakan panduan bagi developer untuk menjelajahi lanskap pengoptimalan. Kita akan mempelajari tingkat abstraksi berikut:

  1. Anchor: Framework tingkat tinggi yang kuat, opinionated, dan menjadi pilihan utama sebagian besar developer
  2. Anchor dengan zero-copy: Kode Anchor yang ditulis untuk mengoptimalkan struktur data besar
  3. Rust murni untuk menyeimbangkan kontrol dan kemudahan penggunaan
  4. Unsafe Rust dengan panggilan sistem (syscall) langsung: Mendorong performa hingga batas maksimal

Tujuannya bukan menetapkan satu solusi yang cocok untuk semua, melainkan membekali developer dengan pengetahuan untuk mengambil keputusan yang tepat tentang cara menulis kode program berdasarkan kasus penggunaan spesifik mereka.

Di akhir artikel ini, Anda akan lebih memahami cara mempertimbangkan berbagai tingkat abstraksi tersebut dan kapan perlu melanjutkan ke tahap pengoptimalan berikutnya. Ingat, kode yang paling optimal tidak selalu menjadi solusi terbaik — yang terpenting adalah menemukan keseimbangan yang tepat bagi kebutuhan proyek Anda.

Artikel ini mengasumsikan bahwa Anda memahami dasar-dasar Rust, model account Solana, dan framework Anchor. 

Bagi yang tidak sabar:

Unit Komputasi

Arsitektur Solana berperforma tinggi mengandalkan pengelolaan sumber daya yang efisien. Inti sistem ini adalah unit komputasi (CU) — ukuran sumber daya komputasi yang digunakan validator untuk memproses transaksi tertentu.

‍Mengapa unit komputasi penting?

  1. Keberhasilan Transaksi: Setiap transaksi memiliki batas CU. Jika batas tersebut terlampaui, transaksi akan gagal
  2. Efisiensi Biaya: Penggunaan CU yang lebih rendah berarti biaya transaksi yang lebih rendah
  3. Pengalaman Pengguna: Program yang dioptimalkan berjalan lebih cepat sehingga meningkatkan UX secara keseluruhan
  4. Skalabilitas: Program yang efisien memungkinkan lebih banyak transaksi per blok sehingga meningkatkan throughput jaringan

Mengukur Unit Komputasi

Syscall solana_program::log::sol_log_compute_units() mencatat jumlah unit komputasi yang digunakan program pada titik tertentu selama eksekusinya.

Berikut adalah implementasi makro compute_fn! sederhana yang menggunakan syscall:

Kode
#[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
    };
}

Makro ini diambil dari repositori GitHub Solana Developers untuk pengoptimalan CU. Cuplikan kode ini menerapkan program penghitung dengan dua instruksi, initialize dan increment

Untuk artikel ini, kita akan menulis program penghitung yang sama dengan dua instruksi yang sama, initialize dan increment, dalam empat cara berbeda lalu membandingkan penggunaan CU semuanya: Anchor, Anchor dengan deserialisasi zero-copy, Rust native, dan unsafe Rust

Menginisialisasi account dan membuat perubahan kecil pada account tersebut (dalam kasus ini, menambah nilainya) merupakan tolok ukur yang cukup baik untuk membandingkan berbagai pendekatan ini. Untuk saat ini, kita tidak akan menggunakan PDA.

Bagi yang tidak sabar, berikut perbandingan CU untuk keempat pendekatan tersebut:

Mari kita mulai…

Deserialisasi Zero-Copy

Deserialisasi zero-copy memungkinkan kita menafsirkan data account secara langsung tanpa mengalokasikan memori baru atau menyalin data. Teknik ini dapat mengurangi penggunaan CPU dan memori serta berpotensi menghasilkan instruksi yang lebih efisien.

Mari kita mulai dengan program penghitung Anchor dasar:‍

Kode
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,
}

Tidak ada yang istimewa di atas. Sekarang mari kita tingkatkan dengan zero_copy:

Kode
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,
}

Perubahan Utama:

Berikut perubahan utama yang kita buat:

1. AccountLoader, Bukan Account

Sekarang kita menggunakan AccountLoader<’info, CounterData> alih-alih Account<’info, Counter>. Ini memungkinkan akses zero-copy ke data account.

2. Atribut Zero-Copy

Atribut #[account(zero_copy)] pada CounterData menunjukkan bahwa struct ini dapat ditafsirkan secara langsung dari byte mentah di memori.

3. Akses Data Langsung

Dalam fungsi initialize dan increment, kita masing-masing menggunakan load_init() dan load_mut() untuk mendapatkan akses yang dapat diubah ke data account tanpa menyalinnya

4. Mengurangi Kerentanan Account Duplikat 

Deserialisasi zero-copy mengatasi potensi kerentanan dalam serialisasi Borsh. Dengan Borsh, salinan account yang berbeda dibuat dan diubah, lalu disalin kembali ke alamat yang sama. Proses ini dapat menimbulkan inkonsistensi jika account yang sama disertakan beberapa kali dalam suatu transaksi.

Namun, zero-copy membaca dari dan menulis ke alamat memori yang sama secara langsung. Pendekatan ini memastikan semua referensi ke suatu account dalam transaksi beroperasi pada data yang sama sehingga menghilangkan risiko inkonsistensi akibat account duplikat.

5. Jaminan Tata Letak Memori

Atribut zero_copy memastikan bahwa CounterData memiliki tata letak memori yang konsisten sehingga memungkinkan penafsiran ulang yang aman dari byte mentah. Implementasi ini mengurangi penggunaan CU instruksi initialize dari 5095 menjadi 5022 dan instruksi increment dari 1162 menjadi 1124.

Dalam kasus kita, zero-copy hanya menghasilkan peningkatan minimal yang sebagian besar tidak signifikan. Namun, deserialisasi zero-copy dapat berguna saat menangani struktur data besar. Alasannya, teknik ini dapat mengurangi penggunaan CPU dan memori secara signifikan ketika menangani account yang menyimpan data kompleks atau berukuran besar

Kompromi dan Pertimbangan

Zero-copy juga memiliki tantangan:

1. Kompleksitas yang Meningkat

Kode menjadi sedikit lebih kompleks dan memerlukan penanganan data mentah secara cermat

2. Kompatibilitas

Tidak semua struktur data cocok untuk deserialisasi zero-copy — struktur tersebut harus memiliki tata letak memori yang dapat diprediksi. Contohnya, struktur dengan field berukuran dinamis seperti Vec atau String tidak kompatibel dengan deserialisasi zero-copy.

Penggunaan zero-copy harus didasarkan pada kasus penggunaan spesifik Anda. Untuk program sederhana seperti penghitung kita, manfaatnya mungkin minimal. Namun, seiring program bertambah kompleks dan menangani struktur data yang lebih besar, zero-copy dapat menjadi alat pengoptimalan yang efektif.

Meskipun pengoptimalan zero-copy tidak menghasilkan peningkatan signifikan untuk program penghitung sederhana kita, upaya mencapai efisiensi tidak berhenti di sini. Mari kita jelajahi opsi lain: menulis program Solana native dalam Rust tanpa framework Anchor. Pendekatan ini menawarkan kontrol dan potensi pengoptimalan yang lebih besar, meskipun dengan kompleksitas yang lebih tinggi.

Menggunakan Rust Native

Program Rust native menyediakan antarmuka tingkat rendah sehingga developer harus menangani berbagai tugas yang diotomatiskan Anchor. Ini mencakup deserialisasi dan serialisasi account serta berbagai pemeriksaan keamanan. Meskipun menuntut lebih banyak dari developer, pendekatan ini juga membuka peluang untuk pengoptimalan yang lebih terperinci.

Mari kita pelajari implementasi Rust native dari program penghitung kita:‍

Kode
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 })
    }
}

Perbedaan dan Pertimbangan Utama

Berikut beberapa hal yang perlu dipertimbangkan:

1. Parsing Instruksi Manual

Tidak seperti Anchor yang merutekan instruksi secara otomatis, kita melakukan parsing data instruksi secara manual dan merutekannya ke fungsi yang sesuai.

Kode
let instruction = instruction_data
        .get(0)
        .ok_or(ProgramError::InvalidInstructionData)?;

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

2. Pengelolaan Account

Kita menggunakan next_account_info untuk melakukan iterasi melalui account, lalu memeriksa signer dan owner secara manual. Anchor menangani ini secara otomatis dengan makro #[derive(Accounts)].

Kode
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. Serialisasi Khusus

Kita menerapkan metode serialize dan deserialize khusus untuk struct Counter. Secara default, Anchor menggunakan serialisasi Borsh dan menyembunyikan detail ini melalui abstraksi.

Kode
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. Interaksi dengan System Program

Pembuatan account melibatkan interaksi langsung dengan System Program menggunakan invoke dan melakukan Cross Program Invocation (CPI), yang disederhanakan Anchor melalui constraint init:

Kode
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. Kontrol Terperinci

Secara umum, program native menawarkan kontrol lebih besar atas tata letak dan pemrosesan data karena tidak mengikuti satu framework opinionated tertentu sehingga memungkinkan kode yang lebih optimal.

Cara Mempertimbangkan Anchor versus Rust Native

Berikut beberapa hal yang perlu dipertimbangkan:

1. Eksplisit vs. Implisit

Program native mengharuskan penanganan eksplisit atas banyak aspek yang dikelola Anchor secara implisit. Ini mencakup validasi account, serialisasi, dan perutean instruksi

2. Pertimbangan Keamanan

Tanpa pemeriksaan bawaan Anchor, developer harus cermat dalam menerapkan langkah-langkah keamanan yang tepat, seperti memeriksa kepemilikan account dan status signer

3. Penyesuaian Performa

Program native memungkinkan pengoptimalan performa yang lebih terperinci, tetapi memerlukan pemahaman lebih mendalam tentang perilaku runtime Solana

4. Kode Boilerplate

Bersiaplah menulis lebih banyak kode boilerplate untuk operasi umum yang diabstraksikan oleh Anchor

5. Kurva Pembelajaran

Meskipun berpotensi lebih efisien, pemrograman native memiliki kurva pembelajaran yang lebih curam dan memerlukan pengetahuan lebih mendalam tentang arsitektur Solana

Ringkasnya

Faktor pembatas terbesar saat beralih dari Anchor ke native adalah penanganan serialisasi dan deserialisasi. Dalam kasus kita, prosesnya relatif sederhana. Namun, ini akan semakin rumit seiring bertambahnya kompleksitas pengelolaan state.

Namun, Borsh yang digunakan Anchor juga sangat mahal secara komputasi sehingga upaya tersebut sepadan.

Perjalanan pengoptimalan kita belum berakhir. Di bagian berikutnya, kita akan melangkah lebih jauh dengan memanfaatkan syscall langsung dan menghindari pustaka standar Rust.

Pendekatan ini menantang, tetapi saya jamin akan memberikan wawasan menarik tentang cara kerja internal runtime Solana.

Mendorong Batas dengan Unsafe Rust dan Syscall Langsung

Untuk mendorong performa program penghitung kita hingga batas maksimal, sekarang kita akan mempelajari penggunaan unsafe Rust dan syscall langsung. Unsafe Rust memungkinkan developer melewati pemeriksaan keamanan standar sehingga dapat melakukan manipulasi memori langsung dan pengoptimalan tingkat rendah. Sementara itu, syscall menyediakan antarmuka langsung ke runtime Solana.

Meskipun kompleks dan memerlukan pengembangan yang sangat cermat, pendekatan ini dapat menghasilkan penghematan CU yang signifikan. Namun, pendekatan ini juga menuntut pemahaman lebih mendalam tentang arsitektur Solana dan perhatian khusus terhadap keamanan program. Potensi peningkatan performanya besar, tetapi tanggung jawab yang menyertainya juga meningkat.

Mari kita pelajari versi program penghitung kita yang sangat optimal dengan memanfaatkan berbagai teknik lanjutan ini:

Kode
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(())
}

Perbedaan dan Pengoptimalan Utama

Mari kita lihat pengoptimalannya:

1. Lingkungan No-std

Kita menggunakan solana_nostd_entrypoint, yang menyediakan lingkungan no-std. Ini menghilangkan overhead pustaka standar Rust, mengurangi ukuran program, dan berpotensi meningkatkan performa. Apresiasi untuk cavemanloverboy dan repositori GitHub miliknya tentang entrypoint no-std untuk program Solana.

2. Fungsi Inline

Fungsi penting ditandai dengan #[inline(always)]. Inlining adalah pengoptimalan compiler yang menyisipkan body fungsi di lokasi pemanggilan sehingga menghilangkan overhead pemanggilan fungsi. Ini dapat mempercepat eksekusi, terutama untuk fungsi kecil yang sering dipanggil.

3. Manipulasi Bit untuk Parsing Instruksi

Kita menggunakan manipulasi bit instruction_data[0] & 1 untuk menentukan jenis instruksi, yang dapat lebih efisien daripada metode parsing lainnya:

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

4. Pengelolaan Memori Tanpa Biaya dan Penanganan Panic Minimal

Makro noalloc_allocator! dan basic_panic_impl! menerapkan pengelolaan memori tanpa overhead dan penanganan panic minimal:

Noalloc_allocator! mendefinisikan allocator khusus yang memicu panic pada setiap upaya alokasi dan tidak melakukan apa pun saat dealokasi. Menetapkannya sebagai allocator global untuk program Solana secara efektif mencegah alokasi memori dinamis selama runtime:

Kode
#[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;
        }
    };
}

Ini sangat penting karena:

  1. Menghilangkan overhead operasi alokasi dan dealokasi memori
  2. Memaksa developer menggunakan memori berbasis stack atau statis, yang umumnya lebih cepat dan performanya lebih dapat diprediksi
  3. Mengurangi jejak memori program

basic_panic_impl! menyediakan panic handler minimal yang hanya mencatat pesan “panicked!”:

Kode
#[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. Persiapan CPI yang Efisien

Struct InstructionC serta fungsi to_meta_c dan to_info_c menyediakan cara tingkat rendah yang efisien untuk menyiapkan data bagi CPI:

Kode
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()
];

Fungsi-fungsi ini membuat struktur yang kompatibel dengan C dan dapat diteruskan secara langsung ke syscall sol_invoke_signed_c. Dengan menghindari overhead abstraksi Rust tingkat tinggi dan bekerja langsung menggunakan pointer mentah serta struktur yang kompatibel dengan C, fungsi-fungsi ini meminimalkan biaya komputasi untuk mempersiapkan CPI.

Pendekatan ini menghemat CU dengan mengurangi alokasi, penyalinan, dan konversi memori yang biasanya terjadi saat menggunakan tipe Rust yang lebih abstrak.

Sebagai contoh, metode to_info_c secara efisien membangun struct AccountInfoC menggunakan aritmetika pointer langsung:

Kode
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 …
  }
}

Manipulasi langsung terhadap tata letak memori ini memungkinkan pembuatan struktur yang diperlukan untuk CPI dengan sangat efisien sehingga mengurangi biaya CU operasi tersebut.

6. Syscall Langsung dan Unsafe Rust

Pendekatan ini melewati abstraksi Rust biasa dan berinteraksi langsung dengan runtime Solana sehingga menawarkan manfaat performa yang signifikan. Namun, cara ini juga meningkatkan kompleksitas dan memerlukan penanganan unsafe Rust secara cermat:

Kode
// 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. Kompilasi Bersyarat:

Atribut #[cfg(target_os = “solana”)] memastikan kode ini hanya dikompilasi saat menargetkan runtime Solana. Hal ini diperlukan karena syscall tersebut hanya tersedia di lingkungan itu.

Potensi Masalah dengan Unsafe Rust 

Meskipun efektif, unsafe Rust dapat menimbulkan masalah serius jika tidak ditangani dengan benar:

  • Kebocoran dan kerusakan memori
  • Perilaku yang tidak terdefinisi
  • Race condition

Untuk mengurangi risiko saat menggunakan unsafe Rust:

  • Gunakan blok unsafe secara terbatas dan hanya jika diperlukan
  • Dokumentasikan semua asumsi dan invariant keamanan
  • Gunakan alat seperti Miri dan sanitizer bawaan Rust untuk pengujian
  • Pertimbangkan teknik verifikasi formal untuk bagian penting
  • Lakukan peninjauan kode menyeluruh yang berfokus pada blok unsafe

Ringkasnya

Meskipun semua ini menarik, penggunaan pendekatan yang sangat optimal ini dalam program siap produksi untuk mengamankan uang sungguhan sulit dibenarkan karena peningkatan kompleksitas, potensi kesalahan, dan tantangan pemeliharaan. Untuk sebagian besar aplikasi, risiko munculnya bug kritis sering kali lebih besar daripada manfaat performanya.

Penggunaan pendekatan ini kemungkinan besar akan membuat Anda terjebak dalam pengoptimalan prematur.

‍Namun, beberapa hal dapat direplikasi dengan mudah:

  1. Menggunakan nostd_entrypoint alih-alih entrypoint yang membengkak oleh solana_program
  2. Menggunakan fungsi inline jika memungkinkan
  3. Meminimalkan alokasi dinamis dan mengutamakan struktur data berbasis stack‍

Kesimpulan 

Artikel ini telah membahas berbagai tingkat pengoptimalan untuk program Solana, mulai dari pengembangan Anchor tingkat tinggi hingga unsafe Rust tingkat rendah dengan syscall langsung. Kita telah melihat bahwa setiap pendekatan menawarkan kompromi yang berbeda antara kemudahan penggunaan, keamanan, dan performa.‍

Poin utama:

  • Anchor menyediakan framework yang ramah pengguna dengan sejumlah overhead performa
  • Deserialisasi zero-copy dapat meningkatkan efisiensi secara signifikan untuk struktur data besar
  • Rust native menawarkan kontrol dan potensi pengoptimalan yang lebih besar
  • Unsafe Rust dan syscall langsung memberikan performa maksimal, tetapi disertai kompleksitas dan risiko yang lebih tinggi

Pilihan tingkat pengoptimalan bergantung pada kasus penggunaan, persyaratan performa, dan toleransi risiko Anda. Selalu ukur dampak pengoptimalan dan pertimbangkan implikasi pemeliharaan jangka panjang dari pilihan Anda.

Jika Anda sudah membaca sejauh ini, terima kasih, anon! Pastikan Anda memasukkan alamat email di bawah agar tidak pernah melewatkan kabar terbaru tentang Solana. Siap mempelajari lebih dalam? Jelajahi artikel terbaru di blog Helius dan lanjutkan perjalanan Solana Anda hari ini.

Referensi Tambahan

Berlangganan Helius

Ikuti perkembangan terbaru dalam pengembangan Solana dan dapatkan pembaruan saat kami memublikasikan postingan

Gambar diperbesar