原文:https://xuejie.space/2020_03_03_introduction_to_ckb_script_programming_performant_wasm/
在先前的文章中,我已经向您展示过你可以在 CKB 上运行 WASM 程序,但需要注意的是,WASM 程序的性能可能并不那么出色。我也提到了,有一个潜在的解决方案可以解决这个问题。就在今天!我们有了一个新项目,可以用来生成高效的 WASM 程序。让我们赶紧来看看它具体是如何操作的。
背景
(如果你迫不及待了,可以跳过本节,直接跳至示例部分)
在先前的文章中,我们是先将 WebAssembly 程序转换成了 C 代码,然后再将其从 C 代码编译成了 RISC-V。但这有很多缺点:
-
使用 C 语言做中间层时,我们并非总是可以保留所有对代码进行的优化。
-
由于 C 语言的限制,无法完全自定义内存分配以实现最佳性能。
-
C 语言层有时会变得非常奇怪,而且并非总是易于调试的。
这里,我们尝试一种不同的解决方案:WAVM 是一种高性能的(基准测试表明,这是迄今为止最高性能的 WASM 实现)转换层,可以通过 LLVM 将 WASM 代码直接编译成机器码。鉴于 RISC-V 官方支持 LLVM 9+,因此我们可以将 WAVM 编译成 RISC-V 代码,然后它就可以直接将 WASM 程序直接转换成 RISC-V 可以运行的机器码。
还有一个问题:WAVM 需要一个 runtime 的部分,使得本机环境和周围环境互补。当前,它被包含在 WAVM 中并依赖于 LLVM,这使得整个二进制文件相当庞大。有一天,我突然想到构建 runtime 所需的所有信息已经都包含在了原始的 WASM 文件中,因此我们可以单独构建一个项目,用于处理原始的 WASM 文件并通过 C 语言输出一个最小的 runtime 部分,然后将其与生成的机器码连接在一起,这样我们就可以得到一个由 WASM 代码编译得到并且可以独立运行的 RISC-V 的本地程序。
示例
在这里,我们将使用与先前文章中完全相同的示例:用 AssemblyScript 写一个斐波那契代码和一个纯 Rust 写的 secp256k1。我们将对生成的代码大小以及在 CKB VM 中运行所消耗的 cycles 数量进行比较。 为了完整起见,每个示例中还将包括用纯 C 编写的原生版本。 正如我们将在下面看到的那样,即使我们当前的 WASM 解决方案仍然和纯 C 版本有一点差距,但已经非常接近,并且已经可以满足很多用例的需求了。
首先让我们复制所需的代码库并进行一些必要的准备:
$ export TOP=$(pwd)
$ git clone https://github.com/AssemblyScript/assemblyscript.git
$ cd assemblyscript
$ git checkout b433bc425633c3df6a4a30c735c91c78526a9eb7
$ npm install
$ cd $TOP
$ git clone --recursive https://github.com/WebAssembly/wabt
$ cd wabt
$ git checkout bec78eafbc203d81b9a6d1ce81f5a80dd7bf692a
$ mkdir build
$ cd build
$ cmake ..
$ cmake --build .
$ cd $TOP
$ git clone https://github.com/xxuejie/WAVM
$ cd WAVM
$ git checkout cb35225feeb4ba1b5a9c73cbbdb07f4cace9b359
$ mkdir build
$ cd build
# Make sure you are using LLVM 9+, you might need to tweak this path depending
# on your environment
$ cmake .. -DLLVM_DIR=/usr/lib/llvm-9/lib/cmake/llvm
$ cmake --build .
$ cd $TOP
$ git clone https://github.com/xxuejie/wavm-aot-generator
$ cd wavm-aot-generator
$ git checkout 8c818747eb19494fc9c5e0289810aa7ad484a22e
$ cargo build --release
$ cd $TOP
$ git clone https://github.com/xxuejie/ckb-standalone-debugger
$ cd ckb-standalone-debugger
$ git checkout 15e8813b8cb886e95e2c81bbee9f26d47a831850
$ cd bins
$ cargo build --release
$ cd $TOP
$ git clone https://github.com/xxuejie/ckb-binary-patcher
$ cd ckb-binary-patcher
$ git checkout 930f0b468a8f426ebb759d9da735ebaa1e2f98ba
$ cd ckb-binary-patcher
$ cargo build --release
$ cd $TOP
$ git clone https://github.com/nervosnetwork/ckb-c-stdlib
$ cd ckb-c-stdlib
$ git checkout 693c58163fe37d6abd326c537447260a846375f0
AssemblyScript 示例
这是我们先前用 AssemblyScript 写的斐波那契的例子,让我们先将其编译成 WebAssembly 程序:
$ cd $TOP
$ cat << EOF > fib.ts
export function fib(n: i32): i32 {
var a = 0, b = 1;
for (let i = 0; i < n; i++) {
let t = a + b; a = b; b = t;
}
return b;
}
EOF
$ assemblyscript/bin/asc fib.ts -b fib.wasm -O3
我们将这个 WASM 编译成两个版本:
$ cd $TOP
$ wabt/build/wasm2c fib.wasm -o fib.c
$ WAVM/build/bin/wavm compile --target-triple riscv64 fib.wasm fib_precompiled.wasm
$ wavm-aot-generator/target/release/wavm-aot-generator fib_precompiled.wasm fib_precompiled
你可能会注意到,我们不是生成 RISC-V 的机器码,而是使用 WAVM 来生成一个 precomplied object
的格式化文件。这本质上就是原本的 WASM 文件,其中嵌入了自定义部分中的机器码,这样我们就可以方便地将单个文件提供给生成器。
让我们附加 2 个不同的包装文件到 2 个 WASM 中去,同时也提供一个 C 的原生实现:
$ cd $TOP
$ cat << EOF > fib_wabt_main.c
#include <stdio.h>
#include <stdlib.h>
#include "ckb_syscalls.h"
#include "fib.h"
void (*Z_envZ_abortZ_viiii)(u32, u32, u32, u32);
void env_abort(u32 a, u32 b, u32 c, u32 d) {
abort();
}
int main() {
uint32_t value;
uint64_t len = 4;
int ret = ckb_load_witness((void*) &value, &len, 0, 0,
CKB_SOURCE_GROUP_INPUT);
if (ret != CKB_SUCCESS) {
return ret;
}
if (len < 4) {
return -1;
}
init();
u8 result = Z_fibZ_ii(value);
return result;
}
EOF
$ cat << EOF > fib_wavm_main.c
#include "fib_precompiled_glue.h"
#include "abi/ckb_vm_wasi_abi.h"
#include "ckb_syscalls.h"
void* wavm_env_abort(void* dummy, int32_t code, int32_t a, int32_t b, int32_t c)
{
ckb_exit(code);
return dummy;
}
int main() {
uint32_t value;
uint64_t len = 4;
int ret = ckb_load_witness((void*) &value, &len, 0, 0,
CKB_SOURCE_GROUP_INPUT);
if (ret != CKB_SUCCESS) {
return ret;
}
if (len < 4) {
return -1;
}
wavm_ret_int32_t wavm_ret = wavm_exported_function_fib(NULL, value);
return wavm_ret.value;
}
EOF
$ cat << EOF > fib_native_main.c
#include "ckb_syscalls.h"
int32_t fib(int32_t n) {
int32_t a = 0;
int32_t b = 1;
for (int32_t i = 0; i < n; i++) {
int32_t t = a + b;
a = b;
b = t;
}
return b;
}
int main() {
uint32_t value;
uint64_t len = 4;
int ret = ckb_load_witness((void*) &value, &len, 0, 0,
CKB_SOURCE_GROUP_INPUT);
if (ret != CKB_SUCCESS) {
return ret;
}
if (len < 4) {
return -1;
}
return fib(value);
}
EOF
你可能会注意到,我们改变了先前文章中使用的 wabt 包装器,所以这里的三个版本都会从 witness data 加载 input 到斐波那契函数,这样我们就可以设置相同的比较标准。
让我们先来编译这 3 个文件:
$ cd $TOP
$ sudo docker run --rm -it -v `pwd`:/code nervos/ckb-riscv-gnu-toolchain:bionic-20191209 bash
root@7f24745ca702:/# cd /code
root@7f24745ca702:/code# riscv64-unknown-elf-gcc -O3 -I ckb-c-stdlib -I wavm-aot-generator -I wabt/wasm2c fib_wabt_main.c fib.c wabt/wasm2c/wasm-rt-impl.c -o fib_wabt
root@7f24745ca702:/code# riscv64-unknown-elf-gcc -O3 -I ckb-c-stdlib -I wavm-aot-generator -I wabt/wasm2c fib_wavm_main.c wavm-aot-generator/abi/riscv64_runtime.S fib_precompiled.o -o fib_wavm -Wl,-T wavm-aot-generator/abi/riscv64.lds
root@7f24745ca702:/code# riscv64-unknown-elf-gcc -O3 -I ckb-c-stdlib -I wavm-aot-generator -I wabt/wasm2c fib_native_main.c -o fib_native
root@7f24745ca702:/code# exit
exit
$ ckb-binary-patcher/target/release/ckb-binary-patcher -i fib_wavm -o fib_wavm_patched
由于一个 VM bug,已经提供了一个补丁程序来解决可能生成 bug 的 RISC-V 代码。尽管我们只观察到 LLVM 会受到这个 bug 的影响(GCC 有一些优化,会生成不同的代码),我们仍然建议你,如果你需要在 CKB 上跑任何脚本都运行一下这个补丁程序。
我们还准备了一个运行程序来运行脚本:
$ cd $TOP
$ cat << EOF > runner.rb
#!/usr/bin/env ruby
require "rbnacl"
def bin_to_hex(bin)
"0x#{bin.unpack1('H*')}"
end
def blake2b(data)
RbNaCl::Hash::Blake2b.digest(data,
personal: "ckb-default-hash",
digest_size: 32)
end
if ARGV.length != 2
STDERR.puts "Usage: runner.rb <script file> <witness args>"
exit 1
end
script_binary = File.read(ARGV[0])
script_hash = blake2b(script_binary)
tx = DATA.read.sub("@FIB_CODE", bin_to_hex(script_binary))
.sub("@FIB_HASH", bin_to_hex(script_hash))
.sub("@FIB_ARG", ARGV[1])
File.write("tx.json", tx)
commandline = "ckb-standalone-debugger/bins/target/release/ckb-debugger --tx-file tx.json --script-group-type type -i 0 -e input"
STDERR.puts "Executing: #{commandline}"
exec(commandline)
__END__
{
"mock_info": {
"inputs": [
{
"input": {
"previous_output": {
"tx_hash": "0xa98c57135830e1b91345948df6c4b8870828199a786b26f09f7dec4bc27a73da",
"index": "0x0"
},
"since": "0x0"
},
"output": {
"capacity": "0x4b9f96b00",
"lock": {
"args": "0x",
"code_hash": "0x0000000000000000000000000000000000000000000000000000000000000000",
"hash_type": "data"
},
"type": {
"args": "0x",
"code_hash": "@FIB_HASH",
"hash_type": "data"
}
},
"data": "0x"
}
],
"cell_deps": [
{
"cell_dep": {
"out_point": {
"tx_hash": "0xfcd1b3ddcca92b1e49783769e9bf606112b3f8cf36b96cac05bf44edcf5377e6",
"index": "0x0"
},
"dep_type": "code"
},
"output": {
"capacity": "0x702198d000",
"lock": {
"args": "0x",
"code_hash": "0x0000000000000000000000000000000000000000000000000000000000000000",
"hash_type": "data"
},
"type": null
},
"data": "@FIB_CODE"
}
],
"header_deps": []
},
"tx": {
"version": "0x0",
"cell_deps": [
{
"out_point": {
"tx_hash": "0xfcd1b3ddcca92b1e49783769e9bf606112b3f8cf36b96cac05bf44edcf5377e6",
"index": "0x0"
},
"dep_type": "code"
}
],
"header_deps": [
],
"inputs": [
{
"previous_output": {
"tx_hash": "0xa98c57135830e1b91345948df6c4b8870828199a786b26f09f7dec4bc27a73da",
"index": "0x0"
},
"since": "0x0"
}
],
"outputs": [
{
"capacity": "0x0",
"lock": {
"args": "0x",
"code_hash": "0x0000000000000000000000000000000000000000000000000000000000000000",
"hash_type": "data"
},
"type": null
}
],
"witnesses": [
"@FIB_ARG"
],
"outputs_data": [
"0x"
]
}
}
EOF
$ chmod +x runner.rb
现在我们可以看看每个版本的二进制文件的大小,以及运行 3 个版本的斐波那契数列的计算:
$ ls -lh fib_wabt fib_wavm_patched fib_native
-rwxr-xr-x 1 root 11K Mar 3 03:27 fib_native*
-rwxr-xr-x 1 root 53K Mar 3 03:26 fib_wabt*
-rwxr-xr-x 1 root 88K Mar 3 03:26 fib_wavm_patched*
$ ./runner.rb fib_wabt 0x10000000
Run result: Ok(61)
Total cycles consumed: 549478
Transfer cycles: 6530, running cycles: 542948
$ ./runner.rb fib_wabt 0x20000000
Run result: Ok(-30)
Total cycles consumed: 549590
Transfer cycles: 6530, running cycles: 543060
$ ./runner.rb fib_wabt 0x00010000
Run result: Ok(29)
Total cycles consumed: 551158
Transfer cycles: 6530, running cycles: 544628
$ ./runner.rb fib_wavm_patched 0x10000000
Run result: Ok(61)
Total cycles consumed: 22402
Transfer cycles: 19696, running cycles: 2706
$ ./runner.rb fib_wavm_patched 0x20000000
Run result: Ok(-30)
Total cycles consumed: 22578
Transfer cycles: 19696, running cycles: 2882
$ ./runner.rb fib_wavm_patched 0x00010000
Run result: Ok(29)
Total cycles consumed: 25042
Transfer cycles: 19696, running cycles: 5346
$ ./runner.rb fib_native 0x10000000
Run result: Ok(61)
Total cycles consumed: 3114
Transfer cycles: 1137, running cycles: 1977
$ ./runner.rb fib_native 0x20000000
Run result: Ok(-30)
Total cycles consumed: 3226
Transfer cycles: 1137, running cycles: 2089
$ ./runner.rb fib_native 0x00010000
Run result: Ok(29)
Total cycles consumed: 4794
Transfer cycles: 1137, running cycles: 3657
在 witness 部分,输入值被编译成 32 位无符号的低字节序整数,这里的 0x10000000
,0x20000000
和 0x00010000
分别代表 16
,32
和 256
。
由于 CKB VM 会将 8 位有符号的值作为输出,所以计算值在这里会被截断。但是我们并不关心实际的斐波那契数(当然,假设 3 个版本生成相同的结果),我们关心的是这里消耗的 cycle 数。
可以从这些值中得出以下一些结论:
-
WAVM 版本生成的二进制文件最大(88 K),实际上它还需要往 VM 内加载更多的字节,这是由
transfer cycle
确定的(粗略地说,一个 transfer cycle 意味着需要往 VM 内加载 4 个字节) -
WABT 版本和 C 原生版本都需要大约 7 个 cycles 来计算一轮的斐波那契迭代,而 WAVM 版本需要大约 11 个 cycles 来计算一轮迭代
-
然而,WAVM 版本只需要大约 2530 个 cycles 来设置运行环境,而 WABT 版本需要大约 542836 个 cycles 来设置运行环境。
我们可以看到,WAVM 版本在初始设置中使用了更少的 cycles(这主要是由于我们可以在 WAVM 版本中使用定制的内存设计),但是 WAVM 版本在每次运行斐波纳契迭代时会稍微慢一些。这可能是因为 LLVM 仍然需要一些工作来赶上 GCC 为 RISC-V 生成代码的质量,也可能是因为斐波那契函数非常简单,所以 GCC 可以从还原的 C 代码中完美地获得计算结构。对于更复杂的示例,可能不再是这种情况。
我个人确实对 WAVM 二进制文件的大小做了一些调查,问题是,在 WAVM 中生成的所有符号都是声明的公共符号。这意味着我们不能依靠无用代码删除(dead code elimination DCE)来清除那些我们没有使用的变量和函数,因此这里生成了一个更大的二进制文件。如果原始的 WASM 程序是由 Rust 或 LLVM 直接生成的,就不会出现这样的问题了,因为已经执行了 DCE,但是 AssemblyScript 执行的 DCE 很少,因此我们会有一个更大的二进制文件。稍后,我可能会查看 WAVM,看看是否有办法将未导出的函数调整为本地模块,如果可以解决这个问题,我们应该能够将 WAVM 版本的二进制文件大小降低到与其他解决方案相同的级别。
Rust Secp256k1 示例
让我们也尝试一下更复杂的基于 Rust 的 secp256k1 的例子:
$ cd $TOP
$ git clone https://github.com/nervosnetwork/wasm-secp256k1-test
$ cd wasm-secp256k1-test
$ cargo build --release --target=wasm32-unknown-unknown
$ cd $TOP
$ wabt/bin/wasm2c wasm-secp256k1-test/target/wasm32-unknown-unknown/release/wasm-secp256k1-test.wasm -o secp.c
# There's a symbol confliction in the latest versioni of gcc with wabt here, this
# can serve as a temporary solutin
$ sed -i s/bcmp/bcmp1/g secp.c
$ WAVM/build/bin/wavm compile --target-triple riscv64 wasm-secp256k1-test/target/wasm32-unknown-unknown/release/wasm-secp256k1-test.wasm secp_precompiled.wasm
$ wavm-aot-generator/target/release/wavm-aot-generator secp_precompiled.wasm secp_precompiled
$ cd $TOP
$ cat << EOF > secp_wabt_main.c
#include <stdio.h>
#include <stdlib.h>
#include "ckb_syscalls.h"
#include "secp.h"
int main() {
uint32_t value;
uint64_t len = 4;
int ret = ckb_load_witness((void*) &value, &len, 0, 0,
CKB_SOURCE_GROUP_INPUT);
if (ret != CKB_SUCCESS) {
return ret;
}
if (len < 4) {
return -1;
}
init();
uint32_t times = value >> 8;
value = value & 0xFF;
uint8_t result = 0;
for (int i = 0; i < times; i++) {
result += Z_runZ_ii(value);
}
return result;
}
EOF
$ cat << EOF > secp_wavm_main.c
#include "secp_precompiled_glue.h"
#include "abi/ckb_vm_wasi_abi.h"
#include "ckb_syscalls.h"
int main() {
uint32_t value;
uint64_t len = 4;
int ret = ckb_load_witness((void*) &value, &len, 0, 0,
CKB_SOURCE_GROUP_INPUT);
if (ret != CKB_SUCCESS) {
return ret;
}
if (len < 4) {
return -1;
}
uint32_t times = value >> 8;
value = value & 0xFF;
uint8_t result = 0;
for (int i = 0; i < times; i++) {
ckb_debug("One run!");
wavm_ret_int32_t wavm_ret = wavm_exported_function_run(NULL, value);
result += wavm_ret.value;
}
return result;
}
EOF
现在我们可以编译代码,然后比较二进制文件大小和运行 cycles 数:
$ cd $TOP
$ sudo docker run --rm -it -v `pwd`:/code nervos/ckb-riscv-gnu-toolchain:bionic-20191209 bash
root@a237c0d00b1c:/# cd /code/
root@a237c0d00b1c:/code# riscv64-unknown-elf-gcc -O3 -I ckb-c-stdlib -I wavm-aot-generator -I wabt/wasm2c secp_wabt_main.c secp.c wabt/wasm2c/wasm-rt-impl.c -o secp_wabt
root@a237c0d00b1c:/code# riscv64-unknown-elf-gcc -O3 -I ckb-c-stdlib -I wavm-aot-generator -I wabt/wasm2c secp_wavm_main.c wavm-aot-generator/abi/riscv64_runtime.S secp_precompiled.o -o secp_wavm -Wl,-T wavm-aot-generator/abi/riscv64.lds
root@a237c0d00b1c:/code# exit
exit
$ ckb-binary-patcher/target/release/ckb-binary-patcher -i secp_wavm -o secp_wavm_patched
$ ls -l secp_wabt secp_wavm_patched
-rwxrwxr-x 1 ubuntu 1791744 Mar 3 05:27 secp_wabt*
-rw-rw-r-- 1 ubuntu 1800440 Mar 3 05:29 secp_wavm_patched
$ ./runner.rb secp_wabt 0x01010000
Run result: Ok(0)
Total cycles consumed: 35702943
Transfer cycles: 438060, running cycles: 35264883
$ ./runner.rb secp_wabt 0x01050000
Run result: Ok(0)
Total cycles consumed: 90164183
Transfer cycles: 438060, running cycles: 89726123
$ ./runner.rb secp_wavm_patched 0x01010000
Run result: Ok(0)
Total cycles consumed: 10206568
Transfer cycles: 428764, running cycles: 9777804
$ ./runner.rb secp_wavm_patched 0x01050000
Run result: Ok(0)
Total cycles consumed: 49307936
Transfer cycles: 428764, running cycles: 48879172
和前面的例子一样,我们也可以从这些值中推断出一些事实:
-
用两个版本生成的二级制文件的大小只有不大的差别,WAVM 的二级制文件稍微大一些,但是需要加载到 VM 中的字节会更少
-
在这种情况下,单个 secp256k1 验证在 WAVM 版本中需要 9775342 个 cycles,而在 WABT 版本中需要 13615310 个 cycles。在这里,我们可以看到在 LLVM 中直接进行翻译确实比 WABT 中还原成 C 语言的版本提供更好的性能
-
对于如此复杂的程序,WAVM 版本仍然只需要 2462 个 cycles 来进行设置,而 WABT 版本需要大量的 21649573 个 cycles 来设置。在这里,WAVM 版本将带你走向胜利。
由于从 Rust 到 RISC-V 的当前直接路径不允许使用 std。我们不能直接提供类似的 C 的原生版本。但是对于那些好奇的朋友,我仍然在纯 C 语言中提供了一个类似的函数,我们可以测量直接编译成 RISC-V 的 C 版本所花费的周期:
$ cd $TOP
$ git clone --recursive https://github.com/nervosnetwork/ckb-vm-bench-scripts
$ cd ckb-vm-bench-scripts
$ git checkout f7ab37c055b1a59bbc4f931c732331642c728c1d
$ cd $TOP
$ sudo docker run --rm -it -v `pwd`:/code nervos/ckb-riscv-gnu-toolchain:bionic-20191209 bash
root@ad22c452cb54:/# cd /code/ckb-vm-bench-scripts
root@ad22c452cb54:/code/ckb-vm-bench-scripts# make
(omitted ...)
root@ad22c452cb54:/code/ckb-vm-bench-scripts# exit
exit
$ ./runner.rb ckb-vm-bench-scripts/build/secp256k1_bench 0x01010000
Run result: Ok(0)
Total cycles consumed: 1621594
Transfer cycles: 272630, running cycles: 1348964
$ ./runner.rb ckb-vm-bench-scripts/build/secp256k1_bench 0x01050000
Run result: Ok(0)
Total cycles consumed: 7007598
Transfer cycles: 272630, running cycles: 6734968
正如我们在这里看到的,C 原生版本每个 secp256k1 步骤需要 1346501 个 cycles,初始的统计工作需要 2463 个 cycles。二级制文件的大小和加载所需的字节都更小。
我先在这里提一下,我们并不是在比较相同的代码,C 版本和 Rust 版本使用的是不同的实现,而且我们还没有直接对这两种实现的质量进行评估。话虽如此,假设这两个版本有相似的性能,先编译成 WASM,再编译成 RISC-V 的 Rust 代码的性能大约是 C 代码的 1/7。考虑到 Rust 版本也可以执行边界检查逻辑,我认为这对于很多用例来说是很好的性能。有很多脚本可以处理这种级别的性能。更重要的是,您总是可以将 C 实现的面向性能的代码和 Rust 实现的逻辑代码结合在一起,以享受两者的最佳效果。我们还没有提到这条新路线的优点。最后但并非最不重要的是,所有涉及的项目,包括 Rust、LLVM、WAVM 和我们的生成器目前都是处于快速开发中的活跃项目,很快,随着优秀工程师们不断做出提升和改进,这个差距可能在未来会变得更小。
WASI
我一直在说通过 WASM 在 CKB 上做 Rust,我的同事已经证明有一条可以直接从 Rust 到 RISC-V 的路径,WASM 作为的中间路径在这里有什么帮助?直接路径的问题是,在 RISC-V 的端口不支持 Rust 的 std,更糟糕的是,libc 绑定也不存在。这意味着你将不得不使用 core Rust,一种最小且有限的 Rust。请不要误解我的意思,使用 core Rust 并没有什么不好的,如果你的用例中不需要 Rust 的 std 就已经足够了,那么你可以很好地使用它了。但是我确实想提供一个不同的路径,在那里 std 是可用的,所以大多数 crates 上的 Rust 库都可以用来构建很棒的 CKB 脚本。这样就可以通过 WASM 的路径实现 WASI。
你还没有听过?WASI 是一种与 WebAssembly 程序的运行环境进行交互的标准方法。已经 证明,Rust 在 WASM 的未来取决于一个新的 wasm32-wasi
的目标。通过执行 WASM 的中间步骤,我们可以将对 WASI 的支持直接构建到 CKB 脚本中,享受未来 Rust 的 wasm32-wasi
的目标!事实上,WAVM 已经提供了一个利用 WASI 的 API 的例子,让我们看看我们是否可以在 CKB 上实现:
$ cd $TOP
$ WAVM/build/bin/wavm compile --target-triple riscv64 WAVM/Examples/helloworld.wast helloworld_precompiled.wasm
$ wavm-aot-generator/target/release/wavm-aot-generator helloworld_precompiled.wasm helloworld_precompiled
$ cat << EOF > helloworld_wavm_main.c
#include "helloworld_precompiled_glue.h"
#include "abi/ckb_vm_wasi_abi.h"
/* main is already generated via wavm-aot-generator */
EOF
$ sudo docker run --rm -it -v `pwd`:/code nervos/ckb-riscv-gnu-toolchain:bionic-20191209 bash
root@d28602dba318:/# cd /code
root@d28602dba318:/code# riscv64-unknown-elf-gcc -O3 -I ckb-c-stdlib -I wavm-aot-generator -I wabt/wasm2c helloworld_wavm_main.c wavm-aot-generator/abi/riscv64_runtime.S
helloworld_precompiled.o -o helloworld_wavm -Wl,-T wavm-aot-generator/abi/riscv64.lds
root@d28602dba318:/code# exit
exit
$ ckb-binary-patcher/target/release/ckb-binary-patcher -i helloworld_wavm -o helloworld_wavm_patched
$ RUST_LOG=debug ./runner.rb helloworld_wavm_patched 0x
DEBUG:<unknown>: script group: Byte32(0x86cfac3b49b8f97f913aa5a09d02ad1e5b1ab5be0be793815e9cb714ba831948) DEBUG OUTPUT: Hello World!
Run result: Ok(0)
Total cycles consumed: 20260
Transfer cycles: 17728, running cycles: 2532
我们可以看到 WASI API 运行的非常好!这是因为我已经为这里使用的两个 API 提供了实现。虽然现在还不完整,但我将为所有 WASI API 添加 shims。之后,我们可以使用支持 std 的 Rust 程序,编译成 wasm32-wasi
目标的 WASM 代码,然后完美地转换成 RISC-V。
你可以看到很多不同的区块链项目声称他们每天都在使用 WebAssembly,但他们没有告诉你的是,WebAssembly 被设计成多种风格,他们只是选择支持其中的一种。事实上,许多 著名的 区块链 项目 往往只支持最简单的 WebAssembly 程序。然后他们中的大多数让你使用 Rust,他们只使用奇怪的和可能被弃用的 wasm32-unknown-unknown
目标。结果,他们要么直接禁用 Rust std,声称您不需要它,要么提供不可靠的支持,将来可能会中断这种支持,或者由于兼容性的原因,他们无法承担更改代码的费用。另一方面,您可以享受 WASI 和全功能的 Rust 在CKB 上。许多人问我们为什么不直接使用 WebAssembly,我想说我们是第一个,或者至少是最先在区块链上正确使用 WebAssembly 的项目之一。
“Vice Verca”并不总是有效的
我们经常听到的一个话题是,如果你能把 WASM 翻译成 RISC-V,你也能把 RISC-V 翻译成WASM!从某种意义上说,这是对的,但是在一种可行的方法和另一种可行的方法之间是有区别的。
由于 RISC-V 的设计,它是一个非常简单的规范,可以很好地映射到现代的 CPUs。如果检查我们的 VM 实现,您可能会注意到大多数 RISC-V 指令都直接映射到 12 个 x86-64 CPU 指令。我们只是构建了一个最小的安全层,它在您机器的 CPUs 上工作。而另一方面,WASM 是一个像 JVM 一样的庞然大物,规范中已经有大量的特性,而且每天都有大量的特性被添加到规范中。许多新特性在 CPUs 上没有直接的映射,从底层指令如 memory.grow
或者 call indirect
,到更高层次的功能,如垃圾收集或者线程。当您选择 WebAssembly 作为区块链的引擎时,您将不得不选择一组特性,并决定当新特性不断出现时如何/如果您想要迁移。使问题复杂化的是,您不能仅仅更改当前 WebAssembly 引擎中的某些功能的实现,因为这可能会带来不兼容的更改。
当您选择 RISC-V 作为底层引擎时,您不会有这样的顾虑。RISC-V 是为永不改变的硬件设计的。当规范是固定的,它将永远是固定的,所有的编译器都必须尊重规范中的 bugs。当你在 RISC-V 上实现 WebAssembly 程序时,你可以自由地在任何你想要的地方修改更高结构的实现。例如,您可能会发现一个新的垃圾收集算法,它将有助于您的智能合约,您可以通过升级不同的智能合约来部署该算法,不需要任何硬分叉来支持它。如果您从 WebAssembly 引擎开始解决这个问题,那么所有这些都是非常困难的,甚至是不可能的。这就是我所相信的 CKB 独特的设计之美所在。
扼要重述
这里有一个建议:如果有人告诉您他/她的区块链使用 WebAssembly,为了您自己着想,询问一下他/她的 WebAssembly 引擎使用的具体规范是什么,以及当更多特性添加到 WebAssembly 规范中时,他/她计划如何处理这个问题。WebAssembly 是一个不断增长的规范,由于它的 Web 根源,选择一个规范并冻结它从来就不是一个在堆栈中使用 WebAssembly 的好策略。在区块链的世界中,依赖 WebAssembly 没有什么错,但是如何以一个正确的方式使用 WebAssembly 就很重要了。对我来说,CKB 就是一个通过正确的方式来使用 WebAssembly 并考虑到未来的问题的例子。我相信,如果您付出额外的努力,确保您选择的区块链以正确的方式部署 WebAssembly,多年后您会感谢自己的。