SEXT and ZEXT in RISC-V

Table of Contents

1. SEXT and ZEXT in RISCV-V

1.1. overview

RISC-V 所有 GPR 都是 XLEN 长度, 导致不同宽度的整型数据进行转换时, 会涉及到 sext/zext 的问题. 以 rv64 为例, 由于不存在 32 位(以及 16/8 位)的 GPR, 导致 32/16/8 位整数必需通过 sext/zext 保存在 64 位 GPR 中.

例如:

  1. int32 的 0x00000000f0000000 转换为 int64 时需要通过 sext 变成 0xfffffffff0000000
  2. uint32 的 0x00000000f0000000 转换为 int64 时需要通过 zext 变成 0x00000000f0000000
  3. int64 的 0x00000000f0000000 转换成 int32 需要通过 sext 变成 0xfffffffff0000000
  4. uint64 的 0xfffffffff0000000 转换成 uint32 需要通过 zext 变成 0x00000000f0000000

为了消除 sext:

  1. RISC-V 提供了一些带有 implicit sext 功能的指令
  2. RISC-V psABI 规定函数参数必须是经过 sext 的, 不需要再对它做 sext
  3. 通过一些优化, 可以去掉一些不必要的 sext/zext

1.2. sext

1.2.1. implicit sext

有些 RISC-V 指令(大部分都带 w 后缀, 除了 lui, lh, lb 等)只会写目的寄存器的一部分,例如低 32 位, 寄存器中没有被写部分会通过隐式的 sext 补上符号位, 而不是保留原来的数据. 例如:

lw a0, 0(a0)
sext.w a0, a0    # <-- 这条指令并不需要, 因为 lw 会隐式的对 a0 做 sext
addi a0, a0, 1

另外, `sext.w a0, a1` 实际是 `addiw a0, a1, 0` 的伪指令, 因为 `addiw a0, a1, 0` 隐含着对 a0 的符号扩展

具体可以分为以下几类:

  1. load 数据的宽度小于 XLEN 时, 例如 lh, lb 以及 rv64 的 lw

    x[rd] = sext(M[x[rs1] + sext(offset)][31:0])
    
    • lw (rv64), lh, lb 会进行 sext
    • lwu (rv64), lhu, lbu 会进行 zext
  2. lui (rv64)

    和 load 指令类似, 只不过要 load 的数据来自立即数

    x[rd] = sext(immediate[31:12] << 12)
    
  3. rv64 中的 xxxw 指令, 例如 addiw, slliw, mulw, amoxor.w, …

    x[rd] = sext((x[rs1] + sext(immediate))[31:0])
    
  4. rv{32,64}{f,d} 中的 xxx.x.w, 例如 fmv.x.w

    x[rd] = sext(f[rs1][31:0])
    

1.2.2. sext in gcc

1.2.2.1. implicit sext

gcc 涉及到类型转换时本来需要生成 sext 指令, 但由于 implicit sext, 这些 sext 实际上并没有必要, 例如

int64_t foo(int32_t a, int32_t b) { return a + b; }
    # gcc -O0
foo:
    ...
    addw    a5,a4,a5
    sext.w  a5,a5
    #~~~~~~~~~~~~
    #...
    mv      a0,a5
    #...
    jr      ra

实际上 addw 包括 implicit sext, 所以 sext.w 并不需要:

    # gcc -O2
foo:
    addw    a0,a0,a1
    ret

这个优化只需要 md 中的一个 pattern 即可:

(define_insn "*addsi3_extended"
  [(set (match_operand:DI               0 "register_operand" "=r,r")
    (sign_extend:DI
         (plus:SI (match_operand:SI 1 "register_operand" " r,r")
              (match_operand:SI 2 "arith_operand"    " r,I"))))]
  "TARGET_64BIT"
  "add%i2w\t%0,%1,%2"
  # %i2 是指 %2 是 immediate 时输出 `i`, 即 addiw, 否则输出 ``, 即 addw
  [(set_attr "type" "arith")
   (set_attr "mode" "SI")])

但目前 gcc (13.2.0) 还不能去掉所有类似的 sext, 例如:

Unnecessary sext.w after amoor.w in __atomic_fetch_or on RV64

