Rust
Pinocchio pour les nuls

Pinocchio pour les nuls

Performance

Si de nombreux développeurs se tournent vers Pinocchio pour son contrôle précis des champs des compte, sa véritable force réside dans sa capacité à offrir des performances maximales.

Dans cette section, nous explorerons des stratégies pratiques pour optimiser l'efficacité de vos programmes Solana.

Vérifications Superflues

Les développeurs ajoutent souvent des contraintes supplémentaires aux comptes pour des raisons de sécurité mais celles-ci peuvent entraîner une charge inutile. Il est important de faire la distinction entre les vérifications essentielles et les vérifications redondantes.

Par exemple, lors de la lecture d'un Token Account ou d'un Mint, la désérialisation et la validation sont nécessaires. Mais si ces mêmes comptes sont ensuite utilisés dans un CPI (Invocation de Programme Croisé), toute incompatibilité ou erreur entraînera l'échec de l'instruction à ce stade. Ainsi, les contrôles préventifs peuvent s'avérer redondants.

De même, vérifier le "propriétaire" d'un Compte de Jetons est souvent superflu, en particulier si le compte est contrôlé par une Adresse Dérivée de Programme (PDA). Si le propriétaire est incorrect, le CPI échouera en raison de seeds invalides. Dans les cas où le transfert n'est pas effectué par un PDA, vous devez vous concentrer sur la validation du destinataire, en particulier lors d'un dépôt sur un compte contrôlé par un PDA car les attentes de l'expéditeur correspondent à celles du programme.

Prenons l'exemple d'un Escrow:

...

Programme de Jetons Associés

Associated Token Accounts (Comptes de Jetons Associés ou ATAs) sont pratiques mais ont un coût en termes de performances. Évitez d'imposer leur utilisation sauf en cas d'absolue nécessité et n'exigez jamais leur création dans votre logique d'instruction. Dans la plupart des cas, init-if-needed ajoute une complexité et une utilisation des ressources qui pourraient être évitées (comme dans les instructions d'Amm qui sont composées par un routeur tel que Jupiter).

Si votre programme utilise des ATAs, assurez-vous qu'ils sont créés en externe. Dans votre programme, vérifiez leur exactitude en dérivant directement l'adresse attendue comme ceci :

rust
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,
);

En minimisant les vérifications inutiles et les exigences liées aux comptes, vous réduisez les coûts de calcul et rationalisez l'exécution de votre programme, libérant ainsi tout le potentiel de performance du développement natif sur Solana.

L'ajout de vérifications permettant à l'instruction d'échouer rapidement présente des avantages car les unités de calcul consommées seront certainement moins nombreuses. Réfléchissez donc si l'instruction sera principalement utilisée avec des drapeaux tels que { skipPreflight: true }.

Drapeau Perf

Les drapeaux de fonctionnalité (Feature flags) de Rust constituent un moyen puissant de compiler du code de manière conditionnelle, vous permettant d'activer ou de désactiver des fonctionnalités pour différents profils de compilation, tels que le développement, les tests ou les performances maximales en production.

Cela est particulièrement utile dans les programmes Solana où chaque unité de calcul compte.

Configuration des Drapeaux de Fonctionnalité

Les drapeaux de fonctionnalités sont définis dans votre fichier Cargo.toml sous la section [features]. Par exemple, vous pouvez avoir envie d'un drapeau perf qui active les optimisations de performance en désactivant la journalisation et les vérifications supplémentaires :

text
[features]
default = ["perf"]
perf = []

Ici, la fonctionnalité perf est activée par défaut, mais vous pouvez la désactiver lors de la compilation ou des tests.

Utilisation des Drapeaux de Fonctionnalité dans le Code

Vous pouvez utiliser les attributs de compilation conditionnelle de Rust pour inclure ou exclure du code en fonction de la fonctionnalité active. Par exemple :

rust
pub fn process(ctx: Context<'info>) -> ProgramResult {
    #[cfg(not(feature = "perf"))]
    sol_log("Create Class");
    Self::try_from(ctx)?.execute()
}

La plupart des programmes renvoient le nom de l'instruction sous forme de log afin de faciliter le débogage et de s'assurer que la bonne instruction est appelée.

Cependant, cela coûte cher et n'est pas vraiment nécessaire, sauf pour rendre l'explorateur plus lisible et améliorer le débogage.

rust
#[cfg(not(feature = "perf"))]
if name.len() > MAX_NAME_LEN {
    return Err(ProgramError::InvalidArgument);
}

Un autre exemple est celui des contrôles superflus évoqués précédemment.

Si nous savons que notre instruction est sûre sans ces vérifications, nous ne devrions pas les définir comme paramètres par défaut, mais plutôt les masquer derrière un drapeau.

Dans cet exemple, nous avons créé un drapeau perf qui indique que, si nous voulons que le programme soit aussi performant que possible, nous devons utiliser le drapeau perf lors de sa compilation.

Compilation avec Différents Drapeaux

Pour compiler votre programme avec ou sans la fonctionnalité perf, utilisez :

  • Avec optimisations des performances (par défaut):

text
cargo build-bpf
  • Avec des vérifications supplémentaires et la journalisation :

text
cargo build-bpf --no-default-features

Cette approche vous permet de conserver une base de code unique qui peut être optimisée pour la sécurité du développement ou la vitesse de production simplement en activant ou désactivant un drapeau de fonctionnalité.

