CKB 脚本编程简介[9]: 减少 Duktape 脚本的执行周期
Xuejie Xiao
Nervos Core Team之前的文章介绍过,可以使用纯 JavaScript 构建 CKB 脚本。但是,如果你以前尝试过这种方法,你会注意到一个问题,即基于 JavaScript 的脚本比原生版本消耗更多的周期。虽然这在实验中不是什么问题,但在生产环境中却是非常现实的:更多的周期可以自然地反映在更多的交易费用上。很明显,以下的解决方案可以用来解决这个问题:
- 用本机编译语言重写 JavaScript,例如 C 或 Rust;
- 使用更好的算法使得周期更少;
在本文中,我们将采取不同的方法,只看一看 JavaScript 脚本。 虽然 JavaScript 消耗更多的周期,它能快速迭代,这在某些使用情况下可能很重要。 因此,我在这里想问的是:如果我们决定使用 JavaScript 来构建 CKB 脚本,并且已经将算法和实现改进到了最优状态,是否可以采取其他步骤进一步优化周期减少?在这里,我们将针对该问题进行一些尝试。
构建脚本
我们将在此处构建一个简单的脚本,该脚本读取并打印当前的脚本参数。要构建 JavaScript 脚本,我们首先需要duktape 模板:
$ export TOP=$(pwd)$ git clone https://github.com/xxuejie/ckb-duktape-template$ cd ckb-duktape-template$ git checkout 1a3536ae1dc14abe1e91461ab356e8967cde8d7b$ npm i
$ cat << EOF > src/index.jsimport { Script } from "../schema/blockchain.js"
function bytesToHex(b) { return "0x" + Array.prototype.map.call( new Uint8Array(b), function(x) { return ('00' + x.toString(16)).slice(-2) } ).join('')}
const script = new Script(CKB.load_script(0))const args = script.getArgs().raw()CKB.debug(bytesToHex(args))EOF
$ npm run build
注意,这个例子还包括了重构过的 Molecule JavaScript 插件。与前一个相比,它提供了更好的 API,同时节省了巨大的代码量和运行时间。
首先让我们获得一个基准数字:
$ cd $TOP$ git clone --recursive https://github.com/xxuejie/ckb-duktape$ cd ckb-duktape$ git checkout d6241938247b402ec56c7af218acfc9049ac753d$ docker run --rm -it -v `pwd`:/code nervos/ckb-riscv-gnu-toolchain:bionic-20191209 bashroot@0d31cad7a539:~# cd /coderoot@0d31cad7a539:/code# makeroot@0d31cad7a539:/code# exitexit
$ cd $TOP$ git clone https://github.com/xxuejie/ckb-standalone-debugger$ cd ckb-standalone-debugger/bins$ git checkout 3c503b95962e29057b248aeed4f639180c132fff$ cargo build --release$ 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 && ARGV.length != 3 STDERR.puts "Usage: runner.rb <duktape file> <script file> <optional dump file>" exit 1end
duktape_binary = File.read(ARGV[0])duktape_hash = blake2b(duktape_binary)script_binary = File.read(ARGV[1])
tx = DATA.read.sub("@DUKTAPE_CODE", bin_to_hex(duktape_binary)) .sub("@DUKTAPE_HASH", bin_to_hex(duktape_hash)) .sub("@SCRIPT_CODE", bin_to_hex(script_binary))
File.write("tx.json", tx)commandline = "ckb-standalone-debugger/bins/target/release/ckb-debugger --tx-file tx.json --script-group-type lock -i 0 -e input"if ARGV.length == 3 commandline += " -d #{ARGV[2]}"endSTDERR.puts "Executing: #{commandline}"exec(commandline)
__END__{ "mock_info": { "inputs": [ { "input": { "previous_output": { "tx_hash": "0xa98c57135830e1b91345948df6c4b8870828199a786b26f09f7dec4bc27a73da", "index": "0x0" }, "since": "0x0" }, "output": { "capacity": "0x4b9f96b00", "lock": { "args": "0x32e555f3ff8e135cece1351a6a2971518392c1e30375c1e006ad0ce8eac07947c219351b150b900e50a7039f1e448b844110927e5fd9bd30425806cb8ddff1fd970dd9a8", "code_hash": "@DUKTAPE_HASH", "hash_type": "data" }, "type": null }, "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": "@SCRIPT_CODE" }, { "cell_dep": { "out_point": { "tx_hash": "0xfcd1b3ddcca92b1e49783769e9bf606112b3f8cf36b96cac05bf44edcf5377e6", "index": "0x1" }, "dep_type": "code" }, "output": { "capacity": "0x702198d000", "lock": { "args": "0x", "code_hash": "0x0000000000000000000000000000000000000000000000000000000000000000", "hash_type": "data" }, "type": null }, "data": "@DUKTAPE_CODE" } ], "header_deps": [ { "compact_target": "0x1a1e4c2f", "hash": "0x51d199c4060f703344eab3c9b8794e6c60195ae9093986c35dba7c3486224409", "number": "0xd8fc4", "parent_hash": "0xc02e01eb57b205c6618c9870667ed90e13adb7e9a7ae00e7a780067a6bfa6a7b", "nonce": "0xca8c7caa8100003400231b4f9d6e0300", "timestamp": "0x17061eab69e", "transactions_root": "0xffb0863f4ae1f3026ba99b2458de2fa69881f7508599e2ff1ee51a54c88b5f88", "proposals_hash": "0x0000000000000000000000000000000000000000000000000000000000000000", "uncles_hash": "0x0000000000000000000000000000000000000000000000000000000000000000", "version": "0x0", "epoch": "0x53f00fa000232", "dao": "0x4bfe53a5a9bb9a30c88898b9dfe22300a58f2bafed47680000d3b9f5b6630107" } ] }, "tx": { "version": "0x0", "cell_deps": [ { "out_point": { "tx_hash": "0xfcd1b3ddcca92b1e49783769e9bf606112b3f8cf36b96cac05bf44edcf5377e6", "index": "0x0" }, "dep_type": "code" }, { "out_point": { "tx_hash": "0xfcd1b3ddcca92b1e49783769e9bf606112b3f8cf36b96cac05bf44edcf5377e6", "index": "0x1" }, "dep_type": "code" } ], "header_deps": [ "0x51d199c4060f703344eab3c9b8794e6c60195ae9093986c35dba7c3486224409" ], "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": [ "0x210000000c0000001d0000000d0000006920616d20612073656372657400000000" ], "outputs_data": [ "0x" ] }}EOF
$ chmod +x runner.rb$ RUST_LOG=debug ./runner.rb ckb-duktape/build/load0 ckb-duktape-template/build/duktape.jsExecuting: ckb-standalone-debugger/bins/target/release/ckb-debugger --tx-file tx.json --script-group-type lock -i 0 -e inputDEBUG:<unknown>: script group: Byte32(0xcf13fa84ff3a615dd496e9ad8647af01078b11ba1c2757889f0a95e2520fdeb9) DEBUG OUTPUT: 0x32e555f3ff8e135cece1351a6a2971518392c1e30375c1e006ad0ce8eac07947c219351b150b900e50a7039f1e448b844110927e5fd9bd30425806cb8ddff1fd970dd9a8Run result: Ok(0)Total cycles consumed: 20198757Transfer cycles: 67328, running cycles: 20131429
这个简单的脚本需要大约 2000 万个周期。 作为比较,我们用 C 代码中实现类似的功能:
$ cd $TOP
$ cat << EOF > c.c#include "blockchain.h"#include "ckb_syscalls.h"
#define MAXIMUM_ARG_SIZE 4096#define SCRIPT_SIZE 32768
#define ERROR_ARGUMENTS_LEN -1#define ERROR_ENCODING -2#define ERROR_SYSCALL -3#define ERROR_SCRIPT_TOO_LONG -21#define ERROR_OVERFLOWING -51#define ERROR_AMOUNT -52
int main() { unsigned char script[SCRIPT_SIZE]; uint64_t len = SCRIPT_SIZE; int ret = ckb_load_script(script, &len, 0); if (ret != CKB_SUCCESS) { return ERROR_SYSCALL; } if (len > SCRIPT_SIZE) { return ERROR_SCRIPT_TOO_LONG; } mol_seg_t script_seg; script_seg.ptr = (uint8_t *)script; script_seg.size = len;
if (MolReader_Script_verify(&script_seg, false) != MOL_OK) { return ERROR_ENCODING; }
mol_seg_t args_seg = MolReader_Script_get_args(&script_seg); mol_seg_t args_bytes_seg = MolReader_Bytes_raw_bytes(&args_seg); if (args_bytes_seg.size > MAXIMUM_ARG_SIZE) { return ERROR_ARGUMENTS_LEN; }
static const char HEXCHARS[] = "0123456789abcdef"; char hex[MAXIMUM_ARG_SIZE * 2 + 1]; for (size_t i = 0; i < args_bytes_seg.size; i++) { hex[i * 2] = HEXCHARS[args_bytes_seg.ptr[i] >> 4]; hex[i * 2 + 1] = HEXCHARS[args_bytes_seg.ptr[i] & 0xF]; } hex[args_bytes_seg.size * 2] = '\0'; ckb_debug(hex);
return CKB_SUCCESS;}EOF
$ docker run --rm -it -v `pwd`:/code nervos/ckb-riscv-gnu-toolchain:bionic-20191209 bashroot@57b79063c965:/# cd /coderoot@57b79063c965:/code# riscv64-unknown-elf-gcc -O3 -I ckb-duktape/deps/ckb-c-stdlib -I ckb-duktape/deps/molecule c.c -o c -Wl,-static -fdata-sections -ffunction-sections -Wl,--gc-sections -Wl,-sroot@57b79063c965:/code# exitexit
$ RUST_LOG=debug ./runner.rb c ckb-duktape-template/build/duktape.jsExecuting: ckb-standalone-debugger/bins/target/release/ckb-debugger --tx-file tx.json --script-group-type lock -i 0 -e inputDEBUG:<unknown>: script group: Byte32(0x9f637b251b36de8e6c8b48a1db2f2dcbb0e7b667de1d3ec02c589a7b680842e1) DEBUG OUTPUT: 32e555f3ff8e135cece1351a6a2971518392c1e30375c1e006ad0ce8eac07947c219351b150b900e50a7039f1e448b844110927e5fd9bd30425806cb8ddff1fd970dd9a8Run result: Ok(0)Total cycles consumed: 5456Transfer cycles: 878, running cycles: 4578
正如我们将在这里看到的,C 脚本只需要 4578 个周期,这比 JavaScript 脚本要少得多。虽然 JavaScript 脚本消耗了更多的周期,我们仍然可以显著地减少周期消耗。
第 1 步:跳过初始化
熟悉动态语言的人,会意识到所有的动态语言实现都需要一个初始化阶段来创建和正确设置 VM,这可能需要大量的工作,花费大量的周期。 我们的第一个想法来自于这里:如果我们可以保存已经初始化的状态,并在以后的 VM 执行中重用该状态,结果会怎样? CKB VM 实例的整个状态是 33 个寄存器(32 个通用寄存器+ PC 寄存器)和内存状态。如果我们可以将它们转储到一个单独的二进制文件中,并在以后重新创建相同的 VM 状态,那么我们就不需要一次又一次地执行整个初始化步骤。
ckb-standalone-debugger 实际上提供了这样一个转储特性。它将一个新的 syscall 添加到调试器创建的 VM 实例中(请注意,这个 syscall 在生产环境中是无用的,并且可能永远不会进入生产环境)。当 syscall 执行时,调试器实例会将所有 VM 状态(包括所有寄存器和内存)序列化到自定义构建的可执行文件中。稍后,如果我们用这个生成的可执行文件实例化一个新的 VM 实例,它将恢复所有 VM 状态,并且 VM 将继续运行,就像它刚从 syscall 返回一样。通过这种方式,我们可以通过调试器离线执行必要的初始化步骤,然后只将生成的可执行文件部署到 CKB,所有后续的可执行文件的执行都可以跳过昂贵的初始化部分,从而节省大量的周期。
我已经准备了一个 duktape实例来执行设置。然后执行转储系统,我们现在可以给这个测试:
$ cd $TOP$ RUST_LOG=debug ./runner.rb ckb-duktape/build/dump_load0 ckb-duktape-template/build/duktape.js dump0.binExecuting: ckb-standalone-debugger/bins/target/release/ckb-debugger --tx-file tx.json --script-group-type lock -i 0 -e input -d dump0.binDEBUG:<unknown>: script group: Byte32(0xb5656359cbcd52cfa68e163cdd217657f0cfc533c909d13a1fdd8032f6b4f1f0) DEBUG OUTPUT: 0x32e555f3ff8e135cece1351a6a2971518392c1e30375c1e006ad0ce8eac07947c219351b150b900e50a7039f1e448b844110927e5fd9bd30425806cb8ddff1fd970dd9a8Run result: Ok(0)Total cycles consumed: 20199104Transfer cycles: 67352, running cycles: 20131752
$ RUST_LOG=debug ./runner.rb dump0.bin ckb-duktape-template/build/duktape.jsExecuting: ckb-standalone-debugger/bins/target/release/ckb-debugger --tx-file tx.json --script-group-type lock -i 0 -e inputDEBUG:<unknown>: script group: Byte32(0x51959c6288a1cfba0d7f7dc8c5a90cf9a84bf5b58f1d5ed3b355497d119fba16) DEBUG OUTPUT: 0x32e555f3ff8e135cece1351a6a2971518392c1e30375c1e006ad0ce8eac07947c219351b150b900e50a7039f1e448b844110927e5fd9bd30425806cb8ddff1fd970dd9a8Run result: Ok(0)Total cycles consumed: 16249542Transfer cycles: 96998, running cycles: 16152544
这里的第一个命令将正常执行脚本,但是在内部它会调用 dump syscall,导致转储 VM 状态,然后转储到dump0.bin
可执行文件中。 稍后,当我们直接在dump0.bin
上运行 CKB VM 时,我们会注意到它执行的操作与上述 duktape 二进制文件相同,但是节省了近 400 万个周期。
第 2 步: Bytecode Over Source 源上的字节码
之前,我们一直在 CKB VM 上直接针对 JavaScript 源代码运行 duktape,这意味着在运行时,duktape 首先需要解析 JavaScript 源代码,然后执行它。 解析时间存在于同一 JavaScript 文件的每次调用中,这也可能会浪费大量的周期。幸运的是,duktape 提供了一种 bytecode 格式:我们可以将 JavaScript 源解析为 duktape 字节码格式,并且仅在运行时加载并运行字节码。 让我们现在尝试一下:
$ cd $TOP$ ckb-duktape/build/dump_bytecode ckb-duktape-template/build/duktape.js script.bin$ RUST_LOG=debug ./runner.rb ckb-duktape/build/dump_load0 script.bin dump0.binExecuting: ckb-standalone-debugger/bins/target/release/ckb-debugger --tx-file tx.json --script-group-type lock -i 0 -e input -d dump0.binDEBUG:<unknown>: script group: Byte32(0xb5656359cbcd52cfa68e163cdd217657f0cfc533c909d13a1fdd8032f6b4f1f0) DEBUG OUTPUT: 0x32e555f3ff8e135cece1351a6a2971518392c1e30375c1e006ad0ce8eac07947c219351b150b900e50a7039f1e448b844110927e5fd9bd30425806cb8ddff1fd970dd9a8Run result: Ok(0)Total cycles consumed: 9239414Transfer cycles: 67352, running cycles: 9172062
$ RUST_LOG=debug ./runner.rb dump0.bin script.binExecuting: ckb-standalone-debugger/bins/target/release/ckb-debugger --tx-file tx.json --script-group-type lock -i 0 -e inputDEBUG:<unknown>: script group: Byte32(0x51959c6288a1cfba0d7f7dc8c5a90cf9a84bf5b58f1d5ed3b355497d119fba16) DEBUG OUTPUT: 0x32e555f3ff8e135cece1351a6a2971518392c1e30375c1e006ad0ce8eac07947c219351b150b900e50a7039f1e448b844110927e5fd9bd30425806cb8ddff1fd970dd9a8Run result: Ok(0)Total cycles consumed: 5289852Transfer cycles: 96998, running cycles: 5192854
dump_load0 二进制实际上同时支持 duktape 字节码和 JavaScript 源代码。它执行一个运行时检查,在加载的数据时判断是 duktape 字节码还是 JavaScript 源代码。在这里我们可以看到,通过结合前面的两个解决方案,我们已经可以将周期消耗从大约 2000 万减少到大约 500 万。
请注意,duktape 字节码是有权衡的。它永远不能保证兼容性,在不同版本的 duktape 上,甚至同一个 duktape 版本的不同构建版本都可能使用不同的字节码格式。在正常环境中,这可能是个问题,但是由于在这里我们还将 duktape 二进制文件作为智能合约发布,因此我们可以锁定我们正在使用的 duktape 二进制文件的版本,以确保字节码始终有效。另一个缺点是使字节码文件实际上比原始 JavaScript 源文件大,这让很多人感到意外。
$ cd $TOP$ ls -l script.bin-rw-rw-r-- 1 ubuntu 7810 Mar 19 05:28 script.bin
$ ls -l ckb-duktape-template/build/duktape.js-rw-rw-r-- 1 ubuntu 3551 Mar 19 04:46 ckb-duktape-template/build/duktape.js
在我们的示例中,尽管原始的最小化 JavaScript 文件约为 3.5K,但生成的字节码文件接近 8K。这取决于你的用例,是一个折衷方案:你想要文件更小,还是周期消耗更少?
第 3 步:跳过清理工作
CKB VM 的工作方式与其他环境略有不同:它为你提供一个固定的 4MB 内存段,一旦代码执行完毕,整个内存段就会被丢弃。 这带来了一个有趣的点:在正常的环境中,你肯定希望在退出之前清理你使用的资源,但是在 CKB VM 环境中,这是不必要的,因为整个内存段将一起被销毁。只要你发出正确的响应信号,清除步骤实际上只是浪费 CKB VM 的循环。
考虑到这一点,我提供了dump_load0_nocleanup的变体,运行脚本后,该操作不执行任何清理工作。
现在是时候尝试这个最终版本了:
$ cd $TOP$ RUST_LOG=debug ./runner.rb ckb-duktape/build/dump_load0_nocleanup script.bin dump0.binExecuting: ckb-standalone-debugger/bins/target/release/ckb-debugger --tx-file tx.json --script-group-type lock -i 0 -e input -d dump0.binDEBUG:<unknown>: script group: Byte32(0x06034ffb00fec553882c6a9c7614333a728828772d3c236a7f8fa6af60669538) DEBUG OUTPUT: 0x32e555f3ff8e135cece1351a6a2971518392c1e30375c1e006ad0ce8eac07947c219351b150b900e50a7039f1e448b844110927e5fd9bd30425806cb8ddff1fd970dd9a8Run result: Ok(0)Total cycles consumed: 7856033Transfer cycles: 67348, running cycles: 7788685
$ RUST_LOG=debug ./runner.rb dump0.bin script.binExecuting: ckb-standalone-debugger/bins/target/release/ckb-debugger --tx-file tx.json --script-group-type lock -i 0 -e inputDEBUG:<unknown>: script group: Byte32(0x0e948e69dd75f2d6676048569073afe4ec2b284144bbe33a6216b13171606d18) DEBUG OUTPUT: 0x32e555f3ff8e135cece1351a6a2971518392c1e30375c1e006ad0ce8eac07947c219351b150b900e50a7039f1e448b844110927e5fd9bd30425806cb8ddff1fd970dd9a8Run result: Ok(0)Total cycles consumed: 3903352Transfer cycles: 96994, running cycles: 3806358
结合所有的解决方案,我们成功地将基于 JavaScript 的 CKB 智能合约的周期消耗从 2000 万减少到不到 400 万。这仍然与 C 版本相差甚远,C 版本需要不到 5000 个周期。但在很多情况下,像 JavaScript 这样的高级语言会比普通的老式 C 语言更有优势,而且这里的周期消耗可能已经足够好了。
未来
以上只是你可以学习的三个简单解决方案,很有可能有更多的方法可以减少周期。要记住的一件事是,你不必照顾日常运行程序中建立的规则。只要脚本满足链上的验证需求,你就可以采用任何技术来减少周期消耗。