FPGA開発日記

カテゴリ別記事インデックス https://msyksphinz.github.io/github_pages , English Version https://fpgadevdiary.hatenadiary.com/

RISC-VプロセッサHiFive1で機械学習コードを動作させる(6. 高速化の検討)

RISC-VプロセッサHiFive1でMNISTを実行させている。現状の問題点としては、いろいろなところが遅いことだ。 予想よりも性能が出ない。いろいろ試行した結果、やはり問題としては学習データがCPUに対して遠いところにある事だろう。 つまり、固定値の学習データをフラッシュメモリの上に置いており、そこにデータを毎回取りに行っている。 行列式の計算なので、縦方向の計算になり、あらかじめ局所的な部分をあらかじめL1キャッシュにロードしておくのも難しい。 何とかして少しでも高速化できないだろうか。

固定値の中でも、小さなものはL1に置きなおすことができるかもしれない。 現状、固定値としてフラッシュメモリに置いているのは、

  • 入力データ (28x28バイトx100個分)
  • MNIST学習済みデータ1. (28x28x50x4バイト : 50はHidden Layerのサイズ)
  • MNIST学習済みデータ2. (50x4バイト : 50はHidden Layerのサイズ)
  • MNIST学習済みデータ3. (50x10x4バイト : 10は出力層のサイズ)
  • MNIST学習済みデータ4. (10x4バイト : 10は出力層のサイズ)

ということで、とりあえず一番大きいのは最初の学習済みデータな訳だが、これを全部L1キャッシュに入れるのはちょっと無理だ。 それ以外のものなら入るかもしれない。プログラムを実行する前に、これらの学習済みデータをL1キャッシュにコピーしてみよう。

もともとのフラッシュメモリに格納されたデータは、以下のようにして参照している。

extern char _binary_wb0_bin_start[];
extern char _binary_wb0_bin_end[];
extern char _binary_wb1_bin_start[];
extern char _binary_wb1_bin_end[];
extern char _binary_wh0_bin_start[];
extern char _binary_wh0_bin_end[];
extern char _binary_wh1_bin_start[];
extern char _binary_wh1_bin_end[];

const fix16_t *wh0 = (fix16_t *)_binary_wh0_bin_start;  // [INPUTNO * HIDDENNO];
const fix16_t *wb0 = (fix16_t *)_binary_wb0_bin_start;  // [HIDDENNO];
const fix16_t *wh1 = (fix16_t *)_binary_wh1_bin_start;  // [HIDDENNO * OUTPUTNO];
const fix16_t *wb1 = (fix16_t *)_binary_wb1_bin_start;  // [OUTPUTNO];          

最初のwh0は非常に大きいので、さすがにこれはL1に格納できない。それ以外のものをコピーする。

const fix16_t *wh0 = (fix16_t *)_binary_wh0_bin_start;  // [INPUTNO * HIDDENNO];
const fix16_t *c_wb0 = (fix16_t *)_binary_wb0_bin_start;  // [HIDDENNO];
const fix16_t *c_wh1 = (fix16_t *)_binary_wh1_bin_start;  // [HIDDENNO * OUTPUTNO];
const fix16_t *c_wb1 = (fix16_t *)_binary_wb1_bin_start;  // [OUTPUTNO];          

