
Solana-Programme optimieren
Praktische Erkenntnisse
- Nutze Zero-Copy-Deserialisierung für große Datenstrukturen und häufig ausgeführte Operationen
- Nutze
nostd_entrypointstatt des aufgeblähtensolana_program’s-Entrypoints - Minimiere dynamische Allokationen und bevorzuge stackbasierte Datenstrukturen
- Implementiere eine eigene Serialisierung und Deserialisierung, um den Overhead von Borsh zu vermeiden
- Markiere kritische Funktionen mit
#[inline(always)], um die Performance potenziell zu steigern - Nutze Bitmanipulation, um Instructions effizient zu parsen
- Nutze Solana-spezifische C-Syscalls wie
sol_invoke_signed_c - Miss die Nutzung von Compute Units, um deine Optimierungen gezielt auszurichten
Einleitung
Beim Schreiben von Programmen müssen Solana-Entwickler mehrere Entscheidungen treffen und Benutzerfreundlichkeit, Performance und Sicherheit ausbalancieren. Das Spektrum reicht vom benutzerfreundlichen Anchor-Framework, das die Entwicklung auf Kosten eines gewissen Overheads vereinfacht, bis zu Low-Level-Ansätzen mit Unsafe Rust und direkten Syscalls. Letztere bieten maximale Performance, erhöhen aber die Komplexität und mögliche Sicherheitsrisiken. Entwickler müssen daher nicht nur wissen, wie sie optimieren, sondern auch wann und in welchem Umfang.
Dieser Blogbeitrag untersucht diese Optionen ausführlich und bietet Entwicklern einen Wegweiser durch die Optimierungslandschaft. Wir betrachten die folgenden Abstraktionsebenen:
- Anchor: Das etablierte, leistungsstarke High-Level-Framework mit klaren Konventionen für die meisten Entwickler
- Anchor mit Zero-Copy: Anchor-Code, der für große Datenstrukturen optimiert ist
- Reines Rust, um Kontrolle und Benutzerfreundlichkeit auszubalancieren
- Unsafe Rust mit direkten Systemaufrufen (Syscalls): So reizt du die Performance bis ans Limit aus
Wir wollen keine Universallösung vorschreiben. Stattdessen geben wir Entwicklern das nötige Wissen, um ihre Programme passend zum jeweiligen Anwendungsfall zu implementieren.
Am Ende dieses Beitrags verstehst du besser, wie du über die verschiedenen Abstraktionsebenen nachdenken solltest und wann sich der nächste Schritt auf dem Optimierungspfad lohnt. Denk daran: Der am stärksten optimierte Code ist nicht immer die beste Lösung. Entscheidend ist die richtige Balance für dein Projekt.
Dieser Artikel setzt Kenntnisse in grundlegendem Rust, im Account-Modell von Solana und im Anchor-Framework voraus.
Für Ungeduldige:
Compute Units
Die leistungsstarke Architektur von Solana basiert auf effizientem Ressourcenmanagement. Im Zentrum dieses Systems stehen Compute Units (CUs). Sie messen die Rechenressourcen, die Validatoren zur Verarbeitung einer bestimmten Transaktion aufwenden.
Warum sind Compute Units wichtig?
- Erfolgreiche Transaktionen: Jede Transaktion hat ein CU-Limit. Überschreitet sie dieses Limit, schlägt sie fehl
- Kosteneffizienz: Eine geringere CU-Nutzung bedeutet niedrigere Transaktionsgebühren
- Benutzererlebnis: Optimierte Programme werden schneller ausgeführt und verbessern damit die gesamte UX
- Skalierbarkeit: Effiziente Programme ermöglichen mehr Transaktionen pro Block und erhöhen den Netzwerkdurchsatz
Compute Units messen
Der Syscall solana_program::log::sol_log_compute_units() protokolliert, wie viele Compute Units ein Programm zu einem bestimmten Zeitpunkt seiner Ausführung verbraucht.
Hier ist eine einfache Implementierung des Makros compute_fn!, die diesen Syscall nutzt:
#[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
};
}Dieses Makro stammt aus dem GitHub-Repository von Solana Developers für CU-Optimierungen. Dieses Codebeispiel implementiert ein Counter-Programm mit den zwei Instructions initialize und increment
Für diesen Artikel schreiben wir dasselbe Counter-Programm mit denselben zwei Instructions, initialize und increment, auf vier verschiedene Arten und vergleichen ihre CU-Nutzung: Anchor, Anchor mit Zero-Copy-Deserialisierung, natives Rust und Unsafe Rust
Das Initialisieren eines Accounts und eine kleine Änderung an diesem Account – in diesem Fall das Inkrementieren – sind ein guter Benchmark für den Vergleich dieser Ansätze. Vorerst verwenden wir keine PDAs.
Für Ungeduldige findest du hier den CU-Vergleich der vier Ansätze:
Legen wir los …
Zero-Copy-Deserialisierung
Mit Zero-Copy-Deserialisierung können wir Account-Daten direkt interpretieren, ohne neuen Speicher zu allokieren oder Daten zu kopieren. Diese Technik kann die CPU- und Speichernutzung reduzieren und Instructions effizienter machen.
Beginnen wir mit einem einfachen Counter-Programm in 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,
}Oben gibt es nichts Besonderes. Machen wir es jetzt mit zero_copy interessanter:
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,
}Wichtigste Änderungen:
Wir haben folgende zentrale Änderungen vorgenommen:
1. AccountLoader statt Account
Wir verwenden jetzt AccountLoader<’info, CounterData> statt Account<’info, Counter>. Dadurch können wir per Zero-Copy auf die Daten des Accounts zugreifen.
2. Zero-Copy-Attribut
Das Attribut #[account(zero_copy)] für CounterData gibt an, dass sich diese Struct direkt aus den Rohbytes im Speicher interpretieren lässt.
3. Direkter Datenzugriff
In den Funktionen initialize und increment verwenden wir load_init() beziehungsweise load_mut(). So erhalten wir veränderbaren Zugriff auf die Account-Daten, ohne sie zu kopieren
4. Schutz vor Schwachstellen durch doppelte Accounts
Die Zero-Copy-Deserialisierung behebt eine potenzielle Schwachstelle der Borsh-Serialisierung. Borsh erstellt und verändert separate Kopien von Accounts und kopiert sie anschließend an dieselbe Adresse zurück. Dieser Prozess kann zu Inkonsistenzen führen, wenn derselbe Account mehrfach in einer Transaktion enthalten ist.
Zero-Copy liest und schreibt dagegen direkt an derselben Speicheradresse. Dadurch arbeiten alle Referenzen auf einen Account innerhalb einer Transaktion mit denselben Daten. Das verhindert Inkonsistenzen durch doppelte Accounts.
5. Garantiertes Speicherlayout
Das Attribut zero_copy sorgt dafür, dass CounterData ein konsistentes Speicherlayout hat und sich sicher aus Rohbytes reinterpretieren lässt. Diese Implementierung reduzierte die CU-Nutzung der Instruction initialize von 5095 auf 5022 und die der Instruction increment von 1162 auf 1124.
In unserem Fall bringt Zero-Copy nur minimale, weitgehend unbedeutende Verbesserungen. Bei großen Datenstrukturen kann Zero-Copy-Deserialisierung jedoch nützlich sein. Sie kann die CPU- und Speichernutzung deutlich reduzieren, wenn Accounts komplexe oder umfangreiche Daten speichern
Kompromisse und wichtige Aspekte
Zero-Copy bringt auch Herausforderungen mit sich:
1. Höhere Komplexität
Der Code wird etwas komplexer, da du die Rohdaten sorgfältig verarbeiten musst
2. Kompatibilität
Nicht alle Datenstrukturen eignen sich für Zero-Copy-Deserialisierung. Sie müssen ein vorhersagbares Speicherlayout haben. Strukturen mit dynamisch großen Feldern wie Vec oder String sind beispielsweise nicht mit Zero-Copy-Deserialisierung kompatibel.
Ob du Zero-Copy nutzen solltest, hängt von deinem konkreten Anwendungsfall ab. Bei einfachen Programmen wie unserem Counter sind die Vorteile möglicherweise gering. Wenn deine Programme jedoch komplexer werden und größere Datenstrukturen verarbeiten, kann Zero-Copy zu einem leistungsstarken Optimierungswerkzeug werden.
Die Zero-Copy-Optimierung brachte unserem einfachen Counter-Programm zwar keine deutlichen Verbesserungen, doch die Suche nach mehr Effizienz endet hier nicht. Betrachten wir einen anderen Weg: native Solana-Programme in Rust ohne das Anchor-Framework. Dieser Ansatz bietet mehr Kontrolle und Optimierungspotenzial, erhöht aber auch die Komplexität.
Natives Rust verwenden
Native Rust-Programme bieten eine Low-Level-Schnittstelle. Entwickler müssen dabei verschiedene Aufgaben selbst übernehmen, die Anchor automatisiert. Dazu gehören die Deserialisierung und Serialisierung von Accounts sowie verschiedene Sicherheitsprüfungen. Das verlangt Entwicklern mehr ab, eröffnet aber auch Möglichkeiten für präzise Optimierungen.
Sehen wir uns die native Rust-Implementierung unseres Counter-Programms an:
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 })
}
}Wichtige Unterschiede und Überlegungen
Folgende Punkte solltest du beachten:
1. Manuelles Parsen von Instructions
Anchor leitet Instructions automatisch weiter. Hier parsen wir die Instruction-Daten dagegen manuell und leiten sie an die passende Funktion weiter.
let instruction = instruction_data
.get(0)
.ok_or(ProgramError::InvalidInstructionData)?;
match instruction {
0 => initialize(program_id, accounts),
1 => increment(accounts),
_ => Err(ProgramError::InvalidInstructionData),
}2. Account-Verwaltung
Wir durchlaufen die Accounts mit next_account_info und prüfen Signer und Eigentümer manuell. Anchor erledigt das mit seinem Makro #[derive(Accounts)] automatisch.
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. Eigene Serialisierung
Wir implementieren eigene Methoden serialize und deserialize für unsere Struct Counter. Anchor verwendet standardmäßig die Borsh-Serialisierung und abstrahiert diese Aufgabe.
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. Interaktionen mit dem System Program
Beim Erstellen von Accounts interagieren wir über invoke direkt mit dem System Program und führen eine Cross Program Invocation (CPI) aus. Anchor vereinfacht dies mit seiner init-Constraint:
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. Präzise Kontrolle
Native Programme bieten generell mehr Kontrolle über Datenlayout und Verarbeitung, da sie keinem einzelnen Framework mit festen Konventionen folgen. Das ermöglicht stärker optimierten Code.
Anchor und natives Rust richtig abwägen
Folgende Punkte solltest du berücksichtigen:
1. Explizit vs. implizit
Native Programme verlangen, dass du viele Aspekte explizit behandelst, die Anchor implizit verwaltet. Dazu gehören die Account-Validierung, Serialisierung und Weiterleitung von Instructions
2. Sicherheitsaspekte
Ohne die integrierten Prüfungen von Anchor müssen Entwickler sorgfältig geeignete Sicherheitsmaßnahmen implementieren. Dazu zählen die Prüfung des Account-Eigentümers und des Signer-Status
3. Performance-Optimierung
Native Programme ermöglichen präzisere Performance-Optimierungen, setzen aber ein tieferes Verständnis des Laufzeitverhaltens von Solana voraus
4. Boilerplate-Code
Rechne damit, mehr Boilerplate-Code für gängige Operationen zu schreiben, die Anchor abstrahiert
5. Lernkurve
Native Programmierung kann effizienter sein, hat aber eine steilere Lernkurve und erfordert tiefere Kenntnisse der Solana-Architektur
Kurz gesagt
Die größte Hürde beim Wechsel von Anchor zu nativem Rust ist die Serialisierung und Deserialisierung. In unserem Fall war sie relativ einfach. Mit komplexerem State-Management wird sie jedoch zunehmend schwieriger.
Allerdings ist die von Anchor verwendete Borsh-Serialisierung rechnerisch sehr teuer. Der Aufwand lohnt sich daher.
Unsere Optimierungsreise endet hier noch nicht. Im nächsten Abschnitt gehen wir noch weiter: Wir nutzen direkte Syscalls und verzichten auf die Rust-Standardbibliothek.
Dieser Ansatz ist anspruchsvoll. Ich verspreche dir aber interessante Einblicke in die interne Funktionsweise der Solana-Runtime.
Mit Unsafe Rust und direkten Syscalls ans Limit gehen
Um die Performance unseres Counter-Programms vollständig auszureizen, untersuchen wir jetzt Unsafe Rust und direkte Syscalls. Mit Unsafe Rust können Entwickler die üblichen Sicherheitsprüfungen umgehen. Das ermöglicht direkte Speichermanipulation und Low-Level-Optimierungen. Syscalls bieten wiederum direkte Schnittstellen zur Solana-Runtime.
Dieser Ansatz ist komplex und erfordert äußerst sorgfältige Entwicklung, kann aber deutlich CUs sparen. Gleichzeitig setzt er ein tieferes Verständnis der Solana-Architektur und besondere Aufmerksamkeit für die Programmsicherheit voraus. Die möglichen Performance-Gewinne sind erheblich, bringen jedoch mehr Verantwortung mit sich.
Sehen wir uns eine stark optimierte Version unseres Counter-Programms an, die diese fortgeschrittenen Techniken nutzt:
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(())
}Wichtige Unterschiede und Optimierungen
Sehen wir uns die Optimierungen an:
1. No-std-Umgebung
Wir verwenden solana_nostd_entrypoint, das eine no-std-Umgebung bereitstellt. Dadurch entfällt der Overhead der Rust-Standardbibliothek. Das reduziert die Programmgröße und kann die Performance verbessern. Danke an cavemanloverboy und sein GitHub-Repository zum no-std-Entrypoint für Solana-Programme.
2. Inline-Funktionen
Kritische Funktionen werden mit #[inline(always)] markiert. Inlining ist eine Compiler-Optimierung, bei der der Funktionskörper an der Aufrufstelle eingefügt wird. Dadurch entfällt der Overhead des Funktionsaufrufs. Das kann die Ausführung beschleunigen, insbesondere bei kleinen, häufig aufgerufenen Funktionen.
3. Bitmanipulation zum Parsen von Instructions
Wir bestimmen den Typ der Instruction mit Bitmanipulation instruction_data[0] & 1. Das kann effizienter sein als andere Parsing-Methoden:
// Use the least significant bit to determine the instruction
match instruction_data[0] & 1 {
0 => initialize(accounts),
1 => increment(accounts),
_ => unreachable!(),
}4. Kostenfreies Speichermanagement und minimale Panic-Behandlung
Die Makros noalloc_allocator! und basic_panic_impl! implementieren minimales Speichermanagement und Panic-Handling ohne zusätzlichen Overhead:
Noalloc_allocator! definiert einen eigenen Allocator, der bei jedem Allokationsversuch eine Panic auslöst und bei der Deallokation nichts tut. Wird er als globaler Allocator für Solana-Programme festgelegt, verhindert er effektiv jede dynamische Speicherallokation während der Laufzeit:
#[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;
}
};
}Das ist entscheidend, weil:
- der Overhead von Speicherallokationen und -deallokationen entfällt
- Entwickler stackbasierten oder statischen Speicher verwenden müssen, der im Allgemeinen schneller ist und eine besser vorhersagbare Performance bietet
- der Speicherbedarf des Programms sinkt
basic_panic_impl! stellt einen minimalen Panic-Handler bereit, der lediglich die Meldung „panicked!“ protokolliert:
#[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. Effiziente CPI-Vorbereitung
Die Struct InstructionC sowie die Funktionen to_meta_c und to_info_c bieten eine effiziente Low-Level-Methode, um Daten für CPIs vorzubereiten:
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()
];Diese Funktionen erstellen C-kompatible Strukturen, die sich direkt an den Syscall sol_invoke_signed_c übergeben lassen. Sie vermeiden den Overhead der höherstufigen Rust-Abstraktionen und arbeiten direkt mit Raw Pointern und C-kompatiblen Strukturen. Dadurch minimieren sie die Rechenkosten für die Vorbereitung von CPIs.
Dieser Ansatz spart CUs, da er Speicherallokationen, Kopien und Konvertierungen reduziert, die bei abstrakteren Rust-Typen normalerweise anfallen würden.
Die Methode to_info_c erstellt beispielsweise effizient eine Struct AccountInfoC mithilfe direkter Pointer-Arithmetik:
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 …
}
}Durch diese direkte Manipulation der Speicherlayouts lassen sich die für CPIs benötigten Strukturen äußerst effizient erstellen. Das reduziert die CU-Kosten dieser Operationen.
6. Direkte Syscalls und Unsafe Rust
Dieser Ansatz umgeht die üblichen Rust-Abstraktionen und interagiert direkt mit der Solana-Runtime. Das bietet erhebliche Performance-Vorteile, erhöht aber auch die Komplexität und erfordert einen sorgfältigen Umgang mit 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. Bedingte Kompilierung:
Das Attribut #[cfg(target_os = “solana”)] sorgt dafür, dass dieser Code nur für die Solana-Runtime kompiliert wird. Das ist notwendig, weil diese Syscalls nur in dieser Umgebung verfügbar sind.
Potenzielle Probleme mit Unsafe Rust
Unsafe Rust ist leistungsstark, kann bei falscher Verwendung aber ernsthafte Probleme verursachen:
- Speicherlecks und Speicherkorruption
- Undefiniertes Verhalten
- Race Conditions
So reduzierst du die Risiken bei der Verwendung von Unsafe Rust:
- Verwende unsafe-Blöcke sparsam und nur, wenn sie wirklich nötig sind
- Dokumentiere alle Sicherheitsannahmen und Invarianten
- Nutze für Tests Werkzeuge wie Miri und die integrierten Sanitizer von Rust
- Erwäge formale Verifikationstechniken für kritische Abschnitte
- Führe gründliche Code-Reviews mit besonderem Fokus auf unsafe-Blöcke durch
Kurz gesagt
All das ist faszinierend. Dieser extrem optimierte Ansatz lässt sich jedoch nur schwer für ein produktionsreifes Programm rechtfertigen, das echtes Geld absichert. Grund dafür sind die höhere Komplexität, die Fehleranfälligkeit und die Wartungsprobleme. Bei den meisten Anwendungen überwiegt das Risiko kritischer Bugs die Performance-Vorteile.
Mit diesem Ansatz gerätst du höchstwahrscheinlich in die Falle vorzeitiger Optimierung.
Einige Dinge lassen sich jedoch leicht übernehmen:
nostd_entrypointstatt des durchsolana_programaufgeblähtenentrypointverwenden- Wo immer möglich Inline-Funktionen verwenden
- Dynamische Allokationen minimieren und stackbasierte Datenstrukturen bevorzugen
Fazit
Dieser Artikel hat verschiedene Optimierungsstufen für Solana-Programme untersucht – von der High-Level-Entwicklung mit Anchor bis zu Low-Level Unsafe Rust mit direkten Syscalls. Wir haben gesehen, dass jeder Ansatz andere Kompromisse zwischen Benutzerfreundlichkeit, Sicherheit und Performance bietet.
Die wichtigsten Erkenntnisse:
- Anchor bietet ein benutzerfreundliches Framework, verursacht aber einen gewissen Performance-Overhead
- Zero-Copy-Deserialisierung kann die Effizienz bei großen Datenstrukturen deutlich steigern
- Natives Rust bietet mehr Kontrolle und Optimierungspotenzial
- Unsafe Rust und direkte Syscalls liefern maximale Performance, erhöhen aber Komplexität und Risiko
Welche Optimierungsstufe die richtige ist, hängt von deinem konkreten Anwendungsfall, den Performance-Anforderungen und deiner Risikotoleranz ab. Miss immer die Auswirkungen deiner Optimierungen und berücksichtige die langfristigen Wartungsfolgen deiner Entscheidungen.
Wenn du bis hierhin gelesen hast: Danke, Anon! Trag unten deine E-Mail-Adresse ein, damit du keine Neuigkeiten rund um Solana verpasst. Bereit, tiefer einzusteigen? Entdecke die neuesten Artikel im Helius-Blog und setze deine Solana-Reise noch heute fort.
Zusätzliche Ressourcen
Ähnliche Artikel
Helius abonnieren
Bleib bei der Solana-Entwicklung auf dem Laufenden und erhalte Updates, wenn wir neue Beiträge veröffentlichen


