たまりば

パソコン・インターネット パソコン・インターネット三鷹市 三鷹市

PIC16のDhrystone MIPSを測ろうとしてみた
2020年04月29日 02:23

PIC、中でも昔ながらのミッドレンジは演算性能が低いことで有名だ。
ベンチマークでどれほど低い値が出るか気になる。マイコンの性能比較に広く使われているDhrystoneは整数演算だけなので低性能なCPUでも走りそうだ。

そう思ってDhrystoneのソースコードを見てみると…
int    Arr_2_Glob [50] [50];

ミッドレンジPICに5000バイトもメモリは無い。最大でも368バイトだ。

-完-



…とここで諦めてしまうのはつまらない。
よく見てみれば2500個の要素を全て使っているわけではない。アクセスするのはわずか4箇所。これならなんとかなるのではないか。
なんとかなるというのはつまり、Dhrystoneをそのまま走らせることはできないにせよ、なるべく結果に影響の出ない範囲でコードをいじったり、もし本来のコードが走ったらどの程度の速度が出るかを推測することで、それなりに意味のある値を出せるのではないか。

というわけでやってみた。
使うコンパイラはMPLABの標準のCコンパイラ、XC8(v1.44)。無償版なのできっと最適化とかに制限があるだろうが気にしない。

まずは何はともあれコンパイルが通るようにしなければならない。適当に配列要素の数を減らそう。50×50要素を7×6要素に。
int Arr_2_Glob [7] [6]; //Arr_2_Glob [50] [50];
typedef int     Arr_2_Dim [7][6]; //[50] [50];
これが妥当かどうかは後で見ていく。
ついでにもう1つこっちも減らさないといけない。50要素を25要素に。
int Arr_1_Glob [25]; //Arr_1_Glob [50];
typedef int     Arr_1_Dim [25]; //[50];
int 50個=100バイトはあるんではないかと思うかもしれないが、コンパイラの仕様上1つのオブジェクトは1バンクに収まっていないとならない。
また総メモリ量もわりときつく、ちょっと増やすとすぐ割り付けに失敗する。

配列の読み書きのコードは変えていない。
範囲外を読み書きすることになるがそこは自分の足を撃てるC言語。何の問題もない。

他数箇所変更した。
・エラーが出るたび適当にそれっぽいヘッダファイルをinclude
よく分からないまま追加しているがまあ多い分には問題ないだろう。

・NOSTRUCTASSIGN
structassignで「can't generate code for this expression」が出たので、それ用に用意されたコードが使われるよう
#define NOSTRUCTASSIGN
したら動くようになった。
これにともないmemcpyがコードに書かれたものが使われるようになったが、ライブラリ側のものと衝突したのでコメントアウトした。

・時間計測のためのTIMEだのなんだのを削除
結果に影響の無い部分なのでバッサリと。
このへんをPICで動くように変更するのは手間なので時間計測はシミュレータで行うことにする。
PICは実行クロック数が確定的なので問題ない。

・mallocができない
のでユーザーズガイドを見ると、
Dynamic memory allocation, (heap-based allocation using malloc, etc.) is not supported on any 8-bit device.
とある。
幸いmallocが使われているのは初期化の部分、ベンチマーク本体のループの外だ。直接宣言してしまおう。
ポインタが指す先のメモリがどう用意されたものかはプログラムを走らせる上で違いはないだろう。
おそらく最適化を防ぐために持ってまわった定義をしているのだろうと思われ、そこまでの最適化はきっとされていないだろうと判断する。少なくともポインタ参照はされている。
/*
  Next_Ptr_Glob = (Rec_Pointer) malloc (sizeof (Rec_Type));
  Ptr_Glob = (Rec_Pointer) malloc (sizeof (Rec_Type));
*/
  Rec_Type temp1;
  Rec_Type temp2;
  Next_Ptr_Glob = &temp1;
  Ptr_Glob = &temp2;

なお、これで測り終えた後見つけたのだが、別の対処法として、呼び出されると事前に用意したポインタを返す疑似mallocを使っている人がいた。(後述のAVRのコード)
こちらの方がより安全そうなので真似してみたのだが、後述のポインタ幅違いがまた出てどうしてもうまくいかなかった。
諦めて直接宣言のままにした。

・よく分からないエラーが出た
 *Ptr_Ref_Par = Ptr_Glob->Ptr_Comp;
