НОВОЕ: Helius приобретает Light Protocol
Что такое виртуальная машина Solana (SVM)?
Блог/Основы

Что такое виртуальная машина Solana (SVM)?

Developer Experience Engineer0xIchigo в X0xIchigo в LinkedIn0xIchigo на GitHub
Чтение: 67 мин

Большое спасибо Lostin, Alessandro, Brian, Brady и Daniel Cumming за рецензирование предыдущих версий этой работы. 

Практические выводы

  • SVM охватывает весь стек выполнения транзакций, в отличие от EVM, под которой однозначно понимается исполнитель байт-кода.
  • Требование заранее указывать в транзакции аккаунты, к которым она будет обращаться, позволяет параллельно выполнять транзакции на разных ядрах CPU и создавать локальные рынки комиссий.
  • Исходный код Rust компилируется с помощью rustc в LLVM IR, после чего бэкенд eBPF в LLVM, а именно форк Solana под названием sBPF, преобразует его в байт-код sBPF. Поэтому для написания программ Solana можно использовать любой язык с фронтендом LLVM, например C, C++ или Zig.
  • sBPF — это форк Linux eBPF от Solana. По сути, они практически идентичны, если не считать некоторых исторически появившихся дополнений. Однако эти дополнения планируется отменить. Единственное существенное различие заключается в том, что функции основной версии eBPF принимают не более 5 аргументов, а функции форка Solana могут принимать больше.
  • Скомпилированный байт-код sBPF хранится в файлах ELF с секциями инструкций и констант, а также таблицами релокаций. Компоновщик преобразует ссылки на системные вызовы в детерминированные 32-битные хеши Murmur3, а внутренние функции — в относительные переходы для обеспечения переносимости. 
  • BPF Loader Upgradeable использует модель из двух аккаунтов для обновлений без смены адреса и развертываний, а будущий Loader V4 упрощает ее до одного аккаунта с необязательным сжатием. Перед присвоением статуса исполняемого весь байт-код проходит статическую проверку.
  • Транзакции содержат массив адресов аккаунтов с правами на чтение и запись, инструкции и подписи. Такой структурированный формат позволяет обнаруживать конфликты и планировать параллельное выполнение неконфликтующих транзакций.
  • Banking Stage в TPU отвечает за планирование параллельного выполнения неконфликтующих транзакций. Bank загружает состояние аккаунтов из AccountsDB. BPF Loaders подготавливают изолированные виртуальные машины sBPF с ограниченными областями памяти и бюджетами вычислений. Успешные изменения состояния фиксируются атомарно, а при ошибках полностью отменяются.
  • SVM ISA — единственная формальная спецификация. Помимо технического документа Alpenglow и серии статей, изначально написанных Toly, единой «спецификации SVM» не существует. Среда выполнения формируется за счет взаимодействия Bank, планировщика, BPF Loaders и самой виртуальной машины sBPF.

Введение

Виртуальная машина Solana (SVM) — одна из самых неправильно понимаемых систем в современных блокчейнах. В отличие от виртуальной машины Ethereum (EVM), под которой однозначно понимается исполнитель опкодов, термин SVM охватывает весь конвейер выполнения транзакций: от планировщика Banking Stage до самого интерпретатора байт-кода sBPF. Эта неоднозначность отражает архитектурное отличие Solana: традиционной спецификации, которая определяла бы «SVM» изолированно, не существует. Ближе всего к ней спецификация архитектуры набора инструкций виртуальной машины Solana (SVM ISA). Она описывает порядок выполнения байт-кода sBPF, но ничего не говорит о более широкой среде выполнения.

Эта статья призвана стать подробным справочным материалом о том, что представляет собой SVM, как она работает и чем принципиально отличается на примере реализации валидатора Agave от Anza. Анализ работы клиента Firedancer и его собственной реализации виртуальной машины, соответствующей SVM ISA, выходит за рамки этой статьи.

Нас интересует реальная кодовая база, а не абстрактная спецификация. Мы проследим весь конвейер выполнения: как исходный код Rust компилируется через LLVM в байт-код sBPF, как программы развертываются и проверяются, как среда выполнения подготавливает изолированные окружения для параллельного выполнения и как транзакции взаимодействуют с развернутым байт-кодом.

Первые несколько разделов объясняют неоднозначность понятия SVM и дают общее представление о ее работе. Остальная часть статьи предназначена для более технической аудитории, которой необходимо глубокое понимание уровня выполнения Solana.

Спорное определение

Термин «виртуальная машина Solana» (SVM) вызвал жаркие споры в сообществе, особенно после появления сетевых расширений и других блокчейнов второго уровня, построенных поверх Solana. Причина разногласий — область применения термина: относится ли SVM исключительно к низкоуровневому интерпретатору sBPF или охватывает весь стек выполнения транзакций? 

В узком понимании SVM подобна традиционной виртуальной машине (VM), например исполнителю опкодов EVM. Точнее, это производная от eBPF виртуальная машина (сначала rBPF, а теперь sBPF), которая интерпретирует и JIT-компилирует байт-код. При таком подходе SVM рассматривается как изолированный исполнитель на основе регистров, который обрабатывает такие инструкции, как операции ALU или системные вызовы Solana. По сути, SVM основана на модели безопасности Linux eBPF, но адаптирована для блокчейн-инфраструктуры. Этому соответствуют такие термины в коде валидатора, как SVM ISA (Instruction Set Architecture), где SVM означает только уровень виртуальной машины.

В широком понимании SVM — это весь уровень выполнения транзакций валидатора Solana. Он включает не только исполнение байт-кода, но и предшествующие компоненты: планировщик Banking Stage, управление бюджетом вычислительных единиц и обновление состояния через базу данных аккаунтов, которую обычно называют AccountsDB. Это «среда выполнения», которая преобразует необработанные транзакции в проверенные изменения состояния.

Неоднозначность возникла из-за того, что в официальных материалах Solana термины «среда выполнения» и «SVM» используются как взаимозаменяемые и не имеют единого закрепленного определения. Anza внесла столь необходимую ясность в этот спор, прямо признав проблему и предложив прагматичный, ориентированный на действия инженерный подход. Определение SVM как управляемой Bank среды выполнения, которая подготавливает виртуальную машину eBPF, дает гораздо более широкое представление, охватывающее весь конвейер. На его основе можно сформулировать корректное определение SVM.

Это формализовано в официальной спецификации SVM от Anza, где SVM определяется как «компоненты, отвечающие за выполнение транзакций», оформленные в виде автономной библиотеки для валидаторов, доказательств мошенничества, сайдкаров и других систем.

Для целей этой статьи виртуальную машину Solana можно определить так:

Отделенный интерфейс среды выполнения и конвейер обработки транзакций в валидаторах Solana, управляемый компонентом Bank. Он координирует параллельное выполнение инструкций и ончейн-программ, подготавливая специализированную виртуальную машину на основе eBPF для безопасной интерпретации байт-кода, JIT-компиляции и учета ресурсов.

Краткий обзор SVM

Виртуальная машина Solana (SVM) служит средой выполнения для обработки транзакций, которые взаимодействуют с ончейн-программами в сети. Это уровень, где код встречается с состоянием, — среда выполнения, преобразующая криптографически подписанные транзакции в проверенные изменения состояния. 

Чтобы по-настоящему понять SVM, сначала нужно разобраться, что означает виртуальная машина в контексте блокчейнов.

Виртуальные машины

Виртуальная машина (VM) — это программное обеспечение, которое виртуализирует или эмулирует компьютерную систему и предоставляет изолированную среду выполнения, работающую подобно физическому оборудованию. Концепция возникла в 1960-х годах в ходе работы IBM над мейнфреймами, которые позволяли нескольким пользователям запускать разные операционные системы на одной физической машине. Существуют две основные категории VM: системные и процессные. Первые заменяют реальную машину, а вторые предназначены для выполнения программ в платформонезависимой среде. В рамках этой статьи нас интересуют системные виртуальные машины. Далее мы будем называть их «виртуальными машинами» или просто «VM».

Виртуальные машины решают несколько фундаментальных задач. Прежде всего, они обеспечивают уровень абстракции над оборудованием. Иными словами, программы, написанные для VM, могут работать на любом физическом оборудовании, которое поддерживает эту VM, без необходимости переписывать программу. Примером служит философия Java «написано один раз — работает везде»: байт-код Java одинаково выполняется в Windows, macOS, Linux и других системах с установленной виртуальной машиной Java (JVM).

VM также обеспечивают изоляцию и гарантии безопасности. Каждый экземпляр VM работает в песочнице, то есть не может обращаться к ресурсам хост-системы или другим VM без явного разрешения. Поэтому, если программа завершится с ошибкой или содержит вредоносный код, ущерб будет ограничен этим экземпляром VM. Именно благодаря такому принципу изоляции облачные провайдеры, включая Google Cloud и AWS, используют VM для разделения рабочих нагрузок клиентов.

VM также обеспечивают предсказуемые результаты. Они создают контролируемую среду, в которой одинаковые входные данные всегда дают одинаковые выходные данные независимо от используемого оборудования. Эта предсказуемость критически важна для отладки, тестирования и достижения консенсуса в распределенных системах.

VM также могут демонстрировать очень высокую производительность. Современные VM используют JIT-компиляцию, чтобы минимизировать накладные расходы. JIT-компиляция преобразует байт-код VM в нативный машинный код во время выполнения. Это обеспечивает производительность, близкую к нативной, при сохранении переносимости и упомянутых выше гарантий безопасности.

Виртуальные машины в блокчейнах

Блокчейны адаптировали концепцию VM для решения уникальной задачи: как тысячам независимых компьютеров по всему миру выполнять недоверенный код и получать одинаковые результаты? VM служат детерминированной средой выполнения, которая исполняет смарт-контракты, то есть программы в Solana, и управляет состоянием сети — текущим состоянием всех аккаунтов, балансов и других данных в сети.

Когда транзакция отправляется в блокчейн, VM отвечает за следующие действия:

  • Загрузка необходимых данных аккаунтов из хранилища.
  • Выполнение байт-кода программы, указанной в транзакции.
  • Учет потребления ресурсов для предотвращения бесконечных циклов и атак типа «отказ в обслуживании» (DoS).
  • Проверка соответствия всех изменений состояния заранее определенным правилам консенсуса сети.
  • Запись обновленного состояния обратно в постоянное хранилище, то есть в реестр.

Конкретные правила изменения состояния определяются архитектурой набора инструкций VM и ограничениями среды выполнения.

Как работает SVM

SVM — это конвейер подсистем, которые совместно обеспечивают безопасное и эффективное выполнение транзакций. Bank координирует выполнение для конкретного слота: управляет состоянием аккаунтов, обеспечивает соблюдение правил консенсуса и координирует взаимодействие между Banking Stage и постоянным хранилищем, то есть AccountsDB. Каждый Bank представляет состояние всех аккаунтов в определенном слоте и проходит три этапа жизненного цикла: активный, то есть открытый для новых транзакций; замороженный, то есть закрытый для новых транзакций после завершения слота; и корневой, то есть входящий в каноническую цепочку.

Выполнение транзакций происходит в Banking Stage внутри модуля обработки транзакций валидатора (TPU). Banking Stage получает проверенные транзакции от этапа SigVerify, буферизует их и планирует параллельное выполнение, обнаруживая конфликты по блокировкам аккаунтов. Рабочие потоки Banking Stage обрабатывают пакеты неконфликтующих транзакций и вызывают методы выполнения Bank, чтобы загрузить аккаунты, подготовить экземпляры VM sBPF для каждой инструкции, выполнить байт-код программы и собрать результаты. Banking Stage продолжает обрабатывать пакеты неконфликтующих транзакций, пока Bank не будет заморожен на границе слота. Обратите внимание, что пакеты отличаются от записей: записи — это зафиксированные группы транзакций, записываемые в реестр для репликации и консенсуса.