可以通过单独的优化 pass 来完成这件事,例如:llvm 的 SExt W Removal, 基本思想是如果 sext.w 操作的寄存器是某个 implicit sext 指令的直接或间接输出,则这个 sext.w 是多余的。

1.2.2.2. explicit sext

指令没有 implicit sext 时, 需要显式的 sext:

int32_t a0(int64_t a) { return a; }
int32_t a1(int64_t a, int64_t b) { return a & b; }
a0:
    sext.w  a0,a0
    ret
a1:
    and     a0,a0,a1
    sext.w  a0,a0
    ret

上面的例子中, sext.w 是必须的, 但编译器可以根据值的范围判定某些显式 sext 是多余的, 例如 gcc 的 combine 可以根据值的范围去掉不需要的 sext.w:

int32_t b0(int64_t a) { return a & 1; }
int32_t b1(int64_t a) { return a & 1 << 30; }
int32_t b2(int64_t a) { return a & 1 << 31; }
$> riscv-gcc test.c -O2 -c -s
b0:
        andi    a0,a0,1
        ret
b1:
        li      a5,1073741824
        and     a0,a0,a5
        ret
b2:
        li      a5,-2147483648
        and     a0,a0,a5
        sext.w  a0,a0
        ret

$> riscv-gcc test.c -O2 -c -s -fdisable-rtl-combine
b0:
        andi    a0,a0,1
        sext.w  a0,a0
        ret
b1:
        li      a5,1073741824
        and     a0,a0,a5
        sext.w  a0,a0
        ret
b2:
        li      a5,-2147483648
        and     a0,a0,a5
        sext.w  a0,a0
        ret

1.3. zext

https://forums.sifive.com/t/how-can-i-repeat-the-coremark-score/1947

#include <stdint.h>

#ifdef BAD
typedef uint32_t INT;
#else
typedef int32_t INT;
#endif

void matrix_add_const(INT N, INT *A, INT val) {
    INT i, j;
    for (i = 0; i < N; i++) {
        for (j = 0; j < N; j++) {
            A[i * N + j] += val;
        }
    }
}
// good:
// riscv64-linux-gnu-gcc-10 test.c -Ofast -c
0000000000000000 <matrix_add_const>:
   0:   02a05963                blez    a0,32 <.L1>
   4:   fff5069b                addiw   a3,a0,-1
   8:   1682                    slli    a3,a3,0x20
   a:   82f9                    srli    a3,a3,0x1e
   c:   00458793                addi    a5,a1,4
  10:   00251893                slli    a7,a0,0x2
  14:   96be                    add     a3,a3,a5
  16:   4801                    li      a6,0

0000000000000018 <.L3>:
  18:   87ae                    mv      a5,a1 # a1 是 i*N

000000000000001a <.L4>:
  1a:   4398                    lw      a4,0(a5)
  1c:   0791                    addi    a5,a5,4
  1e:   9f31                    addw    a4,a4,a2
  20:   fee7ae23                sw      a4,-4(a5)
  24:   fef69be3                bne     a3,a5,1a <.L4>
  28:   2805                    addiw   a6,a6,1
  2a:   95c6                    add     a1,a1,a7
  2c:   96c6                    add     a3,a3,a7
  2e:   ff0515e3                bne     a0,a6,18 <.L3>

0000000000000032 <.L1>:
  32:   8082                    ret

// bad:
// riscv64-linux-gnu-gcc-10 test.c -Ofast -c -DBAD
0000000000000000 <matrix_add_const>:
   0:   882a                    mv      a6,a0
   2:   4301                    li      t1,0
   4:   4881                    li      a7,0
   6:   c505                    beqz    a0,2e <.L9>

0000000000000008 <.L2>:
   8:   871a                    mv      a4,t1

000000000000000a <.L4>:

