NOUVEAU : Helius acquiert Light Protocol
Optimiser les programmes Solana
Blog/Développement

Optimiser les programmes Solana

Ingénieur intégrationHet Dagli sur XHet Dagli sur LinkedIn
12 min de lecture

Conseils pratiques

  • Utilisez la désérialisation zero-copy pour les grandes structures de données et les opérations à haute fréquence
  • Utilisez nostd_entrypoint au lieu du point d’entrée volumineux solana_program’s
  • Limitez les allocations dynamiques en privilégiant les structures de données sur la pile
  • Implémentez une sérialisation et une désérialisation personnalisées pour éviter la surcharge de Borsh
  • Marquez les fonctions critiques avec #[inline(always)] pour améliorer potentiellement les performances
  • Utilisez la manipulation de bits pour analyser efficacement les instructions
  • Utilisez des appels système C propres à Solana comme sol_invoke_signed_c
  • Mesurez la consommation d’unités de calcul pour orienter vos efforts d’optimisation

Introduction

Lorsqu’ils écrivent des programmes, les développeurs Solana doivent trouver un équilibre entre facilité d’utilisation, performances et sécurité. Les options vont d’Anchor, un framework convivial qui simplifie le développement au prix d’une certaine surcharge, aux approches de bas niveau reposant sur du Rust non sécurisé et des appels système directs. Si ces dernières offrent des performances maximales, elles s’accompagnent d’une complexité accrue et de risques de sécurité potentiels. Pour les développeurs, la question essentielle n’est pas seulement de savoir comment optimiser, mais aussi quand et jusqu’où aller.

Cet article examine ces options en profondeur et propose aux développeurs une feuille de route pour s’orienter dans le domaine de l’optimisation. Nous étudierons les niveaux d’abstraction suivants :

  1. Anchor : le framework de haut niveau, puissant et directif privilégié par la plupart des développeurs
  2. Anchor avec zero-copy : du code Anchor optimisé pour les grandes structures de données
  3. Rust pur pour équilibrer contrôle et facilité d’utilisation
  4. Rust non sécurisé avec des appels système (syscalls) directs : repousser les limites des performances

L’objectif n’est pas de prescrire une solution universelle, mais de donner aux développeurs les connaissances nécessaires pour décider en toute connaissance de cause comment coder leurs programmes selon leurs cas d’usage spécifiques.

À la fin de cet article, vous saurez mieux appréhender ces différents niveaux d’abstraction et déterminer quand progresser sur la voie de l’optimisation. N’oubliez pas : le code le plus optimisé n’est pas toujours la meilleure solution. Il s’agit de trouver le bon équilibre pour les besoins de votre projet.

Cet article suppose que vous connaissez les bases de Rust, le modèle de compte de Solana et le framework Anchor. 

Pour les plus pressés :

Unités de calcul

L’architecture haute performance de Solana repose sur une gestion efficace des ressources. Au cœur de ce système se trouvent les unités de calcul (CU), qui mesurent les ressources de calcul dépensées par les validateurs pour traiter une transaction donnée.

‍Pourquoi s’intéresser aux unités de calcul ?

  1. Réussite des transactions : chaque transaction dispose d’une limite de CU. Si elle est dépassée, la transaction échoue
  2. Maîtrise des coûts : une consommation de CU plus faible réduit les frais de transaction
  3. Expérience utilisateur : les programmes optimisés s’exécutent plus rapidement, ce qui améliore l’expérience utilisateur globale
  4. Évolutivité : les programmes efficaces permettent de traiter davantage de transactions par bloc, ce qui améliore le débit du réseau

Mesurer les unités de calcul

L’appel système solana_program::log::sol_log_compute_units() consigne le nombre d’unités de calcul consommées par un programme à un moment précis de son exécution.

Voici une implémentation simple de la macro compute_fn! utilisant l’appel système :

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

Cette macro provient du dépôt GitHub de Solana Developers consacré à l’optimisation des CU. Cet extrait de code implémente un programme de compteur avec deux instructions, initialize et increment

Pour cet article, nous écrirons le même programme de compteur avec les deux mêmes instructions, initialize et increment, de quatre manières différentes, puis nous comparerons leur consommation de CU : Anchor, Anchor avec désérialisation zero-copy, Rust natif et Rust non sécurisé

Initialiser un compte et lui apporter une modification mineure, ici une incrémentation, constitue un benchmark convenable pour comparer ces différentes approches. Nous n’utiliserons pas de PDA pour le moment.

Pour les plus pressés, voici une comparaison des CU pour les quatre approches :

Commençons…

Désérialisation zero-copy

