介绍#
最近正在学习伯克利的 CS61C 这门课,其中 Project2 是使用 RISC-V 实现手写数字识别。
听起来很复杂,但做起来其实还好,主要考验的是如何高效利用寄存器,使用汇编语言编写和调用函数,如何从堆栈上手动分配内存,以及使用 Venus 调试汇编程序的能力。
最后,只需要把它们连接起来,就能组成一个能对手写数字进行分类的人工神经网络 (ANN)
RISC-V Calling Conventions#
要说什么是 RISC-V 中编程最重要的部分,那非 Calling Convention 莫属了。它让函数能够自由地使用寄存器而不用担心产生错误,试想一下如果 A 函数要使用 s1 寄存器,但是此时 s1 寄存器里保存了 B 函数的重要的值,如果 A 函数把它改掉了那么 B 函数在使用它时就会发生错误。如果我们编程时需要时刻小心翼翼地检查我们要使用的寄存器是不是还被别的函数需要,那我们的编程会变成一场灾难。这时 Calling Convention 就能让我们的生活变得更美好!
在函数调用时我们把发起调用的函数称为 Caller,把被调用的函数称为 Callee。它们对寄存器都有不同的保存责任
Saved Registers (Callee Saved)#
- s0-s11(saved registers)
- sp(stack pointer)
这些寄存器对 Caller 来说十分重要,它在里面保存了一些重要的值,所以 Caller 希望在调用函数后这些值不会变化
这就对 Callee 产生了要求,它需要:
- 在栈上分配空间 (减 sp 寄存器)
- 把它要用的 s 寄存器保存在栈上
- 随意调用 s 寄存器
- 从栈上把原先的 s 寄存器值恢复
- 释放栈上的空间 (加 sp 寄存器)
这样 Callee 既向 Caller 保证了在调用它前后 s 和 sp 寄存器的值都是不变的,又让自己可以随意使用这些寄存器
Volatile Registers (Caller Saved)#
- t0-t6 (temporary registers)
- a0-a7 (arguments & return values)
- ra (return address)
这些寄存器 Callee 没有义务保存,因为它只会保存最重要的 s 寄存器。所以如果 Caller 需要它们,只能在调用函数前自行保存,在调用函数后自行恢复了
Structure of a Function#
所以根据我们的 Calling Convention,一个函数的结构应该是这样的:
首先这个函数作为 Callee,要保存会使用到的 s 寄存器。如果这个函数要调用别的函数,那么作为 Caller,它要保存自己需要的一些其他寄存器,在调用结束后恢复这些值,在函数结束时恢复 s 寄存器的值,最后跳转回被调用的地方
很简单的思想:所有的 Callee 都对 Caller 负责,那么当 Callee 自己当 Caller 时也不用担心自己的重要寄存器被篡改
神经网络#
我们要用 RISC-V 写一个神经网络来识别数字。简单来说,神经网络想要近似一个将输入映射到输出的非线性函数。在这个项目中,我们已经有了预训练的矩阵 $m_0$ 和 $m_1$,因此我们只需使用它们进行推理。我们的输入是 MNIST 数据集,其中包含 60,000 个 28x28 像素的图像,涵盖了手写数字 0-9
我们需要编写以下函数:
- relu:激活函数 $f (x)=max (0,x)$
- argmax: 返回向量中最大元素索引
- dot:向量点乘
- matmul:矩阵乘法
- read_matrix:读取矩阵文件
- write_matrix:写入矩阵文件
- calssify:调用以上函数连接各层
同时我们需要编写测试文件来测试程序的正确性,让我们用向量点乘举个例子
dot.s#
功能:将两个向量点乘
输入:
- a0 (int*) 指向 v0 第一个元素的指针
- a1 (int*) 指向 v1 第一个元素的指针
- a2 (int) 向量长度
- a3 (int) v0 的步长
- a4 (int) v1 的步长
返回值:a0 (int) 点乘的结果
代码:
dot:
bge x0, a2, exit_5
bge x0, a3, exit_6
bge x0, a4, exit_6
li t0 0 # loop counter
li t4 0 # dot product accumulator
#Multiply stride by 4 to get byte offset
slli a3 a3 2
slli a4 a4 2
loop_start:
beq t0, a2, loop_end
lw t1, 0(a0)
lw t2, 0(a1)
mul t3, t1, t2
add t4, t4, t3
add a0, a0, a3
add a1, a1, a4
addi t0, t0, 1
j loop_start
loop_end:
mv a0 t4
ret
这个点乘函数不需要调用任何函数,所以只作为 Callee,我们可以不使用任何 s 寄存器来取消保存寄存器到栈的这个环节,从而提高程序的运行速度
测试代码:
# Set vector values for testing
.data
vector0: .word 1 2 3 4 5 6 7 8 9
vector1: .word 1 2 3 4 5 6 7 8 9
.text
# main function for testing
main:
# Load vector addresses into registers
la s0 vector0
la s1 vector1
# Set vector attributes
addi a2, x0, 9
addi a3, x0, 1
addi a4, x0, 1
# Call dot function
mv a0, s0
mv a1, s1
jal ra, dot
# Print integer result
mv a1, a0
jal ra, print_int
# Print newline
li a1 '\n'
jal ra print_char
# Exit
jal exit
测试结果:
最终效果#
就这样实现一个一个的函数,并且在最后把它们统统放到一起,我们就实现了一个能分类手写数字的神经网络
当我们输入这张图片:
运行程序后就得到了我们的结果:
总结#
这次用 RISC-V 编写手写数字识别的项目提高了我编写汇编语言的能力,同时也锻炼了测试代码的编写能力。特别有意思的是编写完向量内积、矩阵乘法等简单的函数后可以实现一个功能强大的神经网络,十分有成就感
最后还是想感慨一下伯克利的分数设置,考试占 40% 的分数,分散为三次,可以很检测各阶段的学习情况而不是到期末前一周疯狂复习,四个有意思的项目 (汇编手写数字识别、画一个自己的 CPU……) 占 40%,作业实验加考勤占 20%,多元化且有意思,很好
活在当下!