BPF Loaders управляют жизненным циклом программы: развертыванием, JIT-компиляцией, обновлениями и выполнением. Когда инструкция обращается к определенной программе, для нее подготавливается VM sBPF с собственными областями памяти и бюджетом вычислений, после чего управление передается байт-коду программы.

VM sBPF — это изолированная среда выполнения, в которой непосредственно работает байт-код программы. Она создана на основе Linux eBPF и использует регистровую архитектуру с 11 регистрами общего назначения. VM обеспечивает изоляцию памяти с помощью пяти отдельных областей, каждая из которых имеет явно заданные границы и разрешения. VM также учитывает расход вычислительных единиц, чтобы предотвратить бесконтрольное выполнение, и направляет системные вызовы для привилегированных операций, таких как криптография, журналирование и межпрограммные вызовы (CPI).

AccountsDB — это уровень постоянного состояния, в котором хранятся все данные аккаунтов. Перед выполнением состояние аккаунтов загружается с использованием кешей, чтобы избежать повторного чтения с диска часто используемых аккаунтов. После успешного выполнения обновления записываются обратно в AccountsDB. При ошибке выполнения все изменения состояния атомарно отменяются.

Вместе эти компоненты образуют SVM — отделенный и повторно используемый механизм выполнения.

Главная особенность SVM: предварительное объявление аккаунтов

Ключевое архитектурное решение SVM состоит в том, что до начала выполнения все транзакции должны явно объявить, из каких аккаунтов они будут читать и в какие записывать данные. Это простое требование, встроенное непосредственно в формат транзакции, открывает две принципиально новые возможности, отличающие Solana: параллельное выполнение и локальные рынки комиссий.

Параллельное выполнение (Sealevel)

В отличие от виртуальной машины Ethereum (EVM), которая обрабатывает транзакции последовательно — по одной, ожидая завершения каждой перед переходом к следующей, — SVM обеспечивает горизонтальное масштабирование, одновременно выполняя несколько транзакций на нескольких ядрах CPU. Такая параллельная обработка возможна, поскольку все транзакции Solana явно указывают, из каких аккаунтов они будут читать и в какие записывать данные, до начала выполнения. 

Объявление аккаунтов, из которых транзакция будет читать и в которые будет записывать данные, позволяет среде выполнения анализировать зависимости между аккаунтами, обнаруживать конфликты и планировать неконфликтующие транзакции:

  • Транзакции, затрагивающие совершенно разные аккаунты, могут выполняться параллельно без накладных расходов на координацию.
  • Транзакции, которые только читают одни и те же аккаунты, также могут выполняться параллельно, поскольку операции чтения не конфликтуют.
  • Транзакции, которые пытаются записать данные в одни и те же аккаунты, выполняются последовательно, чтобы предотвратить состояния гонки и обеспечить согласованность состояния. 

Локальные рынки комиссий

Поскольку среда выполнения еще до начала обработки точно знает, к каким аккаунтам обратится каждая транзакция, комиссии можно привязать к конкретным аккаунтам, а не определять через глобальную конкуренцию во всей сети. Эта концепция называется локальными рынками комиссий. 

В Ethereum и других EVM-цепочках каждая транзакция конкурирует на едином глобальном рынке комиссий. Отправка ETH другу, выпуск NFT и торговля на Uniswap соперничают за одно и то же место в блоке. Всплеск активности в одной области повышает комиссии для всех, даже если они пытаются выполнить совершенно другие операции. 

В Solana друг с другом конкурируют только транзакции, обращающиеся к одним и тем же аккаунтам. Пользователь, переводящий SOL между двумя аккаунтами, не должен беспокоиться о том, что одновременно проходит популярный выпуск NFT. Приоритетная комиссия транзакции определяется исключительно конкуренцией за аккаунты. Благодаря такой локализации транзакции Solana могут оставаться недорогими даже в периоды высокой активности.

Например, 10 октября на криптовалютном рынке произошло крупнейшее в его истории событие ликвидации. Несмотря на рекордный всплеск активности, транзакции Solana оставались относительно недорогими: медианная комиссия достигла $0.007, средняя комиссия ненадолго поднялась до $0.10, а комиссия для верхнего 1% транзакций достигла пика чуть выше $1.00. За тот же период медианные комиссии в Ethereum и Arbitrum превысили $100, а комиссии в Base достигли пика выше $3.

Смена парадигмы

АспектEVMSVM
АрхитектураСтековая VMРегистровая VM на основе eBPF
ВыполнениеПоследовательноеПараллельное с обнаружением конфликтов
Рынок комиссийГлобальныйЛокальный, на основе конкуренции за аккаунты
Объявление аккаунтовЗаранее не требуетсяТребуется заранее
ISA~140 опкодов, стековые операции~100 опкодов, регистры в стиле RISC
JIT-компиляцияНеобязательная, зависит от клиентаСтандартная, с нативной производительностью
Модель состоянияКомиссии за хранилище контрактовЕдиная база данных аккаунтов
ЯзыкиSolidity/Vyper → байт-код EVMRust/C/C++ → LLVM → sBPF

SVM представляет принципиально иной подход к выполнению операций в блокчейне. Bitcoin представил программируемые деньги. Ethereum — универсальные смарт-контракты и произвольное ончейн-выполнение. Однако обе системы ограничены последовательным выполнением и глобальными рынками комиссий — архитектурными решениями, которые фундаментально ограничивают пропускную способность и стоимость.

SVM отходит от традиционных ограничений и предлагает сеть, способную поддерживать высокую пропускную способность без ущерба для программируемости и без непомерно дорогих аукционов комиссий для пользователей. Требование заранее объявлять аккаунты — простое, но эффективное решение. Оно обеспечивает параллельное выполнение на ядрах CPU и локализует комиссии на уровне рынков отдельных аккаунтов.

Разумеется, это не единственные оптимизации Solana по сравнению с другими блокчейнами. Принцип Solana «Повышать пропускную способность, сокращать задержку» и предельная сосредоточенность на воплощении идеи интернет-рынков капитала привели к появлению множества оптимизаций производительности, архитектурных решений и реализаций, направленных на создание сети с высокой пропускной способностью.

В оставшейся части статьи мы подробно рассмотрим, как все это работает: как исходный код Rust компилируется в байт-код, как этот байт-код развертывается и проверяется и как среда выполнения подготавливает изолированные окружения, чтобы безопасно запускать тысячи программ параллельно, сохраняя строгий детерминизм и гарантии безопасности. 

От исходного кода Rust до байт-кода sBPF: конвейер компиляции

Rust

Rust — лингва франка разработки программ Solana. Такие фреймворки, как Anchor, предоставляют разработчикам надежный и структурированный подход к эффективному созданию безопасных программ. solana_program задумана как базовая библиотека для всех ончейн-программ. В последнее время Pinocchio, высокооптимизированная библиотека без зависимостей, стала предпочтительным вариантом для разработчиков, создающих нативные программы Solana. 

Независимо от выбранного фреймворка или библиотеки, у каждой программы есть точка входа, которую среда выполнения вызывает при запуске программы. Макрос entrypoint из solana_program генерирует стандартный шаблонный код, необходимый для начала выполнения программы: десериализацию входных данных, настройку глобального аллокатора и обработчика паники. Pinocchio экспортирует макросы точки входа, работающие похожим образом, но отделяет точку входа от настройки аллокатора кучи и обработчика паники, предоставляя разработчикам больше возможностей выбора. 

Базовая структура программы 

Программа — это разновидность аккаунта, способная выполнять код. Точнее, программа представляет собой исполняемый аккаунт, который хранит блок байт-кода sBPF в аккаунте, принадлежащем BPF Loader и имеющем уникальный публичный ключ. По своей архитектуре программы не имеют состояния: все постоянные данные находятся в отдельных аккаунтах, из которых программы могут читать или в которые могут записывать данные при вызове.

SVM ожидает, что все программы будут иметь определенную структуру — точку входа, принимающую три входных параметра:

  • Program ID: адрес самой программы, используемый для проверок с обращением к самой себе, например для проверки владельца.
  • Accounts: массив метаданных аккаунтов, то есть публичных ключей, балансов в лампортах, буферов данных, владельцев и флагов. Это «состояние», которое программа должна читать и изменять.
  • Instruction Data: срез байтов с произвольными данными из транзакции.

Ожидается, что программа обработает эти входные данные через точку входа, изменит соответствующие доступные для записи аккаунты, создаст записи журнала или события, а затем вернет статус успеха, указывающий, удалось ли выполнить все эти действия. По сути, все сводится к функции process_instruction.

Простая программа, написанная на Rust с использованием крейта solana_program, выглядит так:

simple_rust_program.rs
use solana_program::{
    account_info::AccountInfo,
    entrypoint,
    entrypoint::ProgramResult,
    msg,
    pubkey::Pubkey,
};

entrypoint!(process_instruction);

pub fn process_instruction(
    _program_id: &Pubkey,
    _accounts: &[AccountInfo],
    _instruction_data: &[u8],
) -> ProgramResult {
    msg!("Hello, Solana!");

    Ok(())
}

На низком уровне все это определяется двоичным интерфейсом приложений (ABI) SVM, который мы рассмотрим далее.

Компилятор Rust и LLVM IR

Rust, как и многие другие языки программирования, представляет собой абстракцию второго порядка поверх ассемблера. Он позволяет людям писать безопасный, параллельный и понятный код, не управляя вручную каждой мельчайшей операцией с оборудованием. Однако компьютеры не понимают Rust, как и любой другой высокоуровневый язык. 

Компьютеры понимают машинный код — двоичные инструкции, предназначенные для конкретной архитектуры или виртуальной машины. В конечном счете все программы преобразуются в двоичный код, причем это преобразование выполняют сами компьютеры. Компиляция — это многоэтапный процесс перевода, который устраняет высокоуровневые абстракции, оптимизирует код и создает исполняемый байт-код.

rustc — официальный компилятор Rust. Большинство разработчиков обычно не взаимодействуют с rustc напрямую, а запускают его через Cargo, менеджер пакетов Rust. Тем не менее rustc проводит исходный код Rust через три основных этапа, прежде чем создать исполняемый байт-код. На каждом этапе устраняется часть абстракций, обеспечивается безопасность и подготавливается код для следующего преобразования:

  • Синтаксический анализ и развертывание
  • MIR (промежуточное представление среднего уровня)
  • LLVM IR (промежуточное представление Low Level Virtual Machine)

Синтаксический анализ и развертывание

Компилятор читает заданный файл Rust с расширением .rs как обычный текст. В процессе лексического анализа он ищет определенные токены, например use, fn, None, impl и &[u8]. Лексическая токенизация — это преобразование текста в значимые лексические токены, относящиеся к определенной категории: идентификаторы, операторы, разделители, литералы или ключевые слова. 

rustc берет эти лексические токены и преобразует их в структуру данных, называемую абстрактным синтаксическим деревом (AST). Эта древовидная структура отражает вложенную иерархию исходного кода Rust: функции содержат блоки, блоки — выражения, выражения — операторы и так далее. Хотя AST по-прежнему остается высокоуровневым представлением, оно точно отражает исходный код и лежащую в его основе логику.

