介紹#
最近、私はバークレーの CS61C という授業を勉強しています。その中の Project2 では、RISC-V を使用して手書き数字の認識を実装します。
聞こえは複雑ですが、実際にはそれほど難しくありません。主にレジスタの効率的な使用方法、アセンブリ言語での関数の書き方と呼び出し方、スタックからの手動メモリ割り当ての方法、およびアセンブリプログラムのデバッグ能力をテストします。
最後に、それらを組み合わせるだけで、手書きの数字を分類することができる人工ニューラルネットワーク(ANN)が完成します。
RISC-V 呼び出し規約#
RISC-V プログラミングで最も重要な部分は、呼び出し規約です。これにより、関数は寄存器を自由に使用できるため、エラーが発生する心配はありません。たとえば、関数 A が s1 レジスタを使用する必要がある場合、しかし、この時点で s1 レジスタには関数 B の重要な値が保存されている場合、関数 A がそれを変更すると、関数 B がそれを使用する際にエラーが発生します。使用する寄存器が他の関数に必要かどうかを常に慎重にチェックする必要がある場合、プログラミングは災害になります。このような場合、呼び出し規約により、私たちの生活がより良くなります!
関数呼び出し時に、呼び出し元の関数を Caller、呼び出される関数を Callee と呼びます。それぞれの関数には、異なるレジスタの保存責任があります。
保存されるレジスタ(Callee Saved)#
- s0-s11(保存されるレジスタ)
- sp(スタックポインタ)
これらのレジスタは Caller にとって非常に重要であり、いくつかの重要な値が保存されていますので、Caller はこれらの値が変化しないことを望んでいます。
これにより、Callee には次の要件が生じます:
- スタック上にスペースを割り当てる(sp レジスタを減らす)
- 使用する s レジスタをスタック上に保存する
- s レジスタを自由に使用する
- スタックから元の s レジスタの値を復元する
- スタック上のスペースを解放する(sp レジスタを増やす)
これにより、Callee は Caller に対して、呼び出し前後の s および sp レジスタの値が変わらないことを保証し、自身がこれらのレジスタを自由に使用できるようにします。
ボラティルレジスタ(Caller Saved)#
- t0-t6(一時レジスタ)
- a0-a7(引数および戻り値)
- ra(戻りアドレス)
これらのレジスタは Callee が保存する義務はありません。なぜなら、Callee は最も重要な s レジスタのみを保存するからです。したがって、Caller がこれらのレジスタを必要とする場合、Caller 自身が保存し、関数呼び出し後に復元する必要があります。
関数の構造#
したがって、呼び出し規約に基づいて、関数の構造は次のようになります:
まず、この関数は Callee として、使用する s レジスタを保存する必要があります。この関数が他の関数を呼び出す場合、Caller として、自身が必要とする他のいくつかのレジスタを保存し、呼び出し終了後にこれらの値を復元し、関数終了時に s レジスタの値を復元し、最後に呼び出し元の場所にジャンプします。
非常にシンプルな考え方です:すべての Callee は Caller に責任を持ちますので、Callee 自身が Caller として機能する場合でも、重要なレジスタが改ざんされる心配はありません。
ニューラルネットワーク#
私たちは RISC-V を使用して数字を認識するニューラルネットワークを作成する必要があります。簡単に言えば、ニューラルネットワークは入力を出力にマッピングする非線形関数の近似を目指します。このプロジェクトでは、事前にトレーニングされた行列 $m_0$ と $m_1$ がありますので、それらを使用して推論を行うだけです。入力は MNIST データセットであり、手書き数字 0-9 の 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 # 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
この dot 関数は他の関数を呼び出さないため、Callee としてのみ機能するため、レジスタをスタックに保存する必要がないため、プログラムの実行速度が向上します。
テストコード:
# 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%の重みであり、3 回に分散されているため、学習の進捗状況を適切に評価することができます。最終試験前の 1 週間だけではなく、興味深い 4 つのプロジェクト(アセンブリ手書き数字認識、独自の CPU の設計など)が 40%を占め、課題、実験、出席が 20%を占めるため、多様で興味深い評価方法です。
現在を生きることを忘れずに!