NUEVO: Helius adquiere Light Protocol
Optimización de programas de Solana
Blog/Desarrollo

Optimización de programas de Solana

Ingeniero de integraciónHet Dagli en XHet Dagli en LinkedIn
12 min de lectura

Conclusiones prácticas

  • Usa deserialización sin copias para estructuras de datos grandes y operaciones de alta frecuencia
  • Usa nostd_entrypoint en lugar del punto de entrada sobredimensionado solana_program’s
  • Minimiza las asignaciones dinámicas y prioriza las estructuras de datos basadas en la pila
  • Implementa serialización y deserialización personalizadas para evitar la sobrecarga de Borsh
  • Marca las funciones críticas con #[inline(always)] para obtener posibles mejoras de rendimiento
  • Usa manipulación de bits para analizar instrucciones de manera eficiente
  • Usa syscalls de C específicas de Solana, como sol_invoke_signed_c
  • Mide el uso de unidades de cómputo para orientar tus esfuerzos de optimización

Introducción

Los desarrolladores de Solana deben tomar varias decisiones al escribir programas: equilibrar la facilidad de uso, el rendimiento y la seguridad. Este espectro abarca desde Anchor, un framework fácil de usar que simplifica el desarrollo a costa de cierta sobrecarga, hasta enfoques de bajo nivel que usan Rust no seguro y syscalls directas. Aunque estos últimos ofrecen el máximo rendimiento, también implican mayor complejidad y posibles riesgos de seguridad. La pregunta clave para los desarrolladores no es solo cómo optimizar, sino cuándo y en qué medida hacerlo.

Este artículo explora estas opciones en profundidad y ofrece una hoja de ruta para que los desarrolladores naveguen por el panorama de la optimización. Examinaremos los siguientes niveles de abstracción:

  1. Anchor: El framework de alto nivel, potente y con criterios definidos al que recurren la mayoría de los desarrolladores
  2. Anchor con zero-copy: Código de Anchor escrito para optimizar estructuras de datos grandes
  3. Rust puro para equilibrar el control y la facilidad de uso
  4. Rust no seguro con llamadas al sistema (syscalls) directas: Llevar el rendimiento al límite

El objetivo no es imponer una solución universal, sino brindar a los desarrolladores el conocimiento necesario para tomar decisiones fundamentadas sobre cómo programar según sus casos de uso específicos.

Al terminar este artículo, comprenderás mejor cómo pensar en estos distintos niveles de abstracción y cuándo considerar avanzar por la ruta de optimización. Recuerda que el código más optimizado no siempre es la mejor solución: se trata de encontrar el equilibrio adecuado para las necesidades de tu proyecto.

Este artículo asume que conoces los fundamentos de Rust, el modelo de cuentas de Solana y el framework Anchor. 

Para quienes tienen prisa:

Unidades de cómputo

La arquitectura de alto rendimiento de Solana depende de una gestión eficiente de los recursos. En el centro de este sistema están las unidades de cómputo (CU), una medida de los recursos computacionales que consumen los validadores para procesar una transacción determinada.

‍**¿Por qué importan las unidades de cómputo?**

  1. Éxito de la transacción: Cada transacción tiene un límite de CU. Si lo supera, la transacción falla
  2. Eficiencia de costos: Un menor uso de CU implica comisiones de transacción más bajas
  3. Experiencia de usuario: Los programas optimizados se ejecutan más rápido y mejoran la experiencia general
  4. Escalabilidad: Los programas eficientes permiten más transacciones por bloque y mejoran el rendimiento de la red

Medición de unidades de cómputo

La syscall solana_program::log::sol_log_compute_units() registra la cantidad de unidades de cómputo que consume un programa en un punto específico de su ejecución.

Esta es una implementación sencilla de la macro compute_fn! que usa la syscall:

Código
#[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
    };
}

Esta macro proviene del repositorio de GitHub de Solana Developers para optimizaciones de CU. Este fragmento de código implementa un programa contador con dos instrucciones, initialize e increment

Para este artículo, escribiremos el mismo programa contador con las mismas dos instrucciones, initialize e increment, de cuatro formas distintas y compararemos el uso de CU de todas ellas: Anchor, Anchor con deserialización sin copias, Rust nativo y Rust no seguro

Inicializar una cuenta y hacerle un cambio menor (en este caso, incrementarla) es una referencia adecuada para comparar estos enfoques. Por ahora no usaremos PDA.

Para quienes tienen prisa, estas son las comparaciones de CU de los cuatro enfoques:

Comencemos…

Deserialización sin copias