После построения AST компилятор выполняет несколько ключевых преобразований:

  • Развертывание макросов: макросы вроде entrypoint! и println! развертываются в обычный код Rust, чтобы все макросы превратились в стандартные узлы AST.
  • Понижение уровня: высокоуровневые сокращенные конструкции, призванные сделать код понятнее, переписываются в более примитивные формы. Этот процесс называется понижением уровня. Например, цикл for преобразуется в loop с ручной итерацией. Результатом понижения становится высокоуровневое промежуточное представление (HIR).
  • Проверка заимствований и анализ безопасности: Rust обрабатывает HIR и выполняет проверку типов, разрешение трейтов и вывод типов. Результатом становится типизированное высокоуровневое промежуточное представление (THIR).

На этом этапе компилятор обрабатывает небезопасный код. Он позволяет разработчикам выполнять операции в обход гарантий безопасности Rust, например разыменовывать необработанные указатели, вызывать внешние функции и реализовывать небезопасные трейты. Это намеренно предусмотренный обходной путь для низкоуровневого управления: он смягчает отдельные правила, но код по-прежнему должен компилироваться в соответствии с семантикой Rust. Это важно для программ Solana, где небезопасный код можно ограниченно использовать для ускорения критически важных операций, например десериализации аккаунтов без копирования, как в структуре-обертке Pinocchio для Account.    

Небезопасный код впервые выявляется после лексического анализа при построении AST: небезопасные токены распознаются и помечаются как специальные узлы. Затем он обрабатывается после развертывания AST во время проверки типов и заимствований. Компилятор проверяет, что небезопасные операции ограничены небезопасными контекстами, и в противном случае сообщает об ошибках, например «невозможно разыменовать необработанный указатель вне unsafe». При этом компилятор, например, не проверяет, повреждает ли небезопасный код память или неправильно управляет ею.

К концу этого этапа развернутое AST, теперь представленное как THIR, становится проверенным и пониженным представлением исходного кода Rust.

MIR

Затем THIR понижается до промежуточного представления среднего уровня (MIR) — ориентированной на Rust формы, которая представляет исходный код как упрощенный граф потока управления (CFG). Все специфические для Rust синтаксические конструкции и сложные элементы, например сопоставление с образцом, трейты и замыкания, выражаются через базовые блоки с присваиваниями и ветвлениями. Эти базовые блоки соединяются переходами, точнее Goto, и ветвлениями, что упрощает анализ потока выполнения программы. 

MIR не является строго обязательным. Компилятор мог бы напрямую понизить THIR до LLVM IR. Однако MIR предоставляет учитывающий особенности Rust уровень, на котором компилятор может обеспечивать соблюдение правил Rust и выполнять оптимизации до применения универсальных оптимизаций LLVM. Благодаря этому MIR идеально подходит для проверок и преобразований, слишком высокоуровневых для LLVM, но слишком низкоуровневых для THIR, в том числе:

  • Проверка заимствований: первоначальные семантические проверки выполняются при анализе типов в THIR. Однако упрощенный CFG в MIR позволяет провести полную и точную проверку заимствований и обеспечить соблюдение всех правил владения, заимствования и времени жизни.
  • Проверка перемещений и освобождения: компилятор проверяет, что все значения перемещаются и освобождаются в соответствии с гарантиями безопасности памяти Rust, предотвращая ошибки использования после освобождения.
  • Анализ инициализации: компилятор проверяет, что все переменные инициализированы до использования.
  • Встраивание и ранние оптимизации: компилятор может встраивать небольшие функции, упрощать арифметические выражения и удалять недостижимый код.

Например, ранее в примере программы Rust мы вызвали msg!(“Hello, Solana!”). Это макрос, определенный в крейте solana-program. Для одиночного выражения, такого как статическая строка, он на этапе развертывания макросов преобразуется в прямой вызов sol_log($msg), где $msg — это выражение. Системный вызов sol_log принимает указатель на строковые данные и их длину, записывая их в вывод SVM без накладных расходов на форматирование. В MIR это может быть упрощено следующим образом:

Код
bb0: {
  _0 = const "Hello, Solana!"; // Constant string allocation
  _1 = len(_0); // Compute length
  sol_log(move _0, move _1); // Syscall invocation with explicit moves for ownership
  return = Ok(());
}

Здесь:

  • _0 = const “Hello, Solana!”; — MIR вводит временные переменные, то есть _0, для промежуточных значений. Строка рассматривается как константный срез, размещенный в данных только для чтения.
  • _1 = len(_0); — MIR представляет простую операцию получения длины среза, которую потенциально можно вычислить на этапе компиляции.
  • sol_log(move _0, move _1); — вызов системной функции, который преобразуется в последовательность инструкций sBPF, загружающую регистры и вызывающую идентификатор системного вызова. В следующих разделах мы подробно разберем, что именно это означает. Сейчас важно отметить, что эти перемещения связаны с семантикой владения Rust и обеспечивают ее соблюдение при компиляции.
  • Return = Ok(()); — завершает блок терминатором, сообщая SVM об успехе.

Ориентация MIR на семантику Rust делает его идеальным уровнем для поиска неэффективных решений и отладки конструкций с высоким расходом CU. Например, если журнал содержит динамические строки, MIR может выявить дополнительные выделения памяти или циклы, которые можно оптимизировать. Разработчики могут выгрузить MIR командой cargo rustc -- -Z dump-mir=all.

MIR обеспечивает семантическую корректность и оптимизацию кода, а также удаляет все специфичные для Rust правила перед окончательным понижением до LLVM IR.

Обратите внимание, что этот этап обычно называют генерацией кода. Это не обязательно LLVM. Однако LLVM широко распространен, и именно его большинство людей связывают с генерацией кода Rust. Компилятор Rust также поставляется с бэкендами GCC и Cranelift, которые создают GIMPLE и CLIF соответственно. В контексте Solana мы рассматриваем LLVM IR, но в Rust в целом он используется не всегда.

LLVM IR

LLVM, изначально расшифровывавшееся как "Low Level Virtual Machine", — это модульная инфраструктура компилятора. Вместо единого компилятора LLVM предлагает набор повторно используемых компонентов для создания компиляторов, оптимизаторов и генераторов кода. Многие языки, включая Rust, C, C++, Julia, Swift, Brainfuck и Zig, используют LLVM благодаря его способности работать с разными целевыми архитектурами: от CPU x86 до виртуальных ISA. 

rustc преобразует MIR в LLVM IR (Low Level Virtual Machine Intermediate Representation) — связующее звено между семантикой Rust и конечным байт-кодом, развертываемым в Solana. LLVM IR намного ближе к машинному коду и содержит явные операции выделения памяти, то есть alloca, а также сохранения, загрузки и вызовы функций. В нем отсутствуют понятия владения, времени жизни и трейтов, поскольку эти абстракции Rust уже развернуты и больше не существуют. При этом гарантии предыдущих этапов сохраняются.

На этом этапе применяются различные оптимизации, в том числе:

  • Свертка констант, то есть вычисление констант во время компиляции.
  • Встраивание, то есть замена вызова телом функции.
  • Удаление мертвого кода, то есть удаление инструкций, не влияющих на результат.
  • Развертывание и векторизация циклов, то есть переписывание циклов для ускорения выполнения.

Таким образом, LLVM предоставляет:

  • LLVM IR: переносимый промежуточный формат, похожий на ассемблер.
  • Проходы оптимизации: для создания LLVM IR LLVM использует статическое однократное присваивание (SSA), при котором каждой переменной значение присваивается ровно один раз. Это позволяет выполнять такие оптимизации, как встраивание и удаление мертвого кода.
  • Генераторы кода: целевые компоненты, которые понижают LLVM IR до реального машинного кода, например x86_64, ARM, WebAssembly или eBPF.

Обычно программы Rust компилируются для аппаратных платформ, таких как x86_64 или ARM. Однако программы Solana не выполняются непосредственно на оборудовании. Они работают внутри виртуальной машины Solana. Поэтому бэкенд LLVM понижает LLVM IR до байт-кода BPF, который в Solana становится байт-кодом sBPF — форком eBPF, удаляющим недетерминированные функции и добавляющим специфичные для Solana системные вызовы.

Хотя Rust — лингва франка разработки программ Solana, можно использовать любой язык, поддерживающий целевой бэкенд BPF в LLVM, например C, Nim, Swift или Zig.

eBPF

LLVM IR понижается до eBPF — регистровой ISA, лежащей в основе среды выполнения Solana. eBPF (Extended Berkeley Packet Filter) берет начало от Berkeley Packet Filter (BPF), разработанного в 1992 году Steven McCanne и Van Jacobson в Lawrence Berkeley Laboratory для Unix-систем Berkeley Software Distribution (BSD). По сути, BPF — это средство перехвата и фильтрации сетевого трафика, которое позволяет захватывать и фильтровать сетевые пакеты на уровне операционной системы без копирования данных с помощью квалификаторов. 

С тех пор eBPF эволюционировал, то есть был расширен, до универсальной изолированной VM внутри ядра Linux. Его возможности для ядра сопоставимы с тем, что JavaScript дал веб-разработке: это безопасный скриптовый механизм для ядер. eBPF позволяет разработчикам запускать небольшие проверенные программы непосредственно в ядре Linux с ограниченным набором инструкций для таких задач, как мониторинг производительности, наблюдаемость, безопасность и работа с сетью.

Это важно, поскольку разработчики получают:

  • Изолированное выполнение: программы eBPF работают в ограниченной виртуальной машине внутри ядра, поэтому не могут вызвать сбой или повреждение памяти ядра.
  • Гарантии безопасности: перед загрузкой байт-код eBPF проходит статическую проверку на отсутствие недопустимого доступа к памяти, переходов за допустимые границы и других привилегированных операций. Это обеспечивает безопасность без накладных расходов во время выполнения.
  • Эффективность: eBPF использует регистровую, а не стековую, как EVM, архитектуру. Благодаря легкой конструкции без накладных расходов полноценной ОС его можно JIT-компилировать в машинный код с почти нативной скоростью.
  • Гибкость: eBPF предоставляет системные вызовы, также известные как syscalls, которые фактически служат точками доступа к функциям ядра. Системные вызовы можно расширять новыми возможностями без изменения архитектуры набора инструкций.

Solana требовалась детерминированная, безопасная и высокопроизводительная VM для запуска недоверенных программ на всех валидаторах. eBPF предлагает проверенную модель безопасности, переносимую и эффективную ISA для выполнения тысяч легковесных программ, а также поддержку JIT для повышения производительности. Поэтому вместо создания совершенно новой VM Solana сделала форк eBPF и создала sBPF.

sBPF 

Изначально Solana Labs создала форк rBPF от Quentin Monnet, чтобы разработать собственную версию rBPF и гарантировать, что каждый валидатор будет использовать формат байт-кода, который дает совершенно одинаковые результаты при выполнении заданной программы с заданными входными данными. 

Считалось, что форк eBPF необходим, поскольку консенсус Solana требовал детерминированного выполнения и ограниченного использования ресурсов. Хотя eBPF сам по себе детерминирован, Solana требовались дополнительные гарантии и специфичные для блокчейна возможности:

  • Фиксированное время и стоимость инструкций.
  • Выполнение в пользовательском пространстве.
  • Детерминированная среда выполнения.

