1.前言
RISCV V扩展即向量指令扩展(RVV),这部分作为研究AI加速计算领域有着非常关键的作用。既然的D1支持了rvv扩展(0.7.1,最新的版本已经0.10版本),那么就实际的从底层原理角度分析一下使用的流程。利用了多媒体加速指令集,可以让计算变得更加的高效,同时并行计算的特性使得同时多次计算一组数字成为可能,类似于arm的NEON等等,那么RISCV又该如何去开启和使用V扩展指令,让计算变得更加高效呢?
下面会通过一个裸机代码入手,结合实战去展示riscv rvv的使用。
https://github.com/bigmagic123/d1-nezha-baremeta/tree/main/src/2.vector_example
2.机器模式处理器状态寄存器(MSTATUS)
机器模式状态处理寄存器可以查看玄铁C910的用户手册,开启的V扩展的位是[23:24]位,如果不设置这两位,那么使用V扩展指令的时候,会出现指令未定义的异常。
这里需要注意的是,RISCV的各家的VS标志并不是一定是这两位,比如sifive会定义在
但是无论怎么说,都需要设置机器状态控制器去开启v扩展指令的支持。
/* Enable FPU and accelerator if present */
li t0, MSTATUS_FS | MSTATUS_XS | (0x01800000)
csrs mstatus, t0
在启动代码中,通过0x01800000设置mstatus开启V扩展支持。
3.编译选项支持V扩展
默认情况下,平头哥提供的交叉编译工具链已支持了V扩展的编译。只需要在编译选项中开启即可。
从传递给riscv 的gcc的选项来看,带有v扩展即可。
-march是指定了riscv的模块化的指令集选项,可以通过选项指定目标RISC-V支持的模块化的指令集的组合。比如下面几种组合。
rv32i[m][a][f[d]][c]
rv32g[c]
rv64i[m][a][f[d]][c]
rv64g[c]
往往也会结合-mabi进行使用。-mabi决定了RISCV目标支持的ABI函数调用的规程。
4.RISCV向量计算的原理
在riscv的V扩展中,一共定义了32个寄存器,v0~v31,这32个寄存器,每个长度都是VLEN长度。在玄铁C906定义长度为128位。
而在V扩展的操作中,需要扩展下面的寄存器组。
下面来具体分析一些每个寄存器的作用。
vstart
矢量起始位置寄存器指定了执行矢量指令时起始元素位置,每条矢量指令执行后 VSTART 会被清零。
该寄存器只有在处理器进入陷阱或者中断状态时,才会被硬件写入。
所以的向量指令都会从vstart中给定的元素编号开始执行,支持完成后,自动变为0。
为什么会有这个寄存器,原因是在V扩展指令中,每个寄存器是可以分割与合并的,并不是单独操作。
vxsat
这个是向量定点的饱和标志位,该位指示定点指令是否必须使输出值饱和,以此适应目标格式。
vxrm
向量定点舍入模式寄存器,指定了定点指令采用的舍入模式。
vl
矢量长度寄存器指定了矢量指令更新目的寄存器的范围,矢量指令更新目的寄存器中元素序号小于 VL 的元素,清零目的寄存器中元素序号大于等于 VL 的元素。特别的,当 VSTART>=VL 或 VL 为 0 时,目的寄存器的所有元素不 被更新。该寄存器是任意模式下的只读寄存器,但是 vsetvli、vsetvl 以及 fault-only-first 指令能够更新该寄存器的值。
该寄存器的值是通过vsetvli/vsetvl指令自动设置的。
vtype
VTYPE 寄存器指定了矢量寄存器组的数据类型以及矢量寄存器的元素组成。
通过C910的数据手册,可看出
向量长度寄存器VLENB
该寄存器用于表示矢量寄存器的数据位宽,以实际位宽除以 8 得到的字节数体现。C906 矢量寄存器为 128 位,因此 VLENB 值固定为 16。该寄存器位长是 64 位,用户模式只读。
5.通过实例分析RISCV V扩展的运作机制
下面一个rvv实际的函数
void test_v(void)
{
float a[]={1.0,2.0,3.0,4.0};
float b[]={1.0,2.0,3.0,4.0};
float c[]={0.0,0.0,0.0,0.0};
int len=4;
int i=0;
//inline assembly for RVV 0.7.1
//for(i=0; i asm volatile(
"mv t4, %[LEN] nt"
"mv t1, %[PA] nt"
"mv t2, %[PB] nt"
"mv t3, %[PC] nt"
"LOOP1: nt"
"vsetvli t0, t4, e32,m1 nt"
"sub t4, t4, t0 nt"
"slli t0, t0, 2 nt" //Multiply number done by 4 bytes
"vle.v v0, (t1) nt"
"add t1, t1, t0 nt"
"vle.v v1, (t2) nt"
"add t2, t2, t0 nt"
"vfadd.vv v2, v0, v1 nt"
"vse.v v2, (t3) nt"
"add t3, t3, t0 nt"
"bnez t4, LOOP1 nt"
:
:[LEN]"r"(len), [PA]"r"(a),[PB]"r"(b),[PC]"r"(c)
:"cc","memory", "t0", "t1", "t2", "t3", "t4",
"v0", "v1", "v2"
);
for(i=0; i printf("n");
printf("%fn",c);
printf("n");
}
}
这里采用的是内联汇编,可以更加深入的分析RVV的运作机制和底层原理。
在riscv中,内联汇编的写法
asm volatile("nop");
这样编译器在编译后会生成可以执行的汇编代码。
该函数的功能
for(i=0; i<len; i++){c[i]=a+b;}
通过上述分析,通过向量计算,可以一次性计算出上面四次循环加法。
vsetvli t0, t4, e32,m1
vsetvli表示设置每个向量的长度,t4的值表示的是len,也就是4。
e32表示每个元素为32位,m1表示使用1倍数量的向量寄存器。
该条指令相当于把一个向量寄存器(128位)分成四等分,这是一条设置指令,设置vl寄存器。返回值为t0,这里由于是刚好装下4条32位的数字,所以返回值为4。
sub t4, t4, t0
通过查看数组是否计算完成,来进行循环计算,这里t4为0了。
slli t0, t0, 2
往左移动两位,也就是将t0乘以4。这里计算的目的是如果存在很长的数组,可以偏移t0个字节从而指向数组的下个地址。
vle.v v0, (t1)
填充向量寄存器(t1)为a数组,一条指令将数据放到向量寄存器v0中。
add t1, t1, t0
将a数组的起始元素加上16字节(4个元素)的偏移。
vle.v v1, (t2)
填充b数组的数组到向量寄存器v1中。
add t2, t2, t0
将数组b的元素的起始地址偏移16字节,也就是4个元素。
vfadd.vv v2, v0, v1
执行向量加法,将向量的结果保存到向量寄存器v2中。
vse.v v2, (t3)
将向量寄存器中值写回到c数组中。
add t3, t3, t0
将数组c的元素指针偏移4个元素。
bnez t4, LOOP1
直到计算的len长度为0,此时跳出循环计算。
由于此时计算只有4字节,所以一次循环就计算完成了,不用多次计算。
采用向量寄存器的计算,可以把四次循环计算用一次计算就完成。当然这种如果大量计算时,才能体现出更大的优势。
最后的结果如下:
通过对数组的计算
float a[]={1.0,2.0,3.0,4.0};
float b[]={1.0,2.0,3.0,4.0};
float c[]={0.0,0.0,0.0,0.0};
最后c数组的结果
float c[]={2.0,4.0,6.0,8.0};
其理论数据和实际数据一样。
6.RVV使用体验
刚接触到riscv 的 V扩展编程时,很多概念都理解的很模糊,感觉十分的困难,通过一段时间梳理之后,发现和以前mips上接触的mxu或者arm的neno使用上大多数是一样的,就需要去设置使用寄存器的长度,当然这些底层函数如果进行一层封装后,再给用户使用,那才是比较方便的,但是本文只是介绍底层实现的原理,并不多介绍使用的细节。
RVV还有一个特性就是寄存器的扩充,比如D1采用的玄铁C906的核,支持的是32个128位的向量寄存器,也可以将两个或多个向量寄存器拼成一个来使用。这样寄存器的长度更加长,能够同时做到并行计算也就更多。这取决于如何做向量的优化设计。
|