En Ethereum, el precio de una transacción se calcula como . Esto nos dice cuánto Ether se gastará para incluir la transacción en la blockchain. Antes de enviar una transacción, se especifica un gasLimit y se paga por adelantado. Si la transacción se queda sin gas, se revierte.
A diferencia de las cadenas EVM, los opcodes/instrucciones de Solana consumen “compute units” (posiblemente un nombre mejor) y no gas, y cada transacción tiene un límite flexible de 200,000 compute units. Si la transacción cuesta más de 200,000 compute units, se revierte.
En Ethereum, los costos de gas para computación se tratan igual que los costos de gas asociados con el almacenamiento. En Solana, el almacenamiento se maneja de manera diferente, por lo que la fijación de precios de los datos persistentes en Solana es un tema de discusión distinto.
Sin embargo, desde la perspectiva de la fijación de precios en la ejecución de op codes, Ethereum y Solana se comportan de manera similar.
Ambas cadenas ejecutan bytecode compilado y cobran una tarifa por cada instrucción ejecutada. Ethereum utiliza bytecode de la EVM, pero Solana ejecuta una versión modificada de berkeley packet filter llamada Solana packet filter.
Ethereum cobra precios diferentes por distintos op codes dependiendo del tiempo que tarden en ejecutarse, variando desde un gas hasta miles de gas. En Solana, cada opcode cuesta un compute unit.
Qué hacer cuando no tienes suficientes compute units
Al realizar operaciones computacionales pesadas que no se pueden completar por debajo del límite, la estrategia tradicional es “guardar tu progreso” y hacerlo en múltiples transacciones.
La parte de “guardar tu progreso” necesita colocarse en almacenamiento permanente, lo cual no es algo que hayamos cubierto todavía. Esto es similar a si estuvieras intentando iterar sobre un bucle masivo en Ethereum; tendrías una variable de almacenamiento para el índice en el que te detuviste, y una variable de almacenamiento guardando la computación realizada hasta ese punto.
Optimización de compute units
Como ya sabemos, Solana utiliza los compute units para prevenir el problema de la parada (halting problem) y evitar la ejecución de código que corra indefinidamente. Tiene un límite de compute units por transacción de 200,000 CU (puede incrementarse hasta 1.4m CU con un costo adicional), el cual si se excede (el límite elegido), el programa se detiene, todos los estados modificados se revierten y las tarifas no se devuelven al llamador. Esto previene que atacantes intenten ejecutar un programa interminable o computacionalmente intensivo en los nodos para ralentizarlos o detener la cadena.
Sin embargo, a diferencia de las cadenas EVM, los recursos computacionales utilizados en una transacción no afectan las tarifas pagadas por esa transacción. Se te cobrará como si hubieras utilizado todo tu límite o si hubieras utilizado muy poco de él. Por ejemplo, una transacción de 400 compute units cuesta lo mismo que una transacción de 200,000 compute units.
Además de los compute units, el número de firmantes de la transacción de Solana afecta el costo en compute units. Según la documentación de Solana:
“Así que en este momento, las tarifas de transacción están determinadas únicamente por el número de firmas que necesitan ser verificadas en una transacción. El único límite en el número de firmas en una transacción es el tamaño máximo de la transacción en sí. Cada firma (64 bytes) en una transacción (máx. 1232 bytes) debe hacer referencia a una clave pública única (32 bytes), por lo que una sola transacción podría contener hasta 12 firmas (no estoy seguro de por qué harías eso).”
Podemos ver esto en acción con este pequeño ejemplo. Comienza con un programa de Solana vacío como este:
use anchor_lang::prelude::*;
declare_id!("6CCLqLGeyExCFegJDjRDirWQRRSbM5XNq3yKvmaWS2ZC");
#[program]
pub mod compute_unit {
use super::*;
pub fn initialize(_ctx: Context<Initialize>) -> Result<()> {
Ok(())
}
}
#[derive(Accounts)]
pub struct Initialize {}
Actualizar el archivo de prueba:
import * as anchor from "@coral-xyz/anchor";
import { Program } from "@coral-xyz/anchor";
import { ComputeUnit } from "../target/types/compute_unit";
describe("compute_unit", () => {
// Configure the client to use the local cluster.
anchor.setProvider(anchor.AnchorProvider.env());
const program = anchor.workspace.ComputeUnit as Program<ComputeUnit>;
const defaultKeyPair = new anchor.web3.PublicKey(
// replace this with your default provider keypair, you can get it by running `solana address` in your terminal
"EXJupeVMqDbHk7xY4XP4TVXq22L3ZJxJ9Gm68hJccpLp"
);
it("Is initialized!", async () => {
// log the keypair's initial balance
let bal_before = await program.provider.connection.getBalance(
defaultKeyPair
);
console.log("before:", bal_before);
// call the initialize function of our program
const tx = await program.methods.initialize().rpc();
// log the keypair's balance after
let bal_after = await program.provider.connection.getBalance(
defaultKeyPair
);
console.log("after:", bal_after);
// log the difference
console.log(
"diff:",
BigInt(bal_before.toString()) - BigInt(bal_after.toString())
);
});
});
Nota: En JavaScript, la “n” al final de un número significa que es un BigInt.
Ejecuta: solana logs también, si es que aún no lo tienes ejecutándose.
Cuando ejecutamos anchor test --skip-local-validator obtenemos esta salida como registros de prueba y registros del validador de Solana:
# test logs
compute_unit
before: 15538436120
after: 15538431120
diff: 5000n
# solana logs
Status: Ok
Log Messages:
Program 6CCLqLGeyExCFegJDjRDirWQRRSbM5XNq3yKvmaWS2ZC invoke [1]
Program log: Instruction: Initialize
Program 6CCLqLGeyExCFegJDjRDirWQRRSbM5XNq3yKvmaWS2ZC consumed 320 of 200000 compute units
Program 6CCLqLGeyExCFegJDjRDirWQRRSbM5XNq3yKvmaWS2ZC success
La diferencia de balance de 5000 lamports se debe a que necesitamos/usamos solo 1 firma (la de nuestra dirección proveedora por defecto) al enviar esta transacción. Esto es consistente con lo que establecimos anteriormente, es decir, 1 * 5000 = 5000. Observa también que esto cuesta 320 en compute units, pero esta cantidad no afecta nuestra tarifa de transacción.
Ahora, agreguemos algo de complejidad a nuestro programa y veamos qué sucede:
pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
let mut a = Vec::new();
a.push(1);
a.push(2);
a.push(3);
a.push(4);
a.push(5);
Ok(())
}
Seguramente esto debería hacer alguna diferencia en nuestra tarifa de transacción, ¿verdad?
Cuando ejecutamos anchor test --skip-local-validator obtenemos esta salida como registros de prueba y registros del validador de Solana:
# test logs
compute_unit
before: 15538436120
after: 15538431120
diff: 5000n
# solana logs
Status: Ok
Log Messages:
Program 6CCLqLGeyExCFegJDjRDirWQRRSbM5XNq3yKvmaWS2ZC invoke [1]
Program log: Instruction: Initialize
Program 6CCLqLGeyExCFegJDjRDirWQRRSbM5XNq3yKvmaWS2ZC consumed 593 of 200000 compute units
Program 6CCLqLGeyExCFegJDjRDirWQRRSbM5XNq3yKvmaWS2ZC success
Podemos ver que esto cuesta más compute units, casi el doble de nuestro primer ejemplo. Pero esto no afecta nuestras tarifas de transacción. Esto es lo esperado y demuestra que, verdaderamente, los compute units no afectan las tarifas de transacción pagadas por los usuarios.
Independientemente de los compute units consumidos, la transacción cobró 5000 lamports o 0.000005 SOL.
Volviendo a los compute units. Entonces, ¿por qué querríamos optimizar los compute units dado que no afectan las tarifas pagadas por las transacciones?
- Primero, esto solo es cierto por ahora; en el futuro, Solana podría concluir elevar el límite y tendría que incentivar a los nodos para que no traten estas transacciones complejas de manera diferente a las simples. Esto significaría considerar los compute units consumidos al calcular las tarifas de transacción.
- Segundo, es más probable que una transacción más pequeña sea incluida en un bloque si hay una actividad de red significativa compitiendo por espacio en el bloque.
- Tercero, hará que tu programa sea más componible con otros programas. Si otro programa llama al tuyo, la transacción no obtiene un límite de cómputo adicional. Es posible que otros programas no quieran integrarse con el tuyo si tu transacción usa demasiado cómputo, dejando poco para el programa original.
Los números enteros más pequeños ahorran compute units
Cuanto más grandes sean los tipos de valor utilizados, mayor será el consumo de compute units. Es mejor usar tipos más pequeños cuando sea aplicable. Tomemos el ejemplo de código y los comentarios:
pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
// this costs 600 CU (type defaults to Vec<i32>)
let mut a = Vec::new();
a.push(1);
a.push(1);
a.push(1);
a.push(1);
a.push(1);
a.push(1);
// this costs 618 CU
let mut a: Vec<u64> = Vec::new();
a.push(1);
a.push(1);
a.push(1);
a.push(1);
a.push(1);
a.push(1);
// this costs 600 CU (same as the first one but the type was explicitly denoted)
let mut a: Vec<i32> = Vec::new();
a.push(1);
a.push(1);
a.push(1);
a.push(1);
a.push(1);
a.push(1);
// this costs 618 CU (takes the same space as u64)
let mut a: Vec<i64> = Vec::new();
a.push(1);
a.push(1);
a.push(1);
a.push(1);
a.push(1);
a.push(1);
// this costs 459 CU
let mut a: Vec<u8> = Vec::new();
a.push(1);
a.push(1);
a.push(1);
a.push(1);
a.push(1);
a.push(1);
Ok(())
}
Nota la reducción en el costo de compute units a medida que se reduce el tipo de entero. Esto es esperado ya que los tipos más grandes ocupan un espacio mayor en memoria que los tipos más pequeños, independientemente del valor representado.
Generar una program derived account (PDA) on-chain utilizando find_program_address puede consumir más compute units porque este método itera sobre llamadas a create_program_address hasta encontrar una PDA que no esté en la curva ed25519. Para reducir el costo computacional, utiliza find_program_address() off-chain y pasa el bump seed resultante al programa cuando sea posible. Se discutirá más sobre esto en una sección posterior, ya que está fuera del alcance de esta sección.
Esta no es una lista exhaustiva, sino algunos puntos para dar una idea de qué hace que un programa sea más computacionalmente intensivo que otro.
¿Qué es eBPF?
El bytecode de Solana deriva en gran medida de BPF. “eBPF” simplemente significa “extended BPF” (BPF extendido). Esta sección explica BPF en el contexto de Linux.
Como es de esperar, la Solana VM no entiende Rust ni C. Los programas escritos en estos lenguajes se compilan a eBPF (extended Berkeley Packet Filter).
En pocas palabras, eBPF permite la ejecución de bytecode eBPF arbitrario dentro del kernel (en un entorno sandbox) cuando el kernel emite un evento al que el bytecode eBPF está suscrito, por ejemplo:
- red: abrir/cerrar un socket
- disco: escribir/leer
- creación de un proceso
- creación de un hilo
- invocación de instrucción de la CPU
- soporta hasta 64 bits (es por eso que Solana tiene un tipo uint máximo de u64)
Puedes pensarlo como JavaScript pero para el kernel. JavaScript realiza acciones en el navegador cuando se emite un evento; eBPF hace algo muy similar cuando se emiten eventos dentro del kernel, por ejemplo, cuando se ejecuta un syscall.
Esto nos permite construir programas para varios casos de uso, por ejemplo (basado en los eventos enumerados anteriormente):
- red: para analizar rutas y más
- seguridad: filtrar tráfico basado en ciertas reglas y reportar cualquier tráfico malicioso/bloqueado
- rastreo y perfilado: recopilar el flujo de ejecución detallado desde el programa en el userspace hasta las instrucciones del kernel
- observabilidad: reportar y analizar actividades del kernel
El programa solo se ejecuta cuando lo necesitamos (es decir, cuando se emite un evento en el kernel). Por ejemplo, digamos que deseas obtener el nombre de un archivo y los datos escritos en él en el momento de la escritura, entonces escuchamos/registramos/suscribimos el evento del syscall vfs_write(). Ahora, siempre que se escriba en ese archivo, tendremos esos datos a nuestra disposición.
Solana Bytecode Format (SBF)
El Solana Bytecode Format es una variante de eBPF con ciertos cambios, y el que más destaca es la eliminación del verificador de bytecode. El verificador de bytecode está presente en eBPF para asegurar que todas las rutas de ejecución posibles sean finitas y seguras de ejecutar.
Solana maneja esto utilizando un límite de compute units. Tener un medidor de cómputo que limite los recursos computacionales gastados con un tope traslada las comprobaciones de seguridad al runtime y permite el acceso arbitrario a la memoria, saltos indirectos, bucles y otros comportamientos interesantes.
En un tutorial posterior, profundizaremos en un programa simple y su bytecode, lo modificaremos, entenderemos los diferentes costos de compute units y aprenderemos exactamente cómo funciona el bytecode de Solana y cómo analizarlo.
Aprende más con RareSkills
Este tutorial es parte de nuestro curso de Solana.
Publicado originalmente el 23 de febrero de 2024