の部分で、「error: (1466) registers unavailable for code generation of this expression」というエラーが出た。
PICでレジスタってなんだろう。RAMとは違うのか。
なんだかよく分からないが2分割したらエラーが消えたのでこれでよしとする。
struct record *temp;

    temp = Ptr_Glob->Ptr_Comp;
    *Ptr_Ref_Par = temp;
…が、後にこれは必要なくなる。後述。

これで先へ進むようになり、コンパイルが通るか…と思いきや意外なことにプログラムメモリが足りない。結構食うんだなあ。
RAMがミッドレンジ最大の368バイトあるものとして手持ちからPIC16F88を選んだのだが、こいつのプログラムメモリは4kワード。
どうせシミュレータで動かすのだし最大の8kワードある石を適当に選ぶか…と思ったが、コードを見ると大量のprintfがあって容量を食っていそうだ。
状況説明のための出力は不要なので開始と終了を1文字だけ残して消す。
結果出力部分に未使用の変数が消されることを防ぐためのprintfがあるがこちらも極力切り詰める。
例えばこれを
  printf ("Int_Glob:            %d\n", Int_Glob);
  printf ("        should be:   %d\n", 5);
  printf ("Bool_Glob:           %d\n", Bool_Glob);
  printf ("        should be:   %d\n", 1);
こうする感じで。
  printf ("a:%d\n", Int_Glob);
  printf ("b:%d\n", Bool_Glob);
こうして切り詰めたら余裕で入った。3kワード弱にまでなった。
なおprintfから呼ばれるputchの実装は、意味のある出力をするものではなく、シミュレータで適宜止めて出力内容を見るための、引数が消されない最低限のコードにした。

これでビルドが通り、(内容はともかく)動作をするようになった。

出力を確かめると、配列の部分の値が違うのは想定通りだが、他に構造体のポインタ演算あたりの部分も違っている。
調べてみるとおかしいのは例の2分割で代入したところで、16bitのポインタに8bitのポインタを代入している。
この際データが不可解な変化を見せており、その影響でそのあとif文で通らないはずの方を通っている。

XC8ではポインタが8bitと16bitの2種類あるのだが、使い分けは手動ではできず、コード中の全ての代入を認識して自動で最適なものを使い分けてくれるという。
これが判定に失敗して間違った代入をしてしまっているように見える。つまりコンパイラのバグではないか。

ループ外に適当に代入文を書いてみたところ今まで8bitだったところに16bitのポインタが使われるようになり、出力が正しくなった。
そして例の2分割代入部分も元のコードでコンパイルが通るようになった。

配列部分も正しく動作しているか調べよう。
配列のアクセスの部分の数値を書き換えて配列内をアクセスするようにし、正しい結果が出るかを見る。
出た。
また、ここで時間計測し、配列アクセス部以外にかかる時間が変化していないことを確認した。
よって、不正なアドレスをアクセスすることで他の実行結果に影響を与えていることは無いと考えられる。

これで多分配列部分以外は本来のDhrystoneの想定通り動いているだろう。



さて配列の量をいじった事によりベンチマーク結果にどれほどの影響が出ているか考えてみよう。

1次元配列の方の要素数は、(2次元配列を[2][3]にしてメモリを空けた上で)[25][30][35][40][48]を試し、実行時間は一切変化しなかった。
こちらの影響は無いものとみて良いだろう。

2次元配列の要素数を[7][6]から変えて変化を見てみる。
配列の宣言の部分と、typedefの部分。
宣\def[50][50][50][6][7][50][7][6][2][2]
[7][6]58005693580056935571
[4][6]58005693580056935571
[8][3]58005693580056935571
[2][3]58005800580058005571

これを見ると、typedefの方の2次元めを変えると実行時間にやや変化が見られるのに対し、
配列の宣言の方は[2][3]でのみ値が変わるが他は要素数によらず一定である。

本来の[50][50]に近づけられないのが気がかりだが、現状の判断として、配列宣言の要素数を大きくしてもベンチマークの実行時間には影響を与えないと考えることにする。
typedefの方は折角なので本来の[50][50]にしておく。

なおこの要素数だが、変えるとコンパイルに失敗することがかなり多い。
要素数の積が48(メモリ量が96バイト)を越えるとメモリ不足でコンパイルできないのは分かるのだが、次表のようにそれ以下でも値に応じて不規則に失敗する。
[*][2][*][3][*][4][*][5][*][6][*][7][*][8][*][9]
[2][*]×××××
[3][*]××××××
[4][*]×××××
[5][*]××××
[6][*]××
[7][*]
[8][*]
[9][*]??
[10][*]??
[11][*]??
[12][*]??
(○: コンパイル成功 / ×: コンパイルエラー / ?および表範囲外: 未チェック / 空欄: 96バイト超)

