Skip to content

Redis RISC-V 移植与优化指南

1. 移植范围与收益概览

RISC-V 移植并不只是“能编译、能跑”,在 Redis 这种对**延迟敏感、数据路径密集**的服务中,需要关注三类优化点:

  1. 标量位操作扩展(Zbb)BITCOUNT 命令及内部 redisPopcount() 是高频位统计路径。RISC-V Zbb 扩展提供 cpop 指令,可把 64-bit popcount 从软件实现(Hamming weight 算法)替换为单条硬件指令,显著提升吞吐。
  2. 数据路径向量化(RVV):典型代表是 HyperLogLog(HLL)稠密编码的打包/解包。原始实现按位操作,16384 个 6-bit 寄存器的转换在命令热路径上开销可观;AVX2 / AArch64 NEON 已经做了向量化,RISC-V 侧对应使用 RVV
  3. 时间源优化:Redis 内部大量调用单调时钟(超时、延迟统计、慢日志等)。clock_gettime 是系统调用,RISC-V 上可替换为读取 time CSR(mtime 的影子寄存器),显著降低取时开销。

涉及文件(按当前 upstream):

文件 作用
src/config.h 检测 __riscv_zbb__riscv_v_intrinsic;定义 HAVE_POPCNTHAVE_RISCV_RVV
src/bitops.c Zbb 路径下 redisPopcount() 使用 __builtin_popcountll(对应 cpop
src/hyperloglog.c RVV 版 hllMergeDenseRVV / hllDenseCompressRVV
src/monotonic.c USE_PROCESSOR_CLOCK 框架下的 RISC-V 实现
src/siphash.c 利用 __riscv_zicclsm 启用非对齐小端 64-bit 加载
src/debug.c RISC-V Linux 信号上下文 PC / 寄存器打印

2. 编译条件与宏检测

2.1 Zbb 编译开关

src/config.h 中,补丁增加如下分支:

#elif defined(__riscv) && defined(__riscv_zbb)
    #define HAVE_POPCNT
    #define ATTRIBUTE_TARGET_POPCNT

说明:

  • __riscv_zbb 由支持 Zbb 扩展的工具链在 -march=..._zbb 时预定义;
  • 与 x86 不同,RISC-V 上 ATTRIBUTE_TARGET_POPCNT 为空,因为当前工具链没有类似 __attribute__((target("zbb"))) 的按函数目标切换机制;是否使用 cpop 在编译期即已确定。

2.2 RVV 编译开关

src/config.h 中加入:

/* Check for RISC-V Vector Extension (RVV) optimizations.
 * This typically requires compiling with -march=rv64gcv or similar. */
#if defined(__riscv) && defined(__riscv_v_intrinsic)
#define HAVE_RISCV_RVV
#endif

说明:

  • __riscv 由 RISC-V 工具链预定义;
  • __riscv_v_intrinsic 表示当前编译目标支持 RISC-V Vector Intrinsic(对应 RVV 1.0 规范);
  • 需要在 CFLAGS 中显式指定向量扩展,例如 -march=rv64gcv-march=rv64gcv_zba_zbb_zbs 等;
  • 头文件使用 <riscv_vector.h>

2.3 处理器时钟编译开关

src/monotonic.c 顶部的注释已经说明:

/* To use the processor clock on other architectures, either uncomment this line,
 * or build with CFLAGS="-DUSE_PROCESSOR_CLOCK" */

典型编译命令:

# 同时启用 Zbb + RVV + 处理器时钟
make CC=riscv64-linux-gnu-gcc \
     CFLAGS="-march=rv64gcv_zbb -O3 -DUSE_PROCESSOR_CLOCK" \
     -j$(nproc)

如果只想启用其中某一项:

# 仅 Zbb
make CC=riscv64-linux-gnu-gcc CFLAGS="-march=rv64gc_zbb -O3" -j$(nproc)

# 仅 RVV
make CC=riscv64-linux-gnu-gcc CFLAGS="-march=rv64gcv -O3" -j$(nproc)

# 仅处理器时钟
make CC=riscv64-linux-gnu-gcc CFLAGS="-O3 -DUSE_PROCESSOR_CLOCK" -j$(nproc)

3. BITCOUNT / redisPopcount() 的 Zbb 优化

3.1 背景

redisPopcount(void *s, long count) 负责统计一段内存中 1 的个数,是 BITCOUNT 命令的核心路径,也被 Redis 内部多处使用。原实现有两条路径:

  • POPCNT 硬件路径:使用 __builtin_popcountll 每次处理 8 bytes,四条累加器并行,32 bytes 一循环;
  • 软件路径:使用经典的 Hamming weight 算法,每次处理 28 bytes。

x86 上 HAVE_POPCNT 默认启用,并通过 __builtin_cpu_supports("popcnt") 在运行时判断是否真正使用硬件指令。RISC-V Zbb 扩展提供了等价的 cpop 指令,但 GCC/Clang 没有对应的 __builtin_cpu_supports,因此只能在编译期确定。

3.2 补丁实现

src/config.h

#elif defined(__riscv) && defined(__riscv_zbb)
    #define HAVE_POPCNT
    #define ATTRIBUTE_TARGET_POPCNT

src/bitops.c

#if defined(HAVE_POPCNT)
    #if defined(__x86_64__)
    int use_popcnt = __builtin_cpu_supports("popcnt"); /* x86 运行时检测 */
    #elif defined(__riscv)
    int use_popcnt = 1; /* Zbb 的 cpop 可用性在编译期已知 */
    #endif
#else
    int use_popcnt = 0;
#endif

后续代码与 x86 版本完全一致:

if (likely(use_popcnt)) {
    uint64_t cnt[4];
    memset(cnt, 0, sizeof(cnt));
    while (count >= 32) {
        cnt[0] += __builtin_popcountll(*(uint64_t*)(p));
        cnt[1] += __builtin_popcountll(*(uint64_t*)(p + 8));
        cnt[2] += __builtin_popcountll(*(uint64_t*)(p + 16));
        cnt[3] += __builtin_popcountll(*(uint64_t*)(p + 24));
        count -= 32;
        p += 32;
        redis_prefetch_read(p + 2048);
    }
    bits += cnt[0] + cnt[1] + cnt[2] + cnt[3];
    goto remain;
}

编译后,__builtin_popcountll-march=..._zbb 下会生成 cpop 指令(RV64 上为 64-bit 人口计数)。四条独立累加器的设计保留了指令级并行(ILP),redis_prefetch_read(p + 2048) 则继续掩盖 L3 未命中延迟。

3.3 性能收益

根据 PR #15204 的测试数据,在 SG2044 平台上启用 Zbb cpop 后,BITCOUNT 表现如下:

指标 未启用zbb 启用zbb 收益
吞吐(throughput) 95.72 371.42 提升 288%
延迟(latency) 10.422 2.653 降低 74%

测试方法是直接对比原始软件实现与启用 cpop 后的 redisPopcount 路径在 BITCOUNT 场景下的表现。由于 BITCOUNT 操作本质上是对长二进制数组做人口计数,Zbb 将 64-bit popcount 从多指令 Hamming weight 算法压缩为单条 cpop,因此在长 bitmap 上收益非常显著。

3.4 使用注意事项

  • 编译期绑定:与 x86 不同,RISC-V 路径没有运行时探测。若使用 -march=rv64gcv_zbb 编译后,在**不支持 Zbb** 的核上运行,cpop 会触发非法指令异常。生产二进制必须与目标硬件匹配,或在启动时通过 riscv_hwprobe 做额外校验。
  • 与 RVV 同时启用BITCOUNT 目前只有标量 Zbb 优化,没有 RVV 向量化版本。Zbb 与 RVV 可共存,但互不影响。
  • 收益场景:对长 bitmap 的 BITCOUNT 操作提升最明显;对于短 key,函数前端对齐与尾处理的开销会稀释收益。

4. HyperLogLog 的 RVV 向量化

4.1 HLL 稠密编码回顾

默认配置下:

#define HLL_P       14
#define HLL_REGISTERS (1 << HLL_P)   /* 16384 */
#define HLL_BITS      6
  • raw 表示:每个寄存器 1 byte,共 16384 bytes;
  • dense 表示:每个寄存器 6 bits,共 16384 * 6 / 8 = 12288 bytes

dense 位序按小端方式紧密排列(见 src/hyperloglog.c 中的图示):

+--------+--------+--------+------//
|11000000|22221111|33333322|55444444
+--------+--------+--------+------//

HLL 的关键路径有两个:

  • hllMergeDense:把 dense 数据解包成 raw,再与已有 raw 取 max(用于 PFMERGEPFCOUNT 等);
  • hllDenseCompress:把 raw 数据压缩回 dense(用于编码转换或持久化)。

4.2 解包:hllMergeDenseRVV

核心思路与 AVX2 / NEON 版本一致:每次处理 16 个寄存器,对应 dense 输入 12 bytes、raw 输出 16 bytes。

static const uint8_t merge_idx[16] = {
    0, 1, 2, 16, 3, 4, 5, 16,
    6, 7, 8, 16, 9, 10, 11, 16
};

RVV 实现流程:

  1. 设置向量长度
const size_t vl8  = __riscv_vsetvl_e8m1(16);   /* 16 x 8-bit */
const size_t vl32 = __riscv_vsetvl_e32m1(4);   /* 4  x 32-bit */

两个长度在比特数上等价(16*8 = 4*32 = 128 bits),因此可以使用同一段 alignas(16) 临时内存做 8-bit / 32-bit 视角切换。

  1. 加载并展开 12 bytes → 16 bytes
vuint8m1_t x0 = __riscv_vle8_v_u8m1(r, vl8);        /* 读 16 bytes,但只有前 12 有效 */
vuint8m1_t x  = __riscv_vrgather_vv_u8m1(x0, vidx, vl8);

vrgather 按照 merge_idx 重排。索引 16 大于等于向量长度 vl8=16,RVV 规定越界索引返回 0,从而在字节 3、7、11、15 处自动插入零。

  1. 按 32-bit lane 提取 6-bit 字段

将 16 bytes 重新解释成 4 x uint32_t,每个 lane 形如:

| byte3 | byte2 | byte1 | byte0 |
| 0000  | cccccc| bbbbbb| aaaaaa|   (原始 6-bit 字段未对齐)

通过掩码与左移把四个 6-bit 字段对齐到低字节:

vuint32m1_t a1 = __riscv_vand_vx_u32m1(x32, 0x0000003fu, vl32);
vuint32m1_t a2 = __riscv_vand_vx_u32m1(x32, 0x00000fc0u, vl32);
vuint32m1_t a3 = __riscv_vand_vx_u32m1(x32, 0x0003f000u, vl32);
vuint32m1_t a4 = __riscv_vand_vx_u32m1(x32, 0x00fc0000u, vl32);

a2 = __riscv_vsll_vx_u32m1(a2, 2, vl32);
a3 = __riscv_vsll_vx_u32m1(a3, 4, vl32);
a4 = __riscv_vsll_vx_u32m1(a4, 6, vl32);
  1. 合并并转回字节向量
vuint32m1_t y32 = __riscv_vor_vv_u32m1(
    __riscv_vor_vv_u32m1(a1, a2, vl32),
    __riscv_vor_vv_u32m1(a3, a4, vl32), vl32);

由于 RVV intrinsic 没有直接的 8-bit / 32-bit 向量互转,需要借助 alignas(16) uint8_t tmp[16]

__riscv_vse32_v_u32m1((uint32_t *)tmp, y32, vl32);
vuint8m1_t y = __riscv_vle8_v_u8m1(tmp, vl8);
  1. 与已有 raw 取最大值并写回
vuint8m1_t z = __riscv_vle8_v_u8m1(t, vl8);
z = __riscv_vmaxu_vv_u8m1(z, y, vl8);
__riscv_vse8_v_u8m1(t, z, vl8);
  1. 尾处理
for (int i = HLL_REGISTERS - 16; i < HLL_REGISTERS; i++) {
    HLL_DENSE_GET_REGISTER(val, reg_dense, i);
    reg_raw[i] = MAX(reg_raw[i], val);
}

循环次数为 HLL_REGISTERS / 16 - 1,避免最后一次 16-byte load/store 越界;最后 16 个寄存器用标量宏处理。

4.3 打包:hllDenseCompressRVV

压缩是解包的逆过程:16 raw bytes → 12 dense bytes。

static const uint8_t compress_idx[16] = {
    0, 1, 2, 4, 5, 6, 8, 9, 10, 12, 13, 14, 16, 16, 16, 16
};

流程:

  1. 加载 16 raw bytes 作为 4 x uint32_t
  2. 用掩码提取四个低 6-bit 字段;
  3. 右移到目标打包位置:
a2 = __riscv_vsrl_vx_u32m1(a2, 2, vl32);
a3 = __riscv_vsrl_vx_u32m1(a3, 4, vl32);
a4 = __riscv_vsrl_vx_u32m1(a4, 6, vl32);
  1. vor 合并四个字段,得到每个 32-bit lane 内含有 3 个打包字节的格式;
  2. 写回 tmp,以 8-bit 视角读取,再用 vrgather 按照 compress_idx 丢弃每个 lane 的填充字节;
  3. 写回 dense,循环前进 r += 16, t += 12

4.4 性能收益

性能测试步骤 1. 准备测试数据(构造 Dense 状态的 HLL) HLL 默认在添加了少量元素时是 Sparse(稀疏)编码,只有当元素足够多(或内部寄存器被修改得足够多)时才会转为 Dense(密集)编码。RVV 优化只针对 Dense 编码。 先启动编译好的 redis-server,然后用脚本灌入大量数据,确保它们变成 Dense:

用 redis-cli 灌入数据,每个 HLL 插入 10000 个不同元素(足以触发 Sparse -> Dense 转换)

for i in {1..10}; do
  ./src/redis-cli --pipe <<EOF
$(seq 1 10000 | awk -v i=$i '{print "PFADD hll:" i " elem:" i ":" $1}')
EOF
done
检查它们是否变成了 Dense:
./src/redis-cli PFDEBUG ENCODING hll:1
# 预期输出: dense
2. 使用 redis-benchmark 压测 测试 PFCOUNT(多键合并):
# 每次请求计算 10 个 Dense HLL 的合并基数
./src/redis-benchmark -n 100000 -c 50 pfcount hll:1 hll:2 hll:3 hll:4 hll:5 hll:6 hll:7 hll:8 hll:9 hll:10
开启 RVV 的性能提升: | 指标 | 关闭RVV | 开启RVV | 收益 | |------|------|------|------| | 吞吐(throughput)| 1326.28 | 4783.09 | 提升 260.5% | | 延迟(latency) | 37.441 | 10.223 | 降低 72.7% |

测试 PFMERGE:

# 每次请求将 10 个 Dense HLL 合并到一个新键
./src/redis-benchmark -n 100000 -c 50 pfmerge hll:dest hll:1 hll:2 hll:3 hll:4 hll:5 hll:6 hll:7 hll:8 hll:9 hll:10
开启 RVV 的性能提升: | 指标 | 关闭RVV | 开启RVV | 收益 | |------|------|------|------| | 吞吐(throughput)| 1331.52 | 4641.45 | 提升 248.6% | | 延迟(latency) | 37.280 | 10.542 | 降低 71.7% |


5. 单调时钟优化:USE_PROCESSOR_CLOCK

5.1 设计原理

Redis 内部大量调用 monotonicUs()。POSIX 路径是 clock_gettime(CLOCK_MONOTONIC),每次调用都触发系统调用/陷入。RISC-V 提供 time CSR(mtime 的低特权影子),读取它是一条 csrr 指令,无需陷入内核。

5.2 实现要点

#if defined(USE_PROCESSOR_CLOCK) && defined(__riscv) && defined(__linux__)

static inline uint64_t read_mtime(void) {
    uint64_t val;
    asm volatile("csrr %0, time" : "=r"(val));
    return val;
}
  • time CSR 对所有 U-mode 可读(需要 SBI / 平台正确虚拟化);
  • 返回值是按 timebase-frequency 计数的 tick 数;
  • 需要把 tick 数转成微秒:mtime / ticks_per_us

5.3 读取 timebase-frequency

频率信息来自设备树:

static uint64_t get_timebase_frequency(void) {
    FILE *fp = fopen("/proc/device-tree/cpus/timebase-frequency", "rb");
    /* ... */
    if (cnt == 8) {
        memcpy(&be64, buf, sizeof(be64));
        freq = __builtin_bswap64(be64);
    } else if (cnt == 4) {
        memcpy(&be32, buf, sizeof(be32));
        freq = __builtin_bswap32(be32);
    }
    /* ... */
}

注意:

  • 设备树中该属性以 大端 存储,可能是 32-bit 或 64-bit;
  • 读取失败或频率为 0 时,优雅回退到 POSIX clock_gettime
  • monotonicInit() 中,RISC-V 初始化函数在 x86 / aarch64 之后、POSIX fallback 之前被调用。

5.4 性能收益

根据 PR #14251 提供的 micro-benchmark(测试平台:Sophgo SG2042 RISC-V CPU),对比 getMonotonicUs_riscv64()getMonotonicUs_posix() 各执行 **1,000 万次**取时操作的结果如下:

实现 总耗时 单次平均耗时 加速比
RISC-V 处理器时钟(csrr time 286,378 us ~28.6 ns 2.78×
POSIX clock_gettime(CLOCK_MONOTONIC) 794,999 us ~79.5 ns 1.00×

即使用 mtime 的单调时钟实现约为 clock_gettime2.78 倍。考虑到 Redis 内部大量调用单调时钟,这一收益会体现在:

  • 命令超时判断;
  • 慢查询、延迟监控;
  • expire 与 eviction 逻辑;
  • redis-benchmark 等工具的时间戳采样;
  • 任何依赖 monotonicUs() / monotonicMs() 的周期性事件循环。

测试源码(monotonic_bench.c)的核心逻辑为:分别循环调用两种取时函数各 1000 万次,用 volatile monotime dummy 防止编译器优化掉调用,最后打印总耗时。该 benchmark 关注的是**纯取时开销**,因此加速比能够直接反映时钟源替换带来的 CPU 开销降低。


6. 其他可移植与微优化点

6.1 siphash.c:非对齐加载

#if defined(__X86_64__) || defined(__x86_64__) || defined (__i386__) \
    || defined (__aarch64__) || defined (__arm64__) \
    || (defined(__riscv) && defined(__riscv_zicclsm))
#define UNALIGNED_LE_CPU
#endif
  • 当 RISC-V 核实现 Zicclsm(支持主存非对齐加载/存储)时,可直接使用 64-bit 小端非对齐读取,避免 U8TO64_LE 的字节拼接;
  • 若目标核没有 Zicclsm,则仍走安全但较慢的字节路径。

6.2 debug.c:信号上下文

  • PC 获取:uc->uc_mcontext.__gregs[REG_PC]
  • 崩溃时寄存器打印:按照 RISC-V ABI 顺序输出 ra/gp/tp/t0-t6/s0-s11/a0-a7

这些修改让 redis-server 在 RISC-V Linux 上发生段错误时,能输出与 x86 / ARM 同等级别的调试信息。


7. 构建、验证与测试建议

7.1 构建

# 同时启用 Zbb + RVV + 处理器时钟(示例,使用 riscv64-linux-gnu-gcc)
make CC=riscv64-linux-gnu-gcc \
     CFLAGS="-march=rv64gcv_zbb -O3 -DUSE_PROCESSOR_CLOCK" \
     -j$(nproc)

分项启用命令见第 2 节。

7.2 验证 Zbb / popcount 路径

运行 BITCOUNT 单元测试:

./runtest --single unit/bitops

验证 redisPopcount() 是否生成 cpop

riscv64-linux-gnu-objdump -d src/bitops.o | grep -i cpop

应能看到类似 cpop 的指令。

7.3 验证 RVV 路径是否生效

启动 redis-server 后连接执行:

redis> PFDEBUG SIMD ON

若返回 enabled,说明 RVV 路径已被编译进二进制并处于启用状态。可配合 PFCOUNTPFMERGE 等命令测试。

运行单元测试:

./runtest --single unit/hyperloglog

7.4 验证单调时钟

启动日志或运行 INFO 命令时观察 monotonic_info_string。若成功启用 RISC-V 处理器时钟,会输出类似:

RISC-V mtime @ 1000 ticks/us

若未启用或读取失败,则回退为:

POSIX clock_gettime

也可直接检查:

cat /proc/device-tree/cpus/timebase-frequency | xxd

8. 经验总结与注意事项

  1. Zbb / RVV 均为编译期特性:当前实现没有运行时 CPU 特征探测。若希望同一份二进制兼容不同 RISC-V 核,需要在启动时通过 riscv_hwprobe(Linux 6.4+)或 SIGILL 探测,并设计函数指针 / ifunc 分发。

  2. cpop 与目标硬件绑定:使用 -march=rv64gc_zbb 编译的二进制只能运行在有 Zbb 的核上;缺少 Zbb 会触发非法指令。

  3. 向量长度请求固定为 16:在 VLEN≥128 的硬件上工作正常;对于 VLEN<128 的嵌入式向量实现,需要额外确认。若未来面向 VLEN=256/512 的服务器级 RISC-V 核,可考虑 strip-mining 一次处理更多寄存器。

  4. mtime 的单调性:RISC-V time CSR 在规范上是单调递增的,但跨 hart 读取可能看到微小差异。Redis 只要求单调,不要求跨核严格同步,因此满足需求;若平台 SBI 实现异常,应关闭 USE_PROCESSOR_CLOCK

  5. 设备树属性大小端timebase-frequency 在设备树中为大端,务必使用 __builtin_bswap32/64 转换,不要依赖 CPU 小端直接 fread 到整型变量。

  6. RVV intrinsic 版本:代码使用 RVV 1.0 intrinsic。若使用较老工具链(例如 0.7.1/0.9 实验版),接口名或 vsetvl 语义可能不兼容,建议用 GCC 13+ / Clang 17+ 编译。

  7. Zicclsm 的判定__riscv_zicclsm 需要编译时通过 -march 显式指定。若编译目标与实际运行核不一致,可能触发非对齐异常。生产环境建议使用与硬件匹配或更保守的 -march


9. 结论

砺睿微提交的 RISC-V 移植补丁展示了 Redis 向 RISC-V 平台迁移时的三条核心优化路线:

  • Zbb 位操作指令加速 BITCOUNT:利用 cpop 替代软件 Hamming weight,在长 bitmap 上获得显著吞吐提升;
  • 向量扩展加速数据转换:在 HLL 这类位压缩/解压缩密集的场景,利用 vrgather + 32-bit 位域操作,把原本按位串行处理的任务并行化;
  • 处理器时钟替代系统调用:通过 csrr time + 设备树频率,将高频取时操作从内核路径中剥离。

结合 siphash.c 的非对齐优化与 debug.c 的信号上下文支持,Redis 在 RISC-V Linux 上已具备完整、可用的运行能力,并能在支持的硬件上获得可测量的性能提升。

涉及的PR链接: - https://github.com/redis/redis/pull/14251 - https://github.com/redis/redis/pull/15204 - https://github.com/redis/redis/pull/15273