Оптимізація серіалізації в Anchor-програмах безпосередньо зменшує споживання compute units, знижує ймовірність перевищення ліміту в 200 000 CU і дозволяє помістити більше логіки в одну транзакцію. Нижче — інженерний розбір того, як Anchor серіалізує дані, де приховані витрати та як їх усунути без руйнування архітектури.

Anchor serialize/deserialize під капотом

Anchor за замовчуванням використовує формат Borsh — компактну бінарну серіалізацію з фіксованою схемою типів. Кожен макрос #[account] автоматично генерує реалізації трейтів AnchorSerialize та AnchorDeserialize, а також AccountSerialize і AccountDeserialize, які додають дискримінатор (перші 8 байтів SHA256-хешу від імені типу).

Ключові механізми, які варто розуміти:

  • Фіксовані типи (u8, u16, u32, u64, i64, bool) записуються як є, без префіксів довжини. Порядок байтів — little-endian.
  • Змінної довжини типи (String, Vec) серіалізуються з u32-префіксом, що вказує кількість елементів або байтів.
  • Enum серіалізуються як u32-тег варіанту, після якого йдуть дані цього варіанту.
  • Option<T> займає 1 байт (0 або 1) плюс розмір T, якщо значення є.

Десеріалізація в Anchor відбувається у два етапи. Спочатку фреймворк перевіряє дискримінатор (перші 8 байтів), потім викликає try_deserialize, який отримує &mut &[u8] і послідовно зчитує поля зрізу. Кожне зчитування змінює початковий зріз, просуваючи вказівник. Це означає, що десеріалізація — це лінійний прохід по байтах без додаткових алокацій для самого буфера, але створення полів типу String або Vec викликає алокацію в купі.

Типова помилка — припускати, що оскільки буфер акаунта вже завантажено в пам'ять, десеріалізація «безкоштовна». Насправді алокації для Vec і String, перевірки кордонів зрізу та копіювання фіксованих типів у структуру коштують compute units. У навантажених інструкціях ці витрати помітні.

Кастомні типи з оптимальною серіалізацією

Перший інструмент оптимізації — заміна змінної довжини на фіксовану там, де це допустимо за бізнес-логікою.

Фіксовані масиви замість Vec

Якщо максимальна кількість елементів відома заздалегідь і невелика (до ~20–30), використовуйте [T; N] замість Vec<T>. Це усуває u32-префікс довжини та алокацію в купі під час десеріалізації.

#[account]
pub struct PoolState {
  pub authorities: [Pubkey; 4],
  pub fee_bps: u16,
}

// замість
#[account]
pub struct PoolState {
  pub authorities: Vec<Pubkey>,
  pub fee_bps: u16,
}

Обмеження: фіксований масив збільшує мінімальний розмір акаунта навіть при неповному заповненні. Рахуйте баланс: якщо економія compute units на серіалізації перевищує втрати від збільшеного rent-exempt мінімуму — варто використовувати.

ZeroCopy-акаунти

Для акаунтів, які читаються часто, але рідко модифікуються (наприклад, конфігураційні структури, пули ліквідності), Anchor пропонує атрибут #[account(zero_copy)]. Такий акаунт повинен складатися виключно з типів, що реалізують трейт Pod з крейту bytemuck: фіксовані числа, фіксовані масиви, Pod-структури.

ZeroCopy-акаунти не десеріалізуються в класичному розумінні. Замість цього ви отримуєте посилання на дані, які вже лежать у пам'яті у правильному форматі, і працюєте з ними через Ref<T> або RefMut<T>. Жодних алокацій, жодного копіювання.

use anchor_lang::prelude::*;
use bytemuck::{Pod, Zeroable};

#[derive(AnchorSerialize, AnchorDeserialize, Clone, Copy, Pod, Zeroable)]
#[repr(C)]
pub struct PoolConfig {
  pub fee_rate: u64,
  pub paused: u8,
  pub reserve: [u8; 7], // вирівнювання до 16 байтів
}

#[account(zero_copy)]
pub struct ConfigAccount {
  pub config: PoolConfig,
}

Обмеження ZeroCopy: неможливо використовувати String, Vec, Option, enum з даними. Усе має бути фіксованого розміру. Підхід непридатний для акаунтів зі змінною кількістю записів.

Бітова упаковка прапорців

Якщо в структурі є кілька bool-полів, кожне з них займає 1 байт. Замість цього можна запакувати до 8 прапорців в один u8 і працювати з бітовими масками. Це економить байти в акаунті та зменшує кількість операцій зчитування під час десеріалізації.

Обмеження: такий код складніше читати та аудирувати. Використовуйте лише там, де економія байтів критична для розміру акаунта або де акаунт читається в тісному циклі.

Уникнення непотрібних десеріалізацій

Не кожен акаунт, переданий в інструкцію, потрібно десеріалізувати. Anchor дозволяє працювати з AccountInfo безпосередньо, якщо вам потрібні лише метадані акаунта, а не його дані.