La désérialisation zero-copy permet d’interpréter directement les données d’un compte sans allouer de nouvelle mémoire ni copier de données. Cette technique peut réduire l’utilisation du CPU et la consommation de mémoire, et potentiellement rendre les instructions plus efficaces.

Commençons par un programme de compteur Anchor simple :‍

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

Rien de sophistiqué ci-dessus. Rendons-le maintenant plus élaboré avec zero_copy :

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

Principales modifications :

Voici les principales modifications apportées :

1. AccountLoader au lieu d’Account

Nous utilisons désormais AccountLoader<’info, CounterData> au lieu de Account<’info, Counter>. Cela permet d’accéder aux données du compte en zero-copy.

2. Attribut zero-copy

L’attribut #[account(zero_copy)] appliqué à CounterData indique que cette structure peut être interprétée directement à partir des octets bruts en mémoire.

3. Accès direct aux données

Dans les fonctions initialize et increment, nous utilisons respectivement load_init() et load_mut() pour obtenir un accès mutable aux données du compte sans les copier

4. Réduction des vulnérabilités liées aux comptes dupliqués 

La désérialisation zero-copy corrige une vulnérabilité potentielle de la sérialisation Borsh. Avec Borsh, des copies distinctes des comptes sont créées et modifiées, puis recopiées à la même adresse. Ce processus peut entraîner des incohérences si le même compte figure plusieurs fois dans une transaction.

Le zero-copy, en revanche, lit et écrit directement à la même adresse mémoire. Cette approche garantit que toutes les références à un compte au sein d’une transaction opèrent sur les mêmes données, éliminant ainsi le risque d’incohérences lié aux comptes dupliqués.

5. Garanties de disposition en mémoire

L’attribut zero_copy garantit que CounterData possède une disposition en mémoire cohérente, ce qui permet de le réinterpréter en toute sécurité à partir d’octets bruts. Cette implémentation a réduit la consommation de CU de l’instruction initialize de 5 095 à 5 022 et celle de l’instruction increment de 1 162 à 1 124.

Dans notre cas, le zero-copy n’apporte que des améliorations minimes et largement négligeables. Toutefois, la désérialisation zero-copy peut être utile avec de grandes structures de données, car elle peut réduire considérablement l’utilisation du CPU et de la mémoire lorsque les comptes stockent des données complexes ou volumineuses

Compromis et points à prendre en compte

Le zero-copy présente aussi des difficultés :

1. Complexité accrue

Le code devient légèrement plus complexe et nécessite une manipulation attentive des données brutes

2. Compatibilité

Toutes les structures de données ne se prêtent pas à la désérialisation zero-copy : leur disposition en mémoire doit être prévisible. Par exemple, les structures comportant des champs de taille dynamique comme Vec ou String sont incompatibles avec la désérialisation zero-copy.

L’utilisation du zero-copy doit dépendre de votre cas d’usage spécifique. Pour des programmes simples comme notre compteur, les avantages peuvent être minimes. Toutefois, à mesure que vos programmes gagnent en complexité et manipulent de plus grandes structures de données, le zero-copy peut devenir un puissant outil d’optimisation.

Même si l’optimisation zero-copy n’a pas apporté d’amélioration significative à notre programme de compteur simple, notre quête d’efficacité ne s’arrête pas là. Explorons une autre piste : écrire des programmes Solana natifs en Rust sans le framework Anchor. Cette approche offre davantage de contrôle et de possibilités d’optimisation, au prix d’une complexité accrue.

Utiliser Rust natif

Les programmes Rust natifs fournissent une interface de plus bas niveau et obligent les développeurs à gérer les différentes tâches automatisées par Anchor. Cela comprend la désérialisation et la sérialisation des comptes, ainsi que divers contrôles de sécurité. Cette approche exige davantage du développeur, mais ouvre aussi la voie à des optimisations précises.

Examinons l’implémentation en Rust natif de notre programme de compteur :‍

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

Principales différences et considérations

Voici quelques points à prendre en compte :

1. Analyse manuelle des instructions

Contrairement à Anchor, qui achemine automatiquement les instructions, nous analysons manuellement les données de l’instruction et les dirigeons vers la fonction appropriée.

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

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

2. Gestion des comptes

Nous utilisons next_account_info pour parcourir les comptes et vérifier manuellement les signataires et les propriétaires. Anchor s’en charge automatiquement grâce à sa macro #[derive(Accounts)].

Code
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. Sérialisation personnalisée

Nous implémentons des méthodes serialize et deserialize personnalisées pour notre structure Counter. Anchor utilise par défaut la sérialisation Borsh et masque ces détails.

Code
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. Interactions avec le programme système

La création de comptes implique une interaction directe avec le programme système à l’aide de invoke, ainsi qu’un appel inter-programmes (CPI), qu’Anchor simplifie grâce à sa contrainte init :