Важно, что система предназначена для работы в пользовательском пространстве, а не в ядре. Поэтому ей не нужны привилегии ядра или его модификации. Это позволяет развертывать ее в разных ОС без root-доступа и пользовательских модулей ядра. Пользовательское пространство — практичный выбор для Solana: оно обеспечивает переносимость, тестируемость и более простое развертывание. Несмотря на выполнение в пользовательском пространстве, JIT по-прежнему обеспечивает почти нативную производительность. Кроме того, такой подход позволяет проводить тестирование и фаззинг без доступа к ядру.

rBPF больше не используется. После создания Anza команда сделала форк rBPF и разработала sBPF (Solana Berkeley Packet Filter). Репозиторий rBPF на GitHub, принадлежащий Solana Labs, был архивирован 10 января 2025 года.

SVM ISA

SVM ISA (Solana Virtual Machine Instruction Set Architecture) — основная спецификация, определяющая, как совместимые с Solana VM, например sBPF в Agave или новая реализация Firedancer, должны выполнять программы. Это не сама VM, а стандарт или контракт, обеспечивающий единообразие и соответствие протоколу в разных реализациях SVM. Именно SVM ISA накладывает на eBPF ограничения безопасности и детерминизма, удаляет функции, ориентированные на ядро, и добавляет специфичные для блокчейна возможности.

ISA определяет регистры, кодирование инструкций, опкоды, классы, правила проверки, условия паники и двоичный интерфейс приложений (ABI). Любые изменения SVM ISA должны внедряться через SIMD, чтобы обеспечить контролируемое развитие набора инструкций и сохранить детерминированное выполнение на всех валидаторах.

Регистры

Регистры — это небольшие ячейки хранения внутри VM, которые содержат числа или адреса во время выполнения инструкций. Их можно сравнить с переменными или подписанными ящиками на рабочем столе. SVM ISA определяет 64-битную регистровую архитектуру с 11 регистрами общего назначения (R0-R10) и скрытым счетчиком команд. Регистры имеют ширину 64 бита для целых чисел и адресов, что позволяет эффективно обрабатывать большие значения и указатели. R0 хранит возвращаемые функциями значения, R1-R5 передают первые пять аргументов функций как параметры, R6-R9 сохраняются вызываемой функцией и остаются неизменными между вызовами, а R10 служит доступным только для чтения указателем кадра, обозначающим текущий кадр стека. Скрытый счетчик команд отслеживает выполнение и указывает следующую инструкцию.

Инструкции

Инструкция — это одна операция, которую умеет выполнять VM, например «сложить эти два числа» или «перейти к этой строке кода». Инструкции имеют дизайн в стиле RISC и используют около 100 опкодов, тогда как в CISC-архитектурах вроде x86 их тысячи. Благодаря этому проверка выполняется быстро, а JIT-компиляция остается эффективной.

Инструкции кодируются как 64-битные значения в формате Little Endian со следующей структурой:

  • opcode: 8 бит
  • dst_reg: 4 бита
  • src_reg: 4 бита
  • offset: 16 бит со знаком
  • immediate: 32 бита со знаком

opcode указывает, что нужно сделать, dst_reg — куда поместить результат, src_reg — откуда взять входное значение, offset — какое смещение в памяти использовать, а immediate — дополнительная константа, которую можно включить в инструкцию.

lddw, или загрузка двойного слова, — единственная широкая инструкция, занимающая два 64-битных слота для поддержки полных 64-битных непосредственных значений. 

Инструкции делятся на классы, включая операции с памятью, арифметические и логические операции, условные и безусловные переходы, вызовы и возвраты функций, а также преобразование порядка байтов.

Области памяти

ISA определяет пять областей памяти, каждая из которых имеет явные границы, то есть [addr, addr+len], в пределах которых программа может читать или записывать данные:

  • Код программы: сами скомпилированные инструкции, доступные для чтения и выполнения.
  • Стек: временная рабочая область функций, доступная для чтения и записи, обычно размером 4 КБ на кадр.
  • Куча: динамическая память, которую может запрашивать программа, доступная для чтения и записи.
  • Входные данные: доступные только для чтения байты, переданные с транзакцией.
  • Данные только для чтения: константы и неизменяемые значения.

Программы используют заранее определенную карту виртуальной памяти: код программы начинается с адреса 0x000000000 или 0x100000000 в зависимости от версии компиляции, кадры стека — с 0x200000000, куча — с 0x300000000, а входные данные — с 0x400000000.

Верификатор

Верификатор выполняет статический анализ перед запуском. Не выполняя программу, он исследует все возможные пути исполнения, чтобы обеспечить гарантии безопасности на этапе загрузки, а не во время выполнения. В частности, он проверяет следующее:

  • Отсутствуют неизвестные или неподдерживаемые инструкции.
  • Все цели переходов находятся на допустимых границах инструкций, а обратные переходы обрабатываются.
  • Отсутствуют недостижимые пути выполнения.
  • Соблюдаются ограничения глубины вызова функций.
  • Деление или вычисление остатка от деления на ноль статически отклоняется.
  • Программы не превышают максимально допустимый размер.

Несмотря на пользу верификатора, он не мешает разработчику внести непредусмотренное поведение. Например, разработчики по-прежнему могут допустить в программе Solana ошибки использования после освобождения памяти или переполнения буфера. 

Условия паники

Условия паники — это определенный SVM ISA список ошибок среды выполнения. К ним относятся:

  • Недопустимые или неподдерживаемые инструкции.
  • Деление или вычисление остатка от деления на ноль.
  • Доступ к памяти за допустимыми границами.
  • Недопустимый доступ к разным областям памяти, то есть нарушение разрешений.
  • Переполнение стека.
  • Превышение глубины вызовов.
  • Превышение максимально допустимого количества инструкций.
  • Программа вернула код ошибки.

ABI

Двоичный интерфейс приложений (ABI) — это контракт формата между программой Solana и SVM. В предыдущем разделе «Базовая структура программы» было показано, как это работает в Rust, то есть функция process_instruction с тремя входными параметрами. ABI определяет, как эти входные и выходные данные представлены в памяти, чтобы каждый валидатор мог детерминированно выполнять программы.

На высоком уровне ABI определяет три аспекта: соглашение о точке входа, соглашения о вызовах и регистрах, а также структуру памяти.

Каждая программа Solana обязана предоставлять функцию точки входа. Загрузчик сериализует входные данные программы в пространство памяти VM в каноническом порядке: идентификатор программы, массив аккаунтов и данные инструкции. Затем VM передает указатели на эти области в точку входа программы.

Первые пять регистров, то есть R1-R5, зарезервированы для аргументов точки входа, а регистр возврата, то есть R0, содержит код завершения программы. Нулевой код завершения считается успехом, а ненулевое значение — ошибкой, сопоставленной с конкретным InstructionError. Это гарантирует, что все программы возвращают коды состояния единообразно. Параметры после первых пяти передаются через стек. R6-R9 следуют соглашению о сохранении вызываемой функцией: если функции используют эти значения, они обязаны их сохранить.

Аккаунты и данные сериализуются в линейную память VM в виде срезов байтов. Программы должны десериализовать их в высокоуровневые типы Rust (например, AccountInfo, Pubkey). ABI задает строгие границы, поэтому ни одна программа не может обращаться к памяти за пределами выделенных ей областей.

В совокупности эти правила делают ABI связующим звеном между высокоуровневой средой разработки и низкоуровневой ISA. Благодаря ему простая сигнатура функции Rust компилируется с корректным использованием регистров, структурой памяти и кодами возврата. Поэтому каждый валидатор всегда интерпретирует конкретную программу совершенно одинаково.

Системные вызовы

ISA намеренно минималистична и не содержит встроенных аккаунтов или состояния. Она также напрямую не предоставляет высокоуровневые функции, такие как логирование, хеширование или межпрограммные вызовы. Вместо этого доступны системные вызовы — специальные встроенные в VM функции, которые позволяют программам взаимодействовать с внешним миром. Обычно их называют syscalls.

Syscalls можно рассматривать как API, предоставляемые VM. Вместо того чтобы каждая программа заново реализовывала определенные криптографические примитивы или логику аккаунтов, syscalls предоставляют безопасные стандартизированные операции с гарантированно детерминированным поведением на всех валидаторах.

Популярные категории syscall:

  • Логирование и отладка (например, syscall sol_log записывает строку UTF-8 в логи программы и используется внутри msg!).
  • Межпрограммный вызов (CPI) (например, sol_invoke_signed позволяет программе вызвать другую ончейн-программу, передав аккаунты и данные инструкции, что критически важно для компонуемости Solana).
  • Криптография (например, syscalls sol_sha256, sol_keccak256 и sol_ed25519_verify предоставляют детерминированные и быстрые криптографические примитивы, избавляя разработчиков от необходимости создавать собственные реализации).
  • Утилиты для памяти и аккаунтов (syscalls, предоставляющие вспомогательные функции для заимствования данных аккаунтов, перераспределения памяти или работы с выделениями в куче, принадлежащими программе).
  • Вычислительный бюджет и учет ресурсов (каждый syscall расходует CU, что контролирует система учета среды выполнения).

Syscalls вызываются специальной инструкцией CALL_IMM с уникальным хеш-идентификатором. Когда программа вызывает syscall, sBPF VM перехватывает выполнение, ищет хеш в реестре syscall и передает управление нативной реализации, работающей в привилегированном коде среды выполнения. Syscalls выполняются вне песочницы и имеют доступ к состоянию среды выполнения. Это принципиально отличается от выполнения обычных вызовов функций внутри программы.

Syscalls используют тот же ABI, что и обычные функции: первые пять аргументов передаются в регистры с R1 по R5, а возвращаемое значение — в R0. Каждый syscall имеет фиксированную стоимость в вычислительных единицах, что обеспечивает детерминированное потребление ресурсов. Например, все вызовы syscall secp256k1_recover расходуют 25 000 CU.

Syscalls образуют контролируемую границу безопасности: каждый из них проверяет входные данные и соответствующие разрешения перед выполнением привилегированных операций. Например, syscalls межпрограммного вызова (CPI) проверяют, есть ли у вызывающей стороны необходимые разрешения для передаваемых аккаунтов.

Новые syscalls можно добавлять через feature gates без изменения самой ISA. Это позволяет Solana расширять возможности VM, включая поддержку новых криптографических примитивов, сохраняя обратную совместимость с существующими программами.  

Бинарный файл программы

В конце компиляции все этапы — от исходного кода Rust и LLVM IR до eBPF, sBPF и соответствия SVM ISA — формируют единый результат: бинарный файл программы. Именно этот файл развертывается в Solana. 

ELF

Программы Solana компилируются в файлы Executable and Linkable Format (ELF) — стандартный бинарный формат, используемый в Unix-подобных системах. Формат ELF служит контейнером, в котором упаковано все необходимое VM для выполнения конкретной программы с сохранением платформенной независимости.

Файл ELF обычно содержит следующие секции:

  • Секция байт-кода: содержит скомпилированные инструкции sBPF в секции .text.
  • Секция данных только для чтения: содержит константы, статические строки и неизменяемые значения в секции .rodata.
  • Секции BSS и данных: содержат глобальные или статические изменяемые переменные в секциях .bss и .data соответственно. Обратите внимание: Solana не допускает изменяемые данные. То есть ELF может содержать .rodata, но не секции BSS и данных. 
  • Таблицы символов и релокаций: определяют, как при загрузке разрешаются вызовы функций, syscalls и ссылки на память. Для символов используются секции .symtab и .strtab, а для записей релокации — .rel.dyn и .rela.dyn.

Каждый файл ELF также содержит заголовок с описанием архитектуры, ширины инструкции (то есть 64 бита), порядка байтов (то есть Little Endian) и адреса точки входа.

Линковка и релокация

