テストステ論

高テス協会会長が, テストステロンに関する情報をお届けします.

dm-lc紹介 (2) dm-lcの全体設計

需要があるか知りませんが, dm-lcの説明をマイペースに続けます. 合計7回を, 今のところ予定しています. 最終的に, device-mapperの仕組みとその例として洋書化します.

この記事では, dm-lcの大雑把な設計について話します. 次回以降, 詳細に切り込んでいくこととします.

dm-lcは, ディスクキャッシュを拡張するものと言うことが出来ます. ディスクキャッシュとは何でしょうか?みなさんの使っているHDDには, DRAMキャッシュが入っています. 一般的に, DRAMキャッシュの上のデータは, 計算機の電源が落ちると消滅します. サーバに入れるRAIDカード*1のセッティングをしたことがある人は分かるでしょう. 1Gや2Gのキャッシュがあり, BBU(Battery Backup Unit)がないならライトスルーオススメだよ!ということが書いてあるのだと思います. DRAMキャッシュのデータを永続化する(きちんと, persistent mediumに書き込んで確定する)ためには, ライトバリアを発行する(か, ライトバックされるのを待つ)必要があります. それはLinux 3.xでは, bioに対してREQ_FLUSH, REQ_FUAフラグをつけることで実現出来ます. 明示的な永続化のためにはペナルティがあるということがポイントです. 後の記事で, dm-lcがこのペナルティとどのように向かい合っているかについて説明します *2.

dm-lcは, キャッシュ層として3層追加します. DRAMバッファ, SSD内部キャッシュ, フラッシュチップの3つです. が, ここでは, 後者2つをまとめてしまい, DRAMバッファ, (SSD内部キャッシュ, フラッシュチップ)の2層と見て説明をします.

dm-lcはdevice-mapperというLinuxカーネルのプラグイン層を利用して実装されています. この層の役割は, 非常に単純にいうと, 「bio*3の宛先を書き換えて(map)しまう」ことです. キャッシュを実装するためには, bioの宛先を「SSD上の〜番地にする」という書き換えを行えば良いことが分かります. device-mapperの詳しい仕様については別の記事で書くことにしようと思いますが, 例として, linearターゲットのmap関数を紹介します.

  • linear_map_bioという関数がbioの行き先を変更して, DM_MAPIO_REMAPPEDをリターンする (この呼び出し元でフレームワークがgeneric_make_requestを発行します. あと, blktraceしてます).
  • linear_cというprivateなデータの中に初期化時に設定されたoffset情報が入っていて, これを利用してmappingをしている.
    という点が要点だと思います.

device-mapperは簡単だね!じゃあ早速カーネルコーディングを始めようか!つ赤本

static sector_t linear_map_sector(struct dm_target *ti, sector_t bi_sector)
{
        struct linear_c *lc = ti->private;

        return lc->start + dm_target_offset(ti, bi_sector);
}

static void linear_map_bio(struct dm_target *ti, struct bio *bio)
{
        struct linear_c *lc = ti->private;

        bio->bi_bdev = lc->dev->bdev;
        if (bio_sectors(bio))
                bio->bi_sector = linear_map_sector(ti, bio->bi_sector);
}

static int linear_map(struct dm_target *ti, struct bio *bio,
                      union map_info *map_context)
{
        linear_map_bio(ti, bio);

        return DM_MAPIO_REMAPPED;
}

dm-linearでは, bioはデバイスに対してマッピングされて, デバイスへのI/O終了でACKされますが, dm-lcでは, DRAMのバッファに対してデータをコピーすることでACKを返しています. これをImmediate Completionと言います. これによって, 4us程度のライトレイテンシを実現しています. そして, バッファがいっぱいになると, SSDに対して, データとメタデータをまとめ書き(これをログ書きと呼びます)します. メタデータも一緒に書き出しているため, クラッシュ時にもキャッシュデータを復元することが可能です*4. DRAMバッファにもデータがあるため, REQ_FLUSHを受け取った時には, これらもすべて吐き出さなければなりません. 永続化に関する仕組みについては後に話します.

バッファを書き出す際に気をつけることはたくさんありますが, その一つは, 「吐き出していないデータを上書きしてはならん」ということです. キャッシュに書き出されたデータはいずれ, 必要ならばmigrate (SSDからbacking storeに対してライトバックされること)されます. それらのデータがmigrateされる前に上書きされてしまうと, データロストになります. dm-lcではこの問題に対して, completionを使うことで対処しています.

#define NR_WB_POOL 64
struct writebuffer {
        void *data;
        struct completion done;
};

NR_WB_POOLというのは何でしょうか?バッファが1MBとすると, dm-lcでは64MBのバッファをpre-allocateします. そして, それをcyclicに使い回します. したがって, k番目のログ書き出しとk+64番目のログ書き出しは同じwritebufferオブジェクトを使うことになります. これによって, 「やってはならない上書き」を防止しています. ちなみにここでいうkを, dm-lcではsegment_idという言葉で表現しています

バッファを複数確保しているのは, 性能のためです. 仮に, バッファが1枚しかないとすると, ログ書き出しは, 次のバッファ書き込みをブロックします. バッファを複数枚確保することで, foregroundでは次のバッファ書き込みに進めることになります. また, そのために, 実際にログ書き出しを行うスレッドを分離しています. それをflush daemonと呼びます. flush daemonは, flush_queueに追加されたログ書き出しジョブを逐次処理していきます. foregroundでは, DRAMへの書き込みで済ませ, backgroundでそれを実際にSSDにログ書きだすという, ストリームプロセシングのようなことを行なっています.
ちなみに, migrationについてもmigration daemonがいます. foregroundの処理をシンプルにして, その他の処理をbackgroundで非同期に行うという設計思想にしています. またそのための工夫(例えば, キャッシュブロックのdirtinessに関する)も行なっています. これらは後に話します.

        /*
         * For Flush daemon
         */
        spinlock_t flush_queue_lock;
        struct list_head flush_queue;
        struct work_struct flush_work;
        wait_queue_head_t flush_wait_queue;
        struct workqueue_struct *flush_wq;

以上です. 質問はtwitter(@akiradeveloper)に. GW暇なので, 基本的にtwitterに張り付いてます.

*1:RAIDカードのキャッシュとしてSSDをキャッシュにするという製品はすでに存在します. 性能は知らない

*2:ロクに技術力のないアホが作ると, こういうものを無視して実装します. 私にコードを書かせてください

*3:bioは, block I/Oの略です. ブロックデバイスへのone I/Oを表現します. 主な抽象は, デバイスの名前と行き先セクタです

*4:何の終了処理もせず, いきなり電源を切っても良いです. この特性が実装を簡易にしている意味もあります. 実装のsimplicityは重要です