文章详细介绍了Solana区块链中的计算单元(Compute Units)概念,与以太坊的gas机制进行了对比,并探讨了计算单元的优化策略及其对交易费用的影响。
在以太坊中,交易的价格计算为 $\text{gasUsed} \times \text{gasPrice}$。这告诉我们,将交易包含在区块链中将花费多少 Ether。在发送交易之前,会指定并预付 gasLimit。如果交易用尽了 gas,它将被回滚。
与 EVM 链不同,Solana 的操作码/指令消耗的是“计算单元”(可以说是一个更好的名称),而不是 gas,每笔交易的软上限为 200,000 个计算单元。如果交易消耗的计算单元超过 200,000,它将被回滚。
在以太坊中,计算所需的 gas 成本与存储相关的 gas 成本是相同的。在 Solana 中,存储的处理方式不同,因此 Solana 中持久化数据的定价是一个不同的讨论话题。
然而,从运行操作码的定价角度来看,以太坊和 Solana 的行为是相似的。
两条链都执行编译后的字节码,并对每条执行的指令收取费用。以太坊使用 EVM 字节码,但 Solana 运行的是 Berkeley Packet Filter 的修改版本,称为 Solana Packet Filter。
以太坊根据执行时间的长短对不同操作码收取不同的费用,范围从 1 gas 到数千 gas。在 Solana 中,每个操作码消耗 1 个计算单元。
在执行无法在限制内完成的重计算操作时,传统策略是“保存你的工作”并在多个交易中完成。
“保存你的工作”部分需要放入永久存储中,这是我们尚未涉及的内容。这类似于在以太坊中尝试遍历一个巨大的循环;你会有一个存储变量来保存你离开时的索引,以及一个存储变量来保存到该点为止的计算结果。
正如我们所知,Solana 使用计算单元来防止停机问题,并防止运行永远执行的代码。每笔交易的计算单元限制为 200,000 CU(可以以额外成本增加到 1.4m CU),如果超过(选择的限制),程序将终止,所有更改的状态将回滚,费用不会退还给调用者。这可以防止攻击者意图在节点上运行永不结束或计算密集型的程序以减慢或停止链。
然而,与 EVM 链不同,交易中使用的计算资源不会影响为该交易支付的费用。无论你使用了整个限制还是只使用了很少的一部分,你都将被收取相同的费用。例如,400 计算单元的交易与 200,000 计算单元的交易费用相同。
除了计算单元之外,Solana 交易的签名者数量也会影响计算单元成本。根据 Solana 文档:
“目前,交易费用仅由交易中需要验证的签名数量决定。交易中签名数量的唯一限制是交易本身的最大大小。交易中的每个签名(64 字节)(最大 1232 字节)必须引用一个唯一的公钥(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", () => {
// 配置客户端以使用本地集群。
anchor.setProvider(anchor.AnchorProvider.env());
const program = anchor.workspace.ComputeUnit as Program<ComputeUnit>;
const defaultKeyPair = new anchor.web3.PublicKey(
// 将此替换为你的默认提供者密钥对,你可以通过在终端中运行 `solana address` 来获取它
"EXJupeVMqDbHk7xY4XP4TVXq22L3ZJxJ9Gm68hJccpLp"
);
it("Is initialized!", async () => {
// 记录密钥对的初始余额
let bal_before = await program.provider.connection.getBalance(
defaultKeyPair
);
console.log("before:", bal_before);
// 调用我们程序的初始化函数
const tx = await program.methods.initialize().rpc();
// 记录密钥对的余额之后
let bal_after = await program.provider.connection.getBalance(
defaultKeyPair
);
console.log("after:", bal_after);
// 记录差异
console.log(
"diff:",
BigInt(bal_before.toString()) - BigInt(bal_after.toString())
);
});
});
注意: 在 JavaScript 中,数字末尾的“n”表示它是一个 BigInt
。
运行:solana logs
,如果你还没有运行它的话。
当我们运行 anchor test --skip-local-validator
时,我们得到以下输出作为测试日志和 Solana 验证器日志:
## 测试日志
compute_unit
before: 15538436120
after: 15538431120
diff: 5000n
## solana 日志
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 个签名(我们的默认提供者地址的签名)。这与我们上面建立的结论一致,即 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 验证器日志:
## 测试日志
compute_unit
before: 15538436120
after: 15538431120
diff: 5000n
## solana 日志
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。
回到计算单元。那么,既然计算单元不影响交易费用,我们为什么要优化计算单元呢?
使用的值类型越大,消耗的计算单元越多。在适用的情况下,最好使用较小的类型。让我们以代码示例和注释为例:
pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
// 这消耗 600 CU(类型默认为 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);
// 这消耗 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);
// 这消耗 600 CU(与第一个相同,但类型已明确表示)
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);
// 这消耗 618 CU(与 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);
// 这消耗 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 传递给程序。更多内容将在后面的部分讨论,因为这超出了本节的范围。
这不是一个详尽的列表,但几点可以让你了解什么会使一个程序比另一个程序更计算密集。
Solana 的字节码主要源自 BPF。“eBPF” 简单来说就是“扩展的 BPF”。本节解释了 Linux 上下文中的 BPF。
正如你所料,Solana VM 不理解 Rust 或 C。用这些语言编写的程序被编译为 eBPF(扩展的 Berkeley Packet Filter)。
简而言之,eBPF 允许在内核中(在沙盒环境中)执行任意的 eBPF 字节码,当内核发出 eBPF 字节码订阅的事件时,例如:
你可以将其视为内核的 JavaScript。JavaScript 在浏览器中发出事件时执行操作,eBPF 在内核中发出事件时执行类似的操作,例如当执行系统调用时。
这使我们能够为各种用例构建程序,例如(基于上面列出的事件):
程序仅在我们需要时执行(即当内核中发出事件时)。例如,假设你想在文件被写入时获取文件名和写入的数据,我们监听/注册/订阅 vfs_write()
系统调用事件。现在,每当该文件被写入时,我们就可以使用这些数据。
Solana 字节码格式是 eBPF 的一个变体,具有某些变化,最突出的是移除了字节码验证器。字节码验证器存在于 eBPF 中,以确保所有可能的执行路径都是有限且安全执行的。
Solana 使用计算单元限制来处理这个问题。拥有一个限制计算资源消耗的计算单元计量器将安全检查移到运行时,并允许任意内存访问、间接跳转、循环和其他有趣的行为。
在后面的教程中,我们将深入探讨一个简单的程序及其字节码,调整它,了解不同的计算单元成本,并确切了解 Solana 字节码的工作原理以及如何分析它。
本教程是我们 Solana 课程 的一部分。
最初发布于 2024 年 2 月 23 日
- 原文链接: rareskills.io/post/solan...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!