Rust
Pinocchio pour les nuls

Pinocchio pour les nuls

Point d'entrée du middleware

Toutes les instructions ne sont pas créées égales. Certaines instructions sont appelées plus fréquemment que d'autres, créant des goulots d'étranglement de performance.

Pour privilégier l'efficacité et l'optimisation, nous avons besoin d'une approche différente pour gérer ces instructions à haute fréquence.

C'est là que la stratégie des chemins "hot" (chaud) et "cold" (froid) entre en jeu.

Le chemin "hot" crée un point d'entrée optimisé pour les instructions fréquemment appelées, conçu pour atteindre un état d'"échec" aussi rapidement que possible afin que nous puissions revenir au point d'entrée standard de Pinocchio lorsque nécessaire.

Le chemin Hot

Le chemin hot contourne la logique standard du point d'entrée qui désérialise toutes les données des comptes avant le traitement. Au lieu de cela, il travaille directement avec les données brutes pour une performance maximale.

Traitement standard vs chemin Hot

Dans le point d'entrée standard, le chargeur empaquette tout ce dont une instruction a besoin dans un enregistrement plat de style C stocké sur la page d'entrée de la VM BPF. La macro du point d'entrée décompresse cet enregistrement et fournit trois tranches sécurisées : program_id, accounts, et instruction_data.

Dans le chemin Hot, puisque nous savons exactement à quoi nous attendre, nous pouvons vérifier et manipuler les comptes directement à partir des données brutes du point d'entrée, éliminant ainsi les frais généraux de désérialisation inutiles.

La structure d'entrée brute ressemble à ceci :

rust
pub struct Entrypoint {
    account_len: u64,
    account_info: [AccountRaw; account_len]
    instruction_len: u64,
    instruction_data: [u8; instruction_len]
    program_id: [u8; 32],
}

pub struct AccountRaw {
    is_duplicate: u8,
    is_signer: u8,
    is_writable: u8,
    executable: u8,
    alignment: u32,
    key: [u8; 32],
    owner: [u8; 32],
    lamports: u64,
    data_len: usize,
    data: [u8; data_len],
    padding: [u8; 10_240],
    alignment_padding: [u8; ?],
    rent_epoch: i64,
}

Discriminateurs du chemin Hot

Lors de la conception du chemin hot, nous avons besoin d'un moyen fiable pour déterminer si l'instruction actuelle doit être traitée par le chemin hot ou acheminée vers le chemin cold aussi rapidement que possible.

Les discriminateurs traditionnels ne fonctionneront pas ici car ils apparaissent à différents décalages chaque fois en fonction du nombre de comptes et des données qu'ils contiennent.

Nous devons vérifier quelque chose qui est toujours au même décalage. Actuellement, nous avons deux approches pour discriminer ces instructions :

  • Nombre de comptes : Comme le nombre de comptes est la première entrée dans le point d'entrée brut, nous pouvons discriminer en fonction du nombre de comptes transmis : if *input == 4u64. Cela fonctionne car le nombre de comptes a une position fixe au tout début des données d'entrée.

  • Clé publique du premier compte : Comme la clé publique du premier compte apparaît à un décalage fixe, nous pouvons concevoir notre programme pour toujours placer une paire de clés constante (comme une autorité ou un programme) en première position et discriminer en fonction de cette clé.

Actuellement, il y a un SIMD ouvert pour avoir dans le registre r2 les données d'instruction. Cela signifie qu'après son implémentation, nous pourrons utiliser le discriminateur pour notre chemin rapide.

Conception du point d'entrée

Avec cette PR, Dean a introduit un nouveau point d'entrée appelé middleware_entrypoint dans Pinocchio, permettant une création facile de chemins rapides pour les programmes.

Voici comment l'implémenter :

rust
/// A "dummy" function with a hint to the compiler that it is unlikely to be
/// called.
///
/// This function is used as a hint to the compiler to optimize other code paths
/// instead of the one where the function is used.
#[cold]
pub const fn cold_path() {}

/// Return the given `bool` value with a hint to the compiler that `true` is the
/// likely case.
#[inline(always)]
pub const fn likely(b: bool) -> bool {
    if b {
        true
    } else {
        cold_path();
        false
    }
}