Преобразование результата компиляции в единый исполняемый файл ELF включает последний компонент — линкер. Он объединяет несколько скомпилированных модулей кода в один целостный бинарный файл. Кроме того, он разрешает все символьные ссылки (то есть заполнители), которые компилятор оставил неразрешенными. Например:

  • Когда программа вызывает функцию, такую как sol_log, компилятор не знает, где она находится в памяти, поэтому использует заполнитель.
  • Линкер заменяет эту символьную ссылку уникальным хешированным идентификатором syscall (то есть детерминированным 32-битным хешем Murmur3).
  • Аналогично, вызовы между внутренними функциями переписываются как относительные переходы к смещениям инструкций внутри секции .text.

Процесс замены символьных ссылок конкретными адресами или хешированными ID syscall называется релокацией. Однако релокации во многом являются следствием устройства первоначального инструментария, а не фундаментальным требованием. В будущих версиях набора инструментов планируется полностью отказаться от релокаций, чтобы упростить развертывание.

Этот этап релокации необходим, чтобы один и тот же бинарный файл ELF выполнялся идентично на всех валидаторах, поскольку в него не встраиваются абсолютные адреса памяти или системно-зависимые символы. 

Байт-код с уже примененными релокациями также кешируется в памяти. Поэтому все последующие выполнения используют обновленный байт-код без повторной обработки релокаций.

После того как линкер создает полностью релокированный файл ELF, программа готова к развертыванию. Итоговый бинарный файл обладает следующими свойствами:

  • Переносимость: он одинаково работает на любом валидаторе или в любой реализации SVM.
  • Детерминированность: он не содержит недетерминированных syscall или зависимостей от ОС.
  • Самодостаточность: он содержит весь байт-код и метаданные, необходимые для выполнения.

Как байт-код загружается в Solana

После компиляции и линковки программы Solana в корректный файл ELF его нужно загрузить в блокчейн, чтобы валидаторы могли выполнять программу. Этот процесс, называемый развертыванием программы, включает несколько совместно работающих компонентов: BPF Loader, модели аккаунтов, проверку байт-кода и управление состоянием.

Программа BPF Loader

BPF Loader — это нативная программа, которая проверяет и релокирует файлы ELF, а затем помечает их как исполняемые. По сути, она управляет жизненным циклом развернутых программ: обрабатывает инструкции для инициализации аккаунтов, записывает байт-код, развертывает программы и выполняет обновления.

Solana прошла через несколько версий загрузчиков, каждая из которых улучшала предыдущую:

  • BPF Loader: первоначальный загрузчик для статических программ без возможности обновления, который больше не поддерживается. 
  • BPF Loader V2: упрощенный загрузчик без управляющих инструкций.
  • BPF Loader Upgradeable: текущий загрузчик, добавивший возможность обновления программ.
  • BPF Loader V4: новейшая версия с улучшенными функциями развертывания, которая упрощает текущую модель с двумя аккаунтами до модели с одним аккаунтом.

Архитектура развертывания: модели аккаунтов

Текущая модель аккаунтов

Текущий загрузчик использует архитектуру с двумя аккаунтами, чтобы отделить логику программы от ее данных. Поэтому у каждой программы есть два аккаунта: аккаунт Program и аккаунт ProgramData.

Аккаунт Program — это небольшой аккаунт размером около 36 байт, который содержит метаданные и помечен как исполняемый. Он хранит ссылку на ProgramData через UpgradeableLoaderState::Program { programdata_address }.

Аккаунт ProgramData имеет больший размер и хранит фактический байт-код ELF вместе с метаданными развертывания (например, слотом и адресом полномочий на обновление) через UpgradeableLoaderState::ProgramData.

Разделение на два аккаунта позволяет выполнять обновления на месте. Аккаунт Program сохраняет прежний адрес, а байт-код аккаунта ProgramData можно заменить.

Будущая модель аккаунтов

Loader V4 призван упростить развертывание с помощью модели с одним аккаунтом. Аккаунт программы будет напрямую хранить метаданные и байт-код, поэтому отдельный аккаунт ProgramData больше не потребуется. Разработчики также смогут хранить образ, сжатый с помощью zstd, чтобы снизить расходы на ренту.

Как развертываются программы Solana

При развертывании скомпилированный бинарный файл ELF загружается, после чего BPF Loader проверяет и кеширует его, а затем помечает как исполняемый. Процесс немного различается для обновляемого загрузчика и V4 из-за архитектуры развертывания, описанной в предыдущем разделе.

BPF Loader Upgradeable

Текущий процесс развертывания через обновляемый загрузчик начинается с инициализации буферного аккаунта для промежуточного хранения байт-кода ELF. Развертывающая сторона отправляет инструкцию InitializeBuffer в BPF Loader Upgradeable. Он создает новый принадлежащий загрузчику аккаунт и устанавливает его состояние в UpgradeableLoaderState::Buffer { authority_address }, записывая адрес, которому разрешена запись в буфер. 

Скомпилированный бинарный файл ELF загружается в буфер частями с помощью инструкции Write { offset, bytes }. Каждая инструкция записи проверяет, что подписант соответствует полномочиям буфера, что буфер все еще изменяемый (то есть еще не развернут), и записывает байты по указанному смещению после заголовка метаданных. Из-за ограничений размера транзакции для загрузки всего файла ELF крупным программам требуется несколько инструкций Write.

Когда буфер содержит полный ELF, развертывающая сторона отправляет инструкцию DeployWithMaxDataLen { max_data_len }. Это самый сложный этап всего процесса: он координирует фактическое развертывание от проверки аккаунтов до окончательной фиксации состояния.

Сначала загрузчик проверяет все аккаунты, участвующие в развертывании, и убеждается в следующем:

  • Аккаунт программы не инициализирован и освобожден от ренты.
  • Буфер содержит корректные данные, а сторона, инициализировавшая буфер, подписала транзакцию.
  • Значение max_data_len достаточно велико для размещения данных буфера.
  • Общий размер не превышает MAX_PERMITTED_DATA_LENGTH (то есть 10 МиБ, или 10 485 760 байт). 

Затем загрузчик создает аккаунт ProgramData, выводя его адрес как PDA с помощью ID программы и ID загрузчика. После этого лампорты из буфера возвращаются плательщику, поскольку после развертывания буферный аккаунт больше не нужен. 

Кроме того, загрузчик создает аккаунт ProgramData через CPI к System Program, выделяя достаточно места для метаданных и max_data_len байт. Затем загрузчик использует bump seed PDA для подписи CPI. 

Макрос deploy_program! проверяет безопасность выполнения байт-кода. Сначала он разбирает структуру файла ELF, проверяет сигнатуру ELF (то есть 0x7f ‘E’ ‘L’ ‘F’) и заголовки (то есть 64-битный формат, Little Endian), извлекает секции программы, обрабатывает таблицы релокаций и проверяет границы и выравнивание секций. Если ELF поврежден или использует неподдерживаемые функции, загрузка немедленно завершится ошибкой.

Затем RequisiteVerifier (то есть верификатор sBPF) выполняет статический анализ всех возможных путей выполнения без запуска программы. Это позволяет доказать ее безопасность до выполнения любых инструкций. Верификатор также обеспечивает соблюдение упомянутых ограничений SVM ISA. При сбое проверки развертывание отклоняется с ошибкой InstructionError::InvalidAccountData, а программа не помечается как исполняемая.

После успешной проверки байт-код компилируется и кешируется для выполнения. Функция load_program_from_bytes создает ProgramCacheEntry, который содержит:

  • Исполняемый код, скомпилированный JIT-компилятором: байт-код sBPF компилируется методом Just-In-Time (JIT) в нативный машинный код для архитектуры CPU валидатора. Это обеспечивает скорость, близкую к нативной, при сохранении безопасности.
  • Метаданные слота: время развертывания и время доступности программы записываются как deployment_slot и effective_slot соответственно. Задержка не позволяет использовать программу в том же слоте, в котором она была развернута.
  • Среда выполнения: ссылки на реестр syscall, определяющий доступные syscalls, и конфигурация выполнения, которая будет применяться при запуске программы. 

Запись кеша сохраняется в program_cache_for_tx_batch, после чего программа становится доступна для выполнения в последующих транзакциях. После успешной проверки и кеширования программы загрузчик обновляет состояния аккаунтов, завершая развертывание. Состояние аккаунта ProgramData обновляется: в нем записываются время развертывания программы и сторона, уполномоченная ее обновлять. Байт-код ELF также копируется из буфера в аккаунт. Состояние аккаунта Program обновляется для связи с аккаунтом ProgramData, а сам аккаунт помечается как исполняемый. Наконец, длина данных буфера устанавливается равной размеру метаданных, что фактически обнуляет байт-код и освобождает место.

Теперь программа полностью развернута, и ее можно вызывать из транзакций.

BPF Loader V4

BPF Loader V4 упрощает развертывание: отдельный аккаунт ProgramData больше не нужен, а аккаунт программы может напрямую хранить байт-код. Он также поддерживает хранение ELF со сжатием zstd. Это значительно снижает расходы на ренту, а при загрузке данные распаковываются по требованию.

Развертывающая сторона вызывает SetProgramLength { new_size }, чтобы выделить место для метаданных и байт-кода программы. Для новых программ эта операция инициализирует аккаунт со статусом LoaderV4State::Retracted, записывает полномочия и помечает аккаунт как исполняемый, хотя вызвать его пока нельзя.

Затем развертывающая сторона записывает бинарный файл ELF непосредственно в аккаунт программы с помощью инструкций Write { offset, bytes }. Такая запись разрешена, только когда программа находится в состоянии Retracted. Инструкцию Copy также можно использовать для копирования байт-кода из другой программы независимо от версии загрузчика, что удобно при миграции.

Затем инструкция Deploy переводит программу из состояния Retracted в состояние Deployed. Она извлекает байт-код из аккаунта программы по указанному смещению и запускает тот же конвейер проверки, что и BPF Loader Upgradeable: разбор ELF, статическую проверку, JIT-компиляцию и кеширование. Если проверка проходит успешно, состояние программы меняется на LoaderV4Status::Deployed, а слот развертывания записывается.

Теперь программа полностью развернута, и ее можно вызывать из транзакций.

Loader V4 также задает период ожидания между переходами состояний (то есть развертыванием и отзывом), чтобы предотвращать атаки с повторным развертыванием. Программы нельзя развернуть или отозвать в течение одного слота после последнего развертывания. Это не позволяет злоумышленникам быстро обновлять программы для эксплуатации состояний гонки или введения пользователей в заблуждение и обеспечивает атомарность в пределах слота вместо многослотовых задержек. Этот период ожидания применяется к инструкциям Deploy и Retract.

Программы также можно сделать неизменяемыми с помощью инструкции Finalize. Она переводит программу из статуса Deployed в статус Finalized, после чего программу нельзя отозвать или обновить. Поле полномочий меняет назначение и начинает указывать на адрес программы «следующей версии». Это создает явные пути обновления, сохраняя неизменяемость программы.

Как выполняется код в SVM

SVM — это механизм обработки транзакций внутри валидаторов, отвечающий за выполнение вызовов программ и соответствующее обновление состояния. 

Когда транзакция поступает валидатору, она проходит многоэтапный конвейер: проверку, загрузку аккаунтов, выполнение программы в изолированной sBPF VM, проверку инвариантов и фиксацию состояния. Если все инструкции выполнены успешно, изменения аккаунтов записываются в AccountsDB. Если хотя бы одна инструкция завершается ошибкой, вся транзакция атомарно откатывается.

