Hiệu suất
Mặc dù nhiều nhà phát triển chuyển sang Pinocchio vì khả năng kiểm soát chi tiết các trường account, sức mạnh thực sự của nó nằm ở việc cho phép ttoois đa hóa hiệu suất.
Trong phần này, chúng ta sẽ khám phá các chiến lược thực tế để đạt được hiệu suất tối ưu trong các chương trình Solana của bạn.
Kiểm tra thừa
Các nhà phát triển thường thêm các ràng buộc account bổ sung cho mục đích an toàn, nhưng điều này có thể dẫn đến phát sinh các chi phí không cần thiết. Do đó, rất quan trọng để phân biệt giữa các kiểm tra thiết yếu và kiểm tra dư thừa.
Ví dụ, khi chỉ đọc từ Token Account hoặc Mint, deserialization và validation là cần thiết. Nhưng nếu những account tương tự này sau đó được sử dụng trong CPI (Cross-Program Invocation), bất kỳ sự không khớp hoặc lỗi nào sẽ khiến instruction thất bại tại thời điểm đó. Do đó, các kiểm tra phòng ngừa có thể là dư thừa.
Tương tự, việc xác minh "owner" của Token Account thường là thừa; đặc biệt nếu account được kiểm soát bởi PDA (Program Derived Address). Nếu owner không chính xác, CPI sẽ thất bại do seed không hợp lệ. Trong trường hợp transfer không được thực thi bởi PDA, bạn nên tập trung vào việc xác thực người nhận, đặc biệt khi gửi vào account được kiểm soát bởi PDA vì lợi ích của người gửi phù hợp với lợi ích của chương trình.
Hãy lấy ví dụ về Escrow:
...
Associated Token Program
Việc sử dụng Associated Token Account (ATA) thì thuận tiện nhưng đi kèm với chi phí hiệu suất. Tránh ép buộc sử dụng chúng trừ khi thực sự cần thiết, và không bao giờ yêu cầu tạo chúng trong logic instruction của bạn. Đối với hầu hết các kịch bản, pattern init-if-needed làm tăng thêm độ phức tạp và sử dụng tài nguyên có thể tránh được (như trong instruction Amm được tạo bởi router như Jupiter).
Nếu chương trình của bạn dựa vào ATA, hãy đảm bảo chúng được tạo bên ngoài. Trong chương trình của bạn, xác minh tính đúng đắn của chúng bằng cách derive địa chỉ mong đợi trực tiếp như thế này:
let (associated_token_account, _) = find_program_address(
&[
self.accounts.owner.key(),
self.accounts.token_program.key(),
self.accounts.mint.key(),
],
&pinocchio_associated_token_account::ID,
);Bằng cách tối giản hóa những kiểm tra và yêu cầu account không cần thiết, bạn giảm chi phí tính toán và làm cho quá trình thực thi của chương trình của bạn trở nên linh hoạt hơn; giúp bạn tận dụng tối đa tiềm năng hiệu suất của phát triển native trên Solana.
Perf Flag
Các cờ chức năng của Rust cung cấp một cách mạnh mẽ để biên dịch mã một cách điều kiện, cho phép bạn bật/tắt các tính năng cho các profile build khác nhau; chẳng hạn như development, testing, hoặc hiệu suất tối đa trong production.
Điều này đặc biệt hữu ích trong các chương trình Solana, nơi mỗi compute unit đều quan trọng.
Thiết lập Feature Flag
Feature flag được định nghĩa trong file Cargo.toml của bạn dưới phần [features]. Ví dụ, bạn có thể muốn một flag perf cho phép tối ưu hóa hiệu suất bằng cách vô hiệu hóa logging và kiểm tra bổ sung:
[features]
default = ["perf"]
perf = []Ở đây, feature perf được bật theo mặc định, nhưng bạn có thể ghi đè nó khi building hoặc testing.
Sử dụng Feature Flag trong Code
Bạn có thể sử dụng các thuộc tính biên dịch có điều kiện của Rust để bao gồm hoặc loại trừ mã dựa trên tính năng hoạt động. Ví dụ:
pub fn process(ctx: Context<'info>) -> ProgramResult {
#[cfg(not(feature = "perf"))]
sol_log("Create Class");
Self::try_from(ctx)?.execute()
}Hầu hết các chương trình trả về tên của instruction dưới dạng log để làm cho việc debugging dễ dàng hơn và đảm bảo rằng instruction đúng được gọi.
Tuy nhiên, điều này tốn kém và thực sự không cần thiết ngoại trừ để làm cho explorer dễ đọc hơn và cải thiện quá trình gỡ lỗi.
#[cfg(not(feature = "perf"))]
if name.len() > MAX_NAME_LEN {
return Err(ProgramError::InvalidArgument);
}Một ví dụ khác là các kiểm tra thừa đã thảo luận trước đó.
Nếu bạn không chắc chắn rằng instruction của bạn là an toàn mà không cần các kiểm tra này, bạn không nên làm chúng mặc định, nhưng thay vào đó ẩn chúng sau một cờ.
Building với các Flag khác nhau
Để build chương trình của bạn có hoặc không có feature perf, sử dụng:
Với tối ưu hóa hiệu suất (mặc định):
cargo build-bpfVới kiểm tra bổ sung và logging:
cargo build-bpf --no-default-featuresCách tiếp cận này cho phép bạn duy trì một codebase duy nhất có thể được điều chỉnh cho an toàn trogn quá trình phát triển hoặc tốc độ trên production chỉ bằng cách bật/tắt các cờ chức năng.
Các phép toán dịch bit
Khi thảo luận về các phép toán hiệu quả, các giá trị boolean là một trong những thứ lãng phí nhất. Hãy xem xét điều này: chúng chiếm 1 byte để đại diện cho chỉ hai giá trị có thể: 0 hoặc 1.
Nếu bạn có nhiều giá trị boolean trong mã của mình, bạn có thể lưu trữ chúng hiệu quả hơn nhiều bằng cách sử dụng thao tác bit. Với các phép toán dịch bit, bạn có thể lưu trữ lên đến 8 giá trị boolean khác nhau trong một byte duy nhất.
Định nghĩa cờ
Định nghĩa cờ dưới dạng các vị trí bit bằng cách sử dụng các phép dịch trái:
const FLAG_ACTIVE: u8 = 1 << 0; // 0000_0001
const FLAG_VERIFIED: u8 = 1 << 1; // 0000_0010
const FLAG_PREMIUM: u8 = 1 << 2; // 0000_0100
const FLAG_LOCKED: u8 = 1 << 3; // 0000_1000Để đặt một cờ, chúng ta có thể đơn giản làm như sau:
let mut flags = 0u8; // flags = 0000_0000
flags |= FLAG_ACTIVE; // flags = 0000_0001
flags |= FLAG_VERIFIED; // flags = 0000_0011
flags |= FLAG_PREMIUM | FLAG_LOCKED; // flags = 0000_1111Phép toán | (phép toán OR) là hoàn hảo để "bật" các bit cụ thể mà không ảnh hưởng đến các bit khác vì khi bạn OR với 0, bit gốc được bảo toàn và khi bạn OR với 1, kết quả luôn là 1 (cờ được đặt)
Nếu chúng ta muốn kiểm tra xem một cờ có đang hoạt động hay không, chúng ta có thể làm như sau:
let flags = 0b0000_0101u8; // Có cờ ACTIVE và PREMIUM được đặt
// Kiểm tra xem một cờ đơn lẻ có được đặt hay không
if flags & FLAG_ACTIVE != 0 {
println!("Account is active");
}
// Kiểm tra nếu nhiều cờ được đặt
if (flags & (FLAG_ACTIVE | FLAG_PREMIUM)) == (FLAG_ACTIVE | FLAG_PREMIUM) {
println!("Account is both active and premium");
}
// Kiểm tra nếu bất kỳ cờ nào trong số nhiều cờ được đặt
if flags & (FLAG_VERIFIED | FLAG_PREMIUM) != 0 {
println!("Account is either verified or premium (or both)");
}Phép toán & (phép toán AND) là hoàn hảo để "masking" các bit cụ thể nhằm kiểm tra giá trị của chúng, vì khi bạn AND với 0, kết quả luôn là 0 và khi bạn AND với 1, bit gốc được bảo toàn.
Để xóa hoặc chuyển đổi các cờ, thay vào đó chúng ta có thể làm như sau:
let mut flags = 0b0000_1111u8; // Tất cả các cờ đều được đặt
// Xóa một cờ đơn lẻ
flags &= !FLAG_ACTIVE; // flags = 0000_1110
// Xóa nhiều cờ cùng một lúc
flags &= !(FLAG_VERIFIED | FLAG_PREMIUM); // flags = 0000_1000
// Chuyển đổi một cờ đơn lẻ
flags ^= FLAG_ACTIVE; // flags = 0000_1001 (now has VERIFIED)
flags ^= FLAG_LOCKED; // flags = 0000_0001 (LOCKED now cleared)
// Chuyển đổi nhiều cờ cùng một lúc
flags ^= FLAG_PREMIUM | FLAG_LOCKED; // flags = 0000_1011Bộ nhớ trên Solana
Máy ảo Solana (SVM) sử dụng kiến trúc bộ nhớ ba tầng, tách biệt rõ ràng giữa bộ nhớ ngăn xếp (biến cục bộ), bộ nhớ heap (cấu trúc động) và không gian tài khoản (lưu trữ bền vững).
Hiểu kiến trúc này là rất quan trọng để viết các chương trình Solana hiệu suất cao.
Các chương trình hoạt động trong các không gian địa chỉ ảo cố định với ánh xạ bộ nhớ có thể dự đoán:
Program Code:
0x100000000- Nơi mã byte đã biên dịch của bạn được lưu trữStack Data:
0x200000000- Biến cục bộ và khung gọi hàmHeap Data:
0x300000000- Cấp phát động (đắt đỏ!)
Bố cục xác định này cho phép tối ưu hóa mạnh mẽ nhưng cũng tạo ra các ràng buộc nghiêm ngặt về hiệu suất.
Lợi thế của Zero-Allocation
Đột phá hiệu suất chính của Pinocchio đến từ việc sử dụng các tham chiếu thay vì cấp phát heap cho mọi thứ.
Cách tiếp cận này tận dụng một hiểu biết quan trọng: SVM đã tải tất cả các đầu vào chương trình của bạn vào bộ nhớ, vì vậy việc sao chép dữ liệu đó vào các cấp phát heap mới là lãng phí hoàn toàn.
Cấp phát heap không phải là tệ về bản chất, nhưng trên Solana thì nó đắt đỏ và phức tạp vì mỗi lần cấp phát tiêu tốn các đơn vị tính toán quý giá: mỗi lần cấp phát phân mảnh không gian heap hạn chế và các thao tác dọn dẹp tiêu tốn thêm các đơn vị tính toán.
Các kỹ thuật Zero-Allocation
Các cấu trúc dữ liệu dựa trên tham chiếu - Chuyển đổi dữ liệu sở hữu thành tham chiếu mượn:
rust// HEAP ALLOCATION: struct AccountInfo { key: Pubkey, // Owned data - copied to heap data: Vec<u8>, // Vector - definitely heap allocated } // ZERO ALLOCATION: struct AccountInfo<'a> { key: &'a Pubkey, // Reference - no allocation data: &'a [u8], // Slice reference - no allocation }Truy cập dữ liệu Zero-Copy - Truy cập dữ liệu tại chỗ mà không cần giải tuần tự hóa:
rust// Instead of deserializing, access data in-place: pub fn process_transfer(accounts: &[u8], instruction_data: &[u8]) { // Parse accounts directly from byte slice - NO HEAP ALLOCATION let source_account = &accounts[0..36]; // Just slice references let dest_account = &accounts[36..72]; // Access fields through pointer arithmetic - NO ALLOCATION let amount = u64::from_le_bytes(instruction_data[0..8].try_into().unwrap()); }Các ràng buộc No-Std - Ngăn chặn việc sử dụng heap không mong muốn tại thời điểm biên dịch:
rust// Enforces no-std to prevents accidental heap usage: #![no_std]
Bộ phân phối bộ nhớ của Pinocchio
Nếu bạn muốn đảm bảo rằng bạn không cấp phát bất kỳ heap nào, Pinocchio cung cấp một macro chuyên dụng để đảm bảo điều này xảy ra: no_allocator!()
Ngoài điều này ra, bạn luôn có thể viết một bộ phân phối heap tốt hơn biết cách dọn dẹp chính nó sau. Nếu bạn quan tâm đến cách tiếp cận này, đây là một ví dụ.