middleware_entrypoint!(hot, process_instruction);

#[inline(always)]
pub fn hot(input: *mut u8) -> u64 {
    unsafe { *input as u64 }
}

#[inline(always)]
fn process_instruction(
    _program_id: &Pubkey,
    accounts: &[AccountInfo],
    instruction_data: &[u8],
) -> ProgramResult {    
    match instruction_data.split_first() {
        Some((Instruction1::DISCRIMINATOR, data)) => Instruction1::try_from((data, accounts))?.process(),
        Some((Instruction2::DISCRIMINATOR, _)) => Instruction2::try_from(accounts)?.process(),
        _ => Err(ProgramError::InvalidInstructionData)
    }
}

Comment ça fonctionne

Le point d'entrée appelle d'abord la fonction "rapide", vérifie si elle renvoie une erreur, et si c'est le cas, se replie sur le point d'entrée Pinocchio par défaut. Cela crée un chemin rapide pour les opérations courantes tout en maintenant la compatibilité.

L'attribut #[cold] indique au compilateur que cette fonction est rarement appelée. Pendant l'optimisation, le compilateur déprioritise les chemins de code froids et concentre les ressources sur l'optimisation du chemin rapide.

Principes de conception du chemin rapide

Lors de la conception du chemin rapide, une validation rigoureuse est essentielle car nous travaillons avec des entrées brutes. Tout comportement non défini pourrait compromettre l'ensemble du programme.

Vérifiez toujours que les décalages et les longueurs des comptes correspondent aux attentes. Utilisez sbpf.xyz pour déterminer les décalages corrects, puis validez comme ceci :

rust
if *input == 4
    && (*input.add(ACCOUNT1_DATA_LEN).cast::<u64>() == 165)
    && (*input.add(ACCOUNT2_DATA_LEN).cast::<u64>() == 82)
    && (*input.add(IX12_ACCOUNT3_DATA_LEN).cast::<u64>() == 165)
{
    //...
}

Lorsque les comptes ont des longueurs de données variables, générez des décalages dynamiques pour localiser les données d'instruction comme ceci :

rust
/// Align an address to the next multiple of 8.
#[inline(always)]
fn align(input: u64) -> u64 {
    (input + 7) & (!7)
}

//...

// The `authority` account can have variable data length.
    let account_4_data_len_aligned =
        align(*input.add(IX12_ACCOUNT4_DATA_LEN).cast::<u64>()) as usize;
    let offset = IX12_EXPECTED_INSTRUCTION_DATA_LEN_OFFSET + account_4_data_len_aligned;

Une fois la validation réussie, extrayez les données d'instruction, transmutez les comptes et traitez normalement :

rust
// Check that we have enough instruction data.
if input.add(offset).cast::<usize>().read() >= INSTRUCTION_DATA_SIZE {
    let discriminator = input.add(offset + size_of::<u64>()).cast::<u8>().read();

    // Check for instruction discriminator.
    if likely(discriminator == 12) {
        // instruction data length (u64) + discriminator (u8)
        let instruction_data = unsafe { from_raw_parts(input.add(offset + size_of::<u64>() + size_of::<u8>()), INSTRUCTION_DATA_SIZE - size_of::<u8>()) };

        let accounts = unsafe {
            [
                transmute::<*mut u8, AccountInfo>(input.add(ACCOUNT1_HEADER_OFFSET)),
                transmute::<*mut u8, AccountInfo>(input.add(ACCOUNT2_HEADER_OFFSET)),
                transmute::<*mut u8, AccountInfo>(input.add(IX12_ACCOUNT3_HEADER_OFFSET)),
                transmute::<*mut u8, AccountInfo>(input.add(IX12_ACCOUNT4_HEADER_OFFSET)),
            ]
        };

        return match Instruction1::try_from((instruction_data, accounts))?.process() {
            Ok(()) => SUCCESS,
            Err(error) => {
                log_error(&error);
                error.into()
            }
        };
    }
}
Blueshift © 2025Commit: e573eab