La deserialización sin copias nos permite interpretar los datos de una cuenta directamente, sin asignar memoria nueva ni copiar datos. Esta técnica puede reducir el uso de CPU y el consumo de memoria, y generar instrucciones más eficientes.

Comencemos con un programa contador básico de Anchor:‍

Código
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,
}

Nada sofisticado hasta aquí. Ahora mejorémoslo con zero_copy:

Código
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,
}

Cambios clave:

Estos son los cambios principales que hicimos:

1. AccountLoader en lugar de Account

Ahora usamos AccountLoader<’info, CounterData> en lugar de Account<’info, Counter>. Esto permite acceder a los datos de la cuenta sin copiarlos.

2. Atributo zero-copy

El atributo #[account(zero_copy)] en CounterData indica que esta estructura se puede interpretar directamente a partir de bytes sin procesar en la memoria.

3. Acceso directo a los datos

En las funciones initialize e increment, usamos load_init() e load_mut(), respectivamente, para obtener acceso mutable a los datos de la cuenta sin copiarlos

4. Mitigación de vulnerabilidades por cuentas duplicadas 

La deserialización sin copias aborda una posible vulnerabilidad de la serialización Borsh. Con Borsh, se crean y modifican copias separadas de las cuentas, que luego se vuelven a copiar en la misma dirección. Este proceso puede producir inconsistencias si una misma cuenta aparece varias veces en una transacción.

En cambio, zero-copy lee y escribe directamente en la misma dirección de memoria. Este enfoque garantiza que todas las referencias a una cuenta dentro de una transacción operen sobre los mismos datos, lo que elimina el riesgo de inconsistencias por cuentas duplicadas.

5. Garantías de disposición de memoria

El atributo zero_copy garantiza que CounterData tenga una disposición de memoria uniforme, lo que permite reinterpretarlo de forma segura a partir de bytes sin procesar. Esta implementación redujo el uso de CU de la instrucción initialize de 5095 a 5022 y el de la instrucción increment de 1162 a 1124.

En nuestro caso, zero-copy produce mejoras mínimas y poco significativas. Sin embargo, la deserialización sin copias puede ser útil al trabajar con estructuras de datos grandes. Esto se debe a que puede reducir considerablemente el uso de CPU y memoria al procesar cuentas que almacenan datos complejos o extensos

Ventajas, desventajas y consideraciones

Zero-copy también presenta desafíos:

1. Mayor complejidad

El código se vuelve un poco más complejo y exige manejar con cuidado los datos sin procesar

2. Compatibilidad

No todas las estructuras de datos son adecuadas para la deserialización sin copias: deben tener una disposición de memoria predecible. Por ejemplo, las estructuras con campos de tamaño dinámico como Vec o String son incompatibles con este tipo de deserialización.

La decisión de usar zero-copy debe basarse en tu caso de uso específico. En programas sencillos como nuestro contador, los beneficios pueden ser mínimos. Sin embargo, a medida que tus programas se vuelven más complejos y manejan estructuras de datos más grandes, zero-copy puede convertirse en una potente herramienta de optimización.

Aunque la optimización con zero-copy no produjo mejoras significativas en nuestro sencillo programa contador, la búsqueda de eficiencia no termina aquí. Exploremos otra vía: escribir programas nativos de Solana en Rust sin el framework Anchor. Este enfoque ofrece más control y posibilidades de optimización, aunque también aumenta la complejidad.

Uso de Rust nativo

Los programas en Rust nativo ofrecen una interfaz de más bajo nivel y requieren que los desarrolladores se encarguen de varias tareas que Anchor automatiza. Esto incluye la deserialización y serialización de cuentas, además de distintas comprobaciones de seguridad. Aunque exige más del desarrollador, también abre oportunidades para realizar optimizaciones precisas.

Examinemos la implementación en Rust nativo de nuestro programa contador:‍

Código
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 })
    }
}

Diferencias y consideraciones clave

Estos son algunos aspectos que debes considerar:

1. Análisis manual de instrucciones

A diferencia de Anchor, que enruta las instrucciones automáticamente, aquí analizamos manualmente los datos de cada instrucción y los dirigimos a la función correspondiente.

Código
let instruction = instruction_data
        .get(0)
        .ok_or(ProgramError::InvalidInstructionData)?;

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

2. Gestión de cuentas

Usamos next_account_info para recorrer las cuentas y comprobar manualmente los firmantes y propietarios. Anchor lo hace automáticamente con su macro #[derive(Accounts)].

Código
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. Serialización personalizada

Implementamos métodos serialize e deserialize personalizados para nuestra estructura Counter. Anchor usa la serialización Borsh de forma predeterminada y abstrae este proceso.

Código
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. Interacciones con el programa del sistema

