
Solana Virtual Machine(SVM)이란?
목차
- 핵심 인사이트
- 소개
- 논쟁적인 정의
- SVM 한눈에 보기
- 가상 머신
- 블록체인의 가상 머신
- SVM의 작동 방식
- SVM의 특별한 점: 사전 계정 선언
- 패러다임 전환
- Rust 소스에서 sBPF 바이트코드까지: 컴파일 파이프라인
- Rust
- 프로그램의 기본 구조
- Rust 컴파일러와 LLVM IR
- eBPF
- sBPF
- SVM ISA
- Syscall
- 프로그램 바이너리
- 바이트코드를 Solana에 업로드하는 방식
- BPF Loader 프로그램
- 배포 아키텍처: 계정 모델
- Solana 프로그램 배포 방식
- SVM의 실행 방식
- 트랜잭션
- Transaction Processing Unit(TPU)
- Bank 오케스트레이션
- 프로그램 로드
- JIT 컴파일
- sBPF VM 프로비저닝
- 프로그램 실행
- 실행 후 검증
- 실행 결과
- 앞으로의 방향
- 추가 자료
이 글의 이전 버전을 검토해 주신 Lostin, Alessandro, Brian, Brady, Daniel Cumming께 깊이 감사드립니다.
핵심 인사이트
- 바이트코드 실행기를 명확히 가리키는 EVM과 달리, SVM은 전체 트랜잭션 실행 스택을 포괄합니다.
- 트랜잭션이 실행 전에 접근할 계정을 선언하도록 하면 여러 CPU 코어에서 병렬 실행하고 로컬 수수료 시장을 구현할 수 있습니다.
- Rust 소스는 rustc를 통해 LLVM IR로 컴파일된 후 LLVM의 eBPF 백엔드, 구체적으로 Solana의 포크인 sBPF를 거쳐 sBPF 바이트코드로 변환됩니다. 따라서 LLVM 프런트엔드가 있는 모든 언어(예: C, C++, Zig)로 Solana 프로그램을 작성할 수 있습니다.
- sBPF는 Linux eBPF의 Solana 포크입니다. 몇 가지 역사적 추가 사항을 제외하면 사실상 동일하며, 이 추가 사항도 되돌릴 예정입니다. 유일하게 의미 있는 차이는 업스트림 eBPF 함수가 최대 5개의 인수만 받을 수 있는 반면 Solana 포크는 더 많이 받을 수 있다는 점입니다.
- 컴파일된 sBPF 바이트코드는 명령어와 상수 섹션, 재배치 테이블이 포함된 ELF 파일에 저장됩니다. 링커는 syscall 참조를 결정론적 32비트 Murmur3 해시로 해석하고, 이식성을 위해 내부 함수를 상대 점프로 다시 작성합니다.
- BPF Loader Upgradeable은 인플레이스 업그레이드와 배포에 2계정 모델을 사용합니다. 출시 예정인 Loader V4는 선택적 압축을 지원하는 단일 계정으로 이를 간소화합니다. 모든 바이트코드는 실행 가능 상태로 표시되기 전에 정적으로 검증됩니다.
- 트랜잭션에는 읽기 및 쓰기 권한이 지정된 계정 주소 배열, 명령어, 서명이 포함됩니다. 이 구조화된 형식 덕분에 충돌을 감지하고 충돌하지 않는 트랜잭션을 병렬로 예약할 수 있습니다.
- TPU의 Banking Stage는 충돌하지 않는 트랜잭션을 병렬로 예약합니다. Bank는 AccountsDB에서 계정 상태를 불러옵니다. BPF Loaders는 제한된 메모리 영역과 컴퓨트 예산을 갖춘 격리된 sBPF VM을 제공합니다. 성공한 상태 변경은 원자적으로 커밋되고, 실패하면 완전히 되돌아갑니다.
- SVM ISA가 유일한 공식 사양입니다. Alpenglow 백서와 Toly가 처음 작성한 일련의 글 외에는 단일한 “SVM 사양”이 없습니다. 런타임은 Bank, 스케줄러, BPF Loaders, sBPF VM 자체의 상호작용으로 구현됩니다.
소개
Solana Virtual Machine(SVM)은 오늘날 블록체인에서 가장 많이 오해받는 시스템 중 하나입니다. 명확히 opcode 실행기를 가리키는 Ethereum Virtual Machine(EVM)과 달리, SVM이라는 용어는 Banking Stage 스케줄러부터 sBPF 바이트코드 인터프리터 자체까지 전체 트랜잭션 실행 파이프라인을 포괄합니다. 이러한 모호함은 Solana의 아키텍처 차이를 반영합니다. “SVM”만을 독립적으로 정의하는 전통적인 사양은 없습니다. 가장 가까운 사양은 sBPF 바이트코드의 실행 방식을 설명하는 Solana Virtual Machine Instruction Set Architecture(SVM ISA)이지만, 더 광범위한 런타임에 대해서는 다루지 않습니다.
이 글은 Anza의 Agave 검증인 구현을 기준으로 SVM이 무엇이고, 어떻게 작동하며, 근본적으로 무엇이 다른지 설명하는 종합 자료를 목표로 합니다. Firedancer 클라이언트의 작동 방식과 SVM ISA를 준수하는 자체 가상 머신 구현은 이 글의 범위를 벗어납니다.
추상적인 사양이 아니라 실제 코드베이스를 살펴봅니다. Rust 소스 코드가 LLVM을 거쳐 sBPF 바이트코드로 컴파일되는 방식, 프로그램이 배포되고 검증되는 방식, 런타임이 병렬 실행을 위해 격리된 실행 환경을 제공하는 방식, 트랜잭션이 배포된 바이트코드와 상호작용하는 방식을 포함한 전체 실행 파이프라인을 추적합니다.
첫 몇 개 섹션에서는 SVM의 모호성을 맥락화하고 작동 방식을 개괄합니다. 나머지 부분은 Solana 실행 레이어를 엄밀하게 이해하려는 기술 독자를 대상으로 합니다.
논쟁적인 정의
“Solana Virtual Machine”(SVM)이라는 용어는 커뮤니티에서 격렬한 논쟁을 일으켰습니다. 특히 네트워크 확장과 Solana 위에 구축되는 다른 레이어 블록체인이 등장한 이후 더 두드러졌습니다. 논쟁의 핵심은 용어의 범위입니다. SVM은 엄밀히 저수준 sBPF 인터프리터만을 뜻할까요, 아니면 전체 트랜잭션 실행 스택을 포괄할까요?
좁은 관점은 SVM을 EVM의 opcode 실행기 같은 전통적인 가상 머신(VM)과 유사하게 봅니다. 더 구체적으로는 바이트코드를 해석하고 JIT 컴파일하는 eBPF 기반 가상 머신(rBPF, 현재 sBPF)을 뜻합니다. 이 관점에서 SVM은 ALU 연산이나 Solana 전용 시스템 호출 같은 명령어를 처리하는 샌드박스형 레지스터 기반 실행기입니다. 본질적으로 SVM은 Linux eBPF의 안전 모델에서 영감을 받았지만 블록체인 인프라에 맞게 설계됐습니다. 이는 검증인 코드의 SVM ISA(Instruction Set Architecture) 같은 표현과 일치하며, 여기서 SVM은 VM 레이어만을 뜻합니다.
넓은 관점은 SVM을 Solana 검증인의 전체 트랜잭션 실행 레이어로 정의합니다. 단순한 바이트코드 실행뿐 아니라 Banking Stage의 스케줄러, 컴퓨트 유닛 예산 책정, 흔히 AccountsDB라 부르는 계정 데이터베이스를 통한 상태 업데이트 같은 업스트림 구성 요소도 포함합니다. 원시 트랜잭션을 검증된 상태 변경으로 전환하는 “런타임”입니다.
공식 Solana 커뮤니케이션에서 “런타임”과 “SVM”을 하나의 고정된 정의 없이 혼용하기 때문에 모호성이 생깁니다. Anza는 이 논쟁에 꼭 필요한 명확성을 더했습니다. 논쟁 자체를 명시적으로 인정하면서 엔지니어링에 기반한 실용적이고 실행 중심적인 관점을 제시했습니다. SVM을 eBPF VM을 제공하는 Bank 중심 런타임으로 규정함으로써, 파이프라인 전체를 포함하는 훨씬 넓은 관점을 제공하며 이를 바탕으로 SVM을 적절히 정의할 수 있습니다.
이는 Anza의 공식 SVM 사양에 정식으로 명시돼 있습니다. 이 사양은 SVM을 “트랜잭션 실행을 담당하는 구성 요소”로 정의하며, 검증인, 사기 증명, 사이드카 등에 사용할 수 있는 독립형 라이브러리로 패키징합니다.
이 글에서는 Solana Virtual Machine을 다음과 같이 정의합니다.
Solana 검증인 내부에서 Bank 구성 요소가 구동하는 분리형 런타임 인터페이스이자 트랜잭션 처리 파이프라인입니다. 병렬 명령어 및 온체인 프로그램 실행을 조율하고, 안전한 바이트코드 해석, JIT 컴파일, 리소스 계측을 위해 맞춤형 eBPF 기반 가상 머신을 제공합니다.
SVM 한눈에 보기
Solana Virtual Machine(SVM)은 네트워크 전반의 온체인 프로그램과 상호작용하는 트랜잭션을 처리하는 실행 환경입니다. 코드와 상태가 만나는 런타임 레이어이며, 암호학적으로 서명된 트랜잭션을 검증된 상태 변경으로 바꾸는 실행 환경입니다.
SVM을 제대로 이해하려면 먼저 블록체인 맥락에서 가상 머신이 무엇인지 알아야 합니다.
가상 머신
가상 머신(VM)은 컴퓨터 시스템을 가상화하거나 에뮬레이션하여 물리적 하드웨어처럼 작동하는 격리된 실행 환경을 제공하는 소프트웨어입니다. 이 개념은 1960년대 IBM의 메인프레임 시스템 연구에서 시작됐으며, 여러 사용자가 하나의 물리적 머신에서 서로 다른 운영체제를 실행할 수 있게 했습니다. VM은 크게 시스템 VM과 프로세스 VM으로 나뉩니다. 전자는 실제 머신을 대체하고, 후자는 플랫폼에 독립적인 환경에서 프로그램을 실행하도록 설계됩니다. 이 글에서는 시스템 가상 머신에 집중하며, 이후에는 이를 “가상 머신” 또는 간단히 “VM”이라고 부릅니다.
가상 머신은 몇 가지 근본적인 문제를 해결합니다. 먼저 하드웨어 추상화 레이어를 제공합니다. 즉, VM용으로 작성된 프로그램은 다시 작성하지 않아도 해당 VM을 지원하는 모든 물리적 하드웨어에서 실행할 수 있습니다. Java의 “한 번 작성하면 어디서든 실행” 철학이 대표적인 예입니다. Java 바이트코드는 Java Virtual Machine(JVM)이 설치된 Windows, macOS, Linux 등에서 동일하게 실행됩니다.
VM은 격리와 보안도 보장합니다. 각 VM 인스턴스는 샌드박스에서 작동하므로 명시적으로 허용하지 않는 한 호스트 시스템의 리소스나 다른 VM에 접근할 수 없습니다. 따라서 프로그램이 충돌하거나 악성 코드를 포함하더라도 피해는 해당 VM 인스턴스에 국한됩니다. Google Cloud와 AWS 같은 클라우드 제공업체가 VM으로 고객 워크로드를 분리하는 이유도 이 격리 원칙 때문입니다.
VM은 예측 가능한 출력도 제공합니다. 기반 하드웨어와 관계없이 같은 입력이 항상 같은 출력을 내는 통제된 환경을 제공합니다. 이러한 예측 가능성은 디버깅, 테스트, 분산 시스템의 합의 달성에 매우 중요합니다.
VM은 성능도 매우 뛰어날 수 있습니다. 최신 VM은 Just-In-Time(JIT) 컴파일을 사용해 성능 오버헤드를 최소화합니다. JIT 컴파일은 런타임에 VM 바이트코드를 네이티브 머신 코드로 변환하여 이식성과 앞서 언급한 보안 보장을 유지하면서 네이티브에 가까운 성능을 냅니다.
블록체인의 가상 머신
블록체인은 VM 개념을 활용해 고유한 과제를 해결했습니다. 전 세계 수천 대의 독립 컴퓨터가 신뢰할 수 없는 코드를 실행하고 어떻게 동일한 결과에 도달할 수 있을까요? VM은 스마트 컨트랙트(Solana에서는 프로그램)를 실행하고 네트워크 상태(네트워크 전반의 모든 계정, 잔액, 기타 데이터의 현재 상태)를 관리하는 결정론적 런타임 환경 역할을 합니다.
트랜잭션이 블록체인에 제출되면 VM은 다음을 담당합니다.
- 스토리지에서 필요한 계정 데이터를 불러옵니다.
- 트랜잭션에 지정된 프로그램 바이트코드를 실행합니다.
- 무한 루프나 서비스 거부(DoS) 공격을 막기 위해 리소스 사용량을 계측합니다.
- 모든 상태 변경이 네트워크의 사전 정의된 합의 규칙을 따르는지 검증합니다.
- 업데이트된 상태를 영구 스토리지, 즉 원장에 다시 커밋합니다.
상태 전환 방식에 관한 구체적인 규칙은 VM의 명령어 집합 아키텍처와 런타임 제약 조건으로 정의됩니다.
SVM의 작동 방식
SVM은 트랜잭션을 안전하고 효율적으로 실행하기 위해 함께 작동하는 하위 시스템의 파이프라인입니다. Bank는 특정 슬롯의 실행을 조율하고, 계정 상태를 관리하며, 합의 규칙을 적용하고, Banking Stage와 영구 스토리지인 AccountsDB 사이를 조정합니다. 각 Bank는 특정 슬롯의 모든 계정 상태를 나타내며 활성(새 트랜잭션 수용), 동결(슬롯이 완료되어 새 트랜잭션을 수용하지 않음), 루트(정식 체인의 일부)라는 세 생명주기를 거칩니다.
Banking Stage는 검증인의 Transaction Processing Unit(TPU)에서 트랜잭션 실행이 일어나는 곳입니다. SigVerify 단계에서 검증된 트랜잭션을 받아 버퍼링하고, 계정 잠금 충돌 감지를 이용해 병렬 실행을 예약합니다. Banking Stage의 작업자 스레드는 충돌하지 않는 트랜잭션 배치를 처리합니다. Bank의 실행 메서드를 호출해 계정을 불러오고, 각 명령어에 sBPF VM 인스턴스를 제공하며, 프로그램 바이트코드를 실행하고 결과를 수집합니다. Banking Stage는 슬롯 경계에서 Bank가 동결될 때까지 충돌하지 않는 트랜잭션 배치를 계속 처리합니다. 배치는 엔트리와 다릅니다. 엔트리는 복제와 합의를 위해 원장에 기록되는 트랜잭션 단위입니다.
BPF Loaders는 배포, JIT 컴파일, 업그레이드, 실행 등 프로그램 생명주기를 관리합니다. 명령어가 특정 프로그램을 대상으로 하면 자체 메모리 영역과 컴퓨트 예산을 갖춘 sBPF VM이 제공되고, 실행이 프로그램 바이트코드로 전달됩니다.
sBPF VM은 프로그램 바이트코드가 실제로 실행되는 샌드박스형 실행 환경입니다. Linux eBPF에서 파생됐으며 11개의 범용 레지스터를 사용하는 레지스터 기반 아키텍처입니다. VM은 각각 명시적인 범위와 권한이 있는 5개의 메모리 영역으로 메모리를 격리합니다. 또한 컴퓨트 유닛 사용량을 계측해 폭주 실행을 막고, 암호화, 로깅, Cross-Program Invocation(CPI) 같은 권한 작업을 위한 시스템 호출을 디스패치합니다.
AccountsDB는 모든 계정 데이터가 저장되는 영구 상태 레이어입니다. 실행 전에 계정 상태를 불러오며, 자주 접근하는 계정은 반복적인 디스크 읽기를 피하도록 캐시를 활용합니다. 실행에 성공하면 업데이트가 AccountsDB에 다시 커밋됩니다. 실행에 실패하면 모든 상태 변경이 원자적으로 되돌아갑니다.
이 구성 요소들이 함께 분리 가능하고 재사용 가능한 실행 엔진인 SVM을 구성합니다.
SVM의 특별한 점: 사전 계정 선언
SVM을 정의하는 핵심 아키텍처 결정은 모든 트랜잭션이 실행 전에 읽고 쓸 계정을 명시적으로 선언해야 한다는 것입니다. 트랜잭션 형식 자체에 내장된 이 단순한 요구 사항은 Solana를 차별화하는 두 가지 혁신적 기능, 즉 병렬 실행과 로컬 수수료 시장을 가능하게 합니다.
병렬 실행(Sealevel)
각 트랜잭션을 하나씩 순차 처리하고 완료될 때까지 기다린 후 다음으로 넘어가는 Ethereum Virtual Machine(EVM)과 달리, SVM은 여러 CPU 코어에서 여러 트랜잭션을 동시에 실행해 수평 확장을 지원합니다. 모든 Solana 트랜잭션이 실행 전에 읽고 쓸 계정을 명시적으로 선언하기 때문에 이러한 병렬화가 가능합니다.
트랜잭션이 읽고 쓸 계정을 선언하면 런타임에서 계정 종속성을 분석해 충돌을 감지하고 충돌하지 않는 트랜잭션을 예약할 수 있습니다.
- 완전히 다른 계정을 사용하는 트랜잭션은 조정 오버헤드 없이 병렬로 실행할 수 있습니다.
- 같은 계정을 읽기만 하는 트랜잭션도 읽기 충돌이 없으므로 병렬로 실행할 수 있습니다.
- 같은 계정에 쓰려는 트랜잭션은 경쟁 상태를 방지하고 상태 일관성을 보장하기 위해 순차 실행됩니다.
로컬 수수료 시장
런타임은 실행 전에 각 트랜잭션이 접근할 계정을 정확히 알기 때문에 네트워크 전체에서 경쟁시키는 대신 특정 계정에 수수료를 국한할 수 있습니다. 이를 로컬 수수료 시장이라고 합니다.
Ethereum과 다른 EVM 체인에서는 모든 트랜잭션이 하나의 글로벌 수수료 시장에서 경쟁합니다. 친구에게 ETH를 보내거나, NFT를 민팅하거나, Uniswap에서 거래하는 모든 작업이 같은 블록 공간을 두고 입찰합니다. 한 영역의 수요가 급증하면 전혀 다른 작업을 하려는 사용자까지 모두 더 높은 수수료를 내야 합니다.
Solana에서는 같은 계정에 접근하는 트랜잭션끼리만 경쟁합니다. 두 계정 사이에서 SOL을 전송하는 사용자는 동시에 인기 NFT 민팅이 진행되더라도 걱정할 필요가 없습니다. 트랜잭션의 우선순위 수수료는 오직 계정 경합으로 결정됩니다. 이러한 지역화 덕분에 활동량이 많은 시기에도 Solana 트랜잭션 비용은 낮게 유지될 수 있습니다.
예를 들어 10월 10일, 암호화폐 시장에서는 사상 최대 규모의 청산이 발생했습니다. 기록적인 활동량 급증에도 Solana 트랜잭션 비용은 비교적 낮았습니다. 트랜잭션 수수료 중앙값은 $0.007, 평균은 잠시 $0.10에 도달했고 상위 1% 트랜잭션도 최고치가 $1.00을 약간 넘는 수준이었습니다. 같은 기간 Ethereum과 Arbitrum은 수수료 중앙값이 $100를 넘었고 Base는 $3를 초과했습니다.
패러다임 전환
| 항목 | EVM | SVM |
| 아키텍처 | 스택 기반 VM | 레지스터 기반 VM(eBPF 파생) |
| 실행 | 순차 | 병렬(충돌 감지) |
| 수수료 시장 | 글로벌 | 로컬(계정별 경합) |
| 계정 선언 | 사전 선언 불필요 | 사전 선언 필수 |
| ISA | 약 ~140개 opcode, 스택 연산 | 약 ~100개 opcode, RISC형 레지스터 |
| JIT 컴파일 | 선택 사항(클라이언트에 따라 다름) | 표준(네이티브 성능) |
| 상태 모델 | 컨트랙트 스토리지 수수료 | 단일 계정 데이터베이스 |
| 언어 | Solidity/Vyper → EVM 바이트코드 | Rust/C/C++ → LLVM → sBPF |
SVM은 블록체인 실행에 근본적으로 다른 접근 방식을 제시합니다. Bitcoin은 프로그래밍 가능한 화폐를 도입했습니다. Ethereum은 범용 스마트 컨트랙트와 임의의 온체인 실행을 도입했습니다. 하지만 둘 다 순차 실행과 글로벌 수수료 시장의 제약을 받습니다. 이러한 아키텍처 결정은 처리량과 비용 모두에 근본적인 한계를 만듭니다.
SVM은 전통적인 제약에서 벗어나 프로그래밍 가능성을 희생하거나 사용자를 지나치게 비싼 수수료 경매로 몰지 않으면서 높은 처리량을 지원하는 네트워크를 제공합니다. 계정 사전 선언 요구는 단순하지만 강력합니다. 여러 CPU 코어에서 병렬 실행을 지원하고 수수료를 계정 수준 시장으로 지역화합니다.
물론 이것이 Solana가 다른 블록체인보다 제공하는 유일한 최적화는 아닙니다. 대역폭을 늘리고 지연 시간을 줄인다는 Solana의 원칙과 Internet Capital Markets의 꿈을 실현하려는 집요한 집중은 고처리량 네트워크를 만들기 위한 다양한 성능 최적화, 설계 선택, 구현으로 이어졌습니다.
이 글의 나머지 부분에서는 Rust 소스 코드가 바이트코드로 컴파일되는 방식, 해당 바이트코드가 배포되고 검증되는 방식, 엄격한 결정론과 보안을 유지하면서 수천 개의 프로그램을 안전하게 병렬 실행하도록 런타임이 격리된 실행 환경을 제공하는 방식을 자세히 살펴봅니다.
Rust 소스에서 sBPF 바이트코드까지: 컴파일 파이프라인
Rust
Rust는 Solana 프로그램 개발의 공용어입니다. Anchor 같은 프레임워크는 개발자가 안전한 프로그램을 효율적으로 구축할 수 있도록 견고하고 명확한 방식을 제공합니다. solana_program은 모든 온체인 프로그램의 기본 라이브러리로 설계됐습니다. 최근에는 고도로 최적화된 무종속성 라이브러리 Pinocchio가 네이티브 Solana 프로그램을 구축하려는 개발자에게 선호되는 선택지가 됐습니다.
사용하는 프레임워크나 라이브러리와 관계없이 모든 프로그램에는 호출 시 런타임이 실행할 진입점이 있습니다. solana_program의 entrypoint 매크로는 프로그램 실행을 시작하는 데 필요한 표준 보일러플레이트를 생성합니다. 입력 역직렬화, 전역 할당자 설정, 패닉 핸들러가 여기에 포함됩니다. Pinocchio도 비슷하게 작동하는 진입점 매크로를 내보내지만 진입점을 힙 할당자와 패닉 핸들러 설정에서 분리해 개발자에게 더 많은 선택권을 줍니다.
프로그램의 기본 구조
프로그램은 코드를 실행할 수 있는 계정 유형입니다. 더 구체적으로는 고유 공개 키를 가지며 BPF Loader가 소유한 계정에 sBPF 바이트코드 블롭을 저장하는 실행 가능 계정입니다. 프로그램은 설계상 무상태입니다. 모든 영구 데이터는 별도 계정에 저장되며, 프로그램은 호출될 때 이 계정을 읽거나 쓸 수 있습니다.
SVM은 모든 프로그램이 세 가지 입력을 받는 진입점이라는 특정 골격을 갖추기를 요구합니다.
- 프로그램 ID: 프로그램 자체의 주소로, 소유권 같은 자기 참조 검사에 사용됩니다.
- 계정: 계정 메타데이터(공개 키, lamport 잔액, 데이터 버퍼, 소유자, 플래그)의 배열입니다. 프로그램이 읽고 써야 하는 “상태”입니다.
- 명령어 데이터: 트랜잭션에서 전달된 임의 데이터의 바이트 슬라이스입니다.
프로그램은 진입점을 통해 이러한 입력을 처리하고, 관련 쓰기 가능 계정을 변경하며, 로그나 이벤트를 내보낸 뒤 모든 작업을 성공적으로 수행했는지 나타내는 성공 상태를 반환해야 합니다. 이는 결국 process_instruction 함수로 요약됩니다.
solana_program 크레이트를 사용해 Rust로 작성한 간단한 프로그램은 다음과 같습니다.
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(())
}내부적으로 이 모든 것은 SVM의 Application Binary Interface(ABI)로 정의됩니다. 뒤에서 자세히 살펴보겠습니다.
Rust 컴파일러와 LLVM IR
다른 많은 프로그래밍 언어와 마찬가지로 Rust는 어셈블리 위에 구축된 2차 추상화입니다. 사람이 세세한 하드웨어 상호작용을 일일이 관리하지 않고도 안전하고 동시성 있으며 읽기 쉬운 코드를 작성하도록 설계됐습니다. 하지만 컴퓨터는 Rust를 비롯한 어떤 고수준 언어도 직접 이해하지 못합니다.
컴퓨터는 특정 아키텍처나 가상 머신에 맞춘 이진 명령어인 머신 코드를 이해합니다. 모든 프로그램은 결국 이진 코드로 변환되며, 이 번역도 궁극적으로 컴퓨터가 수행합니다. 컴파일은 고수준 추상화를 제거하고 효율성을 최적화해 실행 가능한 바이트코드를 출력하는 다단계 번역 과정입니다.
rustc는 Rust의 공식 컴파일러입니다. 대부분의 개발자는 보통 rustc와 직접 상호작용하지 않고 Rust 패키지 관리자인 Cargo를 통해 호출합니다. rustc는 실행 가능한 바이트코드를 생성하기 전에 Rust 소스 코드를 세 가지 주요 단계로 처리합니다. 각 단계는 추상화를 제거하고 안전성을 적용하며 다음 변환을 준비합니다.
- 파싱 및 확장
- MIR(Mid-level Intermediate Representation)
- LLVM IR(Low Level Virtual Machine Intermediate Representation)
파싱 및 확장
컴파일러는 .rs 확장자로 표시되는 Rust 파일을 일반 텍스트로 읽습니다. 렉싱이라는 과정에서 use, fn, None, impl, &[u8] 같은 특정 토큰을 찾습니다. 어휘 토큰화는 텍스트를 식별자, 연산자, 구분자, 리터럴, 키워드 같은 특정 범주에 속하는 의미 있는 어휘 토큰으로 변환하는 과정입니다.
rustc는 이 어휘 토큰을 Abstract Syntax Tree(AST)라는 데이터 구조로 변환합니다. 이 트리 구조는 Rust 소스 코드의 중첩된 계층 구조를 나타냅니다. 함수에는 블록이, 블록에는 표현식이, 표현식에는 연산자가 포함되는 식입니다. 여전히 고수준이지만 AST는 소스 코드와 기반 로직을 충실히 표현합니다.
AST가 만들어지면 컴파일러는 몇 가지 핵심 변환을 수행합니다.
- 매크로 확장: entrypoint!, println! 같은 매크로를 원시 Rust 코드로 확장해 모든 매크로를 일반 AST 노드로 변환합니다.
- Lowering: 코드 가독성을 높이는 고수준 축약 구문을 더 원시적인 형태로 다시 작성합니다. 이를 lowering이라고 합니다. 예를 들어 for 루프는 수동 반복이 포함된 loop로 변환됩니다. 결과는 High-Level Intermediate Representation(HIR)입니다.
- 대여 검사 및 안전성 분석: Rust는 HIR에 타입 검사, 트레이트 해석, 타입 추론을 수행합니다. 결과는 Typed High-Level Intermediate Representation(THIR)입니다.
이 단계에서 컴파일러는 unsafe 코드를 처리합니다. unsafe 코드는 개발자가 원시 포인터 역참조, 외부 함수 호출, unsafe 트레이트 구현처럼 Rust의 안전 보장을 우회하는 작업을 수행할 수 있게 합니다. 저수준 제어를 위한 의도적인 탈출구로, 특정 규칙을 완화하면서도 코드가 Rust 의미 체계에 따라 컴파일될 것을 요구합니다. 이는 제로카피 계정 역직렬화 같은 성능 핵심 작업에서 unsafe 코드를 제한적으로 사용할 수 있는 Solana 프로그램에 중요합니다. 예로 Pinocchio의 Account 래퍼 구조체가 있습니다.
unsafe 토큰은 AST를 구축하는 렉싱 이후 처음 식별되어 특수 노드로 표시됩니다. 이후 AST 확장 뒤의 타입 및 대여 검사에서 처리됩니다. 컴파일러는 unsafe 연산이 unsafe 컨텍스트 안에서만 수행되는지 확인하고, 그렇지 않으면 “unsafe 외부에서는 원시 포인터를 역참조할 수 없음” 같은 오류를 표시합니다. 다만 unsafe 코드가 메모리를 손상시키거나 잘못 관리하는지까지 검사하지는 않습니다.
이 단계가 끝나면 확장된 AST는 THIR이 되어 검증되고 lowering된 Rust 소스 코드 표현이 됩니다.
MIR
THIR은 Mid-Level Intermediate Representation(MIR)로 lowering됩니다. MIR은 소스 코드를 단순화된 제어 흐름 그래프(CFG)로 나타내는 Rust 중심 형식입니다. 패턴 매칭, 트레이트, 클로저 같은 모든 Rust 전용 문법과 복잡한 구조가 대입과 분기를 포함한 기본 블록으로 표현됩니다. 이 기본 블록은 점프, 더 구체적으로 Goto와 분기로 연결되어 프로그램 흐름을 쉽게 분석할 수 있습니다.
MIR이 반드시 필요한 것은 아닙니다. 컴파일러가 THIR을 LLVM IR로 직접 lowering할 수도 있습니다. 하지만 MIR은 LLVM의 범용 최적화 전에 컴파일러가 Rust 고유 규칙을 적용하고 최적화할 수 있는 Rust 인식 레이어를 제공합니다. 따라서 LLVM에는 너무 고수준이고 THIR에는 너무 저수준인 다음 검사와 변환에 적합합니다.
- 대여 검사: 초기 의미 검사는 THIR의 타입 분석 중 수행됩니다. 하지만 MIR의 단순화된 CFG를 사용하면 모든 소유권, 대여, 수명 규칙을 적용하는 완전하고 정밀한 대여 검사가 가능합니다.
- 이동 및 드롭 검사: 컴파일러는 모든 값이 Rust의 메모리 안전 보장에 따라 이동되고 드롭되는지 확인해 해제 후 사용 오류를 막습니다.
- 초기화 분석: 모든 변수가 사용 전에 초기화됐는지 확인합니다.
- 인라이닝 및 초기 최적화: 작은 함수를 인라인하고, 산술 표현식을 단순화하며, 도달할 수 없는 코드를 제거할 수 있습니다.
예를 들어 앞의 Rust 샘플 프로그램에서 **msg!(“Hello, Solana!”)**를 호출했습니다. 이는 solana-program 크레이트에 정의된 매크로입니다. 정적 문자열 같은 단일 표현식은 매크로 확장 단계에서 **sol_log($msg)**를 직접 호출하도록 확장되며, 여기서 $msg는 표현식입니다. sol_log syscall은 문자열 데이터의 포인터와 길이를 받아 형식 지정 오버헤드 없이 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);—레지스터를 로드하고 syscall ID를 호출하는 sBPF 명령어 시퀀스로 변환되는 syscall 호출입니다. 뒤에서 정확한 의미를 설명하겠지만, 핵심은 이러한 이동이 Rust의 소유권 의미 체계와 연결되며 컴파일 시 이를 적용한다는 점입니다.
- Return = Ok(());—종료자로 블록을 끝내고 SVM에 성공을 알립니다.
MIR은 Rust 의미 체계에 집중하므로 비효율성과 CU 비용이 큰 패턴을 찾는 데 적합합니다. 예를 들어 로그에 동적 문자열이 있으면 MIR에서 최적화할 수 있는 추가 할당이나 루프를 식별할 수 있습니다. 개발자는 cargo rustc -- -Z dump-mir=all 명령어로 MIR을 덤프할 수 있습니다.
MIR은 코드를 LLVM IR로 최종 lowering하기 전에 의미상 올바르고 최적화됐으며 Rust 고유 규칙이 모두 제거됐는지 확인합니다.
이는 일반적으로 코드 생성 단계라고 합니다. 반드시 LLVM을 뜻하지는 않지만, LLVM이 일반적이며 대부분 Rust 코드 생성을 떠올릴 때 생각하는 대상입니다. Rust 컴파일러는 각각 GIMPLE과 CLIF를 생성하는 GCC 및 Cranelift 백엔드도 제공합니다. 여기서는 Solana 맥락의 LLVM IR에 집중하지만, 일반적인 Rust에서 항상 그런 것은 아닙니다.
LLVM IR
원래 "Low Level Virtual Machine"을 뜻했던 LLVM은 모듈식 컴파일러 프레임워크입니다. 단일 컴파일러가 아니라 컴파일러, 최적화 도구, 코드 생성기를 구축하는 재사용 가능한 구성 요소의 도구 모음입니다. Rust, C, C++, Julia, Swift, Brainfuck, Zig 등 많은 언어가 x86 CPU부터 가상 ISA까지 다양한 아키텍처를 대상으로 할 수 있는 LLVM의 기능을 활용합니다.
rustc는 MIR을 LLVM IR(Low Level Virtual Machine Intermediate Representation)로 변환합니다. 이는 Rust 의미 체계와 Solana에 최종 배포되는 바이트코드 사이의 다리입니다. 명시적인 메모리 할당(alloca), 저장, 로드, 함수 호출이 있어 머신 코드에 훨씬 가깝습니다. 소유권, 수명, 트레이트 개념은 이미 확장되어 사라졌으며, 이전 단계에서 얻은 보장은 이후에도 유지됩니다.
이 단계에서는 다음을 비롯한 다양한 최적화가 적용됩니다.
- 상수 폴딩(컴파일 시 상수 평가)
- 인라이닝(호출을 함수 본문으로 대체)
- 데드 코드 제거(결과에 영향을 주지 않는 명령어 제거)
- 루프 펼치기 및 벡터화(더 빠르게 실행되도록 루프 재작성)
따라서 LLVM은 다음을 제공합니다.
- LLVM IR: 이식 가능한 어셈블리형 중간 형식입니다.
- 최적화 패스: LLVM은 LLVM IR을 만들 때 각 변수가 정확히 한 번만 할당되도록 하는 Static Single Assignment(SSA)를 사용합니다. 이를 통해 인라이닝과 데드 코드 제거 같은 최적화가 가능합니다.
- 코드 생성기: LLVM IR을 실제 머신 코드(예: x86_64, ARM, WebAssembly, eBPF)로 lowering하는 대상입니다.
Rust 프로그램은 보통 x86_64나 ARM 같은 하드웨어 대상을 위해 컴파일됩니다. 하지만 Solana 프로그램은 하드웨어에서 직접 실행되지 않고 Solana Virtual Machine 안에서 실행됩니다. 따라서 LLVM 백엔드는 LLVM IR을 BPF 바이트코드로 lowering하며, Solana에서는 이것이 sBPF 바이트코드가 됩니다. sBPF는 비결정적 기능을 제거하고 Solana 전용 syscall을 도입한 eBPF 포크입니다.
Rust가 Solana 프로그램 개발의 공용어이기는 하지만 LLVM의 BPF 백엔드를 대상으로 할 수 있는 모든 언어(예: C, Nim, Swift, Zig)를 사용할 수 있습니다.
eBPF
LLVM IR은 Solana 런타임의 기반을 이루는 레지스터 기반 ISA인 eBPF로 lowering됩니다. eBPF(Extended Berkeley Packet Filter)는 Steven McCanne과 Van Jacobson이 1992년 Lawrence Berkeley Laboratory에서 Berkeley Software Distribution(BSD) Unix 시스템용으로 개발한 Berkeley Packet Filter(BPF)에서 시작됐습니다. 본질적으로 BPF는 한정자를 활용해 데이터를 복사하지 않고 운영체제 수준에서 네트워크 패킷을 캡처하고 필터링할 수 있는 네트워크 탭이자 패킷 필터입니다.
이후 eBPF는 Linux 커널 안의 범용 샌드박스형 VM으로 확장됐습니다. 이는 JavaScript가 웹 개발에 열어준 가능성과 비슷합니다. 커널을 위한 안전한 스크립트 엔진입니다. eBPF를 사용하면 개발자는 성능 모니터링, 관측성, 보안, 네트워킹 같은 작업을 위해 제한된 명령어 집합을 가진 작고 검증된 프로그램을 Linux 커널 안에서 직접 실행할 수 있습니다.
개발자는 이를 통해 다음을 얻을 수 있습니다.
- 샌드박스 실행: eBPF 프로그램은 커널 내부의 제한된 가상 머신에서 실행되므로 커널 메모리를 충돌시키거나 손상할 수 없습니다.
- 안전 보장: eBPF 바이트코드는 로드 전에 정적으로 검증되어 잘못된 메모리 접근, 범위 밖 점프, 기타 권한 작업이 없음을 확인합니다. 런타임 오버헤드 없이 안전성을 제공합니다.
- 효율성: eBPF는 EVM 같은 스택 기반이 아니라 레지스터 기반이며, 전체 OS 오버헤드가 없는 가벼운 설계 덕분에 머신 코드로 JIT 컴파일해 네이티브에 가까운 속도를 낼 수 있습니다.
- 유연성: eBPF는 커널 기능에 대한 훅인 시스템 호출, 즉 syscall을 노출합니다. 명령어 집합을 다시 설계하지 않고도 syscall에 새 기능을 추가할 수 있습니다.
Solana에는 전체 검증인 집합에서 신뢰할 수 없는 프로그램을 실행할 결정론적이고 안전하며 성능이 뛰어난 VM이 필요했습니다. eBPF는 검증된 안전 모델, 수천 개의 경량 프로그램을 실행하도록 설계된 이식 가능하고 효율적인 ISA, 더 나은 성능을 위한 JIT 지원을 제공합니다. 따라서 Solana는 완전히 새로운 VM을 만드는 대신 eBPF를 포크해 sBPF를 만들었습니다.
sBPF
처음에 Solana Labs는 Quentin Monnet의 rBPF를 포크해 Solana 버전의 rBPF를 만들었습니다. 모든 검증인이 주어진 프로그램과 입력을 실행할 때 정확히 같은 결과를 내는 바이트코드 형식을 갖도록 보장하기 위해서였습니다.
Solana 합의에는 결정론적 실행과 제한된 리소스 사용이 필요했기 때문에 eBPF 포크가 필요하다고 여겨졌습니다. eBPF 자체도 결정론적이지만 Solana에는 추가 보장과 블록체인 전용 기능이 필요했습니다.
- 고정된 명령어 타이밍과 비용
- 사용자 공간 실행
- 결정론적 런타임
특히 커널이 아닌 사용자 공간에서 실행되도록 설계되어 커널 권한이나 수정이 필요하지 않습니다. 따라서 루트 접근 권한이나 맞춤형 커널 모듈 없이 다양한 OS 환경에 배포할 수 있습니다. 사용자 공간은 Solana에 실용적인 선택입니다. 이식성, 테스트, 배포 편의성이 향상됩니다. 사용자 공간에서 실행돼도 JIT는 네이티브에 가까운 성능을 냅니다. 또한 커널 접근 없이 테스트와 퍼징을 수행할 수 있습니다.
rBPF는 더 이상 사용되지 않습니다. Anza가 설립되면서 rBPF를 포크해 sBPF(Solana Berkeley Packet Filter)를 만들었습니다. Solana Labs가 소유한 rBPF GitHub 저장소는 2025년 1월 10일에 보관 처리됐습니다.
SVM ISA
SVM ISA(Solana Virtual Machine Instruction Set Architecture)는 Agave의 sBPF나 Firedancer의 재구현처럼 Solana 호환 VM이 프로그램을 실행해야 하는 방식을 정의하는 핵심 사양입니다. VM 자체가 아니라 여러 SVM 구현의 일관성과 프로토콜 준수를 보장하는 표준 또는 계약입니다. SVM ISA는 eBPF에 안전성과 결정론 제약을 적용하고, 커널 중심 기능을 제거하면서 블록체인 전용 기능을 추가합니다.
ISA는 레지스터, 명령어 인코딩, opcode, 클래스, 검증 규칙, 패닉 조건, Application Binary Interface(ABI)를 규정합니다. SVM ISA 변경은 SIMD를 통해 구현해야 합니다. 이를 통해 명령어 집합을 통제된 방식으로 발전시키고 검증인 전체의 결정론적 실행을 보장합니다.
레지스터
레지스터는 명령어가 실행되는 동안 숫자나 주소를 보관하는 VM 내부의 작은 저장 슬롯으로, 작업대의 변수나 이름표가 붙은 상자와 비슷합니다. SVM ISA는 11개의 범용 레지스터(R0-R10)와 숨겨진 프로그램 카운터가 있는 64비트 레지스터 아키텍처를 정의합니다. 레지스터는 정수와 주소에 대해 64비트 폭을 가지므로 큰 값이나 포인터를 효율적으로 처리할 수 있습니다. R0은 함수 반환 값을 보관하고, R1-R5는 매개변수처럼 첫 5개 함수 인수를 전달하며, R6-R9는 피호출자 보존 레지스터로 함수 호출 간 유지됩니다. R10은 현재 스택 프레임을 표시하는 읽기 전용 프레임 포인터입니다. 숨겨진 프로그램 카운터는 실행을 추적해 다음에 실행할 명령어를 나타냅니다.
명령어
명령어는 “두 숫자를 더한다” 또는 “이 코드 줄로 이동한다”처럼 VM이 수행할 수 있는 단일 연산입니다. x86 같은 CISC 아키텍처의 수천 개 opcode와 달리 약 100개의 opcode를 가진 RISC형 설계로, 검증이 빠르고 JIT 컴파일이 효율적입니다.
명령어는 다음 구조의 64비트 값으로 Little Endian 형식으로 인코딩됩니다.
- opcode: 8비트
- dst_reg: 4비트
- src_reg: 4비트
- offset: 16비트(부호 있음)
- immediate: 32비트(부호 있음)
opcode는 수행할 작업, dst_reg는 결과가 저장될 위치, src_reg는 입력이 오는 위치, offset은 확인할 메모리 오프셋을 나타냅니다. immediate는 명령어에 포함할 수 있는 추가 상수입니다.
lddw, 즉 load double word는 전체 64비트 즉시값을 지원하기 위해 두 개의 64비트 슬롯을 차지하는 유일한 와이드 명령어입니다.
명령어는 메모리 연산, 산술 또는 논리 연산, 조건부 및 무조건 분기, 함수 호출과 반환, 엔디언 변환 등의 클래스로 분류됩니다.
메모리 영역
ISA는 프로그램이 읽거나 쓸 수 있는 위치에 명시적 범위(즉, [addr, addr+len])가 지정된 5개의 메모리 영역을 정의합니다.
- 프로그램 코드: 컴파일된 명령어 자체입니다(읽기 + 실행).
- 스택: 함수를 위한 임시 작업 공간입니다(읽기 + 쓰기, 일반적으로 프레임당 4KB).
- 힙: 프로그램이 요청할 수 있는 동적 메모리입니다(읽기 + 쓰기).
- 입력 데이터: 트랜잭션과 함께 전달되는 읽기 전용 바이트입니다.
- 읽기 전용 데이터: 상수와 변경 불가능한 값입니다.
프로그램에는 사전 정의된 가상 메모리 맵이 있습니다. 컴파일 버전에 따라 프로그램 코드는 주소 0x000000000 또는 0x100000000에서 시작하고, 스택 프레임은 0x200000000, 힙은 0x300000000, 입력 데이터는 0x400000000에서 시작합니다.
검증기
검증기는 실행 전에 정적 분석을 수행합니다. 프로그램을 실행하지 않고 가능한 모든 코드 경로를 검사해 런타임이 아닌 로드 시점에 안전성을 보장합니다. 다음을 검사합니다.
- 알 수 없거나 지원되지 않는 명령어가 없습니다.
- 모든 점프 대상이 유효한 명령어 경계에 도달하며 역방향 점프가 처리됩니다.
- 도달할 수 없는 코드 경로가 없습니다.
- 함수 호출 깊이 제한이 적용됩니다.
- 0으로 나누거나 나머지를 구하는 연산은 정적으로 거부됩니다.
- 프로그램은 최대 크기 제한 이내여야 합니다.
검증기는 유용하지만 개발자가 예기치 않은 동작을 도입하는 것까지 막지는 못합니다. 예를 들어 개발자는 Solana 프로그램 안에서 해제 후 사용이나 버퍼 오버플로 오류를 일으킬 수 있습니다.
패닉 조건
패닉 조건은 SVM ISA가 정의한 런타임 오류 사례 목록입니다. 다음이 포함됩니다.
- 유효하지 않거나 지원되지 않는 명령어
- 0으로 나누기 또는 나머지 연산
- 범위를 벗어난 메모리 접근
- 서로 다른 메모리 영역에 대한 잘못된 메모리 접근(권한 위반)
- 스택 오버플로
- 호출 깊이 초과
- 허용된 최대 명령어 수 초과
- 프로그램이 오류 코드 반환
ABI
Application Binary Interface(ABI)는 Solana 프로그램과 SVM 사이의 형식 계약입니다. 앞의 프로그램 기본 구조 섹션에서는 Rust에서 작동하는 방식, 즉 세 입력을 받는 process_instruction을 설명했습니다. ABI는 이러한 입력과 출력이 메모리에 표현되는 방식을 지정해 모든 검증인이 프로그램을 결정론적으로 실행하도록 보장합니다.
상위 수준에서 ABI는 진입점 규약, 호출 규약과 레지스터, 메모리 레이아웃이라는 세 가지를 정의합니다.
모든 Solana 프로그램은 진입점 함수를 반드시 노출해야 합니다. 로더는 프로그램 ID, 계정 배열, 명령어 데이터 순서로 프로그램 입력을 VM 메모리 공간에 표준화해 직렬화합니다. VM은 프로그램 진입점에 이 영역들의 포인터를 전달합니다.
첫 5개 레지스터(R1-R5)는 진입점 인수용으로 예약되며, 반환 레지스터(R0)는 프로그램 종료 코드를 보관합니다. 종료 코드 0은 성공이고 0이 아닌 값은 특정 InstructionError에 매핑되는 실패입니다. 이를 통해 모든 프로그램이 일관된 방식으로 상태 코드를 반환합니다. 첫 5개를 넘는 매개변수는 스택으로 전달됩니다. R6-R9는 피호출자 보존 규약을 따르므로 함수가 이를 사용하면 값을 보존해야 합니다.
계정과 데이터는 VM의 선형 메모리에 바이트 슬라이스로 직렬화됩니다. 프로그램은 이를 AccountInfo, Pubkey 같은 고수준 Rust 타입으로 역직렬화해야 합니다. ABI는 엄격한 범위를 적용해 프로그램이 할당된 영역 밖의 메모리에 접근하지 못하도록 합니다.
이 규칙들이 함께 ABI를 고수준 개발자 경험과 저수준 ISA를 연결하는 “접착제”로 만듭니다. 간단한 Rust 함수 시그니처가 올바른 레지스터 사용, 메모리 레이아웃, 반환 코드로 컴파일되도록 하여 모든 검증인이 주어진 프로그램을 매번 정확히 같은 방식으로 해석하게 합니다.
Syscall
ISA는 의도적으로 최소화되어 기본 제공 계정이나 상태가 없습니다. 로깅, 해싱, 프로그램 간 호출 같은 고수준 기능도 직접 제공하지 않습니다. 대신 프로그램이 외부 세계와 상호작용할 수 있도록 VM에 내장된 특수 함수인 시스템 호출을 노출합니다. 이러한 호출은 흔히 syscall이라고 합니다.
syscall은 VM이 제공하는 API라고 볼 수 있습니다. 모든 프로그램이 특정 암호화 프리미티브나 계정 로직을 다시 구현하는 대신, syscall은 모든 검증인에서 결정론적으로 작동하는 안전하고 표준화된 연산을 노출합니다.
대표적인 syscall 범주는 다음과 같습니다.
- 로깅 및 디버깅(예: sol_log syscall은 프로그램 로그에 UTF-8 문자열을 쓰며 내부적으로 **msg!**가 사용합니다.)
- Cross-Program Invocation(CPI)(예: sol_invoke_signed는 프로그램이 계정과 명령어 데이터를 전달하며 다른 온체인 프로그램을 호출할 수 있게 하며 Solana의 조합 가능성에 핵심적입니다.)
- 암호화(예: sol_sha256, sol_keccak256, sol_ed25519_verify syscall은 개발자가 직접 구현하지 않아도 결정론적이고 빠른 암호화 프리미티브를 제공합니다.)
- 메모리 및 계정 유틸리티(계정 데이터 대여, 메모리 재할당, 프로그램 소유 힙 할당 작업을 위한 도우미를 노출하는 syscall입니다.)
- 컴퓨트 예산 및 계측(모든 syscall은 CU를 사용하며 런타임 계측 시스템이 이를 적용합니다.)
syscall은 고유한 해시 식별자가 있는 특수 CALL_IMM 명령어로 호출됩니다. 프로그램이 syscall을 호출하면 sBPF VM은 실행을 트랩하고 syscall 레지스트리에서 해시를 찾아 권한 있는 런타임 코드에서 실행되는 네이티브 구현으로 디스패치합니다. syscall은 샌드박스 밖에서 런타임 상태에 접근해 실행되며, 프로그램 내부의 일반 함수 호출과 완전히 다릅니다.
syscall은 일반 함수와 같은 ABI를 따릅니다. 첫 5개 인수는 R1부터 R5까지의 레지스터로 전달되고 반환 값은 R0에 저장됩니다. 각 syscall에는 고정된 컴퓨트 유닛 비용이 있어 결정론적 리소스 사용을 보장합니다. 예를 들어 secp256k1_recover syscall 호출은 모두 25,000 CU를 사용합니다.
syscall은 통제된 보안 경계를 형성합니다. 각 syscall은 권한 작업을 수행하기 전에 입력을 검증하고 관련 권한을 확인합니다. 예를 들어 Cross-Program Invocation(CPI) syscall은 전달되는 계정에 대해 호출자에게 적절한 권한이 있는지 검증합니다.
새 syscall은 ISA 자체를 수정하지 않고 기능 게이트를 통해 추가할 수 있습니다. 이를 통해 Solana는 기존 프로그램과의 하위 호환성을 유지하면서 새로운 암호화 프리미티브 지원을 비롯한 VM 기능을 확장할 수 있습니다.
프로그램 바이너리
컴파일이 끝나면 Rust 소스에서 LLVM IR, eBPF, sBPF, SVM ISA 준수까지 모든 단계가 하나의 출력인 프로그램 바이너리를 생성합니다. 이 바이너리가 실제로 Solana에 배포됩니다.
ELF
Solana 프로그램은 Unix 계열 시스템 전반에서 사용되는 표준 바이너리 형식인 Executable and Linkable Format(ELF) 파일로 컴파일됩니다. ELF 형식은 플랫폼 독립성을 유지하면서 VM이 특정 프로그램을 실행하는 데 필요한 모든 것을 패키징하는 컨테이너 역할을 합니다.
ELF 파일에는 일반적으로 다음 섹션이 포함됩니다.
- 바이트코드 섹션: .text 섹션에 컴파일된 sBPF 명령어가 들어 있습니다.
- 읽기 전용 데이터 섹션: .rodata 섹션에 상수, 정적 문자열, 변경 불가능한 값이 들어 있습니다.
- BSS 및 데이터 섹션: 각각 .bss와 .data 섹션에 전역 또는 정적 변경 가능 변수가 들어 있습니다. Solana는 변경 가능한 데이터를 허용하지 않습니다. 즉, ELF에는 .rodata가 있을 수 있지만 BSS와 데이터 섹션은 허용되지 않습니다.
- 심볼 및 재배치 테이블: 심볼용 .symtab 및 .strtab 섹션과 재배치 엔트리용 .rel.dyn 및 .rela.dyn 섹션에서 로드 중 함수 호출, syscall, 메모리 참조가 해석되는 방식을 정의합니다.
각 ELF 파일에는 아키텍처, 명령어 폭(64비트), 엔디언(Little Endian), 진입점 주소를 설명하는 헤더도 포함됩니다.
링킹과 재배치
컴파일러 출력을 하나의 실행 가능한 ELF 파일로 만드는 과정에는 마지막 구성 요소인 링커가 필요합니다. 링커는 여러 컴파일된 코드 유닛을 하나의 일관된 바이너리로 결합합니다. 또한 컴파일러가 해석하지 못한 모든 심볼 참조, 즉 자리표시자를 해석합니다. 예를 들어,
- 프로그램이 sol_log 같은 함수를 호출할 때 컴파일러는 해당 함수의 메모리 위치를 알지 못하므로 자리표시자를 사용합니다.
- 링커는 이 심볼 참조를 syscall의 고유 해시 식별자, 즉 결정론적 32비트 Murmur3 해시로 대체합니다.
- 마찬가지로 내부 함수 간 호출은 .text 섹션 안의 명령어 오프셋에 대한 상대 점프로 다시 작성됩니다.
심볼 참조를 구체적인 주소나 해시된 syscall ID로 다시 작성하는 과정을 재배치라고 합니다. 다만 재배치는 근본적인 요구 사항이라기보다 초기 도구가 구축된 방식에서 비롯된 산물에 가깝습니다. 실제로 배포 과정을 단순화하기 위해 향후 도구 체인 버전에서 재배치를 완전히 제거할 계획이 있습니다.
절대 메모리 주소나 시스템별 심볼을 포함하지 않고 모든 검증인에서 같은 ELF 바이너리가 동일하게 실행되도록 하려면 이 재배치 단계가 필요합니다.
또한 재배치가 이미 적용된 바이트코드는 메모리에 캐시되므로 이후 모든 실행은 재배치를 다시 처리하지 않고 업데이트된 바이트코드를 사용합니다.
링커가 완전히 재배치된 ELF 파일을 생성하면 프로그램을 배포할 준비가 끝납니다. 최종 바이너리는 다음 특성을 가집니다.
- 이식 가능: 모든 검증인이나 SVM 구현에서 동일하게 실행됩니다.
- 결정론적: 비결정적 syscall이나 OS 종속성이 없습니다.
- 자체 완결적: 실행에 필요한 모든 바이트코드와 메타데이터를 포함합니다.
바이트코드를 Solana에 업로드하는 방식
Solana 프로그램이 유효한 ELF 파일로 컴파일되고 링크되면 다음 단계는 검증인이 실행할 수 있도록 블록체인에 업로드하는 것입니다. 프로그램 배포라고 하는 이 과정에는 BPF Loader, 계정 모델, 바이트코드 검증, 상태 관리 등 여러 구성 요소가 함께 작동합니다.
BPF Loader 프로그램
BPF Loader는 ELF 파일을 검증하고 재배치하며 실행 가능 상태로 표시하는 네이티브 프로그램입니다. 본질적으로 배포된 프로그램의 생명주기를 관리합니다. 계정 초기화 명령어를 처리하고, 바이트코드를 쓰고, 프로그램을 배포하며, 업그레이드를 처리합니다.
Solana의 로더는 여러 버전을 거치며 이전 버전을 개선해 왔습니다.
- BPF Loader: 업그레이드할 수 없는 정적 프로그램용 최초 로더로, 더 이상 지원되지 않습니다.
- BPF Loader V2: 관리 명령어가 없는 단순화된 로더입니다.
- BPF Loader Upgradeable: 프로그램 업그레이드 기능을 도입한 현재 로더입니다.
- BPF Loader V4: 배포 기능을 개선하고 현재의 2계정 모델을 단일 계정 모델로 단순화한 최신 버전입니다.
배포 아키텍처: 계정 모델
현재 계정 모델
현재 로더는 프로그램 로직과 프로그램 데이터를 분리하기 위해 2계정 아키텍처를 사용합니다. 따라서 각 프로그램에는 Program 계정과 ProgramData 계정이 있습니다.
Program 계정은 약 36바이트 크기의 작은 계정으로, 메타데이터를 보관하고 실행 가능 상태로 표시됩니다. **UpgradeableLoaderState::Program { programdata_address }**를 통해 ProgramData 참조를 저장합니다.
ProgramData 계정은 더 큰 계정으로, UpgradeableLoaderState::ProgramData를 통해 실제 ELF 바이트코드와 배포 메타데이터(슬롯, 업그레이드 권한 주소 등)를 저장합니다.
두 계정을 분리하면 인플레이스 업그레이드가 가능합니다. Program 계정은 같은 주소에 유지하면서 ProgramData 계정의 바이트코드를 교체할 수 있습니다.
향후 계정 모델
Loader V4는 단일 계정 모델로 배포 과정을 간소화합니다. 프로그램 계정이 메타데이터와 바이트코드를 직접 저장하므로 별도의 ProgramData 계정이 필요 없습니다. 개발자는 임대료를 절감하기 위해 zstd 압축 이미지를 저장할 수도 있습니다.
Solana 프로그램 배포 방식
배포 과정에서는 컴파일된 ELF 바이너리를 업로드하고 BPF Loader가 이를 검증, 캐시, 실행 가능 상태로 표시합니다. 앞서 설명한 배포 아키텍처 차이로 업그레이드 가능 로더와 V4의 과정은 조금 다릅니다.
BPF Loader Upgradeable
현재 업그레이드 가능 로더를 이용한 배포 과정에서는 ELF 바이트코드를 스테이징할 버퍼 계정을 초기화합니다. 배포자는 BPF Loader Upgradeable에 InitializeBuffer 명령어를 보냅니다. 로더가 소유하는 새 계정을 만들고 계정 상태를 **UpgradeableLoaderState::Buffer { authority_address }**로 설정해 버퍼에 쓸 권한이 있는 주소를 기록합니다.
컴파일된 ELF 바이너리는 Write { offset, bytes } 명령어를 사용해 청크 단위로 버퍼에 업로드됩니다. 각 쓰기 명령어는 서명자가 버퍼 권한과 일치하는지, 버퍼가 아직 변경 가능한지(아직 배포되지 않았는지) 확인한 뒤 메타데이터 헤더 이후의 지정된 오프셋에 바이트를 씁니다. 트랜잭션 크기 제한 때문에 큰 프로그램은 전체 ELF 파일을 업로드하는 데 여러 Write 명령어가 필요합니다.
버퍼에 완전한 ELF가 들어가면 배포자는 DeployWithMaxDataLen { max_data_len } 명령어를 보냅니다. 계정 검증부터 상태 마무리까지 실제 배포를 조율하므로 전체 배포 과정에서 가장 복잡한 단계입니다.
로더는 먼저 배포 과정의 모든 계정을 검증하고 다음을 확인합니다.
- 프로그램 계정이 초기화되지 않았고 임대료 면제 상태입니다.
- 버퍼에 유효한 데이터가 있고 버퍼를 초기화한 권한자가 트랜잭션에 서명했습니다.
- max_data_len이 버퍼 데이터를 담기에 충분합니다.
- 총 크기가 MAX_PERMITTED_DATA_LENGTH, 즉 10MiB 또는 10,485,760바이트를 넘지 않습니다.
그런 다음 로더는 프로그램 ID와 로더 ID를 사용해 주소를 PDA로 파생하고 ProgramData 계정을 만듭니다. 배포 후에는 버퍼 계정이 필요 없으므로 버퍼의 lamport를 지불자에게 돌려줍니다.
또한 System Program에 대한 CPI로 ProgramData 계정을 만들고 메타데이터와 max_data_len 바이트를 위한 충분한 공간을 할당합니다. 이후 로더는 PDA의 bump seed를 사용해 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 바이트코드가 검증인의 CPU 아키텍처용 네이티브 머신 코드로 Just-In-Time(JIT) 컴파일됩니다. 안전성을 유지하면서 네이티브에 가까운 실행 속도를 제공합니다.
- 슬롯 메타데이터: 프로그램의 배포 시점과 노출 시점이 각각 deployment_slot과 effective_slot으로 기록됩니다. 이 지연은 프로그램이 배포된 슬롯에서 바로 사용되는 것을 방지합니다.
- 런타임 환경: 사용 가능한 syscall을 정의하는 syscall 레지스트리 참조와 프로그램 실행 시 사용할 실행 설정입니다.
캐시 엔트리는 program_cache_for_tx_batch에 저장되어 이후 트랜잭션에서 프로그램을 실행할 수 있게 합니다. 프로그램 검증과 캐싱이 성공하면 로더가 계정 상태를 업데이트해 배포를 마무리합니다. ProgramData 계정 상태에는 프로그램 배포 시점과 업그레이드 권한자가 기록됩니다. ELF 바이트코드도 버퍼에서 계정으로 복사됩니다. Program 계정 상태도 ProgramData 계정에 연결되도록 업데이트되고 실행 가능 상태로 표시됩니다. 마지막으로 버퍼의 데이터 길이를 메타데이터 크기로 설정해 바이트코드를 사실상 지우고 공간을 회수합니다.
이제 프로그램이 완전히 배포되어 트랜잭션에서 호출할 수 있습니다.
BPF Loader V4
BPF Loader V4는 별도의 ProgramData 계정 없이 프로그램 계정이 바이트코드를 직접 저장하도록 해 배포를 간소화합니다. zstd 압축 ELF 저장도 지원합니다. 로드 시 필요할 때 압축을 해제하면서 임대료를 크게 줄일 수 있습니다.
배포자는 **SetProgramLength { new_size }**를 호출해 프로그램 메타데이터와 바이트코드용 공간을 할당합니다. 새 프로그램이라면 계정을 LoaderV4State::Retracted 상태로 초기화하고 권한자를 기록하며 계정을 실행 가능 상태로 표시합니다. 다만 아직 호출할 수는 없습니다.
그런 다음 배포자는 Write { offset, bytes } 명령어로 ELF 바이너리를 프로그램 계정에 직접 씁니다. 프로그램이 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 실행을 조율하고 결과를 영구 스토리지에 커밋합니다. SVM은 바이트코드 로드부터 컴퓨트 예산 적용까지 프로그램 실행을 관리합니다. 이러한 관심사 분리 덕분에 SVM은 검증인 외부에서도 재사용할 수 있습니다.
트랜잭션
트랜잭션은 Solana뿐 아니라 모든 블록체인의 생명선입니다. 프로그램을 호출해 상태를 변경합니다.
트랜잭션은 어떤 작업을 어떤 계정에 수행할지, 필요한 권한이 있는지를 정의하는 명령어 묶음입니다.
명령어는 단일 프로그램 호출을 위한 지시입니다. 실행 로직의 최소 단위이자 Solana에서 가장 기본적인 작업 단위입니다.
프로그램은 명령어로 전달된 데이터를 해석해 지정된 계정에 작업을 수행합니다. 명령어에는 프로그램 ID(호출되는 프로그램), 읽고 쓸 계정 목록, 프로그램에 전달되는 입력이 포함됩니다.
트랜잭션은 사용자가 다른 계정으로 10 SOL을 전송하는 것 같은 목표를 정의할 때 시작됩니다. 이 의도는 System Program에 계정 A에서 계정 B로 10 SOL을 전송하라고 지시하는 명령어로 변환됩니다. 계정 A는 쓰기 가능한 서명자로, 계정 B는 쓰기 가능한 계정으로 트랜잭션에 전달됩니다. 이후 명령어는 수수료 지불자, 서명자, 최근 블록 해시도 지정하는 트랜잭션으로 패키징됩니다.
그런 다음 트랜잭션은 일반적으로 Helius 같은 RPC 제공업체로 전송됩니다. 트랜잭션을 받은 RPC 노드는 필수 서명이 모두 존재하고 유효한지, 트랜잭션이 이미 처리되지 않았는지, 제공된 최근 블록 해시가 여전히 유효한지, 트랜잭션이 최대 크기인 1232바이트를 넘지 않는지 검증합니다.
그런 다음 RPC는 트랜잭션을 현재 리더의 Transaction Processing Unit(TPU)으로 전달합니다.
Transaction Processing Unit(TPU)
Transaction Processing Unit(TPU)은 Solana 검증인 내부의 트랜잭션 수집 및 처리 파이프라인입니다. 트랜잭션이 Solana 원장에 커밋되기 전에 수신, 검증, 예약, 실행하는 여러 단계로 구성됩니다.
여기서는 Bank와 sBPF VM 프로비저닝으로 넘어가기 전에 Fetch Stage, SigVerify Stage, Banking Stage를 자세히 살펴봅니다.
TPU에 대한 자세한 설명은 Stake-Weighted Quality of Service: 알아야 할 모든 것을 참고하세요.
Fetch Stage
Fetch Stage는 TPU 파이프라인의 첫 단계입니다. 기반 전송 레이어로 UDP 소켓을 사용하는 QUIC 연결을 통해 들어오는 모든 트랜잭션을 받고, 이후 처리를 위해 배치로 묶습니다.
- 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를 사용해 각각 1,024개 패킷을 담는 1,000개의 패킷 배치를 미리 할당합니다. 매번 새 배치를 할당하지 않고 패킷 배치 메모리를 재사용해 메모리 할당 오버헤드를 줄입니다. 과거에는 CUDA 메모리 고정에 재활용기가 필요했지만 지금은 작동하지 않아 대부분 기술 부채로 볼 수 있습니다.
SigVerify Stage
SigVerify Stage는 이름 그대로 서명 검증을 담당하는 TPU 파이프라인의 두 번째 단계입니다. Ed25519 서명 검증은 트랜잭션 실행만큼은 아니지만 계산 비용이 높기 때문에 파이프라인 초기에 수행됩니다. 실행 전에 트랜잭션을 검증하면 검증인이 사기 트랜잭션을 거부하고 서비스 거부 공격을 방지하며 올바른 형식의 트랜잭션만 Banking Stage에 도달하게 할 수 있습니다.
SigVerify Stage는 Fetch Stage 채널에서 패킷을 계속 받아 검증 파이프라인으로 처리하는 단일 스레드로 작동합니다. 단일 스레드지만 내부 병렬성이 상당합니다.
기본적으로 병렬 반복자를 사용해 CPU에서 서명을 검증합니다. 검증 작업을 사용 가능한 모든 CPU 코어에 분산하여 각 코어가 서명의 일부를 독립적으로 검증하게 합니다.
**perf_libs::api()**를 통해 성능 라이브러리가 감지되면 서명 검증을 GPU로 오프로딩할 수도 있습니다. 패킷이 최소 64개이고 90%가 유효할 것으로 예상될 때만 GPU를 사용할 수 있습니다. GPU는 설정과 전송에 약 ~15-20ms의 오버헤드가 들지만 CPU는 서명 64개를 약 ~10-20ms에 검증하기 때문입니다. 실제 운영에서는 지연 시간 오버헤드 때문에 이 기능이 비현실적이며 현실적인 워크로드에서 CPU 검증보다 훨씬 느린 것으로 드러났습니다. 사용되지 않는 이 코드 경로는 제거할 예정입니다.
검증 과정은 비교적 간단합니다.
- Fetch Stage의 무제한 채널에서 패킷 배치를 받습니다.
- 패킷 양이 165,000개를 넘으면 부하 분산을 통해 트랜잭션을 무작위로 폐기합니다.
- 중복 트랜잭션을 제거합니다.
- 단일 IP가 검증 대역폭을 독점하지 못하도록 초과 패킷을 폐기합니다.
- 캐시 지역성을 높이고 메모리 낭비를 줄이기 위해 배치를 미리 축소하고 재구성합니다.
- 서명을 검증합니다.
유효한 모든 패킷은 Banking Stage로 진행합니다.
Banking Stage
Banking Stage에서 트랜잭션이 실행됩니다. 여기서 트랜잭션을 버퍼링하고 예약하며 병렬 작업자 스레드로 실행합니다.
중앙 스케줄러 패턴을 사용하며 투표 트랜잭션과 비투표 트랜잭션을 별도로 처리합니다.
- 하나의 작업자 스레드가 투표 및 gossip 투표 트랜잭션을 처리합니다.
- 4개의 작업자 스레드가 모든 비투표 트랜잭션을 처리합니다.
- 하나의 스레드가 작업자 스레드 사이의 작업 분배를 조정합니다.
들어오는 패킷은 역직렬화되어 최대 100,000개 트랜잭션까지 버퍼링됩니다. 스케줄러는 이 버퍼를 관리하면서 새 트랜잭션을 계속 받고, 한 번에 최대 10,000개 트랜잭션을 검사하는 큐 정리 작업 중 만료되거나 유효하지 않은 트랜잭션을 제거합니다.
스케줄러는 충돌 감지, 즉 같은 계정에 읽기 및 쓰기 잠금을 확보하려는 트랜잭션을 기준으로 실행 순서를 결정합니다. 기본적으로 두 가지 스케줄러 구현을 사용할 수 있습니다.
- PrioGraphScheduler: 계정 충돌을 감지하기 위해 우선순위 그래프를 만드는 스케줄러입니다.
- GreedyScheduler: 더 단순한 FIFO 방식으로 트랜잭션 순서를 정하는 기본 스케줄러입니다.
그런 다음 스케줄러는 충돌하지 않는 트랜잭션을 선택해 작업자 스레드로 보냅니다. 각 작업자는 배치를 받아 처리를 시작합니다.
Bank 오케스트레이션
Bank는 특정 슬롯의 모든 계정 상태를 나타냅니다. 계정 데이터를 관리하고 런타임 규칙을 적용하며, 트랜잭션 실행 시 Banking Stage 작업자와 sBPF VM 사이를 조율하는 중앙 데이터 구조입니다.
생명주기
각 Bank는 세 가지 상태를 거칩니다.
- 활성: 새로 생성되어 트랜잭션을 받을 수 있는 Bank입니다. Bank가 목표 틱 수에 도달하거나 슬롯의 모든 엔트리가 처리될 때까지 Banking Stage 작업자가 트랜잭션을 적용합니다.
- 동결: 틱 수에 도달하거나 모든 엔트리가 처리되면 Bank가 동결됩니다. 더 이상 트랜잭션을 적용할 수 없습니다. 이때 트랜잭션 수수료가 블록 리더에게 누적되고, sysvar 계정이 업데이트되며, 최종 Bank 해시가 계산됩니다.
- 루트: 동결된 Bank가 검증인으로부터 충분한 투표를 받으면 루트가 됩니다. 상태가 확정되어 체인 원장의 일부가 됩니다.
제네시스 Bank를 제외한 각 Bank는 부모 Bank를 가리키며 원장의 여러 포크를 나타내는 트리 구조를 형성합니다.
실행 흐름
Banking Stage 작업자는 현재 작업 Bank, 즉 현재 슬롯을 위해 구축 중인 활성 상태의 동결되지 않은 Bank에서 작동합니다. 작업자가 스케줄러에서 트랜잭션 배치를 받으면 Bank가 실행을 조율합니다.
- 계정 잠금: 작업자는 동시 변경을 막기 위해 prepare_sanitized_batch_with_results() 함수를 호출해 트랜잭션에서 참조하는 모든 계정을 잠급니다.
- 계정 로드: Bank가 잠긴 모든 계정의 데이터를 AccountsDB에서 가져옵니다.
- 수수료 차감: 실행 전에 Bank가 수수료 지불자 계정에서 트랜잭션 수수료를 차감합니다.
- 검증: Bank는 블록 해시가 최신인지 검증하고, nonce 계정 상태와 계정 소유권을 확인합니다.
- VM 전달: Bank가 load_and_execute_transactions()를 호출해 불러온 계정을 sBPF VM으로 전달합니다.
- 실행: sBPF VM이 각 명령어의 프로그램 바이트코드를 실행합니다.
- 결과: sBPF VM이 전체 실행 출력, 즉 LoadAndExecuteTransactionOutput을 반환합니다.
- 커밋: Bank가 **bank.commit_transactions()**를 통해 업데이트된 계정 상태를 AccountsDB에 다시 씁니다.
이제 실행은 SVM 자체, 즉 sBPF VM으로 진입합니다. Bank가 **load_and_execute_transactions()**를 호출하면 트랜잭션은 각 명령어가 프로그램 바이트코드 실행용으로 제공된 새롭고 격리된 sBPF VM 인스턴스에서 처리되는 다단계 파이프라인을 거칩니다.
트랜잭션의 명령어는 순차 실행됩니다. 각 명령어의 파이프라인은 다음과 같습니다.
- 프로그램 찾기: 명령어의 프로그램 ID로 프로그램 계정을 찾습니다.
- 캐시에서 로드: 해당 프로그램이 프로그램 캐시에 이미 JIT 컴파일돼 있는지 확인합니다.
- 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가 없으면 VM이 sBPF 바이트코드를 명령어 단위로 해석해야 하므로 상당한 오버헤드가 발생합니다.
각 바이트코드 명령어는 가져오기, 디코딩, 핸들러 코드로의 디스패치가 필요해 해석 오버헤드가 추가됩니다. 또한 해석된 코드는 파이프라이닝, 분기 예측, 비순차 실행 같은 CPU 수준 최적화를 활용할 수 없습니다. 따라서 모든 sBPF 명령어가 인터프리터의 함수 호출이 됩니다.
JIT 컴파일은 CPU에서 직접 실행되는 네이티브 머신 코드를 생성해 이러한 비용을 완전히 없앱니다. 그 결과 네이티브에 가까운 성능, 최적화된 범위 검사, 인라인 컴퓨트 계측, 하드웨어 수준 최적화, 깔끔한 레지스터 할당 매핑을 얻습니다.
JIT 컴파일러는 sBPF 바이트코드를 네이티브 머신 코드로 단일 패스 변환합니다. 각 sBPF 명령어에 대해 다음을 수행합니다.
- 명령어를 디코딩해 opcode, 레지스터, 오프셋, 즉시값을 추출합니다.
- 스택 프레임을 설정하고 피호출자 보존 레지스터를 저장합니다.
- 각 연산이 네이티브 명령어로 컴파일되도록 sBPF 연산을 동등한 CPU 명령어에 매핑합니다. 예를 들어 x86-64 매핑에서 rax는 R0, rbp는 R10에 매핑됩니다.
- 메모리 접근을 위한 범위 검사와 소프트웨어 주소 변환을 생성합니다. 게스트 주소를 호스트 주소로 변환하는 작업은 오버헤드 때문에 VM에서 가장 느린 작업 중 하나입니다.
- 스택 격리를 유지합니다. 즉, 게스트 스택은 호스트 스택이 아니라 힙에 할당된 버퍼에 위치합니다.
- CU 차감과 예산 검사를 삽입해 컴퓨트를 계측합니다.
- 레지스터를 복원하고 스택을 정리합니다.
명령어 변환
JIT 컴파일러는 산술, 메모리 접근, 저장 연산, 조건부 분기, syscall 디스패치 같은 다양한 명령어 유형을 각각 특정 방식으로 변환합니다.
산술 연산은 sBPF 레지스터가 검증인의 해당 하드웨어 레지스터에 잘 매핑되므로 오버헤드 없이 단일 네이티브 CPU 명령어에 직접 매핑됩니다.
메모리 접근 연산에는 범위를 벗어난 읽기와 쓰기를 막기 위한 범위 검사가 필요합니다. JIT 컴파일러는 각 메모리 접근의 하한과 상한을 유효한 영역 경계와 비교하는 검증 코드를 생성합니다.
이는 실질적으로 유효 주소를 계산하고 예상 범위 안에 있는지 검증한 뒤 실제 로드를 수행하는 3~6개의 네이티브 명령어로 구성됩니다. 범위 위반은 드물기 때문에 분기 예측을 통해 비교적 효율적으로 처리됩니다.
저장 연산에는 범위 검사와 쓰기 권한 검증이 포함됩니다. 컴파일된 코드는 메모리에 쓰기 전에 대상 주소가 범위 안에 있고 해당 메모리 영역에 쓰기 권한이 활성화돼 있는지 검증합니다.
조건부 분기는 네이티브 조건부 점프 명령어로 컴파일됩니다. JIT 컴파일러는 컴파일 중 모든 점프 대상을 해석하여 sBPF의 상대 명령어 오프셋을 네이티브 코드의 절대 주소로 변환합니다.
syscall 디스패치는 VM 상태, 즉 11개 레지스터를 모두 저장하고 네이티브 syscall 핸들러를 호출해야 합니다. 이후 반환 값과 함께 VM 상태를 복원합니다. 이러한 상태 관리 오버헤드 때문에 syscall의 고정 CU 비용은 일반 명령어보다 높습니다.
컴퓨트 유닛 계측
JIT 컴파일러는 생성된 코드에 컴퓨트 유닛 추적을 직접 인라인합니다. 각 sBPF 명령어는 남은 CU 수에서 비용을 차감할 때 예산이 소진되지 않았는지 확인하는 검사를 포함합니다.
이러한 인라이닝은 함수 호출 오버헤드를 없애며 분기 예측, 비순차 실행(CU 검사와 연산의 병렬 실행), 명령어 수준 병렬성을 통해 효율적으로 수행됩니다.
비용이 가변적인 syscall의 경우, 예를 들어 데이터 길이에 따라 비용이 늘어나는 sol_sha256 syscall은 VM으로 돌아가기 전에 네이티브 syscall 구현 안에서 비용을 계산합니다.
컴파일된 코드 캐싱
JIT 컴파일이 끝나면 네이티브 실행 파일이 다음을 포함하는 ProgramCacheEntry에 저장됩니다.
- JIT 컴파일된 코드
- 배포 슬롯과 적용 슬롯 메타데이터
- syscall 레지스트리 참조
- 런타임 환경 설정
캐시 엔트리는 트랜잭션 배치 캐시와 전역 프로그램 캐시에 배치됩니다. 전자는 현재 배치의 모든 명령어에서 사용할 수 있고, 후자는 이후 모든 트랜잭션에서 사용할 수 있습니다.
프로그램이 업그레이드되거나 새 바이트코드가 배포될 때, 프로그램 계정이 닫힐 때, 기능 게이트가 syscall 가용성을 변경할 때, 또는 검증인이 캐시를 비우기로 결정할 때 캐시 엔트리가 무효화될 수 있습니다.
적용 슬롯 지연
슬롯 n에 배포된 프로그램은 슬롯 n + 1까지 호출할 수 없습니다. 이 지연은 모든 검증인이 배포를 관찰하고, 프로그램 캐시가 네트워크 전반에서 동기화되며, 슬롯별 원자성이 적용되도록 합니다.
sBPF VM 프로비저닝
프로그램을 불러와 JIT 컴파일하거나 캐시에서 가져오면 각 명령어 실행을 위해 새 sBPF VM이 제공됩니다. 이 프로비저닝은 BPF Loader에서 이뤄지며 5개의 개별 메모리 영역 설정, 컴퓨트 예산 초기화, syscall 등록을 포함합니다.
메모리 영역
SVM ISA 섹션에서 설명했듯 VM은 5개의 개별 메모리 영역을 만듭니다. 이 영역들이 함께 Solana 프로그램이 실행되는 격리된 샌드박스를 구성합니다. 프로그램 메모리 영역은 일반적으로 주소 0x100000000에서 시작합니다. 검증인의 CPU 아키텍처에 따라 실행할 JIT 컴파일된 네이티브 코드, 즉 실제 머신 코드가 들어 있습니다. 디버깅을 위해 JIT를 비활성화하면 대신 해석되는 sBPF 바이트코드가 들어 있습니다. 이 섹션의 권한은 읽기와 실행만 허용합니다.
읽기 전용 데이터도 0x100000000에 포함됩니다. 프로그램 로드 중 ELF .rodata 섹션에서 추출한 상수와 정적 문자열이 들어 있습니다. 힙 할당 없이 컴파일 타임 상수에 효율적으로 접근하기 위한 메모리 영역입니다.
스택은 0x200000000에서 시작하며 로컬 변수, 함수 호출 프레임, 반환 주소를 포함합니다. 실행 중 임시 계산이 일어나는 곳입니다. 영역 위쪽에서 아래로 증가하며 레지스터 R10, 즉 프레임 포인터가 현재 프레임 경계를 표시합니다. 이 영역에는 읽기와 쓰기가 허용되며 크기는 호출 프레임당 4KB로 고정됩니다. 이 제한을 넘으면 스택 오버플로를 나타내는 StackAccessViolation 오류가 발생합니다. 작은 스택 크기는 개발자가 스택 기반 스토리지에 의존하지 않고 힙이나, 더 나은 방법으로 계정에 데이터를 저장하도록 유도합니다.
힙은 0x300000000에서 시작하며 스택이나 계정에 들어가지 않는 런타임 데이터 구조를 위한 동적 할당 메모리를 포함합니다. 크기는 기본 32KB에서 최대 256KB까지입니다. 이전에는 sol_alloc_free syscall로 힙을 확장할 수 있었습니다. 하지만 sol_alloc_free syscall은 더 이상 사용되지 않으며 새 프로그램 배포에서는 비활성화됩니다. 프로그램은 동적으로 확장하는 대신 배포 시 필요한 힙 크기를 지정해야 합니다.
참고: 힙 증가는 (heap_size / 32KB) * 8,000 CUs 공식에 따라 컴퓨트 유닛을 사용하며 기본 힙 비용은 8 CU입니다.
입력 데이터 메모리 영역은 주소 0x400000000에서 시작합니다. 프로그램 호출 시 받는 직렬화된 진입점 매개변수가 들어 있습니다. 실제 크기가 트랜잭션마다 달라지는 읽기 전용 메모리 영역입니다. 직렬화된 세 구성 요소는 호출되는 프로그램의 32바이트 공개 키, 계정 배열, 명령어 데이터입니다.
메모리 접근 적용
VM은 모든 메모리 로드와 저장 명령어에 범위 검사를 수행합니다. 각 메모리 접근 전에 주소가 영역의 유효 범위 안에 있는지, 해당 영역에서 접근 유형(읽기 또는 쓰기)이 허용되는지 검증합니다.
검사 하나라도 실패하면 AccessViolation 오류와 함께 실행이 즉시 중단됩니다. 전체 트랜잭션이 롤백되고 상태 변경은 커밋되지 않습니다.
JIT 컴파일러가 이러한 검사를 CPU에서 직접 실행할 수 있는 효율적인 네이티브 코드로 컴파일하므로 런타임 비용은 거의 없습니다. 위반이 드물기 때문에 최신 분기 예측이 검사를 효율적으로 처리합니다.
컴퓨트 예산 초기화
각 VM 인스턴스는 프로그램이 수행할 수 있는 총 작업량을 제한하는 컴퓨트 유닛 예산으로 초기화됩니다. 이 제한된 실행 모델은 프로그램이 무기한 실행되지 않게 하고 모든 검증인이 예측 가능한 시간 안에 트랜잭션을 실행하도록 합니다.
현재 예산 매개변수는 다음과 같습니다.
- 명령어당 기본 컴퓨트 유닛 제한: 200,000 CUs
- 트랜잭션당 최대 컴퓨트 유닛 제한: 1,400,000 CUs
- 내장 명령어 제한: 3,000 CUs
트랜잭션당 기본 CU는 **min(1_400_00, (200_000*non_reserve_instructions + 3_000*reserve_instructions))**입니다. 제공된 명령어를 기준으로 최대 컴퓨트 유닛 제한과 명령어 유형별 기본 비용 중 최솟값을 선택합니다.
컴퓨트 예산은 실행 내내 남은 유닛을 추적합니다. 각 sBPF 명령어가 실행될 때 남은 예산에서 CU 비용을 차감합니다. 프로그램 완료 전에 예산이 0이 되면 InstructionError::ComputationalBudgetExceeded와 함께 실행이 즉시 중단됩니다. 트랜잭션은 실패하고 상태 변경은 커밋되지 않지만, 검증인이 처리한 작업을 보상하기 위해 수수료 지불자에게 트랜잭션 수수료는 부과됩니다.
Syscall 레지스트리
VM 프로비저닝 과정에서는 사용 가능한 모든 syscall이 등록되도록 각 syscall의 고유 32비트 Murmur 해시 식별자와 네이티브 Rust 구현 사이의 매핑도 생성됩니다.
프로그램이 syscall 해시가 포함된 CALL_IMM 명령어를 실행하면 다음이 일어납니다.
- VM이 sBPF 명령어 스트림을 일시 중지합니다.
- syscall 디스패처가 해시를 사용해 레지스트리에서 구현을 찾습니다.
- syscall이 호출자에게 필요한 권한이 있는지 확인합니다(예: CPU 호출, 계정 소유권).
- syscall의 Rust 구현이 샌드박스 밖에서 전체 런타임 접근 권한으로 실행됩니다.
- syscall의 고정 비용이 남은 컴퓨트 예산에서 차감됩니다.
- 결과가 레지스터 R0에 저장되고 sBPF 실행이 재개됩니다.
프로그램 실행
sBPF VM이 준비되면 프로그램 진입점 함수에서 실행이 시작됩니다. JIT 컴파일된 프로그램의 경우 VM은 네이티브 머신 코드로 바로 점프하고 검증인의 CPU가 이를 네이티브로 실행하게 합니다. 앞서 설명했듯 JIT 컴파일된 코드에는 최대 성능을 위해 모두 인라인된 메모리 범위 검사, 컴퓨트 계측, 제어 흐름 검증 등 필요한 모든 계측이 포함됩니다.
레지스터 R1에는 세 가지 직렬화된 매개변수, 즉 호출되는 프로그램의 공개 키, 계정 배열, 명령어 데이터가 있는 입력 데이터 영역의 포인터가 들어 있습니다.
참고: 계정 데이터는 복사되지 않고 포인터를 통해 접근됩니다. 포인터를 사용하면 프로그램이 계정 데이터를 인플레이스 방식으로 읽고 수정할 수 있어 성능에 매우 중요합니다.
각 함수 호출은 새 4KB 스택 프레임을 할당하고 레지스터 R10을 새 프레임을 가리키도록 업데이트합니다. 컴퓨트는 앞서 설명한 컴퓨트 예산에 따라 계측됩니다.
Cross-Program Invocation(CPI)
실행 중 프로그램은 SVM 조합 가능성의 핵심인 Cross-Program Invocation(CPI)을 통해 다른 프로그램을 호출할 수 있습니다.
CPI는 1,000 CU와 전달되는 직렬화된 계정 데이터에 따른 추가 비용이 드는 sol_invoke_signed syscall을 통해 시작됩니다. 계정 데이터와 명령어 데이터 직렬화는 모두 CU당 250바이트의 비용이 듭니다.
프로그램이 CPI를 수행하면 자체 명령어 스택 프레임을 가진 새 실행 컨텍스트가 생성됩니다. 작성 시점의 최대 명령어 스택 깊이는 5이며, SIMD-0268을 활성화하면 9입니다. 즉, 프로그램이 다른 프로그램을 호출하고 그 프로그램이 다시 다른 프로그램을 호출하는 과정을 깊이 제한까지 반복할 수 있습니다. 중첩된 각 호출은 자체 쓰기 가능 계정 집합과 서명자 권한을 유지합니다. CPI에는 최대 16명의 서명자가 참여할 수 있으며 128개의 AccountInfo 구조체를 전달할 수 있습니다.
호출자는 대상 프로그램 ID, 계정, 명령어 데이터를 직렬화한 뒤 syscall을 호출합니다. 현재 프로그램 실행은 일시 중지되고, 앞서 설명한 것과 같은 프로비저닝 과정에 따라 피호출자 프로그램용 새 sBPF VM 인스턴스가 제공됩니다. 이후 피호출자 실행이 시작됩니다. 피호출자는 호출자의 남은 예산에서 가져온 자체 컴퓨트 예산으로 실행되므로 CPI 호출은 트랜잭션의 총 컴퓨트 예산을 공유합니다.
프로그램은 Program Derived Addresses(PDA)를 통해 자신이 소유한 계정을 대신해 서명할 수 있습니다. sol_invoke_signed로 호출할 때 호출자는 PDA 소유권을 증명하는 seed를 제공합니다. 피호출자에게 서명 권한을 부여하기 전에 PDA 파생을 검증합니다.
피호출자가 완료되면 제어권이 호출자에게 돌아갑니다. 피호출자가 변경한 계정은 호출자에게 보이므로 상태가 호출 체인을 따라 흐를 수 있습니다. CPI 체인의 프로그램 하나라도 실패하면 전체 트랜잭션이 중단되고 모든 상태 변경이 되돌아갑니다.
참고: sol_invoke는 seed 없이 sol_invoke_signed를 호출하는 도우미입니다.
실행 후 검증
직접 실행이든 CPI 체인의 일부든 프로그램 실행이 완료되면 상태 일관성과 보안 불변 조건을 보장하기 위한 여러 실행 후 검사가 이뤄집니다.
예를 들어 런타임은 쓰기 가능으로 표시된 모든 계정이 실제로 프로그램 소유이거나 적절히 서명됐는지 확인합니다. 계정이 명시적으로 쓰기 가능으로 표시되고 소유자가 권한을 부여하지 않는 한 프로그램은 자신이 소유하지 않은 계정을 수정할 수 없습니다. 이를 통해 무단 상태 변경을 막습니다.
런타임은 System Program 명령어로 lamport를 명시적으로 전송한 경우를 제외하고 트랜잭션 내 모든 계정의 lamport 총합이 같은지도 확인합니다. 이 보존 검사는 프로그램이 lamport를 생성하거나 소멸하지 못하게 하여 SOL 총공급량이 일정하게 유지되도록 합니다.
런타임은 실행 가능 플래그가 있는 모든 계정, 즉 프로그램이 수정되지 않았는지 검증합니다. 프로그램 데이터는 일반 실행 중 변경할 수 없으며 BPF Loader의 업그레이드 권한 메커니즘을 통해서만 업그레이드할 수 있습니다.
실행 결과
실행 후 검증이 끝나면 실행 결과가 Bank의 트랜잭션 프로세서로 반환됩니다. 결과에는 성공 또는 오류 상태(각각 0 또는 0이 아닌 결과), 사용된 컴퓨트 유닛 수, 계정 상태 변경 사항이 포함됩니다.
Bank는 성공한 실행의 모든 계정 변경을 원자적으로 커밋합니다. 업데이트된 계정 데이터, lamport 잔액, 메타데이터가 AccountsDB에 기록되고 이후 트랜잭션에서 볼 수 있게 됩니다. 사용된 컴퓨트 유닛은 트랜잭션 수수료 계산과 네트워크 지표를 위해 기록됩니다.
실패한 트랜잭션은 상태 변경을 커밋하지 않습니다. Solana에는 부분 되돌리기가 없으며 트랜잭션 전체가 롤백됩니다. 하지만 수행된 계산 작업에 대해 검증인에게 보상하기 위해 트랜잭션 수수료는 여전히 수수료 지불자 계정에서 차감됩니다. 오류 코드와 사용된 컴퓨트 유닛은 디버깅과 분석을 위해 트랜잭션 메타데이터에 기록됩니다.
실행 결과는 Banking Stage 스케줄러로 돌아가며, 스케줄러는 내부 지표를 업데이트하고 다음 트랜잭션으로 넘어갑니다. 성공한 트랜잭션과 실패한 트랜잭션 모두 Proof of History 스트림에 기록되어 현재 구축 중인 블록에 포함됩니다. 실패한 트랜잭션도 재생 공격을 막고 완전한 트랜잭션 기록을 유지하기 위해 포함됩니다.
슬롯이 완료되어 최대 틱 수에 도달하면 Bank는 동결 상태로 전환됩니다. 동결은 새 트랜잭션 커밋을 막고 Bank 해시를 계산하는 단방향 작업입니다. 동결이 확정을 뜻하는 것은 아닙니다. 슬롯이 폐기되는 포크에 있을 수도 있습니다.
검증인이 BankForks::set_root()를 호출해 Bank를 정식 체인의 일부로 지정하면 루트가 됩니다. 루트 설정은 루트 Bank의 계정 상태를 AccountsDB에 평탄화하고 모든 부모 상태를 병합해 검증인 관점에서 영구적으로 만드는 squash 작업을 트리거합니다. 루트가 아닌 포크는 정리되고 폐기됩니다. Solana에는 여러 커밋 수준이 있으므로 루트 Bank도 클러스터 관점에서는 아직 확정된 것이 아닙니다.
앞으로의 방향
Solana Virtual Machine은 블록체인 실행에 근본적으로 다른 접근 방식을 제시합니다. 병렬 처리, 로컬 수수료 시장, 성능이 뛰어나고 결정론적인 eBPF 기반 런타임 덕분에 누구나 사용할 수 있는 확장 가능한 블록체인을 구현합니다.
SVM을 이해하려면 Rust 소스 코드 컴파일부터 LLVM, sBPF, 최종적으로 격리된 VM 인스턴스 제공까지 전체 실행 파이프라인을 살펴봐야 합니다.
SVM을 정의하는 단일 “사양”은 없습니다. SVM은 Bank, 스케줄러, BPF Loaders, sBPF VM, SVM ISA의 상호작용으로 구현됩니다.
SVM은 계속 발전하고 있으며 미래가 밝습니다.
Solana 도구 체인은 수년간 개발자 온보딩을 어렵게 만든 맞춤형 LLVM 인프라를 제거하기 위해 전면 개편되고 있습니다.
현재 방식에서는 개발자가 플랫폼별 스크립트를 통해 맞춤형 도구 체인을 설치해야 합니다. 해결책은 Rust eBPF 라이브러리인 Aya와 같은 도구 체인을 도입하는 것입니다. 개발자는 간단한 명령어 두 개로 eBPF 바이트코드에 직접 컴파일할 수 있게 됩니다.
rustup toolchain install nightly
cargo build --target=bpfel-unknown-none
스크립트도, 맞춤형 LLVM 포크도 없습니다. 표준 Rust 도구만으로 업스트림 bpfel-unknown-none 대상을 사용해 eBPF 바이트코드로 직접 컴파일하며, 오랜 Linux 커널 개발과 LLVM 인프라 개선을 활용합니다.
SVM ISA도 SIMD-0377에 따라 업데이트될 예정입니다. 이 제안은 Solana의 eBPF 구현, 즉 sBPF를 최신 eBPF 표준에 맞춥니다. JMP32 명령어 변형, 부호 있는 나눗셈과 나머지 연산, 간접 점프, 동적 스택 프레임 도입이 포함됩니다. 이러한 변경은 프로그램 컴퓨트 비용을 줄이고, 업스트림 LLVM 인프라와의 호환성을 높이며, 더 효율적인 코드 생성을 지원합니다.
SVM은 근본적으로 컴퓨트 예산으로 계측되는 시스템입니다. SIMD-0370은 블록 수준 컴퓨트 상한과 경우에 따라 트랜잭션 상한을 제거해 이 방식을 바꿀 예정입니다. 이러한 컴퓨트 상한을 없애면 블록 생산자는 인위적인 제한이 아니라 하드웨어 역량에 따라 처리량을 극대화할 수 있습니다. Alpenglow의 타임아웃 메커니즘과 결합하면 프로토콜 수준 제약 대신 시장의 힘이 최적의 블록 크기를 결정하게 됩니다. 물론 이는 상당히 먼 미래의 이야기입니다. Anza는 상한을 제거하기 전에 먼저 CU 제한을 100m+로 높이려 합니다.
이 모든 변화의 기반에는 안전성, 결정론, 탈중앙화를 희생하지 않으면서 블록체인이 달성할 수 있는 한계를 넓히려는 빌더 정신이 있습니다.
SVM은 단순한 바이트코드 인터프리터가 아닙니다. 블록체인의 역량을 혁신하는 완전한 실행 파이프라인입니다. 처리량과 낮은 지연 시간을 우선시한 아키텍처 결정의 집약체입니다. Solana가 성숙함에 따라 SVM도 계속 발전해 고성능의 자본 효율적인 애플리케이션을 지원할 것입니다.
Internet Capital Markets의 꿈을 실현하려면 글로벌 금융 시스템의 처리량, 지연 시간, 비용 요구 사항을 감당할 수 있는 인프라가 필요합니다. Solana Virtual Machine은 그 비전을 실현하기 위한 핵심 단계입니다.
추가 자료
관련 아티클
Helius 구독하기
최신 Solana 개발 소식을 확인하고 새 게시물 알림을 받아보세요


