CKB脚本编程简介[8]: 高性能 WASM

原文: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 位无符号的低字节序整数,这里的 0x100000000x200000000x00010000 分别代表 1632256

由于 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,多年后您会感谢自己的。

3 Likes