Code
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. Contrôle précis

En général, les programmes natifs offrent davantage de contrôle sur la disposition et le traitement des données, car ils ne suivent pas un framework unique et directif. Ils permettent ainsi d’obtenir un code plus optimisé.

Comment comparer Anchor et Rust natif

Voici quelques éléments à considérer :

1. Explicite ou implicite

Les programmes natifs exigent de gérer explicitement de nombreux aspects qu’Anchor prend en charge implicitement. Cela comprend la validation des comptes, la sérialisation et l’acheminement des instructions

2. Considérations de sécurité

Sans les contrôles intégrés d’Anchor, les développeurs doivent veiller à mettre en œuvre des mesures de sécurité appropriées, notamment en vérifiant le propriétaire des comptes et le statut des signataires

3. Réglage des performances

Les programmes natifs permettent des optimisations de performances plus précises, mais exigent une compréhension approfondie du fonctionnement de l’environnement d’exécution de Solana

4. Code standard répétitif

Attendez-vous à écrire davantage de code standard répétitif pour les opérations courantes qu’Anchor abstrait

5. Courbe d’apprentissage

Bien qu’elle soit potentiellement plus efficace, la programmation native présente une courbe d’apprentissage plus abrupte et exige une connaissance plus approfondie de l’architecture de Solana

En bref

Le principal facteur limitant lors du passage d’Anchor au natif est la gestion de la sérialisation et de la désérialisation. Dans notre cas, elle était relativement simple. Mais elle devient de plus en plus complexe à mesure que la gestion de l’état se complexifie.

Cependant, il est également vrai que Borsh, utilisé par Anchor, est très coûteux en ressources de calcul. L’effort en vaut donc la peine.

Notre parcours d’optimisation ne s’arrête pas là. Dans la section suivante, nous repousserons encore les limites en utilisant des appels système directs et en évitant la bibliothèque standard de Rust.

Cette approche est exigeante, mais je vous promets qu’elle apportera des informations intéressantes sur le fonctionnement interne de l’environnement d’exécution de Solana.

Repousser les limites avec du Rust non sécurisé et des appels système directs

Pour pousser au maximum les performances de notre programme de compteur, nous allons maintenant explorer l’utilisation de Rust non sécurisé et d’appels système directs. Rust non sécurisé permet aux développeurs de contourner les contrôles de sécurité standard afin de manipuler directement la mémoire et d’effectuer des optimisations de bas niveau. Les appels système fournissent quant à eux des interfaces directes avec l’environnement d’exécution de Solana.

Cette approche complexe, qui exige un développement méticuleux, peut réduire considérablement la consommation de CU. Elle nécessite toutefois une compréhension approfondie de l’architecture de Solana et une attention particulière à la sécurité du programme. Les gains de performances potentiels sont importants, mais ils s’accompagnent de responsabilités accrues.

Examinons une version hautement optimisée de notre programme de compteur qui exploite ces techniques avancées :

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

Principales différences et optimisations

Examinons les optimisations :

1. Environnement sans bibliothèque standard

Nous utilisons solana_nostd_entrypoint, qui fournit un environnement no-std. Cela élimine la surcharge de la bibliothèque standard de Rust, réduit la taille du programme et peut améliorer les performances. Merci à cavemanloverboy et à son dépôt GitHub consacré au point d’entrée no-std pour les programmes Solana.

2. Fonctions inline

Les fonctions critiques sont marquées avec #[inline(always)]. L’inlining est une optimisation du compilateur qui insère le corps de la fonction à l’endroit où elle est appelée, éliminant ainsi la surcharge liée à l’appel de fonction. Cela peut accélérer l’exécution, en particulier pour les petites fonctions fréquemment appelées.

3. Manipulation de bits pour analyser les instructions

Nous utilisons la manipulation de bits instruction_data[0] & 1 pour déterminer le type d’instruction, ce qui peut être plus efficace que d’autres méthodes d’analyse :

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

4. Gestion de la mémoire sans coût et traitement minimal des paniques

Les macros noalloc_allocator! et basic_panic_impl! mettent en œuvre une gestion de la mémoire et un traitement des paniques minimaux et sans surcharge :

Noalloc_allocator! définit un allocateur personnalisé qui déclenche une panique à chaque tentative d’allocation et ne fait rien lors de la désallocation. Le définir comme allocateur global des programmes Solana empêche efficacement toute allocation dynamique de mémoire pendant l’exécution :

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

C’est essentiel pour les raisons suivantes :

  1. Cela élimine la surcharge des opérations d’allocation et de désallocation de mémoire
  2. Cela oblige les développeurs à utiliser la pile ou de la mémoire statique, généralement plus rapides et plus prévisibles en matière de performances
  3. Cela réduit l’empreinte mémoire du programme

