公開日:2022/03/26
20th Season
#20-4 32ビットに拡張するゾ(ひ)
FPGAボード(Sipeed Lichee Tang Primer)を使って設計したオリジナル16ビットCPUコアMoscoviumのパフォーマンスがちょっと気になった件。
問題と感じたのは、SDRAMにキャッシュメモリを適用して実行したマーチングテストの話です。
「#20-2 キャッシュメモリコントローラの設計(下)」
上記事中の実験では、キャッシュヒット率が85%を超えていたにもかかわらず、マーチングテストは10%程しか高速化できていませんでした。
原因は、SDRAMアクセスに要していた時間よりも、Moscoviumの32ビットデータ処理の方が支配的だったため、でした。何という事でしょう!キャッシュメモリの速さを実感する前に、Moscoviumの遅さが目立ってしまうとは。
なんとかせねば、と思ったのです。
Moscoviumを高速化する
「Moscoviumを高速化するゾ」と、意気込んでは見たものの、何だかんだと、Moscoviumは1サイクル/1命令実行のCPUなので、その意味では十分高速なのでした。
CPI(Clocks Per Instruction)と言う指標があります。その名の通り、1個の命令を実行するのに要するクロックサイクル数を示した指標です。
CPI=<命令の実行に要した時間(サイクル数)>÷<実行した命令数>
一般的には、CPIの数値が大きくなると低速、小さくなると高速であることを示す指標です。
※上の様に、CPIの大/小と、速度の高/低が逆転してしまうため、IPC (Instructions Per Clock、CPIの逆数=1/CPI)を使う方も居るようです。
Moscoviumは1サイクル/1命令のCPUなので、理想的にはCPI≒1です。一部メモリアクセス命令や分岐命令などで2サイクル以上となる命令があるので、実効的にはCPI=1.2〜2程度になるのではないかと思われます。
実際にMoscoviumの全命令セルフテストプログラム(mcvm_self.asm)で計測したところ、CPI≒1.1でした。
※SDRAMアクセスはリード3サイクル、ライト2サイクルと遅いので、これを多用するプログラムでは、CPI=2〜4になる可能性もあります。CPUの性能としてCPIを論ずる時は、メモリアクセスの影響は排除しておかないと話がおかしくなりますので、最速の1サイクルでアクセスできた場合を想定してCPIを議論します。
さらに高速化するには:
- クロック周波数を上げる
- パイプライン
- パイプラインの段数を増やす(→ 3〜5段)。
- どんなにパイプラインの段数を増やしても、CPIは1未満にはならない。
- その代わりクリティカルパスが緩和されて、クロック周波数を上げることができる。
- CPI(Clocks Per Instruction)を下げる
- スーパスケイラ(Super Scalar)
- 並列実行可能な複数命令を同時に発行する方式。
- 連続する複数命令の依存関係を判定して、並列実行する。
- 複数命令を並列に実行できると、CPIを1以下に下げることができる。
- 「CPI=命令当たりのクロックサイクル数」なので、下げると高速、上げると低速。
- 並列実行する命令スロットを2個以上有する。……通常のCPUだと、スロット数は1個。
- アウトオブオーダ(Out of Order)
- 機械語の並び順に捕らわれず、準備ができた命令を次々と並列実行する方式。
- スーパスケイラの考え方をさらに一歩進めた感じ。
- スーパスケイラだと、連続する命令が並列実行できるかどうか?しか、判定の場に現れない。
- アウトオブオーダでは、もっと先の命令までプリフェッチして、現在実行している命令の終了を待たなくても実行可能な命令をどんどん先取りしてスロットに発行する。
- 1命令に複数の処理を実行させる
- CISC(Complexed Instruction Set Computer)
- 命令に複雑な動作を割り付けて、多数の処理を並列して実行する。
- 例)macw r0,[r1++],[r2++]
- r1の指すアドレスからワードデータdat1を読み出す。
- r2の指すアドレスからワードデータdat2を読み出す。
- 掛け算してr0に加算する:r0+=dat1×dat2。
- r1およびr2を+2インクリメントする。
- 以上の動作が1命令。連続実行すると、FIRなどのデジタルフィルタを実装できる。
- 上の処理を1つ1つ順番に実行するのではなく、同時並列的に実行することで高速化できる。
- SIMD(Single Instruction Multi Data)
- 1命令で複数のデータを処理する。
- 例えば、ベクタ演算など。
- 1サイクルで1個のデータだけを処理するのと異なり、1サイクルで複数のデータを同時に処理することで高速化する。
- VLIW(Very Long Instruction Word)
- 非常に長い命令コード長を持ち、1命令で複数の動作を実行する。
- 並列実行可能な命令を、CPUが実行時動的に判定して動作するのがスーパスケイラなのに対して、VLIWは、並列実行可能な処理を、コンパイル時静的に1命令に押し込んでおいて、CPU実行時には複雑な判定処理を行わなくて済む方式と、見ることができる。
- 並列実行可否を判定するスコープが狭いスーパスケイラに対して、コンパイラは処理全体を俯瞰して最適化を図れるため、有利。
上に挙げた案は、色々と影響が大きい(修正量莫大、論理規模も大幅増加)ので却下しました。さて、どうした物か……。
一応、却下の理由
- パイプライン:
- Moscoviumは、2段と言う浅いパイプラインで動作していて、24MHzという比較的遅いクロックで動作しています。
- パイプラインが浅いことで、分岐ペナルティが極めて小さいという利点があります。
- 現に、Moscoviumの分岐命令は、分岐しない場合1サイクル、分岐した場合2サイクルと、分岐ペナルティはわずか1サイクルです。
- パイプラインを5段等に分割すると、分岐ペナルティが大きくなりますので、条件に従って動作を変えることの多いマイコンの処理では大きな欠点になりかねません。
- 段数を増やして、動作周波数を上げる場合も、周辺I/Oユニットはそのまま24MHzで動作させたいですよね?
- 大したスピードの要らない周辺I/Oユニットには、48MHzとか、もっと速いクロックとか、必要ないからです。
- そうすると、SDRAMの時に検討したような、異クロック間のデータ授受について検討し、バスステートコントローラなどでクロック周波数差を吸収する必要が発生します。
- ことによると、ALU等の処理も分割しないと、クロック周波数を上げられないケースも考えられます。
- CPUの変更量もさることながら、周辺の回路にまで大きく手を入れる必要が有ったため、却下としました。
- スーパスケイラ
- 命令を実行するスロットを複数(例えば2本)用意するので、ほぼ、CPU×2個分の論理規模になります。
- そこまでしたところで、パフォーマンスが×2倍に向上するか?と言うと、そうではありません。
- 並列実行できない命令の組み合わせも沢山あるので、実効的には数10%高速化できる程度でしょう。
- そこにCPU×2個分のコストをかけるか?と、言う問題です。
- 今回のテーマでは、一旦却下(一旦保留)とます。
- アウトオブオーダ
- 基本的には、スーパスケイラと同じです。
- さらに、スーパスケイラ以上に命令復号(IDステージ)の処理が複雑化するので、パイプラインの浅いMoscoviumで実行するのは厳しい状況です。
- クリティカルパスを強化することとなって、動作周波数24MHzで動作させることさえ、厳しくなってしまうため。
- まず、パイプライン段数を増やしてクリティカルパスを緩和してから、アウトオブオーダに手を付ける、という2段階の対策が必要でしょう。
- この案も却下です。
- CISC化
- 現在のMoscoviumは、RISC(Reduced Instruction Set Computer)なので、もっと複雑な動作をする命令を追加して、CISC化することは可能です。
- ですが同時に、Moscoviumは16ビット固定長命令を採用しており、命令コードにあまり空きがありません。
- より複雑な動作をする命令を追加する場合の障害となります。
- 命令を複雑化するだけでは高速化には結びつかず、その動作をなるべく同時並列的に処理できるよう、リソース(演算器やバス等)を多重化して行く必要があります。
- 多重化したリソースは、ある特定の命令を高速に実行するための専用リソースとなってしまいかねず、よく吟味して命令の動作を定義しないとなりません。
- この案は、効果とコストが釣り合うという確証が現時点で持てず、イメージできないため却下します。
- SIMD化
- 1個の命令で、複数のデータを扱うだけなので、必要数の演算器(ALU)や汎用レジスタをN倍化するだけで済みそうです。
- 問題はその効果です。3Dグラフィックスとか、ベクタ演算を多用するようなアプリケーションならいざ知らず、マイコンが扱うような電子機器の制御において、複数のデータを一度に(一命令で)処理できてうれしいケースとは何でしょうか?
- この案も、使い道にイメージが沸きません。それでいて、「演算器(ALU)と汎用レジスタのN倍化」というコスト増加量だけは具体的に見えているので、費用対効果が釣り合わないと感じました。
- それが却下の理由です。
- VLIW化
- ハードウェアで動的に複数命令を並列実行するスーパスケイラに対して、ソフトウェアで静的に並列実行可能な命令を1命令に押し込むというのがVLIWだと考えることができます。
- ソフトウェア(コンパイラ)で最適化することが前提となる技術なのです。
- 現在、MoscoviumではC言語などのコンパイラサポートがありません。アセンブラを使って作成したプログラムを元に、人間が最適化してVLIW命令を構築する必要があります。
- VLIWの特長を活かして最適なプログラムを構築できるかどうか?は、作り手(プログラマ、人間様)の手に委ねられてしまいます。
- 多分、VLIWをやるくらいなら、スーパスケイラを採用した方が、幾分プログラムは組みやすいでしょう。
- この案も却下です。
汎用レジスタと演算器(ALU)を32ビット化する
そもそも、マーチングテストが遅かった理由は、24ビットアドレスを処理するため32ビットデータ処理が必要となり、16ビットのMoscoviumで複数命令が必要となった事でした。
例)32ビットの加算
// r6:r7+=r0:r1
add r7,r1
adc r6,r0
Moscoviumの汎用レジスタは16ビット幅なので、32ビットデータを処理しようとすると、上例の様に複数レジスタの組み合わせ(レジスタペア)と複数命令が必要となります。
汎用レジスタと演算器(ALU)の32ビット化によって、1命令で処理できるようになると、それだけ高速化することができます。
// r7,r1が32ビットレジスタなら、1命令
add r7,r1
2命令2サイクル掛かっていた処理が、1命令1サイクルで終了するので、該当箇所に関しては一気に2倍速となります。この内部32ビット化案が、一番安易かつ現実的なソリューションとなりそうです。
Moscoviumの32ビット化
Moscoviumの汎用レジスタと演算器(ALU)のビット幅を16ビット→32ビットに拡張して行きましょう。この目標に従って、どのブロックをどのように拡張すべきか?検討を進めます。演算器が32ビット化するので、ある程度の論理規模増加は止むを得ませんが、なるべくその増加量を抑えたいと考えています。
と、その前に、汎用レジスタを32ビット化すると、プログラマモデル的に全く異なる別CPUとなるので、オリジナル32ビットCPUコアの呼称を決めておきます。
- Moscoviumは、MCUのMcを元素記号とする元素名Moscoviumから取った。
-
Moscoviumは放射性元素で半減期はミリ秒オーダ。崩壊するとNihoniumになることがある。
↓
- オリジナル32ビットCPUコアをNihoniumと呼称することとする。
- Nihoniumは、日本の研究者が合成したことで命名権を獲得、「日本」にちなんで命名された(2016年)。
- ニュースにもなったので、ご存知の方も多いと思う。
- 新たな元素を発見して命名したのは、アジアでは「初」だったとか。
- 原子番号は113、半減期は同位体によって異なり、0.24ms〜20s。
呼称📛が決まったところで、早速、命令コードをどうするか?から検討を始めたいと思います。
コード効率
コード効率とは、簡単に言うと、「同じプログラムを機械語に翻訳した時のコードサイズの指標」です。コードサイズの小さいことを「コード効率が高い」と表現します。
※と言う事は、コード効率=プログラム÷コードサイズ、的な指標なんですね。
特に、組み込み用途に使うマイコンなどで、プログラムメモリ(ROMやFlash)の容量が限られている時に、重要な指標となります。
イメージを掴んでいただくために、例題を設定してコード効率を確かめてみましょう。
例1)フィボナッチ数列を生成するプログラムコード比較
fibonacci |
Moscovium |
SuperH |
Thumb ARM |
Native ARM |
AVR8 |
1)f0=0 |
ldbiu r0,0 |
mov r0,#0 |
mov r0,#0 |
mov r0,#0 |
ldwi r0,0 |
2)f1=1 |
ldbiu r1,1 |
mov r1,#1 |
mov r1,#1 |
mov r1,#1 |
ldwi r1,1 |
3)f2=f0+f1=1 |
mov r2,r0
add r2,r1
|
mov r2,r0
add r2,r1
|
add r2,r1,r0 |
add r2,r1,r0 |
mov r2,r0
add r2,r1
|
4)f3=f1+f2=2 |
mov r3,r1
add r3,r2
|
mov r3,r1
add r3,r2
|
add r3,r2,r1 |
add r3,r2,r1 |
mov r3,r1
add r3,r2
|
5)f4=f2+f3=3 |
mov r4,r2
add r4,r3
|
mov r4,r2
add r4,r3
|
add r4,r3,r2 |
add r4,r3,r2 |
mov r4,r2
add r4,r3
|
6)f5=f3+f4=5 |
mov r5,r3
add r5,r4
|
mov r5,r3
add r5,r4
|
add r5,r4,r3 |
add r5,r4,r3 |
mov r5,r3
add r5,r4
|
7)f6=f4+f5=8 |
mov r6,r4
add r6,r5
|
mov r6,r4
add r6,r5
|
add r6,r5,r4 |
add r6,r5,r4 |
mov r6,r4
add r6,r5
|
8)f7=f5+f6=13 |
mov r7,r5
add r7,r6
|
mov r7,r5
add r7,r6
|
add r7,r6,r5 |
add r7,r6,r5 |
mov r7,r5
add r7,r6
|
命令コード長 |
16ビット |
16ビット |
16ビット |
32ビット |
16ビット |
コードサイズ
[バイト]
|
2×14=28 |
2×14=28 |
2×8=16 |
4×8=32 |
2×14=28 |
- フィボナッチ数列を汎用レジスタr0〜r7まで8個計算するプログラムのコード効率比較。
- 各CPUのデータシートを見た範囲で、普通にプログラムした。
- 詳しい方が見たら、もっとコード効率の高い命令やテクニックがあるかもしれない。
- けど、CPU命令の中には、コード効率とか実行速度とかのベンチマークを良く見せるためだけに追加されたものもある。
- そういう「見せかけのお化粧」は使わず、素直に命令を選択して比較した。
- 命令コード長が16ビット同士のMoscoviumとSuperH (SH)は互角(コードサイズ=28バイト)。
- 上表の範囲だと、フィボナッチ数は13までしか出現せず、8ビットレジスタで充足するので、AVR8も互角。
- AVR8: Arduino UNO等に搭載されている、ATmega328等の8ビットマイコンシリーズ。
- 8ビットを超えるレジスタ幅が必要になったら、AVR8は一気に命令数が増えてコード効率が低下する。
- 普通は8ビット幅では処理が足りないことが多いので、参考データとする。
- 16ビット命令コードなのに3レジスタオペランド命令を持っていたThumb ARMが最強だった(コードサイズ=16バイト)。
- Native ARMは、3レジスタオペランドのお陰で、意外に健闘(コードサイズ=32バイト)。
- MoscoviumやSHではmov命令+add命令の2命令が必要だったところ、Native ARMはadd命令1個で済んでいるため。
- ※誰が何と言おうと、ARMはCISCなんだよ。その分のコード効率を舐めてた。
- もしや、例題が良くなかったのか?
- 3レジスタオペランドがこんなに効果的に使える場面って、そんなに多くは無いはず。
- そもそも、なるべく多くの変数をレジスタ割り当てして使いたいのに、演算するだけで3つもレジスタ消費するなんて、ナンセンスでしょ?
- 上の例は、3レジスタオペランドが効果的に機能した例。
例2)線形合同法による疑似乱数列生成のプログラムコード
LCGs |
Nihonium |
SuperH |
Thumb ARM |
Native ARM |
1)r0=seed |
ldli r0,seed |
mov.l r0,@(disp,pc) |
ldr r0,[pc,#] |
ldr r0,[pc,#] |
2)r1=1,103,515,245 |
ldli r1,1103515245 |
mov.l r1,@(disp,pc) |
ldr r1,[pc,#] |
ldr r1,[pc,#] |
3)r2=12,345 |
ldwi r2,12345 |
mov.w r2,@(disp,pc) |
ldrh r2,[pc,#] |
ldrh r2,[pc,#] |
4)r0*=r1 |
mulu r0,r1 |
mul.l r0,r1
sts r0,macl
|
mul r0,r1,r0 |
mul r0,r1,r0 |
5)r0+=r2 |
add r0,r2 |
add r0,r2 |
add r0,r2,r0 |
add r0,r2,r0 |
命令コード長 |
16ビット |
16ビット |
16ビット |
32ビット |
コードサイズ
[バイト]
|
2×10=20 |
2×11=22 |
2×10=20 |
4×7.5=30 |
- 線形合同法(Linear Congruential Generators)による擬似乱数列の生成
- 漸化式: X[n+1]=(1,103,515,245×X[n]+12,345)%232
- 1)〜5)まで終わったら、4)5)を繰り返すことで、必要数の擬似乱数列を生成することができる。
- Nihonium、Thumbは互角(コードサイズ=20バイト)、SHもほぼ互角(コードサイズ=22バイト)。
- SHは、乗算結果がMACレジスタに入ってしまう命令しか見つけられなかったけど、汎用レジスタに転送される命令が有れば、NihoniumやThumbと同等になる。
- SHとThumbは、1)〜3)で使用した定数ロード命令がリテラル方式のため、リテラル領域を跳び越すための分岐命令が必要となるケースがある。分岐命令が生成されると、その分コード効率は低下する。
- Native ARMはコードサイズ=30バイトと、だいぶ見劣りした。
- 3レジスタオペランドが活きないプログラムになると、この程度の差が付く。
- 上コード例の様に、普通は2レジスタオペランドで充足するから。
- ARMの3レジスタオペランドが活きてくるのは、「レジスタオフセット付きレジスタ間接」アドレッシングみたいな命令を使う場合等。
- 例)LDR R1,[R2,R3,LSL#2]
- こういうのを見るにつけ、ARMってCISCなんだよね……。
- ARMはプロセッサ(MPU)指向だから。これで良し。
- 組み込み用途(MCU)で見劣りしないようにThumbコードが派生した。
- Thumbのmul命令は、3レジスタオペランドに見えるけど、実際2レジスタしか指定できない。
- Thumb(T1)のmul命令(16ビット長)は、第1および第3レジスタが等しくなくてはならない。
- Thumb(T2)のmul命令(32ビット長)には3レジスタ独立して指定可能な命令があるが、こちらは命令長が32ビットなので、コード効率を低下させる。
- add命令(16ビット長)は、独立して3レジスタ指定可能。
- これが最初の例1でチャンピオンになった理由。
- でも、今回の例2では3レジスタオペランドの使い道が無かった(2レジスタオペランドで充足)。
- Native ARMのmul命令はちゃんと3レジスタ独立して指定可能だけど、上のプログラムでは使い道無し。
- add命令も同様。
- 折角の3レジスタオペランドも、使い道が無ければ、でかい32ビット命令コードだけが残ってコード効率を低下させる。
上の二例を眺めながら一般論として、同じ動作をする命令なら、32ビット長より16ビット長命令の方がコード効率が高くなることがお解りでしょう。もし、命令コード長を16ビット長→32ビット長に変更するなら、1個の命令に2命令分の動作を押し込まないと、コード効率がバランスしません。
そう言えば、パソコン(PC)のCPUといえばPentium(今はIntel CORE?)ですが、この子、1バイトコードなんて物を持っているのですよ。今やIntel COREは64ビットが主流だというのに、1バイトの命令コードを持っているなんて、おかしい(興味深い)でしょう(1^−^)d?
もし、固定長命令に拘らず、コード効率の向上を最大優先に持ってくるのであれば、色々なプログラムで生成された命令の統計データを取って、より出現頻度の高い命令に、より短い命令コードを割り当てる、と言うハフマン符号化を適用すると、コード効率を最適化することが可能です。
多バイト長命令を使うことで、Moscoviumみたいに命令コードの枯渇に頭を悩ませることも無くなって、一石二鳥かも知れません。
Moscoviumもそうですが、固定長にしておいた方が、ハードウェア(CPU)の設計が楽になるので、今回はそこまでコード効率至上主義には走りません。マイコンとしては、16ビット固定長命令で十分なコード効率を得られると判断します(本節考察の通り)。
16ビット固定長命令
Moscoviumは16ビット固定長命令を採用していて、比較的コード効率は高いです。Nihoniumでも、16ビット固定長命令は維持した方が良いと結論付けました。
「add r0,r1」は、C言語風に書くと「r0+=r1」です。Moscoviumでは、これを16ビット幅で演算しました。
32ビット演算を実行するNihoniumでは、一般的には、addl (add long word)命令などを追加することになるのでしょう。ですが、今回はaddl命令を追加しません!追加したくても、命令コードがそんなに余っていないので、ちょっと無理……。
それに、プログラムを作るユーザが、「ここは32ビットだからaddl命令、こっちは16ビットだからadd命令」等と気にしながらプログラムするのも難儀な話です。32ビット汎用レジスタに、誤って16ビット演算命令を実行してしまった場合、どんな演算結果が戻るのか?などと言う、余計な心配もしなければなりません。
そこで:
- Moscoviumでは、「add r0,r1」によって、16ビット加算r0+=r1を実行する。
- Nihoniumでは、「add r0,r1」によって、32ビット加算r0+=r1を実行する。
だけ。それだけ。
MoscoviumでもNihoniumでも、「add r0,r1」の命令コードは0x7981で共通とします。Moscoviumと全く同じ命令と命令コードを使い、汎用レジスタと演算器(ALU)のビット幅だけを16 → 32ビットに拡張するのが、Nihoniumとなります。
※C言語のint型をイメージしてください。8/16ビットCPUではint型は16ビットですが、32/64ビットCPUではint型は32ビットです。int型のビット幅が変わっても、「int r0,r1; r0+=r1;」は、不変です。
Moscoviumを高速化する目的に向けて、32ビット化したCPUコアをNihoniumと呼ぶこと、命令コードを16ビット固定長とすることまで決めました。次回以降、検討を続けていきたいと思います。