Opérations bit à bit

Lorsqu'on parle d'opérations efficaces, les booléens sont parmi les plus gaspilleurs. Considérez ceci : ils occupent 1 octet pour représenter seulement deux valeurs possibles : 0 ou 1.

Si vous avez plusieurs booléens dans votre code, vous pouvez les stocker de manière beaucoup plus efficace en utilisant la manipulation de bits. Avec les opérations bit à bit, vous pouvez stocker jusqu'à 8 valeurs booléennes différentes dans un seul octet.

Définition des drapeaux

Définissez les drapeaux comme des positions de bits en utilisant des opérations de décalage à gauche :

rust
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

Pour définir un drapeau, nous pouvons simplement faire :

rust
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_1111

L'opérateur | (OU) est parfait pour "activer" des bits spécifiques sans affecter les autres, car lorsque vous faites un OU avec 0, le bit original est préservé et lorsque vous faites un OU avec 1, le résultat est toujours 1 (le drapeau est activé)

Si nous voulons vérifier si un drapeau est actif, nous pouvons faire quelque chose comme ceci :

rust
let flags = 0b0000_0101u8;  // Has ACTIVE and PREMIUM flags set

// Check if a single flag is set
if flags & FLAG_ACTIVE != 0 {
    println!("Account is active");
}

// Check if multiple flags are set
if (flags & (FLAG_ACTIVE | FLAG_PREMIUM)) == (FLAG_ACTIVE | FLAG_PREMIUM) {
    println!("Account is both active and premium");
}

// Check if any of multiple flags are set
if flags & (FLAG_VERIFIED | FLAG_PREMIUM) != 0 {
    println!("Account is either verified or premium (or both)");
}

L'opérateur & (ET) est parfait pour "masquer" des bits spécifiques afin de vérifier leurs valeurs, car lorsque vous faites un ET avec 0, le résultat est toujours 0 et lorsque vous faites un ET avec 1, le bit original est préservé

Pour effacer ou basculer des drapeaux, nous pouvons faire quelque chose comme ceci :

rust
let mut flags = 0b0000_1111u8;      // All flags set

// Clear a single flag
flags &= !FLAG_ACTIVE;              // flags = 0000_1110

// Clear multiple flags at once
flags &= !(FLAG_VERIFIED | FLAG_PREMIUM);   // flags = 0000_1000

// Toggle a single flag
flags ^= FLAG_ACTIVE;               // flags = 0000_1001 (now has VERIFIED)
flags ^= FLAG_LOCKED;               // flags = 0000_0001 (LOCKED now cleared)

// Toggle multiple flags at once
flags ^= FLAG_PREMIUM | FLAG_LOCKED;        // flags = 0000_1011

Mémoire sur Solana

La Machine Virtuelle Solana (SVM) utilise une architecture de mémoire à trois niveaux qui sépare strictement la mémoire de pile (variables locales), la mémoire du tas (structures dynamiques) et l'espace de compte (stockage persistant).

Comprendre cette architecture est crucial pour écrire des programmes Solana haute performance.

Les programmes fonctionnent dans des espaces d'adressage virtuel fixes avec un mappage mémoire prévisible :

  • Code du programme : 0x100000000 - Où réside votre bytecode de programme compilé

  • Données de pile : 0x200000000 - Variables locales et cadres d'appel de fonction

  • Données de tas : 0x300000000 - Allocations dynamiques (coûteuses !)

Cette disposition déterministe permet des optimisations puissantes mais crée également des contraintes strictes de performance.

Solana impose des limitations strictes de mémoire : 4 Ko de cadres de pile par appel de fonction et 32 Ko d'espace de tas total par exécution de programme.

L'avantage de la zéro-allocation

La principale avancée en performance de Pinocchio vient de l'utilisation de références au lieu d'allocations de tas pour tout.

Cette approche s'appuie sur une idée clé : la SVM charge déjà toutes vos entrées de programme en mémoire, donc copier ces données dans de nouvelles allocations de tas est un pur gaspillage.

L'allocation de tas n'est pas intrinsèquement mauvaise, mais sur Solana elle est coûteuse et complexe car chaque allocation consomme de précieuses unités de calcul : chaque allocation fragmente l'espace de tas limité et les opérations de nettoyage consomment des unités de calcul supplémentaires.

Techniques de zéro-allocation

  1. Structures de données basées sur les références - Transformer les données possédées en références empruntées :

    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
    }
  2. Accès aux données sans copie - Accéder aux données sur place sans désérialisation :

    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());
    }
  3. Contraintes No-Std - Empêcher l'utilisation accidentelle du tas au moment de la compilation :

    rust
    // Enforces no-std to prevents accidental heap usage:
    #![no_std]

Allocateur de mémoire Pinocchio

Si vous voulez vous assurer de ne pas allouer de tas, Pinocchio fournit une macro spécialisée pour garantir cela : no_allocator!()

Si non défini, Pinocchio utilisera le default_allocator!() pour les programmes nécessitant des opérations de tas traditionnelles.

En dehors de cela, vous pouvez toujours écrire un meilleur allocateur de tas qui sait comment se nettoyer après utilisation. Si cette approche vous intéresse, voici un exemple.

Blueshift © 2025Commit: e573eab