この時のエラーは次のようなもので、調べるとメモリのバンクへの割り付けに失敗しているエラーで、PICにはよくあることのようだ。有料版だと出なくなることもあるらしい。
D:\P\xc8\v1.44\sources\common\lwdiv.c:30: error: (1357) fixup overflow storing 0x8E in 1 byte at 0x950/0x2 -> 0x4A8 (dist/default/debug\Dhrystone88.X.debug.obj 120/0x26)

配列の宣言の方はこれでいいとして、実際にアクセスする部分はどうか。この分をどう考えるかは難しい。

何らかの計算をしてアドレスを求め、配列の範囲外とはいえどこかしらをアクセスしてはいる。
しかし、ここで生成されているコードはどうあっても2500の全ての要素にアクセスすることはできない。
そのような不完全なコードで時間を計ってよいものか。

とはいえ2500要素5000バイト分のアドレスというものはミッドレンジではそもそも存在し得ないのだから、それを計算するコードとしてどのようなものを想定するのかの正解は無い。

考えた末、以下2点を補正することにした。

・乗算
まず、アドレスの生成部分を見ると、1バイト×1バイト=1バイトの乗算ルーチンが呼ばれている。実行時間は100サイクル程度。
これはまずい。50×50要素の配列にアクセスするには積を2バイトで持たなければならない。
試しに2つのchar型の変数を乗算してみると実行時間は値によって変わり、124や178サイクル。
    char temp01 = 8;
    char temp02 = 100;
    int temp03 = temp01*temp02;
→124
    char temp01 = 38;
    char temp02 = 100;
    int temp03 = temp01*temp02;
→178
この分を適当に補正することにする。1回あたり50サイクル、配列アクセスは4回あるので計200サイクル追加することにする。

・バンク
5000バイトのメモリは存在しないが、せめて複数バンクにわたるメモリのアドレスを算出するコードは考慮しておきたい。
1バンクあたり96バイトという中途半端な値を使うのはさすがに大変すぎる。
仮に大量のメモリを積むならもっとまともなアクセスができるようにするだろう(というか、実際にPIC18やEnhancedミッドレンジでされている)
悩んだ末、「80バイトや96バイトの容量があるバンクが十分に大量にあり、バンクごとに切りの良い64バイトだけを使う」「バンク選択にかかる時間は考えない」ことを仮定し、以下のコードを書いた。
int address = 0x1234
char addrh = address >> 6;
char addrl = address & 0x3F;
これに50サイクル掛かった。2次元配列のアクセスは4箇所あるので200サイクルを追加する。

以上より、実測値5800サイクルに2次元配列アクセスの分を想定した補正値400サイクルを加えた6200サイクルを、ミッドレンジPICにおけるDhrystone1回分の実行時間に相当する値と考える。

これをクロック数に直して24800クロック、1MHz・1秒あたりDhrystone相当値は40.3、1757で割りDMIPS/MHzの推定値は、
0.0229 DMIPSっぽい値/MHz
と得られた。



比較しよう。

16bitや32bitのマイコンの宣伝にはよく公式にDMIPSが書かれている。見ると、比較的性能の低い(インオーダー・シングルコア)のものでも1DMIPS/MHz程度はある。
CPUDMIPS/MHz情報ソース
PIC32MM 1.53http://ww1.microchip.com/downloads/en/DeviceDoc/60001324b.pdf
RL781.39https://www.renesas.com/ja-jp/products/microcontrollers-microprocessors/rl78/rl78-features.html
ARM Cortex-M00.89 (最適化条件を変えれば1.02や1.27)https://developer.arm.com/products/processors/cortex-m/cortex-m0

何十倍も違うとあまり比較にならない。
もっと性能の低い、8bitCPUの値を探そう。
探してみるとそれなりに見つかる。

http://oneweekwonder.blogspot.jp/2013/12/z80-dhrystones.html
Z80。定番だ。
3.5MHzで0.142DMIPSというので、0.04DMIPS/MHz。

http://www.homebrewcpu.com/new_stuff.htm
65(C)02とZ80。6502はファミコンで有名だ。
6502 - 1.02MHz: 37Dhrystone
Z80 - 2.5MHz: 91Dhrystone
というので、
37/1757/1.02 = 0.02DMIPS/MHz
91/1757/2.5 = 0.02DMIPS/MHz
Z80の値が上の半分だ。まあ環境やコンパイラでこの程度の誤差は出るものだろう。