,---- 计算偏移量时需要做无符号扩展
|    a:   02071793                slli    a5,a4,0x20
|    e:   83f9                    srli    a5,a5,0x1e
`----
  10:   97ae                    add     a5,a5,a1
  12:   4394                    lw      a3,0(a5)
  14:   2705                    addiw   a4,a4,1
  16:   9eb1                    addw    a3,a3,a2
  18:   c394                    sw      a3,0(a5)
  1a:   fee818e3                bne     a6,a4,a <.L4>
  1e:   2885                    addiw   a7,a7,1
  20:   0065033b                addw    t1,a0,t1
  24:   0105083b                addw    a6,a0,a6
  28:   ff1510e3                bne     a0,a7,8 <.L2>
  2c:   8082                    ret

rv64 下 uint32_t 需要显式的无符号扩展的场景有:

  1. uint32 -> 64

    例如:

    #include <stdint.h>
    
    int64_t foo(uint32_t N) { return N; }
    
    0000000000000000 <foo>:
       0:   1502                    slli    a0,a0,0x20
       2:   9101                    srli    a0,a0,0x20
       4:   8082                    ret
    
  2. 像上面的例子, 做为地址使用. 实际上也是 uint32_t -> 64, 因为 riscv 内存寻址时必须使用整个基地址寄存器的所有 bit (例如它没有办法只使用 a0 的低 32 位)

上面的问题最根本的原因是, int32_t/uint32_t 的在做为地址时需要扩展成 64bit, 而 riscv 大部分指令会自动做符号扩展, 所以针对 int32_t 的符号扩展可以省掉, 但无符号扩展则必须通过显式的 zext 类指令(例如 slli+srli), 所以 rv64 上涉及访存的变量用 int32_t 性能好于 uint32_t. 实际上我们应当使用 size_t 来索引数组(rv64 下 size_t 为 uint64_t, rv32 下 size_t 为 uint32_t)

1.4. 数据转型

许多情况下 sext/zext 都是因为参数和返回值的数据转型导致的, riscv psABI 中关于参数和返回值的 sext 规定:

Scalars that are at most XLEN bits wide are passed in a single argument register, or on the stack by value if none is available. When passed in registers or on the stack, integer scalars narrower than XLEN bits are widened according to the sign of their type up to 32 bits, then sign-extended to XLEN bits.

数据转型包括以下的情形:

,---- 64 -> 32 时需要 sext.w, 因为 psABI 规定比 XLEN 窄的返回值一定经过 sext, 即使是 uint32
|
| int32_t a0(int64_t a) { return a; }
| uint32_t a1(int64_t a) { return a; }
| int32_t a2(uint64_t a) { return a; }
| uint32_t a3(uint64_t a) { return a; }
|
| a0:
|         sext.w  a0,a0
|         ret
| a1:
|         sext.w  a0,a0
|         ret
| a2:
|         sext.w  a0,a0
|         ret
| a3:
|         sext.w  a0,a0
|         ret
`----

,---- int32 -> 64 不需要 sext.w, 因为 psABI 规定比 XLEN 窄的参数一定经过 sext
|
| int64_t b0(int32_t a) { return a; }
| uint64_t b1(int32_t a) { return a; }
|
| b0:
|         ret
| b1:
|         ret
`----

,---- uint32 -> 64 时需要 zext.w, 因为 psABI 没有规定 uint32 需要经过 zext
|
| int64_t b2(uint32_t a) { return a; }
| uint64_t b3(uint32_t a) { return a; }
|
| b2:
|         slli    a0,a0,32
|         srli    a0,a0,32
|         ret
| b3:
|         slli    a0,a0,32
|         srli    a0,a0,32
|         ret
`----

1.5. 其它

由于符号扩展会在高位加入符号位, 所以涉及 bit 操作时用 signed 则可能出错, 例如:

// 2023-04-27 20:48
#include <stdint.h>
#include <stdio.h>

int main(int argc, char *argv[]) {
    int8_t tmp = -1;
    int8_t sign = tmp & (1 << 7);
    /* NOTE: gcc 先把左侧 sign 符号扩展成 int32_t, 为 0xffffff80, 右侧也是以
     * int32_t 为计算, 导致结果为 0x00000080. 如果 int8_t 换成 uint8_t 则没有问
     * 题, 因为没有符号扩展. 所以 bit 操作最好用 unsigned */
    if (sign == (tmp & (1 << 7))) {
        printf("ok\n");
    }
    return 0;
}

综上, 由于有符号/无符号扩展的原因, riscv 用 signed int 可能性能好一些, 但 unsigned int 更不容易出一些奇怪的错误.

Author: [email protected]
Date: 2023-11-17 Fri 11:42
Last updated: 2024-01-15 Mon 11:10

知识共享许可协议