【最適化】算術論理演算器(ALU)を最適化してみた(Nand to Tetris part2)
はじめに
こんにちは、enari_Kと申します。普段は東京科学大学(旧東工大)で材料工学を専攻する傍ら、traPというサークルでゲーム制作、競プロ、kaggle、web開発などに取り組んでいます。
今回は、算術論理演算器を最適化してみました。
Nand to Tetrisとは
Nandのみしか使えないという縛りの元で、最小の論理回路の一つであるNandを出発点にCPUを構築し最後テトリスをプレイするところまでもっていくという海外の有名なコースになります。気になる方はこちら。実際にハードを作るところからはじめ、OS、アセンブリ、コンパイルなどを経てゲームを作るところまで学べるので、低レイヤのすべてを学べます。日本語のガイドとしてこちらの書籍があり、大変優れていて、私もとてもお世話になったのでおすすめです。それぞれのセクションについて原理などがしっかり網羅されており、勉強になると思われますので、せひ。
動機
さて、この記事では最適化と銘打っている通り算術論理演算器(ALU)の高速化を目指しています。コンピューターは1動作につき少なくとも1回は計算するはずですので、この算術論理演算器(ALU)の高速化はコンピューターの性能に直接かかわってきます。また、Nandを使う数が増えるのも問題です。Nandを使う数が増えるほど消費電力も増えていきます。ゆえに、この記事では算術論理演算器のNand数最小化と高速化に挑んでいます。
読者への挑戦:ALUの最適化
課題2は課題1で製作したチップを用いて、半加算器・全加算器・加算器そして指定された算術論理演算器(ALU)を製作する課題です。ここからは、課題に取り組む方もいらっしゃると思いますので下に最も最適化した場合Nandいくつで組めるのか、そして組むべき加算器・演算器の入出力表を載せておきます。ぜひ考えてみてください。
半加算器
| a | b | carry | sum |
|---|---|---|---|
| 0 | 0 | 0 | 0 |
| 0 | 1 | 0 | 1 |
| 1 | 0 | 0 | 1 |
| 1 | 1 | 1 | 0 |
※加算器の名前の通り、aとbから来た値の和を計算して出力します。2進数で計算し、1の位をsumに桁上がりをcarryとして出力します。
全加算器
| a | b | c | carry | sum |
|---|---|---|---|---|
| 0 | 0 | 0 | 0 | 0 |
| 0 | 0 | 1 | 0 | 1 |
| 0 | 1 | 0 | 0 | 1 |
| 0 | 1 | 1 | 1 | 0 |
| 1 | 0 | 0 | 0 | 1 |
| 1 | 0 | 1 | 1 | 0 |
| 1 | 1 | 0 | 1 | 0 |
| 1 | 1 | 1 | 1 | 1 |
※加算器の名前の通り、aとbとcから来た値の和を計算して出力します。2進数で計算し、1の位をsumに桁上がりをcarryとして出力します。
16ビット加算器
16ビットの数aと16ビットの数bの和を16ビットで出力する演算器です。17ビット以上の値については無視して出力します。
16ビットインクリメンタ
16ビットの入力された値に1を加えた値を出力する演算器です。17ビット以上の値については無視して出力します。
算術論理演算器(ALU)
| zx | nx | zy | ny | f | no | out |
|---|---|---|---|---|---|---|
| 1 | 0 | 1 | 0 | 1 | 0 | 0 |
| 1 | 1 | 1 | 1 | 1 | 1 | 1 |
| 1 | 1 | 1 | 0 | 1 | 0 | -1 |
| 0 | 0 | 1 | 1 | 0 | 0 | x |
| 1 | 1 | 0 | 0 | 0 | 0 | y |
| 0 | 0 | 1 | 1 | 0 | 1 | !x |
| 1 | 1 | 0 | 0 | 0 | 1 | !y |
| 0 | 0 | 1 | 1 | 1 | 1 | -x |
| 1 | 1 | 0 | 0 | 1 | 1 | -y |
| 0 | 1 | 1 | 1 | 1 | 1 | x+1 |
| 1 | 1 | 0 | 1 | 1 | 1 | y+1 |
| 0 | 0 | 1 | 1 | 1 | 0 | x-1 |
| 1 | 1 | 0 | 0 | 1 | 0 | y-1 |
| 0 | 0 | 0 | 0 | 1 | 0 | x+y |
| 0 | 1 | 0 | 0 | 1 | 1 | x-y |
| 0 | 0 | 0 | 1 | 1 | 1 | y-x |
| 0 | 0 | 0 | 0 | 0 | 0 | x&y |
| 0 | 1 | 0 | 1 | 0 | 1 | xory |
最小化した場合
| 加算器・演算器 | 最小Nand数 |
|---|---|
| 半加算器 | 5 |
| 全加算器 | 9 |
| 16ビット加算器 | 140 |
| 16ビットインクリメンタ | 76 |
| 算術論理演算器 | 479 |
高速化した場合
| 加算器・演算器 | 最小Nand段数 |
|---|---|
| 16ビット加算器 | 22段 |
| 16ビットインクリメンタ | 4段 |
※上記で言う段数は、演算器・加算器の中で最も多くNandを通る場所を調べた場合に一番Nandを通るところが何個通っているかを指します。現実の場合、Nandの計算は同時にこなされるはずなので、一番Nandを通ったところが何個Nandを通ったかが高速化に影響します。
ここから解説パートに入ります。自分で実装したい方はいったん読むのを中断することをお勧めします。
解説
図で示す装置は特に言及がなければ、Nandです。
半加算器
- 半加算器
| a | b | carry | sum |
|---|---|---|---|
| 0 | 0 | 0 | 0 |
| 0 | 1 | 0 | 1 |
| 1 | 0 | 0 | 1 |
| 1 | 1 | 1 | 0 |
- And
| a | b | out |
|---|---|---|
| 0 | 0 | 0 |
| 0 | 1 | 0 |
| 1 | 0 | 0 |
| 1 | 1 | 1 |
- Xor
| a | b | out |
|---|---|---|
| 0 | 0 | 0 |
| 0 | 1 | 1 |
| 1 | 0 | 1 |
| 1 | 1 | 0 |
見比べてみると、半加算器はcarryにAnd(a,b)をsumにXor(a,b)を出力するだけのものだと気付けると思います。
ここで、XorとAndのNandのみの回路図を見比べてみましょう。
-
And

