イントロダクション#
最近、バークレーの CS61C というコースを学んでおり、その中の Project2 は RISC-V を使用して手書き数字認識を実装するものです。
複雑に聞こえますが、実際にはそれほど難しくなく、主にレジスタを効率的に利用する方法、アセンブリ言語で関数を記述し呼び出す方法、スタックから手動でメモリを割り当てる方法、そして Venus を使用してアセンブリプログラムをデバッグする能力が試されます。
最後に、それらをつなげるだけで、手書き数字を分類する人工神経ネットワーク(ANN)を構成することができます。
RISC-V 呼び出し規約#
RISC-V におけるプログラミングで最も重要な部分は、呼び出し規約です。これにより、関数はエラーを心配することなくレジスタを自由に使用できます。例えば、A 関数が s1 レジスタを使用したい場合、s1 レジスタに B 関数の重要な値が保存されているとします。もし A 関数がそれを変更してしまった場合、B 関数がそれを使用する際にエラーが発生します。プログラミング中に使用するレジスタが他の関数によって必要とされているかどうかを常に注意深く確認しなければならない場合、私たちのプログラミングは災難になってしまいます。この時、呼び出し規約が私たちの生活をより良くしてくれます!
関数呼び出しの際、呼び出しを行う関数を Caller、呼び出される関数を Callee と呼びます。これらはレジスタに対して異なる保存の責任を持っています。
保存されたレジスタ(Callee 保存)#
- s0-s11(保存されたレジスタ)
- sp(スタックポインタ)
これらのレジスタは Caller にとって非常に重要であり、重要な値が保存されているため、Caller は関数を呼び出した後にこれらの値が変わらないことを望みます。
これがCallee に要求を生じさせます。Callee は以下を行う必要があります:
- スタック上にスペースを割り当てる(sp レジスタを減少させる)
- 使用する s レジスタをスタックに保存する
- s レジスタを自由に呼び出す
- スタックから元の s レジスタの値を復元する
- スタック上のスペースを解放する(sp レジスタを増加させる)
これにより、Callee は Caller に対して、呼び出し前後で s および sp レジスタの値が変わらないことを保証しつつ、自身はこれらのレジスタを自由に使用できます。
ボラタイルレジスタ(Caller 保存)#
- t0-t6(テンポラリレジスタ)
- a0-a7(引数および戻り値)
- ra(戻りアドレス)
これらのレジスタは Callee が保存する義務はありません。なぜなら、Callee は最も重要な s レジスタのみを保存するからです。したがって、Caller がそれらを必要とする場合、関数を呼び出す前に自分で保存し、関数呼び出し後に自分で復元する必要があります。
関数の構造#
したがって、私たちの呼び出し規約に基づくと、関数の構造は次のようになります:
まず、この関数は Callee として、使用する s レジスタを保存する必要があります。この関数が他の関数を呼び出す場合、Caller として、必要な他のレジスタを保存し、呼び出しが終了した後にこれらの値を復元し、関数終了時に s レジスタの値を復元し、最後に呼び出された場所にジャンプします。
非常にシンプルな考え方:すべての Callee は Caller に責任を持つため、Callee が自ら Caller になるときも、自分の重要なレジスタが改ざんされる心配はありません。
ニューラルネットワーク#
私たちは RISC-V を使用して数字を認識するニューラルネットワークを作成します。簡単に言うと、ニューラルネットワークは入力を出力にマッピングする非線形関数を近似したいと考えています。このプロジェクトでは、すでに事前にトレーニングされた行列 $m_0$ と $m_1$ があるため、それらを使用して推論を行うだけです。私たちの入力は MNIST データセットで、手書き数字 0-9 をカバーする 60,000 の 28x28 ピクセルの画像が含まれています。
以下の関数を作成する必要があります:
- relu:活性化関数 $f (x)=max (0,x)$
- argmax:ベクトル内の最大要素のインデックスを返す
- dot:ベクトルのドット積
- matmul:行列の乗算
- read_matrix:行列ファイルを読み込む
- write_matrix:行列ファイルに書き込む
- classify:上記の関数を呼び出して各層を接続する
同時に、プログラムの正確性をテストするためのテストファイルを作成する必要があります。ベクトルのドット積を例に挙げてみましょう。
dot.s#
機能:2 つのベクトルのドット積を計算する
入力:
- 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 # ループカウンタ
li t4 0 # ドット積アキュムレータ
# ストライドを4倍してバイトオフセットを取得
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 としてのみ機能し、スタックにレジスタを保存する必要がないため、プログラムの実行速度を向上させることができます。
テストコード:
# テスト用のベクトル値を設定
.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:
# ベクトルのアドレスをレジスタにロード
la s0 vector0
la s1 vector1
# ベクトルの属性を設定
addi a2, x0, 9
addi a3, x0, 1
addi a4, x0, 1
# dot関数を呼び出す
mv a0, s0
mv a1, s1
jal ra, dot
# 整数結果を印刷
mv a1, a0
jal ra, print_int
# 改行を印刷
li a1 '\n'
jal ra print_char
# 終了
jal exit
テスト結果:
最終的な効果#
このようにして関数を一つずつ実装し、最後にそれらをすべてまとめることで、手書き数字を分類するニューラルネットワークを実現しました。
この画像を入力すると:
プログラムを実行すると、私たちの結果が得られました:
まとめ#
RISC-V を使用して手書き数字認識のプロジェクトを作成することで、アセンブリ言語の能力が向上し、テストコードの作成能力も鍛えられました。特に、ベクトル内積や行列乗算などの簡単な関数を作成した後に、強力なニューラルネットワークを実現できることに大きな達成感を感じました。
最後に、バークレーの成績設定について感慨を述べたいと思います。試験は 40% の成績を占め、3 回に分散されており、各段階の学習状況を検査できるため、期末前の 1 週間での狂った復習を避けることができます。4 つの興味深いプロジェクト(アセンブリ手書き数字認識、自分の CPU を描くなど)が 40% を占め、課題実験と出席が 20% を占め、多様で面白く、とても良いです。
今を生きよう!