Crear cuentas implica interactuar directamente con el programa del sistema mediante invoke y realizar una invocación entre programas (CPI), algo que Anchor simplifica con su restricción init:

Código
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. Control detallado

En general, los programas nativos ofrecen más control sobre la disposición y el procesamiento de datos porque no siguen un único framework con criterios definidos. Esto permite optimizar mejor el código.

Cómo evaluar Anchor frente a Rust nativo

Estos son algunos aspectos que debes considerar:

1. Explícito frente a implícito

Los programas nativos requieren gestionar explícitamente muchos aspectos que Anchor maneja de manera implícita. Esto incluye la validación de cuentas, la serialización y el enrutamiento de instrucciones

2. Consideraciones de seguridad

Sin las comprobaciones integradas de Anchor, los desarrolladores deben implementar cuidadosamente las medidas de seguridad adecuadas, como verificar la propiedad de las cuentas y el estado de los firmantes

3. Ajuste del rendimiento

Los programas nativos permiten optimizaciones de rendimiento más precisas, pero requieren comprender mejor el comportamiento del entorno de ejecución de Solana

4. Código repetitivo

Tendrás que escribir más código repetitivo para operaciones comunes que Anchor abstrae

5. Curva de aprendizaje

Aunque puede ser más eficiente, la programación nativa tiene una curva de aprendizaje más pronunciada y exige un conocimiento más profundo de la arquitectura de Solana

En resumen

El principal factor limitante al pasar de Anchor a código nativo es gestionar la serialización y deserialización. En nuestro caso, fue relativamente sencillo. Sin embargo, se volvería cada vez más complicado a medida que aumentara la complejidad de la gestión del estado.

No obstante, también es cierto que Borsh, utilizado por Anchor, tiene un costo computacional muy alto, por lo que el esfuerzo vale la pena.

Nuestro recorrido de optimización no termina aquí. En la siguiente sección llevaremos los límites aún más lejos mediante syscalls directas y sin usar la biblioteca estándar de Rust.

Este enfoque es difícil, pero prometo que ofrecerá ideas interesantes sobre el funcionamiento interno del entorno de ejecución de Solana.

Llevar el rendimiento al límite con Rust no seguro y syscalls directas

Para llevar al límite el rendimiento de nuestro programa contador, ahora exploraremos el uso de Rust no seguro y syscalls directas. Rust no seguro permite omitir las comprobaciones de seguridad estándar, lo que posibilita manipular directamente la memoria y aplicar optimizaciones de bajo nivel. Por su parte, las syscalls ofrecen interfaces directas con el entorno de ejecución de Solana.

Aunque este enfoque es complejo y requiere un desarrollo meticuloso, puede ahorrar una cantidad significativa de CU. Sin embargo, también exige comprender mejor la arquitectura de Solana y prestar especial atención a la seguridad del programa. Las posibles mejoras de rendimiento son considerables, pero conllevan una mayor responsabilidad.

Examinemos una versión muy optimizada de nuestro programa contador que aprovecha estas técnicas avanzadas:

Código
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(())
}

Diferencias y optimizaciones clave

Veamos las optimizaciones:

1. Entorno no-std

Usamos solana_nostd_entrypoint, que proporciona un entorno no-std. Esto elimina la sobrecarga de la biblioteca estándar de Rust, reduce el tamaño del programa y puede mejorar el rendimiento. Damos crédito a cavemanloverboy y a su repositorio de GitHub sobre el punto de entrada no-std para programas de Solana.

2. Funciones inline

Las funciones críticas están marcadas con #[inline(always)]. La inserción inline es una optimización del compilador en la que el cuerpo de la función se inserta en el lugar de la llamada, lo que elimina su sobrecarga. Esto puede acelerar la ejecución, sobre todo en funciones pequeñas que se llaman con frecuencia.

3. Manipulación de bits para analizar instrucciones

Usamos manipulación de bits instruction_data[0] & 1 para determinar el tipo de instrucción, lo que puede ser más eficiente que otros métodos de análisis:

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

4. Gestión de memoria sin costo y manejo mínimo de pánicos

Las macros noalloc_allocator! e basic_panic_impl! implementan una gestión de memoria y un manejo de pánicos mínimos, sin sobrecarga:

Noalloc_allocator! define un asignador personalizado que genera un pánico ante cualquier intento de asignación y no hace nada durante la desasignación. Configurarlo como asignador global de los programas de Solana impide de forma efectiva cualquier asignación dinámica de memoria durante la ejecución:

Código
#[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;
        }
    };
}

Esto es crucial porque:

  1. Elimina la sobrecarga de las operaciones de asignación y desasignación de memoria
  2. Obliga a los desarrolladores a usar memoria estática o basada en la pila, que suele ser más rápida y predecible en términos de rendimiento
  3. Reduce la huella de memoria del programa

