
Tối ưu hóa chương trình Solana
Thông tin chuyên sâu có thể áp dụng ngay
- Sử dụng giải tuần tự zero-copy cho các cấu trúc dữ liệu lớn và thao tác tần suất cao
- Sử dụng
nostd_entrypointthay cho entrypointsolana_program’scồng kềnh - Giảm thiểu cấp phát động, ưu tiên các cấu trúc dữ liệu dựa trên stack
- Triển khai tuần tự hóa/giải tuần tự tùy chỉnh để tránh chi phí xử lý của Borsh
- Đánh dấu các hàm quan trọng bằng
#[inline(always)]để có thể cải thiện hiệu năng - Sử dụng thao tác bit để phân tích lệnh hiệu quả
- Sử dụng các syscall C dành riêng cho Solana như
sol_invoke_signed_c - Đo mức sử dụng đơn vị tính toán để định hướng nỗ lực tối ưu hóa
Giới thiệu
Các nhà phát triển Solana phải đưa ra nhiều quyết định khi viết chương trình: cân bằng giữa tính dễ sử dụng, hiệu năng và độ an toàn. Phổ lựa chọn trải dài từ framework Anchor thân thiện với người dùng, giúp đơn giản hóa quá trình phát triển nhưng phát sinh thêm một số chi phí xử lý, đến các phương pháp cấp thấp sử dụng unsafe Rust và syscall trực tiếp. Dù phương pháp thứ hai mang lại hiệu năng tối đa, nó cũng làm tăng độ phức tạp và rủi ro bảo mật tiềm ẩn. Câu hỏi quan trọng với nhà phát triển không chỉ là tối ưu hóa như thế nào, mà còn là khi nào và đến mức nào.
Bài viết này phân tích chuyên sâu các lựa chọn đó, đồng thời cung cấp lộ trình giúp nhà phát triển định hướng trong quá trình tối ưu hóa. Chúng ta sẽ xem xét các cấp độ trừu tượng sau:
- Anchor: Framework cấp cao, mạnh mẽ, có định hướng rõ ràng và là lựa chọn hàng đầu của hầu hết nhà phát triển
- Anchor với zero-copy: Mã Anchor được viết để tối ưu hóa cho các cấu trúc dữ liệu lớn
- Rust thuần để cân bằng khả năng kiểm soát và tính dễ sử dụng
- Unsafe Rust với lời gọi hệ thống (syscall) trực tiếp: Đẩy hiệu năng đến giới hạn
Mục tiêu không phải là đưa ra một giải pháp chung cho mọi trường hợp, mà là trang bị cho nhà phát triển kiến thức để đưa ra quyết định sáng suốt về cách viết mã chương trình dựa trên trường hợp sử dụng cụ thể.
Sau khi đọc xong bài viết này, bạn sẽ hiểu rõ hơn cách nhìn nhận các cấp độ trừu tượng khác nhau và thời điểm nên cân nhắc tiến sâu hơn trên lộ trình tối ưu hóa. Hãy nhớ rằng mã được tối ưu hóa nhiều nhất không phải lúc nào cũng là giải pháp tốt nhất — điều quan trọng là tìm được sự cân bằng phù hợp với nhu cầu của dự án.
Bài viết này giả định bạn đã quen với Rust cơ bản, mô hình tài khoản của Solana và framework Anchor.
Nếu bạn muốn xem nhanh:
Đơn vị tính toán
Kiến trúc hiệu năng cao của Solana dựa vào khả năng quản lý tài nguyên hiệu quả. Trọng tâm của hệ thống này là đơn vị tính toán (CU) — thước đo lượng tài nguyên tính toán mà validator sử dụng để xử lý một giao dịch nhất định.
Tại sao cần quan tâm đến đơn vị tính toán?
- Giao dịch thành công: Mỗi giao dịch có một giới hạn CU. Vượt quá giới hạn này sẽ khiến giao dịch thất bại
- Hiệu quả chi phí: Mức sử dụng CU thấp hơn đồng nghĩa với phí giao dịch thấp hơn
- Trải nghiệm người dùng: Chương trình được tối ưu hóa thực thi nhanh hơn, cải thiện trải nghiệm người dùng tổng thể
- Khả năng mở rộng: Chương trình hiệu quả cho phép xử lý nhiều giao dịch hơn trên mỗi block, qua đó cải thiện thông lượng mạng
Đo đơn vị tính toán
Syscall solana_program::log::sol_log_compute_units() ghi log số đơn vị tính toán mà chương trình sử dụng tại một thời điểm cụ thể trong quá trình thực thi.
Dưới đây là một cách triển khai macro compute_fn! đơn giản bằng syscall:
#[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
};
}Macro này được lấy từ kho lưu trữ GitHub của Solana Developers về tối ưu hóa CU. Đoạn mã này triển khai một chương trình bộ đếm với hai lệnh là initialize và increment
Trong bài viết này, chúng ta sẽ viết cùng một chương trình bộ đếm với hai lệnh initialize và increment theo bốn cách khác nhau, sau đó so sánh mức sử dụng CU của tất cả các cách: Anchor, Anchor sử dụng giải tuần tự zero-copy, Rust gốc và unsafe Rust
Khởi tạo một tài khoản và thực hiện một thay đổi nhỏ với tài khoản đó (trong trường hợp này là tăng giá trị) là một phép đo chuẩn phù hợp để so sánh các phương pháp khác nhau này. Tạm thời, chúng ta sẽ không sử dụng PDA.
Nếu muốn xem nhanh, dưới đây là phần so sánh CU của bốn phương pháp:
Hãy bắt đầu…
Giải tuần tự zero-copy
Giải tuần tự zero-copy cho phép diễn giải trực tiếp dữ liệu tài khoản mà không cần cấp phát bộ nhớ mới hoặc sao chép dữ liệu. Kỹ thuật này có thể giảm mức sử dụng CPU, giảm mức tiêu thụ bộ nhớ và có khả năng tạo ra các lệnh hiệu quả hơn.
Hãy bắt đầu với một chương trình bộ đếm Anchor cơ bản:
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,
}Phần trên không có gì đặc biệt. Giờ hãy nâng cấp nó bằng 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,
}Các thay đổi chính:
Dưới đây là những thay đổi chính mà chúng ta đã thực hiện:
1. AccountLoader thay cho Account
Giờ đây, chúng ta sử dụng AccountLoader<’info, CounterData> thay cho Account<’info, Counter>. Cách này cho phép truy cập zero-copy vào dữ liệu của tài khoản.
2. Thuộc tính zero-copy
Thuộc tính #[account(zero_copy)] trên CounterData cho biết struct này có thể được diễn giải trực tiếp từ các byte thô trong bộ nhớ.
3. Truy cập dữ liệu trực tiếp
Trong các hàm initialize và increment, chúng ta lần lượt sử dụng load_init() và load_mut() để có quyền truy cập có thể thay đổi vào dữ liệu tài khoản mà không cần sao chép
4. Giảm thiểu lỗ hổng do tài khoản trùng lặp
Giải tuần tự zero-copy khắc phục một lỗ hổng tiềm ẩn trong quá trình tuần tự hóa Borsh. Với Borsh, các bản sao riêng biệt của tài khoản được tạo và thay đổi, sau đó được sao chép trở lại cùng một địa chỉ. Quy trình này có thể gây ra sự không nhất quán nếu cùng một tài khoản xuất hiện nhiều lần trong một giao dịch.
Ngược lại, zero-copy đọc và ghi trực tiếp tại cùng một địa chỉ bộ nhớ. Cách tiếp cận này đảm bảo mọi tham chiếu đến một tài khoản trong giao dịch đều thao tác trên cùng một dữ liệu, loại bỏ nguy cơ không nhất quán do tài khoản trùng lặp.
5. Đảm bảo bố cục bộ nhớ
Thuộc tính zero_copy đảm bảo CounterData có bố cục bộ nhớ nhất quán, cho phép diễn giải lại an toàn từ các byte thô. Cách triển khai này đã giảm mức sử dụng CU của lệnh initialize từ 5095 xuống 5022 và của lệnh increment từ 1162 xuống 1124.
Trong trường hợp của chúng ta, zero-copy chỉ mang lại mức cải thiện rất nhỏ, hầu như không đáng kể. Tuy nhiên, giải tuần tự zero-copy có thể hữu ích khi xử lý các cấu trúc dữ liệu lớn. Lý do là nó có thể giảm đáng kể mức sử dụng CPU và bộ nhớ khi làm việc với các tài khoản lưu trữ dữ liệu phức tạp hoặc có dung lượng lớn
Đánh đổi và các yếu tố cần cân nhắc
Zero-copy cũng có những thách thức riêng:
1. Độ phức tạp tăng lên
Mã trở nên phức tạp hơn đôi chút và đòi hỏi xử lý dữ liệu thô một cách cẩn thận
2. Khả năng tương thích
Không phải mọi cấu trúc dữ liệu đều phù hợp với giải tuần tự zero-copy — chúng phải có bố cục bộ nhớ có thể dự đoán được. Ví dụ: các cấu trúc có trường kích thước động như Vec hoặc String không tương thích với giải tuần tự zero-copy.
Việc sử dụng zero-copy nên dựa trên trường hợp sử dụng cụ thể. Với các chương trình đơn giản như bộ đếm của chúng ta, lợi ích có thể rất nhỏ. Tuy nhiên, khi chương trình trở nên phức tạp hơn và xử lý các cấu trúc dữ liệu lớn hơn, zero-copy có thể trở thành một công cụ tối ưu hóa mạnh mẽ.
Dù tối ưu hóa zero-copy không mang lại cải thiện đáng kể cho chương trình bộ đếm đơn giản, hành trình nâng cao hiệu quả chưa dừng lại ở đây. Hãy khám phá một hướng khác: viết chương trình Solana gốc bằng Rust mà không dùng framework Anchor. Phương pháp này cung cấp nhiều quyền kiểm soát và tiềm năng tối ưu hóa hơn, nhưng cũng phức tạp hơn.
Sử dụng Rust gốc
Chương trình Rust gốc cung cấp giao diện cấp thấp hơn, yêu cầu nhà phát triển tự xử lý nhiều tác vụ mà Anchor tự động hóa. Các tác vụ này bao gồm giải tuần tự và tuần tự hóa tài khoản cũng như nhiều bước kiểm tra bảo mật. Dù đòi hỏi nhiều công sức hơn từ nhà phát triển, cách này cũng mở ra cơ hội tối ưu hóa chi tiết.
Hãy xem cách triển khai chương trình bộ đếm bằng Rust gốc:
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 })
}
}Khác biệt chính và các yếu tố cần cân nhắc
Dưới đây là một số điều cần cân nhắc:
1. Phân tích lệnh thủ công
Không giống Anchor tự động định tuyến lệnh, chúng ta phân tích dữ liệu lệnh theo cách thủ công và định tuyến dữ liệu đó đến hàm thích hợp.
let instruction = instruction_data
.get(0)
.ok_or(ProgramError::InvalidInstructionData)?;
match instruction {
0 => initialize(program_id, accounts),
1 => increment(accounts),
_ => Err(ProgramError::InvalidInstructionData),
}2. Quản lý tài khoản
Chúng ta sử dụng next_account_info để duyệt qua các tài khoản, đồng thời kiểm tra signer và chủ sở hữu theo cách thủ công. Anchor tự động xử lý việc này bằng macro #[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. Tuần tự hóa tùy chỉnh
Chúng ta triển khai các phương thức serialize và deserialize tùy chỉnh cho struct Counter. Theo mặc định, Anchor sử dụng tuần tự hóa Borsh và trừu tượng hóa quá trình này.
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. Tương tác với System Program
Việc tạo tài khoản đòi hỏi tương tác trực tiếp với System Program bằng invoke và thực hiện một lời gọi chéo chương trình (CPI). Anchor đơn giản hóa việc này bằng ràng buộc 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. Khả năng kiểm soát chi tiết
Nhìn chung, chương trình gốc cung cấp nhiều quyền kiểm soát hơn đối với bố cục và quá trình xử lý dữ liệu vì chúng không tuân theo một framework duy nhất có định hướng sẵn, nhờ đó cho phép tối ưu hóa mã tốt hơn.
Cách nhìn nhận Anchor so với Rust gốc
Dưới đây là một số điều cần cân nhắc:
1. Tường minh so với ngầm định
Chương trình gốc yêu cầu xử lý tường minh nhiều khía cạnh mà Anchor quản lý ngầm định. Các khía cạnh này bao gồm xác thực tài khoản, tuần tự hóa và định tuyến lệnh
2. Các yếu tố bảo mật cần cân nhắc
Nếu không có các bước kiểm tra tích hợp sẵn của Anchor, nhà phát triển phải thận trọng triển khai các biện pháp bảo mật phù hợp, chẳng hạn như kiểm tra quyền sở hữu tài khoản và trạng thái signer
3. Tinh chỉnh hiệu năng
Chương trình gốc cho phép tối ưu hóa hiệu năng chi tiết hơn nhưng đòi hỏi hiểu biết sâu hơn về hành vi runtime của Solana
4. Mã soạn sẵn
Bạn sẽ phải viết nhiều mã soạn sẵn hơn cho các thao tác phổ biến mà Anchor đã trừu tượng hóa
5. Đường cong học tập
Dù có khả năng hiệu quả hơn, lập trình gốc có đường cong học tập dốc hơn và đòi hỏi kiến thức chuyên sâu hơn về kiến trúc Solana
Tóm tắt
Yếu tố hạn chế lớn nhất khi chuyển từ Anchor sang chương trình gốc là việc xử lý tuần tự hóa và giải tuần tự. Trong trường hợp của chúng ta, quá trình này tương đối đơn giản. Tuy nhiên, nó sẽ ngày càng phức tạp khi việc quản lý trạng thái trở nên phức tạp hơn.
Tuy nhiên, Borsh mà Anchor sử dụng cũng tiêu tốn rất nhiều tài nguyên tính toán, vì vậy nỗ lực này là xứng đáng.
Hành trình tối ưu hóa chưa dừng lại ở đây. Trong phần tiếp theo, chúng ta sẽ tiếp tục mở rộng giới hạn bằng cách tận dụng syscall trực tiếp và tránh sử dụng thư viện chuẩn của Rust.
Cách tiếp cận này đầy thách thức, nhưng tôi tin rằng nó sẽ cung cấp một số góc nhìn thú vị về cơ chế hoạt động bên trong runtime của Solana.
Đẩy giới hạn bằng unsafe Rust và syscall trực tiếp
Để đẩy hiệu năng của chương trình bộ đếm đến giới hạn, giờ đây chúng ta sẽ khám phá cách sử dụng unsafe Rust và syscall trực tiếp. Unsafe Rust cho phép nhà phát triển bỏ qua các bước kiểm tra an toàn tiêu chuẩn, nhờ đó có thể thao tác trực tiếp với bộ nhớ và tối ưu hóa ở cấp thấp. Trong khi đó, syscall cung cấp giao diện trực tiếp với runtime của Solana.
Dù phức tạp và đòi hỏi quá trình phát triển tỉ mỉ, cách tiếp cận này có thể tiết kiệm đáng kể CU. Tuy nhiên, nó cũng đòi hỏi hiểu biết sâu hơn về kiến trúc Solana và sự chú ý cẩn thận đến độ an toàn của chương trình. Tiềm năng cải thiện hiệu năng là rất lớn, nhưng đi kèm với trách nhiệm cao hơn.
Hãy xem một phiên bản chương trình bộ đếm được tối ưu hóa cao, tận dụng các kỹ thuật nâng cao này:
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(())
}Khác biệt và tối ưu hóa chính
Hãy xem xét các điểm tối ưu hóa:
1. Môi trường no-std
Chúng ta đang sử dụng solana_nostd_entrypoint, cung cấp môi trường no-std. Cách này loại bỏ chi phí xử lý của thư viện chuẩn Rust, giảm kích thước chương trình và có khả năng cải thiện hiệu năng. Xin ghi nhận đóng góp của cavemanloverboy và kho lưu trữ GitHub của anh ấy về entrypoint no-std cho chương trình Solana.
2. Hàm inline
Các hàm quan trọng được đánh dấu bằng #[inline(always)]. Inline là một kỹ thuật tối ưu hóa của trình biên dịch, trong đó phần thân hàm được chèn tại vị trí gọi, qua đó loại bỏ chi phí gọi hàm. Cách này có thể giúp thực thi nhanh hơn, đặc biệt với các hàm nhỏ được gọi thường xuyên.
3. Thao tác bit để phân tích lệnh
Chúng ta sử dụng thao tác bit instruction_data[0] & 1 để xác định loại lệnh. Cách này có thể hiệu quả hơn các phương pháp phân tích khác:
// Use the least significant bit to determine the instruction
match instruction_data[0] & 1 {
0 => initialize(accounts),
1 => increment(accounts),
_ => unreachable!(),
}4. Quản lý bộ nhớ không chi phí và xử lý panic tối giản
Các macro noalloc_allocator! và basic_panic_impl! triển khai cơ chế quản lý bộ nhớ và xử lý panic tối giản, không phát sinh chi phí:
Noalloc_allocator! định nghĩa một trình cấp phát tùy chỉnh sẽ panic với mọi yêu cầu cấp phát và không thực hiện gì khi giải phóng. Việc đặt trình này làm trình cấp phát toàn cục cho chương trình Solana giúp ngăn chặn hiệu quả mọi hoạt động cấp phát bộ nhớ động trong runtime:
#[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;
}
};
}Điều này rất quan trọng vì:
- Nó loại bỏ chi phí của các thao tác cấp phát và giải phóng bộ nhớ
- Nó buộc nhà phát triển sử dụng bộ nhớ dựa trên stack hoặc bộ nhớ tĩnh, vốn thường nhanh hơn và có hiệu năng dễ dự đoán hơn
- Nó giảm lượng bộ nhớ mà chương trình sử dụng
basic_panic_impl! cung cấp một trình xử lý panic tối giản, chỉ ghi log thông báo “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. Chuẩn bị CPI hiệu quả
Struct InstructionC cùng các hàm to_meta_c và to_info_c cung cấp phương thức cấp thấp, hiệu quả để chuẩn bị dữ liệu cho 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()
];Các hàm này tạo ra những cấu trúc tương thích với C để có thể truyền trực tiếp vào syscall sol_invoke_signed_c. Bằng cách tránh chi phí xử lý từ các lớp trừu tượng cấp cao hơn của Rust và làm việc trực tiếp với con trỏ thô cũng như cấu trúc tương thích với C, các hàm này giảm thiểu chi phí tính toán khi chuẩn bị CPI.
Cách tiếp cận này tiết kiệm CU bằng cách giảm số lần cấp phát bộ nhớ, sao chép và chuyển đổi vốn thường xảy ra khi sử dụng các kiểu Rust trừu tượng hơn.
Ví dụ: phương thức to_info_c xây dựng struct AccountInfoC một cách hiệu quả bằng phép toán con trỏ trực tiếp:
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 …
}
}Việc thao tác trực tiếp với bố cục bộ nhớ cho phép tạo cực kỳ hiệu quả các cấu trúc cần thiết cho CPI, qua đó giảm chi phí CU của những thao tác này.
6. Syscall trực tiếp và unsafe Rust
Cách tiếp cận này bỏ qua các lớp trừu tượng Rust thông thường và tương tác trực tiếp với runtime của Solana, mang lại lợi ích đáng kể về hiệu năng. Tuy nhiên, nó cũng làm tăng độ phức tạp và đòi hỏi xử lý unsafe Rust cẩn thận:
// 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. Biên dịch có điều kiện:
Thuộc tính #[cfg(target_os = “solana”)] đảm bảo mã này chỉ được biên dịch khi nhắm đến runtime Solana. Điều này là cần thiết vì các syscall này chỉ có trong môi trường đó.
Các vấn đề tiềm ẩn với unsafe Rust
Dù mạnh mẽ, unsafe Rust có thể dẫn đến những vấn đề nghiêm trọng nếu không được xử lý đúng cách:
- Rò rỉ và hỏng bộ nhớ
- Hành vi không xác định
- Điều kiện tranh chấp
Để giảm thiểu rủi ro khi sử dụng unsafe Rust:
- Hạn chế sử dụng các block unsafe và chỉ dùng khi cần thiết
- Ghi lại mọi giả định và bất biến liên quan đến độ an toàn
- Sử dụng các công cụ như Miri và sanitizer tích hợp sẵn của Rust để kiểm thử
- Cân nhắc các kỹ thuật xác minh hình thức cho những phần quan trọng
- Tiến hành đánh giá mã kỹ lưỡng, tập trung vào các block unsafe
Tóm tắt
Dù tất cả những điều này rất hấp dẫn, việc sử dụng phương pháp siêu tối ưu hóa này trong một chương trình sẵn sàng cho môi trường production để bảo vệ tiền thật là một lựa chọn khó thuyết phục do độ phức tạp tăng lên, nguy cơ xảy ra lỗi và thách thức trong việc bảo trì. Với hầu hết ứng dụng, rủi ro tạo ra lỗi nghiêm trọng thường lớn hơn lợi ích về hiệu năng.
Sử dụng cách tiếp cận này rất có thể sẽ khiến bạn rơi vào cái bẫy tối ưu hóa quá sớm.
Tuy nhiên, một số cách có thể dễ dàng áp dụng lại:
- Sử dụng
nostd_entrypointthay choentrypointcồng kềnh củasolana_program - Sử dụng hàm inline bất cứ khi nào có thể
- Giảm thiểu cấp phát động và ưu tiên các cấu trúc dữ liệu dựa trên stack
Kết luận
Bài viết này đã khám phá nhiều cấp độ tối ưu hóa khác nhau cho chương trình Solana, từ phát triển cấp cao bằng Anchor đến unsafe Rust cấp thấp với syscall trực tiếp. Chúng ta đã thấy mỗi phương pháp có những đánh đổi khác nhau giữa tính dễ sử dụng, độ an toàn và hiệu năng.
Những điểm chính:
- Anchor cung cấp một framework thân thiện với người dùng nhưng phát sinh thêm một số chi phí hiệu năng
- Giải tuần tự zero-copy có thể cải thiện đáng kể hiệu quả khi xử lý các cấu trúc dữ liệu lớn
- Rust gốc cung cấp nhiều quyền kiểm soát và tiềm năng tối ưu hóa hơn
- Unsafe Rust và syscall trực tiếp mang lại hiệu năng tối đa nhưng đi kèm độ phức tạp và rủi ro cao hơn
Việc chọn cấp độ tối ưu hóa phụ thuộc vào trường hợp sử dụng, yêu cầu hiệu năng và khả năng chấp nhận rủi ro cụ thể. Luôn đo lường tác động của các biện pháp tối ưu hóa và cân nhắc hệ quả bảo trì dài hạn từ những lựa chọn của bạn.
Nếu đã đọc đến đây, cảm ơn bạn! Hãy nhập địa chỉ email bên dưới để không bỏ lỡ bất kỳ thông tin cập nhật nào về những điều mới trên Solana. Sẵn sàng tìm hiểu sâu hơn? Hãy khám phá các bài viết mới nhất trên blog Helius và tiếp tục hành trình Solana ngay hôm nay.
Tài nguyên bổ sung
Bài viết liên quan
Đăng ký nhận tin từ Helius
Luôn cập nhật những thông tin mới nhất về phát triển Solana và nhận thông báo khi chúng tôi đăng bài