-
Xor

この二つを合成するだけなのですが、見比べてみると、Nand(a,b)の部分が使いまわせることに気付くと思います。この部分を使いまわすことで、半加算器をNand5個で実装することが出来ます。回路図は以下のようになります。

全加算器
半加算器を2つ合わせると、全加算器になるという情報から推察します。半加算器のsumをHsum、carryをHcarryと表記すると、求めるcarryとsumは
※Hsum(X,Y)を2進数におけるXとYの和の一の位、Hcarry(X,Y)を2進数におけるXとYの和の十の位と考えると理解しやすいかもです。
となります。ここで、先ほどの班加算器の回路図から
また、前回の記事での議論から
より、これらを代入して今回のcarryは
よって、Not二重を消去して
また、今回のsumは上記の議論の内容から代入すると
より
※
上記の赤い部分さらには点線部分を使いまわして、回路図は以下のようになります。

このようにして、Nand9個で全加算器を作ることが出来ました!
16ビット加算器
Nand数を最小化する実装
今回作った、加算器と半加算器を用います。一桁目は値が繰り上がってこないので、半加算器を用います。2桁目から16桁目は繰り上がりの数(=前の桁を計算した加算器のcarry)と数aと数bを足さなければならないので、加算器を用います。
今回作った加算器はNand9個で、半加算器はNand5個ですので
よりNand140個で実装することが出来ました!
しかし、この方法だと最もNandを多く通るところで、34回Nandを通る(=(5回)2の位のsum + 2
より高速化する実装
キャリー先読み(CLA)とは
この16ビット加算器をより高速化するための理論になります。
16ビット加算器についてすべての情報を計算するのに必要な入力は同時に与えられます。つまりすべてのキャリーと各桁の出力は同時に計算を開始できるはずです。よりNandを通る数を少なく実装しようと思ったら、Nandの数を最小化するのではなくNandを贅沢に使ってこの同時計算をよりたくさんこなす実装を考えます。同時計算するうえで障害になるのがこれまでの実装では前の桁が分かるまで確定しなかったcarryです。しかし、このcarryについても計算に必要な値は入力で全部同時に与えられるので、同時に計算を開始できるはずです。これをキャリー先読みといいます。
実装
以下では16ビット加算器でaとbを足す場合について考え、i桁目の値を
上記のようにi桁目のXorとAndについて表すこととします。また、以下ではAndを
3ビットについては上記のように表されます。
4ビットについては、上記のように表されます。
これまでの議論から、↑のような式が成り立つと考えられます。
さて、上記式を用いて計算します。
-
の生成(第1〜3段)式の中のg_i, p_i とg_i = And(a_i, b_i) を作ります。入力が届いてから、まずp_i = Xor(a_i, b_i) が確定するまで 3段 消費します。p_i
2.式の中の
4ビット分の
4段4ビット分の
4ビットの情報をまとめるのに、2入力Nandの組み合わせで 6段 消費します(累計9段)。
-
先ほど求めた各ブロックの
を用いて、ブロックをまたぐキャリー(G_B, P_B )を予測します。これは先ほどの式を「ブロック単位」で適用する工程です。2入力Nandで And/Or ツリーを構成すると、ここでも 6段 かかります。これにより、最上位のキャリーc_4, c_8, c_{12}, c_{16} が確定するまで、累計 15段 となります。c_{16} -
上位ユニットで決まった
を、各4ビットブロックの中にある各桁へ配り、個別のc_4, c_8, c_{12} を確定させます。式の中のc_i などの項を最終的に計算する段階です。この分配と結合に 4段 消費します。c_{in} \land \prod p_k -
最後は
です。Xorゲートの段数である 3段 を加算します。合計:sum_i = Xor(p_i, c_i) 段19 + 3 = \mathbf{22}
よって、高速化する実装の場合22段になることが示されました。
16ビットインクリメンタ
Nand数を最小化する実装
今回作った半加算器と前回作ったNotを用います。
16ビットインクリメンタは、入力された16ビットの数に対して1加算した値を出力する演算器です。
まず、1の位について考えてみましょう。
1加算された値を返すので、入力の1の位が1なら出力の1の位が0で1繰り上がり、入力の1の位が0なら出力の1の位が1になり繰り上がりはなしになることが分かると思います。
以上を整理すると一の位については入力された数の1の位の数をXとすると、
- Not(X)を一の位に出力
- Xをcarryとして次の位に渡す
ということをすることで、Not一つで表すことが出来ます。
また、それより上の桁についても桁上がりと入力された数しか考えなくていいので半加算器で表すことが出来ます。
以上より、16ビットインクリメンタをNand76個(= 1個(Not)
しかし、この方法だと最もNandを多く通るところで、31回Nandを通る(=0回(一の位のcarry)+2回
より高速化する実装
16ビットインクリメンタの場合、16ビット加算器よりもさらに容易にキャリー先読みでの高速化をすることが出来る。なぜなら、もとの数で1桁目から連続して1をとる桁のみが繰り上がり(=carryが1をとる)して、それ以外は繰り上がりをしないからだ。
-
16桁すべてをAndに適用することで、一桁目からどの桁まで1を連続で取るかが確定する。これらは並列に計算することで、
すなわち4段のAndで計算することが可能である。Nandのみで回路を構成する場合、Andと同様の効果を得るためには前回の議論からNandが2つ必要であるのでcarryをすべてそろえるために2log_216 4 = 8段使う。\times -
すべてのcarryが揃ってから、各桁のsumを出すためには半加算器でのぎろんよりxorを用いる。xorの計算には3段用いるので、ここまでの議論を適切に実装することで、入力から出力が揃うまで累計で11段でできることが示された。
以上より、16ビットインクリメンタを11段で実装できることが示されました!
算術論理演算器(ALU)
ALUの各入力の役割を整理します。
-
zxは1の時、入力xを0に書き換えます。
-
nxは1の時、入力xのビットを反転します。
-
zyは1の時、入力yを0に書き換えます。
-
nyは1の時、入力yのビットを反転します。
-
fは1の時、出力結果をxとyの加算に、0の時出力結果をxとyの各桁についてAndを適応したものにします。
-
noは1の時、出力結果のビットを反転します。
実装
普通に考えると下記のような実装になります。

この実装の場合のNandの使用数は560個(=6
Add16とAnd16の合体
半加算器と全加算器は以下のような回路図になっています。


上記を注意深く観察すると、Addを計算する過程で2数のAndも出していることが分かると思います。つまり、ここからAnd16の出力をとってくることによってAnd16分の32個のNandを節約することが出来ます。
Mux16→Not16→Mux16の合体
zxについては、このような論理式で表すことが出来ます。
これによって、zx = 1なら、強制的に出力は全部0に、zx = 0なら、xの値がそのまま出力することが確認できると思います。また、nxについては上記の式の考えかたを応用すると、このような論理式で表されます。
(さっきのzx = 1なら、強制的に出力は全部0に、zx = 0なら、xの値がそのまま出力する論理式から片方は強制的に全部0になるので、自動的にもう一方が採用される論理式)
上記の式は前回のxorでの議論から
より、赤文字部分さらにはNot zxを全ビットで使い回すことでx1ビットにつきNand6個で表すことが出来ます。よって、このようなユニットはNand97個で表すことが出来ます。よってこのようなユニット一つにつき、17個Nandを節約でき、上記の回路にはこのMux16→Not16→Mux16が3個あるので合計で51個Nandを節約することが出来ました。
まとめ
ここまでの、議論を適切に実装することによりALUをNand479個(
高速化について
実はこれまでの議論でいくつかの部品を統合しましたが、同時に高速化にも成功しています。これとこれまでで議論した全加算器の高速化を組み合わせることで、Nand数を減らしつつ十分に高速なALUを製作することが出来ます。
おわりに
ということで、今回はNand to Tetris第二回としてNand回路一つを用いて可能な限り最小のNand数でさまざまな加算器・演算器を構成し、さらには一部高速化にも挑戦してみました。Nand to Tetrisの第三回ではNand to TetrisのソフトではDFFが作れないということで、これまでやってきたことを全部C#で構築しなおし、メモリやDFFも実装してみるので楽しみにしていてください!
ここまで読んでくれてありがとうございました。
Discussion