在 Ethereum 中,交易的价格计算方式为 。这告诉我们将交易包含在区块链中需要花费多少 Ether。在发送交易之前,需要指定一个 gasLimit 并提前支付。如果交易耗尽了 gas,它将会回滚(revert)。
与 EVM 链不同,Solana 的操作码(opcodes)/指令消耗的是“计算单元”(compute units,这可以说是一个更好的名称)而不是 gas,并且每笔交易的软上限为 200,000 个计算单元。如果交易花费超过 200,000 个计算单元,它将会回滚。
在 Ethereum 中,计算的 gas 成本与存储的 gas 成本被一视同仁。但在 Solana 中,存储的处理方式有所不同,因此 Solana 中持久化数据的定价是一个不同的话题。
然而,从运行操作码的定价角度来看,Ethereum 和 Solana 的表现是相似的。
两条链都执行编译后的字节码,并对执行的每条指令收取费用。Ethereum 使用 EVM 字节码,而 Solana 运行的是 berkeley packet filter 的修改版本,称为 Solana packet filter。
Ethereum 根据操作码执行所需的时间对其收取不同的费用,范围从 1 个 gas 到数千个 gas 不等。而在 Solana 中,每个操作码消耗 1 个计算单元。
当计算单元不足时该怎么办
当执行无法在限制范围内完成的繁重计算操作时,传统的策略是“保存你的工作”,并分多次交易来完成。
“保存你的工作”这部分需要放入永久存储中,这是我们尚未涵盖的内容。这类似于如果你试图在 Ethereum 中迭代一个巨大的循环;你会需要一个存储变量来记录中断处的索引,以及一个存储变量来保存到该点为止已完成的计算结果。
计算单元优化
众所周知,Solana 使用计算单元来防止停机问题(halting problem),并避免代码无限期运行。它规定每笔交易的计算单元限制为 200,000 CU(支付额外费用最高可提升至 140 万 CU),如果超过了这个(选定的)限制,程序将终止,所有更改的状态将会回滚,且费用不会退还给调用者。这可以防止攻击者试图在节点上运行永不结束或计算密集型的程序,从而拖慢节点或导致整条链停止运行。
然而,与 EVM 链不同的是,交易中使用的计算资源并不影响该交易所需支付的费用。无论你耗尽了全部额度还是仅使用了极少部分,系统都会收取相同的费用。例如,一笔消耗 400 个计算单元的交易与一笔消耗 200,000 个计算单元的交易成本相同。
除了计算单元,Solana 交易的签名者数量也会影响计算单元的成本。根据 Solana docs(文档):
“所以目前,交易费用完全由一笔交易中需要验证的签名数量决定。交易中签名数量的唯一限制是交易本身的最大尺寸。在(最大 1232 字节的)交易中,每个签名(64 字节)必须引用一个唯一的公钥(32 字节),因此单笔交易最多可包含 12 个签名(不确定为什么你会这么做)。”
我们可以通过这个小示例来看看实际效果。从一个如下所示的空 Solana 程序开始:
use anchor_lang::prelude::*;
declare_id!("6CCLqLGeyExCFegJDjRDirWQRRSbM5XNq3yKvmaWS2ZC");
#[program]
pub mod compute_unit {
use super::*;
pub fn initialize(_ctx: Context<Initialize>) -> Result<()> {
Ok(())
}
}
#[derive(Accounts)]
pub struct Initialize {}
更新测试文件:
import * as anchor from "@coral-xyz/anchor";
import { Program } from "@coral-xyz/anchor";
import { ComputeUnit } from "../target/types/compute_unit";
describe("compute_unit", () => {
// Configure the client to use the local cluster.
anchor.setProvider(anchor.AnchorProvider.env());
const program = anchor.workspace.ComputeUnit as Program<ComputeUnit>;
const defaultKeyPair = new anchor.web3.PublicKey(
// replace this with your default provider keypair, you can get it by running `solana address` in your terminal
"EXJupeVMqDbHk7xY4XP4TVXq22L3ZJxJ9Gm68hJccpLp"
);
it("Is initialized!", async () => {
// log the keypair's initial balance
let bal_before = await program.provider.connection.getBalance(
defaultKeyPair
);
console.log("before:", bal_before);
// call the initialize function of our program
const tx = await program.methods.initialize().rpc();
// log the keypair's balance after
let bal_after = await program.provider.connection.getBalance(
defaultKeyPair
);
console.log("after:", bal_after);
// log the difference
console.log(
"diff:",
BigInt(bal_before.toString()) - BigInt(bal_after.toString())
);
});
});
注:在 JavaScript 中,数字末尾的 “n” 表示它是一个 BigInt。
运行:solana logs(如果尚未运行的话)。
当我们运行 anchor test --skip-local-validator 时,我们会得到以下输出,作为测试日志和 Solana 验证节点日志:
# test logs
compute_unit
before: 15538436120
after: 15538431120
diff: 5000n
# solana logs
Status: Ok
Log Messages:
Program 6CCLqLGeyExCFegJDjRDirWQRRSbM5XNq3yKvmaWS2ZC invoke [1]
Program log: Instruction: Initialize
Program 6CCLqLGeyExCFegJDjRDirWQRRSbM5XNq3yKvmaWS2ZC consumed 320 of 200000 compute units
Program 6CCLqLGeyExCFegJDjRDirWQRRSbM5XNq3yKvmaWS2ZC success
余额差值为 5000 lamports 是因为我们在发送这笔交易时只需要/使用了 1 个签名(即我们默认 provider 地址的签名)。这与我们上文得出的结论一致,即 1 * 5000 = 5000。同时请注意,这消耗了 320 个计算单元,但这并不会影响我们的交易费用。
现在,让我们给程序增加一些复杂性,看看会发生什么:
pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
let mut a = Vec::new();
a.push(1);
a.push(2);
a.push(3);
a.push(4);
a.push(5);
Ok(())
}
这肯定会对我们的交易费用产生一些影响,对吧?
当我们运行 anchor test --skip-local-validator 时,我们会得到以下输出,作为测试日志和 Solana 验证节点日志:
# test logs
compute_unit
before: 15538436120
after: 15538431120
diff: 5000n
# solana logs
Status: Ok
Log Messages:
Program 6CCLqLGeyExCFegJDjRDirWQRRSbM5XNq3yKvmaWS2ZC invoke [1]
Program log: Instruction: Initialize
Program 6CCLqLGeyExCFegJDjRDirWQRRSbM5XNq3yKvmaWS2ZC consumed 593 of 200000 compute units
Program 6CCLqLGeyExCFegJDjRDirWQRRSbM5XNq3yKvmaWS2ZC success
我们可以看到这消耗了更多的计算单元,几乎是我们第一个示例的两倍。但这并没有影响我们的交易费用。这符合预期,并表明计算单元确实不影响用户支付的交易费用。
无论消耗了多少计算单元,该交易始终收取 5000 lamports 或 0.000005 SOL 的费用。
回到计算单元的话题。既然计算单元不影响交易支付的费用,为什么我们还要去优化它呢?
- 首先,这只是目前的状况,未来 Solana 可能会决定提高这一限制,并且需要激励节点不要对这些复杂交易产生歧视。这意味着在计算交易费用时将会把消耗的计算单元考虑在内。
- 其次,在网络活动频繁、竞争区块空间激烈的情况下,较小的交易更有可能被包含在区块中。
- 第三,它会使你的程序与其他程序更具可组合性。如果其他程序调用了你的程序,该交易不会获得额外的计算限制。如果你的交易占用了过多的计算资源,只留给原程序很少的额度,其他程序可能就不愿意与你的程序进行集成。
较小的整数可以节省计算单元
使用的值类型越大,消耗的计算单元就越多。最好在适用的地方使用较小的类型。让我们看看以下代码示例和注释:
pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
// this costs 600 CU (type defaults to Vec<i32>)
let mut a = Vec::new();
a.push(1);
a.push(1);
a.push(1);
a.push(1);
a.push(1);
a.push(1);
// this costs 618 CU
let mut a: Vec<u64> = Vec::new();
a.push(1);
a.push(1);
a.push(1);
a.push(1);
a.push(1);
a.push(1);
// this costs 600 CU (same as the first one but the type was explicitly denoted)
let mut a: Vec<i32> = Vec::new();
a.push(1);
a.push(1);
a.push(1);
a.push(1);
a.push(1);
a.push(1);
// this costs 618 CU (takes the same space as u64)
let mut a: Vec<i64> = Vec::new();
a.push(1);
a.push(1);
a.push(1);
a.push(1);
a.push(1);
a.push(1);
// this costs 459 CU
let mut a: Vec<u8> = Vec::new();
a.push(1);
a.push(1);
a.push(1);
a.push(1);
a.push(1);
a.push(1);
Ok(())
}
请注意,随着整数类型的缩小,计算单元成本也随之降低。这符合预期,因为无论表示的值是多少,较大的类型在内存中占用的空间都比较小的类型大。
在链上使用 find_program_address 生成程序派生账户(PDA)可能会消耗更多的计算单元,因为该方法会迭代调用 create_program_address,直到找到一个不在 ed25519 曲线上的 PDA。为了降低计算成本,请尽可能在链下使用 find_program_address() 并将结果的 bump seed 传递给程序。关于这一点的更多内容将在后面的章节中讨论,因为它超出了本节的范围。
这并不是一份详尽的列表,只是提供几点思路,让你了解是什么导致一个程序的计算密集度高于另一个程序。
什么是 eBPF?
Solana 的字节码在很大程度上衍生自 BPF。“eBPF”简单来说就是“扩展的 BPF”(extended BPF)。本节将在 Linux 的背景下解释 BPF。
正如你所预料的那样,Solana VM 并不能理解 Rust 或 C 语言。用这些语言编写的程序会被编译成 eBPF(扩展的 Berkeley Packet Filter)。
简而言之,当内核发出 eBPF 字节码订阅的事件时,eBPF 允许在内核(沙盒环境)中执行任意 eBPF 字节码,例如:
- 网络:打开/关闭 socket
- 磁盘:写入/读取
- 进程的创建
- 线程的创建
- CPU 指令调用
- 支持最高 64 位(这就是为什么 Solana 的最大 uint 类型是 u64)
你可以将其看作是针对内核的 JavaScript。JavaScript 在浏览器中发生事件时执行操作,而 eBPF 在内核中发出事件(例如执行 syscall)时执行非常类似的操作。
这允许我们为各种用例构建程序,例如(基于上面列出的事件):
- 网络:用于分析路由等
- 安全:根据特定规则过滤流量,并报告任何异常/被拦截的流量
- 追踪与分析:收集从用户空间程序到内核指令的详细执行流程
- 可观测性:报告并分析内核活动
程序只在我们需时(即内核中发出事件时)才执行。例如,假设你想在一个文件被写入时获取该文件的名称和写入的数据,我们可以监听/注册/订阅 vfs_write() 这一 syscall 事件。现在,每当该文件被写入时,我们就可以随时获取这些数据。
Solana 字节码格式(SBF)
Solana Bytecode Format 是 eBPF 的一个变体,其中包含一些更改,最引人注目的是去除了字节码验证器。eBPF 中存在字节码验证器是为了确保所有可能的执行路径都是有限且可以安全执行的。
Solana 通过使用计算单元限制来处理这个问题。拥有一个计算计量器通过设定上限来限制消耗的计算资源,从而将安全检查转移到了运行时,并允许任意内存访问、间接跳转、循环和其他有趣的行为。
在以后的教程中,我们将深入研究一个简单的程序及其字节码,对其进行调整,了解不同的计算单元成本,并准确学习 Solana 字节码的工作原理及其分析方法。
在 RareSkills 了解更多
本教程是我们 Solana course 的一部分。
首发于 2024 年 2 月 23 日