用RISC-V矢量指令编程

2020-08-07 18:48:51

开放RISC-V指令集体系结构(ISA)中最有趣的部分可能是矢量扩展(RISC-V&34;V&34;)。与一般的单指令多数据(SIMD)指令集相比,RISC-V矢量指令是矢量长度不可知的(VLA)。因此,RISC-V&34;V&34;CPU可以灵活地选择矢量寄存器大小,而RISC-V&34;V&34;

本文比较了两种主要的向量ISA风格,讨论了使用RISC-V&34;V";Draft Version 0.8(截至2020年初的当前版本)向量指令实现的字符串处理示例,并详细说明了如何在Linux下设置RISC-V&34;V";开发环境。

对于特定于向量长度(VLS)的SIMD指令集,主要问题是选择正确的向量寄存器大小。当然,在数据级并行度和硬件成本之间存在权衡。此外,一些用户对具有更宽向量寄存器的强大CPU感兴趣,而普通用户对中等大小的寄存器是没问题的。这以x86为例说明,其中的答案是提供一个接一个的VLS ISA,诸如MMX(64位寄存器)、SSE(128位)、AVX(256位)和AVX512(512位)。因此,对于x86,答案是提供一个接一个的VLS ISA,诸如MMX(64位寄存器)、SSE(128位)、AVX(256位)和AVX512(512位)。因此,对于x86,答案是提供一个接一个的VLS ISA,例如MMX(64位寄存器)、SSE(128位)、AVX(256位。

由于向后兼容,每个添加新的VLS ISA的CPU还必须支持所有现有的VLS ISA。这会导致操作码空间的浪费,并增加CPU指令解码器的复杂度。当然,这也增加了程序员的复杂度,他们随后会记住(或一直查找)所有VLS ISA之间的句法和功能差异。

这意味着,虽然为较小的向量寄存器编写的VLS代码在较新的CPU上运行,但它不能利用较宽的向量寄存器。因此,必须一次又一次地重新实现现有代码才能使用新的VLS ISA。类似地,为高端CPU编写的代码不能在中端CPU上运行(因为它需要具有更宽向量寄存器的VLS-ISA)。因此,人们要么必须瞄准一些较旧的(希望广泛使用的)VSL-ISA,要么已经将目标对准了一些较老的(希望广泛使用的)VSL-ISA。

所有这一切的解决方案是设计一个可变长度的向量指令集,这样指令就与具体CPU实现的向量寄存器大小无关,因此二进制代码可以在低端、中端和高端CPU之间移植,并自动利用较新CPU中更宽的寄存器。

RISC-V矢量扩展";V&34;实现了这样的矢量指令集。从2020年初开始,RISC-V&34;V&34;规范的版本为0.8,处于草稿状态。

RISC-V&34;V&34;增加32个向量寄存器,其中第一个寄存器可用作屏蔽寄存器,最多可将8个寄存器组合在一起。向量指令(如vadd.vv)的操作数是单个向量寄存器或向量寄存器组。

由于矢量寄存器的长度可变,因此RISC-V&34;V&34;代码必须指示其想要使用的最大矢量长度,例如:

这意味着当指令在寄存器t0中返回结果长度时,请求高达a2个8位宽(E8)元素的向量长度(VL)。因此,如果A2寄存器被设置为-比方说-4096,则在具有128位的向量寄存器长度(Vlen)的CPU上,以下向量指令对16个元素宽的向量起作用,因此t0被设置为16,而在具有512位寄存器的CPU上,向量被配置为64个元素宽,并且t0被设置为64。

此方法还简化了以向量长度块迭代输入数组的循环。例如(其中a1包含2乘以4字节的数组的地址):

.Loop:#由于.L前缀vsetvli t0,a2,e32的本地符号名称#配置32位元素vlw.v4的向量,(A1)#将t0个元素加载到v4中,#从存储在a1中的地址开始...#使用该块slli t1,t0,2#左移逻辑,即时间4添加a1,a1,t1#通过读取元素suba2,a2,t0递增src#递减n bnez a2,.Loop#如果不等于零,则分支到循环头...#继续。

如果a2不是最大向量长度的倍数,则最后一次迭代会将向量长度设置为较小的值,随后的向量指令会忽略未使用的尾随元素。这种隐式掩码机制与大多数RISC-V向量指令支持的可选掩码操作数正交。

与此形成对比的是,对于向量长度特定的ISA,主循环之后通常必须跟随一些终结代码块,以显式处理未填满完整寄存器的最后元素,例如:

Const unsign char*p=inp;size_t l=n/(Vector_length*element_bytes);for(size_t i=0;i<;l;++i,p+=Vector_length*element_bytes){...//将p加载到向量寄存器中...//执行一些向量指令}//处理一些剩余字节//例如通过为(size_t i=l;I<;n;++i,p+=ELEMENT_BYTES){...//处理位于p}的下一个元素。

为了用一个真实的例子来说明RISC-V&V#34;这一节展示了如何实现一个将二进制编码小数(BCD)字符串转换成ASCII字符串的矢量化函数。为什么要将BCD转换成ASCII字符串?这项任务非常复杂,以至于使用了大多数不同的向量指令。另一方面,它足够简单,可以放入一篇小文章中,并且不需要特定领域的知识。它还演示了一些可能不是很明显的内容

使用BCD,一个字节(8位)被分成两个半字节(4位),使得每个半字节存储一个(十六进制)十进制数字。请注意,4位允许对24个值进行精确编码,因此当它仅用于存储十进制数字时,它不是一种非常有效的编码。

就我们的示例而言,练习是编写矢量代码,将诸如{0x12,0x34,...,0xcd,0xef}之类的BCD字符串有效地转换为相应的ASCII字符串(例如,{';1';,';2';,';3';,';4';,...,';c';,';d';,';,';f';})。在较高级别上,解决方案包括将半字节分离为单个字节,然后将每个字节转换为匹配的ASCII值。

这意味着从src读取n个输入字节,并且转换将2*n个字节写入dst输出缓冲器。在RISC-V调用约定下,dst被传递到寄存器a0中,src被传递到寄存器a1中,而n被传递到寄存器a2中。

.Loop:#本地符号名称,因为.L前缀vsetvli a3,a2,e16,m8#切换到16位元素大小,#4组8个寄存器#-->;a3=min(a2,8*vlenb/2)vlbu.v v16,(A1)#加载a3无符号字节,#每16位元素一个字节,零扩展,#从存储在a1中的addr开始#-->;V16=|0,a1[vlenb/2-1],...,0,a1[1],0,a1[0]|,...,#v23=|0,a1[a3-1],...,0,a1[7*vlenb/2]|#-->;v16=|...00MN 00kl 00ij 00gh|添加a1,a1,a3#通过读取元件suba2,a2,a3#递增src。

主循环首先配置16位的向量元素大小(E16),将8个寄存器分组在一起(M8),并请求等于剩余源字节数或CPU最大值的向量长度。在此分组中,通过使用可被8整除的向量寄存器来访问每个寄存器组。也就是说,V0标识由V0、V1、...、V7组成的组,V8标识V8、...、V15等。

VL*.v LOAD指令有不同的变体。这里,vlbu.v变量为每个16位元素扩展每个输入字节,这在我们的示例中很有用,因为这直接为半字节的混洗留出了空间。换句话说,它是一个加宽加载,从而节省了单独的加宽操作,如vwaddu.vx。

这意味着在具有256位矢量寄存器的CPU上,此代码将最多128个输入字节加载到v16寄存器组。

请注意,注释中的寄存器内容括在||中,从最低有效元素开始从右向左书写。任意半字节有时由占位符变量(如g、h、...)表示。

Vsll.vi v24、v16、8#将每个元素向左移位8位逻辑#-->;v24=|...。Mn00 kl00 ij00 gh00|vsrl.vi v16,v16,4#移位-右移-逻辑每个元素4位#-->;V16=|...000m 000k 000i 000g|slli A3,A3,1#按立即数左移逻辑,#即将矢量元素vsetvli T4,A3,E8,M8#的数量翻倍,切换到8位元素大小,#4组8个寄存器vand.vx v24,v24,T2#,每个元素具有0x0f,#,即将高位字节#-->置零;V24=|...0n 00 0l 00 0j 00 0h 00|vor.vv v16、v16、v24#或每个元素#-->;v16=|...0n 0m 0l 0k 0j 0i 0h 0g|。

到目前为止,该示例显示了ISA的大多数语法约定。向量指令以v开头,后缀(如.vi、.vx和.vv)描述源操作数类型,即向量立即数、向量标量和向量向量。

位移位指令不会跨越元素边界,因此,只需对向量组v24进行零掩码,而不需要对v16进行掩码。掩码位于循环开始前设置的寄存器T2中。

此时将矢量寄存器配置切换到8位元素(E8)允许使用0xf作为掩码值,而不是较大的0xf00。因此,它适合加载立即指令的立即操作数,例如保存一条附加指令(即addi t2、0、15)。它甚至适合压缩加载立即指令的立即操作数,它只编码成两个字节(即c.li),而不是常规的四个字节。

这里,向量组V8用作查找ASCII值的表。这意味着V8查找表将整数{0,1,2,...,0xd,0xe,0xf}映射到ASCII字符{';0';,';1';,';2';,...,';d';e';,';f'。

Li a6,16#加载立即数(伪指令)vsetvli t0,a6,e8,m8#切换到8位元素大小,#即4组8个寄存器。v v8#存储向量元素索引,#即v8=|16,...,2,1,0|vmsgtu.vi v0,v8,9#如果大于无符号立即数#-->,则设置掩码位;V0=|1,1,1,1,1,0,0,0,0,0,0,0,0,48#立即加载,即vadd.vx v8,v8,A7#将标量添加到每个元素addi A7,A7,-9#添加立即数,即设置为39==#39;a';-';0';-10,#即到达a&39;a';,b';,……。Vadd.vx v8、v8、A7、v0.t#用于附加偏移量的屏蔽添加。

为16个元素的向量配置8个寄存器的分组可能看起来有点矫枉过正,因为128位向量寄存器就足够了,应该可以广泛使用。另一方面,可能有一个CPU只支持实现64位向量寄存器,而我们需要对2个寄存器进行分组。由于可能需要这样的分组,因此在这里配置最大值并不会有什么坏处。

V0.t语法只是将V0用作掩码的标记。请注意,即使配置了寄存器组,掩码也始终只由一个向量寄存器组成。对于当前的";V";0.8草稿,V0寄存器是掩码操作数的唯一有效选择。

与前面类似,值39是用addi构造的,而不是用伪指令li直接将其加载到另一个寄存器中,因为-9适合于压缩的c.addi指令的立即操作数。

Vsb.v v24,(A0)#将结果写入dst#-->;a0[0]=v24[0],a0[1]=v24[1],...,a0[vl-1]=v24[vlenb-1],...,#a0[vlenb*7]=v31[0],...,a0[t0-1]=v31[vlenb-1]#-->;at。,';h';,';i';,';j';,';k';,';l';,';m';,';n';]如果不等于零ret,则添加a0,a0,t3#增量DST bnez a2,.Loop#分支到循环头。

如果处理完整个输入缓冲区,则会留下循环与函数。请注意,虽然大多数RISC-V指令的语法遵循目标-源顺序,但存储指令的顺序是颠倒的。

RISC-V&34;V&34;矢量扩展ISA具有足够的多样性,因为它包含有用的位和字节洗牌指令、允许屏蔽元素的指令以及实现对字符串处理(如元素收集和加宽)有用的操作的指令。

可用的指令与矢量长度不可知(VLA)设计相结合产生了紧凑的代码,例如,主循环的每次迭代只执行14条指令,并且不需要额外的代码来处理尾部字节。

这样实现的吞吐量非常好,即生成的二进制代码自动利用每个CPU上完整的向量寄存器大小,无论是低端还是高端。此外,向量寄存器的分组允许增加吞吐量,因为有许多可用的寄存器。例如,在具有128位向量寄存器的CPU上,每个指令的循环吞吐量为9位。

例如,所呈现的bcd2ascii函数具有96字节的大小。当在汇编期间启用压缩指令扩展(使得某些指令可以被压缩的2字节版本取代)时,大小下降20%至76字节。这很好,特别是考虑到该函数的大多数指令是向量指令并且没有压缩变体。

这可以与x86-64形成对比,例如,在x86-64中,SSSE 3随机指令编码为5字节,一些移动编码为7字节。当然,当使用SSSE 3作为最低公分母SIMD ISA时,矢量长度固定为128位。

发出压缩的RISC-V指令对汇编器程序员来说是某种透明的,人们只需设置汇编器命令行选项。但是,当然,由于压缩指令实现了折衷(否则为什么不是所有指令都被压缩?!),程序员必须注意以可压缩的方式写入指令,在可能的情况下。例如,一些压缩指令仅在寄存器子集(例如,s0..s1,a0..5)上工作,一个源操作数是隐式的,在该寄存器子集(例如,s0..s1,a0..5)上有一个源操作数是隐含的。例如,如果可能,某些压缩指令仅在寄存器子集(例如s0..s1,a0..5)上工作,一个源操作数是隐含的。

由于截至2020年初,矢量扩展仍处于草案状态,并且最近刚刚发布0.8版,因此对它支持并不广泛。这意味着没有配备RISC-V&34;V&34;CPU的硬件可用,但一些知名的RISC-V模拟器(如QEMU)不支持";V&34;扩展或仅支持较旧版本的";V&#。标准开发工具链(binutils,GCC)的第0.8版已经可用,但还没有上行,这意味着人们必须查找存储库,识别正确的分支,并使用正确的标志编译这些分支,而不仅仅是能够使用发行版软件包。

另一个缺陷是,必须通过设置状态寄存器在运行的系统中启用";V&34;扩展(类似于";F";和";D";浮点扩展)。因为状态寄存器只能在机器/系统模式下访问,这意味着还需要内核支持";V&34;扩展。

本节详细介绍如何构建RISC-V&34;V&34;0.8工具链和仿真器所需的不同组件。

Spike RISC-V仿真器确实支持0.8版。截至2020年初,还有一个仿真器支持0.8版,但它不是开源的。

Sudo DNF install Dtc#即设备树编译器克隆https://github.com/riscv/riscv-isa-sim.git--深度1 cd riscv-isa-simmkdir build CD build../CONFIGURE--PREFIX=$HOME/LOCAL/riscvv08/Spikemakemake install。

默认情况下,SPEKE启用RV64IMAFDC ISA,但此默认值可以在运行时更改(甚至可以在配置时更改)。例如,当我们这样调用Spike时:

从技术上讲,具有扩展支持的binutils足以组装我们的示例,但是,构建代理内核需要完整的GNU工具链。

Git克隆https://github.com/riscv/riscv-gnu-toolchain.git--分支rvv-0.8.x\--单分支-深度1 riscv-gnu-toolchain_rvv-0.8.x cd riscv-gnu-toolchain_rvv-0.8.xgit子模块更新--初始化--递归--深度1 riscv-binutils riscv-GCC\riscv-glibc riscv-dejagnu riscv-newlib riscv-gdbm。

显式的子模块更新是这样做的,以跳过可选的QEMU模块。除了QEMU不支持";V&34;扩展之外,它还需要更深的克隆,会占用一些磁盘空间并浪费一些编译时间。

请注意,make install步骤是多余的,因为前面的make调用已经安装了所有内容。

RISC-V Proxy-Kernel(PK)实现了足以让用户空间程序在Spike中运行的功能,包括在机器模式下设置一些状态寄存器、切换到用户模式以及实现一些syscall,这意味着调用write syscall来写入stdout,然后在Spike中工作,并将文本打印到控制台。

GIT克隆-深度1 https://github.com/riscv/riscv-pk.git cd riscv-pkmkdir build CD build path=$HOME/LOCAL/riscvv08/gnu/bin:$PATH../CONFIGURE--PREFIX=$HOME/LOCAL/riscvv08/pk\--HOST=riscv64-UNKNOWN-ELF PATH=$HOME/LOCAL/riscvv08/gnu/bin:$PATH make path=$HOME/local/riscvv08/gnu/bin:$path make install。

如果您已经拥有GNU工具链,则可以跳过此步骤(因为它已经包含具有支持的二进制文件)。如果您已获得二进制形式的Proxy-Kernel支持,并且希望跳过构建GNU工具链,则这一点很重要。

Git克隆https://github.com/riscv/riscv-binutils-gdb.git--分支rvv-0.8.x\--单分支--深度1 risv-binutils-gdb_rvv-0.8.xmkdir build CD build../CONFIGURE--PREFIX=$HOME/local/riscvv08/binutils--target riscv64-UNKNOWN-ELF\--ENABLE-multilibmakemake install。

最后,要实际执行我们的示例,需要一个小测试程序,它使用一些样本输入调用bcd2ascii()函数并打印结果。如果有完整的GNU工具链,最简单的事情是用C语言编写该部分,例如:

#include<;stddef.h>;void bcd2ascii(void*dst,const void*src,size_t n);静态常量无符号字符INP[]={0x01,0x23,0x45,0x67,0x89,0xab,0xcd,0xef,0xfe,0xdc,0xba,0x98,0x76,0x54,0x32,0x10,0x01,0x23,0x45,0x67,0x89,0xab,0xcd,0xef,0xfe,0xdc。Int main(){char out[sizeof INP*2+1]={0};//预期输出://out={';0';,';1';,';2';,';3';,...}bcd2ascii(out,inp,sizeof INP);put(Out);return 0;}。

或者,在没有C交叉编译器但交叉二进制文件的情况下,我们需要一个汇编测试程序,例如:

.text#start text段.balign 4#按4字节对齐4字节指令.global_start#global_start:#检查是否启用了向量扩展#user-m。

.