Amosapientiam

https://yuchiki.github.io/

【C#】メモ化再帰の抽象化

C#を真面目に勉強しようと思い、kekyoさんの

Final LINQ Extensions

を読んでしょっぱなから衝撃を受けた。 C#ってこんなに簡単に無限列が書けるんだ!

イテレータと無限列

たとえば0の無限列のイテレータは以下の通り。

   public static IEnumerable<int> Infinity() {
        while(true) yield return 0;
    }

以下はInfinityを用いて0を100回出力するコード。

   public static void Main (string[] args) {
        Console.WriteLine(string.Join("\n", Infinity().Take(100)));
    }

Fibonacci数列なら以下のように書ける。

   public static IEnumerable<ulong> Fib(){
        yield return 0;
        yield return 1;
        foreach (var k in Fib().Skip(1).Zip(Fib(), (m, n) => m + n)) {
            yield return k; 
        }
    }

    public static void Main (string[] args) {
        foreach (var n in Fib().Take(40)) {
            Console.WriteLine(n);
        }
    }

書ける。が、上記のコードは再帰を使っており遅い。そこで、メモ化して線形時間で計算できるようにする。

   public static IEnumerable<ulong> MemoFib(){
        ulong predpred = 0;
        ulong pred = 1;
        yield return predpred;
        yield return pred;
        while (true) {
            ulong current = predpred + pred;
            predpred = pred;
            pred = current;
            yield return current;
        }
    }

無事に書けた。が、メモ化により状態管理のコードが混ざったため、先ほどのfib実装が宣言的に書けているのに比べると手続き的だ。 また、メモ化自体は様々な無限列で必要になりうる。そこで、メモ化の仕組みと再帰的な数列生成の部分を切り分けたい。

メモ化

というわけで下のように切り分けた。

   public static IEnumerable<T> memoize<T>(Func<Func<int, T>, int, T> generator){
        Dictionary<int, T> memo = new Dictionary<int, T>();
        T accessor (int n){
            if (!memo.ContainsKey(n)) memo[n] = generator(accessor, n);
            return memo[n];
        };
        int i = 0;
        while(true) yield return accessor(i++);
    }

    public static ulong FibFactory (Func<int, ulong>accessor,int i){
        switch (i) {
        case 0:
        case 1:
            return (ulong)i;
        default:
            return accessor(i - 2) + accessor(i - 1);
        };
    }
        
    public static void Main (string[] args) {
            foreach (var n in memoize<ulong> (FibFactory).Take(80)) {
            Console.WriteLine (n);
        }
    }

memoize内部のaccessor関数はメモテーブルの読み出しをラップし、すでに値が計算されていればテーブルの値を使い、そうでなければgeneratorを用い値を計算する。 generatorはaccessorとindexを受け取り、数列のindex番目の値を計算する。

いかに僕がIOに20日以上を費やしたか

この記事は愚痴です。

動かないIOラッパ

まさかIOでハマるわけがないと思っていた。IOなんて夏学期と夏休みで何回も書いたはずなのに...

Carina 内部で用いるデータは4byte単位なので、夏学期に書いたIOをword=4bytes 単位アクセスできるようにちょっとラッパするだけだ。1日あれば終わるはずだった。まさか20日もハマるなんて。Carina 最大の誤算がこれだ。シミュレータでは動くのに実機では全く動かない現象。完全に寿命が縮んだ。

絶対ucfファイル確認しような(12 days)

実機は0x00を返すばかり。完全に謎だった。

SRAMRS232Cを用いる。そこでucfファイルは夏学期の実験で使用したものをコピペして使った。SRAMを使えているかのテストをし、デバッグ出力を実機に送信するという内容の実験だった。ここに大きな見落としがあって、この実験ではデータをPCから受信していないのでRS-RXの制約が抜けていたのだ。ucfの入れ忘れではなく、100行近いucfファイルに1行抜けがあったのだ。

ここのデバッグは概ね次の要領で行われた。

  1.  余計に待ってみたり、ラッチを入れたりして確実にデータを受け取る
  2.  シミュレータで正しく動くことを確認する
  3.  ucfがないので実機で正しく動かない
  4. タイミングのバグを疑う
  5. 1に戻る

ラッパするだけだったモジュールは、いつしか完全にグニャゲッティと化した。これが後述の full-empty-hack問題を起こす。

First Word Fall Through(2 days)

core generator が生成する同期FIFOには大きく2つのモードがある。Standard FIFOと FWFT FIFOだ。前者はread_enableを掛けると、次にREを掛けるまでFIFOの最後のデータが出力され続ける。後者はFIFOの最後のデータは到着以降常に出力されており、REを掛けるとFIFOが一つ流れて、次のデータが出力されるようになる。感覚としては前者のREは「データを読ませてくれ」のREであり、後者のREは「データを読んだので次を頼む」のREだ。

IOのbufferとしては後者のFIFOの方が良かった。前者だとREを掛けてから1clk待たねば読めないからだ。しかし、夏学期の僕は前者を用いてIO moduleを構成していた。そもそもFWFTモードの存在を知らなかったのである。コアは後者のFIFOを前提して組んでいた。ここの埋め合わせもIOラッパがしなければならない。IOラッパが更に水膨れする。

 full-empty-hack(3 days)

元々のIOユニットにはFIFOがついていたので、タイミングを気にすることなくバーストアクセスできるはずだった。しかしFIFOから4byteを読んで1wordのデータをこしらえている以上、それは不可能になった。どうするか。

最適な設計は、 FIFOをbyteではなくword単位のものに変えて、ラッパの外側におくことだろう。しかし、このIOラッパは下手なところを触ると途端に動かなくなった。それに今動いているところを書き直す体力は残っていなかった。そこで、FIFOから出てくるfull,emptyを誤魔化そうという発想に至った。ラッパで作業している最中はfullやemptyを立てておき、コア側からのアクセスを遠慮してもらう。自分がもはや正気で無いのはわかっていたが、一刻も早くIOの実装を終わらせたかった。

int main(){

  puts("hoge");

  return 0;

}

を2週間掛けても書けないでいるなんて!

test(1 day)

無心でテストを書いていた。IOを書くためだけに学校に10泊以上していた。たかがIOのために。テストケースが全て動作していることを確認すると、僕はIOフォルダに心の中で chmod 444 した。僕自身は二度とそのディレクトリを覗くことはないはずだ。アクセスごとにfullとemptyが数クロック起ち続けるゴミIOが一つ世に生まれた。

 IO revival(3 days)

衝撃の事実。raytracerはword-wise送信をしなかった。全てが無駄だった。

IOの中身も、プログラムローダも、コアも、この頃には闇IOラッパの闇と四つに組んでしまい分離不可能であった。byte-wiseアクセスモードを追加するのに3日要した。

 

IO再設計

完動した。次にすることは明らかだ。一番下から一番上までIOを再設計する。夏学期のIOは使わない。CarinaのIOラッパは見もしない。一から全部書き直す。前回の反省を踏まえてたっぷり2週間取った。

1日で完成した。

教訓

ダメなときはフルスクラッチしましょう。