basic_panic_impl! fournit un gestionnaire de panique minimal qui consigne simplement le message « panicked! » :

Code
#[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. Préparation efficace des CPI

La structure InstructionC ainsi que les fonctions to_meta_c et to_info_c fournissent une méthode efficace et de bas niveau pour préparer les données destinées aux CPI :

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

Ces fonctions créent des structures compatibles avec C qui peuvent être transmises directement à l’appel système sol_invoke_signed_c. En évitant la surcharge des abstractions Rust de haut niveau et en travaillant directement avec des pointeurs bruts et des structures compatibles avec C, elles réduisent le coût de calcul de la préparation des CPI.

Cette approche économise des CU en réduisant les allocations de mémoire, les copies et les conversions qui se produiraient normalement avec des types Rust plus abstraits.

Par exemple, la méthode to_info_c construit efficacement une structure AccountInfoC à l’aide de l’arithmétique directe des pointeurs :

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

Cette manipulation directe des dispositions en mémoire permet de créer avec une efficacité extrême les structures nécessaires aux CPI, réduisant ainsi le coût en CU de ces opérations.

6. Appels système directs et Rust non sécurisé

Cette approche contourne les abstractions Rust habituelles et interagit directement avec l’environnement d’exécution de Solana, ce qui offre des gains de performances considérables. Elle ajoute toutefois de la complexité et exige une manipulation rigoureuse du Rust non sécurisé :

Code
// 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. Compilation conditionnelle :

L’attribut #[cfg(target_os = “solana”)] garantit que ce code est compilé uniquement lorsqu’il cible l’environnement d’exécution de Solana. C’est nécessaire, car ces appels système ne sont disponibles que dans cet environnement.

Problèmes potentiels du Rust non sécurisé 

Bien qu’il soit puissant, Rust non sécurisé peut entraîner de graves problèmes s’il n’est pas utilisé correctement :

  • Fuites et corruption de mémoire
  • Comportement indéfini
  • Conditions de concurrence

Pour limiter les risques liés à Rust non sécurisé :

  • Utilisez les blocs unsafe avec parcimonie et uniquement lorsque cela est nécessaire
  • Documentez toutes les hypothèses et tous les invariants de sécurité
  • Utilisez des outils comme Miri et les sanitizers intégrés à Rust pour les tests
  • Envisagez des techniques de vérification formelle pour les sections critiques
  • Effectuez des revues de code approfondies axées sur les blocs unsafe

En bref

Bien que tout cela soit fascinant, il est difficile de justifier l’utilisation de cette approche hyperoptimisée dans un programme prêt pour la production et chargé de sécuriser de l’argent réel, en raison de sa complexité accrue, du risque d’erreurs et des difficultés de maintenance. Pour la plupart des applications, le risque d’introduire des bugs critiques dépasse souvent les gains de performances.

Cette approche vous conduira très probablement dans le piège de l’optimisation prématurée.

‍Cependant, certains éléments sont faciles à reproduire :

  1. Utiliser nostd_entrypoint au lieu du volumineux entrypoint de solana_program
  2. Utiliser des fonctions inline partout où cela est possible
  3. Limiter les allocations dynamiques et privilégier les structures de données sur la pile‍

Conclusion 

Cet article a exploré différents niveaux d’optimisation pour les programmes Solana, du développement de haut niveau avec Anchor au Rust non sécurisé de bas niveau avec des appels système directs. Nous avons vu que chaque approche présente des compromis différents entre facilité d’utilisation, sécurité et performances.‍

Points essentiels :

  • Anchor fournit un framework convivial, mais entraîne une certaine surcharge en matière de performances
  • La désérialisation zero-copy peut améliorer considérablement l’efficacité pour les grandes structures de données
  • Rust natif offre davantage de contrôle et de possibilités d’optimisation
  • Rust non sécurisé et les appels système directs offrent des performances maximales, mais augmentent la complexité et les risques

Le choix du niveau d’optimisation dépend de votre cas d’usage, de vos exigences de performances et de votre tolérance au risque. Mesurez toujours l’effet des optimisations et tenez compte des conséquences à long terme de vos choix sur la maintenance.

Si vous avez lu jusqu’ici, merci, anon ! Saisissez votre adresse e-mail ci-dessous pour ne manquer aucune nouveauté sur Solana. Prêt à aller plus loin ? Découvrez les derniers articles du blog Helius et poursuivez dès aujourd’hui votre parcours sur Solana.

Ressources supplémentaires

Abonnez-vous à Helius

Suivez les dernières actualités du développement sur Solana et recevez une notification à chaque publication

Image agrandie