basic_panic_impl! proporciona un controlador de pánicos mínimo que solo registra el mensaje “panicked!”:

Código
#[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. Preparación eficiente de CPI

La estructura InstructionC y las funciones to_meta_c e to_info_c ofrecen una forma eficiente y de bajo nivel de preparar datos para las CPI:

Código
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()
];

Estas funciones crean estructuras compatibles con C que pueden pasarse directamente a la syscall sol_invoke_signed_c. Al evitar la sobrecarga de las abstracciones de alto nivel de Rust y trabajar directamente con punteros sin procesar y estructuras compatibles con C, estas funciones minimizan el costo computacional de preparar las CPI.

Este enfoque ahorra CU al reducir las asignaciones de memoria, las copias y las conversiones que normalmente ocurrirían al usar tipos de Rust más abstractos.

Por ejemplo, el método to_info_c construye eficientemente una estructura AccountInfoC mediante aritmética directa de punteros:

Código
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 …
  }
}

Esta manipulación directa de las disposiciones de memoria permite crear con gran eficiencia las estructuras necesarias para las CPI, lo que reduce el costo en CU de estas operaciones.

6. Syscalls directas y Rust no seguro

Este enfoque omite las abstracciones habituales de Rust e interactúa directamente con el entorno de ejecución de Solana, lo que ofrece importantes ventajas de rendimiento. Sin embargo, también introduce complejidad y exige manejar con cuidado Rust no seguro:

Código
// 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. Compilación condicional:

El atributo #[cfg(target_os = “solana”)] garantiza que este código solo se compile para el entorno de ejecución de Solana. Esto es necesario porque estas syscalls solo están disponibles en ese entorno.

Posibles problemas de Rust no seguro 

Aunque es potente, Rust no seguro puede causar problemas graves si no se maneja correctamente:

  • Fugas y corrupción de memoria
  • Comportamiento indefinido
  • Condiciones de carrera

Para mitigar los riesgos al usar Rust no seguro:

  • Usa bloques no seguros con moderación y solo cuando sea necesario
  • Documenta todas las suposiciones e invariantes de seguridad
  • Usa herramientas como Miri y los sanitizers integrados de Rust para realizar pruebas
  • Considera técnicas de verificación formal para las secciones críticas
  • Realiza revisiones exhaustivas del código enfocadas en los bloques no seguros

En resumen

Aunque todo esto es fascinante, resulta difícil justificar el uso de este enfoque hiperoptimizado en un programa listo para producción que proteja dinero real. La mayor complejidad, la posibilidad de errores y los desafíos de mantenimiento son obstáculos importantes. En la mayoría de las aplicaciones, el riesgo de introducir errores críticos suele superar los beneficios de rendimiento.

Es muy probable que este enfoque te lleve a la trampa de la optimización prematura.

‍Sin embargo, algunas cosas son fáciles de replicar:

  1. Usar nostd_entrypoint en lugar del sobredimensionado entrypoint de solana_program
  2. Usar funciones inline siempre que sea posible
  3. Minimizar las asignaciones dinámicas y priorizar las estructuras de datos basadas en la pila‍

Conclusión 

Este artículo exploró varios niveles de optimización para programas de Solana, desde el desarrollo de alto nivel con Anchor hasta Rust no seguro de bajo nivel con syscalls directas. Vimos cómo cada enfoque ofrece un equilibrio distinto entre facilidad de uso, seguridad y rendimiento.‍

Conclusiones clave:

  • Anchor ofrece un framework fácil de usar con cierta sobrecarga de rendimiento
  • La deserialización sin copias puede mejorar considerablemente la eficiencia de las estructuras de datos grandes
  • Rust nativo ofrece más control y posibilidades de optimización
  • Rust no seguro y las syscalls directas ofrecen el máximo rendimiento, pero aumentan la complejidad y el riesgo

La elección del nivel de optimización depende de tu caso de uso específico, tus requisitos de rendimiento y tu tolerancia al riesgo. Mide siempre el impacto de las optimizaciones y considera las implicaciones de mantenimiento a largo plazo de tus decisiones.

Si llegaste hasta aquí, ¡gracias, anon! Ingresa tu correo electrónico a continuación para no perderte ninguna novedad sobre Solana. ¿Quieres profundizar? Explora los artículos más recientes del blog de Helius y continúa hoy mismo tu recorrido por Solana.

Recursos adicionales

Suscríbete a Helius

Mantente al día con las novedades del desarrollo en Solana y recibe actualizaciones cuando publiquemos

Imagen ampliada