SVM работает как изолированный механизм выполнения. Она не управляет консенсусом, сетью или историей реестра. Ее задача — безопасно, детерминированно и эффективно выполнять программы. 

Bank координирует выполнение SVM, предоставляет контекст среды выполнения (например, blockhash, ренту и набор функций) и фиксирует результаты в постоянном хранилище. SVM управляет выполнением программ — от загрузки байт-кода до контроля вычислительных бюджетов. Такое разделение ответственности позволяет использовать SVM не только в валидаторах.

Транзакции 

Транзакции — основа Solana, как и любого другого блокчейна: они вызывают программы для изменения состояния. 

Транзакция — это набор инструкций, которые определяют, какие действия нужно выполнить, с какими аккаунтами и есть ли необходимые разрешения. 

Инструкция — это директива для одного вызова программы. Это минимальная единица логики выполнения и базовая операционная единица Solana.

Программы интерпретируют переданные инструкцией данные и выполняют операции с указанными аккаунтами. Инструкция содержит ID программы (то есть вызываемой программы), список аккаунтов для чтения и записи, а также входные данные, передаваемые программе.

Транзакция начинается с цели пользователя, например перевода 10 SOL на другой аккаунт. Это намерение преобразуется в инструкцию, которая предписывает System Program перевести 10 SOL с аккаунта A на аккаунт B. Аккаунт A передается в транзакцию как подписант с правом записи, а аккаунт B — как аккаунт с правом записи. Затем инструкция упаковывается в транзакцию, где также указываются плательщик комиссии, подписанты и недавний blockhash.

Обычно транзакция затем отправляется RPC-провайдеру, например Helius. Получившая транзакцию RPC-нода проверяет наличие и корректность всех обязательных подписей, отсутствие предыдущей обработки транзакции, актуальность переданного недавнего blockhash и соответствие транзакции максимальному размеру (то есть 1232 байта).

Затем RPC пересылает транзакцию в Transaction Processing Unit (TPU) текущего лидера. 

Transaction Processing Unit (TPU)

Transaction Processing Unit (TPU) — это конвейер приема и обработки транзакций внутри валидаторов Solana. Он включает несколько этапов, которые принимают, проверяют, планируют и выполняют транзакции перед их фиксацией в реестре Solana.

Для наших целей мы подробно рассмотрим Fetch Stage, SigVerify Stage и Banking Stage, а затем перейдем к Bank и подготовке sBPF VM. 

Более подробный разбор TPU см. в статье Взвешенное по стейку качество обслуживания: все, что нужно знать.

Fetch Stage

Fetch Stage — первый этап конвейера TPU. Он принимает все входящие транзакции через соединения QUIC, которые используют UDP-сокеты как базовый транспортный уровень, и группирует их в пакеты для последующей обработки.

Создаются три UDP-сокета:

  • tpu: обычные транзакции, например переводы токенов, выпуск NFT и взаимодействия с программами.
  • tpu_vote: голосующие транзакции от валидаторов — это изменится с появлением Alpenglow, когда голосующие транзакции будут удалены.
  • tpu_forwards: перенаправленные необработанные транзакции от предыдущего лидера, который не успел их обработать.

Эти сокеты регистрируются в gossip-сервисе валидатора и сохраняются в структуре ContactInfo. Благодаря этому другие валидаторы и RPC-ноды могут узнать, куда отправлять транзакции.

Fetch Stage создает по одному потоку для каждого сокета. Все потоки непрерывно выполняют следующие действия:

  • Опрашивают UDP-сокет на наличие входящих пакетов.
  • Создают группу из 64 пакетов.
  • Отправляют группу через свой неограниченный канал.

Сейчас для передачи групп на последующие этапы используются неограниченные каналы, то есть каналы с неограниченной емкостью. Благодаря этому Fetch Stage может работать независимо от скорости последующей обработки. 

Это предотвращает немедленную потерю пакетов при скачках трафика, но может вызвать проблемы с памятью. Если последующие этапы не успевают за Fetch Stage, каналы растут без ограничений, что может привести к замедлению или сбоям из-за нехватки памяти (OOM). 

Ведется работа над ограниченными каналами с корректным обратным давлением. Это позволит системе сигнализировать о перегрузке и предотвращать неограниченный рост потребления памяти.

Fetch Stage также создает отдельный поток для обработки перенаправленных пакетов. Такие пакеты помечаются флагом FORWARDED, после чего сохраняются или отбрасываются в зависимости от расписания лидеров:

  • Принимаются: если текущий валидатор скоро станет лидером, перенаправленные пакеты обрабатываются через обычный канал TPU и отправляются на следующий этап.
  • Отбрасываются: если текущий валидатор не станет лидером в ближайшее время, перенаправленные пакеты удаляются, чтобы не тратить ресурсы на их обработку. 

Fetch Stage использует PacketBatchRecycler для предварительного выделения 1000 групп пакетов, каждая из которых содержит 1024 пакета. Это снижает накладные расходы на выделение памяти, поскольку память групп можно использовать повторно вместо создания новых групп. Исторически recycler требовался для закрепления памяти CUDA, которое больше не работает, поэтому теперь его в основном можно считать техническим долгом. 

SigVerify Stage

SigVerify Stage — второй этап конвейера TPU. Как следует из названия, он отвечает за проверку подписей. Она выполняется на раннем этапе, поскольку проверка подписей Ed25519 требует значительных вычислительных ресурсов, хотя и меньше, чем выполнение транзакций. Проверяя транзакции до выполнения, валидаторы могут отклонять мошеннические транзакции, предотвращать атаки типа «отказ в обслуживании» и пропускать на Banking Stage только корректно сформированные транзакции.

SigVerify Stage работает как один поток, который непрерывно получает пакеты из каналов Fetch Stage и проводит их через конвейер проверки. Несмотря на один поток, внутри используется значительный параллелизм.

По умолчанию подписи проверяются на CPU с помощью параллельного итератора. Проверка распределяется между всеми доступными ядрами CPU, и каждое ядро независимо проверяет часть подписей. 

Проверку подписей также можно перенести на GPU, если через perf_libs::api() обнаружены библиотеки производительности. GPU можно использовать только при наличии не менее 64 пакетов и ожидаемой доле корректных пакетов в 90%. Причина в том, что настройка и передача на GPU создают накладные расходы порядка ~15–20 мс, тогда как CPU может проверить 64 подписи за ~10–20 мс. На практике эта возможность оказалась непригодной для эксплуатации из-за задержки: при реальных нагрузках она работает намного медленнее проверки на CPU. Этот неиспользуемый путь кода планируется удалить.

Процесс проверки относительно прост:

  • Получить группы пакетов из неограниченных каналов Fetch Stage.
  • Случайным образом отбрасывать транзакции посредством сброса нагрузки, если число пакетов превышает 165 000.
  • Удалить дублирующиеся транзакции.
  • Отбросить лишние пакеты, чтобы ни один IP-адрес не мог монополизировать пропускную способность проверки.
  • Предварительно сократить и реорганизовать группы для улучшения локальности кеша и снижения расхода памяти.
  • Проверить подписи.

Все корректные пакеты переходят на Banking Stage. 

Banking Stage

На Banking Stage выполняются транзакции. Здесь они буферизуются, планируются и выполняются параллельными рабочими потоками.

Используется модель с центральным планировщиком, которая разделяет обработку голосующих и неголосующих транзакций:

  • Один рабочий поток обрабатывает голосующие транзакции и голосующие транзакции gossip.
  • Четыре рабочих потока обрабатывают все неголосующие транзакции.
  • Один поток координирует распределение работы между рабочими потоками.

Входящие пакеты десериализуются и буферизуются в объеме до 100 000 транзакций. Планировщик непрерывно получает новые транзакции и одновременно управляет этим буфером. При очистке очереди он проверяет до 10 000 транзакций за раз и удаляет истекшие или недействительные транзакции.

Планировщик определяет порядок выполнения транзакций с помощью обнаружения конфликтов (то есть транзакций, которые хотят получить блокировки чтения и записи для одних и тех же аккаунтов). По умолчанию доступны две реализации планировщика:

  • PrioGraphScheduler: планировщик, который строит граф приоритетов для обнаружения конфликтов аккаунтов.
  • GreedyScheduler: планировщик по умолчанию с более простым FIFO-подходом к упорядочиванию транзакций.

Затем планировщик выбирает неконфликтующие транзакции и отправляет их рабочим потокам. Каждый рабочий поток получает группу и начинает обработку.

Координация Bank

Bank представляет состояние всех аккаунтов в конкретном слоте. Это центральная структура данных, которая управляет данными аккаунтов, обеспечивает соблюдение правил среды выполнения и координирует взаимодействие между рабочими потоками Banking Stage и sBPF VM при выполнении транзакций. 

Жизненный цикл

Каждый Bank проходит через три состояния:

  • Активный: недавно созданный Bank, открытый для транзакций. Рабочие потоки Banking Stage применяют транзакции, пока Bank не достигнет целевого числа тиков или пока не будут обработаны все записи слота.
  • Замороженный: после достижения числа тиков или обработки всех записей Bank замораживается. Применять новые транзакции больше нельзя. На этом этапе комиссии за транзакции начисляются лидеру блока, аккаунты sysvar обновляются и вычисляется итоговый хеш Bank.
  • Укорененный: когда замороженный Bank получает достаточно голосов валидаторов, он становится укорененным. Состояние считается финализированным и становится частью реестра цепочки.

Каждый Bank, кроме генезисного, ссылается на родительский Bank. Так образуется древовидная структура, представляющая разные форки реестра. 

Поток выполнения

Рабочие потоки Banking Stage работают с текущим рабочим Bank — активным незамороженным Bank, создаваемым для текущего слота. Когда рабочий поток получает группу транзакций от планировщика, Bank координирует выполнение:

  • Блокировка аккаунтов: рабочие потоки вызывают функцию prepare_sanitized_batch_with_results(), чтобы заблокировать все аккаунты, указанные в транзакциях, и предотвратить параллельные изменения.
  • Загрузка аккаунтов: Bank получает данные всех заблокированных аккаунтов из AccountsDB.
  • Списание комиссии: до выполнения Bank списывает комиссию за транзакцию с аккаунта плательщика.
  • Проверка: Bank проверяет актуальность blockhash, состояние nonce-аккаунта и владельцев аккаунтов.
  • Передача VM: Bank вызывает load_and_execute_transactions(), передавая загруженные аккаунты в sBPF VM.
  • Выполнение: sBPF VM выполняет байт-код программы каждой инструкции.
  • Результаты: sBPF VM возвращает все результаты выполнения (то есть LoadAndExecuteTransactionOutput).
  • Фиксация: Bank записывает обновленные состояния аккаунтов обратно в AccountsDB через bank.commit_transactions().

Теперь выполнение переходит непосредственно в SVM, то есть в sBPF VM. После вызова Bank функции load_and_execute_transactions() транзакции проходят многоэтапный конвейер. Каждая инструкция обрабатывается в новом изолированном экземпляре sBPF VM, подготовленном для выполнения байт-кода программы.

Инструкции транзакции выполняются последовательно. Каждая инструкция проходит следующий конвейер:

  • Найти программу: найти аккаунт программы по ID программы из инструкции.
  • Загрузить из кеша: проверить, есть ли уже JIT-скомпилированная программа в кешe программ. 
  • Подготовить VM: создать изолированный экземпляр sBPF VM с заданными областями памяти и вычислительным бюджетом.
  • Выполнить байт-код: запустить точку входа программы с входными данными инструкции.
  • Проверить инварианты: убедиться, что выполнение не нарушает правила среды выполнения.
  • Собрать результаты: упаковать результаты выполнения.