int main ()
{
  int i;

  // for (i = 0; i < INPUTNO * HIDDENNO; i++)  wh0[i] = c_wh0[i];
  for (i = 0; i < HIDDENNO; i++)            wb0[i] = c_wb0[i];
  for (i = 0; i < HIDDENNO * OUTPUTNO; i++) wh1[i] = c_wh1[i];
  for (i = 0; i < OUTPUTNO; i++)            wb1[i] = c_wb1[i];

フラッシュメモリのデータへのポインタをc_wb0, c_wh1,c_wb1として、L1のデータ格納場所wb0, wh1, wb1`へコピーする。これでどれくらい速くなるか。

  • 高速化処置前 : 4960983060 サイクル
  • 高速化処理後 : 4893267026 サイクル

割合にして1.5%程度だ。あんまり劇的な効果にはなっていないなあ。やはり一番大きなデータを格納できていないことが原因か。 まあ、これ以上HiFive1で巨大が画像を処理させるのはちょっと無理かな。

RocketChpのRoCCインタフェースに専用ハードウェアを接続して、性能測定する(専用ハードウェア高速化)

前回は、RoCCインタフェースを使って専用ハードウェアを動かし、通常のソフトウェア命令を動かした場合と比べてどの程度性能が異なるのか調査した。 その結果、ハードウェアがシンプルなためか、ソフトウェアを使った場合と大差ない結果になってしまった。

この場合、メモリから読み込んだ値をシンプルに加算していくハードウェアだが、作り方がよくない。もうちょっと性能が出るハードウェアを作れば、もっと高速化できるんじゃないか?

専用ハードウェア高速化のアイデア

メモリからリクエストを出して、メモリのデータを取得するまで、ステートマシンになっている。つまり、データを取得するまでに次のリクエストを出さないようになっている。 これではもったいないので、リクエストが出せるまで出しっぱなし、メモリからの応答データは受け取り次第データを加算するという構造に変更する。

f:id:msyksphinz:20170907020153p:plain

これで性能測定を行った。なぜかprintf()が動作しなくなったのでちょっとデバッグまでは進んでいないが、ログからアクセスの履歴を見て判断。

結果、かなり高速化された。メモリアクセス数を変わらず、2倍高速化されている。

f:id:msyksphinz:20170907021254p:plain

まあまあ、RoCCを使用した価値があったかな。

RocketChipのRoCCインタフェースに専用ハードウェアを接続して、性能測定する

RoCCインタフェースを使って専用命令を作成し、ハードウェアアクセラレーションができるようになった。 とりあえず、まずは小手調べに、メモリからデータを読み込んで加算する専用ハードウェアを作成し、その性能を見てみよう。

作成するハードウェア

まずは小手調べだが、メモリから特定領域のデータを読み出し、その値をすべて加算するハードウェアを作成する。 C言語で記述すると以下のようになるが、これをChiselを使ってハードウェアを記述するわけだ。

    uint64_t total = 0;
    for (int j = 0; j < max_count; j++) {
      total += num_array[j];
    }

たったこれだけだ。非常にシンプルなのでハードウェアは作成しやすい。

専用ハードウェアのデバッグ

Chiselで記述したものをシミュレーションしているので、デバッグ文を挿入してその動作を見ている。例えば、ステートマシンの最初と最後にChiselで言うprintf()を挿入しておけば、ログから確認できる。

  when (io.cmd.fire()) {
    printf("MemTotalExample: Command Received. %x, %x\n", io.cmd.bits.rs1, io.cmd.bits.rs2)

...
  when (r_state === s_finish && io.resp.fire()) {
    r_state := s_idle
    printf("MemTotalExample: Finished.\n")
  }

実際にRoCCを接続した状態でRTLシミュレーションを実行すると、以下のようなログが表示される。

make CONFIG=RoccExampleConfig
./emulator-rocketchip-RoccExampleConfig +verbose ~/riscv64/riscv64-unknown-elf/riscv64-unknown-elf/bin/pk ../../rocket-rocc-examples/build/test-accumulator 2> rocc_memtotal.log
...
C                   0:    4505882 [1] pc=[0000010134] W[r11=000000008fffdaf0][1] R[r19=000000008fffdaf0] R[r 0=0000000000000000] inst=[00098593] DASM(00098593)
C                   0:    4505883 [1] pc=[0000010138] W[r12=0000000000000000][1] R[r 8=0000000000000001] R[r31=0000000000000403] inst=[fff40613] DASM(fff40613)
C                   0:    4505884 [1] pc=[000001013c] W[r10=000000008fffdaf0][1] R[r11=000000008fffdaf0] R[r12=0000000000000000] inst=[00c5f55b] DASM(00c5f55b)
MemTotalExample: Command Received. 000000008fffdaf0, 0000000000000000
C                   0:    4505885 [0] pc=[000001013c] W[r 0=000000008fffdaf0][0] R[r11=0000000000000000] R[r12=000000008fffdaf0] inst=[00c5f55b] DASM(00c5f55b)
C                   0:    4505886 [0] pc=[000001013c] W[r 0=000000008fffdaf0][0] R[r11=000000008fffdaf0] R[r12=000000008fffdaf0] inst=[00c5f55b] DASM(00c5f55b)
C                   0:    4505887 [0] pc=[000001013c] W[r 0=000000008fffdaf0][0] R[r11=000000008fffdaf0] R[r12=000000008fffdaf0] inst=[00c5f55b] DASM(00c5f55b)
C                   0:    4505888 [0] pc=[000001013c] W[r 0=000000008fffdaf0][0] R[r11=000000008fffdaf0] R[r12=000000008fffdaf0] inst=[00c5f55b] DASM(00c5f55b)
MemTotalExample: Finished.
C                   0:    4505889 [0] pc=[000001013c] W[r 0=000000008fffdaf0][0] R[r11=000000008fffdaf0] R[r12=000000008fffdaf0] inst=[00c5f55b] DASM(00c5f55b)
C                   0:    4505890 [0] pc=[000001013c] W[r10=0000000000000000][1] R[r11=000000008fffdaf0] R[r12=000000008fffdaf0] inst=[00c5f55b] DASM(00c5f55b)
C                   0:    4505891 [0] pc=[000001013c] W[r 0=000000008fffdaf0][0] R[r11=000000008fffdaf0] R[r12=000000008fffdaf0] inst=[00c5f55b] DASM(00c5f55b)
C                   0:    4505892 [0] pc=[000001013c] W[r 0=000000008fffdaf0][0] R[r11=000000008fffdaf0] R[r12=000000008fffdaf0] inst=[00c5f55b] DASM(00c5f55b)
...

では、次のようなプログラムシーケンスを作って、データサイズを変えて専用ハードウェアを呼び出し、処理にかかるサイクル数を測定しよう。

  for (int count = 0; count < 10; count++) {
    uint64_t total;
    memtotal (total, phy_data_addr, max_count-1);
    printf ("[countChar] %ld\n", total);
    max_count *= 2;
  }

次に、通常のプログラムを記述してサイクル数を測定する。以下がソフトウェア記述だ。

  for (int count = 0; count < 10; count++) {
    volatile uint64_t start_cycle, stop_cycle;
    uint64_t total = 0;

    start_cycle = 0x0;   // サイクルカウント用

    for (int j = 0; j < max_count; j++) {
      total += num_array[j];
    }

    stop_cycle = 0x0;    // サイクルカウント用

    printf ("[countChar] %ld\n", total);

    max_count *= 2;
  }

サイクル数の取得

加算するメモリサイズを1~512ワードまで変化させ、そのサイクル数を測定した。

専用ハードウェアで動作させた方法と、ソフトウェアで記述した方法のサイクル数をカウントするために、ログ中からプログラムカウンタをインデックスとしてサイクル数を引き出した。

grep -e 00010188 -e 000101b0 rocc_memtotal.log | sed 's/  */ /g' | cut -d' ' -f3 | awk '{if(NR%2==0) {print $1}else{print $1-start}}'
grep MemTotal -B1 rocc_memtotal.log | awk '{if(NR%3==1){print $3}}' | awk '{if(NR%2==1){start=$1}else{print $1-start}}'

それぞれのメモリサイズでサイクル数を比較すると以下のようになった。

f:id:msyksphinz:20170906015923p:plain

まあこの程度の機能じゃあ、ほとんどサイクル数は変わらないか。。。でも、きちんとスケールするプログラムが書けたのは良かった。

RoCCを使ったRocket Core拡張方法の調査 (6. 波形デバッグ2)

ずーっとRoCCインタフェースの勉強をやっているのだが、やっとわかった気がする。途中で動作が止まってしまうのはやはり途中でリクエストキューがいっぱいになってしまうからだ。 RoCCインタフェースにはタグが付いているのだが、このタグにちゃんとしたIDを渡してやらなければならない。

RoCCインタフェース経由でCPUとDcacheが接続される仕組み

ザックリ書くと以下の図の通りなのだが、実際にはRoCCアクセラレータ部とCPUのDcacheの間に接続インタフェースがあり、これのフロー制御を行う必要がある。 簡単に説明するとここにリクエストキューが入っており、過剰なリクエストが発行されてもここでフロー制御が実行されるようになっている。ここのエントリ数はデフォルトで2だ。

f:id:msyksphinz:20170825020600p:plain (https://inst.eecs.berkeley.edu/~cs250/sp16/disc/Disc02.pdf より引用)

Chisel(Scala)では以下のように記述されている。これを波形を取って観測してみると、内部のreplayqというリクエストを保持するためのキューの、Ready信号が途中で落ちてしまうため動作が停止してしまうようだった。

  • rocket-chip/src/main/scala/rocket/SimpleHellaCacheIF.scala
  val replayq = Module(new SimpleHellaCacheIFReplayQueue(2))
  val req_arb = Module(new Arbiter(new HellaCacheReq, 2))

  val req_helper = DecoupledHelper(
    req_arb.io.in(1).ready,
    replayq.io.req.ready,
    io.requestor.req.valid)

  req_arb.io.in(0) <> replayq.io.replay
  req_arb.io.in(1).valid := req_helper.fire(req_arb.io.in(1).ready)
  req_arb.io.in(1).bits := io.requestor.req.bits
  io.requestor.req.ready := req_helper.fire(io.requestor.req.valid)
  replayq.io.req.valid := req_helper.fire(replayq.io.req.ready)
  replayq.io.req.bits := io.requestor.req.bits

f:id:msyksphinz:20170905021202p:plain

DecoupledHelper の動作について

Scalaについてあまり知らないのでずいぶんと苦労したが、DecoupledHelperという記述によりReadyとValidのフロー制御が実現されている。

DecoupledHelperの定義は以下のようになっている。これだけでは何のことだか分からないが、どうもマッチする要素を除いてリストをAndでリダクションする機能を備えているようだ。

object DecoupledHelper {
  def apply(rvs: Bool*) = new DecoupledHelper(rvs)
}

class DecoupledHelper(val rvs: Seq[Bool]) {
  def fire(exclude: Bool, includes: Bool*) = {
    (rvs.filter(_ ne exclude) ++ includes).reduce(_ && _)
  }
}

デフォルトでは、このHelperに対して3つの要素が登録されている。req_arb.io.in(1).ready, replayq.io.req.ready, io.requestor.req.valid の3つだ。

ここで、io.requestor.req.readyが立ち上がるためにはどうしたらよいかというと、fire()の機能として、fire()の引数に指定されている要素を「除いて」すべての信号が立ち上がっていればOKとする回路になる。 これ、そうだったらそのようにScalaで書けばよいのに。なんでこんな風にしているのかは不明。

(req_arb.io.in(1).ready, replayq.io.req.ready, io.requestor.req.valid) から io.requestor.req.valid を除去 --> (req_arb.io.in(1).ready, replayq.io.req.ready) が立ち上がっていればよい。

SimpleHellaCacheIFReplayQueue の制御

SimpleHellaCacheIFReplayQueueは以下で定義されており、Req.Valid, Req.Readyでキューへの代入、Resp.Validで開放する。

  • rocket-chip/src/main/scala/rocket/SimpleHellaCacheIF.scala
  val nackq = Module(new Queue(UInt(width = log2Up(depth)), depth))
  val replaying = Reg(init = Bool(false))

  val next_inflight_onehot = PriorityEncoderOH(~inflight)
  val next_inflight = OHToUInt(next_inflight_onehot)

  val next_replay = nackq.io.deq.bits
  val next_replay_onehot = UIntToOH(next_replay)
  val next_replay_req = reqs(next_replay)

  // Keep sending the head of the nack queue until it succeeds
  io.replay.valid := nackq.io.deq.valid && !replaying
  io.replay.bits := next_replay_req
  // Don't allow new requests if there is are replays waiting
  // or something being nacked.
  io.req.ready := !inflight.andR && !nackq.io.deq.valid && !io.nack.valid

このときに、解放するTagのIDが一致しているかを見ている部分がある。リクエスト元から出すタグ情報が間違っているせいで、前回までの回路は正しく動作しなかった。

  // Match on the tags to determine the index of nacks or responses
  val nack_onehot = Cat(reqs.map(_.tag === io.nack.bits).reverse) & inflight
  val resp_onehot = Cat(reqs.map(_.tag === io.resp.bits.tag).reverse) & inflight

Tagはデフォルトで9ビット用意されている。これをとりあえずリクエスト毎にシーケンシャルに使うように変更すると、とりあえずシンプルな形ではあるが、自作アクセラレータのインタフェースが動き始めた。

f:id:msyksphinz:20170905023544p:plain

RISC-VプロセッサHiFive1で機械学習コードを動作させる(6. MNISTの性能解析と高速化)

f:id:msyksphinz:20170821013230p:plain

RISC-VプロセッサHiFive1でMNISTを動作させよう。実機デバッグをしていて、やっと動作するようになった。 バッチサイズを増やすとデータセクションが入りきらないのでいろいろ工夫したいが、もうちょっと軽量化できないだろうか。

HiFive1(32bit RISC-V)でのサイクルカウント

過去の記事を引用して、サイクル数をカウントして性能を測定する環境を構築した。

msyksphinz.hatenablog.com

改善前の性能

前回までのMNISTのプログラムで、100個分の画像を解析するのに必要なサイクル数を計測した。

Correct = 91
Time = 5216564625

double演算を削除する

どこか高速化できるところが無いだろうか?とりあえず、doubleの計算をしてしまっている所があり、それを削除して高速化していきたい。

   // fix16_in_data[i] = fix16_from_dbl (in_data[i] / 255.0);
      fix16_in_data[i] = (in_data[i] << 8);

これで少し高速化された。約5%の高速化だ。

Correct = 91
Time = 4961182594

バッチサイズを増やす

L1キャッシュの容量の関係上、とりあえずバッチサイズを2に増やした。この時のサイクル数は以下のようになった。 バッチサイズが1の時に比べて、1画像当たりのサイクル性能は少しだけ悪いなあ。なんでだろう。

f:id:msyksphinz:20170904021844p:plain

あと、そもそもlibfixmathで使っているfix16_tのデータタイプが、32bitなんだよなあ。これを16bitにしたい。。 せこい技を使うなら、そもそもデータもfix16の形式で入れておきたいんだよなあ。。。

RISC-VプロセッサHiFive1で機械学習コードを動作させる(5. MNISTの実機動作確認)

f:id:msyksphinz:20170821013230p:plain

RISC-VプロセッサHiFive1でMNISTを動作させよう。実機デバッグをしていて、やっと動作するようになった。 まだバッチサイズは1のままだが、とりあえず動作するようになった。

問題だったのは、データの配置方法だった。ちゃんと調整して入力データを流すと、動作するようになった。 下記の通り、入力画像データに対して、ちゃんと数値を認識できている。

f:id:msyksphinz:20170902122534p:plain

とりあえず動作するようになったので、次の課題としては、

  • プログラムの高速化、軽量化
  • バッチサイズを増やす

など。HiFive1ならでは、っていうはなかなかか難しそうだけど、出来るところまでやってみようかな。

github.com

RISC-VプロセッサHiFive1で機械学習コードを動作させる(4.実機を使ってテストデータを動作させる)

f:id:msyksphinz:20170821013230p:plain

だいぶ時間が空いてしまった。RISC-VプロセッサHiFive1を使った実験、機械学習のネットワークを動作させてMNISTを動作させる件、やっと再開した。

とりあえずMNISTのデータを使ってちゃんと計算できるようになりたい。それでも、デバッグはなかなか大変だ。

MNISTとして提供されているt10k-images-idx3をロードして、正しい結果を得られるようにしたい。 このプログラムは既にx86上で正しく動作しているので、あとはHiFive1上で動かすだけなのだが、どうもまだうまく動いてくれない。

f:id:msyksphinz:20170902023452p:plain

上記の通り、ほとんど結果が合っていない!なんじゃこりゃ!100個データを入れていって9個しか合っていないとか、ほとんど、単なる確率だね。 まあ、こっから先は一つずつデータを合わせながらx86の結果を合わせていくしかないね。地道な作業だけれども。

あと問題なのは、バッチサイズをあまり増やすことができない点。これについては今のところバッチサイズ=1で動作させているが、L1キャッシュのサイズの関係もあるし、サイズは10でも厳しい気がする。まあ、途中の演算用のバッファを繰り返し利用するようにすればもうちょっとましになるかもしれないけど。