CKB/CKB-VM 扩展指令集 B

写代码时, 我们总希望代码执行得越快越好, 对于区块链上的智能合约同样如此.

更快的执行速度意味着更高的 TPS, 当然, TPS 受多种因素影响, 这里说的只是其中一个维度. 与此同时, 更快的执行速度也意味着能够承载更多复杂业务: 当虚拟机足够高效时, 我们才有能力将一些复杂的密码学算法直接在链上实现.

通常有两种途径来提升性能: 软件层面和硬件层面. 在软件层面, 程序员们积累了许多口耳相传的优化技巧, 有些晦涩难懂, 却能带来显著的性能提升, 寥寥数行代码便能替代几十行的朴素实现, 或借助某种精妙的算法将性能提升几十乃至上百倍. 我记得 2017 年做图像处理工作时, 手头有几十 TB 的图像数据需要批量处理, 每一次对算法的微小优化, 都能节省几个小时甚至几十个小时的运行时间, 那种成就感令人印象深刻.

然而, 纯软件优化终究有其上限. 若想进一步突破瓶颈, 就需要从运行软件的底层载体入手. CKB 的链上程序运行在 CKB-VM 上, 因此让 CKB-VM 本身执行得更快, 是提升程序运行效率的另一个关键方向.

B 扩展指令集中的 B 代表 Bit Manipulation, 即"位操作". RISC-V 的 B 扩展指令集旨在增强处理器处理位操作的能力, 典型的位操作包括前导零计数 (CLZ), 尾随零计数 (CTZ), 循环移位等.

下面我将通过几个版本的前导零计数算法, 来说明将 B 扩展指令集引入 CKB-VM 的实际意义. 首先解释一下什么是前导零计数: 将一个整数视作二进制表示, 统计其最高位起连续的零的个数. 例如, 数字 12345678 的二进制表示如下, 其前导零个数为 40:

0000000000000000000000000000000000000000101111000110000101001110(12345678)
|<--------------40-------------------->|

实现前导零计数并不难, 绝大多数程序员能在一分钟内写出来. 最直观的做法是逐位扫描: 每次将数字左移一位, 检查最高位是否为零, 直到遇到第一个 1 为止.

uint64_t clz(uint64_t n)
{
    int c = 0;
    for (int i = 0; i < 64; i++) {
        if (((n << i) & 0x8000000000000000) == 0) {
            c += 1;
        } else {
            break;
        }
    }
    return c;
}

前导零计数在许多密码学算法和底层运算中被频繁使用, 自然令人不禁思考: 有没有办法让它跑得更快? 对于这类范围收缩问题, 二分法往往是个高效的选择: 每次将问题规模减半, 逐步缩小目标范围, 直到得出答案. 具体到前导零计数, 我们可以先判断高 32 位是否全为零, 再依次对 16 位, 8 位, 4 位, 2 位, 1 位做同样的判断:

uint64_t clz(uint64_t n)
{
    if (n == 0) return 64;
    int c = 0;
    if (n <= 0x00000000FFFFFFFF) { c += 32; n <<= 32; };
    if (n <= 0x0000FFFFFFFFFFFF) { c += 16; n <<= 16; };
    if (n <= 0x00FFFFFFFFFFFFFF) { c += 8; n <<= 8; };
    if (n <= 0x0FFFFFFFFFFFFFFF) { c += 4; n <<= 4; };
    if (n <= 0x3FFFFFFFFFFFFFFF) { c += 2; n <<= 2; };
    if (n <= 0x7FFFFFFFFFFFFFFF) { c += 1; };
    return c;
}

第二个版本的性能远优于第一个. 对于目前的 CKB-VM 而言, 这大概是纯软件实现中最快的方案之一, 约需 30 条指令. 此外还可以使用查表法进一步提速, 这是经典的以空间换时间的策略, 但代价是需要预先分配一张较大的查找表.

然而我想更进一步: 既然前导零计数如此常用, 为何不在指令集层面直接提供这一原语? RISC-V 将指令集分为基础指令集和扩展指令集, 前导零计数恰好就被纳入了 B 扩展指令集. 这意味着, 一旦 CKB-VM 实现了 B 扩展, 上述所有实现都可以被一条指令替代:

static inline int64_t _rv64_clz(int64_t rs1) {
    int64_t rd;
    __asm__ ("clz %0, %1" : "=r"(rd) : "r"(rs1));
    return rd;
}

单条指令相较于 30 条指令的软件实现, 性能提升是显而易见的. 完整的 B 扩展指令集共提供了 43 条新指令, 涵盖位计数, 位提取, 字节翻转, 移位混合等多类操作, 可阅读官方文档了解详情. 总体而言, 对于有大量位操作需求的合约(如哈希算法, 椭圆曲线运算等), 引入 B 扩展指令集可以带来一个数量级以上的性能提升.

上面的内联汇编写法需要手动标注每一处位操作, 实际工程中这样做既繁琐又容易出错. 更好的做法是直接告诉编译器目标平台支持 B 扩展, 让编译器自动完成指令选择.

RISC-V 的 B 扩展在规范中被细分为四个子扩展:

子扩展 名称 典型指令
zba Address calculation sh1add, sh2add, sh3add
zbb Basic bit-manipulation clz, ctz, cpop, ror, rol, rev8
zbc Carry-less multiplication clmul, clmulh, clmulr
zbs Single-bit operations bset, bclr, binv, bext

在使用 clang 编译时, 通过 -march 参数将这些子扩展附加到基础 ISA 字符串上即可:

$ clang --target=riscv64-unknown-elf \
        -march=rv64imc_zba_zbb_zbc_zbs \
        -O3 -o main main.c

开启这些选项后, 编译器会自动将 C 标准内置函数(甚至是你手写的朴素实现)映射到对应的 B 扩展指令. 例如:

#include <stdint.h>

// Clang 会将以下函数自动编译为单条指令: clz
uint64_t clz(uint64_t n) { return __builtin_clzll(n); }

// Clang 会将以下函数自动编译为单条指令: cpop
uint64_t pop(uint64_t n) { return __builtin_popcountll(n); }

// Clang 会将以下函数自动编译为单条指令: ror
uint64_t ror(uint64_t n, int shift) { return (n >> shift) | (n << (64 - shift)); }

使用下面的命令编译后, 生成的汇编代码中会直接使用 clz, cpop, ror 等 B 扩展指令, 而不是模拟它们的普通指令序列:

$ clang --target=riscv64-unknown-elf -march=rv64imc_zba_zbb_zbc_zbs -O3 -c -o main.o main.c
$ llvm-objdump -d main.o
# main.o: file format elf64-littleriscv
#
# Disassembly of section .text:
#
# 0000000000000000 <clz>:
#        0: 60051513      clz     a0, a0
#        4: 8082          ret
#
# 0000000000000006 <pop>:
#        6: 60251513      cpop    a0, a0
#        a: 8082          ret
#
# 000000000000000c <ror>:
#        c: 60b55533      ror     a0, a0, a1
#       10: 8082          ret

这意味着: 只要在编译 CKB 合约时指定正确的 -march 参数, 代码中所有符合条件的位操作都会被自动替换为 B 扩展指令, 无需修改任何源代码.