Загрузка программы

До выполнения программу необходимо загрузить из ее ончейн-аккаунта, проверить и скомпилировать в нативный машинный код. Это происходит через кеш программ и конвейер JIT-компиляции.

Кеш программ — это оптимизация производительности, которая позволяет не загружать и не компилировать программы заново при каждом вызове. Он поддерживается на уровне группы транзакций и хранит объекты ProgramCacheEntry, содержащие:

  • Исполняемый код, скомпилированный JIT-компилятором: нативный машинный код для архитектуры CPU валидатора.
  • Метаданные развертывания: слот, в котором программа была развернута и вступила в силу.
  • Среда выполнения: ссылки на реестр syscall и конфигурацию выполнения.

Когда транзакция ссылается на ID программы, выполняется следующая последовательность поиска:

  • Проверить кеш группы транзакций: найти кешированную JIT-скомпилированную версию.
  • Проверить глобальный кеш программ: если версии нет в кеше группы, проверить глобальный кеш валидатора.
  • Загрузить из аккаунта: если во всех кешах нет записи, загрузить аккаунт программы из AccountsDB.
  • Разобрать ELF: извлечь байт-код из данных аккаунта программы.
  • Проверить байт-код: запустить статический верификатор для проверки безопасности.
  • Выполнить JIT-компиляцию: преобразовать байт-код sBPF в нативный машинный код.
  • Добавить запись в кеш: сохранить скомпилированную программу для будущих вызовов.

Таким образом, первый вызов недавно развернутой программы несет полную стоимость загрузки, проверки и компиляции. Последующие вызовы напрямую выполняют кешированный нативный код. 

При промахе кеша программу нужно загрузить из ее ончейн-аккаунта. Для программ, принадлежащих BPF Loader Upgradeable, аккаунт программы содержит ссылку на аккаунт ProgramData, который и загружается из AccountsDB. В модели Loader V4 с одним аккаунтом байт-код загружается непосредственно из аккаунта программы. Если он сжат с помощью zstd, может потребоваться распаковка.

Извлеченные байты ELF разбираются для поиска исполняемого байт-кода и упомянутых ранее секций (то есть .text, .rodata, .data / .bss, .symtab / .strtab). После успешного разбора ELF RequisiteVerifier (то есть статический анализатор sBPF) проверяет все возможные пути выполнения без фактического запуска программы, как описано в предыдущих разделах.

JIT-компиляция

После успешной проверки байт-код компилируется методом Just-In-Time (JIT) в нативный машинный код. JIT-компилятор преобразует каждую инструкцию sBPF в эквивалентные нативные инструкции CPU для архитектуры валидатора. 

JIT-компиляция обеспечивает sBPF VM производительность, достаточную для высокой пропускной способности Solana. Без JIT виртуальной машине пришлось бы интерпретировать байт-код sBPF инструкцию за инструкцией, что создавало бы значительные накладные расходы. 

Для каждой инструкции байт-кода необходимо выполнить извлечение, декодирование и передачу коду обработчика, что увеличивает накладные расходы на интерпретацию. Кроме того, интерпретируемый код не может использовать оптимизации на уровне CPU, такие как конвейеризация, предсказание ветвлений или внеочередное выполнение. Поэтому каждая инструкция sBPF превращается в вызов функции интерпретатора.

JIT-компиляция полностью устраняет эти расходы, создавая нативный машинный код, который выполняется непосредственно на CPU. Это обеспечивает производительность, близкую к нативной, оптимизированные проверки границ, встроенный учет вычислений, аппаратные оптимизации и простое сопоставление регистров.

JIT-компилятор выполняет однопроходное преобразование байт-кода sBPF в нативный машинный код. Для каждой инструкции sBPF выполняются следующие действия:

  • Инструкция декодируется для извлечения кода операции, регистров, смещения и непосредственного значения.
  • Настраивается кадр стека и сохраняются регистры, которые должен сохранять вызываемый код.
  • Операция sBPF сопоставляется с эквивалентной инструкцией CPU, чтобы каждая операция компилировалась в нативные инструкции. Например, при сопоставлении x86-64 rax соответствует R0, а rbp — R10.
  • Добавляются проверки границ и программная трансляция адресов для доступа к памяти. Преобразование гостевых адресов в адреса хоста — одна из самых медленных операций VM из-за накладных расходов.
  • Поддерживается изоляция стека: гостевой стек размещается в буфере, выделенном в куче, а не в стеке хоста.
  • Вставляются списание CU и проверки бюджета для учета вычислений.
  • Регистры восстанавливаются, а стек очищается.

Трансляция инструкций

JIT-компилятор особым образом преобразует разные типы инструкций: арифметические операции, доступ к памяти, операции записи, условные переходы и диспетчеризацию syscall. 

Арифметические операции напрямую сопоставляются с отдельными нативными инструкциями CPU без накладных расходов, поскольку регистры sBPF хорошо соответствуют аппаратным регистрам валидатора. 

Операции доступа к памяти требуют проверки границ для предотвращения чтения и записи за их пределами. JIT-компилятор создает проверочный код, который сопоставляет нижнюю и верхнюю границы каждого обращения к памяти с границами допустимой области. 

Обычно для этого требуется от 3 до 6 нативных инструкций: они вычисляют эффективный адрес, проверяют его попадание в ожидаемые границы и выполняют фактическую загрузку. Благодаря редкости нарушений границ предсказание ветвлений обрабатывает это достаточно эффективно. 

Операции записи включают проверку границ и разрешения на запись. Перед записью в память скомпилированный код проверяет, что целевой адрес находится в допустимых границах и для области памяти разрешена запись.

Условные переходы компилируются в нативные инструкции условного перехода. JIT-компилятор разрешает все цели переходов во время компиляции, преобразуя относительные смещения инструкций sBPF в абсолютные адреса нативного кода. 

Диспетчеризация syscall требует сохранить состояние VM — все 11 регистров — и вызвать нативный обработчик syscall. После этого состояние VM восстанавливается вместе с возвращаемым значением. Из-за этих накладных расходов syscalls имеют фиксированную стоимость в CU, которая выше, чем у обычных инструкций.

Учет вычислительных единиц

JIT-компилятор встраивает отслеживание вычислительных единиц непосредственно в генерируемый код. Каждая инструкция sBPF включает проверку бюджета: инструкции уменьшают оставшееся число CU, а проверка не допускает его исчерпания. 

Такое встраивание устраняет накладные расходы на вызов функций и эффективно работает благодаря предсказанию ветвлений, внеочередному выполнению (то есть проверка CU и операция могут выполняться параллельно) и параллелизму на уровне инструкций.

Для syscall с переменной стоимостью, например sol_sha256, стоимость которого масштабируется в зависимости от длины данных, расчет выполняется внутри нативной реализации syscall перед возвратом в VM.

Кеширование скомпилированного кода

После завершения JIT-компиляции нативный исполняемый код сохраняется в ProgramCacheEntry, который содержит:

  • Код, скомпилированный JIT-компилятором
  • Метаданные слота развертывания и эффективного слота
  • Ссылки на реестр syscall
  • Конфигурацию среды выполнения

Запись помещается в кеш группы транзакций и глобальный кеш программ. Первый доступен всем инструкциям текущей группы, а второй — всем будущим транзакциям. 

Запись кеша может быть аннулирована при обновлении программы (то есть развертывании нового байт-кода), закрытии аккаунта программы, изменении доступности syscall через feature gates или очистке кеша по решению валидатора.

Задержка эффективного слота

Программу, развернутую в слоте n, нельзя вызвать до слота n + 1. Эта задержка гарантирует, что все валидаторы увидят развертывание, кеши программ синхронизируются по сети и будет обеспечена атомарность в пределах слота. 

Подготовка sBPF VM

После загрузки и JIT-компиляции программы или ее получения из кеша для выполнения каждой инструкции подготавливается новый экземпляр sBPF VM. Подготовка выполняется в BPF Loader и включает настройку пяти отдельных областей памяти, инициализацию вычислительного бюджета и регистрацию syscall.

Области памяти

Как упоминалось в разделе о SVM ISA, VM создает пять отдельных областей памяти. Вместе они образуют изолированную песочницу, в которой выполняются программы Solana. Область памяти программы обычно начинается с адреса 0x100000000. Она содержит нативный код, скомпилированный JIT-компилятором, то есть фактический машинный код для архитектуры CPU валидатора. Если JIT отключен для отладки, эта область вместо него содержит интерпретируемый байт-код sBPF. Для этой секции разрешены только чтение и выполнение.

Данные только для чтения также размещаются по адресу 0x100000000. Эта область содержит константы и статические строки, извлеченные из секции ELF .rodata при загрузке программы. Она обеспечивает эффективный доступ к константам времени компиляции без выделения памяти в куче. 

Стек начинается с адреса 0x200000000 и содержит локальные переменные, кадры вызовов функций и адреса возврата. Здесь во время выполнения происходят временные вычисления. Стек растет вниз от верхней границы области, а регистр R10 (то есть указатель кадра) отмечает границу текущего кадра. Для этой области разрешены чтение и запись, а ее размер фиксирован и составляет 4 КБ на кадр вызова. Превышение этого ограничения вызывает ошибку StackAccessViolation, указывающую на переполнение стека. Небольшой размер стека побуждает разработчиков использовать кучу или, что еще лучше, хранить данные в аккаунтах, а не полагаться на стековое хранилище.

Куча начинается с адреса 0x300000000 и содержит динамически выделяемую память для структур данных среды выполнения, которые не помещаются в стеке или аккаунте. Ее размер варьируется от стандартных 32 КБ до максимальных 256 КБ. Ранее программы могли расширять кучу через syscall sol_alloc_free. Однако sol_alloc_free устарел и отключен для новых развертываний программ. Теперь программы должны указывать требуемый размер кучи во время развертывания, а не расширять ее динамически. 

Примечание: увеличение кучи расходует вычислительные единицы по формуле (heap_size / 32KB) * 8,000 CUs, а стандартная стоимость кучи составляет 8 CU.

Область памяти входных данных начинается с адреса 0x400000000. Она содержит сериализованные параметры точки входа, которые программа получает при вызове. Это область памяти только для чтения, фактический размер которой зависит от транзакции. Три сериализованных компонента — это 32-байтовый pubkey вызываемой программы, массив аккаунтов и данные инструкции.

Контроль доступа к памяти

VM проверяет границы каждой инструкции загрузки из памяти и записи в нее. Перед каждым обращением к памяти VM убеждается, что адрес находится в допустимом диапазоне области, а тип доступа (то есть чтение или запись) разрешен для этой области. 

Если какая-либо проверка завершается неудачно, выполнение немедленно прерывается с ошибкой AccessViolation. Вся транзакция откатывается, а изменения состояния не фиксируются. 

Этот контроль почти не создает расходов во время выполнения, поскольку JIT-компилятор преобразует проверки в эффективный нативный код, который CPU выполняет напрямую. Современное предсказание ветвлений эффективно обрабатывает проверки, поскольку нарушения происходят редко. 

Инициализация вычислительного бюджета

Каждый экземпляр VM инициализируется с бюджетом вычислительных единиц, который ограничивает общий объем работы конкретной программы. Такая модель ограниченного выполнения не позволяет программам работать бесконечно и гарантирует, что все валидаторы выполняют транзакции в предсказуемый срок.