UncheckedAccount для акаунтів без десеріалізації

Якщо ви передаєте акаунт лише для перевірки власника, підпису або балансу lamports — використовуйте UncheckedAccount з ручними перевірками. Це повністю усуває виклик десеріалізації.

#[derive(Accounts)]
pub struct TransferContext<'info> {
  #[account(mut)]
  pub from: Signer<'info>,
  /// CHECK: акаунт використовується лише як отримувач, десеріалізація не потрібна
  pub to: UncheckedAccount<'info>,
  pub system_program: Program<'info, System>,
}

Ризик: UncheckedAccount вимагає ручної перевірки безпеки. Якщо ви помилково пропустите перевірку власника або даних, це може стати вразливістю. Обов'язково залишайте коментар /// CHECK: з обґрунтуванням, і цей код має проходити експертний перегляд.

Прямий доступ до байтів через try_borrow_data

Іноді потрібне лише одне поле з великої структури. Замість повної десеріалізації можна звернутися до байтів безпосередньо:

let data = ctx.accounts.large_state.try_borrow_data()?;
let paused = data[8 + 32 + 8]; // пропуск дискримінатора (8) + Pubkey (32) + u64 (8)

Це працює, але є крихким: будь-яка зміна порядку полів у структурі зламає індекси. Підхід виправданий лише у вузьких hotspot-випадках, де бенчмарк підтвердив, що саме десеріалізація є вузьким місцем, а рефакторинг структури неможливий.

Розділення акаунтів на «гарячі» та «холодні»

Якщо акаунт містить як дані, що часто оновлюються, так і дані, що читаються рідко, розгляньте розділення на два акаунти. Наприклад, конфігурацію пулу (fee_rate, paused) винести в окремий ZeroCopy-акаунт, а динамічні дані (balances, timestamps) залишити в основному. Це дозволяє читати конфігурацію без десеріалізації всього стану.

Бенчмаркінг серіалізації

Оптимізація без вимірювань — це гадання. Щоб довести, що зміна серіалізації дає ефект, потрібен бенчмарк.

Вимірювання compute units

Найнадійніший спосіб — порівняти споживання compute units до і після зміни на однакових вхідних даних. Використовуйте solana-test-validator з фіксованим лімітом compute units і логуванням:

solana-test-validator --compute-unit-limit 200000 --log compute-unit-usage

Відправте ідентичну транзакцію до обох версій програми (до і після оптимізації) і порівняйте значення CU у логах. Різниця в 1000–5000 CU на інструкцію — типовий результат заміни Vec на фіксований масив у структурі з 5–10 полями.

Мікробенчмарк у тестах

Для більш гранулярного вимірювання можна використати модульні тести з ручним таймінгом серіалізації/десеріалізації:

#[test]
fn bench_deserialize() {
  let data = create_test_account_data();
  let start = std::time::Instant::now();
  for _ in 0..10_000 {
    let _ = MyAccount::try_deserialize(&mut data.as_slice());
  }
  let elapsed = start.elapsed();
  eprintln!("10k deserializations: {:?}", elapsed);
}

Обмеження: такий бенчмарк вимірює час на локальній машині, а не compute units у BPF-середовищі Solana. Він корисний для відносного порівняння (версія A проти версії B), але не для абсолютних цифр споживання CU.

Що саме вимірювати

  • Десеріалізація всієї структури — порівняйте Vec-версію з фіксованою масивною версією.
  • Десеріалізація ZeroCopy проти стандартної — виміряйте час доступу до першого поля після load_init / load.
  • Серіалізація при закритті (exit) — іноді серіалізація при exit коштує більше, ніж десеріалізація при вході, особливо для структур з великими Vec.
  • Повний цикл інструкції — десеріалізація + логіка + серіалізація, щоб побачити реальний вплив на транзакцію.

План відкату

Перед впровадженням оптимізацій серіалізації в production:

  1. Збережіть поточну версію програми на devnet з фіксованим program-id.
  2. Розгорніть оптимізовану версію під іншим program-id на devnet.
  3. Прогоніть ідентичний набір транзакцій обома версіями і зафіксуйте різницю CU.
  4. Якщо різниця менш ніж 2000 CU на типову інструкцію — оцініть, чи виправдана складність рефакторингу.
  5. Якщо різниця суттєва — деплойте нову версію з тим самим program-id (через upgrade authority) і негайно прогоніть smoke-тести на mainnet-beta з мінімальними сумами.
  6. У разі несподіваної поведінки — виконайте відкат до попередньої версії через upgrade authority (попередньо переконайтеся, що buffer зі старою версією збережено).

Оптимізація серіалізації — це не про мікрооптимізації заради themselves. Це про те, чи поміститься ваша інструкція в ліміт compute units із запасом на майбутню логіку. Якщо ви регулярно стикаєтеся з межею CU, серіалізація — один із перших напрямків, де можна знайти 5–15% економії без зміни бізнес-логіки.

Джерела