http://ww2.tiki.ne.jp/~maro/AVR/project/bench1/index.html
AVR。PICとよく比較されるマイコンだ。
at90s8515・8MHzで、最速の結果で1.938MIPSとある。0.24DMIPS/MHzだ。
ただしこの人、律儀に2500バイト(?)のために読み書きに3クロックかかる外部RAMを接続している。
(*2500バイトと書いてあるのはたぶん間違いだと思う。C言語的にIntは16bit以上のはず。)
自分のやったように必要な分のメモリだけにするなら外部RAMは要らずもっと速くなる。
4クロックから3クロックに変えるだけで1.659MIPSから1.933MIPSに上がったというのだから、同じ割合で1クロックになれば2.920MIPS・0.365MIPS/MHzに上がる計算になる。

http://www.ecrostech.com/Other/Resources/Dhrystone.htm
AVRもうひとつ。
こちらは内蔵メモリに収まるよう配列要素を半分にするなどしているようだ。先述の疑似mallocはここの記述だ。
ATmega64で、0.328DMIPS/MHz。
上の0.24DMIPS/MHzより速く、推定0.365MIPS/MHzよりは遅い。
調べてみるとat90には乗算命令がなくATmegaにはあるようなので、at90より速いはずだ。してみると上の推定0.365MIPS/MHzは少々怪しいか。

これらの値と今回のPICの推定値を比較してみる。

Z80: 0.02~0.04DMIPS/MHz
Z80はゲームボーイのCPUしか扱ったことがないが、1命令のクロック数は4の倍数で、概ねメモリアクセスの回数×4クロックに一致する。これはPICと似た性能である。
本来のZ80は多少クロック数が異なり、またレジスタが多かったりループ命令があったりするが、大勢に影響は無いだろう。
クロックあたりに行う動作がPICと同程度で、命令がZ80の方が豊富であることから、DMIPS値が1倍~2倍というのはだいたい感覚と合っている。

6502: 0.02DMIPS/MHz
6502はメモリアクセスあたり1クロック+α(内部処理分)といった趣で、Z80よりクロックあたりの性能は高い印象がある。
Z80と同等か低い値というのはちょっと感覚と合わない。まあZ80も2つで2倍の差があるくらいだし、この程度は誤差なのかもしれない。

AVR: 0.24~0.365DMIPS/MHz
Z80や6502と1桁違う。PICの10.5~16倍。感覚的にちょっと多すぎな気もするが、よく考えてみよう。
まずAVRは大半の命令が1命令1クロックで動く。PICは基本的に1命令4クロックなので、命令数では2.6~4倍となる。
ミッドレンジPICとAVRのアーキテクチャを比較すると、
・PICは汎用レジスタが1つのみであり何をやるにもメモリに値を置く必要がある
・PICはプログラムメモリのページ・RAMのバンク切り替えで時間を食う
・PICには乗算命令が無い
・PICは間接アドレッシング用のレジスタが1本しか無いことやメモリのバンク分けのため、C言語に向いていない
・一方AVRは間接アドレッシング用のレジスタが16bit×3本もあり、大変C言語向きである
といったあたりの要因からして、2.6倍くらいは妥当だし4倍もまあありうるのかなと思えてくる。

Conclusion


独自の仮定のもとにそれなりにDMIPSに近いと信じる値を算出し、いくつかの8bit CPUの値と比較して感覚とそれなりに合う結果となったので満足である。



結論に問題があるとすれば、まず一番怪しいのが2次元配列アクセスの補正が正当かどうか。
そしてmalloc除去を始め各所で加えた変更が正当かどうか。
あとはPICでまともにC言語を使ったのは初めてなのでなにか手順にとんでもない間違いがあるかもしれない。
有料版で最適化をかけて別次元の速さになったりしないかも少し心配だ。(「(PRO版なら)最大で60%小さく400%速いコードができていたんだぜ」ってコンパイルするたびに言われるのだ)
コードはgithubに上げたので、何か疑問があれば色々試してみるとよいだろう。
https://github.com/Ikadzuchi/Dhrystone88.X

なお、本稿をほぼ書き終えた段階で公開用にコードを整形したところ、ループ外の不要部を削っただけなのだが、ループの実行時間は5800サイクルから5824サイクルに増えた。面倒なので文中の数値は直さない。
コードの位置が変わることによりページをまたいだりして実行時間がこの程度変化することは十分考えられる。
他にもループ外にテスト用の乗算コードを書いたら7016→6996に短縮したこともあった。(不正な場所を実行していた時なので実行時間は大きく違う)