Текущие параметры бюджета:

  • Лимит вычислительных единиц по умолчанию на инструкцию: 200 000 CU.
  • Максимальный лимит вычислительных единиц на транзакцию: 1 400 000 CU.
  • Лимит встроенной инструкции: 3 000 CU.

Количество CU по умолчанию на транзакцию равно min(1_400_00, (200_000*non_reserve_instructions + 3_000*reserve_instructions)). По сути, выбирается меньшее из максимального лимита вычислительных единиц и суммы стандартной стоимости инструкций каждого типа.

Бюджет вычислений отслеживает количество единиц, оставшихся в процессе выполнения. При выполнении каждой инструкции sBPF стоимость этой инструкции в CU вычитается из оставшегося бюджета. Если бюджет исчерпан до завершения программы, выполнение немедленно останавливается с ошибкой InstructionError::ComputationalBudgetExceeded. Транзакция завершается с ошибкой, изменения состояния не фиксируются, но с плательщика всё равно взимается комиссия за транзакцию, чтобы компенсировать валидатору её обработку. 

Реестр системных вызовов

При подготовке VM также создаётся сопоставление уникального 32-битного идентификатора Murmur-хеша каждого системного вызова с его нативной реализацией на Rust. Так регистрируются все доступные системные вызовы. 

Когда программа выполняет инструкцию CALL_IMM с хешем системного вызова, происходит следующее:

  • VM приостанавливает поток инструкций sBPF.
  • Диспетчер системных вызовов находит реализацию в реестре по хешу.
  • Системный вызов проверяет наличие у вызывающей стороны необходимых разрешений, например на использование CPU или владение аккаунтом.
  • Реализация системного вызова на Rust выполняется за пределами песочницы с полным доступом к среде выполнения.
  • Фиксированная стоимость системного вызова вычитается из оставшегося бюджета вычислений.
  • Результат помещается в регистр R0, после чего выполнение sBPF возобновляется. 

Выполнение программы

После подготовки sBPF VM выполнение начинается с функции точки входа программы. Для программ, скомпилированных с помощью JIT, VM сразу переходит к нативному машинному коду и передаёт его на непосредственное выполнение CPU валидатора. Как описано в предыдущем разделе, JIT-код содержит всю необходимую инструментацию: проверки границ памяти, учёт вычислений и проверку потока управления. Всё это встроено в код для максимальной производительности.

Регистр R1 содержит указатель на область входных данных, где находятся три сериализованных параметра: публичный ключ вызываемой программы, массив аккаунтов и данные инструкции. 

Примечание: доступ к данным аккаунта выполняется через указатели, а не путём копирования. Благодаря указателям программы могут читать и изменять данные аккаунта на месте, что критически важно для производительности.

Каждый вызов функции выделяет новый кадр стека размером 4 КБ, а регистр R10 обновляется и указывает на новый кадр. Вычисления учитываются в рамках описанного выше бюджета вычислений.

Межпрограммные вызовы (CPI)

Во время выполнения программы могут вызывать другие программы посредством межпрограммных вызовов (CPI) — основы компонуемости SVM. 

CPI инициируется через системный вызов sol_invoke_signed, который стоит 1 000 CU, а также требует дополнительных затрат, зависящих от передаваемых сериализованных данных аккаунта. И данные аккаунта, и сериализация данных инструкции тарифицируются из расчёта 250 байт на CU.

Когда программа выполняет CPI, создаётся новый контекст выполнения с собственным кадром стека инструкций. На момент написания максимальная глубина стека инструкций составляет 5 или 9 при включённом SIMD-0268. Это означает, что программа может вызывать другую программу, которая, в свою очередь, может вызвать следующую, пока не будет достигнут предел глубины. Каждый вложенный вызов сохраняет собственный набор доступных для записи аккаунтов и привилегий подписанта. CPI может иметь не более 16 подписантов и передавать 128 структур AccountInfo. 

Вызывающая программа сериализует ID целевой программы, аккаунты и данные инструкции, а затем выполняет системный вызов. Выполнение текущей программы приостанавливается, и для вызываемой программы подготавливается новый экземпляр sBPF VM по тому же процессу, который описан выше. Затем начинается выполнение вызываемой программы. Она использует собственный бюджет вычислений, выделенный из оставшегося бюджета вызывающей программы. Таким образом, вызовы CPI совместно используют общий бюджет вычислений транзакции.

Программы могут подписывать операции от имени принадлежащих им аккаунтов через адреса, производные от программы (PDA). При вызове через sol_invoke_signed вызывающая программа передаёт начальные значения, подтверждающие владение PDA. Производный адрес PDA проверяется до предоставления вызываемой программе права подписи. 

Когда вызываемая программа завершает работу, управление возвращается вызывающей программе. Изменения аккаунтов, внесённые вызываемой программой, видны вызывающей программе, благодаря чему состояние передаётся по цепочке вызовов. Если любая программа в цепочке CPI завершается с ошибкой, вся транзакция прерывается, а все изменения состояния откатываются.

Примечание: sol_invoke — это вспомогательная функция, которая вызывает sol_invoke_signed без начальных значений.

Проверка после выполнения

После завершения выполнения программы — напрямую или в составе цепочки CPI — проводится несколько проверок, обеспечивающих согласованность состояния и соблюдение инвариантов безопасности. 

Например, среда выполнения проверяет, что все аккаунты, отмеченные как доступные для записи, действительно принадлежат программе или имеют надлежащую подпись. Программы не могут изменять чужие аккаунты, если они явно не отмечены как доступные для записи и владелец не предоставил разрешение. Это предотвращает несанкционированные изменения состояния.

Среда выполнения также проверяет, что общая сумма лампортов во всех аккаунтах транзакции не изменилась, кроме случаев явного перевода лампортов инструкциями System Program. Эта проверка сохранения не позволяет программам создавать или уничтожать лампорты, поэтому общее предложение SOL остаётся неизменным.

Среда выполнения проверяет, что аккаунты с флагом исполняемого кода, то есть программы, не были изменены. Данные программы нельзя изменять во время обычного выполнения. Обновить её можно только через механизм полномочий на обновление BPF Loader.

Результаты выполнения

После завершения проверки результат выполнения возвращается обработчику транзакций Bank. Результат содержит статус успеха или ошибки — соответственно нулевое или ненулевое значение, — количество использованных вычислительных единиц и все изменения состояния аккаунтов. 

При успешном выполнении Bank атомарно фиксирует все изменения аккаунтов. Обновлённые данные аккаунтов, балансы в лампортах и метаданные записываются в AccountsDB и становятся видимыми для последующих транзакций. Количество использованных вычислительных единиц регистрируется для расчёта комиссии за транзакцию и сетевых метрик.

Для транзакций, завершившихся с ошибкой, изменения состояния не фиксируются. В Solana нет частичного отката: транзакция откатывается целиком. Однако комиссия за транзакцию всё равно списывается с аккаунта плательщика, чтобы компенсировать валидатору выполненную вычислительную работу. Код ошибки и количество использованных вычислительных единиц записываются в метаданные транзакции для отладки и анализа.

Результат выполнения возвращается через планировщик Banking Stage, который обновляет внутренние метрики и переходит к следующей транзакции. Как успешные, так и неуспешные транзакции записываются в поток Proof of History и включаются в формируемый блок. Неуспешные транзакции включаются для защиты от атак повторного воспроизведения и сохранения полной истории транзакций.

Когда слот завершается и достигает максимального количества тиков, Bank переходит в замороженное состояние. Заморозка — необратимая операция, которая запрещает фиксацию новых транзакций и вычисляет хеш Bank. Обратите внимание: замороженный статус не означает финализацию. Слот всё ещё может находиться в форке, который впоследствии будет отброшен. 

Bank становится корневым, когда валидатор вызывает BankForks::set_root(), обозначая его как часть канонической цепочки. Назначение корня запускает операцию squash, которая переносит состояние аккаунтов корневого Bank в AccountsDB, объединяет все родительские состояния и делает результат постоянным с точки зрения валидатора. Некорневые форки обрезаются и отбрасываются. Даже корневые Bank ещё не финализированы с точки зрения кластера из-за разных уровней подтверждения в Solana.

Что дальше

Solana Virtual Machine представляет принципиально иной подход к выполнению блокчейна. Это масштабируемый блокчейн, доступный широкой аудитории благодаря параллельной обработке, локальным рынкам комиссий и производительной детерминированной среде выполнения на основе eBPF. 

Чтобы понять SVM, необходимо рассмотреть весь конвейер выполнения: от компиляции исходного кода Rust в LLVM и sBPF до подготовки изолированных экземпляров VM. 

У SVM нет единой определяющей её «спецификации». Вместо этого она формируется в результате взаимодействия Bank, планировщика, BPF Loaders, sBPF VM и SVM ISA.

По мере развития SVM её будущее выглядит многообещающим. 

Цепочка инструментов Solana полностью перерабатывается, чтобы отказаться от специальной инфраструктуры LLVM, которая годами усложняла начало работы для разработчиков. 

Текущий подход требует от разработчиков устанавливать специальные цепочки инструментов с помощью скриптов для конкретных платформ. Решение — перейти на ту же цепочку инструментов, которую использует Aya, библиотека eBPF для Rust. Разработчики смогут выполнить две простые команды для прямой компиляции в байткод eBPF:

Код
rustup toolchain install nightly
cargo build --target=bpfel-unknown-none

Без скриптов. Без специальных форков LLVM. Только стандартные инструменты Rust, которые напрямую компилируют код в байткод eBPF с помощью основной цели bpfel-unknown-none, используя результаты многолетней разработки ядра Linux и улучшения инфраструктуры LLVM.

SVM ISA также планируется обновить в рамках SIMD-0377, который предлагает привести реализацию eBPF в Solana, то есть sBPF, в соответствие с современными стандартами eBPF. В частности, предлагается добавить варианты инструкций JMP32, операции знакового деления и вычисления остатка, косвенные переходы и динамические кадры стека. Эти изменения помогут снизить вычислительные затраты, улучшить совместимость с основной инфраструктурой LLVM и обеспечить более эффективную генерацию кода. 

В основе SVM лежит система учёта через бюджеты вычислений. SIMD-0370 может изменить этот механизм, отменив лимит вычислений на уровне блока и, возможно, на уровне транзакции. Отмена этих лимитов позволит производителям блоков максимизировать пропускную способность с учётом возможностей своего оборудования, а не искусственных ограничений. В сочетании с механизмом тайм-аутов Alpenglow это изменение позволит определять оптимальные размеры блоков рыночным силам, а не ограничениям на уровне протокола. Конечно, это очень отдалённая перспектива: сначала Anza планирует увеличить лимит CU до 100 млн и более, а уже затем отменять такие ограничения.

В основе всех этих изменений лежит стремление разработчиков расширять границы возможностей блокчейнов без ущерба для безопасности, детерминизма и децентрализации. 

SVM — не просто интерпретатор байткода. Это полноценный конвейер выполнения, который радикально расширяет возможности блокчейнов. Он стал результатом архитектурных решений, отдающих приоритет пропускной способности и низкой задержке. По мере развития Solana SVM продолжит совершенствоваться, чтобы поддерживать высокопроизводительные и капиталоэффективные приложения.

Для реализации концепции интернет-рынков капитала нужна инфраструктура, которая отвечает требованиям глобальных финансовых систем к пропускной способности, задержке и стоимости. Solana Virtual Machine — важнейший шаг к воплощению этой концепции.

Дополнительные материалы

Подпишитесь на Helius

Следите за новостями разработки на Solana и получайте уведомления о наших публикациях

Увеличенное изображение